├── .gitignore ├── images ├── NewDriverButton.png ├── InstalledAppsList.png ├── NewDriverExample.png ├── RokuTVDeviceInfo.png ├── RokuTVPreferences.png ├── RokuTVCurrentState.png ├── RokuTVStateVariables.png ├── AddVirtualDeviceButton.png └── HubitatMenuDriversCode.png ├── .groovylintrc.json ├── APACHE-LICENSE.md ├── tesla ├── LICENSE.md ├── README.md ├── device │ └── tesla.groovy └── app │ └── tesla-connect.groovy ├── devices ├── packageManifest.json └── timer-device.groovy ├── LICENSE.md ├── MIT-LICENSE.md ├── hue ├── LICENSE.md ├── device │ ├── advanced-hue-light-sensor.groovy │ ├── advanced-hue-temperature-sensor.groovy │ ├── advanced-hue-tap-sensor.groovy │ ├── advanced-hue-motion-sensor.groovy │ ├── advanced-hue-dimmer-sensor.groovy │ ├── advanced-hue-runlesswires-sensor.groovy │ └── advanced-hue-group.groovy ├── README.md ├── packageManifest.json └── libs │ └── HueFunctions.groovy ├── roku ├── LICENSE.md ├── packageManifest.json ├── README.md └── app │ └── roku-connect.groovy ├── revproxy ├── LICENSE.md ├── packageManifest.json ├── app │ ├── simple-reverse-proxy-service.groovy │ └── simple-proxy-provider.groovy └── README.md ├── iopool ├── packageManifest.json ├── README.md ├── app │ └── iopool-connect.groovy └── device │ └── eco-sensor ├── repository.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | hue/rest/hueTest.rest 3 | *.rest 4 | -------------------------------------------------------------------------------- /images/NewDriverButton.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apwelsh/hubitat/HEAD/images/NewDriverButton.png -------------------------------------------------------------------------------- /images/InstalledAppsList.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apwelsh/hubitat/HEAD/images/InstalledAppsList.png -------------------------------------------------------------------------------- /images/NewDriverExample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apwelsh/hubitat/HEAD/images/NewDriverExample.png -------------------------------------------------------------------------------- /images/RokuTVDeviceInfo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apwelsh/hubitat/HEAD/images/RokuTVDeviceInfo.png -------------------------------------------------------------------------------- /images/RokuTVPreferences.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apwelsh/hubitat/HEAD/images/RokuTVPreferences.png -------------------------------------------------------------------------------- /images/RokuTVCurrentState.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apwelsh/hubitat/HEAD/images/RokuTVCurrentState.png -------------------------------------------------------------------------------- /images/RokuTVStateVariables.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apwelsh/hubitat/HEAD/images/RokuTVStateVariables.png -------------------------------------------------------------------------------- /images/AddVirtualDeviceButton.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apwelsh/hubitat/HEAD/images/AddVirtualDeviceButton.png -------------------------------------------------------------------------------- /images/HubitatMenuDriversCode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apwelsh/hubitat/HEAD/images/HubitatMenuDriversCode.png -------------------------------------------------------------------------------- /.groovylintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "recommended", 3 | "rules": { 4 | "BlockStartsWithBlankLine": { 5 | "enabled": false 6 | }, 7 | "LineLength": { 8 | "enabled": false 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /APACHE-LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2020 Armand Welsh 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /tesla/LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2020 Armand Welsh 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /devices/packageManifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageName": "Countdown Timer", 3 | "author": "Armand Welsh", 4 | "minimumHEVersion": "0.0", 5 | "licenseFile": "https://raw.githubusercontent.com/apwelsh/hubitat/master/APACHE-LICENSE.md", 6 | "dateReleased": "2020-05-10", 7 | "drivers": [ 8 | { 9 | "id": "622d6f64-7c1d-4d7f-9de0-e4038628438a", 10 | "name": "Timer Device", 11 | "namespace": "apwelsh", 12 | "location": "https://raw.githubusercontent.com/apwelsh/hubitat/master/devices/timer-device.groovy", 13 | "required": true, 14 | "version": "1.10" 15 | } 16 | ], 17 | "releaseNotes": "Version 1.10 Update fixes timeremaining overflow for very large numbers, and add display support for days" 18 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2020 Armand Peter Welsh 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /MIT-LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2019 Armand Peter Welsh 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /hue/LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2020 Armand Peter Welsh 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /roku/LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2020 Armand Peter Welsh 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /revproxy/LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2020 Armand Peter Welsh 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /iopool/packageManifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageName": "iopool EcO Pool/Spa Monitor", 3 | "author": "Armand Welsh", 4 | "minimumHEVersion": "2.2.6", 5 | "licenseFile": "https://raw.githubusercontent.com/apwelsh/hubitat/master/LICENSE.md", 6 | "documentationLink": "https://raw.githubusercontent.com/apwelsh/hubitat/master/iopool/README.md", 7 | "communityLink": "", 8 | "dateReleased": "2022-07-03", 9 | "apps": [ 10 | { 11 | "id": "5e6e32c5-0b47-4b04-a230-c3f2ce73c07d", 12 | "name": "iopool Connect", 13 | "namespace": "apwelsh", 14 | "location": "https://raw.githubusercontent.com/apwelsh/hubitat/master/iopool/app/iopool-connect.groovy", 15 | "required": true, 16 | "oauth": false, 17 | "primary": true, 18 | "version": "1.0.1" 19 | } 20 | ], 21 | "drivers": [ 22 | { 23 | "id": "a9654937-91ff-4a1d-817b-0f70faec38a0", 24 | "name": "EcO Water Quality Sensor", 25 | "namespace": "apwelsh", 26 | "location": "https://raw.githubusercontent.com/apwelsh/hubitat/master/iopool/device/eco-sensor", 27 | "required": true, 28 | "version": "1.0.0" 29 | } 30 | ], 31 | "version": "1.0.1", 32 | "releaseNotes": "1.0.1 - Fixed NullPointerException in app code \n1.0.0 - Initial release" 33 | } -------------------------------------------------------------------------------- /iopool/README.md: -------------------------------------------------------------------------------- 1 | # iopool EcO Pool & Spa Monitor Integration for Hubitat [![Donate](https://img.shields.io/badge/donate-PayPal-blue.svg?logo=paypal&style=plastic)](https://www.paypal.com/donate?hosted_button_id=XZXSPZWAABU8J) 2 | 3 | The iopool EcO Pool & Spa Monitor Integration for Hubitat brings leverages the iopool public API (cloud based) to link your Hubitat hub with the iopool connected devices. 4 | 5 | To use the iopool public API, you will need to obtain an API key from iopool. This can be done by emailing iopool support, or leveraging the iopool app. 6 | 7 | The application works best with the iopool gateway 8 | 9 | To install this integration, it is highly recommended to use the Hubitat Package Manager, however a manual install is supported as well. Please follow Hubitat's guides on install custom applications and drivers. This integrations requires both the eco-sensor.groovy and the iopool-connect.groovy to function. 10 | 11 | ## Support the Author 12 | Please consider donating. This app took a lot of work to make. 13 | Any donations received will be used to fund additional Hue based development. 14 | 15 | [![paypal](https://www.paypalobjects.com/en_US/i/btn/btn_donateCC_LG.gif)](https://www.paypal.com/donate?hosted_button_id=XZXSPZWAABU8J) 16 | 17 | ## License 18 | 19 | This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details. 20 | -------------------------------------------------------------------------------- /revproxy/packageManifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageName": "Simple Reverse Proxy Service", 3 | "author": "Armand Welsh", 4 | "minimumHEVersion": "2.4.0", 5 | "licenseFile": "https://raw.githubusercontent.com/apwelsh/hubitat/master/revproxy/LICENSE.md", 6 | "documentationLink": "https://github.com/apwelsh/hubitat/tree/master/revproxy", 7 | "dateReleased": "2025-02-25", 8 | "apps": [ 9 | { 10 | "id": "3667c53f-55e9-4008-b56c-6ff91c9f666b", 11 | "name": "Simple Reverse Proxy Service", 12 | "namespace": "apwelsh", 13 | "location": "https://raw.githubusercontent.com/apwelsh/hubitat/master/revproxy/app/simple-reverse-proxy-service.groovy", 14 | "required": true, 15 | "oauth": false, 16 | "primary": true, 17 | "version": "1.0.0" 18 | }, 19 | { 20 | "id": "6c229ee3-593d-4389-8bdd-0972b44ca83c", 21 | "name": "Simple Proxy Provider", 22 | "namespace": "apwelsh", 23 | "location": "https://raw.githubusercontent.com/apwelsh/hubitat/master/revproxy/app/simple-proxy-provider.groovy", 24 | "required": true, 25 | "oauth": true, 26 | "primary": false, 27 | "version": "1.0.0" 28 | } 29 | ], 30 | "version": "1.0.0", 31 | "releaseNotes": "v1.0.0\nEnabling the reverse proxy service may expose your Hubitat hub and connected network to significant security risks if proper precautions are not taken. Before enabling remote access, ensure that you have configured adequate network security measures (such as firewalls, VPNs, or strict access controls). By using this service, you assume full responsibility for any unauthorized access or security breaches that may occur. Use this feature only on trusted networks and at your own risk." 32 | } -------------------------------------------------------------------------------- /tesla/README.md: -------------------------------------------------------------------------------- 1 | # Tesla Connect 2 | 3 | This is my implementation of the **Tesla Connect App and Tesla Device** adapted and enhanced for Hubitat and to meet my needs. 4 | 5 | NOTICE: This driver is a very early implementation 6 | 7 | ## Getting Started 8 | 9 | To use this software, you must download two files: 10 | - [tesla.groovy](device/tesla.groovy) 11 | - [tesla-connect.groovy](device/tesla-connect.groovy) 12 | 13 | ## Installation 14 | Sign into your Hubitat device, and add the devices and apps included in this project. To do so, from the menu select the **"Drivers Code"** menu option. 15 | 16 | ![](images/HubitatMenuDriversCode.png) 17 | 18 | Next, click the **"(+) New Driver"** button 19 | 20 | ![](images/NewDriverButton.png) 21 | 22 | ### Tesla Device 23 | Select the import button, and put in the URL to the [tesla.groovy](device/tesla.groovy), Click the import button, and the new driver is ready. 24 | Click **Save**. 25 | 26 | 27 | ### Tesla Connect App 28 | Next, navigate to **"Apps Code"** 29 | and repeat the process for the [tesla-connect.groovy](/app/tesla-connect.groovy) app. 30 | 31 | ## Configuration 32 | 33 | The configuration is quite simple, and tries to be as automatic as possible. Once everything is installed, go to Apps, and select **"Add User Apps"** then select the **Tesla Connect** app. 34 | 35 | Once the app is installed, open the Tesla Connect app (this normally happens automatically after install) and enter you Tesla Connect credentials. Once signed in, you will receive a list of all your connected vehivles. Select the vehicle(s) to install, and click next. This will install the Tesla device for each selected vehicle. Now you are ready to configure and manage each vehicle. 36 | 37 | ### Prerequisite 38 | For smart home automation to work reliably, it is highly recommended that each installed vehicle be configured to join your wifi network, and to always be assigned the same IP Address (check your router documentation of how to configure DHCP reservations). This is not required, but it will help to ensure the best results for consistency. 39 | 40 | **Not yet implemented** 41 | Presence based on distance from home 42 | Custom attribute for geofenced home range 43 | 44 | ### Status Updates 45 | January 20, 2020 46 | - Initial implementation with minor changes from the original SmartThings implementation to adapt this solution to my needs 47 | 48 | ## License 49 | 50 | This project is licensed under the Apache 2.0 License - see the [LICENSE.md](LICENSE.md) file for details. Portions of this code are licensed from Trent Foley (https://github.com/trentfoley) 51 | 52 | ## Acknowledgments 53 | This software would not be possible without the efforts and free sharing of information provided by the original author of the [SmartThings Tesla-Connect App](https://github.com/trentfoley/SmartThingsPublic/tree/master/smartapps/trentfoley/tesla-connect.src) and [SmartThings Tesa Device](https://github.com/trentfoley/SmartThingsPublic/tree/master/devicetypes/trentfoley/tesla.src) created by [Trent Foley](https://github.com/trentfoley). 54 | -------------------------------------------------------------------------------- /revproxy/app/simple-reverse-proxy-service.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Simple Reverse Proxy Service 3 | * 4 | * Parent app that manages multiple Simple Proxy Provider child apps. 5 | * 6 | * SUMMARY: 7 | * This service manages multiple Simple Proxy Provider child apps that act as reverse proxies. 8 | * Each child proxy forwards incoming web requests to a specified target URL and returns the upstream 9 | * response. This allows you to expose content from your private network to trusted external hosts without 10 | * directly exposing your network. 11 | * 12 | * BENEFITS: 13 | * Using a reverse proxy enables selective exposure of internal resources, adding a layer of abstraction 14 | * and security. Trusted external services (e.g., sharptools.io) can securely access specific data on your 15 | * hub's network while the proxy handles requests in a controlled manner. This helps maintain security 16 | * while providing remote access to designated content. 17 | * 18 | * DISCLAIMER: 19 | * Enabling the reverse proxy service may expose your Hubitat hub to significant security risks if proper 20 | * precautions are not taken. Before enabling remote access, ensure that you have configured adequate network 21 | * security measures (such as firewalls, VPNs, or strict access controls). By using this service, you assume 22 | * full responsibility for any unauthorized access or security breaches that may occur. Use this feature only on 23 | * trusted networks and at your own risk. 24 | */ 25 | definition( 26 | name: "Simple Reverse Proxy Service", 27 | namespace: "apwelsh", 28 | author: "Armand Welsh", 29 | description: "Parent app to manage multiple Simple Proxy Provider child apps. Use with caution!", 30 | category: "Utility", 31 | iconUrl: "", 32 | iconX2Url: "", 33 | singleInstance: true 34 | ) 35 | 36 | preferences { 37 | page(name: "mainPage") 38 | } 39 | 40 | def mainPage() { 41 | dynamicPage(name: "mainPage", title: "Simple Reverse Proxy Service", install: true, uninstall: true) { 42 | section("Overview") { 43 | paragraph "This service manages multiple Simple Proxy Provider child apps that act as reverse proxies. Each child proxy forwards incoming web requests to a specified target URL and returns the upstream response. This allows you to expose content from your private network to trusted external hosts without directly exposing your network." 44 | paragraph "Using a reverse proxy enables selective exposure of internal resources, adding a layer of abstraction and security. Trusted external services (e.g., sharptools.io) can securely access specific data on your hub's network while the proxy handles requests in a controlled manner. This helps maintain security while providing remote access to designated content." 45 | } 46 | section("Child Apps") { 47 | app(name: "childApps", appName: "Simple Proxy Provider", namespace: "apwelsh", title: "Add New Simple Proxy Provider", multiple: true) 48 | } 49 | section("
") { 50 | paragraph "WARNING: Enabling the reverse proxy service may expose your Hubitat hub to significant security risks if proper precautions are not taken. Before enabling remote access, ensure that you have configured adequate network security measures (such as firewalls, VPNs, or strict access controls). By using this service, you assume full responsibility for any unauthorized access or security breaches that may occur. Use this feature only on trusted networks and at your own risk." 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /repository.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Armand Welsh", 3 | "gitHubUrl": "https://github.com/apwelsh/hubitat", 4 | "payPalUrl": "https://www.paypal.me/apwelsh", 5 | "packages": [ 6 | { 7 | "name": "Countdown Timer", 8 | "category": "Utility", 9 | "location": "https://raw.githubusercontent.com/apwelsh/hubitat/master/devices/packageManifest.json", 10 | "description": "A simple countdown timer device that is designed to integrate well with SharpTools", 11 | "id": "7ca01f5b-02b5-4ce4-a77c-0067661eb91a", 12 | "tags": [ 13 | "Timers" 14 | ] 15 | }, 16 | { 17 | "name": "Roku TV Integration", 18 | "category": "Integrations", 19 | "location": "https://raw.githubusercontent.com/apwelsh/hubitat/master/roku/packageManifest.json", 20 | "description": "Manage all your Roku TVs and Roku Media players through Hubitat with full support for activating applications as switches, and creating any remote command as a momentary switch. Use this device to control your Roku system, or to make a smart home that responds to Roku actions.", 21 | "id": "6442a8c3-22c2-4cbd-af81-61bc84e1690a", 22 | "tags": [ 23 | "Multimedia", 24 | "LAN" 25 | ] 26 | }, 27 | { 28 | "name": "Advanced Hue Hub Integration", 29 | "category": "Integrations", 30 | "location": "https://raw.githubusercontent.com/apwelsh/hubitat/master/hue/packageManifest.json", 31 | "description": "Add support to Hubitat for Hue Groups, Bulbs, and Scenes. This integration differs from the built-in Hue integration, because this one allows you to import Hue scenes which can be added to any Hubitat scene/group to be turned on. This was created specifically to satisfy a need the Hubitat could not. With this driver, it is possible to smoothly transition hue scenes for any Hue group. I plan to add management of Hue scenes at a later time, as well as a capture state, similar to Hubitat's scene manager.", 32 | "id": "236c1937-ef40-4afb-a571-3cb54bce5723", 33 | "tags": [ 34 | "Lights & Switches", 35 | "LAN" 36 | ] 37 | }, 38 | { 39 | "id": "1fb918e8-21aa-419c-a98d-828673b174dc", 40 | "name": "iopool EcO Pool/Spa Monitor", 41 | "category": "Integrations", 42 | "location": "https://raw.githubusercontent.com/apwelsh/hubitat/master/iopool/packageManifest.json", 43 | "description": "Add support to Hubitat for iopool's EcO pool / spa water quality monitor. iopool can also be used breath new life into the no-longer support pHin pool monitor. The iopool EcO is used to track water pH, Oxidation-reduction potential (ora), and temperature.", 44 | "tags": [ 45 | "Cloud", 46 | "LAN", 47 | "Monitoring", 48 | "Multi Sensors", 49 | "Pools & Spas", 50 | "Water" 51 | ] 52 | }, 53 | { 54 | "id": "e34b1f40-315c-49f2-8ac7-68566d4dfd2a", 55 | "name": "Simple Reverse Proxy Service", 56 | "category": "Integrations", 57 | "location": "https://raw.githubusercontent.com/apwelsh/hubitat/master/revproxy/packageManifest.json", 58 | "description": "A simple reverse proxy service. This service is designed to allow you to access http resources on your Hubitat hub's network from outside your network. This is not a full-featured reverse proxy, but it is a simple one that can be used to access specific web based endpoints without the need for complex VPNs, and without opening up your firewall directly to the internal resource. This is useful for accessing webhooks, or other web-based services that are not directly accessible from the internet.", 59 | "tags": [ 60 | "Security", 61 | "LAN", 62 | "Remote Access" 63 | ] 64 | } 65 | ] 66 | } -------------------------------------------------------------------------------- /revproxy/README.md: -------------------------------------------------------------------------------- 1 | # Simple Reverse Proxy Service for Hubitat [![Donate](https://img.shields.io/badge/donate-PayPal-blue.svg?logo=paypal&style=plastic)](https://www.paypal.com/donate?hosted_button_id=XZXSPZWAABU8J) 2 | 3 | ## SUMMARY 4 | This application provides a service that manages multiple Simple Proxy Provider child apps that act as reverse mico-proxies. Each child proxy forwards incoming web requests to a specified target URL and returns the upstream response. This allows you to expose content from your private network to trusted external hosts without directly exposing your network, and without the need for complex VPNs. 5 | 6 | ## BENEFITS 7 | Using a reverse proxy enables selective exposure of internal resources, adding a layer of abstraction and security. Trusted external services (e.g., sharptools.io) can securely access specific data on your hub's network while the proxy handles requests in a controlled manner. This helps maintain security while providing remote access to designated content. 8 | 9 | ## SECURITY DISCLAIMER 10 | Enabling the reverse proxy service may expose your Hubitat hub and connected network to significant security risks if proper precautions are not taken. Before enabling remote access, ensure that you have configured adequate network security measures (such as firewalls, VPNs, or strict access controls). By using this service, you assume full responsibility for any unauthorized access or security breaches that may occur. Use this feature only on trusted networks and at your own risk. 11 | 12 | 13 | ## Features 14 | 15 | - [Simple Reverse Proxy Service](app/simple-reverse-proxy-service.groovy) 16 | - This is the main app for managing the various reverse micro-proxy endpoints. 17 | - Each endpoint can expose only one specific URL to the consumer of the service. 18 | - This is not just for exposing private network resources to the internet, it can also be used to enable access between IoT networks and home networks, when the Hubitat Elevation hub has visibility to both networks. 19 | - [Simple Proxy Provider](app/simple-proxy-provider.groovy) 20 | - Each reverse micro-proxy can be provided a unique name 21 | - Each reverse micro-proxy must define a fully quialified target URL 22 | - Optional switch to turn on/off support to forward query parameters 23 | - Optional switch to turn on/off support to forward http request headers 24 | - The micro-proxy will not work until the OAuth has been enabled for this driver in the Dev / Apps Code section of hubitat for this driver 25 | - The micro-proxy also requires the activation of the remote endpoint to function properly 26 | 27 | ## Vision / Future Enhancements 28 | The goal of this project is to leverage the Hubitat Hub as a means of accessing select internal resources. As this project matures, additional features will be added to further provide protection from bad actors. At this time, the features is very simple, but it is in all accounts a real proxy. At no time does the consumer of this server talk directly to the back-end web endpoint. All data is downloaded from the back-end (upstream) endpoint before it is sent to the consumer. This is not intended to work as an application reverse proxy, as it is not a simple URL redirector. 29 | 30 | This project was created with the assistance of AI, but every bit of this code it original code. 31 | 32 | ## Support the Author 33 | Please consider donating. This app took a lot of work to make. 34 | Any donations received will be used to fund additional Hue based development. 35 | 36 | [![paypal](https://www.paypalobjects.com/en_US/i/btn/btn_donateCC_LG.gif)](https://www.paypal.com/donate?hosted_button_id=XZXSPZWAABU8J) 37 | 38 | ## License 39 | 40 | This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details. 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Hubitat Drivers [![Donate](https://img.shields.io/badge/donate-PayPal-blue.svg?logo=paypal&style=plastic)](https://www.paypal.com/donate?hosted_button_id=XZXSPZWAABU8J) 3 | 4 | 5 | Welcome to my Hubitat Github repo. All drivers and applications in this repo are free for use. If you like them, please share, and consider a modest donation. If you have recommendations for updates, or observed bugs, please open an issue and communiate it. If you have a bug-fix, or enhancement you think I might like, or others might like, please submit a pull request to merge your changes. 6 | 7 | 8 | ## Current Projects 9 | 10 | - [Roku TV and Media Players](roku/README.md) 11 | - An application and device driver for managing Roku TVs and Media Players. This solution can be used to create rules that respond to Roku events, such as: 12 | - [X] Turn off theater lights, and dim the entry light with Plex loads 13 | - [X] Turn on living room lights when show is paused, and restore prior state with show is playing 14 | - [X] Change the TV LED light strips to Red when Netflix is, Green for Hulu, Blue for Amazon Prime, etc... 15 | - [X] Turn on the living room lights for 5 minutes when the TV is powered off, and the Mode is Night 16 | - Or use the Basic Rules to automate your TV, such as: 17 | - [X] If mode transitions to day, then hallway motion is detected, turn on the kitchen TV, and play a random episode of Dora the Explorer 18 | - [X] If Goodnight Scene is actived, then hallway motion is detected, then bedroom motion is detected, turn off living room TV and turn on Hulu on bedroom TV 19 | - [Timer Device](Timer.md) 20 | - This is a simple count-down timer tile. Use this device when you want a timer that shows you the amount of time remaining and that is SharpTools.io HERO tile friendly. The timer supports start/stop/pause/cancel events. Cancel is basically the same as stop, except the sessionStatus will report canceled, rather than stopped. The device is rather straight forward. To use the timer in applications, the timer attribute sessionStatus will report the timer status as text, and the switch state will be set to on while the timer is running and off when it is stopped. To detect that the timer ended, the timer implemented the PushableButton and will send a button 1 pushed event upon timer completion. 21 | - [Advanced Hue Hub Integration](hue/README.md) 22 | - This project's goal is to bridge the gap that currently exists between Hubitat and Hue Bridge. The hubitat native support should be used whenever possible -- however, there are some things in the native hue integration that create an unpleasent user experience. Hue Scenes are not supported in hubitat. This *experimental*, and **in development** project's goal is to bring Hue scenes to hubitat as child devices of the hue groups. 23 | - This project has been expanded to also support real-time updates for hue device changes, and adds support for hue sensors, and dimmer controllers. 24 | - [iopool EcO Pool/Spa Monitor](iopool/README.md) 25 | - This projects integrates the iopool EcO pool monitor with Hubitat. iopool is pool monitoring solution that can monitor pH, temperature, and sanitization level of your pool or spa water. 26 | - [Tesla Connect](tesla/README.md) **Project is dead** 27 | - This project's goal is to convert the SmartThings Tesla-Connect solution for Hubitat, and enhance it to provide features not available with the current SmartThings solution. This *experimental*, and **in development** project's goal is to bring Tesla Presence based logic to Hubit and provide an alternative to Tesla HomeLink. _** Update **_ Tesla Motors has changed how their service works, and this app no longer works. 28 | 29 | 30 | ## License 31 | 32 | Portions of this repository are licensed under the MIT License - see the [MIT LICENSE](MIT-LICENSE.md) 33 | 34 | Portions of this repository are licensed under the Apache 2.0 License - see the [Apache LICENSE](APACHE-LICENSE.md) 35 | -------------------------------------------------------------------------------- /hue/device/advanced-hue-light-sensor.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Advanced Hue Light Sensor 3 | * Version 1.0.6 4 | * Download: https://github.com/apwelsh/hubitat 5 | * Description: 6 | * This is a child device handler for the Advance Hue Bridge Integration App. This device reports light level 7 | * and battery level from a Hue connected sensor. Although this can work in poll mode, it is highly recommended to 8 | * use the event stream based push notifications 9 | *------------------------------------------------------------------------------------------------------------------- 10 | * Copyright 2020 Armand Peter Welsh 11 | * 12 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 13 | * documentation files (the 'Software'), to deal in the Software without restriction, including without limitation 14 | * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, 15 | * and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 16 | * 17 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions of 18 | * the Software. 19 | * 20 | * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO 21 | * THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF 23 | * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 24 | * IN THE SOFTWARE. 25 | *------------------------------------------------------------------------------------------------------------------- 26 | **/ 27 | 28 | import groovy.transform.Field 29 | 30 | @Field static final Boolean DEFAULT_LOG_ENABLE = true 31 | @Field static final Boolean DEFAULT_DBG_ENABLE = false 32 | 33 | @Field static final String SETTING_LOG_ENABLE = 'logEnable' 34 | @Field static final String SETTING_DBG_ENABLE = 'debug' 35 | 36 | metadata { 37 | definition ( 38 | name: 'AdvancedHueLightSensor', 39 | namespace: 'apwelsh', 40 | author: 'Armand Welsh', 41 | importUrl: 'https://raw.githubusercontent.com/apwelsh/hubitat/master/hue/device/advanced-hue-light-sensor.groovy') { 42 | 43 | capability 'Battery' 44 | capability 'IlluminanceMeasurement' 45 | capability 'Refresh' 46 | capability 'Sensor' 47 | 48 | attribute 'status', 'string' // expect enabled/disabled 49 | attribute 'health', 'string' // reachable/unreachable 50 | } 51 | } 52 | 53 | preferences { 54 | 55 | input name: SETTING_LOG_ENABLE, 56 | type: 'bool', 57 | defaultValue: DEFAULT_LOG_ENABLE, 58 | title: 'Enable informational logging' 59 | 60 | input name: SETTING_DBG_ENABLE, 61 | type: 'bool', 62 | defaultValue: DEFAULT_DBG_ENABLE, 63 | title: 'Enable debug logging' 64 | 65 | } 66 | 67 | void updateSetting(String name, Object value) { 68 | device.updateSetting(name, value) 69 | this[name] = value 70 | } 71 | 72 | /** 73 | * Hubitat DTH Lifecycle Functions 74 | **/ 75 | def installed() { 76 | updated() 77 | refresh() 78 | } 79 | 80 | def updated() { 81 | if (this[SETTING_LOG_ENABLE] == null) { updateSetting(SETTING_LOG_ENABLE, DEFAULT_LOG_ENABLE) } 82 | if (this[SETTING_DBG_ENABLE] == null) { updateSetting(SETTING_DBG_ENABLE, DEFAULT_DBG_ENABLE) } 83 | if (this[SETTING_LOG_ENABLE]) { log.debug 'Preferences updated' } 84 | } 85 | 86 | 87 | /* 88 | * Device Capability Interface Functions 89 | */ 90 | 91 | 92 | void refresh() { 93 | if (this[SETTING_DBG_ENABLE]) { log.debug "Sensor (${this}) refreshing" } 94 | parent.getDeviceState(this) 95 | } 96 | 97 | void setHueProperty(Map args) { 98 | } 99 | -------------------------------------------------------------------------------- /hue/device/advanced-hue-temperature-sensor.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Advanced Hue Temperature Sensor 3 | * Version 1.0.7 4 | * Download: https://github.com/apwelsh/hubitat 5 | * Description: 6 | * This is a child device handler for the Advance Hue Bridge Integration App. This device reports light level 7 | * and battery level from a Hue connected sensor. Although this can work in poll mode, it is highly recommended to 8 | * use the event stream based push notifications 9 | *------------------------------------------------------------------------------------------------------------------- 10 | * Copyright 2020 Armand Peter Welsh 11 | * 12 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 13 | * documentation files (the 'Software'), to deal in the Software without restriction, including without limitation 14 | * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, 15 | * and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 16 | * 17 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions of 18 | * the Software. 19 | * 20 | * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO 21 | * THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF 23 | * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 24 | * IN THE SOFTWARE. 25 | *------------------------------------------------------------------------------------------------------------------- 26 | **/ 27 | 28 | import groovy.transform.Field 29 | 30 | @Field static final Boolean DEFAULT_LOG_ENABLE = true 31 | @Field static final Boolean DEFAULT_DBG_ENABLE = false 32 | 33 | @Field static final String SETTING_LOG_ENABLE = 'logEnable' 34 | @Field static final String SETTING_DBG_ENABLE = 'debug' 35 | 36 | metadata { 37 | definition ( 38 | name: 'AdvancedHueTemperatureSensor', 39 | namespace: 'apwelsh', 40 | author: 'Armand Welsh', 41 | importUrl: 'https://raw.githubusercontent.com/apwelsh/hubitat/master/hue/device/advanced-hue-temperature-sensor.groovy') { 42 | 43 | capability 'Battery' 44 | capability 'TemperatureMeasurement' 45 | capability 'Refresh' 46 | capability 'Sensor' 47 | 48 | attribute 'status', 'string' // expect enabled/disabled 49 | attribute 'health', 'string' // reachable/unreachable 50 | } 51 | } 52 | 53 | preferences { 54 | 55 | input name: SETTING_LOG_ENABLE, 56 | type: 'bool', 57 | defaultValue: DEFAULT_LOG_ENABLE, 58 | title: 'Enable informational logging' 59 | 60 | input name: SETTING_DBG_ENABLE, 61 | type: 'bool', 62 | defaultValue: DEFAULT_DBG_ENABLE, 63 | title: 'Enable debug logging' 64 | 65 | } 66 | 67 | void updateSetting(String name, Object value) { 68 | device.updateSetting(name, value) 69 | this[name] = value 70 | } 71 | 72 | /** 73 | * Hubitat DTH Lifecycle Functions 74 | **/ 75 | def installed() { 76 | updated() 77 | refresh() 78 | } 79 | 80 | def updated() { 81 | if (this[SETTING_LOG_ENABLE] == null) { updateSetting(SETTING_LOG_ENABLE, DEFAULT_LOG_ENABLE) } 82 | if (this[SETTING_DBG_ENABLE] == null) { updateSetting(SETTING_DBG_ENABLE, DEFAULT_DBG_ENABLE) } 83 | if (this[SETTING_LOG_ENABLE]) { log.debug 'Preferences updated' } 84 | } 85 | 86 | 87 | /* 88 | * Device Capability Interface Functions 89 | */ 90 | 91 | 92 | void refresh() { 93 | if (this[SETTING_DBG_ENABLE]) { log.debug "Sensor (${this}) refreshing" } 94 | parent.getDeviceState(this) 95 | } 96 | 97 | void setHueProperty(Map args) { 98 | } 99 | -------------------------------------------------------------------------------- /roku/packageManifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageName": "Roku TV Integration", 3 | "author": "Armand Welsh", 4 | "minimumHEVersion": "2.2.6", 5 | "licenseFile": "https://raw.githubusercontent.com/apwelsh/hubitat/master/roku/LICENSE.md", 6 | "documentationLink": "https://raw.githubusercontent.com/apwelsh/hubitat/master/roku/README.md", 7 | "communityLink": "https://community.hubitat.com/t/new-roku-tv-device-handler/12038", 8 | "dateReleased": "2020-06-11", 9 | "apps": [ 10 | { 11 | "id": "cbf5b247-9c87-4926-83db-69c1dd70e211", 12 | "name": "Roku Connect", 13 | "namespace": "apwelsh", 14 | "location": "https://raw.githubusercontent.com/apwelsh/hubitat/master/roku/app/roku-connect.groovy", 15 | "required": false, 16 | "oauth": false, 17 | "primary": true, 18 | "version": "1.3.0" 19 | } 20 | ], 21 | "drivers": [ 22 | { 23 | "id": "516bab57-6e80-4364-be6b-c4ef7c1361f5", 24 | "name": "Roku TV", 25 | "namespace": "apwelsh", 26 | "location": "https://raw.githubusercontent.com/apwelsh/hubitat/master/roku/device/roku-tv.groovy", 27 | "required": true, 28 | "version": "2.8.4" 29 | } 30 | ], 31 | "version": "1.4.4", 32 | "releaseNotes": "1.4.4 - Removed quiter command (unused, and typo - was supposed to be quieter)\n1.4.3 - Fixed setChannel. Roku changed the live TV app, and with that change, they changed how to send a channel slightly.\n1.4.2 - Initial fix for 5 Series Roku TVs that report Home instead of Roku when the Home app is active.\n1.4.1 - Added volumeUp10 and volumeDown10. Reworked the setChannel to be more accurate by sending key presses synchronously, instead of asynchronously.\n1.4.0 - Added setChannel command to RokuTV driver \n1.3.23 - Fixed bug that prevented the turning on of application child devices. \n1.3.22 - Fixed bug the turns off scheduled refreshes when a app initiated keypress event is issued. Fixed bug that prevents matching of TV input to installed child device, when the TV inuput name has been modified on the TV. Added iconPath to child Input devices. Added ability to manage Input devices from Roku Connect app. \n1.3.21 - Fixed code error that broke the media status updates. Thanks to @mike385 for reporting it. \n1.3.20 - Cnverted most web calls back to async to more accurately represent system load, so idle wait times are not counted as busy time in runtime statistics. \n1.3.19 - Added support to use ping when a TV is powered off, to detect when it is powered back on. Due to performance concerns, the refresh interval is never more frequent than every 20 seconds. Enable Experimental Features to use. \n1.3.18 - Removed trace log from debugging in exception handler \n1.3.17 - Added donate link to app \n1.3.16 - Implement Initialize capability to refresh device state on hub startup. \n1.3.15 - Fixed Null Point Exception parsing active application. Added new Debug Verbose Logging setting. \n1.3.14 - Added logic to skip device info query on non-TV Roku device, since they do not report power state. \n1.3.13 - Corrected annoying bug when adding new devices. \n1.3.12 - Minor update to improve performance on updating values. \n1.3.11 - Implement logic changes to reduce CPU load and network traffic. Suppress redundant events. \n1.3.110 - Fix logic fro updating mediaInputSource, and improved quality of transport status detection. \n1.3.8 - Added a critical bug fix and some more minor fixes for handling power state. \n1.3.7- Enabled support for disabling refresh with refresh interval of 0. \n1.3.6 - Added support for MediaInputSource to support standard input source selection, and removed refresh attribute. This update limits event logging considerably to improve overall performance. \n1.3.5 - Fixed reported bugs in Roku Connect preventing the addition of new channels on newly added Roku devices. \n1.3.4 - Updated refresh logic so that when the TV turns off, the transportStatus changes to stopped, and application changes to Roku. \n1.3.3 - Added support for MediaTransport capability, which emulates the play/pause/stop commands of media players. \n1.3.2 - Added ability to turn off the ssdp auto-discovery. Added ability to choose power on/off command mode" 33 | } 34 | -------------------------------------------------------------------------------- /hue/device/advanced-hue-tap-sensor.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Advanced Hue Tap Sensor 3 | * Version 1.0.3 4 | * Download: https://github.com/apwelsh/hubitat 5 | * Description: 6 | * This is a child device handler for the Advance Hue Bridge Integration App. This device reports light level 7 | * and battery level from a Hue connected sensor. Although this can work in poll mode, it is highly recommended to 8 | * use the event stream based push notifications 9 | *------------------------------------------------------------------------------------------------------------------- 10 | * Copyright 2020 Armand Peter Welsh 11 | * 12 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 13 | * documentation files (the 'Software'), to deal in the Software without restriction, including without limitation 14 | * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, 15 | * and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 16 | * 17 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions of 18 | * the Software. 19 | * 20 | * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO 21 | * THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF 23 | * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 24 | * IN THE SOFTWARE. 25 | *------------------------------------------------------------------------------------------------------------------- 26 | **/ 27 | 28 | import groovy.transform.Field 29 | import java.util.concurrent.ConcurrentHashMap 30 | 31 | 32 | @Field static final Boolean DEFAULT_LOG_ENABLE = true 33 | @Field static final Boolean DEFAULT_DBG_ENABLE = false 34 | 35 | @Field static final String SETTING_LOG_ENABLE = 'logEnable' 36 | @Field static final String SETTING_DBG_ENABLE = 'debug' 37 | 38 | metadata { 39 | definition ( 40 | name: 'AdvancedHueTapSensor', 41 | namespace: 'apwelsh', 42 | author: 'Armand Welsh', 43 | importUrl: 'https://raw.githubusercontent.com/apwelsh/hubitat/master/hue/device/advanced-hue-tap-sensor.groovy') { 44 | 45 | capability 'Battery' 46 | capability 'PushableButton' 47 | capability 'Refresh' 48 | capability 'Initialize' 49 | 50 | attribute 'status', 'string' // expect enabled/disabled 51 | attribute 'health', 'string' // reachable/unreachable 52 | } 53 | } 54 | 55 | preferences { 56 | 57 | input name: SETTING_LOG_ENABLE, 58 | type: 'bool', 59 | defaultValue: DEFAULT_LOG_ENABLE, 60 | title: 'Enable informational logging' 61 | 62 | input name: SETTING_DBG_ENABLE, 63 | type: 'bool', 64 | defaultValue: DEFAULT_DBG_ENABLE, 65 | title: 'Enable debug logging' 66 | 67 | } 68 | 69 | void updateSetting(String name, Object value) { 70 | device.updateSetting(name, value) 71 | this[name] = value 72 | } 73 | 74 | /** 75 | * Hubitat DTH Lifecycle Functions 76 | **/ 77 | def installed() { 78 | updated() 79 | initialize() 80 | refresh() 81 | 82 | mapButtons() 83 | 84 | } 85 | 86 | def initialize() { 87 | String id = parent.deviceIdNode(device.deviceNetworkId) 88 | Long buttons = parent.state.sensors[id]?.capabilities?.inputs?.size()?:0 89 | parent.sendChildEvent(this, [name: 'numberOfButtons', value: buttons]) 90 | } 91 | 92 | def updated() { 93 | if (this[SETTING_LOG_ENABLE] == null) { updateSetting(SETTING_LOG_ENABLE, DEFAULT_LOG_ENABLE) } 94 | if (this[SETTING_DBG_ENABLE] == null) { updateSetting(SETTING_DBG_ENABLE, DEFAULT_DBG_ENABLE) } 95 | if (this[SETTING_LOG_ENABLE]) { log.debug 'Preferences updated' } 96 | } 97 | 98 | void mapButtons() { 99 | String id = parent.deviceIdNode(device.deviceNetworkId) 100 | 101 | state.buttonMap = parent.enumerateResourcesV2().findAll { resource -> 102 | resource.type == 'button' && resource.id_v1 == "/sensors/${id}" 103 | }.collectEntries { button -> 104 | [(button.id): button.metadata.control_id] 105 | } 106 | } 107 | 108 | /* 109 | * Device Capability Interface Functions 110 | */ 111 | 112 | 113 | void refresh() { 114 | if (this[SETTING_DBG_ENABLE]) { log.debug "Sensor (${this}) refreshing" } 115 | parent.getDeviceState(this) 116 | } 117 | 118 | void setHueProperty(Map args) { 119 | if (args.last_event && args.id) { 120 | Number btn = state.buttonMap?.(args.id) ?: 0 121 | if (args.last_event == 'initial_press') { push(btn) } 122 | } 123 | } 124 | 125 | void push(Number buttonNumber) { 126 | sendEvent([name: 'pushed', value: buttonNumber, isStateChange:true]) 127 | } 128 | -------------------------------------------------------------------------------- /hue/device/advanced-hue-motion-sensor.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Advanced Hue Motion Sensor 3 | * Version 1.0.7 4 | * Download: https://github.com/apwelsh/hubitat 5 | * Description: 6 | * This is a child device handler for the Advance Hue Bridge Integration App. This device reports light level 7 | * and battery level from a Hue connected sensor. Although this can work in poll mode, it is highly recommended to 8 | * use the event stream based push notifications 9 | *------------------------------------------------------------------------------------------------------------------- 10 | * Copyright 2020 Armand Peter Welsh 11 | * 12 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 13 | * documentation files (the 'Software'), to deal in the Software without restriction, including without limitation 14 | * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, 15 | * and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 16 | * 17 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions of 18 | * the Software. 19 | * 20 | * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO 21 | * THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF 23 | * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 24 | * IN THE SOFTWARE. 25 | *------------------------------------------------------------------------------------------------------------------- 26 | **/ 27 | 28 | import groovy.transform.Field 29 | 30 | @Field static final Boolean DEFAULT_LOG_ENABLE = true 31 | @Field static final Boolean DEFAULT_DBG_ENABLE = false 32 | 33 | @Field static final String SETTING_LOG_ENABLE = 'logEnable' 34 | @Field static final String SETTING_DBG_ENABLE = 'debug' 35 | @Field static final String SETTING_SENSITIVITY = 'sensitivity' 36 | 37 | metadata { 38 | definition ( 39 | name: 'AdvancedHueMotionSensor', 40 | namespace: 'apwelsh', 41 | author: 'Armand Welsh', 42 | importUrl: 'https://raw.githubusercontent.com/apwelsh/hubitat/master/hue/device/advanced-hue-motion-sensor.groovy') { 43 | 44 | capability 'MotionSensor' 45 | capability 'Battery' 46 | capability 'Refresh' 47 | capability 'Sensor' 48 | 49 | attribute 'status', 'string' // expect enabled/disabled 50 | attribute 'health', 'string' // reachable/unreachable 51 | } 52 | } 53 | 54 | preferences { 55 | 56 | input name: SETTING_LOG_ENABLE, 57 | type: 'bool', 58 | defaultValue: DEFAULT_LOG_ENABLE, 59 | title: 'Enable informational logging' 60 | 61 | input name: SETTING_DBG_ENABLE, 62 | type: 'bool', 63 | defaultValue: DEFAULT_DBG_ENABLE, 64 | title: 'Enable debug logging' 65 | 66 | def sensitivitymax = 1 67 | try { 68 | Map config = parent.stateForNetworkId(deviceInstance.deviceNetworkId)?.config 69 | sensitivitymax = config?.sensitivitymax?:1 70 | int s = config?.sensitivity == null ? sensitivitymax : config.sensitivity 71 | if (this[SETTING_SENSITIVITY] != s) { 72 | updateSetting(SETTING_SENSITIVITY, s) 73 | } 74 | } catch (ex) { 75 | log.error "${ex}" 76 | } 77 | 78 | input name: SETTING_SENSITIVITY, 79 | type: 'number', 80 | defaultValue: sensitivitymax, 81 | range: 0..sensitivitymax, 82 | title: 'Motion Sensitivity', 83 | description: "Enter a value from 0 to ${sensitivitymax}" 84 | 85 | } 86 | 87 | private getDeviceInstance() { 88 | parent.getChildDeviceById(device.deviceId) 89 | } 90 | 91 | void updateSetting(String name, Object value) { 92 | device.updateSetting(name, value) 93 | this[name] = value 94 | } 95 | 96 | /** 97 | * Hubitat DTH Lifecycle Functions 98 | **/ 99 | def installed() { 100 | updated() 101 | refresh() 102 | } 103 | 104 | def updated() { 105 | if (this[SETTING_LOG_ENABLE] == null) { updateSetting(SETTING_LOG_ENABLE, DEFAULT_LOG_ENABLE) } 106 | if (this[SETTING_DBG_ENABLE] == null) { updateSetting(SETTING_DBG_ENABLE, DEFAULT_DBG_ENABLE) } 107 | if (this[SETTING_LOG_ENABLE]) { log.debug 'Preferences updated' } 108 | 109 | Map configs = [:] 110 | Map devcfg = parent.stateForNetworkId(device.deviceNetworkId)?.config 111 | 112 | if (this[SETTING_SENSITIVITY] != devcfg[SETTING_SENSITIVITY]) { configs[SETTING_SENSITIVITY] = this[SETTING_SENSITIVITY] } 113 | 114 | parent.setDeviceConfig(this, configs) 115 | } 116 | 117 | /* 118 | * Device Capability Interface Functions 119 | */ 120 | 121 | 122 | void refresh() { 123 | if (this[SETTING_DBG_ENABLE]) { log.debug "Sensor (${this}) refreshing" } 124 | parent.getDeviceState(this) 125 | } 126 | 127 | void setHueProperty(Map args) { 128 | } 129 | 130 | -------------------------------------------------------------------------------- /hue/README.md: -------------------------------------------------------------------------------- 1 | # Advanced Hue Bridge Integration for Hubitat [![Donate](https://img.shields.io/badge/donate-PayPal-blue.svg?logo=paypal&style=plastic)](https://www.paypal.com/donate?hosted_button_id=XZXSPZWAABU8J) 2 | 3 | The Advanced Hue Bridge Integration for Hubitat brings support the Hue scenes, groups, and lights. It minimizes hubitat complexity by leveraging the built-in Hubitat component drivers whereever possible. Additionally, this system will pick the best fit generic component driver for each light, to ensure the devices are correctly represented in dashboards. 4 | 5 | This application and associated drivers are now ready for general use. A beta branch will be created for those that wish to use the newest features as they are being added and tested. 6 | 7 | Hue Play systems, like the Hue Sync are not yet supported, as I do not own one to test with. 8 | 9 | The functionality is mostly complete at this time. I believe this system already provides more features and better performance than the built-in Hue integration, but that is an opion, not a measured fact. 10 | 11 | This integration fully supports hue push notifaction events, so that sensors can be leveraged in near-realtime, and lights are updated in near-realtime with no need to require a scheduled refresh of the Hue system. 12 | 13 | 14 | ## Features 15 | 16 | - [Advanced Hue Bridge Integration App](app/hue-bridge-integration.groovy) 17 | - This is the main component. Like the native application, this is the app that you will use to link the Hue bridge to your HE system. All drivers in this project are 100% dependent upon this app. 18 | - Used to add Lights, Groups, and Scenes 19 | - [Advanced Hue Bridge Device](device/advanced-hue-bridge.groovy) 20 | - The main app will install an instance of this device after the app successfully pairs the hue bridge. 21 | - Used to schedule the hub refresh event. 22 | - Supports `Switch` capability to turn on/off all Hue lights. 23 | - Can be configured to automatically turn on if Any light is on, or if All ligts are on. 24 | - [Advanced Hue Group Device](device/advanced-hue-group.groovy) 25 | - All imported Hue Groups will use this device to manage the group. This device depends on the parent app to operate. The initial version of this device supports all color light control capabilities, and the ability to activate any hue scene by scene ID or scene name (as identified in the hue bridge). 26 | - Can define a default scene to activate when the group is turned on. 27 | - Hue Lights Device `Generic Hubitat Device` 28 | - All imported Hue Ligts will use one of the matching Hubitat component drivers. Hue lights are installed as individual lights, now as child devices or another device. 29 | - Use `Generic Component Dimmer` for dimmable lights 30 | - Use `Generic Component CT` for color temperature adjustable lights 31 | - Use `Generic Component RGBW` for extended color lights 32 | - Use `Generic Component RGB` for all other lights 33 | - Hue Scenes Device `Generic Hubitat Device` 34 | - One of the key features of this integration, is the ability to use and manage Hue scenes. This device is the a switch devices used to active a hue scene. 35 | - Use `Generic Component Switch` for switching on hue scenes. 36 | - Can be configured as a momentary (trigger) switch, or as a toggle switch. 37 | - Trigger (momentary) mode. When turned on, scene will be activated and will automatically turn off the switch after 400 milliseconds. 38 | - Switch mode. Turning on a scene will result in the scene switch staying on, until *any* attribute of the group or bublbs within the group are changed, or until another scene is activated. Turning off the active scene will turn off the parent Hue group. 39 | - [Advanced Hue Motion Sensor Device](device/advanced-hue-motion-sensor.groovy) 40 | - Support for Motion Sensor 41 | - Support for Battery level 42 | - Support for Refresh 43 | - [Advanced Hue Light Sensor Device](device/advanced-hue-light-sensor.groovy) 44 | - Support for IlluminanceMeasurement 45 | - Support for Battery level 46 | - Support for Refresh 47 | - [Advanced Hue Temperature Sensor Device](device/advanced-hue-temperature-sensor.groovy) 48 | - Support for TemperatureMeasurement 49 | - Support for Battery level 50 | - Support for Refresh 51 | 52 | 53 | 54 | ## Vision / Future Enhancements 55 | The goal of this project is to more tightly integrate the hue system with Hubitat. 56 | - Hue Integrated Group Manager -- Hubitat has support for groups, but adding hue lights to groups results in slow automation, as the bridge controls the light individually. With this project, I will enable the ability to create and manage custom groups on Hue that can include Hue and HE devices. This will enable making changes to the hue group with a single API call to hue. 57 | - Hue Integrated Scene Manager -- Hubitat has support for scenes, but adding hue lights to scenes results in slow and out of sync transitions. With this project, I will enable the ability to create and manage scenes on Hue that can include Hue and HE devices. This will make it possible to syncronize the hue devices with the HE devices as they transition. To what extent I will be able to sync with HE and achieve the zigbee optiminizations of non-hue is not yet known. 58 | 59 | ## Support the Author 60 | Please consider donating. This app took a lot of work to make. 61 | Any donations received will be used to fund additional Hue based development. 62 | 63 | [![paypal](https://www.paypalobjects.com/en_US/i/btn/btn_donateCC_LG.gif)](https://www.paypal.com/donate?hosted_button_id=XZXSPZWAABU8J) 64 | 65 | ## License 66 | 67 | This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details. 68 | -------------------------------------------------------------------------------- /hue/device/advanced-hue-dimmer-sensor.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Advanced Hue Dimmer Sensor 3 | * Version 1.0.3 4 | * Download: https://github.com/apwelsh/hubitat 5 | * Description: 6 | * This is a child device handler for the Advance Hue Bridge Integration App. This device reports light level 7 | * and battery level from a Hue connected sensor. Although this can work in poll mode, it is highly recommended to 8 | * use the event stream based push notifications 9 | *------------------------------------------------------------------------------------------------------------------- 10 | * Copyright 2020 Armand Peter Welsh 11 | * 12 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 13 | * documentation files (the 'Software'), to deal in the Software without restriction, including without limitation 14 | * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, 15 | * and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 16 | * 17 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions of 18 | * the Software. 19 | * 20 | * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO 21 | * THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF 23 | * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 24 | * IN THE SOFTWARE. 25 | *------------------------------------------------------------------------------------------------------------------- 26 | **/ 27 | 28 | import groovy.transform.Field 29 | import java.util.concurrent.ConcurrentHashMap 30 | 31 | 32 | @Field static final Boolean DEFAULT_LOG_ENABLE = true 33 | @Field static final Boolean DEFAULT_DBG_ENABLE = false 34 | 35 | @Field static final String SETTING_LOG_ENABLE = 'logEnable' 36 | @Field static final String SETTING_DBG_ENABLE = 'debug' 37 | 38 | metadata { 39 | definition ( 40 | name: 'AdvancedHueDimmerSensor', 41 | namespace: 'apwelsh', 42 | author: 'Armand Welsh', 43 | importUrl: 'https://raw.githubusercontent.com/apwelsh/hubitat/master/hue/device/advanced-hue-dimmer-sensor.groovy') { 44 | 45 | capability 'Battery' 46 | capability 'PushableButton' 47 | capability 'HoldableButton' 48 | capability 'ReleasableButton' 49 | capability 'Refresh' 50 | capability 'Initialize' 51 | 52 | attribute 'status', 'string' // expect enabled/disabled 53 | attribute 'health', 'string' // reachable/unreachable 54 | } 55 | } 56 | 57 | preferences { 58 | 59 | input name: SETTING_LOG_ENABLE, 60 | type: 'bool', 61 | defaultValue: DEFAULT_LOG_ENABLE, 62 | title: 'Enable informational logging' 63 | 64 | input name: SETTING_DBG_ENABLE, 65 | type: 'bool', 66 | defaultValue: DEFAULT_DBG_ENABLE, 67 | title: 'Enable debug logging' 68 | 69 | } 70 | 71 | void updateSetting(String name, Object value) { 72 | device.updateSetting(name, value) 73 | this[name] = value 74 | } 75 | 76 | /** 77 | * Hubitat DTH Lifecycle Functions 78 | **/ 79 | def installed() { 80 | updated() 81 | initialize() 82 | refresh() 83 | 84 | mapButtons() 85 | 86 | } 87 | 88 | def initialize() { 89 | String id = parent.deviceIdNode(device.deviceNetworkId) 90 | Long buttons = parent.state.sensors[id]?.capabilities?.inputs?.size()?:0 91 | parent.sendChildEvent(this, [name: 'numberOfButtons', value: buttons]) 92 | } 93 | 94 | def updated() { 95 | if (this[SETTING_LOG_ENABLE] == null) { updateSetting(SETTING_LOG_ENABLE, DEFAULT_LOG_ENABLE) } 96 | if (this[SETTING_DBG_ENABLE] == null) { updateSetting(SETTING_DBG_ENABLE, DEFAULT_DBG_ENABLE) } 97 | if (this[SETTING_LOG_ENABLE]) { log.debug 'Preferences updated' } 98 | } 99 | 100 | void mapButtons() { 101 | String id = parent.deviceIdNode(device.deviceNetworkId) 102 | 103 | state.buttonMap = parent.enumerateResourcesV2().findAll { resource -> 104 | resource.type == 'button' && resource.id_v1 == "/sensors/${id}" 105 | }.collectEntries { button -> 106 | [(button.id): button.metadata.control_id] 107 | } 108 | } 109 | 110 | /* 111 | * Device Capability Interface Functions 112 | */ 113 | 114 | 115 | void refresh() { 116 | if (this[SETTING_DBG_ENABLE]) { log.debug "Sensor (${this}) refreshing" } 117 | parent.getDeviceState(this) 118 | } 119 | 120 | void setHueProperty(Map args) { 121 | if (args.last_event && args.id) { 122 | Number btn = state.buttonMap?.(args.id) ?: 0 123 | switch(args.last_event) { 124 | case 'initial_press': 125 | break; 126 | case 'repeat': 127 | hold(btn) 128 | break; 129 | case 'short_release': 130 | push(btn) 131 | break; 132 | case 'long_release': 133 | release(btn) 134 | break; 135 | } 136 | 137 | } 138 | } 139 | 140 | void push(Number buttonNumber) { 141 | sendEvent([name: 'pushed', value: buttonNumber, isStateChange: true]) 142 | } 143 | 144 | void hold(Number buttonNumber) { 145 | sendEvent([name: 'held', value: buttonNumber, isStateChange: true]) 146 | } 147 | 148 | void release(Number buttonNumber) { 149 | sendEvent([name: 'released', value: buttonNumber, isStateChange: true]) 150 | } -------------------------------------------------------------------------------- /tesla/device/tesla.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Tesla 3 | * 4 | * Copyright 2020 Armand Welsh 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 | preferences { 17 | input name: "homelink", type: "bool", title: "Use HomeLink presence", defaultValue: false 18 | if (homelink) 19 | input name: "geofence", type: "number", title: "Meters from home for presence", defaultValue: 50 20 | input name: "logEnable", type: "bool", title: "Enable logging", defaultValue: false 21 | } 22 | 23 | metadata { 24 | definition (name: "Tesla", namespace: "apwelsh", author: "Armand Welsh") { 25 | capability "Actuator" 26 | capability "Battery" 27 | capability "Lock" 28 | capability "MotionSensor" 29 | capability "PresenceSensor" 30 | capability "Refresh" 31 | capability "TemperatureMeasurement" 32 | capability "ThermostatMode" 33 | capability "ThermostatSetpoint" 34 | 35 | attribute "state", "string" 36 | attribute "vin", "string" 37 | attribute "odometer", "number" 38 | attribute "batteryRange", "number" 39 | attribute "chargingState", "string" 40 | 41 | attribute "latitude", "number" 42 | attribute "longitude", "number" 43 | attribute "method", "string" 44 | attribute "heading", "number" 45 | attribute "lastUpdateTime", "date" 46 | attribute "distanceAway", "number" 47 | 48 | command "wake" 49 | command "setThermostatSetpoint" 50 | command "startCharge" 51 | command "stopCharge" 52 | command "openFrontTrunk" 53 | command "openRearTrunk" 54 | } 55 | 56 | } 57 | 58 | def initialize() { 59 | if (logEnable) log.debug "Executing 'initialize'" 60 | 61 | sendEvent(name: "supportedThermostatModes", value: ["auto", "off"]) 62 | 63 | runEvery15Minutes(refresh) 64 | } 65 | 66 | // parse events into attributes 67 | def parse(String description) { 68 | if (logEnable) log.debug "Parsing '${description}'" 69 | } 70 | 71 | private processData(data) { 72 | if(data) { 73 | if (logEnable) log.debug "processData: ${data}" 74 | 75 | sendEvent(name: "state", value: data.state) 76 | sendEvent(name: "motion", value: data.motion) 77 | sendEvent(name: "speed", value: data.speed, unit: "mph") 78 | sendEvent(name: "vin", value: data.vin) 79 | sendEvent(name: "thermostatMode", value: data.thermostatMode) 80 | 81 | if (data.chargeState) { 82 | sendEvent(name: "battery", value: data.chargeState.battery) 83 | sendEvent(name: "batteryRange", value: data.chargeState.batteryRange) 84 | sendEvent(name: "chargingState", value: data.chargeState.chargingState) 85 | } 86 | 87 | if (data.driveState) { 88 | sendEvent(name: "latitude", value: data.driveState.latitude) 89 | sendEvent(name: "longitude", value: data.driveState.longitude) 90 | sendEvent(name: "method", value: data.driveState.method) 91 | sendEvent(name: "heading", value: data.driveState.heading) 92 | sendEvent(name: "lastUpdateTime", value: data.driveState.lastUpdateTime) 93 | def dist = distance(location.latitude, location.longitude, data.driveState.latitude, data.driveState.longitude) 94 | if (logEnable) log.debug "distance: ${dist}" 95 | sendEvent(name: "distanceAway", value: dist) 96 | if (!homelink) { 97 | sendEvent(name: "presence", value: (dist <= (geofence?:50) ? "present" : "not present") 98 | } 99 | } 100 | 101 | if (data.vehicleState) { 102 | sendEvent(name: "presence", value: data.vehicleState.presence) 103 | sendEvent(name: "lock", value: data.vehicleState.lock) 104 | sendEvent(name: "odometer", value: data.vehicleState.odometer) 105 | } 106 | 107 | if (data.climateState) { 108 | sendEvent(name: "temperature", value: data.climateState.temperature) 109 | sendEvent(name: "thermostatSetpoint", value: data.climateState.thermostatSetpoint) 110 | } 111 | } else { 112 | if (logEnable) log.error "No data found for ${device.deviceNetworkId}" 113 | } 114 | } 115 | 116 | def refresh() { 117 | if (logEnable) log.debug "Executing 'refresh'" 118 | def data = parent.refresh(this) 119 | processData(data) 120 | } 121 | 122 | def wake() { 123 | if (logEnable) log.debug "Executing 'wake'" 124 | def data = parent.wake(this) 125 | processData(data) 126 | runIn(30, refresh) 127 | } 128 | 129 | def lock() { 130 | if (logEnable) log.debug "Executing 'lock'" 131 | def result = parent.lock(this) 132 | if (result) { refresh() } 133 | } 134 | 135 | def unlock() { 136 | if (logEnable) log.debug "Executing 'unlock'" 137 | def result = parent.unlock(this) 138 | if (result) { refresh() } 139 | } 140 | 141 | def auto() { 142 | if (logEnable) log.debug "Executing 'auto'" 143 | def result = parent.climateAuto(this) 144 | if (result) { refresh() } 145 | } 146 | 147 | def off() { 148 | if (logEnable) log.debug "Executing 'off'" 149 | def result = parent.climateOff(this) 150 | if (result) { refresh() } 151 | } 152 | 153 | def heat() { 154 | if (logEnable) log.debug "Executing 'heat'" 155 | // Not supported 156 | } 157 | 158 | def emergencyHeat() { 159 | if (logEnable) log.debug "Executing 'emergencyHeat'" 160 | // Not supported 161 | } 162 | 163 | def cool() { 164 | if (logEnable) log.debug "Executing 'cool'" 165 | // Not supported 166 | } 167 | 168 | def setThermostatMode(mode) { 169 | if (logEnable) log.debug "Executing 'setThermostatMode'" 170 | switch (mode) { 171 | case "auto": 172 | auto() 173 | break 174 | case "off": 175 | off() 176 | break 177 | default: 178 | if (logEnable) log.error "setThermostatMode: Only thermostat modes Auto and Off are supported" 179 | } 180 | } 181 | 182 | def setThermostatSetpoint(setpoint) { 183 | if (logEnable) log.debug "Executing 'setThermostatSetpoint'" 184 | def result = parent.setThermostatSetpoint(this, setpoint) 185 | if (result) { refresh() } 186 | } 187 | 188 | def startCharge() { 189 | if (logEnable) log.debug "Executing 'startCharge'" 190 | def result = parent.startCharge(this) 191 | if (result) { refresh() } 192 | } 193 | 194 | def stopCharge() { 195 | if (logEnable) log.debug "Executing 'stopCharge'" 196 | def result = parent.stopCharge(this) 197 | if (result) { refresh() } 198 | } 199 | 200 | def openFrontTrunk() { 201 | if (logEnable) log.debug "Executing 'openFrontTrunk'" 202 | def result = parent.openTrunk(this, "front") 203 | // if (result) { refresh() } 204 | } 205 | 206 | def openRearTrunk() { 207 | if (logEnable) log.debug "Executing 'openRearTrunk'" 208 | def result = parent.openTrunk(this, "rear") 209 | // if (result) { refresh() } 210 | } 211 | 212 | def distance(lat1, lon1, lat2, lon2) { 213 | // compute horizontal distance bteween two points at sea-level, in meters 214 | 215 | final int R = 6371; // Radius of the earth (equator 6378, at poles 6357, median radius 6371) 216 | 217 | def latDistance = Math.toRadians(lat2 - lat1) 218 | def lonDistance = Math.toRadians(lon2 - lon1) 219 | def sinLat = Math.sin(latDistance/2) 220 | def sinLon = Math.sin(lonDistance/2) 221 | def a = sinLat * sinLat + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) * sinLon * sinLon 222 | def c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) 223 | def distance = R * c * 1000; // convert to meters 224 | 225 | return distance 226 | } 227 | 228 | -------------------------------------------------------------------------------- /hue/packageManifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageName": "Advanced Hue Hub Integration", 3 | "author": "Armand Welsh", 4 | "minimumHEVersion": "2.2.9", 5 | "licenseFile": "https://raw.githubusercontent.com/apwelsh/hubitat/master/hue/LICENSE.md", 6 | "documentationLink": "https://github.com/apwelsh/hubitat/tree/master/hue", 7 | "communityLink": "https://community.hubitat.com/t/release-advanced-hue-bridge-integration/51420", 8 | "dateReleased": "2020-01-20", 9 | "apps": [ 10 | { 11 | "id": "4ca23ce9-4e0f-4d0d-9fd2-9034376dc134", 12 | "name": "Advanced Hue Bridge Integration", 13 | "namespace": "apwelsh", 14 | "location": "https://raw.githubusercontent.com/apwelsh/hubitat/master/hue/app/hue-bridge-integration.groovy", 15 | "required": true, 16 | "oauth": false, 17 | "primary": true, 18 | "version": "1.6.8" 19 | } 20 | ], 21 | "drivers": [ 22 | { 23 | "id": "1d0f66d0-afed-4fe6-a2c8-39619f5b8eaa", 24 | "name": "AdvancedHueBridge", 25 | "namespace": "apwelsh", 26 | "location": "https://raw.githubusercontent.com/apwelsh/hubitat/master/hue/device/advanced-hue-bridge.groovy", 27 | "required": true, 28 | "version": "1.5.0" 29 | }, 30 | { 31 | "id": "11de790f-60db-48f4-bda3-72fa1d3c61b9", 32 | "name": "AdvancedHueGroup", 33 | "namespace": "apwelsh", 34 | "location": "https://raw.githubusercontent.com/apwelsh/hubitat/master/hue/device/advanced-hue-group.groovy", 35 | "required": true, 36 | "version": "1.6.1" 37 | }, 38 | { 39 | "id": "4e877c99-7537-47d9-9849-9022e75fc355", 40 | "name": "AdvancedHueLightSensor", 41 | "namespace": "apwelsh", 42 | "location": "https://raw.githubusercontent.com/apwelsh/hubitat/master/hue/device/advanced-hue-light-sensor.groovy", 43 | "required": false, 44 | "version": "1.0.6" 45 | }, 46 | { 47 | "id": "428ff30b-152a-4e21-8daf-fa58935a018a", 48 | "name": "AdvancedHueMotionSensor", 49 | "namespace": "apwelsh", 50 | "location": "https://raw.githubusercontent.com/apwelsh/hubitat/master/hue/device/advanced-hue-motion-sensor.groovy", 51 | "required": false, 52 | "version": "1.0.7" 53 | }, 54 | { 55 | "id": "bbf68d11-1581-40a1-9218-561899546174", 56 | "name": "AdvancedHueTemperatureSensor", 57 | "namespace": "apwelsh", 58 | "location": "https://raw.githubusercontent.com/apwelsh/hubitat/master/hue/device/advanced-hue-temperature-sensor.groovy", 59 | "required": false, 60 | "version": "1.0.7" 61 | }, 62 | { 63 | "id": "99bc549d-ffa2-4b97-bbb3-1b18fb576a37", 64 | "name": "AdvancedHueDimmerSensor", 65 | "namespace": "apwelsh", 66 | "location": "https://raw.githubusercontent.com/apwelsh/hubitat/master/hue/device/advanced-hue-dimmer-sensor.groovy", 67 | "required": false, 68 | "version": "1.0.3" 69 | }, 70 | { 71 | "id": "0fdebe44-6a4b-48ca-859d-480252ac8ec4", 72 | "name": "AdvancedHueTapSensor", 73 | "namespace": "apwelsh", 74 | "location": "https://raw.githubusercontent.com/apwelsh/hubitat/master/hue/device/advanced-hue-tap-sensor.groovy", 75 | "required": false, 76 | "version": "1.0.3" 77 | }, 78 | { 79 | "id": "c7ac5c30-d728-4370-8362-ee541a92ffc5", 80 | "name": "Advanced Hue RunLessWires Sensor", 81 | "namespace": "apwelsh", 82 | "location": "https://raw.githubusercontent.com/apwelsh/hubitat/master/hue/device/advanced-hue-runlesswires-sensor.groovy", 83 | "required": false, 84 | "version": "1.0.0" 85 | } 86 | ], 87 | "version": "1.10.24", 88 | "releaseNotes": "1.10.x revision:\n1.10.24 - Fix infinite :443 appending in host URL bug.\n1.10.23 - Possible fix for host address translation logic. Possible fix for NPE when metadata is not fully updated.\n1.10.22 - added sensor capability to all non-button sendor types.\n1.10.19 - Fixed another bug in hub linking for new installs, and relinking processes. Also added additional code to hue bridge driver to better pause the hub activity when hub is unlinked.\n1.10.18 - Add additional controls to try hub unlinking in attempt to silence hub activity while in an unlinked state.\n1.10.17 - Additional bug fixes to further improve relinking hubs.\n1.10.16 - Improved support for hub discovery, and fixed a bug in hub discovery code that prevented the app from finding the hub on the network.\n1.10.15 - Added support to auto-detect when IP Address of Hub changes, and update it. This change is best implemented with a hub reboot however, it can be update pro-actively by unlinking and re-linking the hub. This change was overlaid with a V2 API update, so that the app can now convert hue XY colors to a best match to HSV. The change in color spaces is a bit wonky, due to proprietary mapping data not shared by Philips Hue, but it does currently get really close to the correct HSV values for Type C hue bulbs. \n1.10.14 - fix for lights turning off when level from Hue < 1\n1.10.13 - Simplified hub referesh and watchdog logic to improve stability.\n1.10.12 - Modified startLevelChange to turn on/off lights based on current level.\n1.10.11 - Added another enhancement to fix tracking of subscribed/unsubscribed state to further improve the watchdog timer behavior, and help the system keep the EventStream online.\n1.10.10 - Added a watchdog setting to force aggressive monitoring of dropped connections, and reworked the workflow that handles connecting/disconnecting processes to better handle the watchdog processing.\n1.10.9 - Fixed unlik hub to completely remove the hub linking details, and improved the ssdpSubscription handler\n1.10.8 - Fix ssdpDiscover scheduling causing event spamming in the events log.\n1.10.7 - fixed typo in last fix.\n1.10.6 - Fix to discovery of IP Address changes; now, unlink and relink will re-bind the hub if a new IP Address is assigned.\n1.10.5 - Fix bug from last change. \n1.10.4 - Added connection watchdog to hub device to attempt to auto-reconnect hub if disconnected.\n1.10.3 - fixed index out of bounds in log when full refresh runs.\n1.10.2 - Added support to set sensitivity on motion sensors and general support for maintaining device configs.\n1.10.1 - Fixed auto-refresh on groups. Added forced device refresh on detected light/group updates from hue event stream. Still researching soltuion for slow to update states from Hue.\n1.10.0 - removed all hub refresh calls, and rely only on the event stream to update device states. It is required that you have an updated Hue bridge to work properly, as this version relies on the new v2 API for all device updates. It is also recommended to turn on auto-refresh at the hub level only, with a very conservative refresh schedule, so as to not overload the HE hub with unnecessary hub processing.\n\n1.9.x revisions:\n1.9.8 - Skip color name computation if hub version is not version 2.3.2 or newer.\n1.9.7 - Fixed log spamming when eventStream messages do not have a Version 1 ID from Hue.\n1.9.6 - Fix type cast conversion error on line 1610.\n1.9.5 - Fix Null pointer when updating some groups lighting values.\n1.9.4 - Change minimum level reported to HE to 1, if hue device is on\n1.9.3 - Fixed scene bug to turn off active scene when group is turned off.\n1.9.2 - Fixed Hue Group handling of color / saturation changes not working on hue hub. Added support for colorName reporting. Removed redundant device info from group logging.\n1.9.1 - Fixed bug resulting in wrong temperatures reported on temp sensors.\n1.9.0 - Added RunLessWires Friends of Hue switch. (Thanks to @pocketgeek)\n1.8.x revisions:\n1.8.6 - Removed colormode state assignment, which has been removed from the Hue API, and no longer works. With this change in Hue, some non-hue lighs will no longer work in groups correctly. I do not have a fix for this Hue change.\n1.8.5 - Modified the main app to support devices w/o label defined.\n1.8.4 - Fixed errors reported in log when adding a new hue group.\n1.8.3 - Fix for default scene of group that prevents the group from turning on when scened default scene is defined. This appears to be a change in Hue hub behavior, possibly related to v2 API updates. \n1.8.2 - Update dimmer and tap devices to allow pressing the same button multiple times, without losing the press event. \n1.8.1 - Correct the Hue Tap device name so the applicaiton can correctly associate the Tap button device to the device driver." 89 | } -------------------------------------------------------------------------------- /iopool/app/iopool-connect.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * iopool Connect 3 | * Version 1.0.1 4 | * Download: https://github.com/apwelsh/hubitat 5 | * Description: 6 | * This is an integration app for Hubitat designed to locate, and install any/all attached iopool managed devices. 7 | * The iopool application works with the iopool EcO and the pHin pool monitor. iopool support the pHin mointor for 8 | * users that bought the pHin pool monitor, and were stuck with a monitor and not support. I do not have a pHin to test 9 | * with. If you have a pHin, the data should be available via the iopool public API for use by this app. Send me a note 10 | * if you would like to use a pHin device. Huibitat does not have a bluetooth receiver. All communications in this application 11 | * use the iopool public API in the cloud. To use this app, you will need the iopool app, and possibly the gateway too. 12 | * To use this application, follow the instruction on GitHub to obtain an API key, and enter it into this application. 13 | *------------------------------------------------------------------------------------------------------------------- 14 | * Copyright 2020 Armand Peter Welsh 15 | * 16 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 17 | * documentation files (the 'Software'), to deal in the Software without restriction, including without limitation 18 | * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, 19 | * and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 20 | * 21 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions of 22 | * the Software. 23 | * 24 | * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO 25 | * THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 26 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF 27 | * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 28 | * IN THE SOFTWARE. 29 | *------------------------------------------------------------------------------------------------------------------- 30 | **/ 31 | 32 | definition( 33 | name: 'iopool Connect', 34 | namespace: 'apwelsh', 35 | author: 'Armand Welsh (apwelsh)', 36 | description: 'iopool Connect Integration', 37 | category: 'Convenience', 38 | //importUrl: 'https://raw.githubusercontent.com/apwelsh/hubitat/master/iopool/app/iopool-connect.groovy', 39 | iconUrl: '', 40 | iconX2Url: '', 41 | iconX3Url: '' 42 | ) 43 | 44 | preferences { 45 | page(name: 'mainPage') 46 | page(name: 'addSelectedDevices') 47 | } 48 | 49 | /* 50 | * Life Cycle Functions 51 | */ 52 | 53 | def installed() { 54 | initialize() 55 | } 56 | 57 | def uninstalled() { 58 | unschedule() 59 | getChildDevices().each { 60 | deleteChildDevice(it.deviceNetworkId) 61 | } 62 | } 63 | 64 | def updated() { 65 | unschedule() 66 | initialize() 67 | } 68 | 69 | def initialize() { 70 | scheduleRefresh() 71 | } 72 | 73 | /* 74 | * Application Screens 75 | */ 76 | 77 | def mainPage() { 78 | 79 | if (!state && !apiKey) { 80 | return dynamicPage(name: 'mainPage', title: 'iopool Connect', uninstall: true, install: true) { 81 | section('Login') { 82 | input name: 'apiKey', type: 'string', title: 'Public API Key', description: 'Once the iopool mobile app is configure, request an API key from the company, and enter it here.' 83 | } 84 | section { 85 | paragraph 'Hit Done to to install the iopool Connect Integration.\nRe-open to setup.' 86 | } 87 | } 88 | } 89 | 90 | Map pools = findPools() 91 | Map availablePools = pools.collectEntries { it } // clone the list to create a copy 92 | List installedPools = childDevices 93 | installedPools.each { child -> availablePools.remove(child.device.deviceNetworkId) } 94 | 95 | if (selectedPools) { 96 | selectedPools?.each { dni -> 97 | log.debug "${dni} selected from [${pools}]" 98 | if (pools[dni]) { 99 | log.debug "installing child device" 100 | installedPools << addChildDevice('apwelsh', 'EcO Water Quality Sensor', "${dni}", [name: pools[dni]]) 101 | availablePools.remove(dni) 102 | } 103 | } 104 | selectedPools = selectedPools?.collectMany { dni -> return availablePools[dni] ? it : [] } ?: [] 105 | app.updateSetting('selectedPools', selectedPools ) 106 | } 107 | 108 | return dynamicPage(name: 'mainPage', title: '', uninstall: (!installedPools), install: true) { 109 | 110 | section(getFormat('title', 'iopool Connect')) { 111 | paragraph getFormat('line') 112 | } 113 | 114 | if (availablePools) { 115 | section('Available Monitors') { 116 | input 'selectedPools', 'enum', title: 'Select Pool/Spa monitors to install', required: false, multiple: true, options: availablePools, submitOnChange: true 117 | } 118 | } 119 | 120 | if (installedPools) { 121 | section('Installed Devices') { 122 | installedPools.sort({ a, b -> a.label <=> b.label }).each { child -> 123 | def desc = child.label != child.name ? child.name : '' 124 | Boolean ena = pools[child.device.deviceNetworkId] 125 | paragraph """""" 128 | 129 | } 130 | } 131 | } 132 | 133 | section('Options') { 134 | input name: 'refreshInterval', type: 'number', defaultValue: 5, title: 'Refresh interval (minutes)' 135 | input name: 'logEnable', type: 'bool', defaultValue: true, title: 'Enable logging' 136 | } 137 | 138 | section { 139 | paragraph getFormat('line') 140 | paragraph "
iopool Connect
Donate

Please consider donating. This app took a lot of work to make.
If you find it valuable, I'd certainly appreciate it!
" 141 | } 142 | } 143 | } 144 | 145 | private String getFormat(String type, String myText='') { 146 | if (type == 'line') return '
' 147 | if (type == 'title') return "

${myText}

" 148 | } 149 | 150 | private String deviceLabel(device) { 151 | return device?.label ?: device?.name 152 | } 153 | 154 | private Map findPools() { 155 | return httpGet([uri:'https://api.iopool.com/v1/pools', timeout: 20, headers: ['x-api-key': apiKey]]) { response -> 156 | if (!response.success) { 157 | log.error "(${response.status}) ${response.statusLine}" 158 | return [:] 159 | } 160 | List data = response.data 161 | refreshChildren(data) 162 | return data?.collectEntries { sensor -> 163 | if (!sensor.id) { return } 164 | [(sensor.id): sensor.title] 165 | } 166 | } 167 | } 168 | 169 | private void scheduleRefresh() { 170 | int interval = Math.max(Math.min(refreshInterval?:5,1440),1) * 60 171 | runIn(interval, 'queryPools', [overwrite: true, misfire: 'ignore']) 172 | } 173 | 174 | private void queryPools() { 175 | try { 176 | httpGet([uri:'https://api.iopool.com/v1/pools', timeout: 20, headers: ['x-api-key': apiKey]]) { response -> 177 | if (!response.success) { 178 | log.error "(${response.status}) ${response.statusLine}" 179 | return 180 | } 181 | refreshChildren(response.data) 182 | } 183 | } finally { 184 | scheduleRefresh() 185 | } 186 | } 187 | 188 | private void refreshChildren(List data) { 189 | try { 190 | data?.each { poolData -> 191 | String nid = poolData.id 192 | def child = getChildDevice(nid) 193 | child?.parseMessage(poolData) 194 | } 195 | } finally { 196 | scheduleRefresh() 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /devices/timer-device.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Timer Device 3 | * v1.10 4 | * Download: https://github.com/apwelsh/hubitat 5 | * Description: 6 | * This is a simple count-down timer I created for a friend. I wanted to use the standard TimedSession capability. 7 | * Making it work with rules is more difficult than it should be though, since HE does not seem to support this yet. 8 | * As such, I implement the PushableButton as well to be able to trigger an event when the timer has expired. This is a 9 | * very simple timer based on a cron type schedule. It is now an accurate timer, that is rather light-weight. 10 | * To use the timer, first set the TimeRemaining attribute, then start the timer. When the timer has expired, it 11 | * will issue a button push. To use this in ruless to trigger timer completion, create a rule on Button 1 Pushed. 12 | * 13 | * I have updated the driver to allow upates of every 1 second, every 5 seconds, or dynamic (every 10 seconds at most) 14 | * This update includes improved timer scheduler logic 15 | *------------------------------------------------------------------------------------------------------------------- 16 | * Copyright 2020 Armand Peter Welsh 17 | * 18 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 19 | * documentation files (the 'Software'), to deal in the Software without restriction, including without limitation 20 | * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, 21 | * and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 22 | * 23 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions of 24 | * the Software. 25 | * 26 | * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO 27 | * THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 28 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF 29 | * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 30 | * IN THE SOFTWARE. 31 | *------------------------------------------------------------------------------------------------------------------- 32 | **/ 33 | preferences { 34 | input name: 'idleText', 35 | type: 'bool', title: 'Idle Message', 36 | description: 'Show Idle message when timer is done', 37 | defaultValue: false 38 | input name: 'useDefault', 39 | type: 'bool', title: 'Use default timer time', 40 | description: 'Enable this switch to define a default timer time to use.' 41 | defaultValue: false 42 | if (useDefault) { 43 | input name: 'defaultTime', 44 | type: 'number', title: 'Default Timer', 45 | description: 'The number of seconds to set the timer to, if the timeRemaining value is zero (0).' 46 | defaultValue: null 47 | } 48 | input name: 'cancelWhenOff', 49 | type: 'bool', title: cancelWhenOff ? 'Timer will be canceled when off' : 'Timer will be stopped when off' 50 | description: 'Toggle to change the behavior when the timer is turned off' 51 | defaultValue: false 52 | input name: 'logEnable', 53 | type: 'bool', title: 'Logging', 54 | description: 'Enable debug logging', 55 | defaultValue: false 56 | input name: 'updateMode', 57 | type: 'enum', title: 'Update Mode', 58 | options: ['aggressive': 'Aggressive - Every seconds', 'moderate': 'Moderate - Every 5 seconds', 'dynamic': 'Dynamic - More aggressive as time nears completion'], 59 | description: 'If enabled, the time remaining will be updated every single second.', 60 | defaultValue: 'dynamic' 61 | } 62 | 63 | metadata { 64 | definition (name: 'Timer Device', 65 | namespace: 'apwelsh', 66 | author: 'Armand Welsh', 67 | importUrl: 'https://raw.githubusercontent.com/apwelsh/hubitat/master/devices/timer-device.groovy') { 68 | 69 | capability 'TimedSession' 70 | capability 'Sensor' 71 | capability 'PushableButton' 72 | capability 'Switch' 73 | 74 | attribute 'display', 'string' 75 | } 76 | } 77 | 78 | /** 79 | ** Lifecycle Methods 80 | **/ 81 | 82 | def updated() { 83 | def timeRemaining = device.currentValue('timeRemaining') 84 | setTimeRemaining(timeRemaining?:0) 85 | } 86 | 87 | def installed() { 88 | sendEvent(name: 'numberOfButtons', value: 1) 89 | setTimeRemaining(0) 90 | } 91 | 92 | /** 93 | ** TimedSession Methods 94 | **/ 95 | 96 | def cancel() { 97 | if (logEnable) log.info 'Canceling timer' 98 | setStatus('canceled') 99 | setTimeRemaining(0) 100 | } 101 | 102 | def pause() { 103 | if (state.alerttime) { 104 | setTimeRemaining(((state.alerttime - now()) / 1000) as int) 105 | unschedule() 106 | state.remove('refreshInterval') 107 | state.remove('alerttime') 108 | setStatus('paused') 109 | if (logEnable) log.info 'Timer paused' 110 | } 111 | } 112 | 113 | def scheduleTimerEvent(secondsRemaining) { 114 | def refreshInterval = 1 115 | 116 | if (secondsRemaining > 60 && updateMode == 'dynamic') { 117 | if ((secondsRemaining as int) % 10 == 0) refreshInterval = 10 118 | else return 119 | } 120 | else if (secondsRemaining > 10 && updateMode != 'aggressive') { 121 | if ((secondsRemaining as int) % 5 == 0) refreshInterval = 5 122 | else return 123 | } 124 | 125 | if (((state.refreshInterval?:0) as int) != refreshInterval) { 126 | def t = refreshInterval == 1 ? '*' : new Date().getSeconds() % refreshInterval 127 | unschedule(timerEvent) 128 | schedule("${t}/${refreshInterval} * * * * ?", timerEvent, [misfire: 'ignore', overwrite: false]) 129 | state.refreshInterval = refreshInterval 130 | if (logEnable) log.info "Changed timer update frequency to every ${refreshInterval} second(s)" 131 | } 132 | 133 | } 134 | 135 | def setTimeRemaining(seconds) { 136 | 137 | if (seconds == 0) { 138 | timerDone() 139 | } 140 | 141 | if (state.alerttime) { 142 | scheduleTimerEvent(seconds as int) 143 | } 144 | 145 | int days = (seconds / 86400) as int 146 | int hours = ((seconds.intValue() % 86400) / 3600) as int 147 | int mins = ((seconds.intValue() % 3600) / 60) as int 148 | int secs = (seconds.intValue() % 60) as int 149 | if (days > 0) { 150 | remaining = String.format('%d %s %d:%02d:%02d', days, days == 1 ? 'day' : 'days', hours, mins, secs) 151 | } else if (hours > 0) { 152 | remaining = String.format('%d:%02d:%02d', hours, mins, secs) 153 | } else { 154 | remaining = String.format('%02d:%02d', mins, secs) 155 | } 156 | 157 | sendEvent(name: 'timeRemaining', value: seconds) 158 | sendEvent(name: 'display', value: remaining) 159 | 160 | } 161 | 162 | 163 | def on() { 164 | start() 165 | } 166 | 167 | def start() { 168 | if (logEnable) log.info 'Timer started' 169 | unschedule() 170 | long timeRemaining = (device.currentValue('timeRemaining') ?: 0 as long) 171 | if (timeRemaining == 0 && useDefault) { 172 | timeRemaining = defaultTime 173 | if (logEnable) log.info "Using default time of ${timeRemaining} seconds" 174 | setTimeRemaining(timeRemaining) 175 | } 176 | 177 | setStatus('running') 178 | 179 | runIn(timeRemaining, timerDone,[overwrite:true, misfire: 'ignore']) 180 | state.alerttime = now() + (timeRemaining * 1000) 181 | 182 | def refreshInterval = 1 183 | state.refreshInterval = refreshInterval 184 | schedule('* * * * * ?', timerEvent, [misfire: 'ignore', overwrite: true]) 185 | } 186 | 187 | def off() { 188 | if (cancelWhenOff == true) { 189 | cancel() 190 | } else { 191 | stop() 192 | } 193 | } 194 | 195 | def stop() { 196 | unschedule() 197 | setTimeRemaining(0) 198 | if (logEnable) log.info 'Timer stopped' 199 | } 200 | 201 | /** 202 | ** PushableButton Method 203 | **/ 204 | 205 | def push() { 206 | sendEvent(name: 'pushed', value: 1, isStateChange: true) 207 | } 208 | 209 | /** 210 | ** Support Methods 211 | **/ 212 | 213 | def setStatus(status) { 214 | sendEvent(name: 'sessionStatus', value: status, isStateChange: true) 215 | switch (status) { 216 | case 'running': 217 | case 'paused': 218 | sendEvent(name: 'switch', value: 'on') 219 | break; 220 | default: 221 | sendEvent(name: 'switch', value: 'off') 222 | } 223 | } 224 | 225 | def resetDisplay() { 226 | sendEvent(name: 'display', value: idleText ? 'idle' : '--:--') 227 | } 228 | 229 | def timerDone() { 230 | if (device.currentValue('switch') == 'on') { 231 | unschedule() 232 | state.remove('alerttime') 233 | state.remove('refreshInterval') 234 | if (device.latestValue('sessionStatus') != 'canceled') { 235 | sendEvent(name: 'timeRemaining', value: 0) 236 | setStatus('stopped') 237 | } 238 | runIn(1, resetDisplay) 239 | if (device.latestValue('sessionStatus') != 'canceled') { 240 | push() 241 | } 242 | } 243 | } 244 | 245 | def timerEvent() { 246 | if (state.alerttime) { 247 | setTimeRemaining(((state.alerttime - now())/1000) as int) 248 | } else { 249 | stop() 250 | } 251 | } 252 | 253 | 254 | -------------------------------------------------------------------------------- /revproxy/app/simple-proxy-provider.groovy: -------------------------------------------------------------------------------- 1 | import java.util.regex.Pattern 2 | 3 | /** 4 | * Simple Proxy Provider 5 | * 6 | * Child app acting as a reverse proxy that forwards incoming requests to a specified target URL. 7 | * It can optionally pass client request headers and append client query parameters to the upstream request. 8 | * The remote endpoint functionality can be toggled on and off. 9 | */ 10 | definition( 11 | name: "Simple Proxy Provider", 12 | namespace: "apwelsh", 13 | author: "Armand Welsh", 14 | description: "Child app acting as a simple proxy for a single web call with options to pass request headers and query parameters.", 15 | category: "Utility", 16 | iconUrl: "", 17 | iconX2Url: "", 18 | parent: "apwelsh:Simple Reverse Proxy Service" 19 | ) 20 | 21 | preferences { 22 | page(name: "mainPage") 23 | } 24 | 25 | def mainPage(params) { 26 | 27 | if (params?.action == "enable") { 28 | initializeAppEndpoint() 29 | } else if (params?.action == "disable") { 30 | revokeAccessToken() 31 | } 32 | 33 | dynamicPage(name: "mainPage", title: "Simple Proxy Provider", install: true, uninstall: true) { 34 | // Built-in label input to update the app's label. 35 | section("") { 36 | label title: "Name", required: true 37 | } 38 | section("Proxy Settings") { 39 | input "targetUrl", "text", title: "Target URL", required: true, submitOnChange: true 40 | } 41 | section("Proxy Options") { 42 | input "passRequestHeaders", "bool", title: "Pass Request Headers to Upstream Host", required: true, defaultValue: true 43 | input "appendClientQueryParams", "bool", title: "Append Client Query Parameters to Upstream URL", required: true, defaultValue: true 44 | input "enableUsageLogging", "bool", title: "Enable Usage Logging", required: true, defaultValue: false 45 | } 46 | section("Remote Endpoint Control") { 47 | if (!state.endpoint) { 48 | paragraph "Remote endpoint is currently disabled." 49 | href(name: "initializeAppEndpoint", title: "Enable Remote Endpoint", description: "Tap to enable", params: [action: "enable"], page: "mainPage") 50 | } else { 51 | paragraph "Local Endpoint:\n${state.localEndpointURL}" 52 | paragraph "Remote Endpoint:\n${state.remoteEndpointURL}" 53 | href(name: "revokeAccessToken", title: "Disable Remote Endpoint", description: "Tap to disable", params: [action: "disable"], page: "mainPage") 54 | } 55 | } 56 | } 57 | } 58 | 59 | mappings { 60 | path("/proxy") { 61 | action: [ 62 | GET: "handleProxy" 63 | ] 64 | } 65 | } 66 | 67 | def isValidUrl(url) { 68 | try { 69 | def uri = new URI(url) 70 | return uri.scheme in ["http", "https"] && uri.host != null 71 | } catch (URISyntaxException e) { 72 | return false 73 | } 74 | } 75 | 76 | def sanitizeQueryParam(value) { 77 | return URLEncoder.encode(value, "UTF-8") 78 | } 79 | 80 | def sanitizeUrl(url) { 81 | try { 82 | return new URI(url).toASCIIString() 83 | } catch (Exception e) { 84 | return "" 85 | } 86 | } 87 | 88 | def handleProxy() { 89 | if (!targetUrl || !isValidUrl(targetUrl)) { 90 | log.warn "Invalid or missing target URL: ${targetUrl}" + (targetUrl ? " - Fails validation" : " - Target URL is empty") 91 | render contentType: "application/json", data: [error: "Invalid or missing target URL. Must be a valid HTTP/HTTPS URL."] 92 | return 93 | } 94 | 95 | def baseUrl = targetUrl 96 | def upstreamQueryMap = [:] 97 | if (targetUrl.contains("?")) { 98 | def parts = targetUrl.split(/\?/, 2) 99 | baseUrl = parts[0] 100 | def queryString = parts[1] 101 | queryString.split("&").each { pair -> 102 | def kv = pair.split("=") 103 | upstreamQueryMap[kv[0]] = (kv.size() > 1 ? sanitizeQueryParam(kv[1]) : "") 104 | } 105 | } 106 | 107 | def mergedParams = [:] 108 | mergedParams.putAll(upstreamQueryMap) 109 | if (appendClientQueryParams && params != null) { 110 | params.each { key, value -> 111 | if (key.toLowerCase() != "access_token" && !upstreamQueryMap.containsKey(key)) { 112 | mergedParams[key] = sanitizeQueryParam(value.toString()) 113 | } 114 | } 115 | } 116 | 117 | def queryStringFinal = mergedParams.collect { key, value -> 118 | "${URLEncoder.encode(key, 'UTF-8')}=${URLEncoder.encode(value, 'UTF-8')}" 119 | }.join("&") 120 | 121 | def upstreamUrl = sanitizeUrl(baseUrl + (queryStringFinal ? "?" + queryStringFinal : "")) 122 | 123 | // Define the list of headers to ignore. 124 | def ignoreHeaders = ["connection", "access_token", "cookie", "host", "upgrade-insecure-requests"] 125 | 126 | // Build request headers if the toggle is enabled. 127 | def requestHeaders = [:] 128 | if (passRequestHeaders && request?.headers) { 129 | if (request.headers instanceof Map) { 130 | request.headers.each { key, value -> 131 | if (!ignoreHeaders.contains(key.toLowerCase())) { 132 | requestHeaders[key] = value 133 | } 134 | } 135 | } else if (request.headers instanceof List) { 136 | request.headers.each { header -> 137 | def key = header.getName() 138 | if (!ignoreHeaders.contains(key.toLowerCase())) { 139 | requestHeaders[key] = header.getValue() 140 | } 141 | } 142 | } 143 | } 144 | 145 | // Log usage details if enabled 146 | if (enableUsageLogging) { 147 | 148 | String reconstructedUrl = (request.requestSource == 'local' ? localApiServerUrl : apiServerUrl) 149 | reconstructedUrl += '/proxy/' + (params ? '?' + params.collect { key, value -> "${URLEncoder.encode(key, 'UTF-8')}=${URLEncoder.encode(value.toString(), 'UTF-8')}" }.join('&') : '') 150 | log.info "${app.getLabel()} (${request.requestSource}) ${reconstructedUrl}" 151 | } 152 | 153 | try { 154 | httpGet(uri: upstreamUrl, headers: requestHeaders) { resp -> 155 | def headerMap = [:] 156 | if (resp.headers instanceof Map) { 157 | resp.headers.each { key, value -> 158 | if (!ignoreHeaders.contains(key.toLowerCase())) { 159 | headerMap[key] = value 160 | } 161 | } 162 | } else if (resp.headers instanceof List) { 163 | resp.headers.each { header -> 164 | def key = header.getName() 165 | if (!ignoreHeaders.contains(key.toLowerCase())) { 166 | headerMap[key] = header.getValue() 167 | } 168 | } 169 | } 170 | 171 | // If no Content-Disposition header exists, deduce a filename from targetUrl. 172 | if (!headerMap['Content-Disposition']) { 173 | def segments = targetUrl.tokenize("/") 174 | def fileName = segments ? segments[-1].split('\\?')[0] : null 175 | if (fileName) { 176 | headerMap['Content-Disposition'] = "attachment; filename=\"${fileName}\"" 177 | } 178 | } 179 | 180 | if (resp.status == 200) { 181 | def ct = headerMap['Content-Type'] ?: "application/json" 182 | def dataOut = resp.data 183 | if (dataOut instanceof java.io.ByteArrayInputStream) { 184 | dataOut = new String(dataOut.bytes, "UTF-8") 185 | } 186 | render contentType: ct, data: dataOut, headers: headerMap 187 | } else { 188 | render contentType: "application/json", data: [error: "HTTP call returned status ${resp.status}"], headers: headerMap 189 | } 190 | } 191 | } catch (Exception e) { 192 | render contentType: "application/json", data: [error: "Exception: ${e.message}"] 193 | } 194 | } 195 | 196 | /** 197 | * Initializes the remote endpoint by creating an access token and storing endpoint URLs. 198 | * The hub automatically stores the access token in state.accessToken. 199 | * The access token is appended as a query parameter named "apikey". 200 | */ 201 | def initializeAppEndpoint() { 202 | if (!state.endpoint) { 203 | try { 204 | def token = createAccessToken() 205 | if (token) { 206 | state.endpoint = true 207 | state.localEndpointURL = getFullLocalApiServerUrl() + "/proxy?access_token=${state.accessToken}" 208 | state.remoteEndpointURL = getFullApiServerUrl() + "/proxy?access_token=${state.accessToken}" 209 | } 210 | } catch(e) { 211 | state.endpoint = null 212 | } 213 | } 214 | return state.endpoint 215 | } 216 | 217 | /** 218 | * Revokes the remote endpoint by clearing the stored token and endpoint URLs. 219 | */ 220 | def revokeAccessToken() { 221 | try { 222 | state.endpoint = false 223 | state.localEndpointURL = null 224 | state.remoteEndpointURL = null 225 | state.accessToken = null 226 | } catch(e) { 227 | } 228 | } 229 | 230 | /** 231 | * Called when the app's preferences are updated. 232 | */ 233 | def updated() { 234 | // No additional label update is necessary since the built-in label is used. 235 | } 236 | -------------------------------------------------------------------------------- /roku/README.md: -------------------------------------------------------------------------------- 1 | # Roku Drivers [![Donate](https://img.shields.io/badge/donate-PayPal-blue.svg?logo=paypal&style=plastic)](https://www.paypal.com/donate?hosted_button_id=XZXSPZWAABU8J) 2 | 3 | This is my implementation of the **Roku TV + App Control** Device handlers for Hubitat. 4 | 5 | NOTICE: This driver has been been through several updates. 6 | 7 | WARNING: Upgrading to this new version may break some of your automations, as some functionality has changed. **Upgrade may require reconfiguration** 8 | 9 | ## Getting Started 10 | 11 | To use this software, you must download just this one file: 12 | - [roku-tv.groovy](device/roku-tv.groovy) - The primary driver for controlling, and querying Roku devices. 13 | - Convert any application into a simple on/off child device 14 | - Convert any remote control button press into a momentary on child device 15 | - Configure simple or custom polling configurations for device state (on, off, play, pause, current application selected on tv, etc) 16 | 17 | _** Note ** that there used to be two files, but I have converted the device driver to use the built-in `Generic Component Switch` driver._ 18 | 19 | _Optional (highly recommended)_ 20 | - [roku-connect.groovy](app/roku-connect.groovy) - A streamlined management tool to make managing your Roku devices easier. 21 | - Discovers active Roku devices using the SSDP device discovery protocol, no more managing IP addresses manually. 22 | - Rename your Roku devices for easier navigation in Hubitat 23 | - Add, Remove, and Rename installed Roku applications 24 | - Add, Remove, and Rename Roku TV input devices 25 | 26 | ## Installation 27 | I recommend using Hubitat Package Manager to install Roku Connect and the Roku TV device driver. 28 | 29 | The following steps are used to install the necessary code manually: 30 | 31 | Sign into your Hubitat device, and add the Roku TV device handler. To do so, from the menu select the **"Drivers Code"** menu option. 32 | 33 | ![](../images/HubitatMenuDriversCode.png) 34 | 35 | Next, click the **"(+) New Driver"** button 36 | 37 | ![](../images/NewDriverButton.png) 38 | 39 | ### Roku TV Driver 40 | Select the import button, and put in the URL to the [roku-tv.groovy](device/roku-tv.groovy) driver. Click the import button, and the new driver is ready. 41 | Click **Save**. 42 | ![save button](../images/NewDriverExample.png) 43 | 44 | ### Roku Connect App 45 | from the menu, select the **"App Code"** menu option. 46 | 47 | Next, click the **"(+) New App"** button 48 | 49 | Select the import button, and put in the URL to the [roku-connect.groovy](app/roku-connect.groovy) app. Click the import button, and the new app is ready. 50 | Click **Save**. 51 | 52 | 53 | ## Configuration 54 | 55 | The configuration is quite simple, and tries to be as automatic as possible. Once the device hander and app are installed, you will need to add the new Roku TV devices that you want to automate, and configure the IP Address. 56 | 57 | ### Prerequisite 58 | For smart home automation to work reliably, all devices on your network that will be access from the Hubitat hub should be configured with a static address. This also true for the Roku TV devices. I cannot provide details on how to do this, as each network is unique, and different routers have different solutions. If your devices receive their network address via the router using DHCP (Dynamica Host Configuration Protocol) -- this is most typical -- then you may need to configure your router to *reserve* the IP address assigned to the Roku device. This way, every time the Roku device is powered on, it will always have the same IP address, and the Hubitat Elevation hub, and this device handler, will know how to find it. 59 | 60 | ### Adding the Roku Connect App 61 | If you choose to use the Roku Connect App, you will not need to add the Roku TV devices individually. Instead, you can skip the **Adding the Roku Device** section. 62 | 63 | To manage your Roku device from Roku Connect, navigate to **"Apps"** in the Hubitat menu, and select **"(+) Add User App"**. 64 | In the pop-up, select Roku Connect, the click the **done** button. 65 | 66 | From the list of installed applicaiton, locate **"Roku Connect"** and run it. 67 | 68 | From here you can **"Discover New Devices"**, and manage instsalled device. 69 | 70 | If you cannot setup DHCP reservations, and static IP addresses are not an option, be sure to turn on **Auto detect IP changes of Roku devices**. This feature will periodically ping the SSDP connected devices, even when the app is not in discovery mode, so locate and detect IP address changes for intalled Roku devices. 71 | 72 | I believe the Roku Connect application is rather simple, needs little explanation. This is my preferred solution for managing your Roku devices. 73 | 74 | 75 | ### Adding the Roku Device 76 | If you would rather not use the Roku Connect application, you can manually install the Roku TV devices. 77 | Any device installed using this manual method cannot be managed by the Roku Connect application, until the installed device is removed from hubitat first. 78 | 79 | To add your Roku device, navigate the **Devices** in the Hubitat menu, and select **Add Virtual Device** 80 | 81 | ![](../images/AddVirtualDeviceButton.png) 82 | 83 | Give your device a Friendly, but unique device name, and device network Id. 84 | I like to prefix my Device Network Id with a discriptive prefix to help ensure uniqueness, and to isolate my virtual devices by type. Be sure to select the new **Roky TV** devicea as the type, and then Save the device information. 85 | 86 | ![](../images/RokuTVDeviceInfo.png) 87 | 88 | Enter the IP Address of your Roku TV device in the Prefrences section, the MAC address is not required, it will attempt to fill-in when the Roku device is queried. 89 | 90 | ![](../images/RokuTVPreferences.png) 91 | 92 | Click the **Save Prefrences** button. 93 | 94 | ### Finalizing the configuration 95 | 96 | The next step is a little quirky, because the Roku TV device handler is going to auto-configure this device as much as possible. If you do not see the device **Current States**, then I recommend issuing a browser refresh. This can be achieved by pressing the `F5` key on Windows, and some Linux systems, or `CMD+R` on MacOS. 97 | 98 | ## Using the new device 99 | 100 | ![](../images/RokuTVCurrentState.png) 101 | 102 | Once the device looks something like the above image, your TV is configured and ready to go. 103 | 104 | At this point, you should see the installed child devices for the apps, which should look something like this: 105 | 106 | ![](../images/InstalledAppsList.png) 107 | 108 | Note: The MAC address is used to institute a wake-on-lan event to wakeup Roku devices that entered into a deep sleep. 109 | 110 | ### How to use 111 | 112 | All the button on the Roku TV device implement the Hubitat standards for control of the associated commands. The Roku API does not appear to provide a direct mechanism to set some of the parameters available. 113 | 114 | **Features** 115 | 116 | | Command | Description | 117 | | - | - | 118 | | Volumne Up | Increments the volume by 1 step | 119 | | Volume Down | Decrements the volume by 1 step | 120 | | Set Volume | _not supported by Roku API_ at this time | 121 | | Channel Up | Change channel up | 122 | | Channel Down | Change channel down | 123 | | Mute | Toggle the audio Mute state on/off | 124 | | Unmute | _not support by Roku API_ same behavior as Mute | 125 | | On | Turn the TV on | 126 | | Off | Turn the TV off | 127 | | Poll | Issues a Refresh | 128 | | Refresh | Forced refresh of TV state | 129 | | Reload Apps | Deletes and reloads all child devices | 130 | 131 | _At present, the `Set Volume` button is ignored_. 132 | The Roku API does not report mute state, so mute and unmute is just a toggle, both calling the mute button event. 133 | 134 | Although Hubitat has what it needs to see this as a TV type device, the current Alexa skill app does not support the TV type, so it will appear as a standard switch. 135 | 136 | Note: The TV Device does not keep an active link with the Roku, and there is not facility within the Roku API for this. As such, this handler will only issue a poll/refresh once every five (5) minutes. And the current application is polled once every minute, as this is a much smaller request. 137 | 138 | ### Roku App (Child Device) 139 | 140 | The Roku App child device handler is just a child switch. The switch allows the buttons to appear in Alexa as devices. The status of the switch will automatically change to on or off based on the currently running application. If the switch is commanded on or off, then the switch status will change to turning-on or turning-off until the actual state is confirmed. 141 | 142 | **Not yet implemented** 143 | Roke Integration App to find and manage roku device installatios 144 | Roke Integration Child App to manage each roku devices features. 145 | Implement a refresh stack to limit TV refresh status to one-at-a-time. Parallel refreshes seem to severely slow down the Hub. 146 | 147 | ### Status Updates 148 | January 21, 2020 149 | - Remove roku-tv child app, and replace with built-in Generic Component Switch device 150 | - Add new attribute "refresh" to track the current refresh status. If refresh is pending, some refresh operations are suppressed. 151 | January 20, 2020 152 | - Relocated device drivers to now location for better management of code 153 | - Created a temporary old device with new URL. This new device should act as a bridge to migrate existing installations to using the new devices. 154 | 155 | ## Support the Author 156 | Please consider donating. This app took a lot of work to make. 157 | Any donations received will be used to help fund new projects 158 | 159 | [![paypal](https://www.paypalobjects.com/en_US/i/btn/btn_donateCC_LG.gif)](https://www.paypal.com/donate?hosted_button_id=XZXSPZWAABU8J) 160 | 161 | ## License 162 | 163 | This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details. Portions of this code are licensed from Eric Boehs [LICENSE.md](https://raw.githubusercontent.com/ericboehs/smartthings-roku-tv/master/LICENSE) 164 | 165 | ## Acknowledgments 166 | This software would not be possible without the efforts and free sharing of information provided by the original author of the [TCL Smartthings Roku TV](https://github.com/ericboehs/smartthings-roku-tv) created by [Eric Boehs](https://github.com/ericboehs), upon which I was inspired learn about the ECP protocol, and Hubitat development. 167 | 168 | Additional thanks go to Roku for freely publishing the [External Control API](https://developer.roku.com/docs/developer-program/debugging/external-control-api.md) documentation. 169 | -------------------------------------------------------------------------------- /hue/device/advanced-hue-runlesswires-sensor.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Advanced Hue RunLessWires Sensor 3 | * Version 1.0.0 4 | * Download: https://github.com/apwelsh/hubitat 5 | * Description: 6 | * This is a child device handler for the Advance Hue Bridge Integration App. Although this can work in poll mode, 7 | * it is highly recommended to use the event stream based push notifications. 8 | * Author: Curtis Edge (@pocketgeek) 9 | *------------------------------------------------------------------------------------------------------------------- 10 | * Copyright 2020 Armand Peter Welsh 11 | * 12 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 13 | * documentation files (the 'Software'), to deal in the Software without restriction, including without limitation 14 | * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, 15 | * and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 16 | * 17 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions of 18 | * the Software. 19 | * 20 | * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO 21 | * THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF 23 | * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 24 | * IN THE SOFTWARE. 25 | *------------------------------------------------------------------------------------------------------------------- 26 | **/ 27 | 28 | import groovy.transform.Field 29 | import java.util.concurrent.ConcurrentHashMap 30 | 31 | 32 | @Field static final Boolean DEFAULT_LOG_ENABLE = true 33 | @Field static final Boolean DEFAULT_DBG_ENABLE = false 34 | 35 | @Field static final String SETTING_LOG_ENABLE = 'logEnable' 36 | @Field static final String SETTING_DBG_ENABLE = 'debug' 37 | 38 | @Field static final Boolean BUTTON_1_HOLDABLE = true 39 | @Field static final Boolean BUTTON_2_HOLDABLE = true 40 | @Field static final Boolean BUTTON_3_HOLDABLE = true 41 | @Field static final Boolean BUTTON_4_HOLDABLE = true 42 | 43 | @Field static final String SETTING_BUTTON_1_HOLDABLE = 'Button 1 long press enable' 44 | @Field static final String SETTING_BUTTON_2_HOLDABLE = 'Button 2 long press enable' 45 | @Field static final String SETTING_BUTTON_3_HOLDABLE = 'Button 3 long press enable' 46 | @Field static final String SETTING_BUTTON_4_HOLDABLE = 'Button 4 long press enable' 47 | 48 | def heldButtonPressDelay = 2000 49 | 50 | metadata { 51 | definition ( 52 | name: 'AdvancedHueRunLessWiresSensor', 53 | namespace: 'apwelsh', 54 | author: 'Armand Welsh', 55 | importUrl: '') { 56 | 57 | capability 'PushableButton' 58 | capability 'HoldableButton' 59 | capability 'Refresh' 60 | capability 'Initialize' 61 | 62 | attribute 'status', 'string' // expect enabled/disabled 63 | attribute 'health', 'string' // reachable/unreachable 64 | } 65 | } 66 | 67 | preferences { 68 | 69 | input name: SETTING_LOG_ENABLE, 70 | type: 'bool', 71 | defaultValue: DEFAULT_LOG_ENABLE, 72 | title: 'Enable informational logging' 73 | 74 | input name: SETTING_DBG_ENABLE, 75 | type: 'bool', 76 | defaultValue: DEFAULT_DBG_ENABLE, 77 | title: 'Enable debug logging' 78 | 79 | input name: SETTING_BUTTON_1_HOLDABLE, 80 | type: 'bool', 81 | defaultValue: BUTTON_1_HOLDABLE, 82 | title: SETTING_BUTTON_1_HOLDABLE 83 | 84 | input name: SETTING_BUTTON_2_HOLDABLE, 85 | type: 'bool', 86 | defaultValue: BUTTON_2_HOLDABLE, 87 | title: SETTING_BUTTON_2_HOLDABLE 88 | 89 | input name: SETTING_BUTTON_3_HOLDABLE, 90 | type: 'bool', 91 | defaultValue: BUTTON_3_HOLDABLE, 92 | title: SETTING_BUTTON_3_HOLDABLE 93 | 94 | input name: SETTING_BUTTON_4_HOLDABLE, 95 | type: 'bool', 96 | defaultValue: BUTTON_4_HOLDABLE, 97 | title: SETTING_BUTTON_4_HOLDABLE 98 | 99 | } 100 | 101 | void updateSetting(String name, Object value) { 102 | device.updateSetting(name, value) 103 | this[name] = value 104 | } 105 | 106 | /** 107 | * Hubitat DTH Lifecycle Functions 108 | **/ 109 | def installed() { 110 | updated() 111 | initialize() 112 | refresh() 113 | 114 | mapButtons() 115 | 116 | } 117 | 118 | def initialize() { 119 | String id = parent.deviceIdNode(device.deviceNetworkId) 120 | Long buttons = parent.state.sensors[id]?.capabilities?.inputs?.size()?:0 121 | parent.sendChildEvent(this, [name: 'numberOfButtons', value: buttons]) 122 | } 123 | 124 | def updated() { 125 | if (this[SETTING_LOG_ENABLE] == null) { updateSetting(SETTING_LOG_ENABLE, DEFAULT_LOG_ENABLE) } 126 | if (this[SETTING_DBG_ENABLE] == null) { updateSetting(SETTING_DBG_ENABLE, DEFAULT_DBG_ENABLE) } 127 | if (this[SETTING_BUTTON_1_HOLDABLE] == null) { updateSetting(SETTING_BUTTON_1_HOLDABLE, BUTTON_1_HOLDABLE) } 128 | if (this[SETTING_BUTTON_2_HOLDABLE] == null) { updateSetting(SETTING_BUTTON_2_HOLDABLE, BUTTON_2_HOLDABLE) } 129 | if (this[SETTING_BUTTON_3_HOLDABLE] == null) { updateSetting(SETTING_BUTTON_3_HOLDABLE, BUTTON_3_HOLDABLE) } 130 | if (this[SETTING_BUTTON_4_HOLDABLE] == null) { updateSetting(SETTING_BUTTON_4_HOLDABLE, BUTTON_4_HOLDABLE) } 131 | if (this[SETTING_LOG_ENABLE]) { log.debug 'Preferences updated' } 132 | } 133 | 134 | void mapButtons() { 135 | String id = parent.deviceIdNode(device.deviceNetworkId) 136 | 137 | state.buttonMap = parent.enumerateResourcesV2().findAll { resource -> 138 | resource.type == 'button' && resource.id_v1 == "/sensors/${id}" 139 | }.collectEntries { button -> 140 | [(button.id): button.metadata.control_id] 141 | } 142 | } 143 | 144 | /* 145 | * Device Capability Interface Functions 146 | */ 147 | 148 | 149 | void refresh() { 150 | if (this[SETTING_DBG_ENABLE]) { log.debug "Sensor (${this}) refreshing" } 151 | parent.getDeviceState(this) 152 | } 153 | 154 | 155 | void setHueProperty(Map args) { 156 | if (args.last_event && args.id) { 157 | Number btn = state.buttonMap?.(args.id) ?: 0 158 | switch(args.last_event) { 159 | // For whatever reason the API for 'Friends of HUE' Switches changed somewhere around December 2021. 160 | // Home Assistent issue on it: https://github.com/home-assistant/core/issues/61671 161 | // Now the only messages that are sent for these are initial_press and short_release. 162 | // And better yet, you always get two 'short_release' messages. One about 1 second after the 'initial_press' message, even if you 163 | // are still holding the button, and one after you actually let go of the button. This made it necessary to just ignore the first 'short_release' message after 164 | // the 'initial_press' message. In my teseting the initial short_release message usually comes about 1 second later, necessitating 165 | // the hold timer being 2 seconds. 166 | case 'initial_press': 167 | state.initial_pressTime = new Date().getTime() // Get time of initial_press 168 | state.short_releaseNumber = 0 // Reset short_release message counter 169 | switch(btn) { // If holdable is false for any button return button press event without waiting for hold counter 170 | case 1: 171 | if (this[SETTING_BUTTON_1_HOLDABLE] == false) { 172 | push(btn) 173 | } 174 | break; 175 | case 2: 176 | if (this[SETTING_BUTTON_2_HOLDABLE] == false) { 177 | push(btn) 178 | } 179 | break; 180 | case 3: 181 | if (this[SETTING_BUTTON_3_HOLDABLE] == false) { 182 | push(btn) 183 | } 184 | break; 185 | case 4: 186 | if (this[SETTING_BUTTON_4_HOLDABLE] == false) { 187 | push(btn) 188 | } 189 | break; 190 | } 191 | case 'short_release': 192 | state.short_releaseNumber++ // Increment short_release counter. 193 | if (state.short_releaseNumber != 2) { 194 | break; 195 | } 196 | state.buttonHoldTime = new Date().getTime() - state.initial_pressTime // Get the difference between initial_press and *actual* short_release time. 197 | switch(btn) { 198 | case 1: 199 | if (this[SETTING_BUTTON_1_HOLDABLE] == true) { 200 | if (state.buttonHoldTime > 2000) { 201 | hold(btn) 202 | } 203 | else { 204 | push(btn) 205 | } 206 | break; 207 | } 208 | break; 209 | case 2: 210 | if (this[SETTING_BUTTON_2_HOLDABLE] == true) { 211 | if (state.buttonHoldTime > 2000) { 212 | hold(btn) 213 | } 214 | else { 215 | push(btn) 216 | } 217 | break; 218 | } 219 | break; 220 | case 3: 221 | if (this[SETTING_BUTTON_3_HOLDABLE] == true) { 222 | if (state.buttonHoldTime > 2000) { 223 | hold(btn) 224 | } 225 | else { 226 | push(btn) 227 | } 228 | break; 229 | } 230 | break; 231 | case 4: 232 | if (this[SETTING_BUTTON_4_HOLDABLE] == true) { 233 | if (state.buttonHoldTime > 2000) { 234 | hold(btn) 235 | } 236 | else { 237 | push(btn) 238 | } 239 | break; 240 | } 241 | break; 242 | } 243 | } 244 | } 245 | } 246 | 247 | void push(Number buttonNumber) { 248 | state.buttonPressTime = 0 249 | sendEvent([name: 'pushed', value: buttonNumber, isStateChange:true]) 250 | } 251 | 252 | void hold(Number buttonNumber) { 253 | sendEvent([name: 'held', value: buttonNumber, isStateChange: true]) 254 | } 255 | 256 | void release(Number buttonNumber) { 257 | sendEvent([name: 'released', value: buttonNumber, isStateChange: true]) 258 | } -------------------------------------------------------------------------------- /tesla/app/tesla-connect.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Tesla Connect 3 | * 4 | * Copyright 2018 Trent Foley 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 | definition( 17 | name: "Tesla Connect", 18 | namespace: "apwelsh", 19 | author: "Armand Welsh", 20 | description: "Integrate your Tesla car with SmartThings.", 21 | category: "Convenience", 22 | iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/tesla-app%402x.png", 23 | iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/tesla-app%403x.png", 24 | iconX3Url: "https://s3.amazonaws.com/smartapp-icons/Partner/tesla-app%403x.png", 25 | singleInstance: true 26 | ) 27 | 28 | preferences { 29 | page(name: "loginToTesla", title: "Tesla") 30 | page(name: "selectVehicles", title: "Tesla") 31 | } 32 | 33 | def loginToTesla() { 34 | def showUninstall = email != null && password != null 35 | return dynamicPage(name: "loginToTesla", title: "Connect your Tesla", nextPage:"selectVehicles", uninstall:showUninstall) { 36 | section("Log in to your Tesla account:") { 37 | input "email", "text", title: "Email", required: true, autoCorrect:false 38 | input "password", "password", title: "Password", required: true, autoCorrect:false 39 | } 40 | section("To use Tesla, SmartThings encrypts and securely stores your Tesla credentials.") {} 41 | } 42 | } 43 | 44 | def selectVehicles() { 45 | try { 46 | refreshAccountVehicles() 47 | 48 | return dynamicPage(name: "selectVehicles", title: "Tesla", install:true, uninstall:true) { 49 | section("Select which Tesla to connect"){ 50 | input(name: "selectedVehicles", type: "enum", required:false, multiple:true, options:state.accountVehicles) 51 | } 52 | } 53 | } catch (Exception e) { 54 | log.error e 55 | return dynamicPage(name: "selectVehicles", title: "Tesla", install:false, uninstall:true, nextPage:"") { 56 | section("") { 57 | paragraph "Please check your username and password" 58 | } 59 | } 60 | } 61 | } 62 | 63 | def getChildNamespace() { "apwelsh" } 64 | def getChildName() { "Tesla" } 65 | def getServerUrl() { "https://owner-api.teslamotors.com" } 66 | def getClientId () { "81527cff06843c8634fdc09e8ac0abefb46ac849f38fe1e431c2ef2106796384" } 67 | def getClientSecret () { "c7257eb71a564034f9419ee651c7d0e5f7aa6bfbd18bafb5c5c033b093bb2fa3" } 68 | def getUserAgent() { "trentacular" } 69 | 70 | def getAccessToken() { 71 | if (!state.accessToken) { 72 | refreshAccessToken() 73 | } 74 | state.accessToken 75 | } 76 | 77 | private convertEpochSecondsToDate(epoch) { 78 | return new Date(epoch * 1000); 79 | } 80 | 81 | def refreshAccessToken() { 82 | log.debug "refreshAccessToken" 83 | try { 84 | if (state.refreshToken) { 85 | log.debug "Found refresh token so attempting an oAuth refresh" 86 | try { 87 | httpPostJson([ 88 | uri: serverUrl, 89 | path: "/oauth/token", 90 | headers: [ 'User-Agent': userAgent ], 91 | body: [ 92 | grant_type: "refresh_token", 93 | client_id: clientId, 94 | client_secret: clientSecret, 95 | refresh_token: state.refreshToken 96 | ] 97 | ]) { resp -> 98 | state.accessToken = resp.data.access_token 99 | state.refreshToken = resp.data.refresh_token 100 | } 101 | } catch (groovyx.net.http.HttpResponseException e) { 102 | log.warn e 103 | state.accessToken = null 104 | if (e.response?.data?.status?.code == 14) { 105 | state.refreshToken = null 106 | } 107 | } 108 | } 109 | 110 | if (!state.accessToken) { 111 | log.debug "Attemtping to get access token using password" 112 | httpPostJson([ 113 | uri: serverUrl, 114 | path: "/oauth/token", 115 | headers: [ 'User-Agent': userAgent ], 116 | body: [ 117 | grant_type: "password", 118 | client_id: clientId, 119 | client_secret: clientSecret, 120 | email: email, 121 | password: password 122 | ] 123 | ]) { resp -> 124 | log.debug "Received access token that will expire on ${convertEpochSecondsToDate(resp.data.created_at + resp.data.expires_in)}" 125 | state.accessToken = resp.data.access_token 126 | state.refreshToken = resp.data.refresh_token 127 | } 128 | } 129 | } catch (Exception e) { 130 | log.error "Unhandled exception in refreshAccessToken: $e" 131 | } 132 | } 133 | 134 | private authorizedHttpRequest(Map options = [:], String path, String method, Closure closure) { 135 | def attempt = options.attempt ?: 0 136 | 137 | log.debug "authorizedHttpRequest ${method} ${path} attempt ${attempt}" 138 | try { 139 | def requestParameters = [ 140 | uri: serverUrl, 141 | path: path, 142 | headers: [ 143 | 'User-Agent': userAgent, 144 | Authorization: "Bearer ${accessToken}" 145 | ] 146 | ] 147 | 148 | if (method == "GET") { 149 | httpGet(requestParameters) { resp -> closure(resp) } 150 | } else if (method == "POST") { 151 | if (options.body) { 152 | requestParameters["body"] = options.body 153 | log.debug "authorizedHttpRequest body: ${options.body}" 154 | httpPostJson(requestParameters) { resp -> closure(resp) } 155 | } else { 156 | httpPost(requestParameters) { resp -> closure(resp) } 157 | } 158 | } else { 159 | log.error "Invalid method ${method}" 160 | } 161 | } catch (groovyx.net.http.HttpResponseException e) { 162 | if (e.response?.data?.status?.code == 14) { 163 | if (attempt < 3) { 164 | refreshAccessToken() 165 | authorizedHttpRequest(path, mehod, closure, body: options.body, attempt: attempt++) 166 | } else { 167 | log.error "Failed after 3 attempts to perform request: ${path}" 168 | } 169 | } else { 170 | log.error "Request failed for path: ${path}. ${e.response?.data}" 171 | } 172 | } 173 | } 174 | 175 | private refreshAccountVehicles() { 176 | log.debug "refreshAccountVehicles" 177 | 178 | state.accountVehicles = [:] 179 | 180 | authorizedHttpRequest("/api/1/vehicles", "GET", { resp -> 181 | log.debug "Found ${resp.data.response.size()} vehicles" 182 | resp.data.response.each { vehicle -> 183 | log.debug "${vehicle.id}: ${vehicle.display_name}" 184 | state.accountVehicles[vehicle.id] = vehicle.display_name 185 | } 186 | }) 187 | } 188 | 189 | 190 | def installed() { 191 | log.debug "Installed" 192 | initialize() 193 | } 194 | 195 | def updated() { 196 | log.debug "Updated" 197 | 198 | unsubscribe() 199 | initialize() 200 | } 201 | 202 | def uninstalled() { 203 | removeChildDevices(getChildDevices()) 204 | } 205 | 206 | private removeChildDevices(delete) { 207 | log.debug "deleting ${delete.size()} vehicles" 208 | delete.each { 209 | deleteChildDevice(it.deviceNetworkId) 210 | } 211 | } 212 | 213 | def initialize() { 214 | ensureDevicesForSelectedVehicles() 215 | removeNoLongerSelectedChildDevices() 216 | } 217 | 218 | private ensureDevicesForSelectedVehicles() { 219 | if (selectedVehicles) { 220 | selectedVehicles.each { dni -> 221 | def d = getChildDevice(dni) 222 | if(!d) { 223 | def vehicleName = state.accountVehicles[dni] 224 | device = addChildDevice(getChildNamespace(), "Tesla", dni, null, [name:"Tesla ${dni}", label:vehicleName]) 225 | log.debug "created device ${device.label} with id ${dni}" 226 | device.initialize() 227 | } else { 228 | log.debug "device for ${d.label} with id ${dni} already exists" 229 | } 230 | } 231 | } 232 | } 233 | 234 | private removeNoLongerSelectedChildDevices() { 235 | // Delete any that are no longer in settings 236 | def delete = getChildDevices().findAll { !selectedVehicles } 237 | removeChildDevices(delete) 238 | } 239 | 240 | private transformVehicleResponse(resp) { 241 | return [ 242 | state: resp.data.response.state, 243 | motion: "inactive", 244 | speed: 0, 245 | vin: resp.data.response.vin, 246 | thermostatMode: "off" 247 | ] 248 | } 249 | 250 | def refresh(child) { 251 | def data = [:] 252 | def id = child.device.deviceNetworkId 253 | authorizedHttpRequest("/api/1/vehicles/${id}", "GET", { resp -> 254 | data = transformVehicleResponse(resp) 255 | }) 256 | 257 | if (data.state == "online") { 258 | authorizedHttpRequest("/api/1/vehicles/${id}/vehicle_data", "GET", { resp -> 259 | def driveState = resp.data.response.drive_state 260 | def chargeState = resp.data.response.charge_state 261 | def vehicleState = resp.data.response.vehicle_state 262 | def climateState = resp.data.response.climate_state 263 | 264 | data.speed = driveState.speed ? driveState.speed : 0 265 | data.motion = data.speed > 0 ? "active" : "inactive" 266 | data.thermostatMode = climateState.is_climate_on ? "auto" : "off" 267 | 268 | data["chargeState"] = [ 269 | battery: chargeState.battery_level, 270 | batteryRange: chargeState.battery_range, 271 | chargingState: chargeState.charging_state 272 | ] 273 | 274 | data["driveState"] = [ 275 | latitude: driveState.latitude, 276 | longitude: driveState.longitude, 277 | method: driveState.native_type, 278 | heading: driveState.heading, 279 | lastUpdateTime: convertEpochSecondsToDate(driveState.gps_as_of) 280 | ] 281 | 282 | data["vehicleState"] = [ 283 | presence: vehicleState.homelink_nearby ? "present" : "not present", 284 | lock: vehicleState.locked ? "locked" : "unlocked", 285 | odometer: vehicleState.odometer 286 | ] 287 | 288 | data["climateState"] = [ 289 | temperature: celciusToFarenhiet(climateState.inside_temp), 290 | thermostatSetpoint: celciusToFarenhiet(climateState.driver_temp_setting) 291 | ] 292 | }) 293 | } 294 | 295 | return data 296 | } 297 | 298 | private celciusToFarenhiet(dC) { 299 | return dC * 9/5 + 32 300 | } 301 | 302 | private farenhietToCelcius(dF) { 303 | return (dF - 32) * 5/9 304 | } 305 | 306 | def wake(child) { 307 | def id = child.device.deviceNetworkId 308 | def data = [:] 309 | authorizedHttpRequest("/api/1/vehicles/${id}/wake_up", "POST", { resp -> 310 | data = transformVehicleResponse(resp) 311 | }) 312 | return data 313 | } 314 | 315 | private executeApiCommand(Map options = [:], child, String command) { 316 | def result = false 317 | authorizedHttpRequest(options, "/api/1/vehicles/${child.device.deviceNetworkId}/command/${command}", "POST", { resp -> 318 | result = resp.data.result 319 | }) 320 | return result 321 | } 322 | 323 | def lock(child) { 324 | return executeApiCommand(child, "door_lock") 325 | } 326 | 327 | def unlock(child) { 328 | return executeApiCommand(child, "door_unlock") 329 | } 330 | 331 | def climateAuto(child) { 332 | return executeApiCommand(child, "auto_conditioning_start") 333 | } 334 | 335 | def climateOff(child) { 336 | return executeApiCommand(child, "auto_conditioning_stop") 337 | } 338 | 339 | def setThermostatSetpoint(child, setpoint) { 340 | def setpointCelcius = farenhietToCelcius(setpoint) 341 | return executeApiCommand(child, "set_temps", body: [driver_temp: setpointCelcius, passenger_temp: setpointCelcius]) 342 | } 343 | 344 | def startCharge(child) { 345 | return executeApiCommand(child, "charge_start") 346 | } 347 | 348 | def stopCharge(child) { 349 | return executeApiCommand(child, "charge_stop") 350 | } 351 | 352 | def openTrunk(child, whichTrunk) { 353 | return executeApiCommand(child, "actuate_trunk", body: [which_trunk: whichTrunk]) 354 | } 355 | -------------------------------------------------------------------------------- /hue/device/advanced-hue-group.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * AdvancedHueGroup v1.6.1 3 | * Download: https://github.com/apwelsh/hubitat 4 | * Description: 5 | * This is a child device for the Advanced Hue Bridge Integeration app. This device is used to manage hue zones and rooms 6 | * as color lights. The key difference between this and the built-in hueGroup device is that this device supports the 7 | * refresh capability -- if enabled -- to allow for fast device refresh, and to act as the parent device for the 8 | * AdvancedHueScene device. 9 | *------------------------------------------------------------------------------------------------------------------- 10 | * Copyright 2020 Armand Peter Welsh 11 | * 12 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 13 | * documentation files (the 'Software'), to deal in the Software without restriction, including without limitation 14 | * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, 15 | * and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 16 | * 17 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions of 18 | * the Software. 19 | * 20 | * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO 21 | * THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF 23 | * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 24 | * IN THE SOFTWARE. 25 | *------------------------------------------------------------------------------------------------------------------- 26 | **/ 27 | 28 | import groovy.transform.Field 29 | 30 | @Field static final String SCENE_MODE_TRIGGER = 'trigger' 31 | @Field static final String SCENE_MODE_SWITCH = 'switch' 32 | 33 | @Field static final String DEFAULT_SCENE = '' 34 | @Field static final String DEFAULT_SCENE_MODE = 'trigger' 35 | @Field static final Boolean DEFAULT_SCENE_OFF = false 36 | @Field static final Boolean DEFAULT_AUTO_REFRESH = false 37 | @Field static final Number DEFAULT_REFRESH_INTERVAL = 300 38 | @Field static final Boolean DEFAULT_ANY_ON = true 39 | @Field static final Boolean DEFAULT_LOG_ENABLE = false 40 | 41 | @Field static final Map OPTIONS_REFRESH_INTERVAL = [ 60: '1 Minute', 42 | 300: '5 Minutes', 43 | 600: '10 Minutes', 44 | 900: '15 Minutes', 45 | 1800: '30 Minutes', 46 | 3600: '1 Hour', 47 | 10800: '3 Hours'] 48 | 49 | @Field static final Map EVENT_SWITCH_ON = [name: 'switch', value: 'on'] 50 | @Field static final Map EVENT_SWITCH_OFF = [name: 'switch', value: 'off'] 51 | 52 | @Field static final Map SCHEDULE_NON_PERSIST = [overwrite: true, misfire:'ignore'] 53 | 54 | preferences { 55 | 56 | input name: 'defaultScene', 57 | type: 'string', 58 | defaultValue: DEFAULT_SCENE, 59 | title: 'Default Scene', 60 | description: 'Enter a scene name or number as define by the Hue Bridge to activate when this group is turned on.' 61 | 62 | input name: 'sceneMode', 63 | type: 'enum', 64 | defaultValue: DEFAULT_SCENE_MODE, 65 | title: 'Scene Child Device Behavior', 66 | options: [SCENE_MODE_TRIGGER, SCENE_MODE_SWITCH], 67 | description: 'If set to switch, the scene can be used to turn off this group. Only one scene can be on at any one time.' 68 | 69 | if (sceneMode == SCENE_MODE_SWITCH) { 70 | input name: 'sceneOff', 71 | type: 'bool', 72 | defaultValue: DEFAULT_SCENE_OFF, 73 | title: 'Track Scene State', 74 | description: 'If enabled, any change to this group will turn off all child scenes.' 75 | } 76 | 77 | input name: 'autoRefresh', 78 | type: 'bool', 79 | defaultValue: DEFAULT_AUTO_REFRESH, 80 | title: 'Auto Refresh', 81 | description: 'Should this device support automatic refresh' 82 | 83 | if (autoRefresh) { 84 | input name: 'refreshInterval', 85 | type: 'enum', 86 | defaultValue: DEFAULT_REFRESH_INTERVAL, 87 | title: 'Refresh Inteval', 88 | options: OPTIONS_REFRESH_INTERVAL, 89 | required: true 90 | } 91 | 92 | input name: 'anyOn', 93 | type: 'bool', 94 | defaultValue: DEFAULT_ANY_ON, 95 | title: 'ANY on or ALL on', 96 | description: 'When ebabled, the group is considered on when any light is on' 97 | 98 | input name: 'logEnable', 99 | type: 'bool', 100 | defaultValue: DEFAULT_LOG_ENABLE, 101 | title: 'Enable informational logging' 102 | } 103 | 104 | metadata { 105 | definition (name: 'AdvancedHueGroup', 106 | namespace: 'apwelsh', 107 | author: 'Armand Welsh', 108 | importUrl: 'https://raw.githubusercontent.com/apwelsh/hubitat/master/hue/device/advanced-hue-group.groovy') { 109 | 110 | capability 'Light' 111 | capability 'ChangeLevel' 112 | capability 'Switch' 113 | capability 'SwitchLevel' 114 | capability 'Actuator' 115 | capability 'ColorControl' 116 | capability 'ColorMode' 117 | capability 'ColorTemperature' 118 | capability 'Refresh' 119 | capability 'Initialize' 120 | 121 | command 'activateScene', [[name: 'Scene Identitier*', type: 'STRING', description: 'Enter a scene name or the scene number as defined by the Hue Bridge']] 122 | } 123 | } 124 | 125 | def installed() { 126 | initialize() 127 | } 128 | 129 | def initialize() { 130 | updated() 131 | refresh() 132 | } 133 | 134 | def updated() { 135 | 136 | if (settings.defaultScene == false) { device.updateSetting('defaultScene', DEFAULT_SCENE) } 137 | if (settings.defaultScene == null) { device.updateSetting('defaultScene', DEFAULT_SCENE) } 138 | if (settings.sceneMode == null) { device.updateSetting('sceneMode', DEFAULT_SCENE_MODE) } 139 | if (settings.sceneOff == null) { device.updateSetting('sceneOff', DEFAULT_SCENE_OFF) } 140 | if (settings.autoRefresh == null) { device.updateSetting('autoRefresh', DEFAULT_AUTO_REFRESH) } 141 | if (settings.refreshInterval == null) { device.updateSetting('refreshInterval', DEFAULT_REFRESH_INTERVAL) } 142 | if (settings.anyOn == null) { device.updateSetting('anyOn', DEFAULT_ANY_ON) } 143 | if (settings.logEnable == null) { device.updateSetting('logEnable', DEFAULT_LOG_ENABLE) } 144 | 145 | if (!OPTIONS_REFRESH_INTERVAL[settings.refreshInterval as int]) { 146 | log.warn "Refresh interval is invalid. Changing refresh interval from ${this[SETTING_REFRESH_INTERVAL]} seconds to default interval of ${OPTIONS_REFRESH_INTERVAL[DEFAULT_REFRESH_INTERVAL]}" 147 | updateSetting(settings.refreshInterval, DEFAULT_REFRESH_INTERVAL) 148 | } 149 | if (settings.autoRefresh) { 150 | resetRefreshSchedule() 151 | } else { 152 | unschedule() 153 | } 154 | 155 | if (logEnable) { log.debug 'Preferences updated' } 156 | } 157 | 158 | /** Switch Commands **/ 159 | 160 | void on() { 161 | if (logEnable) { log.info 'on()' } 162 | if (defaultScene?.trim()) { 163 | if (logEnable) { log.info 'Using scene to turn on group' } 164 | activateScene(defaultScene) 165 | } else { 166 | parent.componentOn(this) 167 | attributeChanged() 168 | } 169 | } 170 | 171 | void off() { 172 | if (logEnable) { log.info 'off()' } 173 | parent.componentOff(this) 174 | attributeChanged() 175 | } 176 | 177 | /** ColorControl Commands **/ 178 | 179 | void setColor(Map colormap) { 180 | if (logEnable) { log.info "Setting mapped color: ${colormap}" } 181 | parent.componentSetColor(this, colormap) 182 | attributeChanged() 183 | } 184 | 185 | void setHue(hue) { 186 | if (logEnable) { log.info "Setting hue: ${hue}" } 187 | parent.componentSetHue(this, hue) 188 | attributeChanged() 189 | } 190 | 191 | void setSaturation(saturation) { 192 | if (logEnable) { log.info "Setting saturation: ${saturation}" } 193 | parent.componentSetSaturation(this, saturation) 194 | attributeChanged() 195 | } 196 | 197 | /** ColorTemperature Commands **/ 198 | 199 | void setColorTemperature(colortemperature, level = null, transitionTime = null) { 200 | if (logEnable) { log.info "Setting color temp: ${colortemperature}" } 201 | parent.componentSetColorTemperature(this, colortemperature, level, transitionTime) 202 | attributeChanged() 203 | } 204 | 205 | /** SwitchLevel Commands **/ 206 | 207 | void setLevel(level, duration=null) { 208 | if (logEnable) { log.info "Setting level: ${level}" } 209 | parent.componentSetLevel(this, level, duration) 210 | attributeChanged() 211 | } 212 | 213 | /** ChangeLevel Commands **/ 214 | 215 | void startLevelChange(String direction) { 216 | if (logEnable) { log.info "Starting level change: ${direction}" } 217 | parent.componentStartLevelChange(this, direction) 218 | attributeChanged() 219 | } 220 | 221 | void stopLevelChange() { 222 | if (logEnable) { log.info 'Stopping level change' } 223 | parent.componentStopLevelChange(this) 224 | attributeChanged() 225 | } 226 | 227 | /** Refresh Commands **/ 228 | void refresh() { 229 | if (debug) { log.debug 'Refreshing state' } 230 | parent.getDeviceState(this) 231 | } 232 | 233 | void resetRefreshSchedule() { 234 | unschedule(refresh) 235 | if (settings.autoRefresh) { 236 | switch (settings.refreshInterval as int) { 237 | case 60: 238 | runEvery1Minute('refresh') 239 | break 240 | case 300: 241 | runEvery5Minutes('refresh') 242 | break 243 | case 600: 244 | runEvery10Minutes('refresh') 245 | break 246 | case 900: 247 | runEvery15Minutes('refresh') 248 | break 249 | case 1800: 250 | runEvery30Minutes('refresh') 251 | break 252 | case 3600: 253 | runEvery1Hour('refresh') 254 | break 255 | case 10800: 256 | runEvery3Hours('refresh') 257 | break 258 | } 259 | } 260 | } 261 | 262 | void attributeChanged() { 263 | if (sceneMode == 'switch' && sceneOff) { allOff() } 264 | } 265 | 266 | void setHueProperty(Map args) { 267 | if (args.name == (anyOn ? 'any_on' : 'all_on')) { 268 | parent.sendChildEvent(this, args.value ? EVENT_SWITCH_ON : EVENT_SWITCH_OFF) 269 | } else if (args.name == 'scene') { 270 | sceneEnabled(args.value) 271 | } 272 | } 273 | 274 | void sceneEnabled(String sceneId) { 275 | String groupId = parent.deviceIdNode(device.deviceNetworkId) 276 | def scene = getChildDevice(parent.networkIdForScene(groupId, sceneId)) 277 | exclusiveOn(scene) 278 | if (scene) { 279 | scene.unschedule() 280 | if (sceneMode == SCENE_MODE_TRIGGER) { scene.runInMillis(400, 'off') } 281 | } 282 | } 283 | 284 | void activateScene(String scene) { 285 | if (logEnable) { log.info "Attempting to activate scene: ${scene}" } 286 | String sceneId = parent.findScene(parent.deviceIdNode(device.deviceNetworkId), scene)?.key 287 | if (sceneId) { 288 | if (logEnable) { log.info "Activating scene with Hue Scene ID: ${sceneId}" } 289 | parent.setDeviceState(this, ['scene': sceneId]) 290 | } else { 291 | if (logEnable) { log.warning "Cannot locate Hue scene ${scene}; verify the scene id or name is correct." } 292 | } 293 | } 294 | 295 | void allOff() { 296 | childDevices.findAll { scene -> parent.currentValue(scene, 'switch') == 'on' }.each { scene -> 297 | parent.sendChildEvent(scene, EVENT_SWITCH_OFF) 298 | log.info "Scene ($scene) turned off" 299 | } 300 | } 301 | 302 | void exclusiveOn(def child) { 303 | if (child) { 304 | String dni = child.device.deviceNetworkId 305 | childDevices.each { scene -> 306 | Map event = (scene.deviceNetworkId == dni) ? EVENT_SWITCH_ON : EVENT_SWITCH_OFF 307 | if (parent.currentValue(scene, 'switch') != event.value) { 308 | parent.sendChildEvent(scene, event) 309 | log.info "Scene (${scene}) turned ${event.value}" 310 | } 311 | } 312 | } 313 | if (!(parent.currentValue(this, 'switch') == 'on')) { 314 | parent.sendChildEvent(this, EVENT_SWITCH_ON) 315 | } 316 | } 317 | 318 | /* 319 | * Component Child Methods (used to capture actions generated on scenes) 320 | */ 321 | 322 | void componentOn(def child) { 323 | if (logEnable) { log.info "Scene (${child}) turning on" } 324 | String sceneId = parent.deviceIdNode(child.deviceNetworkId) 325 | parent.setDeviceState(this, ['scene':sceneId]) 326 | } 327 | 328 | void componentOff(def child) { 329 | if (logEnable) { log.info "Scene (${child}) turning off" } 330 | // Only change the state to off, there is not action to actually be performed. 331 | if (sceneMode == 'switch') { 332 | if (parent.currentValue(child, 'switch') == 'on') { off() } 333 | } else { 334 | parent.sendChildEvent(child, EVENT_SWITCH_OFF) 335 | } 336 | } 337 | 338 | void componentRefresh(def child) { 339 | if (logEnable) { log.info "Received refresh request from ${child.displayName} - ignored" } 340 | } 341 | -------------------------------------------------------------------------------- /iopool/device/eco-sensor: -------------------------------------------------------------------------------- 1 | /** 2 | * iopool EcO pool and spa water quality sensor 3 | * Version 1.0.0 4 | * Download: https://github.com/apwelsh/hubitat 5 | * Description: 6 | * This is a child device handler for the iopool manager app. This device reports water temperature, pH level, and 7 | * Oxidation-reduction potential (ORP). Through the use of this integration, you can integrate your iopool EcO system 8 | * equiped with iopool Gateway products to monitor your pool or spa water quality. 9 | *------------------------------------------------------------------------------------------------------------------- 10 | * Copyright 2022 Armand Peter Welsh 11 | * 12 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 13 | * documentation files (the 'Software'), to deal in the Software without restriction, including without limitation 14 | * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, 15 | * and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 16 | * 17 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions of 18 | * the Software. 19 | * 20 | * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO 21 | * THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF 23 | * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 24 | * IN THE SOFTWARE. 25 | *------------------------------------------------------------------------------------------------------------------- 26 | **/ 27 | 28 | import groovy.transform.Field 29 | import java.math.RoundingMode 30 | 31 | 32 | @Field static final Boolean DEFAULT_LOG_ENABLE = true 33 | @Field static final Boolean DEFAULT_DBG_ENABLE = false 34 | 35 | @Field static final Number DEFAULT_ORP_MIN = 550 36 | @Field static final Number DEFAULT_ORP_LO = 650 37 | @Field static final Number DEFAULT_ORP_HI = 800 38 | @Field static final Number DEFAULT_ORP_MAX = 1000 39 | 40 | @Field static final Number DEFAULT_PH_MIN = 6.8 41 | @Field static final Number DEFAULT_PH_LO = 7.1 42 | @Field static final Number DEFAULT_PH_HI = 7.7 43 | @Field static final Number DEFAULT_PH_MAX = 8.1 44 | 45 | @Field static final Number DEFAULT_TEMP_MINc = 30 46 | @Field static final Number DEFAULT_TEMP_LOc = 33.9 47 | @Field static final Number DEFAULT_TEMP_HIc = 38.3 48 | @Field static final Number DEFAULT_TEMP_MAXc = 40 49 | 50 | @Field static final Number DEFAULT_TEMP_MINf = 86 51 | @Field static final Number DEFAULT_TEMP_LOf = 93 52 | @Field static final Number DEFAULT_TEMP_HIf = 101 53 | @Field static final Number DEFAULT_TEMP_MAXf = 104 54 | 55 | @Field static final String SETTING_ORP_MIN = 'orpMinThreshold' 56 | @Field static final String SETTING_ORP_LO = 'orpLowThreshold' 57 | @Field static final String SETTING_ORP_HI = 'orpHiThreshold' 58 | @Field static final String SETTING_ORP_MAX = 'orpMaxThreshold' 59 | 60 | @Field static final String SETTING_PH_MIN = 'pHMinThreshold' 61 | @Field static final String SETTING_PH_LO = 'pHLowThreshold' 62 | @Field static final String SETTING_PH_HI = 'pHHiThreshold' 63 | @Field static final String SETTING_PH_MAX = 'pHMaxThreshold' 64 | 65 | @Field static final String SETTING_TEMP_MIN = 'tempMinThreshold' 66 | @Field static final String SETTING_TEMP_LO = 'tempLowThreshold' 67 | @Field static final String SETTING_TEMP_HI = 'tempHiThreshold' 68 | @Field static final String SETTING_TEMP_MAX = 'tempMaxThreshold' 69 | 70 | @Field static final String SETTING_LOG_ENABLE = 'logEnable' 71 | @Field static final String SETTING_DBG_ENABLE = 'debug' 72 | 73 | metadata { 74 | definition ( 75 | name: 'EcO Water Quality Sensor', 76 | namespace: 'apwelsh', 77 | author: 'Armand Welsh', 78 | importUrl: 'https://raw.githubusercontent.com/apwelsh/hubitat/master/iopool/device/eco-sensor.groovy') { 79 | 80 | capability 'Sensor' 81 | capability 'TemperatureMeasurement' 82 | capability 'pHMeasurement' 83 | capability 'Refresh' 84 | 85 | attribute 'orp', 'number' // Oxidation-reduction Potential (Disinfection potential) Units: mV 86 | attribute 'orpStatus', 'string' 87 | attribute 'temperatureStatus', 'string' 88 | attribute 'pHStatus', 'string' 89 | 90 | attribute 'mode', 'string' // ENUM ["STANDARD", "OPENING", "WINTER", "INITIALIZING"] 91 | attribute 'actionRequired', 'boolean' 92 | attribute 'lastMeasurement', 'date' // Date/time of last measure in UTC 93 | 94 | } 95 | } 96 | 97 | preferences { 98 | 99 | input title: 'Oxidation-reduction potential (ORP) condition thresholds', 100 | description: 'Threshold values used to determine the effectiveness of water sanitizer (chlorine/bromine)', 101 | type: 'paragraph', 102 | element: 'paragraph' 103 | 104 | input name: SETTING_ORP_MIN, 105 | type: 'number', 106 | defaultValue: DEFAULT_ORP_MIN, 107 | title: 'Minimum ORP threshold (mV)', 108 | description: 'Values below this reading are out of range' 109 | 110 | input name: SETTING_ORP_LO, 111 | type: 'number', 112 | defaultValue: DEFAULT_ORP_LO, 113 | title: 'Low ORP threshold (mV)', 114 | description: 'Values below this reading are out of normal range' 115 | 116 | input name: SETTING_ORP_HI, 117 | type: 'number', 118 | defaultValue: DEFAULT_ORP_HI, 119 | title: 'High ORP threshold (mV)', 120 | description: 'Values above this reading are out of normal range' 121 | 122 | input name: SETTING_ORP_MAX, 123 | type: 'number', 124 | defaultValue: DEFAULT_ORP_MAX, 125 | title: 'Max ORP threshold (mV)', 126 | description: 'Values above this reading are out of range' 127 | 128 | input title: 'Acidity (pH) condition thresholds', 129 | description: 'Threshold values used to determine the overall safety of the water. Low values reduce sanitizer effectiveness, high values cause scale formation and premature equipment failure.', 130 | type: 'paragraph', 131 | element: 'paragraph' 132 | 133 | input name: SETTING_PH_MIN, 134 | type: 'decimal', 135 | defaultValue: DEFAULT_PH_MIN, 136 | title: 'Minimum pH threshold', 137 | description: 'Values below this reading are out of range' 138 | 139 | input name: SETTING_PH_LO, 140 | type: 'decimal', 141 | defaultValue: DEFAULT_PH_LO, 142 | title: 'Low pH threshold', 143 | description: 'Values below this reading are out of normal range' 144 | 145 | input name: SETTING_PH_HI, 146 | type: 'decimal', 147 | defaultValue: DEFAULT_PH_HI, 148 | title: 'High pH threshold', 149 | description: 'Values above this reading are out of normal range' 150 | 151 | input name: SETTING_PH_MAX, 152 | type: 'decimal', 153 | defaultValue: DEFAULT_PH_MAX, 154 | title: 'Max pH threshold', 155 | description: 'Values above this reading are out of range' 156 | 157 | input title: 'Temperature operating thresholds', 158 | description: 'Threshold values used to determine the relative safety and comfort of the water temperature.', 159 | type: 'paragraph', 160 | element: 'paragraph' 161 | 162 | input name: SETTING_TEMP_MIN, 163 | type: 'decimal', 164 | defaultValue: DEFAULT_TEMP_MIN, 165 | title: 'Minimum temp threshold', 166 | description: 'Values below this reading are out of range' 167 | 168 | input name: SETTING_TEMP_LO, 169 | type: 'decimal', 170 | defaultValue: DEFAULT_TEMP_LO, 171 | title: 'Low temp threshold', 172 | description: 'Values below this reading are out of normal range' 173 | 174 | input name: SETTING_TEMP_HI, 175 | type: 'decimal', 176 | defaultValue: DEFAULT_TEMP_HI, 177 | title: 'High temp threshold', 178 | description: 'Values above this reading are out of normal range' 179 | 180 | input name: SETTING_TEMP_MAX, 181 | type: 'decimal', 182 | defaultValue: DEFAULT_TEMP_MAX, 183 | title: 'Max temp threshold', 184 | description: 'Values above this reading are out of range' 185 | 186 | /* 187 | input title: 'Miscellaneous Settings', 188 | type: 'paragraph', 189 | element: 'paragraph' 190 | 191 | input name: SETTING_LOG_ENABLE, 192 | type: 'bool', 193 | defaultValue: DEFAULT_LOG_ENABLE, 194 | title: 'Enable informational logging' 195 | 196 | input name: SETTING_DBG_ENABLE, 197 | type: 'bool', 198 | defaultValue: DEFAULT_DBG_ENABLE, 199 | title: 'Enable debug logging' 200 | */ 201 | } 202 | 203 | void updateSetting(String name, Object value) { 204 | device.updateSetting(name, value) 205 | this[name] = value 206 | } 207 | 208 | /** 209 | * Hubitat DTH Lifecycle Functions 210 | **/ 211 | def installed() { 212 | updated() 213 | refresh() 214 | } 215 | 216 | def updated() { 217 | if (this[SETTING_LOG_ENABLE] == null) { updateSetting(SETTING_LOG_ENABLE, DEFAULT_LOG_ENABLE) } 218 | if (this[SETTING_DBG_ENABLE] == null) { updateSetting(SETTING_DBG_ENABLE, DEFAULT_DBG_ENABLE) } 219 | 220 | if (this[SETTING_ORP_MIN] == null) { updateSetting(SETTING_ORP_MIN, DEFAULT_ORP_MIN) } 221 | if (this[SETTING_ORP_LO] == null) { updateSetting(SETTING_ORP_LO, DEFAULT_ORP_LO) } 222 | if (this[SETTING_ORP_HI] == null) { updateSetting(SETTING_ORP_HI, DEFAULT_ORP_HI) } 223 | if (this[SETTING_ORP_MAX] == null) { updateSetting(SETTING_ORP_MAX, DEFAULT_ORP_MAX) } 224 | 225 | if (this[SETTING_PH_MIN] == null) { updateSetting(SETTING_PH_MIN, DEFAULT_PH_MIN) } 226 | if (this[SETTING_PH_LO] == null) { updateSetting(SETTING_PH_LO, DEFAULT_PH_LO) } 227 | if (this[SETTING_PH_HI] == null) { updateSetting(SETTING_PH_HI, DEFAULT_PH_HI) } 228 | if (this[SETTING_PH_MAX] == null) { updateSetting(SETTING_PH_MAX, DEFAULT_PH_MAX) } 229 | 230 | if (this[SETTING_TEMP_MIN] == null) { updateSetting(SETTING_TEMP_MIN, location.temperatureScale == 'C' ? DEFAULT_TEMP_MINc : DEFAULT_TEMP_MINf) } 231 | if (this[SETTING_TEMP_LO] == null) { updateSetting(SETTING_TEMP_LO, location.temperatureScale == 'C' ? DEFAULT_TEMP_LOc : DEFAULT_TEMP_LOf) } 232 | if (this[SETTING_TEMP_HI] == null) { updateSetting(SETTING_TEMP_HI, location.temperatureScale == 'C' ? DEFAULT_TEMP_HIc : DEFAULT_TEMP_HIf) } 233 | if (this[SETTING_TEMP_MAX] == null) { updateSetting(SETTING_TEMP_MAX, location.temperatureScale == 'C' ? DEFAULT_TEMP_MAXc : DEFAULT_TEMP_MAXf) } 234 | 235 | if (this[SETTING_LOG_ENABLE]) { log.debug 'Preferences updated' } 236 | } 237 | 238 | 239 | /* 240 | * Device Capability Interface Functions 241 | */ 242 | 243 | void refresh() { 244 | if (this[SETTING_DBG_ENABLE]) { log.debug "Sensor (${this}) refreshing" } 245 | //parent.getDeviceState(this) 246 | queryPool() 247 | } 248 | 249 | private String getApiKey() { 250 | return parent.apiKey 251 | } 252 | 253 | public void parseMessage(Map message) { 254 | sendEvent(name: 'mode', value: message.mode) 255 | sendEvent(name: 'actionRequired', value: message.hasAnActionRequired == true) 256 | if (message.latestMeasure?.isValid) { 257 | Map latest = message.latestMeasure 258 | if (latest.temperature) { setTemperature(latest.temperature) } 259 | if (latest.ph) { setPh(latest.ph) } 260 | if (latest.orp) { setOrp(latest.orp) } 261 | if (latest.measuredAt) { sendEvent(name: 'lastMeasurement', value: toDateTime(latest.measuredAt)) } 262 | } 263 | } 264 | 265 | private void queryPool() { 266 | String id = device.deviceNetworkId 267 | httpGet([uri:"https://api.iopool.com/v1/pool/${id}", timeout: 20, headers: ['x-api-key': apiKey]]) { response -> 268 | if (response.success) { parseMessage(response.data) } 269 | } 270 | } 271 | 272 | private void setTemperature(Number tC) { 273 | Number temp = (location.temperatureScale == 'C' ? tC : ((tC * 1.8) + 32)).setScale(1, RoundingMode.HALF_UP) 274 | sendEvent(name: 'temperature', value: temp) 275 | String status = 'normal' 276 | if (temp <= this[SETTING_TEMP_MIN]) { status = 'low' } else 277 | if (temp >= this[SETTING_TEMP_MAX]) { status = 'high' } else 278 | if (temp <= this[SETTING_TEMP_LO]) { status = 'sunken' } else 279 | if (temp >= this[SETTING_TEMP_HI]) { status = 'elevated' } 280 | sendEvent(name: 'temperatureStatus', value: status) 281 | } 282 | 283 | private void setPh(Number newValue) { 284 | Number ph = newValue.setScale(1, RoundingMode.HALF_UP) 285 | sendEvent(name: 'pH', value: ph) 286 | String status = 'normal' 287 | if (ph <= this[SETTING_PH_MIN]) { status = 'low' } else 288 | if (ph >= this[SETTING_PH_MAX]) { status = 'high' } else 289 | if (ph <= this[SETTING_PH_LO]) { status = 'sunken' } else 290 | if (ph >= this[SETTING_PH_HI]) { status = 'elevated' } 291 | sendEvent(name: 'pHStatus', value: status) 292 | } 293 | 294 | private void setOrp(Number orp) { 295 | sendEvent(name: 'orp', value: orp) 296 | String status = 'normal' 297 | if (orp <= this[SETTING_ORP_MIN]) { status = 'low' } else 298 | if (orp >= this[SETTING_ORP_MAX]) { status = 'high' } else 299 | if (orp <= this[SETTING_ORP_LO]) { status = 'sunken' } else 300 | if (orp >= this[SETTING_ORP_HI]) { status = 'elevated' } 301 | sendEvent(name: 'orpStatus', value: status) 302 | } 303 | -------------------------------------------------------------------------------- /roku/app/roku-connect.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Roku Connect 3 | * Version 1.3.0 4 | * Download: https://github.com/apwelsh/hubitat 5 | * Description: 6 | * This is an integration app for Hubitat designed to locate, and install any/all attached Roku devices. 7 | * The app uses SSDP auto-discovery (as supported by the Roku ECP protocol). This integration app is not 8 | * required to use the Roku TV device, but if the app is installed, and used to manage the Roku TV devices, 9 | * then auto-discovery will keep the IP address up-to-date as the SSDP listener discovers that the Roku device's 10 | * IP Address has been changed. 11 | *------------------------------------------------------------------------------------------------------------------- 12 | * Copyright 2020 Armand Peter Welsh 13 | * 14 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 15 | * documentation files (the 'Software'), to deal in the Software without restriction, including without limitation 16 | * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, 17 | * and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 18 | * 19 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions of 20 | * the Software. 21 | * 22 | * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO 23 | * THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 24 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF 25 | * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 26 | * IN THE SOFTWARE. 27 | *------------------------------------------------------------------------------------------------------------------- 28 | **/ 29 | 30 | definition( 31 | name: 'Roku Connect', 32 | namespace: 'apwelsh', 33 | author: 'Armand Welsh (apwelsh)', 34 | description: 'Roku Device Integration', 35 | category: 'Convenience', 36 | //importUrl: 'https://raw.githubusercontent.com/apwelsh/hubitat/master/roku/app/roku-connect.groovy', 37 | iconUrl: '', 38 | iconX2Url: '', 39 | iconX3Url: '' 40 | ) 41 | 42 | preferences { 43 | page(name: 'mainPage') 44 | page(name: 'deviceDiscovery', title: 'Device Discovery', refreshTimeout:10) 45 | page(name: 'addSelectedDevices') 46 | page(name: 'configureDevice') 47 | page(name: 'changeName') 48 | page(name: 'manageApp') 49 | } 50 | 51 | /* 52 | * Life Cycle Functions 53 | */ 54 | 55 | def installed() { 56 | state.discovered=[:] 57 | ssdpSubscribe() 58 | initialize() 59 | } 60 | 61 | def uninstalled() { 62 | unschedule() 63 | unsubscribe() 64 | getChildDevices().each { 65 | deleteChildDevice(it.deviceNetworkId) 66 | } 67 | } 68 | 69 | def updated() { 70 | unschedule() 71 | initialize() 72 | } 73 | 74 | def initialize() { 75 | ssdpDiscover() 76 | if (autoDiscovery) { runEvery5Minutes('ssdpDiscover') } 77 | } 78 | 79 | /* 80 | * Application Screens 81 | */ 82 | 83 | def mainPage() { 84 | 85 | if (!autoDiscovery) { unschedule('ssdpDiscover') } 86 | 87 | if (!state) { 88 | return dynamicPage(name: 'mainPage', title: 'Roku Connect', uninstall: true, install: true) { 89 | section { 90 | paragraph 'Click Done to install the Roku Connect Integration.\nRe-open to setup.' 91 | } 92 | } 93 | } else { 94 | app.removeSetting('deviceNetworkId') 95 | app.removeSetting('selectedDevice') 96 | app.removeSetting('selectedApps') 97 | return dynamicPage(name: 'mainPage', title: '', uninstall: true, install: true) { 98 | section (getFormat("title", "Roku Connect")) { 99 | paragraph getFormat("line") 100 | } 101 | section(){ 102 | href 'deviceDiscovery', title:'Discover New Devices', description:'' 103 | } 104 | section('Installed Devices'){ 105 | getChildDevices().sort({ a, b -> a['label'] <=> b['label'] }).each { 106 | def desc = it.label != it.name ? it.name : '' 107 | href 'configureDevice', title:"${deviceLabel(it)}", description:desc, params: [netId: it.deviceNetworkId] 108 | } 109 | } 110 | section('Options') { 111 | input name: 'autoDiscovery', type: 'bool', defaultValue: true, title: 'Auto detect IP changes of Roku devices' 112 | input name: 'logEnable', type: 'bool', defaultValue: true, title: 'Enable logging' 113 | } 114 | section() { 115 | paragraph getFormat("line") 116 | paragraph "
Roku Connect
Donate

Please consider donating. This app took a lot of work to make.
If you find it valuable, I'd certainly appreciate it!
" 117 | } 118 | } 119 | } 120 | } 121 | 122 | def getFormat(type, myText=""){ // Borrowed from @dcmeglio HPM code 123 | if(type == "line") return "
" 124 | if(type == "title") return "

${myText}

" 125 | } 126 | 127 | def deviceDiscovery() { 128 | if (logEnable) { log.debug 'Searching for Hub additions and updates' } 129 | def refreshInterval = 30 130 | 131 | // Make sure we initiate a new search for Roku devices. 132 | ssdpSubscribe() 133 | ssdpDiscover() 134 | runEvery1Minute('ssdpDiscover') 135 | 136 | def installed = getChildDevices().collect { it.deviceNetworkId } 137 | def discovered = getDiscovered() 138 | 139 | def options = [:] 140 | if (discovered) { 141 | discovered.each {key, value -> 142 | if (installed.find { it == value.mac }) { return } 143 | options["${key}"] = "${value.name}" 144 | } 145 | } 146 | 147 | def numFound = options.size() ?: 0 148 | 149 | def uninstall = false 150 | def nextPage = selectedDevices ? 'addSelectedDevices' : null 151 | 152 | return dynamicPage(name:'deviceDiscovery', title:'Discovery Started!', nextPage:nextPage, refreshInterval:refreshInterval, uninstall:uninstall) { 153 | section('Please wait while we discover your Roku devices. Discovery can take a few minutes, so sit back and relax. The page will reload automatically! Select your Roku below once discovered.') { 154 | input 'selectedDevices', 'enum', required:false, title:"Select Roku Devices to install (${numFound} found)", multiple:true, options:options, submitOnChange: true 155 | } 156 | } 157 | } 158 | 159 | def addSelectedDevices() { 160 | if (!selectedDevices) { return deviceDiscovery() } 161 | 162 | String subject = selectedDevices.size == 1 ? 'device' : 'devices' 163 | String title = '' 164 | String sectionText = '' 165 | 166 | def devices = selectedDevices.collect { it } // clone the list, so as to not accidentally modify it 167 | Integer deviceCount = devices.size() 168 | 169 | selectedDevices.each { rokuId -> 170 | String name = state.discovered[rokuId].name 171 | String dni = state.discovered[rokuId].mac 172 | try { 173 | 174 | def child = addChildDevice('apwelsh', 'Roku TV', dni, [name: name, label: name]) 175 | child.updateSetting('deviceIp', state.discovered[rokuId].networkAddress) 176 | child.updated() 177 | devices.remove(rokuId) 178 | 179 | } catch (ex) { 180 | if (ex.message =~ 'A device with the same device network ID exists.*') { 181 | sectionText = "\nA device with the same device network ID (${dni}) already exists; cannot add Group [${name}]" 182 | } else { 183 | sectionText += "\nFailed to add group [${name}]; see logs for details" 184 | if (logEnable) { log.error "${ex}" } 185 | } 186 | } 187 | } 188 | 189 | if (!devices) { app.removeSetting('selectedDevices') } 190 | 191 | if (!sectionText) { 192 | title = "Adding ${deviceCount} Roku ${subject} to Hubitat" 193 | sectionText = "Added Roku ${subject}" 194 | } else { 195 | title = "Failed to add Roku ${subject}" 196 | } 197 | 198 | return dynamicPage(name:'addSelectedDevices', title:title, nextPage:null) { 199 | section() { 200 | paragraph sectionText 201 | } 202 | } 203 | 204 | } 205 | 206 | def configureDevice(params) { 207 | app.removeSetting('applicationNetworkId') 208 | 209 | def networkId = params?.netId ?: settings['deviceNetworkId'] 210 | if (!networkId) { return mainPage() } 211 | 212 | def child = getChildDevice(networkId) 213 | if (!child) 214 | return mainPage() 215 | 216 | app.updateSetting('deviceNetworkId', networkId) 217 | 218 | Map rokuApps = child.getInstalledApps() 219 | List installedApps = child.getChildDevices().collect { it.deviceNetworkId}.findAll { it =~ /^.*\-\d+$/ } 220 | List selectedApps = settings["${networkId}_selectedApps"] ?: [] 221 | 222 | // Remove unselected apps as children 223 | installedApps?.findAll { !selectedApps.contains(it) }.each { appId -> 224 | if (logEnable) { log.info "Removing child application device ${appId} (${rokuApps[appId]})" } 225 | child.deleteChildAppDevice(appId) 226 | } 227 | 228 | // Add selected apps as children 229 | selectedApps.findAll { !installedApps.contains(it) }.each { appId -> 230 | def appName = rokuApps[appId] 231 | if (logEnable) { log.info "Installing child application device ${appId} (${appName})" } 232 | child.createChildAppDevice(appId, appName) 233 | } 234 | 235 | app.updateSetting("${networkId}_selectedApps", selectedApps) 236 | 237 | 238 | Map rokuInputs = [:] 239 | List installedInputs = [] 240 | List selectedInputs = [] 241 | if (child.getState().isTV) { 242 | rokuInputs = child.getRokuInputs() 243 | installedInputs = child.getChildDevices().collect { it.deviceNetworkId }.findAll { it =~ /^.*\-(AV1|Tuner|hdmi\d)$/ } 244 | selectedInputs = settings["${networkId}_selectedInputs"] ?: [] 245 | 246 | // Remove unselected apps as children 247 | installedInputs?.findAll { !selectedInputs.contains(it) }.each { appId -> 248 | if (logEnable) { log.info "Removing child input device ${appId} (${rokuInputs[appId]})" } 249 | child.deleteChildAppDevice(appId) 250 | } 251 | 252 | // Add selected apps as children 253 | selectedInputs.findAll { !installedInputs.contains(it) }.each { appId -> 254 | def appName = rokuInputs[appId] 255 | if (logEnable) { log.info "Installing child input device ${appId} (${appName})" } 256 | child.createChildAppDevice(appId, appName) 257 | } 258 | 259 | app.updateSetting("${networkId}_selectedInputs", selectedInputs) 260 | 261 | } 262 | 263 | String label = (deviceLabel(child)?:'').trim() 264 | String newLabel = (settings["${networkId}_label"] ?: '').trim() 265 | if (newLabel != '' && label != newLabel) { 266 | renameChildDevice(this, networkId, newLabel) 267 | child.getChildDevices().findAll { deviceLabel(it)?.startsWith(label) }.each { 268 | renameChildDevice(child, it.deviceNetworkId, deviceLabel(it).replace(label, newLabel)) 269 | } 270 | 271 | label = newLabel 272 | } 273 | 274 | app.removeSetting("${networkId}_label") 275 | 276 | 277 | return dynamicPage(name:'configureDevice', title:'Configure device', nextPage:null) { 278 | section() { 279 | paragraph 'Use this section to configure your Roku device settings' 280 | input "${networkId}_label", 'text', title: 'Device name', defaultValue:label, submitOnChange: true 281 | } 282 | 283 | section('Add / Remove Child Devices') { 284 | input "${networkId}_selectedApps", 'enum', title: 'Select Apps to use as switch devices, unlselect Apps to remove the switch device', required: false, multiple: true, options: rokuApps, submitOnChange: true 285 | 286 | if (child.getState().isTV) { 287 | input "${networkId}_selectedInputs", 'enum', title: 'Select Inputs to use as switch devices, unlselect Inputs to remove the switch device', required: false, multiple: true, options: rokuInputs, submitOnChange: true 288 | } 289 | } 290 | 291 | section() { 292 | paragraph 'Manage Installed Apps' 293 | child.getChildDevices()?.sort({ a, b -> a['name'] <=> b['name'] }).findAll { it.deviceNetworkId =~ /^.*\-\d+$/ }.each { 294 | def desc = it.label != it.name ? it.name : '' 295 | href 'manageApp', title:" ${desc}", description:'', params: [netId: networkId, appId: it.deviceNetworkId] 296 | } 297 | if (child.getState().isTV) { 298 | paragraph 'Manage TV Inputs' 299 | child.getChildDevices()?.sort({ a, b -> a['name'] <=> b['name'] }).findAll { it.deviceNetworkId =~ /^.*\-(AV1|hdmi\d|Tuner)$/ }.each { 300 | def desc = it.label != it.name ? it.name : '' 301 | href 'manageApp', title:" ${desc}", description:'', params: [netId: networkId, appId: it.deviceNetworkId] 302 | } 303 | } 304 | } 305 | 306 | } 307 | } 308 | 309 | def manageApp(params) { 310 | def networkId = params?.netId ?: settings['deviceNetworkId'] 311 | def appId = params?.appId ?: settings['applicationNetworkId'] 312 | if (!networkId || !appId) { 313 | app.removeSetting('applicationNetworkId') 314 | return configureDevice() 315 | } 316 | 317 | def child = getChildDevice(networkId) 318 | 319 | if (!child) 320 | return mainPage() 321 | 322 | // track state to backup. 323 | app.updateSetting('deviceNetworkId', networkId) 324 | 325 | def device = child.getChildDevice(appId) 326 | String label = (deviceLabel(device) ?: '').trim() 327 | String newLabel = (settings["${appId}_label"] ?: '').trim() 328 | 329 | if (newLabel != '' && label != newLabel) { 330 | renameChildDevice(child, appId, newLabel) 331 | label = newLabel 332 | } 333 | app.removeSetting("${appId}_label") 334 | 335 | String filter = (appId =~ /^.*\-(AV1|hdmi\d|Tuner)$/) ? "filter: invert(100%)" : "" 336 | 337 | return dynamicPage(name: 'manageApp', title:"Manage Installed Apps for ${deviceLabel(child)}", nextPage:null) { 338 | section() { 339 | paragraph "Use this section to set the ${device.name} application name for ${deviceLabel(child)}" 340 | input "${appId}_label", 'text', title: 'Application name', defaultValue:label, submitOnChange: true 341 | paragraph "" 342 | 343 | } 344 | } 345 | 346 | } 347 | 348 | /* 349 | * SSDP Device Discover 350 | */ 351 | 352 | void ssdpSubscribe() { 353 | subscribe(location, 'ssdpTerm.roku:ecp', ssdpHandler) 354 | } 355 | 356 | void ssdpUnsubscribe() { 357 | unsubscribe(ssdpHandler) 358 | } 359 | 360 | void ssdpDiscover() { 361 | sendHubCommand(new hubitat.device.HubAction('lan discovery roku:ecp', hubitat.device.Protocol.LAN)) 362 | } 363 | 364 | def ssdpHandler(event) { 365 | 366 | def parsedEvent = parseLanMessage(event.description) 367 | 368 | def roku = parsedEvent?.ssdpUSN.replaceAll(~/.*\:/,'') 369 | if (parsedEvent.networkAddress) { 370 | parsedEvent << ['roku':roku, 371 | 'networkAddress': convertHexToIP(parsedEvent.networkAddress), 372 | 'deviceAddress': convertHexToInt(parsedEvent.deviceAddress)] 373 | 374 | def ssdpUSN = parsedEvent.ssdpUSN.toString() 375 | 376 | def discovered = getDiscovered() 377 | if (!discovered."${ssdpUSN}") { 378 | verifyDevice(parsedEvent) 379 | } else { 380 | updateDevice(parsedEvent) 381 | } 382 | } 383 | } 384 | 385 | private verifyDevice(event) { 386 | def ssdpPath = event.ssdpPath 387 | 388 | if (logEnabe) { log.info "Verifying ${event.networkAddress}" } 389 | 390 | // Using the httpGet method, and arrow function, perform the validation check w/o the need for a callback function. 391 | httpGet([uri:"http://${event.networkAddress}:${event.deviceAddress}${ssdpPath}",timeout:5]) { response -> 392 | 393 | if (!response.isSuccess()) { return } 394 | 395 | def data = response.data 396 | if (data) { 397 | def device = data.device 398 | String model = device.modelName 399 | String ssdpUSN = "${event.ssdpUSN.toString()}" 400 | 401 | if (logEnable) { log.debug "Identified model: ${model}" } 402 | def hubId = "${event.mac}"[-6..-1] 403 | String name = "${device.friendlyName} (${hubId})" 404 | 405 | event << [url: "${data.URLBase}", 406 | name: name, 407 | serialNumber: "${device.serialNumber}"] 408 | 409 | def discovered = getDiscovered() 410 | discovered << ["${ssdpUSN}": event] 411 | if (logEnable) { log.debug "Discovered new Roku: ${name}" } 412 | cleanupOrphans(hubId) 413 | } 414 | 415 | }} 416 | 417 | private updateDevice(event) { 418 | def ssdpUSN = event.ssdpUSN.toString() 419 | def discovered = getDiscovered() 420 | def roku = discovered["${ssdpUSN}"] 421 | 422 | if (roku.networkAddress != event.networkAddress || roku.deviceAddress != event.deviceAddress) { 423 | def oldPort = roku.deviceAddress 424 | def oldAddress = roku.networkAddress 425 | roku << ['networkAddress': event.networkAddress, 426 | 'deviceAddress': event.deviceAddress] 427 | 428 | if (logEnable) { log.debug "Detected roku address update: ${roku.name} from ${oldAddress}:${oldPort} to ${event.networkAddress}:${event.deviceAddress}" } 429 | } 430 | 431 | def child = getChildDevice(roku.mac) 432 | if (child) { 433 | child.updateIpAddress(roku.networkAddress) 434 | } else { 435 | cleanupOrphans(roku.mac) 436 | } 437 | } 438 | 439 | private cleanupOrphans(hubId) { 440 | if (getChildDevice(hubId)) { return } 441 | 442 | def orphans = settings.collect { key, value -> key }.findAll { it =~ /^${hubId}((\-\w+)?_\w+)?$/ } 443 | orphans.each { key -> 444 | app.removeSetting("${key}") 445 | } 446 | } 447 | 448 | private getDiscovered() { 449 | state.discovered = state.discovered ?: [:] 450 | } 451 | 452 | def getRokuForMac(mac) { 453 | getDiscovered().find{ key, value -> value.mac == mac}?.value 454 | } 455 | 456 | def renameChildDevice(parent, networkId, name) { 457 | if (networkId) { 458 | def child = parent.getChildDevice(networkId) 459 | if (logEnable) { log.info "Renaming ${child.label} to ${name}" } 460 | child.label = name 461 | } 462 | } 463 | 464 | /* 465 | * Device Helpers 466 | */ 467 | 468 | private String convertHexToIP(hex) { 469 | [hubitat.helper.HexUtils.hexStringToInt(hex[0..1]), 470 | hubitat.helper.HexUtils.hexStringToInt(hex[2..3]), 471 | hubitat.helper.HexUtils.hexStringToInt(hex[4..5]), 472 | hubitat.helper.HexUtils.hexStringToInt(hex[6..7])].join('.') 473 | } 474 | 475 | private Integer convertHexToInt(hex) { 476 | hubitat.helper.HexUtils.hexStringToInt(hex) 477 | } 478 | 479 | private String deviceLabel(device) { 480 | device?.label ?: device?.name 481 | } 482 | -------------------------------------------------------------------------------- /hue/libs/HueFunctions.groovy: -------------------------------------------------------------------------------- 1 | library ( 2 | author: "Armand Welsh", 3 | category: "utilities", 4 | description: "Library of functions for interfacing with Hue", 5 | name: "HueFunctions", 6 | namespace: "apwelsh", 7 | documentationLink: "" 8 | ) 9 | 10 | import hubitat.helper.ColorUtils 11 | import java.math.RoundingMode 12 | 13 | import java.math.RoundingMode 14 | 15 | // ------------------------- 16 | // Helper Functions 17 | // ------------------------- 18 | 19 | /** 20 | * Clamp a value between min and max. 21 | */ 22 | BigDecimal clamp(BigDecimal value, BigDecimal min, BigDecimal max) { 23 | return value.max(min).min(max) 24 | } 25 | 26 | /** 27 | * Convert gamut map to a list of BigDecimal points. 28 | */ 29 | List> parseGamut(Map> gamut) { 30 | return gamut.collect { k, v -> [new BigDecimal(v.x.toString()), new BigDecimal(v.y.toString())] } 31 | } 32 | 33 | /** 34 | * Check if a given (x, y) point is inside the gamut triangle. 35 | */ 36 | boolean isInsideGamut(BigDecimal x, BigDecimal y, List> gamut) { 37 | BigDecimal Xr = gamut[0][0], Yr = gamut[0][1] 38 | BigDecimal Xg = gamut[1][0], Yg = gamut[1][1] 39 | BigDecimal Xb = gamut[2][0], Yb = gamut[2][1] 40 | BigDecimal detT = (Xg - Xr) * (Yb - Yr) - (Xb - Xr) * (Yg - Yr) 41 | BigDecimal alpha = ((x - Xr) * (Yb - Yr) - (y - Yr) * (Xb - Xr)) / detT 42 | BigDecimal beta = ((Xg - Xr) * (y - Yr) - (Yg - Yr) * (x - Xr)) / detT 43 | BigDecimal gamma = BigDecimal.ONE - alpha - beta 44 | return (alpha >= BigDecimal.ZERO && beta >= BigDecimal.ZERO && gamma >= BigDecimal.ZERO) 45 | } 46 | 47 | /** 48 | * Clamp an (x, y) point to the closest point inside the light's gamut triangle. 49 | */ 50 | List clampXYtoGamut(BigDecimal x, BigDecimal y, List> gamut) { 51 | if (isInsideGamut(x, y, gamut)) { 52 | return [x, y] 53 | } 54 | List> edges = [[gamut[0], gamut[1]], [gamut[1], gamut[2]], [gamut[2], gamut[0]]] 55 | List closestPoint = gamut[0] 56 | BigDecimal minDist = BigDecimal.valueOf(Double.MAX_VALUE) 57 | for (List edge : edges) { 58 | List A = edge[0], B = edge[1] 59 | BigDecimal Ax = A[0], Ay = A[1], Bx = B[0], By = B[1] 60 | BigDecimal t = ((x - Ax) * (Bx - Ax) + (y - Ay) * (By - Ay)) / 61 | ((Bx - Ax).pow(2) + (By - Ay).pow(2)) 62 | t = clamp(t, BigDecimal.ZERO, BigDecimal.ONE) 63 | BigDecimal Px = Ax + t * (Bx - Ax) 64 | BigDecimal Py = Ay + t * (By - Ay) 65 | BigDecimal dist = (x - Px).pow(2) + (y - Py).pow(2) 66 | if (dist < minDist) { 67 | minDist = dist 68 | closestPoint = [Px, Py] 69 | } 70 | } 71 | return closestPoint 72 | } 73 | 74 | /** 75 | * Convert RGB to Hue & Saturation. 76 | * Returns [hue (0–100), saturation (0–100)]. 77 | */ 78 | List rgbToHS(BigDecimal r, BigDecimal g, BigDecimal b) { 79 | BigDecimal max = [r, g, b].max() 80 | BigDecimal min = [r, g, b].min() 81 | BigDecimal delta = max - min 82 | BigDecimal hue = BigDecimal.ZERO 83 | if (delta.compareTo(BigDecimal.ZERO) != 0) { 84 | if (max == r) { 85 | hue = ((g - b) / delta).remainder(BigDecimal.valueOf(6)) 86 | } else if (max == g) { 87 | hue = ((b - r) / delta).add(BigDecimal.valueOf(2)) 88 | } else { 89 | hue = ((r - g) / delta).add(BigDecimal.valueOf(4)) 90 | } 91 | hue = hue.multiply(BigDecimal.valueOf(60)) 92 | if (hue.compareTo(BigDecimal.ZERO) < 0) { 93 | hue = hue.add(BigDecimal.valueOf(360)) 94 | } 95 | } 96 | // Scale hue from degrees (0–360) to 0–100. 97 | hue = hue.divide(BigDecimal.valueOf(360), 10, RoundingMode.HALF_UP) 98 | .multiply(BigDecimal.valueOf(100)) 99 | BigDecimal saturation = (max.compareTo(BigDecimal.ZERO) == 0) ? BigDecimal.ZERO : 100 | delta.divide(max, 10, RoundingMode.HALF_UP).multiply(BigDecimal.valueOf(100)) 101 | return [hue, saturation] 102 | } 103 | 104 | /** 105 | * Convert HSV (hue, saturation, brightness on a 0–100 scale) to RGB (0–255 scale). 106 | * Hue is first scaled from 0–100 to degrees. 107 | */ 108 | List hsvToRGB(double hue, double sat, double bri) { 109 | double H = hue * 3.6 // Convert 0–100 to 0–360. 110 | double S = sat / 100.0 111 | double V = bri / 100.0 112 | double C = V * S 113 | double X = C * (1 - Math.abs(((H / 60.0) % 2) - 1)) 114 | double m = V - C 115 | double r = 0, g = 0, b = 0 116 | if (H < 60) { 117 | r = C; g = X; b = 0 118 | } else if (H < 120) { 119 | r = X; g = C; b = 0 120 | } else if (H < 180) { 121 | r = 0; g = C; b = X 122 | } else if (H < 240) { 123 | r = 0; g = X; b = C 124 | } else if (H < 300) { 125 | r = X; g = 0; b = C 126 | } else { 127 | r = C; g = 0; b = X 128 | } 129 | int R = (int)Math.round((r + m) * 255) 130 | int G = (int)Math.round((g + m) * 255) 131 | int B = (int)Math.round((b + m) * 255) 132 | return [R, G, B] 133 | } 134 | 135 | /** 136 | * Inverse sRGB gamma correction. 137 | * Converts a gamma‐corrected channel (in [0,1]) back to linear light. 138 | */ 139 | double inverseGamma(double channel) { 140 | if (channel <= 0.04045) { 141 | return channel / 12.92 142 | } else { 143 | return Math.pow((channel + 0.055) / 1.055, 2.4) 144 | } 145 | } 146 | 147 | // ------------------------- 148 | // Hue Correction via Lagrange Interpolation 149 | // ------------------------- 150 | 151 | /** 152 | * Applies Lagrange interpolation to compute the corrected hue. 153 | * Uses static intended calibration values: 154 | * Red: 0, Yellow: 17, Green: 33.3, Cyan: 50, Blue: 66.6, Magenta: 84. 155 | * 156 | * @param rawHue The raw hue value (0–100). 157 | * @param rawCalib List of raw hue calibration values (0–100) for the six points. 158 | * @return Corrected hue. 159 | */ 160 | double applyHueCorrectionLagrange(double rawHue, List rawCalib) { 161 | List intendedCalib = [0.0, 17.0, 33.3, 50.0, 66.6, 84.0] 162 | int n = rawCalib.size() 163 | double result = 0.0 164 | for (int i = 0; i < n; i++) { 165 | double term = intendedCalib[i] 166 | for (int j = 0; j < n; j++) { 167 | if (i != j) { 168 | term *= (rawHue - rawCalib[j]) / (rawCalib[i] - rawCalib[j]) 169 | } 170 | } 171 | result += term 172 | } 173 | return result 174 | } 175 | 176 | /** 177 | * Inverts the hue correction function. 178 | * Given a corrected hue (0–100), finds the raw hue (0–100) such that 179 | * applyHueCorrectionLagrange(rawHue, rawCalib) approximates the corrected hue. 180 | * Assumes the mapping is monotonic. 181 | */ 182 | double invertHueCorrection(double correctedHue, List rawCalib) { 183 | double low = 0.0, high = 100.0, mid = 0.0 184 | double tol = 0.01 185 | int iterations = 0, maxIter = 100 186 | while ((high - low) > tol && iterations < maxIter) { 187 | mid = (low + high) / 2.0 188 | double test = applyHueCorrectionLagrange(mid, rawCalib) 189 | if (test < correctedHue) { 190 | low = mid 191 | } else { 192 | high = mid 193 | } 194 | iterations++ 195 | } 196 | return mid 197 | } 198 | 199 | // ------------------------- 200 | // Inverse Philips Conversion: Linear RGB -> XYZ 201 | // ------------------------- 202 | /** 203 | * Converts linear RGB values (each in [0,1]) to XYZ using the inverse Philips matrix. 204 | * The inverse matrix here is an approximation. 205 | */ 206 | List linearRGBtoXYZ(double r_lin, double g_lin, double b_lin) { 207 | // Forward Philips matrix: 208 | // r = 1.612*X - 0.203*Y - 0.302*Z 209 | // g = -0.509*X + 1.412*Y + 0.066*Z 210 | // b = 0.026*X - 0.072*Y + 0.962*Z 211 | // An approximate inverse is: 212 | double m00 = 0.6496, m01 = 0.1034, m02 = 0.1970 213 | double m10 = 0.2340, m11 = 0.7430, m12 = 0.0226 214 | double m20 = -0.00003, m21 = 0.0529, m22 = 1.0363 215 | double X = m00 * r_lin + m01 * g_lin + m02 * b_lin 216 | double Y = m10 * r_lin + m11 * g_lin + m12 * b_lin 217 | double Z = m20 * r_lin + m21 * g_lin + m22 * b_lin 218 | return [X, Y, Z] 219 | } 220 | 221 | // ------------------------- 222 | // Forward Conversion: XY+Bri -> Corrected HSV 223 | // ------------------------- 224 | /** 225 | * Convert Philips Hue XY and brightness values to corrected HSV (0–100 scale). 226 | * Returns a list: [corrected hue, saturation, brightness]. 227 | * Brightness is passed through. 228 | * 229 | * This function performs gamut clamping, Philips conversion (XYZ→RGB + gamma correction), 230 | * then computes raw HSV and applies hue correction via Lagrange interpolation. 231 | * 232 | * @param x The x chromaticity coordinate. 233 | * @param y The y chromaticity coordinate. 234 | * @param bri The brightness value (0–100). 235 | * @param gamut A map defining the light's gamut with keys "red", "green", and "blue". 236 | * @param rawCalib List of raw hue calibration values (0–100 scale) for six points. 237 | * @return A list [corrected hue, saturation, brightness] (all on 0–100 scale). 238 | */ 239 | List xyToHSV(Double x, Double y, Double bri, Map> gamut, List rawCalib) { 240 | // Convert inputs to BigDecimal. 241 | BigDecimal X = new BigDecimal(x.toString()) 242 | BigDecimal Y = new BigDecimal(y.toString()) 243 | BigDecimal brightnessPct = new BigDecimal(bri.toString()) 244 | 245 | // Clamp XY to the gamut. 246 | List> gamutBD = parseGamut(gamut) 247 | List clampedXY = clampXYtoGamut(X, Y, gamutBD) 248 | BigDecimal clampedX = clampedXY[0] 249 | BigDecimal clampedY = clampedXY[1] 250 | 251 | // Normalize brightness from 0–100 to 0–1. 252 | BigDecimal briNorm = brightnessPct.divide(BigDecimal.valueOf(100), 10, RoundingMode.HALF_UP) 253 | 254 | // Compute XYZ (Y set by brightness). 255 | BigDecimal Yval = briNorm 256 | BigDecimal Xval = (Yval.divide(clampedY, 10, RoundingMode.HALF_UP)).multiply(clampedX) 257 | BigDecimal Zval = (Yval.divide(clampedY, 10, RoundingMode.HALF_UP)) 258 | .multiply(BigDecimal.ONE.subtract(clampedX).subtract(clampedY)) 259 | 260 | // Convert XYZ to linear RGB using Philips coefficients. 261 | BigDecimal r = Xval.multiply(BigDecimal.valueOf(1.612)) 262 | .subtract(Yval.multiply(BigDecimal.valueOf(0.203))) 263 | .subtract(Zval.multiply(BigDecimal.valueOf(0.302))) 264 | BigDecimal g = Xval.multiply(BigDecimal.valueOf(-0.509)) 265 | .add(Yval.multiply(BigDecimal.valueOf(1.412))) 266 | .add(Zval.multiply(BigDecimal.valueOf(0.066))) 267 | BigDecimal bVal = Xval.multiply(BigDecimal.valueOf(0.026)) 268 | .subtract(Yval.multiply(BigDecimal.valueOf(0.072))) 269 | .add(Zval.multiply(BigDecimal.valueOf(0.962))) 270 | // Clamp negatives. 271 | r = r.max(BigDecimal.ZERO) 272 | g = g.max(BigDecimal.ZERO) 273 | bVal = bVal.max(BigDecimal.ZERO) 274 | // Normalize if any channel > 1. 275 | BigDecimal maxRGB = [r, g, bVal].max() 276 | if (maxRGB.compareTo(BigDecimal.ONE) > 0) { 277 | r = r.divide(maxRGB, 10, RoundingMode.HALF_UP) 278 | g = g.divide(maxRGB, 10, RoundingMode.HALF_UP) 279 | bVal = bVal.divide(maxRGB, 10, RoundingMode.HALF_UP) 280 | } 281 | // Apply sRGB gamma correction. 282 | def gammaCorrect = { BigDecimal channel -> 283 | if (channel.compareTo(BigDecimal.valueOf(0.0031308)) > 0) { 284 | return BigDecimal.valueOf(1.055 * Math.pow(channel.doubleValue(), 1.0/2.4) - 0.055) 285 | } else { 286 | return channel.multiply(BigDecimal.valueOf(12.92)) 287 | } 288 | } 289 | r = gammaCorrect(r) 290 | g = gammaCorrect(g) 291 | bVal = gammaCorrect(bVal) 292 | r = clamp(r, BigDecimal.ZERO, BigDecimal.ONE) 293 | g = clamp(g, BigDecimal.ZERO, BigDecimal.ONE) 294 | bVal = clamp(bVal, BigDecimal.ZERO, BigDecimal.ONE) 295 | 296 | // Convert RGB to raw hue and saturation. 297 | List hs = rgbToHS(r, g, bVal) 298 | double rawHue = hs[0].doubleValue() // on 0–100 scale. 299 | double saturation = hs[1].doubleValue() 300 | 301 | // Apply hue correction. 302 | double correctedHue = applyHueCorrectionLagrange(rawHue, rawCalib) 303 | correctedHue = Math.max(0, Math.min(100, correctedHue)) 304 | 305 | return [correctedHue, saturation, brightnessPct.doubleValue()] 306 | } 307 | 308 | // ------------------------- 309 | // Reverse Conversion: Corrected HSV -> XY+Bri 310 | // ------------------------- 311 | /** 312 | * Convert corrected HSV (0–100 scale) to XY and brightness. 313 | * Inverts the hue correction via binary search, converts raw HSV to RGB, 314 | * applies inverse gamma correction, inverts Philips conversion, and computes chromaticity. 315 | * 316 | * @param correctedHue Corrected hue (0–100). 317 | * @param sat Saturation (0–100). 318 | * @param bri Brightness (0–100). 319 | * @param rawCalib List of raw hue calibration values (0–100) for six points. 320 | * @return A list [x, y, brightness]. 321 | */ 322 | List hsvToXY(double correctedHue, double sat, double bri, List rawCalib) { 323 | // 1. Invert hue correction to obtain raw hue. 324 | double rawHue = invertHueCorrection(correctedHue, rawCalib) 325 | 326 | // 2. Convert raw HSV to RGB. 327 | List rgb = hsvToRGB(rawHue, sat, bri) // RGB in 0–255. 328 | double R = rgb[0] / 255.0 329 | double G = rgb[1] / 255.0 330 | double B = rgb[2] / 255.0 331 | 332 | // 3. Inverse gamma correction. 333 | double r_lin = inverseGamma(R) 334 | double g_lin = inverseGamma(G) 335 | double b_lin = inverseGamma(B) 336 | 337 | // 4. Convert linear RGB to XYZ using inverse Philips matrix. 338 | List xyz = linearRGBtoXYZ(r_lin, g_lin, b_lin) 339 | double X = xyz[0], Y = xyz[1], Z = xyz[2] 340 | double sum = X + Y + Z 341 | if (sum == 0) sum = 1e-6 342 | double x = X / sum 343 | double y = Y / sum 344 | 345 | return [x, y, bri] 346 | } 347 | 348 | 349 | /** 350 | * Ensures a value is between a minimum and maximum range. 351 | * 352 | * @param value The value to check. 353 | * @param min The minimum allowable value. 354 | * @param max The maximum allowable value. 355 | * @return The value clamped between the min and max. 356 | */ 357 | static Number valueBetween(Number value, Number min, Number max) { 358 | return Math.max(min, Math.min(max, value)) 359 | } 360 | 361 | /** 362 | * Converts a hexadecimal string to an IP address. 363 | * 364 | * @param hex The hexadecimal string to convert. 365 | * @return The IP address as a string. 366 | */ 367 | static String convertHexToIP(String hex) { 368 | if (hex == null || hex.length() != 8) { 369 | throw new IllegalArgumentException("Invalid hex string") 370 | } 371 | return [ 372 | Integer.parseInt(hex.substring(0, 2), 16), 373 | Integer.parseInt(hex.substring(2, 4), 16), 374 | Integer.parseInt(hex.substring(4, 6), 16), 375 | Integer.parseInt(hex.substring(6, 8), 16) 376 | ].join('.') 377 | } 378 | 379 | /** 380 | * Converts a hexadecimal string to an integer. 381 | * 382 | * @param hex The hexadecimal string to convert. 383 | * @return The integer value. 384 | */ 385 | static Integer convertHexToInt(String hex) { 386 | if (hex == null) { 387 | throw new IllegalArgumentException("Invalid hex string") 388 | } 389 | return Integer.parseInt(hex, 16) 390 | } 391 | 392 | /** 393 | * Converts a hue level value to a percentage. 394 | * 395 | * This function takes a hue level value and converts it to a percentage 396 | * by dividing the value by 2.54 and rounding the result. The resulting 397 | * value is then constrained to be between 1 and 100, except when the 398 | * input value is 0, in which case the result is 0. 399 | * 400 | * @param value The hue level value to convert. 401 | * @return The converted percentage value, constrained between 1 and 100, 402 | * or 0 if the input value is 0. 403 | */ 404 | static Integer convertFromHueLevel(Number value) { 405 | valueBetween(Math.round(value / 2.54), (value == 0 ? 0 : 1), 100) 406 | } 407 | 408 | /** 409 | * Converts a given value to a Hue level. 410 | * 411 | * This function takes an integer value, multiplies it by 2.54, rounds the result, 412 | * and ensures it is within the range of 0 to 254. 413 | * 414 | * @param value The integer value to be converted. 415 | * @return The converted Hue level as an integer. 416 | */ 417 | static Integer convertToHueLevel(Integer value) { 418 | valueBetween(Math.round(value * 2.54), 0, 254) 419 | } 420 | 421 | /** 422 | * Converts a hue value from the Hue system to a percentage. 423 | * 424 | * The Hue system uses a range of 0 to 65535 for hue values. This function 425 | * converts that range to a percentage (0 to 100). 426 | * 427 | * @param value The hue value from the Hue system (0 to 65535). 428 | * @return The corresponding percentage value (0 to 100). 429 | */ 430 | static Number convertFromHueHue(Number value) { 431 | Math.round(value / 655.35) 432 | } 433 | 434 | /** 435 | * Converts a given value to a corresponding Hue hue value. 436 | * 437 | * The function performs the following conversions: 438 | * - If the input value is 33, it returns 21845. 439 | * - If the input value is 66, it returns 43690. 440 | * - Otherwise, it scales the input value to a range between 0 and 65535. 441 | * 442 | * @param value The input value to be converted. 443 | * @return The corresponding Hue hue value. 444 | */ 445 | static Number convertToHueHue(Number value) { 446 | value == 33 ? 21845 : value == 66 ? 43690 : valueBetween(Math.round(value * 655.35), 0, 65535) 447 | } 448 | 449 | /** 450 | * Converts a given hue saturation value to a different scale. 451 | * 452 | * This function takes a hue saturation value and converts it by dividing it by 2.54 453 | * and rounding the result to the nearest whole number. 454 | * 455 | * @param value The hue saturation value to be converted. 456 | * @return The converted value as a Number. 457 | */ 458 | static Number convertFromHueSaturation(Number value) { 459 | Math.round(value / 2.54) 460 | } 461 | 462 | /** 463 | * Converts a given value to a Hue-compatible saturation value. 464 | * 465 | * This function takes a numerical value, multiplies it by 2.54, rounds it to the nearest whole number, 466 | * and then ensures the result is within the range of 0 to 254. 467 | * 468 | * @param value The numerical value to be converted. 469 | * @return The converted Hue-compatible saturation value, constrained between 0 and 254. 470 | */ 471 | static Number convertToHueSaturation(Number value) { 472 | valueBetween(Math.round(value * 2.54), 0, 254) 473 | } 474 | 475 | /** 476 | * Converts a Hue color temperature value to a standard color temperature value. 477 | * 478 | * @param value The Hue color temperature value to convert. 479 | * @return The converted color temperature value, constrained between 2000 and 6500. 480 | */ 481 | static Number convertFromHueColorTemp(Number value) { 482 | // 4500 / 347 = 12.968 (but 12.96 scales better) 483 | valueBetween(Math.round(((500 - value) * 12.96) + 2000 ), 2000, 6500) 484 | } 485 | 486 | /** 487 | * Converts a given color temperature value to the corresponding Hue color temperature value. 488 | * 489 | * @param value The color temperature value to convert (in Kelvin). 490 | * @return The converted Hue color temperature value, constrained between 153 and 500. 491 | */ 492 | static Number convertToHueColortemp(Number value) { 493 | valueBetween(Math.round(500 - ((value - 2000) / 12.96)), 153, 500) 494 | } 495 | 496 | /** 497 | * Converts a given light level to a Hue light level. 498 | * 499 | * This function takes a light level value and converts it to a Hue-compatible light level 500 | * using a logarithmic scale. The conversion formula is: 501 | * 502 | * HueLightLevel = 10 ^ ((lightLevel - 1) / 10000.0) 503 | * 504 | * If the input light level is null, it defaults to 1. 505 | * 506 | * @param lightLevel The light level to be converted. If null, defaults to 1. 507 | * @return The converted Hue light level as an integer. 508 | */ 509 | static Number convertToHueLightLevel(Number lightLevel) { 510 | Math.pow(10, (((lightLevel?:1)-1)/10000.0)) as int 511 | } 512 | 513 | /** 514 | * Converts the given temperature to the Hue temperature scale. 515 | * 516 | * @param temperature The temperature value to be converted. 517 | * @return The converted temperature value, scaled to one decimal place. 518 | */ 519 | static Number convertFromHueTemperature(Number temperature) { 520 | return (temperature?:0) / 100 521 | } 522 | 523 | /** 524 | * Converts a Hue color mode value to a corresponding string representation. 525 | * 526 | * @param value The Hue color mode value to convert. Expected values are 'hs', 'ct', or 'xy'. 527 | * @return A string representing the converted color mode. Returns 'RGB' for 'hs' and 'xy', 'CT' for 'ct', 528 | * and an empty string for any other value. 529 | */ 530 | static String convertFromHueColorMode(String value) { 531 | if (value == 'hs') { return 'RGB' } 532 | if (value == 'ct') { return 'CT' } 533 | if (value == 'xy') { return 'RGB' } 534 | return '' 535 | } 536 | 537 | --------------------------------------------------------------------------------