homebridge-pax-calima 2.4.0 → 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,58 +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 and share 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:
28
+
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)
16
35
 
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 switch — suppresses automatic boosting during quiet periods |
26
- | **Automations** | Full HomeKit automation support on all sensors |
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
 
42
+ The humidity tile is **turned off by default**, and for good reason.
43
+
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.
45
+
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
49
+
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."
51
+
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:
32
59
  - Homebridge `^1.6.0` or `^2.0.0`
33
- - Node.js `>= 18`
34
- - A Bluetooth LE adapter on the Homebridge host
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)
35
62
 
36
- ### Raspberry Pi / Homebridge OS — one-time setup
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**:
37
64
 
38
65
  ```bash
39
66
  sudo setcap cap_net_raw+eip /opt/homebridge/bin/node
40
67
  ```
41
68
 
42
- If Bluetooth shows as `poweredOff`:
69
+ If Bluetooth shows as off after that:
43
70
 
44
71
  ```bash
45
72
  sudo rfkill unblock bluetooth
46
73
  ```
47
74
 
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.
76
+
48
77
  ---
49
78
 
50
79
  ## Installation
51
80
 
52
- ### Via Homebridge UI (recommended)
53
- 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.
54
82
 
55
- ### Via Terminal
83
+ Or via terminal:
56
84
  ```bash
57
85
  npm install -g homebridge-pax-calima
58
86
  ```
@@ -61,12 +89,15 @@ npm install -g homebridge-pax-calima
61
89
 
62
90
  ## Configuration
63
91
 
64
- No configuration needed for auto-discovery. 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:
65
95
 
66
96
  ```json
67
97
  {
68
98
  "platform": "PaxCalima",
69
99
  "name": "Pax Calima / Vent-Axia Svara",
100
+ "showHumidity": false,
70
101
  "fans": [
71
102
  {
72
103
  "name": "Wetroom Fan",
@@ -77,79 +108,85 @@ No configuration needed for auto-discovery. To set a custom name or PIN per fan:
77
108
  }
78
109
  ```
79
110
 
80
- The `fans` array is optional. Default PIN is `012345`.
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 |
81
117
 
82
118
  ---
83
119
 
84
- ## Accessories in Apple Home
120
+ ## Fan speed control — it depends on how your fan is wired
85
121
 
86
- Each fan appears as a single accessory with:
122
+ The Svara and Calima can be installed in two different modes, and which one you have affects what the speed slider does:
87
123
 
88
- - **Fan** — live speed display; slider available (see wiring note)
89
- - **Boost** — single-press programmable button
90
- - **Silent Hours** — on/off switch
91
- - **Humidity** — % relative humidity
92
- - **Temperature** — °C
93
- - **Light** — lux
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.
94
125
 
95
- > Accessories appear in the **Home app**, not the Homebridge UI accessories tab. This is correct for child bridge plugins.
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.
96
127
 
97
- ---
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.
98
129
 
99
- ## Fan Speed Control — Wiring Mode
130
+ In either mode, the temperature, light and fan speed automations work perfectly.
100
131
 
101
- The fan supports two installation wiring modes:
132
+ ---
102
133
 
103
- **Intermittent mode** — fan is off by default, triggered by sensors or timer. BLE speed commands work fully; the slider controls the fan directly.
134
+ ## Something not working?
104
135
 
105
- **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 setpoint immediately. The slider accurately reflects current RPM but cannot override the onboard speed.
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.
106
138
 
107
- If your fan is in always-on mode, sensors and automations remain fully functional — for bathroom and wetroom use these are the primary value of the integration. To enable full speed control, switch to intermittent wiring mode per the installation guide.
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.
108
141
 
109
- ---
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.
110
144
 
111
- ## Sensor Notes
145
+ **Speed slider does nothing**
146
+ Your fan is in always-on mode — see the wiring section above. Everything else still works.
112
147
 
113
- The light sensor detects occupancy via movement and shadows rather than acting as a precision lux meter. Small variances from a reference device are expected and normal.
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).
114
150
 
115
- For lux-based automations, use **Eve** or **Hesperus** which support lux thresholds synced to HomeKit.
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.
116
153
 
117
154
  ---
118
155
 
119
- ## Troubleshooting
120
-
121
- **Fan not discovered** — run `hciconfig` on Pi; confirm `UP RUNNING`. If `RF-KILL`, run `sudo rfkill unblock bluetooth`. Confirm `setcap` was applied to `/opt/homebridge/bin/node`.
156
+ ## Homebridge 2.0 compliance
122
157
 
123
- **Authentication failed** — default PIN is `012345`. If changed in the official app, add the fan to the `fans` config with the correct PIN.
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
124
165
 
125
- **Speed slider has no effect** — fan is wired in always-on mode. See wiring note above. All sensors work normally.
166
+ ---
126
167
 
127
- **Accessories not in Home app** — clear accessory cache via Homebridge UI → plugin settings → Remove Accessory Cache, then restart.
168
+ ## Release notes
128
169
 
129
- ---
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
130
174
 
131
- ## Release Notes
175
+ ### 2.4.1
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
132
178
 
133
179
  ### 2.4.0
134
- - **Rebuilt:** Connection architecture reverted to persistent BLE connection model — connect once, cache all characteristics, poll with direct `readAsync()` calls. Eliminates the per-poll scan/connect/auth/discover overhead that made 2.x sluggish and error-prone
135
- - **Improved:** Both sensor characteristics now read in parallel via `Promise.all` — faster polls
136
- - **Improved:** Characteristics cached once per connection — no repeated service discovery
137
- - **Improved:** Auth performed once per connection, not per poll
138
- - **Improved:** Automatic reconnection on unexpected disconnect with clean cache flush
139
- - **Fixed:** `Characteristic not found` intermittent errors eliminated — characteristic lookup now uses pre-cached map rather than repeated discovery
140
- - **Fixed:** `writeSpeed` uses cached control characteristic and preserves trickle/boost values
141
- - **Fixed:** Default accessory name changed to `Pax Calima` for international compatibility
142
-
143
- ### 2.3.2
144
- - Documented always-on vs intermittent wiring mode behaviour for speed control
145
-
146
- ### 2.3.1
147
- - Identified correct control characteristic; added write diagnostics
148
-
149
- ### 2.3.0 / 2.2.1 / 2.1.0
150
- - Sensor characteristic UUIDs and byte mappings confirmed via live device
151
- - BLE initialisation fixes for Homebridge 2.0
152
- - Duplicate accessory registration resolved
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)
181
+
182
+ ### 2.3.x
183
+ - Identified the correct control characteristic for fan speed writes
184
+ - Documented always-on vs intermittent wiring mode behaviour
185
+
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
153
190
 
154
191
  ### 1.0.x
155
- - 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
@@ -4,14 +4,14 @@ const noble = require('@abandonware/noble');
4
4
 
5
5
  let Service, Characteristic, Categories, API, PlatformAccessory;
6
6
 
7
- // ── Verified BLE UUIDs (confirmed via live device dump) ───────────────────────
8
- const UUID_AUTH = '4cad343a-209a-40b7-b911-4d9b3df569b2'; // PIN auth — write once
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 — 6 bytes: trickle/normal/boost
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] 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,40 +20,33 @@ module.exports = (homebridge) => {
20
20
  Categories = homebridge.hap.Categories;
21
21
  API = homebridge;
22
22
  PlatformAccessory = homebridge.platformAccessory;
23
-
24
- // true = dynamic platform, required for Homebridge 2.0
25
23
  homebridge.registerPlatform('homebridge-pax-calima', 'PaxCalima', PaxCalimaPlatform, true);
26
24
  };
27
25
 
28
26
  // ─── Platform ─────────────────────────────────────────────────────────────────
29
27
  class PaxCalimaPlatform {
30
28
  constructor(log, config, api) {
31
- this.log = log;
32
- this.config = config || {};
33
- this.api = api;
34
- this.registeredMACs = new Set();
35
- this.discoveryStarted = false;
36
- this.cachedAccessories = []; // held until BLE is ready after didFinishLaunching
29
+ this.log = log;
30
+ this.config = config || {};
31
+ this.api = api;
32
+ this.registeredMACs = new Set();
33
+ this.discoveryStarted = false;
34
+ this.cachedAccessories = [];
37
35
 
38
36
  this.log.info('PaxCalima platform initialised');
39
37
 
40
38
  this.api.on('didFinishLaunching', () => {
41
39
  this.log.info('Homebridge finished launching — starting BLE');
42
-
43
40
  noble.on('stateChange', (state) => {
44
41
  this.log.info('BLE state: ' + state);
45
42
  if (state === 'poweredOn') this.startDiscovery();
46
43
  });
47
-
48
- // Catch case where noble is already poweredOn before listener fires
49
44
  setTimeout(() => {
50
45
  if (noble.state === 'poweredOn') this.startDiscovery();
51
46
  }, 3000);
52
47
  });
53
48
  }
54
49
 
55
- // Called by Homebridge for each cached accessory on startup.
56
- // We hold them until startDiscovery() confirms BLE is ready.
57
50
  configureAccessory(accessory) {
58
51
  const mac = accessory.context && accessory.context.mac;
59
52
  this.log.info('Restoring cached accessory: ' + accessory.displayName);
@@ -67,14 +60,12 @@ class PaxCalimaPlatform {
67
60
  if (this.discoveryStarted) return;
68
61
  this.discoveryStarted = true;
69
62
 
70
- // Spin up handlers for all cached (previously registered) accessories
71
63
  for (const { accessory, mac } of this.cachedAccessories) {
72
- this.log.info('Connecting to cached fan: ' + mac);
73
- new PaxCalimaAccessory(this.log, accessory, mac, this.pinForMac(mac));
64
+ this.log.info('Starting handler for cached fan: ' + mac);
65
+ new PaxCalimaAccessory(this.log, accessory, mac, this.pinForMac(mac), this.showHumidity());
74
66
  }
75
67
  this.cachedAccessories = [];
76
68
 
77
- // Scan for any new fans not yet registered
78
69
  this.log.info('Starting BLE discovery scan...');
79
70
  noble.startScanning([], false);
80
71
 
@@ -90,46 +81,43 @@ class PaxCalimaPlatform {
90
81
  const hbAcc = new PlatformAccessory(this.nameForMac(mac), uuid, Categories.FAN);
91
82
  hbAcc.context.mac = mac;
92
83
 
93
- new PaxCalimaAccessory(this.log, hbAcc, mac, this.pinForMac(mac));
84
+ new PaxCalimaAccessory(this.log, hbAcc, mac, this.pinForMac(mac), this.showHumidity());
94
85
  this.api.registerPlatformAccessories('homebridge-pax-calima', 'PaxCalima', [hbAcc]);
95
86
  });
96
87
  }
97
88
 
98
- fanConfig(mac) { return (this.config.fans || []).find(f => f.mac && f.mac.toLowerCase() === mac) || {}; }
99
- pinForMac(mac) { return this.fanConfig(mac).pin || '012345'; }
100
- 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; }
101
93
  }
102
94
 
103
95
  // ─── Accessory ────────────────────────────────────────────────────────────────
104
96
  class PaxCalimaAccessory {
105
- constructor(log, accessory, mac, pin) {
106
- this.log = log;
107
- this.accessory = accessory;
108
- this.mac = mac.toLowerCase();
109
- 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;
110
103
 
111
- // Sensor state — served to HomeKit between polls
112
104
  this.currentRPM = 0;
113
105
  this.humidity = 0;
114
106
  this.temperature = 20;
115
107
  this.lightLevel = 0.0001;
116
108
  this.silentHours = false;
117
109
 
118
- // BLE connection state
119
- this.peripheral = null;
120
- this.charCache = {}; // uuid (stripped) → characteristic — populated once per connection
121
- this.connected = false;
122
- this.pollTimer = null;
110
+ this.pollTimer = null;
111
+ this.polling = false;
123
112
 
124
113
  this.setupServices();
125
- this.connect(); // connect immediately — BLE is ready by the time we're constructed
114
+ this.schedulePoll(0);
126
115
  }
127
116
 
128
- // ── HomeKit services ────────────────────────────────────────────────────────
129
117
  setupServices() {
130
118
  const n = this.accessory.displayName;
131
119
 
132
- // Fan — always reports as active; speed reflects live RPM
120
+ // Fan — always active; speed reflects live RPM
133
121
  this.fanService = this.accessory.getService(Service.Fanv2)
134
122
  || this.accessory.addService(new Service.Fanv2(n, 'fan'));
135
123
  this.fanService.getCharacteristic(Characteristic.Active)
@@ -145,89 +133,125 @@ class PaxCalimaAccessory {
145
133
  this.boostButton.getCharacteristic(Characteristic.ProgrammableSwitchEvent)
146
134
  .setProps({ validValues: [0] });
147
135
 
148
- // Silent Hours — suppresses automatic boosting overnight
136
+ // Silent Hours
149
137
  this.silentService = this.accessory.getServiceByUUIDAndSubType(Service.Switch, 'silent-hours')
150
138
  || this.accessory.addService(new Service.Switch(n + ' Silent Hours', 'silent-hours'));
151
139
  this.silentService.getCharacteristic(Characteristic.On)
152
140
  .onGet(() => this.silentHours)
153
141
  .onSet((on) => this.setSilentHours(on));
154
142
 
155
- // Sensors
156
- this.humidityService = this.accessory.getService(Service.HumiditySensor)
157
- || this.accessory.addService(new Service.HumiditySensor(n + ' Humidity', 'humidity'));
158
- this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity)
159
- .onGet(() => this.humidity);
160
-
143
+ // Temperature
161
144
  this.tempService = this.accessory.getService(Service.TemperatureSensor)
162
145
  || this.accessory.addService(new Service.TemperatureSensor(n + ' Temperature', 'temperature'));
163
146
  this.tempService.getCharacteristic(Characteristic.CurrentTemperature)
164
147
  .onGet(() => this.temperature);
165
148
 
149
+ // Light
166
150
  this.lightService = this.accessory.getService(Service.LightSensor)
167
151
  || this.accessory.addService(new Service.LightSensor(n + ' Light', 'light'));
168
152
  this.lightService.getCharacteristic(Characteristic.CurrentAmbientLightLevel)
169
153
  .onGet(() => Math.max(0.0001, this.lightLevel));
170
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
+
171
170
  this.log.info('Services ready: ' + n);
172
171
  }
173
172
 
174
- // ── BLE connection ──────────────────────────────────────────────────────────
175
- async connect() {
176
- this.log.info('Connecting to ' + this.mac + '...');
173
+ schedulePoll(delayMs) {
174
+ if (this.pollTimer) clearTimeout(this.pollTimer);
175
+ this.pollTimer = setTimeout(() => this.poll(), delayMs);
176
+ }
177
+
178
+ async poll() {
179
+ if (this.polling) { this.schedulePoll(POLL_MS); return; }
180
+ this.polling = true;
181
+ let peripheral = null;
177
182
 
178
183
  try {
179
- // Find the peripheral via scan
180
- this.peripheral = await this.findPeripheral();
181
- await this.peripheral.connectAsync();
182
- this.log.info('Connected to ' + this.mac);
183
-
184
- // Discover and cache ALL characteristics in one pass
185
- this.charCache = {};
186
- const services = await this.peripheral.discoverServicesAsync();
184
+ peripheral = await this.findPeripheral();
185
+ await peripheral.connectAsync();
186
+
187
+ // Discover and cache all characteristics for this connection
188
+ const charCache = {};
189
+ const services = await peripheral.discoverServicesAsync();
187
190
  for (const svc of services) {
188
191
  const chars = await svc.discoverCharacteristicsAsync();
189
- for (const c of chars) {
190
- this.charCache[c.uuid.replace(/-/g, '')] = c;
191
- }
192
+ for (const c of chars) charCache[c.uuid.replace(/-/g, '')] = c;
192
193
  }
193
194
 
194
- // Authenticate once — stays valid for the life of this connection
195
- const authChar = this.charCache[UUID_AUTH.replace(/-/g, '')];
195
+ // Authenticate
196
+ const authChar = charCache[UUID_AUTH.replace(/-/g, '')];
196
197
  if (!authChar) throw new Error('Auth characteristic not found');
197
198
  const pinBuf = Buffer.alloc(4);
198
199
  pinBuf.writeUInt32LE(parseInt(this.pin, 10), 0);
199
200
  await authChar.writeAsync(pinBuf, true);
200
- this.log.info('Authenticated');
201
201
 
202
- this.connected = true;
202
+ // Read status: RPM + temperature — updates live on every connection
203
+ const statusChar = charCache[UUID_STATUS.replace(/-/g, '')];
204
+ if (!statusChar) throw new Error('Status characteristic not found');
205
+ const statusData = await statusChar.readAsync();
206
+ this.currentRPM = Math.max(0, statusData.readUInt16LE(6));
207
+ this.temperature = Math.round((statusData.readUInt16LE(2) / 6) * 10) / 10;
203
208
 
204
- // Handle unexpected disconnects — reconnect automatically
205
- this.peripheral.once('disconnect', () => {
206
- this.log.warn(this.mac + ' disconnected — reconnecting in ' + (RECONNECT_MS / 1000) + 's');
207
- this.connected = false;
208
- this.charCache = {};
209
- if (this.pollTimer) { clearTimeout(this.pollTimer); this.pollTimer = null; }
210
- setTimeout(() => this.connect(), RECONNECT_MS);
211
- });
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
+ }
226
+
227
+ // Push to HomeKit
228
+ const speed = this.rpmToPercent(this.currentRPM);
229
+ this.fanService.getCharacteristic(Characteristic.RotationSpeed).updateValue(speed);
230
+ this.tempService.getCharacteristic(Characteristic.CurrentTemperature).updateValue(this.temperature);
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
+ }
212
235
 
213
- // Start polling immediately
214
- this.poll();
236
+ this.log.info('Poll: RPM=' + this.currentRPM + ' Speed=' + speed +
237
+ '% Temp=' + this.temperature + 'C Light=' + this.lightLevel + 'lx' +
238
+ (this.showHumidity ? ' Hum=' + this.humidity + '%' : ''));
215
239
 
216
240
  } catch (err) {
217
- this.log.error('Connection failed: ' + err.message);
218
- this.connected = false;
219
- setTimeout(() => this.connect(), RECONNECT_MS);
241
+ this.log.warn('Poll error: ' + err.message);
242
+ } finally {
243
+ if (peripheral) { try { await peripheral.disconnectAsync(); } catch (_) {} }
244
+ this.polling = false;
245
+ this.schedulePoll(POLL_MS);
220
246
  }
221
247
  }
222
248
 
223
- // Targeted scan — resolves as soon as our specific MAC is seen, removes its own listener
224
249
  findPeripheral() {
225
250
  return new Promise((resolve, reject) => {
226
251
  const timeout = setTimeout(() => {
227
252
  noble.removeListener('discover', onDiscover);
228
253
  reject(new Error('Scan timeout for ' + this.mac));
229
254
  }, 20000);
230
-
231
255
  const onDiscover = (periph) => {
232
256
  if (periph.address.toLowerCase() === this.mac) {
233
257
  clearTimeout(timeout);
@@ -235,79 +259,40 @@ class PaxCalimaAccessory {
235
259
  resolve(periph);
236
260
  }
237
261
  };
238
-
239
262
  noble.on('discover', onDiscover);
240
263
  noble.startScanning([], false);
241
264
  });
242
265
  }
243
266
 
244
- // ── Polling — uses cached characteristics, no reconnect overhead ────────────
245
- async poll() {
246
- if (!this.connected) return;
247
-
248
- try {
249
- const statusChar = this.charCache[UUID_STATUS.replace(/-/g, '')];
250
- const sensorChar = this.charCache[UUID_SENSOR.replace(/-/g, '')];
251
-
252
- if (!statusChar || !sensorChar) throw new Error('Characteristics not cached — reconnecting');
253
-
254
- // Read both characteristics
255
- const [statusData, sensorData] = await Promise.all([
256
- statusChar.readAsync(),
257
- sensorChar.readAsync(),
258
- ]);
259
-
260
- // Parse — confirmed mappings from live device
261
- this.currentRPM = Math.max(0, statusData.readUInt16LE(6));
262
- this.temperature = Math.round((statusData.readUInt16LE(2) / 6) * 10) / 10;
263
- this.humidity = Math.max(0, Math.min(100, sensorData.readUInt8(0) * 2));
264
- this.lightLevel = Math.max(0.0001, sensorData.readUInt16BE(2));
265
-
266
- const speed = this.rpmToPercent(this.currentRPM);
267
-
268
- // Push to HomeKit
269
- this.fanService.getCharacteristic(Characteristic.RotationSpeed).updateValue(speed);
270
- this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity).updateValue(this.humidity);
271
- this.tempService.getCharacteristic(Characteristic.CurrentTemperature).updateValue(this.temperature);
272
- this.lightService.getCharacteristic(Characteristic.CurrentAmbientLightLevel).updateValue(this.lightLevel);
273
-
274
- this.log.info('Poll: RPM=' + this.currentRPM + ' Speed=' + speed +
275
- '% Hum=' + this.humidity + '% Temp=' + this.temperature +
276
- 'C Light=' + this.lightLevel + 'lx');
277
-
278
- } catch (err) {
279
- this.log.warn('Poll error: ' + err.message);
280
- // If cache is stale, reconnect
281
- if (!this.charCache[UUID_STATUS.replace(/-/g, '')]) {
282
- this.connected = false;
283
- setTimeout(() => this.connect(), RECONNECT_MS);
284
- return;
285
- }
286
- }
287
-
288
- // Schedule next poll
289
- this.pollTimer = setTimeout(() => this.poll(), POLL_MS);
290
- }
291
-
292
- // ── Fan speed write ─────────────────────────────────────────────────────────
293
267
  async writeSpeed(percent) {
294
- if (!this.connected) {
295
- this.log.warn('Cannot set speed — not connected');
296
- return;
297
- }
268
+ if (this.polling) { this.log.warn('Speed write skipped — poll in progress'); return; }
269
+ let peripheral = null;
298
270
  try {
299
- const ctrlChar = this.charCache[UUID_CONTROL.replace(/-/g, '')];
300
- if (!ctrlChar) throw new Error('Control characteristic not cached');
301
-
302
- // Read current config to preserve trickle + boost values
303
- const current = await ctrlChar.readAsync();
271
+ peripheral = await this.findPeripheral();
272
+ await peripheral.connectAsync();
273
+ const charCache = {};
274
+ const services = await peripheral.discoverServicesAsync();
275
+ for (const svc of services) {
276
+ const chars = await svc.discoverCharacteristicsAsync();
277
+ for (const c of chars) charCache[c.uuid.replace(/-/g, '')] = c;
278
+ }
279
+ const authChar = charCache[UUID_AUTH.replace(/-/g, '')];
280
+ if (!authChar) throw new Error('Auth characteristic not found');
281
+ const pinBuf = Buffer.alloc(4);
282
+ pinBuf.writeUInt32LE(parseInt(this.pin, 10), 0);
283
+ await authChar.writeAsync(pinBuf, true);
284
+ const ctrlChar = charCache[UUID_CONTROL.replace(/-/g, '')];
285
+ if (!ctrlChar) throw new Error('Control characteristic not found');
286
+ const current = await ctrlChar.readAsync();
304
287
  const targetRPM = Math.round((percent / 100) * 2400);
305
- const buf = Buffer.from(current);
306
- buf.writeUInt16LE(targetRPM, 2); // bytes 2-3 = normal speed slot
288
+ const buf = Buffer.from(current);
289
+ buf.writeUInt16LE(targetRPM, 2);
307
290
  await ctrlChar.writeAsync(buf, true);
308
291
  this.log.info('Speed set to ' + percent + '% (' + targetRPM + ' RPM)');
309
292
  } catch (err) {
310
293
  this.log.error('writeSpeed failed: ' + err.message);
294
+ } finally {
295
+ if (peripheral) { try { await peripheral.disconnectAsync(); } catch (_) {} }
311
296
  }
312
297
  }
313
298
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-pax-calima",
3
- "version": "2.4.0",
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": [
@@ -17,7 +17,7 @@
17
17
  ],
18
18
  "engines": {
19
19
  "homebridge": "^1.6.0 || ^2.0.0",
20
- "node": ">=18.0.0"
20
+ "node": ">=18.20.4"
21
21
  },
22
22
  "dependencies": {
23
23
  "@abandonware/noble": "1.9.2-26"