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 +19 -6
- package/config.schema.json +1 -1
- package/index.js +183 -78
- package/package.json +14 -8
package/README.md
CHANGED
|
@@ -1,14 +1,27 @@
|
|
|
1
|
-
#
|
|
1
|
+
# homebridge-pax-calima
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Homebridge platform plugin for **Pax Calima** and **Vent-Axia Svara** Bluetooth LE bathroom extractor fans.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
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
|
package/config.schema.json
CHANGED
package/index.js
CHANGED
|
@@ -1,119 +1,224 @@
|
|
|
1
1
|
const noble = require('@abandonware/noble');
|
|
2
|
-
|
|
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
|
-
|
|
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.
|
|
21
|
+
this.knownMACs = new Set();
|
|
16
22
|
|
|
17
|
-
this.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
let accessory = this.accessories.get(mac);
|
|
41
|
+
startDiscovery() {
|
|
42
|
+
this.log.info('Starting BLE scan...');
|
|
43
|
+
noble.startScanning([], false);
|
|
37
44
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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,
|
|
62
|
+
constructor(log, mac, api) {
|
|
50
63
|
this.log = log;
|
|
51
|
-
this.
|
|
52
|
-
this.
|
|
53
|
-
this.
|
|
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.
|
|
81
|
+
this.connectAndPoll();
|
|
56
82
|
}
|
|
57
83
|
|
|
58
84
|
setupServices() {
|
|
59
|
-
|
|
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
|
-
.
|
|
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.
|
|
64
|
-
this.
|
|
65
|
-
this.
|
|
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
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
this.
|
|
84
|
-
|
|
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
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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.
|
|
4
|
-
"description": "Homebridge plugin for Pax Calima and Vent-Axia Svara
|
|
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
|
-
"
|
|
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": "
|
|
22
|
+
"@abandonware/noble": "1.9.2-26"
|
|
21
23
|
},
|
|
22
|
-
"
|
|
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
|
}
|