homebridge-pax-calima 2.4.1 → 2.4.3
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 +126 -82
- package/config.schema.json +46 -6
- package/index.js +163 -89
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,72 +1,70 @@
|
|
|
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 set up, your fan appears in the Home app with live temperature 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
|
|
10
|
-
|
|
11
|
-
All are hardware-identical, sharing the same BLE protocol and MAC prefix (`58:2b:db`).
|
|
7
|
+
[](https://github.com/homebridge/homebridge/wiki/Updating-To-Homebridge-v2.0)
|
|
8
|
+
[](https://www.npmjs.com/package/homebridge-pax-calima)
|
|
12
9
|
|
|
13
10
|
---
|
|
14
11
|
|
|
15
|
-
##
|
|
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 in the right place.
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
| **Light sensor** | Live lux readings, updates every 10 seconds |
|
|
24
|
-
| **Boost button** | Stateless programmable switch |
|
|
25
|
-
| **Silent Hours** | Toggle — suppresses automatic boosting during quiet periods |
|
|
26
|
-
| **Automations** | Full HomeKit automation support on all sensors |
|
|
16
|
+
**Confirmed compatible:**
|
|
17
|
+
- Pax Calima — 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.
|
|
27
22
|
|
|
28
23
|
---
|
|
29
24
|
|
|
30
|
-
##
|
|
25
|
+
## What you get in the Home app
|
|
31
26
|
|
|
32
|
-
|
|
33
|
-
- Node.js `>= 18.20.4`
|
|
34
|
-
- A Bluetooth LE adapter on the Homebridge host
|
|
27
|
+
Each fan appears as a single accessory with:
|
|
35
28
|
|
|
36
|
-
|
|
29
|
+
- 💨 **Fan speed** — shows current RPM as a percentage, live and accurate
|
|
30
|
+
- 🌡️ **Temperature** — live reading, updates every ~20 seconds
|
|
31
|
+
- ⚡ **Boost** — a button that triggers the fan's high-speed boost mode
|
|
32
|
+
- 🌙 **Silent Hours** — keeps the fan quiet at night; set a schedule, a switch, or both (see below)
|
|
33
|
+
- 💧 **Humidity** — available but off by default (read below before enabling)
|
|
34
|
+
- 💡 **Light level** — available but off by default (read below before enabling)
|
|
37
35
|
|
|
38
|
-
|
|
39
|
-
- Implemented as a **dynamic platform** (`registerPlatform` with `true` flag)
|
|
40
|
-
- Waits for `didFinishLaunching` before any BLE or accessory operations
|
|
41
|
-
- All errors caught and logged — no unhandled exceptions
|
|
42
|
-
- No analytics, no tracking, no post-install scripts
|
|
43
|
-
- Accessories restored from cache without re-registration
|
|
44
|
-
- Compatible with Node.js 18, 20 and 22
|
|
36
|
+
> Accessories appear in the **Home app on your iPhone**, not the Homebridge UI accessories tab — that's normal for child bridge plugins.
|
|
45
37
|
|
|
46
|
-
|
|
38
|
+
---
|
|
47
39
|
|
|
48
|
-
|
|
40
|
+
## Before you start
|
|
41
|
+
|
|
42
|
+
You'll need:
|
|
43
|
+
- Homebridge `^1.6.0` or `^2.0.0`
|
|
44
|
+
- Node.js `18.20.4` or later
|
|
45
|
+
- A Bluetooth adapter on your Homebridge host (the Pi 4's built-in adapter works well)
|
|
46
|
+
|
|
47
|
+
**Running Homebridge OS on a Raspberry Pi?** There's one extra step after installation. Run this once in the **Homebridge terminal**:
|
|
49
48
|
|
|
50
49
|
```bash
|
|
51
50
|
sudo setcap cap_net_raw+eip /opt/homebridge/bin/node
|
|
52
51
|
```
|
|
53
52
|
|
|
54
|
-
If Bluetooth shows as
|
|
53
|
+
If Bluetooth shows as off after that:
|
|
55
54
|
|
|
56
55
|
```bash
|
|
57
56
|
sudo rfkill unblock bluetooth
|
|
58
57
|
```
|
|
59
58
|
|
|
60
|
-
If
|
|
59
|
+
If things still aren't working after both of those — unplug the Pi, wait 10 seconds, plug it back in. A full power cycle rather than a software restart is sometimes needed to reinitialise the Bluetooth stack cleanly.
|
|
61
60
|
|
|
62
61
|
---
|
|
63
62
|
|
|
64
63
|
## Installation
|
|
65
64
|
|
|
66
|
-
|
|
67
|
-
Search for **Pax Calima** or **Svara** and click Install.
|
|
65
|
+
Search for **Pax Calima** or **Svara** in the Homebridge UI and click Install.
|
|
68
66
|
|
|
69
|
-
|
|
67
|
+
Or via terminal:
|
|
70
68
|
```bash
|
|
71
69
|
npm install -g homebridge-pax-calima
|
|
72
70
|
```
|
|
@@ -75,12 +73,19 @@ npm install -g homebridge-pax-calima
|
|
|
75
73
|
|
|
76
74
|
## Configuration
|
|
77
75
|
|
|
78
|
-
|
|
76
|
+
**You don't need to configure anything to get started.** The plugin finds your fan automatically and registers it in HomeKit.
|
|
77
|
+
|
|
78
|
+
A full config example with all options:
|
|
79
79
|
|
|
80
80
|
```json
|
|
81
81
|
{
|
|
82
82
|
"platform": "PaxCalima",
|
|
83
83
|
"name": "Pax Calima / Vent-Axia Svara",
|
|
84
|
+
"silentHoursStart": "23:00",
|
|
85
|
+
"silentHoursEnd": "07:00",
|
|
86
|
+
"showSilentSwitch": false,
|
|
87
|
+
"showHumidity": false,
|
|
88
|
+
"showLight": false,
|
|
84
89
|
"fans": [
|
|
85
90
|
{
|
|
86
91
|
"name": "Wetroom Fan",
|
|
@@ -91,84 +96,123 @@ Auto-discovery requires **no configuration**. To set a custom name or PIN per fa
|
|
|
91
96
|
}
|
|
92
97
|
```
|
|
93
98
|
|
|
94
|
-
|
|
99
|
+
| Setting | Default | What it does |
|
|
100
|
+
|---|---|---|
|
|
101
|
+
| `silentHoursStart` | — | Time the fan stops boosting at night, e.g. `"23:00"` |
|
|
102
|
+
| `silentHoursEnd` | — | Time the fan resumes boosting, e.g. `"07:00"` |
|
|
103
|
+
| `showSilentSwitch` | `false` | Adds a Silent Hours switch to the Home app — great for Goodnight scenes |
|
|
104
|
+
| `showHumidity` | `false` | Shows humidity tile — read the note below first |
|
|
105
|
+
| `showLight` | `false` | Shows light tile — read the note below first |
|
|
106
|
+
| `fans[].name` | Auto | Friendly name for a specific fan |
|
|
107
|
+
| `fans[].mac` | — | Only needed to assign a custom name or PIN |
|
|
108
|
+
| `fans[].pin` | `012345` | 6-digit PIN — only change if set via the official app |
|
|
95
109
|
|
|
96
110
|
---
|
|
97
111
|
|
|
98
|
-
##
|
|
112
|
+
## Silent Hours
|
|
99
113
|
|
|
100
|
-
|
|
114
|
+
Silent Hours tells the fan not to boost during a set period — useful for not being woken by a noisy fan at 3am.
|
|
115
|
+
|
|
116
|
+
**Two ways to use it — pick one or combine both:**
|
|
117
|
+
|
|
118
|
+
**Schedule (set and forget):** Set `silentHoursStart` and `silentHoursEnd` in your config using 24-hour format. The plugin calculates whether you're in the quiet window on every poll and writes the schedule to the fan automatically. Overnight spans work correctly — `23:00` to `07:00` does what you'd expect.
|
|
101
119
|
|
|
102
|
-
|
|
103
|
-
- **Boost** — single-press programmable button
|
|
104
|
-
- **Silent Hours** — on/off switch
|
|
105
|
-
- **Humidity** — % relative humidity
|
|
106
|
-
- **Temperature** — °C
|
|
107
|
-
- **Light** — lux
|
|
120
|
+
**Switch (for scenes and shift workers):** Set `showSilentSwitch: true` to add a Silent Hours on/off switch to your accessory. This is ideal for including in a **Goodnight scene** alongside lights and heating — one tap and the fan goes quiet with everything else. It's also the right choice if your sleep schedule changes regularly, like shift work, where a fixed time window doesn't suit.
|
|
108
121
|
|
|
109
|
-
|
|
122
|
+
**Using both together:** If you set a schedule AND enable the switch, the switch takes priority when you toggle it manually. The schedule still runs in the background — useful as a safety net if you forget to turn the switch back on.
|
|
110
123
|
|
|
111
124
|
---
|
|
112
125
|
|
|
113
|
-
##
|
|
126
|
+
## A note on humidity and light
|
|
114
127
|
|
|
115
|
-
|
|
128
|
+
Both tiles are **off by default**, and for good reason.
|
|
116
129
|
|
|
117
|
-
|
|
130
|
+
During testing we confirmed that both sensors — while active on the device — don't expose live readings via Bluetooth. The fan uses them internally to decide when to boost, but the BLE characteristic returns the fan's last internal threshold state rather than a continuous measurement. In practical terms:
|
|
118
131
|
|
|
119
|
-
|
|
132
|
+
- With all lights off at 21:20, the fan's Bluetooth still reported **52 lux**
|
|
133
|
+
- After a shower that raised the room to **89% humidity**, the Bluetooth still reported **50%**
|
|
120
134
|
|
|
121
|
-
|
|
135
|
+
This isn't a bug in the plugin — it's how the fan's firmware works (confirmed on firmware 02.08, and consistent with observations from the Home Assistant community).
|
|
122
136
|
|
|
123
|
-
|
|
137
|
+
**If you turn them on**, be aware that:
|
|
138
|
+
- Asking Siri "what's the humidity in the bathroom?" will likely return a stale number
|
|
139
|
+
- Automations based on these values won't fire reliably
|
|
140
|
+
|
|
141
|
+
**What does work well for shower detection:** when the fan's internal humidity sensor detects a sharp rise, it boosts to near full speed automatically. A `Fan Speed > 75%` automation in Apple Home is a reliable, practical trigger for "shower in use."
|
|
142
|
+
|
|
143
|
+
If you want to enable humidity or light anyway — perhaps for settled ambient readings — set `showHumidity: true` or `showLight: true` in your config.
|
|
124
144
|
|
|
125
145
|
---
|
|
126
146
|
|
|
127
|
-
##
|
|
147
|
+
## Fan speed control — it depends on how your fan is wired
|
|
128
148
|
|
|
129
|
-
|
|
149
|
+
The fan can be installed in two wiring modes:
|
|
130
150
|
|
|
131
|
-
|
|
151
|
+
**Intermittent mode** — the fan is off by default and spins up when triggered. In this mode the speed slider in Apple Home works as expected.
|
|
132
152
|
|
|
133
|
-
|
|
153
|
+
**Always-on (trickle) mode** — the fan runs continuously at a speed managed by its own controller. Speed commands are accepted via Bluetooth but the fan resets to its own setpoint within seconds. The slider shows the current speed accurately but can't override it.
|
|
154
|
+
|
|
155
|
+
Not sure which mode you have? If your fan is always running — even quietly — you're in always-on mode. In either mode, temperature, fan speed and automations work perfectly.
|
|
134
156
|
|
|
135
157
|
---
|
|
136
158
|
|
|
137
|
-
##
|
|
159
|
+
## Something not working?
|
|
160
|
+
|
|
161
|
+
**Fan not showing up in Home**
|
|
162
|
+
Run `hciconfig` in the Homebridge terminal — you should see `UP RUNNING`. If it shows `RF-KILL`, run `sudo rfkill unblock bluetooth`. If still nothing, unplug the Pi and power cycle it.
|
|
138
163
|
|
|
139
|
-
**
|
|
164
|
+
**setcap not taking effect**
|
|
165
|
+
Make sure you're targeting the right binary — on Homebridge OS it's `/opt/homebridge/bin/node`, not the system Node.
|
|
140
166
|
|
|
141
|
-
**
|
|
167
|
+
**Wrong PIN**
|
|
168
|
+
Default is `012345`. If you changed it in the official app, add the fan to the `fans` config array with the correct PIN.
|
|
142
169
|
|
|
143
|
-
**Speed slider
|
|
170
|
+
**Speed slider does nothing**
|
|
171
|
+
Your fan is in always-on mode — see above. Everything else works normally.
|
|
144
172
|
|
|
145
|
-
**
|
|
173
|
+
**Humidity or light reading seems stuck**
|
|
174
|
+
This is expected firmware behaviour — see the sensor note above.
|
|
175
|
+
|
|
176
|
+
**Accessories not appearing in Home**
|
|
177
|
+
Go to Homebridge UI → plugin settings → Remove Accessory Cache, then restart. They'll reappear within a few seconds.
|
|
146
178
|
|
|
147
179
|
---
|
|
148
180
|
|
|
149
|
-
##
|
|
181
|
+
## Homebridge 2.0 compliance
|
|
150
182
|
|
|
151
|
-
|
|
152
|
-
-
|
|
153
|
-
-
|
|
154
|
-
-
|
|
155
|
-
-
|
|
156
|
-
-
|
|
157
|
-
-
|
|
183
|
+
This plugin meets all Homebridge 2.0 requirements:
|
|
184
|
+
- Registered as a dynamic platform
|
|
185
|
+
- Waits for `didFinishLaunching` before any BLE or accessory work
|
|
186
|
+
- All errors caught and logged — no unhandled exceptions
|
|
187
|
+
- No analytics, tracking or post-install scripts
|
|
188
|
+
- Accessories restored from cache cleanly without re-registration
|
|
189
|
+
- Compatible with Node.js 18, 20 and 22
|
|
158
190
|
|
|
159
|
-
|
|
160
|
-
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## Release notes
|
|
194
|
+
|
|
195
|
+
### 2.4.3
|
|
196
|
+
- **Silent Hours** reworked — now supports a config schedule (`silentHoursStart` / `silentHoursEnd`) that runs automatically, an optional HomeKit switch (`showSilentSwitch: true`) for Goodnight scenes and irregular schedules, or both together. The switch takes priority when toggled manually; the schedule runs as a background default. Any existing Silent Hours switch from earlier versions is automatically removed unless `showSilentSwitch` is enabled.
|
|
197
|
+
- **Light sensor** (`showLight`) added as opt-in — confirmed via live testing that the characteristic returns a static value regardless of lighting conditions.
|
|
198
|
+
- **`showSilentSwitch`**, **`showHumidity`** and **`showLight`** grouped under "Optional Sensors" in the Homebridge UI config for a cleaner settings experience.
|
|
199
|
+
- **`displayName`** added to `package.json` — shows correctly as "Vent-Axia Svara / Pax Calima" in the Homebridge UI.
|
|
200
|
+
- Sensor characteristic only read when at least one optional sensor is enabled — slightly leaner poll cycle for most users.
|
|
201
|
+
- Silent Hours written to fan on every poll — stays in sync after power cuts.
|
|
161
202
|
|
|
162
|
-
### 2.
|
|
163
|
-
-
|
|
203
|
+
### 2.4.2
|
|
204
|
+
- Humidity sensor disabled by default — opt-in via `showHumidity: true`
|
|
205
|
+
- README rewritten with a more human tone
|
|
164
206
|
|
|
165
|
-
### 2.
|
|
166
|
-
-
|
|
207
|
+
### 2.4.1
|
|
208
|
+
- Fixed sensors returning stale values — reverted to connect→read→disconnect per poll
|
|
209
|
+
|
|
210
|
+
### 2.4.0
|
|
211
|
+
- Rebuilt with persistent connection architecture
|
|
167
212
|
|
|
168
|
-
### 2.3.
|
|
169
|
-
-
|
|
170
|
-
-
|
|
171
|
-
- Duplicate accessory registration resolved
|
|
213
|
+
### 2.3.x / 2.2.1 / 2.1.0
|
|
214
|
+
- Correct sensor UUIDs and byte mappings confirmed via live device testing
|
|
215
|
+
- Homebridge 2.0 initialisation fixes; duplicate accessory registration resolved
|
|
172
216
|
|
|
173
217
|
### 1.0.x
|
|
174
|
-
- Initial
|
|
218
|
+
- Initial releases
|
package/config.schema.json
CHANGED
|
@@ -10,9 +10,41 @@
|
|
|
10
10
|
"type": "string",
|
|
11
11
|
"default": "Pax Calima / Vent-Axia Svara"
|
|
12
12
|
},
|
|
13
|
+
"silentHoursStart": {
|
|
14
|
+
"title": "Silent Hours — Start Time",
|
|
15
|
+
"type": "string",
|
|
16
|
+
"pattern": "^([01]?[0-9]|2[0-3]):[0-5][0-9]$",
|
|
17
|
+
"placeholder": "e.g. 23:00",
|
|
18
|
+
"description": "When the fan stops boosting at night. 24-hour format. Leave blank to disable the schedule."
|
|
19
|
+
},
|
|
20
|
+
"silentHoursEnd": {
|
|
21
|
+
"title": "Silent Hours — End Time",
|
|
22
|
+
"type": "string",
|
|
23
|
+
"pattern": "^([01]?[0-9]|2[0-3]):[0-5][0-9]$",
|
|
24
|
+
"placeholder": "e.g. 07:00",
|
|
25
|
+
"description": "When the fan resumes boosting in the morning. Both start and end must be set for the schedule to activate."
|
|
26
|
+
},
|
|
27
|
+
"showSilentSwitch": {
|
|
28
|
+
"title": "Show Silent Hours Switch in Home",
|
|
29
|
+
"type": "boolean",
|
|
30
|
+
"default": false,
|
|
31
|
+
"description": "Adds a Silent Hours on/off switch to your accessory in the Home app. Useful for including in a Goodnight scene or automation. If a schedule is also set, the switch takes priority when toggled manually. Good for shift workers or irregular schedules."
|
|
32
|
+
},
|
|
33
|
+
"showHumidity": {
|
|
34
|
+
"title": "Show Humidity Sensor",
|
|
35
|
+
"type": "boolean",
|
|
36
|
+
"default": false,
|
|
37
|
+
"description": "⚠️ The fan's humidity reading reflects its internal threshold state and updates infrequently — it is NOT a live sensor reading and is NOT suitable for Siri queries or real-time automations. Only enable if you understand and accept this limitation."
|
|
38
|
+
},
|
|
39
|
+
"showLight": {
|
|
40
|
+
"title": "Show Light Sensor",
|
|
41
|
+
"type": "boolean",
|
|
42
|
+
"default": false,
|
|
43
|
+
"description": "⚠️ The fan's light reading reflects its internal threshold state and updates infrequently — it is NOT a live lux reading. Confirmed via testing: the value remains static regardless of lighting conditions. Only enable if you understand this limitation."
|
|
44
|
+
},
|
|
13
45
|
"fans": {
|
|
14
46
|
"title": "Manually Configured Fans",
|
|
15
|
-
"description": "Optional. Leave empty
|
|
47
|
+
"description": "Optional. Leave empty for auto-discovery. Add entries here only to set a custom name or PIN per fan.",
|
|
16
48
|
"type": "array",
|
|
17
49
|
"items": {
|
|
18
50
|
"type": "object",
|
|
@@ -26,13 +58,13 @@
|
|
|
26
58
|
"title": "MAC Address",
|
|
27
59
|
"type": "string",
|
|
28
60
|
"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)
|
|
61
|
+
"description": "MAC address of the fan (e.g. 58:2b:db:xx:xx:xx)"
|
|
30
62
|
},
|
|
31
63
|
"pin": {
|
|
32
64
|
"title": "PIN Code",
|
|
33
65
|
"type": "string",
|
|
34
66
|
"default": "012345",
|
|
35
|
-
"description": "6-digit PIN. Only change
|
|
67
|
+
"description": "6-digit PIN. Only change if you set a custom PIN in the official app."
|
|
36
68
|
}
|
|
37
69
|
},
|
|
38
70
|
"required": ["name", "mac"]
|
|
@@ -43,9 +75,17 @@
|
|
|
43
75
|
"layout": [
|
|
44
76
|
{
|
|
45
77
|
"type": "section",
|
|
46
|
-
"title": "
|
|
47
|
-
"expandable":
|
|
48
|
-
"
|
|
78
|
+
"title": "Silent Hours",
|
|
79
|
+
"expandable": true,
|
|
80
|
+
"expanded": false,
|
|
81
|
+
"items": ["silentHoursStart", "silentHoursEnd", "showSilentSwitch"]
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"type": "section",
|
|
85
|
+
"title": "Optional Sensors",
|
|
86
|
+
"expandable": true,
|
|
87
|
+
"expanded": false,
|
|
88
|
+
"items": ["showHumidity", "showLight"]
|
|
49
89
|
},
|
|
50
90
|
{
|
|
51
91
|
"type": "section",
|
package/index.js
CHANGED
|
@@ -5,23 +5,36 @@ const noble = require('@abandonware/noble');
|
|
|
5
5
|
let Service, Characteristic, Categories, API, PlatformAccessory;
|
|
6
6
|
|
|
7
7
|
// ── Verified BLE UUIDs (confirmed via live device dump April 2026) ────────────
|
|
8
|
-
const UUID_AUTH = '4cad343a-209a-40b7-b911-4d9b3df569b2'; // PIN auth
|
|
9
|
-
const UUID_STATUS = '528b80e8-c47a-4c0a-bdf1-916a7748f412'; // RPM (offset 6
|
|
10
|
-
const UUID_SENSOR = 'a22eae12-dba8-49f3-9c69-1721dcff1d96'; // humidity (offset 0
|
|
11
|
-
const UUID_CONTROL = '1488a757-35bc-4ec8-9a6b-9ecf1502778e'; // speed config
|
|
8
|
+
const UUID_AUTH = '4cad343a-209a-40b7-b911-4d9b3df569b2'; // PIN auth
|
|
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 [trickle|normal|boost] UInt16LE
|
|
12
|
+
const UUID_SILENT = 'b5836b55-57bd-433e-8480-46e4993c5ac0'; // silent hours [enabled, startH, startM, endH, endM]
|
|
12
13
|
|
|
13
14
|
const MAC_PREFIX = '58:2b:db';
|
|
14
|
-
const POLL_MS = 10000;
|
|
15
|
+
const POLL_MS = 10000;
|
|
15
16
|
const RECONNECT_MS = 15000;
|
|
16
17
|
|
|
18
|
+
function parseTime(str) {
|
|
19
|
+
if (!str || typeof str !== 'string') return null;
|
|
20
|
+
const parts = str.split(':');
|
|
21
|
+
if (parts.length !== 2) return null;
|
|
22
|
+
const hour = parseInt(parts[0], 10);
|
|
23
|
+
const minute = parseInt(parts[1], 10);
|
|
24
|
+
if (isNaN(hour) || isNaN(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
|
|
25
|
+
return { hour, minute };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function timeStr(t) {
|
|
29
|
+
return String(t.hour).padStart(2, '0') + ':' + String(t.minute).padStart(2, '0');
|
|
30
|
+
}
|
|
31
|
+
|
|
17
32
|
module.exports = (homebridge) => {
|
|
18
33
|
Service = homebridge.hap.Service;
|
|
19
34
|
Characteristic = homebridge.hap.Characteristic;
|
|
20
35
|
Categories = homebridge.hap.Categories;
|
|
21
36
|
API = homebridge;
|
|
22
37
|
PlatformAccessory = homebridge.platformAccessory;
|
|
23
|
-
|
|
24
|
-
// Dynamic platform — required for Homebridge 2.0
|
|
25
38
|
homebridge.registerPlatform('homebridge-pax-calima', 'PaxCalima', PaxCalimaPlatform, true);
|
|
26
39
|
};
|
|
27
40
|
|
|
@@ -33,7 +46,7 @@ class PaxCalimaPlatform {
|
|
|
33
46
|
this.api = api;
|
|
34
47
|
this.registeredMACs = new Set();
|
|
35
48
|
this.discoveryStarted = false;
|
|
36
|
-
this.cachedAccessories = [];
|
|
49
|
+
this.cachedAccessories = [];
|
|
37
50
|
|
|
38
51
|
this.log.info('PaxCalima platform initialised');
|
|
39
52
|
|
|
@@ -43,16 +56,12 @@ class PaxCalimaPlatform {
|
|
|
43
56
|
this.log.info('BLE state: ' + state);
|
|
44
57
|
if (state === 'poweredOn') this.startDiscovery();
|
|
45
58
|
});
|
|
46
|
-
// Catch case where noble is already poweredOn before listener fires
|
|
47
59
|
setTimeout(() => {
|
|
48
60
|
if (noble.state === 'poweredOn') this.startDiscovery();
|
|
49
61
|
}, 3000);
|
|
50
62
|
});
|
|
51
63
|
}
|
|
52
64
|
|
|
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
65
|
configureAccessory(accessory) {
|
|
57
66
|
const mac = accessory.context && accessory.context.mac;
|
|
58
67
|
this.log.info('Restoring cached accessory: ' + accessory.displayName);
|
|
@@ -66,14 +75,12 @@ class PaxCalimaPlatform {
|
|
|
66
75
|
if (this.discoveryStarted) return;
|
|
67
76
|
this.discoveryStarted = true;
|
|
68
77
|
|
|
69
|
-
// Spin up BLE handlers for all previously registered accessories
|
|
70
78
|
for (const { accessory, mac } of this.cachedAccessories) {
|
|
71
79
|
this.log.info('Starting handler for cached fan: ' + mac);
|
|
72
|
-
new PaxCalimaAccessory(this.log, accessory, mac, this.
|
|
80
|
+
new PaxCalimaAccessory(this.log, accessory, mac, this.fanOptions(mac));
|
|
73
81
|
}
|
|
74
82
|
this.cachedAccessories = [];
|
|
75
83
|
|
|
76
|
-
// Scan for any new fans not yet registered
|
|
77
84
|
this.log.info('Starting BLE discovery scan...');
|
|
78
85
|
noble.startScanning([], false);
|
|
79
86
|
|
|
@@ -81,51 +88,63 @@ class PaxCalimaPlatform {
|
|
|
81
88
|
const mac = peripheral.address.toLowerCase();
|
|
82
89
|
if (!mac.startsWith(MAC_PREFIX)) return;
|
|
83
90
|
if (this.registeredMACs.has(mac)) return;
|
|
84
|
-
|
|
85
91
|
this.registeredMACs.add(mac);
|
|
86
92
|
this.log.info('Discovered new fan: ' + mac);
|
|
87
|
-
|
|
88
93
|
const uuid = API.hap.uuid.generate('pax-calima-' + mac);
|
|
89
94
|
const hbAcc = new PlatformAccessory(this.nameForMac(mac), uuid, Categories.FAN);
|
|
90
95
|
hbAcc.context.mac = mac;
|
|
91
|
-
|
|
92
|
-
new PaxCalimaAccessory(this.log, hbAcc, mac, this.pinForMac(mac));
|
|
96
|
+
new PaxCalimaAccessory(this.log, hbAcc, mac, this.fanOptions(mac));
|
|
93
97
|
this.api.registerPlatformAccessories('homebridge-pax-calima', 'PaxCalima', [hbAcc]);
|
|
94
98
|
});
|
|
95
99
|
}
|
|
96
100
|
|
|
97
101
|
fanConfig(mac) { return (this.config.fans || []).find(f => f.mac && f.mac.toLowerCase() === mac) || {}; }
|
|
98
|
-
pinForMac(mac) { return this.fanConfig(mac).pin
|
|
102
|
+
pinForMac(mac) { return this.fanConfig(mac).pin || '012345'; }
|
|
99
103
|
nameForMac(mac) { return this.fanConfig(mac).name || ('Pax Calima ' + mac.slice(-5).toUpperCase()); }
|
|
104
|
+
|
|
105
|
+
fanOptions(mac) {
|
|
106
|
+
return {
|
|
107
|
+
pin: this.pinForMac(mac),
|
|
108
|
+
showHumidity: this.config.showHumidity === true,
|
|
109
|
+
showLight: this.config.showLight === true,
|
|
110
|
+
showSilentSwitch: this.config.showSilentSwitch === true,
|
|
111
|
+
silentHoursStart: this.config.silentHoursStart || null,
|
|
112
|
+
silentHoursEnd: this.config.silentHoursEnd || null,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
100
115
|
}
|
|
101
116
|
|
|
102
117
|
// ─── Accessory ────────────────────────────────────────────────────────────────
|
|
103
118
|
class PaxCalimaAccessory {
|
|
104
|
-
constructor(log, accessory, mac,
|
|
105
|
-
this.log
|
|
106
|
-
this.accessory
|
|
107
|
-
this.mac
|
|
108
|
-
this.pin
|
|
119
|
+
constructor(log, accessory, mac, options) {
|
|
120
|
+
this.log = log;
|
|
121
|
+
this.accessory = accessory;
|
|
122
|
+
this.mac = mac.toLowerCase();
|
|
123
|
+
this.pin = options.pin || '012345';
|
|
124
|
+
this.showHumidity = options.showHumidity || false;
|
|
125
|
+
this.showLight = options.showLight || false;
|
|
126
|
+
this.showSilentSwitch = options.showSilentSwitch || false;
|
|
127
|
+
this.silentStart = parseTime(options.silentHoursStart);
|
|
128
|
+
this.silentEnd = parseTime(options.silentHoursEnd);
|
|
109
129
|
|
|
110
|
-
// Sensor state — served to HomeKit between polls
|
|
111
130
|
this.currentRPM = 0;
|
|
112
131
|
this.humidity = 0;
|
|
113
132
|
this.temperature = 20;
|
|
114
133
|
this.lightLevel = 0.0001;
|
|
115
|
-
this.silentHours = false;
|
|
134
|
+
this.silentHours = false; // runtime state of the silent hours switch
|
|
116
135
|
|
|
117
136
|
this.pollTimer = null;
|
|
118
|
-
this.polling = false;
|
|
137
|
+
this.polling = false;
|
|
119
138
|
|
|
120
139
|
this.setupServices();
|
|
121
|
-
this.schedulePoll(0);
|
|
140
|
+
this.schedulePoll(0);
|
|
122
141
|
}
|
|
123
142
|
|
|
124
|
-
// ──
|
|
143
|
+
// ── Services ────────────────────────────────────────────────────────────────
|
|
125
144
|
setupServices() {
|
|
126
145
|
const n = this.accessory.displayName;
|
|
127
146
|
|
|
128
|
-
// Fan — always
|
|
147
|
+
// Fan speed — always present
|
|
129
148
|
this.fanService = this.accessory.getService(Service.Fanv2)
|
|
130
149
|
|| this.accessory.addService(new Service.Fanv2(n, 'fan'));
|
|
131
150
|
this.fanService.getCharacteristic(Characteristic.Active)
|
|
@@ -135,42 +154,67 @@ class PaxCalimaAccessory {
|
|
|
135
154
|
.onGet(() => this.rpmToPercent(this.currentRPM))
|
|
136
155
|
.onSet((v) => this.writeSpeed(v));
|
|
137
156
|
|
|
138
|
-
// Boost —
|
|
157
|
+
// Boost button — always present
|
|
139
158
|
this.boostButton = this.accessory.getService(Service.StatelessProgrammableSwitch)
|
|
140
159
|
|| this.accessory.addService(new Service.StatelessProgrammableSwitch(n + ' Boost', 'boost'));
|
|
141
160
|
this.boostButton.getCharacteristic(Characteristic.ProgrammableSwitchEvent)
|
|
142
161
|
.setProps({ validValues: [0] });
|
|
143
162
|
|
|
144
|
-
//
|
|
145
|
-
this.silentService = this.accessory.getServiceByUUIDAndSubType(Service.Switch, 'silent-hours')
|
|
146
|
-
|| this.accessory.addService(new Service.Switch(n + ' Silent Hours', 'silent-hours'));
|
|
147
|
-
this.silentService.getCharacteristic(Characteristic.On)
|
|
148
|
-
.onGet(() => this.silentHours)
|
|
149
|
-
.onSet((on) => this.setSilentHours(on));
|
|
150
|
-
|
|
151
|
-
// Sensors
|
|
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
|
-
|
|
163
|
+
// Temperature — always present
|
|
157
164
|
this.tempService = this.accessory.getService(Service.TemperatureSensor)
|
|
158
165
|
|| this.accessory.addService(new Service.TemperatureSensor(n + ' Temperature', 'temperature'));
|
|
159
166
|
this.tempService.getCharacteristic(Characteristic.CurrentTemperature)
|
|
160
167
|
.onGet(() => this.temperature);
|
|
161
168
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
this.
|
|
165
|
-
.
|
|
169
|
+
// Silent Hours switch — opt-in via showSilentSwitch: true
|
|
170
|
+
// Coexists with config schedule — switch state takes priority on next poll
|
|
171
|
+
if (this.showSilentSwitch) {
|
|
172
|
+
this.silentService = this.accessory.getServiceByUUIDAndSubType(Service.Switch, 'silent-hours')
|
|
173
|
+
|| this.accessory.addService(new Service.Switch(n + ' Silent Hours', 'silent-hours'));
|
|
174
|
+
this.silentService.getCharacteristic(Characteristic.On)
|
|
175
|
+
.onGet(() => this.silentHours)
|
|
176
|
+
.onSet((on) => {
|
|
177
|
+
this.silentHours = on;
|
|
178
|
+
this.log.info('Silent Hours manually ' + (on ? 'enabled' : 'disabled'));
|
|
179
|
+
});
|
|
180
|
+
this.log.info('Silent Hours switch enabled');
|
|
181
|
+
} else {
|
|
182
|
+
// Remove switch if it was registered in a previous version
|
|
183
|
+
const existing = this.accessory.getServiceByUUIDAndSubType(Service.Switch, 'silent-hours');
|
|
184
|
+
if (existing) {
|
|
185
|
+
this.accessory.removeService(existing);
|
|
186
|
+
this.log.info('Silent Hours switch removed — use config schedule or enable showSilentSwitch');
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Humidity — opt-in only
|
|
191
|
+
if (this.showHumidity) {
|
|
192
|
+
this.humidityService = this.accessory.getService(Service.HumiditySensor)
|
|
193
|
+
|| this.accessory.addService(new Service.HumiditySensor(n + ' Humidity', 'humidity'));
|
|
194
|
+
this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity)
|
|
195
|
+
.onGet(() => this.humidity);
|
|
196
|
+
this.log.info('Humidity sensor enabled — reflects fan internal state, not real-time');
|
|
197
|
+
} else {
|
|
198
|
+
const existing = this.accessory.getService(Service.HumiditySensor);
|
|
199
|
+
if (existing) this.accessory.removeService(existing);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Light — opt-in only
|
|
203
|
+
if (this.showLight) {
|
|
204
|
+
this.lightService = this.accessory.getService(Service.LightSensor)
|
|
205
|
+
|| this.accessory.addService(new Service.LightSensor(n + ' Light', 'light'));
|
|
206
|
+
this.lightService.getCharacteristic(Characteristic.CurrentAmbientLightLevel)
|
|
207
|
+
.onGet(() => Math.max(0.0001, this.lightLevel));
|
|
208
|
+
this.log.info('Light sensor enabled — reflects fan internal state, not real-time');
|
|
209
|
+
} else {
|
|
210
|
+
const existing = this.accessory.getService(Service.LightSensor);
|
|
211
|
+
if (existing) this.accessory.removeService(existing);
|
|
212
|
+
}
|
|
166
213
|
|
|
167
214
|
this.log.info('Services ready: ' + n);
|
|
168
215
|
}
|
|
169
216
|
|
|
170
|
-
// ── Poll cycle
|
|
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.
|
|
217
|
+
// ── Poll cycle ──────────────────────────────────────────────────────────────
|
|
174
218
|
schedulePoll(delayMs) {
|
|
175
219
|
if (this.pollTimer) clearTimeout(this.pollTimer);
|
|
176
220
|
this.pollTimer = setTimeout(() => this.poll(), delayMs);
|
|
@@ -185,9 +229,8 @@ class PaxCalimaAccessory {
|
|
|
185
229
|
peripheral = await this.findPeripheral();
|
|
186
230
|
await peripheral.connectAsync();
|
|
187
231
|
|
|
188
|
-
// Discover and cache all characteristics for this connection
|
|
189
232
|
const charCache = {};
|
|
190
|
-
const services
|
|
233
|
+
const services = await peripheral.discoverServicesAsync();
|
|
191
234
|
for (const svc of services) {
|
|
192
235
|
const chars = await svc.discoverCharacteristicsAsync();
|
|
193
236
|
for (const c of chars) charCache[c.uuid.replace(/-/g, '')] = c;
|
|
@@ -200,49 +243,97 @@ class PaxCalimaAccessory {
|
|
|
200
243
|
pinBuf.writeUInt32LE(parseInt(this.pin, 10), 0);
|
|
201
244
|
await authChar.writeAsync(pinBuf, true);
|
|
202
245
|
|
|
203
|
-
// Read
|
|
246
|
+
// Read RPM + temperature
|
|
204
247
|
const statusChar = charCache[UUID_STATUS.replace(/-/g, '')];
|
|
205
248
|
if (!statusChar) throw new Error('Status characteristic not found');
|
|
206
249
|
const statusData = await statusChar.readAsync();
|
|
207
250
|
this.currentRPM = Math.max(0, statusData.readUInt16LE(6));
|
|
208
251
|
this.temperature = Math.round((statusData.readUInt16LE(2) / 6) * 10) / 10;
|
|
209
252
|
|
|
210
|
-
// Read
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
253
|
+
// Read sensor characteristic only if humidity or light is enabled
|
|
254
|
+
if (this.showHumidity || this.showLight) {
|
|
255
|
+
const sensorChar = charCache[UUID_SENSOR.replace(/-/g, '')];
|
|
256
|
+
if (sensorChar) {
|
|
257
|
+
const sensorData = await sensorChar.readAsync();
|
|
258
|
+
if (this.showHumidity) this.humidity = Math.max(0, Math.min(100, sensorData.readUInt8(0) * 2));
|
|
259
|
+
if (this.showLight) this.lightLevel = Math.max(0.0001, sensorData.readUInt16BE(2));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Determine silent hours state:
|
|
264
|
+
// If switch is shown, the user's manual toggle takes priority.
|
|
265
|
+
// If no switch, derive from config schedule based on current time.
|
|
266
|
+
let silentActive = this.silentHours; // default: use switch state
|
|
267
|
+
if (!this.showSilentSwitch && this.silentStart && this.silentEnd) {
|
|
268
|
+
silentActive = this.isWithinSilentHours();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Write silent hours to fan on every poll
|
|
272
|
+
const silentChar = charCache[UUID_SILENT.replace(/-/g, '')];
|
|
273
|
+
if (silentChar && (this.showSilentSwitch || (this.silentStart && this.silentEnd))) {
|
|
274
|
+
const buf = Buffer.alloc(5);
|
|
275
|
+
if (this.showSilentSwitch) {
|
|
276
|
+
// Switch mode: enabled flag driven by switch; use config times if set, else 00:00–00:00
|
|
277
|
+
buf.writeUInt8(this.silentHours ? 1 : 0, 0);
|
|
278
|
+
buf.writeUInt8(this.silentStart ? this.silentStart.hour : 0, 1);
|
|
279
|
+
buf.writeUInt8(this.silentStart ? this.silentStart.minute : 0, 2);
|
|
280
|
+
buf.writeUInt8(this.silentEnd ? this.silentEnd.hour : 0, 3);
|
|
281
|
+
buf.writeUInt8(this.silentEnd ? this.silentEnd.minute : 0, 4);
|
|
282
|
+
} else {
|
|
283
|
+
// Schedule mode: always enabled, times from config
|
|
284
|
+
buf.writeUInt8(1, 0);
|
|
285
|
+
buf.writeUInt8(this.silentStart.hour, 1);
|
|
286
|
+
buf.writeUInt8(this.silentStart.minute, 2);
|
|
287
|
+
buf.writeUInt8(this.silentEnd.hour, 3);
|
|
288
|
+
buf.writeUInt8(this.silentEnd.minute, 4);
|
|
289
|
+
}
|
|
290
|
+
await silentChar.writeAsync(buf, true);
|
|
291
|
+
}
|
|
216
292
|
|
|
217
|
-
// Push
|
|
293
|
+
// Push to HomeKit
|
|
218
294
|
const speed = this.rpmToPercent(this.currentRPM);
|
|
219
295
|
this.fanService.getCharacteristic(Characteristic.RotationSpeed).updateValue(speed);
|
|
220
|
-
this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity).updateValue(this.humidity);
|
|
221
296
|
this.tempService.getCharacteristic(Characteristic.CurrentTemperature).updateValue(this.temperature);
|
|
222
|
-
this.
|
|
297
|
+
if (this.showSilentSwitch && this.silentService)
|
|
298
|
+
this.silentService.getCharacteristic(Characteristic.On).updateValue(this.silentHours);
|
|
299
|
+
if (this.showHumidity && this.humidityService)
|
|
300
|
+
this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity).updateValue(this.humidity);
|
|
301
|
+
if (this.showLight && this.lightService)
|
|
302
|
+
this.lightService.getCharacteristic(Characteristic.CurrentAmbientLightLevel).updateValue(this.lightLevel);
|
|
223
303
|
|
|
224
304
|
this.log.info('Poll: RPM=' + this.currentRPM + ' Speed=' + speed +
|
|
225
|
-
'%
|
|
226
|
-
'
|
|
305
|
+
'% Temp=' + this.temperature + 'C' +
|
|
306
|
+
(this.showSilentSwitch || (this.silentStart && this.silentEnd) ? ' Silent=' + (silentActive ? 'on' : 'off') : '') +
|
|
307
|
+
(this.showHumidity ? ' Hum=' + this.humidity + '%' : '') +
|
|
308
|
+
(this.showLight ? ' Light=' + this.lightLevel + 'lx' : ''));
|
|
227
309
|
|
|
228
310
|
} catch (err) {
|
|
229
311
|
this.log.warn('Poll error: ' + err.message);
|
|
230
312
|
} finally {
|
|
231
|
-
// Always disconnect cleanly — required for fresh sensor reads next cycle
|
|
232
313
|
if (peripheral) { try { await peripheral.disconnectAsync(); } catch (_) {} }
|
|
233
314
|
this.polling = false;
|
|
234
315
|
this.schedulePoll(POLL_MS);
|
|
235
316
|
}
|
|
236
317
|
}
|
|
237
318
|
|
|
238
|
-
//
|
|
319
|
+
// Returns true if current time falls within the silent hours window
|
|
320
|
+
isWithinSilentHours() {
|
|
321
|
+
if (!this.silentStart || !this.silentEnd) return false;
|
|
322
|
+
const now = new Date();
|
|
323
|
+
const current = now.getHours() * 60 + now.getMinutes();
|
|
324
|
+
const start = this.silentStart.hour * 60 + this.silentStart.minute;
|
|
325
|
+
const end = this.silentEnd.hour * 60 + this.silentEnd.minute;
|
|
326
|
+
// Handle overnight spans e.g. 23:00 → 07:00
|
|
327
|
+
if (start > end) return current >= start || current < end;
|
|
328
|
+
return current >= start && current < end;
|
|
329
|
+
}
|
|
330
|
+
|
|
239
331
|
findPeripheral() {
|
|
240
332
|
return new Promise((resolve, reject) => {
|
|
241
333
|
const timeout = setTimeout(() => {
|
|
242
334
|
noble.removeListener('discover', onDiscover);
|
|
243
335
|
reject(new Error('Scan timeout for ' + this.mac));
|
|
244
336
|
}, 20000);
|
|
245
|
-
|
|
246
337
|
const onDiscover = (periph) => {
|
|
247
338
|
if (periph.address.toLowerCase() === this.mac) {
|
|
248
339
|
clearTimeout(timeout);
|
|
@@ -250,49 +341,37 @@ class PaxCalimaAccessory {
|
|
|
250
341
|
resolve(periph);
|
|
251
342
|
}
|
|
252
343
|
};
|
|
253
|
-
|
|
254
344
|
noble.on('discover', onDiscover);
|
|
255
345
|
noble.startScanning([], false);
|
|
256
346
|
});
|
|
257
347
|
}
|
|
258
348
|
|
|
259
349
|
// ── 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
350
|
async writeSpeed(percent) {
|
|
263
|
-
if (this.polling) {
|
|
264
|
-
this.log.warn('Speed write skipped — poll in progress');
|
|
265
|
-
return;
|
|
266
|
-
}
|
|
351
|
+
if (this.polling) { this.log.warn('Speed write skipped — poll in progress'); return; }
|
|
267
352
|
let peripheral = null;
|
|
268
353
|
try {
|
|
269
354
|
peripheral = await this.findPeripheral();
|
|
270
355
|
await peripheral.connectAsync();
|
|
271
|
-
|
|
272
356
|
const charCache = {};
|
|
273
|
-
const services
|
|
357
|
+
const services = await peripheral.discoverServicesAsync();
|
|
274
358
|
for (const svc of services) {
|
|
275
359
|
const chars = await svc.discoverCharacteristicsAsync();
|
|
276
360
|
for (const c of chars) charCache[c.uuid.replace(/-/g, '')] = c;
|
|
277
361
|
}
|
|
278
|
-
|
|
279
362
|
const authChar = charCache[UUID_AUTH.replace(/-/g, '')];
|
|
280
363
|
if (!authChar) throw new Error('Auth characteristic not found');
|
|
281
364
|
const pinBuf = Buffer.alloc(4);
|
|
282
365
|
pinBuf.writeUInt32LE(parseInt(this.pin, 10), 0);
|
|
283
366
|
await authChar.writeAsync(pinBuf, true);
|
|
284
|
-
|
|
285
367
|
const ctrlChar = charCache[UUID_CONTROL.replace(/-/g, '')];
|
|
286
368
|
if (!ctrlChar) throw new Error('Control characteristic not found');
|
|
287
|
-
|
|
288
|
-
// Preserve existing trickle + boost values, update normal speed slot only
|
|
289
369
|
const current = await ctrlChar.readAsync();
|
|
290
370
|
const targetRPM = Math.round((percent / 100) * 2400);
|
|
291
371
|
const buf = Buffer.from(current);
|
|
292
372
|
buf.writeUInt16LE(targetRPM, 2);
|
|
293
373
|
await ctrlChar.writeAsync(buf, true);
|
|
294
374
|
this.log.info('Speed set to ' + percent + '% (' + targetRPM + ' RPM)');
|
|
295
|
-
|
|
296
375
|
} catch (err) {
|
|
297
376
|
this.log.error('writeSpeed failed: ' + err.message);
|
|
298
377
|
} finally {
|
|
@@ -300,11 +379,6 @@ class PaxCalimaAccessory {
|
|
|
300
379
|
}
|
|
301
380
|
}
|
|
302
381
|
|
|
303
|
-
async setSilentHours(on) {
|
|
304
|
-
this.silentHours = on;
|
|
305
|
-
this.log.info('Silent Hours ' + (on ? 'enabled' : 'disabled'));
|
|
306
|
-
}
|
|
307
|
-
|
|
308
382
|
rpmToPercent(rpm) {
|
|
309
383
|
return Math.min(100, Math.max(0, Math.round(rpm / 25)));
|
|
310
384
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-pax-calima",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
3
|
+
"displayName": "Vent-Axia Svara / Pax Calima",
|
|
4
|
+
"version": "2.4.3",
|
|
5
|
+
"description": "Bring your Pax Calima or Vent-Axia Svara bathroom fan into Apple Home via Bluetooth — live temperature and fan speed with Silent Hours scheduling and full automation support.",
|
|
5
6
|
"main": "index.js",
|
|
6
7
|
"keywords": [
|
|
7
8
|
"homebridge-plugin",
|