homebridge-pax-calima 2.4.1 → 2.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,72 +1,86 @@
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 installed, your fan shows up in the Home app with live temperature, light level 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
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)
10
9
 
11
- All are hardware-identical, sharing the same BLE protocol and MAC prefix (`58:2b:db`).
10
+ ---
11
+
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 good to go.
15
+
16
+ **Confirmed compatible:**
17
+ - Pax Calima — sold across Scandinavia and Iberia (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.
12
22
 
13
23
  ---
14
24
 
15
- ## Features
25
+ ## What you get in the Home app
26
+
27
+ Once set up, your fan appears as a single accessory with these tiles:
16
28
 
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 |
29
+ - 🌡️ **Temperature** — live reading, updates every 10 seconds
30
+ - 💡 **Light level** — lux reading, useful for occupancy automations
31
+ - 💨 **Fan speed** — shows current RPM as a percentage
32
+ - ⚡ **Boost** — a button that triggers the fan's high-speed boost mode
33
+ - 🌙 **Silent Hours** — a switch that tells the fan not to boost overnight
34
+ - 💧 **Humidity** — available but off by default (read the note below before enabling)
35
+
36
+ > Your accessories will appear in the **Home app on your iPhone**, not in the Homebridge UI accessories tab — that's normal for child bridge plugins.
27
37
 
28
38
  ---
29
39
 
30
- ## Requirements
40
+ ## A note on humidity
31
41
 
32
- - Homebridge `^1.6.0` or `^2.0.0`
33
- - Node.js `>= 18.20.4`
34
- - A Bluetooth LE adapter on the Homebridge host
42
+ The humidity tile is **turned off by default**, and for good reason.
35
43
 
36
- ### Homebridge 2.0 Compliance
44
+ The fan's Bluetooth firmware doesn't expose humidity as a continuous live reading — it reflects the fan's own internal threshold state and updates infrequently. During our testing, the fan's Bluetooth reported 50% while a calibrated sensor in the same room read 89% after a shower. This isn't a bug in the plugin — it's how the fan's firmware works, and the Home Assistant community has observed the same behaviour.
37
45
 
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
46
+ **If you turn humidity on**, be aware that:
47
+ - Asking Siri "what's the humidity in the bathroom?" may return a stale number
48
+ - Humidity-triggered automations won't fire reliably during a shower
45
49
 
46
- ### Raspberry Pi / Homebridge OS — one-time setup
50
+ **What does work well for shower detection:** when the fan's internal sensor detects a sharp humidity rise, it boosts to near full speed automatically. A `Fan Speed > 75%` automation in Apple Home is a reliable, practical trigger for "someone's in the shower."
47
51
 
48
- The `noble` BLE library requires elevated network capabilities. Run **once** after installation on the **Homebridge terminal**:
52
+ If you still want to enable humidity — perhaps for settled ambient readings rather than real-time monitoring — you can turn it on in the plugin settings with `showHumidity: true`.
53
+
54
+ ---
55
+
56
+ ## Before you start
57
+
58
+ You'll need:
59
+ - Homebridge `^1.6.0` or `^2.0.0`
60
+ - Node.js `18.20.4` or later
61
+ - A Bluetooth adapter on your Homebridge host (the Raspberry Pi 4's built-in adapter works well)
62
+
63
+ **If you're running Homebridge OS on a Raspberry Pi**, there's one extra step after installation. The Bluetooth library needs a small permissions tweak — run this once in the **Homebridge terminal**:
49
64
 
50
65
  ```bash
51
66
  sudo setcap cap_net_raw+eip /opt/homebridge/bin/node
52
67
  ```
53
68
 
54
- If Bluetooth shows as `poweredOff`:
69
+ If Bluetooth shows as off after that:
55
70
 
56
71
  ```bash
57
72
  sudo rfkill unblock bluetooth
58
73
  ```
59
74
 
60
- If the above does not resolve the issue, a full Pi power cycle may be required to reinitialise the BLE stack cleanly.
75
+ And if things still aren't working after both of those — a full **power cycle** of the Pi (unplug, wait, replug) tends to sort it. A software restart alone sometimes isn't enough to reinitialise the Bluetooth stack cleanly.
61
76
 
62
77
  ---
63
78
 
64
79
  ## Installation
65
80
 
66
- ### Via Homebridge UI (recommended)
67
- Search for **Pax Calima** or **Svara** and click Install.
81
+ Search for **Pax Calima** or **Svara** in the Homebridge UI and click Install — that's it.
68
82
 
69
- ### Via Terminal
83
+ Or via terminal:
70
84
  ```bash
71
85
  npm install -g homebridge-pax-calima
72
86
  ```
@@ -75,12 +89,15 @@ npm install -g homebridge-pax-calima
75
89
 
76
90
  ## Configuration
77
91
 
78
- Auto-discovery requires **no configuration**. To set a custom name or PIN per fan:
92
+ **You don't need to configure anything.** The plugin finds your fan automatically by scanning for Bluetooth devices with the right MAC prefix and registers it in HomeKit.
93
+
94
+ If you want to give your fan a custom name, or if you've set a non-default PIN in the official app, you can specify those in settings:
79
95
 
80
96
  ```json
81
97
  {
82
98
  "platform": "PaxCalima",
83
99
  "name": "Pax Calima / Vent-Axia Svara",
100
+ "showHumidity": false,
84
101
  "fans": [
85
102
  {
86
103
  "name": "Wetroom Fan",
@@ -91,84 +108,85 @@ Auto-discovery requires **no configuration**. To set a custom name or PIN per fa
91
108
  }
92
109
  ```
93
110
 
94
- The `fans` array is optional. Default PIN is `012345`.
95
-
96
- ---
97
-
98
- ## Accessories in Apple Home
99
-
100
- Each fan appears as a single accessory with:
101
-
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
108
-
109
- > Accessories appear in the **Home app**, not the Homebridge UI accessories tab. This is correct behaviour for child bridge plugins.
111
+ | Setting | Default | What it does |
112
+ |---|---|---|
113
+ | `showHumidity` | `false` | Enables the humidity tile — read the note above first |
114
+ | `fans[].name` | Auto-generated | Sets a friendly name for a specific fan |
115
+ | `fans[].mac` | — | Only needed if you want to assign a name or custom PIN |
116
+ | `fans[].pin` | `012345` | The 6-digit PIN — only change if you set a custom one in the official app |
110
117
 
111
118
  ---
112
119
 
113
- ## Fan Speed Control — Wiring Mode
120
+ ## Fan speed control — it depends on how your fan is wired
114
121
 
115
- The fan supports two installation wiring modes selected at installation time:
122
+ The Svara and Calima can be installed in two different modes, and which one you have affects what the speed slider does:
116
123
 
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.
124
+ **Intermittent mode** — the fan is off by default and spins up when it detects humidity, light, or a timer trigger. In this mode, the speed slider in Apple Home controls the fan directly.
118
125
 
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.
126
+ **Always-on (trickle) mode** — the fan runs continuously at a speed managed by its own onboard controller. In this mode, you can send speed commands via Bluetooth and they are accepted — but the fan resets to its own setpoint within seconds. The slider will show you the current speed accurately, but won't be able to change it.
120
127
 
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.
128
+ If you're not sure which mode you have, check whether your fan is ever fully off. If it's always running (even quietly), you're in always-on mode.
122
129
 
123
- To enable full speed control, switch to intermittent wiring mode per the fan's installation guide.
130
+ In either mode, the temperature, light and fan speed automations work perfectly.
124
131
 
125
132
  ---
126
133
 
127
- ## Sensor Notes
134
+ ## Something not working?
128
135
 
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.
136
+ **Fan not showing up in Home**
137
+ Try `hciconfig` in the Homebridge terminal — you should see `UP RUNNING` next to `hci0`. If it shows `RF-KILL`, run `sudo rfkill unblock bluetooth`. If still nothing, unplug the Pi, wait 10 seconds, and plug it back in.
130
138
 
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.
139
+ **setcap not sticking / BLE still powered off**
140
+ Make sure you're running setcap against the right binary — on Homebridge OS it's `/opt/homebridge/bin/node`, not whatever `which node` returns.
132
141
 
133
- For lux-based automations use **Eve** or **Hesperus**, which support lux thresholds synced back to HomeKit.
142
+ **Wrong PIN / can't authenticate**
143
+ The default PIN is `012345`. If you changed it in the official Pax or Vent-Axia app, add your fan to the `fans` config array with the correct PIN.
134
144
 
135
- ---
145
+ **Speed slider does nothing**
146
+ Your fan is in always-on mode — see the wiring section above. Everything else still works.
136
147
 
137
- ## Troubleshooting
148
+ **Humidity stuck at the same number**
149
+ This is expected — see the humidity note above. If you'd prefer not to see it, set `showHumidity: false` (or just don't enable it in the first place).
138
150
 
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.
151
+ **Accessories not appearing in the Home app**
152
+ Go to the plugin settings in Homebridge UI, choose Remove Accessory Cache, and restart. They'll re-appear within a few seconds of the plugin starting.
140
153
 
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.
154
+ ---
142
155
 
143
- **Speed slider has no effect** — fan is wired in always-on mode. See wiring note above. All sensors work normally.
156
+ ## Homebridge 2.0 compliance
144
157
 
145
- **Accessories not in Home app** — clear accessory cache via Homebridge UI → plugin settings → Remove Accessory Cache, then restart.
158
+ For those who care about the technical details — this plugin meets all Homebridge 2.0 requirements:
159
+ - Registered as a dynamic platform
160
+ - Waits for `didFinishLaunching` before any BLE or accessory work begins
161
+ - All errors are caught and logged — no unhandled exceptions
162
+ - No analytics, no tracking, no post-install scripts
163
+ - Cached accessories restored cleanly without re-registration
164
+ - Compatible with Node.js 18, 20 and 22
146
165
 
147
166
  ---
148
167
 
149
- ## Release Notes
168
+ ## Release notes
169
+
170
+ ### 2.4.2
171
+ - Humidity sensor is now **off by default** — you have to explicitly enable it with `showHumidity: true`. This follows confirmed testing that showed the fan's BLE firmware updates the humidity characteristic infrequently (we measured 50% while a reference sensor read 89% in the same room post-shower). Exposing it by default risked misleading Siri queries and unreliable automations. If you had it enabled previously, it will be removed from your accessory automatically — you can re-enable it knowingly in settings.
172
+ - README rewritten to be more human — less spec sheet, more "here's what to expect and why"
173
+ - Fan speed documented as the practical shower/occupancy trigger
150
174
 
151
175
  ### 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
176
+ - Fixed sensors returning stale values — the device's GATT server only refreshes humidity and light on a new connection, so we now connect → read → disconnect on each poll rather than holding a persistent connection
177
+ - Eliminated intermittent `Characteristic not found` errors
158
178
 
159
179
  ### 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
180
+ - Rebuilt connection architecture — persistent connection with cached characteristics. Fast and reliable for RPM and temperature; subsequently found that humidity returns stale values on persistent connections (fixed in 2.4.1)
161
181
 
162
- ### 2.3.2
182
+ ### 2.3.x
183
+ - Identified the correct control characteristic for fan speed writes
163
184
  - Documented always-on vs intermittent wiring mode behaviour
164
185
 
165
- ### 2.3.1
166
- - Identified correct control characteristic; write diagnostics added
167
-
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
186
+ ### 2.2.1 / 2.1.0
187
+ - Correct sensor UUIDs and byte mappings confirmed via live device testing
188
+ - Homebridge 2.0 initialisation fixes
189
+ - Duplicate accessory registration on restart resolved
172
190
 
173
191
  ### 1.0.x
174
- - Initial working releases
192
+ - Initial releases
@@ -10,9 +10,15 @@
10
10
  "type": "string",
11
11
  "default": "Pax Calima / Vent-Axia Svara"
12
12
  },
13
+ "showHumidity": {
14
+ "title": "Show Humidity Sensor",
15
+ "type": "boolean",
16
+ "default": false,
17
+ "description": "⚠️ The fan's humidity characteristic updates infrequently due to a firmware limitation — it reflects the fan's internal settled state, not a real-time reading. It is NOT suitable for use with Siri queries or time-sensitive automations. Only enable this if you understand and accept this limitation. Disabled by default."
18
+ },
13
19
  "fans": {
14
20
  "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.",
21
+ "description": "Optional. Leave empty to use auto-discovery. Add entries here only to set a custom name or PIN per fan.",
16
22
  "type": "array",
17
23
  "items": {
18
24
  "type": "object",
@@ -26,13 +32,13 @@
26
32
  "title": "MAC Address",
27
33
  "type": "string",
28
34
  "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."
35
+ "description": "MAC address of the fan (e.g. 58:2b:db:xx:xx:xx)"
30
36
  },
31
37
  "pin": {
32
38
  "title": "PIN Code",
33
39
  "type": "string",
34
40
  "default": "012345",
35
- "description": "6-digit PIN. Only change this if you set a custom PIN in the official Pax / Vent-Axia app."
41
+ "description": "6-digit PIN. Only change if you set a custom PIN in the official app."
36
42
  }
37
43
  },
38
44
  "required": ["name", "mac"]
@@ -45,7 +51,7 @@
45
51
  "type": "section",
46
52
  "title": "General",
47
53
  "expandable": false,
48
- "items": ["name"]
54
+ "items": ["name", "showHumidity"]
49
55
  },
50
56
  {
51
57
  "type": "section",
package/index.js CHANGED
@@ -8,10 +8,10 @@ let Service, Characteristic, Categories, API, PlatformAccessory;
8
8
  const UUID_AUTH = '4cad343a-209a-40b7-b911-4d9b3df569b2'; // PIN auth — write once per connection
9
9
  const UUID_STATUS = '528b80e8-c47a-4c0a-bdf1-916a7748f412'; // RPM (offset 6 UInt16LE) + temp (offset 2 UInt16LE / 6)
10
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
11
+ const UUID_CONTROL = '1488a757-35bc-4ec8-9a6b-9ecf1502778e'; // speed config: [trickle|normal|boost] UInt16LE pairs
12
12
 
13
13
  const MAC_PREFIX = '58:2b:db';
14
- const POLL_MS = 10000; // 10s — responsive for shower humidity spikes
14
+ const POLL_MS = 10000;
15
15
  const RECONNECT_MS = 15000;
16
16
 
17
17
  module.exports = (homebridge) => {
@@ -20,8 +20,6 @@ module.exports = (homebridge) => {
20
20
  Categories = homebridge.hap.Categories;
21
21
  API = homebridge;
22
22
  PlatformAccessory = homebridge.platformAccessory;
23
-
24
- // Dynamic platform — required for Homebridge 2.0
25
23
  homebridge.registerPlatform('homebridge-pax-calima', 'PaxCalima', PaxCalimaPlatform, true);
26
24
  };
27
25
 
@@ -33,7 +31,7 @@ class PaxCalimaPlatform {
33
31
  this.api = api;
34
32
  this.registeredMACs = new Set();
35
33
  this.discoveryStarted = false;
36
- this.cachedAccessories = []; // held until BLE is ready after didFinishLaunching
34
+ this.cachedAccessories = [];
37
35
 
38
36
  this.log.info('PaxCalima platform initialised');
39
37
 
@@ -43,16 +41,12 @@ class PaxCalimaPlatform {
43
41
  this.log.info('BLE state: ' + state);
44
42
  if (state === 'poweredOn') this.startDiscovery();
45
43
  });
46
- // Catch case where noble is already poweredOn before listener fires
47
44
  setTimeout(() => {
48
45
  if (noble.state === 'poweredOn') this.startDiscovery();
49
46
  }, 3000);
50
47
  });
51
48
  }
52
49
 
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
50
  configureAccessory(accessory) {
57
51
  const mac = accessory.context && accessory.context.mac;
58
52
  this.log.info('Restoring cached accessory: ' + accessory.displayName);
@@ -66,14 +60,12 @@ class PaxCalimaPlatform {
66
60
  if (this.discoveryStarted) return;
67
61
  this.discoveryStarted = true;
68
62
 
69
- // Spin up BLE handlers for all previously registered accessories
70
63
  for (const { accessory, mac } of this.cachedAccessories) {
71
64
  this.log.info('Starting handler for cached fan: ' + mac);
72
- new PaxCalimaAccessory(this.log, accessory, mac, this.pinForMac(mac));
65
+ new PaxCalimaAccessory(this.log, accessory, mac, this.pinForMac(mac), this.showHumidity());
73
66
  }
74
67
  this.cachedAccessories = [];
75
68
 
76
- // Scan for any new fans not yet registered
77
69
  this.log.info('Starting BLE discovery scan...');
78
70
  noble.startScanning([], false);
79
71
 
@@ -89,25 +81,26 @@ class PaxCalimaPlatform {
89
81
  const hbAcc = new PlatformAccessory(this.nameForMac(mac), uuid, Categories.FAN);
90
82
  hbAcc.context.mac = mac;
91
83
 
92
- new PaxCalimaAccessory(this.log, hbAcc, mac, this.pinForMac(mac));
84
+ new PaxCalimaAccessory(this.log, hbAcc, mac, this.pinForMac(mac), this.showHumidity());
93
85
  this.api.registerPlatformAccessories('homebridge-pax-calima', 'PaxCalima', [hbAcc]);
94
86
  });
95
87
  }
96
88
 
97
- fanConfig(mac) { return (this.config.fans || []).find(f => f.mac && f.mac.toLowerCase() === mac) || {}; }
98
- pinForMac(mac) { return this.fanConfig(mac).pin || '012345'; }
99
- nameForMac(mac) { return this.fanConfig(mac).name || ('Pax Calima ' + mac.slice(-5).toUpperCase()); }
89
+ fanConfig(mac) { return (this.config.fans || []).find(f => f.mac && f.mac.toLowerCase() === mac) || {}; }
90
+ pinForMac(mac) { return this.fanConfig(mac).pin || '012345'; }
91
+ nameForMac(mac) { return this.fanConfig(mac).name || ('Pax Calima ' + mac.slice(-5).toUpperCase()); }
92
+ showHumidity() { return this.config.showHumidity === true; }
100
93
  }
101
94
 
102
95
  // ─── Accessory ────────────────────────────────────────────────────────────────
103
96
  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';
97
+ constructor(log, accessory, mac, pin, showHumidity) {
98
+ this.log = log;
99
+ this.accessory = accessory;
100
+ this.mac = mac.toLowerCase();
101
+ this.pin = pin || '012345';
102
+ this.showHumidity = showHumidity;
109
103
 
110
- // Sensor state — served to HomeKit between polls
111
104
  this.currentRPM = 0;
112
105
  this.humidity = 0;
113
106
  this.temperature = 20;
@@ -115,13 +108,12 @@ class PaxCalimaAccessory {
115
108
  this.silentHours = false;
116
109
 
117
110
  this.pollTimer = null;
118
- this.polling = false; // guard against overlapping cycles
111
+ this.polling = false;
119
112
 
120
113
  this.setupServices();
121
- this.schedulePoll(0); // BLE is ready by the time constructor is called
114
+ this.schedulePoll(0);
122
115
  }
123
116
 
124
- // ── HomeKit services ────────────────────────────────────────────────────────
125
117
  setupServices() {
126
118
  const n = this.accessory.displayName;
127
119
 
@@ -141,36 +133,43 @@ class PaxCalimaAccessory {
141
133
  this.boostButton.getCharacteristic(Characteristic.ProgrammableSwitchEvent)
142
134
  .setProps({ validValues: [0] });
143
135
 
144
- // Silent Hours — suppresses automatic boosting during quiet periods
136
+ // Silent Hours
145
137
  this.silentService = this.accessory.getServiceByUUIDAndSubType(Service.Switch, 'silent-hours')
146
138
  || this.accessory.addService(new Service.Switch(n + ' Silent Hours', 'silent-hours'));
147
139
  this.silentService.getCharacteristic(Characteristic.On)
148
140
  .onGet(() => this.silentHours)
149
141
  .onSet((on) => this.setSilentHours(on));
150
142
 
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
-
143
+ // Temperature
157
144
  this.tempService = this.accessory.getService(Service.TemperatureSensor)
158
145
  || this.accessory.addService(new Service.TemperatureSensor(n + ' Temperature', 'temperature'));
159
146
  this.tempService.getCharacteristic(Characteristic.CurrentTemperature)
160
147
  .onGet(() => this.temperature);
161
148
 
149
+ // Light
162
150
  this.lightService = this.accessory.getService(Service.LightSensor)
163
151
  || this.accessory.addService(new Service.LightSensor(n + ' Light', 'light'));
164
152
  this.lightService.getCharacteristic(Characteristic.CurrentAmbientLightLevel)
165
153
  .onGet(() => Math.max(0.0001, this.lightLevel));
166
154
 
155
+ // Humidity — optional, disabled by default due to firmware update frequency limitations
156
+ // User must explicitly enable via showHumidity: true in config
157
+ if (this.showHumidity) {
158
+ this.humidityService = this.accessory.getService(Service.HumiditySensor)
159
+ || this.accessory.addService(new Service.HumiditySensor(n + ' Humidity', 'humidity'));
160
+ this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity)
161
+ .onGet(() => this.humidity);
162
+ this.log.info('Humidity sensor enabled — note: reflects fan internal settled state, not real-time');
163
+ } else {
164
+ // Remove humidity service if it was previously registered and user has since disabled it
165
+ const existing = this.accessory.getService(Service.HumiditySensor);
166
+ if (existing) this.accessory.removeService(existing);
167
+ this.log.info('Humidity sensor disabled (showHumidity not set)');
168
+ }
169
+
167
170
  this.log.info('Services ready: ' + n);
168
171
  }
169
172
 
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.
174
173
  schedulePoll(delayMs) {
175
174
  if (this.pollTimer) clearTimeout(this.pollTimer);
176
175
  this.pollTimer = setTimeout(() => this.poll(), delayMs);
@@ -187,7 +186,7 @@ class PaxCalimaAccessory {
187
186
 
188
187
  // Discover and cache all characteristics for this connection
189
188
  const charCache = {};
190
- const services = await peripheral.discoverServicesAsync();
189
+ const services = await peripheral.discoverServicesAsync();
191
190
  for (const svc of services) {
192
191
  const chars = await svc.discoverCharacteristicsAsync();
193
192
  for (const c of chars) charCache[c.uuid.replace(/-/g, '')] = c;
@@ -200,49 +199,59 @@ class PaxCalimaAccessory {
200
199
  pinBuf.writeUInt32LE(parseInt(this.pin, 10), 0);
201
200
  await authChar.writeAsync(pinBuf, true);
202
201
 
203
- // Read status: RPM + temperature
202
+ // Read status: RPM + temperature — updates live on every connection
204
203
  const statusChar = charCache[UUID_STATUS.replace(/-/g, '')];
205
204
  if (!statusChar) throw new Error('Status characteristic not found');
206
205
  const statusData = await statusChar.readAsync();
207
206
  this.currentRPM = Math.max(0, statusData.readUInt16LE(6));
208
207
  this.temperature = Math.round((statusData.readUInt16LE(2) / 6) * 10) / 10;
209
208
 
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));
209
+ // Read sensor characteristic only if humidity is enabled
210
+ // The device updates this characteristic infrequently (firmware limitation)
211
+ if (this.showHumidity) {
212
+ const sensorChar = charCache[UUID_SENSOR.replace(/-/g, '')];
213
+ if (sensorChar) {
214
+ const sensorData = await sensorChar.readAsync();
215
+ this.humidity = Math.max(0, Math.min(100, sensorData.readUInt8(0) * 2));
216
+ this.lightLevel = Math.max(0.0001, sensorData.readUInt16BE(2));
217
+ }
218
+ } else {
219
+ // Read lux from sensor characteristic regardless — only skip humidity update
220
+ const sensorChar = charCache[UUID_SENSOR.replace(/-/g, '')];
221
+ if (sensorChar) {
222
+ const sensorData = await sensorChar.readAsync();
223
+ this.lightLevel = Math.max(0.0001, sensorData.readUInt16BE(2));
224
+ }
225
+ }
216
226
 
217
- // Push all values to HomeKit
227
+ // Push to HomeKit
218
228
  const speed = this.rpmToPercent(this.currentRPM);
219
229
  this.fanService.getCharacteristic(Characteristic.RotationSpeed).updateValue(speed);
220
- this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity).updateValue(this.humidity);
221
230
  this.tempService.getCharacteristic(Characteristic.CurrentTemperature).updateValue(this.temperature);
222
231
  this.lightService.getCharacteristic(Characteristic.CurrentAmbientLightLevel).updateValue(this.lightLevel);
232
+ if (this.showHumidity && this.humidityService) {
233
+ this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity).updateValue(this.humidity);
234
+ }
223
235
 
224
236
  this.log.info('Poll: RPM=' + this.currentRPM + ' Speed=' + speed +
225
- '% Hum=' + this.humidity + '% Temp=' + this.temperature +
226
- 'C Light=' + this.lightLevel + 'lx');
237
+ '% Temp=' + this.temperature + 'C Light=' + this.lightLevel + 'lx' +
238
+ (this.showHumidity ? ' Hum=' + this.humidity + '%' : ''));
227
239
 
228
240
  } catch (err) {
229
241
  this.log.warn('Poll error: ' + err.message);
230
242
  } finally {
231
- // Always disconnect cleanly — required for fresh sensor reads next cycle
232
243
  if (peripheral) { try { await peripheral.disconnectAsync(); } catch (_) {} }
233
244
  this.polling = false;
234
245
  this.schedulePoll(POLL_MS);
235
246
  }
236
247
  }
237
248
 
238
- // Targeted scan — resolves as soon as our MAC is seen, removes its own listener
239
249
  findPeripheral() {
240
250
  return new Promise((resolve, reject) => {
241
251
  const timeout = setTimeout(() => {
242
252
  noble.removeListener('discover', onDiscover);
243
253
  reject(new Error('Scan timeout for ' + this.mac));
244
254
  }, 20000);
245
-
246
255
  const onDiscover = (periph) => {
247
256
  if (periph.address.toLowerCase() === this.mac) {
248
257
  clearTimeout(timeout);
@@ -250,49 +259,36 @@ class PaxCalimaAccessory {
250
259
  resolve(periph);
251
260
  }
252
261
  };
253
-
254
262
  noble.on('discover', onDiscover);
255
263
  noble.startScanning([], false);
256
264
  });
257
265
  }
258
266
 
259
- // ── 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
267
  async writeSpeed(percent) {
263
- if (this.polling) {
264
- this.log.warn('Speed write skipped — poll in progress');
265
- return;
266
- }
268
+ if (this.polling) { this.log.warn('Speed write skipped — poll in progress'); return; }
267
269
  let peripheral = null;
268
270
  try {
269
271
  peripheral = await this.findPeripheral();
270
272
  await peripheral.connectAsync();
271
-
272
273
  const charCache = {};
273
- const services = await peripheral.discoverServicesAsync();
274
+ const services = await peripheral.discoverServicesAsync();
274
275
  for (const svc of services) {
275
276
  const chars = await svc.discoverCharacteristicsAsync();
276
277
  for (const c of chars) charCache[c.uuid.replace(/-/g, '')] = c;
277
278
  }
278
-
279
279
  const authChar = charCache[UUID_AUTH.replace(/-/g, '')];
280
280
  if (!authChar) throw new Error('Auth characteristic not found');
281
281
  const pinBuf = Buffer.alloc(4);
282
282
  pinBuf.writeUInt32LE(parseInt(this.pin, 10), 0);
283
283
  await authChar.writeAsync(pinBuf, true);
284
-
285
284
  const ctrlChar = charCache[UUID_CONTROL.replace(/-/g, '')];
286
285
  if (!ctrlChar) throw new Error('Control characteristic not found');
287
-
288
- // Preserve existing trickle + boost values, update normal speed slot only
289
286
  const current = await ctrlChar.readAsync();
290
287
  const targetRPM = Math.round((percent / 100) * 2400);
291
288
  const buf = Buffer.from(current);
292
289
  buf.writeUInt16LE(targetRPM, 2);
293
290
  await ctrlChar.writeAsync(buf, true);
294
291
  this.log.info('Speed set to ' + percent + '% (' + targetRPM + ' RPM)');
295
-
296
292
  } catch (err) {
297
293
  this.log.error('writeSpeed failed: ' + err.message);
298
294
  } finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-pax-calima",
3
- "version": "2.4.1",
3
+ "version": "2.4.2",
4
4
  "description": "Homebridge platform plugin for Pax Calima and Vent-Axia Svara Bluetooth LE bathroom extractor fans",
5
5
  "main": "index.js",
6
6
  "keywords": [