/**
* Hank's Switch Bot v07-01-2026
* Copyright 2026 Hank Leukart
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
* in compliance with the License. You may obtain a copy of the License at:
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
* for the specific language governing permissions and limitations under the License.
*
* Hubitat app for automated control of lights/scenes from switches based on device names.
* Handles 'All'/Master conventions, zone tags, and roomName property.
* Local switches ("Local Switch" or "(S)") have LEDs updated on mode change only.
*/
import groovy.transform.Field
// +/- window when comparing a light's reported level to a mode's level, because lights
// do not always settle exactly on the requested level.
@Field static final Integer LEVEL_MATCH_TOLERANCE = 2
definition(
name: "Hank's Switch Bot",
namespace: "hankle",
author: "Hank Leukart",
description: "Simple, zero-config control of lights and scenes from switches. Manages local switches for LED changes.",
category: "Convenience",
iconUrl: "",
iconX2Url: "",
singleThreaded: true
)
preferences {
section("
Hank's Switch Bot
") {
paragraph "Switch Bot automatically creates zero-configuration control of light and scenes with switches based on device names. For example, \"Kitchen Ceiling Switch\" will automatically control lights named \"Kitchen Ceiling 1 & 2\" and a scene named \"Kitchen Scene: Cooking.\" A switch named \"Kitchen Switch\" will control all \"Kitchen\" lights not controlled by another switch. Simply select all switches, lights, and scenes you want Switch Bot to handle for you.
"
input "controlledSwitches", "capability.pushableButton",
title: "Switches controlled by Switch Bot:",
multiple: true, required: true, width: 6
input "controlledLightsAndScenes", "capability.actuator,capability.switchLevel,capability.pushableButton",
title: "Lights & Scenes controlled by Switch Bot: (select-all recommended)",
multiple: true, required: true, width: 6
}
section("Switch Control Mappings Summary", hideable: true, hidden: true) {
paragraph "${state.switchControlSummary ?: 'Mappings will be displayed after saving settings.'}"
}
section("Mode Lighting Defaults", hideable: true, hidden: true) {
input name: "globalDefaultLevel", type: "number", title: "Default Brightness (%)",
description: "Used if a mode has no specific level set.",
range: "1..100", defaultValue: 100, required: true, width: 3
input name: "globalDefaultColorTemperature", type: "number", title: "Default Color Temperature (K)",
description: "Used if a mode is enabled for CT but has no specific CT set.",
range: "2000..9000", defaultValue: 2700, required: true, width: 3
input name: "globalDefaultLedOnBrightness", type: "number", title: "Default LED On Brightness (%)",
description: "Used if a mode has no specific LED ON brightness.",
range: "0..100", defaultValue: 30, required: true, width: 3
input name: "globalDefaultLedOffBrightness", type: "number", title: "Default LED Off Brightness (%)",
description: "Used if a mode has no specific LED OFF brightness.",
range: "0..100", defaultValue: 7, required: true, width: 3
paragraph ""
if (location.modes) {
def defaults = [
morning: [level: 100, ct: 3000, ledOn: 30, ledOff: 7],
day: [level: 100, ct: 4000, ledOn: 30, ledOff: 7],
evening: [level: 100, ct: 2700, ledOn: 6, ledOff: 3],
sleep: [level: 40, ct: 2200, ledOn: 6, ledOff: 3]
]
def sortedModes = location.modes.collect().sort { mode ->
def name = mode.name?.toLowerCase() ?: ""
def idx = defaults.keySet().toList().indexOf(name)
[idx != -1 ? idx : 999, name]
}
sortedModes.each { mode ->
String safeModeName = mode.name.replaceAll("[^a-zA-Z0-9_]", "_").toLowerCase()
String currentModeNameLower = mode.name.toLowerCase()
Boolean conditionalEnableCt = true
def modeDefault = defaults[currentModeNameLower]
Integer conditionalDefaultLevel = modeDefault?.level
Integer conditionalDefaultCt = modeDefault?.ct
Integer conditionalDefaultLedOn = modeDefault?.ledOn != null ? modeDefault.ledOn : 30
Integer conditionalDefaultLedOff = modeDefault?.ledOff != null ? modeDefault.ledOff : 7
input(name: "level_${safeModeName}", type: "number", title: "\"${mode.name}\" Brightness (%)", range: "1..100", required: false, width: 3, defaultValue: conditionalDefaultLevel)
input(name: "ct_${safeModeName}", type: "number", title: "\"${mode.name}\" Color Temp (K)", range: "2000..9000", required: false, width: 3, defaultValue: conditionalDefaultCt)
input(name: "ledOnBrightness_${safeModeName}", type: "number", title: "LED On Brightness (%)", range: "0..100", required: false, width: 3, defaultValue: conditionalDefaultLedOn)
input(name: "ledOffBrightness_${safeModeName}", type: "number", title: "LED Off Brightness (%)", range: "0..100", required: false, width: 3, defaultValue: conditionalDefaultLedOff)
if (currentModeNameLower.contains("sleep") || currentModeNameLower.contains("night") || currentModeNameLower.contains("bed")) {
input(name: "ledOffZone_${safeModeName}", type: "text",
title: "While in \"${mode.name},\" LEDs and lights auto-on disabled for this zone:",
description: "If a zone name is entered, LEDs on switches within that zone will be turned completely off (both ON and OFF LEDs set to 0) and motion auto-on triggers will be disabled for switches in this zone when in this mode. Other switches will use the mode's LED brightness settings above.",
required: false, width: 6)
}
paragraph ""
}
} else {
paragraph "Save settings once to see per-mode configuration options."
}
}
section("Auto On/Off Lights (Motion)", hideable: true, hidden: true) {
paragraph "Configure which switches can automatically turn lights on/off using their built-in motion sensors, and define the ambient light (Lux) threshold for auto-on triggers."
input "motionEnabledSwitches", "enum",
title: "Switches that can auto-on/off lights with motion:",
options: (settings?.controlledSwitches ?: controlledSwitches)?.findAll { it.hasCapability("MotionSensor") || it.hasCapability("Motion Sensor") }?.collectEntries { [it.id.toString(), it.displayName] } ?: [:],
multiple: true, required: false, width: 6
input name: "motionIlluminanceThreshold", type: "number", title: "Auto-On Lux Threshold",
description: "Only trigger auto-on if light level is below this threshold. Leave blank to ignore Lux.",
defaultValue: 50, required: false, width: 6
}
section("Advanced Button Mappings (Optional)", hideable: true, hidden: true) {
input "singleTapUpButtonNumber", "number", title: "On Button Number", defaultValue: 1, required: false, width: 2
input "singleTapUpButtonEvent", "enum", title: "On Button Event", options: ["pushed", "held", "released", "doubleTapped"], defaultValue: "pushed", required: false, width: 2
input "singleTapDownButtonNumber", "number", title: "Off Button Number", defaultValue: 1, required: false, width: 2
input "singleTapDownButtonEvent", "enum", title: "Off Button Event", options: ["pushed", "held", "released", "doubleTapped"], defaultValue: "held", required: false, width: 2
input "configButtonNumber", "number", title: "Scene Mode Button Number", defaultValue: 8, required: false, width: 2
input "configButtonEvent", "enum", title: "Scene Mode Button Event", options: ["pushed", "held", "released", "doubleTapped"], defaultValue: "pushed", required: false, width: 2
paragraph "Note: In Scene Mode, the on and off buttons are used for navigating scenes.", width: 12
input "doubleTapUpButtonNumber", "number", title: "Room/Zone On Button Number", defaultValue: 2, required: false, width: 3
input "doubleTapUpButtonEvent", "enum", title: "Room/Zone On Button Event", options: ["pushed", "held", "released", "doubleTapped"], defaultValue: "pushed", required: false, width: 3
input "doubleTapDownButtonNumber", "number", title: "Room/Zone Off Button Number", defaultValue: 2, required: false, width: 3
input "doubleTapDownButtonEvent", "enum", title: "Room/Zone Off Button Event", options: ["pushed", "held", "released", "doubleTapped"], defaultValue: "held", required: false, width: 3
input "holdUpButtonNumber", "number", title: "Brighten Start Button Number", defaultValue: 6, required: false, width: 3
input "holdUpButtonEvent", "enum", title: "Brighten Start Button Event", options: ["pushed", "held", "released", "doubleTapped"], defaultValue: "pushed", required: false, width: 3
input "releaseUpButtonNumber", "number", title: "Brighten Stop Button Number", defaultValue: 7, required: false, width: 3
input "releaseUpButtonEvent", "enum", title: "Brighten Stop Button Event", options: ["pushed", "held", "released", "doubleTapped"], defaultValue: "pushed", required: false, width: 3
input "holdDownButtonNumber", "number", title: "Dim Start Button Number", defaultValue: 6, required: false, width: 3
input "holdDownButtonEvent", "enum", title: "Dim Start Button Event", options: ["pushed", "held", "released", "doubleTapped"], defaultValue: "held", required: false, width: 3
input "releaseDownButtonNumber", "number", title: "Dim Stop Button Number", defaultValue: 7, required: false, width: 3
input "releaseDownButtonEvent", "enum", title: "Dim Stop Button Event", options: ["pushed", "held", "released", "doubleTapped"], defaultValue: "held", required: false, width: 3
input "sceneModeTimeout", "number", title: "Scene Mode Timeout (seconds)", defaultValue: 7, required: false, width: 2
}
}
def installed() {
log.info "Installed Hank's Switch Bot"
initialize()
}
def updated() {
log.info "Updated Hank's Switch Bot"
unsubscribeAndUnschedule()
initialize()
}
def uninstalled() {
log.info "Uninstalling Hank's Switch Bot"
unsubscribeAndUnschedule()
}
private void unsubscribeAndUnschedule() {
log.info "Clearing all subscriptions and schedules for Hank's Switch Bot"
try {
unsubscribe()
unschedule()
} catch (e) {
log.error "Error during unsubscribe/unschedule: ${e.message}"
}
}
def initialize() {
state.sceneIndex = [:]
state.sceneMode = [:]
state.ledUpdateQueue = []
state.modeSettingsMap = [:]
state.currentLocationMode = null
state.switchControlSummary = "Initializing or no switches configured..."
state.siblingSwitchGroupsBySwitchId = [:]
state.switchIdToLocationMap = [:]
state.switchInfoMap = [:]
state.motionBypass = [:]
state.lastMotionActiveTime = [:]
state.switchRoomLights = [:]
state.switchAreaLights = [:]
state.switchZoneLights = [:]
state.switchScenes = [:]
state.firstAreaLightToSwitch = [:]
state.sortedSwitchSceneIds = [:]
state.switchDimmableAreaLightIds = [:]
// Build device ID to index maps for faster lookups
state.deviceToIndexMap = [switches: [:], lightsAndScenes: [:]]
settings.controlledSwitches?.eachWithIndex { sw, index ->
if (sw?.id) state.deviceToIndexMap.switches[sw.id.toString()] = index
}
settings.controlledLightsAndScenes?.eachWithIndex { dev, index ->
if (dev?.id) state.deviceToIndexMap.lightsAndScenes[dev.id.toString()] = index
}
if (settings.controlledSwitches) {
settings.controlledSwitches.each { sw ->
if (sw?.id) {
state.switchIdToLocationMap[sw.id.toString()] = parseDeviceLocation(sw)
}
}
}
normalizeSwitchRoomNames()
groupSiblingSwitches()
buildDeviceMaps()
buildModeSettingsMap()
def buttonEventsToSubscribe = [
settings.singleTapUpButtonEvent, settings.singleTapDownButtonEvent,
settings.configButtonEvent, settings.doubleTapUpButtonEvent,
settings.doubleTapDownButtonEvent, settings.holdUpButtonEvent,
settings.releaseUpButtonEvent, settings.holdDownButtonEvent,
settings.releaseDownButtonEvent
].findAll { it }.unique()
if (!controlledSwitches) {
log.warn "No switches selected. Cannot subscribe to button events."
} else {
controlledSwitches.each { sw ->
def switchIdStr = sw.id.toString()
def sInfo = state.switchInfoMap[switchIdStr]
if (sInfo?.type == "local") {
log.info "Switch ${sw.displayName} is a Local Switch. Skipping button and motion event subscriptions."
} else {
if (sw.hasCapability("PushableButton")) {
buttonEventsToSubscribe.each { eventName ->
subscribe(sw, eventName, buttonHandler)
}
} else {
log.warn "Switch ${sw.displayName} does not support PushableButton capability."
}
boolean isMotionEnabled = settings.motionEnabledSwitches?.any { val ->
(val instanceof String) ? (val == switchIdStr) : (val?.id?.toString() == switchIdStr)
}
if (isMotionEnabled && (sw.hasCapability("MotionSensor") || sw.hasCapability("Motion Sensor"))) {
subscribe(sw, "motion", motionHandler)
log.info "Subscribed to motion events on ${sw.displayName}"
}
}
state.sceneIndex[switchIdStr] = state.sceneIndex[switchIdStr] ?: -1
state.sceneMode[switchIdStr] = state.sceneMode[switchIdStr] ?: false
}
}
// Subscribe to state changes of the first light in each area for non-local switches.
if (state.firstAreaLightToSwitch) {
state.firstAreaLightToSwitch.each { lightId, switchId ->
def lightDevice = getDevicesById(lightId.toString(), settings.controlledLightsAndScenes)
def sInfo = state.switchInfoMap[switchId.toString()]
if (lightDevice && sInfo?.type != "local") {
if (lightDevice.hasCapability("SwitchLevel")) subscribe(lightDevice, "level", firstLightStateHandler)
if (lightDevice.hasCapability("Switch")) subscribe(lightDevice, "switch", firstLightStateHandler)
} else if (sInfo?.type == "local") {
log.debug "Skipping firstLightStateHandler subscription for light ${lightDevice?.displayName} because its primary switch ${sInfo?.displayName} is local."
} else if (!lightDevice) {
log.warn "Could not find first area light device with ID ${lightId} to subscribe for state sync."
}
}
}
state.currentLocationMode = location.currentMode?.name?.toString()?.trim()
log.info "Initial location mode tracked as: ${state.currentLocationMode ?: 'UNKNOWN'}"
try {
subscribe(location, "mode", modeChangeHandler)
} catch (e) {
log.error "Error subscribing to location mode changes: ${e.message}"
}
// Set initial LED brightness for all switches (staggered via queue to avoid blocking)
if (!state.currentLocationMode) {
log.warn "Initial location mode not set. Setting LEDs to global defaults."
}
scheduleLedUpdates(state.currentLocationMode)
updateSwitchControlSummary()
log.info "Initialization complete."
}
private String normalizeDeviceName(String deviceName) {
if (deviceName == null) return null
return deviceName.replaceAll("[‘’]", "'") // Standardize curly apostrophes to straight
}
private String getParsingName(device) {
if (!device) return ""
String originalDisplayName = device.displayName?.trim() ?: ""
String deviceRoomProp = null
try {
deviceRoomProp = device.roomName?.trim()
} catch (MissingPropertyException e) { /* ignore */ }
// Prepend roomName from property if not already part of displayName (for consistent parsing)
if (deviceRoomProp && !deviceRoomProp.isEmpty() && !originalDisplayName.toLowerCase().startsWith(deviceRoomProp.toLowerCase())) {
return "${deviceRoomProp} ${originalDisplayName}"
}
return originalDisplayName
}
/**
* Parses device location: room, area, zone.
* Zone: from [ZoneName] in device.name.
* Room: from device.roomName or first word(s) of displayName.
* Area: word after room, excluding "Switch", "All Switch", "Local Switch".
*/
def parseDeviceLocation(device) {
String roomName = null
String areaName = null
String zoneName = null
// Parse zone from displayName, label, or name (whichever contains brackets)
def findZone = { String text ->
if (!text) return null
def matcher = text =~ /\s*\[\s*(.*?)\s*\]\s*/
return matcher.find() ? matcher[0][1]?.trim() : null
}
zoneName = findZone(device?.displayName) ?: findZone(device?.label) ?: findZone(device?.name)
if (!device?.displayName) {
return [roomName: null, areaName: null, zoneName: zoneName, parsingName: ""]
}
String baseDisplayNameForParsing = getParsingName(device)
// Strip out the zone tag from the parsing string to prevent interference with room/area parsing
if (baseDisplayNameForParsing) {
baseDisplayNameForParsing = baseDisplayNameForParsing.replaceAll(/\s*\[\s*(.*?)\s*\]\s*/, "").trim()
}
String deviceRoomProp = null
try { deviceRoomProp = device.roomName?.trim() } catch (MissingPropertyException e) { /*ignore*/ }
// Room from device.roomName property
if (deviceRoomProp && !deviceRoomProp.isEmpty() && baseDisplayNameForParsing.toLowerCase().startsWith(deviceRoomProp.toLowerCase())) {
roomName = deviceRoomProp
String remainingAfterRoomProp = baseDisplayNameForParsing.substring(deviceRoomProp.length()).trim()
if (remainingAfterRoomProp) {
def remainingParts = remainingAfterRoomProp.tokenize()
if (remainingParts) {
String potentialArea = remainingParts.first()
boolean isSwitchKeyword = potentialArea.equalsIgnoreCase("Switch") ||
(potentialArea.equalsIgnoreCase("All") && remainingParts.size() > 1 && remainingParts.getAt(1).equalsIgnoreCase("Switch")) ||
(potentialArea.equalsIgnoreCase("Local") && remainingParts.size() > 1 && remainingParts.getAt(1).equalsIgnoreCase("Switch"))
if (!isSwitchKeyword && !potentialArea.equalsIgnoreCase("All")) {
areaName = potentialArea
}
}
}
}
// Room from parsing baseDisplayNameForParsing
if (!roomName && baseDisplayNameForParsing) {
def parts = baseDisplayNameForParsing.tokenize()
if (parts) {
roomName = parts[0]
int roomWords = 1
// Check for common two-word room names
if (parts.size() > 1 && (parts[1].equalsIgnoreCase("room") || parts[1].equalsIgnoreCase("rm") || parts[1].equalsIgnoreCase("bath") || parts[1].equalsIgnoreCase("bedroom"))) {
String combinedRoom = "${parts[0]} ${parts[1]}"
if (baseDisplayNameForParsing.toLowerCase().startsWith(combinedRoom.toLowerCase())) {
roomName = combinedRoom
roomWords = 2
}
}
if (parts.size() > roomWords) {
String potentialAreaWord = parts[roomWords]
boolean isSwitchKeyword = potentialAreaWord.equalsIgnoreCase("Switch") ||
(potentialAreaWord.equalsIgnoreCase("All") && parts.size() > roomWords + 1 && parts.getAt(roomWords + 1).equalsIgnoreCase("Switch")) ||
(potentialAreaWord.equalsIgnoreCase("Local") && parts.size() > roomWords + 1 && parts.getAt(roomWords + 1).equalsIgnoreCase("Switch"))
if (!isSwitchKeyword && !potentialAreaWord.equalsIgnoreCase("All")) {
areaName = potentialAreaWord
}
}
}
}
return [roomName: roomName?.trim(), areaName: areaName?.trim(), zoneName: zoneName, parsingName: baseDisplayNameForParsing]
}
/**
* Normalizes room names for switches with same base display name but different roomName properties.
*/
private void normalizeSwitchRoomNames() {
if (settings.controlledSwitches == null || settings.controlledSwitches.isEmpty() || state.switchIdToLocationMap == null || state.switchIdToLocationMap.isEmpty()) {
return
}
Map> displayNameGroups = [:].withDefault { [] }
settings.controlledSwitches.each { swDevice ->
if (!swDevice?.displayName) return
String baseDisplayName = swDevice.displayName.replaceAll(/\s*\(.*\)\s*$/, "").trim() // Ignore suffixes like (1)
displayNameGroups[baseDisplayName] << swDevice.id.toString()
}
displayNameGroups.each { baseDisplayNameKey, switchIdsInGroup ->
if (switchIdsInGroup.size() <= 1) return
List