From 628d591489ea92f4189310da4d0fbf1f3824cc2e Mon Sep 17 00:00:00 2001 From: Konstantin Baumann Date: Sun, 31 May 2026 16:51:13 +0200 Subject: [PATCH 1/2] Support Motion/PIR Sensor E20 (T90M0) with live motion detected events Fixes #903. The Motion/PIR Sensor E20 (device_type 127, PIR_SENSOR_E20), e.g. paired to a HomeBase 3, was deserialized to UnknownDevice and never emitted motion events. Changes: - isMotionSensor()/isSensor() now match PIR_SENSOR_E20 (new isPirSensorE20() guard), so EufySecurity.handleDevices() instantiates it as MotionSensor instead of UnknownDevice. DeviceMotionDetected is declared in its property metadata. - The HomeBase 3 delivers live motion over P2P as a CMD_NOTIFY_PAYLOAD with inner cmd 1829 carrying a generic params batch ({"params":[{"param_type":1605,"param_value":""}]}). This was previously dropped as "Not implemented"; each param is now routed through the existing "parameter" pipeline so the owning device gets a live raw-property update. - MotionSensor maps a fresh CMD_MOTION_SENSOR_PIR_EVT (param 1605) timestamp advance to DeviceMotionDetected and emits a "motion detected" event with an auto-reset cooldown (the sensor reports motion as a last-detection timestamp rather than a CusPushEvent.MOTION_SENSOR_PIR push). Motion is delivered live, without requiring a full cloud refresh. Co-authored-by: Cursor --- src/http/device.ts | 51 ++++++++++++++++++++++++++++++++++++++++++++-- src/http/types.ts | 1 + src/p2p/session.ts | 26 +++++++++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/src/http/device.ts b/src/http/device.ts index 9d66929c..f5efd405 100644 --- a/src/http/device.ts +++ b/src/http/device.ts @@ -2020,7 +2020,12 @@ export class Device extends TypedEmitter { } static isSensor(type: number): boolean { - if (type == DeviceType.SENSOR || type == DeviceType.MOTION_SENSOR || type == DeviceType.ENTRY_SENSOR_E20) + if ( + type == DeviceType.SENSOR || + type == DeviceType.MOTION_SENSOR || + type == DeviceType.ENTRY_SENSOR_E20 || + type == DeviceType.PIR_SENSOR_E20 + ) return true; return false; } @@ -2508,7 +2513,11 @@ export class Device extends TypedEmitter { } static isMotionSensor(type: number): boolean { - return DeviceType.MOTION_SENSOR == type; + return DeviceType.MOTION_SENSOR == type || this.isPirSensorE20(type); + } + + static isPirSensorE20(type: number): boolean { + return DeviceType.PIR_SENSOR_E20 == type; } static isSmartDrop(type: number): boolean { @@ -4609,6 +4618,10 @@ export class EntrySensor extends Sensor { export class MotionSensor extends Sensor { public static readonly MOTION_COOLDOWN_MS = 120000; + // PIR_SENSOR_E20 reports motion as a detection timestamp without an explicit + // "motion ended" signal. The official eufy app keeps motion active for 1 + // minute after the last detection, so mirror that timeout here. + public static readonly PIR_SENSOR_E20_MOTION_TIMEOUT_MS = 60000; //TODO: CMD_MOTION_SENSOR_ENABLE_LED = 1607 //TODO: CMD_MOTION_SENSOR_ENTER_USER_TEST_MODE = 1613 @@ -4664,6 +4677,40 @@ export class MotionSensor extends Sensor { super.handlePropertyChange(metadata, oldValue, newValue); if (metadata.name === PropertyName.DeviceMotionDetected) { this.emit("motion detected", this, newValue as boolean); + } else if ( + metadata.name === PropertyName.DeviceMotionSensorPIREvent && + metadata.key === CommandType.CMD_MOTION_SENSOR_PIR_EVT && + oldValue !== undefined && + newValue !== undefined && + Number(newValue) > Number(oldValue) && + Date.now() - Number(newValue) < MotionSensor.PIR_SENSOR_E20_MOTION_TIMEOUT_MS + ) { + // PIR_SENSOR_E20 (e.g. paired to a HomeBase 3) does not send a + // CusPushEvent.MOTION_SENSOR_PIR push. Instead it reports motion as a + // CMD_MOTION_SENSOR_PIR_EVT (param 1605) update carrying the Unix-ms + // timestamp of the last PIR detection. A timestamp newer than the + // previously known one (and recent enough) indicates a fresh live + // detection, so raise the DeviceMotionDetected event and auto-reset it + // after the same 1 minute timeout the official eufy app uses. + try { + this.updateProperty(PropertyName.DeviceMotionDetected, true); + this.clearEventTimeout(DeviceEvent.MotionDetected); + this.eventTimeouts.set( + DeviceEvent.MotionDetected, + setTimeout(async () => { + this.updateProperty(PropertyName.DeviceMotionDetected, false); + this.eventTimeouts.delete(DeviceEvent.MotionDetected); + }, MotionSensor.PIR_SENSOR_E20_MOTION_TIMEOUT_MS) + ); + } catch (err) { + const error = ensureError(err); + rootHTTPLogger.debug(`MotionSensor handle PIR event property change - Error`, { + error: getError(error), + deviceSN: this.getSerial(), + oldValue: oldValue, + newValue: newValue, + }); + } } } diff --git a/src/http/types.ts b/src/http/types.ts index cfb9a8d2..9dfd785f 100644 --- a/src/http/types.ts +++ b/src/http/types.ts @@ -8733,6 +8733,7 @@ export const DeviceProperties: Properties = { [DeviceType.PIR_SENSOR_E20]: { ...GenericDeviceProperties, [PropertyName.DeviceBattery]: DeviceBatteryProperty, + [PropertyName.DeviceMotionDetected]: DeviceMotionDetectedProperty, [PropertyName.DeviceMotionSensorPIREvent]: DeviceMotionSensorPIREventProperty, [PropertyName.DeviceWifiRSSI]: DeviceWifiRSSILockProperty, [PropertyName.DeviceBatteryLow]: DeviceBatteryLowMotionSensorProperty, diff --git a/src/p2p/session.ts b/src/p2p/session.ts index 564840f9..3069b16e 100644 --- a/src/p2p/session.ts +++ b/src/p2p/session.ts @@ -3654,6 +3654,32 @@ export class P2PClientProtocol extends TypedEmitter { } ); this.emit("hub notify update"); + } else if ( + json.cmd === 1829 && + json.payload !== undefined && + Array.isArray((json.payload as unknown as { params?: unknown }).params) + ) { + // HomeBase 3 delivers live property updates for Sub-1G sensors + // (e.g. PIR_SENSOR_E20 motion via param_type 1605) as a generic + // params batch: + // {"cmd":1829,"payload":{"params":[{"dev_type":17,"param_type":1605,"param_value":"..."}]}} + // Route each param through the normal "parameter" pipeline so the owning + // device receives a live raw-property update instead of the event being + // silently dropped as "Not implemented". + const params = (json.payload as unknown as { params: Array<{ param_type?: number; param_value?: string }> }).params; + rootP2PLogger.debug( + `Handle DATA ${P2PDataType[message.dataType]} - CMD_NOTIFY_PAYLOAD device params update`, + { + stationSN: this.rawStation.station_sn, + channel: message.channel, + params: params, + } + ); + for (const param of params) { + if (param !== undefined && param.param_type !== undefined && param.param_value !== undefined) { + this.emit("parameter", message.channel, param.param_type, param.param_value); + } + } } else { rootP2PLogger.debug( `Handle DATA ${P2PDataType[message.dataType]} - CMD_NOTIFY_PAYLOAD - Not implemented 2`, From 6211d512972d771a79581b9ef82f6e04a335ddf7 Mon Sep 17 00:00:00 2001 From: Konstantin Baumann Date: Wed, 10 Jun 2026 09:01:06 +0200 Subject: [PATCH 2/2] Use CommandType.CMD_HUB_NOTIFY_UPDATE instead of magic number 1829 Address review feedback: reference the named CommandType constant rather than the literal 1829. Since CMD_HUB_NOTIFY_UPDATE (1829) already had an unconditional branch above, fold the Sub-1G params-batch handling into that branch (guarded by the presence of a "params" array) so it stays reachable instead of being shadowed as dead code. Co-authored-by: Cursor --- src/p2p/session.ts | 69 +++++++++++++++++++++++----------------------- 1 file changed, 35 insertions(+), 34 deletions(-) diff --git a/src/p2p/session.ts b/src/p2p/session.ts index 3069b16e..93c06d47 100644 --- a/src/p2p/session.ts +++ b/src/p2p/session.ts @@ -3644,41 +3644,42 @@ export class P2PClientProtocol extends TypedEmitter { } } } else if (json.cmd === CommandType.CMD_HUB_NOTIFY_UPDATE) { - rootP2PLogger.debug( - `Handle DATA ${P2PDataType[message.dataType]} - CMD_NOTIFY_PAYLOAD - Homebase notify update`, - { - stationSN: this.rawStation.station_sn, - commandIdName: CommandType[json.cmd], - commandId: json.cmd, - message: data.toString(), - } - ); - this.emit("hub notify update"); - } else if ( - json.cmd === 1829 && - json.payload !== undefined && - Array.isArray((json.payload as unknown as { params?: unknown }).params) - ) { - // HomeBase 3 delivers live property updates for Sub-1G sensors - // (e.g. PIR_SENSOR_E20 motion via param_type 1605) as a generic - // params batch: - // {"cmd":1829,"payload":{"params":[{"dev_type":17,"param_type":1605,"param_value":"..."}]}} - // Route each param through the normal "parameter" pipeline so the owning - // device receives a live raw-property update instead of the event being - // silently dropped as "Not implemented". - const params = (json.payload as unknown as { params: Array<{ param_type?: number; param_value?: string }> }).params; - rootP2PLogger.debug( - `Handle DATA ${P2PDataType[message.dataType]} - CMD_NOTIFY_PAYLOAD device params update`, - { - stationSN: this.rawStation.station_sn, - channel: message.channel, - params: params, - } - ); - for (const param of params) { - if (param !== undefined && param.param_type !== undefined && param.param_value !== undefined) { - this.emit("parameter", message.channel, param.param_type, param.param_value); + if ( + json.payload !== undefined && + Array.isArray((json.payload as unknown as { params?: unknown }).params) + ) { + // HomeBase 3 delivers live property updates for Sub-1G sensors + // (e.g. PIR_SENSOR_E20 motion via param_type 1605) as a generic + // params batch: + // {"cmd":1829,"payload":{"params":[{"dev_type":17,"param_type":1605,"param_value":"..."}]}} + // Route each param through the normal "parameter" pipeline so the owning + // device receives a live raw-property update instead of the event being + // silently dropped as "Not implemented". + const params = (json.payload as unknown as { params: Array<{ param_type?: number; param_value?: string }> }).params; + rootP2PLogger.debug( + `Handle DATA ${P2PDataType[message.dataType]} - CMD_NOTIFY_PAYLOAD device params update`, + { + stationSN: this.rawStation.station_sn, + channel: message.channel, + params: params, + } + ); + for (const param of params) { + if (param !== undefined && param.param_type !== undefined && param.param_value !== undefined) { + this.emit("parameter", message.channel, param.param_type, param.param_value); + } } + } else { + rootP2PLogger.debug( + `Handle DATA ${P2PDataType[message.dataType]} - CMD_NOTIFY_PAYLOAD - Homebase notify update`, + { + stationSN: this.rawStation.station_sn, + commandIdName: CommandType[json.cmd], + commandId: json.cmd, + message: data.toString(), + } + ); + this.emit("hub notify update"); } } else { rootP2PLogger.debug(