homebridge-pax-calima 2.4.2 → 2.4.4

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 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 installed, your fan shows up in the Home app with live temperature, light level and fan speed — and you can build automations around all of them.
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
  [![Homebridge 2.0 Ready](https://img.shields.io/badge/Homebridge-2.0%20Ready-brightgreen)](https://github.com/homebridge/homebridge/wiki/Updating-To-Homebridge-v2.0)
8
8
  [![npm](https://img.shields.io/npm/v/homebridge-pax-calima)](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 good to go.
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 — sold across Scandinavia and Iberia (Sweden, Norway, Denmark, Finland, Spain, Portugal)
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
- Once set up, your fan appears as a single accessory with these tiles:
27
+ Each fan appears as a single accessory with:
28
28
 
29
- - 🌡️ **Temperature** — live reading, updates every 10 seconds
30
- - 💡 **Light level** — lux reading, useful for occupancy automations
31
- - 💨 **Fan speed** — shows current RPM as a percentage
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** — a switch that tells the fan not to boost overnight
34
- - 💧 **Humidity** — available but off by default (read the note below before enabling)
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
- > Your accessories will appear in the **Home app on your iPhone**, not in the Homebridge UI accessories tab — that's normal for child bridge plugins.
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 Raspberry Pi 4's built-in adapter works well)
45
+ - A Bluetooth adapter on your Homebridge host (the Pi 4's built-in adapter works well)
62
46
 
63
- **If you're running Homebridge OS on a Raspberry Pi**, there's one extra step after installation. The Bluetooth library needs a small permissions tweak — run this once in the **Homebridge terminal**:
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
- And if things still aren't working after both of those — a full **power cycle** of the Pi (unplug, wait, replug) tends to sort it. A software restart alone sometimes isn't enough to reinitialise the Bluetooth stack cleanly.
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 — that's it.
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 by scanning for Bluetooth devices with the right MAC prefix and registers it in HomeKit.
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
- If you want to give your fan a custom name, or if you've set a non-default PIN in the official app, you can specify those in settings:
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,125 @@ 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
- | `showHumidity` | `false` | Enables the humidity tile — read the note above first |
114
- | `fans[].name` | Auto-generated | Sets a friendly name for a specific fan |
115
- | `fans[].mac` | — | Only needed if you want to assign a name or custom PIN |
116
- | `fans[].pin` | `012345` | The 6-digit PIN — only change if you set a custom one in the official app |
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
- ## Fan speed control — it depends on how your fan is wired
112
+ ## Silent Hours
113
+
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.
119
+
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.
121
+
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:
121
131
 
122
- The Svara and Calima can be installed in two different modes, and which one you have affects what the speed slider does:
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%**
123
134
 
124
- **Intermittent mode** — the fan is off by default and spins up when it detects humidity, light, or a timer trigger. In this mode, the speed slider in Apple Home controls the fan directly.
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).
125
136
 
126
- **Always-on (trickle) mode** — the fan runs continuously at a speed managed by its own onboard controller. In this mode, you can send speed commands via Bluetooth and they are accepted — but the fan resets to its own setpoint within seconds. The slider will show you the current speed accurately, but won't be able to change it.
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
127
140
 
128
- If you're not sure which mode you have, check whether your fan is ever fully off. If it's always running (even quietly), you're in always-on mode.
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."
129
142
 
130
- In either mode, the temperature, light and fan speed automations work perfectly.
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
- Try `hciconfig` in the Homebridge terminal — you should see `UP RUNNING` next to `hci0`. If it shows `RF-KILL`, run `sudo rfkill unblock bluetooth`. If still nothing, unplug the Pi, wait 10 seconds, and plug it back in.
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 sticking / BLE still powered off**
140
- Make sure you're running setcap against the right binary — on Homebridge OS it's `/opt/homebridge/bin/node`, not whatever `which node` returns.
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 / can't authenticate**
143
- The default PIN is `012345`. If you changed it in the official Pax or Vent-Axia app, add your fan to the `fans` config array with the correct PIN.
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 the wiring section above. Everything else still works.
171
+ Your fan is in always-on mode — see above. Everything else works normally.
147
172
 
148
- **Humidity stuck at the same number**
149
- This is expected — see the humidity note above. If you'd prefer not to see it, set `showHumidity: false` (or just don't enable it in the first place).
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 the Home app**
152
- Go to the plugin settings in Homebridge UI, choose Remove Accessory Cache, and restart. They'll re-appear within a few seconds of the plugin starting.
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
- For those who care about the technical details — this plugin meets all Homebridge 2.0 requirements:
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 begins
161
- - All errors are caught and logged — no unhandled exceptions
162
- - No analytics, no tracking, no post-install scripts
163
- - Cached accessories restored cleanly without re-registration
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.4
196
+ - **Fixed:** Silent Hours time fields no longer show a raw regex pattern as validation hint in the Homebridge UI — replaced with plain human-readable placeholder text (e.g. `23:00`) and a description
197
+ - **Improved:** Invalid time format now logs a clear warning message rather than silently disabling the schedule
198
+
199
+ ### 2.4.3
200
+ - **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.
201
+ - **Light sensor** (`showLight`) added as opt-in — confirmed via live testing that the characteristic returns a static value regardless of lighting conditions.
202
+ - **`showSilentSwitch`**, **`showHumidity`** and **`showLight`** grouped under "Optional Sensors" in the Homebridge UI config for a cleaner settings experience.
203
+ - **`displayName`** added to `package.json` — shows correctly as "Vent-Axia Svara / Pax Calima" in the Homebridge UI.
204
+ - Sensor characteristic only read when at least one optional sensor is enabled — slightly leaner poll cycle for most users.
205
+ - Silent Hours written to fan on every poll — stays in sync after power cuts.
206
+
170
207
  ### 2.4.2
171
- - Humidity sensor is now **off by default** — you have to explicitly enable it with `showHumidity: true`. This follows confirmed testing that showed the fan's BLE firmware updates the humidity characteristic infrequently (we measured 50% while a reference sensor read 89% in the same room post-shower). Exposing it by default risked misleading Siri queries and unreliable automations. If you had it enabled previously, it will be removed from your accessory automatically — you can re-enable it knowingly in settings.
172
- - README rewritten to be more human — less spec sheet, more "here's what to expect and why"
173
- - Fan speed documented as the practical shower/occupancy trigger
208
+ - Humidity sensor disabled by default — opt-in via `showHumidity: true`
209
+ - README rewritten with a more human tone
174
210
 
175
211
  ### 2.4.1
176
- - Fixed sensors returning stale values — the device's GATT server only refreshes humidity and light on a new connection, so we now connect → read → disconnect on each poll rather than holding a persistent connection
177
- - Eliminated intermittent `Characteristic not found` errors
212
+ - Fixed sensors returning stale values — reverted to connect→read→disconnect per poll
178
213
 
179
214
  ### 2.4.0
180
- - Rebuilt connection architecture — persistent connection with cached characteristics. Fast and reliable for RPM and temperature; subsequently found that humidity returns stale values on persistent connections (fixed in 2.4.1)
181
-
182
- ### 2.3.x
183
- - Identified the correct control characteristic for fan speed writes
184
- - Documented always-on vs intermittent wiring mode behaviour
215
+ - Rebuilt with persistent connection architecture
185
216
 
186
- ### 2.2.1 / 2.1.0
217
+ ### 2.3.x / 2.2.1 / 2.1.0
187
218
  - Correct sensor UUIDs and byte mappings confirmed via live device testing
188
- - Homebridge 2.0 initialisation fixes
189
- - Duplicate accessory registration on restart resolved
219
+ - Homebridge 2.0 initialisation fixes; duplicate accessory registration resolved
190
220
 
191
221
  ### 1.0.x
192
222
  - Initial releases
@@ -10,15 +10,39 @@
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
+ "placeholder": "23:00",
17
+ "description": "When the fan stops boosting at night. Enter in 24-hour format, e.g. 23:00. Leave blank to disable the schedule."
18
+ },
19
+ "silentHoursEnd": {
20
+ "title": "Silent Hours — End Time",
21
+ "type": "string",
22
+ "placeholder": "07:00",
23
+ "description": "When the fan resumes boosting in the morning. Enter in 24-hour format, e.g. 07:00. Both start and end must be set for the schedule to activate."
24
+ },
25
+ "showSilentSwitch": {
26
+ "title": "Show Silent Hours Switch in Home",
27
+ "type": "boolean",
28
+ "default": false,
29
+ "description": "Adds a Silent Hours on/off switch to your accessory in the Home app. Useful for including in a Goodnight scene or automation. Good for shift workers or irregular schedules."
30
+ },
13
31
  "showHumidity": {
14
32
  "title": "Show Humidity Sensor",
15
33
  "type": "boolean",
16
34
  "default": false,
17
- "description": "⚠️ The fan's humidity characteristic updates infrequently due to a firmware limitation — it reflects the fan's internal settled state, not a real-time reading. It is NOT suitable for use with Siri queries or time-sensitive automations. Only enable this if you understand and accept this limitation. Disabled by default."
35
+ "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."
36
+ },
37
+ "showLight": {
38
+ "title": "Show Light Sensor",
39
+ "type": "boolean",
40
+ "default": false,
41
+ "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
42
  },
19
43
  "fans": {
20
44
  "title": "Manually Configured Fans",
21
- "description": "Optional. Leave empty to use auto-discovery. Add entries here only to set a custom name or PIN per fan.",
45
+ "description": "Optional. Leave empty for auto-discovery. Add entries here only to set a custom name or PIN per fan.",
22
46
  "type": "array",
23
47
  "items": {
24
48
  "type": "object",
@@ -31,8 +55,8 @@
31
55
  "mac": {
32
56
  "title": "MAC Address",
33
57
  "type": "string",
34
- "pattern": "^([0-9A-Fa-f]{2}:){5}([0-9A-Fa-f]{2})$",
35
- "description": "MAC address of the fan (e.g. 58:2b:db:xx:xx:xx)"
58
+ "placeholder": "58:2b:db:xx:xx:xx",
59
+ "description": "MAC address of the fan — found in the official Pax or Vent-Axia app under About Device."
36
60
  },
37
61
  "pin": {
38
62
  "title": "PIN Code",
@@ -49,9 +73,17 @@
49
73
  "layout": [
50
74
  {
51
75
  "type": "section",
52
- "title": "General",
53
- "expandable": false,
54
- "items": ["name", "showHumidity"]
76
+ "title": "Silent Hours",
77
+ "expandable": true,
78
+ "expanded": false,
79
+ "items": ["silentHoursStart", "silentHoursEnd", "showSilentSwitch"]
80
+ },
81
+ {
82
+ "type": "section",
83
+ "title": "Optional Sensors",
84
+ "expandable": true,
85
+ "expanded": false,
86
+ "items": ["showHumidity", "showLight"]
55
87
  },
56
88
  {
57
89
  "type": "section",
package/index.js CHANGED
@@ -5,15 +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 — write once per connection
9
- const UUID_STATUS = '528b80e8-c47a-4c0a-bdf1-916a7748f412'; // RPM (offset 6 UInt16LE) + temp (offset 2 UInt16LE / 6)
10
- const UUID_SENSOR = 'a22eae12-dba8-49f3-9c69-1721dcff1d96'; // humidity (offset 0 UInt8 × 2) + lux (offset 2 UInt16BE)
11
- const UUID_CONTROL = '1488a757-35bc-4ec8-9a6b-9ecf1502778e'; // speed config: [trickle|normal|boost] UInt16LE pairs
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, label) {
19
+ if (!str || typeof str !== 'string') return null;
20
+ const parts = str.split(':');
21
+ if (parts.length !== 2 || parts[0] === '' || parts[1] === '') {
22
+ console.warn('[PaxCalima] ' + (label || 'Silent Hours') + ' time "' + str + '" is not valid — use 24-hour format e.g. 23:00. Silent Hours schedule disabled.');
23
+ return null;
24
+ }
25
+ const hour = parseInt(parts[0], 10);
26
+ const minute = parseInt(parts[1], 10);
27
+ if (isNaN(hour) || isNaN(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
28
+ console.warn('[PaxCalima] ' + (label || 'Silent Hours') + ' time "' + str + '" is out of range — use 24-hour format e.g. 23:00. Silent Hours schedule disabled.');
29
+ return null;
30
+ }
31
+ return { hour, minute };
32
+ }
33
+
34
+ function timeStr(t) {
35
+ return String(t.hour).padStart(2, '0') + ':' + String(t.minute).padStart(2, '0');
36
+ }
37
+
17
38
  module.exports = (homebridge) => {
18
39
  Service = homebridge.hap.Service;
19
40
  Characteristic = homebridge.hap.Characteristic;
@@ -62,7 +83,7 @@ class PaxCalimaPlatform {
62
83
 
63
84
  for (const { accessory, mac } of this.cachedAccessories) {
64
85
  this.log.info('Starting handler for cached fan: ' + mac);
65
- new PaxCalimaAccessory(this.log, accessory, mac, this.pinForMac(mac), this.showHumidity());
86
+ new PaxCalimaAccessory(this.log, accessory, mac, this.fanOptions(mac));
66
87
  }
67
88
  this.cachedAccessories = [];
68
89
 
@@ -73,39 +94,50 @@ class PaxCalimaPlatform {
73
94
  const mac = peripheral.address.toLowerCase();
74
95
  if (!mac.startsWith(MAC_PREFIX)) return;
75
96
  if (this.registeredMACs.has(mac)) return;
76
-
77
97
  this.registeredMACs.add(mac);
78
98
  this.log.info('Discovered new fan: ' + mac);
79
-
80
99
  const uuid = API.hap.uuid.generate('pax-calima-' + mac);
81
100
  const hbAcc = new PlatformAccessory(this.nameForMac(mac), uuid, Categories.FAN);
82
101
  hbAcc.context.mac = mac;
83
-
84
- new PaxCalimaAccessory(this.log, hbAcc, mac, this.pinForMac(mac), this.showHumidity());
102
+ new PaxCalimaAccessory(this.log, hbAcc, mac, this.fanOptions(mac));
85
103
  this.api.registerPlatformAccessories('homebridge-pax-calima', 'PaxCalima', [hbAcc]);
86
104
  });
87
105
  }
88
106
 
89
- fanConfig(mac) { return (this.config.fans || []).find(f => f.mac && f.mac.toLowerCase() === mac) || {}; }
90
- pinForMac(mac) { return this.fanConfig(mac).pin || '012345'; }
91
- nameForMac(mac) { return this.fanConfig(mac).name || ('Pax Calima ' + mac.slice(-5).toUpperCase()); }
92
- showHumidity() { return this.config.showHumidity === true; }
107
+ fanConfig(mac) { return (this.config.fans || []).find(f => f.mac && f.mac.toLowerCase() === mac) || {}; }
108
+ pinForMac(mac) { return this.fanConfig(mac).pin || '012345'; }
109
+ nameForMac(mac) { return this.fanConfig(mac).name || ('Pax Calima ' + mac.slice(-5).toUpperCase()); }
110
+
111
+ fanOptions(mac) {
112
+ return {
113
+ pin: this.pinForMac(mac),
114
+ showHumidity: this.config.showHumidity === true,
115
+ showLight: this.config.showLight === true,
116
+ showSilentSwitch: this.config.showSilentSwitch === true,
117
+ silentHoursStart: this.config.silentHoursStart || null,
118
+ silentHoursEnd: this.config.silentHoursEnd || null,
119
+ };
120
+ }
93
121
  }
94
122
 
95
123
  // ─── Accessory ────────────────────────────────────────────────────────────────
96
124
  class PaxCalimaAccessory {
97
- constructor(log, accessory, mac, pin, showHumidity) {
98
- this.log = log;
99
- this.accessory = accessory;
100
- this.mac = mac.toLowerCase();
101
- this.pin = pin || '012345';
102
- this.showHumidity = showHumidity;
125
+ constructor(log, accessory, mac, options) {
126
+ this.log = log;
127
+ this.accessory = accessory;
128
+ this.mac = mac.toLowerCase();
129
+ this.pin = options.pin || '012345';
130
+ this.showHumidity = options.showHumidity || false;
131
+ this.showLight = options.showLight || false;
132
+ this.showSilentSwitch = options.showSilentSwitch || false;
133
+ this.silentStart = parseTime(options.silentHoursStart, 'silentHoursStart');
134
+ this.silentEnd = parseTime(options.silentHoursEnd, 'silentHoursEnd');
103
135
 
104
136
  this.currentRPM = 0;
105
137
  this.humidity = 0;
106
138
  this.temperature = 20;
107
139
  this.lightLevel = 0.0001;
108
- this.silentHours = false;
140
+ this.silentHours = false; // runtime state of the silent hours switch
109
141
 
110
142
  this.pollTimer = null;
111
143
  this.polling = false;
@@ -114,10 +146,11 @@ class PaxCalimaAccessory {
114
146
  this.schedulePoll(0);
115
147
  }
116
148
 
149
+ // ── Services ────────────────────────────────────────────────────────────────
117
150
  setupServices() {
118
151
  const n = this.accessory.displayName;
119
152
 
120
- // Fan — always active; speed reflects live RPM
153
+ // Fan speed — always present
121
154
  this.fanService = this.accessory.getService(Service.Fanv2)
122
155
  || this.accessory.addService(new Service.Fanv2(n, 'fan'));
123
156
  this.fanService.getCharacteristic(Characteristic.Active)
@@ -127,49 +160,67 @@ class PaxCalimaAccessory {
127
160
  .onGet(() => this.rpmToPercent(this.currentRPM))
128
161
  .onSet((v) => this.writeSpeed(v));
129
162
 
130
- // Boost — stateless single-press button
163
+ // Boost button — always present
131
164
  this.boostButton = this.accessory.getService(Service.StatelessProgrammableSwitch)
132
165
  || this.accessory.addService(new Service.StatelessProgrammableSwitch(n + ' Boost', 'boost'));
133
166
  this.boostButton.getCharacteristic(Characteristic.ProgrammableSwitchEvent)
134
167
  .setProps({ validValues: [0] });
135
168
 
136
- // Silent Hours
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
169
+ // Temperature — always present
144
170
  this.tempService = this.accessory.getService(Service.TemperatureSensor)
145
171
  || this.accessory.addService(new Service.TemperatureSensor(n + ' Temperature', 'temperature'));
146
172
  this.tempService.getCharacteristic(Characteristic.CurrentTemperature)
147
173
  .onGet(() => this.temperature);
148
174
 
149
- // Light
150
- this.lightService = this.accessory.getService(Service.LightSensor)
151
- || this.accessory.addService(new Service.LightSensor(n + ' Light', 'light'));
152
- this.lightService.getCharacteristic(Characteristic.CurrentAmbientLightLevel)
153
- .onGet(() => Math.max(0.0001, this.lightLevel));
175
+ // Silent Hours switch — opt-in via showSilentSwitch: true
176
+ // Coexists with config schedule — switch state takes priority on next poll
177
+ if (this.showSilentSwitch) {
178
+ this.silentService = this.accessory.getServiceByUUIDAndSubType(Service.Switch, 'silent-hours')
179
+ || this.accessory.addService(new Service.Switch(n + ' Silent Hours', 'silent-hours'));
180
+ this.silentService.getCharacteristic(Characteristic.On)
181
+ .onGet(() => this.silentHours)
182
+ .onSet((on) => {
183
+ this.silentHours = on;
184
+ this.log.info('Silent Hours manually ' + (on ? 'enabled' : 'disabled'));
185
+ });
186
+ this.log.info('Silent Hours switch enabled');
187
+ } else {
188
+ // Remove switch if it was registered in a previous version
189
+ const existing = this.accessory.getServiceByUUIDAndSubType(Service.Switch, 'silent-hours');
190
+ if (existing) {
191
+ this.accessory.removeService(existing);
192
+ this.log.info('Silent Hours switch removed — use config schedule or enable showSilentSwitch');
193
+ }
194
+ }
154
195
 
155
- // Humidity — optional, disabled by default due to firmware update frequency limitations
156
- // User must explicitly enable via showHumidity: true in config
196
+ // Humidity — opt-in only
157
197
  if (this.showHumidity) {
158
198
  this.humidityService = this.accessory.getService(Service.HumiditySensor)
159
199
  || this.accessory.addService(new Service.HumiditySensor(n + ' Humidity', 'humidity'));
160
200
  this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity)
161
201
  .onGet(() => this.humidity);
162
- this.log.info('Humidity sensor enabled — note: reflects fan internal settled state, not real-time');
202
+ this.log.info('Humidity sensor enabled — reflects fan internal state, not real-time');
163
203
  } else {
164
- // Remove humidity service if it was previously registered and user has since disabled it
165
204
  const existing = this.accessory.getService(Service.HumiditySensor);
166
205
  if (existing) this.accessory.removeService(existing);
167
- this.log.info('Humidity sensor disabled (showHumidity not set)');
206
+ }
207
+
208
+ // Light — opt-in only
209
+ if (this.showLight) {
210
+ this.lightService = this.accessory.getService(Service.LightSensor)
211
+ || this.accessory.addService(new Service.LightSensor(n + ' Light', 'light'));
212
+ this.lightService.getCharacteristic(Characteristic.CurrentAmbientLightLevel)
213
+ .onGet(() => Math.max(0.0001, this.lightLevel));
214
+ this.log.info('Light sensor enabled — reflects fan internal state, not real-time');
215
+ } else {
216
+ const existing = this.accessory.getService(Service.LightSensor);
217
+ if (existing) this.accessory.removeService(existing);
168
218
  }
169
219
 
170
220
  this.log.info('Services ready: ' + n);
171
221
  }
172
222
 
223
+ // ── Poll cycle ──────────────────────────────────────────────────────────────
173
224
  schedulePoll(delayMs) {
174
225
  if (this.pollTimer) clearTimeout(this.pollTimer);
175
226
  this.pollTimer = setTimeout(() => this.poll(), delayMs);
@@ -184,7 +235,6 @@ class PaxCalimaAccessory {
184
235
  peripheral = await this.findPeripheral();
185
236
  await peripheral.connectAsync();
186
237
 
187
- // Discover and cache all characteristics for this connection
188
238
  const charCache = {};
189
239
  const services = await peripheral.discoverServicesAsync();
190
240
  for (const svc of services) {
@@ -199,43 +249,69 @@ class PaxCalimaAccessory {
199
249
  pinBuf.writeUInt32LE(parseInt(this.pin, 10), 0);
200
250
  await authChar.writeAsync(pinBuf, true);
201
251
 
202
- // Read status: RPM + temperature — updates live on every connection
252
+ // Read RPM + temperature
203
253
  const statusChar = charCache[UUID_STATUS.replace(/-/g, '')];
204
254
  if (!statusChar) throw new Error('Status characteristic not found');
205
255
  const statusData = await statusChar.readAsync();
206
256
  this.currentRPM = Math.max(0, statusData.readUInt16LE(6));
207
257
  this.temperature = Math.round((statusData.readUInt16LE(2) / 6) * 10) / 10;
208
258
 
209
- // Read sensor characteristic only if humidity is enabled
210
- // The device updates this characteristic infrequently (firmware limitation)
211
- if (this.showHumidity) {
259
+ // Read sensor characteristic only if humidity or light is enabled
260
+ if (this.showHumidity || this.showLight) {
212
261
  const sensorChar = charCache[UUID_SENSOR.replace(/-/g, '')];
213
262
  if (sensorChar) {
214
263
  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));
264
+ if (this.showHumidity) this.humidity = Math.max(0, Math.min(100, sensorData.readUInt8(0) * 2));
265
+ if (this.showLight) this.lightLevel = Math.max(0.0001, sensorData.readUInt16BE(2));
217
266
  }
218
- } else {
219
- // Read lux from sensor characteristic regardless — only skip humidity update
220
- const sensorChar = charCache[UUID_SENSOR.replace(/-/g, '')];
221
- if (sensorChar) {
222
- const sensorData = await sensorChar.readAsync();
223
- this.lightLevel = Math.max(0.0001, sensorData.readUInt16BE(2));
267
+ }
268
+
269
+ // Determine silent hours state:
270
+ // If switch is shown, the user's manual toggle takes priority.
271
+ // If no switch, derive from config schedule based on current time.
272
+ let silentActive = this.silentHours; // default: use switch state
273
+ if (!this.showSilentSwitch && this.silentStart && this.silentEnd) {
274
+ silentActive = this.isWithinSilentHours();
275
+ }
276
+
277
+ // Write silent hours to fan on every poll
278
+ const silentChar = charCache[UUID_SILENT.replace(/-/g, '')];
279
+ if (silentChar && (this.showSilentSwitch || (this.silentStart && this.silentEnd))) {
280
+ const buf = Buffer.alloc(5);
281
+ if (this.showSilentSwitch) {
282
+ // Switch mode: enabled flag driven by switch; use config times if set, else 00:00–00:00
283
+ buf.writeUInt8(this.silentHours ? 1 : 0, 0);
284
+ buf.writeUInt8(this.silentStart ? this.silentStart.hour : 0, 1);
285
+ buf.writeUInt8(this.silentStart ? this.silentStart.minute : 0, 2);
286
+ buf.writeUInt8(this.silentEnd ? this.silentEnd.hour : 0, 3);
287
+ buf.writeUInt8(this.silentEnd ? this.silentEnd.minute : 0, 4);
288
+ } else {
289
+ // Schedule mode: always enabled, times from config
290
+ buf.writeUInt8(1, 0);
291
+ buf.writeUInt8(this.silentStart.hour, 1);
292
+ buf.writeUInt8(this.silentStart.minute, 2);
293
+ buf.writeUInt8(this.silentEnd.hour, 3);
294
+ buf.writeUInt8(this.silentEnd.minute, 4);
224
295
  }
296
+ await silentChar.writeAsync(buf, true);
225
297
  }
226
298
 
227
299
  // Push to HomeKit
228
300
  const speed = this.rpmToPercent(this.currentRPM);
229
301
  this.fanService.getCharacteristic(Characteristic.RotationSpeed).updateValue(speed);
230
302
  this.tempService.getCharacteristic(Characteristic.CurrentTemperature).updateValue(this.temperature);
231
- this.lightService.getCharacteristic(Characteristic.CurrentAmbientLightLevel).updateValue(this.lightLevel);
232
- if (this.showHumidity && this.humidityService) {
303
+ if (this.showSilentSwitch && this.silentService)
304
+ this.silentService.getCharacteristic(Characteristic.On).updateValue(this.silentHours);
305
+ if (this.showHumidity && this.humidityService)
233
306
  this.humidityService.getCharacteristic(Characteristic.CurrentRelativeHumidity).updateValue(this.humidity);
234
- }
307
+ if (this.showLight && this.lightService)
308
+ this.lightService.getCharacteristic(Characteristic.CurrentAmbientLightLevel).updateValue(this.lightLevel);
235
309
 
236
310
  this.log.info('Poll: RPM=' + this.currentRPM + ' Speed=' + speed +
237
- '% Temp=' + this.temperature + 'C Light=' + this.lightLevel + 'lx' +
238
- (this.showHumidity ? ' Hum=' + this.humidity + '%' : ''));
311
+ '% Temp=' + this.temperature + 'C' +
312
+ (this.showSilentSwitch || (this.silentStart && this.silentEnd) ? ' Silent=' + (silentActive ? 'on' : 'off') : '') +
313
+ (this.showHumidity ? ' Hum=' + this.humidity + '%' : '') +
314
+ (this.showLight ? ' Light=' + this.lightLevel + 'lx' : ''));
239
315
 
240
316
  } catch (err) {
241
317
  this.log.warn('Poll error: ' + err.message);
@@ -246,6 +322,18 @@ class PaxCalimaAccessory {
246
322
  }
247
323
  }
248
324
 
325
+ // Returns true if current time falls within the silent hours window
326
+ isWithinSilentHours() {
327
+ if (!this.silentStart || !this.silentEnd) return false;
328
+ const now = new Date();
329
+ const current = now.getHours() * 60 + now.getMinutes();
330
+ const start = this.silentStart.hour * 60 + this.silentStart.minute;
331
+ const end = this.silentEnd.hour * 60 + this.silentEnd.minute;
332
+ // Handle overnight spans e.g. 23:00 → 07:00
333
+ if (start > end) return current >= start || current < end;
334
+ return current >= start && current < end;
335
+ }
336
+
249
337
  findPeripheral() {
250
338
  return new Promise((resolve, reject) => {
251
339
  const timeout = setTimeout(() => {
@@ -264,6 +352,7 @@ class PaxCalimaAccessory {
264
352
  });
265
353
  }
266
354
 
355
+ // ── Fan speed write ─────────────────────────────────────────────────────────
267
356
  async writeSpeed(percent) {
268
357
  if (this.polling) { this.log.warn('Speed write skipped — poll in progress'); return; }
269
358
  let peripheral = null;
@@ -296,11 +385,6 @@ class PaxCalimaAccessory {
296
385
  }
297
386
  }
298
387
 
299
- async setSilentHours(on) {
300
- this.silentHours = on;
301
- this.log.info('Silent Hours ' + (on ? 'enabled' : 'disabled'));
302
- }
303
-
304
388
  rpmToPercent(rpm) {
305
389
  return Math.min(100, Math.max(0, Math.round(rpm / 25)));
306
390
  }
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "homebridge-pax-calima",
3
- "version": "2.4.2",
4
- "description": "Homebridge platform plugin for Pax Calima and Vent-Axia Svara Bluetooth LE bathroom extractor fans",
3
+ "displayName": "Vent-Axia Svara / Pax Calima",
4
+ "version": "2.4.4",
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",