homebridge-pax-calima 2.4.1 → 2.4.3

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/README.md CHANGED
@@ -1,72 +1,70 @@
1
1
  # homebridge-pax-calima
2
2
 
3
- **Homebridge 2.0 Ready** · [npm](https://www.npmjs.com/package/homebridge-pax-calima) · [GitHub](https://github.com/JayC68/homebridge-pax-calima)
3
+ **Bring your bathroom fan into Apple Home — no hub, no cloud, just Bluetooth.**
4
4
 
5
- Full HomeKit integration for **Pax Calima** and **Vent-Axia Lo-Carbon Svara** Bluetooth LE bathroom extractor fans.
5
+ If you have a **Pax Calima** or **Vent-Axia Lo-Carbon Svara** extractor fan, this plugin connects it directly to HomeKit via Bluetooth. Once set up, your fan appears in the Home app with live temperature and fan speed — and you can build automations around all of them.
6
6
 
7
- **Compatible devices:**
8
- - **Pax Calima** — Sweden, Norway, Denmark, Finland, Spain, Portugal
9
- - **Vent-Axia Lo-Carbon Svara** White & Black — UK
10
-
11
- All are hardware-identical, sharing the same BLE protocol and MAC prefix (`58:2b:db`).
7
+ [![Homebridge 2.0 Ready](https://img.shields.io/badge/Homebridge-2.0%20Ready-brightgreen)](https://github.com/homebridge/homebridge/wiki/Updating-To-Homebridge-v2.0)
8
+ [![npm](https://img.shields.io/npm/v/homebridge-pax-calima)](https://www.npmjs.com/package/homebridge-pax-calima)
12
9
 
13
10
  ---
14
11
 
15
- ## Features
12
+ ## Is this the right plugin for your fan?
13
+
14
+ This plugin works with fans that connect via the official **Pax** or **Vent-Axia** Bluetooth app. If your fan has a MAC address starting with `58:2b:db`, you're in the right place.
16
15
 
17
- | Feature | Detail |
18
- |---|---|
19
- | **Auto-discovery** | Finds all compatible fans automatically — no MAC address needed |
20
- | **Fan speed** | Displays live RPM as 0–100%; speed control available (see wiring note) |
21
- | **Humidity sensor** | Live readings, updates every 10 seconds |
22
- | **Temperature sensor** | Live readings, updates every 10 seconds |
23
- | **Light sensor** | Live lux readings, updates every 10 seconds |
24
- | **Boost button** | Stateless programmable switch |
25
- | **Silent Hours** | Toggle — suppresses automatic boosting during quiet periods |
26
- | **Automations** | Full HomeKit automation support on all sensors |
16
+ **Confirmed compatible:**
17
+ - Pax Calima — Sweden, Norway, Denmark, Finland, Spain, Portugal
18
+ - Vent-Axia Lo-Carbon Svara White — UK
19
+ - Vent-Axia Lo-Carbon Svara Black — UK
20
+
21
+ These fans are hardware-identical. The same plugin works for all of them.
27
22
 
28
23
  ---
29
24
 
30
- ## Requirements
25
+ ## What you get in the Home app
31
26
 
32
- - Homebridge `^1.6.0` or `^2.0.0`
33
- - Node.js `>= 18.20.4`
34
- - A Bluetooth LE adapter on the Homebridge host
27
+ Each fan appears as a single accessory with:
35
28
 
36
- ### Homebridge 2.0 Compliance
29
+ - 💨 **Fan speed** — shows current RPM as a percentage, live and accurate
30
+ - 🌡️ **Temperature** — live reading, updates every ~20 seconds
31
+ - ⚡ **Boost** — a button that triggers the fan's high-speed boost mode
32
+ - 🌙 **Silent Hours** — keeps the fan quiet at night; set a schedule, a switch, or both (see below)
33
+ - 💧 **Humidity** — available but off by default (read below before enabling)
34
+ - 💡 **Light level** — available but off by default (read below before enabling)
37
35
 
38
- This plugin is fully compliant with Homebridge 2.0:
39
- - Implemented as a **dynamic platform** (`registerPlatform` with `true` flag)
40
- - Waits for `didFinishLaunching` before any BLE or accessory operations
41
- - All errors caught and logged — no unhandled exceptions
42
- - No analytics, no tracking, no post-install scripts
43
- - Accessories restored from cache without re-registration
44
- - Compatible with Node.js 18, 20 and 22
36
+ > Accessories appear in the **Home app on your iPhone**, not the Homebridge UI accessories tab — that's normal for child bridge plugins.
45
37
 
46
- ### Raspberry Pi / Homebridge OS — one-time setup
38
+ ---
47
39
 
48
- The `noble` BLE library requires elevated network capabilities. Run **once** after installation on the **Homebridge terminal**:
40
+ ## Before you start
41
+
42
+ You'll need:
43
+ - Homebridge `^1.6.0` or `^2.0.0`
44
+ - Node.js `18.20.4` or later
45
+ - A Bluetooth adapter on your Homebridge host (the Pi 4's built-in adapter works well)
46
+
47
+ **Running Homebridge OS on a Raspberry Pi?** There's one extra step after installation. Run this once in the **Homebridge terminal**:
49
48
 
50
49
  ```bash
51
50
  sudo setcap cap_net_raw+eip /opt/homebridge/bin/node
52
51
  ```
53
52
 
54
- If Bluetooth shows as `poweredOff`:
53
+ If Bluetooth shows as off after that:
55
54
 
56
55
  ```bash
57
56
  sudo rfkill unblock bluetooth
58
57
  ```
59
58
 
60
- If the above does not resolve the issue, a full Pi power cycle may be required to reinitialise the BLE stack cleanly.
59
+ If things still aren't working after both of those — unplug the Pi, wait 10 seconds, plug it back in. A full power cycle rather than a software restart is sometimes needed to reinitialise the Bluetooth stack cleanly.
61
60
 
62
61
  ---
63
62
 
64
63
  ## Installation
65
64
 
66
- ### Via Homebridge UI (recommended)
67
- Search for **Pax Calima** or **Svara** and click Install.
65
+ Search for **Pax Calima** or **Svara** in the Homebridge UI and click Install.
68
66
 
69
- ### Via Terminal
67
+ Or via terminal:
70
68
  ```bash
71
69
  npm install -g homebridge-pax-calima
72
70
  ```
@@ -75,12 +73,19 @@ npm install -g homebridge-pax-calima
75
73
 
76
74
  ## Configuration
77
75
 
78
- Auto-discovery requires **no configuration**. To set a custom name or PIN per fan:
76
+ **You don't need to configure anything to get started.** The plugin finds your fan automatically and registers it in HomeKit.
77
+
78
+ A full config example with all options:
79
79
 
80
80
  ```json
81
81
  {
82
82
  "platform": "PaxCalima",
83
83
  "name": "Pax Calima / Vent-Axia Svara",
84
+ "silentHoursStart": "23:00",
85
+ "silentHoursEnd": "07:00",
86
+ "showSilentSwitch": false,
87
+ "showHumidity": false,
88
+ "showLight": false,
84
89
  "fans": [
85
90
  {
86
91
  "name": "Wetroom Fan",
@@ -91,84 +96,123 @@ Auto-discovery requires **no configuration**. To set a custom name or PIN per fa
91
96
  }
92
97
  ```
93
98
 
94
- The `fans` array is optional. Default PIN is `012345`.
99
+ | Setting | Default | What it does |
100
+ |---|---|---|
101
+ | `silentHoursStart` | — | Time the fan stops boosting at night, e.g. `"23:00"` |
102
+ | `silentHoursEnd` | — | Time the fan resumes boosting, e.g. `"07:00"` |
103
+ | `showSilentSwitch` | `false` | Adds a Silent Hours switch to the Home app — great for Goodnight scenes |
104
+ | `showHumidity` | `false` | Shows humidity tile — read the note below first |
105
+ | `showLight` | `false` | Shows light tile — read the note below first |
106
+ | `fans[].name` | Auto | Friendly name for a specific fan |
107
+ | `fans[].mac` | — | Only needed to assign a custom name or PIN |
108
+ | `fans[].pin` | `012345` | 6-digit PIN — only change if set via the official app |
95
109
 
96
110
  ---
97
111
 
98
- ## Accessories in Apple Home
112
+ ## Silent Hours
99
113
 
100
- Each fan appears as a single accessory with:
114
+ Silent Hours tells the fan not to boost during a set period — useful for not being woken by a noisy fan at 3am.
115
+
116
+ **Two ways to use it — pick one or combine both:**
117
+
118
+ **Schedule (set and forget):** Set `silentHoursStart` and `silentHoursEnd` in your config using 24-hour format. The plugin calculates whether you're in the quiet window on every poll and writes the schedule to the fan automatically. Overnight spans work correctly — `23:00` to `07:00` does what you'd expect.
101
119
 
102
- - **Fan** — live speed display; slider available (see wiring note)
103
- - **Boost** — single-press programmable button
104
- - **Silent Hours** — on/off switch
105
- - **Humidity** — % relative humidity
106
- - **Temperature** — °C
107
- - **Light** — lux
120
+ **Switch (for scenes and shift workers):** Set `showSilentSwitch: true` to add a Silent Hours on/off switch to your accessory. This is ideal for including in a **Goodnight scene** alongside lights and heating — one tap and the fan goes quiet with everything else. It's also the right choice if your sleep schedule changes regularly, like shift work, where a fixed time window doesn't suit.
108
121
 
109
- > Accessories appear in the **Home app**, not the Homebridge UI accessories tab. This is correct behaviour for child bridge plugins.
122
+ **Using both together:** If you set a schedule AND enable the switch, the switch takes priority when you toggle it manually. The schedule still runs in the background — useful as a safety net if you forget to turn the switch back on.
110
123
 
111
124
  ---
112
125
 
113
- ## Fan Speed Control — Wiring Mode
126
+ ## A note on humidity and light
114
127
 
115
- The fan supports two installation wiring modes selected at installation time:
128
+ Both tiles are **off by default**, and for good reason.
116
129
 
117
- **Intermittent mode** — fan is off by default, triggered by sensors or timer. BLE speed commands are fully respected and the slider controls the fan directly.
130
+ During testing we confirmed that both sensors — while active on the device — don't expose live readings via Bluetooth. The fan uses them internally to decide when to boost, but the BLE characteristic returns the fan's last internal threshold state rather than a continuous measurement. In practical terms:
118
131
 
119
- **Always-on (trickle) mode** — fan runs continuously at a speed set by its onboard controller. BLE speed writes are accepted but the firmware reasserts its own speed setpoint immediately. The slider accurately reflects current RPM but cannot override the onboard speed.
132
+ - With all lights off at 21:20, the fan's Bluetooth still reported **52 lux**
133
+ - After a shower that raised the room to **89% humidity**, the Bluetooth still reported **50%**
120
134
 
121
- In always-on mode, the sensors and automations remain fully functional and represent the primary value of the integration — particularly for bathroom and wetroom use where humidity, temperature and light thresholds enable powerful Apple Home automations.
135
+ This isn't a bug in the plugin — it's how the fan's firmware works (confirmed on firmware 02.08, and consistent with observations from the Home Assistant community).
122
136
 
123
- To enable full speed control, switch to intermittent wiring mode per the fan's installation guide.
137
+ **If you turn them on**, be aware that:
138
+ - Asking Siri "what's the humidity in the bathroom?" will likely return a stale number
139
+ - Automations based on these values won't fire reliably
140
+
141
+ **What does work well for shower detection:** when the fan's internal humidity sensor detects a sharp rise, it boosts to near full speed automatically. A `Fan Speed > 75%` automation in Apple Home is a reliable, practical trigger for "shower in use."
142
+
143
+ If you want to enable humidity or light anyway — perhaps for settled ambient readings — set `showHumidity: true` or `showLight: true` in your config.
124
144
 
125
145
  ---
126
146
 
127
- ## Sensor Notes
147
+ ## Fan speed control — it depends on how your fan is wired
128
148
 
129
- Humidity and light values are guaranteed fresh on every poll — the plugin uses a connect→read→disconnect cycle which forces the device's GATT server to return live readings rather than cached values.
149
+ The fan can be installed in two wiring modes:
130
150
 
131
- The light sensor detects occupancy via movement and shadow rather than acting as a precision lux meter. Small variances from a handheld reference device are normal and expected, particularly with ceiling or wall mounting angles.
151
+ **Intermittent mode** — the fan is off by default and spins up when triggered. In this mode the speed slider in Apple Home works as expected.
132
152
 
133
- For lux-based automations use **Eve** or **Hesperus**, which support lux thresholds synced back to HomeKit.
153
+ **Always-on (trickle) mode** — the fan runs continuously at a speed managed by its own controller. Speed commands are accepted via Bluetooth but the fan resets to its own setpoint within seconds. The slider shows the current speed accurately but can't override it.
154
+
155
+ Not sure which mode you have? If your fan is always running — even quietly — you're in always-on mode. In either mode, temperature, fan speed and automations work perfectly.
134
156
 
135
157
  ---
136
158
 
137
- ## Troubleshooting
159
+ ## Something not working?
160
+
161
+ **Fan not showing up in Home**
162
+ Run `hciconfig` in the Homebridge terminal — you should see `UP RUNNING`. If it shows `RF-KILL`, run `sudo rfkill unblock bluetooth`. If still nothing, unplug the Pi and power cycle it.
138
163
 
139
- **Fan not discovered** — run `hciconfig` on the Pi; confirm `UP RUNNING`. If showing `RF-KILL` run `sudo rfkill unblock bluetooth`. If still not found, perform a full Pi power cycle to reinitialise the BLE stack.
164
+ **setcap not taking effect**
165
+ Make sure you're targeting the right binary — on Homebridge OS it's `/opt/homebridge/bin/node`, not the system Node.
140
166
 
141
- **Authentication failed** — default PIN is `012345`. If changed in the official app, add the fan to the `fans` config array with the correct PIN.
167
+ **Wrong PIN**
168
+ Default is `012345`. If you changed it in the official app, add the fan to the `fans` config array with the correct PIN.
142
169
 
143
- **Speed slider has no effect** — fan is wired in always-on mode. See wiring note above. All sensors work normally.
170
+ **Speed slider does nothing**
171
+ Your fan is in always-on mode — see above. Everything else works normally.
144
172
 
145
- **Accessories not in Home app** — clear accessory cache via Homebridge UI → plugin settings → Remove Accessory Cache, then restart.
173
+ **Humidity or light reading seems stuck**
174
+ This is expected firmware behaviour — see the sensor note above.
175
+
176
+ **Accessories not appearing in Home**
177
+ Go to Homebridge UI → plugin settings → Remove Accessory Cache, then restart. They'll reappear within a few seconds.
146
178
 
147
179
  ---
148
180
 
149
- ## Release Notes
181
+ ## Homebridge 2.0 compliance
150
182
 
151
- ### 2.4.1
152
- - **Fixed:** Humidity and light sensors returning stale values — root cause identified as the device's GATT server only refreshing sensor values on a new connection. Poll cycle reverted to proven connect→auth→read→disconnect pattern which guarantees live readings on every poll
153
- - **Fixed:** Characteristics discovered and cached once per poll cycle — eliminates the intermittent `Characteristic not found` errors seen in 2.4.0
154
- - **Fixed:** Overlapping poll guard prevents concurrent BLE connections
155
- - **Improved:** `writeSpeed` correctly skips if a poll is in progress to avoid BLE conflicts
156
- - **Improved:** Node.js minimum version updated to `18.20.4` per Homebridge 2.0 specification
157
- - **Documented:** Homebridge 2.0 compliance details added to README
183
+ This plugin meets all Homebridge 2.0 requirements:
184
+ - Registered as a dynamic platform
185
+ - Waits for `didFinishLaunching` before any BLE or accessory work
186
+ - All errors caught and logged — no unhandled exceptions
187
+ - No analytics, tracking or post-install scripts
188
+ - Accessories restored from cache cleanly without re-registration
189
+ - Compatible with Node.js 18, 20 and 22
158
190
 
159
- ### 2.4.0
160
- - **Rebuilt:** Persistent connection architecture — connect once, cache characteristics, poll with direct reads. Fast RPM and temperature updates confirmed. Humidity and light subsequently found to return stale GATT cached values on persistent connections — resolved in 2.4.1
191
+ ---
192
+
193
+ ## Release notes
194
+
195
+ ### 2.4.3
196
+ - **Silent Hours** reworked — now supports a config schedule (`silentHoursStart` / `silentHoursEnd`) that runs automatically, an optional HomeKit switch (`showSilentSwitch: true`) for Goodnight scenes and irregular schedules, or both together. The switch takes priority when toggled manually; the schedule runs as a background default. Any existing Silent Hours switch from earlier versions is automatically removed unless `showSilentSwitch` is enabled.
197
+ - **Light sensor** (`showLight`) added as opt-in — confirmed via live testing that the characteristic returns a static value regardless of lighting conditions.
198
+ - **`showSilentSwitch`**, **`showHumidity`** and **`showLight`** grouped under "Optional Sensors" in the Homebridge UI config for a cleaner settings experience.
199
+ - **`displayName`** added to `package.json` — shows correctly as "Vent-Axia Svara / Pax Calima" in the Homebridge UI.
200
+ - Sensor characteristic only read when at least one optional sensor is enabled — slightly leaner poll cycle for most users.
201
+ - Silent Hours written to fan on every poll — stays in sync after power cuts.
161
202
 
162
- ### 2.3.2
163
- - Documented always-on vs intermittent wiring mode behaviour
203
+ ### 2.4.2
204
+ - Humidity sensor disabled by default — opt-in via `showHumidity: true`
205
+ - README rewritten with a more human tone
164
206
 
165
- ### 2.3.1
166
- - Identified correct control characteristic; write diagnostics added
207
+ ### 2.4.1
208
+ - Fixed sensors returning stale values — reverted to connect→read→disconnect per poll
209
+
210
+ ### 2.4.0
211
+ - Rebuilt with persistent connection architecture
167
212
 
168
- ### 2.3.0 / 2.2.1 / 2.1.0
169
- - Sensor UUIDs and byte mappings confirmed via live device dump
170
- - BLE initialisation fixes for Homebridge 2.0
171
- - Duplicate accessory registration resolved
213
+ ### 2.3.x / 2.2.1 / 2.1.0
214
+ - Correct sensor UUIDs and byte mappings confirmed via live device testing
215
+ - Homebridge 2.0 initialisation fixes; duplicate accessory registration resolved
172
216
 
173
217
  ### 1.0.x
174
- - Initial working releases
218
+ - Initial releases
@@ -10,9 +10,41 @@
10
10
  "type": "string",
11
11
  "default": "Pax Calima / Vent-Axia Svara"
12
12
  },
13
+ "silentHoursStart": {
14
+ "title": "Silent Hours — Start Time",
15
+ "type": "string",
16
+ "pattern": "^([01]?[0-9]|2[0-3]):[0-5][0-9]$",
17
+ "placeholder": "e.g. 23:00",
18
+ "description": "When the fan stops boosting at night. 24-hour format. Leave blank to disable the schedule."
19
+ },
20
+ "silentHoursEnd": {
21
+ "title": "Silent Hours — End Time",
22
+ "type": "string",
23
+ "pattern": "^([01]?[0-9]|2[0-3]):[0-5][0-9]$",
24
+ "placeholder": "e.g. 07:00",
25
+ "description": "When the fan resumes boosting in the morning. Both start and end must be set for the schedule to activate."
26
+ },
27
+ "showSilentSwitch": {
28
+ "title": "Show Silent Hours Switch in Home",
29
+ "type": "boolean",
30
+ "default": false,
31
+ "description": "Adds a Silent Hours on/off switch to your accessory in the Home app. Useful for including in a Goodnight scene or automation. If a schedule is also set, the switch takes priority when toggled manually. Good for shift workers or irregular schedules."
32
+ },
33
+ "showHumidity": {
34
+ "title": "Show Humidity Sensor",
35
+ "type": "boolean",
36
+ "default": false,
37
+ "description": "⚠️ The fan's humidity reading reflects its internal threshold state and updates infrequently — it is NOT a live sensor reading and is NOT suitable for Siri queries or real-time automations. Only enable if you understand and accept this limitation."
38
+ },
39
+ "showLight": {
40
+ "title": "Show Light Sensor",
41
+ "type": "boolean",
42
+ "default": false,
43
+ "description": "⚠️ The fan's light reading reflects its internal threshold state and updates infrequently — it is NOT a live lux reading. Confirmed via testing: the value remains static regardless of lighting conditions. Only enable if you understand this limitation."
44
+ },
13
45
  "fans": {
14
46
  "title": "Manually Configured Fans",
15
- "description": "Optional. Leave empty to use auto-discovery (recommended). Add entries here only to set a custom name or PIN per fan.",
47
+ "description": "Optional. Leave empty for auto-discovery. Add entries here only to set a custom name or PIN per fan.",
16
48
  "type": "array",
17
49
  "items": {
18
50
  "type": "object",
@@ -26,13 +58,13 @@
26
58
  "title": "MAC Address",
27
59
  "type": "string",
28
60
  "pattern": "^([0-9A-Fa-f]{2}:){5}([0-9A-Fa-f]{2})$",
29
- "description": "MAC address of the fan (e.g. 58:2b:db:xx:xx:xx). Must start with 58:2b:db."
61
+ "description": "MAC address of the fan (e.g. 58:2b:db:xx:xx:xx)"
30
62
  },
31
63
  "pin": {
32
64
  "title": "PIN Code",
33
65
  "type": "string",
34
66
  "default": "012345",
35
- "description": "6-digit PIN. Only change this if you set a custom PIN in the official Pax / Vent-Axia app."
67
+ "description": "6-digit PIN. Only change if you set a custom PIN in the official app."
36
68
  }
37
69
  },
38
70
  "required": ["name", "mac"]
@@ -43,9 +75,17 @@
43
75
  "layout": [
44
76
  {
45
77
  "type": "section",
46
- "title": "General",
47
- "expandable": false,
48
- "items": ["name"]
78
+ "title": "Silent Hours",
79
+ "expandable": true,
80
+ "expanded": false,
81
+ "items": ["silentHoursStart", "silentHoursEnd", "showSilentSwitch"]
82
+ },
83
+ {
84
+ "type": "section",
85
+ "title": "Optional Sensors",
86
+ "expandable": true,
87
+ "expanded": false,
88
+ "items": ["showHumidity", "showLight"]
49
89
  },
50
90
  {
51
91
  "type": "section",
package/index.js CHANGED
@@ -5,23 +5,36 @@ const noble = require('@abandonware/noble');
5
5
  let Service, Characteristic, Categories, API, PlatformAccessory;
6
6
 
7
7
  // ── Verified BLE UUIDs (confirmed via live device dump April 2026) ────────────
8
- const UUID_AUTH = '4cad343a-209a-40b7-b911-4d9b3df569b2'; // PIN auth — write once per connection
9
- const UUID_STATUS = '528b80e8-c47a-4c0a-bdf1-916a7748f412'; // RPM (offset 6 UInt16LE) + temp (offset 2 UInt16LE / 6)
10
- const UUID_SENSOR = 'a22eae12-dba8-49f3-9c69-1721dcff1d96'; // humidity (offset 0 UInt8 × 2) + lux (offset 2 UInt16BE)
11
- const UUID_CONTROL = '1488a757-35bc-4ec8-9a6b-9ecf1502778e'; // speed config: [trickle|normal|boost] as UInt16LE pairs
8
+ const UUID_AUTH = '4cad343a-209a-40b7-b911-4d9b3df569b2'; // PIN auth
9
+ const UUID_STATUS = '528b80e8-c47a-4c0a-bdf1-916a7748f412'; // RPM (offset 6) + temp (offset 2)
10
+ const UUID_SENSOR = 'a22eae12-dba8-49f3-9c69-1721dcff1d96'; // humidity (offset 0) + lux (offset 2 BE)
11
+ const UUID_CONTROL = '1488a757-35bc-4ec8-9a6b-9ecf1502778e'; // speed config [trickle|normal|boost] UInt16LE
12
+ const UUID_SILENT = 'b5836b55-57bd-433e-8480-46e4993c5ac0'; // silent hours [enabled, startH, startM, endH, endM]
12
13
 
13
14
  const MAC_PREFIX = '58:2b:db';
14
- const POLL_MS = 10000; // 10s — responsive for shower humidity spikes
15
+ const POLL_MS = 10000;
15
16
  const RECONNECT_MS = 15000;
16
17
 
18
+ function parseTime(str) {
19
+ if (!str || typeof str !== 'string') return null;
20
+ const parts = str.split(':');
21
+ if (parts.length !== 2) return null;
22
+ const hour = parseInt(parts[0], 10);
23
+ const minute = parseInt(parts[1], 10);
24
+ if (isNaN(hour) || isNaN(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
25
+ return { hour, minute };
26
+ }
27
+
28
+ function timeStr(t) {
29
+ return String(t.hour).padStart(2, '0') + ':' + String(t.minute).padStart(2, '0');
30
+ }
31
+
17
32
  module.exports = (homebridge) => {
18
33
  Service = homebridge.hap.Service;
19
34
  Characteristic = homebridge.hap.Characteristic;
20
35
  Categories = homebridge.hap.Categories;
21
36
  API = homebridge;
22
37
  PlatformAccessory = homebridge.platformAccessory;
23
-
24
- // Dynamic platform — required for Homebridge 2.0
25
38
  homebridge.registerPlatform('homebridge-pax-calima', 'PaxCalima', PaxCalimaPlatform, true);
26
39
  };
27
40
 
@@ -33,7 +46,7 @@ class PaxCalimaPlatform {
33
46
  this.api = api;
34
47
  this.registeredMACs = new Set();
35
48
  this.discoveryStarted = false;
36
- this.cachedAccessories = []; // held until BLE is ready after didFinishLaunching
49
+ this.cachedAccessories = [];
37
50
 
38
51
  this.log.info('PaxCalima platform initialised');
39
52
 
@@ -43,16 +56,12 @@ class PaxCalimaPlatform {
43
56
  this.log.info('BLE state: ' + state);
44
57
  if (state === 'poweredOn') this.startDiscovery();
45
58
  });
46
- // Catch case where noble is already poweredOn before listener fires
47
59
  setTimeout(() => {
48
60
  if (noble.state === 'poweredOn') this.startDiscovery();
49
61
  }, 3000);
50
62
  });
51
63
  }
52
64
 
53
- // Called by Homebridge for each cached accessory on startup.
54
- // Held until startDiscovery() confirms BLE is ready — prevents polling
55
- // before the BLE stack is initialised.
56
65
  configureAccessory(accessory) {
57
66
  const mac = accessory.context && accessory.context.mac;
58
67
  this.log.info('Restoring cached accessory: ' + accessory.displayName);
@@ -66,14 +75,12 @@ class PaxCalimaPlatform {
66
75
  if (this.discoveryStarted) return;
67
76
  this.discoveryStarted = true;
68
77
 
69
- // Spin up BLE handlers for all previously registered accessories
70
78
  for (const { accessory, mac } of this.cachedAccessories) {
71
79
  this.log.info('Starting handler for cached fan: ' + mac);
72
- new PaxCalimaAccessory(this.log, accessory, mac, this.pinForMac(mac));
80
+ new PaxCalimaAccessory(this.log, accessory, mac, this.fanOptions(mac));
73
81
  }
74
82
  this.cachedAccessories = [];
75
83
 
76
- // Scan for any new fans not yet registered
77
84
  this.log.info('Starting BLE discovery scan...');
78
85
  noble.startScanning([], false);
79
86
 
@@ -81,51 +88,63 @@ class PaxCalimaPlatform {
81
88
  const mac = peripheral.address.toLowerCase();
82
89
  if (!mac.startsWith(MAC_PREFIX)) return;
83
90
  if (this.registeredMACs.has(mac)) return;
84
-
85
91
  this.registeredMACs.add(mac);
86
92
  this.log.info('Discovered new fan: ' + mac);
87
-
88
93
  const uuid = API.hap.uuid.generate('pax-calima-' + mac);
89
94
  const hbAcc = new PlatformAccessory(this.nameForMac(mac), uuid, Categories.FAN);
90
95
  hbAcc.context.mac = mac;
91
-
92
- new PaxCalimaAccessory(this.log, hbAcc, mac, this.pinForMac(mac));
96
+ new PaxCalimaAccessory(this.log, hbAcc, mac, this.fanOptions(mac));
93
97
  this.api.registerPlatformAccessories('homebridge-pax-calima', 'PaxCalima', [hbAcc]);
94
98
  });
95
99
  }
96
100
 
97
101
  fanConfig(mac) { return (this.config.fans || []).find(f => f.mac && f.mac.toLowerCase() === mac) || {}; }
98
- pinForMac(mac) { return this.fanConfig(mac).pin || '012345'; }
102
+ pinForMac(mac) { return this.fanConfig(mac).pin || '012345'; }
99
103
  nameForMac(mac) { return this.fanConfig(mac).name || ('Pax Calima ' + mac.slice(-5).toUpperCase()); }
104
+
105
+ fanOptions(mac) {
106
+ return {
107
+ pin: this.pinForMac(mac),
108
+ showHumidity: this.config.showHumidity === true,
109
+ showLight: this.config.showLight === true,
110
+ showSilentSwitch: this.config.showSilentSwitch === true,
111
+ silentHoursStart: this.config.silentHoursStart || null,
112
+ silentHoursEnd: this.config.silentHoursEnd || null,
113
+ };
114
+ }
100
115
  }
101
116
 
102
117
  // ─── Accessory ────────────────────────────────────────────────────────────────
103
118
  class PaxCalimaAccessory {
104
- constructor(log, accessory, mac, pin) {
105
- this.log = log;
106
- this.accessory = accessory;
107
- this.mac = mac.toLowerCase();
108
- this.pin = pin || '012345';
119
+ constructor(log, accessory, mac, options) {
120
+ this.log = log;
121
+ this.accessory = accessory;
122
+ this.mac = mac.toLowerCase();
123
+ this.pin = options.pin || '012345';
124
+ this.showHumidity = options.showHumidity || false;
125
+ this.showLight = options.showLight || false;
126
+ this.showSilentSwitch = options.showSilentSwitch || false;
127
+ this.silentStart = parseTime(options.silentHoursStart);
128
+ this.silentEnd = parseTime(options.silentHoursEnd);
109
129
 
110
- // Sensor state — served to HomeKit between polls
111
130
  this.currentRPM = 0;
112
131
  this.humidity = 0;
113
132
  this.temperature = 20;
114
133
  this.lightLevel = 0.0001;
115
- this.silentHours = false;
134
+ this.silentHours = false; // runtime state of the silent hours switch
116
135
 
117
136
  this.pollTimer = null;
118
- this.polling = false; // guard against overlapping cycles
137
+ this.polling = false;
119
138
 
120
139
  this.setupServices();
121
- this.schedulePoll(0); // BLE is ready by the time constructor is called
140
+ this.schedulePoll(0);
122
141
  }
123
142
 
124
- // ── HomeKit services ────────────────────────────────────────────────────────
143
+ // ── Services ────────────────────────────────────────────────────────────────
125
144
  setupServices() {
126
145
  const n = this.accessory.displayName;
127
146
 
128
- // Fan — always active; speed reflects live RPM
147
+ // Fan speed — always present
129
148
  this.fanService = this.accessory.getService(Service.Fanv2)
130
149
  || this.accessory.addService(new Service.Fanv2(n, 'fan'));
131
150
  this.fanService.getCharacteristic(Characteristic.Active)
@@ -135,42 +154,67 @@ class PaxCalimaAccessory {
135
154
  .onGet(() => this.rpmToPercent(this.currentRPM))
136
155
  .onSet((v) => this.writeSpeed(v));
137
156
 
138
- // Boost — stateless single-press button
157
+ // Boost button — always present
139
158
  this.boostButton = this.accessory.getService(Service.StatelessProgrammableSwitch)
140
159
  || this.accessory.addService(new Service.StatelessProgrammableSwitch(n + ' Boost', 'boost'));
141
160
  this.boostButton.getCharacteristic(Characteristic.ProgrammableSwitchEvent)
142
161
  .setProps({ validValues: [0] });
143
162
 
144
- // Silent Hours — suppresses automatic boosting during quiet periods
145
- this.silentService = this.accessory.getServiceByUUIDAndSubType(Service.Switch, 'silent-hours')
146
- || this.accessory.addService(new Service.Switch(n + ' Silent Hours', 'silent-hours'));
147
- this.silentService.getCharacteristic(Characteristic.On)
148
- .onGet(() => this.silentHours)
149
- .onSet((on) => this.setSilentHours(on));
150
-
151
- // Sensors
152
- this.humidityService = this.accessory.getService(Service.HumiditySensor)
153
- || this.accessory.addService(new Service.HumiditySensor(n + ' Humidity', 'humidity'));
154
- this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity)
155
- .onGet(() => this.humidity);
156
-
163
+ // Temperature — always present
157
164
  this.tempService = this.accessory.getService(Service.TemperatureSensor)
158
165
  || this.accessory.addService(new Service.TemperatureSensor(n + ' Temperature', 'temperature'));
159
166
  this.tempService.getCharacteristic(Characteristic.CurrentTemperature)
160
167
  .onGet(() => this.temperature);
161
168
 
162
- this.lightService = this.accessory.getService(Service.LightSensor)
163
- || this.accessory.addService(new Service.LightSensor(n + ' Light', 'light'));
164
- this.lightService.getCharacteristic(Characteristic.CurrentAmbientLightLevel)
165
- .onGet(() => Math.max(0.0001, this.lightLevel));
169
+ // Silent Hours switch — opt-in via showSilentSwitch: true
170
+ // Coexists with config schedule — switch state takes priority on next poll
171
+ if (this.showSilentSwitch) {
172
+ this.silentService = this.accessory.getServiceByUUIDAndSubType(Service.Switch, 'silent-hours')
173
+ || this.accessory.addService(new Service.Switch(n + ' Silent Hours', 'silent-hours'));
174
+ this.silentService.getCharacteristic(Characteristic.On)
175
+ .onGet(() => this.silentHours)
176
+ .onSet((on) => {
177
+ this.silentHours = on;
178
+ this.log.info('Silent Hours manually ' + (on ? 'enabled' : 'disabled'));
179
+ });
180
+ this.log.info('Silent Hours switch enabled');
181
+ } else {
182
+ // Remove switch if it was registered in a previous version
183
+ const existing = this.accessory.getServiceByUUIDAndSubType(Service.Switch, 'silent-hours');
184
+ if (existing) {
185
+ this.accessory.removeService(existing);
186
+ this.log.info('Silent Hours switch removed — use config schedule or enable showSilentSwitch');
187
+ }
188
+ }
189
+
190
+ // Humidity — opt-in only
191
+ if (this.showHumidity) {
192
+ this.humidityService = this.accessory.getService(Service.HumiditySensor)
193
+ || this.accessory.addService(new Service.HumiditySensor(n + ' Humidity', 'humidity'));
194
+ this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity)
195
+ .onGet(() => this.humidity);
196
+ this.log.info('Humidity sensor enabled — reflects fan internal state, not real-time');
197
+ } else {
198
+ const existing = this.accessory.getService(Service.HumiditySensor);
199
+ if (existing) this.accessory.removeService(existing);
200
+ }
201
+
202
+ // Light — opt-in only
203
+ if (this.showLight) {
204
+ this.lightService = this.accessory.getService(Service.LightSensor)
205
+ || this.accessory.addService(new Service.LightSensor(n + ' Light', 'light'));
206
+ this.lightService.getCharacteristic(Characteristic.CurrentAmbientLightLevel)
207
+ .onGet(() => Math.max(0.0001, this.lightLevel));
208
+ this.log.info('Light sensor enabled — reflects fan internal state, not real-time');
209
+ } else {
210
+ const existing = this.accessory.getService(Service.LightSensor);
211
+ if (existing) this.accessory.removeService(existing);
212
+ }
166
213
 
167
214
  this.log.info('Services ready: ' + n);
168
215
  }
169
216
 
170
- // ── Poll cycle: connect → auth → read → disconnect → wait → repeat ──────────
171
- // The device's GATT server only returns fresh sensor values on a new connection.
172
- // A persistent connection returns cached values for humidity and light.
173
- // We therefore use short-lived connections — proven reliable in testing.
217
+ // ── Poll cycle ──────────────────────────────────────────────────────────────
174
218
  schedulePoll(delayMs) {
175
219
  if (this.pollTimer) clearTimeout(this.pollTimer);
176
220
  this.pollTimer = setTimeout(() => this.poll(), delayMs);
@@ -185,9 +229,8 @@ class PaxCalimaAccessory {
185
229
  peripheral = await this.findPeripheral();
186
230
  await peripheral.connectAsync();
187
231
 
188
- // Discover and cache all characteristics for this connection
189
232
  const charCache = {};
190
- const services = await peripheral.discoverServicesAsync();
233
+ const services = await peripheral.discoverServicesAsync();
191
234
  for (const svc of services) {
192
235
  const chars = await svc.discoverCharacteristicsAsync();
193
236
  for (const c of chars) charCache[c.uuid.replace(/-/g, '')] = c;
@@ -200,49 +243,97 @@ class PaxCalimaAccessory {
200
243
  pinBuf.writeUInt32LE(parseInt(this.pin, 10), 0);
201
244
  await authChar.writeAsync(pinBuf, true);
202
245
 
203
- // Read status: RPM + temperature
246
+ // Read RPM + temperature
204
247
  const statusChar = charCache[UUID_STATUS.replace(/-/g, '')];
205
248
  if (!statusChar) throw new Error('Status characteristic not found');
206
249
  const statusData = await statusChar.readAsync();
207
250
  this.currentRPM = Math.max(0, statusData.readUInt16LE(6));
208
251
  this.temperature = Math.round((statusData.readUInt16LE(2) / 6) * 10) / 10;
209
252
 
210
- // Read sensors: humidity + lux — fresh values guaranteed on each new connection
211
- const sensorChar = charCache[UUID_SENSOR.replace(/-/g, '')];
212
- if (!sensorChar) throw new Error('Sensor characteristic not found');
213
- const sensorData = await sensorChar.readAsync();
214
- this.humidity = Math.max(0, Math.min(100, sensorData.readUInt8(0) * 2));
215
- this.lightLevel = Math.max(0.0001, sensorData.readUInt16BE(2));
253
+ // Read sensor characteristic only if humidity or light is enabled
254
+ if (this.showHumidity || this.showLight) {
255
+ const sensorChar = charCache[UUID_SENSOR.replace(/-/g, '')];
256
+ if (sensorChar) {
257
+ const sensorData = await sensorChar.readAsync();
258
+ if (this.showHumidity) this.humidity = Math.max(0, Math.min(100, sensorData.readUInt8(0) * 2));
259
+ if (this.showLight) this.lightLevel = Math.max(0.0001, sensorData.readUInt16BE(2));
260
+ }
261
+ }
262
+
263
+ // Determine silent hours state:
264
+ // If switch is shown, the user's manual toggle takes priority.
265
+ // If no switch, derive from config schedule based on current time.
266
+ let silentActive = this.silentHours; // default: use switch state
267
+ if (!this.showSilentSwitch && this.silentStart && this.silentEnd) {
268
+ silentActive = this.isWithinSilentHours();
269
+ }
270
+
271
+ // Write silent hours to fan on every poll
272
+ const silentChar = charCache[UUID_SILENT.replace(/-/g, '')];
273
+ if (silentChar && (this.showSilentSwitch || (this.silentStart && this.silentEnd))) {
274
+ const buf = Buffer.alloc(5);
275
+ if (this.showSilentSwitch) {
276
+ // Switch mode: enabled flag driven by switch; use config times if set, else 00:00–00:00
277
+ buf.writeUInt8(this.silentHours ? 1 : 0, 0);
278
+ buf.writeUInt8(this.silentStart ? this.silentStart.hour : 0, 1);
279
+ buf.writeUInt8(this.silentStart ? this.silentStart.minute : 0, 2);
280
+ buf.writeUInt8(this.silentEnd ? this.silentEnd.hour : 0, 3);
281
+ buf.writeUInt8(this.silentEnd ? this.silentEnd.minute : 0, 4);
282
+ } else {
283
+ // Schedule mode: always enabled, times from config
284
+ buf.writeUInt8(1, 0);
285
+ buf.writeUInt8(this.silentStart.hour, 1);
286
+ buf.writeUInt8(this.silentStart.minute, 2);
287
+ buf.writeUInt8(this.silentEnd.hour, 3);
288
+ buf.writeUInt8(this.silentEnd.minute, 4);
289
+ }
290
+ await silentChar.writeAsync(buf, true);
291
+ }
216
292
 
217
- // Push all values to HomeKit
293
+ // Push to HomeKit
218
294
  const speed = this.rpmToPercent(this.currentRPM);
219
295
  this.fanService.getCharacteristic(Characteristic.RotationSpeed).updateValue(speed);
220
- this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity).updateValue(this.humidity);
221
296
  this.tempService.getCharacteristic(Characteristic.CurrentTemperature).updateValue(this.temperature);
222
- this.lightService.getCharacteristic(Characteristic.CurrentAmbientLightLevel).updateValue(this.lightLevel);
297
+ if (this.showSilentSwitch && this.silentService)
298
+ this.silentService.getCharacteristic(Characteristic.On).updateValue(this.silentHours);
299
+ if (this.showHumidity && this.humidityService)
300
+ this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity).updateValue(this.humidity);
301
+ if (this.showLight && this.lightService)
302
+ this.lightService.getCharacteristic(Characteristic.CurrentAmbientLightLevel).updateValue(this.lightLevel);
223
303
 
224
304
  this.log.info('Poll: RPM=' + this.currentRPM + ' Speed=' + speed +
225
- '% Hum=' + this.humidity + '% Temp=' + this.temperature +
226
- 'C Light=' + this.lightLevel + 'lx');
305
+ '% Temp=' + this.temperature + 'C' +
306
+ (this.showSilentSwitch || (this.silentStart && this.silentEnd) ? ' Silent=' + (silentActive ? 'on' : 'off') : '') +
307
+ (this.showHumidity ? ' Hum=' + this.humidity + '%' : '') +
308
+ (this.showLight ? ' Light=' + this.lightLevel + 'lx' : ''));
227
309
 
228
310
  } catch (err) {
229
311
  this.log.warn('Poll error: ' + err.message);
230
312
  } finally {
231
- // Always disconnect cleanly — required for fresh sensor reads next cycle
232
313
  if (peripheral) { try { await peripheral.disconnectAsync(); } catch (_) {} }
233
314
  this.polling = false;
234
315
  this.schedulePoll(POLL_MS);
235
316
  }
236
317
  }
237
318
 
238
- // Targeted scan — resolves as soon as our MAC is seen, removes its own listener
319
+ // Returns true if current time falls within the silent hours window
320
+ isWithinSilentHours() {
321
+ if (!this.silentStart || !this.silentEnd) return false;
322
+ const now = new Date();
323
+ const current = now.getHours() * 60 + now.getMinutes();
324
+ const start = this.silentStart.hour * 60 + this.silentStart.minute;
325
+ const end = this.silentEnd.hour * 60 + this.silentEnd.minute;
326
+ // Handle overnight spans e.g. 23:00 → 07:00
327
+ if (start > end) return current >= start || current < end;
328
+ return current >= start && current < end;
329
+ }
330
+
239
331
  findPeripheral() {
240
332
  return new Promise((resolve, reject) => {
241
333
  const timeout = setTimeout(() => {
242
334
  noble.removeListener('discover', onDiscover);
243
335
  reject(new Error('Scan timeout for ' + this.mac));
244
336
  }, 20000);
245
-
246
337
  const onDiscover = (periph) => {
247
338
  if (periph.address.toLowerCase() === this.mac) {
248
339
  clearTimeout(timeout);
@@ -250,49 +341,37 @@ class PaxCalimaAccessory {
250
341
  resolve(periph);
251
342
  }
252
343
  };
253
-
254
344
  noble.on('discover', onDiscover);
255
345
  noble.startScanning([], false);
256
346
  });
257
347
  }
258
348
 
259
349
  // ── Fan speed write ─────────────────────────────────────────────────────────
260
- // Note: in always-on wiring mode the fan's onboard controller will reassert
261
- // its own speed setpoint after each write. See README for details.
262
350
  async writeSpeed(percent) {
263
- if (this.polling) {
264
- this.log.warn('Speed write skipped — poll in progress');
265
- return;
266
- }
351
+ if (this.polling) { this.log.warn('Speed write skipped — poll in progress'); return; }
267
352
  let peripheral = null;
268
353
  try {
269
354
  peripheral = await this.findPeripheral();
270
355
  await peripheral.connectAsync();
271
-
272
356
  const charCache = {};
273
- const services = await peripheral.discoverServicesAsync();
357
+ const services = await peripheral.discoverServicesAsync();
274
358
  for (const svc of services) {
275
359
  const chars = await svc.discoverCharacteristicsAsync();
276
360
  for (const c of chars) charCache[c.uuid.replace(/-/g, '')] = c;
277
361
  }
278
-
279
362
  const authChar = charCache[UUID_AUTH.replace(/-/g, '')];
280
363
  if (!authChar) throw new Error('Auth characteristic not found');
281
364
  const pinBuf = Buffer.alloc(4);
282
365
  pinBuf.writeUInt32LE(parseInt(this.pin, 10), 0);
283
366
  await authChar.writeAsync(pinBuf, true);
284
-
285
367
  const ctrlChar = charCache[UUID_CONTROL.replace(/-/g, '')];
286
368
  if (!ctrlChar) throw new Error('Control characteristic not found');
287
-
288
- // Preserve existing trickle + boost values, update normal speed slot only
289
369
  const current = await ctrlChar.readAsync();
290
370
  const targetRPM = Math.round((percent / 100) * 2400);
291
371
  const buf = Buffer.from(current);
292
372
  buf.writeUInt16LE(targetRPM, 2);
293
373
  await ctrlChar.writeAsync(buf, true);
294
374
  this.log.info('Speed set to ' + percent + '% (' + targetRPM + ' RPM)');
295
-
296
375
  } catch (err) {
297
376
  this.log.error('writeSpeed failed: ' + err.message);
298
377
  } finally {
@@ -300,11 +379,6 @@ class PaxCalimaAccessory {
300
379
  }
301
380
  }
302
381
 
303
- async setSilentHours(on) {
304
- this.silentHours = on;
305
- this.log.info('Silent Hours ' + (on ? 'enabled' : 'disabled'));
306
- }
307
-
308
382
  rpmToPercent(rpm) {
309
383
  return Math.min(100, Math.max(0, Math.round(rpm / 25)));
310
384
  }
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "homebridge-pax-calima",
3
- "version": "2.4.1",
4
- "description": "Homebridge platform plugin for Pax Calima and Vent-Axia Svara Bluetooth LE bathroom extractor fans",
3
+ "displayName": "Vent-Axia Svara / Pax Calima",
4
+ "version": "2.4.3",
5
+ "description": "Bring your Pax Calima or Vent-Axia Svara bathroom fan into Apple Home via Bluetooth — live temperature and fan speed with Silent Hours scheduling and full automation support.",
5
6
  "main": "index.js",
6
7
  "keywords": [
7
8
  "homebridge-plugin",