homebridge-pax-calima 2.3.2 → 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.
package/README.md CHANGED
@@ -2,7 +2,13 @@
2
2
 
3
3
  **Homebridge 2.0 Ready** · [npm](https://www.npmjs.com/package/homebridge-pax-calima) · [GitHub](https://github.com/JayC68/homebridge-pax-calima)
4
4
 
5
- Full HomeKit integration for **Pax Calima** (Scandinavia) and **Vent-Axia Svara** (UK) Bluetooth LE bathroom extractor fans. These are hardware-identical and use the exact same BLE protocol and MAC prefix (`58:2b:db`).
5
+ Full HomeKit integration for **Pax Calima** and **Vent-Axia Lo-Carbon Svara** Bluetooth LE bathroom extractor fans.
6
+
7
+ **Compatible devices:**
8
+ - **Pax Calima** — Sweden, Norway, Denmark, Finland, Spain, Portugal
9
+ - **Vent-Axia Lo-Carbon Svara** White & Black — UK
10
+
11
+ All are hardware-identical, sharing the same BLE protocol and MAC prefix (`58:2b:db`).
6
12
 
7
13
  ---
8
14
 
@@ -10,38 +16,49 @@ Full HomeKit integration for **Pax Calima** (Scandinavia) and **Vent-Axia Svara*
10
16
 
11
17
  | Feature | Detail |
12
18
  |---|---|
13
- | **Auto-discovery** | Finds all Pax/Svara fans automatically — no MAC address needed |
14
- | **Fan speed indicator** | Displays current RPM as 0–100% in Apple Home |
15
- | **Fan speed control** | Speed slider available — see wiring note below |
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) |
16
21
  | **Humidity sensor** | Live readings, updates every 10 seconds |
17
22
  | **Temperature sensor** | Live readings, updates every 10 seconds |
18
- | **Light sensor** | Live readings in lux, updates every 10 seconds |
19
- | **Boost button** | Stateless programmable switch — triggers temporary high-speed boost |
20
- | **Silent Hours** | Toggle switch — prevents fan boosting during scheduled quiet periods |
21
- | **Automations** | Full support — trigger on humidity, temperature or light thresholds |
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 |
22
27
 
23
28
  ---
24
29
 
25
30
  ## Requirements
26
31
 
27
32
  - Homebridge `^1.6.0` or `^2.0.0`
28
- - Node.js `>= 18`
29
- - A Bluetooth LE adapter on the Homebridge host (built-in or USB dongle)
33
+ - Node.js `>= 18.20.4`
34
+ - A Bluetooth LE adapter on the Homebridge host
35
+
36
+ ### Homebridge 2.0 Compliance
30
37
 
31
- ### Raspberry Pi / Homebridge OS setup
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
32
45
 
33
- The `noble` BLE library requires elevated network capabilities. Run **once** after installation:
46
+ ### Raspberry Pi / Homebridge OS — one-time setup
47
+
48
+ The `noble` BLE library requires elevated network capabilities. Run **once** after installation on the **Homebridge terminal**:
34
49
 
35
50
  ```bash
36
51
  sudo setcap cap_net_raw+eip /opt/homebridge/bin/node
37
52
  ```
38
53
 
39
- If Bluetooth shows as `poweredOff` after this, unblock the radio:
54
+ If Bluetooth shows as `poweredOff`:
40
55
 
41
56
  ```bash
42
57
  sudo rfkill unblock bluetooth
43
58
  ```
44
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
+
45
62
  ---
46
63
 
47
64
  ## Installation
@@ -58,9 +75,7 @@ npm install -g homebridge-pax-calima
58
75
 
59
76
  ## Configuration
60
77
 
61
- Auto-discovery requires **no configuration**. The plugin finds any fan with MAC address starting `58:2b:db` automatically and registers it with a default name.
62
-
63
- To set a **custom name** or **PIN** for a specific fan, add an entry to the `fans` array:
78
+ Auto-discovery requires **no configuration**. To set a custom name or PIN per fan:
64
79
 
65
80
  ```json
66
81
  {
@@ -76,93 +91,84 @@ To set a **custom name** or **PIN** for a specific fan, add an entry to the `fan
76
91
  }
77
92
  ```
78
93
 
79
- The `fans` array is entirely optional. Omit it to use auto-discovery with default names and PIN `012345`.
94
+ The `fans` array is optional. Default PIN is `012345`.
80
95
 
81
96
  ---
82
97
 
83
98
  ## Accessories in Apple Home
84
99
 
85
- Each discovered fan appears in the Home app as a single accessory with the following tiles:
100
+ Each fan appears as a single accessory with:
86
101
 
87
- - **Fan** — displays current speed; slider available (see wiring note below)
88
- - **Boost** — programmable button for temporary high-speed boost
89
- - **Silent Hours** — switch to suppress automatic boosting during quiet periods
90
- - **Humidity** — current relative humidity (%)
91
- - **Temperature** — current temperature (°C)
92
- - **Light** — current ambient light level (lux)
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
93
108
 
94
- > Note: Accessories appear in the **Home app**, not in the Homebridge UI accessories tab. This is correct behaviour 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.
95
110
 
96
111
  ---
97
112
 
98
- ## Fan Speed Control — Wiring Mode Note
113
+ ## Fan Speed Control — Wiring Mode
99
114
 
100
- The Pax Calima and Vent-Axia Svara support two wiring modes, selected at installation time:
115
+ The fan supports two installation wiring modes selected at installation time:
101
116
 
102
- **Intermittent mode** — the fan is off by default and triggered by humidity, light, or a timer. In this mode BLE speed commands are fully respected and the speed slider in Apple Home 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.
103
118
 
104
- **Always-on (trickle) mode** — the fan runs continuously at a speed determined by its onboard controller, typically set via the official app or physical dial. In this mode BLE speed writes are accepted by the fan but the onboard firmware immediately reasserts its own speed. The slider will appear to have no effect.
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.
105
120
 
106
- If your fan is wired in always-on mode, the speed slider will reflect the current RPM accurately but cannot override the onboard setpoint. The sensors and automations remain fully functional regardless of wiring mode — for bathroom and wetroom use this is the primary value of the integration, enabling powerful Apple Home automations based on live humidity, temperature and light readings.
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.
107
122
 
108
- To enable full speed control, consult your fan's installation guide to switch to intermittent wiring mode.
123
+ To enable full speed control, switch to intermittent wiring mode per the fan's installation guide.
109
124
 
110
125
  ---
111
126
 
112
127
  ## Sensor Notes
113
128
 
114
- The Svara's light sensor detects occupancy via light movement and shadows rather than acting as a calibrated lux meter. Readings may vary slightly from a reference device depending on mounting position — this is expected.
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.
115
130
 
116
- Humidity and temperature automations work well natively in the Home app. For lux-based automations, use a third-party app such as **Eve** or **Hesperus** which support lux thresholds and sync back to HomeKit.
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.
132
+
133
+ For lux-based automations use **Eve** or **Hesperus**, which support lux thresholds synced back to HomeKit.
117
134
 
118
135
  ---
119
136
 
120
137
  ## Troubleshooting
121
138
 
122
- **Fan not discovered**
123
- - Confirm BLE is active: run `hciconfig` on the Pi — look for `UP RUNNING`
124
- - If it shows `RF-KILL`: run `sudo rfkill unblock bluetooth`
125
- - Confirm `setcap` targets the correct Node binary: `/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.
126
140
 
127
- **Authentication failed**
128
- - Default PIN is `012345`. If you set a custom PIN in the official Pax/Svara app, add the fan to the `fans` config array 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.
129
142
 
130
- **Speed slider has no effect**
131
- - Your fan is likely wired in always-on mode. See the wiring note above. All sensors and automations are unaffected.
143
+ **Speed slider has no effect** — fan is wired in always-on mode. See wiring note above. All sensors work normally.
132
144
 
133
- **Accessories not appearing in Home app**
134
- - Remove the accessory cache via Homebridge UI → (plugin settings) → Remove Accessory Cache, then restart Homebridge
145
+ **Accessories not in Home app** — clear accessory cache via Homebridge UI → plugin settings → Remove Accessory Cache, then restart.
135
146
 
136
147
  ---
137
148
 
138
149
  ## Release Notes
139
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
+
159
+ ### 2.4.0
160
+ - **Rebuilt:** Persistent connection architecture — connect once, cache characteristics, poll with direct reads. Fast RPM and temperature updates confirmed. Humidity and light subsequently found to return stale GATT cached values on persistent connections — resolved in 2.4.1
161
+
140
162
  ### 2.3.2
141
- - **Documented:** Fan speed control behaviour in always-on vs intermittent wiring modes — the slider correctly reflects current RPM in both modes; in always-on mode the onboard firmware reasserts its own speed setpoint after each BLE write
142
- - **Confirmed:** All sensors (humidity, temperature, light, RPM) accurate and stable
143
- - **Improved:** README wiring mode explanation, troubleshooting entry for speed slider, sensor automation guidance
163
+ - Documented always-on vs intermittent wiring mode behaviour
144
164
 
145
165
  ### 2.3.1
146
- - **Investigated:** Correct control characteristic identified (`1488a757...`) via live BLE dump
147
- - **Added:** Diagnostic logging of control characteristic on every poll and before/after writes
148
- - **Fixed:** `writeSpeed` pauses poll cycle to avoid BLE conflicts during writes
149
-
150
- ### 2.3.0
151
- - **Published:** Stable sensor readings verified against real-world reference device
152
- - **Fixed:** Auth characteristic discovery on first connection after restart
166
+ - Identified correct control characteristic; write diagnostics added
153
167
 
154
- ### 2.2.1
155
- - **Fixed:** Sensors no longer stuck after restart — polling deferred until BLE is ready
156
- - **Fixed:** Poll interval 30s → 10s for responsive shower humidity tracking
157
- - **Fixed:** Overlapping poll guard prevents concurrent BLE connections
158
- - **Fixed:** Multi Mode and Heat Distribution switches removed — not applicable for UK use
159
- - **Fixed:** Duplicate accessory registration on restart resolved
160
- - **Fixed:** Switch UUID collision resolved with explicit subtypes
161
-
162
- ### 2.1.0
163
- - **Fixed:** Correct sensor characteristic UUIDs and byte mappings confirmed via live device
164
- - **Fixed:** BLE refactored to connect → auth → read → disconnect → repeat pattern
165
- - **Fixed:** UUID matching on Linux noble
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
166
172
 
167
173
  ### 1.0.x
168
174
  - Initial working releases
@@ -11,22 +11,58 @@
11
11
  "default": "Pax Calima / Vent-Axia Svara"
12
12
  },
13
13
  "fans": {
14
- "title": "Configured Fans",
14
+ "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.",
15
16
  "type": "array",
16
17
  "items": {
17
18
  "type": "object",
18
19
  "properties": {
19
- "name": { "title": "Name", "type": "string", "default": "Bathroom Fan" },
20
+ "name": {
21
+ "title": "Accessory Name",
22
+ "type": "string",
23
+ "default": "Bathroom Fan"
24
+ },
20
25
  "mac": {
21
26
  "title": "MAC Address",
22
27
  "type": "string",
23
- "pattern": "^([0-9A-Fa-f]{2}[:]){5}([0-9A-Fa-f]{2})$",
24
- "placeholder": "e.g. 58:2b:db:00:00:00"
28
+ "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."
25
30
  },
26
- "pin": { "title": "PIN Code", "type": "string", "default": "012345" }
27
- }
31
+ "pin": {
32
+ "title": "PIN Code",
33
+ "type": "string",
34
+ "default": "012345",
35
+ "description": "6-digit PIN. Only change this if you set a custom PIN in the official Pax / Vent-Axia app."
36
+ }
37
+ },
38
+ "required": ["name", "mac"]
28
39
  }
29
40
  }
30
41
  }
31
- }
42
+ },
43
+ "layout": [
44
+ {
45
+ "type": "section",
46
+ "title": "General",
47
+ "expandable": false,
48
+ "items": ["name"]
49
+ },
50
+ {
51
+ "type": "section",
52
+ "title": "Fans (optional — auto-discovery recommended)",
53
+ "expandable": true,
54
+ "expanded": false,
55
+ "items": [
56
+ {
57
+ "key": "fans",
58
+ "type": "array",
59
+ "items": [
60
+ "fans[].name",
61
+ "fans[].mac",
62
+ "fans[].pin"
63
+ ]
64
+ }
65
+ ]
66
+ }
67
+ ]
32
68
  }
package/index.js CHANGED
@@ -1,15 +1,18 @@
1
+ 'use strict';
2
+
1
3
  const noble = require('@abandonware/noble');
2
4
 
3
5
  let Service, Characteristic, Categories, API, PlatformAccessory;
4
6
 
5
- const UUID_AUTH = '4cad343a-209a-40b7-b911-4d9b3df569b2';
6
- const UUID_STATUS = '528b80e8-c47a-4c0a-bdf1-916a7748f412'; // read: RPM + temp
7
- const UUID_SENSOR = 'a22eae12-dba8-49f3-9c69-1721dcff1d96'; // read: humidity + lux
8
- const UUID_CONTROL = '1488a757-35bc-4ec8-9a6b-9ecf1502778e'; // read/write: speed config
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
9
12
 
10
- const MAC_PREFIX = '58:2b:db';
11
- const POLL_MS = 10000;
12
- const SCAN_TIMEOUT_MS = 15000;
13
+ const MAC_PREFIX = '58:2b:db';
14
+ const POLL_MS = 10000; // 10s — responsive for shower humidity spikes
15
+ const RECONNECT_MS = 15000;
13
16
 
14
17
  module.exports = (homebridge) => {
15
18
  Service = homebridge.hap.Service;
@@ -17,9 +20,12 @@ module.exports = (homebridge) => {
17
20
  Categories = homebridge.hap.Categories;
18
21
  API = homebridge;
19
22
  PlatformAccessory = homebridge.platformAccessory;
23
+
24
+ // Dynamic platform — required for Homebridge 2.0
20
25
  homebridge.registerPlatform('homebridge-pax-calima', 'PaxCalima', PaxCalimaPlatform, true);
21
26
  };
22
27
 
28
+ // ─── Platform ─────────────────────────────────────────────────────────────────
23
29
  class PaxCalimaPlatform {
24
30
  constructor(log, config, api) {
25
31
  this.log = log;
@@ -27,23 +33,26 @@ class PaxCalimaPlatform {
27
33
  this.api = api;
28
34
  this.registeredMACs = new Set();
29
35
  this.discoveryStarted = false;
30
- this.cachedAccessories = [];
36
+ this.cachedAccessories = []; // held until BLE is ready after didFinishLaunching
31
37
 
32
38
  this.log.info('PaxCalima platform initialised');
33
39
 
34
40
  this.api.on('didFinishLaunching', () => {
35
- this.log.info('Homebridge finished launching - starting BLE');
41
+ this.log.info('Homebridge finished launching — starting BLE');
36
42
  noble.on('stateChange', (state) => {
37
43
  this.log.info('BLE state: ' + state);
38
44
  if (state === 'poweredOn') this.startDiscovery();
39
45
  });
46
+ // Catch case where noble is already poweredOn before listener fires
40
47
  setTimeout(() => {
41
- this.log.info('BLE current state: ' + noble.state);
42
48
  if (noble.state === 'poweredOn') this.startDiscovery();
43
49
  }, 3000);
44
50
  });
45
51
  }
46
52
 
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.
47
56
  configureAccessory(accessory) {
48
57
  const mac = accessory.context && accessory.context.mac;
49
58
  this.log.info('Restoring cached accessory: ' + accessory.displayName);
@@ -57,13 +66,14 @@ class PaxCalimaPlatform {
57
66
  if (this.discoveryStarted) return;
58
67
  this.discoveryStarted = true;
59
68
 
69
+ // Spin up BLE handlers for all previously registered accessories
60
70
  for (const { accessory, mac } of this.cachedAccessories) {
61
- const pin = this.pinForMac(mac);
62
- this.log.info('Starting BLE handler for cached fan: ' + mac);
63
- new PaxCalimaAccessory(this.log, accessory, mac, pin);
71
+ this.log.info('Starting handler for cached fan: ' + mac);
72
+ new PaxCalimaAccessory(this.log, accessory, mac, this.pinForMac(mac));
64
73
  }
65
74
  this.cachedAccessories = [];
66
75
 
76
+ // Scan for any new fans not yet registered
67
77
  this.log.info('Starting BLE discovery scan...');
68
78
  noble.startScanning([], false);
69
79
 
@@ -71,23 +81,25 @@ class PaxCalimaPlatform {
71
81
  const mac = peripheral.address.toLowerCase();
72
82
  if (!mac.startsWith(MAC_PREFIX)) return;
73
83
  if (this.registeredMACs.has(mac)) return;
84
+
74
85
  this.registeredMACs.add(mac);
75
86
  this.log.info('Discovered new fan: ' + mac);
76
- const name = this.nameForMac(mac);
77
- const pin = this.pinForMac(mac);
78
- const uuid = API.hap.uuid.generate('pax-svara-' + mac);
79
- const hbAcc = new PlatformAccessory(name, uuid, Categories.FAN);
87
+
88
+ const uuid = API.hap.uuid.generate('pax-calima-' + mac);
89
+ const hbAcc = new PlatformAccessory(this.nameForMac(mac), uuid, Categories.FAN);
80
90
  hbAcc.context.mac = mac;
81
- new PaxCalimaAccessory(this.log, hbAcc, mac, pin);
91
+
92
+ new PaxCalimaAccessory(this.log, hbAcc, mac, this.pinForMac(mac));
82
93
  this.api.registerPlatformAccessories('homebridge-pax-calima', 'PaxCalima', [hbAcc]);
83
94
  });
84
95
  }
85
96
 
86
97
  fanConfig(mac) { return (this.config.fans || []).find(f => f.mac && f.mac.toLowerCase() === mac) || {}; }
87
98
  pinForMac(mac) { return this.fanConfig(mac).pin || '012345'; }
88
- nameForMac(mac) { return this.fanConfig(mac).name || ('Vent-Axia Svara ' + mac.slice(-5).toUpperCase()); }
99
+ nameForMac(mac) { return this.fanConfig(mac).name || ('Pax Calima ' + mac.slice(-5).toUpperCase()); }
89
100
  }
90
101
 
102
+ // ─── Accessory ────────────────────────────────────────────────────────────────
91
103
  class PaxCalimaAccessory {
92
104
  constructor(log, accessory, mac, pin) {
93
105
  this.log = log;
@@ -95,6 +107,7 @@ class PaxCalimaAccessory {
95
107
  this.mac = mac.toLowerCase();
96
108
  this.pin = pin || '012345';
97
109
 
110
+ // Sensor state — served to HomeKit between polls
98
111
  this.currentRPM = 0;
99
112
  this.humidity = 0;
100
113
  this.temperature = 20;
@@ -102,15 +115,17 @@ class PaxCalimaAccessory {
102
115
  this.silentHours = false;
103
116
 
104
117
  this.pollTimer = null;
105
- this.polling = false;
118
+ this.polling = false; // guard against overlapping cycles
106
119
 
107
120
  this.setupServices();
108
- this.schedulePoll(0);
121
+ this.schedulePoll(0); // BLE is ready by the time constructor is called
109
122
  }
110
123
 
124
+ // ── HomeKit services ────────────────────────────────────────────────────────
111
125
  setupServices() {
112
126
  const n = this.accessory.displayName;
113
127
 
128
+ // Fan — always active; speed reflects live RPM
114
129
  this.fanService = this.accessory.getService(Service.Fanv2)
115
130
  || this.accessory.addService(new Service.Fanv2(n, 'fan'));
116
131
  this.fanService.getCharacteristic(Characteristic.Active)
@@ -120,17 +135,20 @@ class PaxCalimaAccessory {
120
135
  .onGet(() => this.rpmToPercent(this.currentRPM))
121
136
  .onSet((v) => this.writeSpeed(v));
122
137
 
138
+ // Boost — stateless single-press button
123
139
  this.boostButton = this.accessory.getService(Service.StatelessProgrammableSwitch)
124
140
  || this.accessory.addService(new Service.StatelessProgrammableSwitch(n + ' Boost', 'boost'));
125
141
  this.boostButton.getCharacteristic(Characteristic.ProgrammableSwitchEvent)
126
142
  .setProps({ validValues: [0] });
127
143
 
144
+ // Silent Hours — suppresses automatic boosting during quiet periods
128
145
  this.silentService = this.accessory.getServiceByUUIDAndSubType(Service.Switch, 'silent-hours')
129
146
  || this.accessory.addService(new Service.Switch(n + ' Silent Hours', 'silent-hours'));
130
147
  this.silentService.getCharacteristic(Characteristic.On)
131
148
  .onGet(() => this.silentHours)
132
149
  .onSet((on) => this.setSilentHours(on));
133
150
 
151
+ // Sensors
134
152
  this.humidityService = this.accessory.getService(Service.HumiditySensor)
135
153
  || this.accessory.addService(new Service.HumiditySensor(n + ' Humidity', 'humidity'));
136
154
  this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity)
@@ -146,9 +164,13 @@ class PaxCalimaAccessory {
146
164
  this.lightService.getCharacteristic(Characteristic.CurrentAmbientLightLevel)
147
165
  .onGet(() => Math.max(0.0001, this.lightLevel));
148
166
 
149
- this.log.info('Services set up for: ' + n);
167
+ this.log.info('Services ready: ' + n);
150
168
  }
151
169
 
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.
152
174
  schedulePoll(delayMs) {
153
175
  if (this.pollTimer) clearTimeout(this.pollTimer);
154
176
  this.pollTimer = setTimeout(() => this.poll(), delayMs);
@@ -158,59 +180,69 @@ class PaxCalimaAccessory {
158
180
  if (this.polling) { this.schedulePoll(POLL_MS); return; }
159
181
  this.polling = true;
160
182
  let peripheral = null;
183
+
161
184
  try {
162
185
  peripheral = await this.findPeripheral();
163
186
  await peripheral.connectAsync();
164
187
 
165
- const authChar = await this.getCharacteristic(peripheral, UUID_AUTH);
188
+ // Discover and cache all characteristics for this connection
189
+ const charCache = {};
190
+ const services = await peripheral.discoverServicesAsync();
191
+ for (const svc of services) {
192
+ const chars = await svc.discoverCharacteristicsAsync();
193
+ for (const c of chars) charCache[c.uuid.replace(/-/g, '')] = c;
194
+ }
195
+
196
+ // Authenticate
197
+ const authChar = charCache[UUID_AUTH.replace(/-/g, '')];
198
+ if (!authChar) throw new Error('Auth characteristic not found');
166
199
  const pinBuf = Buffer.alloc(4);
167
200
  pinBuf.writeUInt32LE(parseInt(this.pin, 10), 0);
168
201
  await authChar.writeAsync(pinBuf, true);
169
202
 
170
- const statusChar = await this.getCharacteristic(peripheral, UUID_STATUS);
203
+ // Read status: RPM + temperature
204
+ const statusChar = charCache[UUID_STATUS.replace(/-/g, '')];
205
+ if (!statusChar) throw new Error('Status characteristic not found');
171
206
  const statusData = await statusChar.readAsync();
172
207
  this.currentRPM = Math.max(0, statusData.readUInt16LE(6));
173
208
  this.temperature = Math.round((statusData.readUInt16LE(2) / 6) * 10) / 10;
174
209
 
175
- const sensorChar = await this.getCharacteristic(peripheral, UUID_SENSOR);
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');
176
213
  const sensorData = await sensorChar.readAsync();
177
214
  this.humidity = Math.max(0, Math.min(100, sensorData.readUInt8(0) * 2));
178
215
  this.lightLevel = Math.max(0.0001, sensorData.readUInt16BE(2));
179
216
 
180
- // Also log the raw control characteristic on each poll for diagnostics
181
- try {
182
- const ctrlChar = await this.getCharacteristic(peripheral, UUID_CONTROL);
183
- const ctrlData = await ctrlChar.readAsync();
184
- this.log.info('CTRL raw: ' + Array.from(ctrlData).join(',') +
185
- ' | UInt16s: ' + ctrlData.readUInt16LE(0) + ',' +
186
- ctrlData.readUInt16LE(2) + ',' + ctrlData.readUInt16LE(4));
187
- } catch (_) {}
188
-
217
+ // Push all values to HomeKit
189
218
  const speed = this.rpmToPercent(this.currentRPM);
190
219
  this.fanService.getCharacteristic(Characteristic.RotationSpeed).updateValue(speed);
191
220
  this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity).updateValue(this.humidity);
192
221
  this.tempService.getCharacteristic(Characteristic.CurrentTemperature).updateValue(this.temperature);
193
222
  this.lightService.getCharacteristic(Characteristic.CurrentAmbientLightLevel).updateValue(this.lightLevel);
194
223
 
195
- this.log.info('Status: RPM=' + this.currentRPM + ' Speed=' + speed +
224
+ this.log.info('Poll: RPM=' + this.currentRPM + ' Speed=' + speed +
196
225
  '% Hum=' + this.humidity + '% Temp=' + this.temperature +
197
226
  'C Light=' + this.lightLevel + 'lx');
198
227
 
199
228
  } catch (err) {
200
229
  this.log.warn('Poll error: ' + err.message);
201
230
  } finally {
231
+ // Always disconnect cleanly — required for fresh sensor reads next cycle
202
232
  if (peripheral) { try { await peripheral.disconnectAsync(); } catch (_) {} }
203
233
  this.polling = false;
204
234
  this.schedulePoll(POLL_MS);
205
235
  }
206
236
  }
207
237
 
238
+ // Targeted scan — resolves as soon as our MAC is seen, removes its own listener
208
239
  findPeripheral() {
209
240
  return new Promise((resolve, reject) => {
210
241
  const timeout = setTimeout(() => {
211
242
  noble.removeListener('discover', onDiscover);
212
243
  reject(new Error('Scan timeout for ' + this.mac));
213
- }, SCAN_TIMEOUT_MS);
244
+ }, 20000);
245
+
214
246
  const onDiscover = (periph) => {
215
247
  if (periph.address.toLowerCase() === this.mac) {
216
248
  clearTimeout(timeout);
@@ -218,57 +250,53 @@ class PaxCalimaAccessory {
218
250
  resolve(periph);
219
251
  }
220
252
  };
253
+
221
254
  noble.on('discover', onDiscover);
222
255
  noble.startScanning([], false);
223
256
  });
224
257
  }
225
258
 
226
- async getCharacteristic(peripheral, uuid) {
227
- const stripped = uuid.replace(/-/g, '');
228
- const services = await peripheral.discoverServicesAsync();
229
- for (const service of services) {
230
- const chars = await service.discoverCharacteristicsAsync();
231
- for (const char of chars) {
232
- if (char.uuid.replace(/-/g, '') === stripped) return char;
233
- }
234
- }
235
- throw new Error('Characteristic ' + uuid + ' not found');
236
- }
237
-
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.
238
262
  async writeSpeed(percent) {
239
- // Pause the poll cycle to avoid BLE conflicts during a write
240
- if (this.pollTimer) { clearTimeout(this.pollTimer); this.pollTimer = null; }
241
-
263
+ if (this.polling) {
264
+ this.log.warn('Speed write skipped — poll in progress');
265
+ return;
266
+ }
242
267
  let peripheral = null;
243
268
  try {
244
269
  peripheral = await this.findPeripheral();
245
270
  await peripheral.connectAsync();
246
271
 
247
- const authChar = await this.getCharacteristic(peripheral, UUID_AUTH);
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
+ }
278
+
279
+ const authChar = charCache[UUID_AUTH.replace(/-/g, '')];
280
+ if (!authChar) throw new Error('Auth characteristic not found');
248
281
  const pinBuf = Buffer.alloc(4);
249
282
  pinBuf.writeUInt32LE(parseInt(this.pin, 10), 0);
250
283
  await authChar.writeAsync(pinBuf, true);
251
284
 
252
- // Read current control config to preserve trickle/boost values
253
- const ctrlChar = await this.getCharacteristic(peripheral, UUID_CONTROL);
254
- const current = await ctrlChar.readAsync();
255
- this.log.info('CTRL before write: ' + Array.from(current).join(','));
285
+ const ctrlChar = charCache[UUID_CONTROL.replace(/-/g, '')];
286
+ if (!ctrlChar) throw new Error('Control characteristic not found');
256
287
 
257
- // Control characteristic layout (6 bytes, all UInt16LE):
258
- // [0-1] trickle RPM, [2-3] normal RPM, [4-5] boost RPM
259
- // We write the target RPM into the normal speed slot [2-3]
288
+ // Preserve existing trickle + boost values, update normal speed slot only
289
+ const current = await ctrlChar.readAsync();
260
290
  const targetRPM = Math.round((percent / 100) * 2400);
261
- const buf = Buffer.from(current); // preserve existing trickle + boost values
262
- buf.writeUInt16LE(targetRPM, 2); // set normal speed
291
+ const buf = Buffer.from(current);
292
+ buf.writeUInt16LE(targetRPM, 2);
263
293
  await ctrlChar.writeAsync(buf, true);
294
+ this.log.info('Speed set to ' + percent + '% (' + targetRPM + ' RPM)');
264
295
 
265
- this.log.info('Speed set to ' + percent + '% (' + targetRPM + ' RPM) — CTRL written: ' + Array.from(buf).join(','));
266
- } catch (e) {
267
- this.log.error('writeSpeed failed: ' + e.message);
296
+ } catch (err) {
297
+ this.log.error('writeSpeed failed: ' + err.message);
268
298
  } finally {
269
299
  if (peripheral) { try { await peripheral.disconnectAsync(); } catch (_) {} }
270
- // Resume polling after write
271
- this.schedulePoll(2000);
272
300
  }
273
301
  }
274
302
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-pax-calima",
3
- "version": "2.3.2",
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"