homebridge-pax-calima 2.4.1 → 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 +104 -86
- package/config.schema.json +10 -4
- package/index.js +62 -66
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,72 +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:
|
|
16
28
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
| **Silent Hours** | Toggle — suppresses automatic boosting during quiet periods |
|
|
26
|
-
| **Automations** | Full HomeKit automation support on all sensors |
|
|
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)
|
|
35
|
+
|
|
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
|
|
|
32
|
-
|
|
33
|
-
- Node.js `>= 18.20.4`
|
|
34
|
-
- A Bluetooth LE adapter on the Homebridge host
|
|
42
|
+
The humidity tile is **turned off by default**, and for good reason.
|
|
35
43
|
|
|
36
|
-
|
|
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.
|
|
37
45
|
|
|
38
|
-
|
|
39
|
-
-
|
|
40
|
-
-
|
|
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
|
|
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
|
|
45
49
|
|
|
46
|
-
|
|
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."
|
|
47
51
|
|
|
48
|
-
|
|
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:
|
|
59
|
+
- Homebridge `^1.6.0` or `^2.0.0`
|
|
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)
|
|
62
|
+
|
|
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**:
|
|
49
64
|
|
|
50
65
|
```bash
|
|
51
66
|
sudo setcap cap_net_raw+eip /opt/homebridge/bin/node
|
|
52
67
|
```
|
|
53
68
|
|
|
54
|
-
If Bluetooth shows as
|
|
69
|
+
If Bluetooth shows as off after that:
|
|
55
70
|
|
|
56
71
|
```bash
|
|
57
72
|
sudo rfkill unblock bluetooth
|
|
58
73
|
```
|
|
59
74
|
|
|
60
|
-
|
|
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.
|
|
61
76
|
|
|
62
77
|
---
|
|
63
78
|
|
|
64
79
|
## Installation
|
|
65
80
|
|
|
66
|
-
|
|
67
|
-
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.
|
|
68
82
|
|
|
69
|
-
|
|
83
|
+
Or via terminal:
|
|
70
84
|
```bash
|
|
71
85
|
npm install -g homebridge-pax-calima
|
|
72
86
|
```
|
|
@@ -75,12 +89,15 @@ npm install -g homebridge-pax-calima
|
|
|
75
89
|
|
|
76
90
|
## Configuration
|
|
77
91
|
|
|
78
|
-
|
|
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:
|
|
79
95
|
|
|
80
96
|
```json
|
|
81
97
|
{
|
|
82
98
|
"platform": "PaxCalima",
|
|
83
99
|
"name": "Pax Calima / Vent-Axia Svara",
|
|
100
|
+
"showHumidity": false,
|
|
84
101
|
"fans": [
|
|
85
102
|
{
|
|
86
103
|
"name": "Wetroom Fan",
|
|
@@ -91,84 +108,85 @@ Auto-discovery requires **no configuration**. To set a custom name or PIN per fa
|
|
|
91
108
|
}
|
|
92
109
|
```
|
|
93
110
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
Each fan appears as a single accessory with:
|
|
101
|
-
|
|
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
|
|
108
|
-
|
|
109
|
-
> Accessories appear in the **Home app**, not the Homebridge UI accessories tab. This is correct behaviour for child bridge plugins.
|
|
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 |
|
|
110
117
|
|
|
111
118
|
---
|
|
112
119
|
|
|
113
|
-
## Fan
|
|
120
|
+
## Fan speed control — it depends on how your fan is wired
|
|
114
121
|
|
|
115
|
-
The
|
|
122
|
+
The Svara and Calima can be installed in two different modes, and which one you have affects what the speed slider does:
|
|
116
123
|
|
|
117
|
-
**Intermittent mode** — fan is off by default
|
|
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.
|
|
118
125
|
|
|
119
|
-
**Always-on (trickle) mode** — fan runs continuously at a speed
|
|
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.
|
|
120
127
|
|
|
121
|
-
|
|
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.
|
|
122
129
|
|
|
123
|
-
|
|
130
|
+
In either mode, the temperature, light and fan speed automations work perfectly.
|
|
124
131
|
|
|
125
132
|
---
|
|
126
133
|
|
|
127
|
-
##
|
|
134
|
+
## Something not working?
|
|
128
135
|
|
|
129
|
-
|
|
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.
|
|
130
138
|
|
|
131
|
-
|
|
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.
|
|
132
141
|
|
|
133
|
-
|
|
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.
|
|
134
144
|
|
|
135
|
-
|
|
145
|
+
**Speed slider does nothing**
|
|
146
|
+
Your fan is in always-on mode — see the wiring section above. Everything else still works.
|
|
136
147
|
|
|
137
|
-
|
|
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).
|
|
138
150
|
|
|
139
|
-
**
|
|
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.
|
|
140
153
|
|
|
141
|
-
|
|
154
|
+
---
|
|
142
155
|
|
|
143
|
-
|
|
156
|
+
## Homebridge 2.0 compliance
|
|
144
157
|
|
|
145
|
-
|
|
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
|
|
146
165
|
|
|
147
166
|
---
|
|
148
167
|
|
|
149
|
-
## Release
|
|
168
|
+
## Release notes
|
|
169
|
+
|
|
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
|
|
150
174
|
|
|
151
175
|
### 2.4.1
|
|
152
|
-
-
|
|
153
|
-
-
|
|
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
|
|
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
|
|
158
178
|
|
|
159
179
|
### 2.4.0
|
|
160
|
-
-
|
|
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)
|
|
161
181
|
|
|
162
|
-
### 2.3.
|
|
182
|
+
### 2.3.x
|
|
183
|
+
- Identified the correct control characteristic for fan speed writes
|
|
163
184
|
- Documented always-on vs intermittent wiring mode behaviour
|
|
164
185
|
|
|
165
|
-
### 2.
|
|
166
|
-
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
- Sensor UUIDs and byte mappings confirmed via live device dump
|
|
170
|
-
- BLE initialisation fixes for Homebridge 2.0
|
|
171
|
-
- Duplicate accessory registration resolved
|
|
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
|
|
172
190
|
|
|
173
191
|
### 1.0.x
|
|
174
|
-
- 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
|
@@ -8,10 +8,10 @@ let Service, Characteristic, Categories, API, PlatformAccessory;
|
|
|
8
8
|
const UUID_AUTH = '4cad343a-209a-40b7-b911-4d9b3df569b2'; // PIN auth — write once per connection
|
|
9
9
|
const UUID_STATUS = '528b80e8-c47a-4c0a-bdf1-916a7748f412'; // RPM (offset 6 UInt16LE) + temp (offset 2 UInt16LE / 6)
|
|
10
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]
|
|
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,8 +20,6 @@ module.exports = (homebridge) => {
|
|
|
20
20
|
Categories = homebridge.hap.Categories;
|
|
21
21
|
API = homebridge;
|
|
22
22
|
PlatformAccessory = homebridge.platformAccessory;
|
|
23
|
-
|
|
24
|
-
// Dynamic platform — required for Homebridge 2.0
|
|
25
23
|
homebridge.registerPlatform('homebridge-pax-calima', 'PaxCalima', PaxCalimaPlatform, true);
|
|
26
24
|
};
|
|
27
25
|
|
|
@@ -33,7 +31,7 @@ class PaxCalimaPlatform {
|
|
|
33
31
|
this.api = api;
|
|
34
32
|
this.registeredMACs = new Set();
|
|
35
33
|
this.discoveryStarted = false;
|
|
36
|
-
this.cachedAccessories = [];
|
|
34
|
+
this.cachedAccessories = [];
|
|
37
35
|
|
|
38
36
|
this.log.info('PaxCalima platform initialised');
|
|
39
37
|
|
|
@@ -43,16 +41,12 @@ class PaxCalimaPlatform {
|
|
|
43
41
|
this.log.info('BLE state: ' + state);
|
|
44
42
|
if (state === 'poweredOn') this.startDiscovery();
|
|
45
43
|
});
|
|
46
|
-
// Catch case where noble is already poweredOn before listener fires
|
|
47
44
|
setTimeout(() => {
|
|
48
45
|
if (noble.state === 'poweredOn') this.startDiscovery();
|
|
49
46
|
}, 3000);
|
|
50
47
|
});
|
|
51
48
|
}
|
|
52
49
|
|
|
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.
|
|
56
50
|
configureAccessory(accessory) {
|
|
57
51
|
const mac = accessory.context && accessory.context.mac;
|
|
58
52
|
this.log.info('Restoring cached accessory: ' + accessory.displayName);
|
|
@@ -66,14 +60,12 @@ class PaxCalimaPlatform {
|
|
|
66
60
|
if (this.discoveryStarted) return;
|
|
67
61
|
this.discoveryStarted = true;
|
|
68
62
|
|
|
69
|
-
// Spin up BLE handlers for all previously registered accessories
|
|
70
63
|
for (const { accessory, mac } of this.cachedAccessories) {
|
|
71
64
|
this.log.info('Starting handler for cached fan: ' + mac);
|
|
72
|
-
new PaxCalimaAccessory(this.log, accessory, mac, this.pinForMac(mac));
|
|
65
|
+
new PaxCalimaAccessory(this.log, accessory, mac, this.pinForMac(mac), this.showHumidity());
|
|
73
66
|
}
|
|
74
67
|
this.cachedAccessories = [];
|
|
75
68
|
|
|
76
|
-
// Scan for any new fans not yet registered
|
|
77
69
|
this.log.info('Starting BLE discovery scan...');
|
|
78
70
|
noble.startScanning([], false);
|
|
79
71
|
|
|
@@ -89,25 +81,26 @@ class PaxCalimaPlatform {
|
|
|
89
81
|
const hbAcc = new PlatformAccessory(this.nameForMac(mac), uuid, Categories.FAN);
|
|
90
82
|
hbAcc.context.mac = mac;
|
|
91
83
|
|
|
92
|
-
new PaxCalimaAccessory(this.log, hbAcc, mac, this.pinForMac(mac));
|
|
84
|
+
new PaxCalimaAccessory(this.log, hbAcc, mac, this.pinForMac(mac), this.showHumidity());
|
|
93
85
|
this.api.registerPlatformAccessories('homebridge-pax-calima', 'PaxCalima', [hbAcc]);
|
|
94
86
|
});
|
|
95
87
|
}
|
|
96
88
|
|
|
97
|
-
fanConfig(mac)
|
|
98
|
-
pinForMac(mac)
|
|
99
|
-
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; }
|
|
100
93
|
}
|
|
101
94
|
|
|
102
95
|
// ─── Accessory ────────────────────────────────────────────────────────────────
|
|
103
96
|
class PaxCalimaAccessory {
|
|
104
|
-
constructor(log, accessory, mac, pin) {
|
|
105
|
-
this.log
|
|
106
|
-
this.accessory
|
|
107
|
-
this.mac
|
|
108
|
-
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;
|
|
109
103
|
|
|
110
|
-
// Sensor state — served to HomeKit between polls
|
|
111
104
|
this.currentRPM = 0;
|
|
112
105
|
this.humidity = 0;
|
|
113
106
|
this.temperature = 20;
|
|
@@ -115,13 +108,12 @@ class PaxCalimaAccessory {
|
|
|
115
108
|
this.silentHours = false;
|
|
116
109
|
|
|
117
110
|
this.pollTimer = null;
|
|
118
|
-
this.polling = false;
|
|
111
|
+
this.polling = false;
|
|
119
112
|
|
|
120
113
|
this.setupServices();
|
|
121
|
-
this.schedulePoll(0);
|
|
114
|
+
this.schedulePoll(0);
|
|
122
115
|
}
|
|
123
116
|
|
|
124
|
-
// ── HomeKit services ────────────────────────────────────────────────────────
|
|
125
117
|
setupServices() {
|
|
126
118
|
const n = this.accessory.displayName;
|
|
127
119
|
|
|
@@ -141,36 +133,43 @@ class PaxCalimaAccessory {
|
|
|
141
133
|
this.boostButton.getCharacteristic(Characteristic.ProgrammableSwitchEvent)
|
|
142
134
|
.setProps({ validValues: [0] });
|
|
143
135
|
|
|
144
|
-
// Silent Hours
|
|
136
|
+
// Silent Hours
|
|
145
137
|
this.silentService = this.accessory.getServiceByUUIDAndSubType(Service.Switch, 'silent-hours')
|
|
146
138
|
|| this.accessory.addService(new Service.Switch(n + ' Silent Hours', 'silent-hours'));
|
|
147
139
|
this.silentService.getCharacteristic(Characteristic.On)
|
|
148
140
|
.onGet(() => this.silentHours)
|
|
149
141
|
.onSet((on) => this.setSilentHours(on));
|
|
150
142
|
|
|
151
|
-
//
|
|
152
|
-
this.humidityService = this.accessory.getService(Service.HumiditySensor)
|
|
153
|
-
|| this.accessory.addService(new Service.HumiditySensor(n + ' Humidity', 'humidity'));
|
|
154
|
-
this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity)
|
|
155
|
-
.onGet(() => this.humidity);
|
|
156
|
-
|
|
143
|
+
// Temperature
|
|
157
144
|
this.tempService = this.accessory.getService(Service.TemperatureSensor)
|
|
158
145
|
|| this.accessory.addService(new Service.TemperatureSensor(n + ' Temperature', 'temperature'));
|
|
159
146
|
this.tempService.getCharacteristic(Characteristic.CurrentTemperature)
|
|
160
147
|
.onGet(() => this.temperature);
|
|
161
148
|
|
|
149
|
+
// Light
|
|
162
150
|
this.lightService = this.accessory.getService(Service.LightSensor)
|
|
163
151
|
|| this.accessory.addService(new Service.LightSensor(n + ' Light', 'light'));
|
|
164
152
|
this.lightService.getCharacteristic(Characteristic.CurrentAmbientLightLevel)
|
|
165
153
|
.onGet(() => Math.max(0.0001, this.lightLevel));
|
|
166
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
|
+
|
|
167
170
|
this.log.info('Services ready: ' + n);
|
|
168
171
|
}
|
|
169
172
|
|
|
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.
|
|
174
173
|
schedulePoll(delayMs) {
|
|
175
174
|
if (this.pollTimer) clearTimeout(this.pollTimer);
|
|
176
175
|
this.pollTimer = setTimeout(() => this.poll(), delayMs);
|
|
@@ -187,7 +186,7 @@ class PaxCalimaAccessory {
|
|
|
187
186
|
|
|
188
187
|
// Discover and cache all characteristics for this connection
|
|
189
188
|
const charCache = {};
|
|
190
|
-
const services
|
|
189
|
+
const services = await peripheral.discoverServicesAsync();
|
|
191
190
|
for (const svc of services) {
|
|
192
191
|
const chars = await svc.discoverCharacteristicsAsync();
|
|
193
192
|
for (const c of chars) charCache[c.uuid.replace(/-/g, '')] = c;
|
|
@@ -200,49 +199,59 @@ class PaxCalimaAccessory {
|
|
|
200
199
|
pinBuf.writeUInt32LE(parseInt(this.pin, 10), 0);
|
|
201
200
|
await authChar.writeAsync(pinBuf, true);
|
|
202
201
|
|
|
203
|
-
// Read status: RPM + temperature
|
|
202
|
+
// Read status: RPM + temperature — updates live on every connection
|
|
204
203
|
const statusChar = charCache[UUID_STATUS.replace(/-/g, '')];
|
|
205
204
|
if (!statusChar) throw new Error('Status characteristic not found');
|
|
206
205
|
const statusData = await statusChar.readAsync();
|
|
207
206
|
this.currentRPM = Math.max(0, statusData.readUInt16LE(6));
|
|
208
207
|
this.temperature = Math.round((statusData.readUInt16LE(2) / 6) * 10) / 10;
|
|
209
208
|
|
|
210
|
-
// Read
|
|
211
|
-
|
|
212
|
-
if (
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
+
}
|
|
216
226
|
|
|
217
|
-
// Push
|
|
227
|
+
// Push to HomeKit
|
|
218
228
|
const speed = this.rpmToPercent(this.currentRPM);
|
|
219
229
|
this.fanService.getCharacteristic(Characteristic.RotationSpeed).updateValue(speed);
|
|
220
|
-
this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity).updateValue(this.humidity);
|
|
221
230
|
this.tempService.getCharacteristic(Characteristic.CurrentTemperature).updateValue(this.temperature);
|
|
222
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
|
+
}
|
|
223
235
|
|
|
224
236
|
this.log.info('Poll: RPM=' + this.currentRPM + ' Speed=' + speed +
|
|
225
|
-
'%
|
|
226
|
-
'
|
|
237
|
+
'% Temp=' + this.temperature + 'C Light=' + this.lightLevel + 'lx' +
|
|
238
|
+
(this.showHumidity ? ' Hum=' + this.humidity + '%' : ''));
|
|
227
239
|
|
|
228
240
|
} catch (err) {
|
|
229
241
|
this.log.warn('Poll error: ' + err.message);
|
|
230
242
|
} finally {
|
|
231
|
-
// Always disconnect cleanly — required for fresh sensor reads next cycle
|
|
232
243
|
if (peripheral) { try { await peripheral.disconnectAsync(); } catch (_) {} }
|
|
233
244
|
this.polling = false;
|
|
234
245
|
this.schedulePoll(POLL_MS);
|
|
235
246
|
}
|
|
236
247
|
}
|
|
237
248
|
|
|
238
|
-
// Targeted scan — resolves as soon as our MAC is seen, removes its own listener
|
|
239
249
|
findPeripheral() {
|
|
240
250
|
return new Promise((resolve, reject) => {
|
|
241
251
|
const timeout = setTimeout(() => {
|
|
242
252
|
noble.removeListener('discover', onDiscover);
|
|
243
253
|
reject(new Error('Scan timeout for ' + this.mac));
|
|
244
254
|
}, 20000);
|
|
245
|
-
|
|
246
255
|
const onDiscover = (periph) => {
|
|
247
256
|
if (periph.address.toLowerCase() === this.mac) {
|
|
248
257
|
clearTimeout(timeout);
|
|
@@ -250,49 +259,36 @@ class PaxCalimaAccessory {
|
|
|
250
259
|
resolve(periph);
|
|
251
260
|
}
|
|
252
261
|
};
|
|
253
|
-
|
|
254
262
|
noble.on('discover', onDiscover);
|
|
255
263
|
noble.startScanning([], false);
|
|
256
264
|
});
|
|
257
265
|
}
|
|
258
266
|
|
|
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.
|
|
262
267
|
async writeSpeed(percent) {
|
|
263
|
-
if (this.polling) {
|
|
264
|
-
this.log.warn('Speed write skipped — poll in progress');
|
|
265
|
-
return;
|
|
266
|
-
}
|
|
268
|
+
if (this.polling) { this.log.warn('Speed write skipped — poll in progress'); return; }
|
|
267
269
|
let peripheral = null;
|
|
268
270
|
try {
|
|
269
271
|
peripheral = await this.findPeripheral();
|
|
270
272
|
await peripheral.connectAsync();
|
|
271
|
-
|
|
272
273
|
const charCache = {};
|
|
273
|
-
const services
|
|
274
|
+
const services = await peripheral.discoverServicesAsync();
|
|
274
275
|
for (const svc of services) {
|
|
275
276
|
const chars = await svc.discoverCharacteristicsAsync();
|
|
276
277
|
for (const c of chars) charCache[c.uuid.replace(/-/g, '')] = c;
|
|
277
278
|
}
|
|
278
|
-
|
|
279
279
|
const authChar = charCache[UUID_AUTH.replace(/-/g, '')];
|
|
280
280
|
if (!authChar) throw new Error('Auth characteristic not found');
|
|
281
281
|
const pinBuf = Buffer.alloc(4);
|
|
282
282
|
pinBuf.writeUInt32LE(parseInt(this.pin, 10), 0);
|
|
283
283
|
await authChar.writeAsync(pinBuf, true);
|
|
284
|
-
|
|
285
284
|
const ctrlChar = charCache[UUID_CONTROL.replace(/-/g, '')];
|
|
286
285
|
if (!ctrlChar) throw new Error('Control characteristic not found');
|
|
287
|
-
|
|
288
|
-
// Preserve existing trickle + boost values, update normal speed slot only
|
|
289
286
|
const current = await ctrlChar.readAsync();
|
|
290
287
|
const targetRPM = Math.round((percent / 100) * 2400);
|
|
291
288
|
const buf = Buffer.from(current);
|
|
292
289
|
buf.writeUInt16LE(targetRPM, 2);
|
|
293
290
|
await ctrlChar.writeAsync(buf, true);
|
|
294
291
|
this.log.info('Speed set to ' + percent + '% (' + targetRPM + ' RPM)');
|
|
295
|
-
|
|
296
292
|
} catch (err) {
|
|
297
293
|
this.log.error('writeSpeed failed: ' + err.message);
|
|
298
294
|
} finally {
|