homebridge-pax-calima 2.3.2 → 2.4.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 +71 -65
- package/config.schema.json +43 -7
- package/index.js +94 -66
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
|
|
3
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
|
-
Full HomeKit integration for **Pax Calima**
|
|
5
|
+
Full HomeKit integration for **Pax Calima** and **Vent-Axia Lo-Carbon Svara** Bluetooth LE bathroom extractor fans.
|
|
6
|
+
|
|
7
|
+
**Compatible devices:**
|
|
8
|
+
- **Pax Calima** — Sweden, Norway, Denmark, Finland, Spain, Portugal
|
|
9
|
+
- **Vent-Axia Lo-Carbon Svara** White & Black — UK
|
|
10
|
+
|
|
11
|
+
All are hardware-identical, sharing the same BLE protocol and MAC prefix (`58:2b:db`).
|
|
6
12
|
|
|
7
13
|
---
|
|
8
14
|
|
|
@@ -10,38 +16,49 @@ Full HomeKit integration for **Pax Calima** (Scandinavia) and **Vent-Axia Svara*
|
|
|
10
16
|
|
|
11
17
|
| Feature | Detail |
|
|
12
18
|
|---|---|
|
|
13
|
-
| **Auto-discovery** | Finds all
|
|
14
|
-
| **Fan speed
|
|
15
|
-
| **Fan speed control** | Speed slider available — see wiring note below |
|
|
19
|
+
| **Auto-discovery** | Finds all compatible fans automatically — no MAC address needed |
|
|
20
|
+
| **Fan speed** | Displays live RPM as 0–100%; speed control available (see wiring note) |
|
|
16
21
|
| **Humidity sensor** | Live readings, updates every 10 seconds |
|
|
17
22
|
| **Temperature sensor** | Live readings, updates every 10 seconds |
|
|
18
|
-
| **Light sensor** | Live readings
|
|
19
|
-
| **Boost button** | Stateless programmable switch
|
|
20
|
-
| **Silent Hours** | Toggle
|
|
21
|
-
| **Automations** | Full
|
|
23
|
+
| **Light sensor** | Live lux readings, updates every 10 seconds |
|
|
24
|
+
| **Boost button** | Stateless programmable switch |
|
|
25
|
+
| **Silent Hours** | Toggle — suppresses automatic boosting during quiet periods |
|
|
26
|
+
| **Automations** | Full HomeKit automation support on all sensors |
|
|
22
27
|
|
|
23
28
|
---
|
|
24
29
|
|
|
25
30
|
## Requirements
|
|
26
31
|
|
|
27
32
|
- Homebridge `^1.6.0` or `^2.0.0`
|
|
28
|
-
- Node.js `>= 18`
|
|
29
|
-
- A Bluetooth LE adapter on the Homebridge host
|
|
33
|
+
- Node.js `>= 18.20.4`
|
|
34
|
+
- A Bluetooth LE adapter on the Homebridge host
|
|
35
|
+
|
|
36
|
+
### Homebridge 2.0 Compliance
|
|
30
37
|
|
|
31
|
-
|
|
38
|
+
This plugin is fully compliant with Homebridge 2.0:
|
|
39
|
+
- Implemented as a **dynamic platform** (`registerPlatform` with `true` flag)
|
|
40
|
+
- Waits for `didFinishLaunching` before any BLE or accessory operations
|
|
41
|
+
- All errors caught and logged — no unhandled exceptions
|
|
42
|
+
- No analytics, no tracking, no post-install scripts
|
|
43
|
+
- Accessories restored from cache without re-registration
|
|
44
|
+
- Compatible with Node.js 18, 20 and 22
|
|
32
45
|
|
|
33
|
-
|
|
46
|
+
### Raspberry Pi / Homebridge OS — one-time setup
|
|
47
|
+
|
|
48
|
+
The `noble` BLE library requires elevated network capabilities. Run **once** after installation on the **Homebridge terminal**:
|
|
34
49
|
|
|
35
50
|
```bash
|
|
36
51
|
sudo setcap cap_net_raw+eip /opt/homebridge/bin/node
|
|
37
52
|
```
|
|
38
53
|
|
|
39
|
-
If Bluetooth shows as `poweredOff
|
|
54
|
+
If Bluetooth shows as `poweredOff`:
|
|
40
55
|
|
|
41
56
|
```bash
|
|
42
57
|
sudo rfkill unblock bluetooth
|
|
43
58
|
```
|
|
44
59
|
|
|
60
|
+
If the above does not resolve the issue, a full Pi power cycle may be required to reinitialise the BLE stack cleanly.
|
|
61
|
+
|
|
45
62
|
---
|
|
46
63
|
|
|
47
64
|
## Installation
|
|
@@ -58,9 +75,7 @@ npm install -g homebridge-pax-calima
|
|
|
58
75
|
|
|
59
76
|
## Configuration
|
|
60
77
|
|
|
61
|
-
Auto-discovery requires **no configuration**.
|
|
62
|
-
|
|
63
|
-
To set a **custom name** or **PIN** for a specific fan, add an entry to the `fans` array:
|
|
78
|
+
Auto-discovery requires **no configuration**. To set a custom name or PIN per fan:
|
|
64
79
|
|
|
65
80
|
```json
|
|
66
81
|
{
|
|
@@ -76,93 +91,84 @@ To set a **custom name** or **PIN** for a specific fan, add an entry to the `fan
|
|
|
76
91
|
}
|
|
77
92
|
```
|
|
78
93
|
|
|
79
|
-
The `fans` array is
|
|
94
|
+
The `fans` array is optional. Default PIN is `012345`.
|
|
80
95
|
|
|
81
96
|
---
|
|
82
97
|
|
|
83
98
|
## Accessories in Apple Home
|
|
84
99
|
|
|
85
|
-
Each
|
|
100
|
+
Each fan appears as a single accessory with:
|
|
86
101
|
|
|
87
|
-
- **Fan** —
|
|
88
|
-
- **Boost** — programmable button
|
|
89
|
-
- **Silent Hours** — switch
|
|
90
|
-
- **Humidity** —
|
|
91
|
-
- **Temperature** —
|
|
92
|
-
- **Light** —
|
|
102
|
+
- **Fan** — live speed display; slider available (see wiring note)
|
|
103
|
+
- **Boost** — single-press programmable button
|
|
104
|
+
- **Silent Hours** — on/off switch
|
|
105
|
+
- **Humidity** — % relative humidity
|
|
106
|
+
- **Temperature** — °C
|
|
107
|
+
- **Light** — lux
|
|
93
108
|
|
|
94
|
-
>
|
|
109
|
+
> Accessories appear in the **Home app**, not the Homebridge UI accessories tab. This is correct behaviour for child bridge plugins.
|
|
95
110
|
|
|
96
111
|
---
|
|
97
112
|
|
|
98
|
-
## Fan Speed Control — Wiring Mode
|
|
113
|
+
## Fan Speed Control — Wiring Mode
|
|
99
114
|
|
|
100
|
-
The
|
|
115
|
+
The fan supports two installation wiring modes selected at installation time:
|
|
101
116
|
|
|
102
|
-
**Intermittent mode** —
|
|
117
|
+
**Intermittent mode** — fan is off by default, triggered by sensors or timer. BLE speed commands are fully respected and the slider controls the fan directly.
|
|
103
118
|
|
|
104
|
-
**Always-on (trickle) mode** —
|
|
119
|
+
**Always-on (trickle) mode** — fan runs continuously at a speed set by its onboard controller. BLE speed writes are accepted but the firmware reasserts its own speed setpoint immediately. The slider accurately reflects current RPM but cannot override the onboard speed.
|
|
105
120
|
|
|
106
|
-
|
|
121
|
+
In always-on mode, the sensors and automations remain fully functional and represent the primary value of the integration — particularly for bathroom and wetroom use where humidity, temperature and light thresholds enable powerful Apple Home automations.
|
|
107
122
|
|
|
108
|
-
To enable full speed control,
|
|
123
|
+
To enable full speed control, switch to intermittent wiring mode per the fan's installation guide.
|
|
109
124
|
|
|
110
125
|
---
|
|
111
126
|
|
|
112
127
|
## Sensor Notes
|
|
113
128
|
|
|
114
|
-
|
|
129
|
+
Humidity and light values are guaranteed fresh on every poll — the plugin uses a connect→read→disconnect cycle which forces the device's GATT server to return live readings rather than cached values.
|
|
115
130
|
|
|
116
|
-
|
|
131
|
+
The light sensor detects occupancy via movement and shadow rather than acting as a precision lux meter. Small variances from a handheld reference device are normal and expected, particularly with ceiling or wall mounting angles.
|
|
132
|
+
|
|
133
|
+
For lux-based automations use **Eve** or **Hesperus**, which support lux thresholds synced back to HomeKit.
|
|
117
134
|
|
|
118
135
|
---
|
|
119
136
|
|
|
120
137
|
## Troubleshooting
|
|
121
138
|
|
|
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`
|
|
139
|
+
**Fan not discovered** — run `hciconfig` on the Pi; confirm `UP RUNNING`. If showing `RF-KILL` run `sudo rfkill unblock bluetooth`. If still not found, perform a full Pi power cycle to reinitialise the BLE stack.
|
|
126
140
|
|
|
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.
|
|
141
|
+
**Authentication failed** — default PIN is `012345`. If changed in the official app, add the fan to the `fans` config array with the correct PIN.
|
|
129
142
|
|
|
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.
|
|
143
|
+
**Speed slider has no effect** — fan is wired in always-on mode. See wiring note above. All sensors work normally.
|
|
132
144
|
|
|
133
|
-
**Accessories not
|
|
134
|
-
- Remove the accessory cache via Homebridge UI → (plugin settings) → Remove Accessory Cache, then restart Homebridge
|
|
145
|
+
**Accessories not in Home app** — clear accessory cache via Homebridge UI → plugin settings → Remove Accessory Cache, then restart.
|
|
135
146
|
|
|
136
147
|
---
|
|
137
148
|
|
|
138
149
|
## Release Notes
|
|
139
150
|
|
|
151
|
+
### 2.4.1
|
|
152
|
+
- **Fixed:** Humidity and light sensors returning stale values — root cause identified as the device's GATT server only refreshing sensor values on a new connection. Poll cycle reverted to proven connect→auth→read→disconnect pattern which guarantees live readings on every poll
|
|
153
|
+
- **Fixed:** Characteristics discovered and cached once per poll cycle — eliminates the intermittent `Characteristic not found` errors seen in 2.4.0
|
|
154
|
+
- **Fixed:** Overlapping poll guard prevents concurrent BLE connections
|
|
155
|
+
- **Improved:** `writeSpeed` correctly skips if a poll is in progress to avoid BLE conflicts
|
|
156
|
+
- **Improved:** Node.js minimum version updated to `18.20.4` per Homebridge 2.0 specification
|
|
157
|
+
- **Documented:** Homebridge 2.0 compliance details added to README
|
|
158
|
+
|
|
159
|
+
### 2.4.0
|
|
160
|
+
- **Rebuilt:** Persistent connection architecture — connect once, cache characteristics, poll with direct reads. Fast RPM and temperature updates confirmed. Humidity and light subsequently found to return stale GATT cached values on persistent connections — resolved in 2.4.1
|
|
161
|
+
|
|
140
162
|
### 2.3.2
|
|
141
|
-
-
|
|
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
|
|
163
|
+
- Documented always-on vs intermittent wiring mode behaviour
|
|
144
164
|
|
|
145
165
|
### 2.3.1
|
|
146
|
-
-
|
|
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
|
|
166
|
+
- Identified correct control characteristic; write diagnostics added
|
|
153
167
|
|
|
154
|
-
### 2.2.1
|
|
155
|
-
-
|
|
156
|
-
-
|
|
157
|
-
-
|
|
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
|
|
168
|
+
### 2.3.0 / 2.2.1 / 2.1.0
|
|
169
|
+
- Sensor UUIDs and byte mappings confirmed via live device dump
|
|
170
|
+
- BLE initialisation fixes for Homebridge 2.0
|
|
171
|
+
- Duplicate accessory registration resolved
|
|
166
172
|
|
|
167
173
|
### 1.0.x
|
|
168
174
|
- Initial working releases
|
package/config.schema.json
CHANGED
|
@@ -11,22 +11,58 @@
|
|
|
11
11
|
"default": "Pax Calima / Vent-Axia Svara"
|
|
12
12
|
},
|
|
13
13
|
"fans": {
|
|
14
|
-
"title": "Configured Fans",
|
|
14
|
+
"title": "Manually Configured Fans",
|
|
15
|
+
"description": "Optional. Leave empty to use auto-discovery (recommended). Add entries here only to set a custom name or PIN per fan.",
|
|
15
16
|
"type": "array",
|
|
16
17
|
"items": {
|
|
17
18
|
"type": "object",
|
|
18
19
|
"properties": {
|
|
19
|
-
"name": {
|
|
20
|
+
"name": {
|
|
21
|
+
"title": "Accessory Name",
|
|
22
|
+
"type": "string",
|
|
23
|
+
"default": "Bathroom Fan"
|
|
24
|
+
},
|
|
20
25
|
"mac": {
|
|
21
26
|
"title": "MAC Address",
|
|
22
27
|
"type": "string",
|
|
23
|
-
"pattern": "^([0-9A-Fa-f]{2}
|
|
24
|
-
"
|
|
28
|
+
"pattern": "^([0-9A-Fa-f]{2}:){5}([0-9A-Fa-f]{2})$",
|
|
29
|
+
"description": "MAC address of the fan (e.g. 58:2b:db:xx:xx:xx). Must start with 58:2b:db."
|
|
25
30
|
},
|
|
26
|
-
"pin": {
|
|
27
|
-
|
|
31
|
+
"pin": {
|
|
32
|
+
"title": "PIN Code",
|
|
33
|
+
"type": "string",
|
|
34
|
+
"default": "012345",
|
|
35
|
+
"description": "6-digit PIN. Only change this if you set a custom PIN in the official Pax / Vent-Axia app."
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"required": ["name", "mac"]
|
|
28
39
|
}
|
|
29
40
|
}
|
|
30
41
|
}
|
|
31
|
-
}
|
|
42
|
+
},
|
|
43
|
+
"layout": [
|
|
44
|
+
{
|
|
45
|
+
"type": "section",
|
|
46
|
+
"title": "General",
|
|
47
|
+
"expandable": false,
|
|
48
|
+
"items": ["name"]
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"type": "section",
|
|
52
|
+
"title": "Fans (optional — auto-discovery recommended)",
|
|
53
|
+
"expandable": true,
|
|
54
|
+
"expanded": false,
|
|
55
|
+
"items": [
|
|
56
|
+
{
|
|
57
|
+
"key": "fans",
|
|
58
|
+
"type": "array",
|
|
59
|
+
"items": [
|
|
60
|
+
"fans[].name",
|
|
61
|
+
"fans[].mac",
|
|
62
|
+
"fans[].pin"
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
]
|
|
66
|
+
}
|
|
67
|
+
]
|
|
32
68
|
}
|
package/index.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
1
3
|
const noble = require('@abandonware/noble');
|
|
2
4
|
|
|
3
5
|
let Service, Characteristic, Categories, API, PlatformAccessory;
|
|
4
6
|
|
|
5
|
-
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
const
|
|
7
|
+
// ── Verified BLE UUIDs (confirmed via live device dump April 2026) ────────────
|
|
8
|
+
const UUID_AUTH = '4cad343a-209a-40b7-b911-4d9b3df569b2'; // PIN auth — write once per connection
|
|
9
|
+
const UUID_STATUS = '528b80e8-c47a-4c0a-bdf1-916a7748f412'; // RPM (offset 6 UInt16LE) + temp (offset 2 UInt16LE / 6)
|
|
10
|
+
const UUID_SENSOR = 'a22eae12-dba8-49f3-9c69-1721dcff1d96'; // humidity (offset 0 UInt8 × 2) + lux (offset 2 UInt16BE)
|
|
11
|
+
const UUID_CONTROL = '1488a757-35bc-4ec8-9a6b-9ecf1502778e'; // speed config: [trickle|normal|boost] as UInt16LE pairs
|
|
9
12
|
|
|
10
|
-
const MAC_PREFIX
|
|
11
|
-
const POLL_MS
|
|
12
|
-
const
|
|
13
|
+
const MAC_PREFIX = '58:2b:db';
|
|
14
|
+
const POLL_MS = 10000; // 10s — responsive for shower humidity spikes
|
|
15
|
+
const RECONNECT_MS = 15000;
|
|
13
16
|
|
|
14
17
|
module.exports = (homebridge) => {
|
|
15
18
|
Service = homebridge.hap.Service;
|
|
@@ -17,9 +20,12 @@ module.exports = (homebridge) => {
|
|
|
17
20
|
Categories = homebridge.hap.Categories;
|
|
18
21
|
API = homebridge;
|
|
19
22
|
PlatformAccessory = homebridge.platformAccessory;
|
|
23
|
+
|
|
24
|
+
// Dynamic platform — required for Homebridge 2.0
|
|
20
25
|
homebridge.registerPlatform('homebridge-pax-calima', 'PaxCalima', PaxCalimaPlatform, true);
|
|
21
26
|
};
|
|
22
27
|
|
|
28
|
+
// ─── Platform ─────────────────────────────────────────────────────────────────
|
|
23
29
|
class PaxCalimaPlatform {
|
|
24
30
|
constructor(log, config, api) {
|
|
25
31
|
this.log = log;
|
|
@@ -27,23 +33,26 @@ class PaxCalimaPlatform {
|
|
|
27
33
|
this.api = api;
|
|
28
34
|
this.registeredMACs = new Set();
|
|
29
35
|
this.discoveryStarted = false;
|
|
30
|
-
this.cachedAccessories = [];
|
|
36
|
+
this.cachedAccessories = []; // held until BLE is ready after didFinishLaunching
|
|
31
37
|
|
|
32
38
|
this.log.info('PaxCalima platform initialised');
|
|
33
39
|
|
|
34
40
|
this.api.on('didFinishLaunching', () => {
|
|
35
|
-
this.log.info('Homebridge finished launching
|
|
41
|
+
this.log.info('Homebridge finished launching — starting BLE');
|
|
36
42
|
noble.on('stateChange', (state) => {
|
|
37
43
|
this.log.info('BLE state: ' + state);
|
|
38
44
|
if (state === 'poweredOn') this.startDiscovery();
|
|
39
45
|
});
|
|
46
|
+
// Catch case where noble is already poweredOn before listener fires
|
|
40
47
|
setTimeout(() => {
|
|
41
|
-
this.log.info('BLE current state: ' + noble.state);
|
|
42
48
|
if (noble.state === 'poweredOn') this.startDiscovery();
|
|
43
49
|
}, 3000);
|
|
44
50
|
});
|
|
45
51
|
}
|
|
46
52
|
|
|
53
|
+
// Called by Homebridge for each cached accessory on startup.
|
|
54
|
+
// Held until startDiscovery() confirms BLE is ready — prevents polling
|
|
55
|
+
// before the BLE stack is initialised.
|
|
47
56
|
configureAccessory(accessory) {
|
|
48
57
|
const mac = accessory.context && accessory.context.mac;
|
|
49
58
|
this.log.info('Restoring cached accessory: ' + accessory.displayName);
|
|
@@ -57,13 +66,14 @@ class PaxCalimaPlatform {
|
|
|
57
66
|
if (this.discoveryStarted) return;
|
|
58
67
|
this.discoveryStarted = true;
|
|
59
68
|
|
|
69
|
+
// Spin up BLE handlers for all previously registered accessories
|
|
60
70
|
for (const { accessory, mac } of this.cachedAccessories) {
|
|
61
|
-
|
|
62
|
-
this.log
|
|
63
|
-
new PaxCalimaAccessory(this.log, accessory, mac, pin);
|
|
71
|
+
this.log.info('Starting handler for cached fan: ' + mac);
|
|
72
|
+
new PaxCalimaAccessory(this.log, accessory, mac, this.pinForMac(mac));
|
|
64
73
|
}
|
|
65
74
|
this.cachedAccessories = [];
|
|
66
75
|
|
|
76
|
+
// Scan for any new fans not yet registered
|
|
67
77
|
this.log.info('Starting BLE discovery scan...');
|
|
68
78
|
noble.startScanning([], false);
|
|
69
79
|
|
|
@@ -71,23 +81,25 @@ class PaxCalimaPlatform {
|
|
|
71
81
|
const mac = peripheral.address.toLowerCase();
|
|
72
82
|
if (!mac.startsWith(MAC_PREFIX)) return;
|
|
73
83
|
if (this.registeredMACs.has(mac)) return;
|
|
84
|
+
|
|
74
85
|
this.registeredMACs.add(mac);
|
|
75
86
|
this.log.info('Discovered new fan: ' + mac);
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
const hbAcc = new PlatformAccessory(name, uuid, Categories.FAN);
|
|
87
|
+
|
|
88
|
+
const uuid = API.hap.uuid.generate('pax-calima-' + mac);
|
|
89
|
+
const hbAcc = new PlatformAccessory(this.nameForMac(mac), uuid, Categories.FAN);
|
|
80
90
|
hbAcc.context.mac = mac;
|
|
81
|
-
|
|
91
|
+
|
|
92
|
+
new PaxCalimaAccessory(this.log, hbAcc, mac, this.pinForMac(mac));
|
|
82
93
|
this.api.registerPlatformAccessories('homebridge-pax-calima', 'PaxCalima', [hbAcc]);
|
|
83
94
|
});
|
|
84
95
|
}
|
|
85
96
|
|
|
86
97
|
fanConfig(mac) { return (this.config.fans || []).find(f => f.mac && f.mac.toLowerCase() === mac) || {}; }
|
|
87
98
|
pinForMac(mac) { return this.fanConfig(mac).pin || '012345'; }
|
|
88
|
-
nameForMac(mac) { return this.fanConfig(mac).name || ('
|
|
99
|
+
nameForMac(mac) { return this.fanConfig(mac).name || ('Pax Calima ' + mac.slice(-5).toUpperCase()); }
|
|
89
100
|
}
|
|
90
101
|
|
|
102
|
+
// ─── Accessory ────────────────────────────────────────────────────────────────
|
|
91
103
|
class PaxCalimaAccessory {
|
|
92
104
|
constructor(log, accessory, mac, pin) {
|
|
93
105
|
this.log = log;
|
|
@@ -95,6 +107,7 @@ class PaxCalimaAccessory {
|
|
|
95
107
|
this.mac = mac.toLowerCase();
|
|
96
108
|
this.pin = pin || '012345';
|
|
97
109
|
|
|
110
|
+
// Sensor state — served to HomeKit between polls
|
|
98
111
|
this.currentRPM = 0;
|
|
99
112
|
this.humidity = 0;
|
|
100
113
|
this.temperature = 20;
|
|
@@ -102,15 +115,17 @@ class PaxCalimaAccessory {
|
|
|
102
115
|
this.silentHours = false;
|
|
103
116
|
|
|
104
117
|
this.pollTimer = null;
|
|
105
|
-
this.polling = false;
|
|
118
|
+
this.polling = false; // guard against overlapping cycles
|
|
106
119
|
|
|
107
120
|
this.setupServices();
|
|
108
|
-
this.schedulePoll(0);
|
|
121
|
+
this.schedulePoll(0); // BLE is ready by the time constructor is called
|
|
109
122
|
}
|
|
110
123
|
|
|
124
|
+
// ── HomeKit services ────────────────────────────────────────────────────────
|
|
111
125
|
setupServices() {
|
|
112
126
|
const n = this.accessory.displayName;
|
|
113
127
|
|
|
128
|
+
// Fan — always active; speed reflects live RPM
|
|
114
129
|
this.fanService = this.accessory.getService(Service.Fanv2)
|
|
115
130
|
|| this.accessory.addService(new Service.Fanv2(n, 'fan'));
|
|
116
131
|
this.fanService.getCharacteristic(Characteristic.Active)
|
|
@@ -120,17 +135,20 @@ class PaxCalimaAccessory {
|
|
|
120
135
|
.onGet(() => this.rpmToPercent(this.currentRPM))
|
|
121
136
|
.onSet((v) => this.writeSpeed(v));
|
|
122
137
|
|
|
138
|
+
// Boost — stateless single-press button
|
|
123
139
|
this.boostButton = this.accessory.getService(Service.StatelessProgrammableSwitch)
|
|
124
140
|
|| this.accessory.addService(new Service.StatelessProgrammableSwitch(n + ' Boost', 'boost'));
|
|
125
141
|
this.boostButton.getCharacteristic(Characteristic.ProgrammableSwitchEvent)
|
|
126
142
|
.setProps({ validValues: [0] });
|
|
127
143
|
|
|
144
|
+
// Silent Hours — suppresses automatic boosting during quiet periods
|
|
128
145
|
this.silentService = this.accessory.getServiceByUUIDAndSubType(Service.Switch, 'silent-hours')
|
|
129
146
|
|| this.accessory.addService(new Service.Switch(n + ' Silent Hours', 'silent-hours'));
|
|
130
147
|
this.silentService.getCharacteristic(Characteristic.On)
|
|
131
148
|
.onGet(() => this.silentHours)
|
|
132
149
|
.onSet((on) => this.setSilentHours(on));
|
|
133
150
|
|
|
151
|
+
// Sensors
|
|
134
152
|
this.humidityService = this.accessory.getService(Service.HumiditySensor)
|
|
135
153
|
|| this.accessory.addService(new Service.HumiditySensor(n + ' Humidity', 'humidity'));
|
|
136
154
|
this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity)
|
|
@@ -146,9 +164,13 @@ class PaxCalimaAccessory {
|
|
|
146
164
|
this.lightService.getCharacteristic(Characteristic.CurrentAmbientLightLevel)
|
|
147
165
|
.onGet(() => Math.max(0.0001, this.lightLevel));
|
|
148
166
|
|
|
149
|
-
this.log.info('Services
|
|
167
|
+
this.log.info('Services ready: ' + n);
|
|
150
168
|
}
|
|
151
169
|
|
|
170
|
+
// ── Poll cycle: connect → auth → read → disconnect → wait → repeat ──────────
|
|
171
|
+
// The device's GATT server only returns fresh sensor values on a new connection.
|
|
172
|
+
// A persistent connection returns cached values for humidity and light.
|
|
173
|
+
// We therefore use short-lived connections — proven reliable in testing.
|
|
152
174
|
schedulePoll(delayMs) {
|
|
153
175
|
if (this.pollTimer) clearTimeout(this.pollTimer);
|
|
154
176
|
this.pollTimer = setTimeout(() => this.poll(), delayMs);
|
|
@@ -158,59 +180,69 @@ class PaxCalimaAccessory {
|
|
|
158
180
|
if (this.polling) { this.schedulePoll(POLL_MS); return; }
|
|
159
181
|
this.polling = true;
|
|
160
182
|
let peripheral = null;
|
|
183
|
+
|
|
161
184
|
try {
|
|
162
185
|
peripheral = await this.findPeripheral();
|
|
163
186
|
await peripheral.connectAsync();
|
|
164
187
|
|
|
165
|
-
|
|
188
|
+
// Discover and cache all characteristics for this connection
|
|
189
|
+
const charCache = {};
|
|
190
|
+
const services = await peripheral.discoverServicesAsync();
|
|
191
|
+
for (const svc of services) {
|
|
192
|
+
const chars = await svc.discoverCharacteristicsAsync();
|
|
193
|
+
for (const c of chars) charCache[c.uuid.replace(/-/g, '')] = c;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Authenticate
|
|
197
|
+
const authChar = charCache[UUID_AUTH.replace(/-/g, '')];
|
|
198
|
+
if (!authChar) throw new Error('Auth characteristic not found');
|
|
166
199
|
const pinBuf = Buffer.alloc(4);
|
|
167
200
|
pinBuf.writeUInt32LE(parseInt(this.pin, 10), 0);
|
|
168
201
|
await authChar.writeAsync(pinBuf, true);
|
|
169
202
|
|
|
170
|
-
|
|
203
|
+
// Read status: RPM + temperature
|
|
204
|
+
const statusChar = charCache[UUID_STATUS.replace(/-/g, '')];
|
|
205
|
+
if (!statusChar) throw new Error('Status characteristic not found');
|
|
171
206
|
const statusData = await statusChar.readAsync();
|
|
172
207
|
this.currentRPM = Math.max(0, statusData.readUInt16LE(6));
|
|
173
208
|
this.temperature = Math.round((statusData.readUInt16LE(2) / 6) * 10) / 10;
|
|
174
209
|
|
|
175
|
-
|
|
210
|
+
// Read sensors: humidity + lux — fresh values guaranteed on each new connection
|
|
211
|
+
const sensorChar = charCache[UUID_SENSOR.replace(/-/g, '')];
|
|
212
|
+
if (!sensorChar) throw new Error('Sensor characteristic not found');
|
|
176
213
|
const sensorData = await sensorChar.readAsync();
|
|
177
214
|
this.humidity = Math.max(0, Math.min(100, sensorData.readUInt8(0) * 2));
|
|
178
215
|
this.lightLevel = Math.max(0.0001, sensorData.readUInt16BE(2));
|
|
179
216
|
|
|
180
|
-
//
|
|
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
|
-
|
|
217
|
+
// Push all values to HomeKit
|
|
189
218
|
const speed = this.rpmToPercent(this.currentRPM);
|
|
190
219
|
this.fanService.getCharacteristic(Characteristic.RotationSpeed).updateValue(speed);
|
|
191
220
|
this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity).updateValue(this.humidity);
|
|
192
221
|
this.tempService.getCharacteristic(Characteristic.CurrentTemperature).updateValue(this.temperature);
|
|
193
222
|
this.lightService.getCharacteristic(Characteristic.CurrentAmbientLightLevel).updateValue(this.lightLevel);
|
|
194
223
|
|
|
195
|
-
this.log.info('
|
|
224
|
+
this.log.info('Poll: RPM=' + this.currentRPM + ' Speed=' + speed +
|
|
196
225
|
'% Hum=' + this.humidity + '% Temp=' + this.temperature +
|
|
197
226
|
'C Light=' + this.lightLevel + 'lx');
|
|
198
227
|
|
|
199
228
|
} catch (err) {
|
|
200
229
|
this.log.warn('Poll error: ' + err.message);
|
|
201
230
|
} finally {
|
|
231
|
+
// Always disconnect cleanly — required for fresh sensor reads next cycle
|
|
202
232
|
if (peripheral) { try { await peripheral.disconnectAsync(); } catch (_) {} }
|
|
203
233
|
this.polling = false;
|
|
204
234
|
this.schedulePoll(POLL_MS);
|
|
205
235
|
}
|
|
206
236
|
}
|
|
207
237
|
|
|
238
|
+
// Targeted scan — resolves as soon as our MAC is seen, removes its own listener
|
|
208
239
|
findPeripheral() {
|
|
209
240
|
return new Promise((resolve, reject) => {
|
|
210
241
|
const timeout = setTimeout(() => {
|
|
211
242
|
noble.removeListener('discover', onDiscover);
|
|
212
243
|
reject(new Error('Scan timeout for ' + this.mac));
|
|
213
|
-
},
|
|
244
|
+
}, 20000);
|
|
245
|
+
|
|
214
246
|
const onDiscover = (periph) => {
|
|
215
247
|
if (periph.address.toLowerCase() === this.mac) {
|
|
216
248
|
clearTimeout(timeout);
|
|
@@ -218,57 +250,53 @@ class PaxCalimaAccessory {
|
|
|
218
250
|
resolve(periph);
|
|
219
251
|
}
|
|
220
252
|
};
|
|
253
|
+
|
|
221
254
|
noble.on('discover', onDiscover);
|
|
222
255
|
noble.startScanning([], false);
|
|
223
256
|
});
|
|
224
257
|
}
|
|
225
258
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
for (const service of services) {
|
|
230
|
-
const chars = await service.discoverCharacteristicsAsync();
|
|
231
|
-
for (const char of chars) {
|
|
232
|
-
if (char.uuid.replace(/-/g, '') === stripped) return char;
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
throw new Error('Characteristic ' + uuid + ' not found');
|
|
236
|
-
}
|
|
237
|
-
|
|
259
|
+
// ── Fan speed write ─────────────────────────────────────────────────────────
|
|
260
|
+
// Note: in always-on wiring mode the fan's onboard controller will reassert
|
|
261
|
+
// its own speed setpoint after each write. See README for details.
|
|
238
262
|
async writeSpeed(percent) {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
263
|
+
if (this.polling) {
|
|
264
|
+
this.log.warn('Speed write skipped — poll in progress');
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
242
267
|
let peripheral = null;
|
|
243
268
|
try {
|
|
244
269
|
peripheral = await this.findPeripheral();
|
|
245
270
|
await peripheral.connectAsync();
|
|
246
271
|
|
|
247
|
-
const
|
|
272
|
+
const charCache = {};
|
|
273
|
+
const services = await peripheral.discoverServicesAsync();
|
|
274
|
+
for (const svc of services) {
|
|
275
|
+
const chars = await svc.discoverCharacteristicsAsync();
|
|
276
|
+
for (const c of chars) charCache[c.uuid.replace(/-/g, '')] = c;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const authChar = charCache[UUID_AUTH.replace(/-/g, '')];
|
|
280
|
+
if (!authChar) throw new Error('Auth characteristic not found');
|
|
248
281
|
const pinBuf = Buffer.alloc(4);
|
|
249
282
|
pinBuf.writeUInt32LE(parseInt(this.pin, 10), 0);
|
|
250
283
|
await authChar.writeAsync(pinBuf, true);
|
|
251
284
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const current = await ctrlChar.readAsync();
|
|
255
|
-
this.log.info('CTRL before write: ' + Array.from(current).join(','));
|
|
285
|
+
const ctrlChar = charCache[UUID_CONTROL.replace(/-/g, '')];
|
|
286
|
+
if (!ctrlChar) throw new Error('Control characteristic not found');
|
|
256
287
|
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
// We write the target RPM into the normal speed slot [2-3]
|
|
288
|
+
// Preserve existing trickle + boost values, update normal speed slot only
|
|
289
|
+
const current = await ctrlChar.readAsync();
|
|
260
290
|
const targetRPM = Math.round((percent / 100) * 2400);
|
|
261
|
-
const buf
|
|
262
|
-
buf.writeUInt16LE(targetRPM, 2);
|
|
291
|
+
const buf = Buffer.from(current);
|
|
292
|
+
buf.writeUInt16LE(targetRPM, 2);
|
|
263
293
|
await ctrlChar.writeAsync(buf, true);
|
|
294
|
+
this.log.info('Speed set to ' + percent + '% (' + targetRPM + ' RPM)');
|
|
264
295
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
this.log.error('writeSpeed failed: ' + e.message);
|
|
296
|
+
} catch (err) {
|
|
297
|
+
this.log.error('writeSpeed failed: ' + err.message);
|
|
268
298
|
} finally {
|
|
269
299
|
if (peripheral) { try { await peripheral.disconnectAsync(); } catch (_) {} }
|
|
270
|
-
// Resume polling after write
|
|
271
|
-
this.schedulePoll(2000);
|
|
272
300
|
}
|
|
273
301
|
}
|
|
274
302
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-pax-calima",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.1",
|
|
4
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": [
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
],
|
|
18
18
|
"engines": {
|
|
19
19
|
"homebridge": "^1.6.0 || ^2.0.0",
|
|
20
|
-
"node": ">=18.
|
|
20
|
+
"node": ">=18.20.4"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@abandonware/noble": "1.9.2-26"
|