├── .github └── FUNDING.yml ├── .gitignore ├── LICENSE ├── README.md ├── cli ├── deconz.js ├── otau.js └── ui.js ├── config.schema.json ├── deconz.png ├── homebridge-ui ├── public │ ├── homebridge-deconz.png │ ├── index.html │ ├── index.old.html │ └── style.css └── server.js ├── index.js ├── jsdoc.json ├── lib ├── Deconz │ ├── Device.js │ ├── Resource.js │ └── index.js ├── DeconzAccessory │ ├── AirPurifier.js │ ├── Gateway.js │ ├── Light.js │ ├── Sensor.js │ ├── Thermostat.js │ ├── WarningDevice.js │ ├── WindowCovering.js │ └── index.js ├── DeconzPlatform.js └── DeconzService │ ├── AirPressure.js │ ├── AirPurifier.js │ ├── AirQuality.js │ ├── Alarm.js │ ├── AlarmSystem.js │ ├── Battery.js │ ├── Button.js │ ├── CarbonMonoxide.js │ ├── Consumption.js │ ├── Contact.js │ ├── Daylight.js │ ├── Flag.js │ ├── Gateway.js │ ├── Humidity.js │ ├── Label.js │ ├── Leak.js │ ├── Light.js │ ├── LightLevel.js │ ├── LightsResource.js │ ├── Motion.js │ ├── Outlet.js │ ├── Power.js │ ├── Schedule.js │ ├── SensorsResource.js │ ├── Smoke.js │ ├── Status.js │ ├── Switch.js │ ├── Temperature.js │ ├── Thermostat.js │ ├── Valve.js │ ├── WarningDevice.js │ ├── WindowCovering.js │ └── index.js ├── package-lock.json └── package.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [ebaauw] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: ["https://www.paypal.me/ebaauw/EUR"] 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | out 3 | npm-debug.log 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | 6 | # Homebridge deCONZ 7 | [![Downloads](https://img.shields.io/npm/dt/homebridge-deconz)](https://www.npmjs.com/package/homebridge-deconz) 8 | [![Version](https://img.shields.io/npm/v/homebridge-deconz)](https://www.npmjs.com/package/homebridge-deconz) 9 | [![Homebridge Discord](https://img.shields.io/discord/432663330281226270?color=728ED5&logo=discord&label=discord)](https://discord.gg/zUhSZSNb4P) 10 | [![verified-by-homebridge](https://badgen.net/badge/homebridge/verified/purple)](https://github.com/homebridge/homebridge/wiki/Verified-Plugins) 11 | 12 | [![GitHub issues](https://img.shields.io/github/issues/ebaauw/homebridge-deconz)](https://github.com/ebaauw/homebridge-deconz/issues) 13 | [![GitHub pull requests](https://img.shields.io/github/issues-pr/ebaauw/homebridge-deconz)](https://github.com/ebaauw/homebridge-deconz/pulls) 14 | [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen)](https://standardjs.com) 15 | 16 | 17 | 18 | ## Homebridge plugin for deCONZ 19 | Copyright © 2022-2025 Erik Baauw. All rights reserved. 20 | 21 | ### Introduction 22 | This [Homebridge](https://github.com/homebridge/homebridge) plugin exposes to Apple's [HomeKit](http://www.apple.com/ios/home/) ZigBee devices (lights, plugs, sensors, switches, ...) and virtual devices on a deCONZ gateway by dresden elektronik. 23 | Homebridge deCONZ communicates with deCONZ over its [REST API](https://dresden-elektronik.github.io/deconz-rest-doc/), provided by its [REST API plugin](https://github.com/dresden-elektronik/deconz-rest-plugin). 24 | It runs independently from the Phoscon web app, see [deCONZ for Dummies](https://github.com/dresden-elektronik/deconz-rest-plugin/wiki/deCONZ-for-Dummies). 25 | 26 | Homebridge deCONZ is the successor of Homebridge Hue for exposing Zigbee devices connected to a deCONZ gateway. 27 | See [Future Development of Homebridge Hue](https://github.com/ebaauw/homebridge-hue/issues/1070) for more details. 28 | 29 | ### Prerequisites 30 | You need a deCONZ gateway to connect Homebridge deCONZ to your ZigBee devices (lights, plugs, sensors, switches, ...). 31 | For Zigbee communication, the deCONZ gateway requires a [ConBee II](https://phoscon.de/en/conbee2) or [Conbee](https://phoscon.de/en/conbee) USB stick, or a [RaspBee II](https://phoscon.de/en/raspbee2) or [RaspBee](https://phoscon.de/en/raspbee) Raspberry Pi shield. 32 | I recommend to run deCONZ with its GUI enabled, even on a headless system. 33 | When needed, you can access the deCONZ GUI over screen sharing. 34 | 35 | You need a server to run Homebridge. 36 | This can be anything running [Node.js](https://nodejs.org): from a Raspberry Pi, a NAS system, or an always-on PC running Linux, macOS, or Windows. 37 | I strongly recommend to use a standard Homebridge installation, see the [Homebridge Wiki](https://github.com/homebridge/homebridge/wiki) for details. 38 | I recommend to run deCONZ and Homebridge deCONZ on the same server, avoiding any network latency between deCONZ and Homebridge deCONZ, and preventing any potential network issues. 39 | I strongly recommend to run Homebridge deCONZ in a separate [child bridge](https://github.com/homebridge/homebridge/wiki/Child-Bridges). 40 | 41 | To interact with HomeKit, you need an Apple device with Siri or a HomeKit app. 42 | Please note that Siri and Apple's [Home](https://support.apple.com/en-us/HT204893) app only provide limited HomeKit support. 43 | To use the full features of Homebridge deCONZ, you need another HomeKit app, like [Eve](https://www.evehome.com/en/eve-app) (free) or Matthias Hochgatterer's [Home+](https://hochgatterer.me/home/) (paid). 44 | As HomeKit uses mDNS (formally known as Bonjour) to discover Homebridge, the server running Homebridge must be on the same subnet as your Apple devices running HomeKit. 45 | Most cases of _Not Responding_ accessories are due to mDNS issues. 46 | For remote access and for HomeKit automations (incl. support for wireless switches), you need to setup an Apple TV (4th generation or later), HomePod, or iPad as [home hub](https://support.apple.com/en-us/HT207057). 47 | I recommend to use the latest released non-beta version of the Apple device OS: iOS, iPadOS, macOS, ... 48 | HomeKit doesn't seem to like using different Apple device OS versions. 49 | 50 | ### Configuration 51 | Most settings for Homebridge deCONZ, can be changed at run-time, including which devices to expose, how to expose these, and the level of logging. 52 | This keeps `config.json` extremely simple. 53 | Typically, you only need to specify the hostname and port of the deCONZ gateway(s) in `config.json`. 54 | See [`Configuration`](https://github.com/ebaauw/homebridge-deconz/wiki/Configuration) in the Wiki for details. 55 | I strongly recommended to run Homebridge deCONZ in a separate [child bridge](https://github.com/homebridge/homebridge/wiki/Child-Bridges). 56 | 57 | Homebridge deCONZ provides a Configuration API to change the run-time settings. 58 | These changes take effect immediately, and are persisted across Homebridge restarts. 59 | See [`Dynamic Configuration`](https://github.com/ebaauw/homebridge-deconz/wiki/Dynamic-Configuration) in the Wiki for details. 60 | For now, these dynamic settings are managed through the `ui` command-line tool. 61 | Eventually, Homebridge deCONZ might provide a configuration user interface to the Homebridge UI, using this configuration API. 62 | 63 | When it connects to a deCONZ gateway for the first time, Homebridge deCONZ will try to obtain an API key for two minutes, before exposing the gateway accessory. 64 | Unless Homebridge deCONZ runs on the same server as the deCONZ gateway, you need to unlock the gateway to allow Homebridge deCONZ to obtain an API key. 65 | If you don't, Homebridge deCONZ will give up, after two minutes. 66 | In this case, you need to set `expose` on the gateway dynamic settings, to retry obtaining an API key. 67 | Homebridge deCONZ will **not** retry to obtain an API key on Homebridge restart. 68 | 69 | Once it has obtained an API key, Homebridge deCONZ will expose all Zigbee devices connected to the gateway, by default. 70 | Use the dynamic settings to exclude devices from being exposed, to change how devices are exposed, and to expose virtual devices like groups or CLIP sensors. 71 | Homebridge deCONZ exposes a [gateway accessory](https://github.com/ebaauw/homebridge-deconz/wiki/Gateway-Accessory) for each deCONZ gateway. 72 | In Apple's Home app, this accessory looks like a wireless switch; you'll need another HomeKit app to use the other features of this accessory. 73 | 74 | Note that HomeKit doesn't like configuration changes. 75 | After adding or removing accessories, allow ample time for HomeKit to sync the changed configuration to all Apple devices. 76 | 77 | ### Command-Line Utilities 78 | Homebridge deCONZ includes the following command-line utilities: 79 | - `deconz`, to discover, monitor, and interact with deCONZ gateways. 80 | See the [`deconz` Command-Line Utility](https://github.com/ebaauw/homebridge-deconz/wiki/deconz-Command%E2%80%90Line-Utility) in the Wiki for more info. 81 | - `otau`, to download and analyse over-the-air-update firmware files for Zigbee devices. 82 | - `ui` to configure a running instance of Homebridge deCONZ. 83 | See [`Dynamic Configuration`](https://github.com/ebaauw/homebridge-deconz/wiki/Dynamic-Configuration) in the Wiki for more info. 84 | 85 | Each command-line tool takes a `-h` or `--help` argument to provide a brief overview of its functionality and command-line arguments. 86 | 87 | ### Troubleshooting 88 | Please check the [FAQ](https://github.com/ebaauw/homebridge-hue/wiki/FAQ) (for now still on Homebridge Hue Wiki). 89 | 90 | #### Check Dependencies 91 | If you run into Homebridge startup issues, please double-check what versions of Node.js and of Homebridge have been installed. 92 | Homebridge deCONZ has been developed and tested using the [latest LTS](https://nodejs.org/en/about/releases/) version of Node.js and the [latest](https://www.npmjs.com/package/homebridge) version of Homebridge. 93 | Other versions might or might not work - I simply don't have the bandwidth to test these. 94 | 95 | #### Run Homebridge deCONZ Solo 96 | If you run into Homebridge startup issues, please run Homebridge deCONZ in a separate [child bridge](https://github.com/homebridge/homebridge/wiki/Child-Bridges). 97 | 98 | #### Debug Log File 99 | Homebridge deCONZ outputs an info message to the Homebridge log, for each HomeKit characteristic value it sets and for each HomeKit characteristic value change notification it receives. Make sure that `logLevel` of the corresponding accessory is at least 1, to see these info messages. 100 | 101 | Homebridge deCONZ outpits a debug message to the Homebridge log, for each interaction with a deCONZ gateway. 102 | Make sure to run Homebridge in DEBUG mode, and that `logLevel` of the corresponding accessory is at least 2, to see these debug messages. Set `logLevel` to 3 to log the payload of the interaction with deCONZ as well. 103 | 104 | #### Debug Dump File 105 | To aid troubleshooting, on startup, Homebridge deCONZ dumps its environment, including its `config.json` settings, dynamic settings, and the full state of all gateways into a compresed json file, `homebridge-deconz.json.gz`. 106 | This file is located in the Homebridge user directory. 107 | 108 | #### Getting help 109 | If you have a question about Homebridge deCONZ, please post a message to the **#deconz** channel of the Homebridge community on [Discord](https://discord.gg/zUhSZSNb4P). 110 | 111 | If you encounter a problem with Homebridge deCONZ, please open an issue on [GitHub](https://github.com/ebaauw/homebridge-deconz/issues). 112 | Please attach a copy of `homebridge-deconz.json.gz` to the issue, see [**Debug Dump File**](#debug-dump-file). 113 | Please attach a copy of the (compressed) Homebridge log file to the issue, see [**Debug Log File**](#debug-log-file). 114 | Please do **not** copy/paste large amounts of log output. 115 | 116 | ### Contributing 117 | Sometimes I get the question how people can support my work on Homebridge deCONZ. 118 | I created Homebridge deCONZ as a hobby project, for my own use. 119 | I share it on GitHub so others might benefit, and to give back to the open source community, without whom Homebridge Hue wouldn't have been possible. 120 | 121 | Having said that, adding support for new devices, in Homebridge deCONZ, and in the deCONZ REST API plugin, is very hard without having physical access to the device. 122 | Since this is a hobby project, I cannot afford to spend money on devices I won't be using myself, just to integrate them for the benefit of others. 123 | I am happy to receive small donations in the form of new devices to integrate, or the money to buy these devices myself. 124 | I am also happy to return the devices afterwards, if you provide the shipping costs. 125 | Please contact me by email or on Discord for shipping details. 126 | 127 | ### Caveats 128 | Homebridge deCONZ is a hobby project of mine, provided as-is, with no warranty whatsoever. I've been running it successfully at my home since May 2023, replacing Homebridge Hue, but your mileage might vary. 129 | -------------------------------------------------------------------------------- /cli/deconz.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // deconz.js 4 | // Copyright © 2018-2025 Erik Baauw. All rights reserved. 5 | // 6 | // Command line interface to deCONZ gateway. 7 | 8 | import { createRequire } from 'node:module' 9 | 10 | import { DeconzTool } from 'hb-deconz-tools/DeconzTool' 11 | 12 | const require = createRequire(import.meta.url) 13 | const packageJson = require('../package.json') 14 | 15 | new DeconzTool(packageJson).main() 16 | -------------------------------------------------------------------------------- /cli/otau.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // otau.js 4 | // Copyright © 2023-2025 Erik Baauw. All rights reserved. 5 | // 6 | // Command line interface to deCONZ gateway. 7 | 8 | import { createRequire } from 'node:module' 9 | 10 | import { OtauTool } from 'hb-deconz-tools/OtauTool' 11 | 12 | const require = createRequire(import.meta.url) 13 | const packageJson = require('../package.json') 14 | 15 | new OtauTool(packageJson).main() 16 | -------------------------------------------------------------------------------- /config.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "pluginAlias": "deCONZ", 3 | "pluginType": "platform", 4 | "singular": true, 5 | "customUi": false, 6 | "headerDisplay": "Homebridge plugin for deCONZ", 7 | "footerDisplay": "For a detailed description, see the [wiki](https://github.com/ebaauw/homebridge-deconz/wiki/Configuration).", 8 | "schema": { 9 | "type": "object", 10 | "properties": { 11 | "name": { 12 | "description": "Plugin name as displayed in the Homebridge log.", 13 | "type": "string", 14 | "required": true, 15 | "default": "deCONZ" 16 | }, 17 | "forceHttp": { 18 | "description": "Use plain http instead of https.", 19 | "type": "boolean" 20 | }, 21 | "hosts": { 22 | "title": "Gateways", 23 | "type": "array", 24 | "items": { 25 | "type": "string" 26 | } 27 | }, 28 | "noResponse": { 29 | "description": "Report unreachable lights as No Response in HomeKit.", 30 | "type": "boolean" 31 | }, 32 | "parallelRequests": { 33 | "description": "The number of ansynchronous requests Homebridge deCONZ sends in parallel to a deCONZ gateway. Default: 10.", 34 | "type": "integer", 35 | "minimum": 1, 36 | "maximum": 30 37 | }, 38 | "stealth": { 39 | "description": "Stealth mode: don't make any calls to the Internet. Default: false.", 40 | "type": "boolean" 41 | }, 42 | "timeout": { 43 | "description": "The timeout in seconds to wait for a response from a deCONZ gateway. Default: 5.", 44 | "type": "integer", 45 | "minimum": 1, 46 | "maximum": 30 47 | }, 48 | "waitTimePut": { 49 | "description": "The time, in milliseconds, to wait after sending a PUT request, before sending the next PUT request. Default: 50.", 50 | "type": "integer", 51 | "minimum": 0, 52 | "maximum": 50 53 | }, 54 | "waitTimePutGroup": { 55 | "description": "The time, in milliseconds, to wait after sending a PUT request to a group, before sending the next PUT request. Default: 1000.", 56 | "type": "integer", 57 | "minimum": 0, 58 | "maximum": 1000 59 | }, 60 | "waitTimeResend": { 61 | "description": "The time, in milliseconds, to wait before resending a request after an ECONNRESET or http status 503 error. Default: 300.", 62 | "type": "integer", 63 | "minimum": 100, 64 | "maximum": 1000 65 | }, 66 | "waitTimeReset": { 67 | "description": "The timeout in milliseconds, to wait before resetting a characteristic value. Default: 500.", 68 | "type": "integer", 69 | "minimum": 10, 70 | "maximum": 2000 71 | }, 72 | "waitTimeUpdate": { 73 | "description": "The time, in milliseconds, to wait for a change from HomeKit to another characteristic for the same light or group, before updating the deCONZ gateway. Default: 100.", 74 | "type": "integer", 75 | "minimum": 0, 76 | "maximum": 500 77 | } 78 | } 79 | }, 80 | "layout": [ 81 | "name", 82 | { 83 | "key": "hosts", 84 | "type": "array", 85 | "items": { 86 | "title": "Gateway", 87 | "description": "Hostname and port of the deCONZ gateway. Leave empty to discover gateways.", 88 | "type": "string" 89 | } 90 | }, 91 | { 92 | "type": "fieldset", 93 | "expandable": true, 94 | "title": "Advanced Settings", 95 | "description": "Don't change these, unless you understand what you're doing.", 96 | "items": [ 97 | "forceHttp", 98 | "noResponse", 99 | "parallelRequests", 100 | "stealth", 101 | "timeout", 102 | "waitTimePut", 103 | "waitTimePutGroup", 104 | "waitTimeResend", 105 | "waitTimeReset", 106 | "waitTimeUpdate" 107 | ] 108 | } 109 | ] 110 | } 111 | -------------------------------------------------------------------------------- /deconz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ebaauw/homebridge-deconz/1d6ee605966dd906761bd5ee9e4dfb85542c69d2/deconz.png -------------------------------------------------------------------------------- /homebridge-ui/public/homebridge-deconz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ebaauw/homebridge-deconz/1d6ee605966dd906761bd5ee9e4dfb85542c69d2/homebridge-ui/public/homebridge-deconz.png -------------------------------------------------------------------------------- /homebridge-ui/public/index.html: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | 11 |

12 | 13 | 14 | 15 |

16 | 17 |
18 | 19 |
20 |
21 |

Gateways

22 | 23 | 24 |
25 | 26 | 49 |
50 | 51 |
52 |

53 | Configure Gateway 54 | 55 | - {{ selectedGateway.name }} 56 | 57 |

58 | 59 | 60 | 61 | 62 | 63 | 64 |
65 |
66 | 67 | 296 | -------------------------------------------------------------------------------- /homebridge-ui/public/index.old.html: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 |

10 | 11 | 12 | 13 |

14 | 15 | 505 | -------------------------------------------------------------------------------- /homebridge-ui/public/style.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ebaauw/homebridge-deconz/1d6ee605966dd906761bd5ee9e4dfb85542c69d2/homebridge-ui/public/style.css -------------------------------------------------------------------------------- /homebridge-ui/server.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/homebridge-ui/server.js 2 | // 3 | // Homebridge plug-in for deCONZ. 4 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 5 | 6 | import { UiServer } from 'hb-lib-tools/UiServer' 7 | import { Discovery } from 'hb-deconz-tools/Discovery' 8 | 9 | class DeconzUiServer extends UiServer { 10 | constructor () { 11 | super() 12 | 13 | this.onRequest('discover', async (params) => { 14 | if (this.discovery == null) { 15 | this.discovery = new Discovery({ 16 | // forceHttp: this.config.forceHttp, 17 | // timeout: this.config.timeout 18 | }) 19 | this.discovery 20 | .on('error', (error) => { 21 | this.log( 22 | '%s: request %d: %s %s', error.request.name, 23 | error.request.id, error.request.method, error.request.resource 24 | ) 25 | this.warn( 26 | '%s: request %d: %s', error.request.name, error.request.id, error 27 | ) 28 | }) 29 | .on('request', (request) => { 30 | this.debug( 31 | '%s: request %d: %s %s', request.name, 32 | request.id, request.method, request.resource 33 | ) 34 | }) 35 | .on('response', (response) => { 36 | this.debug( 37 | '%s: request %d: %d %s', response.request.name, 38 | response.request.id, response.statusCode, response.statusMessage 39 | ) 40 | }) 41 | .on('found', (name, id, address) => { 42 | this.debug('%s: found %s at %s', name, id, address) 43 | }) 44 | .on('searching', (host) => { 45 | this.debug('upnp: listening on %s', host) 46 | }) 47 | .on('searchDone', () => { this.debug('upnp: search done') }) 48 | } 49 | const configs = await this.discovery.discover() 50 | return configs 51 | }) 52 | this.ready() 53 | } 54 | } 55 | 56 | new DeconzUiServer() // eslint-disable-line no-new 57 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/index.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { createRequire } from 'node:module' 7 | 8 | import { DeconzPlatform } from './lib/DeconzPlatform.js' 9 | 10 | const require = createRequire(import.meta.url) 11 | const packageJson = require('./package.json') 12 | 13 | function main (homebridge) { 14 | DeconzPlatform.loadPlatform(homebridge, packageJson, 'deCONZ', DeconzPlatform) 15 | } 16 | 17 | export { main as default } 18 | -------------------------------------------------------------------------------- /jsdoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "plugins/markdown" 4 | ], 5 | "rescurseDepth": 10, 6 | "source": { 7 | "include": [ 8 | "README.md", 9 | "index.js", 10 | "lib", 11 | "cli", 12 | "node_modules/hb-deconz-tools/index.js", 13 | "node_modules/hb-deconz-tools/lib", 14 | "node_modules/hb-lib-tools/lib/HttpClient.js" 15 | ] 16 | }, 17 | "opts": { 18 | "recurse": true 19 | }, 20 | "templates": { 21 | "monospaceLinks": true 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /lib/Deconz/Device.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/Deconz/Device.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { Deconz } from './index.js' 7 | 8 | /** Delegate class for a Zigbee or virtual device on a deCONZ gateway. 9 | * 10 | * The deCONZ REST API exposes a Zigbee device using one or more resources. 11 | * These resources are linked to the device through the `uniqueid` in the 12 | * resource body. 13 | * Each supported device corresponds to a HomeKit accessory. 14 | * Each supported resource corresponds to a HomeKit service. 15 | * @memberof Deconz 16 | */ 17 | class Device { 18 | /** Create a new instance of a delegate of a device, from a resource. 19 | * 20 | * @param {Deconz.Resource} resource - The resource. 21 | */ 22 | constructor (resource) { 23 | /** The device ID. 24 | * 25 | * This is the {@link Deconz.Resource#id id} of the delegates 26 | * of all resources for the device. 27 | * @type {string} 28 | */ 29 | this.id = resource.id 30 | 31 | /** The key of the delegate for the primary resource for the device in 32 | * {@link DeconzDevice#resourceBySubtype resourceBySubtype} 33 | * 34 | * This is the {@link DeconzDevice.Resource#subtype subtype} of the 35 | * HomeKit service corresponding to the primary resource. 36 | * @type {string} 37 | */ 38 | this.primary = resource.subtype 39 | 40 | /** An array of keys of the delegates for the resources for the device in 41 | * {@link DeconzDevice#resourceBySubtype resourceBySubtype} by service name. 42 | * 43 | * These are the {@link DeconzDevice.Resource#subtype subtype} values of the 44 | * HomeKit service corresponding to the resource. 45 | * @type {Object.>} 46 | */ 47 | this.subtypesByServiceName = {} 48 | this.subtypesByServiceName[resource.serviceName] = [resource.subtype] 49 | 50 | /** The delegates of the resources for the device, by subtype of the 51 | * corresponding HomeKit service. 52 | * @type {Object.} 53 | */ 54 | this.resourceBySubtype = {} 55 | this.resourceBySubtype[resource.subtype] = resource 56 | 57 | /** Zigbee device vs virtual device. 58 | * 59 | * This is the {@link Deconz.Resource#zigbee zigbee} of the 60 | * delegates of all resources for the device. 61 | * @type {boolean} 62 | */ 63 | this.zigbee = resource.zigbee 64 | 65 | /** Device has a resource with `config.battery` in their `body`. 66 | * @type {boolean} 67 | */ 68 | this.hasBattery = resource.body.config?.battery !== undefined 69 | } 70 | 71 | /** The delegate of the primary resource of the device. 72 | * @type {Deconz.Resource} 73 | */ 74 | get resource () { return this.resourceBySubtype[this.primary] } 75 | 76 | /** List of resource paths of the resources for the device. 77 | * @type {string[]} 78 | */ 79 | get rpaths () { 80 | return Object.keys(this.resourceBySubtype || {}).map((subtype) => { 81 | return this.resourceBySubtype[subtype].rpath 82 | }) 83 | } 84 | 85 | /** Add a {@link Deconz.Resource Resource}. 86 | * 87 | * Updates {@link Deconz.Device#resourceBySubtype resourceBySubtype}, 88 | * {@link Deconz.Device#rpaths rpaths}, and, when the added resource 89 | * has a higher priority, {@link Deconz.Device#primary primary} and 90 | * {@link Deconz.Device#resource resource}. 91 | * @param {Deconz.Resource} resource - The resource. 92 | */ 93 | addResource (resource) { 94 | const { body, id, prio, rtype, subtype, zigbee } = resource 95 | if (this.resourceBySubtype[subtype] != null) { 96 | const r = this.resourceBySubtype[subtype] 97 | throw new Error( 98 | `${resource.rpath}: duplicate uniqueid ${body.uniqueid} in ${r.rpath}` 99 | ) 100 | } 101 | if (zigbee !== this.zigbee || (zigbee && id !== this.id)) { 102 | const r = this.resourceBySubtype[subtype] 103 | throw new SyntaxError( 104 | `${resource.rpath}: cannot combine ${r.rpath}` 105 | ) 106 | } 107 | if (this.subtypesByServiceName[resource.serviceName] == null) { 108 | this.subtypesByServiceName[resource.serviceName] = [resource.subtype] 109 | } else { 110 | this.subtypesByServiceName[resource.serviceName].push(resource.subtype) 111 | } 112 | this.resourceBySubtype[subtype] = resource 113 | if (resource.body.config?.battery !== undefined) { 114 | this.hasBattery = true 115 | } 116 | const p = this.resourceBySubtype[this.primary] 117 | if (p.rtype === rtype && p.prio < prio) { 118 | this.primary = resource.subtype 119 | } 120 | } 121 | } 122 | 123 | Deconz.Device = Device 124 | -------------------------------------------------------------------------------- /lib/Deconz/index.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/Deconz/index.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | /** Library to discover, monitor, and interact with a deCONZ gateway. 7 | * @hideconstructor 8 | */ 9 | class Deconz {} 10 | 11 | export { Deconz } 12 | -------------------------------------------------------------------------------- /lib/DeconzAccessory/AirPurifier.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzAccessory/Thermostat.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { DeconzAccessory } from '../DeconzAccessory/index.js' 7 | 8 | class AirPurifier extends DeconzAccessory { 9 | /** Instantiate a delegate for an accessory corresponding to a device. 10 | * @param {DeconzAccessory.Gateway} gateway - The gateway. 11 | * @param {Deconz.Device} device - The device. 12 | */ 13 | constructor (gateway, device, settings = {}) { 14 | super(gateway, device, gateway.Accessory.Categories.AIR_PURIFIER) 15 | this.identify() 16 | 17 | this.service = this.createService(device.resource, { primaryService: true }) 18 | 19 | for (const subtype in device.resourceBySubtype) { 20 | const resource = device.resourceBySubtype[subtype] 21 | if (subtype === device.primary) { 22 | continue 23 | } 24 | this.createService(resource) 25 | } 26 | 27 | setImmediate(() => { 28 | this.debug('initialised') 29 | this.emit('initialised') 30 | }) 31 | } 32 | } 33 | 34 | DeconzAccessory.AirPurifier = AirPurifier 35 | -------------------------------------------------------------------------------- /lib/DeconzAccessory/Light.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzAccessory/Light.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { ServiceDelegate } from 'homebridge-lib/ServiceDelegate' 7 | import 'homebridge-lib/ServiceDelegate/History' 8 | 9 | import { DeconzAccessory } from '../DeconzAccessory/index.js' 10 | 11 | /** Delegate class for a HomeKit accessory, corresponding to a light device 12 | * or groups resource. 13 | * @extends DeconzAccessory 14 | * @memberof DeconzAccessory 15 | */ 16 | class Light extends DeconzAccessory { 17 | /** Instantiate a delegate for an accessory corresponding to a device. 18 | * @param {DeconzAccessory.Gateway} gateway - The gateway. 19 | * @param {Deconz.Device} device - The device. 20 | */ 21 | constructor (gateway, device, settings = {}) { 22 | super(gateway, device, gateway.Accessory.Categories.LIGHTBULB) 23 | 24 | this.identify() 25 | 26 | this.addPropertyDelegate({ 27 | key: 'serviceName', 28 | value: device.resource.serviceName 29 | }).on('didSet', (value) => { 30 | gateway.context.settingsById[device.id].serviceName = value 31 | }) 32 | gateway.context.settingsById[device.id].serviceName = this.values.serviceName 33 | 34 | this.service = this.createService(device.resource, { 35 | primaryService: true, 36 | serviceName: this.values.serviceName 37 | }) 38 | 39 | for (const subtype in device.resourceBySubtype) { 40 | const resource = device.resourceBySubtype[subtype] 41 | if (subtype === device.primary) { 42 | continue 43 | } 44 | if (resource.rtype === 'lights') { 45 | this.createService(resource, { serviceName: this.values.serviceName }) 46 | } else { 47 | this.createService(resource) 48 | } 49 | } 50 | 51 | const params = {} 52 | 53 | if (this.values.serviceName === 'Valve') { 54 | // No history 55 | } else if ( 56 | this.servicesByServiceName[this.values.serviceName].length > 1 || 57 | this.values.serviceName === 'Light' 58 | ) { 59 | params.lightOnDelegate = this.service.characteristicDelegate('on') 60 | params.lastLightOnDelegate = this.service.addCharacteristicDelegate({ 61 | key: 'lastActivation', 62 | Characteristic: this.Characteristics.eve.LastActivation, 63 | silent: true 64 | }) 65 | } else { // Outlet or Switch 66 | if (this.values.serviceName === 'Outlet') { 67 | this.service.addCharacteristicDelegate({ 68 | key: 'lockPhysicalControls', 69 | Characteristic: this.Characteristics.hap.LockPhysicalControls 70 | }) 71 | } 72 | params.onDelegate = this.service.characteristicDelegate('on') 73 | params.lastOnDelegate = this.service.addCharacteristicDelegate({ 74 | key: 'lastActivation', 75 | Characteristic: this.Characteristics.eve.LastActivation, 76 | silent: true 77 | }) 78 | } 79 | 80 | if (this.servicesByServiceName.Consumption?.length === 1) { 81 | const service = this.servicesByServiceName.Consumption[0] 82 | params.totalConsumptionDelegate = service.characteristicDelegate('totalConsumption') 83 | if (service.values.consumption === undefined) { 84 | // Power to be computed by history if not exposed by device 85 | params.computedConsumptionDelegate = service.addCharacteristicDelegate({ 86 | key: 'consumption', 87 | Characteristic: this.Characteristics.eve.Consumption, 88 | unit: ' W' 89 | }) 90 | } 91 | } else if (this.servicesByServiceName.Power?.length === 1) { 92 | const service = this.servicesByServiceName.Power[0] 93 | params.consumptionDelegate = service.characteristicDelegate('consumption') 94 | // Total Consumption to be computed by history 95 | params.computedTotalConsumptionDelegate = service.addCharacteristicDelegate({ 96 | key: 'totalConsumption', 97 | Characteristic: this.Characteristics.eve.TotalConsumption, 98 | unit: ' kWh' 99 | }) 100 | } 101 | 102 | if (Object.keys(params).length > 0) { 103 | this.historyService = new ServiceDelegate.History(this, params) 104 | } 105 | 106 | if (this.servicesByServiceName[this.values.serviceName].length === 1) { 107 | if ( 108 | this.values.serviceName === 'Outlet' && 109 | this.servicesByServiceName.Consumption == null && 110 | this.servicesByServiceName.Power == null 111 | ) { 112 | // Dumb Outlet 113 | const service = new ServiceDelegate(this, { 114 | name: this.name + ' Consumption', 115 | Service: this.Services.eve.Consumption, 116 | hidden: true 117 | }) 118 | service.addCharacteristicDelegate({ 119 | key: 'dummyTotalConsumption', 120 | Characteristic: this.Characteristics.eve.TotalConsumption, 121 | props: { 122 | perms: [ 123 | this.Characteristic.Perms.PAIRED_READ, 124 | this.Characteristic.Perms.NOTIFY, 125 | this.Characteristic.Perms.HIDDEN 126 | ] 127 | }, 128 | silent: true, 129 | value: 0 130 | }) 131 | } 132 | } else { 133 | this.debug('servicesByServiceName: %j', Object.keys(this.servicesByServiceName)) 134 | this.debug('servicesByServiceName[%s]: %j', this.values.serviceName, Object.keys(this.servicesByServiceName[this.values.serviceName])) 135 | for (const i in this.servicesByServiceName[this.values.serviceName]) { 136 | try { 137 | const service = this.servicesByServiceName[this.values.serviceName][i] 138 | this.debug('service %d: %j', i, service.rpath) 139 | service.addCharacteristicDelegate({ 140 | key: 'index', 141 | Characteristic: this.Characteristics.hap.ServiceLabelIndex, 142 | silent: true, 143 | value: Number(i) + 1 144 | }) 145 | service.values.index = Number(i) + 1 146 | if (i === '0') { 147 | continue 148 | } 149 | this.historyService?.addLastOnDelegate( 150 | service.characteristicDelegate('on'), 151 | service.addCharacteristicDelegate({ 152 | key: 'lastActivation', 153 | Characteristic: this.Characteristics.eve.LastActivation, 154 | silent: true 155 | }) 156 | ) 157 | } catch (error) { 158 | this.warn(error) 159 | } 160 | } 161 | } 162 | 163 | setImmediate(() => { 164 | this.debug('initialised') 165 | this.emit('initialised') 166 | }) 167 | } 168 | } 169 | 170 | DeconzAccessory.Light = Light 171 | DeconzAccessory.Outlet = Light 172 | DeconzAccessory.Switch = Light 173 | -------------------------------------------------------------------------------- /lib/DeconzAccessory/Sensor.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzAccessory/Sensor.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | // Keep separate for Eve History 7 | // Switch/Outlet/Lightbulb 8 | // Stateless Programmable Switch (Eve button) 9 | // Sensors 10 | 11 | import { ServiceDelegate } from 'homebridge-lib/ServiceDelegate' 12 | import 'homebridge-lib/ServiceDelegate/History' 13 | 14 | import { DeconzAccessory } from '../DeconzAccessory/index.js' 15 | 16 | class Sensor extends DeconzAccessory { 17 | constructor (gateway, device) { 18 | super(gateway, device, gateway.Accessory.Categories.SENSOR) 19 | 20 | this.identify() 21 | 22 | this.service = this.createService(device.resource, { primaryService: true }) 23 | 24 | for (const subtype in device.resourceBySubtype) { 25 | const resource = device.resourceBySubtype[subtype] 26 | if (subtype === device.primary) { 27 | continue 28 | } 29 | this.createService(resource) 30 | } 31 | 32 | switch (device.resource.serviceName) { 33 | case 'Daylight': 34 | case 'LightLevel': 35 | // Create dummy motion sensor service. 36 | this.motionService = new ServiceDelegate(this, { 37 | name: this.name + ' Motion', 38 | Service: this.Services.hap.MotionSensor, 39 | hidden: true 40 | }) 41 | this.motionService.addCharacteristicDelegate({ 42 | key: 'motion', 43 | Characteristic: this.Characteristics.hap.MotionDetected, 44 | props: { 45 | perms: [ 46 | this.Characteristic.Perms.PAIRED_READ, 47 | this.Characteristic.Perms.NOTIFY, 48 | this.Characteristic.Perms.HIDDEN 49 | ] 50 | }, 51 | value: 0 52 | }) 53 | break 54 | default: 55 | break 56 | } 57 | 58 | const params = {} 59 | if (this.servicesByServiceName.Contact?.length === 1) { 60 | const service = this.servicesByServiceName.Contact[0] 61 | params.contactDelegate = service.characteristicDelegate('contact') 62 | params.lastContactDelegate = service.addCharacteristicDelegate({ 63 | key: 'lastActivation', 64 | Characteristic: this.Characteristics.eve.LastActivation, 65 | silent: true 66 | }) 67 | params.timesOpenedDelegate = service.addCharacteristicDelegate({ 68 | key: 'timesOpened', 69 | Characteristic: this.Characteristics.eve.TimesOpened, 70 | value: 0, 71 | silent: true 72 | }) 73 | } 74 | if (this.servicesByServiceName.Motion?.length === 1) { 75 | const service = this.servicesByServiceName.Motion[0] 76 | params.motionDelegate = service.characteristicDelegate('motion') 77 | params.lastMotionDelegate = service.addCharacteristicDelegate({ 78 | key: 'lastActivation', 79 | Characteristic: this.Characteristics.eve.LastActivation, 80 | silent: true 81 | }) 82 | } 83 | if (this.servicesByServiceName.LightLevel?.length === 1) { 84 | const service = this.servicesByServiceName.LightLevel[0] 85 | params.lightLevelDelegate = service.characteristicDelegate('lightLevel') 86 | } 87 | if (this.servicesByServiceName.Daylight?.length === 1) { 88 | const service = this.servicesByServiceName.Daylight[0] 89 | params.lightLevelDelegate = service.characteristicDelegate('lightLevel') 90 | } 91 | if (this.servicesByServiceName.Temperature?.length === 1) { 92 | const service = this.servicesByServiceName.Temperature[0] 93 | params.temperatureDelegate = service.characteristicDelegate('temperature') 94 | } 95 | if (this.servicesByServiceName.Humidity?.length === 1) { 96 | const service = this.servicesByServiceName.Humidity[0] 97 | params.humidityDelegate = service.characteristicDelegate('humidity') 98 | } 99 | if (this.servicesByServiceName.AirPressure?.length === 1) { 100 | const service = this.servicesByServiceName.AirPressure[0] 101 | params.airPressureDelegate = service.characteristicDelegate('airPressure') 102 | } 103 | if (this.servicesByServiceName.AirQuality?.length >= 1) { 104 | const service = this.servicesByServiceName.AirQuality[0] 105 | if (service.characteristicDelegate('vocDensity') != null) { 106 | params.vocDensityDelegate = service.characteristicDelegate('vocDensity') 107 | } 108 | } 109 | if (this.servicesByServiceName.Flag?.length === 1) { 110 | const service = this.servicesByServiceName.Flag[0] 111 | params.onDelegate = service.characteristicDelegate('on') 112 | params.lastOnDelegate = service.addCharacteristicDelegate({ 113 | key: 'lastActivation', 114 | Characteristic: this.Characteristics.eve.LastActivation, 115 | silent: true 116 | }) 117 | } 118 | if ( 119 | params.temperatureDelegate != null && params.humidityDelegate != null && 120 | params.airPressureDelegate == null && params.vocDensityDelegate == null && 121 | this.servicesByServiceName.Battery?.length === 1 122 | ) { 123 | // Eve would see this as an Eve Thermo Control. 124 | this.airPressureService = new ServiceDelegate(this, { 125 | name: this.name + ' Pressure', 126 | Service: this.Services.eve.AirPressureSensor, 127 | hidden: true 128 | }) 129 | this.airPressureService.addCharacteristicDelegate({ 130 | key: 'airPressure', 131 | Characteristic: this.Characteristics.eve.AirPressure, 132 | props: { 133 | perms: [ 134 | this.Characteristic.Perms.PAIRED_READ, 135 | this.Characteristic.Perms.NOTIFY, 136 | this.Characteristic.Perms.HIDDEN 137 | ] 138 | }, 139 | value: 0 140 | }) 141 | } 142 | if (Object.keys(params).length > 0) { 143 | this.historyService = new ServiceDelegate.History(this, params) 144 | } 145 | 146 | setImmediate(() => { 147 | this.debug('initialised') 148 | this.emit('initialised') 149 | }) 150 | } 151 | } 152 | 153 | DeconzAccessory.Sensor = Sensor 154 | -------------------------------------------------------------------------------- /lib/DeconzAccessory/Thermostat.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzAccessory/Thermostat.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { ServiceDelegate } from 'homebridge-lib/ServiceDelegate' 7 | import 'homebridge-lib/ServiceDelegate/History' 8 | 9 | import { DeconzAccessory } from '../DeconzAccessory/index.js' 10 | 11 | class Thermostat extends DeconzAccessory { 12 | /** Instantiate a delegate for an accessory corresponding to a device. 13 | * @param {DeconzAccessory.Gateway} gateway - The gateway. 14 | * @param {Deconz.Device} device - The device. 15 | */ 16 | constructor (gateway, device, settings = {}) { 17 | super(gateway, device, gateway.Accessory.Categories.THERMOSTAT) 18 | this.identify() 19 | 20 | this.service = this.createService(device.resource, { primaryService: true }) 21 | 22 | for (const subtype in device.resourceBySubtype) { 23 | const resource = device.resourceBySubtype[subtype] 24 | if (subtype === device.primary) { 25 | continue 26 | } 27 | this.createService(resource) 28 | } 29 | 30 | if (device.resource.body.state.valve !== undefined) { 31 | this.historyService = new ServiceDelegate.History(this, { 32 | temperatureDelegate: this.service.characteristicDelegate('currentTemperature'), 33 | targetTemperatureDelegate: this.service.characteristicDelegate('targetTemperature'), 34 | valvePositionDelegate: this.service.characteristicDelegate('valvePosition') 35 | }) 36 | } 37 | 38 | setImmediate(() => { 39 | this.debug('initialised') 40 | this.emit('initialised') 41 | }) 42 | } 43 | } 44 | 45 | DeconzAccessory.Thermostat = Thermostat 46 | -------------------------------------------------------------------------------- /lib/DeconzAccessory/WarningDevice.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzAccessory/WarningDevice.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { ServiceDelegate } from 'homebridge-lib/ServiceDelegate' 7 | import 'homebridge-lib/ServiceDelegate/History' 8 | 9 | import { DeconzAccessory } from '../DeconzAccessory/index.js' 10 | 11 | /** Delegate class for a HomeKit accessory, corresponding to a light device 12 | * or groups resource. 13 | * @extends DeconzAccessory 14 | * @memberof DeconzAccessory 15 | */ 16 | class WarningDevice extends DeconzAccessory { 17 | /** Instantiate a delegate for an accessory corresponding to a device. 18 | * @param {DeconzAccessory.Gateway} gateway - The gateway. 19 | * @param {Deconz.Device} device - The device. 20 | */ 21 | constructor (gateway, device, settings = {}) { 22 | super(gateway, device, gateway.Accessory.Categories.SENSOR) 23 | 24 | this.identify() 25 | 26 | this.service = this.createService(device.resource, { primaryService: true }) 27 | 28 | for (const subtype in device.resourceBySubtype) { 29 | const resource = device.resourceBySubtype[subtype] 30 | if (subtype === device.primary) { 31 | continue 32 | } 33 | this.createService(resource) 34 | } 35 | 36 | const params = {} 37 | if (this.servicesByServiceName.WarningDevice?.length === 1) { 38 | params.onDelegate = this.service.characteristicDelegate('on') 39 | params.lastOnDelegate = this.service.addCharacteristicDelegate({ 40 | key: 'lastActivation', 41 | Characteristic: this.Characteristics.eve.LastActivation, 42 | silent: true 43 | }) 44 | } 45 | if (this.servicesByServiceName.Temperature?.length === 1) { 46 | const service = this.servicesByServiceName.Temperature[0] 47 | params.temperatureDelegate = service.characteristicDelegate('temperature') 48 | } 49 | if (Object.keys(params).length > 0) { 50 | this.historyService = new ServiceDelegate.History(this, params) 51 | } 52 | 53 | setImmediate(() => { 54 | this.debug('initialised') 55 | this.emit('initialised') 56 | }) 57 | } 58 | } 59 | 60 | DeconzAccessory.WarningDevice = WarningDevice 61 | -------------------------------------------------------------------------------- /lib/DeconzAccessory/WindowCovering.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzAccessory/WindowCovering.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { DeconzAccessory } from '../DeconzAccessory/index.js' 7 | 8 | /** Delegate class for a HomeKit accessory, corresponding to a light device 9 | * or groups resource. 10 | * @extends DeconzAccessory 11 | * @memberof DeconzAccessory 12 | */ 13 | class WindowCovering extends DeconzAccessory { 14 | /** Instantiate a delegate for an accessory corresponding to a device. 15 | * @param {DeconzAccessory.Gateway} gateway - The gateway. 16 | * @param {Deconz.Device} device - The device. 17 | */ 18 | constructor (gateway, device, settings = {}) { 19 | super(gateway, device, gateway.Accessory.Categories.WINDOW_COVERING) 20 | 21 | this.identify() 22 | 23 | this.service = this.createService(device.resource, { 24 | primaryService: true, 25 | serviceName: this.values.serviceName 26 | }) 27 | 28 | for (const subtype in device.resourceBySubtype) { 29 | const resource = device.resourceBySubtype[subtype] 30 | if (subtype === device.primary) { 31 | continue 32 | } 33 | this.createService(resource) 34 | } 35 | 36 | setImmediate(() => { 37 | this.debug('initialised') 38 | this.emit('initialised') 39 | }) 40 | } 41 | } 42 | 43 | DeconzAccessory.WindowCovering = WindowCovering 44 | -------------------------------------------------------------------------------- /lib/DeconzAccessory/index.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzAccessory/index.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { AccessoryDelegate } from 'homebridge-lib/AccessoryDelegate' 7 | import { OptionParser } from 'homebridge-lib/OptionParser' 8 | 9 | import { ApiClient } from 'hb-deconz-tools/ApiClient' 10 | 11 | import { DeconzService } from '../DeconzService/index.js' 12 | import '../DeconzService/Button.js' 13 | 14 | const { HttpError } = ApiClient 15 | const { SINGLE, DOUBLE, LONG } = DeconzService.Button 16 | 17 | /** Abstract superclass for a delegate of a HomeKit accessory, 18 | * corresponding to a Zigbee or virtual device on a deCONZ gateway. 19 | * @extends AccessoryDelegate 20 | */ 21 | class DeconzAccessory extends AccessoryDelegate { 22 | /** Instantiate a delegate for an accessory corresponding to a device. 23 | * @param {DeconzAccessory.Gateway} gateway - The gateway. 24 | * @param {Deconz.Device} device - The device. 25 | * @param {Accessory.Category} category - The HomeKit accessory category. 26 | */ 27 | constructor (gateway, device, category) { 28 | super(gateway.platform, { 29 | id: device.id, 30 | name: device.resource.body.name, 31 | manufacturer: device.resource.manufacturer, 32 | model: device.resource.model, 33 | firmware: device.resource.firmware, 34 | category, 35 | logLevel: gateway.logLevel 36 | }) 37 | 38 | this.context.gid = gateway.id 39 | 40 | this.serviceByRpath = {} 41 | this.serviceBySubtype = {} 42 | this.servicesByServiceName = {} 43 | 44 | /** The gateway. 45 | * @type {DeconzAccessory.Gateway} 46 | */ 47 | this.gateway = gateway 48 | 49 | /** The accessory ID. 50 | * 51 | * This is the {@link Deconz.Device#id id} of the corresponding device. 52 | * @type {string} 53 | */ 54 | this.id = device.id 55 | 56 | /** The corresponding device. 57 | * @type {Deconz.Device} 58 | */ 59 | this.device = device 60 | 61 | /** The API client instance for the gateway. 62 | * @type {ApiClient} 63 | */ 64 | this.client = gateway.client 65 | 66 | this 67 | .on('polled', (device) => { 68 | let reExpose = false 69 | this.values.firmware = device.resource.firmware 70 | for (const subtype in device.resourceBySubtype) { 71 | const resource = device.resourceBySubtype[subtype] 72 | this.debug('%s: polled: %j', resource.rpath, resource.body) 73 | const service = this.serviceBySubtype[subtype] 74 | if (service == null) { 75 | this.log('%s: new resource: %j', resource.rpath, resource.body) 76 | reExpose = true 77 | } else { 78 | service.update(resource.body, resource.rpath) 79 | } 80 | } 81 | for (const subtype in this.serviceBySubtype) { 82 | const service = this.serviceBySubtype[subtype] 83 | const resource = device.resourceBySubtype[subtype] 84 | if (resource == null) { 85 | this.log('%s: resource deleted', service.rpath) 86 | reExpose = true 87 | } 88 | } 89 | if (reExpose) { 90 | this.gateway.reExposeAccessory(this.id) 91 | } 92 | }) 93 | .on('changed', (rpath, body) => { 94 | this.debug('%s: changed: %j', rpath, body) 95 | const service = this.serviceByRpath[rpath] 96 | if (service != null) { 97 | service.update(body, rpath) 98 | } 99 | }) 100 | .on('identify', async () => { 101 | try { 102 | await this.identify() 103 | } catch (error) { 104 | if (!(error instanceof HttpError)) { 105 | this.warn(error) 106 | } 107 | } 108 | }) 109 | } 110 | 111 | /** The primary resource of the device. 112 | * @type {Deconz.Resource} 113 | */ 114 | get resource () { return this.device.resource } 115 | 116 | /** List of resource paths of associated resources in order of prio. 117 | * @type {string[]} 118 | */ 119 | get rpaths () { return this.device.rpaths } 120 | 121 | async identify () { 122 | this.log( 123 | '%s %s v%s (%d resources)', this.values.manufacturer, this.values.model, 124 | this.values.firmware, this.rpaths.length 125 | ) 126 | this.debug('%d resources: %s', this.rpaths.length, this.rpaths.join(', ')) 127 | this.vdebug('device: %j', this.device) 128 | if (this.service != null) { 129 | await this.service.identify() 130 | } 131 | } 132 | 133 | createService (resource, params = {}) { 134 | if (resource == null) { 135 | return 136 | } 137 | if (params.serviceName == null) { 138 | params.serviceName = resource.serviceName 139 | } 140 | if (DeconzService[params.serviceName] == null) { 141 | this.warn( 142 | '%s: %s: service type not available', 143 | resource.rpath, params.serviceName 144 | ) 145 | return 146 | } 147 | this.debug( 148 | '%s: capabilities: %j', resource.rpath, resource.capabilities 149 | ) 150 | this.debug('%s: params: %j', resource.rpath, params) 151 | 152 | let service 153 | if (params.serviceName === 'AirQuality') { 154 | service = this.servicesByServiceName.AirQuality?.[0] 155 | if (service != null) { 156 | service.addResource(resource) 157 | } 158 | } else if (params.serviceName === 'Battery') { 159 | service = this.servicesByServiceName.Battery?.[0] 160 | } else if (params.serviceName === 'Consumption') { 161 | service = this.servicesByServiceName.Power?.[0] 162 | if (service != null) { 163 | service.addResource(resource) 164 | } 165 | } else if (params.serviceName === 'Power') { 166 | service = this.servicesByServiceName.Consumption?.[0] 167 | if (service != null) { 168 | service.addResource(resource) 169 | } 170 | } else if (params.serviceName === 'Label') { 171 | service = this.servicesByServiceName.Label?.[0] 172 | // Default button 173 | if (resource.capabilities.buttons == null) { 174 | if (service == null) { 175 | this.warn( 176 | '%s: unknown %s: %j', resource.rpath, resource.body.type, 177 | resource.body 178 | ) 179 | resource.capabilities.buttons = { 180 | 1: { 181 | label: 'Unknown Button', 182 | events: SINGLE | DOUBLE | LONG 183 | } 184 | } 185 | resource.capabilities.namespace = 186 | this.Characteristics.hap.ServiceLabelNamespace.ARABIC_NUMERALS 187 | } else { 188 | resource.capabilities.buttons = {} 189 | } 190 | } 191 | } 192 | if (service == null) { 193 | service = new DeconzService[params.serviceName](this, resource, { 194 | primaryService: params.primaryService 195 | }) 196 | } 197 | if (this.servicesByServiceName[params.serviceName] == null) { 198 | this.servicesByServiceName[params.serviceName] = [service] 199 | } else { 200 | this.servicesByServiceName[params.serviceName].push(service) 201 | } 202 | if (params.serviceName === 'Label') { 203 | service.createButtonServices(resource, params) 204 | } 205 | this.serviceBySubtype[resource.subtype] = service 206 | this.serviceByRpath[resource.rpath] = service 207 | if (resource.body.config?.battery !== undefined) { 208 | if (this.servicesByServiceName.Battery?.[0] == null) { 209 | this.servicesByServiceName.Battery = [new DeconzService.Battery(this, resource)] 210 | } 211 | service.batteryService = this.servicesByServiceName.Battery[0] 212 | } 213 | return service 214 | } 215 | 216 | onUiGet (details = false) { 217 | const resource = this.device.resourceBySubtype[this.device.primary] 218 | const body = { 219 | id: details ? this.id : undefined, 220 | manufacturer: this.values.manufacturer, 221 | model: this.values.model, 222 | name: this.name, 223 | resources: this.device.rpaths, 224 | settings: details 225 | ? { 226 | anyOn: this.device.resource.rtype === 'groups' 227 | ? this.values.anyOn 228 | : undefined, 229 | buttonRepeat: undefined, // map per button 230 | expose: true, 231 | exposeEffects: this.service.values.exposeEffects, 232 | exposeScenes: this.service.values.exposeScenes, 233 | multiClip: undefined, 234 | multiLight: undefined, 235 | logLevel: this.values.logLevel, 236 | lowBatteryThreshold: this.servicesByServiceName?.Battery?.[0].values.lowBatteryThreshold, 237 | // offset: this.servicesByServiceName?.Temperature?.[0].values.offset, 238 | pin: this.service.values.pin, 239 | serviceName: this.values.serviceName, 240 | venetianBlind: this.service.values.venetianBlind, 241 | useExternalTemperature: this.service.values.useExternalTemperature, 242 | wallSwitch: this.service.values.wallSwitch 243 | } 244 | : undefined, 245 | type: resource.rtype, 246 | zigbee: this.device.zigbee 247 | } 248 | return { status: 200, body } 249 | } 250 | 251 | onUiPut (body) { 252 | let reExpose = false 253 | const responseBody = {} 254 | for (const key in body) { 255 | try { 256 | let value 257 | switch (key) { 258 | case 'expose': 259 | value = OptionParser.toBool(key, body[key]) 260 | if (value) { 261 | reExpose = true 262 | } else { 263 | this.gateway.exposeDevice(this.id, value) 264 | } 265 | responseBody[key] = value 266 | continue 267 | // Settings for the primary service. 268 | case 'anyOn': 269 | case 'exposeEffects': 270 | case 'exposeScenes': 271 | case 'venetianBlind': 272 | if (this.service.values[key] != null) { 273 | value = OptionParser.toBool(key, body[key]) 274 | this.service.values[key] = value 275 | reExpose = true 276 | responseBody[key] = value 277 | continue 278 | } 279 | break 280 | case 'logLevel': 281 | value = OptionParser.toInt(key, body[key], 0, 3) 282 | this.values[key] = value 283 | responseBody[key] = value 284 | continue 285 | case 'lowBatteryThreshold': 286 | if (this.servicesByServiceName.Battery?.[0] != null) { 287 | value = OptionParser.toInt(key, body[key], 10, 100) 288 | this.servicesByServiceName.Battery[0].values[key] = value 289 | responseBody[key] = value 290 | continue 291 | } 292 | break 293 | case 'pin': 294 | if (this.service.values[key] != null) { 295 | value = OptionParser.toString(key, body[key]) 296 | this.service.values[key] = value 297 | responseBody[key] = value 298 | continue 299 | } 300 | break 301 | case 'serviceName': 302 | if (this.values.serviceName != null) { 303 | value = OptionParser.toString(key, body[key]) 304 | if (['Light', 'Outlet', 'Switch', 'Valve'].includes(value) == null) { 305 | throw new Error(`${value}: illegal serviceName`) 306 | } 307 | this.values.serviceName = value 308 | reExpose = true 309 | responseBody[key] = value 310 | continue 311 | } 312 | break 313 | case 'useExternalTemperature': 314 | case 'wallSwitch': 315 | if (this.service.values[key] != null) { 316 | value = OptionParser.toBool(key, body[key]) 317 | this.service.values[key] = value 318 | responseBody[key] = value 319 | continue 320 | } 321 | break 322 | default: 323 | break 324 | } 325 | this.warn('ui error: %s: invalid key', key) 326 | } catch (error) { this.warn('ui error: %s', error) } 327 | } 328 | if (reExpose) { 329 | this.gateway.reExposeAccessory(this.id) 330 | } 331 | return { status: 200, body: responseBody } 332 | } 333 | } 334 | 335 | export { DeconzAccessory } 336 | -------------------------------------------------------------------------------- /lib/DeconzPlatform.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzPlatform.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { once } from 'node:events' 7 | 8 | import { timeout } from 'homebridge-lib' 9 | import { HttpClient } from 'homebridge-lib/HttpClient' 10 | import { OptionParser } from 'homebridge-lib/OptionParser' 11 | import { Platform } from 'homebridge-lib/Platform' 12 | 13 | import { Discovery } from 'hb-deconz-tools/Discovery' 14 | 15 | import { DeconzAccessory } from './DeconzAccessory/index.js' 16 | import './DeconzAccessory/Gateway.js' 17 | 18 | class DeconzPlatform extends Platform { 19 | constructor (log, configJson, homebridge, bridge) { 20 | super(log, configJson, homebridge) 21 | this.parseConfigJson(configJson) 22 | this.debug('config: %j', this.config) 23 | 24 | this 25 | .on('accessoryRestored', this.accessoryRestored) 26 | .once('heartbeat', this.init) 27 | .on('heartbeat', this.heartbeat) 28 | } 29 | 30 | parseConfigJson (configJson) { 31 | this.config = { 32 | forceHttp: false, 33 | hosts: [], 34 | noResponse: false, 35 | parallelRequests: 10, 36 | stealth: false, 37 | timeout: 5, 38 | waitTimePut: 50, 39 | waitTimePutGroup: 1000, 40 | waitTimeResend: 300, 41 | waitTimeReset: 500, 42 | waitTimeUpdate: 100 43 | } 44 | const optionParser = new OptionParser(this.config, true) 45 | optionParser 46 | .on('userInputError', (message) => { 47 | this.warn('config.json: %s', message) 48 | }) 49 | .stringKey('name') 50 | .stringKey('platform') 51 | .boolKey('forceHttp') 52 | .stringKey('host') 53 | .arrayKey('hosts') 54 | .boolKey('noResponse') 55 | .intKey('parallelRequests', 1, 30) 56 | .boolKey('stealth') 57 | .intKey('timeout', 5, 30) 58 | .intKey('waitTimePut', 0, 50) 59 | .intKey('waitTimePutGroup', 0, 1000) 60 | .intKey('waitTimeResend', 100, 1000) 61 | .intKey('waitTimeReset', 10, 2000) 62 | .intKey('waitTimeUpdate', 0, 500) 63 | 64 | this.gatewayMap = {} 65 | 66 | try { 67 | optionParser.parse(configJson) 68 | if (this.config.host != null) { 69 | this.config.hosts.push(this.config.host) 70 | } 71 | this.discovery = new Discovery({ 72 | forceHttp: this.config.forceHttp, 73 | timeout: this.config.timeout 74 | }) 75 | this.discovery 76 | .on('error', (error) => { 77 | if (error instanceof HttpClient.HttpError) { 78 | this.log( 79 | '%s: request %d: %s %s', error.request.name, 80 | error.request.id, error.request.method, error.request.resource 81 | ) 82 | this.warn( 83 | '%s: request %d: %s', error.request.name, error.request.id, error 84 | ) 85 | return 86 | } 87 | this.warn(error) 88 | }) 89 | .on('request', (request) => { 90 | this.debug( 91 | '%s: request %d: %s %s', request.name, 92 | request.id, request.method, request.resource 93 | ) 94 | }) 95 | .on('response', (response) => { 96 | this.debug( 97 | '%s: request %d: %d %s', response.request.name, 98 | response.request.id, response.statusCode, response.statusMessage 99 | ) 100 | }) 101 | .on('found', (name, id, address) => { 102 | this.debug('%s: found %s at %s', name, id, address) 103 | }) 104 | .on('searching', (host) => { 105 | this.debug('upnp: listening on %s', host) 106 | }) 107 | .on('searchDone', () => { this.debug('upnp: search done') }) 108 | } catch (error) { 109 | this.error(error) 110 | } 111 | } 112 | 113 | async foundGateway (host, config) { 114 | const id = config.bridgeid 115 | if (this.gatewayMap[id] == null) { 116 | this.gatewayMap[id] = new DeconzAccessory.Gateway(this, { config, host }) 117 | } 118 | await this.gatewayMap[id].found(host, config) 119 | await once(this.gatewayMap[id], 'initialised') 120 | this.emit('found') 121 | } 122 | 123 | async findHost (host) { 124 | try { 125 | const config = await this.discovery.config(host) 126 | await this.foundGateway(host, config) 127 | } catch (error) { 128 | this.warn('%s: %s - retrying in 60s', host, error) 129 | await timeout(60000) 130 | return this.findHost(host) 131 | } 132 | } 133 | 134 | async init () { 135 | try { 136 | const jobs = [] 137 | if (this.config.hosts.length > 0) { 138 | for (const host of this.config.hosts) { 139 | this.debug('job %d: find gateway at %s', jobs.length, host) 140 | jobs.push(this.findHost(host)) 141 | } 142 | } else { 143 | this.debug('job %d: find at least one gateway', jobs.length) 144 | jobs.push(once(this, 'found')) 145 | for (const id in this.gatewayMap) { 146 | const gateway = this.gatewayMap[id] 147 | const host = gateway.values.host 148 | this.debug('job %d: find gateway %s', jobs.length, id) 149 | jobs.push(once(gateway, 'initialised')) 150 | try { 151 | const config = await this.discovery.config(host) 152 | await this.foundGateway(host, config) 153 | } catch (error) { 154 | this.warn('%s: %s', id, error) 155 | } 156 | } 157 | } 158 | 159 | this.debug('waiting for %d jobs', jobs.length) 160 | for (const id in jobs) { 161 | try { 162 | await jobs[id] 163 | this.debug('job %d/%d: done', Number(id) + 1, jobs.length) 164 | } catch (error) { 165 | this.warn(error) 166 | } 167 | } 168 | 169 | this.log('%d gateways', Object.keys(this.gatewayMap).length) 170 | this.emit('initialised') 171 | const dumpInfo = { 172 | config: this.config, 173 | gatewayMap: {} 174 | } 175 | for (const id in this.gatewayMap) { 176 | const gateway = this.gatewayMap[id] 177 | dumpInfo.gatewayMap[id] = Object.assign({}, gateway.context) 178 | dumpInfo.gatewayMap[id].deviceById = gateway.deviceById 179 | } 180 | await this.createDumpFile(dumpInfo) 181 | } catch (error) { this.error(error) } 182 | } 183 | 184 | async onUiRequest (method, url, body) { 185 | const path = url.split('/').slice(1) 186 | if (path.length < 1) { 187 | return { status: 403 } // Forbidden 188 | } 189 | if (path[0] === 'gateways') { 190 | if (path.length === 1) { 191 | if (method === 'GET') { 192 | // const gatewayByHost = await this.discovery.discover() 193 | const body = {} 194 | for (const id of Object.keys(this.gatewayMap).sort()) { 195 | const gateway = this.gatewayMap[id] 196 | body[gateway.values.host] = { 197 | config: gateway.context.config, 198 | host: gateway.values.host, 199 | id 200 | } 201 | } 202 | return { status: 200, body } 203 | } 204 | return { status: 405 } // Method Not Allowed 205 | } 206 | const gateway = this.gatewayMap[path[1]] 207 | if (gateway == null) { 208 | return { status: 404 } // Not Found 209 | } 210 | if (method === 'GET') { 211 | return gateway.onUiGet(path.slice(2)) 212 | } 213 | if (method === 'PUT') { 214 | return gateway.onUiPut(path.slice(2), body) 215 | } 216 | return { status: 405 } // Method Not Allowed 217 | } 218 | return { status: 403 } // Forbidden 219 | } 220 | 221 | async heartbeat (beat) { 222 | try { 223 | if (beat % 300 === 5 && this.config.hosts.length === 0) { 224 | const configs = await this.discovery.discover() 225 | const jobs = [] 226 | for (const host in configs) { 227 | jobs.push(this.foundGateway(host, configs[host])) 228 | } 229 | for (const job of jobs) { 230 | try { 231 | await job 232 | } catch (error) { 233 | this.error(error) 234 | } 235 | } 236 | } 237 | } catch (error) { this.error(error) } 238 | } 239 | 240 | /** Called when an accessory has been restored. 241 | * 242 | * Re-create {@link DeconzAccessory.Gateway Gateway} delegates for restored 243 | * gateway accessories. 244 | * Accessories for devices exposed by the gateway will be restored from 245 | * the gateway context, once Homebridge has started it's HAP server. 246 | */ 247 | accessoryRestored (className, version, id, name, context) { 248 | try { 249 | if (className === 'Gateway') { 250 | if ( 251 | this.config.hosts.length === 0 || 252 | this.config.hosts.includes(context.host) 253 | ) { 254 | this.gatewayMap[id] = new DeconzAccessory.Gateway(this, context) 255 | } 256 | } else { 257 | const gateway = this.gatewayMap[context.gid] 258 | if (gateway != null) { 259 | gateway.addAccessory(id) 260 | } 261 | } 262 | } catch (error) { this.error(error) } 263 | } 264 | } 265 | 266 | export { DeconzPlatform } 267 | -------------------------------------------------------------------------------- /lib/DeconzService/AirPressure.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzService/AirPressure.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { DeconzService } from '../DeconzService/index.js' 7 | import '../DeconzService/SensorsResource.js' 8 | 9 | /** 10 | * @memberof DeconzService 11 | */ 12 | class AirPressure extends DeconzService.SensorsResource { 13 | constructor (accessory, resource, params = {}) { 14 | params.Service = accessory.Services.eve.AirPressureSensor 15 | super(accessory, resource, params) 16 | 17 | this.addCharacteristicDelegate({ 18 | key: 'airPressure', 19 | Characteristic: this.Characteristics.eve.AirPressure, 20 | unit: ' hPa' 21 | }) 22 | 23 | this.addCharacteristicDelegate({ 24 | key: 'elevation', 25 | Characteristic: this.Characteristics.eve.Elevation, 26 | value: 0 27 | }) 28 | 29 | // this.addCharacteristicDelegate({ 30 | // key: 'trend', 31 | // Characteristic: this.Characteristics.eve.WeatherTrend, 32 | // value: 0 33 | // }) 34 | 35 | this.addCharacteristicDelegates() 36 | 37 | this.update(resource.body, resource.rpath) 38 | } 39 | 40 | updateState (state) { 41 | if (state.pressure != null) { 42 | this.values.airPressure = Math.round(state.pressure * 10) / 10 43 | } 44 | // this.values.trend = Math.round(new Date().valueOf() / 60000) % 16 45 | super.updateState(state) 46 | } 47 | } 48 | 49 | DeconzService.AirPressure = AirPressure 50 | -------------------------------------------------------------------------------- /lib/DeconzService/AirPurifier.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzService/AirPurifier.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { DeconzService } from '../DeconzService/index.js' 7 | import '../DeconzService/SensorsResource.js' 8 | 9 | class FilterMaintenance extends DeconzService.SensorsResource { 10 | constructor (accessory, resource, params = {}) { 11 | params.Service = accessory.Services.hap.FilterMaintenance 12 | super(accessory, resource, params) 13 | 14 | this.addCharacteristicDelegate({ 15 | key: 'filterChange', 16 | Characteristic: this.Characteristics.hap.FilterChangeIndication 17 | }) 18 | 19 | if ( 20 | resource.body.config.filterlifetime !== undefined && 21 | resource.body.state.filterruntime !== undefined 22 | ) { 23 | this.addCharacteristicDelegate({ 24 | key: 'filterLifeLevel', 25 | Characteristic: this.Characteristics.hap.FilterLifeLevel, 26 | unit: '%' 27 | }) 28 | this.addCharacteristicDelegate({ 29 | key: 'resetFilter', 30 | Characteristic: this.Characteristics.hap.ResetFilterIndication, 31 | props: { adminOnlyAccess: [this.Characteristic.Access.WRITE] }, 32 | value: 0 33 | }).on('didSet', async (value, fromHomeKit) => { 34 | await this.put('/config', { filterlifetime: 6 * 30 * 24 * 60 }) 35 | }) 36 | this.values.filterLifeTime = resource.body.config.filterlifetime 37 | } 38 | 39 | this.update(resource.body, resource.rpath) 40 | } 41 | 42 | updateState (state) { 43 | if (this.values.filterLifeTime != null && state.filterruntime != null) { 44 | this.values.filterLifeLevel = 100 - Math.round( 45 | 100 * state.filterruntime / this.values.filterLifeTime 46 | ) 47 | } 48 | if (state.replacefilter != null) { 49 | this.values.filterChange = state.replacefilter 50 | ? this.Characteristics.hap.FilterChangeIndication.CHANGE_FILTER 51 | : this.Characteristics.hap.FilterChangeIndication.FILTER_OK 52 | } 53 | } 54 | 55 | updateConfig (config) { 56 | if (config.filterlifetime != null) { 57 | this.values.filterLifeTime = config.filterlifetime 58 | } 59 | } 60 | } 61 | 62 | /** 63 | * @memberof DeconzService 64 | */ 65 | class AirPurifier extends DeconzService.SensorsResource { 66 | constructor (accessory, resource, params = {}) { 67 | params.Service = accessory.Services.hap.AirPurifier 68 | super(accessory, resource, params) 69 | 70 | this.addCharacteristicDelegate({ 71 | key: 'active', 72 | Characteristic: this.Characteristics.hap.Active 73 | }).on('didSet', async (value, fromHomeKit) => { 74 | if (fromHomeKit) { 75 | await this.put('/config', { mode: this.modeValue(value) }) 76 | } 77 | }) 78 | 79 | this.addCharacteristicDelegate({ 80 | key: 'currentState', 81 | Characteristic: this.Characteristics.hap.CurrentAirPurifierState 82 | }) 83 | 84 | this.addCharacteristicDelegate({ 85 | key: 'targetState', 86 | Characteristic: this.Characteristics.hap.TargetAirPurifierState 87 | }).on('didSet', async (value, fromHomeKit) => { 88 | if (fromHomeKit) { 89 | await this.put('/config', { mode: this.modeValue(null, value) }) 90 | } 91 | }) 92 | 93 | this.addCharacteristicDelegate({ 94 | key: 'rotationSpeed', 95 | Characteristic: this.Characteristics.hap.RotationSpeed, 96 | unit: '%' 97 | }).on('didSet', async (value, fromHomeKit) => { 98 | if (fromHomeKit) { 99 | await this.put('/config', { mode: this.modeValue(null, null, value) }) 100 | } 101 | }) 102 | 103 | if (resource.body.state.airquality !== undefined) { 104 | this.airQualityService = new DeconzService.AirQuality(accessory, resource, { 105 | linkedServiceDelegate: this 106 | }) 107 | } 108 | 109 | if (resource.body.state.replacefilter !== undefined) { 110 | this.filterService = new FilterMaintenance(accessory, resource, { 111 | linkedServiceDelegate: this 112 | }) 113 | } 114 | 115 | if (resource.body.state.deviceruntime !== undefined) { 116 | // TODO 117 | } 118 | 119 | if (resource.body.config.ledindication !== undefined) { 120 | // TODO 121 | } 122 | 123 | if (resource.body.config.locked !== undefined) { 124 | this.addCharacteristicDelegate({ 125 | key: 'lockPhysicalControls', 126 | Characteristic: this.Characteristics.hap.LockPhysicalControls 127 | }).on('didSet', async (value, fromHomeKit) => { 128 | if (fromHomeKit) { 129 | await this.put('/config', { 130 | locked: value === this.Characteristics.hap.LockPhysicalControls 131 | .CONTROL_LOCK_ENABLED 132 | }) 133 | } 134 | }) 135 | } 136 | 137 | super.addCharacteristicDelegates() 138 | 139 | this.update(resource.body, resource.rpath) 140 | } 141 | 142 | modeValue ( 143 | active = this.values.active, 144 | targetState = this.values.targetState, 145 | rotationSpeed = this.values.rotationSpeed 146 | ) { 147 | if (active === this.Characteristics.hap.Active.INACTIVE) { 148 | return 'off' 149 | } 150 | if ( 151 | targetState === this.Characteristics.hap.TargetAirPurifierState.AUTO || 152 | rotationSpeed === 0 153 | ) { 154 | return 'auto' 155 | } 156 | return 'speed_' + Math.round(rotationSpeed / 20) 157 | } 158 | 159 | updateState (state) { 160 | if (state.speed != null) { 161 | this.values.active = state.speed > 0 162 | ? this.Characteristics.hap.Active.ACTIVE 163 | : this.Characteristics.hap.Active.INACTIVE 164 | this.values.currentState = state.speed === 0 165 | ? this.Characteristics.hap.CurrentAirPurifierState.INACTIVE 166 | : this.Characteristics.hap.CurrentAirPurifierState.PURIFYING_AIR 167 | this.values.rotationSpeed = state.speed 168 | } 169 | super.updateState(state) 170 | if (this.airQualityService != null) { 171 | this.airQualityService.updateState(state) 172 | } 173 | if (this.filterService != null) { 174 | this.filterService.updateState(state) 175 | } 176 | } 177 | 178 | updateConfig (config) { 179 | if (config.filterlifetime != null) { 180 | this.values.filterLifeTime = config.filterlifetime 181 | } 182 | if (config.ledindication != null) { 183 | // TODO 184 | } 185 | if (config.locked != null) { 186 | this.values.lockPhysicalControls = config.locked 187 | ? this.Characteristics.hap.LockPhysicalControls.CONTROL_LOCK_ENABLED 188 | : this.Characteristics.hap.LockPhysicalControls.CONTROL_LOCK_DISABLED 189 | } 190 | if (config.mode != null) { 191 | this.values.targetState = config.mode === 'auto' 192 | ? this.Characteristics.hap.TargetAirPurifierState.AUTO 193 | : this.Characteristics.hap.TargetAirPurifierState.MANUAL 194 | } 195 | super.updateConfig(config) 196 | if (this.airQualityService != null) { 197 | this.airQualityService.updateConfig(config) 198 | } 199 | if (this.filterService != null) { 200 | this.filterService.updateConfig(config) 201 | } 202 | } 203 | } 204 | 205 | DeconzService.AirPurifier = AirPurifier 206 | -------------------------------------------------------------------------------- /lib/DeconzService/AirQuality.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzService/AirQuality.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { ApiClient } from 'hb-deconz-tools/ApiClient' 7 | 8 | import { DeconzService } from '../DeconzService/index.js' 9 | import '../DeconzService/SensorsResource.js' 10 | 11 | const { dateToString } = ApiClient 12 | 13 | /** 14 | * @memberof DeconzService 15 | */ 16 | class AirQuality extends DeconzService.SensorsResource { 17 | static addResource (service, resource) { 18 | if (service.values.airQuality === undefined) { 19 | service.addCharacteristicDelegate({ 20 | key: 'airQuality', 21 | Characteristic: service.Characteristics.hap.AirQuality 22 | }) 23 | } 24 | 25 | if ( 26 | resource.body.state.measured_value !== undefined && 27 | resource.body.capabilities?.measured_value != null 28 | ) { 29 | const cmv = resource.body.capabilities.measured_value 30 | switch (cmv.substance) { 31 | case 'PM2.5': 32 | if (cmv.quantity !== 'density') { 33 | service.warn('%s: unsupported substance', cmv.quantity) 34 | break 35 | } 36 | if (cmv.unit !== 'ug/m^3') { 37 | service.warn('%s: unsupported unit', cmv.unit) 38 | break 39 | } 40 | service.addCharacteristicDelegate({ 41 | key: 'pm25Density', 42 | Characteristic: service.Characteristics.hap.PM2_5Density, 43 | unit: ' µg/m³', 44 | props: { minValue: cmv.min, maxValue: cmv.max } 45 | }) 46 | service.resources[resource.rpath] = { 47 | key: 'pm25Density', 48 | f: (v) => { return v } 49 | } 50 | break 51 | case 'tVOC': 52 | if (cmv.quantity !== 'level') { 53 | service.warn('%s: unsupported substance', cmv.quantity) 54 | break 55 | } 56 | if (cmv.unit !== 'ppb') { 57 | service.warn('%s: unsupported unit', cmv.unit) 58 | break 59 | } 60 | service.addCharacteristicDelegate({ 61 | key: 'vocDensity', 62 | Characteristic: service.Characteristics.hap.VOCDensity, 63 | unit: ' µg/m³', 64 | props: { 65 | minValue: Math.floor(cmv.min * 4.57), 66 | maxValue: Math.ceil(cmv.max * 4.57) 67 | } 68 | }) 69 | service.resources[resource.rpath] = { 70 | key: 'vocDensity', 71 | f: (v) => { return Math.round(v * 4.57) } 72 | } 73 | break 74 | default: 75 | service.warn('%s: unsupported substance', cmv.substance) 76 | break 77 | } 78 | } else if (resource.body.state.airqualityppb !== undefined) { 79 | service.addCharacteristicDelegate({ 80 | key: 'vocDensity', 81 | Characteristic: service.Characteristics.hap.VOCDensity, 82 | unit: ' µg/m³', 83 | props: { minValue: 0, maxValue: 65535, minStep: 1 } 84 | }) 85 | } else if (resource.body.state.pm2_5 !== undefined) { 86 | service.addCharacteristicDelegate({ 87 | key: 'pm25Density', 88 | Characteristic: service.Characteristics.hap.PM2_5Density, 89 | unit: ' µg/m³', 90 | props: { minValue: 0, maxValue: 65535, minStep: 1 } 91 | }) 92 | } 93 | 94 | if (service.values.lastUpdated === undefined) { 95 | service.addCharacteristicDelegate({ 96 | key: 'lastUpdated', 97 | Characteristic: service.Characteristics.my.LastUpdated, 98 | silent: true 99 | }) 100 | } 101 | 102 | AirQuality.updateResourceState(service, resource.body.state) 103 | } 104 | 105 | static airQualityValue (service, value) { 106 | switch (value) { 107 | case 'excellent': 108 | return service.Characteristics.hap.AirQuality.EXCELLENT 109 | case 'good': 110 | return service.Characteristics.hap.AirQuality.GOOD 111 | case 'moderate': 112 | return service.Characteristics.hap.AirQuality.FAIR 113 | case 'poor': 114 | return service.Characteristics.hap.AirQuality.INFERIOR 115 | case 'unhealthy': 116 | return service.Characteristics.hap.AirQuality.POOR 117 | default: 118 | return service.Characteristics.hap.AirQuality.UNKNOWN 119 | } 120 | } 121 | 122 | static updateResourceState (service, state, rpath) { 123 | if (state.measured_value != null && service.resources[rpath] != null) { 124 | const { key, f } = service.resources[rpath] 125 | service.values[key] = f(state.measured_value) 126 | if ( 127 | state.airquality != null && ( 128 | key === 'vocDensity' || service.values.vocDensity === undefined 129 | ) 130 | ) { 131 | service.values.airQuality = AirQuality.airQualityValue( 132 | service, state.airquality 133 | ) 134 | } 135 | } else if (state.airqualityppb != null) { 136 | service.values.vocDensity = Math.round(state.airqualityppb * 4.57) 137 | if (state.airquality != null) { 138 | service.values.airQuality = AirQuality.airQualityValue( 139 | service, state.airquality 140 | ) 141 | } 142 | } else if (state.pm2_5 != null) { 143 | service.values.pm25Density = state.pm2_5 144 | if (state.airquality != null && service.values.vocDensity === undefined) { 145 | service.values.airQuality = AirQuality.airQualityValue( 146 | service, state.airquality 147 | ) 148 | } 149 | } 150 | if (state.lastupdated != null) { 151 | service.values.lastUpdated = dateToString(state.lastupdated) 152 | } 153 | } 154 | 155 | constructor (accessory, resource, params = {}) { 156 | params.Service = accessory.Services.hap.AirQualitySensor 157 | super(accessory, resource, params) 158 | this.resources = {} 159 | 160 | AirQuality.addResource(this, resource) 161 | 162 | super.addCharacteristicDelegates({ noLastUpdated: true }) 163 | 164 | this.update(resource.body, resource.rpath) 165 | } 166 | 167 | updateState (state, rpath) { 168 | AirQuality.updateResourceState(this, state, rpath) 169 | super.updateState(state) 170 | } 171 | } 172 | 173 | DeconzService.AirQuality = AirQuality 174 | -------------------------------------------------------------------------------- /lib/DeconzService/Alarm.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzService/Alarm.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { DeconzService } from '../DeconzService/index.js' 7 | import '../DeconzService/SensorsResource.js' 8 | 9 | /** 10 | * @memberof DeconzService 11 | */ 12 | class Alarm extends DeconzService.SensorsResource { 13 | constructor (accessory, resource, params = {}) { 14 | params.Service = accessory.Services.my.Resource 15 | super(accessory, resource, params) 16 | 17 | this.addCharacteristicDelegate({ 18 | key: 'alarm', 19 | Characteristic: this.Characteristics.my.Alarm 20 | }) 21 | 22 | super.addCharacteristicDelegates(params) 23 | 24 | this.update(resource.body, resource.rpath) 25 | } 26 | 27 | updateState (state) { 28 | if (state.test) { 29 | if (this.testTimout) { 30 | clearTimeout(this.testTimeout) 31 | } 32 | this.test = true 33 | this.testTimout = setTimeout(() => { 34 | delete this.testTimeout 35 | this.test = false 36 | }, 5000) 37 | } 38 | if (state.alarm) { 39 | this.values.alarm = true 40 | } else if (this.test) { 41 | this.values.alarm = true 42 | } else { 43 | this.values.alarm = false 44 | } 45 | super.updateState(state) 46 | } 47 | } 48 | 49 | DeconzService.Alarm = Alarm 50 | -------------------------------------------------------------------------------- /lib/DeconzService/AlarmSystem.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzService/AlarmSystem.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { DeconzService } from '../DeconzService/index.js' 7 | 8 | let mapsInitialised = false 9 | const armModeMap = {} 10 | const armStateMap = {} 11 | const targetStateMap = {} 12 | 13 | function initMaps (currentState, targetState) { 14 | if (mapsInitialised) { 15 | return 16 | } 17 | armStateMap.disarmed = currentState.DISARMED 18 | armStateMap.armed_away = currentState.AWAY_ARM 19 | armStateMap.armed_stay = currentState.STAY_ARM 20 | armStateMap.armed_night = currentState.NIGHT_ARM 21 | armStateMap.in_alarm = currentState.ALARM_TRIGGERED 22 | armModeMap.disarmed = targetState.DISARM 23 | armModeMap.armed_away = targetState.AWAY_ARM 24 | armModeMap.armed_stay = targetState.STAY_ARM 25 | armModeMap.armed_night = targetState.NIGHT_ARM 26 | targetStateMap[targetState.DISARM] = 'disarm' 27 | targetStateMap[targetState.AWAY_ARM] = 'arm_away' 28 | targetStateMap[targetState.STAY_ARM] = 'arm_stay' 29 | targetStateMap[targetState.NIGHT_ARM] = 'arm_night' 30 | mapsInitialised = true 31 | } 32 | 33 | /** 34 | * @memberof DeconzService 35 | */ 36 | class AlarmSystem extends DeconzService { 37 | constructor (accessory, resource, params = {}) { 38 | params.Service = accessory.Services.hap.SecuritySystem 39 | super(accessory, resource, params) 40 | 41 | initMaps( 42 | this.Characteristics.hap.SecuritySystemCurrentState, 43 | this.Characteristics.hap.SecuritySystemTargetState 44 | ) 45 | this.addCharacteristicDelegate({ 46 | key: 'currentState', 47 | Characteristic: this.Characteristics.hap.SecuritySystemCurrentState 48 | }) 49 | this.addCharacteristicDelegate({ 50 | key: 'targetState', 51 | Characteristic: this.Characteristics.hap.SecuritySystemTargetState 52 | }).on('didSet', async (value, fromHomeKit) => { 53 | if (fromHomeKit) { 54 | await this.put(`/${targetStateMap[value]}`, { 55 | code0: this.values.pin 56 | }) 57 | } 58 | }) 59 | this.addCharacteristicDelegate({ 60 | key: 'alarmType', 61 | Characteristic: this.Characteristics.hap.SecuritySystemAlarmType 62 | }) 63 | this.addCharacteristicDelegate({ 64 | key: 'pin', 65 | value: '0000' 66 | }) 67 | 68 | this.update(resource.body, resource.rpath) 69 | } 70 | 71 | updateState (state) { 72 | if (armStateMap[state.armstate] != null) { 73 | this.values.currentState = armStateMap[state.armstate] 74 | } 75 | this.values.alarmType = state.armstate === 'in_alarm' 76 | ? 1 // this.Characteristics.hap.SecuritySystemAlarmType.UNKNOWN 77 | : 0 // this.Characteristics.hap.SecuritySystemAlarmType.NO_ALARM 78 | } 79 | 80 | updateConfig (config) { 81 | if (armModeMap[config.armmode] != null) { 82 | this.values.targetState = armModeMap[config.armmode] 83 | } 84 | } 85 | } 86 | 87 | DeconzService.AlarmSystem = AlarmSystem 88 | -------------------------------------------------------------------------------- /lib/DeconzService/Battery.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzService/Battery.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { ServiceDelegate } from 'homebridge-lib/ServiceDelegate' 7 | import 'homebridge-lib/ServiceDelegate/Battery' 8 | 9 | import { ApiClient } from 'hb-deconz-tools/ApiClient' 10 | 11 | import { DeconzService } from '../DeconzService/index.js' 12 | 13 | const { dateToString } = ApiClient 14 | 15 | /** 16 | * @memberof DeconzService 17 | */ 18 | class Battery extends ServiceDelegate.Battery { 19 | constructor (accessory, resource, params = {}) { 20 | const batteryParams = { 21 | name: accessory.name + ' Battery', 22 | exposeConfiguredName: true 23 | } 24 | const state = resource.body.state 25 | const config = resource.body.config 26 | if (state.battery != null) { 27 | batteryParams.batteryLevel = state.battery 28 | batteryParams.lowBatteryThreshold = 20 29 | if (state.charging != null) { 30 | batteryParams.chargingState = state.charging 31 | } 32 | } else if (config.battery != null) { 33 | batteryParams.batteryLevel = config.battery 34 | batteryParams.lowBatteryThreshold = 20 35 | } 36 | super(accessory, batteryParams) 37 | 38 | if (state.battery != null) { 39 | this.addCharacteristicDelegate({ 40 | key: 'lastUpdated', 41 | Characteristic: this.Characteristics.my.LastUpdated, 42 | silent: true 43 | }) 44 | } 45 | 46 | this.update(resource.body, resource.rpath) 47 | } 48 | 49 | update (body, rpath) { 50 | if (this.updating) { 51 | return 52 | } 53 | if (body.config != null) { 54 | this.updateConfig(body.config) 55 | } 56 | if (body.state != null) { 57 | this.updateState(body.state) 58 | } 59 | } 60 | 61 | updateState (state) { 62 | if (state.battery != null) { 63 | this.values.batteryLevel = state.battery 64 | if (state.charging != null) { 65 | this.values.chargingState = state.charging 66 | ? this.Characteristics.hap.ChargingState.CHARGING 67 | : this.Characteristics.hap.ChargingState.NOT_CHARGING 68 | } 69 | this.values.lastUpdated = dateToString(state.lastupdated) 70 | } 71 | } 72 | 73 | updateConfig (config) { 74 | if (config.battery != null) { 75 | this.values.batteryLevel = config.battery 76 | } 77 | } 78 | } 79 | 80 | DeconzService.Battery = Battery 81 | -------------------------------------------------------------------------------- /lib/DeconzService/Button.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzService/Button.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { ServiceDelegate } from 'homebridge-lib/ServiceDelegate' 7 | 8 | import { DeconzService } from './index.js' 9 | 10 | const deconzEvent = { 11 | PRESS: 0, 12 | HOLD: 1, 13 | SHORT_RELEASE: 2, 14 | LONG_RELEASE: 3, 15 | DOUBLE_PRESS: 4, 16 | TRIPLE_PRESS: 5, 17 | QUADRUPLE_PRESS: 6, 18 | SHAKE: 7, 19 | DROP: 8, 20 | TILT: 9 21 | } 22 | 23 | let homeKitEvent 24 | 25 | /** 26 | * @memberof DeconzService 27 | */ 28 | class Button extends ServiceDelegate { 29 | static get SINGLE () { return 0x01 } 30 | static get DOUBLE () { return 0x02 } 31 | static get LONG () { return 0x04 } 32 | 33 | props (bitmap) { 34 | if (homeKitEvent == null) { 35 | homeKitEvent = { 36 | SINGLE_PRESS: this.Characteristics.hap.ProgrammableSwitchEvent.SINGLE_PRESS, 37 | DOUBLE_PRESS: this.Characteristics.hap.ProgrammableSwitchEvent.DOUBLE_PRESS, 38 | LONG_PRESS: this.Characteristics.hap.ProgrammableSwitchEvent.LONG_PRESS 39 | } 40 | } 41 | 42 | switch (bitmap) { 43 | case Button.SINGLE: 44 | return { 45 | minValue: homeKitEvent.SINGLE_PRESS, 46 | maxValue: homeKitEvent.SINGLE_PRESS, 47 | validValues: [ 48 | homeKitEvent.SINGLE_PRESS 49 | ] 50 | } 51 | case Button.SINGLE | Button.DOUBLE: 52 | return { 53 | minValue: homeKitEvent.SINGLE_PRESS, 54 | maxValue: homeKitEvent.DOUBLE_PRESS, 55 | validValues: [ 56 | homeKitEvent.SINGLE_PRESS, 57 | homeKitEvent.DOUBLE_PRESS 58 | ] 59 | } 60 | case Button.SINGLE | Button.LONG: 61 | return { 62 | minValue: homeKitEvent.SINGLE_PRESS, 63 | maxValue: homeKitEvent.LONG_PRESS, 64 | validValues: [ 65 | homeKitEvent.SINGLE_PRESS, 66 | homeKitEvent.LONG_PRESS 67 | ] 68 | } 69 | case Button.SINGLE | Button.DOUBLE | Button.LONG: 70 | return { 71 | minValue: homeKitEvent.SINGLE_PRESS, 72 | maxValue: homeKitEvent.LONG_PRESS, 73 | validValues: [ 74 | homeKitEvent.SINGLE_PRESS, 75 | homeKitEvent.DOUBLE_PRESS, 76 | homeKitEvent.LONG_PRESS 77 | ] 78 | } 79 | case Button.DOUBLE: 80 | return { 81 | minValue: homeKitEvent.DOUBLE_PRESS, 82 | maxValue: homeKitEvent.DOUBLE_PRESS, 83 | validValues: [ 84 | homeKitEvent.DOUBLE_PRESS 85 | ] 86 | } 87 | case Button.DOUBLE | Button.LONG: 88 | return { 89 | minValue: homeKitEvent.DOUBLE_PRESS, 90 | maxValue: homeKitEvent.LONG_PRESS, 91 | validValues: [ 92 | homeKitEvent.DOUBLE_PRESS, 93 | homeKitEvent.LONG_PRESS 94 | ] 95 | } 96 | case Button.LONG: 97 | return { 98 | minValue: homeKitEvent.LONG_PRESS, 99 | maxValue: homeKitEvent.LONG_PRESS, 100 | validValues: [ 101 | homeKitEvent.LONG_PRESS 102 | ] 103 | } 104 | } 105 | } 106 | 107 | constructor (deconzAccessory, params = {}) { 108 | params.Service = deconzAccessory.Services.hap.StatelessProgrammableSwitch 109 | params.subtype = params.button 110 | params.exposeConfiguredName = true 111 | super(deconzAccessory, params) 112 | this.button = params.button 113 | 114 | this.addCharacteristicDelegate({ 115 | key: 'event', 116 | Characteristic: this.Characteristics.hap.ProgrammableSwitchEvent, 117 | props: this.props(params.events) 118 | }) 119 | 120 | this.addCharacteristicDelegate({ 121 | key: 'index', 122 | Characteristic: this.Characteristics.hap.ServiceLabelIndex, 123 | value: params.button 124 | }) 125 | } 126 | 127 | homeKitValue (value, oldValue = 0, repeat = false) { 128 | const button = Math.floor(value / 1000) 129 | if (button !== this.button) { 130 | return null 131 | } 132 | const oldButton = Math.floor(oldValue / 1000) 133 | const event = value % 1000 134 | const oldEvent = oldValue % 1000 135 | switch (event) { 136 | case deconzEvent.PRESS: 137 | // Wait for Hold or Release after press. 138 | return null 139 | case deconzEvent.SHORT_RELEASE: 140 | return homeKitEvent.SINGLE_PRESS 141 | case deconzEvent.HOLD: 142 | if (repeat) { 143 | return homeKitEvent.SINGLE_PRESS 144 | } 145 | // falls through 146 | case deconzEvent.LONG_RELEASE: 147 | if (repeat || (button === oldButton && oldEvent === deconzEvent.HOLD)) { 148 | // Already issued action on previous Hold. 149 | return null 150 | } 151 | // falls through 152 | case deconzEvent.TRIPLE_PRESS: 153 | case deconzEvent.QUADRUPLE_PRESS: 154 | case deconzEvent.SHAKE: 155 | return homeKitEvent.LONG_PRESS 156 | case deconzEvent.DOUBLE_PRESS: 157 | case deconzEvent.DROP: 158 | return homeKitEvent.DOUBLE_PRESS 159 | case deconzEvent.TILT: 160 | default: 161 | return null 162 | } 163 | } 164 | 165 | update (value, oldValue, repeat) { 166 | const event = this.homeKitValue(value, oldValue, repeat) 167 | if (event !== null) { 168 | this.values.event = event 169 | } 170 | } 171 | 172 | updateRotation () { 173 | this.values.event = homeKitEvent.SINGLE_PRESS 174 | } 175 | } 176 | 177 | DeconzService.Button = Button 178 | -------------------------------------------------------------------------------- /lib/DeconzService/CarbonMonoxide.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzService/CarbonMonoxide.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { DeconzService } from '../DeconzService/index.js' 7 | import '../DeconzService/SensorsResource.js' 8 | 9 | /** 10 | * @memberof DeconzService 11 | */ 12 | class CarbonMonoxide extends DeconzService.SensorsResource { 13 | constructor (accessory, resource, params = {}) { 14 | params.Service = accessory.Services.hap.CarbonMonoxideSensor 15 | super(accessory, resource, params) 16 | 17 | this.addCharacteristicDelegate({ 18 | key: 'carbonmonoxide', 19 | Characteristic: this.Characteristics.hap.CarbonMonoxideDetected 20 | }) 21 | 22 | super.addCharacteristicDelegates(params) 23 | 24 | this.update(resource.body, resource.rpath) 25 | } 26 | 27 | updateState (state) { 28 | if (state.test) { 29 | if (this.testTimout) { 30 | clearTimeout(this.testTimeout) 31 | } 32 | this.test = true 33 | this.testTimout = setTimeout(() => { 34 | delete this.testTimeout 35 | this.test = false 36 | }, 5000) 37 | } 38 | if (state.carbonmonoxide) { 39 | this.values.carbonmonoxide = this.Characteristics.hap.CarbonMonoxideDetected.CO_LEVELS_ABNORMAL 40 | } else if (this.test) { 41 | this.values.carbonmonoxide = this.Characteristics.hap.CarbonMonoxideDetected.CO_LEVELS_ABNORMAL 42 | } else { 43 | this.values.carbonmonoxide = this.Characteristics.hap.CarbonMonoxideDetected.CO_LEVELS_NORMAL 44 | } 45 | super.updateState(state) 46 | } 47 | } 48 | 49 | DeconzService.CarbonMonoxide = CarbonMonoxide 50 | -------------------------------------------------------------------------------- /lib/DeconzService/Consumption.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzService/Consumption.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { ApiClient } from 'hb-deconz-tools/ApiClient' 7 | 8 | import { DeconzService } from '../DeconzService/index.js' 9 | import '../DeconzService/SensorsResource.js' 10 | 11 | const { dateToString } = ApiClient 12 | 13 | /** 14 | * @memberof DeconzService 15 | */ 16 | class Consumption extends DeconzService.SensorsResource { 17 | static addResource (service, resource) { 18 | service.addCharacteristicDelegate({ 19 | key: 'totalConsumption', 20 | Characteristic: service.Characteristics.eve.TotalConsumption, 21 | unit: ' kWh' 22 | }) 23 | 24 | if ( 25 | resource.body.state.power !== undefined && 26 | service.values.consumption === undefined 27 | ) { 28 | service.addCharacteristicDelegate({ 29 | key: 'consumption', 30 | Characteristic: service.Characteristics.eve.Consumption, 31 | unit: ' W' 32 | }) 33 | } 34 | 35 | if (service.values.lastUpdated === undefined) { 36 | service.addCharacteristicDelegate({ 37 | key: 'lastUpdated', 38 | Characteristic: service.Characteristics.my.LastUpdated, 39 | silent: true 40 | }) 41 | } 42 | 43 | Consumption.updateResourceState(service, resource.body.state) 44 | } 45 | 46 | static updateResourceState (service, state) { 47 | if (state.consumption != null) { 48 | service.values.totalConsumption = state.consumption / 1000 49 | } 50 | if (state.power != null) { 51 | service.values.consumption = state.power 52 | } 53 | if (state.lastupdated != null) { 54 | service.values.lastUpdated = dateToString(state.lastupdated) 55 | } 56 | } 57 | 58 | constructor (accessory, resource, params = {}) { 59 | params.name = accessory.name + ' Consumption' 60 | params.Service = accessory.Services.eve.Consumption 61 | params.exposeConfiguredName = true 62 | super(accessory, resource, params) 63 | 64 | Consumption.addResource(this, resource) 65 | 66 | super.addCharacteristicDelegates({ noLastUpdated: true }) 67 | 68 | this.update(resource.body, resource.rpath) 69 | } 70 | 71 | updateState (state) { 72 | Consumption.updateResourceState(this, state) 73 | super.updateState(state) 74 | } 75 | } 76 | 77 | DeconzService.Consumption = Consumption 78 | -------------------------------------------------------------------------------- /lib/DeconzService/Contact.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzService/Contact.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { DeconzService } from '../DeconzService/index.js' 7 | import '../DeconzService/SensorsResource.js' 8 | 9 | /** 10 | * @memberof DeconzService 11 | */ 12 | class Contact extends DeconzService.SensorsResource { 13 | constructor (accessory, resource, params = {}) { 14 | params.Service = accessory.Services.hap.ContactSensor 15 | super(accessory, resource, params) 16 | 17 | this.addCharacteristicDelegate({ 18 | key: 'contact', 19 | Characteristic: this.Characteristics.hap.ContactSensorState 20 | }) 21 | 22 | // With _Status Tapered_ Eve thinks the _Door Sensor_ is an Eve Windows Guard 23 | // (instead of an Eve Door & Window) and won't display it. 24 | this.addCharacteristicDelegates({ noTampered: true }) 25 | 26 | this.update(resource.body, resource.rpath) 27 | } 28 | 29 | updateState (state) { 30 | if (state.open != null) { 31 | this.values.contact = state.open 32 | ? this.Characteristics.hap.ContactSensorState.CONTACT_NOT_DETECTED 33 | : this.Characteristics.hap.ContactSensorState.CONTACT_DETECTED 34 | } 35 | super.updateState(state) 36 | } 37 | } 38 | 39 | DeconzService.Contact = Contact 40 | -------------------------------------------------------------------------------- /lib/DeconzService/Daylight.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzService/Daylight.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { ApiClient } from 'hb-deconz-tools/ApiClient' 7 | 8 | import { DeconzService } from '../DeconzService/index.js' 9 | import '../DeconzService/SensorsResource.js' 10 | 11 | const { dateToString } = ApiClient 12 | 13 | const daylightEvents = { 14 | 100: { name: 'Solar Midnight', period: 'Night' }, 15 | 110: { name: 'Astronomical Dawn', period: 'Astronomical Twilight' }, 16 | 120: { name: 'Nautical Dawn', period: 'Nautical Twilight' }, 17 | 130: { name: 'Dawn', period: 'Twilight' }, 18 | 140: { name: 'Sunrise', period: 'Sunrise' }, 19 | 150: { name: 'End Sunrise', period: 'Golden Hour' }, 20 | 160: { name: 'End Golden Hour', period: 'Day' }, 21 | 170: { name: 'Solar Noon', period: 'Day' }, 22 | 180: { name: 'Start Golden Hour', period: 'Golden Hour' }, 23 | 190: { name: 'Start Sunset', period: 'Sunset' }, 24 | 200: { name: 'Sunset', period: 'Twilight' }, 25 | 210: { name: 'Dusk', period: 'Nautical Twilight' }, 26 | 220: { name: 'Nautical Dusk', period: 'Astronomical Twilight' }, 27 | 230: { name: 'Astronomical Dusk', period: 'Night' } 28 | } 29 | 30 | // Eve uses the following thresholds: 31 | const VERY_BRIGHT = 1000 32 | const BRIGHT = 300 33 | const NORMAL = 100 34 | const DIM = 10 35 | const DARK = 0 36 | 37 | const daylightPeriods = { 38 | Night: { lightLevel: DARK, dark: true, daylight: false }, 39 | 'Astronomical Twilight': { lightLevel: DIM, dark: true, daylight: false }, 40 | 'Nautical Twilight': { lightLevel: DIM, dark: true, daylight: false }, 41 | Twilight: { lightLevel: NORMAL, dark: false, daylight: false }, 42 | Sunrise: { lightLevel: BRIGHT, dark: false, daylight: true }, 43 | Sunset: { lightLevel: BRIGHT, dark: false, daylight: true }, 44 | 'Golden Hour': { lightLevel: BRIGHT, dark: false, daylight: true }, 45 | Day: { lightLevel: VERY_BRIGHT, dark: false, daylight: true } 46 | } 47 | 48 | /** 49 | * @memberof DeconzService 50 | */ 51 | class Daylight extends DeconzService.SensorsResource { 52 | constructor (accessory, resource, params = {}) { 53 | params.Service = accessory.Services.hap.LightSensor 54 | super(accessory, resource, params) 55 | 56 | this.addCharacteristicDelegate({ 57 | key: 'lightLevel', 58 | Characteristic: this.Characteristics.hap.CurrentAmbientLightLevel, 59 | unit: ' lux' 60 | }) 61 | 62 | this.addCharacteristicDelegate({ 63 | key: 'dark', 64 | Characteristic: this.Characteristics.my.Dark 65 | }) 66 | 67 | this.addCharacteristicDelegate({ 68 | key: 'daylight', 69 | Characteristic: this.Characteristics.my.Daylight 70 | }) 71 | 72 | this.addCharacteristicDelegate({ 73 | key: 'status', 74 | Characteristic: this.Characteristics.my.Status, 75 | props: { 76 | minValue: 100, 77 | maxValue: 230, 78 | perms: [ 79 | this.Characteristic.Perms.PAIRED_READ, 80 | this.Characteristic.Perms.NOTIFY] 81 | }, 82 | value: resource.body.state.status 83 | }) 84 | 85 | this.addCharacteristicDelegate({ 86 | key: 'lastEvent', 87 | Characteristic: this.Characteristics.my.LastEvent 88 | }) 89 | 90 | this.addCharacteristicDelegate({ 91 | key: 'period', 92 | Characteristic: this.Characteristics.my.Period 93 | }) 94 | 95 | this.addCharacteristicDelegates() 96 | 97 | this.addCharacteristicDelegate({ 98 | key: 'sunrise', 99 | Characteristic: this.Characteristics.my.Sunrise 100 | }) 101 | 102 | this.addCharacteristicDelegate({ 103 | key: 'sunset', 104 | Characteristic: this.Characteristics.my.Sunset 105 | }) 106 | 107 | if (!resource.body.config.configured) { 108 | this.warn('%s: %s not configured', resource.rpath, resource.body.type) 109 | } 110 | 111 | this.update(resource.body, resource.rpath) 112 | } 113 | 114 | updateState (state) { 115 | if (state.status != null) { 116 | this.values.status = state.status 117 | const { name, period } = daylightEvents[state.status] 118 | this.values.lastEvent = name 119 | this.values.period = period 120 | const { lightLevel, dark, daylight } = daylightPeriods[period] 121 | this.values.lightLevel = lightLevel 122 | this.values.dark = dark 123 | this.values.daylight = daylight 124 | } 125 | if (state.sunrise != null) { 126 | this.values.sunrise = dateToString(state.sunrise) 127 | } 128 | if (state.sunset != null) { 129 | this.values.sunset = dateToString(state.sunset) 130 | } 131 | super.updateState(state) 132 | } 133 | } 134 | 135 | DeconzService.Daylight = Daylight 136 | -------------------------------------------------------------------------------- /lib/DeconzService/Flag.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzService/Flag.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { DeconzService } from '../DeconzService/index.js' 7 | import '../DeconzService/SensorsResource.js' 8 | 9 | /** 10 | * @memberof DeconzService 11 | */ 12 | class Flag extends DeconzService.SensorsResource { 13 | constructor (accessory, resource, params = {}) { 14 | params.Service = accessory.Services.hap.Switch 15 | super(accessory, resource, params) 16 | 17 | if (resource.capabilities.readonly) { 18 | this.addCharacteristicDelegate({ 19 | key: 'on', 20 | Characteristic: this.Characteristics.hap.On, 21 | props: { 22 | perms: [ 23 | this.Characteristic.Perms.PAIRED_READ, this.Characteristic.Perms.NOTIFY 24 | ] 25 | } 26 | }) 27 | } else { 28 | this.addCharacteristicDelegate({ 29 | key: 'on', 30 | Characteristic: this.Characteristics.hap.On 31 | }).on('didSet', async (value, fromHomeKit) => { 32 | if (fromHomeKit) { 33 | await this.put('/state', { flag: value }) 34 | } 35 | }) 36 | } 37 | 38 | this.addCharacteristicDelegates() 39 | 40 | this.update(resource.body, resource.rpath) 41 | } 42 | 43 | updateState (state) { 44 | if (state.flag != null) { 45 | this.values.on = state.flag 46 | } 47 | super.updateState(state) 48 | } 49 | } 50 | 51 | DeconzService.Flag = Flag 52 | -------------------------------------------------------------------------------- /lib/DeconzService/Gateway.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzService/Gateway.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { ServiceDelegate } from 'homebridge-lib/ServiceDelegate' 7 | 8 | import { DeconzService } from './index.js' 9 | 10 | /** Delegate class for a DeconzGateway service. 11 | * @extends ServiceDelegate 12 | * @memberof DeconzService 13 | */ 14 | class Gateway extends ServiceDelegate { 15 | constructor (gateway, params = {}) { 16 | params.Service = gateway.Services.my.DeconzGateway 17 | params.exposeConfiguredName = true 18 | super(gateway, params) 19 | this.gateway = gateway 20 | 21 | this.addCharacteristicDelegate({ 22 | key: 'lastUpdated', 23 | Characteristic: this.Characteristics.my.LastUpdated, 24 | silent: true 25 | }) 26 | 27 | this.addCharacteristicDelegate({ 28 | key: 'statusActive', 29 | Characteristic: this.Characteristics.hap.StatusActive, 30 | value: true, 31 | silent: true 32 | }) 33 | 34 | this.addCharacteristicDelegate({ 35 | key: 'search', 36 | Characteristic: this.Characteristics.my.Search, 37 | value: false 38 | }).on('didSet', (value, fromHomeKit) => { 39 | if (fromHomeKit) { 40 | this.gateway.values.search = value 41 | } 42 | }) 43 | 44 | this.addCharacteristicDelegate({ 45 | key: 'transitionTime', 46 | Characteristic: this.Characteristics.my.TransitionTime, 47 | value: this.gateway.defaultTransitionTime 48 | }) 49 | this.values.transitionTime = this.gateway.defaultTransitionTime 50 | } 51 | 52 | update (config) { 53 | this.values.expose = true 54 | this.values.lastUpdated = new Date().toString().slice(0, 24) 55 | } 56 | } 57 | 58 | DeconzService.Gateway = Gateway 59 | -------------------------------------------------------------------------------- /lib/DeconzService/Humidity.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzService/Humidity.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { DeconzService } from '../DeconzService/index.js' 7 | import '../DeconzService/SensorsResource.js' 8 | 9 | /** 10 | * @memberof DeconzService 11 | */ 12 | class Humidity extends DeconzService.SensorsResource { 13 | constructor (accessory, resource, params = {}) { 14 | params.Service = accessory.Services.hap.HumiditySensor 15 | super(accessory, resource, params) 16 | 17 | this.addCharacteristicDelegate({ 18 | key: 'humidity', 19 | Characteristic: this.Characteristics.hap.CurrentRelativeHumidity, 20 | unit: '%' 21 | }) 22 | 23 | this.addCharacteristicDelegates() 24 | 25 | this.update(resource.body, resource.rpath) 26 | } 27 | 28 | updateState (state) { 29 | if (state.measured_value != null) { 30 | this.values.humidity = Math.round(state.measured_value * 10) / 10 31 | } else if (state.humidity != null) { 32 | this.values.humidity = Math.round(state.humidity / 10) / 10 33 | } else if (state.moisture != null) { 34 | this.values.humidity = Math.round(state.moisture / 10) / 10 35 | } 36 | super.updateState(state) 37 | } 38 | } 39 | 40 | DeconzService.Humidity = Humidity 41 | -------------------------------------------------------------------------------- /lib/DeconzService/Label.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzService/Label.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { DeconzService } from '../DeconzService/index.js' 7 | import '../DeconzService/SensorsResource.js' 8 | 9 | /** 10 | * @memberof DeconzService 11 | */ 12 | class Label extends DeconzService.SensorsResource { 13 | constructor (accessory, resource, params = {}) { 14 | params.Service = accessory.Services.hap.ServiceLabel 15 | super(accessory, resource, params) 16 | 17 | this.addCharacteristicDelegate({ 18 | key: 'namespace', 19 | Characteristic: this.Characteristics.hap.ServiceLabelNamespace, 20 | value: resource.capabilities.namespace 21 | }) 22 | 23 | this.addCharacteristicDelegates({ noLastUpdated: true }) 24 | 25 | this.buttonResources = {} 26 | this.buttonServices = {} 27 | this.hasRepeat = false 28 | } 29 | 30 | createButtonServices (resource) { 31 | if (resource.body.type.endsWith('Switch')) { 32 | this.buttonResources[resource.rpath] = { 33 | buttonEvent: resource.body.state.buttonevent, 34 | lastUpdated: new Date(), 35 | toButtonEvent: resource.capabilities.toButtonEvent 36 | } 37 | } else if (resource.body.type.endsWith('RelativeRotary')) { 38 | const keys = Object.keys(resource.capabilities.buttons) 39 | this.buttonResources[resource.rpath] = { 40 | expectedRotation: resource.body.state.expectedrotation, 41 | lastUpdated: new Date(), 42 | right: keys[0], 43 | left: keys[1] 44 | } 45 | } else if (resource.body.type.endsWith('AncillaryControl')) { 46 | this.buttonResources[resource.rpath] = { 47 | buttonEvent: resource.body.state.action, 48 | lastUpdated: new Date(), 49 | toButtonEvent: resource.capabilities.toButtonEvent 50 | } 51 | } 52 | for (const i in resource.capabilities.buttons) { 53 | const { label, events, hasRepeat } = resource.capabilities.buttons[i] 54 | if (hasRepeat) { 55 | this.hasRepeat = hasRepeat 56 | } 57 | this.buttonServices[i] = new DeconzService.Button(this.accessoryDelegate, { 58 | name: this.name + ' ' + label, 59 | button: Number(i), 60 | events, 61 | hasRepeat 62 | }) 63 | } 64 | } 65 | 66 | updateState (state, rpath) { 67 | const buttonResource = this.buttonResources[rpath] 68 | if (buttonResource == null) { 69 | this.log('%s: resource deleted', rpath) 70 | this.gateway.reExposeAccessory(this.accessory.id) 71 | return 72 | } 73 | const lastUpdated = new Date(state.lastupdated + 'Z') 74 | if (lastUpdated > buttonResource.lastUpdated) { 75 | buttonResource.lastUpdated = lastUpdated 76 | if (buttonResource.buttonEvent !== undefined) { 77 | const oldValue = buttonResource.buttonEvent 78 | if (state.buttonevent != null) { 79 | buttonResource.buttonEvent = buttonResource.toButtonEvent == null 80 | ? state.buttonevent 81 | : buttonResource.toButtonEvent(state.buttonevent) 82 | } else if (state.action != null) { 83 | buttonResource.buttonEvent = buttonResource.toButtonEvent(state.action) 84 | } 85 | // TODO handle repeat 86 | const i = Math.floor(buttonResource.buttonEvent / 1000) 87 | this.buttonServices[i]?.update( 88 | buttonResource.buttonEvent, oldValue, 89 | false // this.accessoryDelegate.settingsService.values.repeat 90 | ) 91 | } else { 92 | if (state.expectedrotation != null) { 93 | buttonResource.expectedRotation = state.expectedrotation 94 | } 95 | const i = buttonResource.expectedRotation >= 0 96 | ? buttonResource.right 97 | : buttonResource.left 98 | this.buttonServices[i]?.updateRotation() 99 | } 100 | } 101 | super.updateState(state) 102 | } 103 | 104 | updateConfig (config) { 105 | // TODO handle change in devicemode 106 | super.updateConfig(config) 107 | } 108 | 109 | async identify () { 110 | this.debug('hasRepeat: %j', this.hasRepeat) 111 | return super.identify() 112 | } 113 | } 114 | 115 | DeconzService.Label = Label 116 | -------------------------------------------------------------------------------- /lib/DeconzService/Leak.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzService/Leak.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { DeconzService } from '../DeconzService/index.js' 7 | import '../DeconzService/SensorsResource.js' 8 | 9 | /** 10 | * @memberof DeconzService 11 | */ 12 | class Leak extends DeconzService.SensorsResource { 13 | constructor (accessory, resource, params = {}) { 14 | params.Service = accessory.Services.hap.LeakSensor 15 | super(accessory, resource, params) 16 | 17 | this.addCharacteristicDelegate({ 18 | key: 'leak', 19 | Characteristic: this.Characteristics.hap.LeakDetected 20 | }) 21 | 22 | super.addCharacteristicDelegates() 23 | 24 | this.update(resource.body, resource.rpath) 25 | } 26 | 27 | updateState (state) { 28 | if (state.test) { 29 | if (this.testTimout) { 30 | clearTimeout(this.testTimeout) 31 | } 32 | this.test = true 33 | this.testTimout = setTimeout(() => { 34 | delete this.testTimeout 35 | this.test = false 36 | }, 5000) 37 | } 38 | if (state.water) { 39 | this.values.leak = this.Characteristics.hap.LeakDetected.LEAK_DETECTED 40 | } else if (this.test) { 41 | this.values.leak = this.Characteristics.hap.LeakDetected.LEAK_DETECTED 42 | } else { 43 | this.values.leak = this.Characteristics.hap.LeakDetected.LEAK_NOT_DETECTED 44 | } 45 | super.updateState(state) 46 | } 47 | } 48 | 49 | DeconzService.Leak = Leak 50 | -------------------------------------------------------------------------------- /lib/DeconzService/LightLevel.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzService/LightLevel.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { ApiClient } from 'hb-deconz-tools/ApiClient' 7 | 8 | import { DeconzService } from '../DeconzService/index.js' 9 | import '../DeconzService/SensorsResource.js' 10 | 11 | const { lightLevelToLux } = ApiClient 12 | 13 | /** 14 | * @memberof DeconzService 15 | */ 16 | class LightLevel extends DeconzService.SensorsResource { 17 | constructor (accessory, resource, params = {}) { 18 | params.Service = accessory.Services.hap.LightSensor 19 | super(accessory, resource, params) 20 | 21 | this.addCharacteristicDelegate({ 22 | key: 'lightLevel', 23 | Characteristic: this.Characteristics.hap.CurrentAmbientLightLevel, 24 | unit: ' lux' 25 | }) 26 | 27 | this.addCharacteristicDelegate({ 28 | key: 'dark', 29 | Characteristic: this.Characteristics.my.Dark 30 | }) 31 | 32 | this.addCharacteristicDelegate({ 33 | key: 'daylight', 34 | Characteristic: this.Characteristics.my.Daylight 35 | }) 36 | 37 | this.addCharacteristicDelegates() 38 | 39 | this.update(resource.body, resource.rpath) 40 | } 41 | 42 | updateState (state) { 43 | if (state.lightlevel != null) { 44 | this.values.lightLevel = lightLevelToLux(state.lightlevel) 45 | } 46 | if (state.dark != null) { 47 | this.values.dark = state.dark 48 | } 49 | if (state.daylight != null) { 50 | this.values.daylight = state.daylight 51 | } 52 | super.updateState(state) 53 | } 54 | } 55 | 56 | DeconzService.LightLevel = LightLevel 57 | -------------------------------------------------------------------------------- /lib/DeconzService/LightsResource.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzService/LightsResource.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { timeout } from 'homebridge-lib' 7 | 8 | import { DeconzService } from '../DeconzService/index.js' 9 | 10 | class LightsResource extends DeconzService { 11 | constructor (accessory, resource, params) { 12 | super(accessory, resource, params) 13 | this.stateKey = resource.rtype === 'groups' ? 'action' : 'state' 14 | this.statePath = '/' + this.stateKey 15 | 16 | this.updating = 0 17 | this.targetState = {} 18 | } 19 | 20 | addCharacteristicDelegates (params = {}) { 21 | if (this.resource.rtype !== 'groups') { 22 | this.addCharacteristicDelegate({ 23 | key: 'lastSeen', 24 | Characteristic: this.Characteristics.my.LastSeen, 25 | silent: true 26 | }) 27 | 28 | this.addCharacteristicDelegate({ 29 | key: 'statusFault', 30 | Characteristic: this.Characteristics.hap.StatusFault 31 | }) 32 | } 33 | } 34 | 35 | updateState (state) { 36 | if (state.reachable != null) { 37 | this.values.statusFault = state.reachable 38 | ? this.Characteristics.hap.StatusFault.NO_FAULT 39 | : this.Characteristics.hap.StatusFault.GENERAL_FAULT 40 | } 41 | } 42 | 43 | updateConfig (config) { 44 | } 45 | 46 | async identify () { 47 | if (this.resource.body?.capabilities?.alerts?.includes('breathe')) { 48 | await this.put(this.statePath, { alert: 'breathe' }) 49 | await timeout(1000) 50 | return this.put(this.statePath, { alert: 'finish' }) 51 | } 52 | return this.put(this.statePath, { alert: 'select' }) 53 | } 54 | 55 | // Collect state changes into a combined request. 56 | async putState (state) { 57 | for (const key in state) { 58 | this.resource.body[this.stateKey][key] = state[key] 59 | this.targetState[key] = state[key] 60 | } 61 | return this._putState() 62 | } 63 | 64 | // Send the request (for the combined state changes) to the gateway. 65 | async _putState () { 66 | try { 67 | if (this.platform.config.waitTimeUpdate > 0) { 68 | this.updating++ 69 | await timeout(this.platform.config.waitTimeUpdate) 70 | if (--this.updating > 0) { 71 | return 72 | } 73 | } 74 | const targetState = this.targetState 75 | this.targetState = {} 76 | if ( 77 | this.gateway.transitionTime !== this.gateway.defaultTransitionTime && 78 | targetState.transitiontime === undefined 79 | ) { 80 | targetState.transitiontime = this.gateway.transitionTime * 10 81 | this.gateway.resetTransitionTime() 82 | } 83 | if (this.resource.body?.capabilities?.transition_block) { 84 | if ( 85 | ( 86 | targetState.on != null || targetState.bri != null || 87 | targetState.bri_inc != null 88 | ) && ( 89 | targetState.xy != null || targetState.ct != null || 90 | targetState.hue != null || targetState.sat != null || 91 | targetState.effect != null 92 | ) 93 | ) { 94 | targetState.transitiontime = 0 95 | } 96 | } 97 | await this.put(this.statePath, targetState) 98 | this.recentlyUpdated = true 99 | await timeout(500) 100 | this.recentlyUpdated = false 101 | } catch (error) { this.warn(error) } 102 | } 103 | } 104 | 105 | DeconzService.LightsResource = LightsResource 106 | -------------------------------------------------------------------------------- /lib/DeconzService/Motion.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzService/Motion.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { DeconzService } from '../DeconzService/index.js' 7 | import '../DeconzService/SensorsResource.js' 8 | 9 | /** 10 | * @memberof DeconzService 11 | */ 12 | class Motion extends DeconzService.SensorsResource { 13 | constructor (accessory, resource, params = {}) { 14 | params.Service = accessory.Services.hap.MotionSensor 15 | super(accessory, resource, params) 16 | 17 | this.durationKey = resource.body.config.delay != null ? 'delay' : 'duration' 18 | this.sensitivitymax = resource.body.config.sensitivitymax 19 | 20 | this.addCharacteristicDelegate({ 21 | key: 'motion', 22 | Characteristic: this.Characteristics.hap.MotionDetected, 23 | value: false 24 | }) 25 | 26 | if (resource.body.state.distance !== undefined) { 27 | this.addCharacteristicDelegate({ 28 | key: 'distance', 29 | Characteristic: this.Characteristics.my.Distance, 30 | unit: ' cm', 31 | value: 0 32 | }) 33 | } 34 | 35 | this.addCharacteristicDelegate({ 36 | key: 'sensitivity', 37 | Characteristic: this.Characteristics.eve.Sensitivity 38 | }).on('didSet', async (value, fromHomeKit) => { 39 | if (fromHomeKit) { 40 | const sensitivity = value === this.Characteristics.eve.Sensitivity.HIGH 41 | ? this.sensitivitymax 42 | : value === this.Characteristics.eve.Sensitivity.LOW 43 | ? 0 44 | : Math.round(this.sensitivitymax / 2) 45 | await this.put('/config', { sensitivity }) 46 | } 47 | }) 48 | 49 | this.addCharacteristicDelegate({ 50 | key: 'duration', 51 | Characteristic: this.Characteristics.eve.Duration, 52 | unit: 's' 53 | }).on('didSet', async (value, fromHomeKit) => { 54 | if (fromHomeKit) { 55 | const config = {} 56 | config[this.durationKey] = 57 | value === this.Characteristics.eve.Duration.VALID_VALUES[0] ? 0 : value 58 | await this.put('/config', config) 59 | } 60 | }) 61 | 62 | if (resource.body.config.detectionrange !== undefined) { 63 | this.addCharacteristicDelegate({ 64 | key: 'detectionRange', 65 | Characteristic: this.Characteristics.my.DetectionRange, 66 | unit: ' cm', 67 | value: 600 68 | }).on('didSet', async (value, fromHomeKit) => { 69 | if (fromHomeKit) { 70 | const detectionrange = Math.max(0, Math.min(value, 600)) 71 | await this.putConfig({ detectionrange }) 72 | } 73 | }) 74 | } 75 | 76 | if (resource.body.config.resetpresence !== undefined) { 77 | this.addCharacteristicDelegate({ 78 | key: 'reset', 79 | Characteristic: this.Characteristics.my.Reset, 80 | value: false 81 | }).on('didSet', async (value, fromHomeKit) => { 82 | if (fromHomeKit) { 83 | await this.put('/config', { resetpresence: value }) 84 | } 85 | }) 86 | } 87 | 88 | this.addCharacteristicDelegates() 89 | 90 | this.update(resource.body, resource.rpath) 91 | } 92 | 93 | updateState (state) { 94 | if (state.presence != null) { 95 | this.values.motion = state.presence 96 | } 97 | if (state.distance != null) { 98 | this.values.distance = state.distance 99 | } 100 | if (state.vibration != null) { 101 | this.values.motion = state.vibration 102 | } 103 | super.updateState(state) 104 | } 105 | 106 | updateConfig (config) { 107 | if (config[this.durationKey] != null) { 108 | let duration 109 | for (const value of this.Characteristics.eve.Duration.VALID_VALUES) { 110 | duration = value 111 | if (config[this.durationKey] <= value) { 112 | break 113 | } 114 | } 115 | this.values.duration = duration 116 | } 117 | if (config.sensitivity != null) { 118 | this.values.sensitivity = config.sensitivity === this.sensitivitymax 119 | ? this.Characteristics.eve.Sensitivity.HIGH 120 | : config.sensitivity === 0 121 | ? this.Characteristics.eve.Sensitivity.LOW 122 | : this.Characteristics.eve.Sensitivity.MEDIUM 123 | } 124 | if (config.detectionrange != null) { 125 | this.values.detectionRange = config.detectionrange 126 | } 127 | if (config.resetpresence != null) { 128 | this.values.reset = config.resetpresence 129 | } 130 | super.updateConfig(config) 131 | } 132 | } 133 | 134 | DeconzService.Motion = Motion 135 | -------------------------------------------------------------------------------- /lib/DeconzService/Outlet.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzService/Outlet.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { DeconzService } from '../DeconzService/index.js' 7 | import '../DeconzService/LightsResource.js' 8 | 9 | class Outlet extends DeconzService.LightsResource { 10 | constructor (accessory, resource, params = {}) { 11 | params.Service = accessory.Services.hap.Outlet 12 | super(accessory, resource, params) 13 | 14 | this.addCharacteristicDelegate({ 15 | key: 'on', 16 | Characteristic: this.Characteristics.hap.On, 17 | value: this.capabilities.on 18 | ? this.resource.body.state.on 19 | : this.resource.body.state.all_on 20 | }).on('didSet', (value, fromHomeKit) => { 21 | if (fromHomeKit) { 22 | this.putState({ on: value }) 23 | } 24 | }) 25 | 26 | if (this.resource.body.state.on === undefined) { 27 | this.addCharacteristicDelegate({ 28 | key: 'anyOn', 29 | Characteristic: this.Characteristics.my.AnyOn, 30 | value: this.resource.body.state.any_on 31 | }).on('didSet', (value, fromHomeKit) => { 32 | if (fromHomeKit) { 33 | this.putState({ on: value }) 34 | } 35 | }) 36 | } 37 | 38 | this.addCharacteristicDelegate({ 39 | key: 'outletInUse', 40 | Characteristic: this.Characteristics.hap.OutletInUse, 41 | value: 1 // Eve interpretes OutletInUse as: device is physically plugged in. 42 | }) 43 | 44 | if (this.resource.rtype === 'lights') { 45 | this.addCharacteristicDelegate({ 46 | key: 'wallSwitch', 47 | value: false 48 | }) 49 | } 50 | 51 | this.addCharacteristicDelegates() 52 | } 53 | 54 | updateState (state) { 55 | for (const key in state) { 56 | const value = state[key] 57 | this.resource.body.state[key] = value 58 | switch (key) { 59 | case 'all_on': 60 | this.values.on = value 61 | break 62 | case 'any_on': 63 | this.values.anyOn = value 64 | break 65 | case 'on': 66 | if (this.values.wallSwitch && !state.reachable) { 67 | if (this.values.on) { 68 | this.log('not reachable: force On to false') 69 | } 70 | this.values.on = false 71 | break 72 | } 73 | this.values.on = value 74 | break 75 | default: 76 | break 77 | } 78 | } 79 | super.updateState(state) 80 | } 81 | } 82 | 83 | DeconzService.Outlet = Outlet 84 | -------------------------------------------------------------------------------- /lib/DeconzService/Power.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzService/Power.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { ApiClient } from 'hb-deconz-tools/ApiClient' 7 | 8 | import { DeconzService } from '../DeconzService/index.js' 9 | import '../DeconzService/SensorsResource.js' 10 | 11 | const { dateToString } = ApiClient 12 | 13 | /** 14 | * @memberof DeconzService 15 | */ 16 | class Power extends DeconzService.SensorsResource { 17 | static addResource (service, resource) { 18 | if (service.values.consumption === undefined) { 19 | service.addCharacteristicDelegate({ 20 | key: 'consumption', 21 | Characteristic: service.Characteristics.eve.Consumption, 22 | unit: ' W' 23 | }) 24 | } 25 | 26 | if (resource.body.state.current !== undefined) { 27 | service.addCharacteristicDelegate({ 28 | key: 'electricCurrent', 29 | Characteristic: service.Characteristics.eve.ElectricCurrent, 30 | unit: ' A' 31 | }) 32 | } 33 | 34 | if (resource.body.state.voltage !== undefined) { 35 | service.addCharacteristicDelegate({ 36 | key: 'voltage', 37 | Characteristic: service.Characteristics.eve.Voltage, 38 | unit: ' V' 39 | }) 40 | } 41 | 42 | if (service.values.lastUpdated === undefined) { 43 | service.addCharacteristicDelegate({ 44 | key: 'lastUpdated', 45 | Characteristic: service.Characteristics.my.LastUpdated, 46 | silent: true 47 | }) 48 | } 49 | 50 | Power.updateResourceState(service, resource.body.state) 51 | } 52 | 53 | static updateResourceState (service, state) { 54 | if (state.power != null) { 55 | service.values.consumption = state.power 56 | } 57 | if (state.current != null) { 58 | service.values.electricCurrent = state.current / 1000 59 | } 60 | if (state.voltage != null) { 61 | service.values.voltage = state.voltage 62 | } 63 | if (state.lastupdated != null) { 64 | service.values.lastUpdated = dateToString(state.lastupdated) 65 | } 66 | } 67 | 68 | constructor (accessory, resource, params = {}) { 69 | params.name = accessory.name + ' Consumption' 70 | params.Service = accessory.Services.eve.Consumption 71 | params.exposeConfiguredName = true 72 | super(accessory, resource, params) 73 | 74 | Power.addResource(this, resource) 75 | 76 | super.addCharacteristicDelegates({ noLastUpdated: true }) 77 | 78 | this.update(resource.body, resource.rpath) 79 | } 80 | 81 | updateState (state) { 82 | Power.updateResourceState(this, state) 83 | super.updateState(state) 84 | } 85 | } 86 | 87 | DeconzService.Power = Power 88 | -------------------------------------------------------------------------------- /lib/DeconzService/Schedule.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzService/Schedule.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { ServiceDelegate } from 'homebridge-lib/ServiceDelegate' 7 | 8 | import { ApiClient } from 'hb-deconz-tools/ApiClient' 9 | 10 | import { DeconzService } from '../DeconzService/index.js' 11 | 12 | const { HttpError } = ApiClient 13 | 14 | /** 15 | * @memberof DeconzService 16 | */ 17 | class Schedule extends ServiceDelegate { 18 | constructor (accessory, rid, body) { 19 | super(accessory, { 20 | id: accessory.gateway.id + '-T' + rid, 21 | name: body.name, 22 | Service: accessory.Services.my.Resource, 23 | subtype: 'T' + rid, 24 | exposeConfiguredName: true 25 | }) 26 | this.id = accessory.gateway.id + '-T' + rid 27 | this.gateway = accessory.gateway 28 | this.accessory = accessory 29 | this.client = accessory.client 30 | this.rtype = 'schedules' 31 | this.rid = rid 32 | this.rpath = '/' + this.rtype + '/' + this.rid 33 | 34 | this.addCharacteristicDelegate({ 35 | key: 'enabled', 36 | Characteristic: this.Characteristics.my.Enabled 37 | }).on('didSet', async (value, fromHomeKit) => { 38 | await this.put({ status: value ? 'enabled' : 'disabled' }) 39 | this.values.statusActive = value 40 | }) 41 | 42 | this.addCharacteristicDelegate({ 43 | key: 'statusActive', 44 | Characteristic: this.Characteristics.hap.StatusActive 45 | }) 46 | 47 | // this.addCharacteristicDelegate({ 48 | // key: 'index', 49 | // Characteristic: this.Characteristics.hap.ServiceLabelIndex, 50 | // value: rid 51 | // }) 52 | } 53 | 54 | update (body) { 55 | this.values.enabled = body.status === 'enabled' 56 | this.values.statusActive = this.values.enabled 57 | } 58 | 59 | async put (body) { 60 | try { 61 | await this.client.put(this.rpath, body) 62 | } catch (error) { 63 | if (!(error instanceof HttpError)) { 64 | this.warn(error) 65 | } 66 | } 67 | } 68 | } 69 | 70 | DeconzService.Schedule = Schedule 71 | -------------------------------------------------------------------------------- /lib/DeconzService/SensorsResource.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzService/SensorsResource.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { timeout } from 'homebridge-lib' 7 | 8 | import { ApiClient } from 'hb-deconz-tools/ApiClient' 9 | 10 | import { DeconzService } from '../DeconzService/index.js' 11 | 12 | const { dateToString } = ApiClient 13 | 14 | /** 15 | * @memberof DeconzService 16 | */ 17 | class SensorsResource extends DeconzService { 18 | constructor (accessory, resource, params) { 19 | super(accessory, resource, params) 20 | 21 | this.updating = 0 22 | this.targetConfig = {} 23 | } 24 | 25 | addCharacteristicDelegates (params = {}) { 26 | if (!params.noLastUpdated) { 27 | this.addCharacteristicDelegate({ 28 | key: 'lastUpdated', 29 | Characteristic: this.Characteristics.my.LastUpdated, 30 | silent: true 31 | }) 32 | } 33 | 34 | this.addCharacteristicDelegate({ 35 | key: 'enabled', 36 | Characteristic: this.Characteristics.my.Enabled 37 | }).on('didSet', async (value, fromHomeKit) => { 38 | this.values.statusActive = value 39 | if (fromHomeKit) { 40 | await this.put('/config', { on: value }) 41 | } 42 | }) 43 | 44 | this.addCharacteristicDelegate({ 45 | key: 'statusActive', 46 | Characteristic: this.Characteristics.hap.StatusActive 47 | }) 48 | 49 | if (this.resource.body.config.reachable !== undefined) { 50 | this.addCharacteristicDelegate({ 51 | key: 'statusFault', 52 | Characteristic: this.Characteristics.hap.StatusFault 53 | }) 54 | } 55 | 56 | if (this.resource.body.state.tampered !== undefined && !params.noTampered) { 57 | this.addCharacteristicDelegate({ 58 | key: 'tampered', 59 | Characteristic: this.Characteristics.hap.StatusTampered 60 | }) 61 | } 62 | } 63 | 64 | updateState (state) { 65 | if (state.lastupdated != null) { 66 | this.values.lastUpdated = dateToString(state.lastupdated) 67 | } 68 | if (state.tampered != null) { 69 | this.values.tampered = state.tampered 70 | ? this.Characteristics.hap.StatusTampered.TAMPERED 71 | : this.Characteristics.hap.StatusTampered.NOT_TAMPERED 72 | } 73 | } 74 | 75 | updateConfig (config) { 76 | if (config.on != null) { 77 | this.values.enabled = !!config.on 78 | } 79 | if (config.reachable != null) { 80 | this.values.statusFault = config.reachable 81 | ? this.Characteristics.hap.StatusFault.NO_FAULT 82 | : this.Characteristics.hap.StatusFault.GENERAL_FAULT 83 | } 84 | } 85 | 86 | async identify () { 87 | if (this.resource.body.config.alert) { 88 | return this.put('/config', { alert: 'select' }) 89 | } 90 | } 91 | 92 | // Collect config changes into a combined request. 93 | async putConfig (config) { 94 | for (const key in config) { 95 | this.resource.body.config[key] = config[key] 96 | this.targetConfig[key] = config[key] 97 | } 98 | return this._putConfig() 99 | } 100 | 101 | // Send the request (for the combined config changes) to the gateway. 102 | async _putConfig () { 103 | try { 104 | if (this.platform.config.waitTimeUpdate > 0) { 105 | this.updating++ 106 | await timeout(this.platform.config.waitTimeUpdate) 107 | if (--this.updating > 0) { 108 | return 109 | } 110 | } 111 | const targetConfig = this.targetConfig 112 | this.targetConfig = {} 113 | await this.put('/config', targetConfig) 114 | // this.recentlyUpdated = true 115 | // await timeout(500) 116 | // this.recentlyUpdated = false 117 | } catch (error) { this.warn(error) } 118 | } 119 | } 120 | 121 | DeconzService.SensorsResource = SensorsResource 122 | -------------------------------------------------------------------------------- /lib/DeconzService/Smoke.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzService/Smoke.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { DeconzService } from '../DeconzService/index.js' 7 | import '../DeconzService/SensorsResource.js' 8 | 9 | /** 10 | * @memberof DeconzService 11 | */ 12 | class Smoke extends DeconzService.SensorsResource { 13 | constructor (accessory, resource, params = {}) { 14 | params.Service = accessory.Services.hap.SmokeSensor 15 | super(accessory, resource, params) 16 | 17 | this.addCharacteristicDelegate({ 18 | key: 'smoke', 19 | Characteristic: this.Characteristics.hap.SmokeDetected 20 | }) 21 | 22 | this.addCharacteristicDelegate({ 23 | key: 'deviceStatus', 24 | Characteristic: this.Characteristics.eve.ElgatoDeviceStatus 25 | }) 26 | 27 | super.addCharacteristicDelegates(params) 28 | 29 | this.update(resource.body, resource.rpath) 30 | } 31 | 32 | updateState (state) { 33 | if (state.test) { 34 | if (this.testTimout) { 35 | clearTimeout(this.testTimeout) 36 | } 37 | this.test = true 38 | this.testTimout = setTimeout(() => { 39 | delete this.testTimeout 40 | this.test = false 41 | }, 5000) 42 | } 43 | let status = 0 44 | if (state.fire) { 45 | this.values.smoke = this.Characteristics.hap.SmokeDetected.SMOKE_DETECTED 46 | status |= this.Characteristics.eve.ElgatoDeviceStatus.SMOKE_DETECTED 47 | } else if (this.test) { 48 | this.values.smoke = this.Characteristics.hap.SmokeDetected.SMOKE_DETECTED 49 | status |= this.Characteristics.eve.ElgatoDeviceStatus.ALARM_TEST_ACTIVE 50 | } else { 51 | this.values.smoke = this.Characteristics.hap.SmokeDetected.SMOKE_NOT_DETECTED 52 | } 53 | this.values.deviceStatus = status 54 | super.updateState(state) 55 | } 56 | } 57 | 58 | DeconzService.Smoke = Smoke 59 | -------------------------------------------------------------------------------- /lib/DeconzService/Status.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzService/Status.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { DeconzService } from '../DeconzService/index.js' 7 | import '../DeconzService/SensorsResource.js' 8 | 9 | /** 10 | * @memberof DeconzService 11 | */ 12 | class Status extends DeconzService.SensorsResource { 13 | props (caps) { 14 | if (caps.min == null || caps.max == null) { 15 | return undefined 16 | } 17 | // Eve 3.1 displays the following controls, depending on the properties: 18 | // 1. {minValue: 0, maxValue: 1, minStep: 1} switch 19 | // 2. {minValue: a, maxValue: b, minStep: 1}, 1 < b - a <= 20 down|up 20 | // 3. {minValue: a, maxValue: b}, (a, b) != (0, 1) slider 21 | // 4. {minValue: a, maxValue: b, minStep: 1}, b - a > 20 slider 22 | // Avoid the following bugs: 23 | // 5. {minValue: 0, maxValue: 1} nothing 24 | // 6. {minValue: a, maxValue: b, minStep: 1}, b - a = 1 switch* 25 | // *) switch sends values 0 and 1 instead of a and b; 26 | if (caps.min === 0 && caps.max === 1) { 27 | // Workaround Eve bug (case 5 above). 28 | return { minValue: caps.min, maxValue: caps.max, minStep: 1 } 29 | } 30 | if (caps.max - caps.min === 1) { 31 | // Workaround Eve bug (case 6 above). 32 | return { minValue: caps.min, maxValue: caps.max } 33 | } 34 | return { minValue: caps.min, maxValue: caps.max, minStep: 1 } 35 | } 36 | 37 | constructor (accessory, resource, params = {}) { 38 | params.Service = accessory.Services.my.Status 39 | super(accessory, resource, params) 40 | 41 | if (resource.capabilities.readonly) { 42 | this.addCharacteristicDelegate({ 43 | key: 'status', 44 | Characteristic: this.Characteristics.my.Status, 45 | props: { 46 | perms: [ 47 | this.Characteristic.Perms.PAIRED_READ, this.Characteristic.Perms.NOTIFY 48 | ] 49 | } 50 | }) 51 | } else { 52 | this.addCharacteristicDelegate({ 53 | key: 'status', 54 | Characteristic: this.Characteristics.my.Status, 55 | props: this.props(resource.capabilities) 56 | }).on('didSet', async (value, fromHomeKit) => { 57 | if (fromHomeKit) { 58 | await this.put('/state', { status: value }) 59 | } 60 | }) 61 | } 62 | 63 | this.addCharacteristicDelegates() 64 | 65 | this.update(resource.body, resource.rpath) 66 | } 67 | 68 | updateState (state) { 69 | if (state.status != null) { 70 | this.values.status = state.status 71 | } 72 | super.updateState(state) 73 | } 74 | } 75 | 76 | DeconzService.Status = Status 77 | -------------------------------------------------------------------------------- /lib/DeconzService/Switch.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzService/Switch.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { DeconzService } from '../DeconzService/index.js' 7 | import '../DeconzService/LightsResource.js' 8 | 9 | class Switch extends DeconzService.LightsResource { 10 | constructor (accessory, resource, params = {}) { 11 | params.Service = accessory.Services.hap.Switch 12 | super(accessory, resource, params) 13 | 14 | this.addCharacteristicDelegate({ 15 | key: 'on', 16 | Characteristic: this.Characteristics.hap.On, 17 | value: this.capabilities.on 18 | ? this.resource.body.state.on 19 | : this.resource.body.state.all_on 20 | }).on('didSet', (value, fromHomeKit) => { 21 | if (fromHomeKit) { 22 | this.putState({ on: value }) 23 | } 24 | }) 25 | 26 | if (this.resource.body.state.on === undefined) { 27 | this.addCharacteristicDelegate({ 28 | key: 'anyOn', 29 | Characteristic: this.Characteristics.my.AnyOn, 30 | value: this.resource.body.state.any_on 31 | }).on('didSet', (value, fromHomeKit) => { 32 | if (fromHomeKit) { 33 | this.putState({ on: value }) 34 | } 35 | }) 36 | } 37 | 38 | if (this.resource.rtype === 'lights') { 39 | this.addCharacteristicDelegate({ 40 | key: 'wallSwitch', 41 | value: false 42 | }) 43 | } 44 | 45 | this.addCharacteristicDelegates() 46 | } 47 | 48 | updateState (state) { 49 | for (const key in state) { 50 | const value = state[key] 51 | this.resource.body.state[key] = value 52 | switch (key) { 53 | case 'all_on': 54 | this.values.on = value 55 | break 56 | case 'any_on': 57 | this.values.anyOn = value 58 | break 59 | case 'on': 60 | if (this.values.wallSwitch && !state.reachable) { 61 | if (this.values.on) { 62 | this.log('not reachable: force On to false') 63 | } 64 | this.values.on = false 65 | break 66 | } 67 | this.values.on = value 68 | break 69 | default: 70 | break 71 | } 72 | } 73 | super.updateState(state) 74 | } 75 | } 76 | 77 | DeconzService.Switch = Switch 78 | -------------------------------------------------------------------------------- /lib/DeconzService/Temperature.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzService/Temperature.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { DeconzService } from '../DeconzService/index.js' 7 | import '../DeconzService/SensorsResource.js' 8 | 9 | /** 10 | * @memberof DeconzService 11 | */ 12 | class Temperature extends DeconzService.SensorsResource { 13 | constructor (accessory, resource, params = {}) { 14 | params.Service = accessory.Services.hap.TemperatureSensor 15 | super(accessory, resource, params) 16 | 17 | this.addCharacteristicDelegate({ 18 | key: 'temperature', 19 | Characteristic: this.Characteristics.hap.CurrentTemperature, 20 | unit: '°C', 21 | props: { 22 | minValue: Math.round(resource.body.capabilities?.measured_value?.min ?? -40), 23 | maxValue: Math.round(resource.body.capabilities?.measured_value?.max ?? 100), 24 | minStep: 0.1 25 | }, 26 | value: 0 27 | }) 28 | 29 | this.addCharacteristicDelegate({ 30 | key: 'offset', 31 | Characteristic: this.Characteristics.my.Offset, 32 | unit: '°C', 33 | props: { minValue: -5, maxValue: 5, minStep: 0.1 }, 34 | value: 0 35 | }).on('didSet', async (value, fromHomeKit) => { 36 | if (fromHomeKit) { 37 | await this.put('/config', { offset: Math.round(value * 100) }) 38 | } 39 | }) 40 | 41 | this.addCharacteristicDelegate({ 42 | key: 'displayUnits', 43 | Characteristic: this.Characteristics.hap.TemperatureDisplayUnits, 44 | value: this.Characteristics.hap.TemperatureDisplayUnits.CELSIUS 45 | }) 46 | 47 | this.addCharacteristicDelegates() 48 | 49 | this.update(resource.body, resource.rpath) 50 | } 51 | 52 | updateState (state) { 53 | if (state.measured_value != null) { 54 | this.values.temperature = Math.round(state.measured_value * 10) / 10 55 | } else if (state.temperature != null) { 56 | this.values.temperature = Math.round(state.temperature / 10) / 10 57 | } 58 | super.updateState(state) 59 | } 60 | 61 | updateConfig (config) { 62 | if (config.offset != null) { 63 | this.values.offset = Math.round(config.offset / 10) / 10 64 | } 65 | super.updateConfig(config) 66 | } 67 | } 68 | 69 | DeconzService.Temperature = Temperature 70 | -------------------------------------------------------------------------------- /lib/DeconzService/Thermostat.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzService/Thermostat.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { DeconzService } from '../DeconzService/index.js' 7 | import '../DeconzService/SensorsResource.js' 8 | 9 | /** 10 | * @memberof DeconzService 11 | */ 12 | class Thermostat extends DeconzService.SensorsResource { 13 | constructor (accessory, resource, params = {}) { 14 | params.Service = accessory.Services.hap.Thermostat 15 | super(accessory, resource, params) 16 | 17 | this.addCharacteristicDelegate({ 18 | key: 'currentTemperature', 19 | Characteristic: this.Characteristics.hap.CurrentTemperature, 20 | unit: '°C', 21 | props: { minValue: -40, maxValue: 100, minStep: 0.1 }, 22 | value: 0 23 | }) 24 | 25 | this.addCharacteristicDelegate({ 26 | key: 'targetTemperature', 27 | Characteristic: this.Characteristics.hap.TargetTemperature, 28 | unit: '°C', 29 | props: { minValue: 5, maxValue: 30, minStep: 0.5 }, 30 | value: 0 31 | }).on('didSet', async (value, fromHomeKit) => { 32 | if (fromHomeKit) { 33 | await this.put('/config', { heatsetpoint: Math.round(value * 100) }) 34 | } 35 | }) 36 | 37 | if (resource.body.state.valve !== undefined) { 38 | this.addCharacteristicDelegate({ 39 | key: 'valvePosition', 40 | Characteristic: this.Characteristics.eve.ValvePosition, 41 | unit: '%' 42 | }) 43 | } 44 | 45 | this.addCharacteristicDelegate({ 46 | key: 'currentState', 47 | Characteristic: this.Characteristics.hap.CurrentHeatingCoolingState, 48 | props: { 49 | validValues: [ 50 | this.Characteristics.hap.CurrentHeatingCoolingState.OFF, 51 | this.Characteristics.hap.CurrentHeatingCoolingState.HEAT 52 | ] 53 | } 54 | }) 55 | 56 | this.addCharacteristicDelegate({ 57 | key: 'targetState', 58 | Characteristic: this.Characteristics.hap.TargetHeatingCoolingState, 59 | props: { 60 | validValues: [ 61 | this.Characteristics.hap.TargetHeatingCoolingState.OFF, 62 | this.Characteristics.hap.TargetHeatingCoolingState.HEAT 63 | ] 64 | } 65 | }).on('didSet', async (value, fromHomeKit) => { 66 | if (fromHomeKit) { 67 | await this.put('/config', { 68 | mode: value === this.Characteristics.hap.TargetHeatingCoolingState.OFF 69 | ? 'off' 70 | : this.capabilities.heatValue 71 | }) 72 | } 73 | }) 74 | 75 | this.addCharacteristicDelegate({ 76 | key: 'offset', 77 | Characteristic: this.Characteristics.my.Offset, 78 | unit: '°C', 79 | props: { minValue: -5, maxValue: 5, minStep: 0.1 }, 80 | value: 0 81 | }).on('didSet', async (value, fromHomeKit) => { 82 | if (fromHomeKit) { 83 | await this.put('/config', { offset: Math.round(value * 100) }) 84 | } 85 | }) 86 | 87 | this.addCharacteristicDelegate({ 88 | key: 'displayUnits', 89 | Characteristic: this.Characteristics.hap.TemperatureDisplayUnits, 90 | value: this.Characteristics.hap.TemperatureDisplayUnits.CELSIUS 91 | }) 92 | 93 | this.addCharacteristicDelegate({ 94 | key: 'programData', 95 | Characteristic: this.Characteristics.eve.ProgramData, 96 | silent: true, 97 | value: Buffer.from('ff04f6', 'hex').toString('base64') 98 | }) 99 | 100 | this.addCharacteristicDelegate({ 101 | key: 'programCommand', 102 | Characteristic: this.Characteristics.eve.ProgramCommand, 103 | silent: true 104 | }) 105 | 106 | if (resource.body.config.displayflipped !== undefined) { 107 | this.addCharacteristicDelegate({ 108 | key: 'imageMirroring', 109 | Characteristic: this.Characteristics.hap.ImageMirroring 110 | }).on('didSet', async (value, fromHomeKit) => { 111 | if (fromHomeKit) { 112 | await this.put('/config', { displayflipped: value }) 113 | } 114 | }) 115 | } 116 | if (resource.body.config.externalsensortemp !== undefined) { 117 | this.addCharacteristicDelegate({ 118 | key: 'useExternalTemperature', 119 | value: false, 120 | silent: true 121 | }) 122 | } 123 | 124 | if (resource.body.config.locked !== undefined) { 125 | this.addCharacteristicDelegate({ 126 | key: 'lockPhysicalControls', 127 | Characteristic: this.Characteristics.hap.LockPhysicalControls 128 | }).on('didSet', async (value, fromHomeKit) => { 129 | if (fromHomeKit) { 130 | await this.put('/config', { 131 | locked: value === this.Characteristics.hap.LockPhysicalControls 132 | .CONTROL_LOCK_ENABLED 133 | }) 134 | } 135 | }) 136 | } 137 | 138 | super.addCharacteristicDelegates() 139 | 140 | this.update(resource.body, resource.rpath) 141 | } 142 | 143 | updateState (state) { 144 | if (state.on != null) { 145 | this.values.currentState = state.on 146 | ? this.Characteristics.hap.CurrentHeatingCoolingState.HEAT 147 | : this.Characteristics.hap.CurrentHeatingCoolingState.OFF 148 | } 149 | if (!this.values.useExternalTemperature && state.temperature != null) { 150 | this.values.currentTemperature = Math.round(state.temperature / 10) / 10 151 | } 152 | if (state.valve != null) { 153 | this.values.valvePosition = state.valve 154 | } 155 | super.updateState(state) 156 | } 157 | 158 | updateConfig (config) { 159 | if (config.displayflipped != null) { 160 | this.values.imageMirroring = config.displayflipped 161 | } 162 | if (this.values.useExternalTemperature && config.externalsensortemp != null) { 163 | this.values.currentTemperature = Math.round(config.externalsensortemp / 10) / 10 164 | } 165 | if (config.heatsetpoint != null) { 166 | this.values.targetTemperature = Math.round(config.heatsetpoint / 50) / 2 167 | } 168 | if (config.locked != null) { 169 | this.values.lockPhysicalControls = config.locked 170 | ? this.Characteristics.hap.LockPhysicalControls.CONTROL_LOCK_ENABLED 171 | : this.Characteristics.hap.LockPhysicalControls.CONTROL_LOCK_DISABLED 172 | } 173 | if (config.mode != null) { 174 | this.values.targetState = config.mode === 'off' 175 | ? this.Characteristics.hap.TargetHeatingCoolingState.OFF 176 | : this.Characteristics.hap.TargetHeatingCoolingState.HEAT 177 | } 178 | if (config.offset != null) { 179 | this.values.offset = Math.round(config.offset / 10) / 10 180 | } 181 | super.updateConfig(config) 182 | } 183 | } 184 | 185 | DeconzService.Thermostat = Thermostat 186 | -------------------------------------------------------------------------------- /lib/DeconzService/Valve.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzService/Valve.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { DeconzService } from '../DeconzService/index.js' 7 | import '../DeconzService/LightsResource.js' 8 | 9 | class Valve extends DeconzService.LightsResource { 10 | constructor (accessory, resource, params = {}) { 11 | params.Service = accessory.Services.hap.Valve 12 | super(accessory, resource, params) 13 | 14 | this.addCharacteristicDelegate({ 15 | key: 'active', 16 | Characteristic: this.Characteristics.hap.Active, 17 | value: this.capabilities.on 18 | ? this.resource.body.state.on 19 | : this.resource.body.state.all_on 20 | }).on('didSet', async (value, fromHomeKit) => { 21 | try { 22 | if (fromHomeKit) { 23 | await this.put(this.statePath, { on: value === this.Characteristics.hap.Active.ACTIVE }) 24 | } 25 | if (this.values.active) { 26 | this.values.inUse = this.Characteristics.hap.InUse.IN_USE 27 | if (this.values.setDuration > 0) { 28 | this.values.remainingDuration = this.values.setDuration 29 | this.autoInActive = new Date().valueOf() + this.values.setDuration * 1000 30 | this.autoInActiveTimeout = setTimeout(async () => { 31 | try { 32 | await this.put(this.statePath, { on: false }) 33 | } catch (error) { this.warn(error) } 34 | }, this.values.setDuration * 1000) 35 | } 36 | } else { 37 | this.values.inUse = this.Characteristics.hap.InUse.NOT_IN_USE 38 | if (this.autoInActiveTimeout != null) { 39 | clearTimeout(this.autoInActiveTimeout) 40 | delete this.autoInActiveTimeout 41 | delete this.autoInActive 42 | this.values.remainingDuration = 0 43 | } 44 | } 45 | } catch (error) { this.warn(error) } 46 | }) 47 | 48 | this.addCharacteristicDelegate({ 49 | key: 'inUse', 50 | Characteristic: this.Characteristics.hap.InUse, 51 | value: this.Characteristics.hap.InUse.NOT_IN_USE 52 | }) 53 | 54 | this.addCharacteristicDelegate({ 55 | key: 'remainingDuration', 56 | Characteristic: this.Characteristics.hap.RemainingDuration, 57 | value: 0, 58 | props: { 59 | maxValue: 4 * 3600 60 | }, 61 | getter: async () => { 62 | const remaining = this.autoInActive - new Date().valueOf() 63 | return remaining > 0 ? Math.round(remaining / 1000) : 0 64 | } 65 | }) 66 | 67 | this.addCharacteristicDelegate({ 68 | key: 'setDuration', 69 | Characteristic: this.Characteristics.hap.SetDuration, 70 | value: 300, 71 | props: { 72 | maxValue: 4 * 3600 73 | } 74 | }) 75 | 76 | this.addCharacteristicDelegate({ 77 | key: 'valveType', 78 | Characteristic: this.Characteristics.hap.ValveType, 79 | value: this.Characteristics.hap.ValveType.GENERIC_VALVE 80 | }) 81 | 82 | if (this.resource.rtype === 'lights') { 83 | this.addCharacteristicDelegate({ 84 | key: 'wallSwitch', 85 | value: false 86 | }) 87 | } 88 | 89 | this.addCharacteristicDelegates() 90 | 91 | this.values.active = this.Characteristics.hap.Active.INACTIVE 92 | } 93 | 94 | updateState (state) { 95 | for (const key in state) { 96 | const value = state[key] 97 | this.resource.body.state[key] = value 98 | switch (key) { 99 | case 'on': 100 | if (this.values.wallSwitch && !state.reachable) { 101 | this.log('not reachable: force Active to false') 102 | this.values.active = this.Characteristics.hap.Active.INACTIVE 103 | this.values.inUse = this.Characteristics.hap.InUse.NOT_IN_USE 104 | break 105 | } 106 | // falls through 107 | case 'all_on': 108 | this.values.active = value 109 | ? this.Characteristics.hap.Active.ACTIVE 110 | : this.Characteristics.hap.Active.INACTIVE 111 | this.values.inUse = value 112 | ? this.Characteristics.hap.InUse.IN_USE 113 | : this.Characteristics.hap.InUse.NOT_IN_USE 114 | break 115 | default: 116 | break 117 | } 118 | } 119 | super.updateState(state) 120 | } 121 | } 122 | 123 | DeconzService.Valve = Valve 124 | -------------------------------------------------------------------------------- /lib/DeconzService/WarningDevice.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzService/WarningDevice.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { timeout } from 'homebridge-lib' 7 | 8 | import { DeconzService } from '../DeconzService/index.js' 9 | import '../DeconzService/LightsResource.js' 10 | 11 | class WarningDevice extends DeconzService.LightsResource { 12 | constructor (accessory, resource, params = {}) { 13 | params.Service = accessory.Services.hap.Switch 14 | super(accessory, resource, params) 15 | 16 | this.addCharacteristicDelegate({ 17 | key: 'on', 18 | Characteristic: this.Characteristics.hap.On, 19 | value: false 20 | }).on('didSet', async (value, fromHomeKit) => { 21 | if (fromHomeKit) { 22 | const onTime = this.values.duration > 0 ? this.values.duration : 1 23 | let body = { alert: 'none' } 24 | if (value) { 25 | if (this.values.mute) { 26 | body = { alert: 'blink', ontime: onTime } 27 | } else if (this.values.duration === 0) { 28 | body = { alert: 'select' } 29 | } else { 30 | body = { alert: 'lselect', ontime: onTime } 31 | } 32 | } 33 | await this.put(this.statePath, body) 34 | if (value) { 35 | await timeout(onTime * 1000) 36 | this.values.on = false 37 | } 38 | } 39 | }) 40 | 41 | this.addCharacteristicDelegate({ 42 | key: 'duration', 43 | Characteristic: this.Characteristics.hap.SetDuration 44 | }) 45 | 46 | this.addCharacteristicDelegate({ 47 | key: 'mute', 48 | Characteristic: this.Characteristics.hap.Mute 49 | }) 50 | 51 | this.addCharacteristicDelegates() 52 | 53 | this.update(resource.body, resource.rpath) 54 | } 55 | 56 | updateState (state) { 57 | if (state.on != null) { 58 | this.values.on = state.on 59 | } 60 | super.updateState(state) 61 | } 62 | } 63 | 64 | DeconzService.WarningDevice = WarningDevice 65 | -------------------------------------------------------------------------------- /lib/DeconzService/WindowCovering.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzService/WindowCovering.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { timeout } from 'homebridge-lib' 7 | 8 | import { DeconzService } from '../DeconzService/index.js' 9 | import '../DeconzService/LightsResource.js' 10 | 11 | class WindowCovering extends DeconzService.LightsResource { 12 | constructor (accessory, resource, params = {}) { 13 | params.Service = accessory.Services.hap.WindowCovering 14 | super(accessory, resource, params) 15 | 16 | this.addCharacteristicDelegate({ 17 | key: 'venetianBlind', 18 | value: false, 19 | silent: true 20 | }) 21 | 22 | this.addCharacteristicDelegate({ 23 | key: 'currentPosition', 24 | Characteristic: this.Characteristics.hap.CurrentPosition, 25 | unit: '%' 26 | }) 27 | 28 | this.addCharacteristicDelegate({ 29 | key: 'targetPosition', 30 | Characteristic: this.Characteristics.hap.TargetPosition, 31 | unit: '%' 32 | }).on('didSet', async (value, fromHomeKit) => { 33 | if (!fromHomeKit) { 34 | return 35 | } 36 | this.values.targetPosition = Math.round(this.values.targetPosition / 5) * 5 37 | return this.setPosition() 38 | }) 39 | 40 | this.addCharacteristicDelegate({ 41 | key: 'positionState', 42 | Characteristic: this.Characteristics.hap.PositionState, 43 | value: this.Characteristics.hap.PositionState.STOPPED 44 | }) 45 | 46 | this.addCharacteristicDelegate({ 47 | key: 'holdPosition', 48 | Characteristic: this.Characteristics.hap.HoldPosition 49 | }).on('didSet', async () => { 50 | await this.put(this.statePath, { stop: true }) 51 | this.values.positionState = this.Characteristics.hap.PositionState.STOPPED 52 | }) 53 | 54 | if (this.values.venetianBlind) { 55 | this.addCharacteristicDelegate({ 56 | key: 'closeUpwards', 57 | Characteristic: this.Characteristics.my.CloseUpwards 58 | }).on('didSet', async (value, fromHomeKit) => { 59 | if (!fromHomeKit) { 60 | return 61 | } 62 | if (this.values.currentPosition !== 100) { 63 | return this.setPosition() 64 | } 65 | }) 66 | } 67 | 68 | if (resource.capabilities.maxSpeed != null) { 69 | this.addCharacteristicDelegate({ 70 | key: 'motorSpeed', 71 | Characteristic: this.Characteristics.my.MotorSpeed, 72 | unit: '', 73 | props: { 74 | unit: '', 75 | minValue: 0, 76 | maxValue: resource.capabilities.maxSpeed, 77 | minStep: 1 78 | } 79 | }).on('didSet', async (value, fromHomeKit) => { 80 | if (!fromHomeKit) { 81 | return 82 | } 83 | await this.put('/config', { speed: value }) 84 | }) 85 | } 86 | 87 | if (resource.capabilities.positionChange) { 88 | this.addCharacteristicDelegate({ 89 | key: 'positionChange', 90 | Characteristic: this.Characteristics.my.PositionChange 91 | }).on('didSet', async (value) => { 92 | if (value !== 0) { 93 | await this.put(this.statePath, { lift_inc: -value }) 94 | await timeout(this.platform.config.waitTimeReset) 95 | this.values.positionChange = 0 96 | } 97 | }) 98 | this.values.positionChange = 0 99 | } 100 | 101 | this.addCharacteristicDelegates() 102 | 103 | this.update(resource.body, resource.rpath) 104 | this.values.targetPosition = this.values.currentPosition 105 | } 106 | 107 | async setPosition () { 108 | let lift = 100 - this.values.targetPosition // % closed --> % open 109 | if (this.values.venetianBlind) { 110 | if (this.values.closeUpwards) { 111 | lift *= -1 112 | } 113 | lift += 100 114 | lift /= 2 115 | lift = Math.round(lift) 116 | this.targetCloseUpwards = this.values.closeUpwards 117 | } 118 | this.values.positionState = 119 | this.values.targetPosition > this.values.currentPosition 120 | ? this.Characteristics.hap.PositionState.INCREASING 121 | : this.Characteristics.hap.PositionState.DECREASING 122 | this.moving = new Date() 123 | if ( 124 | this.resource.capabilities.useOpen && 125 | (this.lift === 0 || lift === 100) 126 | ) { 127 | return this.put(this.statePath, { open: lift === 0 }) 128 | } 129 | return this.put(this.statePath, { lift }) 130 | } 131 | 132 | updateState (state) { 133 | if (state.lift != null) { 134 | let position = Math.round(state.lift / 5) * 5 135 | let closeUpwards 136 | if (this.values.venetianBlind) { 137 | position *= 2 138 | position -= 100 139 | if (position < 0) { 140 | position *= -1 141 | closeUpwards = true 142 | } else if (position > 0) { 143 | closeUpwards = false 144 | } 145 | } 146 | position = 100 - position // % open -> % closed 147 | this.values.currentPosition = position 148 | if (closeUpwards != null) { 149 | this.values.closeUpwards = closeUpwards 150 | } 151 | if ( 152 | this.moving == null || new Date() - this.moving >= 30000 || ( 153 | position === this.values.targetPosition && 154 | (closeUpwards == null || closeUpwards === this.targetCloseUpwards) 155 | ) 156 | ) { 157 | this.moving = null 158 | this.values.targetPosition = position 159 | this.values.positionState = this.Characteristics.hap.PositionState.STOPPED 160 | } 161 | } 162 | if (state.speed != null) { 163 | this.values.motorSpeed = state.speed 164 | } 165 | super.updateState(state) 166 | } 167 | } 168 | 169 | DeconzService.WindowCovering = WindowCovering 170 | -------------------------------------------------------------------------------- /lib/DeconzService/index.js: -------------------------------------------------------------------------------- 1 | // homebridge-deconz/lib/DeconzService/index.js 2 | // Copyright © 2022-2025 Erik Baauw. All rights reserved. 3 | // 4 | // Homebridge plugin for deCONZ. 5 | 6 | import { ServiceDelegate } from 'homebridge-lib/ServiceDelegate' 7 | 8 | import { ApiClient } from 'hb-deconz-tools/ApiClient' 9 | 10 | const { HttpError, dateToString } = ApiClient 11 | 12 | /** Service delegates. 13 | * @extends ServiceDelegate 14 | */ 15 | class DeconzService extends ServiceDelegate { 16 | constructor (accessory, resource, params) { 17 | super(accessory, { 18 | id: resource.id, 19 | name: params.name ?? resource.body.name, 20 | Service: params.Service, 21 | subtype: resource.subtype, 22 | primaryService: params.primaryService, 23 | exposeConfiguredName: true 24 | }) 25 | this.id = resource.id 26 | this.gateway = accessory.gateway 27 | this.accessory = accessory 28 | this.client = accessory.client 29 | this.resource = resource 30 | this.rtype = resource.rtype 31 | this.rid = resource.rid 32 | this.rpath = resource.rpath 33 | this.capabilities = resource.capabilities 34 | 35 | this.serviceNameByRpath = {} 36 | 37 | // this.characteristicDelegate('configuredName') 38 | // .on('didSet', async (value, fromHomeKit) => { 39 | // if (fromHomeKit && value != null && value !== '') { 40 | // this.debug('PUT %s %j', this.rpath, { name: value }) 41 | // await this.client.put(this.rpath, { name: value }) 42 | // } 43 | // }) 44 | } 45 | 46 | addResource (resource) { 47 | this.serviceNameByRpath[resource.rpath] = resource.serviceName 48 | DeconzService[resource.serviceName].addResource(this, resource) 49 | } 50 | 51 | update (body, rpath) { 52 | if (this.updating) { 53 | return 54 | } 55 | const serviceName = this.serviceNameByRpath[rpath] 56 | if (serviceName != null) { 57 | if (body.state != null) { 58 | DeconzService[serviceName].updateResourceState(this, body.state) 59 | } 60 | return 61 | } 62 | // if (body.name != null) { 63 | // this.values.configuredName = body.name.slice(0, 31).trim() 64 | // } 65 | if (body.lastseen != null && this.rtype === 'lights') { 66 | this.values.lastSeen = dateToString(body.lastseen) 67 | } 68 | if (body.config != null) { 69 | this.updateConfig(body.config) 70 | if (this.batteryService != null) { 71 | this.batteryService.updateConfig(body.config) 72 | } 73 | } 74 | if (body.state != null) { 75 | this.updateState(body.state, rpath) 76 | } 77 | if (this.rtype === 'groups') { 78 | if (body.action != null) { 79 | this.updateState(body.action, rpath, 'action') 80 | } 81 | if (body.scenes != null) { 82 | this.updateScenes(body.scenes) 83 | } 84 | } 85 | } 86 | 87 | async put (path, body) { 88 | this.debug('PUT %s %j', path, body) 89 | try { 90 | await this.client.put(this.rpath + path, body) 91 | } catch (error) { 92 | if (!(error instanceof HttpError)) { 93 | this.warn(error) 94 | } 95 | } 96 | } 97 | } 98 | 99 | export { DeconzService } 100 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "homebridge-deconz", 3 | "description": "Homebridge plugin for deCONZ", 4 | "displayName": "Homebridge deCONZ", 5 | "author": "Erik Baauw", 6 | "maintainers": [ 7 | "ebaauw" 8 | ], 9 | "license": "Apache-2.0", 10 | "version": "1.2.0-0", 11 | "keywords": [ 12 | "homebridge-plugin", 13 | "homekit", 14 | "deconz", 15 | "phoscon", 16 | "raspbee", 17 | "conbee", 18 | "dresden-elektronik", 19 | "dresdenelektronik" 20 | ], 21 | "type": "module", 22 | "main": "index.js", 23 | "bin": { 24 | "deconz": "cli/deconz.js", 25 | "otau": "cli/otau.js", 26 | "ui": "cli/ui.js" 27 | }, 28 | "engines": { 29 | "deCONZ": "2.30.2", 30 | "homebridge": "^1.9.0||^2.0.0-beta", 31 | "node": "^22||^20||^18" 32 | }, 33 | "dependencies": { 34 | "hb-deconz-tools": "~2.0.11", 35 | "homebridge-lib": "~7.1.5" 36 | }, 37 | "scripts": { 38 | "prepare": "standard && rm -rf out && jsdoc -c jsdoc.json", 39 | "test": "standard && echo \"Error: no test specified\" && exit 1" 40 | }, 41 | "repository": { 42 | "type": "git", 43 | "url": "git+https://github.com/ebaauw/homebridge-deconz.git" 44 | }, 45 | "bugs": { 46 | "url": "https://github.com/ebaauw/homebridge-deconz/issues" 47 | }, 48 | "homepage": "https://github.com/ebaauw/homebridge-deconz#readme", 49 | "funding": [ 50 | { 51 | "type": "github", 52 | "url": "https://github.com/sponsors/ebaauw" 53 | }, 54 | { 55 | "type": "paypal", 56 | "url": "https://www.paypal.me/ebaauw/EUR" 57 | } 58 | ] 59 | } 60 | --------------------------------------------------------------------------------