homebridge-pax-calima 2.2.1 → 2.3.2
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 +155 -14
- package/index.js +219 -160
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -1,27 +1,168 @@
|
|
|
1
1
|
# homebridge-pax-calima
|
|
2
2
|
|
|
3
|
-
Homebridge
|
|
3
|
+
**Homebridge 2.0 Ready** · [npm](https://www.npmjs.com/package/homebridge-pax-calima) · [GitHub](https://github.com/JayC68/homebridge-pax-calima)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Full HomeKit integration for **Pax Calima** (Scandinavia) and **Vent-Axia Svara** (UK) Bluetooth LE bathroom extractor fans. These are hardware-identical and use the exact same BLE protocol and MAC prefix (`58:2b:db`).
|
|
6
|
+
|
|
7
|
+
---
|
|
6
8
|
|
|
7
9
|
## Features
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
10
|
+
|
|
11
|
+
| Feature | Detail |
|
|
12
|
+
|---|---|
|
|
13
|
+
| **Auto-discovery** | Finds all Pax/Svara fans automatically — no MAC address needed |
|
|
14
|
+
| **Fan speed indicator** | Displays current RPM as 0–100% in Apple Home |
|
|
15
|
+
| **Fan speed control** | Speed slider available — see wiring note below |
|
|
16
|
+
| **Humidity sensor** | Live readings, updates every 10 seconds |
|
|
17
|
+
| **Temperature sensor** | Live readings, updates every 10 seconds |
|
|
18
|
+
| **Light sensor** | Live readings in lux, updates every 10 seconds |
|
|
19
|
+
| **Boost button** | Stateless programmable switch — triggers temporary high-speed boost |
|
|
20
|
+
| **Silent Hours** | Toggle switch — prevents fan boosting during scheduled quiet periods |
|
|
21
|
+
| **Automations** | Full support — trigger on humidity, temperature or light thresholds |
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Requirements
|
|
26
|
+
|
|
27
|
+
- Homebridge `^1.6.0` or `^2.0.0`
|
|
28
|
+
- Node.js `>= 18`
|
|
29
|
+
- A Bluetooth LE adapter on the Homebridge host (built-in or USB dongle)
|
|
30
|
+
|
|
31
|
+
### Raspberry Pi / Homebridge OS setup
|
|
32
|
+
|
|
33
|
+
The `noble` BLE library requires elevated network capabilities. Run **once** after installation:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
sudo setcap cap_net_raw+eip /opt/homebridge/bin/node
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
If Bluetooth shows as `poweredOff` after this, unblock the radio:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
sudo rfkill unblock bluetooth
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
---
|
|
19
46
|
|
|
20
47
|
## Installation
|
|
21
48
|
|
|
22
49
|
### Via Homebridge UI (recommended)
|
|
23
|
-
Search for **Pax Calima** or **Svara** and
|
|
50
|
+
Search for **Pax Calima** or **Svara** and click Install.
|
|
24
51
|
|
|
25
52
|
### Via Terminal
|
|
26
53
|
```bash
|
|
27
54
|
npm install -g homebridge-pax-calima
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Configuration
|
|
60
|
+
|
|
61
|
+
Auto-discovery requires **no configuration**. The plugin finds any fan with MAC address starting `58:2b:db` automatically and registers it with a default name.
|
|
62
|
+
|
|
63
|
+
To set a **custom name** or **PIN** for a specific fan, add an entry to the `fans` array:
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"platform": "PaxCalima",
|
|
68
|
+
"name": "Pax Calima / Vent-Axia Svara",
|
|
69
|
+
"fans": [
|
|
70
|
+
{
|
|
71
|
+
"name": "Wetroom Fan",
|
|
72
|
+
"mac": "58:2b:db:xx:xx:xx",
|
|
73
|
+
"pin": "012345"
|
|
74
|
+
}
|
|
75
|
+
]
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The `fans` array is entirely optional. Omit it to use auto-discovery with default names and PIN `012345`.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Accessories in Apple Home
|
|
84
|
+
|
|
85
|
+
Each discovered fan appears in the Home app as a single accessory with the following tiles:
|
|
86
|
+
|
|
87
|
+
- **Fan** — displays current speed; slider available (see wiring note below)
|
|
88
|
+
- **Boost** — programmable button for temporary high-speed boost
|
|
89
|
+
- **Silent Hours** — switch to suppress automatic boosting during quiet periods
|
|
90
|
+
- **Humidity** — current relative humidity (%)
|
|
91
|
+
- **Temperature** — current temperature (°C)
|
|
92
|
+
- **Light** — current ambient light level (lux)
|
|
93
|
+
|
|
94
|
+
> Note: Accessories appear in the **Home app**, not in the Homebridge UI accessories tab. This is correct behaviour for child bridge plugins.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Fan Speed Control — Wiring Mode Note
|
|
99
|
+
|
|
100
|
+
The Pax Calima and Vent-Axia Svara support two wiring modes, selected at installation time:
|
|
101
|
+
|
|
102
|
+
**Intermittent mode** — the fan is off by default and triggered by humidity, light, or a timer. In this mode BLE speed commands are fully respected and the speed slider in Apple Home controls the fan directly.
|
|
103
|
+
|
|
104
|
+
**Always-on (trickle) mode** — the fan runs continuously at a speed determined by its onboard controller, typically set via the official app or physical dial. In this mode BLE speed writes are accepted by the fan but the onboard firmware immediately reasserts its own speed. The slider will appear to have no effect.
|
|
105
|
+
|
|
106
|
+
If your fan is wired in always-on mode, the speed slider will reflect the current RPM accurately but cannot override the onboard setpoint. The sensors and automations remain fully functional regardless of wiring mode — for bathroom and wetroom use this is the primary value of the integration, enabling powerful Apple Home automations based on live humidity, temperature and light readings.
|
|
107
|
+
|
|
108
|
+
To enable full speed control, consult your fan's installation guide to switch to intermittent wiring mode.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Sensor Notes
|
|
113
|
+
|
|
114
|
+
The Svara's light sensor detects occupancy via light movement and shadows rather than acting as a calibrated lux meter. Readings may vary slightly from a reference device depending on mounting position — this is expected.
|
|
115
|
+
|
|
116
|
+
Humidity and temperature automations work well natively in the Home app. For lux-based automations, use a third-party app such as **Eve** or **Hesperus** which support lux thresholds and sync back to HomeKit.
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Troubleshooting
|
|
121
|
+
|
|
122
|
+
**Fan not discovered**
|
|
123
|
+
- Confirm BLE is active: run `hciconfig` on the Pi — look for `UP RUNNING`
|
|
124
|
+
- If it shows `RF-KILL`: run `sudo rfkill unblock bluetooth`
|
|
125
|
+
- Confirm `setcap` targets the correct Node binary: `/opt/homebridge/bin/node`
|
|
126
|
+
|
|
127
|
+
**Authentication failed**
|
|
128
|
+
- Default PIN is `012345`. If you set a custom PIN in the official Pax/Svara app, add the fan to the `fans` config array with the correct PIN.
|
|
129
|
+
|
|
130
|
+
**Speed slider has no effect**
|
|
131
|
+
- Your fan is likely wired in always-on mode. See the wiring note above. All sensors and automations are unaffected.
|
|
132
|
+
|
|
133
|
+
**Accessories not appearing in Home app**
|
|
134
|
+
- Remove the accessory cache via Homebridge UI → (plugin settings) → Remove Accessory Cache, then restart Homebridge
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## Release Notes
|
|
139
|
+
|
|
140
|
+
### 2.3.2
|
|
141
|
+
- **Documented:** Fan speed control behaviour in always-on vs intermittent wiring modes — the slider correctly reflects current RPM in both modes; in always-on mode the onboard firmware reasserts its own speed setpoint after each BLE write
|
|
142
|
+
- **Confirmed:** All sensors (humidity, temperature, light, RPM) accurate and stable
|
|
143
|
+
- **Improved:** README wiring mode explanation, troubleshooting entry for speed slider, sensor automation guidance
|
|
144
|
+
|
|
145
|
+
### 2.3.1
|
|
146
|
+
- **Investigated:** Correct control characteristic identified (`1488a757...`) via live BLE dump
|
|
147
|
+
- **Added:** Diagnostic logging of control characteristic on every poll and before/after writes
|
|
148
|
+
- **Fixed:** `writeSpeed` pauses poll cycle to avoid BLE conflicts during writes
|
|
149
|
+
|
|
150
|
+
### 2.3.0
|
|
151
|
+
- **Published:** Stable sensor readings verified against real-world reference device
|
|
152
|
+
- **Fixed:** Auth characteristic discovery on first connection after restart
|
|
153
|
+
|
|
154
|
+
### 2.2.1
|
|
155
|
+
- **Fixed:** Sensors no longer stuck after restart — polling deferred until BLE is ready
|
|
156
|
+
- **Fixed:** Poll interval 30s → 10s for responsive shower humidity tracking
|
|
157
|
+
- **Fixed:** Overlapping poll guard prevents concurrent BLE connections
|
|
158
|
+
- **Fixed:** Multi Mode and Heat Distribution switches removed — not applicable for UK use
|
|
159
|
+
- **Fixed:** Duplicate accessory registration on restart resolved
|
|
160
|
+
- **Fixed:** Switch UUID collision resolved with explicit subtypes
|
|
161
|
+
|
|
162
|
+
### 2.1.0
|
|
163
|
+
- **Fixed:** Correct sensor characteristic UUIDs and byte mappings confirmed via live device
|
|
164
|
+
- **Fixed:** BLE refactored to connect → auth → read → disconnect → repeat pattern
|
|
165
|
+
- **Fixed:** UUID matching on Linux noble
|
|
166
|
+
|
|
167
|
+
### 1.0.x
|
|
168
|
+
- Initial working releases
|
package/index.js
CHANGED
|
@@ -1,224 +1,283 @@
|
|
|
1
1
|
const noble = require('@abandonware/noble');
|
|
2
2
|
|
|
3
|
-
let Service, Characteristic,
|
|
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
|
|
7
|
-
Characteristic
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
19
|
-
this.config
|
|
20
|
-
this.api
|
|
21
|
-
this.
|
|
22
|
-
|
|
23
|
-
this.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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.
|
|
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(
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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,
|
|
63
|
-
this.log
|
|
64
|
-
this.
|
|
65
|
-
this.
|
|
66
|
-
this.pin
|
|
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.
|
|
72
|
-
this.
|
|
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.
|
|
75
|
-
this.
|
|
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.
|
|
108
|
+
this.schedulePoll(0);
|
|
82
109
|
}
|
|
83
110
|
|
|
84
111
|
setupServices() {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
this.fanService.
|
|
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(() =>
|
|
91
|
-
.onSet((
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
this.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
this.
|
|
119
|
-
|
|
120
|
-
this.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
this.accessory.
|
|
125
|
-
|
|
126
|
-
this.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
this.accessory.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
136
|
-
this.
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
|
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.
|
|
169
|
-
|
|
170
|
-
this.
|
|
171
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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(
|
|
179
|
-
|
|
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(
|
|
235
|
+
throw new Error('Characteristic ' + uuid + ' not found');
|
|
182
236
|
}
|
|
183
237
|
|
|
184
|
-
async
|
|
185
|
-
|
|
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
|
-
|
|
189
|
-
|
|
244
|
+
peripheral = await this.findPeripheral();
|
|
245
|
+
await peripheral.connectAsync();
|
|
190
246
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
this.
|
|
194
|
-
|
|
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
|
-
|
|
197
|
-
this.
|
|
198
|
-
|
|
199
|
-
this.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
276
|
+
this.silentHours = on;
|
|
277
|
+
this.log.info('Silent Hours ' + (on ? 'enabled' : 'disabled'));
|
|
219
278
|
}
|
|
220
279
|
|
|
221
|
-
|
|
222
|
-
return
|
|
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
|
|
4
|
-
"description": "Homebridge plugin for Pax Calima and Vent-Axia Svara Bluetooth bathroom fans",
|
|
3
|
+
"version": "2.3.2",
|
|
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"
|