From f19313c25f336c67e66b6d331be05f6782b5b629 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 12:10:05 +0000 Subject: [PATCH 1/3] Initial plan From 2bb4aeec2f71fc083d0d15c63bd51e535b996010 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 12:21:23 +0000 Subject: [PATCH 2/3] feat: add Plug Mini (EU) support following same pattern as JP Co-authored-by: donavanbecker <9875439+donavanbecker@users.noreply.github.com> --- src/device.ts | 131 ++++++++++++++++++++++++++++++++++++++++++- src/switchbot-ble.ts | 3 +- src/types/ble.ts | 7 +++ src/types/openapi.ts | 4 ++ 4 files changed, 142 insertions(+), 3 deletions(-) diff --git a/src/device.ts b/src/device.ts index c39e8db..f32006f 100644 --- a/src/device.ts +++ b/src/device.ts @@ -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' @@ -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 } @@ -127,6 +127,7 @@ export declare interface SwitchBotBLEDevice { StripLight: DeviceInfo PlugMiniUS: DeviceInfo PlugMiniJP: DeviceInfo + PlugMiniEU: DeviceInfo Lock: DeviceInfo LockPro: DeviceInfo CeilingLight: DeviceInfo @@ -161,6 +162,7 @@ export enum SwitchBotModel { StripLight = 'W1701100', PlugMiniUS = 'W1901400/W1901401', PlugMiniJP = 'W2001400/W2001401', + PlugMiniEU = 'W7732300', Lock = 'W1601700', LockPro = 'W3500000', LockUltra = 'W3600000', @@ -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', @@ -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) case SwitchBotBLEModel.Lock: return WoSmartLock.parseServiceData(serviceData, manufacturerData, emitLog) case SwitchBotBLEModel.LockPro: @@ -2638,6 +2643,128 @@ 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} - Parsed service data or null if invalid. + */ + static async parseServiceData( + manufacturerData: Buffer, + emitLog: (level: string, message: string) => void, + ): Promise { + 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} - Resolves with a boolean that tells whether the plug is ON (true) or OFF (false). + */ + public async readState(): Promise { + return this.operatePlug([0x57, 0x0F, 0x51, 0x01]) + } + + /** + * Sets the state of the plug. + * @private + * @param {number[]} reqByteArray - The request byte array. + * @returns {Promise} - Resolves with a boolean that tells whether the plug is ON (true) or OFF (false). + */ + public async setState(reqByteArray: number[]): Promise { + const base = [0x57, 0x0F, 0x50, 0x01] + return this.operatePlug([...base, ...reqByteArray]) + } + + /** + * Turns on the plug. + * @returns {Promise} - Resolves with a boolean that tells whether the plug is ON (true) or OFF (false). + */ + async turnOn(): Promise { + return this.setState([0x01, 0x80]) + } + + /** + * Turns off the plug. + * @returns {Promise} - Resolves with a boolean that tells whether the plug is ON (true) or OFF (false). + */ + async turnOff(): Promise { + return this.setState([0x01, 0x00]) + } + + /** + * Toggles the state of the plug. + * @returns {Promise} - Resolves with a boolean that tells whether the plug is ON (true) or OFF (false). + */ + async toggle(): Promise { + 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} - Resolves with a boolean that tells whether the plug is ON (true) or OFF (false). + */ + public async operatePlug(bytes: number[]): Promise { + 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 diff --git a/src/switchbot-ble.ts b/src/switchbot-ble.ts index 05b984e..6135871 100644 --- a/src/switchbot-ble.ts +++ b/src/switchbot-ble.ts @@ -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' @@ -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) diff --git a/src/types/ble.ts b/src/types/ble.ts index 6b86ba3..1c68672 100644 --- a/src/types/ble.ts +++ b/src/types/ble.ts @@ -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 @@ -440,6 +446,7 @@ export type BLEDeviceServiceData | presenceSensorServiceData | plugMiniJPServiceData | plugMiniUSServiceData + | plugMiniEUServiceData | relaySwitch1PMServiceData | relaySwitch1ServiceData | remoteServiceData diff --git a/src/types/openapi.ts b/src/types/openapi.ts index 3dffd66..ca04f53 100644 --- a/src/types/openapi.ts +++ b/src/types/openapi.ts @@ -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' From 67d72533a3c0096247e535f1ef13ea72b8f94243 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:24:12 +0000 Subject: [PATCH 3/3] fix: remove @private tag from WoPlugMiniEU.setState and add EU tests Co-authored-by: donavanbecker <9875439+donavanbecker@users.noreply.github.com> --- src/device.test.ts | 95 +++++++++++++++++++++++++++++++++++++++++++++- src/device.ts | 1 - 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/src/device.test.ts b/src/device.test.ts index 6008f16..9762362 100644 --- a/src/device.test.ts +++ b/src/device.test.ts @@ -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', () => { @@ -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') + }) }) }) diff --git a/src/device.ts b/src/device.ts index f32006f..497999f 100644 --- a/src/device.ts +++ b/src/device.ts @@ -2709,7 +2709,6 @@ export class WoPlugMiniEU extends SwitchbotDevice { /** * Sets the state of the plug. - * @private * @param {number[]} reqByteArray - The request byte array. * @returns {Promise} - Resolves with a boolean that tells whether the plug is ON (true) or OFF (false). */