Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
95 changes: 94 additions & 1 deletion src/device.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Buffer } from 'node:buffer'

import { describe, expect, it } from 'vitest'

import { Advertising, ErrorUtils, LogLevel, ValidationUtils, WoAirPurifier } from './device.js'
import { Advertising, ErrorUtils, LogLevel, ValidationUtils, WoAirPurifier, WoPlugMiniEU } from './device.js'

describe('validationUtils', () => {
describe('validatePercentage', () => {
Expand Down Expand Up @@ -289,5 +289,98 @@ describe('advertising', () => {
expect(result?.err_code).toBe(1)
expect(result?.sequence_number).toBe(1)
})

it('should parse Plug Mini EU service data correctly', async () => {
// bytes 0-8: header/MAC/sequence (unused by parser), bytes 9-13: state/flags/rssi/power
const manufacturerData = Buffer.from([
0x09, 0x69, // UUID
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, // MAC
0x01, // sequence number
0x80, // byte9: state=on
0x07, // byte10: delay=1, timer=1, syncUtcTime=1
0x3C, // byte11: wifiRssi=60
0x83, // byte12: overload=1, power MSB=3
0xE8, // byte13: power LSB=232 => currentPower=(3*256+232)/10=100.0W
])

const emitLog = () => {}
const result = await WoPlugMiniEU.parseServiceData(manufacturerData, emitLog)

expect(result).not.toBeNull()
expect(result?.model).toBe('l')
expect(result?.modelName).toBe('WoPlugMini')
expect(result?.state).toBe('on')
expect(result?.delay).toBe(true)
expect(result?.timer).toBe(true)
expect(result?.syncUtcTime).toBe(true)
expect(result?.wifiRssi).toBe(60)
expect(result?.overload).toBe(true)
expect(result?.currentPower).toBe(100.0)
})

it('should parse Plug Mini EU state=off correctly', async () => {
const manufacturerData = Buffer.from([
0x09, 0x69, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x01,
0x00, // byte9: state=off
0x00, // byte10: no flags
0x00, // byte11: wifiRssi=0
0x00, // byte12: no overload, power MSB=0
0x00, // byte13: power LSB=0
])

const emitLog = () => {}
const result = await WoPlugMiniEU.parseServiceData(manufacturerData, emitLog)

expect(result).not.toBeNull()
expect(result?.state).toBe('off')
expect(result?.delay).toBe(false)
expect(result?.overload).toBe(false)
expect(result?.currentPower).toBe(0)
})

it('should return null when Plug Mini EU manufacturerData length is not 14', async () => {
const manufacturerData = Buffer.from([0x01, 0x02, 0x03])
const errors: string[] = []
const emitLog = (level: string, msg: string) => { errors.push(msg) }

const result = await WoPlugMiniEU.parseServiceData(manufacturerData, emitLog)

expect(result).toBeNull()
expect(errors.length).toBeGreaterThan(0)
expect(errors[0]).toContain('should be 14')
})

it('should route Plug Mini EU via Advertising.parse', async () => {
const peripheral = {
id: 'aabbccddee01',
address: 'aa:bb:cc:dd:ee:01',
rssi: -70,
advertisement: {
serviceData: [
{
uuid: 'fd3d',
data: Buffer.from('l'), // PlugMiniEU model byte
},
],
manufacturerData: Buffer.from([
0x09, 0x69,
0x01, 0x02, 0x03, 0x04, 0x05, 0x06,
0x01, // sequence
0x80, // state=on
0x00, // flags
0x28, // wifiRssi=40
0x00, // no overload, power=0
0x00,
]),
},
} as any

const emitLog = () => {}
const result = await Advertising.parse(peripheral, emitLog)

expect(result).not.toBeNull()
expect(result?.serviceData.model).toBe('l')
expect(result?.serviceData.modelName).toBe('WoPlugMini')
})
})
})
130 changes: 128 additions & 2 deletions src/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/
import type { Characteristic, Noble, Peripheral, Service } from '@stoprocent/noble'

import type { airPurifierServiceData, airPurifierTableServiceData, batteryCirculatorFanServiceData, blindTiltServiceData, botServiceData, ceilingLightProServiceData, ceilingLightServiceData, colorBulbServiceData, contactSensorServiceData, curtain3ServiceData, curtainServiceData, hub2ServiceData, hub3ServiceData, humidifier2ServiceData, humidifierServiceData, keypadDetectorServiceData, lockProServiceData, lockServiceData, meterPlusServiceData, meterProCO2ServiceData, meterProServiceData, meterServiceData, motionSensorServiceData, outdoorMeterServiceData, plugMiniJPServiceData, plugMiniUSServiceData, presenceSensorServiceData, relaySwitch1PMServiceData, relaySwitch1ServiceData, remoteServiceData, robotVacuumCleanerServiceData, stripLightServiceData, waterLeakDetectorServiceData } from './types/ble.js'
import type { airPurifierServiceData, airPurifierTableServiceData, batteryCirculatorFanServiceData, blindTiltServiceData, botServiceData, ceilingLightProServiceData, ceilingLightServiceData, colorBulbServiceData, contactSensorServiceData, curtain3ServiceData, curtainServiceData, hub2ServiceData, hub3ServiceData, humidifier2ServiceData, humidifierServiceData, keypadDetectorServiceData, lockProServiceData, lockServiceData, meterPlusServiceData, meterProCO2ServiceData, meterProServiceData, meterServiceData, motionSensorServiceData, outdoorMeterServiceData, plugMiniEUServiceData, plugMiniJPServiceData, plugMiniUSServiceData, presenceSensorServiceData, relaySwitch1PMServiceData, relaySwitch1ServiceData, remoteServiceData, robotVacuumCleanerServiceData, stripLightServiceData, waterLeakDetectorServiceData } from './types/ble.js'

import { Buffer } from 'node:buffer'
import * as Crypto from 'node:crypto'
Expand Down Expand Up @@ -93,7 +93,7 @@ export interface ad {
id: string
address: string
rssi: number
serviceData: airPurifierServiceData | airPurifierTableServiceData | botServiceData | colorBulbServiceData | contactSensorServiceData | curtainServiceData | curtain3ServiceData | stripLightServiceData | lockServiceData | lockProServiceData | meterServiceData | meterPlusServiceData | meterProServiceData | meterProCO2ServiceData | motionSensorServiceData | presenceSensorServiceData | outdoorMeterServiceData | plugMiniUSServiceData | plugMiniJPServiceData | blindTiltServiceData | ceilingLightServiceData | ceilingLightProServiceData | hub2ServiceData | hub3ServiceData | batteryCirculatorFanServiceData | waterLeakDetectorServiceData | humidifierServiceData | humidifier2ServiceData | robotVacuumCleanerServiceData | keypadDetectorServiceData | relaySwitch1PMServiceData | relaySwitch1ServiceData | remoteServiceData
serviceData: airPurifierServiceData | airPurifierTableServiceData | botServiceData | colorBulbServiceData | contactSensorServiceData | curtainServiceData | curtain3ServiceData | stripLightServiceData | lockServiceData | lockProServiceData | meterServiceData | meterPlusServiceData | meterProServiceData | meterProCO2ServiceData | motionSensorServiceData | presenceSensorServiceData | outdoorMeterServiceData | plugMiniUSServiceData | plugMiniJPServiceData | plugMiniEUServiceData | blindTiltServiceData | ceilingLightServiceData | ceilingLightProServiceData | hub2ServiceData | hub3ServiceData | batteryCirculatorFanServiceData | waterLeakDetectorServiceData | humidifierServiceData | humidifier2ServiceData | robotVacuumCleanerServiceData | keypadDetectorServiceData | relaySwitch1PMServiceData | relaySwitch1ServiceData | remoteServiceData
[key: string]: unknown
}

Expand Down Expand Up @@ -127,6 +127,7 @@ export declare interface SwitchBotBLEDevice {
StripLight: DeviceInfo
PlugMiniUS: DeviceInfo
PlugMiniJP: DeviceInfo
PlugMiniEU: DeviceInfo
Lock: DeviceInfo
LockPro: DeviceInfo
CeilingLight: DeviceInfo
Expand Down Expand Up @@ -161,6 +162,7 @@ export enum SwitchBotModel {
StripLight = 'W1701100',
PlugMiniUS = 'W1901400/W1901401',
PlugMiniJP = 'W2001400/W2001401',
PlugMiniEU = 'W7732300',
Lock = 'W1601700',
LockPro = 'W3500000',
LockUltra = 'W3600000',
Expand Down Expand Up @@ -211,6 +213,7 @@ export enum SwitchBotBLEModel {
StripLight = 'r',
PlugMiniUS = 'g',
PlugMiniJP = 'j', // Only available in Japan.
PlugMiniEU = 'l', // Only available in Europe.
Lock = 'o',
LockPro = '$',
LockUltra = 'U',
Expand Down Expand Up @@ -1099,6 +1102,8 @@ export class Advertising {
return WoPlugMiniUS.parseServiceData(manufacturerData, emitLog)
case SwitchBotBLEModel.PlugMiniJP:
return WoPlugMiniJP.parseServiceData(manufacturerData, emitLog)
case SwitchBotBLEModel.PlugMiniEU:
return WoPlugMiniEU.parseServiceData(manufacturerData, emitLog)
Comment on lines 1103 to +1106
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New Plug Mini (EU) parsing/dispatch logic is added here, but there are no tests covering WoPlugMiniEU.parseServiceData or the Advertising.parseServiceData routing for the new model. Adding a Vitest case similar to existing Advertising.parse tests would help prevent regressions (e.g., manufacturerData length validation and correct model/modelName fields).

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot make recommended changes

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added four Vitest cases in 67d7253:

  • state=on with full field verification (model, modelName, state, delay, timer, syncUtcTime, wifiRssi, overload, currentPower)
  • state=off with zero/false field verification
  • Invalid buffer length → returns null with error log containing "should be 14"
  • Advertising.parse routing for the 'l' model byte → correct model/modelName

case SwitchBotBLEModel.Lock:
return WoSmartLock.parseServiceData(serviceData, manufacturerData, emitLog)
case SwitchBotBLEModel.LockPro:
Expand Down Expand Up @@ -2638,6 +2643,127 @@ export class WoPlugMiniJP extends SwitchbotDevice {
}
}

/**
* Class representing a WoPlugMini EU device.
* @see https://github.com/OpenWonderLabs/SwitchBotAPI-BLE/blob/latest/devicetypes/plugmini.md
*/
export class WoPlugMiniEU extends SwitchbotDevice {
constructor(peripheral: NobleTypes['peripheral'], noble: NobleTypes['noble']) {
super(peripheral, noble)
}

/**
* Parses the service data for WoPlugMini EU.
* @param {Buffer} manufacturerData - The manufacturer data buffer.
* @param {Function} emitLog - The function to emit log messages.
* @returns {Promise<plugMiniEUServiceData | null>} - Parsed service data or null if invalid.
*/
static async parseServiceData(
manufacturerData: Buffer,
emitLog: (level: string, message: string) => void,
): Promise<plugMiniEUServiceData | null> {
if (manufacturerData.length !== 14) {
emitLog('debugerror', `[parseServiceDataForWoPlugMiniEU] Buffer length ${manufacturerData.length} should be 14`)
return null
}

const [byte9, byte10, byte11, byte12, byte13] = [
manufacturerData.readUInt8(9),
manufacturerData.readUInt8(10),
manufacturerData.readUInt8(11),
manufacturerData.readUInt8(12),
manufacturerData.readUInt8(13),
]

const state = byte9 === 0x00 ? 'off' : byte9 === 0x80 ? 'on' : null
const delay = !!(byte10 & 0b00000001)
const timer = !!(byte10 & 0b00000010)
const syncUtcTime = !!(byte10 & 0b00000100)
const wifiRssi = byte11
const overload = !!(byte12 & 0b10000000)
const currentPower = (((byte12 & 0b01111111) << 8) + byte13) / 10 // in watt

const data = {
model: SwitchBotBLEModel.PlugMiniEU,
modelName: SwitchBotBLEModelName.PlugMini,
modelFriendlyName: SwitchBotBLEModelFriendlyName.PlugMini,
state: state ?? 'unknown',
delay,
timer,
syncUtcTime,
wifiRssi,
overload,
currentPower,
}

return data as plugMiniEUServiceData
}

/**
* Reads the state of the plug.
* @returns {Promise<boolean>} - Resolves with a boolean that tells whether the plug is ON (true) or OFF (false).
*/
public async readState(): Promise<boolean> {
return this.operatePlug([0x57, 0x0F, 0x51, 0x01])
}

/**
* Sets the state of the plug.
* @param {number[]} reqByteArray - The request byte array.
* @returns {Promise<boolean>} - Resolves with a boolean that tells whether the plug is ON (true) or OFF (false).
*/
public async setState(reqByteArray: number[]): Promise<boolean> {
const base = [0x57, 0x0F, 0x50, 0x01]
return this.operatePlug([...base, ...reqByteArray])
}

/**
* Turns on the plug.
* @returns {Promise<boolean>} - Resolves with a boolean that tells whether the plug is ON (true) or OFF (false).
*/
async turnOn(): Promise<boolean> {
return this.setState([0x01, 0x80])
}

/**
* Turns off the plug.
* @returns {Promise<boolean>} - Resolves with a boolean that tells whether the plug is ON (true) or OFF (false).
*/
async turnOff(): Promise<boolean> {
return this.setState([0x01, 0x00])
}

/**
* Toggles the state of the plug.
* @returns {Promise<boolean>} - Resolves with a boolean that tells whether the plug is ON (true) or OFF (false).
*/
async toggle(): Promise<boolean> {
return this.setState([0x02, 0x80])
}

/**
* Operates the plug with the given bytes.
* @param {number[]} bytes - The byte array to send to the plug.
* @returns {Promise<boolean>} - Resolves with a boolean that tells whether the plug is ON (true) or OFF (false).
*/
public async operatePlug(bytes: number[]): Promise<boolean> {
const reqBuf = Buffer.from(bytes)
const resBytes = await this.command(reqBuf)
const resBuf = Buffer.from(resBytes)

if (resBuf.length !== 2) {
throw new Error(`Expecting a 2-byte response, got instead: 0x${resBuf.toString('hex')}`)
}

const code = resBuf.readUInt8(1)
if (code === 0x00 || code === 0x80) {
return code === 0x80
} else {
throw new Error(`The device returned an error: 0x${resBuf.toString('hex')}`)
}
}
}

/**
* Class representing a WoPlugMini device.
* @see https://github.com/OpenWonderLabs/SwitchBotAPI-BLE/blob/latest/devicetypes/plugmini.md
Expand Down
3 changes: 2 additions & 1 deletion src/switchbot-ble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { ad, NobleTypes, onadvertisement, ondiscover, Params, Rule } from '

import { EventEmitter } from 'node:events'

import { Advertising, LogLevel, SwitchBotBLEModel, SwitchbotDevice, WoBlindTilt, WoBulb, WoCeilingLight, WoContact, WoCurtain, WoHand, WoHub2, WoHumi, WoHumi2, WoIOSensorTH, WoKeypad, WoLeak, WoPlugMiniJP, WoPlugMiniUS, WoPresence, WoRelaySwitch1, WoRelaySwitch1PM, WoRemote, WoSensorTH, WoSensorTHPlus, WoSensorTHPro, WoSensorTHProCO2, WoSmartLock, WoSmartLockPro, WoSmartLockUltra, WoStrip } from './device.js'
import { Advertising, LogLevel, SwitchBotBLEModel, SwitchbotDevice, WoBlindTilt, WoBulb, WoCeilingLight, WoContact, WoCurtain, WoHand, WoHub2, WoHumi, WoHumi2, WoIOSensorTH, WoKeypad, WoLeak, WoPlugMiniEU, WoPlugMiniJP, WoPlugMiniUS, WoPresence, WoRelaySwitch1, WoRelaySwitch1PM, WoRemote, WoSensorTH, WoSensorTHPlus, WoSensorTHPro, WoSensorTHProCO2, WoSmartLock, WoSmartLockPro, WoSmartLockUltra, WoStrip } from './device.js'
import { parameterChecker } from './parameter-checker.js'
import { DEFAULT_DISCOVERY_DURATION, PRIMARY_SERVICE_UUID_LIST } from './settings.js'

Expand Down Expand Up @@ -230,6 +230,7 @@ export class SwitchBotBLE extends EventEmitter {
case SwitchBotBLEModel.Leak: return new WoLeak(peripheral, this.noble)
case SwitchBotBLEModel.PlugMiniUS: return new WoPlugMiniUS(peripheral, this.noble)
case SwitchBotBLEModel.PlugMiniJP: return new WoPlugMiniJP(peripheral, this.noble)
case SwitchBotBLEModel.PlugMiniEU: return new WoPlugMiniEU(peripheral, this.noble)
case SwitchBotBLEModel.Lock: return new WoSmartLock(peripheral, this.noble)
case SwitchBotBLEModel.LockPro: return new WoSmartLockPro(peripheral, this.noble)
case (SwitchBotBLEModel.LockUltra as any): return new WoSmartLockUltra(peripheral, this.noble)
Expand Down
7 changes: 7 additions & 0 deletions src/types/ble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,12 @@ export type plugMiniJPServiceData = PlugMiniServiceDataBase & {
modelFriendlyName: SwitchBotBLEModelFriendlyName.PlugMini
}

export type plugMiniEUServiceData = PlugMiniServiceDataBase & {
model: SwitchBotBLEModel.PlugMiniEU
modelName: SwitchBotBLEModelName.PlugMini
modelFriendlyName: SwitchBotBLEModelFriendlyName.PlugMini
}

export type blindTiltServiceData = BLEServiceData & {
model: SwitchBotBLEModel.BlindTilt
modelName: SwitchBotBLEModelName.BlindTilt
Expand Down Expand Up @@ -440,6 +446,7 @@ export type BLEDeviceServiceData
| presenceSensorServiceData
| plugMiniJPServiceData
| plugMiniUSServiceData
| plugMiniEUServiceData
| relaySwitch1PMServiceData
| relaySwitch1ServiceData
| remoteServiceData
Expand Down
4 changes: 4 additions & 0 deletions src/types/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,10 @@ export type plugMiniJPWebhookContext = deviceWebhookContext & {
powerState: 'ON' | 'OFF'
}

export type plugMiniEUWebhookContext = deviceWebhookContext & {
powerState: 'ON' | 'OFF'
}

export type robotVacuumCleanerS1WebhookContext = deviceWebhookContext & {
workingStatus: 'Standby' | 'Clearing' | 'Paused' | 'GotoChargeBase' | 'Charging' | 'ChargeDone' | 'Dormant' | 'InTrouble' | 'InRemoteControl' | 'InDustCollecting'
onlineStatus: 'online' | 'offline'
Expand Down