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