homebridge-pax-calima 2.4.0 → 2.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -26
- package/index.js +102 -113
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
Full HomeKit integration for **Pax Calima** and **Vent-Axia Lo-Carbon Svara** Bluetooth LE bathroom extractor fans.
|
|
6
6
|
|
|
7
|
-
Compatible devices
|
|
7
|
+
**Compatible devices:**
|
|
8
8
|
- **Pax Calima** — Sweden, Norway, Denmark, Finland, Spain, Portugal
|
|
9
|
-
- **Vent-Axia Lo-Carbon Svara**
|
|
9
|
+
- **Vent-Axia Lo-Carbon Svara** White & Black — UK
|
|
10
10
|
|
|
11
|
-
All are hardware-identical
|
|
11
|
+
All are hardware-identical, sharing the same BLE protocol and MAC prefix (`58:2b:db`).
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
|
@@ -22,7 +22,7 @@ All are hardware-identical and share the same BLE protocol and MAC prefix (`58:2
|
|
|
22
22
|
| **Temperature sensor** | Live readings, updates every 10 seconds |
|
|
23
23
|
| **Light sensor** | Live lux readings, updates every 10 seconds |
|
|
24
24
|
| **Boost button** | Stateless programmable switch |
|
|
25
|
-
| **Silent Hours** | Toggle
|
|
25
|
+
| **Silent Hours** | Toggle — suppresses automatic boosting during quiet periods |
|
|
26
26
|
| **Automations** | Full HomeKit automation support on all sensors |
|
|
27
27
|
|
|
28
28
|
---
|
|
@@ -30,11 +30,23 @@ All are hardware-identical and share the same BLE protocol and MAC prefix (`58:2
|
|
|
30
30
|
## Requirements
|
|
31
31
|
|
|
32
32
|
- Homebridge `^1.6.0` or `^2.0.0`
|
|
33
|
-
- Node.js `>= 18`
|
|
33
|
+
- Node.js `>= 18.20.4`
|
|
34
34
|
- A Bluetooth LE adapter on the Homebridge host
|
|
35
35
|
|
|
36
|
+
### Homebridge 2.0 Compliance
|
|
37
|
+
|
|
38
|
+
This plugin is fully compliant with Homebridge 2.0:
|
|
39
|
+
- Implemented as a **dynamic platform** (`registerPlatform` with `true` flag)
|
|
40
|
+
- Waits for `didFinishLaunching` before any BLE or accessory operations
|
|
41
|
+
- All errors caught and logged — no unhandled exceptions
|
|
42
|
+
- No analytics, no tracking, no post-install scripts
|
|
43
|
+
- Accessories restored from cache without re-registration
|
|
44
|
+
- Compatible with Node.js 18, 20 and 22
|
|
45
|
+
|
|
36
46
|
### Raspberry Pi / Homebridge OS — one-time setup
|
|
37
47
|
|
|
48
|
+
The `noble` BLE library requires elevated network capabilities. Run **once** after installation on the **Homebridge terminal**:
|
|
49
|
+
|
|
38
50
|
```bash
|
|
39
51
|
sudo setcap cap_net_raw+eip /opt/homebridge/bin/node
|
|
40
52
|
```
|
|
@@ -45,6 +57,8 @@ If Bluetooth shows as `poweredOff`:
|
|
|
45
57
|
sudo rfkill unblock bluetooth
|
|
46
58
|
```
|
|
47
59
|
|
|
60
|
+
If the above does not resolve the issue, a full Pi power cycle may be required to reinitialise the BLE stack cleanly.
|
|
61
|
+
|
|
48
62
|
---
|
|
49
63
|
|
|
50
64
|
## Installation
|
|
@@ -61,7 +75,7 @@ npm install -g homebridge-pax-calima
|
|
|
61
75
|
|
|
62
76
|
## Configuration
|
|
63
77
|
|
|
64
|
-
|
|
78
|
+
Auto-discovery requires **no configuration**. To set a custom name or PIN per fan:
|
|
65
79
|
|
|
66
80
|
```json
|
|
67
81
|
{
|
|
@@ -92,35 +106,39 @@ Each fan appears as a single accessory with:
|
|
|
92
106
|
- **Temperature** — °C
|
|
93
107
|
- **Light** — lux
|
|
94
108
|
|
|
95
|
-
> Accessories appear in the **Home app**, not the Homebridge UI accessories tab. This is correct for child bridge plugins.
|
|
109
|
+
> Accessories appear in the **Home app**, not the Homebridge UI accessories tab. This is correct behaviour for child bridge plugins.
|
|
96
110
|
|
|
97
111
|
---
|
|
98
112
|
|
|
99
113
|
## Fan Speed Control — Wiring Mode
|
|
100
114
|
|
|
101
|
-
The fan supports two installation wiring modes:
|
|
115
|
+
The fan supports two installation wiring modes selected at installation time:
|
|
102
116
|
|
|
103
|
-
**Intermittent mode** — fan is off by default, triggered by sensors or timer. BLE speed commands
|
|
117
|
+
**Intermittent mode** — fan is off by default, triggered by sensors or timer. BLE speed commands are fully respected and the slider controls the fan directly.
|
|
104
118
|
|
|
105
|
-
**Always-on (trickle) mode** — fan runs continuously at a speed set by its onboard controller. BLE speed writes are accepted but the firmware reasserts its own setpoint immediately. The slider accurately reflects current RPM but cannot override the onboard speed.
|
|
119
|
+
**Always-on (trickle) mode** — fan runs continuously at a speed set by its onboard controller. BLE speed writes are accepted but the firmware reasserts its own speed setpoint immediately. The slider accurately reflects current RPM but cannot override the onboard speed.
|
|
106
120
|
|
|
107
|
-
|
|
121
|
+
In always-on mode, the sensors and automations remain fully functional and represent the primary value of the integration — particularly for bathroom and wetroom use where humidity, temperature and light thresholds enable powerful Apple Home automations.
|
|
122
|
+
|
|
123
|
+
To enable full speed control, switch to intermittent wiring mode per the fan's installation guide.
|
|
108
124
|
|
|
109
125
|
---
|
|
110
126
|
|
|
111
127
|
## Sensor Notes
|
|
112
128
|
|
|
113
|
-
|
|
129
|
+
Humidity and light values are guaranteed fresh on every poll — the plugin uses a connect→read→disconnect cycle which forces the device's GATT server to return live readings rather than cached values.
|
|
130
|
+
|
|
131
|
+
The light sensor detects occupancy via movement and shadow rather than acting as a precision lux meter. Small variances from a handheld reference device are normal and expected, particularly with ceiling or wall mounting angles.
|
|
114
132
|
|
|
115
|
-
For lux-based automations
|
|
133
|
+
For lux-based automations use **Eve** or **Hesperus**, which support lux thresholds synced back to HomeKit.
|
|
116
134
|
|
|
117
135
|
---
|
|
118
136
|
|
|
119
137
|
## Troubleshooting
|
|
120
138
|
|
|
121
|
-
**Fan not discovered** — run `hciconfig` on Pi; confirm `UP RUNNING`. If `RF-KILL
|
|
139
|
+
**Fan not discovered** — run `hciconfig` on the Pi; confirm `UP RUNNING`. If showing `RF-KILL` run `sudo rfkill unblock bluetooth`. If still not found, perform a full Pi power cycle to reinitialise the BLE stack.
|
|
122
140
|
|
|
123
|
-
**Authentication failed** — default PIN is `012345`. If changed in the official app, add the fan to the `fans` config with the correct PIN.
|
|
141
|
+
**Authentication failed** — default PIN is `012345`. If changed in the official app, add the fan to the `fans` config array with the correct PIN.
|
|
124
142
|
|
|
125
143
|
**Speed slider has no effect** — fan is wired in always-on mode. See wiring note above. All sensors work normally.
|
|
126
144
|
|
|
@@ -130,24 +148,25 @@ For lux-based automations, use **Eve** or **Hesperus** which support lux thresho
|
|
|
130
148
|
|
|
131
149
|
## Release Notes
|
|
132
150
|
|
|
151
|
+
### 2.4.1
|
|
152
|
+
- **Fixed:** Humidity and light sensors returning stale values — root cause identified as the device's GATT server only refreshing sensor values on a new connection. Poll cycle reverted to proven connect→auth→read→disconnect pattern which guarantees live readings on every poll
|
|
153
|
+
- **Fixed:** Characteristics discovered and cached once per poll cycle — eliminates the intermittent `Characteristic not found` errors seen in 2.4.0
|
|
154
|
+
- **Fixed:** Overlapping poll guard prevents concurrent BLE connections
|
|
155
|
+
- **Improved:** `writeSpeed` correctly skips if a poll is in progress to avoid BLE conflicts
|
|
156
|
+
- **Improved:** Node.js minimum version updated to `18.20.4` per Homebridge 2.0 specification
|
|
157
|
+
- **Documented:** Homebridge 2.0 compliance details added to README
|
|
158
|
+
|
|
133
159
|
### 2.4.0
|
|
134
|
-
- **Rebuilt:**
|
|
135
|
-
- **Improved:** Both sensor characteristics now read in parallel via `Promise.all` — faster polls
|
|
136
|
-
- **Improved:** Characteristics cached once per connection — no repeated service discovery
|
|
137
|
-
- **Improved:** Auth performed once per connection, not per poll
|
|
138
|
-
- **Improved:** Automatic reconnection on unexpected disconnect with clean cache flush
|
|
139
|
-
- **Fixed:** `Characteristic not found` intermittent errors eliminated — characteristic lookup now uses pre-cached map rather than repeated discovery
|
|
140
|
-
- **Fixed:** `writeSpeed` uses cached control characteristic and preserves trickle/boost values
|
|
141
|
-
- **Fixed:** Default accessory name changed to `Pax Calima` for international compatibility
|
|
160
|
+
- **Rebuilt:** Persistent connection architecture — connect once, cache characteristics, poll with direct reads. Fast RPM and temperature updates confirmed. Humidity and light subsequently found to return stale GATT cached values on persistent connections — resolved in 2.4.1
|
|
142
161
|
|
|
143
162
|
### 2.3.2
|
|
144
|
-
- Documented always-on vs intermittent wiring mode behaviour
|
|
163
|
+
- Documented always-on vs intermittent wiring mode behaviour
|
|
145
164
|
|
|
146
165
|
### 2.3.1
|
|
147
|
-
- Identified correct control characteristic;
|
|
166
|
+
- Identified correct control characteristic; write diagnostics added
|
|
148
167
|
|
|
149
168
|
### 2.3.0 / 2.2.1 / 2.1.0
|
|
150
|
-
- Sensor
|
|
169
|
+
- Sensor UUIDs and byte mappings confirmed via live device dump
|
|
151
170
|
- BLE initialisation fixes for Homebridge 2.0
|
|
152
171
|
- Duplicate accessory registration resolved
|
|
153
172
|
|
package/index.js
CHANGED
|
@@ -4,14 +4,14 @@ const noble = require('@abandonware/noble');
|
|
|
4
4
|
|
|
5
5
|
let Service, Characteristic, Categories, API, PlatformAccessory;
|
|
6
6
|
|
|
7
|
-
// ── Verified BLE UUIDs (confirmed via live device dump)
|
|
8
|
-
const UUID_AUTH = '4cad343a-209a-40b7-b911-4d9b3df569b2'; // PIN auth — write once
|
|
9
|
-
const UUID_STATUS = '528b80e8-c47a-4c0a-bdf1-916a7748f412'; // RPM (offset 6) + temp (offset 2)
|
|
10
|
-
const UUID_SENSOR = 'a22eae12-dba8-49f3-9c69-1721dcff1d96'; // humidity (offset 0) + lux (offset 2
|
|
11
|
-
const UUID_CONTROL = '1488a757-35bc-4ec8-9a6b-9ecf1502778e'; // 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
|
|
12
12
|
|
|
13
13
|
const MAC_PREFIX = '58:2b:db';
|
|
14
|
-
const POLL_MS = 10000;
|
|
14
|
+
const POLL_MS = 10000; // 10s — responsive for shower humidity spikes
|
|
15
15
|
const RECONNECT_MS = 15000;
|
|
16
16
|
|
|
17
17
|
module.exports = (homebridge) => {
|
|
@@ -21,30 +21,28 @@ module.exports = (homebridge) => {
|
|
|
21
21
|
API = homebridge;
|
|
22
22
|
PlatformAccessory = homebridge.platformAccessory;
|
|
23
23
|
|
|
24
|
-
//
|
|
24
|
+
// Dynamic platform — required for Homebridge 2.0
|
|
25
25
|
homebridge.registerPlatform('homebridge-pax-calima', 'PaxCalima', PaxCalimaPlatform, true);
|
|
26
26
|
};
|
|
27
27
|
|
|
28
28
|
// ─── Platform ─────────────────────────────────────────────────────────────────
|
|
29
29
|
class PaxCalimaPlatform {
|
|
30
30
|
constructor(log, config, api) {
|
|
31
|
-
this.log
|
|
32
|
-
this.config
|
|
33
|
-
this.api
|
|
34
|
-
this.registeredMACs
|
|
35
|
-
this.discoveryStarted
|
|
31
|
+
this.log = log;
|
|
32
|
+
this.config = config || {};
|
|
33
|
+
this.api = api;
|
|
34
|
+
this.registeredMACs = new Set();
|
|
35
|
+
this.discoveryStarted = false;
|
|
36
36
|
this.cachedAccessories = []; // held until BLE is ready after didFinishLaunching
|
|
37
37
|
|
|
38
38
|
this.log.info('PaxCalima platform initialised');
|
|
39
39
|
|
|
40
40
|
this.api.on('didFinishLaunching', () => {
|
|
41
41
|
this.log.info('Homebridge finished launching — starting BLE');
|
|
42
|
-
|
|
43
42
|
noble.on('stateChange', (state) => {
|
|
44
43
|
this.log.info('BLE state: ' + state);
|
|
45
44
|
if (state === 'poweredOn') this.startDiscovery();
|
|
46
45
|
});
|
|
47
|
-
|
|
48
46
|
// Catch case where noble is already poweredOn before listener fires
|
|
49
47
|
setTimeout(() => {
|
|
50
48
|
if (noble.state === 'poweredOn') this.startDiscovery();
|
|
@@ -53,7 +51,8 @@ class PaxCalimaPlatform {
|
|
|
53
51
|
}
|
|
54
52
|
|
|
55
53
|
// Called by Homebridge for each cached accessory on startup.
|
|
56
|
-
//
|
|
54
|
+
// Held until startDiscovery() confirms BLE is ready — prevents polling
|
|
55
|
+
// before the BLE stack is initialised.
|
|
57
56
|
configureAccessory(accessory) {
|
|
58
57
|
const mac = accessory.context && accessory.context.mac;
|
|
59
58
|
this.log.info('Restoring cached accessory: ' + accessory.displayName);
|
|
@@ -67,9 +66,9 @@ class PaxCalimaPlatform {
|
|
|
67
66
|
if (this.discoveryStarted) return;
|
|
68
67
|
this.discoveryStarted = true;
|
|
69
68
|
|
|
70
|
-
// Spin up handlers for all
|
|
69
|
+
// Spin up BLE handlers for all previously registered accessories
|
|
71
70
|
for (const { accessory, mac } of this.cachedAccessories) {
|
|
72
|
-
this.log.info('
|
|
71
|
+
this.log.info('Starting handler for cached fan: ' + mac);
|
|
73
72
|
new PaxCalimaAccessory(this.log, accessory, mac, this.pinForMac(mac));
|
|
74
73
|
}
|
|
75
74
|
this.cachedAccessories = [];
|
|
@@ -115,21 +114,18 @@ class PaxCalimaAccessory {
|
|
|
115
114
|
this.lightLevel = 0.0001;
|
|
116
115
|
this.silentHours = false;
|
|
117
116
|
|
|
118
|
-
|
|
119
|
-
this.
|
|
120
|
-
this.charCache = {}; // uuid (stripped) → characteristic — populated once per connection
|
|
121
|
-
this.connected = false;
|
|
122
|
-
this.pollTimer = null;
|
|
117
|
+
this.pollTimer = null;
|
|
118
|
+
this.polling = false; // guard against overlapping cycles
|
|
123
119
|
|
|
124
120
|
this.setupServices();
|
|
125
|
-
this.
|
|
121
|
+
this.schedulePoll(0); // BLE is ready by the time constructor is called
|
|
126
122
|
}
|
|
127
123
|
|
|
128
124
|
// ── HomeKit services ────────────────────────────────────────────────────────
|
|
129
125
|
setupServices() {
|
|
130
126
|
const n = this.accessory.displayName;
|
|
131
127
|
|
|
132
|
-
// Fan — always
|
|
128
|
+
// Fan — always active; speed reflects live RPM
|
|
133
129
|
this.fanService = this.accessory.getService(Service.Fanv2)
|
|
134
130
|
|| this.accessory.addService(new Service.Fanv2(n, 'fan'));
|
|
135
131
|
this.fanService.getCharacteristic(Characteristic.Active)
|
|
@@ -145,7 +141,7 @@ class PaxCalimaAccessory {
|
|
|
145
141
|
this.boostButton.getCharacteristic(Characteristic.ProgrammableSwitchEvent)
|
|
146
142
|
.setProps({ validValues: [0] });
|
|
147
143
|
|
|
148
|
-
// Silent Hours — suppresses automatic boosting
|
|
144
|
+
// Silent Hours — suppresses automatic boosting during quiet periods
|
|
149
145
|
this.silentService = this.accessory.getServiceByUUIDAndSubType(Service.Switch, 'silent-hours')
|
|
150
146
|
|| this.accessory.addService(new Service.Switch(n + ' Silent Hours', 'silent-hours'));
|
|
151
147
|
this.silentService.getCharacteristic(Characteristic.On)
|
|
@@ -171,56 +167,75 @@ class PaxCalimaAccessory {
|
|
|
171
167
|
this.log.info('Services ready: ' + n);
|
|
172
168
|
}
|
|
173
169
|
|
|
174
|
-
// ──
|
|
175
|
-
|
|
176
|
-
|
|
170
|
+
// ── Poll cycle: connect → auth → read → disconnect → wait → repeat ──────────
|
|
171
|
+
// The device's GATT server only returns fresh sensor values on a new connection.
|
|
172
|
+
// A persistent connection returns cached values for humidity and light.
|
|
173
|
+
// We therefore use short-lived connections — proven reliable in testing.
|
|
174
|
+
schedulePoll(delayMs) {
|
|
175
|
+
if (this.pollTimer) clearTimeout(this.pollTimer);
|
|
176
|
+
this.pollTimer = setTimeout(() => this.poll(), delayMs);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async poll() {
|
|
180
|
+
if (this.polling) { this.schedulePoll(POLL_MS); return; }
|
|
181
|
+
this.polling = true;
|
|
182
|
+
let peripheral = null;
|
|
177
183
|
|
|
178
184
|
try {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
this.charCache = {};
|
|
186
|
-
const services = await this.peripheral.discoverServicesAsync();
|
|
185
|
+
peripheral = await this.findPeripheral();
|
|
186
|
+
await peripheral.connectAsync();
|
|
187
|
+
|
|
188
|
+
// Discover and cache all characteristics for this connection
|
|
189
|
+
const charCache = {};
|
|
190
|
+
const services = await peripheral.discoverServicesAsync();
|
|
187
191
|
for (const svc of services) {
|
|
188
192
|
const chars = await svc.discoverCharacteristicsAsync();
|
|
189
|
-
for (const c of chars)
|
|
190
|
-
this.charCache[c.uuid.replace(/-/g, '')] = c;
|
|
191
|
-
}
|
|
193
|
+
for (const c of chars) charCache[c.uuid.replace(/-/g, '')] = c;
|
|
192
194
|
}
|
|
193
195
|
|
|
194
|
-
// Authenticate
|
|
195
|
-
const authChar =
|
|
196
|
+
// Authenticate
|
|
197
|
+
const authChar = charCache[UUID_AUTH.replace(/-/g, '')];
|
|
196
198
|
if (!authChar) throw new Error('Auth characteristic not found');
|
|
197
199
|
const pinBuf = Buffer.alloc(4);
|
|
198
200
|
pinBuf.writeUInt32LE(parseInt(this.pin, 10), 0);
|
|
199
201
|
await authChar.writeAsync(pinBuf, true);
|
|
200
|
-
this.log.info('Authenticated');
|
|
201
202
|
|
|
202
|
-
|
|
203
|
+
// Read status: RPM + temperature
|
|
204
|
+
const statusChar = charCache[UUID_STATUS.replace(/-/g, '')];
|
|
205
|
+
if (!statusChar) throw new Error('Status characteristic not found');
|
|
206
|
+
const statusData = await statusChar.readAsync();
|
|
207
|
+
this.currentRPM = Math.max(0, statusData.readUInt16LE(6));
|
|
208
|
+
this.temperature = Math.round((statusData.readUInt16LE(2) / 6) * 10) / 10;
|
|
203
209
|
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
setTimeout(() => this.connect(), RECONNECT_MS);
|
|
211
|
-
});
|
|
210
|
+
// Read sensors: humidity + lux — fresh values guaranteed on each new connection
|
|
211
|
+
const sensorChar = charCache[UUID_SENSOR.replace(/-/g, '')];
|
|
212
|
+
if (!sensorChar) throw new Error('Sensor characteristic not found');
|
|
213
|
+
const sensorData = await sensorChar.readAsync();
|
|
214
|
+
this.humidity = Math.max(0, Math.min(100, sensorData.readUInt8(0) * 2));
|
|
215
|
+
this.lightLevel = Math.max(0.0001, sensorData.readUInt16BE(2));
|
|
212
216
|
|
|
213
|
-
//
|
|
214
|
-
this.
|
|
217
|
+
// Push all values to HomeKit
|
|
218
|
+
const speed = this.rpmToPercent(this.currentRPM);
|
|
219
|
+
this.fanService.getCharacteristic(Characteristic.RotationSpeed).updateValue(speed);
|
|
220
|
+
this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity).updateValue(this.humidity);
|
|
221
|
+
this.tempService.getCharacteristic(Characteristic.CurrentTemperature).updateValue(this.temperature);
|
|
222
|
+
this.lightService.getCharacteristic(Characteristic.CurrentAmbientLightLevel).updateValue(this.lightLevel);
|
|
223
|
+
|
|
224
|
+
this.log.info('Poll: RPM=' + this.currentRPM + ' Speed=' + speed +
|
|
225
|
+
'% Hum=' + this.humidity + '% Temp=' + this.temperature +
|
|
226
|
+
'C Light=' + this.lightLevel + 'lx');
|
|
215
227
|
|
|
216
228
|
} catch (err) {
|
|
217
|
-
this.log.
|
|
218
|
-
|
|
219
|
-
|
|
229
|
+
this.log.warn('Poll error: ' + err.message);
|
|
230
|
+
} finally {
|
|
231
|
+
// Always disconnect cleanly — required for fresh sensor reads next cycle
|
|
232
|
+
if (peripheral) { try { await peripheral.disconnectAsync(); } catch (_) {} }
|
|
233
|
+
this.polling = false;
|
|
234
|
+
this.schedulePoll(POLL_MS);
|
|
220
235
|
}
|
|
221
236
|
}
|
|
222
237
|
|
|
223
|
-
// Targeted scan — resolves as soon as our
|
|
238
|
+
// Targeted scan — resolves as soon as our MAC is seen, removes its own listener
|
|
224
239
|
findPeripheral() {
|
|
225
240
|
return new Promise((resolve, reject) => {
|
|
226
241
|
const timeout = setTimeout(() => {
|
|
@@ -241,73 +256,47 @@ class PaxCalimaAccessory {
|
|
|
241
256
|
});
|
|
242
257
|
}
|
|
243
258
|
|
|
244
|
-
// ── Polling — uses cached characteristics, no reconnect overhead ────────────
|
|
245
|
-
async poll() {
|
|
246
|
-
if (!this.connected) return;
|
|
247
|
-
|
|
248
|
-
try {
|
|
249
|
-
const statusChar = this.charCache[UUID_STATUS.replace(/-/g, '')];
|
|
250
|
-
const sensorChar = this.charCache[UUID_SENSOR.replace(/-/g, '')];
|
|
251
|
-
|
|
252
|
-
if (!statusChar || !sensorChar) throw new Error('Characteristics not cached — reconnecting');
|
|
253
|
-
|
|
254
|
-
// Read both characteristics
|
|
255
|
-
const [statusData, sensorData] = await Promise.all([
|
|
256
|
-
statusChar.readAsync(),
|
|
257
|
-
sensorChar.readAsync(),
|
|
258
|
-
]);
|
|
259
|
-
|
|
260
|
-
// Parse — confirmed mappings from live device
|
|
261
|
-
this.currentRPM = Math.max(0, statusData.readUInt16LE(6));
|
|
262
|
-
this.temperature = Math.round((statusData.readUInt16LE(2) / 6) * 10) / 10;
|
|
263
|
-
this.humidity = Math.max(0, Math.min(100, sensorData.readUInt8(0) * 2));
|
|
264
|
-
this.lightLevel = Math.max(0.0001, sensorData.readUInt16BE(2));
|
|
265
|
-
|
|
266
|
-
const speed = this.rpmToPercent(this.currentRPM);
|
|
267
|
-
|
|
268
|
-
// Push to HomeKit
|
|
269
|
-
this.fanService.getCharacteristic(Characteristic.RotationSpeed).updateValue(speed);
|
|
270
|
-
this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity).updateValue(this.humidity);
|
|
271
|
-
this.tempService.getCharacteristic(Characteristic.CurrentTemperature).updateValue(this.temperature);
|
|
272
|
-
this.lightService.getCharacteristic(Characteristic.CurrentAmbientLightLevel).updateValue(this.lightLevel);
|
|
273
|
-
|
|
274
|
-
this.log.info('Poll: RPM=' + this.currentRPM + ' Speed=' + speed +
|
|
275
|
-
'% Hum=' + this.humidity + '% Temp=' + this.temperature +
|
|
276
|
-
'C Light=' + this.lightLevel + 'lx');
|
|
277
|
-
|
|
278
|
-
} catch (err) {
|
|
279
|
-
this.log.warn('Poll error: ' + err.message);
|
|
280
|
-
// If cache is stale, reconnect
|
|
281
|
-
if (!this.charCache[UUID_STATUS.replace(/-/g, '')]) {
|
|
282
|
-
this.connected = false;
|
|
283
|
-
setTimeout(() => this.connect(), RECONNECT_MS);
|
|
284
|
-
return;
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// Schedule next poll
|
|
289
|
-
this.pollTimer = setTimeout(() => this.poll(), POLL_MS);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
259
|
// ── Fan speed write ─────────────────────────────────────────────────────────
|
|
260
|
+
// Note: in always-on wiring mode the fan's onboard controller will reassert
|
|
261
|
+
// its own speed setpoint after each write. See README for details.
|
|
293
262
|
async writeSpeed(percent) {
|
|
294
|
-
if (
|
|
295
|
-
this.log.warn('
|
|
263
|
+
if (this.polling) {
|
|
264
|
+
this.log.warn('Speed write skipped — poll in progress');
|
|
296
265
|
return;
|
|
297
266
|
}
|
|
267
|
+
let peripheral = null;
|
|
298
268
|
try {
|
|
299
|
-
|
|
300
|
-
|
|
269
|
+
peripheral = await this.findPeripheral();
|
|
270
|
+
await peripheral.connectAsync();
|
|
271
|
+
|
|
272
|
+
const charCache = {};
|
|
273
|
+
const services = await peripheral.discoverServicesAsync();
|
|
274
|
+
for (const svc of services) {
|
|
275
|
+
const chars = await svc.discoverCharacteristicsAsync();
|
|
276
|
+
for (const c of chars) charCache[c.uuid.replace(/-/g, '')] = c;
|
|
277
|
+
}
|
|
301
278
|
|
|
302
|
-
|
|
303
|
-
|
|
279
|
+
const authChar = charCache[UUID_AUTH.replace(/-/g, '')];
|
|
280
|
+
if (!authChar) throw new Error('Auth characteristic not found');
|
|
281
|
+
const pinBuf = Buffer.alloc(4);
|
|
282
|
+
pinBuf.writeUInt32LE(parseInt(this.pin, 10), 0);
|
|
283
|
+
await authChar.writeAsync(pinBuf, true);
|
|
284
|
+
|
|
285
|
+
const ctrlChar = charCache[UUID_CONTROL.replace(/-/g, '')];
|
|
286
|
+
if (!ctrlChar) throw new Error('Control characteristic not found');
|
|
287
|
+
|
|
288
|
+
// Preserve existing trickle + boost values, update normal speed slot only
|
|
289
|
+
const current = await ctrlChar.readAsync();
|
|
304
290
|
const targetRPM = Math.round((percent / 100) * 2400);
|
|
305
|
-
const buf
|
|
306
|
-
buf.writeUInt16LE(targetRPM, 2);
|
|
291
|
+
const buf = Buffer.from(current);
|
|
292
|
+
buf.writeUInt16LE(targetRPM, 2);
|
|
307
293
|
await ctrlChar.writeAsync(buf, true);
|
|
308
294
|
this.log.info('Speed set to ' + percent + '% (' + targetRPM + ' RPM)');
|
|
295
|
+
|
|
309
296
|
} catch (err) {
|
|
310
297
|
this.log.error('writeSpeed failed: ' + err.message);
|
|
298
|
+
} finally {
|
|
299
|
+
if (peripheral) { try { await peripheral.disconnectAsync(); } catch (_) {} }
|
|
311
300
|
}
|
|
312
301
|
}
|
|
313
302
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-pax-calima",
|
|
3
|
-
"version": "2.4.
|
|
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.
|
|
20
|
+
"node": ">=18.20.4"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@abandonware/noble": "1.9.2-26"
|