homebridge-luftdaten 1.0.0
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/INSTALL.md +210 -0
- package/LICENSE +21 -0
- package/README.md +141 -0
- package/config.sample.json +13 -0
- package/config.schema.json +64 -0
- package/docs/home-app-preview.svg +86 -0
- package/package.json +45 -0
- package/src/index.js +358 -0
package/INSTALL.md
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# Installation guide — homebridge-luftdaten
|
|
2
|
+
|
|
3
|
+
A step-by-step walkthrough, from nothing to a working air-quality sensor in the
|
|
4
|
+
Apple **Home** app. Assumes you already have a running Homebridge instance.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## 1. Collect what you need
|
|
9
|
+
|
|
10
|
+
Before you start, prepare three things:
|
|
11
|
+
|
|
12
|
+
| Data | What it is | How to get it |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| **name** | the name shown in HomeKit | anything, e.g. `Living Room Air` |
|
|
15
|
+
| **localUrl / IP** | the sensor's local address with the `/data.json` path | see below |
|
|
16
|
+
| **sensorId** | the sensor's ID on Sensor.Community (for the cloud fallback) | see below |
|
|
17
|
+
|
|
18
|
+
### Finding the local IP address (localUrl)
|
|
19
|
+
|
|
20
|
+
1. The airrohr device, once on your Wi-Fi, is reachable on your LAN. Find its
|
|
21
|
+
address via:
|
|
22
|
+
- your router's DHCP client list (the hostname starts with `airrohr-…` /
|
|
23
|
+
`feinstaubsensor-…`), or
|
|
24
|
+
- a network-scanning app (e.g. *Fing*).
|
|
25
|
+
2. Open `http://<SENSOR_IP>/` in a browser — you'll see the airrohr panel.
|
|
26
|
+
3. The raw data lives at **`http://<SENSOR_IP>/data.json`**. Open it and confirm
|
|
27
|
+
you see `sensordatavalues` with `SDS_P1`, `SDS_P2`, etc.
|
|
28
|
+
That is your **localUrl**, e.g. `http://192.168.1.50/data.json`.
|
|
29
|
+
|
|
30
|
+
> Tip: give the sensor a **DHCP reservation** (static IP) in your router so the
|
|
31
|
+
> `localUrl` doesn't change after a reboot.
|
|
32
|
+
|
|
33
|
+
### Finding the sensorId (cloud)
|
|
34
|
+
|
|
35
|
+
1. Go to <https://devices.sensor.community/> and sign in to the account the
|
|
36
|
+
sensor is registered to (or find it on the map at
|
|
37
|
+
<https://maps.sensor.community/>).
|
|
38
|
+
2. The **sensorId** is the numeric ID of the **SDS011** (particulate) sensor.
|
|
39
|
+
Verify it by opening:
|
|
40
|
+
`https://data.sensor.community/airrohr/v1/sensor/<sensorId>/`
|
|
41
|
+
It should return a JSON array of recent measurements.
|
|
42
|
+
|
|
43
|
+
> `sensorId` is optional — it's only a backup for when the sensor is temporarily
|
|
44
|
+
> unreachable on the local network. If you omit it, the plugin uses `localUrl`
|
|
45
|
+
> only.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## 2. Install the plugin
|
|
50
|
+
|
|
51
|
+
### Option A — Homebridge UI (recommended)
|
|
52
|
+
|
|
53
|
+
1. Open **Homebridge Config UI X** in your browser.
|
|
54
|
+
2. **Plugins** tab → search for `homebridge-luftdaten`.
|
|
55
|
+
3. Click **Install** and wait for it to finish.
|
|
56
|
+
|
|
57
|
+
### Option B — manually via npm
|
|
58
|
+
|
|
59
|
+
On the Homebridge host:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
sudo npm install -g homebridge-luftdaten
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
(on a containerised / `hb-service` install, drop `sudo` as appropriate for your
|
|
66
|
+
setup).
|
|
67
|
+
|
|
68
|
+
### Option C — from a local file (e.g. before publishing to npm)
|
|
69
|
+
|
|
70
|
+
Copy the packed tarball to the Homebridge host and install it. Using
|
|
71
|
+
`hb-service` (the standard Homebridge install on Debian / Proxmox / Raspberry Pi)
|
|
72
|
+
puts it in the correct plugin directory:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
# 1) On your machine: build the tarball
|
|
76
|
+
npm pack # produces homebridge-luftdaten-<version>.tgz
|
|
77
|
+
|
|
78
|
+
# 2) Copy it to the Homebridge host
|
|
79
|
+
scp homebridge-luftdaten-*.tgz <USER>@<HOMEBRIDGE_IP>:/tmp/
|
|
80
|
+
|
|
81
|
+
# 3) On the Homebridge host: install
|
|
82
|
+
sudo hb-service add /tmp/homebridge-luftdaten-*.tgz
|
|
83
|
+
# fallback if your install doesn't use hb-service:
|
|
84
|
+
# sudo npm install -g /tmp/homebridge-luftdaten-*.tgz
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
You can also install straight from GitHub:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
sudo hb-service add https://github.com/<YOUR_LOGIN>/homebridge-luftdaten
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## 3. Configure config.json
|
|
96
|
+
|
|
97
|
+
### Via the UI
|
|
98
|
+
|
|
99
|
+
In the **Plugins** tab click **Settings** next to homebridge-luftdaten and fill
|
|
100
|
+
in the fields, or edit the raw JSON in the **Config** tab.
|
|
101
|
+
|
|
102
|
+
### Manually
|
|
103
|
+
|
|
104
|
+
Add an entry to the `accessories` array in `config.json`:
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
{
|
|
108
|
+
"accessories": [
|
|
109
|
+
{
|
|
110
|
+
"accessory": "Luftdaten",
|
|
111
|
+
"name": "Living Room Air",
|
|
112
|
+
"localUrl": "http://192.168.1.50/data.json",
|
|
113
|
+
"sensorId": "12345",
|
|
114
|
+
"pollInterval": 120,
|
|
115
|
+
"requestTimeout": 10,
|
|
116
|
+
"hasTempSensor": true
|
|
117
|
+
}
|
|
118
|
+
]
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
See [README.md](README.md) for the full option reference. The essentials:
|
|
123
|
+
|
|
124
|
+
- `localUrl` — the priority local source.
|
|
125
|
+
- `sensorId` — the cloud backup (optional).
|
|
126
|
+
- `hasTempSensor` — `true` if you have an SHT3X/BME280; `false` for an SDS011
|
|
127
|
+
alone. (The legacy `hasBME280` flag still works.)
|
|
128
|
+
|
|
129
|
+
Save the config and **restart Homebridge**.
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## 4. Pair the bridge with the Home app (only for a new bridge)
|
|
134
|
+
|
|
135
|
+
> If your Homebridge bridge is already added to HomeKit, skip this — the new
|
|
136
|
+
> accessory appears automatically.
|
|
137
|
+
|
|
138
|
+
1. In the Homebridge UI **Status** screen, find the bridge **QR code** (and the
|
|
139
|
+
PIN, default `031-45-154`).
|
|
140
|
+
2. On your iPhone/iPad open the **Home** app → **+** → **Add Accessory**.
|
|
141
|
+
3. Scan the QR code from the Homebridge screen.
|
|
142
|
+
4. Confirm adding the bridge (if you see "Uncertified Accessory", choose **Add
|
|
143
|
+
Anyway**).
|
|
144
|
+
5. Assign the accessory to a room and name the tiles.
|
|
145
|
+
|
|
146
|
+
After pairing you'll see the air-quality sensor plus (if `hasTempSensor`)
|
|
147
|
+
separate temperature and humidity tiles.
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## 5. Verify in the logs
|
|
152
|
+
|
|
153
|
+
In the Homebridge UI open **Logs**. On startup and on each poll you'll see a
|
|
154
|
+
line like:
|
|
155
|
+
|
|
156
|
+
```
|
|
157
|
+
[Living Room Air] [local] PM2.5=4.63 PM10=6.95 temp=21.4°C hum=45.33% airQuality=1
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
- `[local]` — data read from the sensor on the local network.
|
|
161
|
+
- `[cloud]` — the Sensor.Community fallback kicked in.
|
|
162
|
+
- `airQuality=1..5` — 1 = excellent, 5 = poor.
|
|
163
|
+
|
|
164
|
+
If you only see warnings (`Local read failed`, `No sensor data available`), see
|
|
165
|
+
the section below.
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## 6. Troubleshooting
|
|
170
|
+
|
|
171
|
+
### No data / "Local read failed"
|
|
172
|
+
|
|
173
|
+
- **VLANs / segmented networks**: Homebridge must be able to reach the sensor.
|
|
174
|
+
If the sensor lives on a separate IoT VLAN/network, allow traffic from the
|
|
175
|
+
Homebridge host to the sensor's IP (port 80). Test from the Homebridge host:
|
|
176
|
+
```bash
|
|
177
|
+
curl -m 10 http://192.168.1.50/data.json
|
|
178
|
+
```
|
|
179
|
+
If `curl` doesn't return JSON, the problem is network/firewall, not the plugin.
|
|
180
|
+
- **Firewall**: make sure your firewall (router, host, Docker) isn't blocking
|
|
181
|
+
outbound HTTP to the sensor or HTTPS to `data.sensor.community`.
|
|
182
|
+
- **Wrong address**: confirm `localUrl` ends with `/data.json` and the IP is
|
|
183
|
+
current (see the DHCP reservation tip in step 1).
|
|
184
|
+
- **Timeout**: if the sensor responds slowly, raise `requestTimeout` (e.g. `20`).
|
|
185
|
+
|
|
186
|
+
### Cloud fallback is used instead of local
|
|
187
|
+
|
|
188
|
+
You see `[cloud]` instead of `[local]` — the sensor is unreachable locally.
|
|
189
|
+
Start with the `curl` test above. Remember the cloud updates with a delay and may
|
|
190
|
+
return older readings than a direct local read.
|
|
191
|
+
|
|
192
|
+
### No PM values (PM2.5 / PM10 = n/a)
|
|
193
|
+
|
|
194
|
+
- The **SDS011** has a warm-up phase — wait ~1–2 min after startup.
|
|
195
|
+
- In power-saving (sleep) mode the SDS011 measures in cycles; between cycles
|
|
196
|
+
`data.json` may not contain fresh `SDS_P1` / `SDS_P2`. Align `pollInterval`
|
|
197
|
+
with the sensor's measurement cycle.
|
|
198
|
+
- Check `http://<SENSOR_IP>/data.json` to confirm the `SDS_P1`/`SDS_P2` keys are
|
|
199
|
+
present at all.
|
|
200
|
+
|
|
201
|
+
### SHT3X vs BME280 — temperature/humidity is "n/a"
|
|
202
|
+
|
|
203
|
+
- The plugin recognises both automatically (`SHT3X_*`, `BME280_*`, plus
|
|
204
|
+
`BMP280_*`, `DHT_*` and generic `temperature`/`humidity`).
|
|
205
|
+
- If data is still missing: check which exact `value_type` your firmware reports
|
|
206
|
+
in `data.json`, and that `hasTempSensor` is `true`.
|
|
207
|
+
- **BMP280** measures only temperature and pressure (no humidity) — in that case
|
|
208
|
+
humidity stays empty, which is expected.
|
|
209
|
+
- Pressure is not shown in HomeKit (no such characteristic exists) — you'll only
|
|
210
|
+
see it in the logs.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rafał Rudecki
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# homebridge-luftdaten
|
|
2
|
+
|
|
3
|
+
Bring your [Luftdaten / Sensor.Community](https://sensor.community/) air-quality
|
|
4
|
+
sensor into Apple HomeKit — local-first, with an automatic cloud fallback.
|
|
5
|
+
|
|
6
|
+
[](LICENSE)
|
|
7
|
+

|
|
8
|
+

|
|
9
|
+

|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
A Homebridge **accessory** plugin that exposes a Luftdaten / Sensor.Community
|
|
16
|
+
sensor (airrohr firmware — typically an **SDS011** particulate sensor plus an
|
|
17
|
+
**SHT3X** or **BME280** temperature/humidity sensor) to Apple HomeKit.
|
|
18
|
+
|
|
19
|
+
It reads **locally first** from the sensor's own `data.json` endpoint and
|
|
20
|
+
**falls back to the Sensor.Community cloud** when the local device is
|
|
21
|
+
unreachable. Zero external dependencies — it uses the built-in `fetch` (Node 18+)
|
|
22
|
+
and `AbortController` for timeouts.
|
|
23
|
+
|
|
24
|
+
## Features
|
|
25
|
+
|
|
26
|
+
- 🏠 Local-first reading (`http://<ip>/data.json`), cloud fallback via the
|
|
27
|
+
Sensor.Community API.
|
|
28
|
+
- 🌫️ Air quality (1–5) derived from PM2.5 using WHO/EU thresholds, plus raw
|
|
29
|
+
PM2.5 and PM10 density.
|
|
30
|
+
- 🌡️ Temperature and humidity (optional, on by default).
|
|
31
|
+
- 🔄 Configurable polling interval and request timeout.
|
|
32
|
+
- 📦 No runtime dependencies.
|
|
33
|
+
|
|
34
|
+
## Exposed HomeKit services & characteristics
|
|
35
|
+
|
|
36
|
+
| Service | Characteristic | Source | Notes |
|
|
37
|
+
|---|---|---|---|
|
|
38
|
+
| AirQualitySensor | `AirQuality` (1–5) | derived from PM2.5 | see thresholds below |
|
|
39
|
+
| AirQualitySensor | `PM2_5Density` | `SDS_P2` / `P2` | µg/m³ |
|
|
40
|
+
| AirQualitySensor | `PM10Density` | `SDS_P1` / `P1` | µg/m³ |
|
|
41
|
+
| TemperatureSensor | `CurrentTemperature` | `*_temperature` | name `"<name> Temp"`, range −50…100 °C |
|
|
42
|
+
| HumiditySensor | `CurrentRelativeHumidity` | `*_humidity` | name `"<name> Humidity"` |
|
|
43
|
+
| AccessoryInformation | `Manufacturer` | — | `Sensor.Community` |
|
|
44
|
+
| AccessoryInformation | `Model` | — | `SDS011 + SHT3X/BME280` |
|
|
45
|
+
| AccessoryInformation | `SerialNumber` | — | `sensorId` (or `localUrl`) |
|
|
46
|
+
|
|
47
|
+
Temperature and humidity services are only added when `hasTempSensor` is `true`
|
|
48
|
+
(the default).
|
|
49
|
+
|
|
50
|
+
Atmospheric **pressure** (`BME280_pressure` / `BMP280_pressure`, converted Pa → hPa)
|
|
51
|
+
is parsed and written to the Homebridge log, but **not** exposed to HomeKit —
|
|
52
|
+
there is no native HomeKit characteristic for barometric pressure.
|
|
53
|
+
|
|
54
|
+
### value_type variants understood by the parser
|
|
55
|
+
|
|
56
|
+
- PM: `SDS_P1` → PM10, `SDS_P2` → PM2.5 (local); `P1` → PM10, `P2` → PM2.5 (cloud API)
|
|
57
|
+
- Temperature: `SHT3X_temperature`, `BME280_temperature`, `BMP280_temperature`,
|
|
58
|
+
`DHT_temperature`, generic `temperature`
|
|
59
|
+
- Humidity: `SHT3X_humidity`, `BME280_humidity`, `DHT_humidity`, generic `humidity`
|
|
60
|
+
- Pressure: `BME280_pressure`, `BMP280_pressure` (÷100 → hPa)
|
|
61
|
+
|
|
62
|
+
## PM2.5 → AirQuality mapping (µg/m³)
|
|
63
|
+
|
|
64
|
+
| PM2.5 | AirQuality |
|
|
65
|
+
|---|---|
|
|
66
|
+
| ≤ 10 | 1 — EXCELLENT |
|
|
67
|
+
| ≤ 20 | 2 — GOOD |
|
|
68
|
+
| ≤ 25 | 3 — FAIR |
|
|
69
|
+
| ≤ 50 | 4 — INFERIOR |
|
|
70
|
+
| > 50 | 5 — POOR |
|
|
71
|
+
| missing / NaN | 0 — UNKNOWN |
|
|
72
|
+
|
|
73
|
+
## Installation
|
|
74
|
+
|
|
75
|
+
Via the Homebridge UI: search for **homebridge-luftdaten** on the Plugins tab
|
|
76
|
+
and install it. Or from the command line:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
npm install -g homebridge-luftdaten
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
A full, step-by-step walkthrough is in **[INSTALL.md](INSTALL.md)**.
|
|
83
|
+
|
|
84
|
+
## Configuration
|
|
85
|
+
|
|
86
|
+
Add an entry to the `accessories` array of your Homebridge `config.json`:
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{
|
|
90
|
+
"accessories": [
|
|
91
|
+
{
|
|
92
|
+
"accessory": "Luftdaten",
|
|
93
|
+
"name": "Living Room Air",
|
|
94
|
+
"localUrl": "http://192.168.1.50/data.json",
|
|
95
|
+
"sensorId": "12345",
|
|
96
|
+
"pollInterval": 120,
|
|
97
|
+
"requestTimeout": 10,
|
|
98
|
+
"hasTempSensor": true
|
|
99
|
+
}
|
|
100
|
+
]
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
| Option | Type | Default | Description |
|
|
105
|
+
|---|---|---|---|
|
|
106
|
+
| `accessory` | string | — | Must be `"Luftdaten"`. |
|
|
107
|
+
| `name` | string | `"Luftdaten"` | Name shown in the Home app. |
|
|
108
|
+
| `localUrl` | string | — | Local sensor endpoint, e.g. `http://192.168.1.50/data.json`. Tried first. |
|
|
109
|
+
| `sensorId` | string/number | — | Sensor.Community sensor ID, used for the cloud fallback. |
|
|
110
|
+
| `pollInterval` | number | `120` | Seconds between reads (min 10). |
|
|
111
|
+
| `requestTimeout` | number | `10` | Per-request timeout in seconds (min 1). |
|
|
112
|
+
| `hasTempSensor` | boolean | `true` | Add temperature + humidity services. |
|
|
113
|
+
| `hasBME280` | boolean | — | **Deprecated** alias for `hasTempSensor`, kept for backward compatibility. |
|
|
114
|
+
|
|
115
|
+
At least one of `localUrl` or `sensorId` must be set. If both are present, the
|
|
116
|
+
local URL is used and the cloud is only contacted when the local read fails.
|
|
117
|
+
|
|
118
|
+
## How it works
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
┌──────────────────────────┐
|
|
122
|
+
poll → │ GET localUrl (priority) │ ── ok ──► parse ─┐
|
|
123
|
+
└──────────────────────────┘ │
|
|
124
|
+
│ fail/timeout ▼
|
|
125
|
+
▼ update HomeKit
|
|
126
|
+
┌──────────────────────────┐ characteristics
|
|
127
|
+
│ GET cloud API (fallback) │ ── ok ──► parse ─┘
|
|
128
|
+
│ …/v1/sensor/<id>/ │
|
|
129
|
+
└──────────────────────────┘
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Development
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
npm run check # node --check src/index.js
|
|
136
|
+
npm test # node --test
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
[MIT](LICENSE) © Rafał Rudecki
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"pluginAlias": "Luftdaten",
|
|
3
|
+
"pluginType": "accessory",
|
|
4
|
+
"singular": false,
|
|
5
|
+
"headerDisplay": "Reads a Luftdaten / Sensor.Community (airrohr) sensor — local-first, with a cloud fallback. Provide a **Local URL** and/or a **Sensor ID** (at least one is required).",
|
|
6
|
+
"footerDisplay": "See the [README](https://github.com/rafalr100/homebridge-luftdaten) for details. Pressure is logged but not exposed to HomeKit.",
|
|
7
|
+
"schema": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"properties": {
|
|
10
|
+
"name": {
|
|
11
|
+
"title": "Name",
|
|
12
|
+
"type": "string",
|
|
13
|
+
"default": "Luftdaten",
|
|
14
|
+
"required": true,
|
|
15
|
+
"description": "Name shown in the Home app."
|
|
16
|
+
},
|
|
17
|
+
"localUrl": {
|
|
18
|
+
"title": "Local URL",
|
|
19
|
+
"type": "string",
|
|
20
|
+
"format": "uri",
|
|
21
|
+
"placeholder": "http://192.168.1.50/data.json",
|
|
22
|
+
"description": "The sensor's local data endpoint. Tried first."
|
|
23
|
+
},
|
|
24
|
+
"sensorId": {
|
|
25
|
+
"title": "Sensor.Community Sensor ID",
|
|
26
|
+
"type": "string",
|
|
27
|
+
"placeholder": "12345",
|
|
28
|
+
"description": "Numeric ID of the SDS011 sensor, used for the cloud fallback when the local read fails."
|
|
29
|
+
},
|
|
30
|
+
"pollInterval": {
|
|
31
|
+
"title": "Poll interval (seconds)",
|
|
32
|
+
"type": "integer",
|
|
33
|
+
"default": 120,
|
|
34
|
+
"minimum": 10,
|
|
35
|
+
"description": "How often to read the sensor."
|
|
36
|
+
},
|
|
37
|
+
"requestTimeout": {
|
|
38
|
+
"title": "Request timeout (seconds)",
|
|
39
|
+
"type": "integer",
|
|
40
|
+
"default": 10,
|
|
41
|
+
"minimum": 1,
|
|
42
|
+
"description": "Per-request timeout."
|
|
43
|
+
},
|
|
44
|
+
"hasTempSensor": {
|
|
45
|
+
"title": "Has temperature / humidity sensor",
|
|
46
|
+
"type": "boolean",
|
|
47
|
+
"default": true,
|
|
48
|
+
"description": "Add temperature and humidity services (SHT3X / BME280). Turn off for an SDS011-only sensor."
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"layout": [
|
|
53
|
+
"name",
|
|
54
|
+
"localUrl",
|
|
55
|
+
"sensorId",
|
|
56
|
+
{
|
|
57
|
+
"type": "fieldset",
|
|
58
|
+
"title": "Advanced",
|
|
59
|
+
"expandable": true,
|
|
60
|
+
"expanded": false,
|
|
61
|
+
"items": ["pollInterval", "requestTimeout", "hasTempSensor"]
|
|
62
|
+
}
|
|
63
|
+
]
|
|
64
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
<svg width="720" height="460" viewBox="0 0 720 460" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="wall" x1="0" y1="0" x2="1" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#4b4bd6"/>
|
|
5
|
+
<stop offset="0.55" stop-color="#7a5cc8"/>
|
|
6
|
+
<stop offset="1" stop-color="#c065c4"/>
|
|
7
|
+
</linearGradient>
|
|
8
|
+
<linearGradient id="green" x1="0" y1="0" x2="1" y2="0">
|
|
9
|
+
<stop offset="0" stop-color="#34d27b"/>
|
|
10
|
+
<stop offset="1" stop-color="#2bb673"/>
|
|
11
|
+
</linearGradient>
|
|
12
|
+
<filter id="soft" x="-20%" y="-20%" width="140%" height="140%">
|
|
13
|
+
<feDropShadow dx="0" dy="6" stdDeviation="10" flood-color="#000" flood-opacity="0.18"/>
|
|
14
|
+
</filter>
|
|
15
|
+
</defs>
|
|
16
|
+
|
|
17
|
+
<!-- wallpaper -->
|
|
18
|
+
<rect width="720" height="460" rx="28" fill="url(#wall)"/>
|
|
19
|
+
<rect width="720" height="460" rx="28" fill="#ffffff" opacity="0.04"/>
|
|
20
|
+
|
|
21
|
+
<!-- header -->
|
|
22
|
+
<text x="40" y="56" fill="#ffffff" font-size="27" font-weight="700">My Home</text>
|
|
23
|
+
<text x="40" y="80" fill="#ffffff" opacity="0.75" font-size="14" font-weight="500">Living Room · Air Quality Sensor</text>
|
|
24
|
+
|
|
25
|
+
<!-- ===== tiles row ===== -->
|
|
26
|
+
<!-- Tile 1: Air Quality -->
|
|
27
|
+
<g filter="url(#soft)">
|
|
28
|
+
<rect x="40" y="104" width="200" height="128" rx="22" fill="#ffffff" opacity="0.16"/>
|
|
29
|
+
</g>
|
|
30
|
+
<rect x="40" y="104" width="200" height="128" rx="22" fill="none" stroke="#ffffff" stroke-opacity="0.2"/>
|
|
31
|
+
<circle cx="74" cy="142" r="20" fill="url(#green)"/>
|
|
32
|
+
<g stroke="#ffffff" stroke-width="2.6" stroke-linecap="round" fill="none" opacity="0.95">
|
|
33
|
+
<path d="M65 137 h14 a4 4 0 1 0 -4 -4"/>
|
|
34
|
+
<path d="M65 143 h18 a4 4 0 1 1 -4 4"/>
|
|
35
|
+
<path d="M65 149 h11"/>
|
|
36
|
+
</g>
|
|
37
|
+
<text x="64" y="194" fill="#ffffff" font-size="25" font-weight="700">Excellent</text>
|
|
38
|
+
<text x="64" y="216" fill="#ffffff" opacity="0.78" font-size="13" font-weight="500">Air Quality</text>
|
|
39
|
+
|
|
40
|
+
<!-- Tile 2: Temperature -->
|
|
41
|
+
<g filter="url(#soft)">
|
|
42
|
+
<rect x="260" y="104" width="200" height="128" rx="22" fill="#ffffff" opacity="0.16"/>
|
|
43
|
+
</g>
|
|
44
|
+
<rect x="260" y="104" width="200" height="128" rx="22" fill="none" stroke="#ffffff" stroke-opacity="0.2"/>
|
|
45
|
+
<circle cx="294" cy="142" r="20" fill="#ff8a3d"/>
|
|
46
|
+
<g fill="none" stroke="#ffffff" stroke-width="2.6" stroke-linecap="round">
|
|
47
|
+
<path d="M294 133 v12"/>
|
|
48
|
+
</g>
|
|
49
|
+
<rect x="290.5" y="131" width="7" height="14" rx="3.5" fill="#ffffff"/>
|
|
50
|
+
<circle cx="294" cy="150" r="5.5" fill="#ffffff"/>
|
|
51
|
+
<text x="284" y="194" fill="#ffffff" font-size="27" font-weight="700">21.4°</text>
|
|
52
|
+
<text x="284" y="216" fill="#ffffff" opacity="0.78" font-size="13" font-weight="500">Temperature</text>
|
|
53
|
+
|
|
54
|
+
<!-- Tile 3: Humidity -->
|
|
55
|
+
<g filter="url(#soft)">
|
|
56
|
+
<rect x="480" y="104" width="200" height="128" rx="22" fill="#ffffff" opacity="0.16"/>
|
|
57
|
+
</g>
|
|
58
|
+
<rect x="480" y="104" width="200" height="128" rx="22" fill="none" stroke="#ffffff" stroke-opacity="0.2"/>
|
|
59
|
+
<circle cx="514" cy="142" r="20" fill="#3aa0ff"/>
|
|
60
|
+
<path d="M514 130 C520 138 524 143 524 148 a10 10 0 1 1 -20 0 c0 -5 4 -10 10 -18 Z" fill="#ffffff"/>
|
|
61
|
+
<text x="504" y="194" fill="#ffffff" font-size="27" font-weight="700">46%</text>
|
|
62
|
+
<text x="504" y="216" fill="#ffffff" opacity="0.78" font-size="13" font-weight="500">Humidity</text>
|
|
63
|
+
|
|
64
|
+
<!-- ===== detail card ===== -->
|
|
65
|
+
<g filter="url(#soft)">
|
|
66
|
+
<rect x="40" y="256" width="640" height="160" rx="26" fill="#ffffff" opacity="0.16"/>
|
|
67
|
+
</g>
|
|
68
|
+
<rect x="40" y="256" width="640" height="160" rx="26" fill="none" stroke="#ffffff" stroke-opacity="0.2"/>
|
|
69
|
+
|
|
70
|
+
<text x="64" y="290" fill="#ffffff" opacity="0.7" font-size="13" font-weight="600" letter-spacing="0.5">AIR QUALITY SENSOR</text>
|
|
71
|
+
<circle cx="70" cy="318" r="7" fill="url(#green)"/>
|
|
72
|
+
<text x="86" y="324" fill="#ffffff" font-size="22" font-weight="700">Excellent</text>
|
|
73
|
+
<text x="616" y="324" fill="#ffffff" opacity="0.7" font-size="12" font-weight="500" text-anchor="end">WHO / EU thresholds</text>
|
|
74
|
+
|
|
75
|
+
<!-- PM2.5 row -->
|
|
76
|
+
<text x="64" y="362" fill="#ffffff" font-size="14" font-weight="600">PM2.5</text>
|
|
77
|
+
<rect x="150" y="354" width="390" height="9" rx="4.5" fill="#ffffff" opacity="0.2"/>
|
|
78
|
+
<rect x="150" y="354" width="62" height="9" rx="4.5" fill="url(#green)"/>
|
|
79
|
+
<text x="616" y="363" fill="#ffffff" font-size="14" font-weight="600" text-anchor="end">8 µg/m³</text>
|
|
80
|
+
|
|
81
|
+
<!-- PM10 row -->
|
|
82
|
+
<text x="64" y="394" fill="#ffffff" font-size="14" font-weight="600">PM10</text>
|
|
83
|
+
<rect x="150" y="386" width="390" height="9" rx="4.5" fill="#ffffff" opacity="0.2"/>
|
|
84
|
+
<rect x="150" y="386" width="94" height="9" rx="4.5" fill="url(#green)"/>
|
|
85
|
+
<text x="616" y="395" fill="#ffffff" font-size="14" font-weight="600" text-anchor="end">12 µg/m³</text>
|
|
86
|
+
</svg>
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "homebridge-luftdaten",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Homebridge accessory plugin for Luftdaten / Sensor.Community (airrohr) air quality sensors, with local-first reading and cloud fallback.",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "node --test",
|
|
8
|
+
"check": "node --check src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"homebridge-plugin",
|
|
12
|
+
"luftdaten",
|
|
13
|
+
"sensor.community",
|
|
14
|
+
"airrohr",
|
|
15
|
+
"air-quality",
|
|
16
|
+
"sds011",
|
|
17
|
+
"sht3x",
|
|
18
|
+
"bme280",
|
|
19
|
+
"particulate-matter",
|
|
20
|
+
"homekit"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"homebridge": ">=1.6.0",
|
|
24
|
+
"node": ">=18.0.0"
|
|
25
|
+
},
|
|
26
|
+
"author": "Rafał Rudecki",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/rafalr100/homebridge-luftdaten.git"
|
|
31
|
+
},
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/rafalr100/homebridge-luftdaten/issues"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/rafalr100/homebridge-luftdaten#readme",
|
|
36
|
+
"files": [
|
|
37
|
+
"src/",
|
|
38
|
+
"docs/",
|
|
39
|
+
"config.schema.json",
|
|
40
|
+
"config.sample.json",
|
|
41
|
+
"README.md",
|
|
42
|
+
"INSTALL.md",
|
|
43
|
+
"LICENSE"
|
|
44
|
+
]
|
|
45
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* homebridge-luftdaten
|
|
5
|
+
*
|
|
6
|
+
* Accessory plugin reading air-quality data from a Luftdaten / Sensor.Community
|
|
7
|
+
* (airrohr firmware) sensor. Reads locally first (e.g. http://<ip>/data.json)
|
|
8
|
+
* and falls back to the Sensor.Community cloud API.
|
|
9
|
+
*
|
|
10
|
+
* Zero external dependencies — uses the built-in global `fetch` (Node 18+)
|
|
11
|
+
* together with AbortController for request timeouts.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const PLUGIN_NAME = 'homebridge-luftdaten';
|
|
15
|
+
const ACCESSORY_NAME = 'Luftdaten';
|
|
16
|
+
|
|
17
|
+
// HomeKit AirQuality characteristic values (HAP enum).
|
|
18
|
+
const AIR_QUALITY = {
|
|
19
|
+
UNKNOWN: 0,
|
|
20
|
+
EXCELLENT: 1,
|
|
21
|
+
GOOD: 2,
|
|
22
|
+
FAIR: 3,
|
|
23
|
+
INFERIOR: 4,
|
|
24
|
+
POOR: 5,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// HomeKit PM density characteristics have a valid range of 0..1000 µg/m³.
|
|
28
|
+
const DENSITY_MIN = 0;
|
|
29
|
+
const DENSITY_MAX = 1000;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse a single airrohr-style payload (an object with a `sensordatavalues`
|
|
33
|
+
* array) into normalised numeric readings. Handles every firmware variant of
|
|
34
|
+
* the temperature/humidity/pressure value_type names.
|
|
35
|
+
*
|
|
36
|
+
* @param {object} data raw payload (a single sensor record, not the cloud array)
|
|
37
|
+
* @returns {{pm25:?number, pm10:?number, temperature:?number, humidity:?number, pressure:?number}}
|
|
38
|
+
*/
|
|
39
|
+
function parseSensorData(data) {
|
|
40
|
+
const result = {
|
|
41
|
+
pm25: null,
|
|
42
|
+
pm10: null,
|
|
43
|
+
temperature: null,
|
|
44
|
+
humidity: null,
|
|
45
|
+
pressure: null, // hPa — read & logged, but not exposed to HomeKit
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const values =
|
|
49
|
+
data && Array.isArray(data.sensordatavalues) ? data.sensordatavalues : [];
|
|
50
|
+
|
|
51
|
+
for (const entry of values) {
|
|
52
|
+
if (!entry || typeof entry.value_type !== 'string') continue;
|
|
53
|
+
const num = parseFloat(entry.value);
|
|
54
|
+
if (Number.isNaN(num)) continue;
|
|
55
|
+
|
|
56
|
+
switch (entry.value_type) {
|
|
57
|
+
// PM10 — local firmware uses SDS_P1, the Sensor.Community cloud API uses P1.
|
|
58
|
+
case 'SDS_P1':
|
|
59
|
+
case 'P1':
|
|
60
|
+
result.pm10 = num;
|
|
61
|
+
break;
|
|
62
|
+
// PM2.5 — local firmware uses SDS_P2, the cloud API uses P2.
|
|
63
|
+
case 'SDS_P2':
|
|
64
|
+
case 'P2':
|
|
65
|
+
result.pm25 = num;
|
|
66
|
+
break;
|
|
67
|
+
|
|
68
|
+
// Temperature — all known firmware variants.
|
|
69
|
+
case 'SHT3X_temperature':
|
|
70
|
+
case 'BME280_temperature':
|
|
71
|
+
case 'BMP280_temperature':
|
|
72
|
+
case 'DHT_temperature':
|
|
73
|
+
case 'temperature':
|
|
74
|
+
result.temperature = num;
|
|
75
|
+
break;
|
|
76
|
+
|
|
77
|
+
// Humidity — all known firmware variants.
|
|
78
|
+
case 'SHT3X_humidity':
|
|
79
|
+
case 'BME280_humidity':
|
|
80
|
+
case 'DHT_humidity':
|
|
81
|
+
case 'humidity':
|
|
82
|
+
result.humidity = num;
|
|
83
|
+
break;
|
|
84
|
+
|
|
85
|
+
// Pressure reported in Pa — convert to hPa.
|
|
86
|
+
case 'BME280_pressure':
|
|
87
|
+
case 'BMP280_pressure':
|
|
88
|
+
result.pressure = num / 100;
|
|
89
|
+
break;
|
|
90
|
+
|
|
91
|
+
default:
|
|
92
|
+
// signal, samples, min_micro, etc. — ignored.
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Map a PM2.5 reading (µg/m³) to a HomeKit AirQuality value using WHO/EU
|
|
102
|
+
* thresholds.
|
|
103
|
+
* <=10 EXCELLENT, <=20 GOOD, <=25 FAIR, <=50 INFERIOR, >50 POOR, missing UNKNOWN
|
|
104
|
+
*
|
|
105
|
+
* @param {?number} pm25
|
|
106
|
+
* @returns {number} HomeKit AirQuality value (0..5)
|
|
107
|
+
*/
|
|
108
|
+
function pm25ToAirQuality(pm25) {
|
|
109
|
+
if (pm25 === null || pm25 === undefined || Number.isNaN(pm25)) {
|
|
110
|
+
return AIR_QUALITY.UNKNOWN;
|
|
111
|
+
}
|
|
112
|
+
if (pm25 <= 10) return AIR_QUALITY.EXCELLENT;
|
|
113
|
+
if (pm25 <= 20) return AIR_QUALITY.GOOD;
|
|
114
|
+
if (pm25 <= 25) return AIR_QUALITY.FAIR;
|
|
115
|
+
if (pm25 <= 50) return AIR_QUALITY.INFERIOR;
|
|
116
|
+
return AIR_QUALITY.POOR;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Clamp a density value into the HomeKit-valid 0..1000 range. */
|
|
120
|
+
function clampDensity(value) {
|
|
121
|
+
if (value === null || value === undefined || Number.isNaN(value)) return 0;
|
|
122
|
+
return Math.min(DENSITY_MAX, Math.max(DENSITY_MIN, value));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
class LuftdatenAccessory {
|
|
126
|
+
constructor(log, config, api) {
|
|
127
|
+
this.log = log;
|
|
128
|
+
this.config = config || {};
|
|
129
|
+
this.api = api;
|
|
130
|
+
|
|
131
|
+
this.Service = api.hap.Service;
|
|
132
|
+
this.Characteristic = api.hap.Characteristic;
|
|
133
|
+
|
|
134
|
+
this.name = this.config.name || ACCESSORY_NAME;
|
|
135
|
+
this.localUrl = this.config.localUrl || null;
|
|
136
|
+
this.sensorId =
|
|
137
|
+
this.config.sensorId !== undefined && this.config.sensorId !== null
|
|
138
|
+
? String(this.config.sensorId)
|
|
139
|
+
: null;
|
|
140
|
+
|
|
141
|
+
this.pollInterval = Math.max(10, Number(this.config.pollInterval) || 120) * 1000;
|
|
142
|
+
this.requestTimeout =
|
|
143
|
+
Math.max(1, Number(this.config.requestTimeout) || 10) * 1000;
|
|
144
|
+
|
|
145
|
+
// hasTempSensor decides whether temperature + humidity services are added.
|
|
146
|
+
// Backward compatible with the old `hasBME280` flag.
|
|
147
|
+
if (this.config.hasTempSensor !== undefined) {
|
|
148
|
+
this.hasTempSensor = Boolean(this.config.hasTempSensor);
|
|
149
|
+
} else if (this.config.hasBME280 !== undefined) {
|
|
150
|
+
this.hasTempSensor = Boolean(this.config.hasBME280);
|
|
151
|
+
} else {
|
|
152
|
+
this.hasTempSensor = true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Last known readings (served by onGet handlers between polls).
|
|
156
|
+
this.latest = {
|
|
157
|
+
pm25: null,
|
|
158
|
+
pm10: null,
|
|
159
|
+
temperature: null,
|
|
160
|
+
humidity: null,
|
|
161
|
+
pressure: null,
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
if (!this.localUrl && !this.sensorId) {
|
|
165
|
+
this.log.warn(
|
|
166
|
+
'Neither "localUrl" nor "sensorId" configured — no data source available.'
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
this._setupServices();
|
|
171
|
+
this._startPolling();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
_setupServices() {
|
|
175
|
+
const { Service, Characteristic } = this;
|
|
176
|
+
|
|
177
|
+
// Accessory information.
|
|
178
|
+
this.informationService = new Service.AccessoryInformation()
|
|
179
|
+
.setCharacteristic(Characteristic.Manufacturer, 'Sensor.Community')
|
|
180
|
+
.setCharacteristic(Characteristic.Model, 'SDS011 + SHT3X/BME280')
|
|
181
|
+
.setCharacteristic(
|
|
182
|
+
Characteristic.SerialNumber,
|
|
183
|
+
this.sensorId || this.localUrl || 'luftdaten'
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
// Air quality.
|
|
187
|
+
this.airQualityService = new Service.AirQualitySensor(this.name);
|
|
188
|
+
this.airQualityService
|
|
189
|
+
.getCharacteristic(Characteristic.AirQuality)
|
|
190
|
+
.onGet(() => pm25ToAirQuality(this.latest.pm25));
|
|
191
|
+
this.airQualityService
|
|
192
|
+
.getCharacteristic(Characteristic.PM2_5Density)
|
|
193
|
+
.onGet(() => clampDensity(this.latest.pm25));
|
|
194
|
+
this.airQualityService
|
|
195
|
+
.getCharacteristic(Characteristic.PM10Density)
|
|
196
|
+
.onGet(() => clampDensity(this.latest.pm10));
|
|
197
|
+
|
|
198
|
+
this.services = [this.informationService, this.airQualityService];
|
|
199
|
+
|
|
200
|
+
if (this.hasTempSensor) {
|
|
201
|
+
this.temperatureService = new Service.TemperatureSensor(
|
|
202
|
+
`${this.name} Temp`,
|
|
203
|
+
'temperature'
|
|
204
|
+
);
|
|
205
|
+
this.temperatureService
|
|
206
|
+
.getCharacteristic(Characteristic.CurrentTemperature)
|
|
207
|
+
.setProps({ minValue: -50, maxValue: 100 })
|
|
208
|
+
.onGet(() =>
|
|
209
|
+
this.latest.temperature === null ? 0 : this.latest.temperature
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
this.humidityService = new Service.HumiditySensor(
|
|
213
|
+
`${this.name} Humidity`,
|
|
214
|
+
'humidity'
|
|
215
|
+
);
|
|
216
|
+
this.humidityService
|
|
217
|
+
.getCharacteristic(Characteristic.CurrentRelativeHumidity)
|
|
218
|
+
.onGet(() => (this.latest.humidity === null ? 0 : this.latest.humidity));
|
|
219
|
+
|
|
220
|
+
this.services.push(this.temperatureService, this.humidityService);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
_startPolling() {
|
|
225
|
+
// Kick off immediately, then on the configured interval.
|
|
226
|
+
this.poll();
|
|
227
|
+
this._timer = setInterval(() => this.poll(), this.pollInterval);
|
|
228
|
+
if (this._timer.unref) this._timer.unref();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Fetch JSON from a URL with an AbortController-based timeout. */
|
|
232
|
+
async _fetchJson(url) {
|
|
233
|
+
const controller = new AbortController();
|
|
234
|
+
const timer = setTimeout(() => controller.abort(), this.requestTimeout);
|
|
235
|
+
try {
|
|
236
|
+
const res = await fetch(url, {
|
|
237
|
+
signal: controller.signal,
|
|
238
|
+
headers: { Accept: 'application/json' },
|
|
239
|
+
});
|
|
240
|
+
if (!res.ok) {
|
|
241
|
+
throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
|
242
|
+
}
|
|
243
|
+
return await res.json();
|
|
244
|
+
} finally {
|
|
245
|
+
clearTimeout(timer);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Poll the sensor: local first, cloud as fallback. */
|
|
250
|
+
async poll() {
|
|
251
|
+
let raw = null;
|
|
252
|
+
let source = null;
|
|
253
|
+
|
|
254
|
+
// 1. Local read — priority.
|
|
255
|
+
if (this.localUrl) {
|
|
256
|
+
try {
|
|
257
|
+
raw = await this._fetchJson(this.localUrl);
|
|
258
|
+
source = 'local';
|
|
259
|
+
} catch (err) {
|
|
260
|
+
this.log.warn(
|
|
261
|
+
`Local read failed (${this.localUrl}): ${err.message}` +
|
|
262
|
+
(this.sensorId ? ' — trying cloud fallback.' : '')
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// 2. Cloud fallback.
|
|
268
|
+
if (!raw && this.sensorId) {
|
|
269
|
+
const cloudUrl = `https://data.sensor.community/airrohr/v1/sensor/${this.sensorId}/`;
|
|
270
|
+
try {
|
|
271
|
+
const data = await this._fetchJson(cloudUrl);
|
|
272
|
+
// Cloud returns an array of records; the newest is the last element.
|
|
273
|
+
raw = Array.isArray(data) ? data[data.length - 1] : data;
|
|
274
|
+
source = 'cloud';
|
|
275
|
+
} catch (err) {
|
|
276
|
+
this.log.warn(`Cloud read failed (sensor ${this.sensorId}): ${err.message}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (!raw) {
|
|
281
|
+
this.log.warn('No sensor data available from any source this cycle.');
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const parsed = parseSensorData(raw);
|
|
286
|
+
this.latest = parsed;
|
|
287
|
+
this._publish(parsed, source);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Push parsed readings into the HomeKit characteristics and log them. */
|
|
291
|
+
_publish(parsed, source) {
|
|
292
|
+
const { Characteristic } = this;
|
|
293
|
+
|
|
294
|
+
const aq = pm25ToAirQuality(parsed.pm25);
|
|
295
|
+
this.airQualityService.updateCharacteristic(Characteristic.AirQuality, aq);
|
|
296
|
+
if (parsed.pm25 !== null) {
|
|
297
|
+
this.airQualityService.updateCharacteristic(
|
|
298
|
+
Characteristic.PM2_5Density,
|
|
299
|
+
clampDensity(parsed.pm25)
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
if (parsed.pm10 !== null) {
|
|
303
|
+
this.airQualityService.updateCharacteristic(
|
|
304
|
+
Characteristic.PM10Density,
|
|
305
|
+
clampDensity(parsed.pm10)
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (this.hasTempSensor) {
|
|
310
|
+
if (parsed.temperature !== null && this.temperatureService) {
|
|
311
|
+
this.temperatureService.updateCharacteristic(
|
|
312
|
+
Characteristic.CurrentTemperature,
|
|
313
|
+
parsed.temperature
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
if (parsed.humidity !== null && this.humidityService) {
|
|
317
|
+
this.humidityService.updateCharacteristic(
|
|
318
|
+
Characteristic.CurrentRelativeHumidity,
|
|
319
|
+
parsed.humidity
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const parts = [];
|
|
325
|
+
parts.push(`PM2.5=${fmt(parsed.pm25)}`);
|
|
326
|
+
parts.push(`PM10=${fmt(parsed.pm10)}`);
|
|
327
|
+
if (this.hasTempSensor) {
|
|
328
|
+
parts.push(`temp=${fmt(parsed.temperature)}°C`);
|
|
329
|
+
parts.push(`hum=${fmt(parsed.humidity)}%`);
|
|
330
|
+
}
|
|
331
|
+
if (parsed.pressure !== null) {
|
|
332
|
+
parts.push(`pressure=${fmt(parsed.pressure)}hPa`);
|
|
333
|
+
}
|
|
334
|
+
parts.push(`airQuality=${aq}`);
|
|
335
|
+
this.log.info(`[${source}] ${parts.join(' ')}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
getServices() {
|
|
339
|
+
return this.services;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function fmt(value) {
|
|
344
|
+
return value === null || value === undefined || Number.isNaN(value)
|
|
345
|
+
? 'n/a'
|
|
346
|
+
: value;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
module.exports = (api) => {
|
|
350
|
+
api.registerAccessory(PLUGIN_NAME, ACCESSORY_NAME, LuftdatenAccessory);
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
// Exposed for unit tests (does not affect Homebridge registration).
|
|
354
|
+
module.exports.parseSensorData = parseSensorData;
|
|
355
|
+
module.exports.pm25ToAirQuality = pm25ToAirQuality;
|
|
356
|
+
module.exports.clampDensity = clampDensity;
|
|
357
|
+
module.exports.AIR_QUALITY = AIR_QUALITY;
|
|
358
|
+
module.exports.LuftdatenAccessory = LuftdatenAccessory;
|