@whois-homebridge/homebridge-aranet4 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +218 -0
- package/config.schema.json +88 -0
- package/dist/aranet4Parser.d.ts +57 -0
- package/dist/aranet4Parser.js +153 -0
- package/dist/aranet4Parser.js.map +1 -0
- package/dist/bleManager.d.ts +56 -0
- package/dist/bleManager.js +366 -0
- package/dist/bleManager.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/platform.d.ts +26 -0
- package/dist/platform.js +306 -0
- package/dist/platform.js.map +1 -0
- package/dist/platformAccessory.d.ts +40 -0
- package/dist/platformAccessory.js +307 -0
- package/dist/platformAccessory.js.map +1 -0
- package/dist/settings.d.ts +70 -0
- package/dist/settings.js +64 -0
- package/dist/settings.js.map +1 -0
- package/dist/supabaseLogger.d.ts +12 -0
- package/dist/supabaseLogger.js +57 -0
- package/dist/supabaseLogger.js.map +1 -0
- package/package.json +69 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (Unreleased)
|
|
4
|
+
|
|
5
|
+
Initial release.
|
|
6
|
+
|
|
7
|
+
- BLE communication with Aranet4 devices via `@homebridge/noble`
|
|
8
|
+
- HomeKit services: CO2, temperature, humidity, air quality (CO2-derived), battery, atmospheric pressure (Eve custom)
|
|
9
|
+
- Local SQLite data persistence with configurable retention
|
|
10
|
+
- Eve app historical graphs via fakegato-history
|
|
11
|
+
- Device onboard history sync (opt-in, requires BLE pairing)
|
|
12
|
+
- Multi-device auto-discovery and manual MAC address configuration
|
|
13
|
+
- Exponential backoff reconnection and robust error handling
|
|
14
|
+
- Cross-platform: macOS (Core Bluetooth) and Linux/Raspberry Pi (BlueZ)
|
package/README.md
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# homebridge-aranet4-advanced
|
|
2
|
+
|
|
3
|
+
Homebridge plugin for the [Aranet4](https://aranet.com/products/aranet4/) CO2 and environment sensor. Exposes CO2, temperature, humidity, atmospheric pressure, air quality, and battery level to Apple HomeKit via passive BLE advertisement scanning.
|
|
4
|
+
|
|
5
|
+
Supports [Eve](https://apps.apple.com/app/eve-for-matter-homekit/id917695792) app history graphs via FakeGato.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Passive BLE scanning** -- reads sensor data from Aranet4 advertisements without connecting, minimizing battery drain and BLE contention
|
|
10
|
+
- **Minimal dependencies** -- passive BLE scanning, no database or heavy native modules required
|
|
11
|
+
- **Multi-device support** -- monitor multiple Aranet4 sensors simultaneously
|
|
12
|
+
- **Auto-discovery** -- finds Aranet4 devices in range automatically (or configure explicit MAC addresses)
|
|
13
|
+
- **Eve history** -- temperature, humidity, and CO2 graphs in the Eve app
|
|
14
|
+
- **HomeKit services**: CO2 Sensor, Temperature, Humidity, Air Quality, Atmospheric Pressure (Eve-compatible), Battery
|
|
15
|
+
|
|
16
|
+
## Prerequisites
|
|
17
|
+
|
|
18
|
+
- Homebridge v1.8+ or v2.0+
|
|
19
|
+
- Node.js 20 or 22
|
|
20
|
+
- A Bluetooth adapter (built-in or USB) supported by your OS
|
|
21
|
+
- Aranet4 with **"Smart Home integrations"** enabled in the Aranet4 Home app
|
|
22
|
+
|
|
23
|
+
### Enabling Smart Home Integrations
|
|
24
|
+
|
|
25
|
+
The Aranet4 only broadcasts sensor data in BLE advertisements when this setting is enabled:
|
|
26
|
+
|
|
27
|
+
1. Open the **Aranet4 Home** app on your phone
|
|
28
|
+
2. Connect to your Aranet4 device
|
|
29
|
+
3. Go to **Settings** > **Smart Home integrations**
|
|
30
|
+
4. Toggle it **on**
|
|
31
|
+
|
|
32
|
+
Without this, the plugin will detect the device by name but won't receive sensor data.
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
Install via the Homebridge UI (search for `homebridge-aranet4-advanced`) or manually:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npm install -g homebridge-aranet4-advanced
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Bluetooth Permissions
|
|
43
|
+
|
|
44
|
+
**macOS** -- Bluetooth works out of the box via Core Bluetooth. If running Homebridge via Terminal, grant Terminal Bluetooth permission in System Settings > Privacy & Security > Bluetooth.
|
|
45
|
+
|
|
46
|
+
**Linux / Raspberry Pi** -- Install BlueZ and grant the Node.js binary BLE capabilities:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
sudo apt-get install bluetooth bluez libbluetooth-dev libudev-dev
|
|
50
|
+
sudo setcap cap_net_raw+eip $(eval readlink -f $(which node))
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Configuration
|
|
54
|
+
|
|
55
|
+
### Minimal (auto-discovery)
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{
|
|
59
|
+
"platforms": [
|
|
60
|
+
{
|
|
61
|
+
"platform": "Aranet4",
|
|
62
|
+
"name": "Aranet4"
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The plugin will discover any Aranet4 in range and create accessories automatically.
|
|
69
|
+
|
|
70
|
+
### Explicit device configuration
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"platforms": [
|
|
75
|
+
{
|
|
76
|
+
"platform": "Aranet4",
|
|
77
|
+
"name": "Aranet4",
|
|
78
|
+
"devices": [
|
|
79
|
+
{
|
|
80
|
+
"name": "Living Room CO2",
|
|
81
|
+
"address": "AA:BB:CC:DD:EE:FF",
|
|
82
|
+
"pollingInterval": 120,
|
|
83
|
+
"co2AlertThreshold": 1000,
|
|
84
|
+
"lowBatteryThreshold": 15,
|
|
85
|
+
"enableHistory": true
|
|
86
|
+
}
|
|
87
|
+
]
|
|
88
|
+
}
|
|
89
|
+
]
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Device options
|
|
94
|
+
|
|
95
|
+
| Option | Type | Default | Description |
|
|
96
|
+
|--------|------|---------|-------------|
|
|
97
|
+
| `name` | string | `"Aranet4"` | Friendly name shown in HomeKit |
|
|
98
|
+
| `address` | string | *(auto)* | Bluetooth MAC address (`AA:BB:CC:DD:EE:FF`). Recommended for multi-device setups. |
|
|
99
|
+
| `pollingInterval` | integer | `60` | Seconds between HomeKit updates (60--3600). The Aranet4 broadcasts every ~1s; this throttles how often the plugin pushes updates. |
|
|
100
|
+
| `co2AlertThreshold` | integer | `1000` | CO2 ppm level that triggers `CarbonDioxideDetected` in HomeKit (400--5000). |
|
|
101
|
+
| `lowBatteryThreshold` | integer | `15` | Battery % below which low-battery alert triggers (5--50). |
|
|
102
|
+
| `enableHistory` | boolean | `true` | Enable Eve app history graphs (via FakeGato). |
|
|
103
|
+
|
|
104
|
+
### Finding your Aranet4 MAC address
|
|
105
|
+
|
|
106
|
+
The plugin logs the MAC address of each discovered device at startup. Check your Homebridge logs for:
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
[Aranet4] Discovered Aranet4: "Aranet4 Home" [aabbccddeeff] via advertisement
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Use that address (formatted as `AA:BB:CC:DD:EE:FF`) in your config.
|
|
113
|
+
|
|
114
|
+
## HomeKit Services
|
|
115
|
+
|
|
116
|
+
Each Aranet4 device exposes these services:
|
|
117
|
+
|
|
118
|
+
| Sensor Data | HomeKit Service | Details |
|
|
119
|
+
|-------------|----------------|---------|
|
|
120
|
+
| CO2 (ppm) | CarbonDioxideSensor | Level + alert at configurable threshold |
|
|
121
|
+
| Temperature (C) | TemperatureSensor | |
|
|
122
|
+
| Humidity (%) | HumiditySensor | |
|
|
123
|
+
| Air Quality | AirQualitySensor | Derived from CO2 level (see below) |
|
|
124
|
+
| Pressure (hPa) | Eve custom characteristic | Visible in the Eve app |
|
|
125
|
+
| Battery (%) | BatteryService | Low-battery alert at configurable threshold |
|
|
126
|
+
|
|
127
|
+
### Air Quality Mapping
|
|
128
|
+
|
|
129
|
+
| CO2 (ppm) | Air Quality |
|
|
130
|
+
|-----------|-------------|
|
|
131
|
+
| 0--600 | Excellent |
|
|
132
|
+
| 601--800 | Good |
|
|
133
|
+
| 801--1000 | Fair |
|
|
134
|
+
| 1001--1500 | Inferior |
|
|
135
|
+
| >1500 | Poor |
|
|
136
|
+
|
|
137
|
+
## Troubleshooting
|
|
138
|
+
|
|
139
|
+
### "No sensor data in advertisement"
|
|
140
|
+
|
|
141
|
+
The Aranet4 was detected by name but isn't broadcasting sensor data. Enable **Smart Home integrations** in the Aranet4 Home app (see Prerequisites above).
|
|
142
|
+
|
|
143
|
+
### Plugin starts but no devices found
|
|
144
|
+
|
|
145
|
+
- Verify your Bluetooth adapter is working: `hciconfig` (Linux) or check System Settings (macOS)
|
|
146
|
+
- Check Homebridge logs for `Bluetooth adapter state: poweredOn`
|
|
147
|
+
- Ensure no other process is monopolizing the BLE adapter
|
|
148
|
+
- On Linux, verify BLE capabilities are set (see Bluetooth Permissions above)
|
|
149
|
+
|
|
150
|
+
### Readings appear stale or intermittent
|
|
151
|
+
|
|
152
|
+
- The Aranet4 measures every 1--10 minutes (configurable on device). Between measurements, it broadcasts the last known reading.
|
|
153
|
+
- BLE range is typically 10--20m. Move the Homebridge host closer to the sensor.
|
|
154
|
+
- Other BLE devices can cause interference. A dedicated USB Bluetooth adapter may help.
|
|
155
|
+
|
|
156
|
+
### "Bluetooth access denied" (macOS)
|
|
157
|
+
|
|
158
|
+
On macOS, Bluetooth requires explicit permission per executable. The plugin logs a clear error when this happens. This affects both the main bridge and child bridge setups.
|
|
159
|
+
|
|
160
|
+
**Fix — grant Bluetooth permission to the Node.js binary:**
|
|
161
|
+
|
|
162
|
+
1. Open **System Settings → Privacy & Security → Bluetooth**
|
|
163
|
+
2. Click the **+** button to add an application
|
|
164
|
+
3. Press **Cmd+Shift+G** to open the path input dialog
|
|
165
|
+
4. Type the path to your Node.js binary and press Enter:
|
|
166
|
+
- Homebrew: `/opt/homebrew/bin/node` (Apple Silicon) or `/usr/local/bin/node` (Intel)
|
|
167
|
+
- hb-service default: check the path shown in `sudo hb-service status` or in your Homebridge startup logs (look for the `Node.js` line)
|
|
168
|
+
5. Select the binary and click **Open**
|
|
169
|
+
6. Make sure the toggle next to `node` is **ON**
|
|
170
|
+
7. Restart Homebridge
|
|
171
|
+
|
|
172
|
+
This works for both the main bridge and child bridges because Homebridge child bridges use the same `node` binary (via `child_process.fork()`), and macOS tracks Bluetooth permission by executable path.
|
|
173
|
+
|
|
174
|
+
**Caveats:**
|
|
175
|
+
- If you update Node.js (e.g., via Homebrew), the binary path may change and you'll need to re-add it
|
|
176
|
+
- If `node` is a symlink, you may need to add the resolved path instead (e.g., `/opt/homebrew/Cellar/node/24.14.1/bin/node`)
|
|
177
|
+
|
|
178
|
+
### "BLE scan start failed"
|
|
179
|
+
|
|
180
|
+
Make sure Bluetooth is enabled and the Node.js process has the necessary permissions (see Bluetooth Permissions above).
|
|
181
|
+
|
|
182
|
+
### Sensor shows as inactive / "No data received"
|
|
183
|
+
|
|
184
|
+
The plugin automatically marks sensors as inactive when no BLE advertisement is received for several minutes. This typically means:
|
|
185
|
+
|
|
186
|
+
- The Aranet4 is out of BLE range (move it closer, typically within 10m)
|
|
187
|
+
- The device is powered off or battery is dead
|
|
188
|
+
- BLE interference from other devices
|
|
189
|
+
|
|
190
|
+
The sensor will automatically recover when advertisements resume -- no restart required.
|
|
191
|
+
|
|
192
|
+
## Development
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
git clone https://github.com/RobSim/homebridge-aranet4-advanced.git
|
|
196
|
+
cd homebridge-aranet4-advanced
|
|
197
|
+
npm install
|
|
198
|
+
npm run build
|
|
199
|
+
npm test
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### Project structure
|
|
203
|
+
|
|
204
|
+
```
|
|
205
|
+
src/
|
|
206
|
+
index.ts -- Plugin registration
|
|
207
|
+
platform.ts -- Dynamic platform plugin (lifecycle, accessory management)
|
|
208
|
+
platformAccessory.ts -- HomeKit service/characteristic setup per device
|
|
209
|
+
bleManager.ts -- BLE scanning and advertisement parsing
|
|
210
|
+
aranet4Parser.ts -- Binary protocol parser for Aranet4 data
|
|
211
|
+
settings.ts -- Constants, types, UUIDs
|
|
212
|
+
test/
|
|
213
|
+
*.test.ts -- Jest test suites
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## License
|
|
217
|
+
|
|
218
|
+
MIT
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
{
|
|
2
|
+
"pluginAlias": "Aranet4",
|
|
3
|
+
"pluginType": "platform",
|
|
4
|
+
"singular": true,
|
|
5
|
+
"headerDisplay": "Connect your Aranet4 CO2 sensor to HomeKit with full history support.",
|
|
6
|
+
"footerDisplay": "For help, see the [README](https://github.com/RobSim/homebridge-aranet4-advanced#readme).",
|
|
7
|
+
"schema": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"properties": {
|
|
10
|
+
"name": {
|
|
11
|
+
"title": "Platform Name",
|
|
12
|
+
"type": "string",
|
|
13
|
+
"default": "Aranet4",
|
|
14
|
+
"required": true,
|
|
15
|
+
"description": "Display name for this platform instance."
|
|
16
|
+
},
|
|
17
|
+
"devices": {
|
|
18
|
+
"title": "Devices",
|
|
19
|
+
"type": "array",
|
|
20
|
+
"items": {
|
|
21
|
+
"type": "object",
|
|
22
|
+
"properties": {
|
|
23
|
+
"name": {
|
|
24
|
+
"title": "Device Name",
|
|
25
|
+
"type": "string",
|
|
26
|
+
"default": "Aranet4",
|
|
27
|
+
"description": "A friendly name for this sensor."
|
|
28
|
+
},
|
|
29
|
+
"address": {
|
|
30
|
+
"title": "MAC Address",
|
|
31
|
+
"type": "string",
|
|
32
|
+
"description": "Bluetooth MAC address of the Aranet4 device (e.g., AA:BB:CC:DD:EE:FF). Leave blank for auto-discovery.",
|
|
33
|
+
"pattern": "^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$"
|
|
34
|
+
},
|
|
35
|
+
"pollingInterval": {
|
|
36
|
+
"title": "Polling Interval (seconds)",
|
|
37
|
+
"type": "integer",
|
|
38
|
+
"default": 60,
|
|
39
|
+
"minimum": 60,
|
|
40
|
+
"maximum": 3600,
|
|
41
|
+
"description": "How often to read sensor data, in seconds. Minimum 60s."
|
|
42
|
+
},
|
|
43
|
+
"co2AlertThreshold": {
|
|
44
|
+
"title": "CO2 Alert Threshold (ppm)",
|
|
45
|
+
"type": "integer",
|
|
46
|
+
"default": 1000,
|
|
47
|
+
"minimum": 400,
|
|
48
|
+
"maximum": 5000,
|
|
49
|
+
"description": "CO2 level (ppm) at which CarbonDioxideDetected triggers."
|
|
50
|
+
},
|
|
51
|
+
"lowBatteryThreshold": {
|
|
52
|
+
"title": "Low Battery Threshold (%)",
|
|
53
|
+
"type": "integer",
|
|
54
|
+
"default": 15,
|
|
55
|
+
"minimum": 5,
|
|
56
|
+
"maximum": 50,
|
|
57
|
+
"description": "Battery percentage below which low-battery alert triggers."
|
|
58
|
+
},
|
|
59
|
+
"enableHistory": {
|
|
60
|
+
"title": "Enable Eve History",
|
|
61
|
+
"type": "boolean",
|
|
62
|
+
"default": true,
|
|
63
|
+
"description": "Enable historical graphs in the Eve app."
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
"required": ["name"]
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
"supabase": {
|
|
70
|
+
"title": "Supabase",
|
|
71
|
+
"type": "object",
|
|
72
|
+
"properties": {
|
|
73
|
+
"url": {
|
|
74
|
+
"title": "Project URL",
|
|
75
|
+
"type": "string",
|
|
76
|
+
"placeholder": "https://yourproject.supabase.co"
|
|
77
|
+
},
|
|
78
|
+
"key": {
|
|
79
|
+
"title": "Secret API Key",
|
|
80
|
+
"type": "string",
|
|
81
|
+
"placeholder": "your-secret-api-key",
|
|
82
|
+
"secret": true
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Aranet4Reading } from './settings';
|
|
2
|
+
/**
|
|
3
|
+
* Validate parsed sensor values. Returns a human-readable reason string
|
|
4
|
+
* if the reading should be rejected, or null if it looks valid.
|
|
5
|
+
*/
|
|
6
|
+
export declare function validateReading(reading: {
|
|
7
|
+
co2: number;
|
|
8
|
+
temperature: number;
|
|
9
|
+
pressure: number;
|
|
10
|
+
humidity: number;
|
|
11
|
+
battery: number;
|
|
12
|
+
}): string | null;
|
|
13
|
+
/**
|
|
14
|
+
* Parse a 13-byte extended readings buffer from the Aranet4 BLE characteristic
|
|
15
|
+
* (UUID suffix 3001) into a typed Aranet4Reading.
|
|
16
|
+
*
|
|
17
|
+
* Packet layout (little-endian):
|
|
18
|
+
* Offset 0–1 : uint16 CO2 (ppm, raw)
|
|
19
|
+
* Offset 2–3 : uint16 Temperature (raw ÷ 20 = °C)
|
|
20
|
+
* Offset 4–5 : uint16 Pressure (raw ÷ 10 = hPa)
|
|
21
|
+
* Offset 6 : uint8 Humidity (%, raw)
|
|
22
|
+
* Offset 7 : uint8 Battery (%, raw)
|
|
23
|
+
* Offset 8 : uint8 Status byte
|
|
24
|
+
* Offset 9–10 : uint16 Interval (seconds)
|
|
25
|
+
* Offset 11–12: uint16 Age (seconds since last measurement)
|
|
26
|
+
*/
|
|
27
|
+
export declare function parseExtendedReadings(buf: Buffer): Aranet4Reading;
|
|
28
|
+
/**
|
|
29
|
+
* Parse Aranet4 manufacturer-specific advertisement data.
|
|
30
|
+
*
|
|
31
|
+
* When "Smart Home integrations" is enabled in the Aranet4 Home app, the
|
|
32
|
+
* device includes sensor data in its BLE advertisement manufacturer data.
|
|
33
|
+
*
|
|
34
|
+
* The manufacturer data starts with a 2-byte company ID (0x0702 for SAF
|
|
35
|
+
* Tehnika), followed by the payload. Noble includes this company ID prefix
|
|
36
|
+
* in the buffer.
|
|
37
|
+
*
|
|
38
|
+
* Payload layout (little-endian, after 2-byte company ID):
|
|
39
|
+
* Byte 0 : uint8 Flags (bit 5 = integrations enabled)
|
|
40
|
+
* Byte 1–3 : uint8×3 Firmware version (patch, minor, major)
|
|
41
|
+
* Byte 4–5 : uint16 Device type / padding
|
|
42
|
+
* Byte 6–7 : uint16 Additional header
|
|
43
|
+
* Byte 8–9 : uint16 CO2 (ppm)
|
|
44
|
+
* Byte 10–11 : uint16 Temperature (raw ÷ 20 = °C)
|
|
45
|
+
* Byte 12–13 : uint16 Pressure (raw ÷ 10 = hPa)
|
|
46
|
+
* Byte 14 : uint8 Humidity (%)
|
|
47
|
+
* Byte 15 : uint8 Battery (%)
|
|
48
|
+
* Byte 16 : uint8 Status flags
|
|
49
|
+
* Byte 17–18 : uint16 Interval (seconds)
|
|
50
|
+
* Byte 19–20 : uint16 Age (seconds since last measurement)
|
|
51
|
+
*
|
|
52
|
+
* Reference: https://github.com/Anrijs/Aranet4-Python
|
|
53
|
+
* https://github.com/Anrijs/Aranet4-ESP32
|
|
54
|
+
*
|
|
55
|
+
* Returns null if the buffer doesn't contain valid Aranet4 data.
|
|
56
|
+
*/
|
|
57
|
+
export declare function parseAdvertisement(manufacturerData: Buffer): Aranet4Reading | null;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.validateReading = validateReading;
|
|
4
|
+
exports.parseExtendedReadings = parseExtendedReadings;
|
|
5
|
+
exports.parseAdvertisement = parseAdvertisement;
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Plausible sensor value ranges for sanity checking.
|
|
8
|
+
// Values outside these ranges indicate sensor warmup, malfunction, or bad data.
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
/** CO2 0 or 65535 = sensor warmup / invalid. Valid range: 0–10000 ppm. */
|
|
11
|
+
const CO2_MIN = 1;
|
|
12
|
+
const CO2_MAX = 10_000;
|
|
13
|
+
/** Temperature valid range: −40 °C to +60 °C (Aranet4 spec: 0–50 °C). */
|
|
14
|
+
const TEMP_MIN = -40;
|
|
15
|
+
const TEMP_MAX = 60;
|
|
16
|
+
/** Humidity valid range: 0–100 %. */
|
|
17
|
+
const HUMIDITY_MAX = 100;
|
|
18
|
+
/** Pressure valid range: 300–1200 hPa (covers Dead Sea to Everest). */
|
|
19
|
+
const PRESSURE_MIN = 300;
|
|
20
|
+
const PRESSURE_MAX = 1200;
|
|
21
|
+
/**
|
|
22
|
+
* Validate parsed sensor values. Returns a human-readable reason string
|
|
23
|
+
* if the reading should be rejected, or null if it looks valid.
|
|
24
|
+
*/
|
|
25
|
+
function validateReading(reading) {
|
|
26
|
+
if (reading.co2 < CO2_MIN || reading.co2 > CO2_MAX) {
|
|
27
|
+
return `CO2 out of range: ${reading.co2} ppm`;
|
|
28
|
+
}
|
|
29
|
+
if (reading.temperature < TEMP_MIN || reading.temperature > TEMP_MAX) {
|
|
30
|
+
return `Temperature out of range: ${reading.temperature} °C`;
|
|
31
|
+
}
|
|
32
|
+
if (reading.humidity > HUMIDITY_MAX) {
|
|
33
|
+
return `Humidity out of range: ${reading.humidity}%`;
|
|
34
|
+
}
|
|
35
|
+
if (reading.pressure !== 0 && (reading.pressure < PRESSURE_MIN || reading.pressure > PRESSURE_MAX)) {
|
|
36
|
+
return `Pressure out of range: ${reading.pressure} hPa`;
|
|
37
|
+
}
|
|
38
|
+
if (reading.battery > 100) {
|
|
39
|
+
return `Battery out of range: ${reading.battery}%`;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Parse a 13-byte extended readings buffer from the Aranet4 BLE characteristic
|
|
45
|
+
* (UUID suffix 3001) into a typed Aranet4Reading.
|
|
46
|
+
*
|
|
47
|
+
* Packet layout (little-endian):
|
|
48
|
+
* Offset 0–1 : uint16 CO2 (ppm, raw)
|
|
49
|
+
* Offset 2–3 : uint16 Temperature (raw ÷ 20 = °C)
|
|
50
|
+
* Offset 4–5 : uint16 Pressure (raw ÷ 10 = hPa)
|
|
51
|
+
* Offset 6 : uint8 Humidity (%, raw)
|
|
52
|
+
* Offset 7 : uint8 Battery (%, raw)
|
|
53
|
+
* Offset 8 : uint8 Status byte
|
|
54
|
+
* Offset 9–10 : uint16 Interval (seconds)
|
|
55
|
+
* Offset 11–12: uint16 Age (seconds since last measurement)
|
|
56
|
+
*/
|
|
57
|
+
function parseExtendedReadings(buf) {
|
|
58
|
+
if (buf.length < 13) {
|
|
59
|
+
throw new Error(`Expected at least 13 bytes for extended readings, got ${buf.length}`);
|
|
60
|
+
}
|
|
61
|
+
const co2 = buf.readUInt16LE(0);
|
|
62
|
+
const temperatureRaw = buf.readUInt16LE(2);
|
|
63
|
+
const pressureRaw = buf.readUInt16LE(4);
|
|
64
|
+
const humidity = buf.readUInt8(6);
|
|
65
|
+
const battery = buf.readUInt8(7);
|
|
66
|
+
const status = buf.readUInt8(8);
|
|
67
|
+
const interval = buf.readUInt16LE(9);
|
|
68
|
+
const age = buf.readUInt16LE(11);
|
|
69
|
+
const reading = {
|
|
70
|
+
co2,
|
|
71
|
+
temperature: temperatureRaw / 20,
|
|
72
|
+
pressure: pressureRaw / 10,
|
|
73
|
+
humidity,
|
|
74
|
+
battery,
|
|
75
|
+
status,
|
|
76
|
+
interval,
|
|
77
|
+
age,
|
|
78
|
+
timestamp: Date.now(),
|
|
79
|
+
};
|
|
80
|
+
const invalid = validateReading(reading);
|
|
81
|
+
if (invalid) {
|
|
82
|
+
throw new Error(`Invalid sensor data: ${invalid}`);
|
|
83
|
+
}
|
|
84
|
+
return reading;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Parse Aranet4 manufacturer-specific advertisement data.
|
|
88
|
+
*
|
|
89
|
+
* When "Smart Home integrations" is enabled in the Aranet4 Home app, the
|
|
90
|
+
* device includes sensor data in its BLE advertisement manufacturer data.
|
|
91
|
+
*
|
|
92
|
+
* The manufacturer data starts with a 2-byte company ID (0x0702 for SAF
|
|
93
|
+
* Tehnika), followed by the payload. Noble includes this company ID prefix
|
|
94
|
+
* in the buffer.
|
|
95
|
+
*
|
|
96
|
+
* Payload layout (little-endian, after 2-byte company ID):
|
|
97
|
+
* Byte 0 : uint8 Flags (bit 5 = integrations enabled)
|
|
98
|
+
* Byte 1–3 : uint8×3 Firmware version (patch, minor, major)
|
|
99
|
+
* Byte 4–5 : uint16 Device type / padding
|
|
100
|
+
* Byte 6–7 : uint16 Additional header
|
|
101
|
+
* Byte 8–9 : uint16 CO2 (ppm)
|
|
102
|
+
* Byte 10–11 : uint16 Temperature (raw ÷ 20 = °C)
|
|
103
|
+
* Byte 12–13 : uint16 Pressure (raw ÷ 10 = hPa)
|
|
104
|
+
* Byte 14 : uint8 Humidity (%)
|
|
105
|
+
* Byte 15 : uint8 Battery (%)
|
|
106
|
+
* Byte 16 : uint8 Status flags
|
|
107
|
+
* Byte 17–18 : uint16 Interval (seconds)
|
|
108
|
+
* Byte 19–20 : uint16 Age (seconds since last measurement)
|
|
109
|
+
*
|
|
110
|
+
* Reference: https://github.com/Anrijs/Aranet4-Python
|
|
111
|
+
* https://github.com/Anrijs/Aranet4-ESP32
|
|
112
|
+
*
|
|
113
|
+
* Returns null if the buffer doesn't contain valid Aranet4 data.
|
|
114
|
+
*/
|
|
115
|
+
function parseAdvertisement(manufacturerData) {
|
|
116
|
+
// Company ID (2) + header (8) + CO2 (2) + temperature (2) = 14 bytes minimum
|
|
117
|
+
if (manufacturerData.length < 14) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
// Skip 2-byte company ID — remaining bytes are the payload
|
|
121
|
+
const d = manufacturerData.subarray(2);
|
|
122
|
+
// Need at least 12 bytes: 8-byte header + CO2 (2) + temperature (2)
|
|
123
|
+
if (d.length < 12) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
// Sensor data starts at byte 8, after the 8-byte header
|
|
127
|
+
const co2 = d.readUInt16LE(8);
|
|
128
|
+
const temperatureRaw = d.readUInt16LE(10);
|
|
129
|
+
// Remaining fields may not be present in shorter advertisements
|
|
130
|
+
const pressure = d.length >= 14 ? d.readUInt16LE(12) / 10 : 0;
|
|
131
|
+
const humidity = d.length >= 15 ? d.readUInt8(14) : 0;
|
|
132
|
+
const battery = d.length >= 16 ? d.readUInt8(15) : 0;
|
|
133
|
+
const status = d.length >= 17 ? d.readUInt8(16) : 0;
|
|
134
|
+
const interval = d.length >= 19 ? d.readUInt16LE(17) : 0;
|
|
135
|
+
const age = d.length >= 21 ? d.readUInt16LE(19) : 0;
|
|
136
|
+
const reading = {
|
|
137
|
+
co2,
|
|
138
|
+
temperature: temperatureRaw / 20,
|
|
139
|
+
pressure,
|
|
140
|
+
humidity,
|
|
141
|
+
battery,
|
|
142
|
+
status,
|
|
143
|
+
interval,
|
|
144
|
+
age,
|
|
145
|
+
timestamp: Date.now(),
|
|
146
|
+
};
|
|
147
|
+
const invalid = validateReading(reading);
|
|
148
|
+
if (invalid) {
|
|
149
|
+
return null; // Silently skip — advertisements can contain stale/warmup data
|
|
150
|
+
}
|
|
151
|
+
return reading;
|
|
152
|
+
}
|
|
153
|
+
//# sourceMappingURL=aranet4Parser.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"aranet4Parser.js","sourceRoot":"","sources":["../src/aranet4Parser.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Logger } from 'homebridge';
|
|
2
|
+
import { Aranet4Reading, Aranet4DeviceConfig } from './settings';
|
|
3
|
+
/** Callback invoked each time fresh sensor data arrives. */
|
|
4
|
+
export type ReadingCallback = (deviceId: string, reading: Aranet4Reading) => void;
|
|
5
|
+
/** Callback invoked when a device hasn't been heard from in too long. */
|
|
6
|
+
export type StaleCallback = (deviceId: string) => void;
|
|
7
|
+
export declare class BleManager {
|
|
8
|
+
private readonly log;
|
|
9
|
+
private readonly deviceConfigs;
|
|
10
|
+
private readonly devices;
|
|
11
|
+
private scanning;
|
|
12
|
+
private readingCallback;
|
|
13
|
+
private staleCallback;
|
|
14
|
+
private poweredOn;
|
|
15
|
+
private shuttingDown;
|
|
16
|
+
private scanRestartTimer;
|
|
17
|
+
private scanRetryTimer;
|
|
18
|
+
private staleCheckTimer;
|
|
19
|
+
private scanRetryDelay;
|
|
20
|
+
private loggedUnauthorized;
|
|
21
|
+
private readonly onStateChange;
|
|
22
|
+
private readonly onDiscover;
|
|
23
|
+
private readonly onScanStop;
|
|
24
|
+
private readonly onWarning;
|
|
25
|
+
constructor(log: Logger, deviceConfigs: Aranet4DeviceConfig[]);
|
|
26
|
+
/** Register a callback that will be invoked on every new reading. */
|
|
27
|
+
onReading(cb: ReadingCallback): void;
|
|
28
|
+
/** Register a callback for when a device goes stale (no data for too long). */
|
|
29
|
+
onStale(cb: StaleCallback): void;
|
|
30
|
+
/** Begin scanning. Call once after Homebridge finishes launching. */
|
|
31
|
+
start(): void;
|
|
32
|
+
/** Gracefully shut down — stop scanning, clear all timers. */
|
|
33
|
+
shutdown(): void;
|
|
34
|
+
private handleStateChange;
|
|
35
|
+
private startScan;
|
|
36
|
+
private stopScan;
|
|
37
|
+
/**
|
|
38
|
+
* Handle noble's scanStop event — scanning was interrupted externally
|
|
39
|
+
* (another app took BLE, adapter reset, etc.).
|
|
40
|
+
*
|
|
41
|
+
* Debounced: noble issue #569 documents scanStop firing rapidly after
|
|
42
|
+
* reboots. We only schedule one restart attempt at a time.
|
|
43
|
+
*/
|
|
44
|
+
private handleScanStop;
|
|
45
|
+
/**
|
|
46
|
+
* Schedule a scan retry with exponential backoff after a startScanning failure.
|
|
47
|
+
*/
|
|
48
|
+
private scheduleScanRetry;
|
|
49
|
+
private checkForStaleDevices;
|
|
50
|
+
private clearTimers;
|
|
51
|
+
private handleDiscovery;
|
|
52
|
+
private processAdvertisement;
|
|
53
|
+
private handleAranet4Advertisement;
|
|
54
|
+
private findConfigForPeripheral;
|
|
55
|
+
private buildDefaultConfig;
|
|
56
|
+
}
|