├── .automated.eslintrc.json ├── .eslintrc.json ├── LICENSE ├── Readme.md ├── app.js ├── config.default.json ├── deployment ├── dbus │ └── cybele.conf └── systemd │ └── cybele.service ├── docs ├── devices │ ├── BatteryPoweredDevice.md │ ├── EqivaThermostat.md │ ├── MiBodyCompositionScale.md │ ├── MiFlora.md │ ├── MiLYWSD03MMC.md │ ├── MiSmartKettle.md │ ├── OralBToothbrush.md │ └── RoomPresenceBeacon.md └── index.md ├── lib ├── Cybele.js ├── DeviceFactory.js ├── Dongle.js ├── DongleFactory.js ├── MqttHandler.js ├── ServiceFactory.js ├── Tools.js ├── devices │ ├── BatteryPoweredDevice.js │ ├── BodyScale │ │ ├── BodyMetrics.js │ │ └── MiBodyScaleDevice.js │ ├── Device.js │ ├── EqivaThermostat │ │ ├── EqivaThermostatDevice.js │ │ └── EqivaThermostatMqttHandler.js │ ├── GlanceClock │ │ ├── GlanceClockDevice.js │ │ ├── GlanceClockMqttHandler.js │ │ └── Types │ │ │ ├── Enums.js │ │ │ ├── ForecastScene.js │ │ │ ├── Glance.proto │ │ │ ├── Settings.js │ │ │ ├── TextData.js │ │ │ └── index.js │ ├── MiFloraDevice.js │ ├── MiKettle │ │ ├── MiCipher.js │ │ ├── MiKettleDevice.js │ │ └── MiKettleMqttHandler.js │ ├── MiLYWSD03MMCDevice.js │ ├── OralBToothbrushDevice.js │ ├── PollingDevice.js │ ├── RoomPresenceBeaconDevice.js │ └── index.js └── services │ ├── CurrentTimeService.js │ ├── Service.js │ └── index.js ├── package-lock.json └── package.json /.automated.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "overrides": [ 3 | { 4 | "files": [ 5 | "lib/**/index.js" 6 | ], 7 | "rules": { 8 | "sort-keys-fix/sort-keys-fix": "warn" 9 | } 10 | }, 11 | { 12 | "files": [ 13 | "index.js", 14 | "lib/**/*.js", 15 | "util/**/*.js" 16 | ], 17 | "parserOptions": { 18 | "ecmaVersion": 2020 19 | }, 20 | "extends": [ 21 | "eslint:recommended", 22 | "plugin:node/recommended" 23 | ], 24 | "rules": { 25 | "no-process-exit": "off", 26 | "node/no-process-exit": "off" 27 | } 28 | } 29 | ], 30 | "rules": { 31 | "brace-style": [ 32 | "error", 33 | "1tbs" 34 | ], 35 | "no-trailing-spaces": [ 36 | "error", 37 | { 38 | "ignoreComments": true 39 | } 40 | ], 41 | "keyword-spacing": "error", 42 | "eol-last": [ 43 | "error", 44 | "always" 45 | ], 46 | "no-multi-spaces": [ 47 | "error", 48 | { 49 | "ignoreEOLComments": true 50 | } 51 | ], 52 | "semi": [ 53 | "error", 54 | "always" 55 | ], 56 | "quotes": [ 57 | "error", 58 | "double" 59 | ], 60 | "indent": [ 61 | "error", 62 | 4, 63 | { 64 | "SwitchCase": 1 65 | } 66 | ], 67 | "no-empty": "error" 68 | } 69 | } -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "es6": true, 6 | "node": true, 7 | "mocha": true 8 | }, 9 | "plugins": ["jsdoc", "sort-keys-fix", "sort-requires", "node", "regexp"], 10 | "extends": ["eslint:recommended", "plugin:regexp/recommended"], 11 | "globals": { 12 | "Atomics": "readonly", 13 | "SharedArrayBuffer": "readonly" 14 | }, 15 | "parserOptions": { 16 | "ecmaVersion": 2020, 17 | "sourceType": "module" 18 | }, 19 | "settings": { 20 | "jsdoc": { 21 | "mode": "closure", 22 | "tagNamePreference": { 23 | "returns": "returns", 24 | "augments": "extends" 25 | } 26 | } 27 | }, 28 | "rules": { 29 | "no-console": "off", 30 | "no-labels": "error", 31 | "max-classes-per-file": "error", 32 | "eqeqeq": "error", 33 | "curly": "error", 34 | "default-case-last": "error", 35 | "block-scoped-var": "error", 36 | "no-new": "error", 37 | "no-multi-str": "error", 38 | "no-new-wrappers": "error", 39 | "no-sequences": "error", 40 | "no-self-compare": "error", 41 | "no-multi-assign": "error", 42 | "no-whitespace-before-property": "error", 43 | "no-magic-numbers": ["off", { "ignoreArrayIndexes": true }], 44 | "no-unused-vars": ["warn", { "args": "none" }], 45 | "jsdoc/check-alignment": "error", 46 | "jsdoc/check-param-names": "error", 47 | "jsdoc/check-tag-names": "error", 48 | "jsdoc/check-types": "error", 49 | "jsdoc/implements-on-classes": "error", 50 | "jsdoc/newline-after-description": "error", 51 | "jsdoc/no-undefined-types": "error", 52 | "jsdoc/require-param": "error", 53 | "jsdoc/require-param-name": "error", 54 | "jsdoc/require-param-type": "error", 55 | "jsdoc/require-returns-check": "error", 56 | "jsdoc/require-returns-type": "error", 57 | "sort-requires/sort-requires": "warn", 58 | "operator-linebreak": ["error", "after"], 59 | "no-unneeded-ternary": ["error", { "defaultAssignment": false }], 60 | "arrow-body-style": ["error", "always"], 61 | "regexp/no-unused-capturing-group": "off", 62 | 63 | 64 | 65 | "no-empty": "off", 66 | "brace-style": "off", 67 | "no-trailing-spaces": "off", 68 | "keyword-spacing": "off", 69 | "eol-last": "off", 70 | "no-multi-spaces": "off", 71 | "semi": "off", 72 | "quotes": "off", 73 | "indent": "off" 74 | 75 | 76 | }, 77 | "overrides": [ 78 | ] 79 | } 80 | -------------------------------------------------------------------------------- /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 | # Cybele 2 | 3 | Cybele is a generic and extensible application used to bridge Bluetooth Low Energy devices to MQTT. 4 | 5 | It is written in Javascript and utilizes the BlueZ Linux Bluetooth stack via its D-Bus interface. 6 | 7 | ## Features 8 | Cybele can.. 9 | * Connect to a multitude of devices 10 | * Use multiple HCI Dongles to work around hardware limitations 11 | * Run own GATT Services 12 | 13 | ## Supported devices 14 | Currently, the following devices/device types are supported: 15 | 16 | * GlanceClock Smart Wall Clock 17 | * [Xiaomi / Viomi Mi Mija Smart Kettle](docs/devices/MiSmartKettle.md) 18 | * [Xiaomi Mi Body Composition Scale](docs/devices/MiBodyCompositionScale.md) 19 | * [Oral-B Smart Toothbrushes](docs/devices/OralBToothbrush.md) 20 | * [Room Presence tracking using generic BLE Beacons](docs/devices/RoomPresenceBeacon.md) 21 | * [Generic BLE Devices which provide battery information](docs/devices/BatteryPoweredDevice.md) 22 | * [Xiaomi Mi Flora Plant Sensors](docs/devices/MiFlora.md) 23 | * [eQ-3 Eqiva BLUETOOTH® Radiator Thermostats](docs/devices/EqivaThermostat.md) 24 | * [Xiaomi Mijia LYWSD03MMC Bluetooth 4.2 Temperature Humidity sensor](docs/devices/MiLYWSD03MMC.md) 25 | 26 | _You can click on the device to jump to its documentation._ 27 | 28 | ## Requirements 29 | Since Cybele uses BlueZ, you will need some GNU+Linux distribution. 30 | 31 | You will also need a recent version of nodejs. Development was done using Node 11. 32 | 33 | The BlueZ Version needs to be rather new as well. Debian Busters BlueZ 5.50 is sufficient. 34 | 35 | ## Deployment 36 | Deployment is simple: 37 | 1. Clone this repo 38 | 2. Navigate into the cloned repo and run `npm install` 39 | 3. Copy `config.default.json` to `config.json` and edit according to your needs. Documentation can be found [here.](docs/index.md) 40 | 4. Run `app.js`. Either manually using `node app.js` or by using the provided systemd unit file. 41 | 42 | A sample systemd unit file is included [here.](deployment/systemd/cybele.service) 43 | 44 | Place it in `/etc/systemd/system/` and don't forget to change the paths in it if required. 45 | 46 | ## Known Issues 47 | As of now (2020-10-30), there's a bug in bluetoothd which causes it to constantly write all state changes of everything all the time to disk. 48 | This has caused the death of multiple brave 16GB micro sd cards which couldn't handle 50+TBW :( 49 | 50 | As a mitigation, I'm currently using a ramdisk for the bluetooth state directory: 51 | 52 | Add this to your `/etc/fstab`: 53 | 54 | ``` 55 | tmpfs /tmp/bluetoothstate tmpfs nodev,nosuid,size=60M 0 0 56 | ``` 57 | 58 | Create a symlink `ln -s /tmp/bluetoothstate /var/lib/bluetooth` 59 | 60 | And use this systemd service `/etc/systemd/system/bluetoothramdisk.service`: 61 | 62 | ``` 63 | [Unit] 64 | RequiredBy=bluetooth.service 65 | PartOf=bluetooth.service 66 | 67 | [Service] 68 | Type=oneshot 69 | User=root 70 | ExecStart=/usr/bin/rsync -ar /opt/bluetooth_backup/ /tmp/bluetoothstate/ 71 | ExecStop=/usr/bin/rsync -ar /tmp/bluetoothstate/ /opt/bluetooth_backup/ 72 | RemainAfterExit=yes 73 | 74 | [Install] 75 | WantedBy=multi-user.target 76 | ``` 77 | 78 | You may need to create /opt/bluetooth_backup beforehand and initially seed it with your current data. 79 | 80 | 81 | ## Misc 82 | Please note that Cybele is currently in its early stages. 83 | There is still a lot to do regarding both error handling as well as code-cleanup. 84 | 85 | ##### GATT Services 86 | To run own GATT services, you also need permission to bring up a service on the system D-Bus. 87 | 88 | A sample configuration which grants these rights to a user named `pi` is included [here.](deployment/dbus/cybele.conf) 89 | 90 | Just place that file in `/etc/dbus-1/system.d` and you should be able to use the included `CurrentTimeService`. 91 | 92 | ##### Why the name? 93 | No particular reason. I just needed something less generic than `ble2mqtt` or `bleGateway`. 94 | 95 | It also fits nicely with [Valetudo](https://github.com/Hypfer/Valetudo) 96 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | const mqtt = require("mqtt"); 2 | 3 | const Cybele = require("./lib/Cybele"); 4 | 5 | const config = require("./config.json"); 6 | 7 | const mqttClient = mqtt.connect(config.mqtt.url, {}); 8 | 9 | //TODO: validate config file 10 | 11 | mqttClient.on("connect", () => { 12 | let cybele; 13 | console.info("Connected to MQTT Broker"); 14 | 15 | cybele = new Cybele({ 16 | mqttClient: mqttClient, 17 | config: config 18 | }); 19 | 20 | cybele.initialize().then(() => { 21 | console.log("Startup complete"); 22 | 23 | mqttClient.on("message", (topic, message) => { 24 | message = message.toString(); 25 | 26 | Object.keys(cybele.dongles).forEach(dongleKey => { 27 | const dongle = cybele.dongles[dongleKey]; 28 | 29 | dongle.devices.forEach(device => { 30 | device.handleMqttMessage(topic, message); 31 | }); 32 | }); 33 | }); 34 | }).catch(err => { 35 | console.error(err); 36 | process.exit(0); 37 | }); 38 | }); 39 | 40 | ["error", "close", "disconnect", "end"].forEach(event => { 41 | //TODO: Something reasonable 42 | mqttClient.on(event, (e) => { 43 | console.error(e); 44 | process.exit(0); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /config.default.json: -------------------------------------------------------------------------------- 1 | { 2 | "mqtt": { 3 | "url": "mqtt://user:pass@foobar.example" 4 | }, 5 | "dongles": [ 6 | { 7 | "hciDevice": "hci0", 8 | "mode": "le", 9 | "services": [], 10 | "devices": [ 11 | 12 | ] 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /deployment/dbus/cybele.conf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /deployment/systemd/cybele.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=cybele 3 | Requires=bluetooth.service 4 | PartOf=bluetooth.service 5 | 6 | [Service] 7 | Type=simple 8 | User=pi 9 | ExecStart=/usr/local/bin/node /home/pi/Cybele/app.js 10 | WorkingDirectory=/home/pi/Cybele/ 11 | RestartSec=2 12 | TimeoutStartSec=5 13 | Restart=always 14 | 15 | [Install] 16 | WantedBy=multi-user.target 17 | -------------------------------------------------------------------------------- /docs/devices/BatteryPoweredDevice.md: -------------------------------------------------------------------------------- 1 | # Generic BLE Device which provides battery information 2 | This module can be used in combination with other modules to add battery information. 3 | This way, you will know when you need to replace your ble beacons battery. 4 | 5 | Please replace `FF:FF:FF:FF:FF:FF` as well as `ffffffffffff` with your devices mac. 6 | 7 | ## Device Config Entry 8 | ``` 9 | { 10 | "type": "BatteryPoweredDevice", 11 | "friendlyName": "Keychain Beacon", 12 | "mac": "FF:FF:FF:FF:FF:FF", 13 | "pollingInterval": 300000, 14 | "pollOnStartup": false, 15 | "disconnectAfterFetch": true, 16 | "maxDelayAfterConnect": 8000 17 | } 18 | ``` 19 | 20 | `pollingInterval` the interval this module will use to fetch battery information in milliseconds 21 | 22 | If `pollOnStartup` is set to true, the first polling will happen 1s after startup. 23 | 24 | `disconnectAfterFetch` determine if this module should disconnect after fetching battery information 25 | Beacons will usually stop advertising while being connected so not disconnecting might break things 26 | 27 | `maxDelayAfterConnect` maximum time to wait for the battery interface to become available in milliseconds 28 | 29 | 30 | ## MQTT 31 | 32 | #### Autoconfig 33 | The device will attempt to autoconfigure Home Assistant for state information on 34 | `homeassistant/sensor/bat_ffffffffffff/config` 35 | 36 | #### State 37 | `cybele/battery_powered_ble_device/ffffffffffff/state` provides the current battery percentage 38 | -------------------------------------------------------------------------------- /docs/devices/EqivaThermostat.md: -------------------------------------------------------------------------------- 1 | # eQ-3 Eqiva BLUETOOTH® Smart Radiator Thermostat 2 | ![The Device](https://user-images.githubusercontent.com/974410/70374515-680ca480-18f3-11ea-9312-a103388dadc2.png) 3 | 4 | Please replace `FF:FF:FF:FF:FF:FF` as well as `ffffffffffff` with your devices mac. 5 | 6 | Protocol documentation can be found here: [https://github.com/Heckie75/eQ-3-radiator-thermostat/blob/master/eq-3-radiator-thermostat-api.md](https://github.com/Heckie75/eQ-3-radiator-thermostat/blob/master/eq-3-radiator-thermostat-api.md) 7 | 8 | 9 | ## Device Config Entry 10 | ``` 11 | { 12 | "type": "EqivaThermostatDevice", 13 | "friendlyName": "Eqiva Thermostat Kitchen", 14 | "pollingInterval": 3600000, 15 | "pollOnStartup": false, 16 | "mac": "FF:FF:FF:FF:FF:FF" 17 | } 18 | ``` 19 | 20 | `pollingInterval` the interval this module will use to fetch battery information in milliseconds 21 | 22 | If `pollOnStartup` is set to true, the first polling will happen 1s after startup. 23 | 24 | ## MQTT 25 | 26 | #### Autoconfig 27 | The device will attempt to autoconfigure Home Assistant for temperature information + attributes on 28 | `homeassistant/climate/eqiva_thermostat_ffffffffffff/config`. 29 | 30 | #### State 31 | `cybele/eqiva_thermostat/ffffffffffff/state` provides the current state as JSON 32 | 33 | ``` 34 | { 35 | "temperature": 22.5, 36 | "mode": "heat" 37 | } 38 | ``` 39 | 40 | #### Attributes 41 | `cybele/eqiva_thermostat/ffffffffffff/attributes` provides the current attributes as JSON 42 | 43 | ``` 44 | { 45 | "mode": "manual", 46 | "vacation": false, 47 | "boost": false, 48 | "dst": true, 49 | "window_open": false, 50 | "locked": false, 51 | "low_bat": true 52 | } 53 | ``` 54 | 55 | #### Commands 56 | 57 | ##### Set Temperature 58 | **Topic:** `cybele/eqiva_thermostat/ffffffffffff/set_temperature` 59 | 60 | **Payload:** 61 | ``` 62 | 22.5 63 | ``` 64 | 65 | The requested temperature in °C (4.5-30) 66 | 67 | ##### Set Mode 68 | **Topic:** `cybele/eqiva_thermostat/ffffffffffff/set_mode` 69 | 70 | **Payload:** 71 | ``` 72 | heat 73 | ``` 74 | The payload can either be `auto` or `heat` 75 | 76 | #### Troubleshooting 77 | For reasons currently unknown, the initial connection to a new thermostat may fail without any feedback. 78 | If this happens, you will see connection timeouts in Cybele. 79 | 80 | If you look at the kernel message buffer using `dmesg`, you will also see a _lot_ of messages like this: 81 | ``` 82 | [ 325.988680] Bluetooth: hci0: security requested but not available 83 | ``` 84 | 85 | To fix this issue, stop cybele as well as the bluetooth service, navigate to `/var/lib/bluetooth/[dongle Mac]` and 86 | delete the folder named `[thermostat mac]`. 87 | In this folder, there is a file named `info` which _should_ contain keys which are exchanged/generated on the first connection. 88 | 89 | For some reason however, these keys are missing which leads bluetoothd to struggle because that case apparently isn't handled. -------------------------------------------------------------------------------- /docs/devices/MiBodyCompositionScale.md: -------------------------------------------------------------------------------- 1 | # Xiaomi Mi Body Composition Scale 2 | ![The Device](https://user-images.githubusercontent.com/974410/69484453-bd48b080-0e33-11ea-9360-bc1cca53eca7.png) 3 | 4 | Please replace `FF:FF:FF:FF:FF:FF` as well as `ffffffffffff` with your devices mac. 5 | 6 | ## Device Config Entry 7 | ``` 8 | { 9 | "type": "MiBodyScaleDevice", 10 | "friendlyName": "Mi Body Scale", 11 | "mac": "FF:FF:FF:FF:FF:FF", 12 | "userSex": "M", 13 | "userHeight": 180, 14 | "userBirthday": "1990-01-01T00:00:00.000Z" 15 | } 16 | ``` 17 | `userSex` may be either `M` or `F` 18 | 19 | `userHeight` is the height in centimeters 20 | 21 | `userBirthday` will be used for age calculation 22 | 23 | ## MQTT 24 | 25 | #### Autoconfig 26 | The device will attempt to autoconfigure Home Assistant for state information + attributes on 27 | `homeassistant/sensor/body_scale_ffffffffffff/config`. 28 | 29 | #### State 30 | `cybele/body_scale/ffffffffffff/state` provides the weight 31 | 32 | #### Attributes 33 | `cybele/body_scale/ffffffffffff/attributes` provides the current attributes. 34 | 35 | ``` 36 | { 37 | "impedance": 600, 38 | "lbm": "60.00", 39 | "bmi": "20.00", 40 | "fat_pct": "20.00", 41 | "water_pct": "50.00", 42 | "bone_mass_kg": "3.00", 43 | "muscle_mass_kg": "50.00", 44 | "visceral_fat_mass_kg": "5.00", 45 | "bmr_kcal": "1800.00", 46 | "fat": "Normal", 47 | "water": "Normal", 48 | "bone_mass": "Normal", 49 | "muscle_mass": "Normal", 50 | "visceral_fat": "Normal", 51 | "bmi_class": "Normal", 52 | "body_type": "balanced" 53 | } 54 | ``` 55 | Take a look at [BodyMetrics.js](../../lib/devices/BodyScale/BodyMetrics.js) to find out what these mean. 56 | 57 | ## Misc 58 | It might make sense to make the BodyMetrics parameters configurable over MQTT instead of hard-coding them in the configuration. -------------------------------------------------------------------------------- /docs/devices/MiFlora.md: -------------------------------------------------------------------------------- 1 | # Xiaomi Mi Flora Plant Sensor 2 | ![The Device](https://user-images.githubusercontent.com/974410/69484448-b457df00-0e33-11ea-94de-48cefbdffea8.png) 3 | 4 | Please replace `FF:FF:FF:FF:FF:FF` as well as `ffffffffffff` with your devices mac. 5 | 6 | Protocol documentation can be found here: [https://github.com/vrachieru/xiaomi-flower-care-api#protocol](https://github.com/vrachieru/xiaomi-flower-care-api#protocol) 7 | 8 | 9 | ## Device Config Entry 10 | ``` 11 | { 12 | "type": "MiFloraDevice", 13 | "friendlyName": "MiFlora Strawberries", 14 | "pollingInterval": 600000, 15 | "pollOnStartup": false, 16 | "mac": "FF:FF:FF:FF:FF:FF" 17 | } 18 | ``` 19 | 20 | `pollingInterval` the interval this module will use to fetch battery information in milliseconds 21 | 22 | If `pollOnStartup` is set to true, the first polling will happen 1s after startup. 23 | 24 | ## MQTT 25 | 26 | #### Autoconfig 27 | The device will attempt to autoconfigure Home Assistant for state information on the following topics: 28 | `homeassistant/sensor/miflora_ffffffffffff/ffffffffffff_battery/config` 29 | `homeassistant/sensor/miflora_ffffffffffff/ffffffffffff_temperature/config` 30 | `homeassistant/sensor/miflora_ffffffffffff/ffffffffffff_illuminance/config` 31 | `homeassistant/sensor/miflora_ffffffffffff/ffffffffffff_moisture/config` 32 | `homeassistant/sensor/miflora_ffffffffffff/ffffffffffff_conductivity/config` 33 | 34 | #### State 35 | `cybele/miflora/ffffffffffff/state` provides the current state as JSON 36 | 37 | ``` 38 | { 39 | "battery": 21, 40 | "temperature": 23.7, 41 | "illuminance": 210, 42 | "moisture": 17, 43 | "conductivity": 23 44 | } 45 | ``` -------------------------------------------------------------------------------- /docs/devices/MiLYWSD03MMC.md: -------------------------------------------------------------------------------- 1 | # Xiaomi Mijia LYWSD03MMC Bluetooth 4.2 Temperature Humidity sensor 2 | ![The Device](https://community-assets.home-assistant.io/original/3X/6/1/61b3a37f1b2c54dbf9fae66f0fd8484e301fdfeb.png) 3 | 4 | First, upgrade your devices with custom firmware described here: https://github.com/atc1441/ATC_MiThermometer 5 | 6 | Please replace `FF:FF:FF:FF:FF:FF` as well as `ffffffffffff` with your devices mac. 7 | 8 | ## Device Config Entry 9 | ``` 10 | { 11 | "type": "MiLYWSD03MMCDevice", 12 | "friendlyName": "Bedroom temperature sensor", 13 | "mac": "FF:FF:FF:FF:FF:FF" 14 | } 15 | ``` 16 | 17 | ## MQTT 18 | 19 | #### Autoconfig 20 | The device will attempt to autoconfigure Home Assistant for state information on the following topics: 21 | `homeassistant/sensor/MiLYWSD03MMC/ffffffffffff_tem/config` 22 | `homeassistant/sensor/MiLYWSD03MMC/ffffffffffff_hum/config` 23 | `homeassistant/sensor/MiLYWSD03MMC/ffffffffffff_bat/config` 24 | `homeassistant/sensor/MiLYWSD03MMC/ffffffffffff_batv/config` 25 | 26 | #### State 27 | `cybele/MijiaLYWSD03MMC/ffffffffffff/state` provides the current state as JSON 28 | 29 | ``` 30 | { 31 | "tempc": 21.8, 32 | "hum": 49, 33 | "batt": 73, 34 | "volt": 2.863 35 | } 36 | ``` -------------------------------------------------------------------------------- /docs/devices/MiSmartKettle.md: -------------------------------------------------------------------------------- 1 | # Xiaomi / Viomi Mi Mija Smart Kettle 2 | ![The Device](https://user-images.githubusercontent.com/974410/72007682-7d5a5300-3252-11ea-97a8-74e4c109d231.png) 3 | 4 | Please replace `FF:FF:FF:FF:FF:FF` as well as `ffffffffffff` with your devices mac. 5 | 6 | Protocol documentation can be found here: [https://github.com/aprosvetova/xiaomi-kettle](https://github.com/aprosvetova/xiaomi-kettle) 7 | 8 | ## Device revisions 9 | There are quite a few revisions of this device 10 | 11 | | Name | Model | Product ID | Manufacturer Comment | Notes | 12 | |-----------------|---------------------|------------|---------------------------------|-----------------------------------| 13 | | yunmi.kettle.v1 | | 131 | Mainland and Hong Kong versions | May have been available in russia | 14 | | yunmi.kettle.v2 | YM-K1501 | 275 | International version | White, No Display, No Presets(?) | 15 | | yunmi.kettle.v3 | | 395 | Taiwan version | | 16 | | yunmi.kettle.v5 | | | Korean version | | 17 | | yunmi.kettle.v6 | | | | | 18 | | yunmi.kettle.v7 | V-SK152A / V-SK152B | 1116 | International version | Black and White, Display, Presets | 19 | 20 | The handle feels a lot more sturdy on the v2 compared to the v7. v7 also seems to have noticeably worse signal strength. 21 | 22 | If you don't need the display, you might be better off with an older revision. 23 | 24 | ## Device Config Entry 25 | ``` 26 | { 27 | "type": "MiKettleDevice", 28 | "friendlyName": "Mi Kettle", 29 | "mac": "FF:FF:FF:FF:FF:FF", 30 | "productId": 275 31 | } 32 | ``` 33 | The correct productId is required for this to work. Check the table above. 34 | 35 | Optionally, you can keep using the MiHome app by adding the token extracted from the App to this config entry like this: 36 | 37 | ``` 38 | "token" : [255,255,255,255,255,255,255,255,255,255,255,255] 39 | ``` 40 | 41 | ## MQTT 42 | 43 | #### Autoconfig 44 | The device will attempt to autoconfigure Home Assistant for temperature information + attributes on 45 | `homeassistant/sensor/kettle_ffffffffffff/config`. 46 | 47 | #### Presence 48 | `cybele/kettle/ffffffffffff/presence` will either be `online` or `offline` 49 | 50 | You can only send commands when this is `online` 51 | 52 | #### State 53 | `cybele/kettle/ffffffffffff/state` provides the current temperature 54 | 55 | #### Attributes 56 | `cybele/kettle/ffffffffffff/attributes` provides the current attributes. 57 | 58 | ``` 59 | { 60 | "action": "idle", 61 | "mode": "none", 62 | "keep_warm_refill_mode": "turn_off", 63 | "keep_warm_temperature": 65, 64 | "keep_warm_type": "heat_to_temperature", 65 | "keep_warm_time": 0, 66 | "keep_warm_time_limit": 12 67 | } 68 | ``` 69 | `action` may be one of the following: 70 | * `idle` 71 | * `heating` 72 | * `cooling` 73 | * `keeping_warm` 74 | 75 | `mode` may be one of the following: 76 | * `none` 77 | * `boil` 78 | * `keep_warm` 79 | 80 | `keep_warm_refill_mode` is called `Extended warm up` in the official app. 81 | 82 | This defines what happens when the kettle is currently in `keep_warm` mode and gets taken off the dock and put back on again. 83 | If this is set to `keep_warm` >= 45°C and the water temperature hasn't changed by more than 3°C, 84 | the kettle will return to keeping the water at the set temperature without reboiling it. 85 | 86 | If the difference is more than 3°C or this is set to `turn_off` the kettle will just stay off. 87 | 88 | It may be one of the following: 89 | * `turn_off` 90 | * `keep_warm` 91 | 92 | `keep_warm_temperature` is the keep warm temperature in °C (40-90) 93 | 94 | `keep_warm_type` may be one of the following: 95 | * `boil_and_cool_down` 96 | * `heat_to_temperature` 97 | 98 | `keep_warm_time` is the time in minutes since keep warm was enabled 99 | 100 | `keep_warm_time_limit` is the time in hours keep warm will stay on before turning itself off automatically. 0-12. 101 | Half hours are also possible: 7h30m = 7.5 102 | 103 | #### Commands 104 | 105 | ##### Set Keep Warm Parameters 106 | **Topic:** `cybele/kettle/ffffffffffff/set_keep_warm_parameters` 107 | 108 | **Payload:** 109 | ``` 110 | { 111 | "mode": "boil", 112 | "temperature": 65 113 | } 114 | ``` 115 | `mode` can either be `boil` or `heat` 116 | 117 | `temperature` is the keep warm temperature in °C (40-90) 118 | 119 | ##### Set Keep Warm Time Limit 120 | **Topic:** `cybele/kettle/ffffffffffff/set_keep_warm_time_limit` 121 | 122 | **Payload:** 123 | ``` 124 | { 125 | "time": 7.5 126 | } 127 | ``` 128 | `time` is the time in hours keep warm will stay on before turning itself off automatically. 0-12. 129 | Half hours are also possible: 7h30m = 7.5 130 | 131 | ##### Set Keep Warm Refill Mode 132 | **Topic:** `cybele/kettle/ffffffffffff/set_keep_warm_refill_mode` 133 | 134 | **Payload:** 135 | ``` 136 | { 137 | "mode" : "turn_off" 138 | } 139 | ``` 140 | `mode` can either be `turn_off` or `keep_warm` 141 | -------------------------------------------------------------------------------- /docs/devices/OralBToothbrush.md: -------------------------------------------------------------------------------- 1 | # Oral-B Smart Toothbrush 2 | ![The Device](https://user-images.githubusercontent.com/974410/69484450-ba4dc000-0e33-11ea-8282-47e7f6a5a99e.png) 3 | 4 | Please replace `FF:FF:FF:FF:FF:FF` as well as `ffffffffffff` with your devices mac. 5 | 6 | ## Device Config Entry 7 | ``` 8 | { 9 | "type": "OralBToothbrushDevice", 10 | "friendlyName": "Series 7000", 11 | "mac": "FF:FF:FF:FF:FF:FF" 12 | } 13 | ``` 14 | 15 | ## MQTT 16 | 17 | #### Autoconfig 18 | The device will attempt to autoconfigure Home Assistant for state information + attributes on 19 | `homeassistant/sensor/toothbrush_ffffffffffff/config`. 20 | 21 | #### Presence 22 | `cybele/toothbrush/ffffffffffff/presence` will either be `online` or `offline` 23 | 24 | #### State 25 | `cybele/toothbrush/ffffffffffff/state` provides the current temperature 26 | 27 | may be one of the following: 28 | * `unknown` 29 | * `initializing` 30 | * `idle` 31 | * `running` 32 | * `charging` 33 | * `setup` 34 | * `flight_menu` 35 | * `final_test` 36 | * `pcb_test` 37 | * `sleeping` 38 | * `transport` 39 | 40 | #### Attributes 41 | `cybele/toothbrush/ffffffffffff/attributes` provides the current attributes. 42 | 43 | ``` 44 | { 45 | "rssi": -91, 46 | "pressure": 32, 47 | "time": 3, 48 | "mode": "daily_clean", 49 | "sector": "sector_1" 50 | } 51 | ``` 52 | `mode` may be one of the following: 53 | * `off` 54 | * `daily_clean` 55 | * `sensitive` 56 | * `massage` 57 | * `whitening` 58 | * `deep_clean` 59 | * `tongue_cleaning` 60 | * `turbo` 61 | * `unknown` 62 | 63 | 64 | `sector` may be one of the following: 65 | * `sector_1` 66 | * `sector_2` 67 | * `sector_3` 68 | * `sector_4` 69 | * `sector_5` 70 | * `sector_6` 71 | * `sector_7` 72 | * `sector_8` 73 | * `unknown_1` 74 | * `unknown_2` 75 | * `unknown_3` 76 | * `unknown_4` 77 | * `unknown_5` 78 | * `last_sector` 79 | * `no_sector` -------------------------------------------------------------------------------- /docs/devices/RoomPresenceBeacon.md: -------------------------------------------------------------------------------- 1 | # Generic Room Presence BLE Beacon 2 | This module shall be used with [https://www.home-assistant.io/integrations/mqtt_room/](https://www.home-assistant.io/integrations/mqtt_room/) 3 | 4 | Please replace `FF:FF:FF:FF:FF:FF` as well as `ffffffffffff` with your devices mac. 5 | 6 | ## Device Config Entry 7 | ``` 8 | { 9 | "type": "RoomPresenceBeaconDevice", 10 | "friendlyName": "Keychain Beacon", 11 | "mac": "FF:FF:FF:FF:FF:FF", 12 | "room": "living_room" 13 | } 14 | ``` 15 | 16 | ## MQTT 17 | 18 | #### Autoconfig 19 | Sadly, the `mqtt_room` component doesn't allow mqtt auto configuration (yet?) 20 | 21 | #### Presence 22 | When an advertisement is received, this device module will calculate the approximate distance and publish it to 23 | `room_presence/room` where `room` is the room you've chosen in the Device Config Entry section 24 | 25 | The payload will look like this: 26 | ``` 27 | { 28 | "id": "ffffffffffff", 29 | "name": "Keychain Beacon", 30 | "rssi": -81, 31 | "uuid": "ffffffffffff", 32 | "distance": 10.467388920465797 33 | } 34 | ``` -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Cybele 2 | 3 | 4 | ## Hardware recommendations 5 | Due to reliability issues caused by unstable bluetooth adapter firmwares, 6 | it is recommended to only use Broadcom usb Bluetooth Adapters connected to a usb hub supported by [uhubctl](https://github.com/mvp/uhubctl). 7 | 8 | This enables Cybele to power-cycle a misbehaving adapter and _hopefully_ get everything back to a working state. 9 | 10 | A Raspberry Pi 3B+ for example comes with two uhubctl-supported USB Ports (next to the ethernet jack). 11 | Just don't forget to disable on-board bluetooth. 12 | 13 | ## General considerations 14 | BLE can and will spam quite a lot: 15 | ``` 16 | [18893.140515] Bluetooth: hci1: advertising data len corrected 17 | [18893.140534] Bluetooth: hci0: advertising data len corrected 18 | [18894.144542] Bluetooth: hci0: advertising data len corrected 19 | [18894.146531] Bluetooth: hci1: advertising data len corrected 20 | [18895.149522] Bluetooth: hci1: advertising data len corrected 21 | [18895.149562] Bluetooth: hci0: advertising data len corrected 22 | [18896.161532] Bluetooth: hci0: advertising data len corrected 23 | ``` 24 | 25 | While there is no way to suppress these messages from the kernel message buffer, you can at least filter them 26 | from your syslog, which is highly recommended on devices where there is not much storage and the storage available is flash. 27 | 28 | 29 | If you're using rsyslogd, create a file named `/etc/rsyslog.d/01-blocklist.conf` with the 30 | following contents and reload/restart the service: 31 | ``` 32 | :msg,contains,"advertising data len corrected" stop 33 | :msg,contains,"bt_err_ratelimited:" stop 34 | ``` 35 | 36 | 37 | ## Configuring Cybele 38 | 39 | A basic configuration file looks like this 40 | ``` 41 | { 42 | "mqtt": { 43 | "url": "mqtt://user:pass@foobar.example" 44 | }, 45 | "dongles": [ 46 | { 47 | "hciDevice": "hci0", 48 | "mode": "le", 49 | "troubleshooting": {}, 50 | "services": [], 51 | "devices": [ 52 | 53 | ] 54 | } 55 | ] 56 | } 57 | ``` 58 | 59 | #### Devices 60 | Documentation on possible devices can be found [here.](./devices) 61 | 62 | #### Troubleshooting 63 | This is an example troubleshooting configuration 64 | ``` 65 | { 66 | "scanRestartInterval": 300000, 67 | "brickWatchdog": { 68 | "timeout": 60000, 69 | "recoveryCommand": "/usr/sbin/uhubctl -a 2 -l 1-1.1 -p 2" 70 | } 71 | } 72 | ``` 73 | 74 | If `scanRestartInterval` is set, Cybele restarts scanning every `scanRestartInterval` milliseconds. 75 | This may or may not combat issues with dongles not scanning anymore. 76 | 77 | 78 | For issues that can't be fixed by restarting scanning, there is the `brickWatchdog`. 79 | If `brickWatchdog.timeout` milliseconds have passed without any activity from the adapter (no advertisings etc.), 80 | `brickWatchdog.recoveryCommand` gets executed. 81 | In this example, `brickWatchdog.recoveryCommand` will power-cycle the usb port, the corresponding usb bluetooth dongle is connected to. 82 | 83 | Cybele will then notice that the adapter has vanished, wait for it to reappear and set-up everything again. 84 | 85 | This of course requires a usb bluetooth adapter, a uhubctl-supported usb hub as well as constantly advertising BLE devices nearby, 86 | since otherwise the timeout will kick in, even though nothing is broken. 87 | -------------------------------------------------------------------------------- /lib/Cybele.js: -------------------------------------------------------------------------------- 1 | const dbus = require("dbus-native"); 2 | 3 | const DongleFactory = require("./DongleFactory"); 4 | 5 | const bus = dbus.systemBus(); 6 | 7 | 8 | class Cybele { 9 | /** 10 | * @param {object} options 11 | * @param options.mqttClient 12 | * @param options.config 13 | */ 14 | constructor(options) { 15 | this.config = options.config; 16 | this.mqttClient = options.mqttClient; 17 | this.blueZservice = bus.getService("org.bluez"); 18 | this.pathRoot = "/org/bluez/"; 19 | 20 | this.dongleFactory = new DongleFactory({ 21 | bus: bus, 22 | mqttClient: this.mqttClient 23 | }); 24 | 25 | this.dongles = {}; 26 | } 27 | 28 | async initialize() { 29 | for (const dongle of this.config.dongles) { 30 | await this.initializeDongle(dongle); 31 | } 32 | } 33 | 34 | async initializeDongle(dongleConfig) { 35 | const dongle = await this.dongleFactory.manufacture(dongleConfig); 36 | 37 | this.dongles[dongleConfig.hciDevice] = dongle; 38 | 39 | dongle.on("death", msg => { 40 | console.info("Dongle " + dongleConfig.hciDevice + " died"); 41 | 42 | dongle.destroy().then(() => { 43 | delete(this.dongles[dongleConfig]); 44 | 45 | setTimeout(() => { 46 | this.asyncWaitForDongle(dongleConfig.hciDevice, 15000).then(() => { 47 | this.initializeDongle(dongleConfig, err => { 48 | if (err) { 49 | console.error(err); 50 | } else { 51 | console.info("Successfully reinitialized dongle " + dongleConfig.hciDevice); 52 | } 53 | }); 54 | }).catch(err => { 55 | console.error({ 56 | msg: "FATAL: Failed to reinitialize dongle " + dongleConfig.hciDevice, 57 | err: err 58 | }); 59 | }); 60 | }, 2500); 61 | //Wait 2.5s for the dongle to disappear completely 62 | }).catch(err => { 63 | console.error(err); //TODO 64 | }); 65 | }); 66 | } 67 | 68 | asyncWaitForDongle(hciDevice, timeout) { 69 | const self = this; 70 | const start_time = new Date().getTime(); 71 | 72 | timeout = typeof timeout === "number" && timeout > 0 ? timeout : 10000; 73 | 74 | return new Promise(async function(resolve, reject) { 75 | while (true) { 76 | let result; 77 | 78 | if (new Date().getTime() > start_time + timeout) { 79 | return reject("Timeout exceeded"); 80 | } 81 | 82 | result = await new Promise((resolve, reject) => { 83 | self.blueZservice.getInterface( 84 | self.pathRoot + hciDevice, 85 | "org.bluez.Adapter1", 86 | (err, adapterInterface) => { 87 | if (!err && adapterInterface) { 88 | return resolve(true); 89 | } else { 90 | resolve(false); 91 | } 92 | }); 93 | }); 94 | 95 | if (result === true) { 96 | return resolve(true); 97 | } 98 | 99 | await new Promise(resolve => { 100 | return setTimeout(resolve, 100); 101 | }); 102 | } 103 | }); 104 | } 105 | } 106 | 107 | module.exports = Cybele; 108 | -------------------------------------------------------------------------------- /lib/DeviceFactory.js: -------------------------------------------------------------------------------- 1 | const Devices = require("./devices"); 2 | 3 | 4 | class DeviceFactory { 5 | /** 6 | * 7 | * @param {object} options 8 | * @param options.bus 9 | * @param {string} options.hciDevice 10 | * @param options.mqttClient 11 | * @param {Semaphore} options.semaphore 12 | */ 13 | constructor(options) { 14 | this.bus = options.bus; 15 | this.hciDevice = options.hciDevice; 16 | this.mqttClient = options.mqttClient; 17 | this.semaphore = options.semaphore; 18 | } 19 | 20 | /** 21 | * 22 | * @param {object} deviceConfig 23 | * @param {string} deviceConfig.type 24 | * @returns {Promise} 25 | */ 26 | async manufacture(deviceConfig) { 27 | const DeviceConstructor = Devices.DEVICE_BY_TYPE[deviceConfig.type]; 28 | let device; 29 | 30 | if (typeof DeviceConstructor === "function") { 31 | device = new DeviceConstructor(Object.assign({}, deviceConfig, { 32 | bus: this.bus, 33 | hciDevice: this.hciDevice, 34 | mqttClient: this.mqttClient, 35 | semaphore: this.semaphore 36 | })); 37 | 38 | await new Promise((resolve, reject) => { 39 | device.initialize(err => { 40 | if (!err) { 41 | resolve(device); 42 | } else { 43 | reject(err); 44 | } 45 | }); 46 | }); 47 | 48 | return device; 49 | } else { 50 | throw new Error("Invalid Device " + deviceConfig.type); 51 | } 52 | } 53 | } 54 | 55 | module.exports = DeviceFactory; 56 | -------------------------------------------------------------------------------- /lib/Dongle.js: -------------------------------------------------------------------------------- 1 | const child_process = require("child_process"); 2 | const events = require("events"); 3 | 4 | class Dongle extends events.EventEmitter { 5 | /** 6 | * @param {object} options 7 | * @param options.bus 8 | * @param {string} options.hciDevice 9 | * @param {"le"|"bredr"|"auto"} options.mode 10 | * @param {object} [options.troubleshooting] 11 | * @param {number} [options.troubleshooting.scanRestartInterval] 12 | * @param {object} [options.troubleshooting.brickWatchdog] 13 | * @param {number} [options.troubleshooting.brickWatchdog.timeout] 14 | * @param {string} [options.troubleshooting.brickWatchdog.recoveryCommand] 15 | * @param {Array} options.services 16 | * @param {Array} options.devices 17 | * @param {Semaphore} options.semaphore 18 | */ 19 | constructor(options) { 20 | super(); 21 | 22 | this.bus = options.bus; 23 | this.hciDevice = options.hciDevice; 24 | this.mode = options.mode; 25 | this.services = options.services; 26 | this.devices = options.devices; 27 | this.semaphore = options.semaphore; 28 | 29 | if (options.troubleshooting) { 30 | this.scanRestartInterval = options.troubleshooting.scanRestartInterval; 31 | this.brickWatchdog = options.troubleshooting.brickWatchdog; 32 | } 33 | 34 | this.blueZservice = this.bus.getService("org.bluez"); 35 | this.pathRoot = "/org/bluez/" + this.hciDevice; 36 | 37 | this.busListener = (msg) => { 38 | this.busMsgHandler(msg); 39 | }; 40 | 41 | this.restartDiscoveryTimeout = null; 42 | this.brickWatchdogTimeout = null; 43 | this.destroyed = false; 44 | } 45 | 46 | async initialize() { 47 | this.bus.connection.on("message", this.busListener.bind(this)); 48 | 49 | this.bus.addMatch("type='signal'"); 50 | 51 | await this.startDiscovery(); 52 | 53 | if (this.brickWatchdog && this.brickWatchdog.timeout) { 54 | this.brickWatchdogTick(); 55 | } 56 | } 57 | 58 | async destroy() { 59 | this.destroyed = true; 60 | clearTimeout(this.restartDiscoveryTimeout); 61 | clearTimeout(this.brickWatchdogTimeout); 62 | this.bus.connection.removeListener("message", this.busListener); 63 | 64 | for (const device of this.devices) { 65 | await new Promise((resolve) => { 66 | device.destroy((err) => { 67 | if (err) { 68 | console.error(err); //TODO: handle error 69 | } 70 | 71 | resolve(); 72 | }); 73 | }); 74 | } 75 | this.devices = []; 76 | 77 | for (const service of this.services) { 78 | await service.destroy(); 79 | } 80 | this.services = []; 81 | } 82 | 83 | async startDiscovery() { 84 | const adapterInterface = await new Promise((resolve, reject) => { 85 | this.blueZservice.getInterface(this.pathRoot, "org.bluez.Adapter1", (err, adapterInterface) => { 86 | if (!err && adapterInterface) { 87 | resolve(adapterInterface); 88 | } else { 89 | reject(err); 90 | } 91 | }); 92 | }); 93 | 94 | try { 95 | await new Promise((resolve, reject) => { 96 | adapterInterface.StopDiscovery(err => { 97 | err = Array.isArray(err) ? err.join(".") : err; 98 | 99 | if (!err || err === "No discovery started") { 100 | resolve(); 101 | } else { 102 | reject(err); 103 | } 104 | }); 105 | }); 106 | } catch (err) { 107 | await this.executeBrickWatchdog(); 108 | 109 | throw err; 110 | } 111 | 112 | await new Promise((resolve, reject) => { 113 | //https://git.kernel.org/pub/scm/bluetooth/bluez.git/tree/src/adapter.c#n1541 114 | //To get faster scanning without delays, we need to set at least one filter. 115 | //TODO: we _may_ need to clear all other filters? 116 | adapterInterface.SetDiscoveryFilter([["Transport", ["s", this.mode]], ["DuplicateData", ["b", true]]], err => { 117 | err = Array.isArray(err) ? err.join(".") : err; 118 | 119 | if (!err) { 120 | resolve(); 121 | } else { 122 | reject(); 123 | } 124 | }); 125 | }); 126 | 127 | await new Promise((resolve, reject) => { 128 | adapterInterface.StartDiscovery(err => { 129 | err = Array.isArray(err) ? err.join(".") : err; 130 | 131 | if (!err) { 132 | if (this.scanRestartInterval > 0 && this.destroyed === false) { 133 | this.restartDiscoveryTimeout = setTimeout(() => { 134 | if (this.destroyed === false) { 135 | this.startDiscovery(err => { 136 | err = Array.isArray(err) ? err.join(".") : err; 137 | 138 | if (err) { 139 | console.error(err); 140 | } 141 | }); 142 | } 143 | }, this.scanRestartInterval); 144 | } 145 | 146 | resolve(); 147 | } else { 148 | if (err === "Operation already in progress") { 149 | this.emit("death", err); 150 | resolve(); 151 | } else { 152 | reject(err); 153 | } 154 | } 155 | }); 156 | }); 157 | } 158 | 159 | busMsgHandler(msg) { 160 | if (msg?.path?.indexOf(this.pathRoot) === 0) { 161 | if (Array.isArray(msg.body)) { 162 | if (msg.body[0] === "org.bluez.Device1") { 163 | let dev = msg.path.split("/"); 164 | dev = dev[dev.length - 1]; 165 | 166 | const props = {}; 167 | if (Array.isArray(msg.body[1])) { //TODO: Write a working parser for this mess of arrays 168 | msg.body[1].forEach(prop => { 169 | if (Array.isArray(prop) && prop.length === 2 && Array.isArray(prop[1])) { 170 | const key = prop[0]; 171 | let val = prop[1][1]; 172 | 173 | if (Array.isArray(val)) { 174 | if (key === "ManufacturerData") { 175 | try { 176 | val = val[0][0][1][1][0]; 177 | } catch (e) { 178 | console.error(e); 179 | } 180 | } else if (key === "ServiceData") { 181 | try { 182 | val = { 183 | UUID: val[0][0][0], 184 | data: val[0][0][1][1][0] 185 | }; 186 | } catch (e) { 187 | console.error(e); 188 | } 189 | } else if (val.length === 1) { 190 | val = val[0]; 191 | } 192 | 193 | } 194 | 195 | props[key] = val; 196 | } 197 | }); 198 | } else { 199 | console.log("Unhandled Device msg:", msg, JSON.stringify(msg)); 200 | } 201 | 202 | if (this.brickWatchdogTimeout) { 203 | this.brickWatchdogTick(); 204 | } 205 | 206 | this.devices.forEach(d => { 207 | d.handleDbusMessage("org.bluez.Device1", dev, props); 208 | }); 209 | } else if (msg.body[0] === "org.bluez.GattCharacteristic1") { 210 | const splitPath = msg.path.split("/"); 211 | const dev = splitPath[4]; 212 | const characteristic = [splitPath[5], splitPath[6]].join("/"); 213 | 214 | if (Array.isArray(msg.body[1]) && Array.isArray(msg.body[1][0]) && msg.body[1][0][0] === "Value") { 215 | const props = {}; 216 | const value = msg.body[1][0][1][1][0]; //TODO: Will this break on non-buffer values? 217 | 218 | props[characteristic] = value; 219 | 220 | this.devices.forEach(d => { 221 | d.handleDbusMessage("org.bluez.GattCharacteristic1", dev, props); 222 | }); 223 | } 224 | } else { 225 | if (msg && Array.isArray(msg.body) && msg.body[0] === "org.bluez.Adapter1") { 226 | if (JSON.stringify(msg).includes("[\"Powered\",[[{\"type\":\"b\",\"child\":[]}],[false]]]")) { 227 | //TODO: improve condition 228 | 229 | this.emit("death", msg); 230 | } else { 231 | //unhandled adapter message 232 | } 233 | } else { 234 | console.log("Unhandled other msg:", msg, JSON.stringify(msg)); 235 | } 236 | } 237 | } else { 238 | console.log(msg); 239 | } 240 | } else { 241 | //general dbus messages 242 | } 243 | } 244 | 245 | brickWatchdogTick() { 246 | clearTimeout(this.brickWatchdogTimeout); 247 | 248 | this.brickWatchdogTimeout = setTimeout(() => { 249 | console.error("Brick Watchdog executed for " + this.hciDevice); 250 | 251 | this.executeBrickWatchdog().then(() => {}); 252 | 253 | }, this.brickWatchdog.timeout); 254 | } 255 | 256 | async executeBrickWatchdog() { 257 | return new Promise((resolve) => { 258 | if (this.brickWatchdog.recoveryCommand) { 259 | child_process.exec(this.brickWatchdog.recoveryCommand, (err, stdout, stderr) => { 260 | console.info(err, stdout, stderr); 261 | 262 | resolve(); 263 | }); 264 | } else { 265 | resolve(); 266 | } 267 | }); 268 | } 269 | } 270 | 271 | 272 | module.exports = Dongle; 273 | -------------------------------------------------------------------------------- /lib/DongleFactory.js: -------------------------------------------------------------------------------- 1 | const Semaphore = require("semaphore"); 2 | 3 | const DeviceFactory = require("./DeviceFactory"); 4 | const Dongle = require("./Dongle"); 5 | const ServiceFactory = require("./ServiceFactory"); 6 | 7 | class DongleFactory { 8 | /** 9 | * 10 | * @param {object} options 11 | * @param options.bus 12 | * @param options.mqttClient 13 | */ 14 | constructor(options) { 15 | this.bus = options.bus; 16 | this.mqttClient = options.mqttClient; 17 | } 18 | 19 | /** 20 | * 21 | * @param {object} dongleConfig 22 | * @param {string} dongleConfig.hciDevice 23 | * @param {"le"|"bredr"|"auto"} dongleConfig.mode 24 | * @param {object} [dongleConfig.troubleshooting] 25 | * @param {number} [dongleConfig.troubleshooting.scanRestartInterval] 26 | * @param {object} [dongleConfig.troubleshooting.brickWatchdog] 27 | * @param {number} [dongleConfig.troubleshooting.brickWatchdog.timeout] 28 | * @param {string} [dongleConfig.troubleshooting.brickWatchdog.recoveryCommand] 29 | * @param {Array} dongleConfig.services [Service configs] 30 | * @param {Array} dongleConfig.devices [Device configs] 31 | * @returns {Promise} 32 | */ 33 | async manufacture(dongleConfig) { 34 | const services = []; 35 | const devices = []; 36 | const semaphore = new Semaphore(1); 37 | const serviceFactory = new ServiceFactory({ 38 | bus: this.bus, 39 | hciDevice: dongleConfig.hciDevice 40 | }); 41 | const deviceFactory = new DeviceFactory({ 42 | bus: this.bus, 43 | hciDevice: dongleConfig.hciDevice, 44 | mqttClient: this.mqttClient, 45 | semaphore: semaphore 46 | }); 47 | 48 | for (const serviceConfig of dongleConfig.services) { 49 | services.push(await serviceFactory.manufacture(serviceConfig)); 50 | } 51 | 52 | for (const deviceConfig of dongleConfig.devices) { 53 | devices.push(await deviceFactory.manufacture(deviceConfig)); 54 | } 55 | 56 | const dongle = new Dongle({ 57 | bus: this.bus, 58 | hciDevice: dongleConfig.hciDevice, 59 | mode: dongleConfig.mode, 60 | troubleshooting: dongleConfig.troubleshooting, 61 | services: services, 62 | devices: devices, 63 | semaphore: semaphore 64 | }); 65 | 66 | await dongle.initialize(); 67 | 68 | return dongle; 69 | } 70 | } 71 | 72 | 73 | module.exports = DongleFactory; 74 | -------------------------------------------------------------------------------- /lib/MqttHandler.js: -------------------------------------------------------------------------------- 1 | class MqttHandler { 2 | /** 3 | * @param {object} options 4 | * @param {Device} options.device 5 | * @param {string} options.prefix 6 | */ 7 | constructor(options) { 8 | this.device = options.device; 9 | this.prefix = options.prefix; 10 | 11 | this.mqttClient = this.device.mqttClient; 12 | 13 | this.writableTopics = { 14 | state: "cybele/" + this.prefix + "/" + this.device.id + "/state", 15 | attributes: "cybele/" + this.prefix + "/" + this.device.id + "/attributes", 16 | presence: "cybele/" + this.prefix + "/" + this.device.id + "/presence" 17 | }; 18 | 19 | this.topicHandlers = {}; 20 | this.topics = []; 21 | } 22 | 23 | registerTopicHandler(suffix, handler) { 24 | const topic = this.getTopicHandlerTopic(suffix); 25 | 26 | this.topicHandlers[topic] = handler; 27 | this.topics = Object.keys(this.topicHandlers); 28 | } 29 | 30 | getTopicHandlerTopic(suffix) { 31 | return "cybele/" + this.prefix + "/" + this.device.id + "/" + suffix; 32 | } 33 | 34 | initialize(callback) { 35 | this.setupAutodiscovery(err => { 36 | if (!err) { 37 | if (Array.isArray(this.topics) && this.topics.length > 0) { 38 | this.mqttClient.subscribe(this.topics, {}, callback); 39 | } else { 40 | callback(); 41 | } 42 | } else { 43 | callback(err); 44 | } 45 | }); 46 | } 47 | 48 | handleMessage(topic, message) { 49 | if (this.topics.includes(topic)) { 50 | try { 51 | const parsedMessage = JSON.parse(message); 52 | this.topicHandlers[topic](parsedMessage); 53 | } catch (e) { 54 | this.topicHandlers[topic](message); 55 | } 56 | } 57 | } 58 | 59 | setupAutodiscovery(callback) { 60 | callback(); 61 | } 62 | 63 | updatePresence(isPresent) { 64 | const payload = isPresent === true ? "online" : "offline"; 65 | 66 | this.device.mqttClient.publish(this.writableTopics.presence, payload, {retain: true}, err => { 67 | if (err) { 68 | console.error(err); 69 | } 70 | }); 71 | } 72 | } 73 | 74 | 75 | module.exports = MqttHandler; 76 | -------------------------------------------------------------------------------- /lib/ServiceFactory.js: -------------------------------------------------------------------------------- 1 | const Services = require("./services"); 2 | 3 | class ServiceFactory { 4 | /** 5 | * 6 | * @param {object} options 7 | * @param options.bus 8 | * @param {string} options.hciDevice 9 | */ 10 | constructor(options) { 11 | this.bus = options.bus; 12 | this.hciDevice = options.hciDevice; 13 | } 14 | 15 | /** 16 | * 17 | * @param {object} serviceConfig 18 | * @param {string} serviceConfig.type 19 | * @returns {Promise} 20 | */ 21 | async manufacture(serviceConfig) { 22 | const ServiceConstructor = Services.SERVICE_BY_TYPE[serviceConfig.type]; 23 | let service; 24 | 25 | if (typeof ServiceConstructor === "function") { 26 | service = new ServiceConstructor(Object.assign({},serviceConfig, { 27 | bus: this.bus, 28 | hciDevice: this.hciDevice 29 | })); 30 | 31 | await service.initialize(); 32 | return service; 33 | } else { 34 | throw new Error("Invalid Service " + serviceConfig.type); 35 | } 36 | } 37 | 38 | } 39 | 40 | module.exports = ServiceFactory; 41 | -------------------------------------------------------------------------------- /lib/Tools.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | sleep: function(delay) { 3 | return new Promise(function(resolve, reject) { 4 | setTimeout(() => { 5 | resolve(); 6 | 7 | }, delay); 8 | }); 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /lib/devices/BatteryPoweredDevice.js: -------------------------------------------------------------------------------- 1 | const PollingDevice = require("./PollingDevice"); 2 | 3 | class BatteryPoweredDevice extends PollingDevice { 4 | /** 5 | * 6 | * @param {object} options 7 | * @param {number} options.pollingInterval 8 | * @param {boolean} options.disconnectAfterFetch 9 | * @param {number} [options.maxDelayAfterConnect] 10 | */ 11 | constructor(options) { 12 | super(options); 13 | 14 | this.disconnectAfterFetch = options.disconnectAfterFetch; 15 | this.maxDelayAfterConnect = options.maxDelayAfterConnect !== undefined ? options.maxDelayAfterConnect : 5000; 16 | } 17 | 18 | initialize(callback) { 19 | this.mqttClient.publish("homeassistant/sensor/bat_" + this.id + "/config", JSON.stringify({ 20 | "state_topic": BatteryPoweredDevice.MQTT_PREFIX + this.id + "/state", 21 | "name": this.friendlyName + " Battery", 22 | "unique_id": "cybele_bat_" + this.id, 23 | "platform": "mqtt", 24 | "unit_of_measurement": "%", 25 | "device_class": "battery" 26 | }), {retain: true}, err => { 27 | if (!err) { 28 | this.queuePolling(); 29 | } 30 | 31 | callback(err); 32 | }); 33 | } 34 | 35 | async poll() { //TODO: PollingDevice.poll isn't async yet 36 | let deviceInterface; 37 | let batteryInterface; 38 | 39 | try { 40 | deviceInterface = await this.getDBusInterfaceAsync( 41 | this.pathRoot + "/dev_" + this.macInDbusNotation, 42 | "org.bluez.Device1" 43 | ); 44 | } catch (e) { 45 | //If there is no interface, it means that the device is out of range 46 | return this.queuePolling(); 47 | } 48 | 49 | try { 50 | await this.connectDeviceAsync(deviceInterface, this.maxDelayAfterConnect); 51 | } catch (e) { 52 | if (this.disconnectAfterFetch) { 53 | await this.takeSemaphoreAsync(); 54 | deviceInterface.Disconnect(err => { 55 | this.semaphore.leave(); 56 | if (err) { 57 | console.error(err); 58 | } 59 | 60 | this.queuePolling(); 61 | }); 62 | } else { 63 | this.queuePolling(); 64 | } 65 | 66 | return; 67 | } 68 | 69 | try { 70 | batteryInterface = await this.getDBusInterfaceAsync( 71 | this.pathRoot + "/dev_" + this.macInDbusNotation, 72 | "org.bluez.Battery1" 73 | ); 74 | } catch (e) { 75 | console.error(e); 76 | 77 | if (this.disconnectAfterFetch) { 78 | await this.takeSemaphoreAsync(); 79 | 80 | return deviceInterface.Disconnect(err => { 81 | this.semaphore.leave(); 82 | if (err) { 83 | console.error(err); 84 | } 85 | 86 | this.queuePolling(); 87 | }); 88 | } else { 89 | return this.queuePolling(); 90 | } 91 | } 92 | 93 | batteryInterface.Percentage(async (err, value) => { 94 | if (!err && value) { 95 | this.mqttClient.publish(BatteryPoweredDevice.MQTT_PREFIX + this.id +"/state", value.toString(), {retain: true}, err => { 96 | if (err) { 97 | console.error(err); 98 | } 99 | } 100 | ); 101 | } 102 | 103 | if (this.disconnectAfterFetch) { 104 | await this.takeSemaphoreAsync(); 105 | deviceInterface.Disconnect(err => { 106 | this.semaphore.leave(); 107 | if (err) { 108 | console.error(err); 109 | } 110 | 111 | this.queuePolling(); 112 | }); 113 | } else { 114 | this.queuePolling(); 115 | } 116 | }); 117 | } 118 | } 119 | 120 | BatteryPoweredDevice.MQTT_PREFIX = "cybele/battery_powered_ble_device/"; 121 | 122 | module.exports = BatteryPoweredDevice; 123 | -------------------------------------------------------------------------------- /lib/devices/BodyScale/BodyMetrics.js: -------------------------------------------------------------------------------- 1 | //Ported from https://github.com/wiecosystem/Bluetooth/blob/master/sandbox/body_metrics.py 2 | 3 | class BodyMetrics { 4 | /** 5 | * 6 | * @param {object} options 7 | * @param {number} options.height //in cm 8 | * @param {number} options.age 9 | * @param {"M"|"F"} options.sex 10 | */ 11 | constructor(options) { 12 | this.height = parseFloat(options.height); 13 | this.age = parseFloat(options.age); 14 | this.sex = options.sex; 15 | 16 | if (isNaN(this.height) || this.height > 220) { 17 | throw new Error("220cm height max"); 18 | } 19 | 20 | if (isNaN(this.age) || this.age > 99) { 21 | throw new Error("99 years age max"); 22 | } 23 | 24 | if (this.sex !== "M" && this.sex !== "F") { 25 | throw new Error("invalid sex"); 26 | } 27 | } 28 | 29 | getLBMCoefficient(weight, impedance) { 30 | let lbm = (this.height * 9.058 / 100) * (this.height / 100); 31 | lbm += weight * 0.32 + 12.226; 32 | lbm -= impedance * 0.0068; 33 | lbm -= this.age * 0.0542; 34 | 35 | return lbm; 36 | } 37 | 38 | 39 | getBMR(weight) { 40 | let bmr; 41 | 42 | switch (this.sex) { 43 | case "M": 44 | bmr = 877.8 + weight * 14.916; 45 | bmr -= this.height * 0.726; 46 | bmr -= this.age * 8.976; 47 | if (bmr > 2322) { 48 | bmr = 5000; 49 | } 50 | break; 51 | case "F": 52 | bmr = 864.6 + weight * 10.2036; 53 | bmr -= this.height * 0.39336; 54 | bmr -= this.age * 6.204; 55 | if (bmr > 2996) { 56 | bmr = 5000; 57 | } 58 | break; 59 | } 60 | 61 | return BodyMetrics.CHECK_OVERFLOW(bmr, 500, 10000); 62 | } 63 | 64 | getBMRScale(weight) { 65 | let bmrScale; 66 | 67 | Object.keys(BodyMetrics.BMR_COEFFICIENTS_BY_AGE_AND_SEX[this.sex]).some(k => { 68 | if (k > this.age) { 69 | bmrScale = weight * BodyMetrics.BMR_COEFFICIENTS_BY_AGE_AND_SEX[this.sex][k]; 70 | return true; 71 | } 72 | }); 73 | 74 | return bmrScale; 75 | } 76 | 77 | getFatPercentage(weight, impedance) { 78 | const LBM = this.getLBMCoefficient(weight, impedance); 79 | let negativeConstant; 80 | let coefficient; 81 | let fatPercentage; 82 | 83 | if (this.sex === "F") { 84 | if (this.age <= 49) { 85 | negativeConstant = 9.25; 86 | } else { 87 | negativeConstant = 7.25; 88 | } 89 | } else { 90 | negativeConstant = 0.8; 91 | } 92 | 93 | if (this.sex === "M" && weight < 61) { 94 | coefficient = 0.98; 95 | } else if (this.sex === "F" && weight > 60) { 96 | coefficient = 0.96; 97 | if (this.height > 160) { 98 | coefficient = coefficient*1.03; 99 | } 100 | } else if (this.sex === "F" && weight < 50) { 101 | coefficient = 1.02; 102 | if (this.height > 160) { 103 | coefficient = coefficient*1.03; 104 | } 105 | } else { 106 | coefficient = 1.0; 107 | } 108 | 109 | fatPercentage = (1.0 - (((LBM - negativeConstant) * coefficient) / weight)) * 100; 110 | if (fatPercentage > 63) { 111 | fatPercentage = 75; 112 | } 113 | 114 | return BodyMetrics.CHECK_OVERFLOW(fatPercentage, 5, 75); 115 | } 116 | 117 | getFatPercentageScale() { 118 | let scale; 119 | BodyMetrics.FAT_PERCENTAGE_SCALES.some(s => { 120 | if (this.age >= s.min && this.age <= s.max) { 121 | scale = s[this.sex]; 122 | return true; 123 | } 124 | }); 125 | 126 | return scale; 127 | } 128 | 129 | getWaterPercentage(weight, impedance) { 130 | let waterPercentage = (100 - this.getFatPercentage(weight, impedance)) * 0.7; 131 | let coefficient; 132 | 133 | if (waterPercentage <= 50) { 134 | coefficient = 1.02; 135 | } else { 136 | coefficient = 0.98; 137 | } 138 | 139 | if (waterPercentage * coefficient >= 65) { 140 | waterPercentage = 75; 141 | } 142 | 143 | return BodyMetrics.CHECK_OVERFLOW(waterPercentage * coefficient, 35, 75); 144 | } 145 | 146 | getWaterPercentageScale() { 147 | return [53, 67]; //TODO: ??? 148 | } 149 | 150 | getBoneMass(weight, impedance) { 151 | let base; 152 | let boneMass; 153 | 154 | if (this.sex === "F") { 155 | base = 0.245691014; 156 | } else { 157 | base = 0.18016894; 158 | } 159 | 160 | boneMass = (base - (this.getLBMCoefficient(weight, impedance) * 0.05158)) * -1; 161 | 162 | if (boneMass > 2.2) { 163 | boneMass += 0.1; 164 | } else { 165 | boneMass -= 0.1; 166 | } 167 | 168 | if (this.sex === "F" && boneMass > 5.1) { 169 | boneMass = 8; 170 | } else if (this.sex === "M" && boneMass > 5.2) { 171 | boneMass = 8; 172 | } 173 | 174 | return BodyMetrics.CHECK_OVERFLOW(boneMass, 0.5, 8); 175 | } 176 | 177 | getBoneMassScale(weight) { 178 | let scale; 179 | BodyMetrics.BONE_MASS_SCALES.some(s => { 180 | if (weight >= s[this.sex].min) { 181 | scale = [s[this.sex].optimal -1, s[this.sex].optimal +1]; 182 | return true; 183 | } 184 | }); 185 | 186 | return scale; 187 | } 188 | 189 | getMuscleMass(weight, impedance) { 190 | let muscleMass = weight - ((this.getFatPercentage(weight, impedance) * 0.01) * weight) - this.getBoneMass(weight, impedance); 191 | 192 | if (this.sex === "F" && muscleMass >= 84) { 193 | muscleMass = 120; 194 | } else if (this.sex === "M" && muscleMass >= 93.5) { 195 | muscleMass = 120; 196 | } 197 | 198 | return BodyMetrics.CHECK_OVERFLOW(muscleMass, 10, 120); 199 | } 200 | 201 | getMuscleMassScale() { 202 | let scale; 203 | BodyMetrics.MUSCLE_MASS_SCALES.some(s => { 204 | if (this.height >= s.min) { 205 | scale = s[this.sex]; 206 | return true; 207 | } 208 | }); 209 | 210 | return scale; 211 | } 212 | 213 | getVisceralFat(weight) { 214 | let subsubcalc; 215 | let subcalc; 216 | let vfal; 217 | 218 | if (this.sex === "F") { 219 | if (weight > (13 - (this.height * 0.5)) * -1) { 220 | subsubcalc = ((this.height * 1.45) + (this.height * 0.1158) * this.height) - 120; 221 | subcalc = this.weight * 500 / subsubcalc; 222 | vfal = (subcalc - 6) + (this.age * 0.07); 223 | } else { 224 | subcalc = 0.691 + (this.height * -0.0024) + (this.height * -0.0024); 225 | vfal = (((this.height * 0.027) - (subcalc * this.weight)) * -1) + (this.age * 0.07) - this.age; 226 | } 227 | } else { 228 | if (this.height < weight * 1.6) { 229 | subcalc = ((this.height * 0.4) - (this.height * (this.height * 0.0826))) * -1; 230 | vfal = ((weight * 305) / (subcalc + 48)) - 2.9 + (this.age * 0.15); 231 | } else { 232 | subcalc = 0.765 + this.height * -0.0015; 233 | vfal = (((this.height * 0.143) - (weight * subcalc)) * -1) + (this.age * 0.15) - 5.0; 234 | } 235 | } 236 | 237 | return BodyMetrics.CHECK_OVERFLOW(vfal, 1, 50); 238 | } 239 | 240 | getVisceralFatScale() { 241 | return [10, 15]; 242 | } 243 | 244 | getBMI(weight) { 245 | return BodyMetrics.CHECK_OVERFLOW(weight/((this.height/100)*(this.height/100)), 10, 90); 246 | } 247 | 248 | getBMIScale() { 249 | return [18.5, 25, 28, 32]; 250 | } 251 | 252 | //Get ideal weight (just doing a reverse BMI, should be something better) 253 | getIdealWeight() { 254 | return BodyMetrics.CHECK_OVERFLOW((22*this.height)*this.height/10000, 5.5, 198); 255 | } 256 | 257 | //Get ideal weight scale (BMI scale converted to weights) 258 | getIdealWeightScale() { 259 | return this.getBMIScale().map(v => { 260 | return (v * this.height) * this.height / 10000; 261 | }); 262 | } 263 | 264 | //Get fat mass to ideal (guessing mi fit formula) 265 | getFatMassToIdeal(weight, impedance) { 266 | const mass = (weight * (this.getFatPercentage(weight, impedance) / 100)) - (this.weight * (this.getFatPercentageScale()[2] / 100)); 267 | 268 | if (mass < 0) { 269 | return {type: "to_gain", mass: mass*-1}; 270 | } else { 271 | return {type: "to_lose", mass: mass}; 272 | } 273 | } 274 | 275 | //Get protetin percentage (warn: guessed formula) 276 | getProteinPercentage(weight, impedance) { 277 | let proteinPercentage = 100 - (Math.floor(this.getFatPercentage(weight, impedance) * 100) / 100); 278 | proteinPercentage -= Math.floor(this.getWaterPercentage(weight, impedance) * 100) / 100; 279 | proteinPercentage -= Math.floor((this.getBoneMass(weight, impedance)/weight*100) * 100) / 100; 280 | 281 | return proteinPercentage; 282 | } 283 | 284 | //Get protein scale (hardcoded in mi fit) 285 | getProteinPercentageScale() { 286 | return [16, 20]; 287 | } 288 | 289 | getBodyType(weight, impedance) { 290 | const fatPercentage = this.getFatPercentage(weight, impedance); 291 | const muscleMass = this.getMuscleMass(weight, impedance); 292 | let factor; 293 | 294 | if (fatPercentage > this.getFatPercentageScale()[2]) { 295 | factor = 0; 296 | } else if (fatPercentage < this.getFatPercentageScale()[1]) { 297 | factor = 2; 298 | } else { 299 | factor = 1; 300 | } 301 | 302 | if (muscleMass > this.getMuscleMassScale()[1]) { 303 | return BodyMetrics.BODY_TYPES[2 + (factor * 3)]; 304 | } else if (muscleMass < this.getMuscleMassScale()[0]) { 305 | return BodyMetrics.BODY_TYPES[(factor * 3)]; 306 | } else { 307 | return BodyMetrics.BODY_TYPES[1 + (factor * 3)]; 308 | } 309 | } 310 | 311 | getAllMetrics(weight, impedance) { 312 | const measurements = { 313 | LBM: this.getLBMCoefficient(weight, impedance).toFixed(2), 314 | BodyFatPercentage: { 315 | value: this.getFatPercentage(weight, impedance).toFixed(2), 316 | scale: this.getFatPercentageScale() 317 | }, 318 | WaterPercentage: { 319 | value: this.getWaterPercentage(weight, impedance).toFixed(2), 320 | scale: this.getWaterPercentageScale() 321 | }, 322 | BoneMass: { 323 | value: this.getBoneMass(weight, impedance).toFixed(2), 324 | scale: this.getBoneMassScale(weight) 325 | }, 326 | MuscleMass: { 327 | value: this.getMuscleMass(weight, impedance).toFixed(2), 328 | scale: this.getMuscleMassScale() 329 | }, 330 | VisceralFat: { 331 | value: this.getVisceralFat(weight).toFixed(2), 332 | scale: this.getVisceralFatScale() 333 | }, 334 | BMI: { 335 | value: this.getBMI(weight).toFixed(2), 336 | scale: this.getBMIScale() 337 | }, 338 | BMR: { 339 | value: this.getBMR(weight).toFixed(2), 340 | scale: this.getBMRScale(weight).toFixed(2) 341 | }, 342 | IdealWeight: { 343 | value: this.getIdealWeight().toFixed(2), 344 | scale: this.getIdealWeightScale().map(v => { 345 | return v.toFixed(2); 346 | }) 347 | }, 348 | BodyType: this.getBodyType(weight, impedance) 349 | }; 350 | 351 | return { 352 | lbm: measurements.LBM, 353 | bmi: measurements.BMI.value, 354 | 355 | fat_pct: measurements.BodyFatPercentage.value, 356 | water_pct: measurements.WaterPercentage.value, 357 | 358 | bone_mass_kg: measurements.BoneMass.value, 359 | muscle_mass_kg: measurements.MuscleMass.value, 360 | visceral_fat_mass_kg: measurements.VisceralFat.value, 361 | 362 | bmr_kcal: measurements.BMR.value, 363 | 364 | fat: BodyMetrics.GET_SCALE_VALUE_DESCRIPTION( 365 | measurements.BodyFatPercentage.value, 366 | measurements.BodyFatPercentage.scale, 367 | BodyMetrics.SCALE_DESCRIPTIONS.FAT_PCT 368 | ), 369 | water: BodyMetrics.GET_SCALE_VALUE_DESCRIPTION( 370 | measurements.WaterPercentage.value, 371 | measurements.WaterPercentage.scale, 372 | BodyMetrics.SCALE_DESCRIPTIONS.WATER_PCT 373 | ), 374 | bone_mass: BodyMetrics.GET_SCALE_VALUE_DESCRIPTION( 375 | measurements.BoneMass.value, 376 | measurements.BoneMass.scale, 377 | BodyMetrics.SCALE_DESCRIPTIONS.BONE_MASS 378 | ), 379 | muscle_mass: BodyMetrics.GET_SCALE_VALUE_DESCRIPTION( 380 | measurements.MuscleMass.value, 381 | measurements.MuscleMass.scale, 382 | BodyMetrics.SCALE_DESCRIPTIONS.MUSCLE_MASS 383 | ), 384 | visceral_fat: BodyMetrics.GET_SCALE_VALUE_DESCRIPTION( 385 | measurements.VisceralFat.value, 386 | measurements.VisceralFat.scale, 387 | BodyMetrics.SCALE_DESCRIPTIONS.VISCERAL_FAT_MASS 388 | ), 389 | bmi_class: BodyMetrics.GET_SCALE_VALUE_DESCRIPTION( 390 | measurements.BMI.value, 391 | measurements.BMI.scale, 392 | BodyMetrics.SCALE_DESCRIPTIONS.BMI 393 | ), 394 | body_type: measurements.BodyType 395 | }; 396 | 397 | } 398 | } 399 | 400 | 401 | BodyMetrics.GET_SCALE_VALUE_DESCRIPTION = function(val, scale, descriptions) { 402 | let desc; 403 | scale.some((s, i) => { 404 | if (val <= s) { 405 | desc = descriptions[i]; 406 | return true; 407 | } 408 | }); 409 | 410 | if (!desc) { 411 | desc = descriptions[descriptions.length -1]; 412 | } 413 | 414 | return desc; 415 | }; 416 | 417 | BodyMetrics.SCALE_DESCRIPTIONS = { 418 | FAT_PCT: [ 419 | "Very Low", 420 | "Low", 421 | "Normal", 422 | "High", 423 | "Very High" 424 | ], 425 | WATER_PCT: [ 426 | "Insufficient", 427 | "Normal", 428 | "Good" 429 | ], 430 | BONE_MASS: [ 431 | "Insufficient", 432 | "Normal", 433 | "Good" 434 | ], 435 | MUSCLE_MASS: [ 436 | "Insufficient", 437 | "Normal", 438 | "Good" 439 | ], 440 | VISCERAL_FAT_MASS: [ 441 | "Normal", 442 | "High", 443 | "Very High" 444 | ], 445 | BMI: [ 446 | "Underweight", 447 | "Normal", 448 | "Overweight", 449 | "Obese", 450 | "Morbidly Obese" 451 | ], 452 | BMR: [ 453 | "Insufficient", 454 | "Normal" 455 | ] 456 | }; 457 | 458 | 459 | BodyMetrics.BODY_TYPES = [ 460 | "obese", 461 | "overweight", 462 | "thick-set", 463 | "lack-exerscise", 464 | "balanced", 465 | "balanced-muscular", 466 | "skinny", 467 | "balanced-skinny", 468 | "skinny-muscular" 469 | ]; 470 | 471 | 472 | 473 | BodyMetrics.MUSCLE_MASS_SCALES = [ 474 | {"min": 170, "F": [36.5, 42.5], "M": [49.5, 59.4]}, 475 | {"min": 160, "F": [32.9, 37.5], "M": [44.0, 52.4]}, 476 | {"min": 0, "F": [29.1, 34.7], "M": [38.5, 46.5]} 477 | ]; 478 | 479 | BodyMetrics.BONE_MASS_SCALES = [ 480 | {"F": {"min": 60, "optimal": 2.5}, "M": {"min": 75, "optimal": 3.2}}, 481 | {"F": {"min": 45, "optimal": 2.2}, "M": {"min": 69, "optimal": 2.9}}, 482 | {"F": {"min": 0, "optimal": 1.8}, "M": {"min": 0, "optimal": 2.5}} 483 | ]; 484 | 485 | BodyMetrics.FAT_PERCENTAGE_SCALES = [ 486 | {"min": 0, "max": 20, "F": [18, 23, 30, 35], "M": [8, 14, 21, 25]}, 487 | {"min": 21, "max": 25, "F": [19, 24, 30, 35], "M": [10, 15, 22, 26]}, 488 | {"min": 26, "max": 30, "F": [20, 25, 31, 36], "M": [11, 16, 21, 27]}, 489 | {"min": 31, "max": 35, "F": [21, 26, 33, 36], "M": [13, 17, 25, 28]}, 490 | {"min": 46, "max": 40, "F": [22, 27, 34, 37], "M": [15, 20, 26, 29]}, 491 | {"min": 41, "max": 45, "F": [23, 28, 35, 38], "M": [16, 22, 27, 30]}, 492 | {"min": 46, "max": 50, "F": [24, 30, 36, 38], "M": [17, 23, 29, 31]}, 493 | {"min": 51, "max": 55, "F": [26, 31, 36, 39], "M": [19, 25, 30, 33]}, 494 | {"min": 56, "max": 100, "F": [27, 32, 37, 40], "M": [21, 26, 31, 34]}, 495 | ]; 496 | 497 | BodyMetrics.BMR_COEFFICIENTS_BY_AGE_AND_SEX = { 498 | "M": {12: 36, 15: 30, 17: 26, 29: 23, 50: 21, 120: 20}, 499 | "F": {12: 34, 15: 29, 17: 24, 29: 22, 50: 20, 120: 19} 500 | }; 501 | 502 | BodyMetrics.CHECK_OVERFLOW = function(val, min, max) { 503 | if (val < min) { 504 | return min; 505 | } else if (val > max) { 506 | return max; 507 | } else { 508 | return val; 509 | } 510 | }; 511 | 512 | module.exports = BodyMetrics; 513 | -------------------------------------------------------------------------------- /lib/devices/BodyScale/MiBodyScaleDevice.js: -------------------------------------------------------------------------------- 1 | const Device = require("../Device"); 2 | 3 | const BodyMetrics = require("./BodyMetrics"); 4 | 5 | class MiBodyScaleDevice extends Device { 6 | /** 7 | * 8 | * @param {object} options 9 | * @param {Date} options.userBirthday 10 | * @param {number} options.userHeight 11 | * @param {"M"|"F"} options.userSex 12 | */ 13 | constructor(options) { 14 | super(options); 15 | 16 | this.userBirthday = new Date(options.userBirthday); 17 | this.userHeight = options.userHeight; 18 | this.userSex = options.userSex; 19 | 20 | this.lastStatePublish = new Date(0); 21 | } 22 | 23 | initialize(callback) { 24 | this.mqttClient.publish("homeassistant/sensor/body_scale_" + this.id + "/config", JSON.stringify({ 25 | "state_topic": "cybele/body_scale/" + this.id + "/state", 26 | "json_attributes_topic": "cybele/body_scale/" + this.id + "/attributes", 27 | "name": this.friendlyName, 28 | "unique_id": "cybele_body_scale_" + this.id, 29 | "platform": "mqtt", 30 | "unit_of_measurement": "kg", //TODO 31 | "icon": "mdi:scale-bathroom" 32 | }), {retain: true}, err => { 33 | callback(err); 34 | }); 35 | } 36 | 37 | handleAdvertisingForDevice(props) { 38 | super.handleAdvertisingForDevice(props); 39 | //TODO: Since this is a generic Body Composition characteristic, it might make sense to extend this class to 40 | // support all kinds of standard body composition scales 41 | if ( 42 | props.ServiceData && 43 | props.ServiceData.UUID === "0000181b-0000-1000-8000-00805f9b34fb" && 44 | Buffer.isBuffer(props.ServiceData.data) 45 | ) { 46 | const parsedData = this.parseServiceData(props.ServiceData.data); 47 | 48 | if (parsedData) { 49 | const now = new Date(); 50 | if (now.getTime() - 10000 > this.lastStatePublish.getTime()) { 51 | this.lastStatePublish = now; 52 | 53 | this.mqttClient.publish( 54 | "cybele/body_scale/" + this.id + "/state", 55 | parsedData.weight.toFixed(2), 56 | {retain: true} 57 | ); 58 | this.mqttClient.publish( 59 | "cybele/body_scale/" + this.id + "/attributes", 60 | JSON.stringify(parsedData.attributes), 61 | {retain: true} 62 | ); 63 | } 64 | } 65 | } 66 | } 67 | 68 | parseServiceData(data) { 69 | let unit; 70 | 71 | //TODO: Use this value. Handle scales with non-kg-measurements 72 | if ((data[0] & (1<<4)) !== 0) { // Chinese Catty 73 | unit = "jin"; 74 | } else if ((data[0] & 0x0F) === 0x03) { // Imperial pound 75 | unit = "lbs"; 76 | } else if ((data[0] & 0x0F) === 0x02) { // MKS kg 77 | unit = "kg"; 78 | } else { 79 | unit = "???"; 80 | } 81 | 82 | const state = { 83 | isStabilized: ((data[1] & (1 << 5)) !== 0), 84 | loadRemoved: ((data[1] & (1 << 7)) !== 0), 85 | impedanceMeasured: ((data[1] & (1 << 1)) !== 0) 86 | }; 87 | 88 | const measurements = { 89 | weight: (data.readUInt16LE(data.length - 2) / 100) /2, 90 | impedance: data.readUInt16LE(data.length - 4) 91 | }; 92 | 93 | if (state.isStabilized && state.loadRemoved) { 94 | //TODO: Maybe don't do the body metrics here at all? 95 | //By doing those somewhere else, sex, age and height could be completely dynamic 96 | //At least age is dynamic atm. Just don't grow and/or share your scale, okay? 97 | const BM = new BodyMetrics({ 98 | age: (new Date().getTime() - this.userBirthday.getTime()) / 31556926000, 99 | height: this.userHeight, 100 | sex: this.userSex 101 | }); 102 | 103 | return { 104 | weight: measurements.weight, 105 | attributes: Object.assign( 106 | {impedance: measurements.impedance}, 107 | BM.getAllMetrics(measurements.weight, measurements.impedance)) 108 | }; 109 | } else { 110 | return null; 111 | } 112 | } 113 | } 114 | 115 | module.exports = MiBodyScaleDevice; 116 | -------------------------------------------------------------------------------- /lib/devices/Device.js: -------------------------------------------------------------------------------- 1 | const xml2js = require("xml2js"); 2 | 3 | class Device { 4 | /** 5 | * 6 | * @param {object} options 7 | * @param {string} options.friendlyName 8 | * @param {string} options.mac 9 | * @param {string} options.bus 10 | * @param {string} options.hciDevice 11 | * @param {Semaphore} options.semaphore 12 | * @param options.mqttClient 13 | */ 14 | constructor(options) { 15 | this.friendlyName = options.friendlyName; 16 | this.mac = options.mac; 17 | this.macInDbusNotation = this.mac.split(":").map(e => { 18 | return e.toUpperCase(); 19 | }).join("_"); 20 | 21 | this.bus = options.bus; 22 | this.hciDevice = options.hciDevice; 23 | this.semaphore = options.semaphore; 24 | this.mqttClient = options.mqttClient; 25 | this.blueZservice = this.bus.getService("org.bluez"); 26 | this.pathRoot = "/org/bluez/" + this.hciDevice; 27 | 28 | this.id = this.mac.toLowerCase().split(":").join(""); 29 | 30 | this.connected = false; 31 | this.servicesResolved = false; 32 | this.characteristicsByUUID = {}; 33 | this.handlesByUUID = {}; 34 | } 35 | 36 | handleDbusMessage(type, dev, props) { 37 | //This might be overwritten by devices 38 | if (dev === "dev_" + this.macInDbusNotation) { 39 | switch (type) { 40 | case "org.bluez.Device1": 41 | this.handleAdvertisingForDevice(props); 42 | break; 43 | case "org.bluez.GattCharacteristic1": 44 | this.handleNotificationForDevice(props); 45 | break; 46 | } 47 | } 48 | } 49 | 50 | /** 51 | * 52 | * @param {object} props 53 | * @param {number} [props.RSSI] 54 | * @param {string} [props.Name] 55 | * @param {string} [props.Alias] 56 | * @param {bool} [props.Connected] 57 | * @param {bool} [props.ServicesResolved] 58 | * @param {Buffer} [props.ManufacturerData] 59 | * @param {object} [props.ServiceData] 60 | * @param {string} props.ServiceData.UUID 61 | * @param {Buffer} props.ServiceData.data 62 | */ 63 | handleAdvertisingForDevice(props) { 64 | if (props.Connected !== undefined) { 65 | this.connected = props.Connected; 66 | } 67 | 68 | if (props.ServicesResolved !== undefined) { 69 | this.servicesResolved = props.ServicesResolved; 70 | } 71 | 72 | 73 | //This will be overwritten by devices. Dont forget to call this in the device implementation 74 | } 75 | 76 | handleNotificationForDevice(props) { 77 | //This will be overwritten by devices 78 | } 79 | 80 | handleMqttMessage(topic, message) { 81 | if (this.mqttHandler) { 82 | this.mqttHandler.handleMessage(topic, message); 83 | } 84 | } 85 | 86 | initialize(callback) { 87 | callback(); 88 | //Here, mqtt autodiscovery setup happens 89 | } 90 | 91 | takeSemaphoreAsync() { 92 | return new Promise((resolve, reject) => { 93 | this.semaphore.take(() => { 94 | resolve(); 95 | }); 96 | }); 97 | } 98 | 99 | getDBusInterfaceAsync(path, ifaceName) { 100 | return new Promise((resolve, reject) => { 101 | this.blueZservice.getInterface(path, ifaceName, function(err, iface) { 102 | err = Array.isArray(err) ? err.join(".") : err; 103 | 104 | if (!err && iface) { 105 | resolve(iface); 106 | } else { 107 | reject({ 108 | message: "Failed to get Interface " + ifaceName + " for " + path, 109 | error: err 110 | }); 111 | } 112 | }); 113 | }); 114 | } 115 | 116 | connectDeviceAsync(deviceInterface, timeout, retries) { 117 | const self = this; 118 | let retryCount = 0; 119 | 120 | retries = typeof retries === "number" ? retries : 0; 121 | 122 | function connectHelper(callback) { 123 | self.semaphore.take(() => { 124 | deviceInterface.Connect(async function(err) { 125 | self.semaphore.leave(); 126 | err = Array.isArray(err) ? err.join(".") : err; 127 | 128 | if (!err) { //Handle devices which are already connected on startup 129 | deviceInterface.ServicesResolved(async (err, resolved) => { 130 | if (!err) { 131 | if (resolved === false) { 132 | try { 133 | await self.waitForServicesResolved(timeout); 134 | } catch (e) { 135 | return errorHelper(e, callback); 136 | } 137 | } else { 138 | self.servicesResolved = true; 139 | } 140 | 141 | try { 142 | await self.mapServicesAsync(); 143 | callback(); 144 | } catch (e) { 145 | return errorHelper(e, callback); 146 | } 147 | } else { 148 | errorHelper(err, callback); 149 | } 150 | }); 151 | 152 | 153 | } else { 154 | self.connected = false; //Not sure if this is needed. 155 | 156 | errorHelper(err, callback); 157 | } 158 | }); 159 | }); 160 | } 161 | 162 | function errorHelper(err, callback) { 163 | if (retryCount < retries) { 164 | console.info("Connection to " + self.friendlyName + " failed. Retrying. Error: " + err); 165 | retryCount++; 166 | connectHelper(callback); 167 | } else { 168 | callback(err); 169 | } 170 | } 171 | 172 | return new Promise(function(resolve, reject) { 173 | if (deviceInterface) { 174 | connectHelper(err => { 175 | if (!err) { 176 | resolve(); 177 | } else { 178 | reject({ 179 | message: "Failed to connect to " + self.friendlyName, 180 | error: err 181 | }); 182 | } 183 | }); 184 | } else { 185 | reject({ 186 | message: "Missing deviceInterface" 187 | }); 188 | } 189 | }); 190 | } 191 | 192 | async readCharacteristicAsync(characteristicInterface) { 193 | if (characteristicInterface) { 194 | await this.takeSemaphoreAsync(); 195 | 196 | await new Promise((resolve, reject) => { 197 | characteristicInterface.ReadValue({}, (err, data) => { 198 | this.semaphore.leave(); 199 | err = Array.isArray(err) ? err.join(".") : err; 200 | 201 | if (!err) { 202 | resolve(data); 203 | } else { 204 | reject(err); 205 | } 206 | }); 207 | }); 208 | } else { 209 | throw new Error("Missing characteristicInterface"); 210 | } 211 | } 212 | 213 | async writeCharacteristicAsync(characteristicInterface, payload, mode) { 214 | if (characteristicInterface) { 215 | await this.takeSemaphoreAsync(); 216 | 217 | await new Promise((resolve, reject) => { 218 | characteristicInterface.WriteValue(payload, {type: mode}, err => { 219 | this.semaphore.leave(); 220 | err = Array.isArray(err) ? err.join(".") : err; 221 | 222 | if (!err) { 223 | resolve(); 224 | } else { 225 | reject(err); 226 | } 227 | }); 228 | }); 229 | } else { 230 | throw new Error("Missing characteristicInterface"); 231 | } 232 | } 233 | 234 | destroy(callback) { 235 | callback(); 236 | } 237 | 238 | async waitForServicesResolved(timeout) { 239 | const start_time = new Date().getTime(); 240 | 241 | timeout = typeof timeout === "number" && timeout > 0 ? timeout : 10000; 242 | 243 | while (true) { 244 | if (this.servicesResolved === true || new Date().getTime() > start_time + timeout) { 245 | break; 246 | } 247 | 248 | await new Promise(resolve => { 249 | return setTimeout(resolve, 10); 250 | }); 251 | } 252 | 253 | if (this.servicesResolved === true) { 254 | return true; 255 | } else { 256 | throw new Error("Timeout exceeded"); 257 | } 258 | } 259 | 260 | async introspectPathAsync(path) { 261 | const self = this; 262 | 263 | const iface = await this.getDBusInterfaceAsync(path, "org.freedesktop.DBus.Introspectable"); 264 | 265 | return new Promise((resolve, reject) => { 266 | iface.Introspect((err, result) => { 267 | if (!err && typeof result === "string" && result.length > 0 && result[0] === "<") { 268 | xml2js.parseString(result, (err, parsedResult) => { 269 | if (!err && parsedResult) { 270 | resolve(parsedResult); 271 | } else { 272 | reject({ 273 | message: "Failed to parse result", 274 | error: err 275 | }); 276 | } 277 | }); 278 | } else { 279 | reject({ 280 | message: "Failed to introspect", 281 | error: err, 282 | result: result 283 | }); 284 | } 285 | }); 286 | }); 287 | } 288 | 289 | async mapServicesAsync() { 290 | let services = await this.introspectPathAsync(this.pathRoot + "/dev_" + this.macInDbusNotation); 291 | 292 | // noinspection DuplicatedCode 293 | if (services && services.node && Array.isArray(services.node.node)) { 294 | services = services.node.node.map(e => { 295 | if (e["$"] && e["$"].name) { 296 | return e["$"].name; 297 | } else { 298 | return false; 299 | } 300 | }).filter(e => { 301 | return e !== false; 302 | }); 303 | } else { 304 | services = []; 305 | } 306 | 307 | for (const service of services) { 308 | await this.mapCharacteristicsAsync(service); 309 | } 310 | } 311 | 312 | async mapCharacteristicsAsync(service) { 313 | let characteristics = await this.introspectPathAsync( 314 | this.pathRoot + "/dev_" + this.macInDbusNotation + "/" + service 315 | ); 316 | 317 | // noinspection DuplicatedCode 318 | if (characteristics && characteristics.node && Array.isArray(characteristics.node.node)) { 319 | characteristics = characteristics.node.node.map(e => { 320 | if (e["$"] && e["$"].name) { 321 | return e["$"].name; 322 | } else { 323 | return false; 324 | } 325 | }).filter(e => { 326 | return e !== false; 327 | }); 328 | } else { 329 | characteristics = []; 330 | } 331 | 332 | for (const characteristic of characteristics) { 333 | const characteristicIface = await this.getDBusInterfaceAsync( 334 | this.pathRoot + "/dev_" + this.macInDbusNotation + "/" + service + "/" + characteristic, 335 | "org.bluez.GattCharacteristic1" 336 | ); 337 | 338 | await new Promise((resolve, reject) => { 339 | characteristicIface.UUID((err, uuid) => { 340 | if (!err && uuid) { 341 | this.characteristicsByUUID[uuid] = characteristicIface; 342 | this.handlesByUUID[uuid] = service + "/" + characteristic; 343 | 344 | resolve(); 345 | } else { 346 | console.error({ 347 | message: "Failed to fetch uuid for characteristic" + characteristic, 348 | error: err 349 | }); 350 | 351 | reject(err); 352 | } 353 | }); 354 | }); 355 | } 356 | } 357 | } 358 | 359 | 360 | module.exports = Device; 361 | -------------------------------------------------------------------------------- /lib/devices/EqivaThermostat/EqivaThermostatDevice.js: -------------------------------------------------------------------------------- 1 | const EqivaThermostatMqttHandler = require("./EqivaThermostatMqttHandler"); 2 | const PollingDevice = require("../PollingDevice"); 3 | 4 | class EqivaThermostatDevice extends PollingDevice { 5 | /** 6 | * 7 | * @param {object} options 8 | * @param {number} options.pollingInterval 9 | */ 10 | constructor(options) { 11 | super(options); 12 | 13 | this.mqttHandler = new EqivaThermostatMqttHandler({ 14 | device: this, 15 | prefix: EqivaThermostatDevice.MQTT_PREFIX 16 | }); 17 | } 18 | 19 | initialize(callback) { 20 | this.mqttHandler.initialize(err => { 21 | if (!err) { 22 | this.queuePolling(); 23 | } 24 | callback(err); 25 | }); 26 | } 27 | 28 | poll() { 29 | this.sendStatusCommand(err => { 30 | if (err) { 31 | console.error(err); 32 | } 33 | 34 | this.queuePolling(); 35 | }); 36 | } 37 | 38 | handleNotificationForDevice(props) { 39 | if (props[this.handlesByUUID[EqivaThermostatDevice.CHARACTERISTICS.response]]) { 40 | this.handleStatusUpdate(props[this.handlesByUUID[EqivaThermostatDevice.CHARACTERISTICS.response]]); 41 | } 42 | } 43 | 44 | handleStatusUpdate(buf) { 45 | const status = EqivaThermostatDevice.PARSE_STATUS(buf); 46 | 47 | this.mqttHandler.updateState(status); 48 | this.queuePolling(); //Since we already have a status, postpone everything that might be pending atm 49 | 50 | //Also, we don't have to disconnect because the device will do that automatically after 2 minutes of inactivity 51 | } 52 | 53 | connectAndSubscribeAsync() { 54 | const self = this; 55 | 56 | return new Promise(async function (resolve, reject) { 57 | let deviceInterface; 58 | 59 | try { 60 | deviceInterface = await self.getDBusInterfaceAsync( 61 | self.pathRoot + "/dev_" + self.macInDbusNotation, 62 | "org.bluez.Device1" 63 | ); 64 | } catch (e) { 65 | return reject({ 66 | message: "Failed to get device interface. Maybe the thermostat is out of range?", 67 | error: e 68 | }); 69 | } 70 | 71 | try { 72 | await self.connectDeviceAsync(deviceInterface, 5000, 10); //todo: configurable? 73 | } catch (e) { 74 | return reject({ 75 | message: "Failed to connect to thermostat", 76 | error: e 77 | }); 78 | } 79 | 80 | self.characteristicsByUUID[EqivaThermostatDevice.CHARACTERISTICS.response].Notifying(async (err, notifying) => { 81 | err = Array.isArray(err) ? err.join(".") : err; 82 | 83 | if (!err && notifying === true) { 84 | return resolve(); 85 | } else { 86 | await self.takeSemaphoreAsync(); 87 | 88 | self.characteristicsByUUID[EqivaThermostatDevice.CHARACTERISTICS.response].StartNotify(err => { 89 | self.semaphore.leave(); 90 | err = Array.isArray(err) ? err.join(".") : err; 91 | 92 | if (!err) { 93 | resolve(); 94 | } else { 95 | reject({ 96 | message: "Failed to start notifying", 97 | error: err 98 | }); 99 | } 100 | }); 101 | } 102 | }); 103 | }); 104 | } 105 | 106 | disconnectAndUnsubscribeAsync() { 107 | const self = this; 108 | 109 | return new Promise(async function (resolve, reject) { 110 | let deviceInterface; 111 | 112 | try { 113 | deviceInterface = await self.getDBusInterfaceAsync( 114 | self.pathRoot + "/dev_" + self.macInDbusNotation, 115 | "org.bluez.Device1" 116 | ); 117 | } catch (e) { 118 | return reject({ 119 | message: "Failed to get device interface. Maybe the thermostat is out of range?", 120 | error: e 121 | }); 122 | } 123 | 124 | self.characteristicsByUUID[EqivaThermostatDevice.CHARACTERISTICS.response].Notifying(async (err, notifying) => { 125 | err = Array.isArray(err) ? err.join(".") : err; 126 | 127 | if (!err) { 128 | if (notifying === false) { 129 | deviceInterface.Disconnect(err => { 130 | if (!err) { 131 | resolve(); 132 | } else { 133 | reject({ 134 | message: "Failed to disconnect", 135 | error: err 136 | }); 137 | } 138 | }); 139 | } else { 140 | await self.takeSemaphoreAsync(); 141 | self.characteristicsByUUID[EqivaThermostatDevice.CHARACTERISTICS.response].StopNotify([], err => { 142 | self.semaphore.leave(); 143 | err = Array.isArray(err) ? err.join(".") : err; 144 | 145 | if (!err || err === "No notify session started") { 146 | deviceInterface.Disconnect(err => { 147 | if (!err) { 148 | resolve(); 149 | } else { 150 | reject({ 151 | message: "Failed to disconnect", 152 | error: err 153 | }); 154 | } 155 | }); 156 | } else { 157 | reject({ 158 | message: "Failed to stop notifying", 159 | error: err 160 | }); 161 | } 162 | }); 163 | } 164 | } else { 165 | reject({ 166 | message: "Failed to check notifying", 167 | error: err 168 | }); 169 | } 170 | }); 171 | }); 172 | } 173 | 174 | async sendCommand(command, callback) { 175 | try { 176 | await this.connectAndSubscribeAsync(); 177 | 178 | await this.writeCharacteristicAsync( 179 | this.characteristicsByUUID[EqivaThermostatDevice.CHARACTERISTICS.request], 180 | command, 181 | "request" 182 | ); 183 | 184 | callback(); 185 | } catch (e) { 186 | return callback(e); 187 | } 188 | } 189 | 190 | //TODO: window mode 191 | 192 | sendOffsetCommand(offset, callback) { 193 | if (typeof offset === "number" && offset >= -3.5 && offset <= 3.5) { 194 | const cmd = Buffer.alloc(2); 195 | 196 | cmd.writeUInt8(0x13, 0); 197 | cmd.writeUInt8(Math.floor((offset + 3.5)*2)); 198 | 199 | this.sendCommand(cmd, callback).then(); 200 | } else { 201 | callback(new Error("Invalid offset")); 202 | } 203 | } 204 | 205 | sendModeCommand(mode, callback) { 206 | const cmd = Buffer.alloc(2); 207 | 208 | cmd.writeUInt8(0x40, 0); 209 | 210 | switch (mode) { 211 | case "auto": 212 | cmd.writeUInt8(0, 1); 213 | break; 214 | case "manual": 215 | cmd.writeUInt8(0x40, 1); 216 | break; 217 | default: 218 | return callback(new Error("Invalid mode")); 219 | } 220 | 221 | this.sendCommand(cmd, callback).then(); 222 | } 223 | 224 | sendBoostModeCommand(boost, callback) { 225 | if (typeof boost === "boolean") { 226 | const cmd = Buffer.alloc(2); 227 | 228 | cmd.writeUInt8(0x45, 0); 229 | cmd.writeUInt8(boost ? 1 : 0, 1); 230 | 231 | this.sendCommand(cmd, callback).then(); 232 | } else { 233 | callback(new Error("Invalid boost value")); 234 | } 235 | } 236 | 237 | sendTargetTemperatureCommand(temp, callback) { 238 | if (typeof temp === "number" && temp >= 4.5 && temp <= 30) { 239 | const cmd = Buffer.alloc(2); 240 | const targetTemp = Math.floor(temp * 2); 241 | 242 | cmd.writeUInt8(0x41, 0); 243 | cmd.writeUInt8(targetTemp, 1); 244 | 245 | this.sendCommand(cmd, callback).then(); 246 | } else { 247 | callback(new Error("Invalid temp value")); 248 | } 249 | } 250 | 251 | sendStatusCommand(callback) { 252 | const now = new Date(); 253 | const cmd = Buffer.alloc(7); 254 | 255 | cmd.writeUInt8(0x03, 0); //Status+Sync command 256 | 257 | cmd.writeUInt8(now.getFullYear() % 100, 1); 258 | cmd.writeUInt8(now.getMonth() + 1, 2); 259 | cmd.writeUInt8(now.getDate(), 3); 260 | cmd.writeUInt8(now.getHours(), 4); 261 | cmd.writeUInt8(now.getMinutes(), 5); 262 | cmd.writeUInt8(now.getSeconds(), 6); 263 | 264 | 265 | this.sendCommand(cmd, callback).then(); 266 | } 267 | 268 | } 269 | 270 | EqivaThermostatDevice.PARSE_STATUS = function (data) { 271 | const result = {}; 272 | const flags = Array.from(data.readUInt8(2).toString(2).padStart(8)).reverse(); 273 | 274 | if (flags[0] === "1") { 275 | result.mode = "manual"; 276 | } else { 277 | result.mode = "auto"; 278 | } 279 | 280 | result.vacation = flags[1] === "1"; 281 | result.boost = flags[2] === "1"; 282 | result.dst = flags[3] === "1"; 283 | result.window_open = flags[4] === "1"; 284 | result.locked = flags[5] === "1"; 285 | result.low_bat = flags[7] === "1"; 286 | 287 | result.valve = data.readInt8(3); 288 | result.temperature = data.readInt8(5) / 2; 289 | 290 | return result; 291 | }; 292 | 293 | EqivaThermostatDevice.CHARACTERISTICS = { 294 | request: "3fa4585a-ce4a-3bad-db4b-b8df8179ea09", 295 | response: "d0e8434d-cd29-0996-af41-6c90f4e0eb2a" 296 | }; 297 | 298 | EqivaThermostatDevice.MQTT_PREFIX = "eqiva_thermostat"; 299 | 300 | module.exports = EqivaThermostatDevice; 301 | -------------------------------------------------------------------------------- /lib/devices/EqivaThermostat/EqivaThermostatMqttHandler.js: -------------------------------------------------------------------------------- 1 | const MqttHandler = require("../../MqttHandler"); 2 | 3 | class EqivaThermostatMqttHandler extends MqttHandler { 4 | /** 5 | * @param {object} options 6 | */ 7 | constructor(options) { 8 | super(options); 9 | 10 | this.registerTopicHandler("set_temperature", cmd => { 11 | return this.setTemperature(cmd); 12 | }); 13 | this.registerTopicHandler("set_mode", cmd => { 14 | return this.setMode(cmd); 15 | }); 16 | 17 | this.lastState = null; 18 | } 19 | 20 | setupAutodiscovery(callback) { 21 | this.device.mqttClient.publish("homeassistant/climate/" + this.prefix + "_" + this.device.id + "/config", JSON.stringify({ 22 | "json_attributes_topic": this.writableTopics.attributes, 23 | "name": this.device.friendlyName, 24 | "unique_id": "cybele_eqiva_thermostat_" + this.device.id, 25 | "platform": "mqtt", 26 | 27 | "precision": 0.5, 28 | "modes": ["auto", "heat"], 29 | "min_temp": 4.5, 30 | "max_temp": 30, 31 | "temp_step": 0.5, 32 | 33 | "current_temperature_topic": this.writableTopics.state, //todo: this is bogus 34 | "current_temperature_template": "{{ value_json.temperature }}", 35 | 36 | "temperature_command_topic": this.getTopicHandlerTopic("set_temperature"), 37 | "temperature_state_topic": this.writableTopics.state, 38 | "temperature_state_template": "{{ value_json.temperature }}", 39 | 40 | "mode_state_topic": this.writableTopics.state, 41 | "mode_state_template": "{{ value_json.mode }}", 42 | "mode_command_topic": this.getTopicHandlerTopic("set_mode") 43 | }), {retain: true}, callback); 44 | } 45 | 46 | updateState(state) { 47 | const stringifiedState = JSON.stringify(state); 48 | 49 | if (stringifiedState !== this.lastState) { 50 | this.lastState = stringifiedState; 51 | 52 | this.mqttClient.publish(this.writableTopics.state, JSON.stringify({ 53 | temperature: state.temperature, 54 | mode: EqivaThermostatMqttHandler.GET_HA_MODE_FROM_STATE(state.mode) 55 | }), {retain: true}, err => { 56 | if (err) { 57 | console.error(err); 58 | } 59 | }); 60 | this.mqttClient.publish(this.writableTopics.attributes, JSON.stringify({ 61 | valve: state.valve, 62 | mode: state.mode, 63 | vacation: state.vacation, 64 | boost: state.boost, 65 | dst: state.dst, 66 | window_open: state.window_open, 67 | locked: state.locked, 68 | low_bat: state.low_bat 69 | }), {retain: true}, err => { 70 | if (err) { 71 | console.error(err); 72 | } 73 | }); 74 | } 75 | } 76 | 77 | /** 78 | * @param {number} temperature 79 | */ 80 | setTemperature(temperature) { 81 | if (temperature >= 4.5 && temperature <= 30) { 82 | this.device.sendTargetTemperatureCommand(temperature, err => { 83 | if (err) { 84 | console.error(err); 85 | } 86 | }); 87 | } 88 | } 89 | 90 | /** 91 | * 92 | * @param {"auto"|"heat"} mode 93 | */ 94 | setMode(mode) { 95 | if (mode === "auto" || mode === "heat") { 96 | const eqivaMode = mode === "heat" ? "manual" : mode; 97 | 98 | this.device.sendModeCommand(eqivaMode, err => { 99 | if (err) { 100 | console.error(err); 101 | } 102 | }); 103 | } 104 | } 105 | } 106 | 107 | EqivaThermostatMqttHandler.GET_HA_MODE_FROM_STATE = function(state) { 108 | if (state.mode === "auto") { 109 | return "auto"; 110 | } else { 111 | if (state.temperature <= 4.5) { 112 | return "off"; 113 | } else { 114 | return "heat"; 115 | } 116 | } 117 | }; 118 | 119 | module.exports = EqivaThermostatMqttHandler; 120 | -------------------------------------------------------------------------------- /lib/devices/GlanceClock/GlanceClockDevice.js: -------------------------------------------------------------------------------- 1 | const Device = require("../Device"); 2 | const GlanceClockMqttHandler = require("./GlanceClockMqttHandler"); 3 | const Types = require("./Types"); 4 | 5 | //This class needs to check the connection status on every action which is performed and should try to connect if it isn't 6 | //Also, everything should fail if the class isn't initialized 7 | 8 | class GlanceClockDevice extends Device { 9 | constructor(options) { 10 | super(options); 11 | 12 | this.mqttHandler = new GlanceClockMqttHandler({ 13 | device: this, 14 | prefix: "glanceclock" 15 | }); 16 | 17 | this.deviceInterface = null; 18 | } 19 | 20 | initialize(callback) { 21 | this.blueZservice.getInterface( 22 | this.pathRoot + "/dev_" + this.macInDbusNotation, 23 | "org.bluez.Device1", 24 | (err, deviceInterface) => { 25 | err = Array.isArray(err) ? err.join(".") : err; 26 | 27 | if (!err && deviceInterface) { 28 | deviceInterface.Paired((err, isPaired) => { 29 | if (!err && isPaired === true) { 30 | this.deviceInterface = deviceInterface; 31 | 32 | this.mqttHandler.initialize(callback); 33 | } else { 34 | if (err) { 35 | callback(Array.isArray(err) ? err.join(".") : err); 36 | } else { 37 | callback(new Error("GlanceClock needs to be paired with " + this.hciDevice)); 38 | } 39 | } 40 | }); 41 | } else { 42 | callback({ 43 | message: "Failed to fetch device interface from D-Bus. Is the Clock paired with this hci device?", 44 | name: this.friendlyName, 45 | mac: this.mac, 46 | hciDevice: this.hciDevice 47 | }); 48 | } 49 | } 50 | ); 51 | } 52 | 53 | handleAdvertisingForDevice (props) { 54 | super.handleAdvertisingForDevice(props); 55 | 56 | if (props.Connected !== undefined) { 57 | this.mqttHandler.updatePresence(props.Connected); 58 | } 59 | 60 | if (props.ServicesResolved === true) { 61 | this.getSettings((err, settings) => { 62 | if (!err) { 63 | this.mqttHandler.updateState(settings); 64 | } 65 | }); 66 | } 67 | } 68 | 69 | handleMqttMessage(topic, message) { 70 | this.mqttHandler.handleMessage(topic, message); 71 | } 72 | 73 | //This ensures that the device is connected 74 | executionHelper(callback) { //TODO: queue commands and execute only one at a time 75 | const self = this; 76 | let retryCount = 0; 77 | 78 | async function executionHelperHelper(callback) { //This. Name. 79 | try { 80 | await self.connectDeviceAsync(self.deviceInterface); 81 | } catch (e) { 82 | console.error(e); 83 | return errorRetryHelper(callback); 84 | } 85 | 86 | callback(); 87 | } 88 | 89 | function errorRetryHelper(callback) { 90 | if (retryCount < 15) { 91 | retryCount++; 92 | 93 | executionHelperHelper(callback).then(); 94 | } else { 95 | console.info("ExecutionHelper: Retries exceeded for " + self.friendlyName); 96 | callback(new Error("Retries exceeded")); 97 | } 98 | } 99 | 100 | if (this.deviceInterface) { 101 | executionHelperHelper(callback).then(); 102 | } else { 103 | callback(new Error("GlanceClockDevice has not been initialized.")); 104 | } 105 | } 106 | 107 | /** 108 | * 109 | * @param {Array} command //0-255 only 110 | * @param {Array} payload //0-255 only 111 | * @param callback 112 | */ 113 | executeCommand(command, payload, callback) { 114 | const self = this; 115 | let retryCount = 0; 116 | 117 | function executeCommandHelper(command, payload, callback) { 118 | self.executionHelper(err => { 119 | if (!err) { 120 | let value = command; 121 | const options = {type: "request"}; 122 | 123 | if (Array.isArray(payload)) { 124 | value = command.concat(payload); 125 | } 126 | 127 | self.semaphore.take(() => { 128 | self.characteristicsByUUID[GlanceClockDevice.CHARACTERISTICS.settings].WriteValue(value, options, err => { 129 | self.semaphore.leave(); 130 | err = Array.isArray(err) ? err.join(".") : err; 131 | 132 | if (err) { 133 | if (retryCount < 5) { 134 | retryCount++; 135 | 136 | console.info("ExecuteCommand: Got Error " + err + " Retrying for " + self.friendlyName); //TODO: handle notConnected 137 | executeCommandHelper(command, payload, callback); 138 | } else { 139 | console.info("ExecuteCommand: Retries exceeded for " + self.friendlyName); 140 | callback(err); 141 | } 142 | } else { 143 | callback(); 144 | } 145 | }); 146 | }); 147 | } else { 148 | callback(err); 149 | } 150 | }); 151 | } 152 | 153 | if (Array.isArray(command) && command.length > 0) { 154 | executeCommandHelper(command, payload, callback); 155 | } else { 156 | callback(new Error("Missing command")); 157 | } 158 | } 159 | 160 | updateAndRefresh(callback) { 161 | this.executeCommand([35], [], callback); 162 | } 163 | 164 | getSettings(callback) { 165 | this.executionHelper(err => { 166 | if (!err) { 167 | this.semaphore.take(() => { 168 | this.characteristicsByUUID[GlanceClockDevice.CHARACTERISTICS.settings].ReadValue({}, (err, settingsBuf) => { 169 | this.semaphore.leave(); 170 | 171 | if (!err) { 172 | let settings; 173 | 174 | try { 175 | settings = Types.Settings.decode(settingsBuf); 176 | } catch (e) { 177 | console.error(this.friendlyName, "Failed to decode Settings. Replacing with defaults. Error: ", e); 178 | settings = Types.Settings.decode(GlanceClockDevice.DEFAULT_SETTINGS); 179 | } 180 | 181 | callback(null, settings); 182 | } else { 183 | callback(Array.isArray(err) ? err.join(".") : err); 184 | } 185 | }); 186 | }); 187 | } else { 188 | callback(err); 189 | } 190 | }); 191 | } 192 | 193 | 194 | setSettings(settings, callback) { 195 | const command = [0x05,0x00,0x00,0x00]; 196 | const payload = [...Types.Settings.encode(settings).finish()]; 197 | 198 | this.executeCommand(command, payload, err => { 199 | if (!err) { 200 | this.mqttHandler.updateState(settings); 201 | } 202 | 203 | callback(err); 204 | }); 205 | } 206 | 207 | /** 208 | * @param {object} options 209 | * @param {Notice} options.notice 210 | * @param {number} [options.priority] 211 | * @param {number} [options.source] 212 | * @param callback 213 | */ 214 | notify(options, callback) { 215 | const priority = options.priority !== undefined ? options.priority : 16; 216 | const source = options.source !== undefined ? options.source : 0; 217 | 218 | const command = [0x02, priority, 0x00, source]; 219 | const payload = [...Types.Notice.encode(options.notice).finish()]; 220 | 221 | this.executeCommand(command, payload, callback); 222 | } 223 | 224 | /** 225 | * @param {object} options 226 | * @param {ForecastScene} options.forecastScene 227 | * @param {number} options.slot 228 | * @param {number} options.mode 229 | * @param callback 230 | */ 231 | saveForecastScene(options, callback) { 232 | const command = [7, 1, options.mode, options.slot]; 233 | const payload = [...Types.ForecastScene.encode(options.forecastScene).finish()]; 234 | 235 | this.executeCommand(command, payload, callback); 236 | } 237 | 238 | 239 | scenesStop(callback) { 240 | this.executeCommand([30], [], callback); 241 | } 242 | 243 | scenesStart(callback) { 244 | this.executeCommand([31], [], callback); 245 | } 246 | 247 | scenesClear(callback) { 248 | this.executeCommand([32], [], callback); 249 | } 250 | 251 | /** 252 | * @param {object} options 253 | * @param {number} options.slot 254 | * @param callback 255 | */ 256 | scenesDelete(options, callback) { 257 | this.executeCommand([33, 0, 0, options.slot], [], callback); 258 | } 259 | 260 | /** 261 | * @param {object} options 262 | * @param {number} options.from 263 | * @param {number} options.to 264 | * @param callback 265 | */ 266 | scenesDeleteMany(options, callback) { 267 | this.executeCommand([34, 0, options.to, options.from], [], callback); 268 | } 269 | 270 | scenesUpdateAndRefresh(callback) { 271 | this.executeCommand([35], [], callback); 272 | } 273 | 274 | /** 275 | * @param {object} options 276 | * @param {Timer} [options.timer] 277 | * 278 | * @param callback 279 | */ 280 | timerSet(options, callback) { 281 | const command = [0x03, 0x00, 0x00, 0x00]; 282 | const payload = [...Types.Timer.encode(options.timer).finish()]; 283 | 284 | this.executeCommand(command, payload, callback); 285 | } 286 | 287 | timerStop(callback) { 288 | this.executeCommand([10], [], callback); 289 | } 290 | 291 | } 292 | 293 | GlanceClockDevice.CHARACTERISTICS = { 294 | settings: "5075fb2e-1e0e-11e7-93ae-92361f002671" 295 | }; 296 | 297 | GlanceClockDevice.DEFAULT_SETTINGS = Buffer.from([0x10, 0x01, 0x28, 0x01, 0x48, 0x01, 0x50, 0x80, 0xBA, 0xA2, 0x03, 0x58, 0x01, 0x60, 0x00]); 298 | 299 | module.exports = GlanceClockDevice; 300 | -------------------------------------------------------------------------------- /lib/devices/GlanceClock/GlanceClockMqttHandler.js: -------------------------------------------------------------------------------- 1 | const MqttHandler = require("../../MqttHandler"); 2 | const Types = require("./Types"); 3 | 4 | class GlanceClockMqttHandler extends MqttHandler { 5 | constructor(options) { 6 | super(options); 7 | 8 | this.registerTopicHandler("notify", cmd => { 9 | return this.handleNotifyMessage(cmd); 10 | }); 11 | this.registerTopicHandler("setBrightness", cmd => { 12 | return this.handleSetBrightnessMessage(cmd); 13 | }); 14 | this.registerTopicHandler("saveForecastScene", cmd => { 15 | return this.handleSaveForecastSceneMessage(cmd); 16 | }); 17 | this.registerTopicHandler("setTimer", cmd => { 18 | return this.handleSetTimerMessage(cmd); 19 | }); 20 | this.registerTopicHandler("stopTimer", cmd => { 21 | return this.handleStopTimerMessage(cmd); 22 | }); 23 | this.registerTopicHandler("rawCommand", cmd => { 24 | return this.handleRawCommandMessage(cmd); 25 | }); 26 | 27 | this.lastState = null; 28 | } 29 | 30 | updateState(settings) { 31 | const stringifiedSettings = JSON.stringify(settings); 32 | 33 | if (stringifiedSettings !== this.lastState) { 34 | this.lastState = stringifiedSettings; 35 | const state = settings.timeModeEnable ? settings.timeFormat12 ? "12h" : "24h" : "off"; 36 | const brightness = settings.getBrightness(); //TODO: this is shit. 37 | 38 | this.mqttClient.publish(this.writableTopics.state, state, err => { 39 | if (err) { 40 | console.error(err); 41 | } 42 | }); 43 | 44 | this.mqttClient.publish(this.writableTopics.attributes, JSON.stringify({ 45 | dateFormat: Types.ENUMS_REVERSE.Settings_DateFormat[settings.dateFormat], 46 | automaticNightMode: settings.nightModeEnabled, 47 | pointsEnabled: settings.pointsAlwaysEnabled, 48 | brightness: brightness.value, 49 | auto_brightness_max: brightness.auto.max, 50 | auto_brightness_min: brightness.auto.min 51 | }), err => { 52 | if (err) { 53 | console.error(err); 54 | } 55 | }); 56 | } 57 | } 58 | 59 | /** 60 | * @param {object} cmd 61 | * @param {string} cmd.message 62 | * @param {string} [cmd.animation] 63 | * @param {string} [cmd.color] 64 | * @param {string} [cmd.sound] 65 | * @param {number} [cmd.repeatCount] //There seems to be a hard limit of.. 80? //TODO 66 | */ 67 | handleNotifyMessage(cmd) { 68 | if (typeof cmd === "object") { 69 | const animation = Types.ENUMS.Animation[cmd.animation]; 70 | const color = Types.ENUMS.Color[cmd.color]; 71 | const sound = Types.ENUMS.Sound[cmd.sound]; 72 | const text = []; 73 | 74 | let repeatCount = cmd.repeatCount !== undefined ? cmd.repeatCount : 1; 75 | let textData; 76 | let notice; 77 | 78 | if (repeatCount < 1) { 79 | repeatCount = 1; 80 | } 81 | 82 | if (typeof cmd.message === "string") { 83 | textData = Types.TextData.fromObject({}); 84 | textData.setText(cmd.message); 85 | 86 | for (let i = 1; i <= repeatCount; i++) { 87 | text.push(textData); 88 | } 89 | 90 | notice = Types.Notice.fromObject({ 91 | text: text, 92 | type: animation, 93 | sound: sound, 94 | color: color 95 | }); 96 | 97 | this.device.notify({ 98 | notice: notice 99 | }, err => { 100 | if (err) { 101 | console.error(err); 102 | } 103 | }); 104 | } 105 | } else { 106 | console.error({ 107 | msg: "Received invalid command. Not an object", 108 | cmd: cmd 109 | }); 110 | } 111 | } 112 | 113 | /** 114 | * @param {object} cmd 115 | * @param {"auto"|number} [cmd.value] 116 | * @param {object} [cmd.auto] 117 | * @param {number} [cmd.auto.max] 118 | * @param {number} [cmd.auto.min] 119 | */ 120 | handleSetBrightnessMessage(cmd) { 121 | if (typeof cmd === "object") { //TODO: do this for every handler that expects json 122 | this.device.getSettings((err, settings) => { 123 | if (!err) { 124 | const brightness = settings.getBrightness(); 125 | 126 | if (cmd.value !== undefined) { 127 | brightness.value = cmd.value; 128 | } 129 | if (cmd.auto && cmd.auto.max !== undefined) { 130 | brightness.auto.max = cmd.auto.max; 131 | } 132 | if (cmd.auto && cmd.auto.min !== undefined) { 133 | brightness.auto.min = cmd.auto.min; 134 | } 135 | 136 | try { 137 | settings.setBrightness(brightness); 138 | } catch (e) { 139 | console.error(e); 140 | } 141 | 142 | this.device.setSettings(settings, (err) => { 143 | if (err) { 144 | console.error(err); 145 | } 146 | }); 147 | 148 | } else { 149 | console.error(err); 150 | } 151 | }); 152 | } else { 153 | console.error({ 154 | msg: "Received invalid command. Not an object", 155 | cmd: cmd 156 | }); 157 | } 158 | } 159 | 160 | /** 161 | * @param {object} cmd 162 | * @param {"ring"|"text"|"both"} cmd.displayMode 163 | * @param {number} cmd.sceneSlot 164 | * @param {string} cmd.scene 165 | * @param {number} cmd.scene.timestamp 166 | * @param {string} cmd.scene.maxColor 167 | * @param {string} cmd.scene.minColor 168 | * @param {number} cmd.scene.max _not_ a float 169 | * @param {number} cmd.scene.min _not_ a float 170 | * @param {Array} cmd.scene.template 171 | * @param {Array} cmd.scene.values exactly 24. _not_ floats 172 | */ 173 | handleSaveForecastSceneMessage(cmd) { 174 | if (typeof cmd === "object") { 175 | const displayMode = GlanceClockMqttHandler.DISPLAY_MODES[cmd.displayMode]; 176 | 177 | if (displayMode && typeof cmd.sceneSlot === "number" && cmd.scene) { 178 | const sceneOptions = { 179 | timestamp: cmd.scene.timestamp, 180 | maxColor: parseInt(cmd.scene.maxColor.replace("#", "0x")), 181 | minColor: parseInt(cmd.scene.minColor.replace("#", "0x")), 182 | max: Math.round(cmd.scene.max), 183 | min: Math.round(cmd.scene.min), 184 | template: cmd.scene.template 185 | }; 186 | const values = Buffer.alloc(48); 187 | 188 | cmd.scene.values.forEach((val, i) => { 189 | if (i < 24) { 190 | values.writeInt16LE(val, i * 2); 191 | } //drop everything else 192 | }); 193 | 194 | sceneOptions.values = Array.from(values); 195 | 196 | this.device.saveForecastScene({ 197 | slot: cmd.sceneSlot, 198 | mode: displayMode, 199 | forecastScene: sceneOptions 200 | }, err => { 201 | if (err) { 202 | console.error(err); 203 | } 204 | }); 205 | } else { 206 | console.error({ 207 | msg: "Received invalid command", 208 | cmd: cmd 209 | }); 210 | } 211 | } else { 212 | console.error({ 213 | msg: "Received invalid command. Not an object", 214 | cmd: cmd 215 | }); 216 | } 217 | } 218 | 219 | /** 220 | * 221 | * @param {object} cmd 222 | * @param {number} [cmd.preTimerCountdown] 223 | * @param {string} [cmd.text] 224 | * @param {Array} cmd.intervals 225 | * @param {number} [cmd.intervals.countdown] 226 | * @param {number} cmd.intervals.duration in seconds 227 | */ 228 | handleSetTimerMessage(cmd) { 229 | if (typeof cmd === "object") { 230 | const finalText = Types.TextData.fromObject({}); 231 | finalText.setText(typeof cmd.text === "string" ? cmd.text : "Timer expired"); 232 | 233 | const intervals = cmd.intervals.map(e => { 234 | return Types.TimerInterval.fromObject({ 235 | countdown: typeof e.countdown === "number" ? e.countdown : 0, 236 | duration: e.duration 237 | }); 238 | }); 239 | 240 | 241 | 242 | const timerOptions = { 243 | countdown: typeof cmd.preTimerCountdown === "number" ? cmd.preTimerCountdown : 0, 244 | finalText: [finalText], 245 | intervals: intervals 246 | }; 247 | 248 | 249 | this.device.timerSet({ 250 | timer: Types.Timer.fromObject(timerOptions) 251 | }, err => { 252 | if (err) { 253 | console.error(err); 254 | } 255 | }); 256 | } else { 257 | console.error({ 258 | msg: "Received invalid command. Not an object", 259 | cmd: cmd 260 | }); 261 | } 262 | } 263 | 264 | /** 265 | * 266 | * @param {object} cmd 267 | * @param {string} cmd.action 268 | */ 269 | handleStopTimerMessage(cmd) { 270 | if (typeof cmd === "object" && cmd.action === "stop") { 271 | this.device.timerStop(err => { 272 | if (err) { 273 | console.error(err); 274 | } 275 | }); 276 | } else { 277 | console.error({ 278 | msg: "Received invalid command. Not an object", 279 | cmd: cmd 280 | }); 281 | } 282 | } 283 | 284 | /** 285 | * 286 | * @param {object} cmd 287 | * @param {Array} cmd.op 288 | * @param {Array} [cmd.payload] 289 | */ 290 | handleRawCommandMessage(cmd) { 291 | if (typeof cmd === "object" && Array.isArray(cmd.op) && cmd.op.length > 0) { //TODO: validate 292 | this.device.executeCommand(cmd.op, cmd.payload, err => { 293 | if (err) { 294 | console.error(err); 295 | } else { 296 | console.info("Executed successfully"); 297 | } 298 | }); 299 | } 300 | } 301 | } 302 | 303 | 304 | 305 | GlanceClockMqttHandler.DISPLAY_MODES = { 306 | "ring": 8, 307 | "text": 16, 308 | "both": 24 309 | }; 310 | 311 | module.exports = GlanceClockMqttHandler; 312 | -------------------------------------------------------------------------------- /lib/devices/GlanceClock/Types/Enums.js: -------------------------------------------------------------------------------- 1 | const protobuf = require("protobufjs"); 2 | const protoRoot = protobuf.loadSync(require.resolve("./Glance.proto")); 3 | 4 | module.exports = { 5 | Animation: protoRoot.lookupEnum("Animation").values, 6 | Color: protoRoot.lookupEnum("Color").values, 7 | Sound: protoRoot.lookupEnum("Sound").values, 8 | Settings_DateFormat: protoRoot.lookupEnum("Settings.DateFormat").values 9 | }; 10 | -------------------------------------------------------------------------------- /lib/devices/GlanceClock/Types/ForecastScene.js: -------------------------------------------------------------------------------- 1 | const protobuf = require("protobufjs"); 2 | const protoRoot = protobuf.loadSync(require.resolve("./Glance.proto")); 3 | 4 | const ForecastScene = protoRoot.lookupType("ForecastScene"); 5 | 6 | /** 7 | * 8 | * @param {Array} data 9 | */ 10 | ForecastScene.ctor.prototype.setForecastData = function(data) { //TODO 11 | let max; 12 | let min; 13 | 14 | if (data.length > 24) { 15 | throw new Error("Too much data"); 16 | } 17 | 18 | this.values = Buffer.alloc(48); 19 | 20 | data.forEach((d,i) => { 21 | if (d > max || max === undefined) { 22 | max = d; 23 | } 24 | if (d < min || min === undefined) { 25 | min = d; 26 | } 27 | this.values.writeInt16LE(d, i*2); 28 | }); 29 | 30 | this.min = min; 31 | this.max = max; 32 | }; 33 | 34 | ForecastScene.ctor.prototype.setTimestamp = function(date) { 35 | this.timestamp = Math.floor(date.getTime()/1000) - (date.getTimezoneOffset() * 60); 36 | }; 37 | 38 | ForecastScene.MODE = { 39 | RING: 8, 40 | TEXT: 16, 41 | BOTH: 24 42 | }; 43 | 44 | module.exports = ForecastScene; 45 | -------------------------------------------------------------------------------- /lib/devices/GlanceClock/Types/Glance.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto2"; 2 | 3 | //TODO: required with default? 4 | 5 | enum Days { 6 | None = 0; 7 | Monday = 1; 8 | Tuesday = 2; 9 | Wednesday = 3; 10 | Thursday = 4; 11 | Friday = 5; 12 | Saturday = 6; 13 | Sunday = 7; 14 | All = 8; 15 | } 16 | 17 | //TODO: is this even valid at all? 18 | //It might be used for customScenes 19 | enum SomeOtherAnimation { 20 | NoneAnimation = 0; 21 | Pulse = 1; 22 | Wave = 2; 23 | GifStart = 3; 24 | Wheel = 5; 25 | Flower = 6; 26 | Flower2 = 7; 27 | Fan = 8; 28 | Sun = 9; 29 | Thunderstorm = 10; 30 | Cloud = 11; 31 | WeatherStart = 13; 32 | WeatherCloudy = 15; 33 | WeatherFog = 16; 34 | WeatherLightRain = 17; 35 | WeatherRain = 18; 36 | WeatherThunderstorm = 19; 37 | WeatherSnow = 20; 38 | WeatherHail = 21; 39 | WeatherWind = 22; 40 | WeatherTornado = 23; 41 | WeatherHurricane = 24; 42 | WeatherSnowThunderstorm = 25; 43 | } 44 | 45 | enum Animation { 46 | NoneAnimation = 0; 47 | Pulse = 1; 48 | Wave = 2; 49 | Fire = 10; 50 | Wheel = 11; 51 | Flower = 12; 52 | Flower2 = 13; 53 | Fan = 14; 54 | Sun = 15; 55 | Thunderstorm = 16; 56 | Cloud = 17; 57 | WeatherClear = 101; 58 | WeatherCloudy = 102; 59 | WeatherFog = 103; 60 | WeatherLightRain = 104; 61 | WeatherRain = 105; 62 | WeatherThunderstorm = 106; 63 | WeatherSnow = 107; 64 | WeatherHail = 108; 65 | WeatherWind = 109; 66 | WeatherTornado = 110; 67 | WeatherHurricane = 111; 68 | WeatherSnowThunderstorm = 112; 69 | } 70 | 71 | 72 | enum Color { 73 | Black = 0; 74 | DarkGoldenRod = 1; 75 | Darkorange = 2; 76 | Olive = 3; 77 | OrangeRed = 4; 78 | Red = 5; 79 | Maroon = 6; 80 | DarkMagenta = 7; 81 | MediumVioletRed = 8; 82 | Brown = 9; 83 | Indigo = 10; 84 | BlueViolet = 11; 85 | White = 12; 86 | LightSlateBlue = 13; 87 | RoyalBlue = 14; 88 | Blue = 15; 89 | CornflowerBlue = 16; 90 | SkyBlue = 17; 91 | Turquoise = 18; 92 | Aqua = 19; 93 | MediumSpringGreen = 20; 94 | LimeGreen = 21; 95 | DarkGreen = 22; 96 | Lime = 23; 97 | LawnGreen = 24; 98 | Google1 = 25; 99 | Google2 = 26; 100 | Google3 = 27; 101 | Google4 = 28; 102 | Google5 = 29; 103 | Google6 = 30; 104 | Google7 = 31; 105 | Google8 = 32; 106 | Google9 = 33; 107 | Google10 = 34; 108 | Google11 = 35; 109 | Google12 = 36; 110 | Google13 = 37; 111 | Google14 = 38; 112 | Google15 = 39; 113 | Google16 = 40; 114 | Google17 = 41; 115 | Google18 = 42; 116 | Google19 = 43; 117 | Google20 = 44; 118 | Google21 = 45; 119 | Google22 = 46; 120 | Google23 = 47; 121 | Google24 = 48; 122 | Google25 = 49; 123 | Google26 = 50; 124 | Google27 = 51; 125 | Google28 = 52; 126 | Google29 = 53; 127 | } 128 | 129 | enum Sound { 130 | NoneSound = 0; 131 | Waves = 1; //Also known as Alarm 132 | Rise = 2; //Also known as Calendar_alert 133 | Charging = 3; 134 | Steps = 4; //Also known as Fitness_alert 135 | Radar = 5; //Also known as General_alert_1 136 | Bells = 6; //Also known as General_alert_2 137 | Bye = 7; //Also known as Goodbye 138 | Hello = 8; 139 | Flowers = 9; //Also known as Ringtone 140 | Circles = 10; //Also known as Taxi 141 | Complete = 11; //Also known as Timer_end 142 | Popcorn = 12; //Also known as Weather_alert 143 | Break = 13; 144 | Opening = 14; 145 | High = 15; 146 | Shine = 16; 147 | Extension = 17; 148 | } 149 | 150 | //This is most likely the data structure which got generated by the glance backend 151 | //if you had an api key and sent custom scenes 152 | message CustomScene { 153 | repeated Object object = 1; 154 | //This represents a sequence of things where each thing is an object 155 | //The sequence in which they are contained here doesn't specify when they are displayed though 156 | //instead, their visibility depends on their startTime and lifeTime property 157 | 158 | //time values are in frames. Framerate is 50FPS 159 | 160 | //area & segment uint32 both refer to the same data structure: 161 | //22 Bit number. At Least 1px 162 | // 5 4 3 2 1 163 | //000000 00 000000 00 000000 164 | //Divided like this 165 | //1 Start Pixel from 0 to 47 166 | //2 Offset from outer edge 0-3 px 167 | //3 Length in Pixels from 0 to 47. Note that there will always be one more. 168 | //4 Height 1-3 px 169 | //Note that Offset + height cannot exceed 4 or the clock will reject the command+ 170 | //5 Color ID from Color enum 171 | 172 | message Object { 173 | required Method method = 1 [default = Fill]; 174 | optional int32 startTime = 2 [default = 0]; 175 | required int32 lifeTime = 3 [default = 1]; 176 | optional Result result = 4 [default = OK]; 177 | optional AreaAnimationData areaAnimation = 5; 178 | optional FillData fill = 6; 179 | repeated TextData text = 7; 180 | optional Sound sound = 8 [default = NoneSound]; 181 | optional MovingBarData movingBar = 9; 182 | optional CmdData cmd = 11; 183 | optional GifData gif = 12; 184 | optional WeatherData weather = 13; 185 | 186 | message AreaAnimationData { 187 | //This gets layered onto already drawn areas 188 | //you draw some areas using MovingBarData and then apply an effect to the same area with this 189 | 190 | //LightFlashData, PulseData and WaveData can add further settings to the effect choosen in type 191 | //They are however optional. If not specified, the default effect parameters will be used 192 | //They will also be ignored if a different type is used 193 | 194 | required Type type = 1 [default = Pulse]; 195 | repeated int32 area = 2; 196 | optional WaveData wave = 3; 197 | optional PulseData pulse = 4; 198 | optional LightFlashData flashLight = 5; 199 | 200 | message LightFlashData { 201 | optional Color color = 1 [default = White]; 202 | optional int32 speed = 2 [default = 1]; 203 | } 204 | 205 | message PulseData { 206 | optional int32 riseTime = 1 [default = 50]; 207 | optional int32 fallTime = 2 [default = 50]; 208 | 209 | } 210 | 211 | enum Type { 212 | Pulse = 0; 213 | Wave = 1; 214 | LightFlash = 2; 215 | } 216 | 217 | message WaveData { 218 | optional int32 speed = 3 [default = 1]; 219 | } 220 | } 221 | 222 | message CmdData { 223 | //Yeah good luck figuring that out without access to either messages or the firmware sourcecode 224 | required bytes data = 1; 225 | } 226 | 227 | message FillData { 228 | repeated int32 segment = 1; 229 | } 230 | 231 | message GifData { 232 | required Animation type = 1 [default = GifStart]; 233 | //This only accepts a subset of the animation Enum: 10 - 17 234 | 235 | optional int32 segment = 2 [default = 782080]; 236 | //As length in pixels, only multiples of 8 (-1, due to there already being at least one px) work. 237 | //Everything else will be rounded down to the previous multiple of 8 by the clock 238 | //Also, the height property doesn't have any effect 239 | 240 | 241 | optional int32 speed = 3 [default = 3]; //0 to 10 242 | } 243 | 244 | enum Method { 245 | Cmd = 1; 246 | Fill = 2; 247 | Sound = 3; 248 | Text = 4; 249 | MovingBar = 5; 250 | AreaAnimation = 7; 251 | Gif = 8; 252 | Weather = 9; 253 | } 254 | 255 | message MovingBarData { 256 | required int32 area = 1 [default = 0]; //Pixel 0-47 257 | //The color defined in the area data will be ignored 258 | 259 | optional Color frontColor = 2 [default = Blue]; 260 | optional Color backColor = 3 [default = White]; 261 | optional int32 speed = 4 [default = 0]; //-10 to 10 (Controls direction & speed) 262 | } 263 | 264 | enum Result { //TODO: Find out the meaning of these 265 | OK = 0; //Everything drawn will stay (albeit inanimate) after its lifeTime is over 266 | SaveTime = 1; 267 | BackTime = 2; 268 | DelTime = 3; 269 | Expired = 4; //Everything drawn will vanish after its lifeTime is over 270 | End = 5; //The whole scene will end after its lifeTime is over 271 | NewSeq = 6; 272 | } 273 | 274 | message WeatherData { 275 | required Condition condition = 1 [default = Snow]; 276 | optional Position position = 2 [default = FullScreen]; 277 | optional int32 intensity = 3 [default = 5]; //0 to 10 278 | 279 | enum Condition { 280 | Snow = 0; 281 | Rain = 1; 282 | Fog = 2; 283 | } 284 | 285 | enum Position { 286 | FullScreen = 0; 287 | UpperHalf = 1; 288 | DownHalf = 2; 289 | } 290 | } 291 | } 292 | } 293 | 294 | 295 | message Notice { 296 | optional Animation type = 1 [default = Pulse]; 297 | optional Sound sound = 2 [default = General_alert_1]; 298 | optional Color color = 3 [default = Lime]; 299 | repeated TextData text = 4; 300 | } 301 | 302 | message Segments { 303 | repeated Segment segments = 1; 304 | optional Sound sound = 2 [default = Calendar_alert]; 305 | 306 | message Segment { 307 | required int32 segment = 1 [default = 0]; 308 | repeated TextData text = 2; 309 | } 310 | } 311 | 312 | message Settings { //TODO defaults 313 | optional DND dnd = 1; 314 | required bool nightModeEnabled = 2 [default = true]; 315 | optional bool permanentDND = 3 [default = false]; 316 | optional bool permanentMute = 4 [default = false]; 317 | required DateFormat dateFormat = 5 [default = DateDisabled]; 318 | optional int32 mgrSilentIntervalMin = 6 [default = 0]; 319 | optional int32 mgrSilentIntervalMax = 7 [default = 0]; 320 | required bool pointsAlwaysEnabled = 9 [default = false]; 321 | required int32 displayBrightness = 10 [default = 0]; 322 | required bool timeModeEnable = 11 [default = true]; 323 | required bool timeFormat12 = 12 [default = false]; 324 | optional int32 mgrUserActivityTimeout = 13 [default = 600]; 325 | optional Silent silent = 14; 326 | 327 | message DND { 328 | required bool recurring = 1 [default = false]; 329 | required int32 fromHour = 2 [default = 0]; 330 | required int32 tillHour = 3 [default = 0]; 331 | } 332 | 333 | enum DateFormat { 334 | DateDisabled = 0; 335 | Date24Jan = 1; 336 | Date24Tue = 2; 337 | DateJan24 = 3; 338 | DateTue24 = 4; 339 | } 340 | 341 | message Silent { 342 | required bool recurring = 1; 343 | required int32 fromHour = 2; 344 | required int32 tillHour = 3; 345 | } 346 | } 347 | 348 | message TextData { 349 | optional Modificator modificators = 1 [default = Repeat]; 350 | required bytes text = 2; //Each byte is an ascii charcode 351 | 352 | enum Modificator { 353 | None = 0; 354 | Repeat = 1; 355 | Rapid = 2; 356 | Delay = 3; 357 | } 358 | } 359 | 360 | message Timer { 361 | optional int32 countdown = 1 [default = 0]; //Pre-timer countdown. 362 | //Values > 99 will work but with broken text e.g. 100 => ":0" since charcode 58 is ":" and 57 is "9" 363 | 364 | repeated Interval intervals = 2; 365 | repeated TextData finalText = 3; 366 | 367 | message Interval { 368 | repeated TextData text = 1; //Valid, but not used at all? 369 | required int32 duration = 2 [default = 0]; //Seconds 370 | optional int32 countdown = 3 [default = 0]; //Seconds 371 | } 372 | } 373 | 374 | message AlarmData { 375 | optional bool enabled = 2 [default = true]; 376 | optional int32 days = 3 [default = 0]; 377 | required Time time = 4; 378 | required Sound sound = 5 [default = NoneSound]; 379 | repeated TextData text = 6; 380 | 381 | message Time { 382 | required int32 hours = 1 [default = 0]; 383 | required int32 minutes = 2 [default = 0]; 384 | } 385 | } 386 | 387 | message Alarms { 388 | repeated AlarmData alarm = 1; 389 | } 390 | 391 | message ForecastScene { 392 | required int64 timestamp = 1; 393 | //A unix timestamp 394 | 395 | optional uint32 doNotChange = 2 [default = 0]; //Absolutely no idea. 396 | 397 | required int32 maxColor = 4; //RGB FFFFFF = white 398 | required int32 minColor = 5; 399 | required int32 max = 6; 400 | required int32 min = 7; 401 | //The clock will use these to draw the circle from the values in "values" 402 | 403 | required bytes values = 8; 404 | //24 Int16LE. Each representing the forecasted value for one time unit (1h) 405 | //starting at timestamp. Valid for 24h 406 | //The clock will smoothen it automatically, so you can't have quickly alternating data. 407 | //Well.. you can, but it will end up as a star pattern which looks pretty nice actually 408 | 409 | 410 | required bytes template = 9; 411 | //0x08 will be replaced with the current value from values which is valid at this moment 412 | //It can appear multiple times. All appearances will be replaced 413 | //To write an icon, it has to be prefixed with 0xC2. e.g. 0xC2,0xB0 for ° 414 | //Regular ascii codes can be used 415 | //Example template: 0xC2,0x8F,0x08,0xC2,0xB0 = "[Thermometer icon][Current Value]°" 416 | } 417 | 418 | message AppointmentsScene { 419 | required int64 timestamp = 1; 420 | //A unix timestamp; 421 | 422 | required bytes appointmentData = 2; 423 | //I don't really understand the format, sadly. It seems like I'm missing something important 424 | //I've added sample appointment messages to this reposity so if you feel like looking into this further go ahead 425 | //sample_appointments.js 426 | 427 | //8 Bytes per Appointment 428 | 429 | //Byte 0,1 encode the Starting time 430 | //There seem to be two ways times are expressed: 431 | //Upcoming events are defined by the offset in minutes from the messages timestamp 432 | //Currently already active events use a different format. I don't really see a pattern there 433 | 434 | //Since the clock doesn't seem to have a proper understanding of timezones, the offset also includes the offset from UTC 435 | //Time is hard, but this is just weird. 436 | 437 | //Upcoming event offsets can be parsed by shifting the value 3 bits to the right and then parsing the first 11 bits 438 | //as an unsigned int. This will return the offset in minutes from the timestamp. 439 | //Example: 440 | //0x95 0xE5 is an appointment which starts at 2359 UTC2. Timestamp is 0200 UTC2 441 | //1001111111100101 in binary 442 | //1011001111111100 shift to the right by 3 bit 443 | //10110011111 11100 split in 11 and 5 bit 444 | //10110011111 = 1439 Minutes = 23h 59m => 2h UTC offset included 445 | 446 | //There also seem to be bugs in the backend. See this note excerpt: 447 | //1110110011100100 2100UTC2 448 | //0010100011100101 2200UTC2 449 | //0110010011100101 2300UTC2 450 | //1001111111100101 2359UTC2 451 | //0000000011100000 0000UTC2 (NEXT DAY UTC2) 452 | //0011110011100000 0100UTC2 is this the bug? 453 | //0111011111100000 0159UTC2 454 | //0001100011100110 0200UTC2 uuuuh. This must be a bug 455 | //It flipped back to the beginning of the day until it reached 2AM where it returned to the previous format 456 | 457 | //If there are multiple already active events which started in the past, they will all share the offset of the oldest 458 | //I'm unsure if that is a bug in the backend or intended 459 | 460 | 461 | //Byte 2,3 encode the length + notification time. There can only be one notification time per appointment 462 | //To decode the data, you need to read it as a 16-bit LE int, 463 | //shift it 8 bits to the right and then divide it into a 10 and a 6 bit number 464 | 465 | //The 10 bit number is the number is the notification time in minutes e.g. 30m in advance. if there's no notification it's all 1's 466 | //The 6 bit number is the number of started half hour segments. 0 means 1 segment, 1 means 2, 2 means 3, etc. 467 | //Since this is kinda hard to understand let's add an example here: 468 | 469 | //0x01 0x03 is a 1h Appointment with 12m notification 470 | //0000000100000011 in binary. 471 | //0000001100000001 shift to the right by 8 bit 472 | //0000001100 000001 split in 10 and 6 bit 473 | //So now we have 12 and 1 which translates to 12m and 2 Segments 474 | 475 | //Byte 4,5,6 encode the appointment color as B G R (whyever they've changed the order here) 476 | 477 | //Byte 7 encodes the "height" of the appointment if there are multiple appointments at the same time 478 | // => Decimal 3, 6, 9, 12 for 1,2,3 and 4 pixel height 479 | 480 | 481 | repeated TextData appointmentNames = 3; 482 | 483 | required Notify notify = 6; 484 | required Sound sound = 7; 485 | 486 | 487 | enum Notify { 488 | DISABLED = 0; 489 | ENABLED = 1; 490 | } 491 | } -------------------------------------------------------------------------------- /lib/devices/GlanceClock/Types/Settings.js: -------------------------------------------------------------------------------- 1 | const protobuf = require("protobufjs"); 2 | const protoRoot = protobuf.loadSync(require.resolve("./Glance.proto")); 3 | 4 | const Settings = protoRoot.lookupType("Settings"); 5 | 6 | Settings.ctor.prototype.getBrightness = function() { 7 | const briBuf = Buffer.alloc(4); 8 | const brightness = {}; 9 | briBuf.writeInt32LE(this.displayBrightness); 10 | brightness.auto = { 11 | max: briBuf[1], 12 | min: briBuf[2] 13 | }; 14 | brightness.value = briBuf[0] === 0 ? "auto" : briBuf[0]; 15 | 16 | return brightness; 17 | }; 18 | 19 | /** 20 | * 21 | * @param {object} options 22 | * @param {"auto"|number} options.value 23 | * @param {number} options.auto.max 24 | * @param {number} options.auto.min 25 | */ 26 | Settings.ctor.prototype.setBrightness = function(options) { 27 | if ( 28 | (options.value !== "auto" && (options.value > 255 || options.value < 0)) || 29 | options.auto.max > 255 || options.auto.max < 0 || 30 | options.auto.min > 255 || options.auto.min < 0 31 | ) { 32 | throw new Error("Invalid brightness"); 33 | } else { 34 | const value = options.value === "auto" ? 0 : options.value; 35 | 36 | this.displayBrightness = Buffer.from([ 37 | value, 38 | options.auto.max, 39 | options.auto.min, 40 | 0x00 41 | ]).readInt32LE(); 42 | } 43 | }; 44 | 45 | module.exports = Settings; 46 | -------------------------------------------------------------------------------- /lib/devices/GlanceClock/Types/TextData.js: -------------------------------------------------------------------------------- 1 | const protobuf = require("protobufjs"); 2 | const protoRoot = protobuf.loadSync(require.resolve("./Glance.proto")); 3 | 4 | const TextData = protoRoot.lookupType("TextData"); 5 | 6 | TextData.ICONS = { 7 | HOUSE: 128, 8 | PHONE: 129, 9 | CLOCK: 130, 10 | PLUG: 131, 11 | SMARTPHONE: 132, 12 | SUN: 133, 13 | SMILEY: 134, 14 | WHITE_BLOB: 135, 15 | NOTHING: 136, 16 | ZERO_BATTERY: 137, 17 | ONE_BATTERY: 138, 18 | TWO_BATTERY: 139, 19 | THREE_BATTERY: 140, 20 | NOTIFICATION_MUTE: 141, 21 | MUTE: 142, 22 | THERMOMETER: 143, 23 | DROPLET: 144, 24 | HEART: 145, 25 | BAROMETER: 146, 26 | LOWER_BORDER: 147, 27 | UPPER_BORDER: 148, 28 | UMBRELLA: 149, 29 | BELL: 150, 30 | SPEAKER: 151, 31 | WIND_SPEED: 152, 32 | CLOUD: 153, 33 | DEGREE: 176, 34 | TEMPLATE_ICON_REPLACEMENT_CHARACTER: 194 35 | }; 36 | 37 | 38 | Object.keys(TextData.ICONS).forEach(key => { 39 | TextData.ICONS[key] = Buffer.from([TextData.ICONS[key]]); 40 | }); 41 | 42 | TextData.TEXT_TO_BUFFER = function(text) { 43 | const chunks = text.split(/(\$\{[A-Z_]+\})/g); 44 | const outputChunks = []; 45 | 46 | chunks.forEach(c => { 47 | const match = c.match(/^\$\{([A-Z_]+)\}$/); 48 | 49 | if (match && match[1]) { 50 | outputChunks.push(TextData.ICONS[match[1]]); 51 | } else { 52 | outputChunks.push(Buffer.from(c, "ascii")); 53 | } 54 | }); 55 | 56 | return Buffer.concat(outputChunks); 57 | }; 58 | 59 | TextData.ctor.prototype.setText = function(text) { 60 | this.text = TextData.TEXT_TO_BUFFER(text); 61 | }; 62 | 63 | 64 | 65 | 66 | module.exports = TextData; 67 | -------------------------------------------------------------------------------- /lib/devices/GlanceClock/Types/index.js: -------------------------------------------------------------------------------- 1 | const protobuf = require("protobufjs"); 2 | const protoRoot = protobuf.loadSync(require.resolve("./Glance.proto")); 3 | 4 | const Types = { 5 | CustomScene: protoRoot.lookupType("CustomScene"), 6 | ENUMS: require("./Enums"), 7 | ENUMS_REVERSE: {}, 8 | ForecastScene: require("./ForecastScene"), 9 | Notice: protoRoot.lookupType("Notice"), 10 | Settings: require("./Settings"), 11 | TextData: require("./TextData"), 12 | Timer: protoRoot.lookupType("Timer"), 13 | TimerInterval: protoRoot.lookupType("Timer.Interval") 14 | }; 15 | 16 | Object.keys(Types.ENUMS).forEach(key => { 17 | const reversed = {}; 18 | 19 | Object.keys(Types.ENUMS[key]).forEach(k => { 20 | reversed[Types.ENUMS[key][k]] = k; 21 | }); 22 | 23 | Types.ENUMS_REVERSE[key] = reversed; 24 | }); 25 | 26 | module.exports = Types; 27 | -------------------------------------------------------------------------------- /lib/devices/MiFloraDevice.js: -------------------------------------------------------------------------------- 1 | const async = require("async"); 2 | 3 | const PollingDevice = require("./PollingDevice"); 4 | const tools = require("../Tools"); 5 | 6 | class MiFloraDevice extends PollingDevice { 7 | constructor(options) { 8 | super(options); 9 | 10 | this.deviceInterface = null; 11 | } 12 | 13 | initialize(callback) { 14 | async.each([ 15 | { 16 | topic: "homeassistant/sensor/miflora_" + this.id + "/" + this.id + "_battery/config", 17 | payload: { 18 | "state_topic": "cybele/miflora/" + this.id + "/state", 19 | "name": this.friendlyName + " Battery", 20 | "unique_id": "cybele_miflora_battery_" + this.id, 21 | "device_class": "battery", 22 | "unit_of_measurement": "%", 23 | "value_template": "{{ value_json.battery }}" 24 | } 25 | }, 26 | { 27 | topic: "homeassistant/sensor/miflora_" + this.id + "/" + this.id + "_temperature/config", 28 | payload: { 29 | "state_topic": "cybele/miflora/" + this.id + "/state", 30 | "name": this.friendlyName + " Temperature", 31 | "unique_id": "cybele_miflora_temperature_" + this.id, 32 | "device_class": "temperature", 33 | "unit_of_measurement": "°C", 34 | "value_template": "{{ value_json.temperature }}" 35 | } 36 | }, 37 | { 38 | topic: "homeassistant/sensor/miflora_" + this.id + "/" + this.id + "_illuminance/config", 39 | payload: { 40 | "state_topic": "cybele/miflora/" + this.id + "/state", 41 | "name": this.friendlyName + " Illuminance", 42 | "unique_id": "cybele_miflora_illuminance_" + this.id, 43 | "device_class": "illuminance", 44 | "unit_of_measurement": "lux", 45 | "value_template": "{{ value_json.illuminance }}" 46 | } 47 | }, 48 | { 49 | topic: "homeassistant/sensor/miflora_" + this.id + "/" + this.id + "_moisture/config", 50 | payload: { 51 | "state_topic": "cybele/miflora/" + this.id + "/state", 52 | "name": this.friendlyName + " Moisture", 53 | "unique_id": "cybele_miflora_moisture_" + this.id, 54 | "device_class": "humidity", 55 | "unit_of_measurement": "%", 56 | "value_template": "{{ value_json.moisture }}" 57 | } 58 | }, 59 | { 60 | topic: "homeassistant/sensor/miflora_" + this.id + "/" + this.id + "_conductivity/config", 61 | payload: { 62 | "state_topic": "cybele/miflora/" + this.id + "/state", 63 | "name": this.friendlyName + " Conductivity", 64 | "unique_id": "cybele_miflora_conductivity_" + this.id, 65 | "unit_of_measurement": "µS/cm", 66 | "value_template": "{{ value_json.conductivity }}" 67 | } 68 | } 69 | ], (autoconfigEntry, done) => { 70 | this.mqttClient.publish( 71 | autoconfigEntry.topic, 72 | JSON.stringify(autoconfigEntry.payload), 73 | {retain: true}, 74 | err => { 75 | done(err); 76 | }); 77 | }, err => { 78 | if (!err) { 79 | this.queuePolling(); 80 | } 81 | 82 | callback(err); 83 | }); 84 | } 85 | 86 | async poll() { //TODO: PollingDevice.poll isn't async yet 87 | let fwInfo; 88 | let reading; 89 | 90 | try { 91 | if (!this.deviceInterface) { 92 | this.deviceInterface = await this.getDBusInterfaceAsync( 93 | this.pathRoot + "/dev_" + this.macInDbusNotation, 94 | "org.bluez.Device1" 95 | ); 96 | } 97 | } catch (e) { 98 | //If there is no interface, it means that the device is out of range 99 | return this.queuePolling(); 100 | } 101 | 102 | try { 103 | await this.connectDeviceAsync(this.deviceInterface, 5000, 5); //TODO: configurable? 104 | } catch (e) { 105 | console.error(e); 106 | return this.queuePolling(); 107 | } 108 | 109 | try { 110 | await this.writeCharacteristicAsync( 111 | this.characteristicsByUUID[MiFloraDevice.CHARACTERISTICS.deviceMode], 112 | MiFloraDevice.LIVE_MODE_CMD, 113 | "request" 114 | ); 115 | } catch (e) { 116 | console.error(e); 117 | return this.queuePolling(); 118 | } 119 | 120 | try { 121 | fwInfo = MiFloraDevice.PARSE_FIRMWARE_CHARACTERISTIC( 122 | await this.readCharacteristicAsync( this.characteristicsByUUID[MiFloraDevice.CHARACTERISTICS.firmware]) 123 | ); 124 | 125 | this.queuePolling(); 126 | } catch (e) { 127 | console.error(e); 128 | return this.queuePolling(); 129 | } 130 | 131 | //According to this issue, the blinking on connect messes up the readings which can be solved by waiting a little 132 | //https://github.com/open-homeautomation/miflora/issues/136#issuecomment-549128076 133 | await tools.sleep(5000); 134 | 135 | try { 136 | reading = MiFloraDevice.PARSE_DATA( 137 | await this.readCharacteristicAsync( this.characteristicsByUUID[MiFloraDevice.CHARACTERISTICS.sensorData]) 138 | ); 139 | } catch (e) { 140 | console.error(e); 141 | return this.queuePolling(); 142 | } 143 | 144 | 145 | if (reading && fwInfo) { 146 | this.mqttClient.publish("cybele/miflora/" + this.id + "/state", JSON.stringify({ 147 | battery: fwInfo.batteryPct, 148 | temperature: reading.temp, 149 | illuminance: reading.brightness, 150 | moisture: reading.moisture, 151 | conductivity: reading.conductivity 152 | }), {retain: true}, err => { 153 | if (err) { 154 | console.error(err); 155 | } 156 | }); 157 | } else { 158 | console.info("Got invalid or missing data for " + this.friendlyName, reading, fwInfo); 159 | } 160 | 161 | this.queuePolling(); 162 | } 163 | } 164 | 165 | MiFloraDevice.LIVE_MODE_CMD = Buffer.from([0xA0, 0x1F]); 166 | 167 | MiFloraDevice.PARSE_DATA = function(buf) { 168 | if (buf && buf.length === 16) { 169 | const temp = buf.readUInt16LE(0) / 10; 170 | 171 | if (temp < 200) { //For some reason, some readings are trash. We're using the temperature to validate 172 | return { 173 | temp: temp, 174 | brightness: buf.readUInt32LE(3), 175 | moisture: buf.readUInt8(7), 176 | conductivity: buf.readUInt16LE(8) 177 | }; 178 | } 179 | } 180 | }; 181 | 182 | MiFloraDevice.PARSE_FIRMWARE_CHARACTERISTIC = function(buf) { 183 | if (buf && buf.length === 7) { 184 | return { 185 | batteryPct: buf.readUInt8(0), 186 | firmwareVersion: buf.toString("ascii", 2) 187 | }; 188 | } 189 | }; 190 | 191 | MiFloraDevice.CHARACTERISTICS = { 192 | deviceMode: "00001a00-0000-1000-8000-00805f9b34fb", 193 | sensorData: "00001a01-0000-1000-8000-00805f9b34fb", 194 | firmware: "00001a02-0000-1000-8000-00805f9b34fb" 195 | }; 196 | 197 | module.exports = MiFloraDevice; 198 | -------------------------------------------------------------------------------- /lib/devices/MiKettle/MiCipher.js: -------------------------------------------------------------------------------- 1 | const KEY_SIZE = 256; 2 | 3 | module.exports = { 4 | /** 5 | * 6 | * @param {Buffer} mac 7 | * @param {number} productId 8 | */ 9 | mixA: function(mac, productId) { 10 | if (!mac || mac.length !== 6) { 11 | throw new Error("Invalid mac"); 12 | } 13 | return Buffer.from([ 14 | mac[0], 15 | mac[2], 16 | mac[5], 17 | productId & 0xff, 18 | productId & 0xff, 19 | mac[4], 20 | mac[5], 21 | mac[1] 22 | ]); 23 | }, 24 | /** 25 | * 26 | * @param {Buffer} mac 27 | * @param {number} productId 28 | */ 29 | mixB: function(mac, productId) { 30 | if (!mac || mac.length !== 6) { 31 | throw new Error("Invalid mac"); 32 | } 33 | return Buffer.from([ 34 | mac[0], 35 | mac[2], 36 | mac[5], 37 | (productId >> 8) & 0xff, 38 | mac[4], 39 | mac[0], 40 | mac[5], 41 | productId & 0xff 42 | ]); 43 | }, 44 | /** 45 | * 46 | * @param {Buffer} key 47 | * @param {Buffer} input 48 | */ 49 | cipher: function(key, input) { 50 | const perm = Buffer.alloc(KEY_SIZE); 51 | const output = Buffer.alloc(input.length); 52 | 53 | //Init perm 54 | for (let i = 0; i < perm.length; i++) { 55 | perm[i] = i; 56 | } 57 | 58 | let j = 0; 59 | for (let i= 0; i < perm.length; i++) { 60 | const tmp = perm[i]; 61 | 62 | j = (j + perm[i] + key[i%key.length])%256; 63 | 64 | perm[i] = perm[j]; 65 | perm[j] = tmp; 66 | } 67 | 68 | let index1 = 0; 69 | let index2 = 0; 70 | 71 | for (let i = 0; i < input.length; i++) { 72 | let tmp; 73 | index1 = (index1+1)%256; 74 | index2 = (index2 + perm[index1])%256; 75 | 76 | tmp = perm[index1]; 77 | perm[index1] = perm[index2]; 78 | perm[index2] = tmp; 79 | 80 | output[i] = input[i] ^ perm[(perm[index1] + perm[index2])%256]; 81 | } 82 | 83 | return output; 84 | } 85 | }; 86 | -------------------------------------------------------------------------------- /lib/devices/MiKettle/MiKettleDevice.js: -------------------------------------------------------------------------------- 1 | const crypto = require("crypto"); 2 | const Semaphore = require("semaphore"); 3 | 4 | const Device = require("../Device"); 5 | const MiCipher = require("./MiCipher"); 6 | const MiKettleMqttHandler = require("./MiKettleMqttHandler"); 7 | 8 | class MiKettleDevice extends Device { 9 | /** 10 | * 11 | * @param {object} options 12 | * @param {number} options.productId 13 | * @param {Array} [options.token] 14 | */ 15 | constructor(options) { 16 | super(options); 17 | 18 | this.mqttHandler = new MiKettleMqttHandler({ 19 | device: this, 20 | prefix: "kettle" 21 | }); 22 | 23 | this.reversedMac = this.mac.split(":").map(s => { 24 | return parseInt(s, 16); 25 | }).reverse(); 26 | this.productId = options.productId; 27 | 28 | this.connectionSemaphore = new Semaphore(1); 29 | 30 | if (Array.isArray(options.token) && options.token.length === 12) { 31 | this.token = Buffer.from(options.token); 32 | } else { 33 | this.token = Buffer.alloc(12); 34 | crypto.randomFillSync(this.token); 35 | } 36 | 37 | this.extendedAttributes = {}; 38 | } 39 | 40 | initialize(callback) { 41 | this.prepareDeviceInterface((err, deviceInterface) => { 42 | if (!err && deviceInterface) { 43 | deviceInterface.Connected((err, isConnected) => { //This can actually lie for some reason. 44 | if (!err && isConnected === true) { 45 | console.info(this.friendlyName + " is already connected."); 46 | this.connected = true; 47 | 48 | this.mapServicesAsync().then(() => { 49 | this.enableStatusNotifications(err => { 50 | if (err) { 51 | console.error(err); 52 | } else { 53 | this.fetchExtendedAttributes(err => { 54 | if (err) { 55 | console.error(err); 56 | } 57 | }); 58 | } 59 | }); 60 | }).catch(err => { 61 | console.error(err); 62 | }); 63 | } 64 | }); 65 | } 66 | }); 67 | 68 | this.mqttHandler.initialize(err => { 69 | callback(err); 70 | }); 71 | } 72 | 73 | handleAdvertisingForDevice(props) { 74 | super.handleAdvertisingForDevice(props); 75 | 76 | if (props.Connected !== undefined) { 77 | this.mqttHandler.updatePresence(props.Connected); 78 | 79 | if (props.Connected === false) { 80 | console.info("Disconnected from " + this.friendlyName); 81 | } 82 | } 83 | 84 | if (Object.keys(props).length === 1 && props.RSSI !== undefined) { 85 | if (props.RSSI < -98) { 86 | console.info("Signal is very weak. Connection to " + this.friendlyName + " might fail or be unreliable."); 87 | } 88 | if (this.connected === false && this.connectionSemaphore.available()) { 89 | this.connectionSemaphore.take(() => { 90 | this.connectToKettle(err => { 91 | if (err) { 92 | console.error(this.friendlyName, err, "while connecting"); 93 | } 94 | }); 95 | }); 96 | } 97 | } 98 | } 99 | 100 | handleNotificationForDevice(props) { 101 | if (props[this.handlesByUUID[MiKettleDevice.CHARACTERISTICS.auth]]) { 102 | this.doAuthStageThree(props[this.handlesByUUID[MiKettleDevice.CHARACTERISTICS.auth]], err => { 103 | if (err) { 104 | console.error(err); 105 | } else { 106 | this.enableStatusNotifications(err => { 107 | if (err) { 108 | console.error(err); 109 | } else { 110 | console.info("Connected to " + this.friendlyName); 111 | 112 | this.fetchExtendedAttributes(err => { 113 | if (err) { 114 | console.error(err); 115 | } 116 | }); 117 | } 118 | }); 119 | } 120 | }); 121 | } else if (props[this.handlesByUUID[MiKettleDevice.CHARACTERISTICS.status]]) { 122 | const parsedStatus = MiKettleDevice.PARSE_STATUS(props[this.handlesByUUID[MiKettleDevice.CHARACTERISTICS.status]]); 123 | parsedStatus.keep_warm_refill_mode = this.extendedAttributes.keepWarmRefillMode; 124 | parsedStatus.keep_warm_time_limit = this.extendedAttributes.keepWarmTimeLimit; 125 | 126 | this.mqttHandler.updateState(parsedStatus); 127 | } 128 | } 129 | 130 | connectToKettle(callback) { 131 | this.prepareDeviceInterface(async (err, deviceInterface) => { 132 | if (!err && deviceInterface) { 133 | try { 134 | await this.connectDeviceAsync(deviceInterface, 4000); //TODO: does this make sense? 135 | } catch (e) { 136 | this.connectionSemaphore.leave(); //This is so confusing it will definitely break at some point 137 | return callback(e); 138 | } 139 | 140 | this.doAuthStageOne(err => { 141 | this.connectionSemaphore.leave(); 142 | callback(err); 143 | }); 144 | } else { 145 | callback(new Error("Missing device Interface")); 146 | } 147 | }); 148 | } 149 | 150 | doAuthStageOne(callback) { 151 | this.semaphore.take(() => { 152 | this.characteristicsByUUID[MiKettleDevice.CHARACTERISTICS.authInit].WriteValue(MiKettleDevice.KEY1, {type: "command"}, err => { 153 | this.semaphore.leave(); 154 | if (!err) { 155 | this.doAuthStageTwo(callback); 156 | } else { 157 | callback(Array.isArray(err) ? err.join(".") : err); 158 | } 159 | }); 160 | }); 161 | } 162 | 163 | doAuthStageTwo(callback) { 164 | this.semaphore.take(() => { 165 | this.characteristicsByUUID[MiKettleDevice.CHARACTERISTICS.auth].StartNotify([], err => { 166 | this.semaphore.leave(); 167 | 168 | if (!err) { 169 | const value = MiCipher.cipher(MiCipher.mixA(this.reversedMac, this.productId), this.token); 170 | 171 | this.semaphore.take(() => { 172 | this.characteristicsByUUID[MiKettleDevice.CHARACTERISTICS.auth].WriteValue(value, {type: "request"}, err => { 173 | this.semaphore.leave(); 174 | 175 | callback(Array.isArray(err) ? err.join(".") : err); 176 | }); 177 | }); 178 | } else { 179 | callback(Array.isArray(err) ? err.join(".") : err); 180 | } 181 | }); 182 | }); 183 | } 184 | 185 | doAuthStageThree(data, callback) { 186 | const response = MiCipher.cipher( 187 | MiCipher.mixB(this.reversedMac, this.productId), 188 | MiCipher.cipher( 189 | MiCipher.mixA(this.reversedMac, this.productId), 190 | data 191 | ) 192 | ); 193 | 194 | if (response.compare(this.token) === 0) { 195 | this.semaphore.take(() => { 196 | const value = MiCipher.cipher(this.token, MiKettleDevice.KEY2); 197 | 198 | this.characteristicsByUUID[MiKettleDevice.CHARACTERISTICS.auth].WriteValue(value, {type: "command"}, err => { 199 | this.semaphore.leave(); 200 | 201 | if (!err) { 202 | this.doAuthStageFour(callback); 203 | } else { 204 | callback(Array.isArray(err) ? err.join(".") : err); 205 | } 206 | }); 207 | }); 208 | } else { 209 | callback(new Error("Verification failed")); 210 | } 211 | } 212 | 213 | doAuthStageFour(callback) { 214 | this.semaphore.take(() => { 215 | this.characteristicsByUUID[MiKettleDevice.CHARACTERISTICS.verify].ReadValue({}, err => { 216 | this.semaphore.leave(); 217 | callback(Array.isArray(err) ? err.join(".") : err); 218 | }); 219 | }); 220 | } 221 | 222 | prepareDeviceInterface(callback) { 223 | this.blueZservice.getInterface( 224 | this.pathRoot + "/dev_" + this.macInDbusNotation, 225 | "org.bluez.Device1", 226 | (err, deviceInterface) => { 227 | callback(err, deviceInterface); 228 | } 229 | ); 230 | } 231 | 232 | enableStatusNotifications(callback) { 233 | this.semaphore.take(() => { 234 | this.characteristicsByUUID[MiKettleDevice.CHARACTERISTICS.status].StopNotify([], err => { 235 | this.semaphore.leave(); 236 | err = Array.isArray(err) ? err.join(".") : err; 237 | 238 | if (!err || err === "No notify session started") { 239 | setTimeout(() => { 240 | this.semaphore.take(() => { 241 | this.characteristicsByUUID[MiKettleDevice.CHARACTERISTICS.status].StartNotify([], err => { 242 | this.semaphore.leave(); 243 | 244 | callback(Array.isArray(err) ? err.join(".") : err); 245 | }); 246 | }); 247 | }, 1000); //TODO: Why is this needed? 248 | } else { 249 | callback(err); 250 | } 251 | }); 252 | }); 253 | } 254 | 255 | fetchExtendedAttributes(callback) { 256 | this.semaphore.take(() => { 257 | this.characteristicsByUUID[MiKettleDevice.CHARACTERISTICS.keepWarmRefillMode].ReadValue({}, (err, keepWarmRefillMode) => { 258 | this.semaphore.leave(); 259 | err = Array.isArray(err) ? err.join(".") : err; 260 | 261 | 262 | if (!err && keepWarmRefillMode) { 263 | this.extendedAttributes.keepWarmRefillMode = MiKettleDevice.KEEP_WARM_REFILL_MODE[keepWarmRefillMode[0]]; 264 | 265 | this.semaphore.take(() => { 266 | this.characteristicsByUUID[MiKettleDevice.CHARACTERISTICS.time].ReadValue({}, (err, time) => { 267 | this.semaphore.leave(); 268 | err = Array.isArray(err) ? err.join(".") : err; 269 | 270 | if (!err && time) { 271 | this.extendedAttributes.keepWarmTimeLimit = time[0] / 2; 272 | } 273 | 274 | callback(err); 275 | }); 276 | }); 277 | } else { 278 | callback(err); 279 | } 280 | }); 281 | }); 282 | } 283 | 284 | setKeepWarmParameters(mode, temp, callback) { 285 | const payload = Buffer.from([mode, temp]); 286 | 287 | this.semaphore.take(() => { 288 | this.characteristicsByUUID[MiKettleDevice.CHARACTERISTICS.setup].WriteValue(payload, {mode: "request"}, err => { 289 | this.semaphore.leave(); 290 | err = Array.isArray(err) ? err.join(".") : err; 291 | 292 | callback(err); 293 | }); 294 | }); 295 | } 296 | 297 | setKeepWarmTimeLimit(time, callback) { 298 | const payload = Buffer.from([time]); 299 | 300 | this.semaphore.take(() => { 301 | this.characteristicsByUUID[MiKettleDevice.CHARACTERISTICS.time].WriteValue(payload, {mode: "request"}, err => { 302 | this.semaphore.leave(); 303 | err = Array.isArray(err) ? err.join(".") : err; 304 | 305 | if (!err) { 306 | this.extendedAttributes.keepWarmTimeLimit = time / 2; 307 | } 308 | 309 | callback(err); 310 | }); 311 | }); 312 | } 313 | 314 | setKeepWarmRefillMode(keepWarmRefillMode, callback) { 315 | const payload = Buffer.from([keepWarmRefillMode]); 316 | 317 | this.semaphore.take(() => { 318 | this.characteristicsByUUID[MiKettleDevice.CHARACTERISTICS.keepWarmRefillMode].WriteValue(payload, {mode: "request"}, err => { 319 | this.semaphore.leave(); 320 | err = Array.isArray(err) ? err.join(".") : err; 321 | 322 | if (!err) { 323 | this.extendedAttributes.keepWarmRefillMode = MiKettleDevice.KEEP_WARM_REFILL_MODE[keepWarmRefillMode]; 324 | } 325 | 326 | callback(err); 327 | }); 328 | }); 329 | } 330 | } 331 | 332 | MiKettleDevice.CHARACTERISTICS = { 333 | authInit: "00000010-0000-1000-8000-00805f9b34fb", 334 | auth: "00000001-0000-1000-8000-00805f9b34fb", 335 | verify: "00000004-0000-1000-8000-00805f9b34fb", 336 | 337 | setup: "0000aa01-0000-1000-8000-00805f9b34fb", 338 | status: "0000aa02-0000-1000-8000-00805f9b34fb", 339 | time: "0000aa04-0000-1000-8000-00805f9b34fb", 340 | keepWarmRefillMode: "0000aa05-0000-1000-8000-00805f9b34fb" 341 | }; 342 | 343 | MiKettleDevice.KEY1 = Buffer.from([0x90, 0xCA, 0x85, 0xDE]); 344 | MiKettleDevice.KEY2 = Buffer.from([0x92, 0xAB, 0x54, 0xFA]); 345 | 346 | MiKettleDevice.ACTION = { 347 | 0: "idle", 348 | 1: "heating", 349 | 2: "cooling", 350 | 3: "keeping_warm" 351 | }; 352 | MiKettleDevice.MODE = { 353 | 255: "none", 354 | 1: "boil", 355 | 2: "keep_warm" 356 | }; 357 | MiKettleDevice.KEEP_WARM_TYPE = { 358 | 0: "boil_and_cool_down", 359 | 1: "heat_to_temperature" 360 | }; 361 | MiKettleDevice.KEEP_WARM_REFILL_MODE = { 362 | 0: "keep_warm", 363 | 1: "turn_off" 364 | }; 365 | 366 | MiKettleDevice.PARSE_STATUS = (data) => { 367 | return { 368 | action: MiKettleDevice.ACTION[data.readUInt8(0)], 369 | mode: MiKettleDevice.MODE[data.readUInt8(1)], 370 | keep_warm_set_temperature: data.readUInt8(4), 371 | current_temperature: data.readUInt8(5), 372 | keep_warm_type: MiKettleDevice.KEEP_WARM_TYPE[data.readUInt8(6)], 373 | keep_warm_time: data.readUInt16LE(7) 374 | }; 375 | }; 376 | 377 | module.exports = MiKettleDevice; 378 | -------------------------------------------------------------------------------- /lib/devices/MiKettle/MiKettleMqttHandler.js: -------------------------------------------------------------------------------- 1 | const MqttHandler = require("../../MqttHandler"); 2 | 3 | 4 | class MiKettleMqttHandler extends MqttHandler { 5 | /** 6 | * @param {object} options 7 | */ 8 | constructor(options) { 9 | super(options); 10 | 11 | this.registerTopicHandler("set_keep_warm_parameters", cmd => { 12 | return this.setKeepWarmParameters(cmd); 13 | }); 14 | this.registerTopicHandler("set_keep_warm_time_limit", cmd => { 15 | return this.setKeepWarmTimeLimit(cmd); 16 | }); 17 | this.registerTopicHandler("set_keep_warm_refill_mode", cmd => { 18 | return this.setKeepWarmRefillMode(cmd); 19 | }); 20 | 21 | this.lastState = null; 22 | } 23 | 24 | setupAutodiscovery(callback) { 25 | this.device.mqttClient.publish("homeassistant/sensor/kettle_" + this.device.id + "/config", JSON.stringify({ 26 | "state_topic": this.writableTopics.state, 27 | "json_attributes_topic": this.writableTopics.attributes, 28 | "name": this.device.friendlyName, 29 | "unique_id": "cybele_kettle_" + this.device.id, 30 | "platform": "mqtt", 31 | "unit_of_measurement": "°C", 32 | "availability_topic": this.writableTopics.presence, 33 | "icon": "mdi:kettle" 34 | }), {retain: true}, callback); 35 | } 36 | 37 | updateState(state) { 38 | const stringifiedState = JSON.stringify(state); 39 | 40 | if (stringifiedState !== this.lastState) { 41 | this.lastState = stringifiedState; 42 | 43 | this.updatePresence(true); 44 | this.mqttClient.publish(this.writableTopics.state, state.current_temperature.toString(), {retain: true}, err => { 45 | if (err) { 46 | console.error(err); 47 | } 48 | }); 49 | this.mqttClient.publish(this.writableTopics.attributes, JSON.stringify({ 50 | action: state.action, 51 | mode: state.mode, 52 | keep_warm_refill_mode: state.keep_warm_refill_mode, 53 | keep_warm_temperature: state.keep_warm_set_temperature, 54 | keep_warm_type: state.keep_warm_type, 55 | keep_warm_time: state.keep_warm_time, 56 | keep_warm_time_limit: state.keep_warm_time_limit 57 | }), {retain: true}, err => { 58 | if (err) { 59 | console.error(err); 60 | } 61 | }); 62 | } 63 | } 64 | 65 | /** 66 | * @param {object} cmd 67 | * @param {"boil"|"heat"} cmd.mode 68 | * @param {number} cmd.temperature 69 | */ 70 | setKeepWarmParameters(cmd) { 71 | const mode = MiKettleMqttHandler.KEEP_WARM_MODES[cmd.mode]; 72 | const temp = cmd.temperature; 73 | 74 | if (mode !== undefined && temp >= 40 && temp <= 90) { 75 | this.device.setKeepWarmParameters(mode, temp, err => { 76 | if (err) { 77 | console.error(err); 78 | } 79 | }); 80 | } 81 | } 82 | 83 | /** 84 | * 85 | * @param {object} cmd 86 | * @param {number} cmd.time 87 | */ 88 | setKeepWarmTimeLimit(cmd) { 89 | const time = Math.round(cmd.time * 2); 90 | 91 | if (time >= 0 && time <= 24) { 92 | this.device.setKeepWarmTimeLimit(time, err => { 93 | if (err) { 94 | console.error(err); 95 | } 96 | }); 97 | } 98 | } 99 | 100 | /** 101 | * 102 | * @param {object} cmd 103 | * @param {"turn_off"|"keep_warm"} cmd.mode 104 | */ 105 | setKeepWarmRefillMode(cmd) { 106 | const mode = MiKettleMqttHandler.KEEP_WARM_REFILL_MODE[cmd.mode]; 107 | 108 | if (mode !== undefined) { 109 | this.device.setKeepWarmRefillMode(mode, err => { 110 | if (err) { 111 | console.error(err); 112 | } 113 | }); 114 | } 115 | } 116 | } 117 | 118 | 119 | 120 | MiKettleMqttHandler.KEEP_WARM_MODES = { 121 | "boil": 0, 122 | "heat": 1 123 | }; 124 | 125 | MiKettleMqttHandler.KEEP_WARM_REFILL_MODE = { 126 | "turn_off": 1, 127 | "keep_warm": 0 128 | }; 129 | 130 | 131 | module.exports = MiKettleMqttHandler; 132 | -------------------------------------------------------------------------------- /lib/devices/MiLYWSD03MMCDevice.js: -------------------------------------------------------------------------------- 1 | const async = require("async"); 2 | const Device = require("./Device"); 3 | 4 | class MiLYWSD03MMCDevice extends Device { 5 | initialize(callback) { 6 | async.each([ 7 | { 8 | topic: "homeassistant/sensor/MiLYWSD03MMC/" + this.id + "_tem/config", 9 | payload: { 10 | "state_topic": "cybele/MiLYWSD03MMC/" + this.id + "/state", 11 | "name": this.friendlyName + " Temperature", 12 | "unique_id": "cybele_temp_" + this.id, 13 | "platform": "mqtt", 14 | "unit_of_measurement": "°C", 15 | "device_class": "temperature", 16 | "value_template": "{{ value_json.tempc }}" 17 | } 18 | }, 19 | { 20 | topic: "homeassistant/sensor/MiLYWSD03MMC/" + this.id + "_hum/config", 21 | payload: { 22 | "state_topic": "cybele/MiLYWSD03MMC/" + this.id + "/state", 23 | "name": this.friendlyName + " Humidity", 24 | "unique_id": "cybele_hum_" + this.id, 25 | "platform": "mqtt", 26 | "unit_of_measurement": "%", 27 | "device_class": "humidity", 28 | "value_template": "{{ value_json.hum }}" 29 | } 30 | }, 31 | { 32 | topic: "homeassistant/sensor/MiLYWSD03MMC/" + this.id + "_bat/config", 33 | payload: { 34 | "state_topic": "cybele/MiLYWSD03MMC/" + this.id + "/state", 35 | "name": this.friendlyName + " Battery percent", 36 | "unique_id": "cybele_bat_" + this.id, 37 | "platform": "mqtt", 38 | "unit_of_measurement": "%", 39 | "device_class": "battery", 40 | "value_template": "{{ value_json.batt }}" 41 | } 42 | }, 43 | { 44 | topic: "homeassistant/sensor/MiLYWSD03MMC/" + this.id + "_batv/config", 45 | payload: { 46 | "state_topic": "cybele/MiLYWSD03MMC/" + this.id + "/state", 47 | "name": this.friendlyName + " Battery Volt", 48 | "unique_id": "cybele_bat_" + this.id, 49 | "platform": "mqtt", 50 | "unit_of_measurement": "V", 51 | "device_class": "battery", 52 | "value_template": "{{ value_json.volt }}" 53 | } 54 | } 55 | ], (autoconfigEntry, done) => { 56 | this.mqttClient.publish( 57 | autoconfigEntry.topic, 58 | JSON.stringify(autoconfigEntry.payload), 59 | {retain: true}, 60 | err => { 61 | done(err); 62 | }); 63 | }, err => { 64 | callback(err); 65 | }); 66 | } 67 | 68 | handleAdvertisingForDevice(props) { 69 | super.handleAdvertisingForDevice(props); 70 | 71 | if ( 72 | props.ServiceData && 73 | props.ServiceData.UUID === "0000181a-0000-1000-8000-00805f9b34fb" && 74 | Buffer.isBuffer(props.ServiceData.data) 75 | ) { 76 | const parsedData = MiLYWSD03MMCDevice.PARSE_SERVICE_DATA(props.ServiceData.data); 77 | if (parsedData) { 78 | this.mqttClient.publish("cybele/MiLYWSD03MMC/" + this.id + "/state", JSON.stringify(parsedData), {retain: true}); 79 | } 80 | } 81 | } 82 | } 83 | 84 | 85 | MiLYWSD03MMCDevice.PARSE_SERVICE_DATA = function(data) { 86 | return { 87 | tempc: data.readUIntBE(6,2)/10, 88 | hum: data.readUInt8(8), 89 | batt: data.readUInt8(9), 90 | volt: data.readUInt16BE(10)/1000 91 | }; 92 | }; 93 | 94 | module.exports = MiLYWSD03MMCDevice; 95 | -------------------------------------------------------------------------------- /lib/devices/OralBToothbrushDevice.js: -------------------------------------------------------------------------------- 1 | const Device = require("./Device"); 2 | 3 | class OralBToothbrushDevice extends Device { 4 | initialize(callback) { 5 | this.mqttClient.publish("homeassistant/sensor/toothbrush_" + this.id + "/config", JSON.stringify({ 6 | "state_topic": "cybele/toothbrush/" + this.id + "/state", 7 | "json_attributes_topic": "cybele/toothbrush/" + this.id + "/attributes", 8 | "name": this.friendlyName, 9 | "unique_id": "cybele_toothbrush_" + this.id, 10 | "platform": "mqtt", 11 | "availability_topic": "cybele/toothbrush/" + this.id + "/presence", 12 | "icon": "mdi:tooth-outline" 13 | }), {retain: true}, err => { 14 | callback(err); 15 | }); 16 | } 17 | 18 | handleAdvertisingForDevice(props) { 19 | super.handleAdvertisingForDevice(props); 20 | 21 | if (props.ManufacturerData) { 22 | const parsedData = OralBToothbrushDevice.PARSE_TOOTHBRUSH_DATA(props.ManufacturerData); 23 | 24 | this.mqttClient.publish("cybele/toothbrush/" + this.id + "/presence", parsedData.state > 0 ? "online" : "offline", {retain: true}); 25 | this.mqttClient.publish("cybele/toothbrush/" + this.id + "/state", OralBToothbrushDevice.STATES[parsedData.state], {retain: true}); 26 | this.mqttClient.publish("cybele/toothbrush/" + this.id + "/attributes", JSON.stringify({ 27 | rssi: props.RSSI, 28 | pressure: parsedData.pressure, 29 | time: parsedData.time, 30 | mode: OralBToothbrushDevice.MODES[parsedData.mode], 31 | sector: OralBToothbrushDevice.SECTORS[parsedData.sector] 32 | }), {retain: true}); 33 | } 34 | } 35 | } 36 | 37 | 38 | OralBToothbrushDevice.PARSE_TOOTHBRUSH_DATA = function(data) { 39 | return { 40 | state: data[3], 41 | pressure: data[4], 42 | time: data[5] * 60 + data[6], 43 | mode: data[7], 44 | sector: data[8] 45 | }; 46 | }; 47 | 48 | OralBToothbrushDevice.STATES = { 49 | 0: "unknown", 50 | 1: "initializing", 51 | 2: "idle", 52 | 3: "running", 53 | 4: "charging", 54 | 5: "setup", 55 | 6: "flight_menu", 56 | 113: "final_test", 57 | 114: "pcb_test", 58 | 115: "sleeping", 59 | 116: "transport" 60 | }; 61 | 62 | OralBToothbrushDevice.MODES = { 63 | 0: "off", 64 | 1: "daily_clean", 65 | 2: "sensitive", 66 | 3: "massage", 67 | 4: "whitening", 68 | 5: "deep_clean", 69 | 6: "tongue_cleaning", 70 | 7: "turbo", 71 | 255: "unknown" 72 | }; 73 | 74 | OralBToothbrushDevice.SECTORS = { 75 | 1: "sector_1", 76 | 2: "sector_2", 77 | 3: "sector_3", 78 | 4: "sector_4", 79 | 5: "sector_5", 80 | 6: "sector_6", 81 | 7: "sector_7", 82 | 8: "sector_8", 83 | 15: "unknown_1", 84 | 31: "unknown_2", 85 | 23: "unknown_3", 86 | 47: "unknown_4", 87 | 55: "unknown_5", 88 | 254: "last_sector", 89 | 255: "no_sector" 90 | }; 91 | 92 | module.exports = OralBToothbrushDevice; 93 | -------------------------------------------------------------------------------- /lib/devices/PollingDevice.js: -------------------------------------------------------------------------------- 1 | const Device = require("./Device"); 2 | 3 | 4 | class PollingDevice extends Device { 5 | /** 6 | * 7 | * @param {object} options 8 | * @param {number} options.pollingInterval 9 | * @param {boolean} [options.pollOnStartup] 10 | */ 11 | constructor(options) { 12 | super(options); 13 | 14 | this.pollingInterval = options.pollingInterval; 15 | this.pollOnStartup = options.pollOnStartup === true; 16 | 17 | this.pollTimeout = null; 18 | this.destroyed = false; 19 | } 20 | 21 | destroy(callback) { 22 | this.destroyed = true; 23 | clearTimeout(this.pollTimeout); 24 | 25 | callback(); 26 | } 27 | 28 | queuePolling() { 29 | let interval = this.pollingInterval; 30 | 31 | clearTimeout(this.pollTimeout); 32 | 33 | if (this.pollTimeout === null && this.pollOnStartup === true) { 34 | interval = 1000; 35 | } 36 | 37 | this.pollTimeout = setTimeout(() => { 38 | if (this.destroyed === false) { 39 | this.poll(); 40 | } 41 | }, interval); 42 | } 43 | 44 | poll() { 45 | //This will be overwritten by devices 46 | this.queuePolling(); 47 | } 48 | } 49 | 50 | module.exports = PollingDevice; 51 | -------------------------------------------------------------------------------- /lib/devices/RoomPresenceBeaconDevice.js: -------------------------------------------------------------------------------- 1 | const Device = require("./Device"); 2 | 3 | class RoomPresenceBeaconDevice extends Device { 4 | /** 5 | * 6 | * @param {object} options 7 | * @param {string} options.friendlyName 8 | * @param {string} options.mac 9 | * @param {string} options.bus 10 | * @param {string} options.room 11 | */ 12 | constructor(options) { 13 | super(options); 14 | 15 | this.room = options.room; 16 | } 17 | 18 | handleAdvertisingForDevice(props) { 19 | super.handleAdvertisingForDevice(props); 20 | super.handleAdvertisingForDevice(props); 21 | 22 | if (props.RSSI) { 23 | //TODO: for some reason, I'm seeing TxPower values of "10", which doesn't really make sense 24 | const distance = RoomPresenceBeaconDevice.CALCULATE_DISTANCE(props.RSSI, props.TxPower); 25 | 26 | this.mqttClient.publish("room_presence/" + this.room, JSON.stringify({ 27 | id: this.id, 28 | name: this.friendlyName, 29 | rssi: props.RSSI, 30 | uuid: this.id, 31 | distance: distance 32 | }), {}, err => { 33 | if (err) { 34 | console.error(err); 35 | } 36 | }); 37 | } 38 | } 39 | } 40 | 41 | //Taken from https://github.com/mKeRix/room-assistant 42 | RoomPresenceBeaconDevice.CALCULATE_DISTANCE = function calculateDistance(rssi, txPower) { 43 | txPower = txPower !== undefined ? txPower: -59; 44 | if (rssi === 0) { 45 | return -1.0; 46 | } 47 | 48 | const ratio = rssi * 1.0 / txPower; 49 | if (ratio < 1.0) { 50 | return Math.pow(ratio, 10); 51 | } else { 52 | return (0.89976) * Math.pow(ratio, 7.7095) + 0.111; 53 | } 54 | }; 55 | 56 | module.exports = RoomPresenceBeaconDevice; 57 | -------------------------------------------------------------------------------- /lib/devices/index.js: -------------------------------------------------------------------------------- 1 | const device_by_type = {}; 2 | const Devices = { 3 | BatteryPoweredDevice: require("./BatteryPoweredDevice"), 4 | EqivaThermostatDevice: require("./EqivaThermostat/EqivaThermostatDevice"), 5 | GlanceClockDevice: require("./GlanceClock/GlanceClockDevice"), 6 | MiBodyScaleDevice: require("./BodyScale/MiBodyScaleDevice"), 7 | MiFloraDevice: require("./MiFloraDevice"), 8 | MiKettleDevice: require("./MiKettle/MiKettleDevice"), 9 | MiLYWSD03MMCDevice: require("./MiLYWSD03MMCDevice"), 10 | OralBToothbrushDevice: require("./OralBToothbrushDevice"), 11 | RoomPresenceBeaconDevice: require("./RoomPresenceBeaconDevice") 12 | }; 13 | 14 | Object.keys(Devices).forEach(key => { 15 | device_by_type[key] = Devices[key]; 16 | }); 17 | 18 | Devices.DEVICE_BY_TYPE = device_by_type; 19 | Devices.Device = require("./Device"); 20 | 21 | module.exports = Devices; 22 | -------------------------------------------------------------------------------- /lib/services/CurrentTimeService.js: -------------------------------------------------------------------------------- 1 | const Service = require("./Service"); 2 | 3 | 4 | class CurrentTimeService extends Service { 5 | /** 6 | * 7 | * @param {object} options 8 | * @param options.bus 9 | * @param {string} options.hciDevice 10 | * @param {string} [options.serviceName] 11 | */ 12 | constructor(options) { 13 | super(options); 14 | 15 | this.serviceName = options.serviceName || "de.hypfer.cybele"; 16 | this.serviceNameInDBusNotation = "/" + this.serviceName.split(".").join("/"); 17 | 18 | this.blueZservice = this.bus.getService("org.bluez"); 19 | this.pathRoot = "/org/bluez/" + this.hciDevice; 20 | 21 | this.bus.exportInterface( 22 | { 23 | ReadValue: function (options) { 24 | const output = Buffer.alloc(10); 25 | const now = new Date(); 26 | 27 | output.writeInt16LE(now.getFullYear()); 28 | output.writeInt8(now.getMonth() + 1, 2); 29 | output.writeInt8(now.getDate(), 3); 30 | output.writeInt8(now.getHours(), 4); 31 | output.writeInt8(now.getMinutes(), 5); 32 | output.writeInt8(now.getSeconds(), 6); 33 | output.writeInt8(now.getDay(), 7); 34 | output.writeInt8(Math.floor(now.getMilliseconds() / 256), 8); 35 | 36 | if (Array.isArray(options) && Array.isArray(options[0]) && options[0][0] === "device" && Array.isArray(options[0][1])) { 37 | console.info("Current Time Service request from " + options[0][1][1] + ". Response: " + output.toString("hex")); 38 | } 39 | 40 | return output; 41 | }, 42 | Service: this.serviceNameInDBusNotation, 43 | UUID: "00002A2B-0000-1000-8000-00805f9b34fb", 44 | Flags: ["read"] 45 | }, 46 | this.serviceNameInDBusNotation + "/CURRENTTIME", 47 | { 48 | name: "org.bluez.GattCharacteristic1", 49 | methods: { 50 | ReadValue: ["", "ay", [], ["arry{byte}"]], 51 | }, 52 | properties: { 53 | Service: "o", 54 | UUID: "s", 55 | Flags: "as" 56 | }, 57 | signals: {} 58 | } 59 | ); 60 | 61 | this.bus.exportInterface( 62 | { 63 | Primary: true, 64 | UUID: "00001805-0000-1000-8000-00805f9b34fb" 65 | }, 66 | this.serviceNameInDBusNotation, 67 | { 68 | name: "org.bluez.GattService1", 69 | methods: {}, 70 | properties: { 71 | Primary: "b", 72 | UUID: "s" 73 | }, 74 | signals: {} 75 | } 76 | ); 77 | 78 | this.bus.exportInterface( 79 | { 80 | GetManagedObjects: () => { 81 | return [ //This is a dict 82 | [this.serviceNameInDBusNotation, [["org.bluez.GattService1", [["UUID", ["s", "00001805-0000-1000-8000-00805f9b34fb"]], ["Primary", ["b", true]]]]]], 83 | [ 84 | this.serviceNameInDBusNotation + "/CURRENTTIME", 85 | [ 86 | [ 87 | "org.bluez.GattCharacteristic1", 88 | [ 89 | ["UUID", ["s", "00002A2B-0000-1000-8000-00805f9b34fb"]], 90 | ["Service", ["o", this.serviceNameInDBusNotation]], 91 | ["Flags", ["as", ["read"]]] 92 | ] 93 | ] 94 | ] 95 | ] 96 | ]; 97 | } 98 | }, 99 | this.serviceNameInDBusNotation, 100 | { 101 | name: "org.freedesktop.DBus.ObjectManager", 102 | methods: { 103 | GetManagedObjects: ["", "a{oa{sa{sv}}}", [], ["dict_entry"]] 104 | }, 105 | properties: {}, 106 | signals: {} 107 | } 108 | ); 109 | } 110 | 111 | initialize() { 112 | return new Promise((resolve, reject) => { 113 | this.bus.requestName(this.serviceName, 0x4, (err, retCode) => { 114 | err = Array.isArray(err) ? err.join(".") : err; 115 | 116 | if (!err) { 117 | if (retCode === 1) { 118 | this.blueZservice.getInterface(this.pathRoot, "org.bluez.GattManager1", (err, gattMgrIface) => { 119 | err = Array.isArray(err) ? err.join(".") : err; 120 | if (!err && gattMgrIface) { 121 | this.gattMgrIface = gattMgrIface; 122 | 123 | gattMgrIface.RegisterApplication(this.serviceNameInDBusNotation, [], err => { 124 | err = Array.isArray(err) ? err.join(".") : err; 125 | 126 | if (!err) { 127 | console.info("Successfully registered CurrentTimeService on " + this.hciDevice); 128 | 129 | resolve(); 130 | } else { 131 | reject(err); 132 | } 133 | }); 134 | } else { 135 | reject({ 136 | message: "Failed to fetch org.bluez.GattManager1 for " + this.hciDevice, 137 | error: err 138 | }); 139 | } 140 | }); 141 | } else { 142 | reject(new Error("Failed with returnCode " + retCode)); 143 | } 144 | } else { 145 | reject(err); 146 | } 147 | }); 148 | }); 149 | } 150 | 151 | destroy(callback) { 152 | return new Promise((resolve) => { 153 | this.gattMgrIface.UnregisterApplication(this.serviceNameInDBusNotation, err => { 154 | err = Array.isArray(err) ? err.join(".") : err; 155 | 156 | if (err) { 157 | console.error(err); //TODO: handle error 158 | } 159 | 160 | this.bus.releaseName(this.serviceName, () => { 161 | resolve(); 162 | }); 163 | }); 164 | }); 165 | } 166 | } 167 | 168 | module.exports = CurrentTimeService; 169 | -------------------------------------------------------------------------------- /lib/services/Service.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | class Service { 4 | /** 5 | * @param {object} options 6 | * @param options.bus 7 | * @param {string} options.hciDevice 8 | */ 9 | constructor(options) { 10 | this.bus = options.bus; 11 | this.hciDevice = options.hciDevice; 12 | } 13 | 14 | async initialize() { 15 | // Implement me 16 | } 17 | 18 | async destroy() { 19 | // Implement me 20 | } 21 | } 22 | 23 | module.exports = Service; 24 | -------------------------------------------------------------------------------- /lib/services/index.js: -------------------------------------------------------------------------------- 1 | const service_by_type = {}; 2 | const Services = { 3 | CurrentTimeService: require("./CurrentTimeService") 4 | }; 5 | 6 | Object.keys(Services).forEach(key => { 7 | service_by_type[key] = Services[key]; 8 | }); 9 | 10 | Services.SERVICE_BY_TYPE = service_by_type; 11 | Services.Service = require("./Service"); 12 | 13 | module.exports = Services; 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cybele", 3 | "version": "0.0.1", 4 | "description": "BLE to MQTT Gateway for Smarthome and IoT Devices", 5 | "main": "app.js", 6 | "scripts": { 7 | "start": "node app.js", 8 | "lint": "eslint -c .automated.eslintrc.json .", 9 | "lint_fix": "eslint -c .automated.eslintrc.json . --fix" 10 | }, 11 | "author": "", 12 | "license": "Apache-2.0", 13 | "dependencies": { 14 | "async": "^3.1.0", 15 | "dbus-native": "https://github.com/sidorares/dbus-native/", 16 | "mqtt": "^3.0.0", 17 | "protobufjs": "^6.8.8", 18 | "semaphore": "^1.1.0", 19 | "xml2js": "^0.4.22" 20 | }, 21 | "devDependencies": { 22 | "eslint": "8.16.0", 23 | "eslint-plugin-jsdoc": "39.3.0", 24 | "eslint-plugin-node": "11.1.0", 25 | "eslint-plugin-react": "7.30.0", 26 | "eslint-plugin-react-hooks": "4.5.0", 27 | "eslint-plugin-regexp": "1.7.0", 28 | "eslint-plugin-sort-keys-fix": "1.1.2", 29 | "eslint-plugin-sort-requires": "git+https://npm@github.com/Hypfer/eslint-plugin-sort-requires.git#2.1.1" 30 | } 31 | } 32 | --------------------------------------------------------------------------------