From ab804a4b762a8ebdddfb6b2f6694151f65d82fa4 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 27 Jul 2026 20:44:17 +0800 Subject: [PATCH 1/5] feat: add 9 new SwitchBot device types to catalog --- src/devices/catalog.ts | 116 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/src/devices/catalog.ts b/src/devices/catalog.ts index 8605c418..61abdb65 100644 --- a/src/devices/catalog.ts +++ b/src/devices/catalog.ts @@ -275,6 +275,42 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ ], statusFields: ['battery', 'version', 'lockState', 'doorState', 'calibrate'], }, + { + type: 'Smart Lock Pro Wifi', + category: 'physical', + description: 'Matter-enabled Wi-Fi electronic deadbolt with lock and unlock control.', + role: 'security', + aliases: ['Lock Pro Matter Enabled'], + commands: [ + { command: 'lock', parameter: '—', description: 'Lock the door', idempotent: true }, + { command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, safetyTier: 'destructive', safetyReason: 'Physically unlocks the door — anyone nearby can open it.' }, + ], + statusFields: ['battery', 'version', 'lockState', 'doorState', 'calibrate'], + }, + { + type: 'Lock Vision', + category: 'physical', + description: 'Smart lock with camera and passcode management.', + role: 'security', + commands: [ + { command: 'lock', parameter: '—', description: 'Lock the door', idempotent: true }, + { command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, safetyTier: 'destructive', safetyReason: 'Physically unlocks the door — anyone nearby can open it.' }, + { command: 'deleteKey', parameter: '\'{"id":}\'', description: 'Delete a passcode by id', idempotent: true, safetyTier: 'destructive', safetyReason: 'Permanently removes a passcode — the holder immediately loses door access.' }, + ], + statusFields: ['battery', 'version', 'lockState', 'doorState', 'calibrate'], + }, + { + type: 'Lock Vision Pro', + category: 'physical', + description: 'Premium smart lock with camera, passcode management, and advanced features.', + role: 'security', + commands: [ + { command: 'lock', parameter: '—', description: 'Lock the door', idempotent: true }, + { command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, safetyTier: 'destructive', safetyReason: 'Physically unlocks the door — anyone nearby can open it.' }, + { command: 'deleteKey', parameter: '\'{"id":}\'', description: 'Delete a passcode by id', idempotent: true, safetyTier: 'destructive', safetyReason: 'Permanently removes a passcode — the holder immediately loses door access.' }, + ], + statusFields: ['battery', 'version', 'lockState', 'doorState', 'calibrate'], + }, { type: 'Plug', category: 'physical', @@ -423,6 +459,19 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ commands: [...onOffToggle, ...rgbOnlyLightControls0To100], statusFields: ['power', 'brightness', 'color', 'version'], }, + { + type: 'Permanent Outdoor Lights', + category: 'physical', + description: 'Weather-resistant outdoor RGBIC string lights with brightness and color temperature control.', + role: 'lighting', + commands: [ + ...onOffToggle, + { command: 'setBrightness', parameter: '{0-100}', description: 'Set brightness (0-100)', idempotent: true, exampleParams: ['50'] }, + { command: 'setColor', parameter: '\'{0-255}:{0-255}:{0-255}\'', description: 'Set RGB color', idempotent: true, exampleParams: ['255:0:0'] }, + { command: 'setColorTemperature', parameter: '{2700-6500}', description: 'Set color temperature in Kelvin', idempotent: true, exampleParams: ['4000'] }, + ], + statusFields: ['power', 'brightness', 'color', 'colorTemperature', 'version'], + }, { type: 'Ceiling Light', category: 'physical', @@ -436,6 +485,24 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ ], statusFields: ['power', 'brightness', 'colorTemperature', 'version'], }, + { + type: 'RGBICWW Ceiling Light', + category: 'physical', + description: 'RGBICWW ceiling light with separate main light and color light controls.', + role: 'lighting', + commands: [ + ...onOffToggle, + { command: 'turnOnMainLight', parameter: 'default', description: 'Turn on the main light only', idempotent: true }, + { command: 'turnOffMainLight', parameter: 'default', description: 'Turn off the main light only', idempotent: true }, + { command: 'turnOnColorLight', parameter: 'default', description: 'Turn on the ambient color light', idempotent: true }, + { command: 'turnOffColorLight', parameter: 'default', description: 'Turn off the ambient color light', idempotent: true }, + { command: 'setMainLightBrightness', parameter: '{1-100}', description: 'Set main light brightness', idempotent: true, exampleParams: ['50'] }, + { command: 'setMainLightColorTemp', parameter: '{2700-6500}', description: 'Set main light color temperature (K)', idempotent: true, exampleParams: ['4000'] }, + { command: 'setColorLightBrightness', parameter: '{1-100}', description: 'Set color light brightness', idempotent: true, exampleParams: ['50'] }, + { command: 'setColorLightRGB', parameter: '\'{0-255}:{0-255}:{0-255}\'', description: 'Set color light RGB color', idempotent: true }, + ], + statusFields: ['power', 'mainLightPower', 'mainLightBrightness', 'mainLightColorTemp', 'colorLightPower', 'colorLightBrightness', 'colorLightRGB', 'version'], + }, { type: 'Smart Radiator Thermostat', category: 'physical', @@ -509,6 +576,19 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ ], statusFields: ['mode', 'version', 'battery', 'power', 'nightStatus', 'oscillation', 'verticalOscillation', 'chargingStatus', 'fanSpeed'], }, + { + type: 'Battery Circulator Fan 2 Pro', + category: 'physical', + description: 'Battery-powered circulator fan with nightlight, wind modes, and USB-C charging.', + role: 'fan', + commands: [ + ...onOff, + { command: 'setNightLightMode', parameter: 'off | 0 | 1', description: 'Night-light mode', idempotent: true, exampleParams: ['off', '1'] }, + { command: 'setWindMode', parameter: 'direct | natural | sleep | hurricane', description: 'Wind mode', idempotent: true, exampleParams: ['natural', 'sleep'] }, + { command: 'setWindSpeed', parameter: '1-100', description: 'Fan speed', idempotent: true, exampleParams: ['50', '100'] }, + ], + statusFields: ['power', 'mode', 'version', 'battery', 'nightStatus', 'oscillation', 'verticalOscillation', 'chargingStatus', 'fanSpeed'], + }, { type: 'Blind Tilt', category: 'physical', @@ -650,6 +730,18 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ commands: [], statusFields: ['battery', 'version', 'status'], }, + { + type: 'WeatherStation', + category: 'physical', + description: 'Indoor weather station display with customizable quote screen.', + role: 'sensor', + commands: [ + { command: 'customQuote', parameter: 'custom text', description: 'Set a custom quote on the display', idempotent: true }, + { command: 'cancelCustom', parameter: 'default', description: 'Revert to default quote display', idempotent: true }, + { command: 'customPage', parameter: 'custom text', description: 'Set a custom page text', idempotent: true }, + ], + statusFields: ['temperature', 'humidity', 'battery', 'version'], + }, // Status-only hub-class devices (no control commands) { type: 'Hub 2', @@ -750,6 +842,30 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ aliases: ['SwitchBot Remote', 'Remote Button', 'Wireless Remote'], commands: [], }, + { + type: 'AI MindClip', + category: 'physical', + description: 'Wearable AI voice recorder with transcription, summaries, and calendar integration.', + role: 'other', + readOnly: true, + commands: [], + statusFields: ['version', 'battery', 'chargingStatus', 'recordingStatus', 'uploadStatus', 'hasUntransferredFiles'], + }, + { + type: 'Kata Friends', + category: 'physical', + description: 'Interactive AI companion robot with comic diary generation and voice interaction.', + role: 'other', + commands: [ + { command: 'mode', parameter: '"Normal", "Standby", or "Sleep"', description: 'Set the operating mode', idempotent: true }, + { command: 'childLock', parameter: '"on" or "off"', description: 'Toggle child lock', idempotent: true }, + { command: 'backHome', parameter: 'default', description: 'Command Kata to return home', idempotent: true }, + { command: 'pictureTaking', parameter: '"on" or "off"', description: 'Toggle picture taking', idempotent: true }, + { command: 'snapshots', parameter: '"on" or "off"', description: 'Toggle snapshot mode', idempotent: true }, + { command: 'talk', parameter: '"on" or "off"', description: 'Toggle talk mode', idempotent: true }, + ], + statusFields: ['version', 'onlineStatus', 'mode', 'status', 'childLock', 'hospitalized', 'battery'], + }, // ---------- Virtual IR remotes ---------- { From e40bfec538aa9d82c3b4f5bab4924ca304cea12e Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Tue, 28 Jul 2026 09:52:13 +0800 Subject: [PATCH 2/5] fix: clear credential priming cache on saveConfig (#69) --- src/config.ts | 3 ++- src/credentials/prime.ts | 6 ++++-- tests/config.test.ts | 4 ++-- tests/credentials/prime.test.ts | 6 +++--- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/config.ts b/src/config.ts index cb8062bc..cc183791 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,7 +5,7 @@ import { createHash } from 'node:crypto'; import { getConfigPath } from './utils/flags.js'; import { getActiveProfile } from './lib/request-context.js'; import { emitJsonError, isJsonMode } from './utils/output.js'; -import { getPrimedCredentials } from './credentials/prime.js'; +import { getPrimedCredentials, resetPrimedCredentials } from './credentials/prime.js'; export interface SwitchBotConfig { token: string; @@ -178,6 +178,7 @@ export function saveConfig(token: string, secret: string, extras?: Partial ({ default: fsMock, ...fsMock })); vi.mock('node:os', () => ({ default: osMock, ...osMock })); import { loadConfig, saveConfig, showConfig, listProfiles, tryLoadConfig } from '../src/config.js'; -import { __resetPrimedCredentials, primeCredentials } from '../src/credentials/prime.js'; +import { resetPrimedCredentials, primeCredentials } from '../src/credentials/prime.js'; const selectMock = vi.fn(); vi.mock('../src/credentials/keychain.js', async () => { @@ -45,7 +45,7 @@ describe('config', () => { fsMock.readdirSync.mockReset(); fsMock.readdirSync.mockReturnValue([]); selectMock.mockReset(); - __resetPrimedCredentials(); + resetPrimedCredentials(); }); describe('loadConfig', () => { diff --git a/tests/credentials/prime.test.ts b/tests/credentials/prime.test.ts index e37dacff..9d41947f 100644 --- a/tests/credentials/prime.test.ts +++ b/tests/credentials/prime.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { primeCredentials, getPrimedCredentials, - __resetPrimedCredentials, + resetPrimedCredentials, } from '../../src/credentials/prime.js'; const selectMock = vi.fn(); @@ -19,11 +19,11 @@ vi.mock('../../src/credentials/keychain.js', async () => { beforeEach(() => { selectMock.mockReset(); - __resetPrimedCredentials(); + resetPrimedCredentials(); }); afterEach(() => { - __resetPrimedCredentials(); + resetPrimedCredentials(); }); describe('primeCredentials', () => { From d192cb101bda169db09570b85faca6cd6f217c14 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Tue, 28 Jul 2026 10:06:17 +0800 Subject: [PATCH 3/5] fix: correct Hub Mini Matter Enabled alias, add new device types to test list (#70) Renames the 'Hub Mini2' alias to the official marketing name 'Hub Mini Matter Enabled', and registers the 9 new catalog entries from the prior commit in OFFICIAL_API_DEVICE_TYPES. --- src/devices/catalog.ts | 2 +- tests/devices/catalog.test.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/devices/catalog.ts b/src/devices/catalog.ts index 61abdb65..b13a3b81 100644 --- a/src/devices/catalog.ts +++ b/src/devices/catalog.ts @@ -758,7 +758,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ description: 'IR hub that bridges BLE devices to the cloud and learns IR remotes; no direct control commands.', role: 'hub', readOnly: true, - aliases: ['Hub', 'Hub Plus', 'Hub Mini2'], + aliases: ['Hub', 'Hub Plus', 'Hub Mini Matter Enabled'], commands: [], statusFields: ['version'], }, diff --git a/tests/devices/catalog.test.ts b/tests/devices/catalog.test.ts index 57c2c07f..64ae4326 100644 --- a/tests/devices/catalog.test.ts +++ b/tests/devices/catalog.test.ts @@ -17,11 +17,13 @@ import { const OFFICIAL_API_DEVICE_TYPES = [ 'AI Art Frame', 'AI Hub', + 'AI MindClip', 'Air Purifier PM2.5', 'Air Purifier Table PM2.5', 'Air Purifier Table VOC', 'Air Purifier VOC', 'Battery Circulator Fan', + 'Battery Circulator Fan 2 Pro', 'Blind Tilt', 'Bot', 'Candle Warmer Lamp', @@ -45,12 +47,15 @@ const OFFICIAL_API_DEVICE_TYPES = [ 'Indoor Cam', 'K10+', 'K10+ Pro', + 'Kata Friends', 'Keypad', 'Keypad Touch', 'Keypad Vision', 'Keypad Vision Pro', 'Lock Lite', 'Lock Ultra', + 'Lock Vision', + 'Lock Vision Pro', 'Meter', 'MeterPlus', 'MeterPro', @@ -60,6 +65,7 @@ const OFFICIAL_API_DEVICE_TYPES = [ 'Pan/Tilt Cam 2K', 'Pan/Tilt Cam Plus 2K', 'Pan/Tilt Cam Plus 3K', + 'Permanent Outdoor Lights', 'Plug', 'Plug Mini (EU)', 'Plug Mini (JP)', @@ -67,6 +73,7 @@ const OFFICIAL_API_DEVICE_TYPES = [ 'Presence Sensor', 'RGBIC Neon Rope Light', 'RGBIC Neon Wire Rope Light', + 'RGBICWW Ceiling Light', 'RGBICWW Floor Lamp', 'RGBICWW Strip Light', 'Relay Switch 1', @@ -83,6 +90,7 @@ const OFFICIAL_API_DEVICE_TYPES = [ 'Roller Shade', 'Smart Lock', 'Smart Lock Pro', + 'Smart Lock Pro Wifi', 'Smart Lock Ultra', 'Smart Radiator Thermostat', 'Standing Circulator Fan', @@ -90,6 +98,7 @@ const OFFICIAL_API_DEVICE_TYPES = [ 'Strip Light 3', 'Video Doorbell', 'Water Detector', + 'WeatherStation', 'WoIOSensor', ] as const; From b927fab9b526eeb0d6352d8f631cecf9af6091b8 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Tue, 28 Jul 2026 10:27:12 +0800 Subject: [PATCH 4/5] fix: address review findings on device catalog additions - Smart Lock Pro Wifi: add missing nightLatchUnlock, deadbolt commands - Lock Vision / Lock Vision Pro: add missing createKey command - WeatherStation: alias 'Weather Station' display name - Battery Circulator Fan 2 Pro, AI MindClip: drop undocumented statusFields - RGBICWW Ceiling Light: add exampleParams to setColorLightRGB - Backfill OFFICIAL_SUPPORTED_DEVICE_LIST_NAMES with the 9 new device names - Add regression test for #69 (saveConfig clears stale primed credentials) --- src/devices/catalog.ts | 19 ++++++++++++------- tests/config.test.ts | 11 +++++++++++ tests/devices/catalog.test.ts | 9 +++++++++ 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/devices/catalog.ts b/src/devices/catalog.ts index b13a3b81..b36c609d 100644 --- a/src/devices/catalog.ts +++ b/src/devices/catalog.ts @@ -278,14 +278,16 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Smart Lock Pro Wifi', category: 'physical', - description: 'Matter-enabled Wi-Fi electronic deadbolt with lock and unlock control.', + description: 'Matter-enabled Wi-Fi electronic deadbolt with lock, unlock, and deadbolt control.', role: 'security', aliases: ['Lock Pro Matter Enabled'], commands: [ { command: 'lock', parameter: '—', description: 'Lock the door', idempotent: true }, { command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, safetyTier: 'destructive', safetyReason: 'Physically unlocks the door — anyone nearby can open it.' }, + { command: 'nightLatchUnlock', parameter: '—', description: 'Unlock the night latch (EU)', idempotent: true, safetyTier: 'destructive', safetyReason: 'Physically unlocks the door — anyone nearby can open it.' }, + { command: 'deadbolt', parameter: '—', description: 'Disengage the deadbolt or latch', idempotent: true, safetyTier: 'destructive', safetyReason: 'Physically unlocks the door — anyone nearby can open it.' }, ], - statusFields: ['battery', 'version', 'lockState', 'doorState', 'calibrate'], + statusFields: ['battery', 'version', 'lockState', 'doorState', 'calibrate', 'onlineStatus'], }, { type: 'Lock Vision', @@ -295,9 +297,10 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ commands: [ { command: 'lock', parameter: '—', description: 'Lock the door', idempotent: true }, { command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, safetyTier: 'destructive', safetyReason: 'Physically unlocks the door — anyone nearby can open it.' }, + { command: 'createKey', parameter: '\'{"name":"...","type":"...","password":"...","startTime":,"endTime":}\'', description: 'Create a passcode', idempotent: false, safetyTier: 'destructive', safetyReason: 'Provisions a new access credential — anyone with this passcode can unlock the door.' }, { command: 'deleteKey', parameter: '\'{"id":}\'', description: 'Delete a passcode by id', idempotent: true, safetyTier: 'destructive', safetyReason: 'Permanently removes a passcode — the holder immediately loses door access.' }, ], - statusFields: ['battery', 'version', 'lockState', 'doorState', 'calibrate'], + statusFields: ['battery', 'version', 'lockState', 'doorState', 'calibrate', 'onlineStatus'], }, { type: 'Lock Vision Pro', @@ -307,9 +310,10 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ commands: [ { command: 'lock', parameter: '—', description: 'Lock the door', idempotent: true }, { command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, safetyTier: 'destructive', safetyReason: 'Physically unlocks the door — anyone nearby can open it.' }, + { command: 'createKey', parameter: '\'{"name":"...","type":"...","password":"...","startTime":,"endTime":}\'', description: 'Create a passcode', idempotent: false, safetyTier: 'destructive', safetyReason: 'Provisions a new access credential — anyone with this passcode can unlock the door.' }, { command: 'deleteKey', parameter: '\'{"id":}\'', description: 'Delete a passcode by id', idempotent: true, safetyTier: 'destructive', safetyReason: 'Permanently removes a passcode — the holder immediately loses door access.' }, ], - statusFields: ['battery', 'version', 'lockState', 'doorState', 'calibrate'], + statusFields: ['battery', 'version', 'lockState', 'doorState', 'calibrate', 'onlineStatus'], }, { type: 'Plug', @@ -499,7 +503,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { command: 'setMainLightBrightness', parameter: '{1-100}', description: 'Set main light brightness', idempotent: true, exampleParams: ['50'] }, { command: 'setMainLightColorTemp', parameter: '{2700-6500}', description: 'Set main light color temperature (K)', idempotent: true, exampleParams: ['4000'] }, { command: 'setColorLightBrightness', parameter: '{1-100}', description: 'Set color light brightness', idempotent: true, exampleParams: ['50'] }, - { command: 'setColorLightRGB', parameter: '\'{0-255}:{0-255}:{0-255}\'', description: 'Set color light RGB color', idempotent: true }, + { command: 'setColorLightRGB', parameter: '\'{0-255}:{0-255}:{0-255}\'', description: 'Set color light RGB color', idempotent: true, exampleParams: ['255:0:0'] }, ], statusFields: ['power', 'mainLightPower', 'mainLightBrightness', 'mainLightColorTemp', 'colorLightPower', 'colorLightBrightness', 'colorLightRGB', 'version'], }, @@ -587,7 +591,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { command: 'setWindMode', parameter: 'direct | natural | sleep | hurricane', description: 'Wind mode', idempotent: true, exampleParams: ['natural', 'sleep'] }, { command: 'setWindSpeed', parameter: '1-100', description: 'Fan speed', idempotent: true, exampleParams: ['50', '100'] }, ], - statusFields: ['power', 'mode', 'version', 'battery', 'nightStatus', 'oscillation', 'verticalOscillation', 'chargingStatus', 'fanSpeed'], + statusFields: ['power', 'mode', 'version', 'onlineStatus', 'nightStatus', 'oscillation', 'verticalOscillation', 'chargingStatus', 'fanSpeed'], }, { type: 'Blind Tilt', @@ -735,6 +739,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ category: 'physical', description: 'Indoor weather station display with customizable quote screen.', role: 'sensor', + aliases: ['Weather Station'], commands: [ { command: 'customQuote', parameter: 'custom text', description: 'Set a custom quote on the display', idempotent: true }, { command: 'cancelCustom', parameter: 'default', description: 'Revert to default quote display', idempotent: true }, @@ -849,7 +854,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ role: 'other', readOnly: true, commands: [], - statusFields: ['version', 'battery', 'chargingStatus', 'recordingStatus', 'uploadStatus', 'hasUntransferredFiles'], + statusFields: ['battery', 'chargingStatus', 'recordingStatus', 'uploadStatus', 'hasUntransferredFiles'], }, { type: 'Kata Friends', diff --git a/tests/config.test.ts b/tests/config.test.ts index c4a6844b..29e5622c 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -395,5 +395,16 @@ describe('config', () => { fsMock.existsSync.mockReturnValue(false); expect(tryLoadConfig()).toBeNull(); }); + + it('saveConfig clears the primed credential cache so loadConfig picks up the new values (#69)', async () => { + await primeWith('default', { token: 'stale-token', secret: 'stale-secret' }); + expect(loadConfig()).toEqual({ token: 'stale-token', secret: 'stale-secret' }); + + fsMock.existsSync.mockReturnValue(true); + fsMock.readFileSync.mockReturnValue(JSON.stringify({ token: 'fresh-token', secret: 'fresh-secret' })); + saveConfig('fresh-token', 'fresh-secret'); + + expect(loadConfig()).toEqual({ token: 'fresh-token', secret: 'fresh-secret' }); + }); }); }); diff --git a/tests/devices/catalog.test.ts b/tests/devices/catalog.test.ts index 64ae4326..1cfb8a03 100644 --- a/tests/devices/catalog.test.ts +++ b/tests/devices/catalog.test.ts @@ -178,6 +178,15 @@ const OFFICIAL_SUPPORTED_DEVICE_LIST_NAMES = [ 'Home Climate Panel', 'Smart Radiator Thermostat', 'AI Art Frame', + 'Weather Station', + 'Lock Pro Matter Enabled', + 'Lock Vision', + 'Lock Vision Pro', + 'Permanent Outdoor Lights', + 'RGBICWW Ceiling Light', + 'Battery Circulator Fan 2 Pro', + 'Kata Friends', + 'AI MindClip', ] as const; describe('devices/catalog', () => { From a68970d839aa03eb89ae091ecc2f3931c7d5c4a8 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Tue, 28 Jul 2026 12:07:19 +0800 Subject: [PATCH 5/5] fix: address second review pass - Add missing exampleParams for WeatherStation/Kata Friends so suggestedActions() emits runnable commands - Add onlineStatus to Permanent Outdoor Lights / RGBICWW Ceiling Light statusFields - Wire Permanent Outdoor Lights into brightness/color/color-temp validation - Add dedicated validation for Battery Circulator Fan 2 Pro enums - Reuse Keypad passcode validation for Lock Vision / Lock Vision Pro - Fix stale comment on __resetPrimedCredentials --- src/credentials/prime.ts | 4 +- src/devices/catalog.ts | 18 ++++---- src/devices/param-validator.ts | 24 ++++++++--- tests/devices/param-validator.test.ts | 60 +++++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 18 deletions(-) diff --git a/src/credentials/prime.ts b/src/credentials/prime.ts index 8bcd05fe..eb9b5a50 100644 --- a/src/credentials/prime.ts +++ b/src/credentials/prime.ts @@ -80,9 +80,7 @@ export function getPrimedCredentials(profile: string): CredentialBundle | null { } /** - * Clear the in-memory credential cache. Called by saveConfig() when - * credentials are written to disk so the next loadConfig() picks up - * the new values instead of a stale primed entry. + * Test helper. Clears all cached and in-flight credential generations. */ export function __resetPrimedCredentials(): void { for (const p of generations.keys()) { diff --git a/src/devices/catalog.ts b/src/devices/catalog.ts index 650c2769..83f5aefb 100644 --- a/src/devices/catalog.ts +++ b/src/devices/catalog.ts @@ -484,7 +484,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { command: 'setColor', parameter: '\'{0-255}:{0-255}:{0-255}\'', description: 'Set RGB color', idempotent: true, exampleParams: ['255:0:0'] }, { command: 'setColorTemperature', parameter: '{2700-6500}', description: 'Set color temperature in Kelvin', idempotent: true, exampleParams: ['4000'] }, ], - statusFields: ['power', 'brightness', 'color', 'colorTemperature', 'version'], + statusFields: ['power', 'onlineStatus', 'brightness', 'color', 'colorTemperature', 'version'], }, { type: 'Ceiling Light', @@ -515,7 +515,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { command: 'setColorLightBrightness', parameter: '{1-100}', description: 'Set color light brightness', idempotent: true, exampleParams: ['50'] }, { command: 'setColorLightRGB', parameter: '\'{0-255}:{0-255}:{0-255}\'', description: 'Set color light RGB color', idempotent: true, exampleParams: ['255:0:0'] }, ], - statusFields: ['power', 'mainLightPower', 'mainLightBrightness', 'mainLightColorTemp', 'colorLightPower', 'colorLightBrightness', 'colorLightRGB', 'version'], + statusFields: ['power', 'onlineStatus', 'mainLightPower', 'mainLightBrightness', 'mainLightColorTemp', 'colorLightPower', 'colorLightBrightness', 'colorLightRGB', 'version'], }, { type: 'Smart Radiator Thermostat', @@ -752,9 +752,9 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ role: 'sensor', aliases: ['Weather Station'], commands: [ - { command: 'customQuote', parameter: 'custom text', description: 'Set a custom quote on the display', idempotent: true }, + { command: 'customQuote', parameter: 'custom text', description: 'Set a custom quote on the display', idempotent: true, exampleParams: ['Hello from SwitchBot'] }, { command: 'cancelCustom', parameter: 'default', description: 'Revert to default quote display', idempotent: true }, - { command: 'customPage', parameter: 'custom text', description: 'Set a custom page text', idempotent: true }, + { command: 'customPage', parameter: 'custom text', description: 'Set a custom page text', idempotent: true, exampleParams: ['Today is a great day'] }, ], statusFields: ['temperature', 'humidity', 'battery', 'version'], }, @@ -864,12 +864,12 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ description: 'Interactive AI companion robot with comic diary generation and voice interaction.', role: 'other', commands: [ - { command: 'mode', parameter: '"Normal", "Standby", or "Sleep"', description: 'Set the operating mode', idempotent: true }, - { command: 'childLock', parameter: '"on" or "off"', description: 'Toggle child lock', idempotent: true }, + { command: 'mode', parameter: '"Normal", "Standby", or "Sleep"', description: 'Set the operating mode', idempotent: true, exampleParams: ['Normal'] }, + { command: 'childLock', parameter: '"on" or "off"', description: 'Toggle child lock', idempotent: true, exampleParams: ['off'] }, { command: 'backHome', parameter: 'default', description: 'Command Kata to return home', idempotent: true }, - { command: 'pictureTaking', parameter: '"on" or "off"', description: 'Toggle picture taking', idempotent: true }, - { command: 'snapshots', parameter: '"on" or "off"', description: 'Toggle snapshot mode', idempotent: true }, - { command: 'talk', parameter: '"on" or "off"', description: 'Toggle talk mode', idempotent: true }, + { command: 'pictureTaking', parameter: '"on" or "off"', description: 'Toggle picture taking', idempotent: true, exampleParams: ['on'] }, + { command: 'snapshots', parameter: '"on" or "off"', description: 'Toggle snapshot mode', idempotent: true, exampleParams: ['on'] }, + { command: 'talk', parameter: '"on" or "off"', description: 'Toggle talk mode', idempotent: true, exampleParams: ['on'] }, ], statusFields: ['version', 'onlineStatus', 'mode', 'status', 'childLock', 'hospitalized', 'battery'], }, diff --git a/src/devices/param-validator.ts b/src/devices/param-validator.ts index 913f1032..589d0603 100644 --- a/src/devices/param-validator.ts +++ b/src/devices/param-validator.ts @@ -240,6 +240,15 @@ export function validateParameter( if (isCirculatorFan(dt) && command === 'closeDelay') { return validateIntRange(raw, 'closeDelay', 1, 36000, 'auto-off delay in seconds'); } + if (dt === 'Battery Circulator Fan 2 Pro' && command === 'setNightLightMode') { + return validateEnum(raw, 'setNightLightMode', ['off', '0', '1']); + } + if (dt === 'Battery Circulator Fan 2 Pro' && command === 'setWindMode') { + return validateEnum(raw, 'setWindMode', ['direct', 'natural', 'sleep', 'hurricane']); + } + if (dt === 'Battery Circulator Fan 2 Pro' && command === 'setWindSpeed') { + return validateIntRange(raw, 'setWindSpeed', 1, 100, 'fan speed percentage'); + } // --- Smart Radiator Thermostat --- if (dt === 'Smart Radiator Thermostat' && command === 'setMode') { @@ -249,11 +258,11 @@ export function validateParameter( return validateIntRange(raw, 'setManualModeTemperature', 4, 35, 'temperature in °C'); } - // --- Keypad --- - if (dt.startsWith('Keypad') && command === 'createKey') { + // --- Keypad / Lock Vision (same passcode command shape) --- + if ((dt.startsWith('Keypad') || dt.startsWith('Lock Vision')) && command === 'createKey') { return validateKeypadCreateKey(raw); } - if (dt.startsWith('Keypad') && command === 'deleteKey') { + if ((dt.startsWith('Keypad') || dt.startsWith('Lock Vision')) && command === 'deleteKey') { return validateKeypadDeleteKey(raw); } @@ -290,7 +299,8 @@ export function brightnessRange(deviceType: string): [number, number] | null { deviceType === 'RGBICWW Floor Lamp' || deviceType === 'RGBIC Neon Wire Rope Light' || deviceType === 'RGBIC Neon Rope Light' || - deviceType === 'Candle Warmer Lamp' + deviceType === 'Candle Warmer Lamp' || + deviceType === 'Permanent Outdoor Lights' ) { return [0, 100]; } @@ -315,7 +325,8 @@ function isColorDevice(deviceType: string): boolean { deviceType === 'RGBIC Neon Wire Rope Light' || deviceType === 'RGBIC Neon Rope Light' || deviceType === 'Light Strip' || - deviceType === 'Fill Light' + deviceType === 'Fill Light' || + deviceType === 'Permanent Outdoor Lights' ); } @@ -330,7 +341,8 @@ function isColorTemperatureDevice(deviceType: string): boolean { deviceType === 'RGBICWW Floor Lamp' || deviceType === 'Light Strip' || deviceType === 'Dimmer' || - deviceType === 'Fill Light' + deviceType === 'Fill Light' || + deviceType === 'Permanent Outdoor Lights' ); } diff --git a/tests/devices/param-validator.test.ts b/tests/devices/param-validator.test.ts index 9727a9b2..1080b733 100644 --- a/tests/devices/param-validator.test.ts +++ b/tests/devices/param-validator.test.ts @@ -670,6 +670,7 @@ describe('validateParameter — documented lighting ranges', () => { expect(validateParameter('Floor Lamp', 'setBrightness', '0').ok).toBe(true); expect(validateParameter('RGBICWW Strip Light', 'setBrightness', '0').ok).toBe(true); expect(validateParameter('Candle Warmer Lamp', 'setBrightness', '0').ok).toBe(true); + expect(validateParameter('Permanent Outdoor Lights', 'setBrightness', '0').ok).toBe(true); }); it('keeps Color Bulb brightness at 1-100', () => { @@ -677,6 +678,45 @@ describe('validateParameter — documented lighting ranges', () => { }); }); +describe('validateParameter — Permanent Outdoor Lights', () => { + it('rejects out-of-range brightness', () => { + expect(validateParameter('Permanent Outdoor Lights', 'setBrightness', '101').ok).toBe(false); + }); + + it('validates setColor RGB format', () => { + expect(validateParameter('Permanent Outdoor Lights', 'setColor', '255:0:0').ok).toBe(true); + expect(validateParameter('Permanent Outdoor Lights', 'setColor', '256:0:0').ok).toBe(false); + }); + + it('validates setColorTemperature 2700-6500', () => { + expect(validateParameter('Permanent Outdoor Lights', 'setColorTemperature', '4000').ok).toBe(true); + expect(validateParameter('Permanent Outdoor Lights', 'setColorTemperature', '2699').ok).toBe(false); + expect(validateParameter('Permanent Outdoor Lights', 'setColorTemperature', '6501').ok).toBe(false); + }); +}); + +describe('validateParameter — Battery Circulator Fan 2 Pro', () => { + it('validates setNightLightMode against the 2 Pro enum (off | 0 | 1)', () => { + expect(validateParameter('Battery Circulator Fan 2 Pro', 'setNightLightMode', 'off').ok).toBe(true); + expect(validateParameter('Battery Circulator Fan 2 Pro', 'setNightLightMode', '0').ok).toBe(true); + expect(validateParameter('Battery Circulator Fan 2 Pro', 'setNightLightMode', '1').ok).toBe(true); + expect(validateParameter('Battery Circulator Fan 2 Pro', 'setNightLightMode', '2').ok).toBe(false); + }); + + it('validates setWindMode against the 2 Pro enum (includes hurricane, not baby)', () => { + for (const v of ['direct', 'natural', 'sleep', 'hurricane']) { + expect(validateParameter('Battery Circulator Fan 2 Pro', 'setWindMode', v).ok).toBe(true); + } + expect(validateParameter('Battery Circulator Fan 2 Pro', 'setWindMode', 'baby').ok).toBe(false); + }); + + it('validates setWindSpeed 1-100', () => { + expect(validateParameter('Battery Circulator Fan 2 Pro', 'setWindSpeed', '1').ok).toBe(true); + expect(validateParameter('Battery Circulator Fan 2 Pro', 'setWindSpeed', '0').ok).toBe(false); + expect(validateParameter('Battery Circulator Fan 2 Pro', 'setWindSpeed', '101').ok).toBe(false); + }); +}); + describe('validateParameter — Blind Tilt setPosition', () => { it('rejects odd angle values because the API requires multiples of 2', () => { const r = validateParameter('Blind Tilt', 'setPosition', 'up;51'); @@ -729,6 +769,26 @@ describe('validateParameter — Keypad', () => { }); }); +describe('validateParameter — Lock Vision / Lock Vision Pro (same passcode shape as Keypad)', () => { + it('validates createKey', () => { + const valid = '{"name":"test","type":"permanent","password":"123456"}'; + expect(validateParameter('Lock Vision', 'createKey', valid).ok).toBe(true); + expect(validateParameter('Lock Vision Pro', 'createKey', valid).ok).toBe(true); + }); + + it('rejects createKey with invalid password length', () => { + expect(validateParameter('Lock Vision', 'createKey', '{"name":"test","type":"permanent","password":"123"}').ok).toBe(false); + }); + + it('validates deleteKey', () => { + expect(validateParameter('Lock Vision', 'deleteKey', '{"id":12345}').ok).toBe(true); + }); + + it('rejects deleteKey without id', () => { + expect(validateParameter('Lock Vision Pro', 'deleteKey', '{}').ok).toBe(false); + }); +}); + describe('validateParameter — TV SetChannel', () => { it('accepts 1-999', () => { expect(validateParameter('TV', 'SetChannel', '1').ok).toBe(true);