homebridge-pax-calima 2.4.0 → 2.4.1

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.
Files changed (3) hide show
  1. package/README.md +45 -26
  2. package/index.js +102 -113
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -4,11 +4,11 @@
4
4
 
5
5
  Full HomeKit integration for **Pax Calima** and **Vent-Axia Lo-Carbon Svara** Bluetooth LE bathroom extractor fans.
6
6
 
7
- Compatible devices:
7
+ **Compatible devices:**
8
8
  - **Pax Calima** — Sweden, Norway, Denmark, Finland, Spain, Portugal
9
- - **Vent-Axia Lo-Carbon Svara** (White & Black) — UK
9
+ - **Vent-Axia Lo-Carbon Svara** White & Black — UK
10
10
 
11
- All are hardware-identical and share the same BLE protocol and MAC prefix (`58:2b:db`).
11
+ All are hardware-identical, sharing the same BLE protocol and MAC prefix (`58:2b:db`).
12
12
 
13
13
  ---
14
14
 
@@ -22,7 +22,7 @@ All are hardware-identical and share the same BLE protocol and MAC prefix (`58:2
22
22
  | **Temperature sensor** | Live readings, updates every 10 seconds |
23
23
  | **Light sensor** | Live lux readings, updates every 10 seconds |
24
24
  | **Boost button** | Stateless programmable switch |
25
- | **Silent Hours** | Toggle switch — suppresses automatic boosting during quiet periods |
25
+ | **Silent Hours** | Toggle — suppresses automatic boosting during quiet periods |
26
26
  | **Automations** | Full HomeKit automation support on all sensors |
27
27
 
28
28
  ---
@@ -30,11 +30,23 @@ All are hardware-identical and share the same BLE protocol and MAC prefix (`58:2
30
30
  ## Requirements
31
31
 
32
32
  - Homebridge `^1.6.0` or `^2.0.0`
33
- - Node.js `>= 18`
33
+ - Node.js `>= 18.20.4`
34
34
  - A Bluetooth LE adapter on the Homebridge host
35
35
 
36
+ ### Homebridge 2.0 Compliance
37
+
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
45
+
36
46
  ### Raspberry Pi / Homebridge OS — one-time setup
37
47
 
48
+ The `noble` BLE library requires elevated network capabilities. Run **once** after installation on the **Homebridge terminal**:
49
+
38
50
  ```bash
39
51
  sudo setcap cap_net_raw+eip /opt/homebridge/bin/node
40
52
  ```
@@ -45,6 +57,8 @@ If Bluetooth shows as `poweredOff`:
45
57
  sudo rfkill unblock bluetooth
46
58
  ```
47
59
 
60
+ If the above does not resolve the issue, a full Pi power cycle may be required to reinitialise the BLE stack cleanly.
61
+
48
62
  ---
49
63
 
50
64
  ## Installation
@@ -61,7 +75,7 @@ npm install -g homebridge-pax-calima
61
75
 
62
76
  ## Configuration
63
77
 
64
- No configuration needed for auto-discovery. To set a custom name or PIN per fan:
78
+ Auto-discovery requires **no configuration**. To set a custom name or PIN per fan:
65
79
 
66
80
  ```json
67
81
  {
@@ -92,35 +106,39 @@ Each fan appears as a single accessory with:
92
106
  - **Temperature** — °C
93
107
  - **Light** — lux
94
108
 
95
- > Accessories appear in the **Home app**, not the Homebridge UI accessories tab. This is correct for child bridge plugins.
109
+ > Accessories appear in the **Home app**, not the Homebridge UI accessories tab. This is correct behaviour for child bridge plugins.
96
110
 
97
111
  ---
98
112
 
99
113
  ## Fan Speed Control — Wiring Mode
100
114
 
101
- The fan supports two installation wiring modes:
115
+ The fan supports two installation wiring modes selected at installation time:
102
116
 
103
- **Intermittent mode** — fan is off by default, triggered by sensors or timer. BLE speed commands work fully; the slider controls the fan directly.
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.
104
118
 
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.
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.
106
120
 
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.
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.
122
+
123
+ To enable full speed control, switch to intermittent wiring mode per the fan's installation guide.
108
124
 
109
125
  ---
110
126
 
111
127
  ## Sensor Notes
112
128
 
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.
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.
130
+
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.
114
132
 
115
- For lux-based automations, use **Eve** or **Hesperus** which support lux thresholds synced to HomeKit.
133
+ For lux-based automations use **Eve** or **Hesperus**, which support lux thresholds synced back to HomeKit.
116
134
 
117
135
  ---
118
136
 
119
137
  ## Troubleshooting
120
138
 
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`.
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.
122
140
 
123
- **Authentication failed** — default PIN is `012345`. If changed in the official app, add the fan to the `fans` config with the correct PIN.
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.
124
142
 
125
143
  **Speed slider has no effect** — fan is wired in always-on mode. See wiring note above. All sensors work normally.
126
144
 
@@ -130,24 +148,25 @@ For lux-based automations, use **Eve** or **Hesperus** which support lux thresho
130
148
 
131
149
  ## Release Notes
132
150
 
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
158
+
133
159
  ### 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
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
142
161
 
143
162
  ### 2.3.2
144
- - Documented always-on vs intermittent wiring mode behaviour for speed control
163
+ - Documented always-on vs intermittent wiring mode behaviour
145
164
 
146
165
  ### 2.3.1
147
- - Identified correct control characteristic; added write diagnostics
166
+ - Identified correct control characteristic; write diagnostics added
148
167
 
149
168
  ### 2.3.0 / 2.2.1 / 2.1.0
150
- - Sensor characteristic UUIDs and byte mappings confirmed via live device
169
+ - Sensor UUIDs and byte mappings confirmed via live device dump
151
170
  - BLE initialisation fixes for Homebridge 2.0
152
171
  - Duplicate accessory registration resolved
153
172
 
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] as 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; // 10s — responsive for shower humidity spikes
15
15
  const RECONNECT_MS = 15000;
16
16
 
17
17
  module.exports = (homebridge) => {
@@ -21,30 +21,28 @@ module.exports = (homebridge) => {
21
21
  API = homebridge;
22
22
  PlatformAccessory = homebridge.platformAccessory;
23
23
 
24
- // true = dynamic platform, required for Homebridge 2.0
24
+ // Dynamic platform — required for Homebridge 2.0
25
25
  homebridge.registerPlatform('homebridge-pax-calima', 'PaxCalima', PaxCalimaPlatform, true);
26
26
  };
27
27
 
28
28
  // ─── Platform ─────────────────────────────────────────────────────────────────
29
29
  class PaxCalimaPlatform {
30
30
  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;
31
+ this.log = log;
32
+ this.config = config || {};
33
+ this.api = api;
34
+ this.registeredMACs = new Set();
35
+ this.discoveryStarted = false;
36
36
  this.cachedAccessories = []; // held until BLE is ready after didFinishLaunching
37
37
 
38
38
  this.log.info('PaxCalima platform initialised');
39
39
 
40
40
  this.api.on('didFinishLaunching', () => {
41
41
  this.log.info('Homebridge finished launching — starting BLE');
42
-
43
42
  noble.on('stateChange', (state) => {
44
43
  this.log.info('BLE state: ' + state);
45
44
  if (state === 'poweredOn') this.startDiscovery();
46
45
  });
47
-
48
46
  // Catch case where noble is already poweredOn before listener fires
49
47
  setTimeout(() => {
50
48
  if (noble.state === 'poweredOn') this.startDiscovery();
@@ -53,7 +51,8 @@ class PaxCalimaPlatform {
53
51
  }
54
52
 
55
53
  // Called by Homebridge for each cached accessory on startup.
56
- // We hold them until startDiscovery() confirms BLE is ready.
54
+ // Held until startDiscovery() confirms BLE is ready — prevents polling
55
+ // before the BLE stack is initialised.
57
56
  configureAccessory(accessory) {
58
57
  const mac = accessory.context && accessory.context.mac;
59
58
  this.log.info('Restoring cached accessory: ' + accessory.displayName);
@@ -67,9 +66,9 @@ class PaxCalimaPlatform {
67
66
  if (this.discoveryStarted) return;
68
67
  this.discoveryStarted = true;
69
68
 
70
- // Spin up handlers for all cached (previously registered) accessories
69
+ // Spin up BLE handlers for all previously registered accessories
71
70
  for (const { accessory, mac } of this.cachedAccessories) {
72
- this.log.info('Connecting to cached fan: ' + mac);
71
+ this.log.info('Starting handler for cached fan: ' + mac);
73
72
  new PaxCalimaAccessory(this.log, accessory, mac, this.pinForMac(mac));
74
73
  }
75
74
  this.cachedAccessories = [];
@@ -115,21 +114,18 @@ class PaxCalimaAccessory {
115
114
  this.lightLevel = 0.0001;
116
115
  this.silentHours = false;
117
116
 
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;
117
+ this.pollTimer = null;
118
+ this.polling = false; // guard against overlapping cycles
123
119
 
124
120
  this.setupServices();
125
- this.connect(); // connect immediately — BLE is ready by the time we're constructed
121
+ this.schedulePoll(0); // BLE is ready by the time constructor is called
126
122
  }
127
123
 
128
124
  // ── HomeKit services ────────────────────────────────────────────────────────
129
125
  setupServices() {
130
126
  const n = this.accessory.displayName;
131
127
 
132
- // Fan — always reports as active; speed reflects live RPM
128
+ // Fan — always active; speed reflects live RPM
133
129
  this.fanService = this.accessory.getService(Service.Fanv2)
134
130
  || this.accessory.addService(new Service.Fanv2(n, 'fan'));
135
131
  this.fanService.getCharacteristic(Characteristic.Active)
@@ -145,7 +141,7 @@ class PaxCalimaAccessory {
145
141
  this.boostButton.getCharacteristic(Characteristic.ProgrammableSwitchEvent)
146
142
  .setProps({ validValues: [0] });
147
143
 
148
- // Silent Hours — suppresses automatic boosting overnight
144
+ // Silent Hours — suppresses automatic boosting during quiet periods
149
145
  this.silentService = this.accessory.getServiceByUUIDAndSubType(Service.Switch, 'silent-hours')
150
146
  || this.accessory.addService(new Service.Switch(n + ' Silent Hours', 'silent-hours'));
151
147
  this.silentService.getCharacteristic(Characteristic.On)
@@ -171,56 +167,75 @@ class PaxCalimaAccessory {
171
167
  this.log.info('Services ready: ' + n);
172
168
  }
173
169
 
174
- // ── BLE connection ──────────────────────────────────────────────────────────
175
- async connect() {
176
- this.log.info('Connecting to ' + this.mac + '...');
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
+ schedulePoll(delayMs) {
175
+ if (this.pollTimer) clearTimeout(this.pollTimer);
176
+ this.pollTimer = setTimeout(() => this.poll(), delayMs);
177
+ }
178
+
179
+ async poll() {
180
+ if (this.polling) { this.schedulePoll(POLL_MS); return; }
181
+ this.polling = true;
182
+ let peripheral = null;
177
183
 
178
184
  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();
185
+ peripheral = await this.findPeripheral();
186
+ await peripheral.connectAsync();
187
+
188
+ // Discover and cache all characteristics for this connection
189
+ const charCache = {};
190
+ const services = await peripheral.discoverServicesAsync();
187
191
  for (const svc of services) {
188
192
  const chars = await svc.discoverCharacteristicsAsync();
189
- for (const c of chars) {
190
- this.charCache[c.uuid.replace(/-/g, '')] = c;
191
- }
193
+ for (const c of chars) charCache[c.uuid.replace(/-/g, '')] = c;
192
194
  }
193
195
 
194
- // Authenticate once — stays valid for the life of this connection
195
- const authChar = this.charCache[UUID_AUTH.replace(/-/g, '')];
196
+ // Authenticate
197
+ const authChar = charCache[UUID_AUTH.replace(/-/g, '')];
196
198
  if (!authChar) throw new Error('Auth characteristic not found');
197
199
  const pinBuf = Buffer.alloc(4);
198
200
  pinBuf.writeUInt32LE(parseInt(this.pin, 10), 0);
199
201
  await authChar.writeAsync(pinBuf, true);
200
- this.log.info('Authenticated');
201
202
 
202
- this.connected = true;
203
+ // Read status: RPM + temperature
204
+ const statusChar = charCache[UUID_STATUS.replace(/-/g, '')];
205
+ if (!statusChar) throw new Error('Status characteristic not found');
206
+ const statusData = await statusChar.readAsync();
207
+ this.currentRPM = Math.max(0, statusData.readUInt16LE(6));
208
+ this.temperature = Math.round((statusData.readUInt16LE(2) / 6) * 10) / 10;
203
209
 
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
- });
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));
212
216
 
213
- // Start polling immediately
214
- this.poll();
217
+ // Push all values to HomeKit
218
+ const speed = this.rpmToPercent(this.currentRPM);
219
+ this.fanService.getCharacteristic(Characteristic.RotationSpeed).updateValue(speed);
220
+ this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity).updateValue(this.humidity);
221
+ this.tempService.getCharacteristic(Characteristic.CurrentTemperature).updateValue(this.temperature);
222
+ this.lightService.getCharacteristic(Characteristic.CurrentAmbientLightLevel).updateValue(this.lightLevel);
223
+
224
+ this.log.info('Poll: RPM=' + this.currentRPM + ' Speed=' + speed +
225
+ '% Hum=' + this.humidity + '% Temp=' + this.temperature +
226
+ 'C Light=' + this.lightLevel + 'lx');
215
227
 
216
228
  } catch (err) {
217
- this.log.error('Connection failed: ' + err.message);
218
- this.connected = false;
219
- setTimeout(() => this.connect(), RECONNECT_MS);
229
+ this.log.warn('Poll error: ' + err.message);
230
+ } finally {
231
+ // Always disconnect cleanly — required for fresh sensor reads next cycle
232
+ if (peripheral) { try { await peripheral.disconnectAsync(); } catch (_) {} }
233
+ this.polling = false;
234
+ this.schedulePoll(POLL_MS);
220
235
  }
221
236
  }
222
237
 
223
- // Targeted scan — resolves as soon as our specific MAC is seen, removes its own listener
238
+ // Targeted scan — resolves as soon as our MAC is seen, removes its own listener
224
239
  findPeripheral() {
225
240
  return new Promise((resolve, reject) => {
226
241
  const timeout = setTimeout(() => {
@@ -241,73 +256,47 @@ class PaxCalimaAccessory {
241
256
  });
242
257
  }
243
258
 
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
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.
293
262
  async writeSpeed(percent) {
294
- if (!this.connected) {
295
- this.log.warn('Cannot set speed — not connected');
263
+ if (this.polling) {
264
+ this.log.warn('Speed write skipped — poll in progress');
296
265
  return;
297
266
  }
267
+ let peripheral = null;
298
268
  try {
299
- const ctrlChar = this.charCache[UUID_CONTROL.replace(/-/g, '')];
300
- if (!ctrlChar) throw new Error('Control characteristic not cached');
269
+ peripheral = await this.findPeripheral();
270
+ await peripheral.connectAsync();
271
+
272
+ const charCache = {};
273
+ const services = await peripheral.discoverServicesAsync();
274
+ for (const svc of services) {
275
+ const chars = await svc.discoverCharacteristicsAsync();
276
+ for (const c of chars) charCache[c.uuid.replace(/-/g, '')] = c;
277
+ }
301
278
 
302
- // Read current config to preserve trickle + boost values
303
- const current = await ctrlChar.readAsync();
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
+
285
+ const ctrlChar = charCache[UUID_CONTROL.replace(/-/g, '')];
286
+ if (!ctrlChar) throw new Error('Control characteristic not found');
287
+
288
+ // Preserve existing trickle + boost values, update normal speed slot only
289
+ const current = await ctrlChar.readAsync();
304
290
  const targetRPM = Math.round((percent / 100) * 2400);
305
- const buf = Buffer.from(current);
306
- buf.writeUInt16LE(targetRPM, 2); // bytes 2-3 = normal speed slot
291
+ const buf = Buffer.from(current);
292
+ buf.writeUInt16LE(targetRPM, 2);
307
293
  await ctrlChar.writeAsync(buf, true);
308
294
  this.log.info('Speed set to ' + percent + '% (' + targetRPM + ' RPM)');
295
+
309
296
  } catch (err) {
310
297
  this.log.error('writeSpeed failed: ' + err.message);
298
+ } finally {
299
+ if (peripheral) { try { await peripheral.disconnectAsync(); } catch (_) {} }
311
300
  }
312
301
  }
313
302
 
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.1",
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"