homebridge-pax-calima 2.4.0 → 2.4.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 +111 -74
- package/config.schema.json +10 -4
- package/index.js +127 -142
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,58 +1,86 @@
|
|
|
1
1
|
# homebridge-pax-calima
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**Bring your bathroom fan into Apple Home — no hub, no cloud, just Bluetooth.**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
If you have a **Pax Calima** or **Vent-Axia Lo-Carbon Svara** extractor fan, this plugin connects it directly to HomeKit via Bluetooth. Once installed, your fan shows up in the Home app with live temperature, light level and fan speed — and you can build automations around all of them.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
-
|
|
9
|
-
- **Vent-Axia Lo-Carbon Svara** (White & Black) — UK
|
|
7
|
+
[](https://github.com/homebridge/homebridge/wiki/Updating-To-Homebridge-v2.0)
|
|
8
|
+
[](https://www.npmjs.com/package/homebridge-pax-calima)
|
|
10
9
|
|
|
11
|
-
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Is this the right plugin for your fan?
|
|
13
|
+
|
|
14
|
+
This plugin works with fans that connect via the official **Pax** or **Vent-Axia** Bluetooth app. If your fan has a MAC address starting with `58:2b:db`, you're good to go.
|
|
15
|
+
|
|
16
|
+
**Confirmed compatible:**
|
|
17
|
+
- Pax Calima — sold across Scandinavia and Iberia (Sweden, Norway, Denmark, Finland, Spain, Portugal)
|
|
18
|
+
- Vent-Axia Lo-Carbon Svara White — UK
|
|
19
|
+
- Vent-Axia Lo-Carbon Svara Black — UK
|
|
20
|
+
|
|
21
|
+
These fans are hardware-identical. The same plugin works for all of them.
|
|
12
22
|
|
|
13
23
|
---
|
|
14
24
|
|
|
15
|
-
##
|
|
25
|
+
## What you get in the Home app
|
|
26
|
+
|
|
27
|
+
Once set up, your fan appears as a single accessory with these tiles:
|
|
28
|
+
|
|
29
|
+
- 🌡️ **Temperature** — live reading, updates every 10 seconds
|
|
30
|
+
- 💡 **Light level** — lux reading, useful for occupancy automations
|
|
31
|
+
- 💨 **Fan speed** — shows current RPM as a percentage
|
|
32
|
+
- ⚡ **Boost** — a button that triggers the fan's high-speed boost mode
|
|
33
|
+
- 🌙 **Silent Hours** — a switch that tells the fan not to boost overnight
|
|
34
|
+
- 💧 **Humidity** — available but off by default (read the note below before enabling)
|
|
16
35
|
|
|
17
|
-
|
|
18
|
-
|---|---|
|
|
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) |
|
|
21
|
-
| **Humidity sensor** | Live readings, updates every 10 seconds |
|
|
22
|
-
| **Temperature sensor** | Live readings, updates every 10 seconds |
|
|
23
|
-
| **Light sensor** | Live lux readings, updates every 10 seconds |
|
|
24
|
-
| **Boost button** | Stateless programmable switch |
|
|
25
|
-
| **Silent Hours** | Toggle switch — suppresses automatic boosting during quiet periods |
|
|
26
|
-
| **Automations** | Full HomeKit automation support on all sensors |
|
|
36
|
+
> Your accessories will appear in the **Home app on your iPhone**, not in the Homebridge UI accessories tab — that's normal for child bridge plugins.
|
|
27
37
|
|
|
28
38
|
---
|
|
29
39
|
|
|
30
|
-
##
|
|
40
|
+
## A note on humidity
|
|
31
41
|
|
|
42
|
+
The humidity tile is **turned off by default**, and for good reason.
|
|
43
|
+
|
|
44
|
+
The fan's Bluetooth firmware doesn't expose humidity as a continuous live reading — it reflects the fan's own internal threshold state and updates infrequently. During our testing, the fan's Bluetooth reported 50% while a calibrated sensor in the same room read 89% after a shower. This isn't a bug in the plugin — it's how the fan's firmware works, and the Home Assistant community has observed the same behaviour.
|
|
45
|
+
|
|
46
|
+
**If you turn humidity on**, be aware that:
|
|
47
|
+
- Asking Siri "what's the humidity in the bathroom?" may return a stale number
|
|
48
|
+
- Humidity-triggered automations won't fire reliably during a shower
|
|
49
|
+
|
|
50
|
+
**What does work well for shower detection:** when the fan's internal sensor detects a sharp humidity rise, it boosts to near full speed automatically. A `Fan Speed > 75%` automation in Apple Home is a reliable, practical trigger for "someone's in the shower."
|
|
51
|
+
|
|
52
|
+
If you still want to enable humidity — perhaps for settled ambient readings rather than real-time monitoring — you can turn it on in the plugin settings with `showHumidity: true`.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Before you start
|
|
57
|
+
|
|
58
|
+
You'll need:
|
|
32
59
|
- Homebridge `^1.6.0` or `^2.0.0`
|
|
33
|
-
- Node.js
|
|
34
|
-
- A Bluetooth
|
|
60
|
+
- Node.js `18.20.4` or later
|
|
61
|
+
- A Bluetooth adapter on your Homebridge host (the Raspberry Pi 4's built-in adapter works well)
|
|
35
62
|
|
|
36
|
-
|
|
63
|
+
**If you're running Homebridge OS on a Raspberry Pi**, there's one extra step after installation. The Bluetooth library needs a small permissions tweak — run this once in the **Homebridge terminal**:
|
|
37
64
|
|
|
38
65
|
```bash
|
|
39
66
|
sudo setcap cap_net_raw+eip /opt/homebridge/bin/node
|
|
40
67
|
```
|
|
41
68
|
|
|
42
|
-
If Bluetooth shows as
|
|
69
|
+
If Bluetooth shows as off after that:
|
|
43
70
|
|
|
44
71
|
```bash
|
|
45
72
|
sudo rfkill unblock bluetooth
|
|
46
73
|
```
|
|
47
74
|
|
|
75
|
+
And if things still aren't working after both of those — a full **power cycle** of the Pi (unplug, wait, replug) tends to sort it. A software restart alone sometimes isn't enough to reinitialise the Bluetooth stack cleanly.
|
|
76
|
+
|
|
48
77
|
---
|
|
49
78
|
|
|
50
79
|
## Installation
|
|
51
80
|
|
|
52
|
-
|
|
53
|
-
Search for **Pax Calima** or **Svara** and click Install.
|
|
81
|
+
Search for **Pax Calima** or **Svara** in the Homebridge UI and click Install — that's it.
|
|
54
82
|
|
|
55
|
-
|
|
83
|
+
Or via terminal:
|
|
56
84
|
```bash
|
|
57
85
|
npm install -g homebridge-pax-calima
|
|
58
86
|
```
|
|
@@ -61,12 +89,15 @@ npm install -g homebridge-pax-calima
|
|
|
61
89
|
|
|
62
90
|
## Configuration
|
|
63
91
|
|
|
64
|
-
|
|
92
|
+
**You don't need to configure anything.** The plugin finds your fan automatically by scanning for Bluetooth devices with the right MAC prefix and registers it in HomeKit.
|
|
93
|
+
|
|
94
|
+
If you want to give your fan a custom name, or if you've set a non-default PIN in the official app, you can specify those in settings:
|
|
65
95
|
|
|
66
96
|
```json
|
|
67
97
|
{
|
|
68
98
|
"platform": "PaxCalima",
|
|
69
99
|
"name": "Pax Calima / Vent-Axia Svara",
|
|
100
|
+
"showHumidity": false,
|
|
70
101
|
"fans": [
|
|
71
102
|
{
|
|
72
103
|
"name": "Wetroom Fan",
|
|
@@ -77,79 +108,85 @@ No configuration needed for auto-discovery. To set a custom name or PIN per fan:
|
|
|
77
108
|
}
|
|
78
109
|
```
|
|
79
110
|
|
|
80
|
-
|
|
111
|
+
| Setting | Default | What it does |
|
|
112
|
+
|---|---|---|
|
|
113
|
+
| `showHumidity` | `false` | Enables the humidity tile — read the note above first |
|
|
114
|
+
| `fans[].name` | Auto-generated | Sets a friendly name for a specific fan |
|
|
115
|
+
| `fans[].mac` | — | Only needed if you want to assign a name or custom PIN |
|
|
116
|
+
| `fans[].pin` | `012345` | The 6-digit PIN — only change if you set a custom one in the official app |
|
|
81
117
|
|
|
82
118
|
---
|
|
83
119
|
|
|
84
|
-
##
|
|
120
|
+
## Fan speed control — it depends on how your fan is wired
|
|
85
121
|
|
|
86
|
-
|
|
122
|
+
The Svara and Calima can be installed in two different modes, and which one you have affects what the speed slider does:
|
|
87
123
|
|
|
88
|
-
|
|
89
|
-
- **Boost** — single-press programmable button
|
|
90
|
-
- **Silent Hours** — on/off switch
|
|
91
|
-
- **Humidity** — % relative humidity
|
|
92
|
-
- **Temperature** — °C
|
|
93
|
-
- **Light** — lux
|
|
124
|
+
**Intermittent mode** — the fan is off by default and spins up when it detects humidity, light, or a timer trigger. In this mode, the speed slider in Apple Home controls the fan directly.
|
|
94
125
|
|
|
95
|
-
|
|
126
|
+
**Always-on (trickle) mode** — the fan runs continuously at a speed managed by its own onboard controller. In this mode, you can send speed commands via Bluetooth and they are accepted — but the fan resets to its own setpoint within seconds. The slider will show you the current speed accurately, but won't be able to change it.
|
|
96
127
|
|
|
97
|
-
|
|
128
|
+
If you're not sure which mode you have, check whether your fan is ever fully off. If it's always running (even quietly), you're in always-on mode.
|
|
98
129
|
|
|
99
|
-
|
|
130
|
+
In either mode, the temperature, light and fan speed automations work perfectly.
|
|
100
131
|
|
|
101
|
-
|
|
132
|
+
---
|
|
102
133
|
|
|
103
|
-
|
|
134
|
+
## Something not working?
|
|
104
135
|
|
|
105
|
-
**
|
|
136
|
+
**Fan not showing up in Home**
|
|
137
|
+
Try `hciconfig` in the Homebridge terminal — you should see `UP RUNNING` next to `hci0`. If it shows `RF-KILL`, run `sudo rfkill unblock bluetooth`. If still nothing, unplug the Pi, wait 10 seconds, and plug it back in.
|
|
106
138
|
|
|
107
|
-
|
|
139
|
+
**setcap not sticking / BLE still powered off**
|
|
140
|
+
Make sure you're running setcap against the right binary — on Homebridge OS it's `/opt/homebridge/bin/node`, not whatever `which node` returns.
|
|
108
141
|
|
|
109
|
-
|
|
142
|
+
**Wrong PIN / can't authenticate**
|
|
143
|
+
The default PIN is `012345`. If you changed it in the official Pax or Vent-Axia app, add your fan to the `fans` config array with the correct PIN.
|
|
110
144
|
|
|
111
|
-
|
|
145
|
+
**Speed slider does nothing**
|
|
146
|
+
Your fan is in always-on mode — see the wiring section above. Everything else still works.
|
|
112
147
|
|
|
113
|
-
|
|
148
|
+
**Humidity stuck at the same number**
|
|
149
|
+
This is expected — see the humidity note above. If you'd prefer not to see it, set `showHumidity: false` (or just don't enable it in the first place).
|
|
114
150
|
|
|
115
|
-
|
|
151
|
+
**Accessories not appearing in the Home app**
|
|
152
|
+
Go to the plugin settings in Homebridge UI, choose Remove Accessory Cache, and restart. They'll re-appear within a few seconds of the plugin starting.
|
|
116
153
|
|
|
117
154
|
---
|
|
118
155
|
|
|
119
|
-
##
|
|
120
|
-
|
|
121
|
-
**Fan not discovered** — run `hciconfig` on Pi; confirm `UP RUNNING`. If `RF-KILL`, run `sudo rfkill unblock bluetooth`. Confirm `setcap` was applied to `/opt/homebridge/bin/node`.
|
|
156
|
+
## Homebridge 2.0 compliance
|
|
122
157
|
|
|
123
|
-
|
|
158
|
+
For those who care about the technical details — this plugin meets all Homebridge 2.0 requirements:
|
|
159
|
+
- Registered as a dynamic platform
|
|
160
|
+
- Waits for `didFinishLaunching` before any BLE or accessory work begins
|
|
161
|
+
- All errors are caught and logged — no unhandled exceptions
|
|
162
|
+
- No analytics, no tracking, no post-install scripts
|
|
163
|
+
- Cached accessories restored cleanly without re-registration
|
|
164
|
+
- Compatible with Node.js 18, 20 and 22
|
|
124
165
|
|
|
125
|
-
|
|
166
|
+
---
|
|
126
167
|
|
|
127
|
-
|
|
168
|
+
## Release notes
|
|
128
169
|
|
|
129
|
-
|
|
170
|
+
### 2.4.2
|
|
171
|
+
- Humidity sensor is now **off by default** — you have to explicitly enable it with `showHumidity: true`. This follows confirmed testing that showed the fan's BLE firmware updates the humidity characteristic infrequently (we measured 50% while a reference sensor read 89% in the same room post-shower). Exposing it by default risked misleading Siri queries and unreliable automations. If you had it enabled previously, it will be removed from your accessory automatically — you can re-enable it knowingly in settings.
|
|
172
|
+
- README rewritten to be more human — less spec sheet, more "here's what to expect and why"
|
|
173
|
+
- Fan speed documented as the practical shower/occupancy trigger
|
|
130
174
|
|
|
131
|
-
|
|
175
|
+
### 2.4.1
|
|
176
|
+
- Fixed sensors returning stale values — the device's GATT server only refreshes humidity and light on a new connection, so we now connect → read → disconnect on each poll rather than holding a persistent connection
|
|
177
|
+
- Eliminated intermittent `Characteristic not found` errors
|
|
132
178
|
|
|
133
179
|
### 2.4.0
|
|
134
|
-
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
-
|
|
138
|
-
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
- Documented always-on vs intermittent wiring mode behaviour for speed control
|
|
145
|
-
|
|
146
|
-
### 2.3.1
|
|
147
|
-
- Identified correct control characteristic; added write diagnostics
|
|
148
|
-
|
|
149
|
-
### 2.3.0 / 2.2.1 / 2.1.0
|
|
150
|
-
- Sensor characteristic UUIDs and byte mappings confirmed via live device
|
|
151
|
-
- BLE initialisation fixes for Homebridge 2.0
|
|
152
|
-
- Duplicate accessory registration resolved
|
|
180
|
+
- Rebuilt connection architecture — persistent connection with cached characteristics. Fast and reliable for RPM and temperature; subsequently found that humidity returns stale values on persistent connections (fixed in 2.4.1)
|
|
181
|
+
|
|
182
|
+
### 2.3.x
|
|
183
|
+
- Identified the correct control characteristic for fan speed writes
|
|
184
|
+
- Documented always-on vs intermittent wiring mode behaviour
|
|
185
|
+
|
|
186
|
+
### 2.2.1 / 2.1.0
|
|
187
|
+
- Correct sensor UUIDs and byte mappings confirmed via live device testing
|
|
188
|
+
- Homebridge 2.0 initialisation fixes
|
|
189
|
+
- Duplicate accessory registration on restart resolved
|
|
153
190
|
|
|
154
191
|
### 1.0.x
|
|
155
|
-
- Initial
|
|
192
|
+
- Initial releases
|
package/config.schema.json
CHANGED
|
@@ -10,9 +10,15 @@
|
|
|
10
10
|
"type": "string",
|
|
11
11
|
"default": "Pax Calima / Vent-Axia Svara"
|
|
12
12
|
},
|
|
13
|
+
"showHumidity": {
|
|
14
|
+
"title": "Show Humidity Sensor",
|
|
15
|
+
"type": "boolean",
|
|
16
|
+
"default": false,
|
|
17
|
+
"description": "⚠️ The fan's humidity characteristic updates infrequently due to a firmware limitation — it reflects the fan's internal settled state, not a real-time reading. It is NOT suitable for use with Siri queries or time-sensitive automations. Only enable this if you understand and accept this limitation. Disabled by default."
|
|
18
|
+
},
|
|
13
19
|
"fans": {
|
|
14
20
|
"title": "Manually Configured Fans",
|
|
15
|
-
"description": "Optional. Leave empty to use auto-discovery
|
|
21
|
+
"description": "Optional. Leave empty to use auto-discovery. Add entries here only to set a custom name or PIN per fan.",
|
|
16
22
|
"type": "array",
|
|
17
23
|
"items": {
|
|
18
24
|
"type": "object",
|
|
@@ -26,13 +32,13 @@
|
|
|
26
32
|
"title": "MAC Address",
|
|
27
33
|
"type": "string",
|
|
28
34
|
"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)
|
|
35
|
+
"description": "MAC address of the fan (e.g. 58:2b:db:xx:xx:xx)"
|
|
30
36
|
},
|
|
31
37
|
"pin": {
|
|
32
38
|
"title": "PIN Code",
|
|
33
39
|
"type": "string",
|
|
34
40
|
"default": "012345",
|
|
35
|
-
"description": "6-digit PIN. Only change
|
|
41
|
+
"description": "6-digit PIN. Only change if you set a custom PIN in the official app."
|
|
36
42
|
}
|
|
37
43
|
},
|
|
38
44
|
"required": ["name", "mac"]
|
|
@@ -45,7 +51,7 @@
|
|
|
45
51
|
"type": "section",
|
|
46
52
|
"title": "General",
|
|
47
53
|
"expandable": false,
|
|
48
|
-
"items": ["name"]
|
|
54
|
+
"items": ["name", "showHumidity"]
|
|
49
55
|
},
|
|
50
56
|
{
|
|
51
57
|
"type": "section",
|
package/index.js
CHANGED
|
@@ -4,14 +4,14 @@ const noble = require('@abandonware/noble');
|
|
|
4
4
|
|
|
5
5
|
let Service, Characteristic, Categories, API, PlatformAccessory;
|
|
6
6
|
|
|
7
|
-
// ── Verified BLE UUIDs (confirmed via live device dump)
|
|
8
|
-
const UUID_AUTH = '4cad343a-209a-40b7-b911-4d9b3df569b2'; // PIN auth — write once
|
|
9
|
-
const UUID_STATUS = '528b80e8-c47a-4c0a-bdf1-916a7748f412'; // RPM (offset 6) + temp (offset 2)
|
|
10
|
-
const UUID_SENSOR = 'a22eae12-dba8-49f3-9c69-1721dcff1d96'; // humidity (offset 0) + lux (offset 2
|
|
11
|
-
const UUID_CONTROL = '1488a757-35bc-4ec8-9a6b-9ecf1502778e'; // speed config
|
|
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] UInt16LE pairs
|
|
12
12
|
|
|
13
13
|
const MAC_PREFIX = '58:2b:db';
|
|
14
|
-
const POLL_MS = 10000;
|
|
14
|
+
const POLL_MS = 10000;
|
|
15
15
|
const RECONNECT_MS = 15000;
|
|
16
16
|
|
|
17
17
|
module.exports = (homebridge) => {
|
|
@@ -20,40 +20,33 @@ module.exports = (homebridge) => {
|
|
|
20
20
|
Categories = homebridge.hap.Categories;
|
|
21
21
|
API = homebridge;
|
|
22
22
|
PlatformAccessory = homebridge.platformAccessory;
|
|
23
|
-
|
|
24
|
-
// true = dynamic platform, required for Homebridge 2.0
|
|
25
23
|
homebridge.registerPlatform('homebridge-pax-calima', 'PaxCalima', PaxCalimaPlatform, true);
|
|
26
24
|
};
|
|
27
25
|
|
|
28
26
|
// ─── Platform ─────────────────────────────────────────────────────────────────
|
|
29
27
|
class PaxCalimaPlatform {
|
|
30
28
|
constructor(log, config, api) {
|
|
31
|
-
this.log
|
|
32
|
-
this.config
|
|
33
|
-
this.api
|
|
34
|
-
this.registeredMACs
|
|
35
|
-
this.discoveryStarted
|
|
36
|
-
this.cachedAccessories = [];
|
|
29
|
+
this.log = log;
|
|
30
|
+
this.config = config || {};
|
|
31
|
+
this.api = api;
|
|
32
|
+
this.registeredMACs = new Set();
|
|
33
|
+
this.discoveryStarted = false;
|
|
34
|
+
this.cachedAccessories = [];
|
|
37
35
|
|
|
38
36
|
this.log.info('PaxCalima platform initialised');
|
|
39
37
|
|
|
40
38
|
this.api.on('didFinishLaunching', () => {
|
|
41
39
|
this.log.info('Homebridge finished launching — starting BLE');
|
|
42
|
-
|
|
43
40
|
noble.on('stateChange', (state) => {
|
|
44
41
|
this.log.info('BLE state: ' + state);
|
|
45
42
|
if (state === 'poweredOn') this.startDiscovery();
|
|
46
43
|
});
|
|
47
|
-
|
|
48
|
-
// Catch case where noble is already poweredOn before listener fires
|
|
49
44
|
setTimeout(() => {
|
|
50
45
|
if (noble.state === 'poweredOn') this.startDiscovery();
|
|
51
46
|
}, 3000);
|
|
52
47
|
});
|
|
53
48
|
}
|
|
54
49
|
|
|
55
|
-
// Called by Homebridge for each cached accessory on startup.
|
|
56
|
-
// We hold them until startDiscovery() confirms BLE is ready.
|
|
57
50
|
configureAccessory(accessory) {
|
|
58
51
|
const mac = accessory.context && accessory.context.mac;
|
|
59
52
|
this.log.info('Restoring cached accessory: ' + accessory.displayName);
|
|
@@ -67,14 +60,12 @@ class PaxCalimaPlatform {
|
|
|
67
60
|
if (this.discoveryStarted) return;
|
|
68
61
|
this.discoveryStarted = true;
|
|
69
62
|
|
|
70
|
-
// Spin up handlers for all cached (previously registered) accessories
|
|
71
63
|
for (const { accessory, mac } of this.cachedAccessories) {
|
|
72
|
-
this.log.info('
|
|
73
|
-
new PaxCalimaAccessory(this.log, accessory, mac, this.pinForMac(mac));
|
|
64
|
+
this.log.info('Starting handler for cached fan: ' + mac);
|
|
65
|
+
new PaxCalimaAccessory(this.log, accessory, mac, this.pinForMac(mac), this.showHumidity());
|
|
74
66
|
}
|
|
75
67
|
this.cachedAccessories = [];
|
|
76
68
|
|
|
77
|
-
// Scan for any new fans not yet registered
|
|
78
69
|
this.log.info('Starting BLE discovery scan...');
|
|
79
70
|
noble.startScanning([], false);
|
|
80
71
|
|
|
@@ -90,46 +81,43 @@ class PaxCalimaPlatform {
|
|
|
90
81
|
const hbAcc = new PlatformAccessory(this.nameForMac(mac), uuid, Categories.FAN);
|
|
91
82
|
hbAcc.context.mac = mac;
|
|
92
83
|
|
|
93
|
-
new PaxCalimaAccessory(this.log, hbAcc, mac, this.pinForMac(mac));
|
|
84
|
+
new PaxCalimaAccessory(this.log, hbAcc, mac, this.pinForMac(mac), this.showHumidity());
|
|
94
85
|
this.api.registerPlatformAccessories('homebridge-pax-calima', 'PaxCalima', [hbAcc]);
|
|
95
86
|
});
|
|
96
87
|
}
|
|
97
88
|
|
|
98
|
-
fanConfig(mac)
|
|
99
|
-
pinForMac(mac)
|
|
100
|
-
nameForMac(mac)
|
|
89
|
+
fanConfig(mac) { return (this.config.fans || []).find(f => f.mac && f.mac.toLowerCase() === mac) || {}; }
|
|
90
|
+
pinForMac(mac) { return this.fanConfig(mac).pin || '012345'; }
|
|
91
|
+
nameForMac(mac) { return this.fanConfig(mac).name || ('Pax Calima ' + mac.slice(-5).toUpperCase()); }
|
|
92
|
+
showHumidity() { return this.config.showHumidity === true; }
|
|
101
93
|
}
|
|
102
94
|
|
|
103
95
|
// ─── Accessory ────────────────────────────────────────────────────────────────
|
|
104
96
|
class PaxCalimaAccessory {
|
|
105
|
-
constructor(log, accessory, mac, pin) {
|
|
106
|
-
this.log
|
|
107
|
-
this.accessory
|
|
108
|
-
this.mac
|
|
109
|
-
this.pin
|
|
97
|
+
constructor(log, accessory, mac, pin, showHumidity) {
|
|
98
|
+
this.log = log;
|
|
99
|
+
this.accessory = accessory;
|
|
100
|
+
this.mac = mac.toLowerCase();
|
|
101
|
+
this.pin = pin || '012345';
|
|
102
|
+
this.showHumidity = showHumidity;
|
|
110
103
|
|
|
111
|
-
// Sensor state — served to HomeKit between polls
|
|
112
104
|
this.currentRPM = 0;
|
|
113
105
|
this.humidity = 0;
|
|
114
106
|
this.temperature = 20;
|
|
115
107
|
this.lightLevel = 0.0001;
|
|
116
108
|
this.silentHours = false;
|
|
117
109
|
|
|
118
|
-
|
|
119
|
-
this.
|
|
120
|
-
this.charCache = {}; // uuid (stripped) → characteristic — populated once per connection
|
|
121
|
-
this.connected = false;
|
|
122
|
-
this.pollTimer = null;
|
|
110
|
+
this.pollTimer = null;
|
|
111
|
+
this.polling = false;
|
|
123
112
|
|
|
124
113
|
this.setupServices();
|
|
125
|
-
this.
|
|
114
|
+
this.schedulePoll(0);
|
|
126
115
|
}
|
|
127
116
|
|
|
128
|
-
// ── HomeKit services ────────────────────────────────────────────────────────
|
|
129
117
|
setupServices() {
|
|
130
118
|
const n = this.accessory.displayName;
|
|
131
119
|
|
|
132
|
-
// Fan — always
|
|
120
|
+
// Fan — always active; speed reflects live RPM
|
|
133
121
|
this.fanService = this.accessory.getService(Service.Fanv2)
|
|
134
122
|
|| this.accessory.addService(new Service.Fanv2(n, 'fan'));
|
|
135
123
|
this.fanService.getCharacteristic(Characteristic.Active)
|
|
@@ -145,89 +133,125 @@ class PaxCalimaAccessory {
|
|
|
145
133
|
this.boostButton.getCharacteristic(Characteristic.ProgrammableSwitchEvent)
|
|
146
134
|
.setProps({ validValues: [0] });
|
|
147
135
|
|
|
148
|
-
// Silent Hours
|
|
136
|
+
// Silent Hours
|
|
149
137
|
this.silentService = this.accessory.getServiceByUUIDAndSubType(Service.Switch, 'silent-hours')
|
|
150
138
|
|| this.accessory.addService(new Service.Switch(n + ' Silent Hours', 'silent-hours'));
|
|
151
139
|
this.silentService.getCharacteristic(Characteristic.On)
|
|
152
140
|
.onGet(() => this.silentHours)
|
|
153
141
|
.onSet((on) => this.setSilentHours(on));
|
|
154
142
|
|
|
155
|
-
//
|
|
156
|
-
this.humidityService = this.accessory.getService(Service.HumiditySensor)
|
|
157
|
-
|| this.accessory.addService(new Service.HumiditySensor(n + ' Humidity', 'humidity'));
|
|
158
|
-
this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity)
|
|
159
|
-
.onGet(() => this.humidity);
|
|
160
|
-
|
|
143
|
+
// Temperature
|
|
161
144
|
this.tempService = this.accessory.getService(Service.TemperatureSensor)
|
|
162
145
|
|| this.accessory.addService(new Service.TemperatureSensor(n + ' Temperature', 'temperature'));
|
|
163
146
|
this.tempService.getCharacteristic(Characteristic.CurrentTemperature)
|
|
164
147
|
.onGet(() => this.temperature);
|
|
165
148
|
|
|
149
|
+
// Light
|
|
166
150
|
this.lightService = this.accessory.getService(Service.LightSensor)
|
|
167
151
|
|| this.accessory.addService(new Service.LightSensor(n + ' Light', 'light'));
|
|
168
152
|
this.lightService.getCharacteristic(Characteristic.CurrentAmbientLightLevel)
|
|
169
153
|
.onGet(() => Math.max(0.0001, this.lightLevel));
|
|
170
154
|
|
|
155
|
+
// Humidity — optional, disabled by default due to firmware update frequency limitations
|
|
156
|
+
// User must explicitly enable via showHumidity: true in config
|
|
157
|
+
if (this.showHumidity) {
|
|
158
|
+
this.humidityService = this.accessory.getService(Service.HumiditySensor)
|
|
159
|
+
|| this.accessory.addService(new Service.HumiditySensor(n + ' Humidity', 'humidity'));
|
|
160
|
+
this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity)
|
|
161
|
+
.onGet(() => this.humidity);
|
|
162
|
+
this.log.info('Humidity sensor enabled — note: reflects fan internal settled state, not real-time');
|
|
163
|
+
} else {
|
|
164
|
+
// Remove humidity service if it was previously registered and user has since disabled it
|
|
165
|
+
const existing = this.accessory.getService(Service.HumiditySensor);
|
|
166
|
+
if (existing) this.accessory.removeService(existing);
|
|
167
|
+
this.log.info('Humidity sensor disabled (showHumidity not set)');
|
|
168
|
+
}
|
|
169
|
+
|
|
171
170
|
this.log.info('Services ready: ' + n);
|
|
172
171
|
}
|
|
173
172
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
this.
|
|
173
|
+
schedulePoll(delayMs) {
|
|
174
|
+
if (this.pollTimer) clearTimeout(this.pollTimer);
|
|
175
|
+
this.pollTimer = setTimeout(() => this.poll(), delayMs);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async poll() {
|
|
179
|
+
if (this.polling) { this.schedulePoll(POLL_MS); return; }
|
|
180
|
+
this.polling = true;
|
|
181
|
+
let peripheral = null;
|
|
177
182
|
|
|
178
183
|
try {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
this.charCache = {};
|
|
186
|
-
const services = await this.peripheral.discoverServicesAsync();
|
|
184
|
+
peripheral = await this.findPeripheral();
|
|
185
|
+
await peripheral.connectAsync();
|
|
186
|
+
|
|
187
|
+
// Discover and cache all characteristics for this connection
|
|
188
|
+
const charCache = {};
|
|
189
|
+
const services = await peripheral.discoverServicesAsync();
|
|
187
190
|
for (const svc of services) {
|
|
188
191
|
const chars = await svc.discoverCharacteristicsAsync();
|
|
189
|
-
for (const c of chars)
|
|
190
|
-
this.charCache[c.uuid.replace(/-/g, '')] = c;
|
|
191
|
-
}
|
|
192
|
+
for (const c of chars) charCache[c.uuid.replace(/-/g, '')] = c;
|
|
192
193
|
}
|
|
193
194
|
|
|
194
|
-
// Authenticate
|
|
195
|
-
const authChar =
|
|
195
|
+
// Authenticate
|
|
196
|
+
const authChar = charCache[UUID_AUTH.replace(/-/g, '')];
|
|
196
197
|
if (!authChar) throw new Error('Auth characteristic not found');
|
|
197
198
|
const pinBuf = Buffer.alloc(4);
|
|
198
199
|
pinBuf.writeUInt32LE(parseInt(this.pin, 10), 0);
|
|
199
200
|
await authChar.writeAsync(pinBuf, true);
|
|
200
|
-
this.log.info('Authenticated');
|
|
201
201
|
|
|
202
|
-
|
|
202
|
+
// Read status: RPM + temperature — updates live on every connection
|
|
203
|
+
const statusChar = charCache[UUID_STATUS.replace(/-/g, '')];
|
|
204
|
+
if (!statusChar) throw new Error('Status characteristic not found');
|
|
205
|
+
const statusData = await statusChar.readAsync();
|
|
206
|
+
this.currentRPM = Math.max(0, statusData.readUInt16LE(6));
|
|
207
|
+
this.temperature = Math.round((statusData.readUInt16LE(2) / 6) * 10) / 10;
|
|
203
208
|
|
|
204
|
-
//
|
|
205
|
-
this
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
209
|
+
// Read sensor characteristic only if humidity is enabled
|
|
210
|
+
// The device updates this characteristic infrequently (firmware limitation)
|
|
211
|
+
if (this.showHumidity) {
|
|
212
|
+
const sensorChar = charCache[UUID_SENSOR.replace(/-/g, '')];
|
|
213
|
+
if (sensorChar) {
|
|
214
|
+
const sensorData = await sensorChar.readAsync();
|
|
215
|
+
this.humidity = Math.max(0, Math.min(100, sensorData.readUInt8(0) * 2));
|
|
216
|
+
this.lightLevel = Math.max(0.0001, sensorData.readUInt16BE(2));
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
// Read lux from sensor characteristic regardless — only skip humidity update
|
|
220
|
+
const sensorChar = charCache[UUID_SENSOR.replace(/-/g, '')];
|
|
221
|
+
if (sensorChar) {
|
|
222
|
+
const sensorData = await sensorChar.readAsync();
|
|
223
|
+
this.lightLevel = Math.max(0.0001, sensorData.readUInt16BE(2));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Push to HomeKit
|
|
228
|
+
const speed = this.rpmToPercent(this.currentRPM);
|
|
229
|
+
this.fanService.getCharacteristic(Characteristic.RotationSpeed).updateValue(speed);
|
|
230
|
+
this.tempService.getCharacteristic(Characteristic.CurrentTemperature).updateValue(this.temperature);
|
|
231
|
+
this.lightService.getCharacteristic(Characteristic.CurrentAmbientLightLevel).updateValue(this.lightLevel);
|
|
232
|
+
if (this.showHumidity && this.humidityService) {
|
|
233
|
+
this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity).updateValue(this.humidity);
|
|
234
|
+
}
|
|
212
235
|
|
|
213
|
-
|
|
214
|
-
|
|
236
|
+
this.log.info('Poll: RPM=' + this.currentRPM + ' Speed=' + speed +
|
|
237
|
+
'% Temp=' + this.temperature + 'C Light=' + this.lightLevel + 'lx' +
|
|
238
|
+
(this.showHumidity ? ' Hum=' + this.humidity + '%' : ''));
|
|
215
239
|
|
|
216
240
|
} catch (err) {
|
|
217
|
-
this.log.
|
|
218
|
-
|
|
219
|
-
|
|
241
|
+
this.log.warn('Poll error: ' + err.message);
|
|
242
|
+
} finally {
|
|
243
|
+
if (peripheral) { try { await peripheral.disconnectAsync(); } catch (_) {} }
|
|
244
|
+
this.polling = false;
|
|
245
|
+
this.schedulePoll(POLL_MS);
|
|
220
246
|
}
|
|
221
247
|
}
|
|
222
248
|
|
|
223
|
-
// Targeted scan — resolves as soon as our specific MAC is seen, removes its own listener
|
|
224
249
|
findPeripheral() {
|
|
225
250
|
return new Promise((resolve, reject) => {
|
|
226
251
|
const timeout = setTimeout(() => {
|
|
227
252
|
noble.removeListener('discover', onDiscover);
|
|
228
253
|
reject(new Error('Scan timeout for ' + this.mac));
|
|
229
254
|
}, 20000);
|
|
230
|
-
|
|
231
255
|
const onDiscover = (periph) => {
|
|
232
256
|
if (periph.address.toLowerCase() === this.mac) {
|
|
233
257
|
clearTimeout(timeout);
|
|
@@ -235,79 +259,40 @@ class PaxCalimaAccessory {
|
|
|
235
259
|
resolve(periph);
|
|
236
260
|
}
|
|
237
261
|
};
|
|
238
|
-
|
|
239
262
|
noble.on('discover', onDiscover);
|
|
240
263
|
noble.startScanning([], false);
|
|
241
264
|
});
|
|
242
265
|
}
|
|
243
266
|
|
|
244
|
-
// ── Polling — uses cached characteristics, no reconnect overhead ────────────
|
|
245
|
-
async poll() {
|
|
246
|
-
if (!this.connected) return;
|
|
247
|
-
|
|
248
|
-
try {
|
|
249
|
-
const statusChar = this.charCache[UUID_STATUS.replace(/-/g, '')];
|
|
250
|
-
const sensorChar = this.charCache[UUID_SENSOR.replace(/-/g, '')];
|
|
251
|
-
|
|
252
|
-
if (!statusChar || !sensorChar) throw new Error('Characteristics not cached — reconnecting');
|
|
253
|
-
|
|
254
|
-
// Read both characteristics
|
|
255
|
-
const [statusData, sensorData] = await Promise.all([
|
|
256
|
-
statusChar.readAsync(),
|
|
257
|
-
sensorChar.readAsync(),
|
|
258
|
-
]);
|
|
259
|
-
|
|
260
|
-
// Parse — confirmed mappings from live device
|
|
261
|
-
this.currentRPM = Math.max(0, statusData.readUInt16LE(6));
|
|
262
|
-
this.temperature = Math.round((statusData.readUInt16LE(2) / 6) * 10) / 10;
|
|
263
|
-
this.humidity = Math.max(0, Math.min(100, sensorData.readUInt8(0) * 2));
|
|
264
|
-
this.lightLevel = Math.max(0.0001, sensorData.readUInt16BE(2));
|
|
265
|
-
|
|
266
|
-
const speed = this.rpmToPercent(this.currentRPM);
|
|
267
|
-
|
|
268
|
-
// Push to HomeKit
|
|
269
|
-
this.fanService.getCharacteristic(Characteristic.RotationSpeed).updateValue(speed);
|
|
270
|
-
this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity).updateValue(this.humidity);
|
|
271
|
-
this.tempService.getCharacteristic(Characteristic.CurrentTemperature).updateValue(this.temperature);
|
|
272
|
-
this.lightService.getCharacteristic(Characteristic.CurrentAmbientLightLevel).updateValue(this.lightLevel);
|
|
273
|
-
|
|
274
|
-
this.log.info('Poll: RPM=' + this.currentRPM + ' Speed=' + speed +
|
|
275
|
-
'% Hum=' + this.humidity + '% Temp=' + this.temperature +
|
|
276
|
-
'C Light=' + this.lightLevel + 'lx');
|
|
277
|
-
|
|
278
|
-
} catch (err) {
|
|
279
|
-
this.log.warn('Poll error: ' + err.message);
|
|
280
|
-
// If cache is stale, reconnect
|
|
281
|
-
if (!this.charCache[UUID_STATUS.replace(/-/g, '')]) {
|
|
282
|
-
this.connected = false;
|
|
283
|
-
setTimeout(() => this.connect(), RECONNECT_MS);
|
|
284
|
-
return;
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// Schedule next poll
|
|
289
|
-
this.pollTimer = setTimeout(() => this.poll(), POLL_MS);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// ── Fan speed write ─────────────────────────────────────────────────────────
|
|
293
267
|
async writeSpeed(percent) {
|
|
294
|
-
if (
|
|
295
|
-
|
|
296
|
-
return;
|
|
297
|
-
}
|
|
268
|
+
if (this.polling) { this.log.warn('Speed write skipped — poll in progress'); return; }
|
|
269
|
+
let peripheral = null;
|
|
298
270
|
try {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
const
|
|
271
|
+
peripheral = await this.findPeripheral();
|
|
272
|
+
await peripheral.connectAsync();
|
|
273
|
+
const charCache = {};
|
|
274
|
+
const services = await peripheral.discoverServicesAsync();
|
|
275
|
+
for (const svc of services) {
|
|
276
|
+
const chars = await svc.discoverCharacteristicsAsync();
|
|
277
|
+
for (const c of chars) charCache[c.uuid.replace(/-/g, '')] = c;
|
|
278
|
+
}
|
|
279
|
+
const authChar = charCache[UUID_AUTH.replace(/-/g, '')];
|
|
280
|
+
if (!authChar) throw new Error('Auth characteristic not found');
|
|
281
|
+
const pinBuf = Buffer.alloc(4);
|
|
282
|
+
pinBuf.writeUInt32LE(parseInt(this.pin, 10), 0);
|
|
283
|
+
await authChar.writeAsync(pinBuf, true);
|
|
284
|
+
const ctrlChar = charCache[UUID_CONTROL.replace(/-/g, '')];
|
|
285
|
+
if (!ctrlChar) throw new Error('Control characteristic not found');
|
|
286
|
+
const current = await ctrlChar.readAsync();
|
|
304
287
|
const targetRPM = Math.round((percent / 100) * 2400);
|
|
305
|
-
const buf
|
|
306
|
-
buf.writeUInt16LE(targetRPM, 2);
|
|
288
|
+
const buf = Buffer.from(current);
|
|
289
|
+
buf.writeUInt16LE(targetRPM, 2);
|
|
307
290
|
await ctrlChar.writeAsync(buf, true);
|
|
308
291
|
this.log.info('Speed set to ' + percent + '% (' + targetRPM + ' RPM)');
|
|
309
292
|
} catch (err) {
|
|
310
293
|
this.log.error('writeSpeed failed: ' + err.message);
|
|
294
|
+
} finally {
|
|
295
|
+
if (peripheral) { try { await peripheral.disconnectAsync(); } catch (_) {} }
|
|
311
296
|
}
|
|
312
297
|
}
|
|
313
298
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-pax-calima",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.2",
|
|
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"
|