├── .gitignore ├── CoCoHue-Latest-Bundle.zip ├── deprecated └── cocohue-parent-app.groovy ├── packageManifest.json ├── README.md └── drivers ├── cocohue-button-driver.groovy ├── cocohue-motion-sensor-driver.groovy ├── cocohue-plug-driver.groovy ├── cocohue-dimmable-bulb-driver.groovy ├── cocohue-scene-driver.groovy └── cocohue-ct-bulb-driver.groovy /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | apps/.DS_Store 3 | drivers/.DS_Store 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /CoCoHue-Latest-Bundle.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HubitatCommunity/CoCoHue/HEAD/CoCoHue-Latest-Bundle.zip -------------------------------------------------------------------------------- /deprecated/cocohue-parent-app.groovy: -------------------------------------------------------------------------------- 1 | /* 2 | * DEPRECATED - NOT FOR NEW INSTALLS (use only if upgrading from 1.x) 3 | * 4 | * The CoCoHue Parent App has been deprecated in version 2.0. For new installs, install the "regular" app 5 | * directly (it is now the only app you need, but also install the drivers). Existing installations may 6 | * continue to use the parent app if desired by updating the parent app to this version and updating the 7 | * child app to the new 2.x child. 8 | * 9 | */ 10 | 11 | /** 12 | * ========================== CoCoHue (Parent App) ========================== 13 | * 14 | * DESCRIPTION: 15 | * Community-developed Hue Bridge integration app for Hubitat, including support for lights, 16 | * groups, and scenes. (Depcreated; new installs should install the non-deprecated app directly.) 17 | 18 | * TO INSTALL: 19 | * See documentation on Hubitat Community forum. 20 | * 21 | * Copyright 2019-2020 Robert Morris 22 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 23 | * in compliance with the License. You may obtain a copy of the License at: 24 | * 25 | * http://www.apache.org/licenses/LICENSE-2.0 26 | * 27 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 28 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 29 | * for the specific language governing permissions and limitations under the License. 30 | * 31 | * ======================================================================================= 32 | * 33 | * Last modified: 2020-05-05 34 | * 35 | * Changelog: 36 | * 37 | * v1.0 - Initial Public Release 38 | * v1.5 - Minor formatting changes, version ugpraded to match most components 39 | * v2.0 - Deprecated parent app, but you can continue using if it was previously set up this way (see comments or docs for how) 40 | * 41 | */ 42 | 43 | definition( 44 | name: "CoCoHue (Parent App)", 45 | namespace: "RMoRobert", 46 | author: "Robert Morris", 47 | singleInstance: true, 48 | description: "Integrate Hue Bridge lights, groups, and scenes into Hubitat (deprecated; use for existing 1.x-to-2.x upgrades only; new 2.x users should use new app)", 49 | category: "Convenience", 50 | documentationLink: "https://community.hubitat.com/t/release-cocohue-hue-bridge-integration-including-scenes/27978", 51 | iconUrl: "", 52 | iconX2Url: "", 53 | iconX3Url: "" 54 | ) 55 | 56 | preferences { 57 | page(name: "mainPage", title: "CoCoHue (Parent App)", install: true, uninstall: true) { 58 | section { 59 | if (app.getInstallationState() == "INCOMPLETE") { 60 | paragraph("Please press \"Done\" to finish installing this app, then re-open it to add your Hue Bridge.") 61 | return 62 | } 63 | app(name: "childApps", appName: "CoCoHue - Hue Bridge Integration", namespace: "RMoRobert", title: "Add new Hue Bridge...", multiple: true) 64 | } 65 | } 66 | } 67 | 68 | def installed() { 69 | log.debug "Installed with settings: ${settings}" 70 | initialize() 71 | } 72 | 73 | def updated() { 74 | log.debug "Updated with settings: ${settings}" 75 | //unsubscribe() 76 | initialize() 77 | } 78 | 79 | def initialize() { 80 | log.debug "Initializing; there are ${childApps.size()} child apps installed:" 81 | childApps.each {child -> 82 | log.debug " child app: ${child.label}" 83 | } 84 | } -------------------------------------------------------------------------------- /packageManifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageName": "CoCoHue (Hue Bridge Integration)", 3 | "minimumHEVersion": "2.3.9", 4 | "author": "Robert Morris", 5 | "dateReleased": "2025-09-20", 6 | "communityLink": "https://community.hubitat.com/t/release-cocohue-hue-bridge-integration-including-scenes/27978", 7 | "releaseNotes": "IMPORTANT NOTE (new!): If upgrading from 4.x, open the CoCoHue app and hit \"Done\" once after updating. Additionally, download a hub backup beforehand, and vist the Community thread for more information. Additionally, if upgrading to 5.1 or newer from 5.0.x or older and use scene switch states, please view Community thread for more details.\n\nVersion 5.4.1: Fix for automatic Bridge discovery (e.g., if IP address changes) for migrated Bridge setups\n\nVersion 5.4.0: Use HTTPS by default; add mDNS discovery (both contribute to Hue Bridge Pro compatibility)\n\n** Version 5.3.4: Use V2 API for most outgoing commands (excet Set Color); default to HTTPS if V2 API enabled (improve Hue Pro Bridge compatibility). See Community thread for more.\n\nVersion 5.2.5, 5.2.6, 5.2.7: Add Smart Scene support, fix for parse error with zigbee_connectivity; use level 0 as off in CT or color commands; add support for additional scene activation types with different button numbers (see Community for discussion); Version 5.2.8: Ignore 0 CT reports to prevent errors\n\nVersion 5.1: Removed Switch capability from scene driver and related preferences from scene and group drivers (to activate a scene, push button 1 instead of turn on). Version 5.2 (5.2.4): Possible concurrency fixes; efficiency improvements when using V2 API due to increase use of shared cache; minor logging improvements.\n\nVersion 5.1.2 re-adds certain switch features to scene drivers (momentary only and no state by default). Version 5.1.1 fixes motion sensor IDs for new migrations (existing users: see Community thread if problems).\n\n*Version 5.0: Use of V2 API throughout more of CoCoHue app on supported bridges, parsing changes and improvements to all drivers, addition of RGB-only driver; removal of deprecated prestaging preferences and prestaging commands. Support buttons and motion sensors on V2 API only. Version 5.0.1 offers fixes for V1 IDs (and NPE errors for getHueDeviceIdV1 in Logs. Version 5.0.3 offers automatic migration of logging preferences from 4.x, transparent use of V2 API for scene activation where possible, and other minor changes/fixes. Version 5.0.2 futureproofs scene creation for future increased V2 API use (existing users: please run Fetch Scene Data command on each scene if already using or upgraded to V2 API)\n\n*Version 4.2: Generate on/off events for scenes (V2 API only); Hubitat mobile app v2 improvements; preparation for more v2 API use and other minor tweaks. This may be the last release in the 4.x series; expect removal of Hue Labs sensors and prestage options and possibly other breaking changes in 5.x release, with more v2 API use.\n\nFor older release notes, please see Community thread.\n\nNOTE: Users upgrading from v1.x should follow instructions forum or on GitHub (parent app deprecatred but still available) and perform initial upgrade manually, not with HPM.", 8 | "apps": [ 9 | { 10 | "id": "568bab8e-5c69-4800-a038-481026904c05", 11 | "name": "CoCoHue - Hue Bridge Integration", 12 | "namespace": "RMoRobert", 13 | "location": "https://raw.githubusercontent.com/HubitatCommunity/CoCoHue/master/apps/cocohue-app.groovy", 14 | "required": true, 15 | "version": "5.4.1", 16 | "primary": true, 17 | "oauth": false 18 | } 19 | ], 20 | "drivers": [ 21 | { 22 | "id": "bcf030d1-de50-4cd8-83ce-008cb64cef92", 23 | "name": "CoCoHue Bridge", 24 | "namespace": "RMoRobert", 25 | "location": "https://raw.githubusercontent.com/HubitatCommunity/CoCoHue/master/drivers/cocohue-bridge-driver.groovy", 26 | "version": "5.3.4", 27 | "required": true 28 | }, 29 | { 30 | "id": "fbf5f4c5-615f-4c76-b1d8-cefdd40cc45e", 31 | "name": "CoCoHue Button", 32 | "namespace": "RMoRobert", 33 | "location": "https://raw.githubusercontent.com/HubitatCommunity/CoCoHue/master/drivers/cocohue-button-driver.groovy", 34 | "version": "5.0.0", 35 | "required": true 36 | }, 37 | { 38 | "id": "9833a9fd-2742-4ebf-a2cc-519275e4bba3", 39 | "name": "CoCoHue CT Bulb", 40 | "namespace": "RMoRobert", 41 | "location": "https://raw.githubusercontent.com/HubitatCommunity/CoCoHue/master/drivers/cocohue-ct-bulb-driver.groovy", 42 | "version": "5.3.4", 43 | "required": true 44 | }, 45 | { 46 | "id": "a7ad13cb-8ac5-4e57-a9e6-e2fb2598f014", 47 | "name": "CoCoHue Dimmable Bulb", 48 | "namespace": "RMoRobert", 49 | "location": "https://raw.githubusercontent.com/HubitatCommunity/CoCoHue/master/drivers/cocohue-dimmable-bulb-driver.groovy", 50 | "version": "5.3.4", 51 | "required": true 52 | }, 53 | { 54 | "id": "47b893dd-db0c-41b4-a5db-eb3bb892082c", 55 | "name": "CoCoHue Group", 56 | "namespace": "RMoRobert", 57 | "location": "https://raw.githubusercontent.com/HubitatCommunity/CoCoHue/master/drivers/cocohue-group-driver.groovy", 58 | "version": "5.3.4", 59 | "required": true 60 | }, 61 | { 62 | "id": "e65c8727-8483-45d0-925b-4c7eabc66acc", 63 | "name": "CoCoHue Motion Sensor", 64 | "namespace": "RMoRobert", 65 | "location": "https://raw.githubusercontent.com/HubitatCommunity/CoCoHue/master/drivers/cocohue-motion-sensor-driver.groovy", 66 | "version": "5.2.2", 67 | "required": true 68 | }, 69 | { 70 | "id": "4c7d5058-4995-436f-add8-c6d4f949f439", 71 | "name": "CoCoHue On/Off Plug", 72 | "namespace": "RMoRobert", 73 | "location": "https://raw.githubusercontent.com/HubitatCommunity/CoCoHue/master/drivers/cocohue-plug-driver.groovy", 74 | "version": "5.3.4", 75 | "required": true 76 | }, 77 | { 78 | "id": "9ec515f9-0bf3-44dc-bc65-ff8da003ac5e", 79 | "name": "CoCoHue RGB Bulb", 80 | "namespace": "RMoRobert", 81 | "location": "https://raw.githubusercontent.com/HubitatCommunity/CoCoHue/master/drivers/cocohue-rgb-bulb-driver.groovy", 82 | "version": "5.3.4", 83 | "required": true 84 | }, 85 | { 86 | "id": "bf27ffe5-244d-4756-9ab9-30d8f89b47bd", 87 | "name": "CoCoHue RGBW Bulb", 88 | "namespace": "RMoRobert", 89 | "location": "https://raw.githubusercontent.com/HubitatCommunity/CoCoHue/master/drivers/cocohue-rgbw-bulb-driver.groovy", 90 | "version": "5.3.5", 91 | "required": true 92 | }, 93 | { 94 | "id": "19ffbc1b-2261-4f7e-a68f-39e40d1c831f", 95 | "name": "CoCoHue Scene", 96 | "namespace": "RMoRobert", 97 | "location": "https://raw.githubusercontent.com/HubitatCommunity/CoCoHue/master/drivers/cocohue-scene-driver.groovy", 98 | "version": "5.3.4", 99 | "required": true 100 | } 101 | ] 102 | } 103 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CoCoHue 2 | CoCoHue: Community Collection of Hue Bridge Apps and Drivers for Hubitat 3 | 4 | (Hue Bridge Integration App for Hubitat) 5 | 6 | This is a Hue Bridge integration designed to replace (or supplement) Hubitat's buit-in Hue Bridge 7 | integration. It provides several additional features compared to older versions of the built-in integration, including: 8 | 1. Scene support: create switch/button devices that can be used to activate Hue Bridge scenes 9 | 2. Access to Hue bulb "effects" (color loop, select/alert, etc.) 10 | 3. Improved group support ("Change Level" capability--`startLevelChange` and stopLevelChange` commands implemented) 11 | 4. It's open source! Customize the code to suit your requirements if so desired 12 | 13 | As of platform version 2.4.0, most of these features are now also available in the built-in integration. However, 14 | some users may prefer CoCoHue for other reasons (e.g., existing user of this integration, prefer open-source code, 15 | etc.) 16 | 17 | For discussion and more information, visit the Hubitat Community forum thread. (GitHub is used primarily for sharing the code. Releases, discussion, and other issues will be noted in the Hubitat Community forum.) 18 | 19 | **NOTE:** Users upgrading to 5.x from 4.x will need to open the CoCoHue app and select **Done** once after upgrading. Users upgrading from older versions will need to upgrade to the latest CoCoHue 4.x release before upgrading to 5.x, select **Done**, then upgrade by following these instructions again. It is recommended to download a hub backup before upgrading (restoring this backup is the only way to downgrade, as 5.x contains breaking changes). 20 | 21 | Three installation methods are available: 22 | - Hubitat Package Manager (recommended if you have Hubitat Package Manager installed) 23 | - As a bundle (recommended for other users) 24 | - Manually with each app and driver file (the most complicated option, not recommended for most users) 25 | 26 | ## To Install (Hubitat Package Manager/Automatic Method) 27 | 28 | CoCoHue is available via Hubitat Package 29 | Manager, a community app designed to make installing and updating community apps and drivers easier. Search for 30 | "CoCoHue" or browse under the "Integrations" category for "Lights & Switches" or "LAN" tags. 31 | 32 | Upgrading: HPM should offer new versions if available when checked. It is recommended to *read the release notes before 33 | any upgrades* (especially from one major version to another, e.g., 4.x to 5.x) and to *not* enable automatic 34 | updates. While such changes are rare, important changes that affect functionality compared to previous versions have 35 | happened and are always noted in the release notes and Community thread. 36 | 37 | ## To Install (as Bundle) 38 | 39 | CoCoHue is also available as a "bundle," a ZIP file that can be downloaded and imported to the hub, installing 40 | all components without the need to manually install each app/driver. 41 | 42 | * **Bundle download link:** https://github.com/HubitatCommunity/CoCoHue/blob/master/CoCoHue-Latest-Bundle.zip 43 | 44 | To install, navigate to **Bundles** on your hub and import the downloaded file; consult 45 | the Hubitat documentation 46 | for more details. 47 | 48 | ## To Install (Manual Method) 49 | 1. Back up your hub and download a local copy before proceeding. 50 | 51 | 2. Install the app from the "apps" folder in this repository into the **Apps Code** section of Hubitat: https://raw.githubusercontent.com/HubitatCommunity/CoCoHue/master/apps/cocohue-app.groovy 52 | (NOTE: If you are upgrading from 1.x, apply this app code to the old child app, not the parent; the parent app is deprecated, though existing installs should continue to work as-is.) 53 | 54 | 3. Install all necessary drivers from the "drivers" folder in this repository into the **Drivers Code** section of Hubitat. (There aren't very many, so I'd recommend just installing them all, but technically all you need is the Bridge driver plus the driver for any device types you plan to use.) 55 | * Install the Bridge driver code: https://raw.githubusercontent.com/HubitatCommunity/CoCoHue/master/drivers/cocohue-bridge-driver.groovy 56 | * Install the bulb, group, scene, motion sensor, plug, button, etc. drivers: 57 | * https://raw.githubusercontent.com/HubitatCommunity/CoCoHue/master/drivers/cocohue-rgbw-bulb-driver.groovy 58 | * https://raw.githubusercontent.com/HubitatCommunity/CoCoHue/master/drivers/cocohue-rgb-bulb-driver.groovy 59 | * https://raw.githubusercontent.com/HubitatCommunity/CoCoHue/master/drivers/cocohue-ct-bulb-driver.groovy 60 | * https://raw.githubusercontent.com/HubitatCommunity/CoCoHue/master/drivers/cocohue-dimmable-bulb-driver.groovy 61 | * https://raw.githubusercontent.com/HubitatCommunity/CoCoHue/master/drivers/cocohue-plug-driver.groovy 62 | * https://raw.githubusercontent.com/HubitatCommunity/CoCoHue/master/drivers/cocohue-group-driver.groovy 63 | * https://raw.githubusercontent.com/HubitatCommunity/CoCoHue/master/drivers/cocohue-motion-sensor-driver.groovy 64 | * https://raw.githubusercontent.com/HubitatCommunity/CoCoHue/master/drivers/cocohue-scene-driver.groovy 65 | * https://raw.githubusercontent.com/HubitatCommunity/CoCoHue/master/drivers/cocohue-button-driver.groovy 66 | * NOTE: Users upgrading from v4.x or earlier may remove the CoCoHue Generic Status driver if installed, as it is now depcrecated (and any devices creating using it will no longer function and can be removed after being removed from any automations; these are/were Hue Labs activators that should be replaced with supported features). 67 | 68 | 4. Install an instance of app: go to **Apps > Add User App**, choose **CoCoHue**, and follow the prompts. 69 | 70 | **NOTE**: Direct upgrades to version 5.x are possible from version 4.x only. Users of version 3.x or older must first 71 | upgrade to version 4.x (switch to the "cocohue-4.2" branch as an easy way to find this version, or download the release as a 72 | bundle ZIP by browsing old releases). Please carefully follow all instructions above, including open the app after each 73 | upgrade and selecting "Done." 74 | 75 | ## Feature Documentation 76 | CoCoHue is designed to be a replacement (although it can also be used as as supplement) for Hubitat's built-in Hue integration. 77 | 78 | Besides features offered by the built-in integration, this integration adds the following features: 79 | 80 | 1. Scenes: implemented as button and switch devices. To activate, "Push" button "1" or send an `on()` command. If you use 81 | scenes, it is recommended to keep polling enabled (actvating a scene will not update associated Hubitat group or bulb 82 | devices without polling). The `off()` command on a scene device will turn off the associated group (using the Hubitat device if 83 | available) or (for classic/"non-GroupScene" scenes) the associated lights, but in most cases it would be desirable to manually 84 | turn the group/lights off yourself instead of using the scene device (you'll have more control and know exactly what the outcome 85 | should be instead of CoCoHue inferring one for you). CoCoHue provides options for handling scene device on/off state and can 86 | optionally show other scenes for same room/zone/group as off when another is activated. 87 | 88 | 2. Groups: the "Change Level" capability is implemented, meaning the "Start Level Change" and "Stop Level Change" commands are 89 | implemented on CoCoHue group devices, not just individual bulbs. Like Hubitat, by default, most group changes will propagage to 90 | individual bulb devices. CoCoHue extends this with an option to also do the reverse, updating group states when individual bulbs are 91 | updated. (Both are optional; the former direction is on by default and the latter off.) In both cases, unlike Hubitat's 92 | stock integration, CoCoHue considers a group when any (not all) members are on. This is consistent with Hue app 93 | behavior and makes prestaging options make more sense when using both. It also means both bulbs and groups should get 94 | updated without polling when either is mannipulated, though it is recommended to configure some polling interval 95 | regardless. Additionally, an "All Hue Lights" group is available to add if desired. 96 | 97 | 3. Buttons and sensors: support for Hue indor/outdoor motion sensor and button devices like Hue Tap and Hue Dimmer (sensors work best with v2 API, and buttons are supported only with v2 API--see below) 98 | 99 | 4. Hue API V2 support (experimental): allows for instant updates pushed from Bridge instead of polling-based approach from Hubitat. 100 | 101 | 5. Color loop effect: to fit in with Hubitat's "Light Effects" capability, Hue's only effect, `colorloop`, is implemented using 102 | this capability and the command it uses, "Set Effect." Color loop is effect `1`. It can be activated by calling `setEffect(1)`. 103 | "None" (no effect; normal behavior) is implemented as effect `0`, so the effect can be cancelled by calling `setEffect(0)`. 104 | The `nextEffect` and `previousEffect` commands are pretty boring for this reason, but they are implemented to be consistent with 105 | the standard Hubitat capability as it is currently documented. Setting a color, hue, or color temperature will also cancel the effect 106 | (this is consistent with the behavior of other bulbs I tested). Setting a level or saturation will *not* because Hue allows adjustment 107 | of these while the effect (which does not manipulate these values) is in progress. 108 | 109 | 6. "Select" and "LSelect" Hue alerts: these are basically a one-time flash and a 15-time flash. These are implemented as the 110 | `flashOnce()` command and the now-standard `flash()` command, respectively. An in-progress flash can be stopped 111 | with `flashOff()` (or you can wait until it stops on own, approximately 30 seconds on official bulbs). -------------------------------------------------------------------------------- /drivers/cocohue-button-driver.groovy: -------------------------------------------------------------------------------- 1 | /* 2 | * ============================= CoCoHue Button (Driver) =============================== 3 | * 4 | * Copyright 2022-2024 Robert Morris 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 7 | * in compliance with the License. You may obtain a copy of the License at: 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 12 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 13 | * for the specific language governing permissions and limitations under the License. 14 | * 15 | * ======================================================================================= 16 | * 17 | * Last modified: 2024-09-14 18 | * 19 | * Changelog: 20 | * v5.0 - Use API v2 by default, remove deprecated features 21 | * v4.2 - Library updates, prep for more v2 API 22 | * v4.1.5 - Improve button command compatibility 23 | * v4.1.4 - Improved HTTP error handling 24 | * v4.1.2 - Add relative_rotary support (Hue Tap Dial, etc.) 25 | * v4.1.1 - Improved button event parsing 26 | * v4.1 - Initial release (with CoCoHue app/bridge 4.1) 27 | */ 28 | 29 | 30 | import hubitat.scheduling.AsyncResponse 31 | import groovy.transform.Field 32 | 33 | @Field static final Integer debugAutoDisableMinutes = 30 34 | 35 | metadata { 36 | definition(name: "CoCoHue Button", namespace: "RMoRobert", author: "Robert Morris", importUrl: "https://raw.githubusercontent.com/HubitatCommunity/CoCoHue/master/drivers/cocohue-button-driver.groovy") { 37 | capability "Actuator" 38 | //capability "Refresh" 39 | capability "PushableButton" 40 | capability "HoldableButton" 41 | capability "ReleasableButton" 42 | //capability "Configuration" 43 | } 44 | 45 | preferences { 46 | input name: "logEnable", type: "bool", title: "Enable debug logging", defaultValue: true 47 | input name: "txtEnable", type: "bool", title: "Enable descriptionText logging", defaultValue: true 48 | } 49 | } 50 | 51 | void installed() { 52 | log.debug "installed()" 53 | initialize() 54 | } 55 | 56 | void updated() { 57 | log.debug "updated()" 58 | initialize() 59 | } 60 | 61 | void initialize() { 62 | log.debug "initialize()" 63 | if (logEnable) { 64 | log.debug "Debug logging will be automatically disabled in ${debugAutoDisableMinutes} minutes" 65 | runIn(debugAutoDisableMinutes*60, "debugOff") 66 | } 67 | } 68 | 69 | /* 70 | void configure() { 71 | log.debug "configure()" 72 | // nothing? remove capability if not needed... 73 | } 74 | */ 75 | 76 | // Probably won't happen but... 77 | void parse(String description) { 78 | log.warn("Running unimplemented parse for: '${description}'") 79 | } 80 | 81 | /** 82 | * Parses response from Bridge (or not) after sendBridgeCommandV1. Updates device state if 83 | * appears to have been successful. 84 | * @param resp Async HTTP response object 85 | * @param data Map with keys 'attribute' and 'value' containing event data to send if successful (e.g., [attribute: 'switch', value: 'off']) 86 | */ 87 | void parseSendCommandResponseV1(AsyncResponse resp, Map data) { 88 | if (logEnable) log.debug "Response from Bridge: ${resp.status}; data from app = $data" 89 | // TODO: Rethink for buttons... 90 | if (checkIfValidResponse(resp) && data?.attribute != null && data?.value != null) { 91 | if (logEnable) log.debug " Bridge response valid; running creating events" 92 | if (device.currentValue(data.attribute) != data.value) doSendEvent(data.attribute, data.value) 93 | } 94 | else { 95 | if (logEnable) log.debug " Not creating events from map because not specified to do or Bridge response invalid" 96 | } 97 | } 98 | 99 | void push(btnNum) { 100 | if (logEnable) log.debug "push($btnNum)" 101 | doSendEvent("pushed", btnNum.toInteger(), null, true) 102 | } 103 | 104 | void hold(btnNum) { 105 | if (logEnable) log.debug "hold($btnNum)" 106 | doSendEvent("held", btnNum.toInteger(), null, true) 107 | } 108 | 109 | void release(btnNum) { 110 | if (logEnable) log.debug "release($btnNum)" 111 | doSendEvent("released", btnNum.toInteger(), null, true) 112 | } 113 | 114 | /** 115 | * Parses through device data/state in Hue API v2 format (e.g., "on={on=true}") and does 116 | * a sendEvent for each relevant attribute; intended to be called when EventSocket data 117 | * received for device 118 | */ 119 | void createEventsFromMapV2(Map data) { 120 | if (logEnable == true) log.debug "createEventsFromMapV2($data)" 121 | String eventName 122 | if (data.type == "button") { 123 | Integer eventValue = state.buttons.find({ it.key == data.id})?.value ?: 1 124 | switch (data.button.last_event) { 125 | case "initial_press": 126 | eventName = "pushed" 127 | break 128 | case "repeat": 129 | // prevent sending repeated "held" events 130 | if (state.lastHueEvent != "repeat") eventName = "held" 131 | else eventName = null 132 | break 133 | case "long_release": 134 | eventName = "released" 135 | break 136 | case "id_v1": 137 | if (state.id_v1 != value) state.id_v1 = value 138 | break 139 | default: 140 | if (logEnable == true) log.debug "No button event created from: ${data.button.last_event}" 141 | break 142 | } 143 | state.lastHueEvent = data.button.last_event 144 | if (eventName != null) doSendEvent(eventName, eventValue, null, true) 145 | } 146 | else if (data.type == "relative_rotary") { 147 | Integer eventValue = state.relative_rotary.indexOf(data.id) + state.buttons.size() + 1 148 | // using counterclockwise = index+1, clockwise = index+2 for rotary devices 149 | if (data.relative_rotary.last_event.rotation.direction == "clock_wise") eventValue++ 150 | switch (data.relative_rotary.last_event.action) { 151 | case "start": 152 | eventName = "pushed" 153 | break 154 | case "repeat": 155 | // prevent sending repeated "held" events 156 | if (state.lastHueEvent != "repeat") eventName = "held" 157 | else eventName = null 158 | break 159 | default: 160 | break 161 | } 162 | state.lastHueEvent = data.relative_rotary.last_event.action 163 | if (eventName != null) doSendEvent(eventName, eventValue, null, true) 164 | } 165 | else { 166 | if (logEnable) log.debug "ignoring; data.type = ${data.type}" 167 | } 168 | } 169 | 170 | /** 171 | * Sets state.button to IDs a Map in format [subButtonId: buttonNumber], used to determine 172 | * which button number to use for events when it is believed to be one this device "owns". Also 173 | * accepts List of relative_rotary IDs, optional (will be used as additional button numbers) 174 | */ 175 | void setButtons(Map buttons, List relativeRotaries=null) { 176 | if (logEnable) log.debug "setButtons($buttons, $relativeRotaries)" 177 | state.buttons = buttons 178 | if (relativeRotaries) state.relative_rotary = relativeRotaries 179 | Integer numButtons = buttons.keySet().size() 180 | if (relativeRotaries) numButtons += relativeRotaries.size() * 2 // x2 because clockwise + counterclockwise as separate numbers 181 | doSendEvent("numberOfButtons", numButtons) 182 | } 183 | 184 | 185 | // ~~~ IMPORTED FROM RMoRobert.CoCoHue_Common_Lib ~~~ 186 | // Version 1.0.6 187 | // For use with CoCoHue drivers (not app) 188 | 189 | /** 190 | * 1.0.6 - Remove common bridgeAsyncPutV2() method (now call from parent app instead of driver) 191 | * 1.0.5 - Add common bridgeAsyncPutV2() method for asyncHttpPut (goal to reduce individual driver code) 192 | * 1.0.4 - Add common bridgeAsyncGetV2() method asyncHttpGet (goal to reduce individual driver code) 193 | * 1.0.3 - Add APIV1 and APIV2 "constants" 194 | * 1.0.2 - HTTP error handling tweaks 195 | */ 196 | 197 | void debugOff() { 198 | log.warn "Disabling debug logging" 199 | device.updateSetting("logEnable", [value:"false", type:"bool"]) 200 | } 201 | 202 | /** Performs basic check on data returned from HTTP response to determine if should be 203 | * parsed as likely Hue Bridge data or not; returns true (if OK) or logs errors/warnings and 204 | * returns false if not 205 | * @param resp The async HTTP response object to examine 206 | */ 207 | private Boolean checkIfValidResponse(hubitat.scheduling.AsyncResponse resp) { 208 | if (logEnable == true) log.debug "Checking if valid HTTP response/data from Bridge..." 209 | Boolean isOK = true 210 | if (resp.status < 400) { 211 | if (resp.json == null) { 212 | isOK = false 213 | if (resp.headers == null) log.error "Error: HTTP ${resp.status} when attempting to communicate with Bridge" 214 | else log.error "No JSON data found in response. ${resp.headers.'Content-Type'} (HTTP ${resp.status})" 215 | parent.sendBridgeDiscoveryCommandIfSSDPEnabled(true) // maybe IP changed, so attempt rediscovery 216 | parent.setBridgeOnlineStatus(false) 217 | } 218 | else if (resp.json) { 219 | if ((resp.json instanceof List) && resp.json.getAt(0).error) { 220 | // Bridge (not HTTP) error (bad username, bad command formatting, etc.): 221 | isOK = false 222 | log.warn "Error from Hue Bridge: ${resp.json[0].error}" 223 | // Not setting Bridge to offline when light/scene/group devices end up here because could 224 | // be old/bad ID and don't want to consider Bridge offline just for that (but also won't set 225 | // to online because wasn't successful attempt) 226 | } 227 | // Otherwise: probably OK (not changing anything because isOK = true already) 228 | } 229 | else { 230 | isOK = false 231 | log.warn("HTTP status code ${resp.status} from Bridge") 232 | // TODO: Update for mDNS if/when switch: 233 | if (resp?.status >= 400) parent.sendBridgeDiscoveryCommandIfSSDPEnabled(true) // maybe IP changed, so attempt rediscovery 234 | parent.setBridgeOnlineStatus(false) 235 | } 236 | if (isOK == true) parent.setBridgeOnlineStatus(true) 237 | } 238 | else { 239 | log.warn "Error communicating with Hue Bridge: HTTP ${resp?.status}" 240 | isOK = false 241 | } 242 | return isOK 243 | } 244 | 245 | void doSendEvent(String eventName, eventValue, String eventUnit=null, Boolean forceStateChange=false) { 246 | //if (logEnable == true) log.debug "doSendEvent($eventName, $eventValue, $eventUnit)" 247 | String descriptionText = "${device.displayName} ${eventName} is ${eventValue}${eventUnit ?: ''}" 248 | if (settings.txtEnable == true) log.info(descriptionText) 249 | if (eventUnit) { 250 | if (forceStateChange == true) sendEvent(name: eventName, value: eventValue, descriptionText: descriptionText, unit: eventUnit, isStateChange: true) 251 | else sendEvent(name: eventName, value: eventValue, descriptionText: descriptionText, unit: eventUnit) 252 | } else { 253 | if (forceStateChange == true) sendEvent(name: eventName, value: eventValue, descriptionText: descriptionText, isStateChange: true) 254 | else sendEvent(name: eventName, value: eventValue, descriptionText: descriptionText) 255 | } 256 | } 257 | 258 | // HTTP methods (might be better to split into separate library if not needed for some?) 259 | 260 | /** Performs asynchttpGet() to Bridge using data retrieved from parent app or as passed in 261 | * @param callbackMethod Callback method 262 | * @param clipV2Path The Hue V2 API path ('/clip/v2' is automatically prepended), e.g. '/resource' or '/resource/light' 263 | * @param bridgeData Bridge data from parent getBridgeData() call, or will call this method on parent if null 264 | * @param data Extra data to pass as optional third (data) parameter to asynchtttpGet() method 265 | */ 266 | void bridgeAsyncGetV2(String callbackMethod, String clipV2Path, Map bridgeData = null, Map data = null) { 267 | if (bridgeData == null) { 268 | bridgeData = parent.getBridgeData() 269 | } 270 | Map params = [ 271 | uri: "https://${bridgeData.ip}", 272 | path: "/clip/v2${clipV2Path}", 273 | headers: ["hue-application-key": bridgeData.username], 274 | contentType: "application/json", 275 | timeout: 15, 276 | ignoreSSLIssues: true 277 | ] 278 | asynchttpGet(callbackMethod, params, data) 279 | } 280 | 281 | // REMOVED, now call from parent app instead of driver: 282 | // /** Performs asynchttpPut() to Bridge using data retrieved from parent app or as passed in 283 | // * @param callbackMethod Callback method 284 | // * @param clipV2Path The Hue V2 API path ('/clip/v2' is automatically prepended), e.g. '/resource' or '/resource/light' 285 | // * @param body Body data, a Groovy Map representing JSON for the Hue V2 API command, e.g., [on: [on: true]] 286 | // * @param bridgeData Bridge data from parent getBridgeData() call, or will call this method on parent if null 287 | // * @param data Extra data to pass as optional third (data) parameter to asynchtttpPut() method 288 | // */ 289 | // void bridgeAsyncPutV2(String callbackMethod, String clipV2Path, Map body, Map bridgeData = null, Map data = null) { 290 | // if (bridgeData == null) { 291 | // bridgeData = parent.getBridgeData() 292 | // } 293 | // Map params = [ 294 | // uri: "https://${bridgeData.ip}", 295 | // path: "/clip/v2${clipV2Path}", 296 | // headers: ["hue-application-key": bridgeData.username], 297 | // contentType: "application/json", 298 | // body: body, 299 | // timeout: 15, 300 | // ignoreSSLIssues: true 301 | // ] 302 | // asynchttpPut(callbackMethod, params, data) 303 | // if (logEnable == true) log.debug "Command sent to Bridge: $body at ${clipV2Path}" 304 | // pauseExecution(200) // see if helps HTTP 429 errors? 305 | // } 306 | 307 | 308 | // ~~~ IMPORTED FROM RMoRobert.CoCoHue_Constants_Lib ~~~ 309 | // Version 1.0.0 310 | 311 | // -------------------------------------- 312 | // APP AND DRIVER NAMESPACE AND NAMES: 313 | // -------------------------------------- 314 | @Field static final String NAMESPACE = "RMoRobert" 315 | @Field static final String DRIVER_NAME_BRIDGE = "CoCoHue Bridge" 316 | @Field static final String DRIVER_NAME_BUTTON = "CoCoHue Button" 317 | @Field static final String DRIVER_NAME_CT_BULB = "CoCoHue CT Bulb" 318 | @Field static final String DRIVER_NAME_DIMMABLE_BULB = "CoCoHue Dimmable Bulb" 319 | @Field static final String DRIVER_NAME_GROUP = "CoCoHue Group" 320 | @Field static final String DRIVER_NAME_MOTION = "CoCoHue Motion Sensor" 321 | @Field static final String DRIVER_NAME_PLUG = "CoCoHue Plug" 322 | @Field static final String DRIVER_NAME_RGBW_BULB = "CoCoHue RGBW Bulb" 323 | @Field static final String DRIVER_NAME_RGB_BULB = "CoCoHue RGB Bulb" 324 | @Field static final String DRIVER_NAME_SCENE = "CoCoHue Scene" 325 | 326 | // -------------------------------------- 327 | // DNI PREFIX for child devices: 328 | // -------------------------------------- 329 | @Field static final String DNI_PREFIX = "CCH" 330 | 331 | // -------------------------------------- 332 | // OTHER: 333 | // -------------------------------------- 334 | // Used in app and Bridge driver, may eventually find use in more: 335 | @Field static final String APIV1 = "V1" 336 | @Field static final String APIV2 = "V2" -------------------------------------------------------------------------------- /drivers/cocohue-motion-sensor-driver.groovy: -------------------------------------------------------------------------------- 1 | /* 2 | * ============================= CoCoHue Motion Sensor (Driver) =============================== 3 | * 4 | * Copyright 2020-2024 Robert Morris 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 7 | * in compliance with the License. You may obtain a copy of the License at: 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 12 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 13 | * for the specific language governing permissions and limitations under the License. 14 | * 15 | * ======================================================================================= 16 | * 17 | * Last modified: 2024-12-08 18 | * 19 | * Changelog: 20 | * v5.2.2 - Populate initial states from V2 cache if available 21 | * v5.0 - Use API v2 by default, remove deprecated features 22 | * v4.2 - Library updates, prep for more v2 API 23 | * v4.1.4 - Improved error handling, fix missing battery for motion sensors 24 | * v4.0 - Add SSE support for push 25 | * v3.5.1 - Refactor some code into libraries (code still precompiled before upload; should not have any visible changes) 26 | * v3.5 - Minor code cleanup 27 | * v3.1.6 - Fixed runtime error when using temperature offset; ensure battery and lux reported as integers, temperature as BigDecimal 28 | * v3.1.2 - Added optional offset for temperature sensor 29 | * v3.1 - Improved error handling and debug logging 30 | * v3.0 - Initial release 31 | */ 32 | 33 | 34 | import groovy.transform.Field 35 | 36 | @Field static final Integer debugAutoDisableMinutes = 30 37 | 38 | metadata { 39 | definition(name: "CoCoHue Motion Sensor", namespace: "RMoRobert", author: "Robert Morris", importUrl: "https://raw.githubusercontent.com/HubitatCommunity/CoCoHue/master/drivers/cocohue-motion-sensor-driver.groovy") { 40 | capability "Sensor" 41 | capability "Refresh" 42 | capability "MotionSensor" 43 | capability "IlluminanceMeasurement" 44 | capability "TemperatureMeasurement" 45 | capability "Battery" 46 | } 47 | 48 | preferences { 49 | input name: "tempAdjust", type: "number", title: "Adjust temperature reading by this amount", description: "Example: 0.4 or -1.5 (optional)" 50 | input name: "logEnable", type: "bool", title: "Enable debug logging", defaultValue: true 51 | input name: "txtEnable", type: "bool", title: "Enable descriptionText logging", defaultValue: true 52 | } 53 | } 54 | 55 | void installed() { 56 | log.debug "installed()" 57 | if (device.currentValue("motion") == null) { 58 | // Populate initial device data (if V2 available; V1 users would need manual refresh) 59 | List bridgeCacheData = parent.getBridgeCacheV2()?.data ?: [] 60 | Map devCache = bridgeCacheData.find { it.type == "sensor" && it.id == device.deviceNetworkId.split("/").last() } 61 | if (devCache != null) { 62 | log.warn devCache.id 63 | createEventsFromMapV2(devCache) 64 | } 65 | } 66 | initialize() 67 | } 68 | 69 | void updated() { 70 | log.debug "updated()" 71 | initialize() 72 | } 73 | 74 | void initialize() { 75 | log.debug "initialize()" 76 | if (logEnable) { 77 | log.debug "Debug logging will be automatically disabled in ${debugAutoDisableMinutes} minutes" 78 | runIn(debugAutoDisableMinutes*60, "debugOff") 79 | } 80 | } 81 | 82 | void refresh() { 83 | log.warn "Refresh Hue Bridge device instead of individual device to update (all) bulbs/groups/sensors" 84 | } 85 | 86 | // Probably won't happen but... 87 | void parse(String description) { 88 | log.warn("Running unimplemented parse for: '${description}'") 89 | } 90 | 91 | /** 92 | * Iterates over Hue sensor state commands/states in Hue format (e.g., ["lightlevel": 25000]) and does 93 | * a sendEvent for each relevant attribute; for sensors, intended to be called 94 | * to parse/update sensor state on Hubitat based on data received from Bridge 95 | * @param bridgeCmd Map of sensor states from Bridge (for lights, this could be either a command to or response from) 96 | */ 97 | void createEventsFromMapV1(Map bridgeCmd) { 98 | if (!bridgeCmd) { 99 | if (logEnable) log.debug "createEventsFromMapV1 called but map empty; exiting" 100 | return 101 | } 102 | if (logEnable) log.debug "createEventsFromMapV1(): Preparing to create events from map: ${bridgeCmd}" 103 | String eventName, eventUnit, descriptionText 104 | def eventValue // could be numeric (lux, temp) or boolean (motion) 105 | bridgeCmd.each { 106 | switch (it.key) { 107 | case "presence": 108 | eventName = "motion" 109 | eventValue = it.value ? "active" : "inactive" 110 | eventUnit = null 111 | if (device.currentValue(eventName) != eventValue) doSendEvent(eventName, eventValue, eventUnit) 112 | break 113 | case "lightlevel": 114 | eventName = "illuminance" 115 | eventValue = Math.round(10 ** (((it.value as Integer)-1)/10000)) 116 | eventUnit = "lux" 117 | if (device.currentValue(eventName) != eventValue) doSendEvent(eventName, eventValue as Integer, eventUnit) 118 | break 119 | case "temperature": 120 | eventName = "temperature" 121 | if (location.temperatureScale == "C") eventValue = ((it.value as BigDecimal)/100.0).setScale(1, java.math.RoundingMode.HALF_UP) 122 | else eventValue = celsiusToFahrenheit((it.value as BigDecimal)/100.0).setScale(1, java.math.RoundingMode.HALF_UP) 123 | if (settings["tempAdjust"]) eventValue = (eventValue as BigDecimal) + (settings["tempAdjust"] as BigDecimal) 124 | eventUnit = "°${location.temperatureScale}" 125 | if (device.currentValue(eventName) != eventValue) doSendEvent(eventName, eventValue as BigDecimal, eventUnit) 126 | break 127 | case "battery": 128 | eventName = "battery" 129 | eventValue = (it.value != null) ? (it.value as Integer) : 0 130 | eventUnit = "%" 131 | if (device.currentValue(eventName) != eventValue) doSendEvent(eventName, eventValue as Integer, eventUnit) 132 | break 133 | default: 134 | break 135 | //log.warn "Unhandled key/value discarded: $it" 136 | } 137 | } 138 | } 139 | 140 | /** 141 | * (for "new"/v2/EventSocket [SSE] API; not documented and subject to change) 142 | * Iterates over Hue light state states in Hue API v2 format (e.g., "on={on=true}") and does 143 | * a sendEvent for each relevant attribute; intended to be called when EventSocket data 144 | * received for device (as an alternative to polling) 145 | */ 146 | void createEventsFromMapV2(Map data) { 147 | if (logEnable == true) log.debug "createEventsFromMapV2($data)" 148 | String eventName, eventUnit, descriptionText 149 | def eventValue // could be String or number 150 | data.each { String key, value -> 151 | switch (key) { 152 | case "motion": 153 | eventName = "motion" 154 | eventValue = value.motion ? "active" : "inactive" 155 | eventUnit = null 156 | if (device.currentValue(eventName) != eventValue) doSendEvent(eventName, eventValue, eventUnit) 157 | break 158 | case "light": 159 | eventName = "illuminance" 160 | eventValue = Math.round(10 ** (((value.light_level as Integer)-1)/10000)) 161 | eventUnit = "lux" 162 | if (device.currentValue(eventName) != eventValue) doSendEvent(eventName, eventValue as Integer, eventUnit) 163 | break 164 | case "temperature": 165 | eventName = "temperature" 166 | if (location.temperatureScale == "C") eventValue = ((value.temperature as BigDecimal)).setScale(1, java.math.RoundingMode.HALF_UP) 167 | else eventValue = celsiusToFahrenheit((value.temperature as BigDecimal).setScale(1, java.math.RoundingMode.HALF_UP)) 168 | if (settings["tempAdjust"]) eventValue = (eventValue as BigDecimal) + (settings["tempAdjust"] as BigDecimal) 169 | eventUnit = "°${location.temperatureScale}" 170 | if (device.currentValue(eventName) != eventValue) doSendEvent(eventName, eventValue as BigDecimal, eventUnit) 171 | break 172 | case "power_state": 173 | eventName = "battery" 174 | eventValue = value.battery_level 175 | eventUnit = "%" 176 | if (eventValue != null && device.currentValue(eventName) != eventValue) doSendEvent(eventName, eventValue as Integer, eventUnit) 177 | break 178 | case "id_v1": 179 | if (state.id_v1 != value) state.id_v1 = value 180 | break 181 | default: 182 | if (logEnable == true) log.debug "not handling: key $key = value $value" 183 | } 184 | } 185 | } 186 | 187 | 188 | // ~~~ IMPORTED FROM RMoRobert.CoCoHue_Common_Lib ~~~ 189 | // Version 1.0.6 190 | // For use with CoCoHue drivers (not app) 191 | 192 | /** 193 | * 1.0.6 - Remove common bridgeAsyncPutV2() method (now call from parent app instead of driver) 194 | * 1.0.5 - Add common bridgeAsyncPutV2() method for asyncHttpPut (goal to reduce individual driver code) 195 | * 1.0.4 - Add common bridgeAsyncGetV2() method asyncHttpGet (goal to reduce individual driver code) 196 | * 1.0.3 - Add APIV1 and APIV2 "constants" 197 | * 1.0.2 - HTTP error handling tweaks 198 | */ 199 | 200 | void debugOff() { 201 | log.warn "Disabling debug logging" 202 | device.updateSetting("logEnable", [value:"false", type:"bool"]) 203 | } 204 | 205 | /** Performs basic check on data returned from HTTP response to determine if should be 206 | * parsed as likely Hue Bridge data or not; returns true (if OK) or logs errors/warnings and 207 | * returns false if not 208 | * @param resp The async HTTP response object to examine 209 | */ 210 | private Boolean checkIfValidResponse(hubitat.scheduling.AsyncResponse resp) { 211 | if (logEnable == true) log.debug "Checking if valid HTTP response/data from Bridge..." 212 | Boolean isOK = true 213 | if (resp.status < 400) { 214 | if (resp.json == null) { 215 | isOK = false 216 | if (resp.headers == null) log.error "Error: HTTP ${resp.status} when attempting to communicate with Bridge" 217 | else log.error "No JSON data found in response. ${resp.headers.'Content-Type'} (HTTP ${resp.status})" 218 | parent.sendBridgeDiscoveryCommandIfSSDPEnabled(true) // maybe IP changed, so attempt rediscovery 219 | parent.setBridgeOnlineStatus(false) 220 | } 221 | else if (resp.json) { 222 | if ((resp.json instanceof List) && resp.json.getAt(0).error) { 223 | // Bridge (not HTTP) error (bad username, bad command formatting, etc.): 224 | isOK = false 225 | log.warn "Error from Hue Bridge: ${resp.json[0].error}" 226 | // Not setting Bridge to offline when light/scene/group devices end up here because could 227 | // be old/bad ID and don't want to consider Bridge offline just for that (but also won't set 228 | // to online because wasn't successful attempt) 229 | } 230 | // Otherwise: probably OK (not changing anything because isOK = true already) 231 | } 232 | else { 233 | isOK = false 234 | log.warn("HTTP status code ${resp.status} from Bridge") 235 | // TODO: Update for mDNS if/when switch: 236 | if (resp?.status >= 400) parent.sendBridgeDiscoveryCommandIfSSDPEnabled(true) // maybe IP changed, so attempt rediscovery 237 | parent.setBridgeOnlineStatus(false) 238 | } 239 | if (isOK == true) parent.setBridgeOnlineStatus(true) 240 | } 241 | else { 242 | log.warn "Error communicating with Hue Bridge: HTTP ${resp?.status}" 243 | isOK = false 244 | } 245 | return isOK 246 | } 247 | 248 | void doSendEvent(String eventName, eventValue, String eventUnit=null, Boolean forceStateChange=false) { 249 | //if (logEnable == true) log.debug "doSendEvent($eventName, $eventValue, $eventUnit)" 250 | String descriptionText = "${device.displayName} ${eventName} is ${eventValue}${eventUnit ?: ''}" 251 | if (settings.txtEnable == true) log.info(descriptionText) 252 | if (eventUnit) { 253 | if (forceStateChange == true) sendEvent(name: eventName, value: eventValue, descriptionText: descriptionText, unit: eventUnit, isStateChange: true) 254 | else sendEvent(name: eventName, value: eventValue, descriptionText: descriptionText, unit: eventUnit) 255 | } else { 256 | if (forceStateChange == true) sendEvent(name: eventName, value: eventValue, descriptionText: descriptionText, isStateChange: true) 257 | else sendEvent(name: eventName, value: eventValue, descriptionText: descriptionText) 258 | } 259 | } 260 | 261 | // HTTP methods (might be better to split into separate library if not needed for some?) 262 | 263 | /** Performs asynchttpGet() to Bridge using data retrieved from parent app or as passed in 264 | * @param callbackMethod Callback method 265 | * @param clipV2Path The Hue V2 API path ('/clip/v2' is automatically prepended), e.g. '/resource' or '/resource/light' 266 | * @param bridgeData Bridge data from parent getBridgeData() call, or will call this method on parent if null 267 | * @param data Extra data to pass as optional third (data) parameter to asynchtttpGet() method 268 | */ 269 | void bridgeAsyncGetV2(String callbackMethod, String clipV2Path, Map bridgeData = null, Map data = null) { 270 | if (bridgeData == null) { 271 | bridgeData = parent.getBridgeData() 272 | } 273 | Map params = [ 274 | uri: "https://${bridgeData.ip}", 275 | path: "/clip/v2${clipV2Path}", 276 | headers: ["hue-application-key": bridgeData.username], 277 | contentType: "application/json", 278 | timeout: 15, 279 | ignoreSSLIssues: true 280 | ] 281 | asynchttpGet(callbackMethod, params, data) 282 | } 283 | 284 | // REMOVED, now call from parent app instead of driver: 285 | // /** Performs asynchttpPut() to Bridge using data retrieved from parent app or as passed in 286 | // * @param callbackMethod Callback method 287 | // * @param clipV2Path The Hue V2 API path ('/clip/v2' is automatically prepended), e.g. '/resource' or '/resource/light' 288 | // * @param body Body data, a Groovy Map representing JSON for the Hue V2 API command, e.g., [on: [on: true]] 289 | // * @param bridgeData Bridge data from parent getBridgeData() call, or will call this method on parent if null 290 | // * @param data Extra data to pass as optional third (data) parameter to asynchtttpPut() method 291 | // */ 292 | // void bridgeAsyncPutV2(String callbackMethod, String clipV2Path, Map body, Map bridgeData = null, Map data = null) { 293 | // if (bridgeData == null) { 294 | // bridgeData = parent.getBridgeData() 295 | // } 296 | // Map params = [ 297 | // uri: "https://${bridgeData.ip}", 298 | // path: "/clip/v2${clipV2Path}", 299 | // headers: ["hue-application-key": bridgeData.username], 300 | // contentType: "application/json", 301 | // body: body, 302 | // timeout: 15, 303 | // ignoreSSLIssues: true 304 | // ] 305 | // asynchttpPut(callbackMethod, params, data) 306 | // if (logEnable == true) log.debug "Command sent to Bridge: $body at ${clipV2Path}" 307 | // pauseExecution(200) // see if helps HTTP 429 errors? 308 | // } 309 | 310 | 311 | // ~~~ IMPORTED FROM RMoRobert.CoCoHue_Constants_Lib ~~~ 312 | // Version 1.0.0 313 | 314 | // -------------------------------------- 315 | // APP AND DRIVER NAMESPACE AND NAMES: 316 | // -------------------------------------- 317 | @Field static final String NAMESPACE = "RMoRobert" 318 | @Field static final String DRIVER_NAME_BRIDGE = "CoCoHue Bridge" 319 | @Field static final String DRIVER_NAME_BUTTON = "CoCoHue Button" 320 | @Field static final String DRIVER_NAME_CT_BULB = "CoCoHue CT Bulb" 321 | @Field static final String DRIVER_NAME_DIMMABLE_BULB = "CoCoHue Dimmable Bulb" 322 | @Field static final String DRIVER_NAME_GROUP = "CoCoHue Group" 323 | @Field static final String DRIVER_NAME_MOTION = "CoCoHue Motion Sensor" 324 | @Field static final String DRIVER_NAME_CONTACT = "CoCoHue Contact Sensor" 325 | @Field static final String DRIVER_NAME_PLUG = "CoCoHue Plug" 326 | @Field static final String DRIVER_NAME_RGBW_BULB = "CoCoHue RGBW Bulb" 327 | @Field static final String DRIVER_NAME_RGB_BULB = "CoCoHue RGB Bulb" 328 | @Field static final String DRIVER_NAME_SCENE = "CoCoHue Scene" 329 | 330 | // -------------------------------------- 331 | // DNI PREFIX for child devices: 332 | // -------------------------------------- 333 | @Field static final String DNI_PREFIX = "CCH" 334 | 335 | // -------------------------------------- 336 | // OTHER: 337 | // -------------------------------------- 338 | // Used in app and Bridge driver, may eventually find use in more: 339 | @Field static final String APIV1 = "V1" 340 | @Field static final String APIV2 = "V2" -------------------------------------------------------------------------------- /drivers/cocohue-plug-driver.groovy: -------------------------------------------------------------------------------- 1 | /* 2 | * ============================= CoCoHue Plug (On/Off Light) (Driver) =============================== 3 | * 4 | * Copyright 2019-2025 Robert Morris 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 7 | * in compliance with the License. You may obtain a copy of the License at: 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 12 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 13 | * for the specific language governing permissions and limitations under the License. 14 | * 15 | * ======================================================================================= 16 | * 17 | * Last modified: 2025-09-07 18 | * 19 | * Changelog: 20 | * v5.3.4 - Changes to accommodate HTTPS by default 21 | * v5.3.1 - Implement async HTTP call queueing from child drivers through parent app 22 | * v5.3.0 - Use V2 for most commands 23 | * v5.2.2 - Populate initial states from V2 cache if available 24 | * v5.0.1 - Fix for missing V1 IDs after device creation or upgrade 25 | * v5.0 - Use API v2 by default, remove deprecated features 26 | * v4.2 - Library updates, prep for more v2 API 27 | * v4.1.4 - Improved error handling, fix missing battery for motion sensors 28 | * v4.0 - Add SSE support for push 29 | * v3.5.1 - Refactor some code into libraries (code still precompiled before upload; should not have any visible changes) 30 | * v3.5 - Addded "reachable" attribte from Bridge to bulb and group drivers (thanks to @jtp10181 for original implementation) 31 | * v3.1 - Improved error handling and debug logging 32 | * v3.0 - Fix so events no created until Bridge response received (as was done for other drivers in 2.0); improved HTTP error handling 33 | * v2.1 - Minor code cleanup; more static typing 34 | * v2.0 - Improved HTTP error handling; attribute events now generated 35 | * only after hearing back from Bridge; Bridge online/offline status improvements 36 | * v1.8 - Added ability to disable plug->group state propagation; 37 | * Removed ["alert:" "none"] from on() command, now possible explicitly with flashOff() 38 | * v1.7 - Initial Release 39 | */ 40 | 41 | //#include RMoRobert.CoCoHue_Flash_Lib // can uncomment if needed; see also definition() below 42 | 43 | import groovy.transform.Field 44 | import hubitat.scheduling.AsyncResponse 45 | 46 | @Field static final Integer debugAutoDisableMinutes = 30 47 | 48 | // Default list of command Map keys to ignore if SSE enabled and command is sent from hub (not polled from Bridge), used to 49 | // ignore duplicates that are expected to be processed from SSE momentarily: 50 | // (for on/off devices, should cover everything...) 51 | @Field static final List listKeysToIgnoreIfSSEEnabledAndNotFromBridge = ["on"] 52 | 53 | 54 | metadata { 55 | definition(name: "CoCoHue Plug", namespace: "RMoRobert", author: "Robert Morris", importUrl: "https://raw.githubusercontent.com/HubitatCommunity/CoCoHue/master/drivers/cocohue-plug-driver.groovy") { 56 | capability "Actuator" 57 | capability "Refresh" 58 | capability "Switch" 59 | capability "Light" 60 | 61 | // Not supported on (most?) plugs; can uncomment if you are using for lights that support this: 62 | //command "flash" 63 | //command "flashOnce" 64 | //command "flashOff" 65 | 66 | attribute "reachable", "string" 67 | } 68 | 69 | preferences { 70 | input name: "logEnable", type: "bool", title: "Enable debug logging", defaultValue: true 71 | input name: "txtEnable", type: "bool", title: "Enable descriptionText logging", defaultValue: true 72 | 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 73 | } 74 | } 75 | 76 | void installed() { 77 | log.debug "installed()" 78 | if (device.currentValue("switch") == null) { 79 | // Populate initial device data (if V2 available; V1 users would need manual refresh) 80 | List bridgeCacheData = parent.getBridgeCacheV2()?.data ?: [] 81 | Map devCache = bridgeCacheData.find { it.type == "light" && it.id == device.deviceNetworkId.split("/").last() } 82 | if (devCache == null) devCache == bridgeCacheData.find { it.type == "light" && it.id_v1 == device.deviceNetworkId.split("/").last() } 83 | if (devCache != null) { 84 | log.warn devCache.id 85 | createEventsFromMapV2(devCache) 86 | } 87 | } 88 | initialize() 89 | } 90 | 91 | void updated() { 92 | log.debug "updated()" 93 | initialize() 94 | } 95 | 96 | void initialize() { 97 | log.debug "initialize()" 98 | if (logEnable) { 99 | log.debug "Debug logging will be automatically disabled in ${debugAutoDisableMinutes} minutes" 100 | runIn(debugAutoDisableMinutes*60, "debugOff") 101 | } 102 | } 103 | 104 | // Probably won't happen but... 105 | void parse(String description) { 106 | log.warn("Running unimplemented parse for: '${description}'") 107 | } 108 | 109 | /** 110 | * Parses V1 Hue Bridge device ID number out of Hubitat DNI for use with Hue V1 API calls 111 | * Hubitat DNI is created in format "CCH/BridgeMACAbbrev/Light/HueDeviceID", so just 112 | * looks for number after last "/" character; or try state if DNI is V2 format (avoid if posssible, 113 | * as Hue is likely to deprecate V1 ID data in future) 114 | */ 115 | String getHueDeviceIdV1() { 116 | String id = device.deviceNetworkId.split("/").last() 117 | 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 118 | id = state.id_v1?.split("/")?.last() 119 | if (state.id_v1 == null) { 120 | log.warn "Attempting to retrieve V1 ID but not in DNI or state." 121 | } 122 | } 123 | return id 124 | } 125 | 126 | void on() { 127 | if (logEnable == true) log.debug "on()" 128 | Map bridgeCmd = ["on": true] 129 | sendBridgeCommandV1(bridgeCmd) 130 | } 131 | 132 | void off() { 133 | if (logEnable == true) log.debug "off()" 134 | Map bridgeCmd = ["on": false] 135 | sendBridgeCommandV1(bridgeCmd) 136 | } 137 | 138 | void refresh() { 139 | log.warn "Refresh Hue Bridge device instead of individual device to update (all) bulbs/groups" 140 | } 141 | 142 | /** 143 | * Iterates over Hue light state commands/states in Hue format (e.g., ["on": true]) and does 144 | * a sendEvent for each relevant attribute; intended to be called either when commands are sent 145 | * to Bridge or to parse/update light states based on data received from Bridge 146 | * @param bridgeMap Map of light states that are or would be sent to bridge OR state as received from 147 | * Bridge 148 | * @param isFromBridge Set to true if this is data read from Hue Bridge rather than intended to be sent 149 | * to Bridge; TODO: see if still needed now that pseudo-prestaging removed 150 | */ 151 | void createEventsFromMapV1(Map bridgeCommandMap, Boolean isFromBridge = false, Set keysToIgnoreIfSSEEnabledAndNotFromBridge=listKeysToIgnoreIfSSEEnabledAndNotFromBridge) { 152 | if (!bridgeCommandMap) { 153 | if (logEnable == true) log.debug "createEventsFromMapV1 called but map command empty or null; exiting" 154 | return 155 | } 156 | Map bridgeMap = bridgeCommandMap 157 | if (logEnable == true) log.debug "createEventsFromMapV1(): Preparing to create events from map${isFromBridge ? ' from Bridge' : ''}: ${bridgeMap}" 158 | if (!isFromBridge && keysToIgnoreIfSSEEnabledAndNotFromBridge && parent.getEventStreamOpenStatus() == true) { 159 | bridgeMap.keySet().removeAll(keysToIgnoreIfSSEEnabledAndNotFromBridge) 160 | if (logEnable == true) log.debug "Map after ignored keys removed: ${bridgeMap}" 161 | } 162 | String eventName, eventUnit, descriptionText 163 | String eventValue // only String for on/off devices (could be number with others) 164 | bridgeMap.each { 165 | switch (it.key) { 166 | case "on": 167 | eventName = "switch" 168 | eventValue = it.value ? "on" : "off" 169 | eventUnit = null 170 | if (device.currentValue(eventName) != eventValue) doSendEvent(eventName, eventValue, eventUnit) 171 | break 172 | case "reachable": 173 | eventName = "reachable" 174 | eventValue = it.value ? "true" : "false" 175 | eventUnit = null 176 | if (device.currentValue(eventName) != eventValue) { 177 | doSendEvent(eventName, eventValue, eventUnit) 178 | } 179 | break 180 | case "transitiontime": 181 | case "mode": 182 | case "alert": 183 | break 184 | default: 185 | break 186 | //log.warn "Unhandled key/value discarded: $it" 187 | } 188 | } 189 | } 190 | 191 | /** 192 | * (for V2 API) 193 | * Iterates over Hue light state states in Hue API v2 format (e.g., "on={on=true}") and does 194 | * a sendEvent for each relevant attribute; intended to be called when EventSocket data 195 | * received for device (as an alternative to polling) 196 | */ 197 | void createEventsFromMapV2(Map data) { 198 | if (logEnable == true) log.debug "createEventsFromMapV2($data)" 199 | String eventName, eventUnit, descriptionText 200 | def eventValue // could be String or number 201 | data.each { String key, value -> 202 | switch (key) { 203 | case "on": 204 | eventName = "switch" 205 | eventValue = value.on ? "on" : "off" 206 | eventUnit = null 207 | if (device.currentValue(eventName) != eventValue) doSendEvent(eventName, eventValue, eventUnit) 208 | break 209 | case "id_v1": 210 | if (state.id_v1 != value) state.id_v1 = value 211 | break 212 | default: 213 | if (logEnable == true) "not handling: $key: $value" 214 | } 215 | } 216 | } 217 | 218 | /** 219 | * Sends HTTP PUT to Bridge using the either command map provided 220 | * @param commandMap Groovy Map (will be converted to JSON) of Hue API commands to send, e.g., [on: true] 221 | * @param createHubEvents Will iterate over Bridge command map and do sendEvent for all 222 | * affected device attributes (e.g., will send an "on" event for "switch" if ["on": true] in map) 223 | */ 224 | void sendBridgeCommandV1(Map commandMap, Boolean createHubEvents=true) { 225 | if (logEnable == true) log.debug "sendBridgeCommandV1($commandMap)" 226 | if (commandMap == null || commandMap == [:]) { 227 | if (logEnable == true) log.debug "Commands not sent to Bridge because command map null or empty" 228 | return 229 | } 230 | Map data = parent.getBridgeData() 231 | Map params = [ 232 | uri: data.fullHost, 233 | path: "/api/${data.username}/lights/${getHueDeviceIdV1()}/state", 234 | contentType: 'application/json', 235 | body: commandMap, 236 | ignoreSSLIssues: true, 237 | timeout: 15 238 | ] 239 | asynchttpPut("parseSendCommandResponseV1", params, createHubEvents ? commandMap : null) 240 | if (logEnable == true) log.debug "-- Command sent to Bridge! --" 241 | } 242 | 243 | /** 244 | * Parses response from Bridge (or not) after sendBridgeCommandV1. Updates device state if 245 | * appears to have been successful. 246 | * @param resp Async HTTP response object 247 | * @param data Map of commands sent to Bridge if specified to create events from map 248 | */ 249 | void parseSendCommandResponseV1(AsyncResponse resp, Map data) { 250 | if (logEnable == true) log.debug "Response from Bridge: ${resp.status}" 251 | if (checkIfValidResponse(resp) && data) { 252 | if (logEnable == true) log.debug " Bridge response valid; creating events from data map" 253 | createEventsFromMapV1(data) 254 | if ((data.containsKey("on") || data.containsKey("bri")) && settings["updateGroups"]) { 255 | parent.updateGroupStatesFromBulb(data, getHueDeviceIdV1()) 256 | } 257 | } 258 | else { 259 | if (logEnable == true) log.debug " Not creating events from map because not specified to do or Bridge response invalid" 260 | } 261 | } 262 | 263 | 264 | /** 265 | * Parses response from Bridge (or not) after sendBridgeCommandV2. Can optionally use V1-inspired 266 | * logic to update device states if `data` map provided. 267 | * @param resp Async HTTP response object 268 | * @param data Map of commands sent to Bridge if specified to create events from map 269 | */ 270 | void parseSendCommandResponseV2(AsyncResponse resp, Map data) { 271 | if (logEnable == true) log.debug "parseSendCommandResponseV2(): Response status from Bridge: ${resp.status}" 272 | if (checkIfValidResponse(resp) && data) { 273 | if (logEnable == true) log.debug " Bridge response valid; creating events from data map" 274 | createEventsFromMapV2(data) 275 | if ((data.containsKey("on") || data.containsKey("dimming")) && settings["updateGroups"]) { 276 | parent.updateGroupStatesFromBulb(data, getHueDeviceIdV2()) 277 | } 278 | } 279 | else { 280 | if (logEnable == true) log.debug " Not creating events from map because not specified to do or Bridge response invalid" 281 | } 282 | } 283 | 284 | /** 285 | * Sends HTTP PUT to Bridge using the V1-format map data provided 286 | * @param commandMap Groovy Map (will be converted to JSON) of Hue V1 API commands to send, e.g., [on: true] 287 | * @param createHubEvents Will iterate over Bridge command map and do sendEvent for all 288 | * affected device attributes (e.g., will send an "on" event for "switch" if ["on": true] in map) 289 | */ 290 | void sendBridgeCommandV2(Map commandMap, Boolean createHubEvents=false) { 291 | if (logEnable == true) log.debug "sendBridgeCommandV2($commandMap)" 292 | if (commandMap == null || commandMap == [:]) { 293 | if (logEnable == true) log.debug "Commands not sent to Bridge because command map null or empty" 294 | return 295 | } 296 | parent.bridgeAsyncPutV2("parseSendCommandResponseV2", this.device, "/resource/light/${getHueDeviceIdV2()}", 297 | commandMap, createHubEvents ? commandMap : null) 298 | if (logEnable == true) log.debug "-- Command sent to Bridge! --" 299 | } 300 | 301 | // ~~~ IMPORTED FROM RMoRobert.CoCoHue_Common_Lib ~~~ 302 | // Version 1.0.6 303 | // For use with CoCoHue drivers (not app) 304 | 305 | /** 306 | * 1.0.6 - Remove common bridgeAsyncPutV2() method (now call from parent app instead of driver) 307 | * 1.0.5 - Add common bridgeAsyncPutV2() method for asyncHttpPut (goal to reduce individual driver code) 308 | * 1.0.4 - Add common bridgeAsyncGetV2() method asyncHttpGet (goal to reduce individual driver code) 309 | * 1.0.3 - Add APIV1 and APIV2 "constants" 310 | * 1.0.2 - HTTP error handling tweaks 311 | */ 312 | 313 | void debugOff() { 314 | log.warn "Disabling debug logging" 315 | device.updateSetting("logEnable", [value:"false", type:"bool"]) 316 | } 317 | 318 | /** Performs basic check on data returned from HTTP response to determine if should be 319 | * parsed as likely Hue Bridge data or not; returns true (if OK) or logs errors/warnings and 320 | * returns false if not 321 | * @param resp The async HTTP response object to examine 322 | */ 323 | private Boolean checkIfValidResponse(hubitat.scheduling.AsyncResponse resp) { 324 | if (logEnable == true) log.debug "Checking if valid HTTP response/data from Bridge..." 325 | Boolean isOK = true 326 | if (resp.status < 400) { 327 | if (resp.json == null) { 328 | isOK = false 329 | if (resp.headers == null) log.error "Error: HTTP ${resp.status} when attempting to communicate with Bridge" 330 | else log.error "No JSON data found in response. ${resp.headers.'Content-Type'} (HTTP ${resp.status})" 331 | parent.sendBridgeDiscoveryCommandIfSSDPEnabled(true) // maybe IP changed, so attempt rediscovery 332 | parent.setBridgeOnlineStatus(false) 333 | } 334 | else if (resp.json) { 335 | if ((resp.json instanceof List) && resp.json.getAt(0).error) { 336 | // Bridge (not HTTP) error (bad username, bad command formatting, etc.): 337 | isOK = false 338 | log.warn "Error from Hue Bridge: ${resp.json[0].error}" 339 | // Not setting Bridge to offline when light/scene/group devices end up here because could 340 | // be old/bad ID and don't want to consider Bridge offline just for that (but also won't set 341 | // to online because wasn't successful attempt) 342 | } 343 | // Otherwise: probably OK (not changing anything because isOK = true already) 344 | } 345 | else { 346 | isOK = false 347 | log.warn("HTTP status code ${resp.status} from Bridge") 348 | // TODO: Update for mDNS if/when switch: 349 | if (resp?.status >= 400) parent.sendBridgeDiscoveryCommandIfSSDPEnabled(true) // maybe IP changed, so attempt rediscovery 350 | parent.setBridgeOnlineStatus(false) 351 | } 352 | if (isOK == true) parent.setBridgeOnlineStatus(true) 353 | } 354 | else { 355 | log.warn "Error communicating with Hue Bridge: HTTP ${resp?.status}" 356 | isOK = false 357 | } 358 | return isOK 359 | } 360 | 361 | void doSendEvent(String eventName, eventValue, String eventUnit=null, Boolean forceStateChange=false) { 362 | //if (logEnable == true) log.debug "doSendEvent($eventName, $eventValue, $eventUnit)" 363 | String descriptionText = "${device.displayName} ${eventName} is ${eventValue}${eventUnit ?: ''}" 364 | if (settings.txtEnable == true) log.info(descriptionText) 365 | if (eventUnit) { 366 | if (forceStateChange == true) sendEvent(name: eventName, value: eventValue, descriptionText: descriptionText, unit: eventUnit, isStateChange: true) 367 | else sendEvent(name: eventName, value: eventValue, descriptionText: descriptionText, unit: eventUnit) 368 | } else { 369 | if (forceStateChange == true) sendEvent(name: eventName, value: eventValue, descriptionText: descriptionText, isStateChange: true) 370 | else sendEvent(name: eventName, value: eventValue, descriptionText: descriptionText) 371 | } 372 | } 373 | 374 | // HTTP methods (might be better to split into separate library if not needed for some?) 375 | 376 | /** Performs asynchttpGet() to Bridge using data retrieved from parent app or as passed in 377 | * @param callbackMethod Callback method 378 | * @param clipV2Path The Hue V2 API path ('/clip/v2' is automatically prepended), e.g. '/resource' or '/resource/light' 379 | * @param bridgeData Bridge data from parent getBridgeData() call, or will call this method on parent if null 380 | * @param data Extra data to pass as optional third (data) parameter to asynchtttpGet() method 381 | */ 382 | void bridgeAsyncGetV2(String callbackMethod, String clipV2Path, Map bridgeData = null, Map data = null) { 383 | if (bridgeData == null) { 384 | bridgeData = parent.getBridgeData() 385 | } 386 | Map params = [ 387 | uri: "https://${bridgeData.ip}", 388 | path: "/clip/v2${clipV2Path}", 389 | headers: ["hue-application-key": bridgeData.username], 390 | contentType: "application/json", 391 | timeout: 15, 392 | ignoreSSLIssues: true 393 | ] 394 | asynchttpGet(callbackMethod, params, data) 395 | } 396 | 397 | // REMOVED, now call from parent app instead of driver: 398 | // /** Performs asynchttpPut() to Bridge using data retrieved from parent app or as passed in 399 | // * @param callbackMethod Callback method 400 | // * @param clipV2Path The Hue V2 API path ('/clip/v2' is automatically prepended), e.g. '/resource' or '/resource/light' 401 | // * @param body Body data, a Groovy Map representing JSON for the Hue V2 API command, e.g., [on: [on: true]] 402 | // * @param bridgeData Bridge data from parent getBridgeData() call, or will call this method on parent if null 403 | // * @param data Extra data to pass as optional third (data) parameter to asynchtttpPut() method 404 | // */ 405 | // void bridgeAsyncPutV2(String callbackMethod, String clipV2Path, Map body, Map bridgeData = null, Map data = null) { 406 | // if (bridgeData == null) { 407 | // bridgeData = parent.getBridgeData() 408 | // } 409 | // Map params = [ 410 | // uri: "https://${bridgeData.ip}", 411 | // path: "/clip/v2${clipV2Path}", 412 | // headers: ["hue-application-key": bridgeData.username], 413 | // contentType: "application/json", 414 | // body: body, 415 | // timeout: 15, 416 | // ignoreSSLIssues: true 417 | // ] 418 | // asynchttpPut(callbackMethod, params, data) 419 | // if (logEnable == true) log.debug "Command sent to Bridge: $body at ${clipV2Path}" 420 | // pauseExecution(200) // see if helps HTTP 429 errors? 421 | // } 422 | 423 | 424 | // ~~~ IMPORTED FROM RMoRobert.CoCoHue_Constants_Lib ~~~ 425 | // Version 1.0.0 426 | 427 | // -------------------------------------- 428 | // APP AND DRIVER NAMESPACE AND NAMES: 429 | // -------------------------------------- 430 | @Field static final String NAMESPACE = "RMoRobert" 431 | @Field static final String DRIVER_NAME_BRIDGE = "CoCoHue Bridge" 432 | @Field static final String DRIVER_NAME_BUTTON = "CoCoHue Button" 433 | @Field static final String DRIVER_NAME_CT_BULB = "CoCoHue CT Bulb" 434 | @Field static final String DRIVER_NAME_DIMMABLE_BULB = "CoCoHue Dimmable Bulb" 435 | @Field static final String DRIVER_NAME_GROUP = "CoCoHue Group" 436 | @Field static final String DRIVER_NAME_MOTION = "CoCoHue Motion Sensor" 437 | @Field static final String DRIVER_NAME_CONTACT = "CoCoHue Contact Sensor" 438 | @Field static final String DRIVER_NAME_PLUG = "CoCoHue Plug" 439 | @Field static final String DRIVER_NAME_RGBW_BULB = "CoCoHue RGBW Bulb" 440 | @Field static final String DRIVER_NAME_RGB_BULB = "CoCoHue RGB Bulb" 441 | @Field static final String DRIVER_NAME_SCENE = "CoCoHue Scene" 442 | 443 | // -------------------------------------- 444 | // DNI PREFIX for child devices: 445 | // -------------------------------------- 446 | @Field static final String DNI_PREFIX = "CCH" 447 | 448 | // -------------------------------------- 449 | // OTHER: 450 | // -------------------------------------- 451 | // Used in app and Bridge driver, may eventually find use in more: 452 | @Field static final String APIV1 = "V1" 453 | @Field static final String APIV2 = "V2" 454 | 455 | // ~~~ IMPORTED FROM RMoRobert.CoCoHue_V2_DNI_Tools_Lib ~~~ 456 | // Version 1.0.0 457 | 458 | 459 | /** 460 | * Parses V2 Hue Bridge device ID out of Hubitat DNI for use with Hue V2 API calls 461 | * Hubitat DNI is created in format "CCH/BridgeMACAbbrev/Scene/HueDeviceID", so just 462 | * looks for string after last "/" character 463 | */ 464 | String getHueDeviceIdV2() { 465 | if (getHasV2DNI() == true) { 466 | return device.deviceNetworkId.split("/").last() 467 | } 468 | else { 469 | log.error "DNI not in V2 format but attempeting to fetch API V2 ID. Cannot continue." 470 | } 471 | } 472 | 473 | Boolean getHasV2DNI() { 474 | String id = device.deviceNetworkId.split("/").last() 475 | if (id.length() > 32) { // max length of Hue V1 ID per regex in V2 API docs 476 | return true 477 | } 478 | else { 479 | return false 480 | } 481 | } -------------------------------------------------------------------------------- /drivers/cocohue-dimmable-bulb-driver.groovy: -------------------------------------------------------------------------------- 1 | /* 2 | * ============================= CoCoHue Dimmable Bulb (Driver) =============================== 3 | * 4 | * Copyright 2019-2025 Robert Morris 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 7 | * in compliance with the License. You may obtain a copy of the License at: 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 12 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 13 | * for the specific language governing permissions and limitations under the License. 14 | * 15 | * ======================================================================================= 16 | * 17 | * Last modified: 2025-09-07 18 | * 19 | * Changelog: 20 | * v5.3.4 - Changes to accommodate HTTPS by default 21 | * v5.3.1 - Implement async HTTP call queueing from child drivers through parent app 22 | * v5.3.0 - Use V2 for most commands 23 | * v5.2.8 - Add reachable attribute to V2 API parsing 24 | * v5.2.2 - Populate initial states from V2 cache if available 25 | * v5.0.1 - Fix for missing V1 IDs after device creation or upgrade 26 | * v5.0 - Use API v2 by default, remove deprecated features 27 | * v4.2 - Library updates, prep for more v2 API 28 | * v4.1.7 - Fix for unexpected Hubitat event creation when v2 API reports level of 0 29 | * v4.1.5 - Improved v2 brightness parsing 30 | * v4.0.2 - Fix to avoid unepected "off" transition time 31 | * v4.0 - Add SSE support for push 32 | * v3.5.1 - Refactor some code into libraries (code still precompiled before upload; should not have any visible changes) 33 | * v3.5 - Add LevelPreset capability (replaces old level prestaging option); added "reachable" attribte 34 | from Bridge to bulb and group drivers (thanks to @jtp10181 for original implementation) 35 | * v3.1.3 - Adjust setLevel(0) to honor rate 36 | * v3.1 - Improved error handling and debug logging 37 | * v3.0 - Fix so events no created until Bridge response received (as was done for other drivers in 2.0); improved HTTP error handling 38 | * v2.1.1 - Improved rounding for level (brightness) to/from Bridge 39 | * v2.1 - Minor code cleanup and more static typing 40 | * v2.0 - Added startLevelChange rate option; improved HTTP error handling; attribute events now generated 41 | * only after hearing back from Bridge; Bridge online/offline status improvements 42 | * v1.9 - Initial release (based on CT bulb driver) 43 | */ 44 | 45 | 46 | import groovy.transform.Field 47 | import hubitat.scheduling.AsyncResponse 48 | 49 | 50 | @Field static final Integer debugAutoDisableMinutes = 30 51 | 52 | // Default preference values 53 | @Field static final BigDecimal defaultLevelTransitionTime = 400 54 | 55 | // Default list of command Map keys to ignore if SSE enabled and command is sent from hub (not polled from Bridge), used to 56 | // ignore duplicates that are expected to be processed from SSE momentarily: 57 | // (for dim-only devices, should cover everything...) 58 | @Field static final List listKeysToIgnoreIfSSEEnabledAndNotFromBridge = ["on", "bri"] 59 | 60 | metadata { 61 | definition(name: "CoCoHue Dimmable Bulb", namespace: "RMoRobert", author: "Robert Morris", importUrl: "https://raw.githubusercontent.com/HubitatCommunity/CoCoHue/master/drivers/cocohue-dimmable-bulb-driver.groovy") { 62 | capability "Actuator" 63 | capability "Refresh" 64 | capability "Switch" 65 | capability "SwitchLevel" 66 | capability "ChangeLevel" 67 | capability "Light" 68 | 69 | command "flash" 70 | command "flashOnce" 71 | command "flashOff" 72 | 73 | attribute "reachable", "string" 74 | } 75 | 76 | preferences { 77 | input name: "transitionTime", type: "enum", description: "", title: "Transition time", options: 78 | [[0:"ASAP"],[400:"400ms"],[500:"500ms"],[1000:"1s"],[1500:"1.5s"],[2000:"2s"],[5000:"5s"]], defaultValue: 400 79 | input name: "levelChangeRate", type: "enum", description: "", title: '"Start level change" rate', options: 80 | [["slow":"Slow"],["medium":"Medium"],["fast":"Fast (default)"]], defaultValue: "fast" 81 | input name: "updateGroups", type: "bool", description: "", title: "Update state of groups immediately when bulb state changes (applicable only if not using V2 API/eventstream)", 82 | defaultValue: false 83 | input name: "logEnable", type: "bool", title: "Enable debug logging", defaultValue: true 84 | input name: "txtEnable", type: "bool", title: "Enable descriptionText logging", defaultValue: true 85 | } 86 | } 87 | 88 | void installed() { 89 | log.debug "installed()" 90 | if (device.currentValue("switch") == null) { 91 | // Populate initial device data (if V2 available; V1 users would need manual refresh) 92 | List bridgeCacheData = parent.getBridgeCacheV2()?.data ?: [] 93 | Map devCache = bridgeCacheData.find { it.type == "light" && it.id == device.deviceNetworkId.split("/").last() } 94 | if (devCache == null) devCache == bridgeCacheData.find { it.type == "light" && it.id_v1 == device.deviceNetworkId.split("/").last() } 95 | if (devCache != null) { 96 | log.warn devCache.id 97 | createEventsFromMapV2(devCache) 98 | } 99 | } 100 | initialize() 101 | } 102 | 103 | void updated() { 104 | log.debug "updated()" 105 | initialize() 106 | } 107 | 108 | void initialize() { 109 | log.debug "initialize()" 110 | if (logEnable) { 111 | log.debug "Debug logging will be automatically disabled in ${debugAutoDisableMinutes} minutes" 112 | runIn(debugAutoDisableMinutes*60, "debugOff") 113 | } 114 | } 115 | 116 | // Probably won't happen but... 117 | void parse(String description) { 118 | log.warn("Running unimplemented parse for: '${description}'") 119 | } 120 | 121 | /** 122 | * Parses V1 Hue Bridge device ID number out of Hubitat DNI for use with Hue V1 API calls 123 | * Hubitat DNI is created in format "CCH/BridgeMACAbbrev/Light/HueDeviceID", so just 124 | * looks for number after last "/" character; or try state if DNI is V2 format (avoid if posssible, 125 | * as Hue is likely to deprecate V1 ID data in future) 126 | */ 127 | String getHueDeviceIdV1() { 128 | String id = device.deviceNetworkId.split("/").last() 129 | 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 130 | id = state.id_v1?.split("/")?.last() 131 | if (state.id_v1 == null) { 132 | log.warn "Attempting to retrieve V1 ID but not in DNI or state." 133 | } 134 | } 135 | return id 136 | } 137 | 138 | void on(Number transitionTime = null) { 139 | if (logEnable == true) log.debug "on()" 140 | if (getHasV2DNI() == false) { 141 | onV1(transitionTime) 142 | return 143 | } 144 | Map bridgeCmd 145 | Integer scaledRate = transitionTime != null ? Math.round(transitionTime * 1000).toInteger() : getScaledOnTransitionTime() 146 | if (scaledRate == null) { 147 | bridgeCmd = ["on": ["on": true]] 148 | } 149 | else { 150 | bridgeCmd = ["on": ["on": true], "dynamics": ["duration": scaledRate]] 151 | } 152 | sendBridgeCommandV2(bridgeCmd) 153 | } 154 | 155 | void onV1(Number transitionTime = null) { 156 | if (logEnable == true) log.debug "onV1()" 157 | Map bridgeCmd 158 | Integer scaledRate = transitionTime != null ? Math.round(transitionTime * 10).toInteger() : getScaledOnTransitionTime() 159 | if (scaledRate == null) { 160 | bridgeCmd = ["on": true] 161 | } 162 | else { 163 | bridgeCmd = ["on": true, "transitiontime": scaledRate] 164 | } 165 | sendBridgeCommandV1(bridgeCmd) 166 | } 167 | 168 | void off(Number transitionTime = null) { 169 | if (logEnable == true) log.debug "off()" 170 | if (getHasV2DNI() == false) { 171 | offV1(transitionTime) 172 | return 173 | } 174 | Map bridgeCmd 175 | Integer scaledRate = transitionTime != null ? Math.round(transitionTime * 1000).toInteger() : getScaledOnTransitionTime() 176 | if (scaledRate == null) { 177 | bridgeCmd = ["on": ["on": false]] 178 | } 179 | else { 180 | bridgeCmd = ["on": ["on": false], "dynamics": ["duration": scaledRate]] 181 | } 182 | sendBridgeCommandV2(bridgeCmd) 183 | } 184 | 185 | void offV1(Number transitionTime = null) { 186 | if (logEnable == true) log.debug "offV1()" 187 | Map bridgeCmd 188 | Integer scaledRate = transitionTime != null ? Math.round(transitionTime * 10).toInteger() : null 189 | if (scaledRate == null) { 190 | bridgeCmd = ["on": false] 191 | } 192 | else { 193 | bridgeCmd = ["on": false, "transitiontime": scaledRate] 194 | } 195 | sendBridgeCommandV1(bridgeCmd) 196 | } 197 | 198 | void refresh() { 199 | log.warn "Refresh Hue Bridge device instead of individual device to update (all) bulbs/groups" 200 | } 201 | 202 | /** 203 | * Iterates over Hue light state commands/states in Hue format (e.g., ["on": true]) and does 204 | * a sendEvent for each relevant attribute; intended to be called either when commands are sent 205 | * to Bridge or to parse/update light states based on data received from Bridge 206 | * @param bridgeMap Map of light states that are or would be sent to bridge OR state as received from 207 | * Bridge 208 | * @param isFromBridge Set to true if this is data read from Hue Bridge rather than intended to be sent 209 | * to Bridge; TODO: see if still needed now that pseudo-prestaging removed? 210 | */ 211 | void createEventsFromMapV1(Map bridgeCommandMap, Boolean isFromBridge = false, Set keysToIgnoreIfSSEEnabledAndNotFromBridge=listKeysToIgnoreIfSSEEnabledAndNotFromBridge) { 212 | if (!bridgeCommandMap) { 213 | if (logEnable == true) log.debug "createEventsFromMapV1 called but map command empty or null; exiting" 214 | return 215 | } 216 | Map bridgeMap = bridgeCommandMap 217 | if (logEnable == true) log.debug "createEventsFromMapV1(): Preparing to create events from map${isFromBridge ? ' from Bridge' : ''}: ${bridgeMap}" 218 | if (!isFromBridge && keysToIgnoreIfSSEEnabledAndNotFromBridge && parent.getEventStreamOpenStatus() == true) { 219 | bridgeMap.keySet().removeAll(keysToIgnoreIfSSEEnabledAndNotFromBridge) 220 | if (logEnable == true) log.debug "Map after ignored keys removed: ${bridgeMap}" 221 | } 222 | String eventName, eventUnit, descriptionText 223 | def eventValue // could be String or number 224 | Boolean isOn = bridgeMap["on"] 225 | bridgeMap.each { 226 | switch (it.key) { 227 | case "on": 228 | eventName = "switch" 229 | eventValue = it.value ? "on" : "off" 230 | eventUnit = null 231 | if (device.currentValue(eventName) != eventValue) doSendEvent(eventName, eventValue, eventUnit) 232 | break 233 | case "bri": 234 | if (it.value == 0) break // skip invalid value if ever appears... 235 | eventName = "level" 236 | eventValue = scaleBriFromBridge(it.value, APIV1) 237 | eventUnit = "%" 238 | if (device.currentValue(eventName) != eventValue) { 239 | doSendEvent(eventName, eventValue, eventUnit) 240 | } 241 | break 242 | case "reachable": 243 | eventName = "reachable" 244 | eventValue = it.value ? "true" : "false" 245 | eventUnit = null 246 | if (device.currentValue(eventName) != eventValue) { 247 | doSendEvent(eventName, eventValue, eventUnit) 248 | } 249 | break 250 | case "transitiontime": 251 | case "mode": 252 | case "alert": 253 | break 254 | default: 255 | break 256 | //log.warn "Unhandled key/value discarded: $it" 257 | } 258 | } 259 | } 260 | 261 | /** 262 | * (for V2 API) 263 | * Iterates over Hue light state states in Hue API v2 format (e.g., "on={on=true}") and does 264 | * a sendEvent for each relevant attribute; intended to be called when EventSocket data 265 | * received for device (as an alternative to polling) 266 | */ 267 | void createEventsFromMapV2(Map data) { 268 | if (logEnable == true) log.debug "createEventsFromMapV2($data)" 269 | String eventName, eventUnit, descriptionText 270 | def eventValue // could be String or number 271 | data.each { String key, value -> 272 | switch (key) { 273 | case "on": 274 | eventName = "switch" 275 | eventValue = value.on ? "on" : "off" 276 | eventUnit = null 277 | if (device.currentValue(eventName) != eventValue) doSendEvent(eventName, eventValue, eventUnit) 278 | break 279 | case "dimming": 280 | eventName = "level" 281 | eventValue = scaleBriFromBridge(value.brightness, APIV2) 282 | eventUnit = "%" 283 | if (device.currentValue(eventName) != eventValue && eventValue > 0) { 284 | doSendEvent(eventName, eventValue, eventUnit) 285 | } 286 | break 287 | case "status": 288 | if (data.type == "zigbee_connectivity") { // not sure if any other types use this key, but just in case 289 | eventName = "reachable" 290 | if (value == "disconnected" || value == "connectivity_issue") { 291 | eventValue = "true" 292 | } 293 | else { 294 | eventValue = false 295 | } 296 | eventUnit = null 297 | if (device.currentValue(eventName) != eventValue) { 298 | doSendEvent(eventName, eventValue, eventUnit) 299 | } 300 | } 301 | case "id_v1": 302 | if (state.id_v1 != value) state.id_v1 = value 303 | break 304 | default: 305 | if (logEnable == true) "not handling: $key: $value" 306 | } 307 | } 308 | } 309 | 310 | /** 311 | * Sends HTTP PUT to Bridge using the either command map provided 312 | * @param commandMap Groovy Map (will be converted to JSON) of Hue API commands to send, e.g., [on: true] 313 | * @param createHubEvents Will iterate over Bridge command map and do sendEvent for all 314 | * affected device attributes (e.g., will send an "on" event for "switch" if ["on": true] in map) 315 | */ 316 | void sendBridgeCommandV1(Map commandMap, Boolean createHubEvents=true) { 317 | if (logEnable == true) log.debug "sendBridgeCommandV1($commandMap)" 318 | if (commandMap == null || commandMap == [:]) { 319 | if (logEnable == true) log.debug "Commands not sent to Bridge because command map null or empty" 320 | return 321 | } 322 | Map data = parent.getBridgeData() 323 | Map params = [ 324 | uri: data.fullHost, 325 | path: "/api/${data.username}/lights/${getHueDeviceIdV1()}/state", 326 | contentType: 'application/json', 327 | body: commandMap, 328 | ignoreSSLIssues: true, 329 | timeout: 15 330 | ] 331 | asynchttpPut("parseSendCommandResponseV1", params, createHubEvents ? commandMap : null) 332 | if (logEnable == true) log.debug "-- Command sent to Bridge! --" 333 | } 334 | 335 | /** 336 | * Parses response from Bridge (or not) after sendBridgeCommandV1. Updates device state if 337 | * appears to have been successful. 338 | * @param resp Async HTTP response object 339 | * @param data Map of commands sent to Bridge if specified to create events from map 340 | */ 341 | void parseSendCommandResponseV1(AsyncResponse resp, Map data) { 342 | if (logEnable == true) log.debug "Response from Bridge: ${resp.status}" 343 | if (checkIfValidResponse(resp) && data) { 344 | if (logEnable == true) log.debug " Bridge response valid; creating events from data map" 345 | createEventsFromMapV1(data) 346 | if ((data.containsKey("on") || data.containsKey("bri")) && settings["updateGroups"]) { 347 | parent.updateGroupStatesFromBulb(data, getHueDeviceIdV1()) 348 | } 349 | } 350 | else { 351 | if (logEnable == true) log.debug " Not creating events from map because not specified to do or Bridge response invalid" 352 | } 353 | } 354 | 355 | /** 356 | * Parses response from Bridge (or not) after sendBridgeCommandV2. Can optionally use V1-inspired 357 | * logic to update device states if `data` map provided. 358 | * @param resp Async HTTP response object 359 | * @param data Map of commands sent to Bridge if specified to create events from map 360 | */ 361 | void parseSendCommandResponseV2(AsyncResponse resp, Map data) { 362 | if (logEnable == true) log.debug "parseSendCommandResponseV2(): Response status from Bridge: ${resp.status}" 363 | if (checkIfValidResponse(resp) && data) { 364 | if (logEnable == true) log.debug " Bridge response valid; creating events from data map" 365 | createEventsFromMapV2(data) 366 | if ((data.containsKey("on") || data.containsKey("dimming")) && settings["updateGroups"]) { 367 | parent.updateGroupStatesFromBulb(data, getHueDeviceIdV2()) 368 | } 369 | } 370 | else { 371 | if (logEnable == true) log.debug " Not creating events from map because not specified to do or Bridge response invalid" 372 | } 373 | } 374 | 375 | /** 376 | * Sends HTTP PUT to Bridge using the V1-format map data provided 377 | * @param commandMap Groovy Map (will be converted to JSON) of Hue V1 API commands to send, e.g., [on: true] 378 | * @param createHubEvents Will iterate over Bridge command map and do sendEvent for all 379 | * affected device attributes (e.g., will send an "on" event for "switch" if ["on": true] in map) 380 | */ 381 | void sendBridgeCommandV2(Map commandMap, Boolean createHubEvents=false) { 382 | if (logEnable == true) log.debug "sendBridgeCommandV2($commandMap)" 383 | if (commandMap == null || commandMap == [:]) { 384 | if (logEnable == true) log.debug "Commands not sent to Bridge because command map null or empty" 385 | return 386 | } 387 | parent.bridgeAsyncPutV2("parseSendCommandResponseV2", this.device, "/resource/light/${getHueDeviceIdV2()}", 388 | commandMap, createHubEvents ? commandMap : null) 389 | if (logEnable == true) log.debug "-- Command sent to Bridge! --" 390 | } 391 | 392 | // ~~~ IMPORTED FROM RMoRobert.CoCoHue_Common_Lib ~~~ 393 | // Version 1.0.6 394 | // For use with CoCoHue drivers (not app) 395 | 396 | /** 397 | * 1.0.6 - Remove common bridgeAsyncPutV2() method (now call from parent app instead of driver) 398 | * 1.0.5 - Add common bridgeAsyncPutV2() method for asyncHttpPut (goal to reduce individual driver code) 399 | * 1.0.4 - Add common bridgeAsyncGetV2() method asyncHttpGet (goal to reduce individual driver code) 400 | * 1.0.3 - Add APIV1 and APIV2 "constants" 401 | * 1.0.2 - HTTP error handling tweaks 402 | */ 403 | 404 | void debugOff() { 405 | log.warn "Disabling debug logging" 406 | device.updateSetting("logEnable", [value:"false", type:"bool"]) 407 | } 408 | 409 | /** Performs basic check on data returned from HTTP response to determine if should be 410 | * parsed as likely Hue Bridge data or not; returns true (if OK) or logs errors/warnings and 411 | * returns false if not 412 | * @param resp The async HTTP response object to examine 413 | */ 414 | private Boolean checkIfValidResponse(hubitat.scheduling.AsyncResponse resp) { 415 | if (logEnable == true) log.debug "Checking if valid HTTP response/data from Bridge..." 416 | Boolean isOK = true 417 | if (resp.status < 400) { 418 | if (resp.json == null) { 419 | isOK = false 420 | if (resp.headers == null) log.error "Error: HTTP ${resp.status} when attempting to communicate with Bridge" 421 | else log.error "No JSON data found in response. ${resp.headers.'Content-Type'} (HTTP ${resp.status})" 422 | parent.sendBridgeDiscoveryCommandIfSSDPEnabled(true) // maybe IP changed, so attempt rediscovery 423 | parent.setBridgeOnlineStatus(false) 424 | } 425 | else if (resp.json) { 426 | if ((resp.json instanceof List) && resp.json.getAt(0).error) { 427 | // Bridge (not HTTP) error (bad username, bad command formatting, etc.): 428 | isOK = false 429 | log.warn "Error from Hue Bridge: ${resp.json[0].error}" 430 | // Not setting Bridge to offline when light/scene/group devices end up here because could 431 | // be old/bad ID and don't want to consider Bridge offline just for that (but also won't set 432 | // to online because wasn't successful attempt) 433 | } 434 | // Otherwise: probably OK (not changing anything because isOK = true already) 435 | } 436 | else { 437 | isOK = false 438 | log.warn("HTTP status code ${resp.status} from Bridge") 439 | // TODO: Update for mDNS if/when switch: 440 | if (resp?.status >= 400) parent.sendBridgeDiscoveryCommandIfSSDPEnabled(true) // maybe IP changed, so attempt rediscovery 441 | parent.setBridgeOnlineStatus(false) 442 | } 443 | if (isOK == true) parent.setBridgeOnlineStatus(true) 444 | } 445 | else { 446 | log.warn "Error communicating with Hue Bridge: HTTP ${resp?.status}" 447 | isOK = false 448 | } 449 | return isOK 450 | } 451 | 452 | void doSendEvent(String eventName, eventValue, String eventUnit=null, Boolean forceStateChange=false) { 453 | //if (logEnable == true) log.debug "doSendEvent($eventName, $eventValue, $eventUnit)" 454 | String descriptionText = "${device.displayName} ${eventName} is ${eventValue}${eventUnit ?: ''}" 455 | if (settings.txtEnable == true) log.info(descriptionText) 456 | if (eventUnit) { 457 | if (forceStateChange == true) sendEvent(name: eventName, value: eventValue, descriptionText: descriptionText, unit: eventUnit, isStateChange: true) 458 | else sendEvent(name: eventName, value: eventValue, descriptionText: descriptionText, unit: eventUnit) 459 | } else { 460 | if (forceStateChange == true) sendEvent(name: eventName, value: eventValue, descriptionText: descriptionText, isStateChange: true) 461 | else sendEvent(name: eventName, value: eventValue, descriptionText: descriptionText) 462 | } 463 | } 464 | 465 | // HTTP methods (might be better to split into separate library if not needed for some?) 466 | 467 | /** Performs asynchttpGet() to Bridge using data retrieved from parent app or as passed in 468 | * @param callbackMethod Callback method 469 | * @param clipV2Path The Hue V2 API path ('/clip/v2' is automatically prepended), e.g. '/resource' or '/resource/light' 470 | * @param bridgeData Bridge data from parent getBridgeData() call, or will call this method on parent if null 471 | * @param data Extra data to pass as optional third (data) parameter to asynchtttpGet() method 472 | */ 473 | void bridgeAsyncGetV2(String callbackMethod, String clipV2Path, Map bridgeData = null, Map data = null) { 474 | if (bridgeData == null) { 475 | bridgeData = parent.getBridgeData() 476 | } 477 | Map params = [ 478 | uri: "https://${bridgeData.ip}", 479 | path: "/clip/v2${clipV2Path}", 480 | headers: ["hue-application-key": bridgeData.username], 481 | contentType: "application/json", 482 | timeout: 15, 483 | ignoreSSLIssues: true 484 | ] 485 | asynchttpGet(callbackMethod, params, data) 486 | } 487 | 488 | // REMOVED, now call from parent app instead of driver: 489 | // /** Performs asynchttpPut() to Bridge using data retrieved from parent app or as passed in 490 | // * @param callbackMethod Callback method 491 | // * @param clipV2Path The Hue V2 API path ('/clip/v2' is automatically prepended), e.g. '/resource' or '/resource/light' 492 | // * @param body Body data, a Groovy Map representing JSON for the Hue V2 API command, e.g., [on: [on: true]] 493 | // * @param bridgeData Bridge data from parent getBridgeData() call, or will call this method on parent if null 494 | // * @param data Extra data to pass as optional third (data) parameter to asynchtttpPut() method 495 | // */ 496 | // void bridgeAsyncPutV2(String callbackMethod, String clipV2Path, Map body, Map bridgeData = null, Map data = null) { 497 | // if (bridgeData == null) { 498 | // bridgeData = parent.getBridgeData() 499 | // } 500 | // Map params = [ 501 | // uri: "https://${bridgeData.ip}", 502 | // path: "/clip/v2${clipV2Path}", 503 | // headers: ["hue-application-key": bridgeData.username], 504 | // contentType: "application/json", 505 | // body: body, 506 | // timeout: 15, 507 | // ignoreSSLIssues: true 508 | // ] 509 | // asynchttpPut(callbackMethod, params, data) 510 | // if (logEnable == true) log.debug "Command sent to Bridge: $body at ${clipV2Path}" 511 | // pauseExecution(200) // see if helps HTTP 429 errors? 512 | // } 513 | 514 | 515 | // ~~~ IMPORTED FROM RMoRobert.CoCoHue_Constants_Lib ~~~ 516 | // Version 1.0.0 517 | 518 | // -------------------------------------- 519 | // APP AND DRIVER NAMESPACE AND NAMES: 520 | // -------------------------------------- 521 | @Field static final String NAMESPACE = "RMoRobert" 522 | @Field static final String DRIVER_NAME_BRIDGE = "CoCoHue Bridge" 523 | @Field static final String DRIVER_NAME_BUTTON = "CoCoHue Button" 524 | @Field static final String DRIVER_NAME_CT_BULB = "CoCoHue CT Bulb" 525 | @Field static final String DRIVER_NAME_DIMMABLE_BULB = "CoCoHue Dimmable Bulb" 526 | @Field static final String DRIVER_NAME_GROUP = "CoCoHue Group" 527 | @Field static final String DRIVER_NAME_MOTION = "CoCoHue Motion Sensor" 528 | @Field static final String DRIVER_NAME_CONTACT = "CoCoHue Contact Sensor" 529 | @Field static final String DRIVER_NAME_PLUG = "CoCoHue Plug" 530 | @Field static final String DRIVER_NAME_RGBW_BULB = "CoCoHue RGBW Bulb" 531 | @Field static final String DRIVER_NAME_RGB_BULB = "CoCoHue RGB Bulb" 532 | @Field static final String DRIVER_NAME_SCENE = "CoCoHue Scene" 533 | 534 | // -------------------------------------- 535 | // DNI PREFIX for child devices: 536 | // -------------------------------------- 537 | @Field static final String DNI_PREFIX = "CCH" 538 | 539 | // -------------------------------------- 540 | // OTHER: 541 | // -------------------------------------- 542 | // Used in app and Bridge driver, may eventually find use in more: 543 | @Field static final String APIV1 = "V1" 544 | @Field static final String APIV2 = "V2" 545 | 546 | // ~~~ IMPORTED FROM RMoRobert.CoCoHue_Bri_Lib ~~~ 547 | // Version 1.0.5 548 | 549 | // 1.0.5 - allow V2 for all commands 550 | // 1.0.4 - accept String for setLevel() level also 551 | // 1.0.3 - levelhandling tweaks 552 | 553 | // "SwitchLevel" commands: 554 | 555 | void startLevelChange(String direction) { 556 | if (logEnable == true) log.debug "startLevelChange($direction)..." 557 | if (getHasV2DNI() == true) { 558 | Map cmd = [ 559 | "dimming_delta": ["brightness_delta": 100, "action": (direction == "up" ? "up" : "down")], 560 | "dynamics": ["duration": ((settings["levelChangeRate"] == "fast" || !settings["levelChangeRate"]) ? 561 | 3000 : (settings["levelChangeRate"] == "slow" ? 6000 : 4500))]] 562 | sendBridgeCommandV2(cmd, false) 563 | } 564 | else { 565 | Map cmd = ["bri": (direction == "up" ? 254 : 1), 566 | "transitiontime": ((settings["levelChangeRate"] == "fast" || !settings["levelChangeRate"]) ? 567 | 30 : (settings["levelChangeRate"] == "slow" ? 60 : 45))] 568 | sendBridgeCommandV1(cmd, false) 569 | } 570 | } 571 | 572 | void stopLevelChange() { 573 | if (logEnable == true) log.debug "stopLevelChange()..." 574 | if (getHasV2DNI() == true) { 575 | Map cmd = ["dimming_delta": ["action": "stop"]] 576 | sendBridgeCommandV2(cmd, false) 577 | } 578 | else { 579 | Map cmd = ["bri_inc": 0] 580 | sendBridgeCommandV1(cmd, false) 581 | } 582 | } 583 | 584 | void setLevel(value) { 585 | if (logEnable == true) log.debug "setLevel($value)" 586 | setLevel(value, ((transitionTime != null ? transitionTime.toFloat() : defaultLevelTransitionTime.toFloat())) / 1000) 587 | } 588 | 589 | void setLevel(value, rate) { 590 | if (logEnable == true) log.debug "setLevel(Object $value, Object $rate)" 591 | Float floatLevel = Float.parseFloat(value.toString()) 592 | Integer intLevel = Math.round(floatLevel) 593 | Float floatRate = Float.parseFloat(rate.toString()) 594 | setLevel(intLevel, floatRate) 595 | } 596 | 597 | void setLevel(Number value, Number rate) { 598 | if (logEnable == true) log.debug "setLevel(Number $value, Number $rate)" 599 | if (getHasV2DNI() == false) { 600 | setLevelV1(value, rate) 601 | return 602 | } 603 | if (value < 0) value = 0.01 604 | else if (value > 100) value = 100 605 | else if (value == 0) { 606 | off(rate) 607 | return 608 | } 609 | Integer newLevel = scaleBriToBridge(value, APIV2) 610 | Integer scaledRate = (rate * 1000).toInteger() 611 | Map bridgeCmd = [ 612 | "on": ["on": true], 613 | "dimming": ["brightness": scaleBriToBridge(value, APIV2)], 614 | "dynamics": ["duration": scaledRate] 615 | ] 616 | sendBridgeCommandV2(bridgeCmd) 617 | } 618 | 619 | void setLevelV1(Number value, Number rate) { 620 | if (logEnable == true) log.debug "setLevel($value, $rate)" 621 | if (value < 0) value = 1 622 | else if (value > 100) value = 100 623 | else if (value == 0) { 624 | off(rate) 625 | return 626 | } 627 | Integer newLevel = scaleBriToBridge(value, APIV1) 628 | Integer scaledRate = (rate * 10).toInteger() 629 | Map bridgeCmd = [ 630 | "on": true, 631 | "bri": newLevel, 632 | "transitiontime": scaledRate 633 | ] 634 | sendBridgeCommandV1(bridgeCmd) 635 | } 636 | 637 | /** 638 | * Reads device preference for on() transition time, or provides default if not available; device 639 | * can use input(name: onTransitionTime, ...) to provide this 640 | */ 641 | Integer getScaledOnTransitionTime(String apiVersion=APIV1) { 642 | Integer scaledRate = null 643 | if (settings.onTransitionTime == null || settings.onTransitionTime == "-2" || settings.onTransitionTime == -2) { 644 | // keep null; will result in not specifiying with command 645 | } 646 | else { 647 | if (apiVersion == APIV1) scaledRate = Math.round(settings.onTransitionTime.toFloat() / 100) 648 | else scaledRate = settings.onTransitionTime.toInteger() 649 | } 650 | return scaledRate 651 | } 652 | 653 | 654 | /** 655 | * Reads device preference for off() transition time, or provides default if not available; device 656 | * can use input(name: onTransitionTime, ...) to provide this 657 | */ 658 | Integer getScaledOffTransitionTime(String apiVersion=APIV1) { 659 | Integer scaledRate = null 660 | if (settings.offTransitionTime == null || settings.offTransitionTime == "-2" || settings.offTransitionTime == -2) { 661 | // keep null; will result in not specifiying with command 662 | } 663 | else if (settings.offTransitionTime == "-1" || settings.offTransitionTime == -1) { 664 | scaledRate = getScaledOnTransitionTime() 665 | } 666 | else { 667 | if (apiVersion == APIV1) scaledRate = Math.round(settings.offTransitionTime.toFloat() / 100) 668 | else scaledRate = settings.offTransitionTime.toInteger() 669 | } 670 | return scaledRate 671 | } 672 | 673 | // Internal methods for scaling 674 | 675 | 676 | /** 677 | * Scales Hubitat's 1-100 brightness levels to Hue Bridge's 1-254 (or 0-100) 678 | * @param apiVersion: Use APIV1/"V1" (default) for classic, 1-254 API values; use APIV2 for v2/SSE 0.0-100.0 values (note: 0.0 is on) 679 | */ 680 | Number scaleBriToBridge(Number hubitatLevel, String apiVersion=APIV1) { 681 | if (apiVersion == APIV1) { 682 | Integer scaledLevel 683 | scaledLevel = Math.round(hubitatLevel == 1 ? 1 : hubitatLevel.toBigDecimal() / 100 * 254) 684 | return Math.round(scaledLevel) as Integer 685 | } 686 | else { 687 | BigDecimal scaledLevel 688 | // for now, a quick cheat to make 1% the Hue minimum (should scale other values proportionally in future) 689 | scaledLevel = hubitatLevel == 1 ? 0.0 : hubitatLevel.toBigDecimal().setScale(2, java.math.RoundingMode.HALF_UP) 690 | return scaledLevel 691 | } 692 | } 693 | 694 | /** 695 | * Scales Hue Bridge's 1-254 brightness levels to Hubitat's 1-100 (or 0-100) 696 | * @param apiVersion: Use "1" (default) for classic, 1-254 API values; use "2" for v2/SSE 0.0-100.0 values (note: 0.0 is on) 697 | */ 698 | Integer scaleBriFromBridge(Number bridgeLevel, String apiVersion=APIV1) { 699 | Integer scaledLevel 700 | if (apiVersion == APIV1) { 701 | scaledLevel = Math.round(bridgeLevel.toBigDecimal() / 254 * 100) 702 | if (scaledLevel < 1) scaledLevel = 1 703 | } 704 | else { 705 | // for now, a quick cheat to make 1% the Hue minimum (should scale other values proportionally in future) 706 | scaledLevel = Math.round(bridgeLevel <= 1.49 && bridgeLevel > 0.001 ? 1 : bridgeLevel) 707 | } 708 | return scaledLevel 709 | } 710 | 711 | // ~~~ IMPORTED FROM RMoRobert.CoCoHue_Flash_Lib ~~~ 712 | // Version 1.0.2 713 | 714 | void flash() { 715 | if (logEnable == true) log.debug "flash()" 716 | if (getHasV2DNI() == true) { 717 | if (settings.txtEnable == true) log.info("${device.displayName} started ~18-hr flash cycle") 718 | Map cmd = ["signaling": ["signal": "on_off", "duration": 65534000]] 719 | // Possible alternative, likely more similar to V1 behavior if needed: 720 | //Map cmd = ["alert": ["action": "breathe"]] 721 | sendBridgeCommandV2(cmd, false) 722 | } 723 | else { 724 | if (settings.txtEnable == true) log.info("${device.displayName} started 15-cycle flash") 725 | Map cmd = ["alert": "lselect"] 726 | sendBridgeCommandV1(cmd, false) 727 | } 728 | } 729 | 730 | void flashOnce() { 731 | if (logEnable == true) log.debug "flashOnce()" 732 | if (settings.txtEnable == true) log.info("${device.displayName} started 1-cycle flash") 733 | if (getHasV2DNI() == true) { 734 | Map cmd 735 | // Approximation for groups since don't support 'identify': 736 | if (device.deviceNetworkId.tokenize("/")[-2] == "Group") cmd = ["signaling": ["signal": "on_off", "duration": 1500]] 737 | // Otherwise, use normal method (API docs suggest this could change and suggest already doesn't only do single, but always has for me?): 738 | else cmd = ["identify": ["action": "identify"]] 739 | sendBridgeCommandV2(cmd, false) 740 | } 741 | else { 742 | Map cmd = ["alert": "select"] 743 | sendBridgeCommandV1(cmd, false) 744 | } 745 | } 746 | 747 | void flashOff() { 748 | if (logEnable == true) log.debug "flashOff()" 749 | if (settings.txtEnable == true) log.info("${device.displayName} was sent command to stop flash") 750 | if (getHasV2DNI() == true) { 751 | Map cmd = ["signaling": ["signal": "no_signal", "duration": 0]] 752 | sendBridgeCommandV2(cmd, false) 753 | } 754 | else { 755 | Map cmd = ["alert": "none"] 756 | sendBridgeCommandV1(cmd, false) 757 | } 758 | } 759 | 760 | // ~~~ IMPORTED FROM RMoRobert.CoCoHue_V2_DNI_Tools_Lib ~~~ 761 | // Version 1.0.0 762 | 763 | 764 | /** 765 | * Parses V2 Hue Bridge device ID out of Hubitat DNI for use with Hue V2 API calls 766 | * Hubitat DNI is created in format "CCH/BridgeMACAbbrev/Scene/HueDeviceID", so just 767 | * looks for string after last "/" character 768 | */ 769 | String getHueDeviceIdV2() { 770 | if (getHasV2DNI() == true) { 771 | return device.deviceNetworkId.split("/").last() 772 | } 773 | else { 774 | log.error "DNI not in V2 format but attempeting to fetch API V2 ID. Cannot continue." 775 | } 776 | } 777 | 778 | Boolean getHasV2DNI() { 779 | String id = device.deviceNetworkId.split("/").last() 780 | if (id.length() > 32) { // max length of Hue V1 ID per regex in V2 API docs 781 | return true 782 | } 783 | else { 784 | return false 785 | } 786 | } -------------------------------------------------------------------------------- /drivers/cocohue-scene-driver.groovy: -------------------------------------------------------------------------------- 1 | /* 2 | * ============================= CoCoHue Scene =============================== 3 | * 4 | * Copyright 2019-2025 Robert Morris 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 7 | * in compliance with the License. You may obtain a copy of the License at: 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 12 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 13 | * for the specific language governing permissions and limitations under the License. 14 | * 15 | * ======================================================================================= 16 | * 17 | * Last modified: 2025-09-07 18 | * 19 | * Changelog: 20 | * v5.3.4 - Changes to accommodate HTTPS by default 21 | * v5.3.1 - Implement async HTTP call queueing from child drivers through parent app 22 | * v5.2.8 - Add support for different V2 scene activation types (active/default, dynamic_palette, static) and 23 | * custom duration and brightness settings for higher-number button pushes to override scene settings 24 | * v5.2.5 - Add Smart Scene support 25 | * v5.1.2 - Re-added "momentary"-only Switch capability (on does push(1), off does nothing, auto-off after few seconds by default) 26 | * v5.1 - Remove Switch capability and associated preferences for groups and scenes 27 | * v5.0.3 - Use V2 API for scene activation/recall 28 | * v5.0.2 - Fetch V2 grouped_light ID owner for room/zone owners of V2 scenes 29 | * v5.0.1 - Fetch additional info to avoid missing V1 IDs 30 | * v5.0 - Use API v2 by default, remove deprecated features 31 | * v4.2 - Add support for parsing on/off events from v2 API state; library improvements; prep for mre v2 API use 32 | * v4.1.5 - Fix typos 33 | * v4.1.4 - Improved error handling, fix missing battery for motion sensors 34 | * v4.0 - Refactoring to match other CoCoHue drivers 35 | * v3.5.1 - Refactor some code into libraries (code still precompiled before upload; should not have any visible changes); 36 | * Remove capability "Light" from scene driver (better chance of Alexa seeing as switch and not light) 37 | * v3.5 - Minor code cleanup, removal of custom "push" command now that is standard capability command 38 | * v3.1 - Improved error handling and debug logging 39 | * v3.0 - Improved HTTP error handling 40 | * v2.1 - Reduced info logging when not state change; code cleanup and more static typing 41 | * v2.0 - Improved HTTP error handling; attribute events now generated only after hearing back from Bridge; 42 | * Bridge online/offline status improvements; bug fix for off() with light- or group-device-less scenes 43 | * Added options for scene "switch" attribute (on/off) behavior 44 | * Added options for optional Bridge refresh on scene on/off or push (activation) commands 45 | * v1.9 - Added off() functionality 46 | * v1.7 - Added configure() per Capability requirement 47 | * v1.5b - Initial public release 48 | */ 49 | 50 | 51 | import hubitat.scheduling.AsyncResponse 52 | import groovy.transform.Field 53 | 54 | @Field static final Integer debugAutoDisableMinutes = 30 55 | 56 | @Field static final String sACTIVATION_TYPE_ACTIVE = "active" 57 | @Field static final String sACTIVATION_TYPE_DYNAMIC_PALETTE = "dynamic_palette" 58 | @Field static final String sACTIVATION_TYPE_STATIC = "static" 59 | 60 | metadata { 61 | definition(name: "CoCoHue Scene", namespace: "RMoRobert", author: "Robert Morris", importUrl: "https://raw.githubusercontent.com/HubitatCommunity/CoCoHue/master/drivers/cocohue-scene-driver.groovy") { 62 | capability "Actuator" 63 | capability "PushableButton" 64 | capability "Switch" 65 | capability "Configuration" 66 | 67 | command "fetchSceneData" 68 | } 69 | 70 | preferences { 71 | //if (!(getIsSmartScene() == true)) { 72 | input name: "customBrightness", type: "number", title: "Custom brightness level (0-100) for scene activation with button 6 pushed and higher (overrides scene settings)", defaultValue: 100, range: "0..100" 73 | input name: "customDuration", type: "decimal", title: "Custom duration (in seconds) for scene activation transition with buttons 4-5 and 8-9 (overrides scene settings)", defaultValue: 0.4, range: "0..600" 74 | //} 75 | input name: "onRefresh", type: "enum", title: "Bridge refresh on activation/deactivation: when this scene is activated or deactivated by a Hubitat command... (suggested only if depend on status of these devices and not using Hue V2 API)", 76 | options: [["none": "Do not refresh Bridge"], 77 | ["1000": "Refresh Bridge device in 1s"], 78 | ["5000": "Refresh Bridge device in 5s"]], 79 | defaultValue: "none" 80 | input name: "autoOff", type: "enum", title: "Automatically set switch state to off after activating scene with \"on\" command", 81 | options: ["no": "Disabled (not recommended)", "2": "After 2 seconds (default)", "5": "After 5 seconds"], defaultValue: "2" 82 | input name: "logEnable", type: "bool", title: "Enable debug logging", defaultValue: true 83 | input name: "txtEnable", type: "bool", title: "Enable descriptionText logging", defaultValue: true 84 | } 85 | } 86 | 87 | void installed() { 88 | log.debug "installed()" 89 | setDefaultAttributeValues() 90 | runIn(3, "initialize") 91 | } 92 | 93 | void updated() { 94 | log.debug "updated()" 95 | initialize() 96 | } 97 | 98 | void initialize() { 99 | log.debug "initialize()" 100 | if (getIsSmartScene() == false) { 101 | sendEvent(name: "numberOfButtons", value: 9) 102 | state.supportedButtonPushes = [ 103 | 1: "Default", 104 | 2: "Dynamic palette", 105 | 3: "Static", 106 | 4: "Dynamic palette, custom duration", 107 | 5: "Static, custom duration", 108 | 6: "Dynamic palette, custom brightness", 109 | 7: "Static, custom brightness", 110 | 8: "Dynamic palette, custom duration and brightness", 111 | 9: "Static, custom duration and brightness" 112 | ] 113 | } 114 | else { 115 | sendEvent(name: "numberOfButtons", value: 2) 116 | state.supportedButtonPushes = [ 117 | 1: "Activate Smart Scene", 118 | 2: "Deactiveate Smart Scene" 119 | ] 120 | } 121 | if (logEnable) { 122 | log.debug "Debug logging will be automatically disabled in ${debugAutoDisableMinutes} minutes" 123 | runIn(debugAutoDisableMinutes*60, "debugOff") 124 | } 125 | if (!hasV2DNI) fetchSceneData() // Get scene data for V1 devices 126 | } 127 | 128 | void configure() { 129 | log.debug "configure()" 130 | setDefaultAttributeValues() 131 | } 132 | 133 | void push(String btnNum) { 134 | if (logEnable) log.debug "push(String $btnNum)" 135 | Integer intBtnNum = Integer.parseInt(btnNum) 136 | push(intBtnNum) 137 | } 138 | 139 | void push(Number btnNum) { 140 | if (logEnable) log.debug "push(Number $btnNum)" 141 | Integer intBtnNum = btnNum.toInteger() 142 | switch (intBtnNum) { 143 | case 1: 144 | activate() 145 | break 146 | case 2: 147 | if (getIsSmartScene() == true) deactivateSmartScene() 148 | else activate(activationType: sACTIVATION_TYPE_DYNAMIC_PALETTE) 149 | break 150 | case 3: 151 | activate(activationType: sACTIVATION_TYPE_STATIC) 152 | break 153 | case 4: 154 | activate(activationType: sACTIVATION_TYPE_DYNAMIC_PALETTE, 155 | duration: settings.customDuration != null ? settings.customDuration : 400) 156 | break 157 | case 5: 158 | activate(activationType: sACTIVATION_TYPE_STATIC, 159 | duration: settings.customDuration != null ? settings.customDuration : 400) 160 | break 161 | case 6: 162 | activate(activationType: sACTIVATION_TYPE_DYNAMIC_PALETTE, 163 | brightness: settings.customBrightness != null ? settings.customBrightness : 100) 164 | break 165 | case 7: 166 | activate(activationType: sACTIVATION_TYPE_STATIC, 167 | brightness: settings.customBrightness != null ? settings.customBrightness : 100) 168 | break 169 | case 8: 170 | activate(activationType: sACTIVATION_TYPE_DYNAMIC_PALETTE, 171 | duration: settings.customDuration != null ? settings.customDuration : 400, 172 | brightness: settings.customBrightness != null ? settings.customBrightness : 100) 173 | break 174 | case 9: 175 | activate(activationType: sACTIVATION_TYPE_STATIC, 176 | duration: settings.customDuration != null ? settings.customDuration : 400, 177 | brightness: settings.customBrightness != null ? settings.customBrightness : 100) 178 | break 179 | default: 180 | log.warn "Unsupported button number: $intBtnNum; assuming button 1. This may change in future; please check usage in all apps." 181 | activate() 182 | } 183 | doSendEvent("pushed", intBtnNum, null, true) 184 | } 185 | void on() { 186 | if (logEnable) log.debug "on()" 187 | push(1) 188 | doSendEvent("switch", "on", null) 189 | if (settings.autoOff != "no") { 190 | String sec = settings.autoOff ?: "2" 191 | runIn(sec.toInteger(), "autoOffHandler") 192 | } 193 | } 194 | 195 | void off() { 196 | if (!isSmartScene) { 197 | log.warn "command off() not implemented; turn off desired group or light devices instead" 198 | } 199 | else { 200 | deactivateSmartScene() 201 | } 202 | if (device.currentValue("switch") != "off") { 203 | doSendEvent("switch", "off", null) 204 | } 205 | } 206 | 207 | void autoOffHandler(Map data=null) { 208 | if (logEnable) log.debug "autoOffHandler()" 209 | if (device.currentValue("switch") != "off") { 210 | doSendEvent("switch", "off", null) 211 | } 212 | } 213 | 214 | // Probably won't happen but... 215 | void parse(String description) { 216 | log.warn("Running unimplemented parse for: '${description}'") 217 | } 218 | 219 | /** 220 | * Parses V1 Hue Bridge scene ID number out of Hubitat DNI for use with Hue V1 API calls 221 | * Hubitat DNI is created in format "CCH/BridgeMACAbbrev/Scene/HueDeviceID", so just 222 | * looks for number after last "/" character; or try state if DNI is V2 format (avoid if posssible, 223 | * as Hue is likely to deprecate V1 ID data in future) 224 | */ 225 | String getHueDeviceIdV1() { 226 | String id = device.deviceNetworkId.split("/").last() 227 | if (hasV2DNI == true) { 228 | id = state.id_v1?.split("/")?.last() 229 | if (state.id_v1 == null) { 230 | log.warn "Attempting to retrieve V1 ID but not in DNI or state." 231 | } 232 | } 233 | return id 234 | } 235 | 236 | Boolean getIsSmartScene() { 237 | return device.deviceNetworkId.split("/")[-2] == "SmartScene" 238 | } 239 | 240 | /** 241 | * Activates scene or smart scene on Hue Bridge, preferring V2 API if available 242 | * @param options Map with optional keys (optional; apply to regular/non-Smart scenes only): 243 | * - `String activationType`: one of `"active" (default), "dynamic_palette", or "static" (not applicable to Smart Scenes) 244 | - `Number duration`: duration in seconds for transition to fully activated scene (not applicable to Smart Scenes) 245 | - `Long brightness`: override brightness/dimming level for scene (not applicable to Smart Scenes) 246 | */ 247 | void activate(Map options=null) { 248 | if (logEnable) log.debug "activate(Map options=$options)" 249 | if (hasV2DNI == true) { 250 | if (logEnable) log.debug "activation will use V2 API" 251 | String path 252 | Map cmd 253 | if (getIsSmartScene() == false) { 254 | path = "/resource/scene/${getHueDeviceIdV2()}" 255 | String action 256 | if (options?.activationType?.trim()?.toLowerCase() == sACTIVATION_TYPE_DYNAMIC_PALETTE) { 257 | action = sACTIVATION_TYPE_DYNAMIC_PALETTE 258 | } 259 | else if (options?.activationType?.trim()?.toLowerCase() == sACTIVATION_TYPE_STATIC) { 260 | action = sACTIVATION_TYPE_STATIC 261 | } 262 | else { 263 | action = sACTIVATION_TYPE_ACTIVE 264 | } 265 | cmd = [recall: [action: action]] 266 | if (options?.duration != null) { 267 | Long duration = (options.duration * 1000).toLong() // Hue uses ms 268 | cmd.recall.duration = duration 269 | } 270 | if (options?.brightness != null) { 271 | if (options.brightness < 0) options.brightness = 0 272 | if (options.brightness > 100) options.brightness = 100 273 | cmd.recall.dimming = [:] 274 | cmd.recall.dimming.brightness = options.brightness 275 | } 276 | } 277 | else { 278 | path = "/resource/smart_scene/${getHueDeviceIdV2()}" 279 | if (options && logEnable) { 280 | log.warn "Non-default activation options not supported by Hue for Smart Scenes; ignoring" 281 | } 282 | cmd = [recall: [action: "activate"]] 283 | } 284 | parent.bridgeAsyncPutV2("parseSendCommandResponseV2", this.device, path, cmd) 285 | } 286 | else { 287 | if (options && logEnable) { 288 | log.warn "Non-default activation options require V2 API but device uses V1; activating without options using V1" 289 | } 290 | activateV1() 291 | } 292 | } 293 | 294 | void activateV1() { 295 | Map data = parent.getBridgeData() 296 | Map cmd = ["scene": getHueDeviceIdV1()] 297 | Map params = [ 298 | uri: data.fullHost, 299 | path: "/api/${data.username}/groups/0/action", 300 | contentType: 'application/json', 301 | body: cmd, 302 | ignoreSSLIssues: true, 303 | timeout: 15 304 | ] 305 | asynchttpPut("parseSendCommandResponseV1", params /*, [attribute: 'switch', value: 'on']*/) 306 | if (settings["onRefresh"] == "1000" || settings["onRefresh"] == "5000") { 307 | parent.runInMillis(settings["onRefresh"] as Integer, "refreshBridge") 308 | } 309 | if (logEnable) log.debug "Command sent to Bridge: $cmd" 310 | } 311 | 312 | void deactivateSmartScene() { 313 | if (logEnable) log.debug "deactivateSmartScene()" 314 | if (hasV2DNI == true) { 315 | Map cmd = [recall: [action: "deactivate"]] 316 | parent.bridgeAsyncPutV2("parseSendCommandResponseV2", this.device, "/resource/smart_scene/${getHueDeviceIdV2()}", cmd) 317 | } 318 | } 319 | 320 | // remove if do not re-add Switch capability 321 | 322 | // void off() { 323 | // if (logEnable) log.debug "off()" 324 | // if (hasV2DNI == true) { 325 | // if (logEnable) log.debug "off() will use V2 API" 326 | // if (state.ownerGroupId != null) { 327 | // List dniParts = device.deviceNetworkId.split("/") 328 | // String dni = "${dniParts[0]}/${dniParts[1]}/Group/${state.ownerGroupId}" 329 | // com.hubitat.app.DeviceWrapper dev = parent.getChildDevice(dni) 330 | // if (dev != null) { 331 | // if (logEnable) log.debug "Hubitat device for group ${state.group} found; turning off" 332 | // dev.off() 333 | // doSendEvent("switch", "off", null) // may not need with V2 API but can't hurt? 334 | // } 335 | // else { 336 | // Map cmd = [on: [on: false]] 337 | // bridgeAsyncPutV2("parseSendCommandResponseV2", "/resource/grouped_light/${state.ownerGroupId}", cmd, null, [attribute: "switch", value: "off"]) 338 | // } 339 | // } 340 | // else if (state.group != null) { 341 | // if (logEnable) log.debug "Cannot find V2 group ID to perform off() action; attepmting V1..." 342 | // offV1() 343 | // } 344 | // else { 345 | // if (logEnable) log.debug "No group information available to perform off() action. Try running Fetch Scene Data command to fix, or turn off group or lights directly instead of scene device." 346 | // } 347 | // } 348 | // else { 349 | // offV1() 350 | // } 351 | // } 352 | 353 | // void offV1() { 354 | // if (logEnable) log.debug "offV1()" 355 | // if (state.type == "GroupScene") { 356 | // if (logEnable) log.debug "Scene is GroupScene; turning off group $state.group" 357 | // List dniParts = device.deviceNetworkId.split("/") 358 | // String dni = "${dniParts[0]}/${dniParts[1]}/Group/${state.group}" 359 | // com.hubitat.app.DeviceWrapper dev = parent.getChildDevice(dni) 360 | // if (dev) { 361 | // if (logEnable) log.debug "Hubitat device for group ${state.group} found; turning off" 362 | // dev.off() 363 | // doSendEvent("switch", "off", null) // optimistic here; group device will catch if problem 364 | // } 365 | // else { 366 | // if (logEnable) log.debug "Device not found; sending V1 command directly to turn off Hue group" 367 | // Map data = parent.getBridgeData() 368 | // Map cmd = ["on": false] 369 | // Map params = [ 370 | // uri: data.fullHost, 371 | // path: "/api/${data.username}/groups/${state.group}/action", 372 | // contentType: 'application/json', 373 | // body: cmd, 374 | // timeout: 15 375 | // ] 376 | // asynchttpPut("parseSendCommandResponseV1", params, [attribute: 'switch', value: 'off']) 377 | // if (logEnable) log.debug "Command sent to Bridge: $cmd" 378 | // } 379 | // } else if (state.type == "LightScene") { 380 | // doSendEvent("switch", "off", null) // optimistic here (would be difficult to determine and aggregate individual light responses and should be rare anyway) 381 | // if (logEnable) log.debug "Scene is LightScene; turning off lights $state.lights" 382 | // state.lights.each { 383 | // List dniParts = device.deviceNetworkId.split("/") 384 | // String dni = "${dniParts[0]}/${dniParts[1]}/Light/${it}" 385 | // com.hubitat.app.DeviceWrapper dev = parent.getChildDevice(dni) 386 | // if (dev) { 387 | // if (logEnable) log.debug "Hubitat device for light ${it} found; turning off" 388 | // dev.off() 389 | // } 390 | // else { 391 | // if (logEnable) log.debug "Device not found; sending command directly to turn off Hue light" 392 | // Map data = parent.getBridgeData() 393 | // Map cmd = ["on": false] 394 | // Map params = [ 395 | // uri: data.fullHost, 396 | // path: "/api/${data.username}/lights/${it}/state", 397 | // contentType: 'application/json', 398 | // body: cmd, 399 | // timeout: 15 400 | // ] 401 | // asynchttpPut("parseSendCommandResponseV1", params) 402 | // if (logEnable) log.debug "Command sent to Bridge: $cmd" 403 | // } 404 | // } 405 | // if (settings["onRefresh"] == "1000" || settings["onRefresh"] == "5000") { 406 | // parent.runInMillis(settings["onRefresh"] as Integer, "refreshBridge") 407 | // } 408 | // } 409 | // else { 410 | // log.warn "No off() action available for scene $device.displayName" 411 | // } 412 | // } 413 | 414 | /** 415 | * Iterates over Hue scene state state data in Hue API v2 (SSE) format and does 416 | * a sendEvent for each relevant attribute; intended to be called when EventSocket data 417 | * received for device (as an alternative to polling) 418 | */ 419 | void createEventsFromMapV2(Map data) { 420 | if (logEnable == true) log.debug "createEventsFromMapV2($data)" 421 | String eventName, eventUnit, descriptionText 422 | def eventValue // could be String or number 423 | data.each { String key, value -> 424 | //log.trace "$key = $value" 425 | switch (key) { 426 | // case "status": 427 | // eventName = "switch" 428 | // eventValue = (value.active == "inactive" || value.active == null) ? "off" : "on" 429 | // eventUnit = null 430 | // if (device.currentValue(eventName) != eventValue) doSendEvent(eventName, eventValue, eventUnit) 431 | // break 432 | case "state": // smart scene active/inactive status, apparently? not documented but does appear to indicate... 433 | if (value == "active") eventValue = "on" else eventValue = "off" 434 | eventName = "switch" 435 | eventUnit = null 436 | if (device.currentValue(eventName) != eventValue) doSendEvent(eventName, eventValue, eventUnit) 437 | case "id_v1": 438 | if (state.id_v1 != value) state.id_v1 = value 439 | break 440 | default: 441 | if (logEnable == true) "not handling: $key: $value" 442 | } 443 | } 444 | } 445 | 446 | /** 447 | * Parses response from Bridge (or not) after sendBridgeCommandV1 or similar command. Updates device 448 | * device state if appears to have been successful. 449 | * @param resp Async HTTP response object 450 | * @param data Map with keys 'attribute' and 'value' containing event data to send if successful (e.g., [attribute: 'switch', value: 'off']) 451 | */ 452 | void parseSendCommandResponseV1(AsyncResponse resp, Map data) { 453 | if (logEnable) log.debug "Response from Bridge: ${resp.status}; custom data = $data" 454 | if (checkIfValidResponse(resp) && data?.attribute != null && data?.value != null) { 455 | if (logEnable) log.debug " Bridge response valid; running creating events" 456 | if (device.currentValue(data.attribute) != data.value) doSendEvent(data.attribute, data.value) 457 | } 458 | else { 459 | if (logEnable) log.debug " Not creating events from map because not specified to do or Bridge response invalid" 460 | } 461 | } 462 | 463 | /** 464 | * Parses response from Bridge (or not) after bridgeAsyncPutV2(). 465 | * @param resp Async HTTP response object 466 | * @param data Map with keys 'attribute' and 'value' containing event data to send if successful (e.g., [attribute: 'switch', value: 'off'] -- generally 467 | * only useful as last-ditch effort when using V2 API to get scene device state correct if needed). 468 | */ 469 | void parseSendCommandResponseV2(AsyncResponse resp, Map data) { 470 | if (logEnable) log.debug "Response from Bridge: ${resp.status}; custom data = $data" 471 | if (checkIfValidResponse(resp) && data?.attribute != null && data?.value != null) { 472 | if (logEnable) log.debug " Bridge response valid; running creating events" 473 | if (device.currentValue(data.attribute) != data.value) doSendEvent(data.attribute, data.value) 474 | } 475 | else { 476 | if (logEnable) log.debug " Not creating events from map because not specified to do or Bridge response invalid" 477 | } 478 | } 479 | 480 | /** Gets data about scene from Bridge; does not update bulb/group status */ 481 | void fetchSceneData() { 482 | if (logEnable) log.debug "fetchSceneData()" 483 | if (getIsSmartScene()) { 484 | log.warn "fetchSceneData() not supported on Smart Scenes; exiting" 485 | return 486 | } 487 | Map data = parent.getBridgeData() 488 | if (data.apiVersion == APIV1 || data.apiVersion == null) { 489 | Map sceneParams = [ 490 | uri: data.fullHost, 491 | path: "/api/${data.username}/scenes/${getHueDeviceIdV1()}", 492 | contentType: 'application/json', 493 | ignoreSSLIssues: true, 494 | timeout: 15 495 | ] 496 | asynchttpGet("fetchSceneDataResponseV1", sceneParams) 497 | } 498 | else { 499 | bridgeAsyncGetV2("fetchSceneDataResponseV2", "/resource/scene/${getHueDeviceIdV2()}", data) 500 | } 501 | } 502 | 503 | /** 504 | * Parses data returned when getting scene data from Bridge for V1 API 505 | */ 506 | void fetchSceneDataResponseV1(resp, data) { 507 | if (logEnable) log.debug "fetchSceneDataResponseV1 response from Bridge: $resp.status" 508 | Map sceneAttributes 509 | try { 510 | sceneAttributes = resp.json 511 | } catch (ex) { 512 | log.error("Could not parse scene data: ${ex}") 513 | return 514 | } 515 | if (sceneAttributes["type"] == "GroupScene") { 516 | state.type = "GroupScene" 517 | state.group = sceneAttributes["group"] 518 | state.remove("lights") 519 | } 520 | else if (sceneAttributes["type"] == "LightScene") { 521 | state.type = "LightScene" 522 | state.lights = sceneAttributes["lights"] 523 | state.remove("group") 524 | } 525 | else { 526 | log.warn "Unknown scene type; off() commands will not work" 527 | state.remove("group") 528 | state.remove("lights") 529 | state.remove("type") 530 | } 531 | } 532 | 533 | /** 534 | * Parses data returned when getting scene data from Bridge for V2 API 535 | */ 536 | void fetchSceneDataResponseV2(resp, data) { 537 | if (logEnable) log.debug "fetchSceneDataResponseV2 response from Bridge: $resp.status" 538 | Map scData = resp.json.data.first() 539 | def ownerId = scData.group.rid 540 | def ownerType = scData.group.rtype 541 | if (ownerType == "room" || ownerType == "zone") { 542 | bridgeAsyncGetV2("fetchRoomOrZoneGroupIdResponseV2", "/resource/${ownerType}/${ownerId}") 543 | } 544 | else if (ownerType == "grouped_light") { 545 | setOwnerGroupIDV2(ownerId) 546 | } 547 | } 548 | 549 | /** 550 | * Parses grouped_light ID out of room or zone data when fetched after fetching scene owner data (above) 551 | */ 552 | void fetchRoomOrZoneGroupIdResponseV2(resp, data) { 553 | if (logEnable) log.debug "fetchRoomOrZoneGroupIdResponseV2 response from Bridge: $resp.status" 554 | Map groupedLightSvc = resp.json.data.first().services.find { it.rtype == "grouped_light" } 555 | if (groupedLightSvc) setOwnerGroupIDV2(groupedLightSvc.rid) 556 | else if (logEnable) log.debug "Unable to fetch ID for owner" 557 | } 558 | 559 | /** 560 | * Sets all group attribute values to something, intended to be called when device initially created to avoid 561 | * missing attribute values (may cause problems with GH integration, etc. otherwise). Default values are 562 | * approximately warm white and off. 563 | */ 564 | private void setDefaultAttributeValues() { 565 | if (logEnable) log.debug "Setting scene device states to sensibile default values..." 566 | sendEvent(name: "switch", value: "off", isStateChange: false) 567 | sendEvent(name: "pushed", value: 1, isStateChange: false) 568 | } 569 | 570 | // void autoOffHandler() { 571 | // doSendEvent("switch", "off") 572 | // } 573 | 574 | /** 575 | * Returns Hue group ID (as String, since it is likely to be used in DNI check or API call). 576 | * May return null (if is not GroupScene). Used by parent app. 577 | */ 578 | String getGroupID() { 579 | return state.group 580 | } 581 | 582 | /** 583 | * Sets state.owner to specified ID (ID of grouped_light service from owner room or zone); used only for V2 API 584 | */ 585 | String setOwnerGroupIDV2(String id) { 586 | state.ownerGroupId = id 587 | } 588 | 589 | // ~~~ IMPORTED FROM RMoRobert.CoCoHue_Common_Lib ~~~ 590 | // Version 1.0.6 591 | // For use with CoCoHue drivers (not app) 592 | 593 | /** 594 | * 1.0.6 - Remove common bridgeAsyncPutV2() method (now call from parent app instead of driver) 595 | * 1.0.5 - Add common bridgeAsyncPutV2() method for asyncHttpPut (goal to reduce individual driver code) 596 | * 1.0.4 - Add common bridgeAsyncGetV2() method asyncHttpGet (goal to reduce individual driver code) 597 | * 1.0.3 - Add APIV1 and APIV2 "constants" 598 | * 1.0.2 - HTTP error handling tweaks 599 | */ 600 | 601 | void debugOff() { 602 | log.warn "Disabling debug logging" 603 | device.updateSetting("logEnable", [value:"false", type:"bool"]) 604 | } 605 | 606 | /** Performs basic check on data returned from HTTP response to determine if should be 607 | * parsed as likely Hue Bridge data or not; returns true (if OK) or logs errors/warnings and 608 | * returns false if not 609 | * @param resp The async HTTP response object to examine 610 | */ 611 | private Boolean checkIfValidResponse(hubitat.scheduling.AsyncResponse resp) { 612 | if (logEnable == true) log.debug "Checking if valid HTTP response/data from Bridge..." 613 | Boolean isOK = true 614 | if (resp.status < 400) { 615 | if (resp.json == null) { 616 | isOK = false 617 | if (resp.headers == null) log.error "Error: HTTP ${resp.status} when attempting to communicate with Bridge" 618 | else log.error "No JSON data found in response. ${resp.headers.'Content-Type'} (HTTP ${resp.status})" 619 | parent.sendBridgeDiscoveryCommandIfSSDPEnabled(true) // maybe IP changed, so attempt rediscovery 620 | parent.setBridgeOnlineStatus(false) 621 | } 622 | else if (resp.json) { 623 | if ((resp.json instanceof List) && resp.json.getAt(0).error) { 624 | // Bridge (not HTTP) error (bad username, bad command formatting, etc.): 625 | isOK = false 626 | log.warn "Error from Hue Bridge: ${resp.json[0].error}" 627 | // Not setting Bridge to offline when light/scene/group devices end up here because could 628 | // be old/bad ID and don't want to consider Bridge offline just for that (but also won't set 629 | // to online because wasn't successful attempt) 630 | } 631 | // Otherwise: probably OK (not changing anything because isOK = true already) 632 | } 633 | else { 634 | isOK = false 635 | log.warn("HTTP status code ${resp.status} from Bridge") 636 | // TODO: Update for mDNS if/when switch: 637 | if (resp?.status >= 400) parent.sendBridgeDiscoveryCommandIfSSDPEnabled(true) // maybe IP changed, so attempt rediscovery 638 | parent.setBridgeOnlineStatus(false) 639 | } 640 | if (isOK == true) parent.setBridgeOnlineStatus(true) 641 | } 642 | else { 643 | log.warn "Error communicating with Hue Bridge: HTTP ${resp?.status}" 644 | isOK = false 645 | } 646 | return isOK 647 | } 648 | 649 | void doSendEvent(String eventName, eventValue, String eventUnit=null, Boolean forceStateChange=false) { 650 | //if (logEnable == true) log.debug "doSendEvent($eventName, $eventValue, $eventUnit)" 651 | String descriptionText = "${device.displayName} ${eventName} is ${eventValue}${eventUnit ?: ''}" 652 | if (settings.txtEnable == true) log.info(descriptionText) 653 | if (eventUnit) { 654 | if (forceStateChange == true) sendEvent(name: eventName, value: eventValue, descriptionText: descriptionText, unit: eventUnit, isStateChange: true) 655 | else sendEvent(name: eventName, value: eventValue, descriptionText: descriptionText, unit: eventUnit) 656 | } else { 657 | if (forceStateChange == true) sendEvent(name: eventName, value: eventValue, descriptionText: descriptionText, isStateChange: true) 658 | else sendEvent(name: eventName, value: eventValue, descriptionText: descriptionText) 659 | } 660 | } 661 | 662 | // HTTP methods (might be better to split into separate library if not needed for some?) 663 | 664 | /** Performs asynchttpGet() to Bridge using data retrieved from parent app or as passed in 665 | * @param callbackMethod Callback method 666 | * @param clipV2Path The Hue V2 API path ('/clip/v2' is automatically prepended), e.g. '/resource' or '/resource/light' 667 | * @param bridgeData Bridge data from parent getBridgeData() call, or will call this method on parent if null 668 | * @param data Extra data to pass as optional third (data) parameter to asynchtttpGet() method 669 | */ 670 | void bridgeAsyncGetV2(String callbackMethod, String clipV2Path, Map bridgeData = null, Map data = null) { 671 | if (bridgeData == null) { 672 | bridgeData = parent.getBridgeData() 673 | } 674 | Map params = [ 675 | uri: "https://${bridgeData.ip}", 676 | path: "/clip/v2${clipV2Path}", 677 | headers: ["hue-application-key": bridgeData.username], 678 | contentType: "application/json", 679 | timeout: 15, 680 | ignoreSSLIssues: true 681 | ] 682 | asynchttpGet(callbackMethod, params, data) 683 | } 684 | 685 | // REMOVED, now call from parent app instead of driver: 686 | // /** Performs asynchttpPut() to Bridge using data retrieved from parent app or as passed in 687 | // * @param callbackMethod Callback method 688 | // * @param clipV2Path The Hue V2 API path ('/clip/v2' is automatically prepended), e.g. '/resource' or '/resource/light' 689 | // * @param body Body data, a Groovy Map representing JSON for the Hue V2 API command, e.g., [on: [on: true]] 690 | // * @param bridgeData Bridge data from parent getBridgeData() call, or will call this method on parent if null 691 | // * @param data Extra data to pass as optional third (data) parameter to asynchtttpPut() method 692 | // */ 693 | // void bridgeAsyncPutV2(String callbackMethod, String clipV2Path, Map body, Map bridgeData = null, Map data = null) { 694 | // if (bridgeData == null) { 695 | // bridgeData = parent.getBridgeData() 696 | // } 697 | // Map params = [ 698 | // uri: "https://${bridgeData.ip}", 699 | // path: "/clip/v2${clipV2Path}", 700 | // headers: ["hue-application-key": bridgeData.username], 701 | // contentType: "application/json", 702 | // body: body, 703 | // timeout: 15, 704 | // ignoreSSLIssues: true 705 | // ] 706 | // asynchttpPut(callbackMethod, params, data) 707 | // if (logEnable == true) log.debug "Command sent to Bridge: $body at ${clipV2Path}" 708 | // pauseExecution(200) // see if helps HTTP 429 errors? 709 | // } 710 | 711 | 712 | // ~~~ IMPORTED FROM RMoRobert.CoCoHue_Constants_Lib ~~~ 713 | // Version 1.0.0 714 | 715 | // -------------------------------------- 716 | // APP AND DRIVER NAMESPACE AND NAMES: 717 | // -------------------------------------- 718 | @Field static final String NAMESPACE = "RMoRobert" 719 | @Field static final String DRIVER_NAME_BRIDGE = "CoCoHue Bridge" 720 | @Field static final String DRIVER_NAME_BUTTON = "CoCoHue Button" 721 | @Field static final String DRIVER_NAME_CT_BULB = "CoCoHue CT Bulb" 722 | @Field static final String DRIVER_NAME_DIMMABLE_BULB = "CoCoHue Dimmable Bulb" 723 | @Field static final String DRIVER_NAME_GROUP = "CoCoHue Group" 724 | @Field static final String DRIVER_NAME_MOTION = "CoCoHue Motion Sensor" 725 | @Field static final String DRIVER_NAME_CONTACT = "CoCoHue Contact Sensor" 726 | @Field static final String DRIVER_NAME_PLUG = "CoCoHue Plug" 727 | @Field static final String DRIVER_NAME_RGBW_BULB = "CoCoHue RGBW Bulb" 728 | @Field static final String DRIVER_NAME_RGB_BULB = "CoCoHue RGB Bulb" 729 | @Field static final String DRIVER_NAME_SCENE = "CoCoHue Scene" 730 | 731 | // -------------------------------------- 732 | // DNI PREFIX for child devices: 733 | // -------------------------------------- 734 | @Field static final String DNI_PREFIX = "CCH" 735 | 736 | // -------------------------------------- 737 | // OTHER: 738 | // -------------------------------------- 739 | // Used in app and Bridge driver, may eventually find use in more: 740 | @Field static final String APIV1 = "V1" 741 | @Field static final String APIV2 = "V2" 742 | 743 | // ~~~ IMPORTED FROM RMoRobert.CoCoHue_V2_DNI_Tools_Lib ~~~ 744 | // Version 1.0.0 745 | 746 | 747 | /** 748 | * Parses V2 Hue Bridge device ID out of Hubitat DNI for use with Hue V2 API calls 749 | * Hubitat DNI is created in format "CCH/BridgeMACAbbrev/Scene/HueDeviceID", so just 750 | * looks for string after last "/" character 751 | */ 752 | String getHueDeviceIdV2() { 753 | if (getHasV2DNI() == true) { 754 | return device.deviceNetworkId.split("/").last() 755 | } 756 | else { 757 | log.error "DNI not in V2 format but attempeting to fetch API V2 ID. Cannot continue." 758 | } 759 | } 760 | 761 | Boolean getHasV2DNI() { 762 | String id = device.deviceNetworkId.split("/").last() 763 | if (id.length() > 32) { // max length of Hue V1 ID per regex in V2 API docs 764 | return true 765 | } 766 | else { 767 | return false 768 | } 769 | } -------------------------------------------------------------------------------- /drivers/cocohue-ct-bulb-driver.groovy: -------------------------------------------------------------------------------- 1 | /* 2 | * ============================= CoCoHue CT Bulb (Driver) =============================== 3 | * 4 | * Copyright 2019-2025 Robert Morris 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 7 | * in compliance with the License. You may obtain a copy of the License at: 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 12 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 13 | * for the specific language governing permissions and limitations under the License. 14 | * 15 | * ======================================================================================= 16 | * 17 | * Last modified: 2025-09-07 18 | * 19 | * Changelog: 20 | * v5.3.4 - Changes to accommodate HTTPS by default 21 | * v5.3.1 - Implement async HTTP call queueing from child drivers through parent app 22 | * v5.3.0 - Use V2 for most commands, parse 0 mired as 0 K 23 | * v5.2.8 - Add reachable attribute to V2 API parsing; ignore 0 CT values 24 | * v5.2.7 - Use level 0 in color or CT commands as off() 25 | * v5.2.2 - Populate initial states from V2 cache if available 26 | * v5.0.1 - Fix for missing V1 IDs after device creation or upgrade 27 | * v5.0 - Use API v2 by default, remove deprecated features 28 | * v4.2 - Library updates, prep for more v2 API 29 | * v4.1.8 - Fix for division by zero for unexpected colorTemperature values 30 | * v4.1.7 - Fix for unexpected Hubitat event creation when v2 API reports level of 0 31 | * v4.1.5 - Improved v2 brightness parsing 32 | * v4.1.4 - Improved error handling, fix missing battery for motion sensors 33 | * v4.0.2 - Fix to avoid unepected "off" transition time 34 | * v4.0 - Add SSE support for push 35 | * v3.5.1 - Refactor some code into libraries (code still precompiled before upload; should not have any visible changes) 36 | * v3.5 - Add LevelPreset capability (replaces old level prestaging option); added "reachable" attribte 37 | from Bridge to bulb and group drivers (thanks to @jtp10181 for original implementation) 38 | * v3.1.3 - Adjust setLevel(0) to honor rate 39 | * v3.1.1 - Fix for setColorTempeature() not turning bulb on in some cases 40 | * v3.1 - Improved error handling and debug logging; added optional setColorTemperature parameters 41 | * v3.0 - Fix so events no created until Bridge response received (as was done for other drivers in 2.0); improved HTTP error handling 42 | * v2.1.1 - Improved rounding for level (brightness) to/from Bridge 43 | * v2.1 - More static typing 44 | * v2.0 - Added startLevelChange rate option; improved HTTP error handling; attribute events now generated 45 | * only after hearing back from Bridge; Bridge online/offline status improvements 46 | * v1.9 - Initial release (based on RGBW bulb driver) 47 | */ 48 | 49 | 50 | import groovy.transform.Field 51 | import hubitat.scheduling.AsyncResponse 52 | 53 | @Field static final Integer debugAutoDisableMinutes = 30 54 | 55 | // Currently works for all Hue bulbs; can adjust if needed: 56 | @Field static final minMireds = 153 57 | @Field static final maxMireds = 500 58 | 59 | // Default preference values 60 | @Field static final BigDecimal defaultLevelTransitionTime = 400 61 | 62 | // Default list of command Map keys to ignore if SSE enabled and command is sent from hub (not polled from Bridge), used to 63 | // ignore duplicates that are expected to be processed from SSE momentarily: 64 | // (for CT devices, should cover most things) 65 | @Field static final List listKeysToIgnoreIfSSEEnabledAndNotFromBridge = ["on", "ct", "bri"] 66 | 67 | metadata { 68 | definition(name: "CoCoHue CT Bulb", namespace: "RMoRobert", author: "Robert Morris", importUrl: "https://raw.githubusercontent.com/HubitatCommunity/CoCoHue/master/drivers/cocohue-ct-bulb-driver.groovy") { 69 | capability "Actuator" 70 | capability "ColorTemperature" 71 | capability "Refresh" 72 | capability "Switch" 73 | capability "SwitchLevel" 74 | capability "ChangeLevel" 75 | capability "Light" 76 | 77 | command "flash" 78 | command "flashOnce" 79 | command "flashOff" 80 | 81 | attribute "reachable", "string" 82 | } 83 | 84 | preferences { 85 | input name: "transitionTime", type: "enum", description: "", title: "Transition time", options: 86 | [[0:"ASAP"],[400:"400ms"],[500:"500ms"],[1000:"1s"],[1500:"1.5s"],[2000:"2s"],[5000:"5s"]], defaultValue: 400 87 | input name: "levelChangeRate", type: "enum", description: "", title: '"Start level change" rate', options: 88 | [["slow":"Slow"],["medium":"Medium"],["fast":"Fast (default)"]], defaultValue: "fast" 89 | input name: "ctTransitionTime", type: "enum", description: "", title: "Color temperature transition time", options: 90 | [[(-2): "Hue default/do not specify"],[(-1): "Use level transition time (default)"],[0:"ASAP"],[200:"200ms"],[400:"400ms (default)"],[500:"500ms"],[1000:"1s"],[1500:"1.5s"],[2000:"2s"],[5000:"5s"]], defaultValue: -1 91 | input name: "updateGroups", type: "bool", description: "", title: "Update state of groups immediately when bulb state changes (applicable only if not using V2 API/eventstream)", 92 | defaultValue: false 93 | input name: "logEnable", type: "bool", title: "Enable debug logging", defaultValue: true 94 | input name: "txtEnable", type: "bool", title: "Enable descriptionText logging", defaultValue: true 95 | } 96 | } 97 | 98 | void installed() { 99 | log.debug "installed()" 100 | if (device.currentValue("switch") == null) { 101 | // Populate initial device data (if V2 available; V1 users would need manual refresh) 102 | List bridgeCacheData = parent.getBridgeCacheV2()?.data ?: [] 103 | Map devCache = bridgeCacheData.find { it.type == "light" && it.id == device.deviceNetworkId.split("/").last() } 104 | if (devCache == null) devCache == bridgeCacheData.find { it.type == "light" && it.id_v1 == device.deviceNetworkId.split("/").last() } 105 | if (devCache != null) { 106 | log.warn devCache.id 107 | createEventsFromMapV2(devCache) 108 | } 109 | } 110 | initialize() 111 | } 112 | 113 | void updated() { 114 | log.debug "updated()" 115 | initialize() 116 | } 117 | 118 | void initialize() { 119 | log.debug "initialize()" 120 | if (logEnable) { 121 | log.debug "Debug logging will be automatically disabled in ${debugAutoDisableMinutes} minutes" 122 | runIn(debugAutoDisableMinutes*60, "debugOff") 123 | } 124 | } 125 | 126 | // Probably won't happen but... 127 | void parse(String description) { 128 | log.warn("Running unimplemented parse for: '${description}'") 129 | } 130 | 131 | /** 132 | * Parses V1 Hue Bridge device ID number out of Hubitat DNI for use with Hue V1 API calls 133 | * Hubitat DNI is created in format "CCH/BridgeMACAbbrev/Light/HueDeviceID", so just 134 | * looks for number after last "/" character; or try state if DNI is V2 format (avoid if posssible, 135 | * as Hue is likely to deprecate V1 ID data in future) 136 | */ 137 | String getHueDeviceIdV1() { 138 | String id = device.deviceNetworkId.split("/").last() 139 | 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 140 | id = state.id_v1?.split("/")?.last() 141 | if (state.id_v1 == null) { 142 | log.warn "Attempting to retrieve V1 ID but not in DNI or state." 143 | } 144 | } 145 | return id 146 | } 147 | 148 | void on(Number transitionTime = null) { 149 | if (logEnable == true) log.debug "on()" 150 | if (getHasV2DNI() == false) { 151 | onV1(transitionTime) 152 | return 153 | } 154 | Map bridgeCmd 155 | Integer scaledRate = transitionTime != null ? Math.round(transitionTime * 1000).toInteger() : getScaledOnTransitionTime() 156 | if (scaledRate == null) { 157 | bridgeCmd = ["on": ["on": true]] 158 | } 159 | else { 160 | bridgeCmd = ["on": ["on": true], "dynamics": ["duration": scaledRate]] 161 | } 162 | sendBridgeCommandV2(bridgeCmd) 163 | } 164 | 165 | void onV1(Number transitionTime = null) { 166 | if (logEnable == true) log.debug "onV1()" 167 | Map bridgeCmd 168 | Integer scaledRate = transitionTime != null ? Math.round(transitionTime * 10).toInteger() : getScaledOnTransitionTime() 169 | if (scaledRate == null) { 170 | bridgeCmd = ["on": true] 171 | } 172 | else { 173 | bridgeCmd = ["on": true, "transitiontime": scaledRate] 174 | } 175 | sendBridgeCommandV1(bridgeCmd) 176 | } 177 | 178 | void off(Number transitionTime = null) { 179 | if (logEnable == true) log.debug "off()" 180 | if (getHasV2DNI() == false) { 181 | offV1(transitionTime) 182 | return 183 | } 184 | Map bridgeCmd 185 | Integer scaledRate = transitionTime != null ? Math.round(transitionTime * 1000).toInteger() : getScaledOnTransitionTime() 186 | if (scaledRate == null) { 187 | bridgeCmd = ["on": ["on": false]] 188 | } 189 | else { 190 | bridgeCmd = ["on": ["on": false], "dynamics": ["duration": scaledRate]] 191 | } 192 | sendBridgeCommandV2(bridgeCmd) 193 | } 194 | 195 | void offV1(Number transitionTime = null) { 196 | if (logEnable == true) log.debug "offV1()" 197 | Map bridgeCmd 198 | Integer scaledRate = transitionTime != null ? Math.round(transitionTime * 10).toInteger() : null 199 | if (scaledRate == null) { 200 | bridgeCmd = ["on": false] 201 | } 202 | else { 203 | bridgeCmd = ["on": false, "transitiontime": scaledRate] 204 | } 205 | sendBridgeCommandV1(bridgeCmd) 206 | } 207 | 208 | void refresh() { 209 | log.warn "Refresh Hue Bridge device instead of individual device to update (all) bulbs/groups" 210 | } 211 | 212 | /** 213 | * Iterates over Hue light state commands/states in Hue format (e.g., ["on": true]) and does 214 | * a sendEvent for each relevant attribute; intended to be called when commands are sent 215 | * to Bridge or to parse/update light states based on data received from Bridge 216 | * @param bridgeMap Map of light states that are or would be sent to bridge OR state as received from 217 | * Bridge 218 | * @param isFromBridge Set to true if this is data read from Hue Bridge rather than intended to be sent 219 | * to Bridge; TODO: check if this is still needed now that pseudo-prestaging removed 220 | */ 221 | void createEventsFromMapV1(Map bridgeCommandMap, Boolean isFromBridge = false, Set keysToIgnoreIfSSEEnabledAndNotFromBridge=listKeysToIgnoreIfSSEEnabledAndNotFromBridge) { 222 | if (!bridgeCommandMap) { 223 | if (logEnable == true) log.debug "createEventsFromMapV1 called but map command empty or null; exiting" 224 | return 225 | } 226 | Map bridgeMap = bridgeCommandMap 227 | if (logEnable == true) log.debug "createEventsFromMapV1(): Preparing to create events from map${isFromBridge ? ' from Bridge' : ''}: ${bridgeMap}" 228 | if (!isFromBridge && keysToIgnoreIfSSEEnabledAndNotFromBridge && parent.getEventStreamOpenStatus() == true) { 229 | bridgeMap.keySet().removeAll(keysToIgnoreIfSSEEnabledAndNotFromBridge) 230 | if (logEnable == true) log.debug "Map after ignored keys removed: ${bridgeMap}" 231 | } 232 | String eventName, eventUnit, descriptionText 233 | def eventValue // could be String or number 234 | Boolean isOn = bridgeMap["on"] 235 | bridgeMap.each { 236 | switch (it.key) { 237 | case "on": 238 | eventName = "switch" 239 | eventValue = it.value ? "on" : "off" 240 | eventUnit = null 241 | if (device.currentValue(eventName) != eventValue) doSendEvent(eventName, eventValue, eventUnit) 242 | break 243 | case "bri": 244 | if (it.value == 0) break // skip invalid value if ever gets reported... 245 | eventName = "level" 246 | eventValue = scaleBriFromBridge(it.value, APIV1) 247 | eventUnit = "%" 248 | if (device.currentValue(eventName) != eventValue) { 249 | doSendEvent(eventName, eventValue, eventUnit) 250 | } 251 | break 252 | case "ct": 253 | eventName = "colorTemperature" 254 | if (it.value == 0) break // skip invalid value that sometimes appears 255 | eventValue = scaleCTFromBridge(it.value) 256 | eventUnit = "K" 257 | if (device.currentValue(eventName) != eventValue && eventValue != 0) { 258 | doSendEvent(eventName, eventValue, eventUnit) 259 | } 260 | setGenericTempName(eventValue) 261 | break 262 | case "reachable": 263 | eventName = "reachable" 264 | eventValue = it.value ? "true" : "false" 265 | eventUnit = null 266 | if (device.currentValue(eventName) != eventValue) { 267 | doSendEvent(eventName, eventValue, eventUnit) 268 | } 269 | break 270 | case "transitiontime": 271 | case "mode": 272 | case "alert": 273 | break 274 | default: 275 | break 276 | //log.warn "Unhandled key/value discarded: $it" 277 | } 278 | } 279 | } 280 | 281 | /** 282 | * (for V2 API) 283 | * Iterates over Hue light state states in Hue API v2 format (e.g., "on={on=true}") and does 284 | * a sendEvent for each relevant attribute; intended to be called when EventSocket data 285 | * received for device (as an alternative to polling) 286 | */ 287 | void createEventsFromMapV2(Map data) { 288 | if (logEnable == true) log.debug "createEventsFromMapV2($data)" 289 | String eventName, eventUnit, descriptionText 290 | def eventValue // could be String or number 291 | Boolean hasCT = data.color_temperature?.mirek != null 292 | data.each { String key, value -> 293 | switch (key) { 294 | case "on": 295 | eventName = "switch" 296 | eventValue = value.on ? "on" : "off" 297 | eventUnit = null 298 | if (device.currentValue(eventName) != eventValue) doSendEvent(eventName, eventValue, eventUnit) 299 | break 300 | case "dimming": 301 | if (value.brightness == 0) break // skip invalid value if ever gets reported... 302 | eventName = "level" 303 | eventValue = scaleBriFromBridge(value.brightness, APIV2) 304 | eventUnit = "%" 305 | if (device.currentValue(eventName) != eventValue && eventValue > 0) { 306 | doSendEvent(eventName, eventValue, eventUnit) 307 | } 308 | break 309 | case "color_temperature": 310 | if (!hasCT) { 311 | if (logEnable == true) "ignoring color_temperature because mirek null" 312 | return 313 | } 314 | if (value.mirek == 0) break // skip invalid if V2 ever reports this like V1 sometimes does... 315 | eventName = "colorTemperature" 316 | eventValue = scaleCTFromBridge(value.mirek) 317 | eventUnit = "K" 318 | if (device.currentValue(eventName) != eventValue) doSendEvent(eventName, eventValue, eventUnit) 319 | setGenericTempName(eventValue) 320 | break 321 | case "status": 322 | if (data.type == "zigbee_connectivity") { // not sure if any other types use this key, but just in case 323 | eventName = "reachable" 324 | if (value == "disconnected" || value == "connectivity_issue") { 325 | eventValue = "true" 326 | } 327 | else { 328 | eventValue = false 329 | } 330 | eventUnit = null 331 | if (device.currentValue(eventName) != eventValue) { 332 | doSendEvent(eventName, eventValue, eventUnit) 333 | } 334 | } 335 | case "id_v1": 336 | if (state.id_v1 != value) state.id_v1 = value 337 | break 338 | default: 339 | if (logEnable == true) "not handling: $key: $value" 340 | } 341 | } 342 | } 343 | 344 | /** 345 | * Sends HTTP PUT to Bridge using the either command map provided 346 | * @param commandMap Groovy Map (will be converted to JSON) of Hue API commands to send, e.g., [on: true] 347 | * @param createHubEvents Will iterate over Bridge command map and do sendEvent for all 348 | * affected device attributes (e.g., will send an "on" event for "switch" if ["on": true] in map) 349 | */ 350 | void sendBridgeCommandV1(Map commandMap, Boolean createHubEvents=true) { 351 | if (logEnable == true) log.debug "sendBridgeCommandV1($commandMap)" 352 | if (commandMap == null || commandMap == [:]) { 353 | if (logEnable == true) log.debug "Commands not sent to Bridge because command map null or empty" 354 | return 355 | } 356 | Map data = parent.getBridgeData() 357 | Map params = [ 358 | uri: data.fullHost, 359 | path: "/api/${data.username}/lights/${getHueDeviceIdV1()}/state", 360 | contentType: 'application/json', 361 | body: commandMap, 362 | ignoreSSLIssues: true, 363 | timeout: 15 364 | ] 365 | asynchttpPut("parseSendCommandResponseV1", params, createHubEvents ? commandMap : null) 366 | if (logEnable == true) log.debug "-- Command sent to Bridge! --" 367 | } 368 | 369 | /** 370 | * Parses response from Bridge (or not) after sendBridgeCommandV1. Updates device state if 371 | * appears to have been successful. 372 | * @param resp Async HTTP response object 373 | * @param data Map of commands sent to Bridge if specified to create events from map 374 | */ 375 | void parseSendCommandResponseV1(AsyncResponse resp, Map data) { 376 | if (logEnable == true) log.debug "Response from Bridge: ${resp.status}" 377 | if (checkIfValidResponse(resp) && data) { 378 | if (logEnable == true) log.debug " Bridge response valid; creating events from data map" 379 | createEventsFromMapV1(data) 380 | if ((data.containsKey("on") || data.containsKey("bri")) && settings["updateGroups"]) { 381 | parent.updateGroupStatesFromBulb(data, getHueDeviceIdV1()) 382 | } 383 | } 384 | else { 385 | if (logEnable == true) log.debug " Not creating events from map because not specified to do or Bridge response invalid" 386 | } 387 | } 388 | 389 | /** 390 | * Parses response from Bridge (or not) after sendBridgeCommandV2. Can optionally use V1-inspired 391 | * logic to update device states if `data` map provided. 392 | * @param resp Async HTTP response object 393 | * @param data Map of commands sent to Bridge if specified to create events from map 394 | */ 395 | void parseSendCommandResponseV2(AsyncResponse resp, Map data) { 396 | if (logEnable == true) log.debug "parseSendCommandResponseV2(): Response status from Bridge: ${resp.status}" 397 | if (checkIfValidResponse(resp) && data) { 398 | if (logEnable == true) log.debug " Bridge response valid; creating events from data map" 399 | createEventsFromMapV2(data) 400 | if ((data.containsKey("on") || data.containsKey("dimming")) && settings["updateGroups"]) { 401 | parent.updateGroupStatesFromBulb(data, getHueDeviceIdV2()) 402 | } 403 | } 404 | else { 405 | if (logEnable == true) log.debug " Not creating events from map because not specified to do or Bridge response invalid" 406 | } 407 | } 408 | 409 | /** 410 | * Sends HTTP PUT to Bridge using the V1-format map data provided 411 | * @param commandMap Groovy Map (will be converted to JSON) of Hue V1 API commands to send, e.g., [on: true] 412 | * @param createHubEvents Will iterate over Bridge command map and do sendEvent for all 413 | * affected device attributes (e.g., will send an "on" event for "switch" if ["on": true] in map) 414 | */ 415 | void sendBridgeCommandV2(Map commandMap, Boolean createHubEvents=false) { 416 | if (logEnable == true) log.debug "sendBridgeCommandV2($commandMap)" 417 | if (commandMap == null || commandMap == [:]) { 418 | if (logEnable == true) log.debug "Commands not sent to Bridge because command map null or empty" 419 | return 420 | } 421 | parent.bridgeAsyncPutV2("parseSendCommandResponseV2", this.device, "/resource/light/${getHueDeviceIdV2()}", 422 | commandMap, createHubEvents ? commandMap : null) 423 | if (logEnable == true) log.debug "-- Command sent to Bridge! --" 424 | } 425 | 426 | // ~~~ IMPORTED FROM RMoRobert.CoCoHue_Common_Lib ~~~ 427 | // Version 1.0.6 428 | // For use with CoCoHue drivers (not app) 429 | 430 | /** 431 | * 1.0.6 - Remove common bridgeAsyncPutV2() method (now call from parent app instead of driver) 432 | * 1.0.5 - Add common bridgeAsyncPutV2() method for asyncHttpPut (goal to reduce individual driver code) 433 | * 1.0.4 - Add common bridgeAsyncGetV2() method asyncHttpGet (goal to reduce individual driver code) 434 | * 1.0.3 - Add APIV1 and APIV2 "constants" 435 | * 1.0.2 - HTTP error handling tweaks 436 | */ 437 | 438 | void debugOff() { 439 | log.warn "Disabling debug logging" 440 | device.updateSetting("logEnable", [value:"false", type:"bool"]) 441 | } 442 | 443 | /** Performs basic check on data returned from HTTP response to determine if should be 444 | * parsed as likely Hue Bridge data or not; returns true (if OK) or logs errors/warnings and 445 | * returns false if not 446 | * @param resp The async HTTP response object to examine 447 | */ 448 | private Boolean checkIfValidResponse(hubitat.scheduling.AsyncResponse resp) { 449 | if (logEnable == true) log.debug "Checking if valid HTTP response/data from Bridge..." 450 | Boolean isOK = true 451 | if (resp.status < 400) { 452 | if (resp.json == null) { 453 | isOK = false 454 | if (resp.headers == null) log.error "Error: HTTP ${resp.status} when attempting to communicate with Bridge" 455 | else log.error "No JSON data found in response. ${resp.headers.'Content-Type'} (HTTP ${resp.status})" 456 | parent.sendBridgeDiscoveryCommandIfSSDPEnabled(true) // maybe IP changed, so attempt rediscovery 457 | parent.setBridgeOnlineStatus(false) 458 | } 459 | else if (resp.json) { 460 | if ((resp.json instanceof List) && resp.json.getAt(0).error) { 461 | // Bridge (not HTTP) error (bad username, bad command formatting, etc.): 462 | isOK = false 463 | log.warn "Error from Hue Bridge: ${resp.json[0].error}" 464 | // Not setting Bridge to offline when light/scene/group devices end up here because could 465 | // be old/bad ID and don't want to consider Bridge offline just for that (but also won't set 466 | // to online because wasn't successful attempt) 467 | } 468 | // Otherwise: probably OK (not changing anything because isOK = true already) 469 | } 470 | else { 471 | isOK = false 472 | log.warn("HTTP status code ${resp.status} from Bridge") 473 | // TODO: Update for mDNS if/when switch: 474 | if (resp?.status >= 400) parent.sendBridgeDiscoveryCommandIfSSDPEnabled(true) // maybe IP changed, so attempt rediscovery 475 | parent.setBridgeOnlineStatus(false) 476 | } 477 | if (isOK == true) parent.setBridgeOnlineStatus(true) 478 | } 479 | else { 480 | log.warn "Error communicating with Hue Bridge: HTTP ${resp?.status}" 481 | isOK = false 482 | } 483 | return isOK 484 | } 485 | 486 | void doSendEvent(String eventName, eventValue, String eventUnit=null, Boolean forceStateChange=false) { 487 | //if (logEnable == true) log.debug "doSendEvent($eventName, $eventValue, $eventUnit)" 488 | String descriptionText = "${device.displayName} ${eventName} is ${eventValue}${eventUnit ?: ''}" 489 | if (settings.txtEnable == true) log.info(descriptionText) 490 | if (eventUnit) { 491 | if (forceStateChange == true) sendEvent(name: eventName, value: eventValue, descriptionText: descriptionText, unit: eventUnit, isStateChange: true) 492 | else sendEvent(name: eventName, value: eventValue, descriptionText: descriptionText, unit: eventUnit) 493 | } else { 494 | if (forceStateChange == true) sendEvent(name: eventName, value: eventValue, descriptionText: descriptionText, isStateChange: true) 495 | else sendEvent(name: eventName, value: eventValue, descriptionText: descriptionText) 496 | } 497 | } 498 | 499 | // HTTP methods (might be better to split into separate library if not needed for some?) 500 | 501 | /** Performs asynchttpGet() to Bridge using data retrieved from parent app or as passed in 502 | * @param callbackMethod Callback method 503 | * @param clipV2Path The Hue V2 API path ('/clip/v2' is automatically prepended), e.g. '/resource' or '/resource/light' 504 | * @param bridgeData Bridge data from parent getBridgeData() call, or will call this method on parent if null 505 | * @param data Extra data to pass as optional third (data) parameter to asynchtttpGet() method 506 | */ 507 | void bridgeAsyncGetV2(String callbackMethod, String clipV2Path, Map bridgeData = null, Map data = null) { 508 | if (bridgeData == null) { 509 | bridgeData = parent.getBridgeData() 510 | } 511 | Map params = [ 512 | uri: "https://${bridgeData.ip}", 513 | path: "/clip/v2${clipV2Path}", 514 | headers: ["hue-application-key": bridgeData.username], 515 | contentType: "application/json", 516 | timeout: 15, 517 | ignoreSSLIssues: true 518 | ] 519 | asynchttpGet(callbackMethod, params, data) 520 | } 521 | 522 | // REMOVED, now call from parent app instead of driver: 523 | // /** Performs asynchttpPut() to Bridge using data retrieved from parent app or as passed in 524 | // * @param callbackMethod Callback method 525 | // * @param clipV2Path The Hue V2 API path ('/clip/v2' is automatically prepended), e.g. '/resource' or '/resource/light' 526 | // * @param body Body data, a Groovy Map representing JSON for the Hue V2 API command, e.g., [on: [on: true]] 527 | // * @param bridgeData Bridge data from parent getBridgeData() call, or will call this method on parent if null 528 | // * @param data Extra data to pass as optional third (data) parameter to asynchtttpPut() method 529 | // */ 530 | // void bridgeAsyncPutV2(String callbackMethod, String clipV2Path, Map body, Map bridgeData = null, Map data = null) { 531 | // if (bridgeData == null) { 532 | // bridgeData = parent.getBridgeData() 533 | // } 534 | // Map params = [ 535 | // uri: "https://${bridgeData.ip}", 536 | // path: "/clip/v2${clipV2Path}", 537 | // headers: ["hue-application-key": bridgeData.username], 538 | // contentType: "application/json", 539 | // body: body, 540 | // timeout: 15, 541 | // ignoreSSLIssues: true 542 | // ] 543 | // asynchttpPut(callbackMethod, params, data) 544 | // if (logEnable == true) log.debug "Command sent to Bridge: $body at ${clipV2Path}" 545 | // pauseExecution(200) // see if helps HTTP 429 errors? 546 | // } 547 | 548 | 549 | // ~~~ IMPORTED FROM RMoRobert.CoCoHue_Constants_Lib ~~~ 550 | // Version 1.0.0 551 | 552 | // -------------------------------------- 553 | // APP AND DRIVER NAMESPACE AND NAMES: 554 | // -------------------------------------- 555 | @Field static final String NAMESPACE = "RMoRobert" 556 | @Field static final String DRIVER_NAME_BRIDGE = "CoCoHue Bridge" 557 | @Field static final String DRIVER_NAME_BUTTON = "CoCoHue Button" 558 | @Field static final String DRIVER_NAME_CT_BULB = "CoCoHue CT Bulb" 559 | @Field static final String DRIVER_NAME_DIMMABLE_BULB = "CoCoHue Dimmable Bulb" 560 | @Field static final String DRIVER_NAME_GROUP = "CoCoHue Group" 561 | @Field static final String DRIVER_NAME_MOTION = "CoCoHue Motion Sensor" 562 | @Field static final String DRIVER_NAME_CONTACT = "CoCoHue Contact Sensor" 563 | @Field static final String DRIVER_NAME_PLUG = "CoCoHue Plug" 564 | @Field static final String DRIVER_NAME_RGBW_BULB = "CoCoHue RGBW Bulb" 565 | @Field static final String DRIVER_NAME_RGB_BULB = "CoCoHue RGB Bulb" 566 | @Field static final String DRIVER_NAME_SCENE = "CoCoHue Scene" 567 | 568 | // -------------------------------------- 569 | // DNI PREFIX for child devices: 570 | // -------------------------------------- 571 | @Field static final String DNI_PREFIX = "CCH" 572 | 573 | // -------------------------------------- 574 | // OTHER: 575 | // -------------------------------------- 576 | // Used in app and Bridge driver, may eventually find use in more: 577 | @Field static final String APIV1 = "V1" 578 | @Field static final String APIV2 = "V2" 579 | 580 | // ~~~ IMPORTED FROM RMoRobert.CoCoHue_Bri_Lib ~~~ 581 | // Version 1.0.5 582 | 583 | // 1.0.5 - allow V2 for all commands 584 | // 1.0.4 - accept String for setLevel() level also 585 | // 1.0.3 - levelhandling tweaks 586 | 587 | // "SwitchLevel" commands: 588 | 589 | void startLevelChange(String direction) { 590 | if (logEnable == true) log.debug "startLevelChange($direction)..." 591 | if (getHasV2DNI() == true) { 592 | Map cmd = [ 593 | "dimming_delta": ["brightness_delta": 100, "action": (direction == "up" ? "up" : "down")], 594 | "dynamics": ["duration": ((settings["levelChangeRate"] == "fast" || !settings["levelChangeRate"]) ? 595 | 3000 : (settings["levelChangeRate"] == "slow" ? 6000 : 4500))]] 596 | sendBridgeCommandV2(cmd, false) 597 | } 598 | else { 599 | Map cmd = ["bri": (direction == "up" ? 254 : 1), 600 | "transitiontime": ((settings["levelChangeRate"] == "fast" || !settings["levelChangeRate"]) ? 601 | 30 : (settings["levelChangeRate"] == "slow" ? 60 : 45))] 602 | sendBridgeCommandV1(cmd, false) 603 | } 604 | } 605 | 606 | void stopLevelChange() { 607 | if (logEnable == true) log.debug "stopLevelChange()..." 608 | if (getHasV2DNI() == true) { 609 | Map cmd = ["dimming_delta": ["action": "stop"]] 610 | sendBridgeCommandV2(cmd, false) 611 | } 612 | else { 613 | Map cmd = ["bri_inc": 0] 614 | sendBridgeCommandV1(cmd, false) 615 | } 616 | } 617 | 618 | void setLevel(value) { 619 | if (logEnable == true) log.debug "setLevel($value)" 620 | setLevel(value, ((transitionTime != null ? transitionTime.toFloat() : defaultLevelTransitionTime.toFloat())) / 1000) 621 | } 622 | 623 | void setLevel(value, rate) { 624 | if (logEnable == true) log.debug "setLevel(Object $value, Object $rate)" 625 | Float floatLevel = Float.parseFloat(value.toString()) 626 | Integer intLevel = Math.round(floatLevel) 627 | Float floatRate = Float.parseFloat(rate.toString()) 628 | setLevel(intLevel, floatRate) 629 | } 630 | 631 | void setLevel(Number value, Number rate) { 632 | if (logEnable == true) log.debug "setLevel(Number $value, Number $rate)" 633 | if (getHasV2DNI() == false) { 634 | setLevelV1(value, rate) 635 | return 636 | } 637 | if (value < 0) value = 0.01 638 | else if (value > 100) value = 100 639 | else if (value == 0) { 640 | off(rate) 641 | return 642 | } 643 | Integer newLevel = scaleBriToBridge(value, APIV2) 644 | Integer scaledRate = (rate * 1000).toInteger() 645 | Map bridgeCmd = [ 646 | "on": ["on": true], 647 | "dimming": ["brightness": scaleBriToBridge(value, APIV2)], 648 | "dynamics": ["duration": scaledRate] 649 | ] 650 | sendBridgeCommandV2(bridgeCmd) 651 | } 652 | 653 | void setLevelV1(Number value, Number rate) { 654 | if (logEnable == true) log.debug "setLevel($value, $rate)" 655 | if (value < 0) value = 1 656 | else if (value > 100) value = 100 657 | else if (value == 0) { 658 | off(rate) 659 | return 660 | } 661 | Integer newLevel = scaleBriToBridge(value, APIV1) 662 | Integer scaledRate = (rate * 10).toInteger() 663 | Map bridgeCmd = [ 664 | "on": true, 665 | "bri": newLevel, 666 | "transitiontime": scaledRate 667 | ] 668 | sendBridgeCommandV1(bridgeCmd) 669 | } 670 | 671 | /** 672 | * Reads device preference for on() transition time, or provides default if not available; device 673 | * can use input(name: onTransitionTime, ...) to provide this 674 | */ 675 | Integer getScaledOnTransitionTime(String apiVersion=APIV1) { 676 | Integer scaledRate = null 677 | if (settings.onTransitionTime == null || settings.onTransitionTime == "-2" || settings.onTransitionTime == -2) { 678 | // keep null; will result in not specifiying with command 679 | } 680 | else { 681 | if (apiVersion == APIV1) scaledRate = Math.round(settings.onTransitionTime.toFloat() / 100) 682 | else scaledRate = settings.onTransitionTime.toInteger() 683 | } 684 | return scaledRate 685 | } 686 | 687 | 688 | /** 689 | * Reads device preference for off() transition time, or provides default if not available; device 690 | * can use input(name: onTransitionTime, ...) to provide this 691 | */ 692 | Integer getScaledOffTransitionTime(String apiVersion=APIV1) { 693 | Integer scaledRate = null 694 | if (settings.offTransitionTime == null || settings.offTransitionTime == "-2" || settings.offTransitionTime == -2) { 695 | // keep null; will result in not specifiying with command 696 | } 697 | else if (settings.offTransitionTime == "-1" || settings.offTransitionTime == -1) { 698 | scaledRate = getScaledOnTransitionTime() 699 | } 700 | else { 701 | if (apiVersion == APIV1) scaledRate = Math.round(settings.offTransitionTime.toFloat() / 100) 702 | else scaledRate = settings.offTransitionTime.toInteger() 703 | } 704 | return scaledRate 705 | } 706 | 707 | // Internal methods for scaling 708 | 709 | 710 | /** 711 | * Scales Hubitat's 1-100 brightness levels to Hue Bridge's 1-254 (or 0-100) 712 | * @param apiVersion: Use APIV1/"V1" (default) for classic, 1-254 API values; use APIV2 for v2/SSE 0.0-100.0 values (note: 0.0 is on) 713 | */ 714 | Number scaleBriToBridge(Number hubitatLevel, String apiVersion=APIV1) { 715 | if (apiVersion == APIV1) { 716 | Integer scaledLevel 717 | scaledLevel = Math.round(hubitatLevel == 1 ? 1 : hubitatLevel.toBigDecimal() / 100 * 254) 718 | return Math.round(scaledLevel) as Integer 719 | } 720 | else { 721 | BigDecimal scaledLevel 722 | // for now, a quick cheat to make 1% the Hue minimum (should scale other values proportionally in future) 723 | scaledLevel = hubitatLevel == 1 ? 0.0 : hubitatLevel.toBigDecimal().setScale(2, java.math.RoundingMode.HALF_UP) 724 | return scaledLevel 725 | } 726 | } 727 | 728 | /** 729 | * Scales Hue Bridge's 1-254 brightness levels to Hubitat's 1-100 (or 0-100) 730 | * @param apiVersion: Use "1" (default) for classic, 1-254 API values; use "2" for v2/SSE 0.0-100.0 values (note: 0.0 is on) 731 | */ 732 | Integer scaleBriFromBridge(Number bridgeLevel, String apiVersion=APIV1) { 733 | Integer scaledLevel 734 | if (apiVersion == APIV1) { 735 | scaledLevel = Math.round(bridgeLevel.toBigDecimal() / 254 * 100) 736 | if (scaledLevel < 1) scaledLevel = 1 737 | } 738 | else { 739 | // for now, a quick cheat to make 1% the Hue minimum (should scale other values proportionally in future) 740 | scaledLevel = Math.round(bridgeLevel <= 1.49 && bridgeLevel > 0.001 ? 1 : bridgeLevel) 741 | } 742 | return scaledLevel 743 | } 744 | 745 | // ~~~ IMPORTED FROM RMoRobert.CoCoHue_CT_Lib ~~~ 746 | // Version 1.0.6 747 | 748 | void setColorTemperature(String colorTemperature, level=null, transitionTime=null) { 749 | if (logEnable == true) log.debug "setColorTemperature(Object $colorTemperature, $level, $transitionTime)" 750 | setColorTemperature(colorTemperature.toInteger(), 751 | level != null ? level.toBigDecimal() : null, 752 | transitionTime != null ? transitionTime.toBigDecimal() : null 753 | ) 754 | } 755 | 756 | void setColorTemperature(Number colorTemperature, Number level = null, Number transitionTime = null) { 757 | if (logEnable == true) log.debug "setColorTemperature($colorTemperature, $level, $transitionTime)" 758 | if (level == 0) { 759 | off() 760 | return 761 | } 762 | state.lastKnownColorMode = "CT" 763 | if (getHasV2DNI() == true) { 764 | Integer newCT = scaleCTToBridge(colorTemperature) 765 | Integer scaledRate = getScaledCTTransitionTime(APIV2) 766 | if (transitionTime != null) { 767 | scaledRate = (transitionTime * 1000) as Integer 768 | } 769 | Map bridgeCmd = ["on": ["on": true], "color_temperature": ["mirek": newCT]] 770 | if (scaledRate != null) bridgeCmd << ["dynamics": ["duration": scaledRate]] 771 | if (level) { 772 | bridgeCmd << ["dimming": ["brightness": scaleBriToBridge(level, APIV2)]] 773 | } 774 | sendBridgeCommandV2(bridgeCmd, false) 775 | } 776 | else { 777 | setColorTemperatureV1(colorTemperature, level, transitionTime) 778 | } 779 | } 780 | 781 | void setColorTemperatureV1(Number colorTemperature, Number level = null, Number transitionTime = null) { 782 | if (logEnable == true) log.debug "setColorTemperatureV1($colorTemperature, $level, $transitionTime)" 783 | Integer newCT = scaleCTToBridge(colorTemperature) 784 | Integer scaledRate = getScaledCTTransitionTime(APIV1) 785 | if (transitionTime != null) { 786 | scaledRate = (transitionTime * 10) as Integer 787 | } 788 | Map bridgeCmd = ["on": true, "ct": newCT] 789 | if (scaledRate != null) bridgeCmd << ["transitiontime": scaledRate] 790 | if (level) { 791 | bridgeCmd << ["bri": scaleBriToBridge(level, APIV1)] 792 | } 793 | sendBridgeCommandV1(bridgeCmd) 794 | } 795 | 796 | /** 797 | * Scales CT from Kelvin (Hubitat units) to mireds (Hue units) 798 | */ 799 | Integer scaleCTToBridge(Number kelvinCT, Boolean checkIfInRange=true) { 800 | Integer mireds = Math.round(1000000/kelvinCT) as Integer 801 | if (checkIfInRange == true) { 802 | if (mireds < minMireds) mireds = minMireds 803 | else if (mireds > maxMireds) mireds = maxMireds 804 | } 805 | return mireds 806 | } 807 | 808 | /** 809 | * Scales CT from mireds (Hue units) to Kelvin (Hubitat units) 810 | */ 811 | Integer scaleCTFromBridge(Number mireds) { 812 | if (mireds == 0) return 0 813 | Integer kelvin = Math.round(1000000/mireds) as Integer 814 | return kelvin 815 | } 816 | 817 | /** 818 | * Reads device preference for CT transition time, or provides default if not available; device 819 | * can use input(name: ctTransitionTime, ...) to provide this 820 | */ 821 | Integer getScaledCTTransitionTime(String apiVersion = APIV1) { 822 | Integer scaledRate = null 823 | if (settings.ctTransitionTime == "-2" || settings.ctTransitionTime == -2) { 824 | // keep null; will result in not specifiying with command 825 | } 826 | else if (settings.ctTransitionTime == null || settings.ctTransitionTime == "-1" || settings.ctTransitionTime == -1) { 827 | String levelTT = settings.transitionTime 828 | if (levelTT != null) { 829 | scaledRate = Math.round(levelTT.toFloat()) 830 | } 831 | else { 832 | scaledRate = (defaultLevelTransitionTime != null) ? defaultLevelTransitionTime : 400 833 | } 834 | } 835 | else { 836 | scaledRate = Math.round(settings.ctTransitionTime.toFloat()) 837 | } 838 | if (apiVersion == APIV1 && scaledRate) { 839 | scaledRate = scaledRate / 100 840 | } 841 | return scaledRate 842 | } 843 | 844 | void setGenericTempName(temp) { 845 | if (!temp) return 846 | String genericName = convertTemperatureToGenericColorName(temp) 847 | if (device.currentValue("colorName") != genericName) doSendEvent("colorName", genericName) 848 | } 849 | 850 | 851 | // ~~~ IMPORTED FROM RMoRobert.CoCoHue_Flash_Lib ~~~ 852 | // Version 1.0.2 853 | 854 | void flash() { 855 | if (logEnable == true) log.debug "flash()" 856 | if (getHasV2DNI() == true) { 857 | if (settings.txtEnable == true) log.info("${device.displayName} started ~18-hr flash cycle") 858 | Map cmd = ["signaling": ["signal": "on_off", "duration": 65534000]] 859 | // Possible alternative, likely more similar to V1 behavior if needed: 860 | //Map cmd = ["alert": ["action": "breathe"]] 861 | sendBridgeCommandV2(cmd, false) 862 | } 863 | else { 864 | if (settings.txtEnable == true) log.info("${device.displayName} started 15-cycle flash") 865 | Map cmd = ["alert": "lselect"] 866 | sendBridgeCommandV1(cmd, false) 867 | } 868 | } 869 | 870 | void flashOnce() { 871 | if (logEnable == true) log.debug "flashOnce()" 872 | if (settings.txtEnable == true) log.info("${device.displayName} started 1-cycle flash") 873 | if (getHasV2DNI() == true) { 874 | Map cmd 875 | // Approximation for groups since don't support 'identify': 876 | if (device.deviceNetworkId.tokenize("/")[-2] == "Group") cmd = ["signaling": ["signal": "on_off", "duration": 1500]] 877 | // Otherwise, use normal method (API docs suggest this could change and suggest already doesn't only do single, but always has for me?): 878 | else cmd = ["identify": ["action": "identify"]] 879 | sendBridgeCommandV2(cmd, false) 880 | } 881 | else { 882 | Map cmd = ["alert": "select"] 883 | sendBridgeCommandV1(cmd, false) 884 | } 885 | } 886 | 887 | void flashOff() { 888 | if (logEnable == true) log.debug "flashOff()" 889 | if (settings.txtEnable == true) log.info("${device.displayName} was sent command to stop flash") 890 | if (getHasV2DNI() == true) { 891 | Map cmd = ["signaling": ["signal": "no_signal", "duration": 0]] 892 | sendBridgeCommandV2(cmd, false) 893 | } 894 | else { 895 | Map cmd = ["alert": "none"] 896 | sendBridgeCommandV1(cmd, false) 897 | } 898 | } 899 | 900 | // ~~~ IMPORTED FROM RMoRobert.CoCoHue_V2_DNI_Tools_Lib ~~~ 901 | // Version 1.0.0 902 | 903 | 904 | /** 905 | * Parses V2 Hue Bridge device ID out of Hubitat DNI for use with Hue V2 API calls 906 | * Hubitat DNI is created in format "CCH/BridgeMACAbbrev/Scene/HueDeviceID", so just 907 | * looks for string after last "/" character 908 | */ 909 | String getHueDeviceIdV2() { 910 | if (getHasV2DNI() == true) { 911 | return device.deviceNetworkId.split("/").last() 912 | } 913 | else { 914 | log.error "DNI not in V2 format but attempeting to fetch API V2 ID. Cannot continue." 915 | } 916 | } 917 | 918 | Boolean getHasV2DNI() { 919 | String id = device.deviceNetworkId.split("/").last() 920 | if (id.length() > 32) { // max length of Hue V1 ID per regex in V2 API docs 921 | return true 922 | } 923 | else { 924 | return false 925 | } 926 | } --------------------------------------------------------------------------------