homebridge-pax-calima 1.0.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 +27 -0
- package/config.schema.json +54 -0
- package/index.js +268 -0
- package/package.json +33 -0
package/README.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# homebridge-pax-calima
|
|
2
|
+
|
|
3
|
+
Homebridge platform plugin for **Pax Calima** and **Vent-Axia Svara** Bluetooth LE bathroom extractor fans.
|
|
4
|
+
|
|
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
|
+
|
|
7
|
+
## Features
|
|
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.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
### Via Homebridge UI (recommended)
|
|
23
|
+
Search for **Pax Calima** or **Svara** and install.
|
|
24
|
+
|
|
25
|
+
### Via Terminal
|
|
26
|
+
```bash
|
|
27
|
+
npm install -g homebridge-pax-calima
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"pluginAlias": "PaxCalima",
|
|
3
|
+
"pluginType": "platform",
|
|
4
|
+
"singular": true,
|
|
5
|
+
"schema": {
|
|
6
|
+
"type": "object",
|
|
7
|
+
"properties": {
|
|
8
|
+
"name": {
|
|
9
|
+
"title": "Platform Name",
|
|
10
|
+
"type": "string",
|
|
11
|
+
"default": "Pax Calima / Vent-Axia Svara Fans"
|
|
12
|
+
},
|
|
13
|
+
"fans": {
|
|
14
|
+
"title": "Manually Configured Fans (optional — auto-discovery is recommended)",
|
|
15
|
+
"type": "array",
|
|
16
|
+
"items": {
|
|
17
|
+
"type": "object",
|
|
18
|
+
"properties": {
|
|
19
|
+
"name": {
|
|
20
|
+
"title": "Accessory Name",
|
|
21
|
+
"type": "string",
|
|
22
|
+
"default": "Bathroom Fan"
|
|
23
|
+
},
|
|
24
|
+
"mac": {
|
|
25
|
+
"title": "MAC Address",
|
|
26
|
+
"type": "string",
|
|
27
|
+
"pattern": "^([0-9A-Fa-f]{2}:){5}([0-9A-Fa-f]{2})$",
|
|
28
|
+
"description": "Optional. Leave blank for auto-discovery."
|
|
29
|
+
},
|
|
30
|
+
"pin": {
|
|
31
|
+
"title": "PIN Code",
|
|
32
|
+
"type": "string",
|
|
33
|
+
"default": "012345",
|
|
34
|
+
"description": "Usually 012345. Change only if you set a custom PIN in the official app."
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"required": ["name"]
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"layout": [
|
|
43
|
+
{
|
|
44
|
+
"type": "section",
|
|
45
|
+
"title": "General",
|
|
46
|
+
"items": ["name"]
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"type": "section",
|
|
50
|
+
"title": "Fans",
|
|
51
|
+
"items": ["fans"]
|
|
52
|
+
}
|
|
53
|
+
]
|
|
54
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
const noble = require('@abandonware/noble');
|
|
2
|
+
|
|
3
|
+
let Service, Characteristic, Accessory, Categories, API;
|
|
4
|
+
|
|
5
|
+
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;
|
|
11
|
+
|
|
12
|
+
homebridge.registerPlatform('homebridge-pax-calima', 'PaxCalima', PaxCalimaPlatform, true); // dynamic platform for auto-discovery
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
class PaxCalimaPlatform {
|
|
16
|
+
constructor(log, config, api) {
|
|
17
|
+
this.log = log;
|
|
18
|
+
this.config = config || {};
|
|
19
|
+
this.api = api;
|
|
20
|
+
this.accessories = new Map();
|
|
21
|
+
this.knownMACs = new Set();
|
|
22
|
+
|
|
23
|
+
noble.on('stateChange', (state) => {
|
|
24
|
+
if (state === 'poweredOn') {
|
|
25
|
+
this.log.info('BLE ready — scanning for Pax Calima / Vent-Axia Svara fans...');
|
|
26
|
+
this.startDiscovery();
|
|
27
|
+
} else {
|
|
28
|
+
this.log.warn(`BLE state changed to ${state}`);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
startDiscovery() {
|
|
34
|
+
noble.startScanning([], false);
|
|
35
|
+
noble.on('discover', (peripheral) => {
|
|
36
|
+
const mac = peripheral.address.toLowerCase();
|
|
37
|
+
if (mac.startsWith('58:2b:db') && !this.knownMACs.has(mac)) {
|
|
38
|
+
this.knownMACs.add(mac);
|
|
39
|
+
this.log.info(`Discovered Pax Calima / Vent-Axia Svara: ${mac}`);
|
|
40
|
+
const acc = new PaxCalimaAccessory(this.log, mac, this.api);
|
|
41
|
+
this.api.registerPlatformAccessories('homebridge-pax-calima', 'PaxCalima', [acc.getAccessory()]);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
configureAccessory(accessory) {
|
|
47
|
+
this.log.info(`Restoring cached accessory: ${accessory.displayName}`);
|
|
48
|
+
this.accessories.set(accessory.UUID, accessory);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
class PaxCalimaAccessory {
|
|
53
|
+
constructor(log, mac, api) {
|
|
54
|
+
this.log = log;
|
|
55
|
+
this.mac = mac.toLowerCase();
|
|
56
|
+
this.name = `Bathroom Fan ${this.mac.slice(-5).toUpperCase()}`;
|
|
57
|
+
this.pin = '012345'; // override in config.schema if needed
|
|
58
|
+
|
|
59
|
+
// Correct UUID generation for modern Homebridge / HAP-NodeJS
|
|
60
|
+
const uuid = API.hap.uuid.generate(`pax-svara-${this.mac}`);
|
|
61
|
+
this.accessory = new Accessory(this.name, uuid);
|
|
62
|
+
this.accessory.category = Categories.FAN;
|
|
63
|
+
|
|
64
|
+
this.peripheral = null;
|
|
65
|
+
this.connected = false;
|
|
66
|
+
|
|
67
|
+
// Live state
|
|
68
|
+
this.currentRPM = 0;
|
|
69
|
+
this.humidity = 0;
|
|
70
|
+
this.temperature = 0;
|
|
71
|
+
this.lightLevel = 0;
|
|
72
|
+
this.currentMode = 0;
|
|
73
|
+
|
|
74
|
+
this.setupServices();
|
|
75
|
+
this.connectAndPoll();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
setupServices() {
|
|
79
|
+
// Main Fan Service
|
|
80
|
+
this.fanService = new Service.Fanv2(this.name);
|
|
81
|
+
this.fanService.getCharacteristic(Characteristic.Active)
|
|
82
|
+
.onGet(() => 1)
|
|
83
|
+
.onSet((value, cb) => { this.log.info(`Fan active set to ${value}`); cb(null); });
|
|
84
|
+
|
|
85
|
+
this.fanService.getCharacteristic(Characteristic.RotationSpeed)
|
|
86
|
+
.setProps({ minValue: 0, maxValue: 100, minStep: 1 })
|
|
87
|
+
.onGet(this.getRotationSpeed.bind(this))
|
|
88
|
+
.onSet(this.setRotationSpeed.bind(this));
|
|
89
|
+
|
|
90
|
+
// Mode Switches
|
|
91
|
+
this.multiMode = new Service.Switch(`${this.name} - Multi Mode`, 'multi-mode');
|
|
92
|
+
this.multiMode.getCharacteristic(Characteristic.On)
|
|
93
|
+
.onGet(() => this.currentMode === 0)
|
|
94
|
+
.onSet((on, cb) => this.setMode(0, on, cb));
|
|
95
|
+
|
|
96
|
+
this.heatMode = new Service.Switch(`${this.name} - Heat Distribution`, 'heat-mode');
|
|
97
|
+
this.heatMode.getCharacteristic(Characteristic.On)
|
|
98
|
+
.onGet(() => this.currentMode === 4)
|
|
99
|
+
.onSet((on, cb) => this.setMode(4, on, cb));
|
|
100
|
+
|
|
101
|
+
// Boost Button (Stateless Programmable Switch - ideal for automations)
|
|
102
|
+
this.boostButton = new Service.StatelessProgrammableSwitch(`${this.name} - Boost`, 'boost');
|
|
103
|
+
this.boostButton.getCharacteristic(Characteristic.ProgrammableSwitchEvent)
|
|
104
|
+
.onSet(this.triggerBoost.bind(this));
|
|
105
|
+
|
|
106
|
+
// Silent Hours Toggle
|
|
107
|
+
this.silentService = new Service.Switch(`${this.name} - Silent Hours`, 'silent');
|
|
108
|
+
this.silentService.getCharacteristic(Characteristic.On)
|
|
109
|
+
.onGet(() => false) // read-back not implemented yet
|
|
110
|
+
.onSet(this.setSilentHours.bind(this));
|
|
111
|
+
|
|
112
|
+
// Automatic Cycles
|
|
113
|
+
this.cycle30 = new Service.Switch(`${this.name} - 30min Cycles`, 'cycle30');
|
|
114
|
+
this.cycle30.getCharacteristic(Characteristic.On).onSet((on, cb) => this.setCycles(on ? 1 : 0, cb));
|
|
115
|
+
|
|
116
|
+
this.cycle60 = new Service.Switch(`${this.name} - 60min Cycles`, 'cycle60');
|
|
117
|
+
this.cycle60.getCharacteristic(Characteristic.On).onSet((on, cb) => this.setCycles(on ? 2 : 0, cb));
|
|
118
|
+
|
|
119
|
+
this.cycle90 = new Service.Switch(`${this.name} - 90min Cycles`, 'cycle90');
|
|
120
|
+
this.cycle90.getCharacteristic(Characteristic.On).onSet((on, cb) => this.setCycles(on ? 3 : 0, cb));
|
|
121
|
+
|
|
122
|
+
// Trickle Toggle
|
|
123
|
+
this.trickleService = new Service.Switch(`${this.name} - Trickle`, 'trickle');
|
|
124
|
+
this.trickleService.getCharacteristic(Characteristic.On).onSet(this.setTrickle.bind(this));
|
|
125
|
+
|
|
126
|
+
// Sensors
|
|
127
|
+
this.humidityService = new Service.HumiditySensor(`${this.name} Humidity`);
|
|
128
|
+
this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity).onGet(() => this.humidity);
|
|
129
|
+
|
|
130
|
+
this.tempService = new Service.TemperatureSensor(`${this.name} Temperature`);
|
|
131
|
+
this.tempService.getCharacteristic(Characteristic.CurrentTemperature).onGet(() => this.temperature);
|
|
132
|
+
|
|
133
|
+
this.lightService = new Service.LightSensor(`${this.name} Light`);
|
|
134
|
+
this.lightService.getCharacteristic(Characteristic.CurrentAmbientLightLevel).onGet(() => this.lightLevel);
|
|
135
|
+
|
|
136
|
+
// Add all services to the accessory
|
|
137
|
+
const services = [
|
|
138
|
+
this.fanService, this.multiMode, this.heatMode, this.boostButton, this.silentService,
|
|
139
|
+
this.cycle30, this.cycle60, this.cycle90, this.trickleService,
|
|
140
|
+
this.humidityService, this.tempService, this.lightService
|
|
141
|
+
];
|
|
142
|
+
services.forEach(s => this.accessory.addService(s));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async connectAndPoll() {
|
|
146
|
+
try {
|
|
147
|
+
if (!noble.isPoweredOn) {
|
|
148
|
+
this.log.warn('BLE adapter not powered on yet. Retrying...');
|
|
149
|
+
setTimeout(() => this.connectAndPoll(), 5000);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
this.log.info(`Connecting to fan ${this.mac}...`);
|
|
154
|
+
|
|
155
|
+
// Discover the peripheral
|
|
156
|
+
this.peripheral = await new Promise((resolve, reject) => {
|
|
157
|
+
const timeout = setTimeout(() => reject(new Error('Discovery timeout')), 20000);
|
|
158
|
+
noble.startScanning([], false);
|
|
159
|
+
noble.on('discover', (periph) => {
|
|
160
|
+
if (periph.address.toLowerCase() === this.mac) {
|
|
161
|
+
clearTimeout(timeout);
|
|
162
|
+
noble.stopScanning();
|
|
163
|
+
resolve(periph);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await this.peripheral.connectAsync();
|
|
169
|
+
this.connected = true;
|
|
170
|
+
this.log.info(`Connected to ${this.mac}`);
|
|
171
|
+
|
|
172
|
+
// Authentication with PIN
|
|
173
|
+
const authChar = await this.getCharacteristic('4cad343a-209a-40b7-b911-4d9b3df569b2');
|
|
174
|
+
const pinBuf = Buffer.alloc(4);
|
|
175
|
+
pinBuf.writeUInt32LE(parseInt(this.pin), 0);
|
|
176
|
+
await authChar.writeAsync(pinBuf, true);
|
|
177
|
+
this.log.info('PIN authentication successful');
|
|
178
|
+
|
|
179
|
+
this.pollStatus();
|
|
180
|
+
} catch (error) {
|
|
181
|
+
this.log.error(`Failed to connect to ${this.mac}: ${error.message}`);
|
|
182
|
+
this.connected = false;
|
|
183
|
+
setTimeout(() => this.connectAndPoll(), 30000); // retry after 30 seconds
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async getCharacteristic(uuid) {
|
|
188
|
+
const services = await this.peripheral.discoverServicesAsync();
|
|
189
|
+
for (const service of services) {
|
|
190
|
+
const chars = await service.discoverCharacteristicsAsync([uuid]);
|
|
191
|
+
if (chars.length > 0) return chars[0];
|
|
192
|
+
}
|
|
193
|
+
throw new Error(`Characteristic ${uuid} not found on device`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async pollStatus() {
|
|
197
|
+
if (!this.connected || !this.peripheral) return;
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const sensorChar = await this.getCharacteristic('528b80e8-c47a-4c0a-bdf1-916a7748f412');
|
|
201
|
+
const data = await sensorChar.readAsync();
|
|
202
|
+
|
|
203
|
+
// Basic parsing (adjust based on real testing - values are approximate from pycalima style)
|
|
204
|
+
this.humidity = Math.max(0, Math.min(100, Math.round(data.readUInt16LE(0) / 100)));
|
|
205
|
+
this.temperature = Math.round((data.readUInt16LE(2) / 10) * 10) / 10;
|
|
206
|
+
this.lightLevel = Math.max(0, data.readUInt16LE(4));
|
|
207
|
+
this.currentRPM = Math.max(0, data.readUInt16LE(6));
|
|
208
|
+
|
|
209
|
+
// Update HomeKit characteristics
|
|
210
|
+
this.fanService.getCharacteristic(Characteristic.RotationSpeed).updateValue(Math.min(100, Math.round(this.currentRPM / 25)));
|
|
211
|
+
this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity).updateValue(this.humidity);
|
|
212
|
+
this.tempService.getCharacteristic(Characteristic.CurrentTemperature).updateValue(this.temperature);
|
|
213
|
+
this.lightService.getCharacteristic(Characteristic.CurrentAmbientLightLevel).updateValue(this.lightLevel);
|
|
214
|
+
|
|
215
|
+
this.log.debug(`Poll: RPM=${this.currentRPM}, Humidity=${this.humidity}%, Temp=${this.temperature}°C, Light=${this.lightLevel}`);
|
|
216
|
+
} catch (e) {
|
|
217
|
+
this.log.warn(`Status poll failed for ${this.mac}: ${e.message}`);
|
|
218
|
+
this.connected = false;
|
|
219
|
+
this.connectAndPoll();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
setTimeout(() => this.pollStatus(), 8000); // poll every 8 seconds
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
getRotationSpeed(callback) {
|
|
226
|
+
callback(null, Math.min(100, Math.round(this.currentRPM / 25)));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async setRotationSpeed(value, callback) {
|
|
230
|
+
this.log.info(`Requested rotation speed: ${value}% (${Math.round(value * 25)} RPM)`);
|
|
231
|
+
// TODO: Implement write to boost/manual characteristic if needed
|
|
232
|
+
callback(null);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async setMode(modeValue, on, callback) {
|
|
236
|
+
this.currentMode = on ? modeValue : 0;
|
|
237
|
+
this.log.info(`Mode changed to ${on ? (modeValue === 0 ? 'Multi Mode' : 'Heat Distribution') : 'Default'}`);
|
|
238
|
+
// TODO: Write to mode UUID '90cabcd1-bcda-4167-85d8-16dcd8ab6a6b'
|
|
239
|
+
callback(null);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async triggerBoost(value, callback) {
|
|
243
|
+
this.log.info('Boost button pressed - activating high speed for 15 minutes');
|
|
244
|
+
// TODO: Write to manual fan control UUID '118c949c-28c8-4139-b0b3-36657fd055a9' with on=1, speed~2000, duration=900
|
|
245
|
+
callback(null);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async setSilentHours(on, callback) {
|
|
249
|
+
this.log.info(`Silent Hours ${on ? 'enabled (example 22:00-07:00)' : 'disabled'}`);
|
|
250
|
+
// TODO: Write to silent hours UUID 'b5836b55-57bd-433e-8480-46e4993c5ac0'
|
|
251
|
+
callback(null);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async setCycles(value, callback) {
|
|
255
|
+
this.log.info(`Automatic cycles set to ${value} (0=off, 1=30min, 2=60min, 3=90min)`);
|
|
256
|
+
// TODO: Write to cycles UUID 'f508408a-508b-41c6-aa57-61d1fd0d5c39'
|
|
257
|
+
callback(null);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async setTrickle(on, callback) {
|
|
261
|
+
this.log.info(`Trickle ventilation ${on ? 'enabled' : 'disabled'}`);
|
|
262
|
+
callback(null);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
getAccessory() {
|
|
266
|
+
return this.accessory;
|
|
267
|
+
}
|
|
268
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "homebridge-pax-calima",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Homebridge plugin for Pax Calima and Vent-Axia Svara Bluetooth bathroom fans",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"homebridge-plugin",
|
|
8
|
+
"pax",
|
|
9
|
+
"calima",
|
|
10
|
+
"vent-axia",
|
|
11
|
+
"svara",
|
|
12
|
+
"ble",
|
|
13
|
+
"bluetooth",
|
|
14
|
+
"fan",
|
|
15
|
+
"extractor",
|
|
16
|
+
"bathroom"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"homebridge": "^1.6.0 || ^2.0.0"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@abandonware/noble": "1.9.2-26"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/JayC68/homebridge-pax-calima",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/JayC68/homebridge-pax-calima.git"
|
|
28
|
+
},
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/JayC68/homebridge-pax-calima/issues"
|
|
31
|
+
},
|
|
32
|
+
"license": "Apache-2.0"
|
|
33
|
+
}
|