/* * ============================= CoCoHue Plug (On/Off Light) (Driver) =============================== * * Copyright 2019-2025 Robert Morris * * 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. * * ======================================================================================= * * Last modified: 2025-09-07 * * Changelog: * v5.3.4 - Changes to accommodate HTTPS by default * v5.3.1 - Implement async HTTP call queueing from child drivers through parent app * v5.3.0 - Use V2 for most commands * v5.2.2 - Populate initial states from V2 cache if available * v5.0.1 - Fix for missing V1 IDs after device creation or upgrade * v5.0 - Use API v2 by default, remove deprecated features * v4.2 - Library updates, prep for more v2 API * v4.1.4 - Improved error handling, fix missing battery for motion sensors * v4.0 - Add SSE support for push * v3.5.1 - Refactor some code into libraries (code still precompiled before upload; should not have any visible changes) * v3.5 - Addded "reachable" attribte from Bridge to bulb and group drivers (thanks to @jtp10181 for original implementation) * v3.1 - Improved error handling and debug logging * v3.0 - Fix so events no created until Bridge response received (as was done for other drivers in 2.0); improved HTTP error handling * v2.1 - Minor code cleanup; more static typing * v2.0 - Improved HTTP error handling; attribute events now generated * only after hearing back from Bridge; Bridge online/offline status improvements * v1.8 - Added ability to disable plug->group state propagation; * Removed ["alert:" "none"] from on() command, now possible explicitly with flashOff() * v1.7 - Initial Release */ //#include RMoRobert.CoCoHue_Flash_Lib // can uncomment if needed; see also definition() below import groovy.transform.Field import hubitat.scheduling.AsyncResponse @Field static final Integer debugAutoDisableMinutes = 30 // Default list of command Map keys to ignore if SSE enabled and command is sent from hub (not polled from Bridge), used to // ignore duplicates that are expected to be processed from SSE momentarily: // (for on/off devices, should cover everything...) @Field static final List listKeysToIgnoreIfSSEEnabledAndNotFromBridge = ["on"] metadata { definition(name: "CoCoHue Plug", namespace: "RMoRobert", author: "Robert Morris", importUrl: "https://raw.githubusercontent.com/HubitatCommunity/CoCoHue/master/drivers/cocohue-plug-driver.groovy") { capability "Actuator" capability "Refresh" capability "Switch" capability "Light" // Not supported on (most?) plugs; can uncomment if you are using for lights that support this: //command "flash" //command "flashOnce" //command "flashOff" attribute "reachable", "string" } preferences { input name: "logEnable", type: "bool", title: "Enable debug logging", defaultValue: true input name: "txtEnable", type: "bool", title: "Enable descriptionText logging", defaultValue: true input name: "updateGroups", type: "bool", description: "", title: "Update state of groups immediately when plug state changes (applicable only if not using V2 API/eventstream)", defaultValue: false } } void installed() { log.debug "installed()" if (device.currentValue("switch") == null) { // Populate initial device data (if V2 available; V1 users would need manual refresh) List bridgeCacheData = parent.getBridgeCacheV2()?.data ?: [] Map devCache = bridgeCacheData.find { it.type == "light" && it.id == device.deviceNetworkId.split("/").last() } if (devCache == null) devCache == bridgeCacheData.find { it.type == "light" && it.id_v1 == device.deviceNetworkId.split("/").last() } if (devCache != null) { log.warn devCache.id createEventsFromMapV2(devCache) } } initialize() } void updated() { log.debug "updated()" initialize() } void initialize() { log.debug "initialize()" if (logEnable) { log.debug "Debug logging will be automatically disabled in ${debugAutoDisableMinutes} minutes" runIn(debugAutoDisableMinutes*60, "debugOff") } } // Probably won't happen but... void parse(String description) { log.warn("Running unimplemented parse for: '${description}'") } /** * Parses V1 Hue Bridge device ID number out of Hubitat DNI for use with Hue V1 API calls * Hubitat DNI is created in format "CCH/BridgeMACAbbrev/Light/HueDeviceID", so just * looks for number after last "/" character; or try state if DNI is V2 format (avoid if posssible, * as Hue is likely to deprecate V1 ID data in future) */ String getHueDeviceIdV1() { String id = device.deviceNetworkId.split("/").last() if (id.length() > 32) { // max length of last part of V1 IDs per V2 API regex spec, though never seen anything non-numeric longer than 2 (or 3?) for non-scenes id = state.id_v1?.split("/")?.last() if (state.id_v1 == null) { log.warn "Attempting to retrieve V1 ID but not in DNI or state." } } return id } void on() { if (logEnable == true) log.debug "on()" Map bridgeCmd = ["on": true] sendBridgeCommandV1(bridgeCmd) } void off() { if (logEnable == true) log.debug "off()" Map bridgeCmd = ["on": false] sendBridgeCommandV1(bridgeCmd) } void refresh() { log.warn "Refresh Hue Bridge device instead of individual device to update (all) bulbs/groups" } /** * Iterates over Hue light state commands/states in Hue format (e.g., ["on": true]) and does * a sendEvent for each relevant attribute; intended to be called either when commands are sent * to Bridge or to parse/update light states based on data received from Bridge * @param bridgeMap Map of light states that are or would be sent to bridge OR state as received from * Bridge * @param isFromBridge Set to true if this is data read from Hue Bridge rather than intended to be sent * to Bridge; TODO: see if still needed now that pseudo-prestaging removed */ void createEventsFromMapV1(Map bridgeCommandMap, Boolean isFromBridge = false, Set keysToIgnoreIfSSEEnabledAndNotFromBridge=listKeysToIgnoreIfSSEEnabledAndNotFromBridge) { if (!bridgeCommandMap) { if (logEnable == true) log.debug "createEventsFromMapV1 called but map command empty or null; exiting" return } Map bridgeMap = bridgeCommandMap if (logEnable == true) log.debug "createEventsFromMapV1(): Preparing to create events from map${isFromBridge ? ' from Bridge' : ''}: ${bridgeMap}" if (!isFromBridge && keysToIgnoreIfSSEEnabledAndNotFromBridge && parent.getEventStreamOpenStatus() == true) { bridgeMap.keySet().removeAll(keysToIgnoreIfSSEEnabledAndNotFromBridge) if (logEnable == true) log.debug "Map after ignored keys removed: ${bridgeMap}" } String eventName, eventUnit, descriptionText String eventValue // only String for on/off devices (could be number with others) bridgeMap.each { switch (it.key) { case "on": eventName = "switch" eventValue = it.value ? "on" : "off" eventUnit = null if (device.currentValue(eventName) != eventValue) doSendEvent(eventName, eventValue, eventUnit) break case "reachable": eventName = "reachable" eventValue = it.value ? "true" : "false" eventUnit = null if (device.currentValue(eventName) != eventValue) { doSendEvent(eventName, eventValue, eventUnit) } break case "transitiontime": case "mode": case "alert": break default: break //log.warn "Unhandled key/value discarded: $it" } } } /** * (for V2 API) * Iterates over Hue light state states in Hue API v2 format (e.g., "on={on=true}") and does * a sendEvent for each relevant attribute; intended to be called when EventSocket data * received for device (as an alternative to polling) */ void createEventsFromMapV2(Map data) { if (logEnable == true) log.debug "createEventsFromMapV2($data)" String eventName, eventUnit, descriptionText def eventValue // could be String or number data.each { String key, value -> switch (key) { case "on": eventName = "switch" eventValue = value.on ? "on" : "off" eventUnit = null if (device.currentValue(eventName) != eventValue) doSendEvent(eventName, eventValue, eventUnit) break case "id_v1": if (state.id_v1 != value) state.id_v1 = value break default: if (logEnable == true) "not handling: $key: $value" } } } /** * Sends HTTP PUT to Bridge using the either command map provided * @param commandMap Groovy Map (will be converted to JSON) of Hue API commands to send, e.g., [on: true] * @param createHubEvents Will iterate over Bridge command map and do sendEvent for all * affected device attributes (e.g., will send an "on" event for "switch" if ["on": true] in map) */ void sendBridgeCommandV1(Map commandMap, Boolean createHubEvents=true) { if (logEnable == true) log.debug "sendBridgeCommandV1($commandMap)" if (commandMap == null || commandMap == [:]) { if (logEnable == true) log.debug "Commands not sent to Bridge because command map null or empty" return } Map data = parent.getBridgeData() Map params = [ uri: data.fullHost, path: "/api/${data.username}/lights/${getHueDeviceIdV1()}/state", contentType: 'application/json', body: commandMap, ignoreSSLIssues: true, timeout: 15 ] asynchttpPut("parseSendCommandResponseV1", params, createHubEvents ? commandMap : null) if (logEnable == true) log.debug "-- Command sent to Bridge! --" } /** * Parses response from Bridge (or not) after sendBridgeCommandV1. Updates device state if * appears to have been successful. * @param resp Async HTTP response object * @param data Map of commands sent to Bridge if specified to create events from map */ void parseSendCommandResponseV1(AsyncResponse resp, Map data) { if (logEnable == true) log.debug "Response from Bridge: ${resp.status}" if (checkIfValidResponse(resp) && data) { if (logEnable == true) log.debug " Bridge response valid; creating events from data map" createEventsFromMapV1(data) if ((data.containsKey("on") || data.containsKey("bri")) && settings["updateGroups"]) { parent.updateGroupStatesFromBulb(data, getHueDeviceIdV1()) } } else { if (logEnable == true) log.debug " Not creating events from map because not specified to do or Bridge response invalid" } } /** * Parses response from Bridge (or not) after sendBridgeCommandV2. Can optionally use V1-inspired * logic to update device states if `data` map provided. * @param resp Async HTTP response object * @param data Map of commands sent to Bridge if specified to create events from map */ void parseSendCommandResponseV2(AsyncResponse resp, Map data) { if (logEnable == true) log.debug "parseSendCommandResponseV2(): Response status from Bridge: ${resp.status}" if (checkIfValidResponse(resp) && data) { if (logEnable == true) log.debug " Bridge response valid; creating events from data map" createEventsFromMapV2(data) if ((data.containsKey("on") || data.containsKey("dimming")) && settings["updateGroups"]) { parent.updateGroupStatesFromBulb(data, getHueDeviceIdV2()) } } else { if (logEnable == true) log.debug " Not creating events from map because not specified to do or Bridge response invalid" } } /** * Sends HTTP PUT to Bridge using the V1-format map data provided * @param commandMap Groovy Map (will be converted to JSON) of Hue V1 API commands to send, e.g., [on: true] * @param createHubEvents Will iterate over Bridge command map and do sendEvent for all * affected device attributes (e.g., will send an "on" event for "switch" if ["on": true] in map) */ void sendBridgeCommandV2(Map commandMap, Boolean createHubEvents=false) { if (logEnable == true) log.debug "sendBridgeCommandV2($commandMap)" if (commandMap == null || commandMap == [:]) { if (logEnable == true) log.debug "Commands not sent to Bridge because command map null or empty" return } parent.bridgeAsyncPutV2("parseSendCommandResponseV2", this.device, "/resource/light/${getHueDeviceIdV2()}", commandMap, createHubEvents ? commandMap : null) if (logEnable == true) log.debug "-- Command sent to Bridge! --" } // ~~~ IMPORTED FROM RMoRobert.CoCoHue_Common_Lib ~~~ // Version 1.0.6 // For use with CoCoHue drivers (not app) /** * 1.0.6 - Remove common bridgeAsyncPutV2() method (now call from parent app instead of driver) * 1.0.5 - Add common bridgeAsyncPutV2() method for asyncHttpPut (goal to reduce individual driver code) * 1.0.4 - Add common bridgeAsyncGetV2() method asyncHttpGet (goal to reduce individual driver code) * 1.0.3 - Add APIV1 and APIV2 "constants" * 1.0.2 - HTTP error handling tweaks */ void debugOff() { log.warn "Disabling debug logging" device.updateSetting("logEnable", [value:"false", type:"bool"]) } /** Performs basic check on data returned from HTTP response to determine if should be * parsed as likely Hue Bridge data or not; returns true (if OK) or logs errors/warnings and * returns false if not * @param resp The async HTTP response object to examine */ private Boolean checkIfValidResponse(hubitat.scheduling.AsyncResponse resp) { if (logEnable == true) log.debug "Checking if valid HTTP response/data from Bridge..." Boolean isOK = true if (resp.status < 400) { if (resp.json == null) { isOK = false if (resp.headers == null) log.error "Error: HTTP ${resp.status} when attempting to communicate with Bridge" else log.error "No JSON data found in response. ${resp.headers.'Content-Type'} (HTTP ${resp.status})" parent.sendBridgeDiscoveryCommandIfSSDPEnabled(true) // maybe IP changed, so attempt rediscovery parent.setBridgeOnlineStatus(false) } else if (resp.json) { if ((resp.json instanceof List) && resp.json.getAt(0).error) { // Bridge (not HTTP) error (bad username, bad command formatting, etc.): isOK = false log.warn "Error from Hue Bridge: ${resp.json[0].error}" // Not setting Bridge to offline when light/scene/group devices end up here because could // be old/bad ID and don't want to consider Bridge offline just for that (but also won't set // to online because wasn't successful attempt) } // Otherwise: probably OK (not changing anything because isOK = true already) } else { isOK = false log.warn("HTTP status code ${resp.status} from Bridge") // TODO: Update for mDNS if/when switch: if (resp?.status >= 400) parent.sendBridgeDiscoveryCommandIfSSDPEnabled(true) // maybe IP changed, so attempt rediscovery parent.setBridgeOnlineStatus(false) } if (isOK == true) parent.setBridgeOnlineStatus(true) } else { log.warn "Error communicating with Hue Bridge: HTTP ${resp?.status}" isOK = false } return isOK } void doSendEvent(String eventName, eventValue, String eventUnit=null, Boolean forceStateChange=false) { //if (logEnable == true) log.debug "doSendEvent($eventName, $eventValue, $eventUnit)" String descriptionText = "${device.displayName} ${eventName} is ${eventValue}${eventUnit ?: ''}" if (settings.txtEnable == true) log.info(descriptionText) if (eventUnit) { if (forceStateChange == true) sendEvent(name: eventName, value: eventValue, descriptionText: descriptionText, unit: eventUnit, isStateChange: true) else sendEvent(name: eventName, value: eventValue, descriptionText: descriptionText, unit: eventUnit) } else { if (forceStateChange == true) sendEvent(name: eventName, value: eventValue, descriptionText: descriptionText, isStateChange: true) else sendEvent(name: eventName, value: eventValue, descriptionText: descriptionText) } } // HTTP methods (might be better to split into separate library if not needed for some?) /** Performs asynchttpGet() to Bridge using data retrieved from parent app or as passed in * @param callbackMethod Callback method * @param clipV2Path The Hue V2 API path ('/clip/v2' is automatically prepended), e.g. '/resource' or '/resource/light' * @param bridgeData Bridge data from parent getBridgeData() call, or will call this method on parent if null * @param data Extra data to pass as optional third (data) parameter to asynchtttpGet() method */ void bridgeAsyncGetV2(String callbackMethod, String clipV2Path, Map bridgeData = null, Map data = null) { if (bridgeData == null) { bridgeData = parent.getBridgeData() } Map params = [ uri: "https://${bridgeData.ip}", path: "/clip/v2${clipV2Path}", headers: ["hue-application-key": bridgeData.username], contentType: "application/json", timeout: 15, ignoreSSLIssues: true ] asynchttpGet(callbackMethod, params, data) } // REMOVED, now call from parent app instead of driver: // /** Performs asynchttpPut() to Bridge using data retrieved from parent app or as passed in // * @param callbackMethod Callback method // * @param clipV2Path The Hue V2 API path ('/clip/v2' is automatically prepended), e.g. '/resource' or '/resource/light' // * @param body Body data, a Groovy Map representing JSON for the Hue V2 API command, e.g., [on: [on: true]] // * @param bridgeData Bridge data from parent getBridgeData() call, or will call this method on parent if null // * @param data Extra data to pass as optional third (data) parameter to asynchtttpPut() method // */ // void bridgeAsyncPutV2(String callbackMethod, String clipV2Path, Map body, Map bridgeData = null, Map data = null) { // if (bridgeData == null) { // bridgeData = parent.getBridgeData() // } // Map params = [ // uri: "https://${bridgeData.ip}", // path: "/clip/v2${clipV2Path}", // headers: ["hue-application-key": bridgeData.username], // contentType: "application/json", // body: body, // timeout: 15, // ignoreSSLIssues: true // ] // asynchttpPut(callbackMethod, params, data) // if (logEnable == true) log.debug "Command sent to Bridge: $body at ${clipV2Path}" // pauseExecution(200) // see if helps HTTP 429 errors? // } // ~~~ IMPORTED FROM RMoRobert.CoCoHue_Constants_Lib ~~~ // Version 1.0.0 // -------------------------------------- // APP AND DRIVER NAMESPACE AND NAMES: // -------------------------------------- @Field static final String NAMESPACE = "RMoRobert" @Field static final String DRIVER_NAME_BRIDGE = "CoCoHue Bridge" @Field static final String DRIVER_NAME_BUTTON = "CoCoHue Button" @Field static final String DRIVER_NAME_CT_BULB = "CoCoHue CT Bulb" @Field static final String DRIVER_NAME_DIMMABLE_BULB = "CoCoHue Dimmable Bulb" @Field static final String DRIVER_NAME_GROUP = "CoCoHue Group" @Field static final String DRIVER_NAME_MOTION = "CoCoHue Motion Sensor" @Field static final String DRIVER_NAME_CONTACT = "CoCoHue Contact Sensor" @Field static final String DRIVER_NAME_PLUG = "CoCoHue Plug" @Field static final String DRIVER_NAME_RGBW_BULB = "CoCoHue RGBW Bulb" @Field static final String DRIVER_NAME_RGB_BULB = "CoCoHue RGB Bulb" @Field static final String DRIVER_NAME_SCENE = "CoCoHue Scene" // -------------------------------------- // DNI PREFIX for child devices: // -------------------------------------- @Field static final String DNI_PREFIX = "CCH" // -------------------------------------- // OTHER: // -------------------------------------- // Used in app and Bridge driver, may eventually find use in more: @Field static final String APIV1 = "V1" @Field static final String APIV2 = "V2" // ~~~ IMPORTED FROM RMoRobert.CoCoHue_V2_DNI_Tools_Lib ~~~ // Version 1.0.0 /** * Parses V2 Hue Bridge device ID out of Hubitat DNI for use with Hue V2 API calls * Hubitat DNI is created in format "CCH/BridgeMACAbbrev/Scene/HueDeviceID", so just * looks for string after last "/" character */ String getHueDeviceIdV2() { if (getHasV2DNI() == true) { return device.deviceNetworkId.split("/").last() } else { log.error "DNI not in V2 format but attempeting to fetch API V2 ID. Cannot continue." } } Boolean getHasV2DNI() { String id = device.deviceNetworkId.split("/").last() if (id.length() > 32) { // max length of Hue V1 ID per regex in V2 API docs return true } else { return false } }