homebridge-pax-calima 2.3.1 → 2.4.0

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,27 +1,155 @@
1
1
  # homebridge-pax-calima
2
2
 
3
- Homebridge platform plugin for **Pax Calima** and **Vent-Axia Svara** Bluetooth LE bathroom extractor fans.
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
- The Vent-Axia Svara is the UK rebrand of the Pax Calima — they use the **exact same Bluetooth protocol**, MAC prefix (`58:2b:db`), and GATT characteristics.
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 and share the same BLE protocol and MAC prefix (`58:2b:db`).
12
+
13
+ ---
6
14
 
7
15
  ## Features
8
- - Automatic discovery of fans (MAC starting with `58:2b:db`)
9
- - Fan control (On/Off + Rotation Speed 0-100%)
10
- - Live sensors: Humidity, Temperature, Light level
11
- - Mode switching (Multi Mode, Heat Distribution)
12
- - Boost button (press to activate temporary high-speed boost)
13
- - Silent Hours toggle
14
- - Automatic cycle switches (30/60/90 min)
15
- - Trickle ventilation toggle
16
- - Easy configuration via Homebridge Config UI
17
-
18
- Perfect for Apple Home automations.
16
+
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 |
27
+
28
+ ---
29
+
30
+ ## Requirements
31
+
32
+ - Homebridge `^1.6.0` or `^2.0.0`
33
+ - Node.js `>= 18`
34
+ - A Bluetooth LE adapter on the Homebridge host
35
+
36
+ ### Raspberry Pi / Homebridge OS — one-time setup
37
+
38
+ ```bash
39
+ sudo setcap cap_net_raw+eip /opt/homebridge/bin/node
40
+ ```
41
+
42
+ If Bluetooth shows as `poweredOff`:
43
+
44
+ ```bash
45
+ sudo rfkill unblock bluetooth
46
+ ```
47
+
48
+ ---
19
49
 
20
50
  ## Installation
21
51
 
22
52
  ### Via Homebridge UI (recommended)
23
- Search for **Pax Calima** or **Svara** and install.
53
+ Search for **Pax Calima** or **Svara** and click Install.
24
54
 
25
55
  ### Via Terminal
26
56
  ```bash
27
57
  npm install -g homebridge-pax-calima
58
+ ```
59
+
60
+ ---
61
+
62
+ ## Configuration
63
+
64
+ No configuration needed for auto-discovery. To set a custom name or PIN per fan:
65
+
66
+ ```json
67
+ {
68
+ "platform": "PaxCalima",
69
+ "name": "Pax Calima / Vent-Axia Svara",
70
+ "fans": [
71
+ {
72
+ "name": "Wetroom Fan",
73
+ "mac": "58:2b:db:xx:xx:xx",
74
+ "pin": "012345"
75
+ }
76
+ ]
77
+ }
78
+ ```
79
+
80
+ The `fans` array is optional. Default PIN is `012345`.
81
+
82
+ ---
83
+
84
+ ## Accessories in Apple Home
85
+
86
+ Each fan appears as a single accessory with:
87
+
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
94
+
95
+ > Accessories appear in the **Home app**, not the Homebridge UI accessories tab. This is correct for child bridge plugins.
96
+
97
+ ---
98
+
99
+ ## Fan Speed Control — Wiring Mode
100
+
101
+ The fan supports two installation wiring modes:
102
+
103
+ **Intermittent mode** — fan is off by default, triggered by sensors or timer. BLE speed commands work fully; the slider controls the fan directly.
104
+
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.
106
+
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.
108
+
109
+ ---
110
+
111
+ ## Sensor Notes
112
+
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.
114
+
115
+ For lux-based automations, use **Eve** or **Hesperus** which support lux thresholds synced to HomeKit.
116
+
117
+ ---
118
+
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`.
122
+
123
+ **Authentication failed** — default PIN is `012345`. If changed in the official app, add the fan to the `fans` config with the correct PIN.
124
+
125
+ **Speed slider has no effect** — fan is wired in always-on mode. See wiring note above. All sensors work normally.
126
+
127
+ **Accessories not in Home app** — clear accessory cache via Homebridge UI → plugin settings → Remove Accessory Cache, then restart.
128
+
129
+ ---
130
+
131
+ ## Release Notes
132
+
133
+ ### 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
153
+
154
+ ### 1.0.x
155
+ - 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) ───────────────────────
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
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,33 +20,40 @@ module.exports = (homebridge) => {
17
20
  Categories = homebridge.hap.Categories;
18
21
  API = homebridge;
19
22
  PlatformAccessory = homebridge.platformAccessory;
23
+
24
+ // true = 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
- this.log = log;
26
- this.config = config || {};
27
- this.api = api;
28
- this.registeredMACs = new Set();
29
- this.discoveryStarted = false;
30
- this.cachedAccessories = [];
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
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');
42
+
36
43
  noble.on('stateChange', (state) => {
37
44
  this.log.info('BLE state: ' + state);
38
45
  if (state === 'poweredOn') this.startDiscovery();
39
46
  });
47
+
48
+ // Catch case where noble is already poweredOn before listener fires
40
49
  setTimeout(() => {
41
- this.log.info('BLE current state: ' + noble.state);
42
50
  if (noble.state === 'poweredOn') this.startDiscovery();
43
51
  }, 3000);
44
52
  });
45
53
  }
46
54
 
55
+ // Called by Homebridge for each cached accessory on startup.
56
+ // We hold them until startDiscovery() confirms BLE is ready.
47
57
  configureAccessory(accessory) {
48
58
  const mac = accessory.context && accessory.context.mac;
49
59
  this.log.info('Restoring cached accessory: ' + accessory.displayName);
@@ -57,13 +67,14 @@ class PaxCalimaPlatform {
57
67
  if (this.discoveryStarted) return;
58
68
  this.discoveryStarted = true;
59
69
 
70
+ // Spin up handlers for all cached (previously registered) accessories
60
71
  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);
72
+ this.log.info('Connecting to cached fan: ' + mac);
73
+ new PaxCalimaAccessory(this.log, accessory, mac, this.pinForMac(mac));
64
74
  }
65
75
  this.cachedAccessories = [];
66
76
 
77
+ // Scan for any new fans not yet registered
67
78
  this.log.info('Starting BLE discovery scan...');
68
79
  noble.startScanning([], false);
69
80
 
@@ -71,23 +82,25 @@ class PaxCalimaPlatform {
71
82
  const mac = peripheral.address.toLowerCase();
72
83
  if (!mac.startsWith(MAC_PREFIX)) return;
73
84
  if (this.registeredMACs.has(mac)) return;
85
+
74
86
  this.registeredMACs.add(mac);
75
87
  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);
88
+
89
+ const uuid = API.hap.uuid.generate('pax-calima-' + mac);
90
+ const hbAcc = new PlatformAccessory(this.nameForMac(mac), uuid, Categories.FAN);
80
91
  hbAcc.context.mac = mac;
81
- new PaxCalimaAccessory(this.log, hbAcc, mac, pin);
92
+
93
+ new PaxCalimaAccessory(this.log, hbAcc, mac, this.pinForMac(mac));
82
94
  this.api.registerPlatformAccessories('homebridge-pax-calima', 'PaxCalima', [hbAcc]);
83
95
  });
84
96
  }
85
97
 
86
98
  fanConfig(mac) { return (this.config.fans || []).find(f => f.mac && f.mac.toLowerCase() === mac) || {}; }
87
99
  pinForMac(mac) { return this.fanConfig(mac).pin || '012345'; }
88
- nameForMac(mac) { return this.fanConfig(mac).name || ('Vent-Axia Svara ' + mac.slice(-5).toUpperCase()); }
100
+ nameForMac(mac) { return this.fanConfig(mac).name || ('Pax Calima ' + mac.slice(-5).toUpperCase()); }
89
101
  }
90
102
 
103
+ // ─── Accessory ────────────────────────────────────────────────────────────────
91
104
  class PaxCalimaAccessory {
92
105
  constructor(log, accessory, mac, pin) {
93
106
  this.log = log;
@@ -95,22 +108,28 @@ class PaxCalimaAccessory {
95
108
  this.mac = mac.toLowerCase();
96
109
  this.pin = pin || '012345';
97
110
 
111
+ // Sensor state — served to HomeKit between polls
98
112
  this.currentRPM = 0;
99
113
  this.humidity = 0;
100
114
  this.temperature = 20;
101
115
  this.lightLevel = 0.0001;
102
116
  this.silentHours = false;
103
117
 
104
- this.pollTimer = null;
105
- this.polling = false;
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;
106
123
 
107
124
  this.setupServices();
108
- this.schedulePoll(0);
125
+ this.connect(); // connect immediately — BLE is ready by the time we're constructed
109
126
  }
110
127
 
128
+ // ── HomeKit services ────────────────────────────────────────────────────────
111
129
  setupServices() {
112
130
  const n = this.accessory.displayName;
113
131
 
132
+ // Fan — always reports as active; speed reflects live RPM
114
133
  this.fanService = this.accessory.getService(Service.Fanv2)
115
134
  || this.accessory.addService(new Service.Fanv2(n, 'fan'));
116
135
  this.fanService.getCharacteristic(Characteristic.Active)
@@ -120,17 +139,20 @@ class PaxCalimaAccessory {
120
139
  .onGet(() => this.rpmToPercent(this.currentRPM))
121
140
  .onSet((v) => this.writeSpeed(v));
122
141
 
142
+ // Boost — stateless single-press button
123
143
  this.boostButton = this.accessory.getService(Service.StatelessProgrammableSwitch)
124
144
  || this.accessory.addService(new Service.StatelessProgrammableSwitch(n + ' Boost', 'boost'));
125
145
  this.boostButton.getCharacteristic(Characteristic.ProgrammableSwitchEvent)
126
146
  .setProps({ validValues: [0] });
127
147
 
148
+ // Silent Hours — suppresses automatic boosting overnight
128
149
  this.silentService = this.accessory.getServiceByUUIDAndSubType(Service.Switch, 'silent-hours')
129
150
  || this.accessory.addService(new Service.Switch(n + ' Silent Hours', 'silent-hours'));
130
151
  this.silentService.getCharacteristic(Characteristic.On)
131
152
  .onGet(() => this.silentHours)
132
153
  .onSet((on) => this.setSilentHours(on));
133
154
 
155
+ // Sensors
134
156
  this.humidityService = this.accessory.getService(Service.HumiditySensor)
135
157
  || this.accessory.addService(new Service.HumiditySensor(n + ' Humidity', 'humidity'));
136
158
  this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity)
@@ -146,71 +168,66 @@ class PaxCalimaAccessory {
146
168
  this.lightService.getCharacteristic(Characteristic.CurrentAmbientLightLevel)
147
169
  .onGet(() => Math.max(0.0001, this.lightLevel));
148
170
 
149
- this.log.info('Services set up for: ' + n);
171
+ this.log.info('Services ready: ' + n);
150
172
  }
151
173
 
152
- schedulePoll(delayMs) {
153
- if (this.pollTimer) clearTimeout(this.pollTimer);
154
- this.pollTimer = setTimeout(() => this.poll(), delayMs);
155
- }
174
+ // ── BLE connection ──────────────────────────────────────────────────────────
175
+ async connect() {
176
+ this.log.info('Connecting to ' + this.mac + '...');
156
177
 
157
- async poll() {
158
- if (this.polling) { this.schedulePoll(POLL_MS); return; }
159
- this.polling = true;
160
- let peripheral = null;
161
178
  try {
162
- peripheral = await this.findPeripheral();
163
- await peripheral.connectAsync();
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();
187
+ for (const svc of services) {
188
+ const chars = await svc.discoverCharacteristicsAsync();
189
+ for (const c of chars) {
190
+ this.charCache[c.uuid.replace(/-/g, '')] = c;
191
+ }
192
+ }
164
193
 
165
- const authChar = await this.getCharacteristic(peripheral, UUID_AUTH);
194
+ // Authenticate once — stays valid for the life of this connection
195
+ const authChar = this.charCache[UUID_AUTH.replace(/-/g, '')];
196
+ if (!authChar) throw new Error('Auth characteristic not found');
166
197
  const pinBuf = Buffer.alloc(4);
167
198
  pinBuf.writeUInt32LE(parseInt(this.pin, 10), 0);
168
199
  await authChar.writeAsync(pinBuf, true);
200
+ this.log.info('Authenticated');
169
201
 
170
- const statusChar = await this.getCharacteristic(peripheral, UUID_STATUS);
171
- const statusData = await statusChar.readAsync();
172
- this.currentRPM = Math.max(0, statusData.readUInt16LE(6));
173
- this.temperature = Math.round((statusData.readUInt16LE(2) / 6) * 10) / 10;
174
-
175
- const sensorChar = await this.getCharacteristic(peripheral, UUID_SENSOR);
176
- const sensorData = await sensorChar.readAsync();
177
- this.humidity = Math.max(0, Math.min(100, sensorData.readUInt8(0) * 2));
178
- this.lightLevel = Math.max(0.0001, sensorData.readUInt16BE(2));
202
+ this.connected = true;
179
203
 
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
-
189
- const speed = this.rpmToPercent(this.currentRPM);
190
- this.fanService.getCharacteristic(Characteristic.RotationSpeed).updateValue(speed);
191
- this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity).updateValue(this.humidity);
192
- this.tempService.getCharacteristic(Characteristic.CurrentTemperature).updateValue(this.temperature);
193
- this.lightService.getCharacteristic(Characteristic.CurrentAmbientLightLevel).updateValue(this.lightLevel);
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
+ });
194
212
 
195
- this.log.info('Status: RPM=' + this.currentRPM + ' Speed=' + speed +
196
- '% Hum=' + this.humidity + '% Temp=' + this.temperature +
197
- 'C Light=' + this.lightLevel + 'lx');
213
+ // Start polling immediately
214
+ this.poll();
198
215
 
199
216
  } catch (err) {
200
- this.log.warn('Poll error: ' + err.message);
201
- } finally {
202
- if (peripheral) { try { await peripheral.disconnectAsync(); } catch (_) {} }
203
- this.polling = false;
204
- this.schedulePoll(POLL_MS);
217
+ this.log.error('Connection failed: ' + err.message);
218
+ this.connected = false;
219
+ setTimeout(() => this.connect(), RECONNECT_MS);
205
220
  }
206
221
  }
207
222
 
223
+ // Targeted scan — resolves as soon as our specific MAC is seen, removes its own listener
208
224
  findPeripheral() {
209
225
  return new Promise((resolve, reject) => {
210
226
  const timeout = setTimeout(() => {
211
227
  noble.removeListener('discover', onDiscover);
212
228
  reject(new Error('Scan timeout for ' + this.mac));
213
- }, SCAN_TIMEOUT_MS);
229
+ }, 20000);
230
+
214
231
  const onDiscover = (periph) => {
215
232
  if (periph.address.toLowerCase() === this.mac) {
216
233
  clearTimeout(timeout);
@@ -218,57 +235,79 @@ class PaxCalimaAccessory {
218
235
  resolve(periph);
219
236
  }
220
237
  };
238
+
221
239
  noble.on('discover', onDiscover);
222
240
  noble.startScanning([], false);
223
241
  });
224
242
  }
225
243
 
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;
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;
233
285
  }
234
286
  }
235
- throw new Error('Characteristic ' + uuid + ' not found');
287
+
288
+ // Schedule next poll
289
+ this.pollTimer = setTimeout(() => this.poll(), POLL_MS);
236
290
  }
237
291
 
292
+ // ── Fan speed write ─────────────────────────────────────────────────────────
238
293
  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
-
242
- let peripheral = null;
294
+ if (!this.connected) {
295
+ this.log.warn('Cannot set speed — not connected');
296
+ return;
297
+ }
243
298
  try {
244
- peripheral = await this.findPeripheral();
245
- await peripheral.connectAsync();
299
+ const ctrlChar = this.charCache[UUID_CONTROL.replace(/-/g, '')];
300
+ if (!ctrlChar) throw new Error('Control characteristic not cached');
246
301
 
247
- const authChar = await this.getCharacteristic(peripheral, UUID_AUTH);
248
- const pinBuf = Buffer.alloc(4);
249
- pinBuf.writeUInt32LE(parseInt(this.pin, 10), 0);
250
- await authChar.writeAsync(pinBuf, true);
251
-
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(','));
256
-
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]
302
+ // Read current config to preserve trickle + boost values
303
+ const current = await ctrlChar.readAsync();
260
304
  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
305
+ const buf = Buffer.from(current);
306
+ buf.writeUInt16LE(targetRPM, 2); // bytes 2-3 = normal speed slot
263
307
  await ctrlChar.writeAsync(buf, true);
264
-
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);
268
- } finally {
269
- if (peripheral) { try { await peripheral.disconnectAsync(); } catch (_) {} }
270
- // Resume polling after write
271
- this.schedulePoll(2000);
308
+ this.log.info('Speed set to ' + percent + '% (' + targetRPM + ' RPM)');
309
+ } catch (err) {
310
+ this.log.error('writeSpeed failed: ' + err.message);
272
311
  }
273
312
  }
274
313
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-pax-calima",
3
- "version": "2.3.1",
3
+ "version": "2.4.0",
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": [