@switchbot/homebridge-switchbot
Version:
The SwitchBot plugin allows you to access your SwitchBot device(s) from HomeKit.
1,179 lines (1,132 loc) • 44.3 kB
text/typescript
import type { SwitchBotPluginConfig } from './settings.js'
import type { Logger, PlatformConfig } from 'homebridge'
/**
* Indicates which device types should prefer Matter if available.
* Based on HAP service mappings: device implementations use specific HomeKit services
* that map to corresponding Matter clusters when Matter is enabled.
*
* @property {boolean} [deviceType] - True if the device type supports Matter, false otherwise.
* @example
* DEVICE_MATTER_SUPPORTED['bot'] // true
*/
/**
* Factory function to create Matter handlers with Homebridge logger integration.
* Returns handler objects for supported device types, mapping Matter cluster actions to SwitchBot API calls.
*
* @param log - Homebridge logger instance
* @param deviceId - SwitchBot device ID
* @param type - Device type string
* @param client - SwitchBot client instance
* @returns Handler object for Matter clusters
*/
export const DEVICE_MATTER_SUPPORTED: Record<string, boolean> = {
// Core devices
'bot': true, // Switch → OnOff
'curtain': true, // WindowCovering → WindowCovering
'fan': true, // Fan → FanControl
'light': true, // Lightbulb → OnOff + LevelControl
'lightstrip': true, // Lightbulb (color) → OnOff + LevelControl + ColorControl
'motion': true, // MotionSensor → OccupancySensing
'contact': true, // ContactSensor → BooleanState
'vacuum': true, // Switch → RobotVacuumCleaner
'lock': true, // LockMechanism → DoorLock
'humidifier': true, // Fan + Humidity → OnOff + FanControl + RelativeHumidityMeasurement
'temperature': true, // TemperatureSensor → TemperatureMeasurement
// Switch devices
'relay': true, // Switch → OnOff
'relay switch 1': true, // Switch → OnOff
'relay switch 1pm': true, // Switch → OnOff
'plug': true, // Outlet → OnOff
'plug mini (jp)': true, // Outlet → OnOff
'plug mini (us)': true, // Outlet → OnOff
// Window covering variants
'blindtilt': true, // WindowCovering → WindowCovering
'blind tilt': true, // WindowCovering → WindowCovering
'curtain3': true, // WindowCovering → WindowCovering
'rollershade': true, // WindowCovering → WindowCovering
'roller shade': true, // WindowCovering → WindowCovering
'worollershade': true, // WindowCovering → WindowCovering
'wo rollershade': true, // WindowCovering → WindowCovering
// Vacuum variants (normalized to 'vacuum' before lookup)
'wosweeper': true, // VacuumDevice → RobotVacuumCleaner
'wosweepermini': true, // VacuumDevice → RobotVacuumCleaner
'wosweeperminipro': true, // VacuumDevice → RobotVacuumCleaner
'k10+': true, // VacuumDevice → RobotVacuumCleaner
'k10+ pro': true, // VacuumDevice → RobotVacuumCleaner
// Sensors
'meter': true, // TemperatureSensor + HumiditySensor → TemperatureMeasurement + RelativeHumidityMeasurement
'meterplus': true, // TemperatureSensor + HumiditySensor → TemperatureMeasurement + RelativeHumidityMeasurement
'meter plus (jp)': true, // TemperatureSensor + HumiditySensor → TemperatureMeasurement + RelativeHumidityMeasurement
'meterpro': true, // TemperatureSensor + HumiditySensor → TemperatureMeasurement + RelativeHumidityMeasurement
'meterpro(co2)': true, // TemperatureSensor + HumiditySensor → TemperatureMeasurement + RelativeHumidityMeasurement
'waterdetector': true, // LeakSensor → BooleanState
'water detector': true, // LeakSensor → BooleanState
// Other devices
'smart fan': true, // Fan → FanControl
'strip light': true, // Lightbulb (color) → OnOff + LevelControl + ColorControl
'hub 2': false, // Hub device - not exposed as accessory
'walletfinder': false, // Button device - Matter support TBD
}
/**
* Default Matter cluster configurations by device type.
* Maps device types to their Matter cluster states (used when device doesn't provide clusters).
* Note: wosweeper/curtain/plug variants are normalized before cluster lookup (see loadDevices).
*
* @property {object} [deviceType] - The default cluster state for the device type.
* @example
* DEVICE_MATTER_CLUSTERS['bot'] // { onOff: { onOff: false } }
*/
export const DEVICE_MATTER_CLUSTERS: Record<string, any> = {
// Core devices - aligned with HAP service implementations
bot: { onOff: { onOff: false } }, // Switch → OnOff
vacuum: {
rvcRunMode: {
supportedModes: [
{ label: 'Idle', mode: 0, modeTags: [{ value: 16384 }] },
{ label: 'Cleaning', mode: 1, modeTags: [{ value: 16385 }] },
],
currentMode: 0,
},
rvcCleanMode: {
supportedModes: [
{ label: 'Vacuum', mode: 0, modeTags: [{ value: 16385 }] },
],
currentMode: 0,
},
rvcOperationalState: {
operationalStateList: [
{ operationalStateId: 0 }, // Stopped
{ operationalStateId: 1 }, // Running
{ operationalStateId: 2 }, // Paused
{ operationalStateId: 3 }, // Error (required)
{ operationalStateId: 64 }, // Seeking charger
{ operationalStateId: 65 }, // Charging
{ operationalStateId: 66 }, // Docked
],
operationalState: 66,
},
}, // Switch in HAP, RobotVacuumCleaner in Matter
curtain: {
windowCovering: {
currentPositionLiftPercent100ths: 0,
targetPositionLiftPercent100ths: 0,
operationalStatus: {
global: 0,
lift: 0,
tilt: 0,
},
endProductType: 0,
configStatus: {
operational: true,
onlineReserved: true,
liftMovementReversed: false,
liftPositionAware: true,
tiltPositionAware: false,
liftEncoderControlled: true,
tiltEncoderControlled: false,
},
},
}, // WindowCovering → WindowCovering (includes curtain3, rollershade variants via normalization)
blindtilt: {
windowCovering: {
currentPositionLiftPercent100ths: 0,
targetPositionLiftPercent100ths: 0,
currentPositionTiltPercent100ths: 0,
targetPositionTiltPercent100ths: 0,
operationalStatus: {
global: 0,
lift: 0,
tilt: 0,
},
endProductType: 8,
configStatus: {
operational: true,
onlineReserved: true,
liftMovementReversed: false,
liftPositionAware: true,
tiltPositionAware: true,
liftEncoderControlled: true,
tiltEncoderControlled: true,
},
},
}, // WindowCovering with tilt → WindowCovering
fan: {
onOff: { onOff: false },
fanControl: {
fanMode: 0,
percentCurrent: 0,
percentSetting: 0,
speedCurrent: 0,
speedMax: 100,
},
}, // Fan → OnOff + FanControl
light: {
onOff: { onOff: false },
levelControl: {
currentLevel: 0,
minLevel: 0,
maxLevel: 254,
},
}, // Lightbulb → OnOff + LevelControl
lightstrip: {
onOff: { onOff: false },
levelControl: {
currentLevel: 0,
minLevel: 0,
maxLevel: 254,
},
colorControl: {
colorMode: 0,
},
}, // Lightbulb with color → OnOff + LevelControl + ColorControl
lock: {
doorLock: {
lockState: 0,
lockType: 0,
actuatorEnabled: true,
operatingMode: 0,
},
}, // LockMechanism → DoorLock
motion: {
occupancySensing: {
occupancy: 0,
occupancySensorType: 0,
},
}, // MotionSensor → OccupancySensing
contact: {
booleanState: {
stateValue: false,
},
}, // ContactSensor → BooleanState
humidifier: {
onOff: { onOff: false },
fanControl: {
fanMode: 0,
percentCurrent: 0,
},
relativeHumidityMeasurement: {
measuredValue: 0,
minMeasuredValue: 0,
maxMeasuredValue: 100,
},
}, // HumidifierDehumidifier → OnOff + FanControl + RelativeHumidityMeasurement
temperature: {
temperatureMeasurement: {
measuredValue: 0,
minMeasuredValue: -27315,
maxMeasuredValue: 32767,
},
}, // TemperatureSensor → TemperatureMeasurement
// Switch/Outlet devices
relay: { onOff: { onOff: false } }, // Switch → OnOff
plug: {
onOff: { onOff: false },
electricalMeasurement: {
activePower: 0,
rmsCurrent: 0,
rmsVoltage: 0,
},
}, // Outlet → OnOff + ElectricalMeasurement (for PM models)
// Sensors
meter: {
temperatureMeasurement: {
measuredValue: 0,
minMeasuredValue: -27315,
maxMeasuredValue: 32767,
},
relativeHumidityMeasurement: {
measuredValue: 0,
minMeasuredValue: 0,
maxMeasuredValue: 100,
},
}, // TemperatureSensor + HumiditySensor → TemperatureMeasurement + RelativeHumidityMeasurement
waterdetector: {
booleanState: {
stateValue: false,
},
}, // LeakSensor → BooleanState
}
export function createMatterHandlers(log: Logger, deviceId: string, type: string, client: any): any {
const lowerType = type.toLowerCase()
switch (lowerType) {
case 'vacuum':
return {
rvcRunMode: {
changeToMode: async (request: any) => {
const modeNames = ['Idle', 'Cleaning', 'Mapping']
const modeName = modeNames[request?.newMode] || `Unknown (${request?.newMode})`
log.info(`[${deviceId}] RVC run mode change requested: ${modeName}`)
if (!client) {
log.warn(`[${deviceId}] No SwitchBot client available`)
return { success: false }
}
try {
// For K10+ family: use 'start' to begin cleaning (mode 1 = Cleaning)
// For older K10+: only supports start/stop/dock
// For newer K20+/S10/S20: supports startClean with more parameters
// Map Matter mode to SwitchBot command
const switchBotCommand = request?.newMode === 1 ? 'start' : 'stop'
const body = {
command: switchBotCommand,
parameter: 'default',
commandType: 'command',
}
log.debug(`[${deviceId}] Sending RVC mode change request:`, JSON.stringify(body))
const result = await client.setDeviceState(deviceId, body)
log.debug(`[${deviceId}] RVC mode change API response:`, JSON.stringify(result))
log.info(`[${deviceId}] RVC mode changed successfully to ${switchBotCommand}`)
return { success: true, result }
} catch (e) {
log.error(`[${deviceId}] Failed to change RVC mode:`, e)
return { success: false, error: e }
}
},
},
rvcCleanMode: {
changeToMode: async (request: any) => {
const modeName = request?.newMode !== undefined ? `Mode ${request.newMode}` : 'Unknown'
log.info(`[${deviceId}] RVC clean mode change requested: ${modeName}`)
if (!client) {
log.warn(`[${deviceId}] No SwitchBot client available`)
return { success: false }
}
try {
// Clean mode (vacuum/mop/etc) not directly supported via Matter for K10+
// K20+ Pro and newer models support via startClean action parameter
log.info(`[${deviceId}] Clean mode change requires startClean command (not yet implemented for Matter)`)
return { success: true }
} catch (e) {
log.error(`[${deviceId}] Failed to change RVC clean mode:`, e)
return { success: false, error: e }
}
},
},
rvcOperationalState: {
pause: async () => {
log.info(`[${deviceId}] RVC pause command received`)
if (!client) {
log.warn(`[${deviceId}] No SwitchBot client available`)
return { success: false }
}
try {
const body = {
command: 'stop',
parameter: 'default',
commandType: 'command',
}
log.debug(`[${deviceId}] Sending RVC pause request:`, JSON.stringify(body))
const result = await client.setDeviceState(deviceId, body)
log.debug(`[${deviceId}] RVC pause API response:`, JSON.stringify(result))
log.info(`[${deviceId}] RVC paused successfully`)
return { success: true, result }
} catch (e) {
log.error(`[${deviceId}] Failed to pause RVC:`, e)
return { success: false, error: e }
}
},
resume: async () => {
log.info(`[${deviceId}] RVC resume command received`)
if (!client) {
log.warn(`[${deviceId}] No SwitchBot client available`)
return { success: false }
}
try {
const body = {
command: 'start',
parameter: 'default',
commandType: 'command',
}
log.debug(`[${deviceId}] Sending RVC resume request:`, JSON.stringify(body))
const result = await client.setDeviceState(deviceId, body)
log.debug(`[${deviceId}] RVC resume API response:`, JSON.stringify(result))
log.info(`[${deviceId}] RVC resumed successfully`)
return { success: true, result }
} catch (e) {
log.error(`[${deviceId}] Failed to resume RVC:`, e)
return { success: false, error: e }
}
},
goHome: async () => {
log.info(`[${deviceId}] RVC goHome command received`)
if (!client) {
log.warn(`[${deviceId}] No SwitchBot client available`)
return { success: false }
}
try {
const body = {
command: 'dock',
parameter: 'default',
commandType: 'command',
}
log.debug(`[${deviceId}] Sending RVC goHome request:`, JSON.stringify(body))
const result = await client.setDeviceState(deviceId, body)
log.debug(`[${deviceId}] RVC goHome API response:`, JSON.stringify(result))
log.info(`[${deviceId}] RVC sent to dock successfully`)
return { success: true, result }
} catch (e) {
log.error(`[${deviceId}] Failed to send goHome command:`, e)
return { success: false, error: e }
}
},
},
}
case 'bot':
return {
onOff: {
on: async () => {
log.info(`[${deviceId}] Bot ON command received`)
if (!client) {
log.warn(`[${deviceId}] No SwitchBot client available`)
return { success: false }
}
try {
const result = await client.setDeviceState(deviceId, {
command: 'turnOn',
parameter: 'default',
commandType: 'command',
})
log.info(`[${deviceId}] Bot turned on successfully`)
return { success: true, result }
} catch (e) {
log.error(`[${deviceId}] Failed to turn on Bot:`, e)
return { success: false, error: e }
}
},
off: async () => {
log.info(`[${deviceId}] Bot OFF command received`)
if (!client) {
log.warn(`[${deviceId}] No SwitchBot client available`)
return { success: false }
}
try {
const result = await client.setDeviceState(deviceId, {
command: 'turnOff',
parameter: 'default',
commandType: 'command',
})
log.info(`[${deviceId}] Bot turned off successfully`)
return { success: true, result }
} catch (e) {
log.error(`[${deviceId}] Failed to turn off Bot:`, e)
return { success: false, error: e }
}
},
},
}
case 'curtain':
case 'blindtilt':
return {
windowCovering: {
goToLiftPercentage: async (request: any) => {
const percentage = request?.liftPercent100thsValue
log.info(`[${deviceId}] Curtain position change requested: ${percentage}`)
if (!client) {
log.warn(`[${deviceId}] No SwitchBot client available`)
return { success: false }
}
try {
// Convert Matter percentage (0-10000) to SwitchBot (0-100)
const position = Math.max(0, Math.min(100, Math.round((percentage || 0) / 100)))
const result = await client.setDeviceState(deviceId, {
command: 'setPosition',
parameter: String(position),
commandType: 'command',
})
log.info(`[${deviceId}] Curtain position set to ${position}% successfully`)
return { success: true, result }
} catch (e) {
log.error(`[${deviceId}] Failed to set curtain position:`, e)
return { success: false, error: e }
}
},
upOrOpen: async () => {
log.info(`[${deviceId}] Curtain open command received`)
if (!client) {
log.warn(`[${deviceId}] No SwitchBot client available`)
return { success: false }
}
try {
const result = await client.setDeviceState(deviceId, {
command: 'open',
parameter: 'default',
commandType: 'command',
})
log.info(`[${deviceId}] Curtain opened successfully`)
return { success: true, result }
} catch (e) {
log.error(`[${deviceId}] Failed to open curtain:`, e)
return { success: false, error: e }
}
},
downOrClose: async () => {
log.info(`[${deviceId}] Curtain close command received`)
if (!client) {
log.warn(`[${deviceId}] No SwitchBot client available`)
return { success: false }
}
try {
const result = await client.setDeviceState(deviceId, {
command: 'close',
parameter: 'default',
commandType: 'command',
})
log.info(`[${deviceId}] Curtain closed successfully`)
return { success: true, result }
} catch (e) {
log.error(`[${deviceId}] Failed to close curtain:`, e)
return { success: false, error: e }
}
},
stopMotion: async () => {
log.info(`[${deviceId}] Curtain stop command received`)
if (!client) {
log.warn(`[${deviceId}] No SwitchBot client available`)
return { success: false }
}
try {
const result = await client.setDeviceState(deviceId, {
command: 'pause',
parameter: 'default',
commandType: 'command',
})
log.info(`[${deviceId}] Curtain motion stopped successfully`)
return { success: true, result }
} catch (e) {
log.error(`[${deviceId}] Failed to stop curtain:`, e)
return { success: false, error: e }
}
},
},
}
case 'plug':
return {
onOff: {
on: async () => {
log.info(`[${deviceId}] Plug ON command received`)
if (!client) {
log.warn(`[${deviceId}] No SwitchBot client available`)
return { success: false }
}
try {
const result = await client.setDeviceState(deviceId, {
command: 'turnOn',
parameter: 'default',
commandType: 'command',
})
log.info(`[${deviceId}] Plug turned on successfully`)
return { success: true, result }
} catch (e) {
log.error(`[${deviceId}] Failed to turn on plug:`, e)
return { success: false, error: e }
}
},
off: async () => {
log.info(`[${deviceId}] Plug OFF command received`)
if (!client) {
log.warn(`[${deviceId}] No SwitchBot client available`)
return { success: false }
}
try {
const result = await client.setDeviceState(deviceId, {
command: 'turnOff',
parameter: 'default',
commandType: 'command',
})
log.info(`[${deviceId}] Plug turned off successfully`)
return { success: true, result }
} catch (e) {
log.error(`[${deviceId}] Failed to turn off plug:`, e)
return { success: false, error: e }
}
},
},
}
case 'lock':
return {
doorLock: {
setLockState: async (request: any) => {
const state = request?.lockState === 1 ? 'LOCKED' : 'UNLOCKED'
log.info(`[${deviceId}] Lock state change requested: ${state}`)
if (!client) {
log.warn(`[${deviceId}] No SwitchBot client available`)
return { success: false }
}
try {
const command = request?.lockState === 1 ? 'lock' : 'unlock'
const result = await client.setDeviceState(deviceId, {
command,
parameter: 'default',
commandType: 'command',
})
log.info(`[${deviceId}] Lock ${state} successfully`)
return { success: true, result }
} catch (e) {
log.error(`[${deviceId}] Failed to change lock state:`, e)
return { success: false, error: e }
}
},
},
}
case 'fan':
return {
onOff: {
on: async () => {
log.info(`[${deviceId}] Fan ON command received`)
if (!client) {
log.warn(`[${deviceId}] No SwitchBot client available`)
return { success: false }
}
try {
const result = await client.setDeviceState(deviceId, {
command: 'turnOn',
parameter: 'default',
commandType: 'command',
})
log.info(`[${deviceId}] Fan turned on successfully`)
return { success: true, result }
} catch (e) {
log.error(`[${deviceId}] Failed to turn on fan:`, e)
return { success: false, error: e }
}
},
off: async () => {
log.info(`[${deviceId}] Fan OFF command received`)
if (!client) {
log.warn(`[${deviceId}] No SwitchBot client available`)
return { success: false }
}
try {
const result = await client.setDeviceState(deviceId, {
command: 'turnOff',
parameter: 'default',
commandType: 'command',
})
log.info(`[${deviceId}] Fan turned off successfully`)
return { success: true, result }
} catch (e) {
log.error(`[${deviceId}] Failed to turn off fan:`, e)
return { success: false, error: e }
}
},
},
fanControl: {
setFanSpeed: async (request: any) => {
const speed = request?.percentSetting || 0
log.info(`[${deviceId}] Fan speed change requested: ${speed}%`)
if (!client) {
log.warn(`[${deviceId}] No SwitchBot client available`)
return { success: false }
}
try {
// Convert percentage to SwitchBot fan speed parameter
const speedParam = Math.max(1, Math.min(100, speed))
const result = await client.setDeviceState(deviceId, {
command: 'setFanSpeed',
parameter: String(speedParam),
commandType: 'command',
})
log.info(`[${deviceId}] Fan speed set to ${speedParam}% successfully`)
return { success: true, result }
} catch (e) {
log.error(`[${deviceId}] Failed to set fan speed:`, e)
return { success: false, error: e }
}
},
},
}
case 'light':
return {
onOff: {
on: async () => {
log.info(`[${deviceId}] Light ON command received`)
if (!client) {
log.warn(`[${deviceId}] No SwitchBot client available`)
return { success: false }
}
try {
const result = await client.setDeviceState(deviceId, {
command: 'turnOn',
parameter: 'default',
commandType: 'command',
})
log.info(`[${deviceId}] Light turned on successfully`)
return { success: true, result }
} catch (e) {
log.error(`[${deviceId}] Failed to turn on light:`, e)
return { success: false, error: e }
}
},
off: async () => {
log.info(`[${deviceId}] Light OFF command received`)
if (!client) {
log.warn(`[${deviceId}] No SwitchBot client available`)
return { success: false }
}
try {
const result = await client.setDeviceState(deviceId, {
command: 'turnOff',
parameter: 'default',
commandType: 'command',
})
log.info(`[${deviceId}] Light turned off successfully`)
return { success: true, result }
} catch (e) {
log.error(`[${deviceId}] Failed to turn off light:`, e)
return { success: false, error: e }
}
},
},
levelControl: {
moveToLevel: async (request: any) => {
const level = request?.level || 0
// Convert from 0-254 to 0-100
const brightness = Math.round((level / 254) * 100)
log.info(`[${deviceId}] Light brightness change requested: ${brightness}%`)
if (!client) {
log.warn(`[${deviceId}] No SwitchBot client available`)
return { success: false }
}
try {
const param = Math.max(0, Math.min(100, brightness))
const result = await client.setDeviceState(deviceId, {
command: 'setBrightness',
parameter: String(param),
commandType: 'command',
})
log.info(`[${deviceId}] Light brightness set to ${param}% successfully`)
return { success: true, result }
} catch (e) {
log.error(`[${deviceId}] Failed to set light brightness:`, e)
return { success: false, error: e }
}
},
},
}
case 'lightstrip':
return {
onOff: {
on: async () => {
log.info(`[${deviceId}] Lightstrip ON command received`)
if (!client) {
log.warn(`[${deviceId}] No SwitchBot client available`)
return { success: false }
}
try {
const result = await client.setDeviceState(deviceId, {
command: 'turnOn',
parameter: 'default',
commandType: 'command',
})
log.info(`[${deviceId}] Lightstrip turned on successfully`)
return { success: true, result }
} catch (e) {
log.error(`[${deviceId}] Failed to turn on lightstrip:`, e)
return { success: false, error: e }
}
},
off: async () => {
log.info(`[${deviceId}] Lightstrip OFF command received`)
if (!client) {
log.warn(`[${deviceId}] No SwitchBot client available`)
return { success: false }
}
try {
const result = await client.setDeviceState(deviceId, {
command: 'turnOff',
parameter: 'default',
commandType: 'command',
})
log.info(`[${deviceId}] Lightstrip turned off successfully`)
return { success: true, result }
} catch (e) {
log.error(`[${deviceId}] Failed to turn off lightstrip:`, e)
return { success: false, error: e }
}
},
},
levelControl: {
moveToLevel: async (request: any) => {
const level = request?.level || 0
// Convert from 0-254 to 0-100
const brightness = Math.round((level / 254) * 100)
log.info(`[${deviceId}] Lightstrip brightness change requested: ${brightness}%`)
if (!client) {
log.warn(`[${deviceId}] No SwitchBot client available`)
return { success: false }
}
try {
const param = Math.max(0, Math.min(100, brightness))
const result = await client.setDeviceState(deviceId, {
command: 'setBrightness',
parameter: String(param),
commandType: 'command',
})
log.info(`[${deviceId}] Lightstrip brightness set to ${param}% successfully`)
return { success: true, result }
} catch (e) {
log.error(`[${deviceId}] Failed to set lightstrip brightness:`, e)
return { success: false, error: e }
}
},
},
colorControl: {
moveToHueAndSaturation: async (request: any) => {
const hue = request?.hue || 0
const saturation = request?.saturation || 0
log.info(`[${deviceId}] Lightstrip color change requested: hue=${hue}, sat=${saturation}`)
if (!client) {
log.warn(`[${deviceId}] No SwitchBot client available`)
return { success: false }
}
try {
// Convert hue (0-254) and saturation (0-254) to combined color parameter
// SwitchBot typically expects RGB or HSV format as parameter
const colorParam = `${Math.round(hue)},${Math.round(saturation)}`
const result = await client.setDeviceState(deviceId, {
command: 'setColor',
parameter: colorParam,
commandType: 'command',
})
log.info(`[${deviceId}] Lightstrip color set successfully`)
return { success: true, result }
} catch (e) {
log.error(`[${deviceId}] Failed to set lightstrip color:`, e)
return { success: false, error: e }
}
},
moveToColorTemperature: async (request: any) => {
const mireds = request?.colorTemperatureMireds || 400
// Convert mireds (158-500 typical range) to Kelvin: K = 1000000 / mireds
const kelvin = Math.round(1000000 / mireds)
log.info(`[${deviceId}] Lightstrip color temperature change requested: ${mireds} mireds (${kelvin}K)`)
if (!client) {
log.warn(`[${deviceId}] No SwitchBot client available`)
return { success: false }
}
try {
// Map Kelvin to SwitchBot color temperature parameter (typically 0-100 or specific values)
// Normalize to 0-100 scale where 0=warm (2700K) and 100=cool (6500K)
const colorTempParam = Math.max(0, Math.min(100, Math.round(((kelvin - 2700) / 3800) * 100)))
const result = await client.setDeviceState(deviceId, {
command: 'setColorTemperature',
parameter: String(colorTempParam),
commandType: 'command',
})
log.info(`[${deviceId}] Lightstrip color temperature set to ${kelvin}K successfully`)
return { success: true, result }
} catch (e) {
log.error(`[${deviceId}] Failed to set lightstrip color temperature:`, e)
return { success: false, error: e }
}
},
},
}
case 'humidifier':
return {
onOff: {
on: async () => {
log.info(`[${deviceId}] Humidifier ON command received`)
if (!client) {
log.warn(`[${deviceId}] No SwitchBot client available`)
return { success: false }
}
try {
const result = await client.setDeviceState(deviceId, {
command: 'turnOn',
parameter: 'default',
commandType: 'command',
})
log.info(`[${deviceId}] Humidifier turned on successfully`)
return { success: true, result }
} catch (e) {
log.error(`[${deviceId}] Failed to turn on humidifier:`, e)
return { success: false, error: e }
}
},
off: async () => {
log.info(`[${deviceId}] Humidifier OFF command received`)
if (!client) {
log.warn(`[${deviceId}] No SwitchBot client available`)
return { success: false }
}
try {
const result = await client.setDeviceState(deviceId, {
command: 'turnOff',
parameter: 'default',
commandType: 'command',
})
log.info(`[${deviceId}] Humidifier turned off successfully`)
return { success: true, result }
} catch (e) {
log.error(`[${deviceId}] Failed to turn off humidifier:`, e)
return { success: false, error: e }
}
},
},
fanControl: {
setFanSpeed: async (request: any) => {
const speed = request?.percentSetting || 0
log.info(`[${deviceId}] Humidifier speed change requested: ${speed}%`)
if (!client) {
log.warn(`[${deviceId}] No SwitchBot client available`)
return { success: false }
}
try {
// Convert percentage to SwitchBot humidifier speed parameter
const speedParam = Math.max(1, Math.min(100, speed))
const result = await client.setDeviceState(deviceId, {
command: 'setFanSpeed',
parameter: String(speedParam),
commandType: 'command',
})
log.info(`[${deviceId}] Humidifier speed set to ${speedParam}% successfully`)
return { success: true, result }
} catch (e) {
log.error(`[${deviceId}] Failed to set humidifier speed:`, e)
return { success: false, error: e }
}
},
},
}
case 'relay':
return {
onOff: {
on: async () => {
log.info(`[${deviceId}] Relay ON command received`)
if (!client) {
log.warn(`[${deviceId}] No SwitchBot client available`)
return { success: false }
}
try {
const result = await client.setDeviceState(deviceId, {
command: 'turnOn',
parameter: 'default',
commandType: 'command',
})
log.info(`[${deviceId}] Relay turned on successfully`)
return { success: true, result }
} catch (e) {
log.error(`[${deviceId}] Failed to turn on relay:`, e)
return { success: false, error: e }
}
},
off: async () => {
log.info(`[${deviceId}] Relay OFF command received`)
if (!client) {
log.warn(`[${deviceId}] No SwitchBot client available`)
return { success: false }
}
try {
const result = await client.setDeviceState(deviceId, {
command: 'turnOff',
parameter: 'default',
commandType: 'command',
})
log.info(`[${deviceId}] Relay turned off successfully`)
return { success: true, result }
} catch (e) {
log.error(`[${deviceId}] Failed to turn off relay:`, e)
return { success: false, error: e }
}
},
},
}
default:
return undefined
}
}
/**
* Resolves the Matter device type for a given device, using the Matter API and device type mappings.
* Used to map normalized device types to Matter device type definitions.
*
* @param matterApi - The Matter API object containing deviceTypes
* @param type - The normalized device type string
* @param createdDeviceType - Optionally, an already created device type object
* @param clusters - Optionally, the clusters object for the device
* @returns The resolved Matter device type object
*/
const DEVICE_MATTER_DEVICE_TYPE_KEYS: Record<string, string> = {
bot: 'OnOffSwitch',
vacuum: 'RoboticVacuumCleaner',
curtain: 'WindowCovering',
blindtilt: 'WindowCovering',
fan: 'Fan',
light: 'DimmableLight',
lightstrip: 'ExtendedColorLight',
lock: 'DoorLock',
motion: 'MotionSensor',
contact: 'ContactSensor',
humidifier: 'Fan',
temperature: 'TemperatureSensor',
relay: 'OnOffSwitch',
plug: 'OnOffOutlet',
meter: 'TemperatureSensor',
waterdetector: 'LeakSensor',
}
export function resolveMatterDeviceType(matterApi: any, type: string, createdDeviceType?: any, clusters?: any): any {
if (createdDeviceType && typeof createdDeviceType === 'object' && typeof createdDeviceType.with === 'function') {
return createdDeviceType
}
const lowerType = (typeof createdDeviceType === 'string' && createdDeviceType) ? createdDeviceType.toLowerCase() : (type || '').toLowerCase()
// Cluster-based upgrade for color lights if descriptor omitted device type.
const hasColorControl = !!clusters?.colorControl
const inferredType = hasColorControl && lowerType === 'light'
? 'lightstrip'
: lowerType
const mappedKey = DEVICE_MATTER_DEVICE_TYPE_KEYS[inferredType] || 'OnOffSwitch'
return matterApi?.deviceTypes?.[mappedKey] || matterApi?.deviceTypes?.OnOffSwitch
}
/**
* Canonical Matter cluster ID mapping (from matter.js clusters).
* Maps cluster names to their numeric cluster IDs.
*
* @example
* MATTER_CLUSTER_IDS.OnOff // 0x0006
*/
export const MATTER_CLUSTER_IDS = {
OnOff: 0x0006,
LevelControl: 0x0008,
ColorControl: 0x0300,
WindowCovering: 0x0102,
DoorLock: 0x0101,
FanControl: 0x0202,
RelativeHumidityMeasurement: 0x0405,
} as const
/**
* Common Matter attribute IDs grouped by cluster.
* Maps cluster names to objects mapping attribute names to their numeric attribute IDs.
*
* @example
* MATTER_ATTRIBUTE_IDS.OnOff.OnOff // 0x0000
*/
export const MATTER_ATTRIBUTE_IDS = {
OnOff: { OnOff: 0x0000 },
LevelControl: { CurrentLevel: 0x0000 },
ColorControl: { CurrentHue: 0x0000, CurrentSaturation: 0x0001, ColorTemperatureMireds: 0x0002 },
WindowCovering: { CurrentPosition: 0x0000, TargetPosition: 0x0001 },
FanControl: { SpeedCurrent: 0x0000 },
DoorLock: { LockState: 0x0000 },
RelativeHumidityMeasurement: { MeasuredValue: 0x0000 },
} as const
/**
* Normalizes a device type string for Matter integration.
* Maps various device type aliases and variants to canonical Matter device types.
*
* @param {string | undefined | null} typeValue - The device type string to normalize.
* @returns {string} The normalized device type string for Matter.
*
* @example
* normalizeTypeForMatter('wosweeper') // 'vacuum'
* normalizeTypeForMatter('curtain3') // 'curtain'
* normalizeTypeForMatter('plug mini (us)') // 'plug'
*/
export function normalizeTypeForMatter(typeValue: string | undefined | null): string {
const raw = String(typeValue || '').trim().toLowerCase()
if (!raw) {
return 'unknown'
}
// Vacuum variants
if (['wosweeper', 'wosweepermini', 'wosweeperminipro', 'k10+', 'k10+ pro'].includes(raw)) {
return 'vacuum'
}
// Window covering variants
if (['curtain', 'curtain3', 'rollershade', 'roller shade', 'worollershade', 'wo rollershade'].includes(raw)) {
return 'curtain'
}
// Blind tilt variants (normalized to 'blindtilt' for Matter since it uses tilt-capable cluster)
if (['blindtilt', 'blind tilt'].includes(raw)) {
return 'blindtilt'
}
// Plug variants
if (['plug mini (jp)', 'plug mini (us)', 'plug mini (eu)'].includes(raw)) {
return 'plug'
}
// Meter variants
if (['meterplus', 'meter plus', 'meter plus (jp)', 'meterpro', 'meter pro', 'meterpro(co2)', 'meter pro (co2)'].includes(raw)) {
return 'meter'
}
// Relay switch variants
if (['relay switch 1', 'relay switch 1pm'].includes(raw)) {
return 'relay'
}
// Water detector variants
if (['water detector', 'waterdetector'].includes(raw)) {
return 'waterdetector'
}
// Fan variants
if (['smart fan', 'circulator fan', 'battery circulator fan', 'standing circulator fan'].includes(raw)) {
return 'fan'
}
// Light variants
if (['strip light', 'strip light 3', 'rgbic neon rope light', 'rgbic neon wire rope light', 'rgbicww floor lamp', 'rgbicww strip light'].includes(raw)) {
return 'lightstrip'
}
if (['color bulb', 'ceiling light', 'ceiling light pro', 'candle warmer lamp', 'floor lamp'].includes(raw)) {
return 'light'
}
// Sensor variants
if (raw === 'motion sensor') {
return 'motion'
}
if (['contact sensor', 'presence sensor'].includes(raw)) {
return 'contact'
}
// Lock variants
if (['smart lock', 'smart lock pro', 'smart lock ultra', 'lock lite', 'keypad', 'keypad touch', 'keypad vision', 'keypad vision pro', 'lock vision pro'].includes(raw)) {
return 'lock'
}
// Climate variant
if (raw === 'humidifier2') {
return 'humidifier'
}
return raw
}
/**
* Normalizes a Homebridge PlatformConfig object to a SwitchBotPluginConfig.
*
* @param raw The raw Homebridge platform config object.
* @returns The normalized plugin config object.
*/
export function normalizeConfig(raw?: PlatformConfig): SwitchBotPluginConfig {
if (!raw) {
return {}
}
return { ...(raw as any) } as SwitchBotPluginConfig
}
// Create a Proxy constructor that instantiates the right platform implementation at runtime.
/**
* Creates a proxy class that instantiates the correct platform implementation (HAP or Matter) at runtime.
*
* @param HAPPlatform The HAP platform class constructor.
* @param MatterPlatform The Matter platform class constructor.
* @returns A proxy class that delegates to the correct platform implementation.
*
* @class SwitchBotPlatformProxy
* @property impl The instantiated platform implementation (HAP or Matter).
*/
export function createPlatformProxy(HAPPlatform: any, MatterPlatform: any): any {
return class SwitchBotPlatformProxy {
/** The instantiated platform implementation (HAP or Matter) */
private impl: any
/**
* Constructs the proxy and instantiates the correct platform implementation.
* @param log Logger instance
* @param config Platform config
* @param api Homebridge API instance
* @returns The instantiated platform implementation
*/
constructor(log: any, config: PlatformConfig, api: any) {
const cfg = normalizeConfig(config)
const preferMatter = cfg.preferMatter ?? true
const enableMatter = cfg.enableMatter ?? true
const matterAvailable = !!(api?.isMatterAvailable?.() && api?.isMatterEnabled?.())
if (enableMatter && preferMatter && MatterPlatform && matterAvailable) {
this.impl = new MatterPlatform(log, cfg, api)
return this.impl
}
// Fallback to HAP
this.impl = new HAPPlatform(log, cfg, api)
return this.impl
}
}
}