diff --git a/src/device.ts b/src/device.ts index c2cbf42..4ec48f2 100644 --- a/src/device.ts +++ b/src/device.ts @@ -3,13 +3,15 @@ import { ZiGate } from './zigate' import debug from './debug' import { ZGXiaomiAqaraDoorSensorDevice } from './devices/xiaomi/aqara-door-sensor' import { ZGXiaomiAqaraWeatherSensorDevice } from './devices/xiaomi/aqara-weather-sensor' +import { ZGXiaomiAqaraMagicCubeDevice } from './devices/xiaomi/aqara-magic-cube' export interface ZGDevice {} export enum ZGDeviceType { XiaomiAqaraButton, XiaomiAqaraDoorSensor, - XiaomiAqaraWeatherSensor + XiaomiAqaraWeatherSensor, + XiaomiAqaraMagicCube } export function createZGDevice( @@ -25,6 +27,8 @@ export function createZGDevice( return new ZGXiaomiAqaraDoorSensorDevice(zigate, shortAddress) case ZGDeviceType.XiaomiAqaraWeatherSensor: return new ZGXiaomiAqaraWeatherSensorDevice(zigate, shortAddress) + case ZGDeviceType.XiaomiAqaraMagicCube: + return new ZGXiaomiAqaraMagicCubeDevice(zigate, shortAddress) default: throw new Error(`Unsupported device type : ${deviceType}`) } diff --git a/src/devices/xiaomi/aqara-magic-cube.ts b/src/devices/xiaomi/aqara-magic-cube.ts new file mode 100644 index 0000000..0b00aa3 --- /dev/null +++ b/src/devices/xiaomi/aqara-magic-cube.ts @@ -0,0 +1,144 @@ +import { Observable } from 'rxjs' +import { filter, map, tap } from 'rxjs/operators' +import { ZGDevice } from '../../device' +import { ZiGate } from '../../zigate' +import { asAttributeReportMessage, ZGMessageCode } from '../../message' +import { AttributeType, ZGAttributeReportMessage } from '../../messages/attribute-report' +import debug from '../../debug' +import { asCommonBatteryPayload, BatteryType } from './utils/battery' +import { CommonAxis, CommonBatteryPayload, CommonRemotePayload } from '../../common' + +const shakeMessages = (msg: ZGAttributeReportMessage) => { + return ( + msg.getPayload().srcEndpoint === 0x2 && + msg.getPayload().clusterId === 0x0012 && + msg.getPayload().attributeId === 0x0055 && + msg.getPayload().attributeType === AttributeType.UInt16 && + msg.getPayload().attributeData === 0x0000 + ) +} + +const slideMessages = (msg: ZGAttributeReportMessage) => { + return ( + msg.getPayload().srcEndpoint === 0x2 && + msg.getPayload().clusterId === 0x0012 && + msg.getPayload().attributeId === 0x0055 && + msg.getPayload().attributeType === AttributeType.UInt16 && + msg.getPayload().attributeData === 0x0103 + ) +} + +const horizontalRotationMessages = (msg: ZGAttributeReportMessage) => { + return ( + msg.getPayload().srcEndpoint === 0x3 && + msg.getPayload().clusterId === 0x000c && + msg.getPayload().attributeId === 0xff05 && + msg.getPayload().attributeType === AttributeType.UInt16 + ) +} + +const verticalRotationMessages = (msg: ZGAttributeReportMessage) => { + return ( + msg.getPayload().srcEndpoint === 0x2 && + msg.getPayload().clusterId === 0x0012 && + msg.getPayload().attributeId === 0x0055 && + msg.getPayload().attributeType === AttributeType.UInt16 + ) +} + +const tapMessages = (msg: ZGAttributeReportMessage) => { + return ( + msg.getPayload().srcEndpoint === 0x2 && + msg.getPayload().clusterId === 0x0012 && + msg.getPayload().attributeId === 0x0055 && + msg.getPayload().attributeType === AttributeType.UInt16 && + msg.getPayload().attributeData === 0x0204 + ) +} + +const batteryMessages = (msg: ZGAttributeReportMessage) => { + return ( + msg.getPayload().srcEndpoint === 0x1 && + msg.getPayload().clusterId === 0x0 && + msg.getPayload().attributeId === 0xff01 && + msg.getPayload().attributeSize === 0x002a + ) +} + +const asCommonRotationRemotePayload = (msg: ZGAttributeReportMessage): CommonRemotePayload => { + const axis = msg.getPayload().clusterId === 0x000c ? CommonAxis.Horizontal : CommonAxis.Vertical + + const degrees = msg.getPayload().attributeData as number + + return { + rotation: { + axis, + degrees + } + } +} + +export class ZGXiaomiAqaraMagicCubeDevice implements ZGDevice { + label = 'xiaomi-aqara-magic-cube' + shortAddress: string + + messages$: Observable + shake$: Observable + slide$: Observable + rotate$: Observable + tap$: Observable + battery$: Observable + + constructor(zigate: ZiGate, shortAddress: string) { + this.shortAddress = shortAddress + + this.messages$ = zigate.messages$.pipe( + filter(msg => msg.getCode() === ZGMessageCode.AttributeReport), + map(asAttributeReportMessage), + filter(msg => msg.getPayload().srcAddress === this.shortAddress) + ) + + this.shake$ = this.messages$.pipe( + filter(shakeMessages), + map(_ => ({ shake: true })), + tap((payload: CommonRemotePayload) => + debug(`device:${this.label}:${this.shortAddress}:remote`)(payload) + ) + ) + + this.slide$ = this.messages$.pipe( + filter(slideMessages), + map(_ => ({ slide: true })), + tap((payload: CommonRemotePayload) => + debug(`device:${this.label}:${this.shortAddress}:remote`)(payload) + ) + ) + + this.rotate$ = this.messages$.pipe( + filter( + (msg: ZGAttributeReportMessage) => + horizontalRotationMessages(msg) || verticalRotationMessages(msg) + ), + map(asCommonRotationRemotePayload), + tap((payload: CommonRemotePayload) => + debug(`device:${this.label}:${this.shortAddress}:remote`)(payload) + ) + ) + + this.tap$ = this.messages$.pipe( + filter(tapMessages), + map(_ => ({ tap: true })), + tap((payload: CommonRemotePayload) => + debug(`device:${this.label}:${this.shortAddress}:remote`)(payload) + ) + ) + + this.battery$ = this.messages$.pipe( + filter(batteryMessages), + map(msg => asCommonBatteryPayload(msg, BatteryType.CR1632)), + tap((payload: CommonBatteryPayload) => + debug(`device:${this.label}:${this.shortAddress}:battery`)(payload) + ) + ) + } +} diff --git a/test/devices/xiaomi/aqara-magic-cube.spec.ts b/test/devices/xiaomi/aqara-magic-cube.spec.ts new file mode 100644 index 0000000..0536ba4 --- /dev/null +++ b/test/devices/xiaomi/aqara-magic-cube.spec.ts @@ -0,0 +1,305 @@ +import { ZiGate } from '../../../src/zigate' +import { MockZiGate } from '../../mocks/zigate' +import { createZGMessage, ZGMessageCode } from '../../../src/message' +import { TestScheduler } from 'rxjs/testing' +import { ZGXiaomiAqaraMagicCubeDevice } from '../../../src/devices/xiaomi/aqara-magic-cube' +import { CommonAxis } from '../../../src/common' + +function assertDeepEqual(actual: any, expected: any) { + expect(actual).toEqual(expected) +} + +describe('ZGXiaomiAqaraMagicCubeDevice', () => { + let scheduler: TestScheduler + + beforeEach(() => { + scheduler = new TestScheduler(assertDeepEqual) + }) + + it('should emit current device messages', () => { + scheduler.run(helpers => { + /*** GIVEN ***/ + const firstMessage = createZGMessage( + ZGMessageCode.AttributeReport, + Buffer.from([0x0, 0xff, 0xff, 0x1, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0]) + ) + const secondMessage = createZGMessage( + ZGMessageCode.AttributeReport, + Buffer.from([0x0, 0xfe, 0xfe, 0x1, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0]) + ) + + const zigate = new MockZiGate() + + zigate.messages$ = helpers.hot('-a-b-', { + a: firstMessage, + b: secondMessage + }) + + /*** WHEN ***/ + const zgDevice = new ZGXiaomiAqaraMagicCubeDevice(zigate as ZiGate, 'fefe') + + /*** THEN ***/ + helpers.expectObservable(zgDevice.messages$).toBe('---b-', { b: secondMessage }) + }) + }) + + it('should emit messages when shaked', () => { + scheduler.run(helpers => { + /*** GIVEN ***/ + const shakeMessage = createZGMessage( + ZGMessageCode.AttributeReport, + Buffer.from([ + 0x4, + 0xfe, + 0xfe, + 0x2, + 0x0, + 0x12, + 0x0, + 0x55, + 0x0, + 0x21, + 0x0, + 0x2, + 0x0, + 0x0, + 0xc0 + ]) + ) + + const zigate = new MockZiGate() + + zigate.messages$ = helpers.hot('-a-', { + a: shakeMessage + }) + + /*** WHEN ***/ + const zgDevice = new ZGXiaomiAqaraMagicCubeDevice(zigate as ZiGate, 'fefe') + + /*** THEN ***/ + helpers.expectObservable(zgDevice.shake$).toBe('-b-', { b: { shake: true } }) + }) + }) + + it('should emit messages when slided', () => { + scheduler.run(helpers => { + /*** GIVEN ***/ + const slideMessage = createZGMessage( + ZGMessageCode.AttributeReport, + Buffer.from([ + 0x5, + 0xfe, + 0xfe, + 0x2, + 0x0, + 0x12, + 0x0, + 0x55, + 0x0, + 0x21, + 0x0, + 0x2, + 0x1, + 0x3, + 0xde + ]) + ) + + const zigate = new MockZiGate() + + zigate.messages$ = helpers.hot('-a-', { + a: slideMessage + }) + + /*** WHEN ***/ + const zgDevice = new ZGXiaomiAqaraMagicCubeDevice(zigate as ZiGate, 'fefe') + + /*** THEN ***/ + helpers.expectObservable(zgDevice.slide$).toBe('-b-', { b: { slide: true } }) + }) + }) + + it('should emit messages when horizontally rotated', () => { + scheduler.run(helpers => { + /*** GIVEN ***/ + const rotationMessage = createZGMessage( + ZGMessageCode.AttributeReport, + Buffer.from([ + 0x8, + 0xfe, + 0xfe, + 0x3, + 0x0, + 0xc, + 0xff, + 0x5, + 0x0, + 0x21, + 0x0, + 0x2, + 0x1, + 0xf4, + 0xe4 + ]) + ) + + const zigate = new MockZiGate() + + zigate.messages$ = helpers.hot('-a-', { + a: rotationMessage + }) + + /*** WHEN ***/ + const zgDevice = new ZGXiaomiAqaraMagicCubeDevice(zigate as ZiGate, 'fefe') + + /*** THEN ***/ + helpers + .expectObservable(zgDevice.rotate$) + .toBe('-b-', { b: { rotation: { axis: CommonAxis.Horizontal, degrees: 500 } } }) + }) + }) + + it('should emit messages when vertically rotated', () => { + scheduler.run(helpers => { + /*** GIVEN ***/ + const rotationMessage = createZGMessage( + ZGMessageCode.AttributeReport, + Buffer.from([ + 0xd, + 0xfe, + 0xfe, + 0x2, + 0x0, + 0x12, + 0x0, + 0x55, + 0x0, + 0x21, + 0x0, + 0x2, + 0x0, + 0x83, + 0xde + ]) + ) + + const zigate = new MockZiGate() + + zigate.messages$ = helpers.hot('-a-', { + a: rotationMessage + }) + + /*** WHEN ***/ + const zgDevice = new ZGXiaomiAqaraMagicCubeDevice(zigate as ZiGate, 'fefe') + + /*** THEN ***/ + helpers + .expectObservable(zgDevice.rotate$) + .toBe('-b-', { b: { rotation: { axis: CommonAxis.Vertical, degrees: 131 } } }) + }) + }) + + it('should emit messages when tapped', () => { + scheduler.run(helpers => { + /*** GIVEN ***/ + const rotationMessage = createZGMessage( + ZGMessageCode.AttributeReport, + Buffer.from([ + 0x14, + 0xfe, + 0xfe, + 0x2, + 0x0, + 0x12, + 0x0, + 0x55, + 0x0, + 0x21, + 0x0, + 0x2, + 0x2, + 0x4, + 0xdb + ]) + ) + + const zigate = new MockZiGate() + + zigate.messages$ = helpers.hot('-a-', { + a: rotationMessage + }) + + /*** WHEN ***/ + const zgDevice = new ZGXiaomiAqaraMagicCubeDevice(zigate as ZiGate, 'fefe') + + /*** THEN ***/ + helpers.expectObservable(zgDevice.tap$).toBe('-b-', { b: { tap: true } }) + }) + }) + + it('should emit messages about battery health', () => { + scheduler.run(helpers => { + /*** GIVEN ***/ + const batteryMessage = createZGMessage( + ZGMessageCode.AttributeReport, + Buffer.from([ + 0x3c, + 0xfe, + 0xfe, + 0x01, + 0x0, + 0x0, + 0xff, + 0x01, + 0x0, + 0x42, + 0x0, + 0x002a, + 0x1, + 0x21, + 0xf9, + 0x0b, + 0x03, + 0x28, + 0x1e, + 0x04, + 0x21, + 0xa8, + 0x43, + 0x05, + 0x21, + 0x12, + 0x0, + 0x6, + 0x24, + 0x6, + 0x0, + 0x0, + 0x0, + 0x0, + 0x0a, + 0x21, + 0x0, + 0x0 + ]) + ) + + const zigate = new MockZiGate() + + zigate.messages$ = helpers.hot('-a-', { + a: batteryMessage + }) + + /*** WHEN ***/ + const zgDevice = new ZGXiaomiAqaraMagicCubeDevice(zigate as ZiGate, 'fefe') + + /*** THEN ***/ + helpers.expectObservable(zgDevice.battery$).toBe('-b-', { + b: { + voltage: 3.065, + level: 94 + } + }) + }) + }) +})