├── .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 | }
--------------------------------------------------------------------------------