homebridge-pax-calima 2.3.2 → 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
@@ -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 and share the same BLE protocol and MAC prefix (`58:2b:db`).
6
12
 
7
13
  ---
8
14
 
@@ -10,15 +16,14 @@ 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 switch — suppresses automatic boosting during quiet periods |
26
+ | **Automations** | Full HomeKit automation support on all sensors |
22
27
 
23
28
  ---
24
29
 
@@ -26,17 +31,15 @@ Full HomeKit integration for **Pax Calima** (Scandinavia) and **Vent-Axia Svara*
26
31
 
27
32
  - Homebridge `^1.6.0` or `^2.0.0`
28
33
  - Node.js `>= 18`
29
- - A Bluetooth LE adapter on the Homebridge host (built-in or USB dongle)
34
+ - A Bluetooth LE adapter on the Homebridge host
30
35
 
31
- ### Raspberry Pi / Homebridge OS setup
32
-
33
- The `noble` BLE library requires elevated network capabilities. Run **once** after installation:
36
+ ### Raspberry Pi / Homebridge OS — one-time setup
34
37
 
35
38
  ```bash
36
39
  sudo setcap cap_net_raw+eip /opt/homebridge/bin/node
37
40
  ```
38
41
 
39
- If Bluetooth shows as `poweredOff` after this, unblock the radio:
42
+ If Bluetooth shows as `poweredOff`:
40
43
 
41
44
  ```bash
42
45
  sudo rfkill unblock bluetooth
@@ -58,9 +61,7 @@ npm install -g homebridge-pax-calima
58
61
 
59
62
  ## Configuration
60
63
 
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:
64
+ No configuration needed for auto-discovery. To set a custom name or PIN per fan:
64
65
 
65
66
  ```json
66
67
  {
@@ -76,93 +77,79 @@ To set a **custom name** or **PIN** for a specific fan, add an entry to the `fan
76
77
  }
77
78
  ```
78
79
 
79
- The `fans` array is entirely optional. Omit it to use auto-discovery with default names and PIN `012345`.
80
+ The `fans` array is optional. Default PIN is `012345`.
80
81
 
81
82
  ---
82
83
 
83
84
  ## Accessories in Apple Home
84
85
 
85
- Each discovered fan appears in the Home app as a single accessory with the following tiles:
86
+ Each fan appears as a single accessory with:
86
87
 
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)
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
93
94
 
94
- > Note: Accessories appear in the **Home app**, not in the Homebridge UI accessories tab. This is correct behaviour for child bridge plugins.
95
+ > Accessories appear in the **Home app**, not the Homebridge UI accessories tab. This is correct for child bridge plugins.
95
96
 
96
97
  ---
97
98
 
98
- ## Fan Speed Control — Wiring Mode Note
99
+ ## Fan Speed Control — Wiring Mode
99
100
 
100
- The Pax Calima and Vent-Axia Svara support two wiring modes, selected at installation time:
101
+ The fan supports two installation wiring modes:
101
102
 
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.
103
+ **Intermittent mode** — fan is off by default, triggered by sensors or timer. BLE speed commands work fully; the slider controls the fan directly.
103
104
 
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.
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.
105
106
 
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.
107
-
108
- To enable full speed control, consult your fan's installation guide to switch to intermittent wiring mode.
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.
109
108
 
110
109
  ---
111
110
 
112
111
  ## Sensor Notes
113
112
 
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.
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.
115
114
 
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.
115
+ For lux-based automations, use **Eve** or **Hesperus** which support lux thresholds synced to HomeKit.
117
116
 
118
117
  ---
119
118
 
120
119
  ## Troubleshooting
121
120
 
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`
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`.
126
122
 
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.
123
+ **Authentication failed** — default PIN is `012345`. If changed in the official app, add the fan to the `fans` config with the correct PIN.
129
124
 
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.
125
+ **Speed slider has no effect** — fan is wired in always-on mode. See wiring note above. All sensors work normally.
132
126
 
133
- **Accessories not appearing in Home app**
134
- - Remove the accessory cache via Homebridge UI → (plugin settings) → Remove Accessory Cache, then restart Homebridge
127
+ **Accessories not in Home app** — clear accessory cache via Homebridge UI → plugin settings → Remove Accessory Cache, then restart.
135
128
 
136
129
  ---
137
130
 
138
131
  ## Release Notes
139
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
+
140
143
  ### 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
144
+ - Documented always-on vs intermittent wiring mode behaviour for speed control
144
145
 
145
146
  ### 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
153
-
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
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
166
153
 
167
154
  ### 1.0.x
168
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.2",
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": [