/**
* Hank's Switch Bot v05-10-2025
* Copyright 2025 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.
*/
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: ""
)
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) {
location.modes.sort { it.name }.each { mode ->
String safeModeName = mode.name.replaceAll("[^a-zA-Z0-9_]", "_").toLowerCase()
String currentModeNameLower = mode.name.toLowerCase()
Integer conditionalDefaultLevel = null
Integer conditionalDefaultCt = null
Boolean conditionalEnableCt = true
// Default values for common modes
if (currentModeNameLower == "evening") {
conditionalDefaultLevel = 100
conditionalDefaultCt = 2700
} else if (currentModeNameLower == "day") {
conditionalDefaultLevel = 100
conditionalDefaultCt = 5200
} else if (currentModeNameLower == "sleep") {
conditionalDefaultLevel = 5
conditionalDefaultCt = 2700
}
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: "enableCt_${safeModeName}", type: "bool", title: "Set CT?", defaultValue: conditionalEnableCt, required: false, width: 2)
input(name: "ledOnBrightness_${safeModeName}", type: "number", title: "LED On Brightness (%)", range: "0..100", required: false, width: 2, defaultValue: 30)
input(name: "ledOffBrightness_${safeModeName}", type: "number", title: "LED Off Brightness (%)", range: "0..100", required: false, width: 2, defaultValue: 7)
// New input field for Sleep mode to specify a zone for turning off LEDs
if (currentModeNameLower == "sleep") {
input(name: "ledOffZone_${safeModeName}", type: "text",
title: "Turn off LEDs in Sleep mode for 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) when in Sleep mode. Other switches will use the Sleep mode's LED brightness settings above.",
required: false, width: 6)
}
paragraph ""
}
} else {
paragraph "Save settings once to see per-mode configuration options."
}
}
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 Stop 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.sceneTimeoutJob = [:]
state.modeSettingsMap = [:]
state.currentLocationMode = null
state.switchControlSummary = "Initializing or no switches configured..."
state.siblingSwitchGroupsBySwitchId = [:]
state.switchIdToLocationMap = [:]
state.switchInfoMap = [:]
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 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."
}
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)
if (lightDevice.hasCapability("ColorTemperature")) subscribe(lightDevice, "colorTemperature", 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
if (state.currentLocationMode) {
Map initialModeLedSettings = getModeLedSettings(state.currentLocationMode) // Returns {on, off}
settings.controlledSwitches?.each { sw ->
if (sw) {
Map effectiveBrightness = calcLEDLevel(sw, state.currentLocationMode, initialModeLedSettings.on, initialModeLedSettings.off)
updateLEDs(sw, effectiveBrightness.on, effectiveBrightness.off)
}
pause(250)
}
} else {
log.warn "Initial location mode not set. Setting LEDs to global defaults."
settings.controlledSwitches?.each { sw ->
if (sw) updateLEDs(sw, state.globalDefaultLedOnBrightness, state.globalDefaultLedOffBrightness)
pause(250)
}
}
updateSwitchControlSummary()
log.info "Initialization complete."
}
private String normalizeDeviceName(String deviceName) {
if (deviceName == null) return null
return deviceName.replaceAll("[' ]", "'") // Standardize apostrophes
}
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
def zoneMatcher = device?.name =~ /\s*\[\s*(.*?)\s*\]\s*/ // Zone from device.name
if (zoneMatcher?.find()) {
zoneName = zoneMatcher[0][1]?.trim()
}
if (!device?.displayName) {
return [roomName: null, areaName: null, zoneName: zoneName, parsingName: ""]
}
String baseDisplayNameForParsing = getParsingName(device)
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