homebridge-pax-calima 2.3.2 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -67
- package/config.schema.json +43 -7
- package/index.js +147 -108
- package/package.json +1 -1
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 and share the same BLE protocol and MAC prefix (`58:2b:db`).
|
|
6
12
|
|
|
7
13
|
---
|
|
8
14
|
|
|
@@ -10,15 +16,14 @@ 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 switch —
|
|
21
|
-
| **Automations** | Full
|
|
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 |
|
|
22
27
|
|
|
23
28
|
---
|
|
24
29
|
|
|
@@ -26,17 +31,15 @@ Full HomeKit integration for **Pax Calima** (Scandinavia) and **Vent-Axia Svara*
|
|
|
26
31
|
|
|
27
32
|
- Homebridge `^1.6.0` or `^2.0.0`
|
|
28
33
|
- Node.js `>= 18`
|
|
29
|
-
- A Bluetooth LE adapter on the Homebridge host
|
|
34
|
+
- A Bluetooth LE adapter on the Homebridge host
|
|
30
35
|
|
|
31
|
-
### Raspberry Pi / Homebridge OS setup
|
|
32
|
-
|
|
33
|
-
The `noble` BLE library requires elevated network capabilities. Run **once** after installation:
|
|
36
|
+
### Raspberry Pi / Homebridge OS — one-time setup
|
|
34
37
|
|
|
35
38
|
```bash
|
|
36
39
|
sudo setcap cap_net_raw+eip /opt/homebridge/bin/node
|
|
37
40
|
```
|
|
38
41
|
|
|
39
|
-
If Bluetooth shows as `poweredOff
|
|
42
|
+
If Bluetooth shows as `poweredOff`:
|
|
40
43
|
|
|
41
44
|
```bash
|
|
42
45
|
sudo rfkill unblock bluetooth
|
|
@@ -58,9 +61,7 @@ npm install -g homebridge-pax-calima
|
|
|
58
61
|
|
|
59
62
|
## Configuration
|
|
60
63
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
To set a **custom name** or **PIN** for a specific fan, add an entry to the `fans` array:
|
|
64
|
+
No configuration needed for auto-discovery. To set a custom name or PIN per fan:
|
|
64
65
|
|
|
65
66
|
```json
|
|
66
67
|
{
|
|
@@ -76,93 +77,79 @@ To set a **custom name** or **PIN** for a specific fan, add an entry to the `fan
|
|
|
76
77
|
}
|
|
77
78
|
```
|
|
78
79
|
|
|
79
|
-
The `fans` array is
|
|
80
|
+
The `fans` array is optional. Default PIN is `012345`.
|
|
80
81
|
|
|
81
82
|
---
|
|
82
83
|
|
|
83
84
|
## Accessories in Apple Home
|
|
84
85
|
|
|
85
|
-
Each
|
|
86
|
+
Each fan appears as a single accessory with:
|
|
86
87
|
|
|
87
|
-
- **Fan** —
|
|
88
|
-
- **Boost** — programmable button
|
|
89
|
-
- **Silent Hours** — switch
|
|
90
|
-
- **Humidity** —
|
|
91
|
-
- **Temperature** —
|
|
92
|
-
- **Light** —
|
|
88
|
+
- **Fan** — live speed display; slider available (see wiring note)
|
|
89
|
+
- **Boost** — single-press programmable button
|
|
90
|
+
- **Silent Hours** — on/off switch
|
|
91
|
+
- **Humidity** — % relative humidity
|
|
92
|
+
- **Temperature** — °C
|
|
93
|
+
- **Light** — lux
|
|
93
94
|
|
|
94
|
-
>
|
|
95
|
+
> Accessories appear in the **Home app**, not the Homebridge UI accessories tab. This is correct for child bridge plugins.
|
|
95
96
|
|
|
96
97
|
---
|
|
97
98
|
|
|
98
|
-
## Fan Speed Control — Wiring Mode
|
|
99
|
+
## Fan Speed Control — Wiring Mode
|
|
99
100
|
|
|
100
|
-
The
|
|
101
|
+
The fan supports two installation wiring modes:
|
|
101
102
|
|
|
102
|
-
**Intermittent mode** —
|
|
103
|
+
**Intermittent mode** — fan is off by default, triggered by sensors or timer. BLE speed commands work fully; the slider controls the fan directly.
|
|
103
104
|
|
|
104
|
-
**Always-on (trickle) mode** —
|
|
105
|
+
**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 setpoint immediately. The slider accurately reflects current RPM but cannot override the onboard speed.
|
|
105
106
|
|
|
106
|
-
If your fan is
|
|
107
|
-
|
|
108
|
-
To enable full speed control, consult your fan's installation guide to switch to intermittent wiring mode.
|
|
107
|
+
If your fan is in always-on mode, sensors and automations remain fully functional — for bathroom and wetroom use these are the primary value of the integration. To enable full speed control, switch to intermittent wiring mode per the installation guide.
|
|
109
108
|
|
|
110
109
|
---
|
|
111
110
|
|
|
112
111
|
## Sensor Notes
|
|
113
112
|
|
|
114
|
-
The
|
|
113
|
+
The light sensor detects occupancy via movement and shadows rather than acting as a precision lux meter. Small variances from a reference device are expected and normal.
|
|
115
114
|
|
|
116
|
-
|
|
115
|
+
For lux-based automations, use **Eve** or **Hesperus** which support lux thresholds synced to HomeKit.
|
|
117
116
|
|
|
118
117
|
---
|
|
119
118
|
|
|
120
119
|
## Troubleshooting
|
|
121
120
|
|
|
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`
|
|
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`.
|
|
126
122
|
|
|
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.
|
|
123
|
+
**Authentication failed** — default PIN is `012345`. If changed in the official app, add the fan to the `fans` config with the correct PIN.
|
|
129
124
|
|
|
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.
|
|
125
|
+
**Speed slider has no effect** — fan is wired in always-on mode. See wiring note above. All sensors work normally.
|
|
132
126
|
|
|
133
|
-
**Accessories not
|
|
134
|
-
- Remove the accessory cache via Homebridge UI → (plugin settings) → Remove Accessory Cache, then restart Homebridge
|
|
127
|
+
**Accessories not in Home app** — clear accessory cache via Homebridge UI → plugin settings → Remove Accessory Cache, then restart.
|
|
135
128
|
|
|
136
129
|
---
|
|
137
130
|
|
|
138
131
|
## Release Notes
|
|
139
132
|
|
|
133
|
+
### 2.4.0
|
|
134
|
+
- **Rebuilt:** Connection architecture reverted to persistent BLE connection model — connect once, cache all characteristics, poll with direct `readAsync()` calls. Eliminates the per-poll scan/connect/auth/discover overhead that made 2.x sluggish and error-prone
|
|
135
|
+
- **Improved:** Both sensor characteristics now read in parallel via `Promise.all` — faster polls
|
|
136
|
+
- **Improved:** Characteristics cached once per connection — no repeated service discovery
|
|
137
|
+
- **Improved:** Auth performed once per connection, not per poll
|
|
138
|
+
- **Improved:** Automatic reconnection on unexpected disconnect with clean cache flush
|
|
139
|
+
- **Fixed:** `Characteristic not found` intermittent errors eliminated — characteristic lookup now uses pre-cached map rather than repeated discovery
|
|
140
|
+
- **Fixed:** `writeSpeed` uses cached control characteristic and preserves trickle/boost values
|
|
141
|
+
- **Fixed:** Default accessory name changed to `Pax Calima` for international compatibility
|
|
142
|
+
|
|
140
143
|
### 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
|
|
144
|
+
- Documented always-on vs intermittent wiring mode behaviour for speed control
|
|
144
145
|
|
|
145
146
|
### 2.3.1
|
|
146
|
-
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
-
|
|
152
|
-
- **Fixed:** Auth characteristic discovery on first connection after restart
|
|
153
|
-
|
|
154
|
-
### 2.2.1
|
|
155
|
-
- **Fixed:** Sensors no longer stuck after restart — polling deferred until BLE is ready
|
|
156
|
-
- **Fixed:** Poll interval 30s → 10s for responsive shower humidity tracking
|
|
157
|
-
- **Fixed:** Overlapping poll guard prevents concurrent BLE connections
|
|
158
|
-
- **Fixed:** Multi Mode and Heat Distribution switches removed — not applicable for UK use
|
|
159
|
-
- **Fixed:** Duplicate accessory registration on restart resolved
|
|
160
|
-
- **Fixed:** Switch UUID collision resolved with explicit subtypes
|
|
161
|
-
|
|
162
|
-
### 2.1.0
|
|
163
|
-
- **Fixed:** Correct sensor characteristic UUIDs and byte mappings confirmed via live device
|
|
164
|
-
- **Fixed:** BLE refactored to connect → auth → read → disconnect → repeat pattern
|
|
165
|
-
- **Fixed:** UUID matching on Linux noble
|
|
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
|
|
166
153
|
|
|
167
154
|
### 1.0.x
|
|
168
155
|
- 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) ───────────────────────
|
|
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 BE)
|
|
11
|
+
const UUID_CONTROL = '1488a757-35bc-4ec8-9a6b-9ecf1502778e'; // speed config — 6 bytes: trickle/normal/boost
|
|
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,33 +20,40 @@ module.exports = (homebridge) => {
|
|
|
17
20
|
Categories = homebridge.hap.Categories;
|
|
18
21
|
API = homebridge;
|
|
19
22
|
PlatformAccessory = homebridge.platformAccessory;
|
|
23
|
+
|
|
24
|
+
// true = 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
|
-
this.log
|
|
26
|
-
this.config
|
|
27
|
-
this.api
|
|
28
|
-
this.registeredMACs
|
|
29
|
-
this.discoveryStarted
|
|
30
|
-
this.cachedAccessories = [];
|
|
31
|
+
this.log = log;
|
|
32
|
+
this.config = config || {};
|
|
33
|
+
this.api = api;
|
|
34
|
+
this.registeredMACs = new Set();
|
|
35
|
+
this.discoveryStarted = false;
|
|
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');
|
|
42
|
+
|
|
36
43
|
noble.on('stateChange', (state) => {
|
|
37
44
|
this.log.info('BLE state: ' + state);
|
|
38
45
|
if (state === 'poweredOn') this.startDiscovery();
|
|
39
46
|
});
|
|
47
|
+
|
|
48
|
+
// Catch case where noble is already poweredOn before listener fires
|
|
40
49
|
setTimeout(() => {
|
|
41
|
-
this.log.info('BLE current state: ' + noble.state);
|
|
42
50
|
if (noble.state === 'poweredOn') this.startDiscovery();
|
|
43
51
|
}, 3000);
|
|
44
52
|
});
|
|
45
53
|
}
|
|
46
54
|
|
|
55
|
+
// Called by Homebridge for each cached accessory on startup.
|
|
56
|
+
// We hold them until startDiscovery() confirms BLE is ready.
|
|
47
57
|
configureAccessory(accessory) {
|
|
48
58
|
const mac = accessory.context && accessory.context.mac;
|
|
49
59
|
this.log.info('Restoring cached accessory: ' + accessory.displayName);
|
|
@@ -57,13 +67,14 @@ class PaxCalimaPlatform {
|
|
|
57
67
|
if (this.discoveryStarted) return;
|
|
58
68
|
this.discoveryStarted = true;
|
|
59
69
|
|
|
70
|
+
// Spin up handlers for all cached (previously registered) accessories
|
|
60
71
|
for (const { accessory, mac } of this.cachedAccessories) {
|
|
61
|
-
|
|
62
|
-
this.log
|
|
63
|
-
new PaxCalimaAccessory(this.log, accessory, mac, pin);
|
|
72
|
+
this.log.info('Connecting to cached fan: ' + mac);
|
|
73
|
+
new PaxCalimaAccessory(this.log, accessory, mac, this.pinForMac(mac));
|
|
64
74
|
}
|
|
65
75
|
this.cachedAccessories = [];
|
|
66
76
|
|
|
77
|
+
// Scan for any new fans not yet registered
|
|
67
78
|
this.log.info('Starting BLE discovery scan...');
|
|
68
79
|
noble.startScanning([], false);
|
|
69
80
|
|
|
@@ -71,23 +82,25 @@ class PaxCalimaPlatform {
|
|
|
71
82
|
const mac = peripheral.address.toLowerCase();
|
|
72
83
|
if (!mac.startsWith(MAC_PREFIX)) return;
|
|
73
84
|
if (this.registeredMACs.has(mac)) return;
|
|
85
|
+
|
|
74
86
|
this.registeredMACs.add(mac);
|
|
75
87
|
this.log.info('Discovered new fan: ' + mac);
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
const hbAcc = new PlatformAccessory(name, uuid, Categories.FAN);
|
|
88
|
+
|
|
89
|
+
const uuid = API.hap.uuid.generate('pax-calima-' + mac);
|
|
90
|
+
const hbAcc = new PlatformAccessory(this.nameForMac(mac), uuid, Categories.FAN);
|
|
80
91
|
hbAcc.context.mac = mac;
|
|
81
|
-
|
|
92
|
+
|
|
93
|
+
new PaxCalimaAccessory(this.log, hbAcc, mac, this.pinForMac(mac));
|
|
82
94
|
this.api.registerPlatformAccessories('homebridge-pax-calima', 'PaxCalima', [hbAcc]);
|
|
83
95
|
});
|
|
84
96
|
}
|
|
85
97
|
|
|
86
98
|
fanConfig(mac) { return (this.config.fans || []).find(f => f.mac && f.mac.toLowerCase() === mac) || {}; }
|
|
87
99
|
pinForMac(mac) { return this.fanConfig(mac).pin || '012345'; }
|
|
88
|
-
nameForMac(mac) { return this.fanConfig(mac).name || ('
|
|
100
|
+
nameForMac(mac) { return this.fanConfig(mac).name || ('Pax Calima ' + mac.slice(-5).toUpperCase()); }
|
|
89
101
|
}
|
|
90
102
|
|
|
103
|
+
// ─── Accessory ────────────────────────────────────────────────────────────────
|
|
91
104
|
class PaxCalimaAccessory {
|
|
92
105
|
constructor(log, accessory, mac, pin) {
|
|
93
106
|
this.log = log;
|
|
@@ -95,22 +108,28 @@ class PaxCalimaAccessory {
|
|
|
95
108
|
this.mac = mac.toLowerCase();
|
|
96
109
|
this.pin = pin || '012345';
|
|
97
110
|
|
|
111
|
+
// Sensor state — served to HomeKit between polls
|
|
98
112
|
this.currentRPM = 0;
|
|
99
113
|
this.humidity = 0;
|
|
100
114
|
this.temperature = 20;
|
|
101
115
|
this.lightLevel = 0.0001;
|
|
102
116
|
this.silentHours = false;
|
|
103
117
|
|
|
104
|
-
|
|
105
|
-
this.
|
|
118
|
+
// BLE connection state
|
|
119
|
+
this.peripheral = null;
|
|
120
|
+
this.charCache = {}; // uuid (stripped) → characteristic — populated once per connection
|
|
121
|
+
this.connected = false;
|
|
122
|
+
this.pollTimer = null;
|
|
106
123
|
|
|
107
124
|
this.setupServices();
|
|
108
|
-
this.
|
|
125
|
+
this.connect(); // connect immediately — BLE is ready by the time we're constructed
|
|
109
126
|
}
|
|
110
127
|
|
|
128
|
+
// ── HomeKit services ────────────────────────────────────────────────────────
|
|
111
129
|
setupServices() {
|
|
112
130
|
const n = this.accessory.displayName;
|
|
113
131
|
|
|
132
|
+
// Fan — always reports as active; speed reflects live RPM
|
|
114
133
|
this.fanService = this.accessory.getService(Service.Fanv2)
|
|
115
134
|
|| this.accessory.addService(new Service.Fanv2(n, 'fan'));
|
|
116
135
|
this.fanService.getCharacteristic(Characteristic.Active)
|
|
@@ -120,17 +139,20 @@ class PaxCalimaAccessory {
|
|
|
120
139
|
.onGet(() => this.rpmToPercent(this.currentRPM))
|
|
121
140
|
.onSet((v) => this.writeSpeed(v));
|
|
122
141
|
|
|
142
|
+
// Boost — stateless single-press button
|
|
123
143
|
this.boostButton = this.accessory.getService(Service.StatelessProgrammableSwitch)
|
|
124
144
|
|| this.accessory.addService(new Service.StatelessProgrammableSwitch(n + ' Boost', 'boost'));
|
|
125
145
|
this.boostButton.getCharacteristic(Characteristic.ProgrammableSwitchEvent)
|
|
126
146
|
.setProps({ validValues: [0] });
|
|
127
147
|
|
|
148
|
+
// Silent Hours — suppresses automatic boosting overnight
|
|
128
149
|
this.silentService = this.accessory.getServiceByUUIDAndSubType(Service.Switch, 'silent-hours')
|
|
129
150
|
|| this.accessory.addService(new Service.Switch(n + ' Silent Hours', 'silent-hours'));
|
|
130
151
|
this.silentService.getCharacteristic(Characteristic.On)
|
|
131
152
|
.onGet(() => this.silentHours)
|
|
132
153
|
.onSet((on) => this.setSilentHours(on));
|
|
133
154
|
|
|
155
|
+
// Sensors
|
|
134
156
|
this.humidityService = this.accessory.getService(Service.HumiditySensor)
|
|
135
157
|
|| this.accessory.addService(new Service.HumiditySensor(n + ' Humidity', 'humidity'));
|
|
136
158
|
this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity)
|
|
@@ -146,71 +168,66 @@ class PaxCalimaAccessory {
|
|
|
146
168
|
this.lightService.getCharacteristic(Characteristic.CurrentAmbientLightLevel)
|
|
147
169
|
.onGet(() => Math.max(0.0001, this.lightLevel));
|
|
148
170
|
|
|
149
|
-
this.log.info('Services
|
|
171
|
+
this.log.info('Services ready: ' + n);
|
|
150
172
|
}
|
|
151
173
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
this.
|
|
155
|
-
}
|
|
174
|
+
// ── BLE connection ──────────────────────────────────────────────────────────
|
|
175
|
+
async connect() {
|
|
176
|
+
this.log.info('Connecting to ' + this.mac + '...');
|
|
156
177
|
|
|
157
|
-
async poll() {
|
|
158
|
-
if (this.polling) { this.schedulePoll(POLL_MS); return; }
|
|
159
|
-
this.polling = true;
|
|
160
|
-
let peripheral = null;
|
|
161
178
|
try {
|
|
162
|
-
peripheral
|
|
163
|
-
await
|
|
179
|
+
// Find the peripheral via scan
|
|
180
|
+
this.peripheral = await this.findPeripheral();
|
|
181
|
+
await this.peripheral.connectAsync();
|
|
182
|
+
this.log.info('Connected to ' + this.mac);
|
|
183
|
+
|
|
184
|
+
// Discover and cache ALL characteristics in one pass
|
|
185
|
+
this.charCache = {};
|
|
186
|
+
const services = await this.peripheral.discoverServicesAsync();
|
|
187
|
+
for (const svc of services) {
|
|
188
|
+
const chars = await svc.discoverCharacteristicsAsync();
|
|
189
|
+
for (const c of chars) {
|
|
190
|
+
this.charCache[c.uuid.replace(/-/g, '')] = c;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
164
193
|
|
|
165
|
-
|
|
194
|
+
// Authenticate once — stays valid for the life of this connection
|
|
195
|
+
const authChar = this.charCache[UUID_AUTH.replace(/-/g, '')];
|
|
196
|
+
if (!authChar) throw new Error('Auth characteristic not found');
|
|
166
197
|
const pinBuf = Buffer.alloc(4);
|
|
167
198
|
pinBuf.writeUInt32LE(parseInt(this.pin, 10), 0);
|
|
168
199
|
await authChar.writeAsync(pinBuf, true);
|
|
200
|
+
this.log.info('Authenticated');
|
|
169
201
|
|
|
170
|
-
|
|
171
|
-
const statusData = await statusChar.readAsync();
|
|
172
|
-
this.currentRPM = Math.max(0, statusData.readUInt16LE(6));
|
|
173
|
-
this.temperature = Math.round((statusData.readUInt16LE(2) / 6) * 10) / 10;
|
|
174
|
-
|
|
175
|
-
const sensorChar = await this.getCharacteristic(peripheral, UUID_SENSOR);
|
|
176
|
-
const sensorData = await sensorChar.readAsync();
|
|
177
|
-
this.humidity = Math.max(0, Math.min(100, sensorData.readUInt8(0) * 2));
|
|
178
|
-
this.lightLevel = Math.max(0.0001, sensorData.readUInt16BE(2));
|
|
202
|
+
this.connected = true;
|
|
179
203
|
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
this.
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
const speed = this.rpmToPercent(this.currentRPM);
|
|
190
|
-
this.fanService.getCharacteristic(Characteristic.RotationSpeed).updateValue(speed);
|
|
191
|
-
this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity).updateValue(this.humidity);
|
|
192
|
-
this.tempService.getCharacteristic(Characteristic.CurrentTemperature).updateValue(this.temperature);
|
|
193
|
-
this.lightService.getCharacteristic(Characteristic.CurrentAmbientLightLevel).updateValue(this.lightLevel);
|
|
204
|
+
// Handle unexpected disconnects — reconnect automatically
|
|
205
|
+
this.peripheral.once('disconnect', () => {
|
|
206
|
+
this.log.warn(this.mac + ' disconnected — reconnecting in ' + (RECONNECT_MS / 1000) + 's');
|
|
207
|
+
this.connected = false;
|
|
208
|
+
this.charCache = {};
|
|
209
|
+
if (this.pollTimer) { clearTimeout(this.pollTimer); this.pollTimer = null; }
|
|
210
|
+
setTimeout(() => this.connect(), RECONNECT_MS);
|
|
211
|
+
});
|
|
194
212
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
'C Light=' + this.lightLevel + 'lx');
|
|
213
|
+
// Start polling immediately
|
|
214
|
+
this.poll();
|
|
198
215
|
|
|
199
216
|
} catch (err) {
|
|
200
|
-
this.log.
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
this.polling = false;
|
|
204
|
-
this.schedulePoll(POLL_MS);
|
|
217
|
+
this.log.error('Connection failed: ' + err.message);
|
|
218
|
+
this.connected = false;
|
|
219
|
+
setTimeout(() => this.connect(), RECONNECT_MS);
|
|
205
220
|
}
|
|
206
221
|
}
|
|
207
222
|
|
|
223
|
+
// Targeted scan — resolves as soon as our specific MAC is seen, removes its own listener
|
|
208
224
|
findPeripheral() {
|
|
209
225
|
return new Promise((resolve, reject) => {
|
|
210
226
|
const timeout = setTimeout(() => {
|
|
211
227
|
noble.removeListener('discover', onDiscover);
|
|
212
228
|
reject(new Error('Scan timeout for ' + this.mac));
|
|
213
|
-
},
|
|
229
|
+
}, 20000);
|
|
230
|
+
|
|
214
231
|
const onDiscover = (periph) => {
|
|
215
232
|
if (periph.address.toLowerCase() === this.mac) {
|
|
216
233
|
clearTimeout(timeout);
|
|
@@ -218,57 +235,79 @@ class PaxCalimaAccessory {
|
|
|
218
235
|
resolve(periph);
|
|
219
236
|
}
|
|
220
237
|
};
|
|
238
|
+
|
|
221
239
|
noble.on('discover', onDiscover);
|
|
222
240
|
noble.startScanning([], false);
|
|
223
241
|
});
|
|
224
242
|
}
|
|
225
243
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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;
|
|
233
285
|
}
|
|
234
286
|
}
|
|
235
|
-
|
|
287
|
+
|
|
288
|
+
// Schedule next poll
|
|
289
|
+
this.pollTimer = setTimeout(() => this.poll(), POLL_MS);
|
|
236
290
|
}
|
|
237
291
|
|
|
292
|
+
// ── Fan speed write ─────────────────────────────────────────────────────────
|
|
238
293
|
async writeSpeed(percent) {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
294
|
+
if (!this.connected) {
|
|
295
|
+
this.log.warn('Cannot set speed — not connected');
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
243
298
|
try {
|
|
244
|
-
|
|
245
|
-
|
|
299
|
+
const ctrlChar = this.charCache[UUID_CONTROL.replace(/-/g, '')];
|
|
300
|
+
if (!ctrlChar) throw new Error('Control characteristic not cached');
|
|
246
301
|
|
|
247
|
-
|
|
248
|
-
const
|
|
249
|
-
pinBuf.writeUInt32LE(parseInt(this.pin, 10), 0);
|
|
250
|
-
await authChar.writeAsync(pinBuf, true);
|
|
251
|
-
|
|
252
|
-
// Read current control config to preserve trickle/boost values
|
|
253
|
-
const ctrlChar = await this.getCharacteristic(peripheral, UUID_CONTROL);
|
|
254
|
-
const current = await ctrlChar.readAsync();
|
|
255
|
-
this.log.info('CTRL before write: ' + Array.from(current).join(','));
|
|
256
|
-
|
|
257
|
-
// Control characteristic layout (6 bytes, all UInt16LE):
|
|
258
|
-
// [0-1] trickle RPM, [2-3] normal RPM, [4-5] boost RPM
|
|
259
|
-
// We write the target RPM into the normal speed slot [2-3]
|
|
302
|
+
// Read current config to preserve trickle + boost values
|
|
303
|
+
const current = await ctrlChar.readAsync();
|
|
260
304
|
const targetRPM = Math.round((percent / 100) * 2400);
|
|
261
|
-
const buf = Buffer.from(current);
|
|
262
|
-
buf.writeUInt16LE(targetRPM, 2);
|
|
305
|
+
const buf = Buffer.from(current);
|
|
306
|
+
buf.writeUInt16LE(targetRPM, 2); // bytes 2-3 = normal speed slot
|
|
263
307
|
await ctrlChar.writeAsync(buf, true);
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
this.log.error('writeSpeed failed: ' + e.message);
|
|
268
|
-
} finally {
|
|
269
|
-
if (peripheral) { try { await peripheral.disconnectAsync(); } catch (_) {} }
|
|
270
|
-
// Resume polling after write
|
|
271
|
-
this.schedulePoll(2000);
|
|
308
|
+
this.log.info('Speed set to ' + percent + '% (' + targetRPM + ' RPM)');
|
|
309
|
+
} catch (err) {
|
|
310
|
+
this.log.error('writeSpeed failed: ' + err.message);
|
|
272
311
|
}
|
|
273
312
|
}
|
|
274
313
|
|