Created this Switchbot P1 sensor driver for hubitat using our AI overlords (chatGPT Codex). No idea how it works, but it does. I tested it with my own P1 sensor.
It requires the Open Token as well as the Secret Key from Switchbot open API.
The drivers are based off of ToffeHoff API-Bot work here.
/*
* SwitchBot P1 Presence Sensor driver for Hubitat
*
* Maps SwitchBot Cloud API / webhook presence state into Hubitat MotionSensor
* and PresenceSensor capabilities so webCoRE can use either event style.
*/
metadata
{
definition(
name: "SwitchBot P1 Presence Sensor",
namespace: "joelrodz",
author: "joelrodz / Codex",
importUrl: ""
)
{
capability "Battery"
capability "Initialize"
capability "MotionSensor"
capability "Polling"
capability "PresenceSensor"
capability "Refresh"
capability "Sensor"
attribute "apiStatus", "string"
attribute "batteryApiValue", "number"
attribute "batteryRange", "string"
attribute "detectionState", "string"
attribute "hubDeviceId", "string"
attribute "lastUpdated", "string"
attribute "lightLevel", "number"
attribute "switchbotDeviceType", "string"
attribute "version", "string"
command "autoConfigurePresenceSensor"
command "listSwitchBotDevices"
command "parseSwitchBotEvent", [[
name: "SwitchBot event/status JSON",
type: "STRING",
description: "Optional: paste a SwitchBot webhook or status JSON payload for testing."
]]
}
preferences
{
section("SwitchBot Cloud API")
{
input name: "openToken", type: "password", title: "Open Token", required: true
input name: "secretKey", type: "password", title: "Secret Key (API v1.1)", required: false
input name: "switchbotDeviceId", type: "text", title: "SwitchBot Presence Sensor Device ID", required: false
input name: "presenceSensorName", type: "text", title: "Presence Sensor Name Filter (optional, for auto-detect)", required: false
}
section("Polling")
{
input name: "pollIntervalSeconds", type: "enum", title: "Polling interval", required: true,
defaultValue: "60",
options: [
"0": "Disabled",
"30": "30 seconds",
"60": "1 minute",
"120": "2 minutes",
"300": "5 minutes",
"600": "10 minutes"
]
}
section("Logging")
{
input name: "debugLogging", type: "bool", title: "Enable debug logging", defaultValue: true, required: false
}
}
}
def installed()
{
initialize()
}
def updated()
{
initialize()
}
def initialize()
{
unschedule()
updateDataValue("driverVersion", "0.1.0")
updateDataValue("switchbotDeviceId", cleanSetting("switchbotDeviceId"))
if(cleanSetting("openToken") && !cleanSetting("switchbotDeviceId"))
{
autoConfigurePresenceSensor()
}
else
{
refresh()
}
scheduleNextPoll()
}
def autoConfigurePresenceSensor()
{
if(!cleanSetting("openToken"))
{
log.warn "Open Token is required before auto-detect can run."
sendEvent(name: "apiStatus", value: "missingToken")
return
}
try
{
def devices = fetchSwitchBotDevices(false)
def candidates = devices.findAll { isPresenceDevice(it) }
def nameFilter = cleanSetting("presenceSensorName")
if(nameFilter)
{
candidates = candidates.findAll
{ dev ->
dev.deviceName?.toString()?.toLowerCase()?.contains(nameFilter.toLowerCase())
}
}
if(candidates.size() == 1)
{
def selected = candidates[0]
def selectedId = selected.deviceId?.toString()
device.updateSetting("switchbotDeviceId", [value: selectedId, type: "text"])
updateDataValue("switchbotDeviceId", selectedId)
sendEvent(name: "switchbotDeviceType", value: selected.deviceType?.toString())
if(selected.hubDeviceId != null) { sendEvent(name: "hubDeviceId", value: selected.hubDeviceId.toString()) }
log.info "Auto-configured SwitchBot P1 Presence Sensor: name='${selected.deviceName}', id='${selectedId}', hub='${selected.hubDeviceId}'"
sendEvent(name: "apiStatus", value: "ok")
refresh()
return
}
if(candidates.size() == 0)
{
log.warn "No SwitchBot Presence Sensor devices were found. Confirm the P1 is visible in SwitchBot Cloud and cloud service is enabled."
sendEvent(name: "apiStatus", value: "autoDetectNone")
listSwitchBotDevices()
return
}
log.warn "Multiple SwitchBot Presence Sensor devices found. Set Presence Sensor Name Filter or paste one of these IDs into SwitchBot Presence Sensor Device ID:"
candidates.each
{ dev ->
log.warn "Presence candidate: name='${dev.deviceName}', id='${dev.deviceId}', hub='${dev.hubDeviceId}', cloud='${dev.enableCloudService}'"
}
sendEvent(name: "apiStatus", value: "autoDetectMultiple")
}
catch(Exception e)
{
log.warn "SwitchBot presence auto-detect failed: ${e.message}"
sendEvent(name: "apiStatus", value: "error")
}
}
def listSwitchBotDevices()
{
if(!cleanSetting("openToken"))
{
log.warn "Open Token is required before listing SwitchBot devices."
sendEvent(name: "apiStatus", value: "missingToken")
return
}
try
{
def devices = fetchSwitchBotDevices(true)
if(!devices)
{
log.warn "SwitchBot device list was empty."
sendEvent(name: "apiStatus", value: "emptyResponse")
return
}
devices.each
{ dev ->
log.info "SwitchBot device: name='${dev.deviceName}', type='${dev.deviceType}', id='${dev.deviceId}', hub='${dev.hubDeviceId}', cloud='${dev.enableCloudService}'"
}
sendEvent(name: "apiStatus", value: "ok")
}
catch(Exception e)
{
log.warn "SwitchBot device list failed: ${e.message}"
sendEvent(name: "apiStatus", value: "error")
}
}
def fetchSwitchBotDevices(logRawResponse)
{
def params = genParamsMain("devices")
def devices = []
logDebug("GET ${params.uri}")
httpGet(params)
{ resp ->
if(!resp?.data)
{
return
}
if(logRawResponse)
{
log.info "SwitchBot devices response: ${resp.data}"
}
def deviceList = resp.data?.body?.deviceList
if(deviceList instanceof List)
{
devices = deviceList
}
}
return devices
}
def isPresenceDevice(dev)
{
def deviceType = dev?.deviceType?.toString()
return ["Presence Sensor", "WoPresence"].contains(deviceType)
}
def poll()
{
refresh()
scheduleNextPoll()
}
def refresh()
{
if(!hasRequiredSettings())
{
sendEvent(name: "apiStatus", value: cleanSetting("openToken") ? "missingDeviceId" : "missingSettings")
return
}
try
{
def params = genParamsMain("devices/${cleanSetting('switchbotDeviceId')}/status")
logDebug("GET ${params.uri}")
httpGet(params)
{ resp ->
if(!resp)
{
sendEvent(name: "apiStatus", value: "emptyResponse")
return
}
handleApiResponse(resp.data)
}
}
catch(Exception e)
{
log.warn "SwitchBot refresh failed: ${e.message}"
sendEvent(name: "apiStatus", value: "error")
}
}
def parse(String description)
{
parseSwitchBotEvent(description)
}
def parse(Map eventMap)
{
parseSwitchBotEvent(eventMap)
}
def parseSwitchBotEvent(eventPayload)
{
if(!eventPayload)
{
return false
}
try
{
def parsed = eventPayload
if(eventPayload instanceof String)
{
parsed = new groovy.json.JsonSlurper().parseText(eventPayload)
}
if(parsed instanceof Map && parsed.context instanceof Map)
{
def body = [:] + parsed.context
if(parsed.eventType) { body.eventType = parsed.eventType }
if(body.deviceMac && !body.deviceId) { body.deviceId = body.deviceMac }
return handleStatusMap(body)
}
if(parsed instanceof Map && parsed.body instanceof Map)
{
return handleStatusMap(parsed.body)
}
if(parsed instanceof Map)
{
return handleStatusMap(parsed)
}
logDebug("Unsupported event payload: ${parsed}")
return false
}
catch(Exception e)
{
log.warn "SwitchBot event parse failed: ${e.message}"
sendEvent(name: "apiStatus", value: "parseError")
return false
}
}
def handleApiResponse(apiResponse)
{
if(!apiResponse)
{
sendEvent(name: "apiStatus", value: "emptyResponse")
return
}
def statusCode = valueForKeys(apiResponse, ["statusCode"])
if(statusCode != null && statusCode.toString() != "100")
{
log.warn "SwitchBot API returned statusCode=${statusCode}, response=${apiResponse}"
sendEvent(name: "apiStatus", value: "apiStatus${statusCode}")
return
}
def body = valueForKeys(apiResponse, ["body"])
if(body instanceof Map)
{
handleStatusMap(body)
return
}
if(apiResponse instanceof Map)
{
handleStatusMap(apiResponse)
return
}
sendEvent(name: "apiStatus", value: "badResponse")
}
def handleStatusMap(Map body)
{
if(!body)
{
return false
}
if(!payloadMatchesConfiguredDevice(body))
{
logDebug("Ignoring payload for another SwitchBot device: ${body}")
return false
}
logDebug("Applying SwitchBot payload: ${body}")
sendEvent(name: "apiStatus", value: "ok")
sendEvent(name: "lastUpdated", value: nowString())
def detected = extractDetected(body)
if(detected != null)
{
def detectionState = detected ? "DETECTED" : "NOT_DETECTED"
sendEvent(name: "detectionState", value: detectionState)
sendEvent(name: "motion", value: detected ? "active" : "inactive")
sendEvent(name: "presence", value: detected ? "present" : "not present")
}
def battery = valueForKeys(body, ["battery", "Battery"])
if(battery != null && isNumeric(battery))
{
def batteryApiValue = clampInteger(battery, 0, 100)
def batteryInfo = normalizePresenceBattery(batteryApiValue)
sendEvent(name: "batteryApiValue", value: batteryApiValue, unit: "%")
sendEvent(name: "batteryRange", value: batteryInfo.range)
sendEvent(name: "battery", value: batteryInfo.capabilityValue, unit: "%")
}
def lightLevel = valueForKeys(body, ["lightLevel", "light_level"])
if(lightLevel != null && isNumeric(lightLevel))
{
sendEvent(name: "lightLevel", value: lightLevel as Integer)
}
def deviceType = valueForKeys(body, ["deviceType", "type"])
if(deviceType != null)
{
sendEvent(name: "switchbotDeviceType", value: deviceType.toString())
}
def hubId = valueForKeys(body, ["hubDeviceId"])
if(hubId != null)
{
sendEvent(name: "hubDeviceId", value: hubId.toString())
}
def versionValue = valueForKeys(body, ["version"])
if(versionValue != null)
{
sendEvent(name: "version", value: versionValue.toString())
}
return true
}
def extractDetected(Map body)
{
def value = valueForKeys(body, [
"Detected",
"detected",
"moveDetected",
"detectionState",
"status",
"humanDetected",
"presenceDetected"
])
if(value == null)
{
return null
}
return valueToDetected(value)
}
def valueToDetected(value)
{
if(value instanceof Boolean)
{
return value
}
def normalized = value.toString().trim().toLowerCase()
if(["true", "1", "detected", "active", "present", "occupied", "motion"].contains(normalized))
{
return true
}
if(["false", "0", "not_detected", "not detected", "inactive", "not_present", "not present", "unoccupied", "clear", "none"].contains(normalized))
{
return false
}
return null
}
def payloadMatchesConfiguredDevice(Map body)
{
def configuredId = cleanSetting("switchbotDeviceId")
if(!configuredId)
{
return true
}
def payloadId = valueForKeys(body, ["deviceId", "deviceMac", "mac"])
if(!payloadId)
{
return true
}
def configured = configuredId.toString()
def payload = payloadId.toString()
if(configured.equalsIgnoreCase(payload))
{
return true
}
return normalizeId(configured) == normalizeId(payload)
}
def normalizeId(value)
{
return value?.toString()?.replaceAll(/[^A-Fa-f0-9]/, "")?.toUpperCase()
}
def genParamsMain(String suffix)
{
def token = cleanSetting("openToken")
def secret = cleanSetting("secretKey")
def apiVersion = secret ? "v1.1" : "v1.0"
def params =
[
uri: "https://api.switch-bot.com/${apiVersion}/${suffix}",
headers:
[
"Authorization": token,
"Content-Type": "application/json; charset=utf8"
],
timeout: 20
]
if(secret)
{
params.headers += calcSignv11(token, secret)
}
return params
}
def calcSignv11(String token, String secret)
{
def t = new Date().getTime().toString()
def nonce = java.util.UUID.randomUUID().toString()
def data = token + t + nonce
return [
t: t,
nonce: nonce,
sign: hmacSha256(secret, data)?.toUpperCase()
]
}
def hmacSha256(String secret, String data)
{
def mac = javax.crypto.Mac.getInstance("HmacSHA256")
def secretKeySpec = new javax.crypto.spec.SecretKeySpec(secret.getBytes("UTF-8"), "HmacSHA256")
mac.init(secretKeySpec)
return org.apache.commons.codec.binary.Base64.encodeBase64String(mac.doFinal(data.getBytes("UTF-8")))
}
def hasRequiredSettings()
{
return cleanSetting("openToken") && cleanSetting("switchbotDeviceId")
}
def scheduleNextPoll()
{
unschedule("poll")
def seconds = pollSeconds()
if(seconds > 0)
{
runIn(seconds, "poll")
}
}
def pollSeconds()
{
try
{
return (pollIntervalSeconds ?: "60") as Integer
}
catch(Exception ignored)
{
return 60
}
}
def valueForKeys(source, List keys)
{
if(!(source instanceof Map))
{
return null
}
for(key in keys)
{
if(source.containsKey(key) && source[key] != null)
{
return source[key]
}
def matchingKey = source.keySet().find { it?.toString()?.equalsIgnoreCase(key.toString()) }
if(matchingKey != null && source[matchingKey] != null)
{
return source[matchingKey]
}
}
return null
}
def isNumeric(value)
{
try
{
value as BigDecimal
return true
}
catch(Exception ignored)
{
return false
}
}
def clampInteger(value, Integer min, Integer max)
{
def intValue = (value as BigDecimal).intValue()
return Math.max(min, Math.min(max, intValue))
}
def normalizePresenceBattery(Integer apiBattery)
{
if(apiBattery >= 100)
{
return [capabilityValue: 60, range: ">=60%"]
}
if(apiBattery >= 60)
{
return [capabilityValue: 20, range: "20-60%"]
}
if(apiBattery >= 20)
{
return [capabilityValue: 10, range: "10-20%"]
}
if(apiBattery >= 10)
{
return [capabilityValue: 0, range: "<10%"]
}
return [capabilityValue: apiBattery, range: "${apiBattery}%"]
}
def cleanSetting(String name)
{
return settings?."${name}"?.toString()?.trim()
}
def nowString()
{
def tz = location?.timeZone ?: java.util.TimeZone.getTimeZone("UTC")
return new Date().format("yyyy-MM-dd HH:mm:ss", tz)
}
def logDebug(message)
{
if(debugLogging)
{
log.debug "${message}"
}
}
ReadME
# SwitchBot P1 Presence Sensor for Hubitat
This repo contains a standalone Hubitat Groovy driver for the SwitchBot P1 / Presence Sensor.
The driver exposes:
- `motion`: `active` / `inactive`
- `presence`: `present` / `not present`
- `battery`: percent
- `batteryApiValue`: raw SwitchBot Cloud battery bucket
- `batteryRange`: interpreted battery range
- `lightLevel`: SwitchBot's relative light level value
- `detectionState`: raw-ish `DETECTED` / `NOT_DETECTED`
Those attributes are intended to be easy to use from webCoRE.
## Files
- `switchbotP1PresenceSensor.groovy`: install this as a Hubitat driver.
## Basic polling setup
1. In Hubitat, go to **Drivers Code** and add `switchbotP1PresenceSensor.groovy`.
2. Create a new virtual device and set its type to **SwitchBot P1 Presence Sensor**.
3. Add your SwitchBot Open Token and Secret Key.
4. Leave **SwitchBot Presence Sensor Device ID** blank, or paste the device ID if you already know it.
5. Set a polling interval. For one sensor, 60 seconds is usually reasonable; use a slower interval if you have several SwitchBot devices sharing the same API limit.
6. Click **Save Preferences**.
7. Click **autoConfigurePresenceSensor**. If exactly one `Presence Sensor` is visible from the SwitchBot API, the driver will save its device ID automatically.
8. If auto-detect finds multiple presence sensors, set **Presence Sensor Name Filter** to part of the SwitchBot device name and run **autoConfigurePresenceSensor** again.
SwitchBot exposes the Open Token and Secret Key from the SwitchBot mobile app's developer settings. The Device ID is visible from the SwitchBot OpenAPI `/devices` response; for many sensors it is the device MAC without separators.
## Battery reporting
SwitchBot Cloud does not report an exact Presence Sensor battery percentage. The OpenAPI status response reports a four-step bucket:
- API `100` means `>=60%`
- API `60` means `20-60%`
- API `20` means `10-20%`
- API `10` means `<10%`
The driver exposes the raw bucket as `batteryApiValue`, exposes the interpreted range as `batteryRange`, and sets Hubitat's standard `battery` capability to the conservative lower bound of that range. For example, if the SwitchBot app says `80%`, the API can still return `100`; the driver will now show `batteryRange: >=60%` and `battery: 60`.
## Field mapping
The driver accepts the field names currently shown in the SwitchBot OpenAPI docs and the names used by existing integrations:
- `Detected: true/false`
- `detected: true/false`
- `moveDetected: true/false`
- `detectionState: DETECTED/NOT_DETECTED`
- `battery`
- `lightLevel`
## Source references
- SwitchBot OpenAPI: https://github.com/OpenWonderLabs/SwitchBotAPI
- Existing Hubitat SwitchBot integration used as a design reference: https://github.com/tomwpublic/hubitat_switchbot
- Home Assistant SwitchBot Cloud integration behavior reference: https://www.home-assistant.io/integrations/switchbot_cloud/