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 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: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
7
+ ![Node](https://img.shields.io/badge/node-%E2%89%A5%2018-339933.svg)
8
+ ![Homebridge](https://img.shields.io/badge/homebridge-%E2%89%A5%201.6-491F59.svg)
9
+ ![Dependencies](https://img.shields.io/badge/dependencies-none-blue.svg)
10
+
11
+ ![How the sensor looks in the iOS Home app](docs/home-app-preview.svg)
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,13 @@
1
+ {
2
+ "accessories": [
3
+ {
4
+ "accessory": "Luftdaten",
5
+ "name": "<NAME_IN_HOMEKIT>",
6
+ "localUrl": "http://192.168.1.50/data.json",
7
+ "sensorId": "<SDS011_SENSOR_ID>",
8
+ "pollInterval": 120,
9
+ "requestTimeout": 10,
10
+ "hasTempSensor": true
11
+ }
12
+ ]
13
+ }
@@ -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;