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 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,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
- | `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
121
113
 
122
- The Svara and Calima can be installed in two different modes, and which one you have affects what the speed slider does:
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
- **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.
116
+ **Two ways to use it — pick one or combine both:**
125
117
 
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.
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
- 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.
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
- In either mode, the temperature, light and fan speed automations work perfectly.
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
- 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.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 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
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 — 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
208
+ - Fixed sensors returning stale values — reverted to connect→read→disconnect per poll
178
209
 
179
210
  ### 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
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
@@ -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 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."
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 to use auto-discovery. Add entries here only to set a custom name or PIN per fan.",
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": "General",
53
- "expandable": false,
54
- "items": ["name", "showHumidity"]
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 — 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) {
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.pinForMac(mac), this.showHumidity());
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) { 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; }
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, pin, showHumidity) {
98
- this.log = log;
99
- this.accessory = accessory;
100
- this.mac = mac.toLowerCase();
101
- this.pin = pin || '012345';
102
- this.showHumidity = 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 active; speed reflects live RPM
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 — stateless single-press button
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
- // 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
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
- // 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));
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 — optional, disabled by default due to firmware update frequency limitations
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 — note: reflects fan internal settled state, not real-time');
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
- this.log.info('Humidity sensor disabled (showHumidity not set)');
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 status: RPM + temperature — updates live on every connection
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
- // The device updates this characteristic infrequently (firmware limitation)
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
- } 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));
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.lightService.getCharacteristic(Characteristic.CurrentAmbientLightLevel).updateValue(this.lightLevel);
232
- if (this.showHumidity && this.humidityService) {
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 Light=' + this.lightLevel + 'lx' +
238
- (this.showHumidity ? ' Hum=' + this.humidity + '%' : ''));
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
- "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.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",