homebridge-pax-calima 2.0.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,14 +1,27 @@
1
- # Homebridge Vent-Axia Pax Calima / Svara
1
+ # homebridge-pax-calima
2
2
 
3
- **✅ Homebridge 2.0 Ready**
3
+ Homebridge platform plugin for **Pax Calima** and **Vent-Axia Svara** Bluetooth LE bathroom extractor fans.
4
4
 
5
- Full HomeKit integration for the **Pax Calima** (Scandinavia) and **Vent-Axia Svara** (UK). These fans are hardware-identical and this plugin supports both interchangeably.
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.
6
6
 
7
7
  ## Features
8
- - **Variable Speed:** 0-100% slider control in Apple Home.
9
- - **Triple Sensors:** Real-time Humidity, Temperature, and Light level data.
10
- - **Automations:** Support for automations (e.g., boosting fan speed when humidity exceeds a threshold).
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.
11
19
 
12
20
  ## Installation
21
+
22
+ ### Via Homebridge UI (recommended)
23
+ Search for **Pax Calima** or **Svara** and install.
24
+
25
+ ### Via Terminal
13
26
  ```bash
14
27
  npm install -g homebridge-pax-calima
@@ -8,7 +8,7 @@
8
8
  "name": {
9
9
  "title": "Platform Name",
10
10
  "type": "string",
11
- "default": "Vent-Axia Pax Calima / Svara"
11
+ "default": "Pax Calima / Vent-Axia Svara"
12
12
  },
13
13
  "fans": {
14
14
  "title": "Configured Fans",
package/index.js CHANGED
@@ -1,119 +1,224 @@
1
1
  const noble = require('@abandonware/noble');
2
- let Service, Characteristic, PlatformAccessory;
2
+
3
+ let Service, Characteristic, Accessory, Categories, API, PlatformAccessory;
3
4
 
4
5
  module.exports = (homebridge) => {
5
6
  Service = homebridge.hap.Service;
6
7
  Characteristic = homebridge.hap.Characteristic;
8
+ Accessory = homebridge.hap.Accessory;
9
+ Categories = homebridge.hap.Categories;
10
+ API = homebridge;
7
11
  PlatformAccessory = homebridge.platformAccessory;
8
- homebridge.registerPlatform('homebridge-pax-calima', 'PaxCalima', PaxCalimaPlatform);
12
+
13
+ homebridge.registerPlatform('homebridge-pax-calima', 'PaxCalima', PaxCalimaPlatform, true);
9
14
  };
10
15
 
11
16
  class PaxCalimaPlatform {
12
17
  constructor(log, config, api) {
13
18
  this.log = log;
19
+ this.config = config || {};
14
20
  this.api = api;
15
- this.accessories = new Map();
21
+ this.knownMACs = new Set();
16
22
 
17
- this.api.on('didFinishLaunching', () => {
18
- noble.on('stateChange', (state) => {
19
- if (state === 'poweredOn') noble.startScanning([], false);
20
- });
21
- noble.on('discover', (peripheral) => {
22
- if (peripheral.address.toLowerCase().startsWith('58:2b:db')) {
23
- this.setupAccessory(peripheral);
24
- }
25
- });
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
+ }
26
33
  });
27
- }
28
34
 
29
- configureAccessory(accessory) {
30
- this.accessories.set(accessory.context.mac, accessory);
35
+ setTimeout(() => {
36
+ this.log.info(`Initial BLE state: ${noble.state}`);
37
+ if (noble.state === 'poweredOn') this.startDiscovery();
38
+ }, 3000);
31
39
  }
32
40
 
33
- setupAccessory(peripheral) {
34
- const mac = peripheral.address.toLowerCase();
35
- const uuid = this.api.hap.uuid.generate(mac);
36
- let accessory = this.accessories.get(mac);
41
+ startDiscovery() {
42
+ this.log.info('Starting BLE scan...');
43
+ noble.startScanning([], false);
37
44
 
38
- if (!accessory) {
39
- accessory = new PlatformAccessory(`Fan ${mac.slice(-5).toUpperCase()}`, uuid);
40
- accessory.context.mac = mac;
41
- this.api.registerPlatformAccessories('homebridge-pax-calima', 'PaxCalima', [accessory]);
42
- this.accessories.set(mac, accessory);
43
- }
44
- new PaxCalimaAccessory(this.log, accessory, peripheral);
45
+ noble.on('discover', (peripheral) => {
46
+ 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
+ }
53
+ });
54
+ }
55
+
56
+ configureAccessory(accessory) {
57
+ this.log.info(`Restoring cached accessory: ${accessory.displayName}`);
45
58
  }
46
59
  }
47
60
 
48
61
  class PaxCalimaAccessory {
49
- constructor(log, accessory, peripheral) {
62
+ constructor(log, mac, api) {
50
63
  this.log = log;
51
- this.accessory = accessory;
52
- this.peripheral = peripheral;
53
- this.charCache = {};
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);
70
+
71
+ this.peripheral = null;
72
+ this.connected = false;
73
+
74
+ this.currentRPM = 0;
75
+ this.humidity = 0;
76
+ this.temperature = 0;
77
+ this.lightLevel = 0;
78
+ this.currentMode = 0;
79
+
54
80
  this.setupServices();
55
- this.connectDevice();
81
+ this.connectAndPoll();
56
82
  }
57
83
 
58
84
  setupServices() {
59
- this.fanService = this.accessory.getService(Service.Fanv2) || this.accessory.addService(Service.Fanv2);
85
+ // Fan
86
+ this.fanService = new Service.Fanv2(this.name);
87
+ this.fanService.getCharacteristic(Characteristic.Active).onGet(() => 1);
60
88
  this.fanService.getCharacteristic(Characteristic.RotationSpeed)
61
- .onSet(async (value) => this.writeSpeed(value));
89
+ .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));
112
+
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);
62
122
 
63
- this.humidityService = this.accessory.getService(Service.HumiditySensor) || this.accessory.addService(Service.HumiditySensor);
64
- this.tempService = this.accessory.getService(Service.TemperatureSensor) || this.accessory.addService(Service.TemperatureSensor);
65
- this.lightService = this.accessory.getService(Service.LightSensor) || this.accessory.addService(Service.LightSensor);
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}`);
66
133
  }
67
134
 
68
- async connectDevice() {
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
+ }
143
+
69
144
  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
+ });
156
+
70
157
  await this.peripheral.connectAsync();
71
- const services = await this.peripheral.discoverServicesAsync();
72
- for (const s of services) {
73
- const chars = await s.discoverCharacteristicsAsync();
74
- chars.forEach(c => this.charCache[c.uuid.replace(/-/g, '')] = c);
75
- }
76
- // Auth
77
- const authChar = this.charCache['4cad343a209a40b7b9114d9b3df569b2'];
78
- if (authChar) {
79
- const pinBuf = Buffer.alloc(4);
80
- pinBuf.writeUInt32LE(12345, 0);
81
- await authChar.writeAsync(pinBuf, true);
82
- }
83
- this.startPolling();
84
- } catch (e) { this.log.error('Connection failed:', e.message); }
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');
163
+ const pinBuf = Buffer.alloc(4);
164
+ pinBuf.writeUInt32LE(parseInt(this.pin), 0);
165
+ await authChar.writeAsync(pinBuf, true);
166
+ this.log.info('✅ PIN authentication successful');
167
+
168
+ this.pollStatus();
169
+ } catch (error) {
170
+ this.log.error(`Connection failed: ${error.message}`);
171
+ setTimeout(() => this.connectAndPoll(), 15000);
172
+ }
85
173
  }
86
174
 
87
- async writeSpeed(percent) {
88
- const controlChar = this.charCache['528b80e8c47a4c0abdf1916a7748f412'];
89
- if (controlChar) {
90
- const rpm = Math.round((percent / 100) * 2400);
91
- const buffer = Buffer.alloc(3);
92
- buffer.writeUInt8(0x01, 0);
93
- buffer.writeUInt16LE(rpm, 1);
94
- await controlChar.writeAsync(buffer, true);
175
+ async getCharacteristic(uuid) {
176
+ const services = await this.peripheral.discoverServicesAsync();
177
+ for (const service of services) {
178
+ const chars = await service.discoverCharacteristicsAsync([uuid]);
179
+ if (chars.length > 0) return chars[0];
95
180
  }
181
+ throw new Error(`Characteristic ${uuid} not found`);
96
182
  }
97
183
 
98
- async startPolling() {
99
- const sensorChar = this.charCache['528b80e8c47a4c0abdf1916a7748f412'];
100
- if (sensorChar) {
101
- setInterval(async () => {
102
- try {
103
- const data = await sensorChar.readAsync();
104
- const hum = Math.round(data.readUInt16LE(0) / 100);
105
- const temp = data.readInt16LE(2) / 10;
106
- const light = data.readUInt16LE(4);
107
- const rpm = data.readUInt16LE(6);
108
-
109
- this.humidityService.updateCharacteristic(Characteristic.CurrentRelativeHumidity, hum);
110
- this.tempService.updateCharacteristic(Characteristic.CurrentTemperature, temp);
111
- this.lightService.updateCharacteristic(Characteristic.CurrentAmbientLightLevel, Math.max(0.0001, light));
112
-
113
- const speed = Math.min(100, Math.round((rpm / 2400) * 100));
114
- this.fanService.updateCharacteristic(Characteristic.RotationSpeed, speed);
115
- } catch (e) { /* silent */ }
116
- }, 10000);
184
+ async pollStatus() {
185
+ if (!this.connected) return;
186
+
187
+ try {
188
+ const sensorChar = await this.getCharacteristic('528b80e8-c47a-4c0a-bdf1-916a7748f412');
189
+ const data = await sensorChar.readAsync();
190
+
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));
195
+
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);
200
+
201
+ this.log.info(`Status: RPM=${this.currentRPM}, Humidity=${this.humidity}%, Temp=${this.temperature}°C`);
202
+ } catch (e) {
203
+ this.log.warn(`Poll failed: ${e.message}`);
117
204
  }
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
+ }
216
+
217
+ async setSilentHours(on) {
218
+ this.log.info(`Silent Hours ${on ? 'enabled' : 'disabled'}`);
219
+ }
220
+
221
+ getAccessory() {
222
+ return this.accessory;
118
223
  }
119
224
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "homebridge-pax-calima",
3
- "version": "2.0.0",
4
- "description": "Homebridge plugin for Pax Calima and Vent-Axia Svara fans with variable speed and full sensor support",
3
+ "version": "2.2.1",
4
+ "description": "Homebridge plugin for Pax Calima and Vent-Axia Svara Bluetooth bathroom fans",
5
5
  "main": "index.js",
6
6
  "keywords": [
7
7
  "homebridge-plugin",
@@ -10,18 +10,24 @@
10
10
  "vent-axia",
11
11
  "svara",
12
12
  "ble",
13
- "fan"
13
+ "bluetooth",
14
+ "fan",
15
+ "extractor",
16
+ "bathroom"
14
17
  ],
15
18
  "engines": {
16
- "homebridge": "^1.6.0 || ^2.0.0",
17
- "node": ">=18.0.0"
19
+ "homebridge": "^1.6.0 || ^2.0.0"
18
20
  },
19
21
  "dependencies": {
20
- "@abandonware/noble": "^1.9.2-26"
22
+ "@abandonware/noble": "1.9.2-26"
21
23
  },
22
- "license": "Apache-2.0",
24
+ "homepage": "https://github.com/JayC68/homebridge-pax-calima",
23
25
  "repository": {
24
26
  "type": "git",
25
27
  "url": "git+https://github.com/JayC68/homebridge-pax-calima.git"
26
- }
28
+ },
29
+ "bugs": {
30
+ "url": "https://github.com/JayC68/homebridge-pax-calima/issues"
31
+ },
32
+ "license": "Apache-2.0"
27
33
  }