homebridge-pax-calima 2.4.2 → 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 +96 -70
- package/config.schema.json +39 -5
- package/index.js +141 -63
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**Bring your bathroom fan into Apple Home — no hub, no cloud, just Bluetooth.**
|
|
4
4
|
|
|
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
|
|
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
7
|
[](https://github.com/homebridge/homebridge/wiki/Updating-To-Homebridge-v2.0)
|
|
8
8
|
[](https://www.npmjs.com/package/homebridge-pax-calima)
|
|
@@ -11,10 +11,10 @@ If you have a **Pax Calima** or **Vent-Axia Lo-Carbon Svara** extractor fan, thi
|
|
|
11
11
|
|
|
12
12
|
## Is this the right plugin for your fan?
|
|
13
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
|
|
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.
|
|
15
15
|
|
|
16
16
|
**Confirmed compatible:**
|
|
17
|
-
- Pax Calima —
|
|
17
|
+
- Pax Calima — Sweden, Norway, Denmark, Finland, Spain, Portugal
|
|
18
18
|
- Vent-Axia Lo-Carbon Svara White — UK
|
|
19
19
|
- Vent-Axia Lo-Carbon Svara Black — UK
|
|
20
20
|
|
|
@@ -24,32 +24,16 @@ These fans are hardware-identical. The same plugin works for all of them.
|
|
|
24
24
|
|
|
25
25
|
## What you get in the Home app
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
Each fan appears as a single accessory with:
|
|
28
28
|
|
|
29
|
-
-
|
|
30
|
-
-
|
|
31
|
-
- 💨 **Fan speed** — shows current RPM as a percentage
|
|
29
|
+
- 💨 **Fan speed** — shows current RPM as a percentage, live and accurate
|
|
30
|
+
- 🌡️ **Temperature** — live reading, updates every ~20 seconds
|
|
32
31
|
- ⚡ **Boost** — a button that triggers the fan's high-speed boost mode
|
|
33
|
-
- 🌙 **Silent Hours** —
|
|
34
|
-
- 💧 **Humidity** — available but off by default (read
|
|
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)
|
|
35
35
|
|
|
36
|
-
>
|
|
37
|
-
|
|
38
|
-
---
|
|
39
|
-
|
|
40
|
-
## A note on humidity
|
|
41
|
-
|
|
42
|
-
The humidity tile is **turned off by default**, and for good reason.
|
|
43
|
-
|
|
44
|
-
The fan's Bluetooth firmware doesn't expose humidity as a continuous live reading — it reflects the fan's own internal threshold state and updates infrequently. During our testing, the fan's Bluetooth reported 50% while a calibrated sensor in the same room read 89% after a shower. This isn't a bug in the plugin — it's how the fan's firmware works, and the Home Assistant community has observed the same behaviour.
|
|
45
|
-
|
|
46
|
-
**If you turn humidity on**, be aware that:
|
|
47
|
-
- Asking Siri "what's the humidity in the bathroom?" may return a stale number
|
|
48
|
-
- Humidity-triggered automations won't fire reliably during a shower
|
|
49
|
-
|
|
50
|
-
**What does work well for shower detection:** when the fan's internal sensor detects a sharp humidity rise, it boosts to near full speed automatically. A `Fan Speed > 75%` automation in Apple Home is a reliable, practical trigger for "someone's in the shower."
|
|
51
|
-
|
|
52
|
-
If you still want to enable humidity — perhaps for settled ambient readings rather than real-time monitoring — you can turn it on in the plugin settings with `showHumidity: true`.
|
|
36
|
+
> Accessories appear in the **Home app on your iPhone**, not the Homebridge UI accessories tab — that's normal for child bridge plugins.
|
|
53
37
|
|
|
54
38
|
---
|
|
55
39
|
|
|
@@ -58,9 +42,9 @@ If you still want to enable humidity — perhaps for settled ambient readings ra
|
|
|
58
42
|
You'll need:
|
|
59
43
|
- Homebridge `^1.6.0` or `^2.0.0`
|
|
60
44
|
- Node.js `18.20.4` or later
|
|
61
|
-
- A Bluetooth adapter on your Homebridge host (the
|
|
45
|
+
- A Bluetooth adapter on your Homebridge host (the Pi 4's built-in adapter works well)
|
|
62
46
|
|
|
63
|
-
**
|
|
47
|
+
**Running Homebridge OS on a Raspberry Pi?** There's one extra step after installation. Run this once in the **Homebridge terminal**:
|
|
64
48
|
|
|
65
49
|
```bash
|
|
66
50
|
sudo setcap cap_net_raw+eip /opt/homebridge/bin/node
|
|
@@ -72,13 +56,13 @@ If Bluetooth shows as off after that:
|
|
|
72
56
|
sudo rfkill unblock bluetooth
|
|
73
57
|
```
|
|
74
58
|
|
|
75
|
-
|
|
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.
|
|
76
60
|
|
|
77
61
|
---
|
|
78
62
|
|
|
79
63
|
## Installation
|
|
80
64
|
|
|
81
|
-
Search for **Pax Calima** or **Svara** in the Homebridge UI and click Install
|
|
65
|
+
Search for **Pax Calima** or **Svara** in the Homebridge UI and click Install.
|
|
82
66
|
|
|
83
67
|
Or via terminal:
|
|
84
68
|
```bash
|
|
@@ -89,15 +73,19 @@ npm install -g homebridge-pax-calima
|
|
|
89
73
|
|
|
90
74
|
## Configuration
|
|
91
75
|
|
|
92
|
-
**You don't need to configure anything.** The plugin finds your fan automatically
|
|
76
|
+
**You don't need to configure anything to get started.** The plugin finds your fan automatically and registers it in HomeKit.
|
|
93
77
|
|
|
94
|
-
|
|
78
|
+
A full config example with all options:
|
|
95
79
|
|
|
96
80
|
```json
|
|
97
81
|
{
|
|
98
82
|
"platform": "PaxCalima",
|
|
99
83
|
"name": "Pax Calima / Vent-Axia Svara",
|
|
84
|
+
"silentHoursStart": "23:00",
|
|
85
|
+
"silentHoursEnd": "07:00",
|
|
86
|
+
"showSilentSwitch": false,
|
|
100
87
|
"showHumidity": false,
|
|
88
|
+
"showLight": false,
|
|
101
89
|
"fans": [
|
|
102
90
|
{
|
|
103
91
|
"name": "Wetroom Fan",
|
|
@@ -110,83 +98,121 @@ If you want to give your fan a custom name, or if you've set a non-default PIN i
|
|
|
110
98
|
|
|
111
99
|
| Setting | Default | What it does |
|
|
112
100
|
|---|---|---|
|
|
113
|
-
| `
|
|
114
|
-
| `
|
|
115
|
-
| `
|
|
116
|
-
| `
|
|
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 |
|
|
117
109
|
|
|
118
110
|
---
|
|
119
111
|
|
|
120
|
-
##
|
|
112
|
+
## Silent Hours
|
|
121
113
|
|
|
122
|
-
|
|
114
|
+
Silent Hours tells the fan not to boost during a set period — useful for not being woken by a noisy fan at 3am.
|
|
123
115
|
|
|
124
|
-
**
|
|
116
|
+
**Two ways to use it — pick one or combine both:**
|
|
125
117
|
|
|
126
|
-
**
|
|
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.
|
|
127
119
|
|
|
128
|
-
|
|
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.
|
|
129
121
|
|
|
130
|
-
|
|
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.
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## A note on humidity and light
|
|
127
|
+
|
|
128
|
+
Both tiles are **off by default**, and for good reason.
|
|
129
|
+
|
|
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:
|
|
131
|
+
|
|
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%**
|
|
134
|
+
|
|
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).
|
|
136
|
+
|
|
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.
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Fan speed control — it depends on how your fan is wired
|
|
148
|
+
|
|
149
|
+
The fan can be installed in two wiring modes:
|
|
150
|
+
|
|
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.
|
|
152
|
+
|
|
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.
|
|
131
156
|
|
|
132
157
|
---
|
|
133
158
|
|
|
134
159
|
## Something not working?
|
|
135
160
|
|
|
136
161
|
**Fan not showing up in Home**
|
|
137
|
-
|
|
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
|
-
**setcap not
|
|
140
|
-
Make sure you're
|
|
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.
|
|
141
166
|
|
|
142
|
-
**Wrong PIN
|
|
143
|
-
|
|
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.
|
|
144
169
|
|
|
145
170
|
**Speed slider does nothing**
|
|
146
|
-
Your fan is in always-on mode — see
|
|
171
|
+
Your fan is in always-on mode — see above. Everything else works normally.
|
|
147
172
|
|
|
148
|
-
**Humidity
|
|
149
|
-
This is expected — see the
|
|
173
|
+
**Humidity or light reading seems stuck**
|
|
174
|
+
This is expected firmware behaviour — see the sensor note above.
|
|
150
175
|
|
|
151
|
-
**Accessories not appearing in
|
|
152
|
-
Go to
|
|
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.
|
|
153
178
|
|
|
154
179
|
---
|
|
155
180
|
|
|
156
181
|
## Homebridge 2.0 compliance
|
|
157
182
|
|
|
158
|
-
|
|
183
|
+
This plugin meets all Homebridge 2.0 requirements:
|
|
159
184
|
- Registered as a dynamic platform
|
|
160
|
-
- Waits for `didFinishLaunching` before any BLE or accessory work
|
|
161
|
-
- All errors
|
|
162
|
-
- No analytics,
|
|
163
|
-
-
|
|
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
|
|
164
189
|
- Compatible with Node.js 18, 20 and 22
|
|
165
190
|
|
|
166
191
|
---
|
|
167
192
|
|
|
168
193
|
## Release notes
|
|
169
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.
|
|
202
|
+
|
|
170
203
|
### 2.4.2
|
|
171
|
-
- Humidity sensor
|
|
172
|
-
- README rewritten
|
|
173
|
-
- Fan speed documented as the practical shower/occupancy trigger
|
|
204
|
+
- Humidity sensor disabled by default — opt-in via `showHumidity: true`
|
|
205
|
+
- README rewritten with a more human tone
|
|
174
206
|
|
|
175
207
|
### 2.4.1
|
|
176
|
-
- Fixed sensors returning stale values —
|
|
177
|
-
- Eliminated intermittent `Characteristic not found` errors
|
|
208
|
+
- Fixed sensors returning stale values — reverted to connect→read→disconnect per poll
|
|
178
209
|
|
|
179
210
|
### 2.4.0
|
|
180
|
-
- Rebuilt
|
|
181
|
-
|
|
182
|
-
### 2.3.x
|
|
183
|
-
- Identified the correct control characteristic for fan speed writes
|
|
184
|
-
- Documented always-on vs intermittent wiring mode behaviour
|
|
211
|
+
- Rebuilt with persistent connection architecture
|
|
185
212
|
|
|
186
|
-
### 2.2.1 / 2.1.0
|
|
213
|
+
### 2.3.x / 2.2.1 / 2.1.0
|
|
187
214
|
- Correct sensor UUIDs and byte mappings confirmed via live device testing
|
|
188
|
-
- Homebridge 2.0 initialisation fixes
|
|
189
|
-
- Duplicate accessory registration on restart resolved
|
|
215
|
+
- Homebridge 2.0 initialisation fixes; duplicate accessory registration resolved
|
|
190
216
|
|
|
191
217
|
### 1.0.x
|
|
192
218
|
- Initial releases
|
package/config.schema.json
CHANGED
|
@@ -10,15 +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
|
+
},
|
|
13
33
|
"showHumidity": {
|
|
14
34
|
"title": "Show Humidity Sensor",
|
|
15
35
|
"type": "boolean",
|
|
16
36
|
"default": false,
|
|
17
|
-
"description": "⚠️ The fan's humidity
|
|
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."
|
|
18
44
|
},
|
|
19
45
|
"fans": {
|
|
20
46
|
"title": "Manually Configured Fans",
|
|
21
|
-
"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.",
|
|
22
48
|
"type": "array",
|
|
23
49
|
"items": {
|
|
24
50
|
"type": "object",
|
|
@@ -49,9 +75,17 @@
|
|
|
49
75
|
"layout": [
|
|
50
76
|
{
|
|
51
77
|
"type": "section",
|
|
52
|
-
"title": "
|
|
53
|
-
"expandable":
|
|
54
|
-
"
|
|
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"]
|
|
55
89
|
},
|
|
56
90
|
{
|
|
57
91
|
"type": "section",
|
package/index.js
CHANGED
|
@@ -5,15 +5,30 @@ 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
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;
|
|
@@ -62,7 +77,7 @@ class PaxCalimaPlatform {
|
|
|
62
77
|
|
|
63
78
|
for (const { accessory, mac } of this.cachedAccessories) {
|
|
64
79
|
this.log.info('Starting handler for cached fan: ' + mac);
|
|
65
|
-
new PaxCalimaAccessory(this.log, accessory, mac, this.
|
|
80
|
+
new PaxCalimaAccessory(this.log, accessory, mac, this.fanOptions(mac));
|
|
66
81
|
}
|
|
67
82
|
this.cachedAccessories = [];
|
|
68
83
|
|
|
@@ -73,39 +88,50 @@ class PaxCalimaPlatform {
|
|
|
73
88
|
const mac = peripheral.address.toLowerCase();
|
|
74
89
|
if (!mac.startsWith(MAC_PREFIX)) return;
|
|
75
90
|
if (this.registeredMACs.has(mac)) return;
|
|
76
|
-
|
|
77
91
|
this.registeredMACs.add(mac);
|
|
78
92
|
this.log.info('Discovered new fan: ' + mac);
|
|
79
|
-
|
|
80
93
|
const uuid = API.hap.uuid.generate('pax-calima-' + mac);
|
|
81
94
|
const hbAcc = new PlatformAccessory(this.nameForMac(mac), uuid, Categories.FAN);
|
|
82
95
|
hbAcc.context.mac = mac;
|
|
83
|
-
|
|
84
|
-
new PaxCalimaAccessory(this.log, hbAcc, mac, this.pinForMac(mac), this.showHumidity());
|
|
96
|
+
new PaxCalimaAccessory(this.log, hbAcc, mac, this.fanOptions(mac));
|
|
85
97
|
this.api.registerPlatformAccessories('homebridge-pax-calima', 'PaxCalima', [hbAcc]);
|
|
86
98
|
});
|
|
87
99
|
}
|
|
88
100
|
|
|
89
|
-
fanConfig(mac)
|
|
90
|
-
pinForMac(mac)
|
|
91
|
-
nameForMac(mac)
|
|
92
|
-
|
|
101
|
+
fanConfig(mac) { return (this.config.fans || []).find(f => f.mac && f.mac.toLowerCase() === mac) || {}; }
|
|
102
|
+
pinForMac(mac) { return this.fanConfig(mac).pin || '012345'; }
|
|
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
|
+
}
|
|
93
115
|
}
|
|
94
116
|
|
|
95
117
|
// ─── Accessory ────────────────────────────────────────────────────────────────
|
|
96
118
|
class PaxCalimaAccessory {
|
|
97
|
-
constructor(log, accessory, mac,
|
|
98
|
-
this.log
|
|
99
|
-
this.accessory
|
|
100
|
-
this.mac
|
|
101
|
-
this.pin
|
|
102
|
-
this.showHumidity
|
|
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);
|
|
103
129
|
|
|
104
130
|
this.currentRPM = 0;
|
|
105
131
|
this.humidity = 0;
|
|
106
132
|
this.temperature = 20;
|
|
107
133
|
this.lightLevel = 0.0001;
|
|
108
|
-
this.silentHours = false;
|
|
134
|
+
this.silentHours = false; // runtime state of the silent hours switch
|
|
109
135
|
|
|
110
136
|
this.pollTimer = null;
|
|
111
137
|
this.polling = false;
|
|
@@ -114,10 +140,11 @@ class PaxCalimaAccessory {
|
|
|
114
140
|
this.schedulePoll(0);
|
|
115
141
|
}
|
|
116
142
|
|
|
143
|
+
// ── Services ────────────────────────────────────────────────────────────────
|
|
117
144
|
setupServices() {
|
|
118
145
|
const n = this.accessory.displayName;
|
|
119
146
|
|
|
120
|
-
// Fan — always
|
|
147
|
+
// Fan speed — always present
|
|
121
148
|
this.fanService = this.accessory.getService(Service.Fanv2)
|
|
122
149
|
|| this.accessory.addService(new Service.Fanv2(n, 'fan'));
|
|
123
150
|
this.fanService.getCharacteristic(Characteristic.Active)
|
|
@@ -127,49 +154,67 @@ class PaxCalimaAccessory {
|
|
|
127
154
|
.onGet(() => this.rpmToPercent(this.currentRPM))
|
|
128
155
|
.onSet((v) => this.writeSpeed(v));
|
|
129
156
|
|
|
130
|
-
// Boost —
|
|
157
|
+
// Boost button — always present
|
|
131
158
|
this.boostButton = this.accessory.getService(Service.StatelessProgrammableSwitch)
|
|
132
159
|
|| this.accessory.addService(new Service.StatelessProgrammableSwitch(n + ' Boost', 'boost'));
|
|
133
160
|
this.boostButton.getCharacteristic(Characteristic.ProgrammableSwitchEvent)
|
|
134
161
|
.setProps({ validValues: [0] });
|
|
135
162
|
|
|
136
|
-
//
|
|
137
|
-
this.silentService = this.accessory.getServiceByUUIDAndSubType(Service.Switch, 'silent-hours')
|
|
138
|
-
|| this.accessory.addService(new Service.Switch(n + ' Silent Hours', 'silent-hours'));
|
|
139
|
-
this.silentService.getCharacteristic(Characteristic.On)
|
|
140
|
-
.onGet(() => this.silentHours)
|
|
141
|
-
.onSet((on) => this.setSilentHours(on));
|
|
142
|
-
|
|
143
|
-
// Temperature
|
|
163
|
+
// Temperature — always present
|
|
144
164
|
this.tempService = this.accessory.getService(Service.TemperatureSensor)
|
|
145
165
|
|| this.accessory.addService(new Service.TemperatureSensor(n + ' Temperature', 'temperature'));
|
|
146
166
|
this.tempService.getCharacteristic(Characteristic.CurrentTemperature)
|
|
147
167
|
.onGet(() => this.temperature);
|
|
148
168
|
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
+
}
|
|
154
189
|
|
|
155
|
-
// Humidity —
|
|
156
|
-
// User must explicitly enable via showHumidity: true in config
|
|
190
|
+
// Humidity — opt-in only
|
|
157
191
|
if (this.showHumidity) {
|
|
158
192
|
this.humidityService = this.accessory.getService(Service.HumiditySensor)
|
|
159
193
|
|| this.accessory.addService(new Service.HumiditySensor(n + ' Humidity', 'humidity'));
|
|
160
194
|
this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity)
|
|
161
195
|
.onGet(() => this.humidity);
|
|
162
|
-
this.log.info('Humidity sensor enabled —
|
|
196
|
+
this.log.info('Humidity sensor enabled — reflects fan internal state, not real-time');
|
|
163
197
|
} else {
|
|
164
|
-
// Remove humidity service if it was previously registered and user has since disabled it
|
|
165
198
|
const existing = this.accessory.getService(Service.HumiditySensor);
|
|
166
199
|
if (existing) this.accessory.removeService(existing);
|
|
167
|
-
|
|
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);
|
|
168
212
|
}
|
|
169
213
|
|
|
170
214
|
this.log.info('Services ready: ' + n);
|
|
171
215
|
}
|
|
172
216
|
|
|
217
|
+
// ── Poll cycle ──────────────────────────────────────────────────────────────
|
|
173
218
|
schedulePoll(delayMs) {
|
|
174
219
|
if (this.pollTimer) clearTimeout(this.pollTimer);
|
|
175
220
|
this.pollTimer = setTimeout(() => this.poll(), delayMs);
|
|
@@ -184,7 +229,6 @@ class PaxCalimaAccessory {
|
|
|
184
229
|
peripheral = await this.findPeripheral();
|
|
185
230
|
await peripheral.connectAsync();
|
|
186
231
|
|
|
187
|
-
// Discover and cache all characteristics for this connection
|
|
188
232
|
const charCache = {};
|
|
189
233
|
const services = await peripheral.discoverServicesAsync();
|
|
190
234
|
for (const svc of services) {
|
|
@@ -199,43 +243,69 @@ class PaxCalimaAccessory {
|
|
|
199
243
|
pinBuf.writeUInt32LE(parseInt(this.pin, 10), 0);
|
|
200
244
|
await authChar.writeAsync(pinBuf, true);
|
|
201
245
|
|
|
202
|
-
// Read
|
|
246
|
+
// Read RPM + temperature
|
|
203
247
|
const statusChar = charCache[UUID_STATUS.replace(/-/g, '')];
|
|
204
248
|
if (!statusChar) throw new Error('Status characteristic not found');
|
|
205
249
|
const statusData = await statusChar.readAsync();
|
|
206
250
|
this.currentRPM = Math.max(0, statusData.readUInt16LE(6));
|
|
207
251
|
this.temperature = Math.round((statusData.readUInt16LE(2) / 6) * 10) / 10;
|
|
208
252
|
|
|
209
|
-
// Read sensor characteristic only if humidity is enabled
|
|
210
|
-
|
|
211
|
-
if (this.showHumidity) {
|
|
253
|
+
// Read sensor characteristic only if humidity or light is enabled
|
|
254
|
+
if (this.showHumidity || this.showLight) {
|
|
212
255
|
const sensorChar = charCache[UUID_SENSOR.replace(/-/g, '')];
|
|
213
256
|
if (sensorChar) {
|
|
214
257
|
const sensorData = await sensorChar.readAsync();
|
|
215
|
-
this.humidity = Math.max(0, Math.min(100, sensorData.readUInt8(0) * 2));
|
|
216
|
-
this.lightLevel = Math.max(0.0001, sensorData.readUInt16BE(2));
|
|
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));
|
|
217
260
|
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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);
|
|
224
289
|
}
|
|
290
|
+
await silentChar.writeAsync(buf, true);
|
|
225
291
|
}
|
|
226
292
|
|
|
227
293
|
// Push to HomeKit
|
|
228
294
|
const speed = this.rpmToPercent(this.currentRPM);
|
|
229
295
|
this.fanService.getCharacteristic(Characteristic.RotationSpeed).updateValue(speed);
|
|
230
296
|
this.tempService.getCharacteristic(Characteristic.CurrentTemperature).updateValue(this.temperature);
|
|
231
|
-
this.
|
|
232
|
-
|
|
297
|
+
if (this.showSilentSwitch && this.silentService)
|
|
298
|
+
this.silentService.getCharacteristic(Characteristic.On).updateValue(this.silentHours);
|
|
299
|
+
if (this.showHumidity && this.humidityService)
|
|
233
300
|
this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity).updateValue(this.humidity);
|
|
234
|
-
|
|
301
|
+
if (this.showLight && this.lightService)
|
|
302
|
+
this.lightService.getCharacteristic(Characteristic.CurrentAmbientLightLevel).updateValue(this.lightLevel);
|
|
235
303
|
|
|
236
304
|
this.log.info('Poll: RPM=' + this.currentRPM + ' Speed=' + speed +
|
|
237
|
-
'% Temp=' + this.temperature + 'C
|
|
238
|
-
(this.
|
|
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' : ''));
|
|
239
309
|
|
|
240
310
|
} catch (err) {
|
|
241
311
|
this.log.warn('Poll error: ' + err.message);
|
|
@@ -246,6 +316,18 @@ class PaxCalimaAccessory {
|
|
|
246
316
|
}
|
|
247
317
|
}
|
|
248
318
|
|
|
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
|
+
|
|
249
331
|
findPeripheral() {
|
|
250
332
|
return new Promise((resolve, reject) => {
|
|
251
333
|
const timeout = setTimeout(() => {
|
|
@@ -264,6 +346,7 @@ class PaxCalimaAccessory {
|
|
|
264
346
|
});
|
|
265
347
|
}
|
|
266
348
|
|
|
349
|
+
// ── Fan speed write ─────────────────────────────────────────────────────────
|
|
267
350
|
async writeSpeed(percent) {
|
|
268
351
|
if (this.polling) { this.log.warn('Speed write skipped — poll in progress'); return; }
|
|
269
352
|
let peripheral = null;
|
|
@@ -296,11 +379,6 @@ class PaxCalimaAccessory {
|
|
|
296
379
|
}
|
|
297
380
|
}
|
|
298
381
|
|
|
299
|
-
async setSilentHours(on) {
|
|
300
|
-
this.silentHours = on;
|
|
301
|
-
this.log.info('Silent Hours ' + (on ? 'enabled' : 'disabled'));
|
|
302
|
-
}
|
|
303
|
-
|
|
304
382
|
rpmToPercent(rpm) {
|
|
305
383
|
return Math.min(100, Math.max(0, Math.round(rpm / 25)));
|
|
306
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",
|