homebridge-pax-calima 2.2.1 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/index.js +219 -160
  2. package/package.json +4 -3
package/index.js CHANGED
@@ -1,224 +1,283 @@
1
1
  const noble = require('@abandonware/noble');
2
2
 
3
- let Service, Characteristic, Accessory, Categories, API, PlatformAccessory;
3
+ let Service, Characteristic, Categories, API, PlatformAccessory;
4
+
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
9
+
10
+ const MAC_PREFIX = '58:2b:db';
11
+ const POLL_MS = 10000;
12
+ const SCAN_TIMEOUT_MS = 15000;
4
13
 
5
14
  module.exports = (homebridge) => {
6
- Service = homebridge.hap.Service;
7
- Characteristic = homebridge.hap.Characteristic;
8
- Accessory = homebridge.hap.Accessory;
9
- Categories = homebridge.hap.Categories;
10
- API = homebridge;
15
+ Service = homebridge.hap.Service;
16
+ Characteristic = homebridge.hap.Characteristic;
17
+ Categories = homebridge.hap.Categories;
18
+ API = homebridge;
11
19
  PlatformAccessory = homebridge.platformAccessory;
12
-
13
20
  homebridge.registerPlatform('homebridge-pax-calima', 'PaxCalima', PaxCalimaPlatform, true);
14
21
  };
15
22
 
16
23
  class PaxCalimaPlatform {
17
24
  constructor(log, config, api) {
18
- this.log = log;
19
- this.config = config || {};
20
- this.api = api;
21
- this.knownMACs = new Set();
22
-
23
- this.log.info('PaxCalima platform initialized');
24
-
25
- noble.on('stateChange', (state) => {
26
- this.log.info(`BLE state changed to: ${state}`);
27
- if (state === 'poweredOn') {
28
- this.log.info('✅ BLE adapter powered on — starting discovery');
29
- this.startDiscovery();
30
- } else {
31
- this.log.warn(`BLE adapter is ${state}`);
32
- }
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
+
32
+ this.log.info('PaxCalima platform initialised');
33
+
34
+ this.api.on('didFinishLaunching', () => {
35
+ this.log.info('Homebridge finished launching - starting BLE');
36
+ noble.on('stateChange', (state) => {
37
+ this.log.info('BLE state: ' + state);
38
+ if (state === 'poweredOn') this.startDiscovery();
39
+ });
40
+ setTimeout(() => {
41
+ this.log.info('BLE current state: ' + noble.state);
42
+ if (noble.state === 'poweredOn') this.startDiscovery();
43
+ }, 3000);
33
44
  });
45
+ }
34
46
 
35
- setTimeout(() => {
36
- this.log.info(`Initial BLE state: ${noble.state}`);
37
- if (noble.state === 'poweredOn') this.startDiscovery();
38
- }, 3000);
47
+ configureAccessory(accessory) {
48
+ const mac = accessory.context && accessory.context.mac;
49
+ this.log.info('Restoring cached accessory: ' + accessory.displayName);
50
+ if (mac) {
51
+ this.registeredMACs.add(mac);
52
+ this.cachedAccessories.push({ accessory, mac });
53
+ }
39
54
  }
40
55
 
41
56
  startDiscovery() {
42
- this.log.info('Starting BLE scan...');
57
+ if (this.discoveryStarted) return;
58
+ this.discoveryStarted = true;
59
+
60
+ 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);
64
+ }
65
+ this.cachedAccessories = [];
66
+
67
+ this.log.info('Starting BLE discovery scan...');
43
68
  noble.startScanning([], false);
44
69
 
45
70
  noble.on('discover', (peripheral) => {
46
71
  const mac = peripheral.address.toLowerCase();
47
- if (mac.startsWith('58:2b:db') && !this.knownMACs.has(mac)) {
48
- this.knownMACs.add(mac);
49
- this.log.info(`✅ Discovered Vent-Axia Svara: ${mac}`);
50
- const acc = new PaxCalimaAccessory(this.log, mac, this.api);
51
- this.api.registerPlatformAccessories('homebridge-pax-calima', 'PaxCalima', [acc.getAccessory()]);
52
- }
72
+ if (!mac.startsWith(MAC_PREFIX)) return;
73
+ if (this.registeredMACs.has(mac)) return;
74
+ this.registeredMACs.add(mac);
75
+ 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);
80
+ hbAcc.context.mac = mac;
81
+ new PaxCalimaAccessory(this.log, hbAcc, mac, pin);
82
+ this.api.registerPlatformAccessories('homebridge-pax-calima', 'PaxCalima', [hbAcc]);
53
83
  });
54
84
  }
55
85
 
56
- configureAccessory(accessory) {
57
- this.log.info(`Restoring cached accessory: ${accessory.displayName}`);
58
- }
86
+ fanConfig(mac) { return (this.config.fans || []).find(f => f.mac && f.mac.toLowerCase() === mac) || {}; }
87
+ pinForMac(mac) { return this.fanConfig(mac).pin || '012345'; }
88
+ nameForMac(mac) { return this.fanConfig(mac).name || ('Vent-Axia Svara ' + mac.slice(-5).toUpperCase()); }
59
89
  }
60
90
 
61
91
  class PaxCalimaAccessory {
62
- constructor(log, mac, api) {
63
- this.log = log;
64
- this.mac = mac.toLowerCase();
65
- this.name = `Vent-Axia Svara ${this.mac.slice(-5).toUpperCase()}`;
66
- this.pin = '012345';
67
-
68
- const uuid = API.hap.uuid.generate(`pax-svara-${this.mac}`);
69
- this.accessory = new PlatformAccessory(this.name, uuid, Categories.FAN);
92
+ constructor(log, accessory, mac, pin) {
93
+ this.log = log;
94
+ this.accessory = accessory;
95
+ this.mac = mac.toLowerCase();
96
+ this.pin = pin || '012345';
70
97
 
71
- this.peripheral = null;
72
- this.connected = false;
98
+ this.currentRPM = 0;
99
+ this.humidity = 0;
100
+ this.temperature = 20;
101
+ this.lightLevel = 0.0001;
102
+ this.silentHours = false;
73
103
 
74
- this.currentRPM = 0;
75
- this.humidity = 0;
76
- this.temperature = 0;
77
- this.lightLevel = 0;
78
- this.currentMode = 0;
104
+ this.pollTimer = null;
105
+ this.polling = false;
79
106
 
80
107
  this.setupServices();
81
- this.connectAndPoll();
108
+ this.schedulePoll(0);
82
109
  }
83
110
 
84
111
  setupServices() {
85
- // Fan
86
- this.fanService = new Service.Fanv2(this.name);
87
- this.fanService.getCharacteristic(Characteristic.Active).onGet(() => 1);
112
+ const n = this.accessory.displayName;
113
+
114
+ this.fanService = this.accessory.getService(Service.Fanv2)
115
+ || this.accessory.addService(new Service.Fanv2(n, 'fan'));
116
+ this.fanService.getCharacteristic(Characteristic.Active)
117
+ .onGet(() => 1);
88
118
  this.fanService.getCharacteristic(Characteristic.RotationSpeed)
89
119
  .setProps({ minValue: 0, maxValue: 100, minStep: 1 })
90
- .onGet(() => Math.min(100, Math.round(this.currentRPM / 25)))
91
- .onSet((value) => this.log.info(`Speed requested: ${value}%`));
92
-
93
- // Modes
94
- this.multiMode = new Service.Switch(`${this.name} - Multi Mode`);
95
- this.multiMode.getCharacteristic(Characteristic.On)
96
- .onGet(() => this.currentMode === 0)
97
- .onSet((on) => this.setMode(0, on));
98
-
99
- this.heatMode = new Service.Switch(`${this.name} - Heat Distribution`);
100
- this.heatMode.getCharacteristic(Characteristic.On)
101
- .onGet(() => this.currentMode === 4)
102
- .onSet((on) => this.setMode(4, on));
103
-
104
- // Boost Button
105
- this.boostButton = new Service.StatelessProgrammableSwitch(`${this.name} - Boost`);
106
- this.boostButton.getCharacteristic(Characteristic.ProgrammableSwitchEvent)
107
- .onSet(() => this.triggerBoost());
108
-
109
- // Silent Hours
110
- this.silentService = new Service.Switch(`${this.name} - Silent Hours`);
111
- this.silentService.getCharacteristic(Characteristic.On).onSet((on) => this.setSilentHours(on));
120
+ .onGet(() => this.rpmToPercent(this.currentRPM))
121
+ .onSet((v) => this.writeSpeed(v));
112
122
 
113
- // Sensors
114
- this.humidityService = new Service.HumiditySensor(`${this.name} Humidity`);
115
- this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity).onGet(() => this.humidity);
116
-
117
- this.tempService = new Service.TemperatureSensor(`${this.name} Temperature`);
118
- this.tempService.getCharacteristic(Characteristic.CurrentTemperature).onGet(() => this.temperature);
119
-
120
- this.lightService = new Service.LightSensor(`${this.name} Light`);
121
- this.lightService.getCharacteristic(Characteristic.CurrentAmbientLightLevel).onGet(() => this.lightLevel);
122
-
123
- this.accessory.addService(this.fanService);
124
- this.accessory.addService(this.multiMode);
125
- this.accessory.addService(this.heatMode);
126
- this.accessory.addService(this.boostButton);
127
- this.accessory.addService(this.silentService);
128
- this.accessory.addService(this.humidityService);
129
- this.accessory.addService(this.tempService);
130
- this.accessory.addService(this.lightService);
131
-
132
- this.log.info(`Accessory created with switches and sensors: ${this.name}`);
123
+ this.boostButton = this.accessory.getService(Service.StatelessProgrammableSwitch)
124
+ || this.accessory.addService(new Service.StatelessProgrammableSwitch(n + ' Boost', 'boost'));
125
+ this.boostButton.getCharacteristic(Characteristic.ProgrammableSwitchEvent)
126
+ .setProps({ validValues: [0] });
127
+
128
+ this.silentService = this.accessory.getServiceByUUIDAndSubType(Service.Switch, 'silent-hours')
129
+ || this.accessory.addService(new Service.Switch(n + ' Silent Hours', 'silent-hours'));
130
+ this.silentService.getCharacteristic(Characteristic.On)
131
+ .onGet(() => this.silentHours)
132
+ .onSet((on) => this.setSilentHours(on));
133
+
134
+ this.humidityService = this.accessory.getService(Service.HumiditySensor)
135
+ || this.accessory.addService(new Service.HumiditySensor(n + ' Humidity', 'humidity'));
136
+ this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity)
137
+ .onGet(() => this.humidity);
138
+
139
+ this.tempService = this.accessory.getService(Service.TemperatureSensor)
140
+ || this.accessory.addService(new Service.TemperatureSensor(n + ' Temperature', 'temperature'));
141
+ this.tempService.getCharacteristic(Characteristic.CurrentTemperature)
142
+ .onGet(() => this.temperature);
143
+
144
+ this.lightService = this.accessory.getService(Service.LightSensor)
145
+ || this.accessory.addService(new Service.LightSensor(n + ' Light', 'light'));
146
+ this.lightService.getCharacteristic(Characteristic.CurrentAmbientLightLevel)
147
+ .onGet(() => Math.max(0.0001, this.lightLevel));
148
+
149
+ this.log.info('Services set up for: ' + n);
133
150
  }
134
151
 
135
- async connectAndPoll() {
136
- this.log.info(`🔄 Attempting connection to ${this.mac}...`);
137
-
138
- if (noble.state !== 'poweredOn') {
139
- this.log.warn(`BLE state is ${noble.state} — waiting...`);
140
- setTimeout(() => this.connectAndPoll(), 8000);
141
- return;
142
- }
152
+ schedulePoll(delayMs) {
153
+ if (this.pollTimer) clearTimeout(this.pollTimer);
154
+ this.pollTimer = setTimeout(() => this.poll(), delayMs);
155
+ }
143
156
 
157
+ async poll() {
158
+ if (this.polling) { this.schedulePoll(POLL_MS); return; }
159
+ this.polling = true;
160
+ let peripheral = null;
144
161
  try {
145
- this.peripheral = await new Promise((resolve, reject) => {
146
- const timeout = setTimeout(() => reject(new Error('Timeout')), 15000);
147
- noble.startScanning([], false);
148
- noble.on('discover', (periph) => {
149
- if (periph.address.toLowerCase() === this.mac) {
150
- clearTimeout(timeout);
151
- noble.stopScanning();
152
- resolve(periph);
153
- }
154
- });
155
- });
162
+ peripheral = await this.findPeripheral();
163
+ await peripheral.connectAsync();
156
164
 
157
- await this.peripheral.connectAsync();
158
- this.connected = true;
159
- this.log.info(`✅ Connected to ${this.mac}`);
160
-
161
- // PIN Authentication
162
- const authChar = await this.getCharacteristic('4cad343a-209a-40b7-b911-4d9b3df569b2');
165
+ const authChar = await this.getCharacteristic(peripheral, UUID_AUTH);
163
166
  const pinBuf = Buffer.alloc(4);
164
- pinBuf.writeUInt32LE(parseInt(this.pin), 0);
167
+ pinBuf.writeUInt32LE(parseInt(this.pin, 10), 0);
165
168
  await authChar.writeAsync(pinBuf, true);
166
- this.log.info('✅ PIN authentication successful');
167
169
 
168
- this.pollStatus();
169
- } catch (error) {
170
- this.log.error(`Connection failed: ${error.message}`);
171
- setTimeout(() => this.connectAndPoll(), 15000);
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));
179
+
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);
194
+
195
+ this.log.info('Status: RPM=' + this.currentRPM + ' Speed=' + speed +
196
+ '% Hum=' + this.humidity + '% Temp=' + this.temperature +
197
+ 'C Light=' + this.lightLevel + 'lx');
198
+
199
+ } 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);
172
205
  }
173
206
  }
174
207
 
175
- async getCharacteristic(uuid) {
176
- const services = await this.peripheral.discoverServicesAsync();
208
+ findPeripheral() {
209
+ return new Promise((resolve, reject) => {
210
+ const timeout = setTimeout(() => {
211
+ noble.removeListener('discover', onDiscover);
212
+ reject(new Error('Scan timeout for ' + this.mac));
213
+ }, SCAN_TIMEOUT_MS);
214
+ const onDiscover = (periph) => {
215
+ if (periph.address.toLowerCase() === this.mac) {
216
+ clearTimeout(timeout);
217
+ noble.removeListener('discover', onDiscover);
218
+ resolve(periph);
219
+ }
220
+ };
221
+ noble.on('discover', onDiscover);
222
+ noble.startScanning([], false);
223
+ });
224
+ }
225
+
226
+ async getCharacteristic(peripheral, uuid) {
227
+ const stripped = uuid.replace(/-/g, '');
228
+ const services = await peripheral.discoverServicesAsync();
177
229
  for (const service of services) {
178
- const chars = await service.discoverCharacteristicsAsync([uuid]);
179
- if (chars.length > 0) return chars[0];
230
+ const chars = await service.discoverCharacteristicsAsync();
231
+ for (const char of chars) {
232
+ if (char.uuid.replace(/-/g, '') === stripped) return char;
233
+ }
180
234
  }
181
- throw new Error(`Characteristic ${uuid} not found`);
235
+ throw new Error('Characteristic ' + uuid + ' not found');
182
236
  }
183
237
 
184
- async pollStatus() {
185
- if (!this.connected) return;
238
+ 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; }
186
241
 
242
+ let peripheral = null;
187
243
  try {
188
- const sensorChar = await this.getCharacteristic('528b80e8-c47a-4c0a-bdf1-916a7748f412');
189
- const data = await sensorChar.readAsync();
244
+ peripheral = await this.findPeripheral();
245
+ await peripheral.connectAsync();
190
246
 
191
- this.humidity = Math.max(0, Math.min(100, Math.round(data.readUInt16LE(0) / 100)));
192
- this.temperature = Math.round((data.readUInt16LE(2) / 10) * 10) / 10;
193
- this.lightLevel = Math.max(0, data.readUInt16LE(4));
194
- this.currentRPM = Math.max(0, data.readUInt16LE(6));
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);
195
251
 
196
- this.fanService.getCharacteristic(Characteristic.RotationSpeed).updateValue(Math.min(100, Math.round(this.currentRPM / 25)));
197
- this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity).updateValue(this.humidity);
198
- this.tempService.getCharacteristic(Characteristic.CurrentTemperature).updateValue(this.temperature);
199
- this.lightService.getCharacteristic(Characteristic.CurrentAmbientLightLevel).updateValue(this.lightLevel);
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(','));
200
256
 
201
- this.log.info(`Status: RPM=${this.currentRPM}, Humidity=${this.humidity}%, Temp=${this.temperature}°C`);
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]
260
+ 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
263
+ await ctrlChar.writeAsync(buf, true);
264
+
265
+ this.log.info('Speed set to ' + percent + '% (' + targetRPM + ' RPM) — CTRL written: ' + Array.from(buf).join(','));
202
266
  } catch (e) {
203
- this.log.warn(`Poll failed: ${e.message}`);
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);
204
272
  }
205
-
206
- setTimeout(() => this.pollStatus(), 8000);
207
- }
208
-
209
- async setMode(modeValue, on) {
210
- this.log.info(`Mode set to ${on ? (modeValue === 0 ? 'Multi' : 'Heat Distribution') : 'Default'}`);
211
- }
212
-
213
- async triggerBoost() {
214
- this.log.info('Boost button pressed — high speed activated');
215
273
  }
216
274
 
217
275
  async setSilentHours(on) {
218
- this.log.info(`Silent Hours ${on ? 'enabled' : 'disabled'}`);
276
+ this.silentHours = on;
277
+ this.log.info('Silent Hours ' + (on ? 'enabled' : 'disabled'));
219
278
  }
220
279
 
221
- getAccessory() {
222
- return this.accessory;
280
+ rpmToPercent(rpm) {
281
+ return Math.min(100, Math.max(0, Math.round(rpm / 25)));
223
282
  }
224
283
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "homebridge-pax-calima",
3
- "version": "2.2.1",
4
- "description": "Homebridge plugin for Pax Calima and Vent-Axia Svara Bluetooth bathroom fans",
3
+ "version": "2.3.1",
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": [
7
7
  "homebridge-plugin",
@@ -16,7 +16,8 @@
16
16
  "bathroom"
17
17
  ],
18
18
  "engines": {
19
- "homebridge": "^1.6.0 || ^2.0.0"
19
+ "homebridge": "^1.6.0 || ^2.0.0",
20
+ "node": ">=18.0.0"
20
21
  },
21
22
  "dependencies": {
22
23
  "@abandonware/noble": "1.9.2-26"