homebridge-pax-calima 2.3.1 → 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 +142 -14
- package/config.schema.json +43 -7
- package/index.js +147 -108
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,27 +1,155 @@
|
|
|
1
1
|
# homebridge-pax-calima
|
|
2
2
|
|
|
3
|
-
Homebridge
|
|
3
|
+
**Homebridge 2.0 Ready** · [npm](https://www.npmjs.com/package/homebridge-pax-calima) · [GitHub](https://github.com/JayC68/homebridge-pax-calima)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Full HomeKit integration for **Pax Calima** 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`).
|
|
12
|
+
|
|
13
|
+
---
|
|
6
14
|
|
|
7
15
|
## Features
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
|
|
17
|
+
| Feature | Detail |
|
|
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 |
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Requirements
|
|
31
|
+
|
|
32
|
+
- Homebridge `^1.6.0` or `^2.0.0`
|
|
33
|
+
- Node.js `>= 18`
|
|
34
|
+
- A Bluetooth LE adapter on the Homebridge host
|
|
35
|
+
|
|
36
|
+
### Raspberry Pi / Homebridge OS — one-time setup
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
sudo setcap cap_net_raw+eip /opt/homebridge/bin/node
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
If Bluetooth shows as `poweredOff`:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
sudo rfkill unblock bluetooth
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
---
|
|
19
49
|
|
|
20
50
|
## Installation
|
|
21
51
|
|
|
22
52
|
### Via Homebridge UI (recommended)
|
|
23
|
-
Search for **Pax Calima** or **Svara** and
|
|
53
|
+
Search for **Pax Calima** or **Svara** and click Install.
|
|
24
54
|
|
|
25
55
|
### Via Terminal
|
|
26
56
|
```bash
|
|
27
57
|
npm install -g homebridge-pax-calima
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Configuration
|
|
63
|
+
|
|
64
|
+
No configuration needed for auto-discovery. To set a custom name or PIN per fan:
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"platform": "PaxCalima",
|
|
69
|
+
"name": "Pax Calima / Vent-Axia Svara",
|
|
70
|
+
"fans": [
|
|
71
|
+
{
|
|
72
|
+
"name": "Wetroom Fan",
|
|
73
|
+
"mac": "58:2b:db:xx:xx:xx",
|
|
74
|
+
"pin": "012345"
|
|
75
|
+
}
|
|
76
|
+
]
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The `fans` array is optional. Default PIN is `012345`.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Accessories in Apple Home
|
|
85
|
+
|
|
86
|
+
Each fan appears as a single accessory with:
|
|
87
|
+
|
|
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
|
|
94
|
+
|
|
95
|
+
> Accessories appear in the **Home app**, not the Homebridge UI accessories tab. This is correct for child bridge plugins.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## Fan Speed Control — Wiring Mode
|
|
100
|
+
|
|
101
|
+
The fan supports two installation wiring modes:
|
|
102
|
+
|
|
103
|
+
**Intermittent mode** — fan is off by default, triggered by sensors or timer. BLE speed commands work fully; the slider controls the fan directly.
|
|
104
|
+
|
|
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.
|
|
106
|
+
|
|
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.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Sensor Notes
|
|
112
|
+
|
|
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.
|
|
114
|
+
|
|
115
|
+
For lux-based automations, use **Eve** or **Hesperus** which support lux thresholds synced to HomeKit.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Troubleshooting
|
|
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`.
|
|
122
|
+
|
|
123
|
+
**Authentication failed** — default PIN is `012345`. If changed in the official app, add the fan to the `fans` config with the correct PIN.
|
|
124
|
+
|
|
125
|
+
**Speed slider has no effect** — fan is wired in always-on mode. See wiring note above. All sensors work normally.
|
|
126
|
+
|
|
127
|
+
**Accessories not in Home app** — clear accessory cache via Homebridge UI → plugin settings → Remove Accessory Cache, then restart.
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Release Notes
|
|
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
|
+
|
|
143
|
+
### 2.3.2
|
|
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
|
|
153
|
+
|
|
154
|
+
### 1.0.x
|
|
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
|
|