homebridge-pax-calima 2.5.2 → 2.6.0-beta.2
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/CHANGELOG.md +16 -0
- package/README.md +32 -2
- package/RELEASE_NOTES.md +26 -2
- package/config.schema.json +53 -4
- package/docs/CO2-CLEAR-BETA.md +82 -0
- package/docs/VERIFICATION.md +6 -0
- package/lib/accessory.js +59 -10
- package/lib/co2-clear.js +157 -0
- package/lib/platform.js +20 -2
- package/lib/transport.js +63 -30
- package/package.json +8 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.6.0-beta.2
|
|
4
|
+
|
|
5
|
+
- Made CO₂ Clear setup easier to follow, with a checklist for the two automations and help replacing an existing CO₂-to-Boost rule.
|
|
6
|
+
- Clarified that ending CO₂ Clear hands back to the fan’s own ventilation, including its humidity response.
|
|
7
|
+
- Brought speed and maximum running time together in Plugin Config, with the shorter fan timer under advanced settings.
|
|
8
|
+
- Updated the included release notes to match SvaraHome’s everyday voice.
|
|
9
|
+
- Kept existing controls, settings and beta.1 automations in place.
|
|
10
|
+
|
|
11
|
+
## 2.6.0-beta.1
|
|
12
|
+
|
|
13
|
+
- Added **CO₂ Clear**: extra ventilation when your sensor reports high CO₂, with a return to normal when it reports an acceptable level.
|
|
14
|
+
- Kept you in control: using Fan, Boost or Pause takes priority over CO₂ Clear.
|
|
15
|
+
- Added a choice of speed and maximum running time, with a one-hour default if the sensor's all-clear never arrives.
|
|
16
|
+
- Added a setup guide for the two Home automations, recommending Controller for HomeKit for Netatmo's all-clear rule.
|
|
17
|
+
- Added more detail to support logs to help investigate interrupted fan communication.
|
|
18
|
+
|
|
3
19
|
## 2.5.2
|
|
4
20
|
|
|
5
21
|
- Now verified by Homebridge.
|
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ SvaraHome brings local Bluetooth control for Pax Calima and Vent-Axia Svara fans
|
|
|
10
10
|
|
|
11
11
|
[Website and support](https://homebridge.svarahome.kernowekconsulting.co.uk) · [Plugin on npm](https://www.npmjs.com/package/homebridge-pax-calima) · [Release notes](RELEASE_NOTES.md)
|
|
12
12
|
|
|
13
|
-
**SvaraHome 2.
|
|
13
|
+
**Trial build: SvaraHome 2.6.0-beta.2.** Adds optional **CO₂ Clear**, alongside the existing timed Boost and Pause controls. Stable users should stay on `latest` (2.5.2); this beta is for a supervised trial. See the [CO₂ Clear beta guide](docs/CO2-CLEAR-BETA.md).
|
|
14
14
|
|
|
15
15
|
## What you get in Apple Home
|
|
16
16
|
|
|
@@ -18,6 +18,7 @@ SvaraHome brings local Bluetooth control for Pax Calima and Vent-Axia Svara fans
|
|
|
18
18
|
| --- | --- |
|
|
19
19
|
| **Fan** | Shows measured speed as a percentage. Off starts a timed pause; On returns either Pause or Boost to automatic ventilation. |
|
|
20
20
|
| **Boost Fan** | Runs the fan at your chosen speed for a limited time. Turn it off to return sooner. Shown by default. |
|
|
21
|
+
| **CO₂ Clear** | Extra ventilation between your sensor’s high and acceptable CO₂ events. Shown by default and initially Off. Set up both automations below. |
|
|
21
22
|
| **Pause Fan** | Optional separate pause switch for status and automations. Hidden by default unless explicitly enabled. |
|
|
22
23
|
| **Silent Hours** | Optional switch for the fan’s saved quiet-hours schedule. It does not start an immediate pause. |
|
|
23
24
|
|
|
@@ -29,6 +30,31 @@ The displayed speed comes from measured RPM. It can differ from the requested pe
|
|
|
29
30
|
|
|
30
31
|
Temperature, humidity and light are **not exposed as ambient sensors**. SvaraHome does not provide a shower-detection sensor or infer room conditions from fan speed.
|
|
31
32
|
|
|
33
|
+
## CO₂ Clear — give the room the time it needs
|
|
34
|
+
|
|
35
|
+
CO₂ Clear links your CO₂ sensor and fan through two Home automations. It starts extra ventilation when CO₂ rises and hands back to the fan’s own automatic ventilation when the sensor gives the all-clear.
|
|
36
|
+
|
|
37
|
+
**The fan can keep running for humidity after CO₂ Clear turns Off.** Off ends only CO₂ Clear’s extra ventilation. It does not pause the fan or disable its own humidity response. Using Fan, Boost or Pause yourself takes priority over CO₂ Clear.
|
|
38
|
+
|
|
39
|
+
### Set up both automations
|
|
40
|
+
|
|
41
|
+
1. Check that **CO₂ Clear** appears within the fan in Apple Home or Controller. If Controller still shows only Boost and Pause after an update, close and reopen Controller to refresh its list. Do not remove the fan or reset pairing.
|
|
42
|
+
2. Create **CO₂ high → CO₂ Clear On**.
|
|
43
|
+
3. Create **CO₂ acceptable → CO₂ Clear Off**. Select CO₂ Clear, not Fan Off or Pause.
|
|
44
|
+
4. Disable the old CO₂-to-Boost automation once the new pair is ready, so it cannot interrupt CO₂ Clear. Keep a record of it if you want to return to timed Boost.
|
|
45
|
+
|
|
46
|
+
For Netatmo’s all-clear rule we recommend **Controller for HomeKit**, an additional app that may require its own licence. Apple Home’s editor offers Netatmo’s high-CO₂ trigger, but not the matching all-clear trigger. Use Controller’s regular HomeKit automations; its separate Workflows or Controller Hub are not needed. Choose **Carbon dioxide Detected: Yes** for high and **No** for acceptable. Netatmo’s built-in high level is above 1600 ppm. Use that flag, not its combined Air Quality reading.
|
|
47
|
+
|
|
48
|
+
Start with one sensor and one fan. The switch waits for your automations; SvaraHome does not read the sensor directly. Check both saved actions and then observe a real rise and return to acceptable CO₂. Creating the rules is the start of the trial, not proof that both sensor events arrive.
|
|
49
|
+
|
|
50
|
+
### Choose what suits your home
|
|
51
|
+
|
|
52
|
+
In Plugin Config, choose the speed and maximum time for extra ventilation. The defaults are 2400 RPM (96% on the Home speed scale) and one hour, respecting a lower limit set for an individual fan. The shorter fan timer sits under **CO₂ Clear — advanced timer** and normally stays at 15 minutes.
|
|
53
|
+
|
|
54
|
+
If the all-clear never arrives, CO₂ Clear ends at the maximum time and returns to the fan’s automatic ventilation. That limit does not mean the room’s CO₂ has cleared. Sending On again during a session does not extend it. After the limit or a Homebridge restart, it waits for a new On request. An existing fan timer can continue briefly after a restart before returning to automatic ventilation.
|
|
55
|
+
|
|
56
|
+
See the [CO₂ Clear setup and trial guide](https://github.com/KernowekConsulting/homebridge-pax-calima/blob/v2.6.0-beta.2/docs/CO2-CLEAR-BETA.md) for the full walkthrough and ways to return to your previous setup.
|
|
57
|
+
|
|
32
58
|
## Use your own words
|
|
33
59
|
|
|
34
60
|
Assign each fan to its room in Apple Home. In a room named Kitchen, try:
|
|
@@ -85,6 +111,10 @@ The address and PIN are examples. Use your own fan’s details. A Bluetooth addr
|
|
|
85
111
|
| `boostMinutes` / `boostRpm` | Temporary Boost duration and requested speed. Defaults: 10 minutes and 2000 RPM. |
|
|
86
112
|
| `pauseMinutes` | Timed pause duration, including when the main Fan is turned off. Default: 10 minutes. |
|
|
87
113
|
| `showBoostSwitch` | Show Boost Fan. Default: `true`. |
|
|
114
|
+
| `co2ClearEnabled` | Show the separate CO₂ Clear demand control. Default: `true`; always initially Off. |
|
|
115
|
+
| `co2ClearRpm` | Requested CO₂ Clear speed; default 2400 RPM, range 1000–2400 in 25 RPM steps, also limited by the fan ceiling. |
|
|
116
|
+
| `co2ClearLeaseMinutes` | Finite fan timer renewed while demand is active; default 15, range 1–15 minutes. |
|
|
117
|
+
| `co2ClearMaxMinutes` | Maximum demand session; default 60, range 1–240 minutes. Repeated On cannot extend it. |
|
|
88
118
|
| `showPauseSwitch` | Show the separate Pause Fan switch. Default: `false`; an explicitly enabled setting stays enabled. |
|
|
89
119
|
| `fans[].maxManualRpm` | Optional per-fan limit for Boost and slider requests. Leave blank for a conservative 2400 RPM target limit. Only raise it to a speed verified to work on your fan. This does not cap displayed measurement or alter permanent presets. |
|
|
90
120
|
| `silentHoursStart` / `silentHoursEnd` | Optional saved quiet-hours schedule, in 24-hour format. Set both times, or leave both blank to retain the fan’s own schedule. |
|
|
@@ -118,6 +148,6 @@ An explicitly enabled Pause switch remains enabled after upgrading. Turn off **S
|
|
|
118
148
|
|
|
119
149
|
## About the project
|
|
120
150
|
|
|
121
|
-
SvaraHome is independent of Apple, Homebridge, Pax and Vent-Axia. **Homebridge verification
|
|
151
|
+
SvaraHome is independent of Apple, Homebridge, Pax and Vent-Axia. **Homebridge verification was granted on 28 October 2026.** Homebridge controls the verification badge and central plugin icon; bundling the SvaraHome artwork does not grant either.
|
|
122
152
|
|
|
123
153
|
For help, start with the [SvaraHome website](https://homebridge.svarahome.kernowekconsulting.co.uk) or [GitHub issues](https://github.com/KernowekConsulting/homebridge-pax-calima/issues). [Validation evidence](docs/VALIDATION.md) and [verification preparation](docs/VERIFICATION.md) are available separately.
|
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,3 +1,27 @@
|
|
|
1
|
-
# SvaraHome 2.
|
|
1
|
+
# SvaraHome 2.6.0-beta.2 — CO₂ Clear, clearer from the start
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Extra ventilation when CO₂ rises. A little more help getting it ready.
|
|
4
|
+
|
|
5
|
+
CO₂ Clear brings your sensor and fan together in Apple Home, giving the room more time for extra ventilation. This beta makes the setup easier to follow and the choices in Plugin Config clearer.
|
|
6
|
+
|
|
7
|
+
## Two automations, one simple idea
|
|
8
|
+
|
|
9
|
+
**CO₂ high → CO₂ Clear On.**
|
|
10
|
+
|
|
11
|
+
**CO₂ acceptable → CO₂ Clear Off.**
|
|
12
|
+
|
|
13
|
+
The guide now walks through both rules, finding the new control and replacing an existing CO₂-to-Boost automation. For Netatmo’s all-clear rule, we recommend Controller for HomeKit, an additional app.
|
|
14
|
+
|
|
15
|
+
If you already set up both rules with beta.1, keep them. There is no need to create them again.
|
|
16
|
+
|
|
17
|
+
## Humidity still has its say
|
|
18
|
+
|
|
19
|
+
When CO₂ Clear ends, the fan returns to its own automatic ventilation. If the room is still humid, the fan can keep running as usual. The all-clear action ends CO₂ Clear’s extra ventilation; it does not pause the fan.
|
|
20
|
+
|
|
21
|
+
## Choices that are easier to find
|
|
22
|
+
|
|
23
|
+
Speed and maximum running time now sit together in Plugin Config. The shorter fan timer is tucked under advanced settings, where it can usually stay as it is.
|
|
24
|
+
|
|
25
|
+
The defaults remain 96% and a one-hour maximum, within any lower limit you have chosen for your fan. Your ordinary Fan, Boost and Pause controls still take priority.
|
|
26
|
+
|
|
27
|
+
Choose **2.6.0-beta.2** in Homebridge to try it. The [CO₂ Clear setup guide](https://github.com/KernowekConsulting/homebridge-pax-calima/blob/v2.6.0-beta.2/docs/CO2-CLEAR-BETA.md) has the walkthrough. We are continuing the everyday trial before a wider release; **2.5.2** remains the regular release.
|
package/config.schema.json
CHANGED
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"title": "Fan PIN",
|
|
33
33
|
"type": "string",
|
|
34
34
|
"pattern": "^(?:[0-9]{6}|[0-9]{8})$",
|
|
35
|
-
"description": "Enter this fan
|
|
35
|
+
"description": "Enter this fan’s six- or eight-digit PIN, including any leading zeroes. The PIN is stored in your Homebridge configuration.",
|
|
36
36
|
"secret": true
|
|
37
37
|
},
|
|
38
38
|
"maxManualRpm": {
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"maximum": 2500,
|
|
43
43
|
"multipleOf": 25,
|
|
44
44
|
"placeholder": "2400",
|
|
45
|
-
"description": "Optional. Limits Boost and speed-slider requests for this fan. Leave blank for a conservative 2400 RPM limit. Some fans return Pause when they reject a higher target. Only raise this limit to a speed verified to work on your fan. This does not change its permanent presets or measured-speed display."
|
|
45
|
+
"description": "Optional. Limits Boost, CO₂ Clear and speed-slider requests for this fan. Leave blank for a conservative 2400 RPM limit. Some fans return Pause when they reject a higher target. Only raise this limit to a speed verified to work on your fan. This does not change its permanent presets or measured-speed display."
|
|
46
46
|
}
|
|
47
47
|
},
|
|
48
48
|
"required": [
|
|
@@ -97,13 +97,44 @@
|
|
|
97
97
|
"title": "Quiet hours end",
|
|
98
98
|
"type": "string",
|
|
99
99
|
"placeholder": "06:00",
|
|
100
|
-
"description": "Use 24-hour time, such as 06:00. Leave both fields blank to retain the fan
|
|
100
|
+
"description": "Use 24-hour time, such as 06:00. Leave both fields blank to retain the fan’s existing schedule."
|
|
101
101
|
},
|
|
102
102
|
"showSilentSwitch": {
|
|
103
103
|
"title": "Show Silent Hours in Apple Home",
|
|
104
104
|
"type": "boolean",
|
|
105
105
|
"default": false,
|
|
106
|
-
"description": "Enable or disable the fan
|
|
106
|
+
"description": "Enable or disable the fan’s saved quiet-hours schedule. This preserves its times; it does not start an immediate pause."
|
|
107
|
+
},
|
|
108
|
+
"co2ClearEnabled": {
|
|
109
|
+
"title": "Show CO₂ Clear in Apple Home",
|
|
110
|
+
"type": "boolean",
|
|
111
|
+
"default": true,
|
|
112
|
+
"description": "Extra ventilation when CO₂ rises. Set up two automations: CO₂ high → CO₂ Clear On; CO₂ acceptable → CO₂ Clear Off. For Netatmo, use Controller for HomeKit to create the all-clear rule. CO₂ Clear Off returns control to the fan’s own ventilation, including its humidity response. The switch starts Off and waits for an automation or your command."
|
|
113
|
+
},
|
|
114
|
+
"co2ClearRpm": {
|
|
115
|
+
"title": "Speed for extra ventilation (RPM)",
|
|
116
|
+
"type": "integer",
|
|
117
|
+
"minimum": 1000,
|
|
118
|
+
"maximum": 2400,
|
|
119
|
+
"multipleOf": 25,
|
|
120
|
+
"default": 2400,
|
|
121
|
+
"description": "2400 RPM is 96% on the Home speed scale. Any lower limit you have chosen for an individual fan still applies."
|
|
122
|
+
},
|
|
123
|
+
"co2ClearLeaseMinutes": {
|
|
124
|
+
"title": "Fan timer if Homebridge disconnects (minutes)",
|
|
125
|
+
"type": "integer",
|
|
126
|
+
"minimum": 1,
|
|
127
|
+
"maximum": 15,
|
|
128
|
+
"default": 15,
|
|
129
|
+
"description": "Normally leave this at 15 minutes. While CO₂ Clear is active, SvaraHome keeps this short fan timer topped up. If Homebridge stops, the timer expires and the fan returns to its own automatic ventilation. This is separate from the maximum running time above."
|
|
130
|
+
},
|
|
131
|
+
"co2ClearMaxMinutes": {
|
|
132
|
+
"title": "Maximum time for extra ventilation (minutes)",
|
|
133
|
+
"type": "integer",
|
|
134
|
+
"minimum": 1,
|
|
135
|
+
"maximum": 240,
|
|
136
|
+
"default": 60,
|
|
137
|
+
"description": "If the sensor’s all-clear never arrives, CO₂ Clear ends after this time and hands back to the fan’s own automatic ventilation. The default is one hour. Sending On again during a session does not extend it. After it ends or Homebridge restarts, a new On request is needed."
|
|
107
138
|
}
|
|
108
139
|
},
|
|
109
140
|
"required": [
|
|
@@ -138,6 +169,24 @@
|
|
|
138
169
|
"pauseMinutes"
|
|
139
170
|
]
|
|
140
171
|
},
|
|
172
|
+
{
|
|
173
|
+
"type": "section",
|
|
174
|
+
"title": "CO₂ Clear — extra ventilation when CO₂ rises",
|
|
175
|
+
"items": [
|
|
176
|
+
"co2ClearEnabled",
|
|
177
|
+
"co2ClearRpm",
|
|
178
|
+
"co2ClearMaxMinutes"
|
|
179
|
+
]
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
"type": "section",
|
|
183
|
+
"title": "CO₂ Clear — advanced timer",
|
|
184
|
+
"expandable": true,
|
|
185
|
+
"expanded": false,
|
|
186
|
+
"items": [
|
|
187
|
+
"co2ClearLeaseMinutes"
|
|
188
|
+
]
|
|
189
|
+
},
|
|
141
190
|
{
|
|
142
191
|
"type": "section",
|
|
143
192
|
"title": "Quiet hours",
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# CO₂ Clear — 2.6.0-beta.2 trial
|
|
2
|
+
|
|
3
|
+
CO₂ Clear is a separate third control alongside timed Boost Fan and Pause Fan. It maintains a bounded ventilation request between High and Clear events. The existing fan accessory identities, room assignments and ordinary controls are retained.
|
|
4
|
+
|
|
5
|
+
## Before you start
|
|
6
|
+
|
|
7
|
+
CO₂ Clear needs a pair of Home automations: **CO₂ high → CO₂ Clear On**, and **CO₂ acceptable → CO₂ Clear Off**. Check both rules before relying on it.
|
|
8
|
+
|
|
9
|
+
**CO₂ Clear Off ends extra ventilation, not ventilation needed for humidity.** The fan returns to its own automatic control and may keep running. Never use main Fan Off or Pause as the all-clear action: those request a timed pause. CO₂ Clear has no humidity measurement and does not change the fan’s humidity settings.
|
|
10
|
+
|
|
11
|
+
If the new control is missing in Controller after an upgrade, close and reopen Controller first. That refreshed the accessory list during this trial without changing the fan, pairing or Homebridge. A stale third-party list is not a reason to reset accessories.
|
|
12
|
+
|
|
13
|
+
In Plugin Config, the main CO₂ Clear section holds visibility, speed and maximum running time. The shorter disconnect timer is under the collapsed advanced-timer section. Saved settings and existing Home automations are retained when upgrading from beta.1; do not create duplicate rules if the correct pair already exists.
|
|
14
|
+
|
|
15
|
+
## Required automation editor: decide before using this feature
|
|
16
|
+
|
|
17
|
+
**Apple Home alone does not expose the Netatmo CO₂-clear automation event. For this beta we explicitly recommend Controller for HomeKit to create that rule.** Controller is an additional app; check its current licensing and Home access requirements. This is an optional feature dependency, not an assumption that customers already have it. Ordinary timed Boost and Pause need neither Controller nor a CO₂ sensor.
|
|
18
|
+
|
|
19
|
+
Use the regular **HomeKit Automations** editor, not Controller Workflows. These are HomeKit rules for the existing Apple Home hub; this design does not require a separate Controller Hub or SvaraHome cloud account. Other editors are not validated by this beta.
|
|
20
|
+
|
|
21
|
+
On 30 October, read-only inspection of the owner's Netatmo Kitchen Climate showed:
|
|
22
|
+
|
|
23
|
+
- Apple Home's CO₂ event editor offers only **Detects High Carbon Dioxide**.
|
|
24
|
+
- Controller's editor exposes **Carbon dioxide Detected = No** as a **Value is** start event. At inspection the sensor reported No and 916 ppm.
|
|
25
|
+
- Both inspections were cancelled without saving rules or sending fan commands.
|
|
26
|
+
|
|
27
|
+
This establishes an editable clear rule and a readable normal state, not delivery of a real high-to-normal notification. That transition must be demonstrated during the live trial. Netatmo's [official HomeKit guide](https://helpcenter.netatmo.com/hc/en-us/articles/360010067180-What-can-you-do-with-a-Station-paired-with-Apple-HomeKit) documents detected/high above **1600 ppm**. The owner chose to retain this built-in flag, with no custom 1500 ppm threshold. A warning notification and a characteristic state-change event are different things; no separate normal notification banner is required for an automation.
|
|
28
|
+
|
|
29
|
+
## Rules to configure for the trial
|
|
30
|
+
|
|
31
|
+
First install the beta and confirm both ordinary controls remain functional. Check the fan and room, verify the High scene contains only CO₂ Clear On and the Clear scene only CO₂ Clear Off, and confirm both rules are enabled without unintended time or presence restrictions. Do not run an existing CO₂-to-Boost rule alongside the new CO₂ Clear rule: the newer ordinary Boost command would intentionally take priority and cancel CO₂ Clear renewal. Record the old rule so it can be restored for rollback.
|
|
32
|
+
|
|
33
|
+
For **one CO₂ sensor and one fan**:
|
|
34
|
+
|
|
35
|
+
1. **High:** Kitchen Carbon Dioxide Sensor detects High → Kitchen CO₂ Clear On. Apple Home's existing High trigger is suitable. Leave Home's delayed **Turn Off** timer disabled for this CO₂ Clear rule; the Clear rule and plugin session limit handle termination.
|
|
36
|
+
2. **Clear:** in Controller → Automations → Add Start Event → Accessory → Kitchen Climate → Carbon Dioxide Sensor → **Carbon dioxide Detected** → **Value is No**. Action: Kitchen **CO₂ Clear Off**. Select the CO₂ Clear switch, not Fan Off (which would pause ventilation). Create/save the rule only as part of the agreed trial setup.
|
|
37
|
+
3. Review both rules, the selected Home, fan and sensor, and any time/presence restrictions. A sensor that is already High at setup will not necessarily produce a new High transition; a deliberate manual CO₂ Clear trial is separate from a real sensor-trigger test.
|
|
38
|
+
|
|
39
|
+
Use the Detected flag, not **Air Quality**. Netatmo's Air Quality combines CO₂, temperature and humidity and is not an equivalent CO₂-clear measurement. The MVP does not aggregate multiple sensors: several independent Clear rules could clear another sensor's active demand. Multi-sensor demand arbitration is deferred.
|
|
40
|
+
|
|
41
|
+
## Settings and behaviour
|
|
42
|
+
|
|
43
|
+
| Setting | Default | Allowed values |
|
|
44
|
+
|---|---:|---|
|
|
45
|
+
| co2ClearEnabled | true | true/false; shows the third switch, initially Off |
|
|
46
|
+
| co2ClearRpm | 2400 | 1000–2400 RPM, steps of 25; also capped by each fan's maxManualRpm |
|
|
47
|
+
| co2ClearLeaseMinutes | 15 | 1–15 minutes |
|
|
48
|
+
| co2ClearMaxMinutes | 60 | 1–240 minutes |
|
|
49
|
+
|
|
50
|
+
2400 RPM is 96% on the plugin's 2500 RPM display scale. Measured motor speed remains a separate reading. CO₂ Clear starts a finite fan timer, confirms its readback, and renews halfway through the reported remaining time. The last timer is shortened to the remaining maximum-session budget. Repeated On does not restart or extend that maximum session. Boost's ordinary duration/speed configuration remains separate.
|
|
51
|
+
|
|
52
|
+
CO₂ Clear On means the demand session is armed; it is not a CO₂ reading or a claim that every Bluetooth operation succeeded. Failed commands retain existing communication-error/retry behaviour. Receipt, pending/retry, confirmed timer, renewal, cancellation and cap events are logged. A Clear/Off stops renewal immediately and requests release of its owned override. The existing Boost/Pause switches and measured speed show actual verified fan state; demand Off alone is not proof that the release command has reached the fan.
|
|
53
|
+
|
|
54
|
+
Manual Fan On/Off, Boost, Pause or speed-slider commands cancel CO₂ Clear renewal and take priority. A later CO₂ Clear Off does not cancel those replacement requests. Recognisable native-app changes (Pause, a different boost speed, or early return to automatic) also end demand. The BLE protocol has no ownership tag: an out-of-band app command with the identical speed/timer cannot always be distinguished. Avoid simultaneous control from the native app during the beta test.
|
|
55
|
+
|
|
56
|
+
## Missing sensor, restart and maximum session
|
|
57
|
+
|
|
58
|
+
The plugin **does not poll Apple Home or the sensor** and cannot determine freshness from a latched High. If the sensor/network/Home hub fails before Clear, CO₂ Clear keeps renewing only within its configured maximum session. At that limit it turns demand Off and requests automatic ventilation, even if CO₂ has not cleared. It does not report that the air is acceptable. A new explicit On is required to start another session; old High is not replayed automatically.
|
|
59
|
+
|
|
60
|
+
If Homebridge shuts down, renewals stop; the last finite fan timer expires naturally within the configured lease. Restart never restores a previous High or an air-renewal request. A pending ordinary Boost/Pause request retains its existing restart behaviour. A pending CO₂ Clear release is not retried indefinitely: its cleanup window is bounded by a lease, after which the native timer is the fallback. A command already dispatched over BLE can complete after a newer demand arrives; the queued latest request reconciles that state.
|
|
61
|
+
|
|
62
|
+
Switching CO₂ Clear off in configuration removes its control and stops future renewals after the child restart. An existing fan timer can run to expiry. Do not hide the control before reviewing the Home rules that reference it.
|
|
63
|
+
|
|
64
|
+
## Proposed supervised checks
|
|
65
|
+
|
|
66
|
+
These are trial steps, not claims of tests already performed on live fans:
|
|
67
|
+
|
|
68
|
+
1. Preserve configuration and exact installed 2.5.2 package/source, record its checksums and confirm a source-only recovery route. Preserve cached accessories and room identities; do not reset pairing.
|
|
69
|
+
2. Install the beta only on SvaraHome and restart only its child bridge when authorised. Check startup version/source fingerprints, both connections, unchanged Boost/Pause service identities, CO₂ Clear initially Off and no old demand re-armed.
|
|
70
|
+
3. For a shorter observation, set a one-minute lease and three-minute maximum session, retain the verified RPM ceiling, then manually send CO₂ Clear On. Confirm the initial native timer, at least two renewals beyond its first expiry, Off release and return to automatic via readback. Restore the intended 15/60-minute settings afterwards with the agreed child restart.
|
|
71
|
+
4. Test manual Pause during demand, then a delayed Clear: Pause must remain in force. Verify ordinary Boost and Fan On still work and that the old air renewal cannot reappear.
|
|
72
|
+
5. With a native humidity demand present, verify that CO₂ Clear Off releases only its own extra ventilation and leaves automatic humidity ventilation available. Readback returning to automatic is the check; a stopped motor is not required. If CO₂ Clear has never been On, an Off should not send a fan command.
|
|
73
|
+
6. Correlate a natural High transition with receipt and confirmed fan timer. Then correlate the actual Detected Yes→No transition with CO₂ Clear Off receipt and confirmed release. Home's Test Automation action proves only that rule's action path, not the real sensor transition. Do not manufacture CO₂ exposure or falsify readings.
|
|
74
|
+
7. Observe source loss/missing Clear and the maximum-session cutoff, plus a bounded restart check. Do not disrupt the live Home hub, Pi radio or other plugins to manufacture a failure without a separate agreed test.
|
|
75
|
+
|
|
76
|
+
The owner's Netatmo network instability remains an upstream fault. This beta does not repair it or the underlying Bluetooth discovery failures. The existing patient retries remain; no unproven wake delay, adapter reset, new sensor API or changed PIN handling is included.
|
|
77
|
+
|
|
78
|
+
## Rollback
|
|
79
|
+
|
|
80
|
+
Stop CO₂ Clear and confirm its native manual override is released, or allow the finite lease to expire if it cannot be reached. Disable only the new High/Clear rules. Restore the exact backed-up stable plugin/configuration, preserving Homebridge accessory cache and fan identities, and restart only the SvaraHome child bridge. Confirm both fans reconnect and no CO₂ Clear renewal survives. Restore the prior CO₂-to-timed-Boost rule only after confirming the beta rules are disabled. Returning to stable removes the new CO₂ Clear service, so its Home rules need to remain disabled or be deleted deliberately.
|
|
81
|
+
|
|
82
|
+
No beta package installation, fan commands or Home rule changes were performed while preparing this build. The owner is independently updating stable 2.5.2. Beta publication, trial installation and live control checks must be separately recorded; automated tests are not live acceptance.
|
package/docs/VERIFICATION.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
# Current verification status
|
|
2
|
+
|
|
3
|
+
Homebridge verified SvaraHome on 28 October 2026 after checks against 2.5.1. See the [maintainer confirmation](https://github.com/homebridge/plugins/issues/1302#issuecomment-6480171817) and [official register](https://github.com/homebridge/plugins/blob/master/verified-plugins.json). Stable 2.5.2 records that status. This beta adds unvalidated live behaviour; verification is not a guarantee of a particular automation or Bluetooth connection.
|
|
4
|
+
|
|
5
|
+
The dated readiness review below is retained as historical evidence; references there to pending verification are superseded by the confirmation above.
|
|
6
|
+
|
|
1
7
|
# Homebridge verification readiness
|
|
2
8
|
|
|
3
9
|
Reviewed 26 October 2026 against the current requirements and request forms. **Stable 2.5.0 is published and [verification request #1302](https://github.com/homebridge/plugins/issues/1302) has been submitted. Verification has not yet been granted.**
|
package/lib/accessory.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
const { check, bytes, FanError, publicError, decodeStatus, decodeManual, encodeManual,
|
|
3
3
|
verifyManual, decodeSilent, configuredSchedule, MAX_RPM } = require('./protocol');
|
|
4
|
+
const { failureDetails } = require('./transport');
|
|
5
|
+
const { CO2Clear } = require('./co2-clear');
|
|
4
6
|
const POLL_MS = 10000, STALE_MS = 45000;
|
|
5
7
|
// Both tested fans turn a rejected 2450 RPM request into Pause. Use the
|
|
6
8
|
// verified 2400 target by default; explicit per-fan limits remain available.
|
|
@@ -14,12 +16,14 @@ class FanAccessory {
|
|
|
14
16
|
this.config = platform.config; this.last = null; this.fault = true; this.closed = false;
|
|
15
17
|
this.pollPending = false; this.errorCode = null; this.controls = [];
|
|
16
18
|
this.intent = null;
|
|
19
|
+
this.loggedDiagnostics = new WeakSet();
|
|
17
20
|
const saved = accessory.context.pendingManual;
|
|
18
21
|
if (saved && ['boost', 'pause', 'automatic'].includes(saved.kind) && typeof saved.on === 'boolean' &&
|
|
19
22
|
(saved.kind !== 'automatic' || (saved.on === false && saved.rpm === undefined)) &&
|
|
20
23
|
(saved.rpm === undefined || (Number.isInteger(saved.rpm) && saved.rpm >= 1000 && saved.rpm <= MAX_RPM && saved.rpm % 25 === 0))) {
|
|
21
24
|
this.intent = { kind: saved.kind, on: saved.on, rpm: saved.rpm, attempted: saved.attempted === true };
|
|
22
25
|
}
|
|
26
|
+
this.co2Clear = new CO2Clear(this);
|
|
23
27
|
this.setup();
|
|
24
28
|
}
|
|
25
29
|
hapError() { return new this.hap.HapStatusError(this.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE); }
|
|
@@ -60,6 +64,8 @@ class FanAccessory {
|
|
|
60
64
|
s => s.manual.state === 'boost', value => this.manual('boost', value));
|
|
61
65
|
this.pauseService = this.switchService('pause', 'Pause Fan', this.config.showPauseSwitch === true,
|
|
62
66
|
s => s.manual.state === 'pause', value => this.manual('pause', value));
|
|
67
|
+
this.co2ClearService = this.switchService('co2-clear', 'CO₂ Clear', this.co2Clear.enabled,
|
|
68
|
+
() => this.co2Clear.active, value => this.co2Clear.set(value));
|
|
63
69
|
this.silentService = this.switchService('silent-hours', 'Silent Hours', this.config.showSilentSwitch === true,
|
|
64
70
|
s => s.silent.enabled, value => this.silent(value));
|
|
65
71
|
const info = a.getService(S.AccessoryInformation);
|
|
@@ -126,17 +132,24 @@ class FanAccessory {
|
|
|
126
132
|
try { await action(); }
|
|
127
133
|
catch (error) { if (publicError(error) !== 'COMMAND_SUPERSEDED') this.fail(error); throw this.hapError(); }
|
|
128
134
|
}
|
|
129
|
-
async manual(kind, on, rpm) {
|
|
135
|
+
async manual(kind, on, rpm, owner) {
|
|
130
136
|
check(['boost', 'pause', 'automatic'].includes(kind) && typeof on === 'boolean' &&
|
|
131
137
|
(kind !== 'automatic' || (on === false && rpm === undefined)), 'INVALID_COMMAND');
|
|
138
|
+
if (!owner) this.co2Clear.abandon('manual control takes priority');
|
|
139
|
+
if (this.intent) this.logIntent(this.intent, 'replaced by a newer command');
|
|
132
140
|
// Replacing the object invalidates any older queued attempt before its write.
|
|
133
141
|
clearTimeout(this.intentTimer); this.intentTimer = null;
|
|
134
|
-
const intent = { kind, on, rpm, attempted: false };
|
|
142
|
+
const intent = { kind, on, rpm, attempted: false, owner };
|
|
135
143
|
this.intent = intent; this.saveIntent();
|
|
144
|
+
this.logIntent(intent, 'received');
|
|
136
145
|
return this.attemptIntent(intent);
|
|
137
146
|
}
|
|
147
|
+
logIntent(intent, outcome) {
|
|
148
|
+
this.platform.log.info('SvaraHome ' + this.accessory.displayName + ': ' + intent.kind + ' ' +
|
|
149
|
+
(intent.on ? 'On' : 'Off') + ' request ' + outcome + '.');
|
|
150
|
+
}
|
|
138
151
|
saveIntent() {
|
|
139
|
-
if (this.intent) {
|
|
152
|
+
if (this.intent && !this.intent.owner) {
|
|
140
153
|
const { kind, on, rpm, attempted } = this.intent;
|
|
141
154
|
this.accessory.context.pendingManual = { kind, on, rpm, attempted };
|
|
142
155
|
} else delete this.accessory.context.pendingManual;
|
|
@@ -155,10 +168,17 @@ class FanAccessory {
|
|
|
155
168
|
intent.running = true;
|
|
156
169
|
try {
|
|
157
170
|
await this.applyManual(intent);
|
|
158
|
-
if (this.intent === intent) {
|
|
171
|
+
if (this.intent === intent) {
|
|
172
|
+
if (!this.closed && this.last) {
|
|
173
|
+
const state = this.last.manual;
|
|
174
|
+
this.logIntent(intent, 'confirmed; fan reports ' + state.state + ' ' + state.rpm + ' RPM/' + state.seconds + 's');
|
|
175
|
+
}
|
|
176
|
+
this.intent = null; this.saveIntent();
|
|
177
|
+
if (intent.owner) this.co2Clear.confirmed(intent);
|
|
178
|
+
}
|
|
159
179
|
} catch (error) {
|
|
160
180
|
if (!this.closed && this.intent === intent) {
|
|
161
|
-
this.fail(error);
|
|
181
|
+
if (publicError(error) !== 'COMMAND_SUPERSEDED') this.fail(error);
|
|
162
182
|
if (RETRYABLE.has(publicError(error))) {
|
|
163
183
|
this.scheduleIntent(intent);
|
|
164
184
|
if (!intent.announced) {
|
|
@@ -166,7 +186,11 @@ class FanAccessory {
|
|
|
166
186
|
' request pending; retrying until confirmed or replaced by a newer command.');
|
|
167
187
|
intent.announced = true;
|
|
168
188
|
}
|
|
169
|
-
} else {
|
|
189
|
+
} else {
|
|
190
|
+
this.logIntent(intent, 'stopped: ' + publicError(error));
|
|
191
|
+
this.intent = null; this.saveIntent();
|
|
192
|
+
if (intent.owner) this.co2Clear.failed(intent, publicError(error));
|
|
193
|
+
}
|
|
170
194
|
}
|
|
171
195
|
throw error;
|
|
172
196
|
} finally { intent.running = false; }
|
|
@@ -174,14 +198,16 @@ class FanAccessory {
|
|
|
174
198
|
async applyManual(intent) {
|
|
175
199
|
const { kind, on, rpm } = intent;
|
|
176
200
|
const result = await this.session(async io => {
|
|
177
|
-
check(this.intent
|
|
201
|
+
check(this.currentIntent(intent), 'COMMAND_SUPERSEDED');
|
|
178
202
|
const mode = bytes(await io.read('mode'), 1)[0];
|
|
179
203
|
check(mode === 0, 'CONTROL_REQUIRES_MULTIMODE');
|
|
180
204
|
const current = decodeManual(await io.read('manual'));
|
|
205
|
+
if (intent.owner && !on && !this.co2Clear.canRelease(intent, current)) return this.snapshot(io);
|
|
181
206
|
// Fan On clears either override. Individual switch-off still affects
|
|
182
207
|
// only its own kind, so a delayed Boost-off cannot cancel Pause.
|
|
183
208
|
if (!on && (kind === 'automatic' ? current.state === 'automatic' : current.state !== kind)) return this.snapshot(io);
|
|
184
|
-
const seconds =
|
|
209
|
+
const seconds = intent.owner && on ? this.co2Clear.seconds(intent) :
|
|
210
|
+
(kind === 'pause' ? this.config.pauseMinutes || 10 : this.config.boostMinutes || 10) * 60;
|
|
185
211
|
const target = kind === 'pause' ? 0 : Math.min(this.fan.maxManualRpm || DEFAULT_MANUAL_MAX_RPM, rpm || this.config.boostRpm || 2000);
|
|
186
212
|
const value = on ? encodeManual(target, seconds) : Buffer.alloc(5);
|
|
187
213
|
// A write may have reached the fan even if the response was lost. Adopt
|
|
@@ -189,8 +215,9 @@ class FanAccessory {
|
|
|
189
215
|
if (intent.attempted && on && current.state === kind && current.rpm === value.readUInt16LE(1) && current.seconds <= seconds) {
|
|
190
216
|
return this.snapshot(io);
|
|
191
217
|
}
|
|
192
|
-
check(this.intent
|
|
218
|
+
check(this.currentIntent(intent), 'COMMAND_SUPERSEDED');
|
|
193
219
|
intent.attempted = true; this.saveIntent();
|
|
220
|
+
if (intent.owner) this.co2Clear.willWrite(intent, seconds);
|
|
194
221
|
await io.write('manual', value);
|
|
195
222
|
const actual = await io.read('manual');
|
|
196
223
|
try { verifyManual(actual, value); }
|
|
@@ -206,6 +233,9 @@ class FanAccessory {
|
|
|
206
233
|
});
|
|
207
234
|
this.publish(result); this.schedule(2000);
|
|
208
235
|
}
|
|
236
|
+
currentIntent(intent) {
|
|
237
|
+
return !this.closed && this.intent === intent && (!intent.owner || this.co2Clear.valid(intent));
|
|
238
|
+
}
|
|
209
239
|
speed(percent) {
|
|
210
240
|
check(typeof percent === 'number' && Number.isFinite(percent) && percent >= 0 && percent <= 100, 'INVALID_SPEED');
|
|
211
241
|
if (percent === 0) return this.manual('pause', true);
|
|
@@ -228,6 +258,7 @@ class FanAccessory {
|
|
|
228
258
|
publish(state) {
|
|
229
259
|
if (this.closed) return;
|
|
230
260
|
this.last = state; this.fault = false; this.authRejected = false;
|
|
261
|
+
this.co2Clear.observe(state);
|
|
231
262
|
if (this.errorCode) this.platform.log.info('SvaraHome ' + this.accessory.displayName + ': communication restored.');
|
|
232
263
|
this.errorCode = null;
|
|
233
264
|
for (const { characteristic, get } of this.controls) characteristic.updateValue(get(state));
|
|
@@ -237,6 +268,20 @@ class FanAccessory {
|
|
|
237
268
|
fail(error, polling = false) {
|
|
238
269
|
if (this.closed) return;
|
|
239
270
|
const code = publicError(error);
|
|
271
|
+
const detail = failureDetails(error);
|
|
272
|
+
if (detail && !this.loggedDiagnostics.has(detail)) {
|
|
273
|
+
this.loggedDiagnostics.add(detail);
|
|
274
|
+
const key = code + ':' + detail.stage;
|
|
275
|
+
// Commands retain every failed attempt. Repeated background failures at
|
|
276
|
+
// the same stage are sampled once a minute to keep evidence bounded.
|
|
277
|
+
if (!polling || !this.lastDiagnostic || this.lastDiagnostic.key !== key || Date.now() - this.lastDiagnostic.at >= 60000) {
|
|
278
|
+
this.platform.log.info('SvaraHome ' + this.accessory.displayName + ': Bluetooth attempt ' + detail.attempt +
|
|
279
|
+
' failed; operation=' + (polling ? 'poll' : 'command') + '; code=' + code + '; stage=' + detail.stage +
|
|
280
|
+
'; stageMs=' + detail.stageMs + '; sessionMs=' + detail.sessionMs + '; queueMs=' + detail.queueMs +
|
|
281
|
+
'; lastCompleted=' + detail.completed + '.');
|
|
282
|
+
this.lastDiagnostic = { key, at: Date.now() };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
240
285
|
// A missed background poll need not flicker Home to No Response. Keep
|
|
241
286
|
// the last verified snapshot only within the existing freshness limit.
|
|
242
287
|
// Authentication, malformed telemetry and failed commands still fault.
|
|
@@ -245,6 +290,7 @@ class FanAccessory {
|
|
|
245
290
|
// Do not repeatedly submit a known-rejected PIN during unattended polling.
|
|
246
291
|
// A user command can explicitly retry, or a config restart can replace it.
|
|
247
292
|
if (code === 'AUTH_REJECTED') {
|
|
293
|
+
this.co2Clear.abandon('authentication rejected');
|
|
248
294
|
this.authRejected = true; clearTimeout(this.pollTimer); clearTimeout(this.intentTimer); this.intentTimer = null;
|
|
249
295
|
if (this.intent) { this.intent = null; this.saveIntent(); }
|
|
250
296
|
}
|
|
@@ -253,6 +299,9 @@ class FanAccessory {
|
|
|
253
299
|
this.errorCode = code;
|
|
254
300
|
if (!fresh) for (const { characteristic } of this.controls) characteristic.updateValue(this.hapError());
|
|
255
301
|
}
|
|
256
|
-
close() {
|
|
302
|
+
close() {
|
|
303
|
+
this.closed = true; this.co2Clear.close();
|
|
304
|
+
clearTimeout(this.pollTimer); clearTimeout(this.staleTimer); clearTimeout(this.intentTimer);
|
|
305
|
+
}
|
|
257
306
|
}
|
|
258
307
|
module.exports = { FanAccessory, POLL_MS, STALE_MS };
|
package/lib/co2-clear.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { performance } = require('node:perf_hooks');
|
|
3
|
+
const { check } = require('./protocol');
|
|
4
|
+
|
|
5
|
+
// Home supplies demand transitions; this controller has no sensor credentials,
|
|
6
|
+
// sensor polling or knowledge of whether a latched High signal is still fresh.
|
|
7
|
+
class CO2Clear {
|
|
8
|
+
constructor(host) {
|
|
9
|
+
this.host = host;
|
|
10
|
+
this.enabled = host.config.co2ClearEnabled !== false;
|
|
11
|
+
this.now = host.platform.co2ClearNow || (() => performance.now());
|
|
12
|
+
this.current = null;
|
|
13
|
+
this.sequence = 0;
|
|
14
|
+
if (host.accessory.context.co2ClearLease) {
|
|
15
|
+
delete host.accessory.context.co2ClearLease;
|
|
16
|
+
host.platform.save(host.accessory);
|
|
17
|
+
this.log('restart detected; renewal remains Off; any previous fan timer will expire naturally');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
get active() { return Boolean(this.current && this.current.active); }
|
|
21
|
+
log(message) { this.host.platform.log.info('SvaraHome ' + this.host.accessory.displayName + ': CO2 Clear ' + message + '.'); }
|
|
22
|
+
update() {
|
|
23
|
+
if (this.host.co2ClearService) this.host.co2ClearService.getCharacteristic(this.host.hap.Characteristic.On)
|
|
24
|
+
.updateValue(this.host.fault ? this.host.hapError() : this.active);
|
|
25
|
+
}
|
|
26
|
+
async set(on) {
|
|
27
|
+
check(typeof on === 'boolean', 'INVALID_COMMAND');
|
|
28
|
+
check(this.enabled && !this.host.closed, 'CO2_CLEAR_DISABLED');
|
|
29
|
+
if (!on) return this.stop('acceptable/Off signal');
|
|
30
|
+
check(this.host.fan && this.host.platform.transport, 'FAN_NOT_CONFIGURED');
|
|
31
|
+
if (this.active) {
|
|
32
|
+
// Repeated On must never refresh the maximum session deadline.
|
|
33
|
+
if (this.host.intent && this.host.intent.owner === this.current) {
|
|
34
|
+
check(!this.host.intent.running, 'BLE_UNAVAILABLE');
|
|
35
|
+
return this.host.attemptIntent(this.host.intent);
|
|
36
|
+
}
|
|
37
|
+
check(!this.host.fault, 'BLE_UNAVAILABLE');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
this.abandon('replaced by a new air demand');
|
|
41
|
+
const token = {
|
|
42
|
+
active: true, sequence: ++this.sequence, mayOwn: false,
|
|
43
|
+
rpm: Math.min(this.host.fan.maxManualRpm || 2400, this.host.config.co2ClearRpm || 2400),
|
|
44
|
+
leaseSeconds: (this.host.config.co2ClearLeaseMinutes || 15) * 60,
|
|
45
|
+
end: this.now() + (this.host.config.co2ClearMaxMinutes || 60) * 60000,
|
|
46
|
+
};
|
|
47
|
+
this.current = token;
|
|
48
|
+
token.capTimer = setTimeout(() => {
|
|
49
|
+
if (this.current === token) this.stop('maximum session reached').catch(() => {});
|
|
50
|
+
},
|
|
51
|
+
Math.max(0, token.end - this.now()));
|
|
52
|
+
this.log('High/On signal received; session ' + token.sequence + ' armed with a finite limit');
|
|
53
|
+
this.update();
|
|
54
|
+
return this.host.manual('boost', true, token.rpm, token);
|
|
55
|
+
}
|
|
56
|
+
valid(intent) {
|
|
57
|
+
const token = intent.owner;
|
|
58
|
+
return this.current === token && (intent.on ? token.active && this.now() < token.end :
|
|
59
|
+
!token.active && token.releasing && this.now() < token.releaseEnd);
|
|
60
|
+
}
|
|
61
|
+
seconds(intent) {
|
|
62
|
+
check(this.valid(intent), 'COMMAND_SUPERSEDED');
|
|
63
|
+
const seconds = Math.min(intent.owner.leaseSeconds, Math.floor((intent.owner.end - this.now()) / 1000));
|
|
64
|
+
check(seconds >= 1, 'COMMAND_SUPERSEDED');
|
|
65
|
+
return seconds;
|
|
66
|
+
}
|
|
67
|
+
willWrite(intent, seconds) {
|
|
68
|
+
if (!intent.on) return;
|
|
69
|
+
intent.owner.mayOwn = true; // An acknowledged write can still lose its readback.
|
|
70
|
+
intent.owner.lastLeaseSeconds = seconds;
|
|
71
|
+
this.host.accessory.context.co2ClearLease = true;
|
|
72
|
+
this.host.platform.save(this.host.accessory);
|
|
73
|
+
}
|
|
74
|
+
canRelease(intent, current) {
|
|
75
|
+
// Do not clear a Pause or a recognisably different native/app override.
|
|
76
|
+
// The fan protocol cannot distinguish identical out-of-band Boost writes.
|
|
77
|
+
return intent.owner.mayOwn && current.state === 'boost' && current.rpm === intent.owner.rpm &&
|
|
78
|
+
current.seconds <= intent.owner.lastLeaseSeconds;
|
|
79
|
+
}
|
|
80
|
+
confirmed(intent) {
|
|
81
|
+
if (!this.valid(intent)) return;
|
|
82
|
+
const token = intent.owner, state = this.host.last.manual;
|
|
83
|
+
if (!intent.on) {
|
|
84
|
+
this.log('release confirmed; fan reports ' + state.state);
|
|
85
|
+
this.abandon();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
token.expectedEnd = this.now() + state.seconds * 1000;
|
|
89
|
+
this.log('session ' + token.sequence + ' confirmed at ' + state.rpm + ' RPM/' + state.seconds + 's');
|
|
90
|
+
clearTimeout(token.renewTimer);
|
|
91
|
+
token.renewTimer = setTimeout(() => {
|
|
92
|
+
if (this.current !== token || !token.active) return;
|
|
93
|
+
if (this.now() >= token.end) { this.stop('maximum session reached').catch(() => {}); return; }
|
|
94
|
+
this.log('session ' + token.sequence + ' renewing timed ventilation');
|
|
95
|
+
this.host.manual('boost', true, token.rpm, token).catch(() => {});
|
|
96
|
+
}, Math.max(1000, state.seconds * 500));
|
|
97
|
+
}
|
|
98
|
+
failed(intent, code) {
|
|
99
|
+
if (this.current !== intent.owner) return;
|
|
100
|
+
this.log('stopped after ' + code + '; no further renewal');
|
|
101
|
+
this.abandon();
|
|
102
|
+
}
|
|
103
|
+
observe(state) {
|
|
104
|
+
const token = this.current;
|
|
105
|
+
if (!token || !token.active || !token.mayOwn) return;
|
|
106
|
+
const manual = state.manual;
|
|
107
|
+
if (manual.state === 'pause' || (manual.state === 'boost' && manual.rpm !== token.rpm) ||
|
|
108
|
+
(manual.state === 'automatic' && token.expectedEnd && this.now() < token.expectedEnd - 3000)) {
|
|
109
|
+
this.abandon('native/manual control changed');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async stop(reason) {
|
|
113
|
+
const token = this.current;
|
|
114
|
+
if (!token) return; // An unrelated timed Boost or Pause must be left alone.
|
|
115
|
+
if (!token.active && token.releasing) {
|
|
116
|
+
if (this.host.intent && this.host.intent.owner === token && !this.host.intent.running)
|
|
117
|
+
return this.host.attemptIntent(this.host.intent);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
token.active = false;
|
|
121
|
+
clearTimeout(token.capTimer); clearTimeout(token.renewTimer);
|
|
122
|
+
this.log(reason + '; renewal Off');
|
|
123
|
+
this.update();
|
|
124
|
+
if (!token.mayOwn) { this.abandon(); return; }
|
|
125
|
+
token.releasing = true;
|
|
126
|
+
token.releaseEnd = this.now() + token.leaseSeconds * 1000;
|
|
127
|
+
token.cleanupTimer = setTimeout(() => {
|
|
128
|
+
if (this.current === token) this.abandon('release window ended; relying on the finite native timer');
|
|
129
|
+
}, token.leaseSeconds * 1000);
|
|
130
|
+
return this.host.manual('boost', false, undefined, token);
|
|
131
|
+
}
|
|
132
|
+
abandon(reason) {
|
|
133
|
+
const token = this.current;
|
|
134
|
+
if (!token) return;
|
|
135
|
+
token.active = false;
|
|
136
|
+
for (const key of ['capTimer', 'renewTimer', 'cleanupTimer']) clearTimeout(token[key]);
|
|
137
|
+
this.current = null;
|
|
138
|
+
if (this.host.intent && this.host.intent.owner === token) {
|
|
139
|
+
this.host.logIntent(this.host.intent, 'replaced by CO2 Clear cancellation');
|
|
140
|
+
clearTimeout(this.host.intentTimer); this.host.intentTimer = null;
|
|
141
|
+
this.host.intent = null; this.host.saveIntent();
|
|
142
|
+
}
|
|
143
|
+
delete this.host.accessory.context.co2ClearLease;
|
|
144
|
+
this.host.platform.save(this.host.accessory);
|
|
145
|
+
if (reason) this.log(reason + '; renewal Off');
|
|
146
|
+
this.update();
|
|
147
|
+
}
|
|
148
|
+
close() {
|
|
149
|
+
const token = this.current;
|
|
150
|
+
if (!token) return;
|
|
151
|
+
for (const key of ['capTimer', 'renewTimer', 'cleanupTimer']) clearTimeout(token[key]);
|
|
152
|
+
token.active = false; this.current = null;
|
|
153
|
+
// Preserve only a marker, never a resumable High or pending renewal.
|
|
154
|
+
this.log('bridge stopping; no further renewal; native timer remains bounded');
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
module.exports = { CO2Clear };
|
package/lib/platform.js
CHANGED
|
@@ -4,6 +4,15 @@ const { FanError, check, publicError, configuredSchedule, encodePin } = require(
|
|
|
4
4
|
const { FanAccessory } = require('./accessory');
|
|
5
5
|
const PLUGIN = 'homebridge-pax-calima';
|
|
6
6
|
const MAC = /^(?:[0-9a-f]{2}:){5}[0-9a-f]{2}$/;
|
|
7
|
+
function runtimeIdentity() {
|
|
8
|
+
const { createHash } = require('node:crypto'), { readFileSync } = require('node:fs');
|
|
9
|
+
const { join } = require('node:path');
|
|
10
|
+
const files = ['accessory.js', 'transport.js', 'co2-clear.js'];
|
|
11
|
+
return 'version=' + require('../package.json').version + '; ' + files.map(name => {
|
|
12
|
+
try { return name + '=' + createHash('sha256').update(readFileSync(join(__dirname, name))).digest('hex').slice(0, 12); }
|
|
13
|
+
catch (_) { return name + '=unavailable'; }
|
|
14
|
+
}).join('; ');
|
|
15
|
+
}
|
|
7
16
|
|
|
8
17
|
function validateConfig(config) {
|
|
9
18
|
const fans = new Map();
|
|
@@ -28,6 +37,12 @@ function validateConfig(config) {
|
|
|
28
37
|
if (config[key] !== undefined) check(Number.isInteger(config[key]) && config[key] >= 1 && config[key] <= 60, 'INVALID_DURATION');
|
|
29
38
|
}
|
|
30
39
|
if (config.boostRpm !== undefined) check(Number.isInteger(config.boostRpm) && config.boostRpm >= 1000 && config.boostRpm <= 2500 && config.boostRpm % 25 === 0, 'INVALID_SPEED');
|
|
40
|
+
if (config.co2ClearEnabled !== undefined) check(typeof config.co2ClearEnabled === 'boolean', 'INVALID_CO2_CLEAR_CONFIG');
|
|
41
|
+
for (const [key, max] of [['co2ClearLeaseMinutes', 15], ['co2ClearMaxMinutes', 240]]) {
|
|
42
|
+
if (config[key] !== undefined) check(Number.isInteger(config[key]) && config[key] >= 1 && config[key] <= max, 'INVALID_CO2_CLEAR_CONFIG');
|
|
43
|
+
}
|
|
44
|
+
if (config.co2ClearRpm !== undefined) check(Number.isInteger(config.co2ClearRpm) && config.co2ClearRpm >= 1000 &&
|
|
45
|
+
config.co2ClearRpm <= 2400 && config.co2ClearRpm % 25 === 0, 'INVALID_CO2_CLEAR_CONFIG');
|
|
31
46
|
configuredSchedule(config.silentHoursStart, config.silentHoursEnd);
|
|
32
47
|
return fans;
|
|
33
48
|
}
|
|
@@ -42,7 +57,9 @@ function createPlatform(homebridge, dependencies = {}) {
|
|
|
42
57
|
for (const code of this.fans.issues) this.log.warn('SvaraHome skipped fan setup: ' + code + '. Check its address and PIN in Plugin Config.');
|
|
43
58
|
}
|
|
44
59
|
catch (error) {
|
|
45
|
-
|
|
60
|
+
const code = publicError(error);
|
|
61
|
+
this.log.warn('SvaraHome setup needs attention: ' + code + (code === 'INVALID_CO2_CLEAR_CONFIG' ?
|
|
62
|
+
'. Check CO2 Clear settings in Plugin Config.' : '. Check each fan address and its six- or eight-digit PIN in Plugin Config.'));
|
|
46
63
|
}
|
|
47
64
|
api.on('didFinishLaunching', () => this.launch());
|
|
48
65
|
api.on('shutdown', () => this.shutdown());
|
|
@@ -54,6 +71,7 @@ function createPlatform(homebridge, dependencies = {}) {
|
|
|
54
71
|
launch() {
|
|
55
72
|
if (this.launched || this.stopped) return;
|
|
56
73
|
this.launched = true;
|
|
74
|
+
if (this.log.info) this.log.info('SvaraHome runtime ' + runtimeIdentity() + '.');
|
|
57
75
|
// Retain cached identities but fault them when config cannot be routed.
|
|
58
76
|
for (const [mac, accessory] of this.cached) {
|
|
59
77
|
const fan = this.valid && this.fans.get(mac);
|
|
@@ -95,4 +113,4 @@ function createPlatform(homebridge, dependencies = {}) {
|
|
|
95
113
|
}
|
|
96
114
|
};
|
|
97
115
|
}
|
|
98
|
-
module.exports = { createPlatform, validateConfig };
|
|
116
|
+
module.exports = { createPlatform, validateConfig, runtimeIdentity };
|
package/lib/transport.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { UUID, normalise, FanError, check, encodePin, confirmAuth } = require('./protocol');
|
|
4
|
+
const { performance } = require('node:perf_hooks');
|
|
5
|
+
// Side-channel metadata only: preserve the original error and retry semantics.
|
|
6
|
+
// Never copy backend messages, addresses, PINs or characteristic payloads.
|
|
7
|
+
const failures = new WeakMap();
|
|
8
|
+
const failureDetails = error => error && typeof error === 'object' ? failures.get(error) : undefined;
|
|
4
9
|
|
|
5
10
|
async function bounded(promise, ms, code = 'BLE_TIMEOUT') {
|
|
6
11
|
let timer;
|
|
@@ -17,15 +22,17 @@ class Transport {
|
|
|
17
22
|
this.noble = noble; this.timeout = timeout; this.tail = Promise.resolve();
|
|
18
23
|
this.authSettle = authSettle;
|
|
19
24
|
this.pending = 0; this.closed = false; this.peripheral = null;
|
|
25
|
+
this.sequence = 0;
|
|
20
26
|
}
|
|
21
27
|
run(mac, pin, action) {
|
|
22
28
|
if (this.closed || this.pending >= 32) return Promise.reject(new FanError('BLE_UNAVAILABLE'));
|
|
29
|
+
const queued = performance.now(), attempt = ++this.sequence;
|
|
23
30
|
this.pending++;
|
|
24
|
-
const job = this.tail.then(() => this.session(mac, pin, action));
|
|
31
|
+
const job = this.tail.then(() => this.session(mac, pin, action, { queued, attempt }));
|
|
25
32
|
this.tail = job.catch(() => {}).finally(() => { this.pending--; });
|
|
26
33
|
return job;
|
|
27
34
|
}
|
|
28
|
-
async find(mac) {
|
|
35
|
+
async find(mac, step = (_stage, action) => action()) {
|
|
29
36
|
check(this.noble.state === 'poweredOn', 'BLUETOOTH_NOT_READY');
|
|
30
37
|
return new Promise((resolve, reject) => {
|
|
31
38
|
let settled = false;
|
|
@@ -33,7 +40,7 @@ class Transport {
|
|
|
33
40
|
clearTimeout(timer); this.noble.removeListener('discover', found);
|
|
34
41
|
this.noble.removeListener('stateChange', changed);
|
|
35
42
|
if (this.noble.stopScanningAsync) {
|
|
36
|
-
await bounded(this.noble.stopScanningAsync(), this.timeout, 'SCAN_STOP_FAILED');
|
|
43
|
+
await step('scan-stop', () => bounded(this.noble.stopScanningAsync(), this.timeout, 'SCAN_STOP_FAILED'));
|
|
37
44
|
} else { this.noble.stopScanning(); }
|
|
38
45
|
};
|
|
39
46
|
const fail = code => {
|
|
@@ -52,32 +59,56 @@ class Transport {
|
|
|
52
59
|
catch (_) { fail('SCAN_FAILED'); }
|
|
53
60
|
});
|
|
54
61
|
}
|
|
55
|
-
async session(mac, pin, action) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
62
|
+
async session(mac, pin, action, context = { queued: performance.now(), attempt: 0 }) {
|
|
63
|
+
const started = performance.now(), queueMs = Math.round(started - context.queued);
|
|
64
|
+
let completed = 'none';
|
|
65
|
+
const observed = new WeakSet();
|
|
66
|
+
const step = async (stage, operation) => {
|
|
67
|
+
const at = performance.now();
|
|
68
|
+
try {
|
|
69
|
+
const result = await operation();
|
|
70
|
+
completed = stage;
|
|
71
|
+
return result;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (error && typeof error === 'object' && !observed.has(error)) {
|
|
74
|
+
observed.add(error);
|
|
75
|
+
failures.set(error, Object.freeze({ attempt: context.attempt, stage, completed, queueMs,
|
|
76
|
+
stageMs: Math.round(performance.now() - at), sessionMs: Math.round(performance.now() - started) }));
|
|
77
|
+
}
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
await step('preflight', () => {
|
|
82
|
+
check(!this.closed, 'BLE_UNAVAILABLE');
|
|
83
|
+
// Retry a stalled disconnect before reusing the radio. Never overlap an
|
|
84
|
+
// unresolved connection or pretend the backend has disconnected.
|
|
85
|
+
check(!this.connecting, 'BLE_RECOVERY_REQUIRED');
|
|
86
|
+
});
|
|
60
87
|
if (this.peripheral && this.peripheral.state !== 'disconnected') {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
88
|
+
await step('recover-disconnect', async () => {
|
|
89
|
+
try { await bounded(this.peripheral.disconnectAsync(), this.timeout, 'BLE_RECOVERY_REQUIRED'); }
|
|
90
|
+
catch (_) { throw new FanError('BLE_RECOVERY_REQUIRED'); }
|
|
91
|
+
check(this.peripheral.state === 'disconnected', 'BLE_RECOVERY_REQUIRED');
|
|
92
|
+
});
|
|
64
93
|
}
|
|
65
|
-
const peripheral = await this.find(mac);
|
|
94
|
+
const peripheral = await step('scan', () => this.find(mac, step));
|
|
66
95
|
this.peripheral = peripheral;
|
|
67
96
|
let expired = false;
|
|
68
97
|
try {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
98
|
+
await step('connect', async () => {
|
|
99
|
+
this.connecting = true;
|
|
100
|
+
const connection = peripheral.connectAsync();
|
|
101
|
+
// Noble may complete a connection after its timeout. Disconnect it then.
|
|
102
|
+
connection.then(() => {
|
|
103
|
+
this.connecting = false;
|
|
104
|
+
if (expired || this.closed) { try { peripheral.disconnect(() => {}); } catch (_) { /* recovery gate remains */ } }
|
|
105
|
+
}, () => { this.connecting = false; });
|
|
106
|
+
await bounded(connection, this.timeout);
|
|
107
|
+
check(!this.closed, 'BLE_UNAVAILABLE');
|
|
108
|
+
});
|
|
78
109
|
const chars = new Map();
|
|
79
|
-
for (const service of await bounded(peripheral.discoverServicesAsync(), this.timeout)) {
|
|
80
|
-
for (const char of await bounded(service.discoverCharacteristicsAsync(), this.timeout)) chars.set(normalise(char.uuid), char);
|
|
110
|
+
for (const service of await step('discover-services', () => bounded(peripheral.discoverServicesAsync(), this.timeout))) {
|
|
111
|
+
for (const char of await step('discover-characteristics', () => bounded(service.discoverCharacteristicsAsync(), this.timeout))) chars.set(normalise(char.uuid), char);
|
|
81
112
|
}
|
|
82
113
|
const get = (name, property) => {
|
|
83
114
|
check(!this.closed, 'BLE_UNAVAILABLE');
|
|
@@ -85,19 +116,21 @@ class Transport {
|
|
|
85
116
|
check(char && char.properties.includes(property), 'CHARACTERISTIC_UNAVAILABLE');
|
|
86
117
|
return char;
|
|
87
118
|
};
|
|
88
|
-
const
|
|
89
|
-
const
|
|
119
|
+
const label = name => Object.hasOwn(UUID, name) ? name : 'unknown';
|
|
120
|
+
const read = name => step('read-' + label(name), () => bounded(get(name, 'read').readAsync(), this.timeout));
|
|
121
|
+
const write = (name, value) => step('write-' + label(name), () => {
|
|
90
122
|
check(['auth', 'manual', 'silent'].includes(name), 'WRITE_NOT_ALLOWED');
|
|
91
123
|
return bounded(get(name, 'write').writeAsync(value, false), this.timeout);
|
|
92
|
-
};
|
|
124
|
+
});
|
|
93
125
|
const secret = encodePin(pin);
|
|
94
126
|
try { await write('auth', secret); } finally { secret.fill(0); }
|
|
95
|
-
|
|
127
|
+
const confirmation = await read('confirmation');
|
|
128
|
+
await step('authenticate', () => confirmAuth(confirmation));
|
|
96
129
|
// Svara firmware needs a short processing interval after authentication
|
|
97
130
|
// before accepting protected control writes.
|
|
98
|
-
if (this.authSettle) await new Promise(resolve => setTimeout(resolve, this.authSettle));
|
|
131
|
+
if (this.authSettle) await step('auth-settle', () => new Promise(resolve => setTimeout(resolve, this.authSettle)));
|
|
99
132
|
check(!this.closed, 'BLE_UNAVAILABLE');
|
|
100
|
-
return await action({ read, write });
|
|
133
|
+
return await step('action', () => action({ read, write }));
|
|
101
134
|
} finally {
|
|
102
135
|
expired = true;
|
|
103
136
|
// Noble sets state to disconnecting even when already disconnected;
|
|
@@ -116,4 +149,4 @@ class Transport {
|
|
|
116
149
|
}
|
|
117
150
|
}
|
|
118
151
|
}
|
|
119
|
-
module.exports = { Transport, bounded };
|
|
152
|
+
module.exports = { Transport, bounded, failureDetails };
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-pax-calima",
|
|
3
3
|
"displayName": "SvaraHome",
|
|
4
|
-
"version": "2.
|
|
5
|
-
"description": "SvaraHome brings timed Boost, Pause and local Bluetooth control
|
|
4
|
+
"version": "2.6.0-beta.2",
|
|
5
|
+
"description": "SvaraHome brings CO₂ Clear automation, timed Boost, Pause and local Bluetooth fan control into Apple Home.",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"keywords": [
|
|
8
8
|
"homebridge-plugin",
|
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
"extractor",
|
|
17
17
|
"bathroom",
|
|
18
18
|
"supports-hap",
|
|
19
|
-
"svarahome"
|
|
19
|
+
"svarahome",
|
|
20
|
+
"co2",
|
|
21
|
+
"ventilation"
|
|
20
22
|
],
|
|
21
23
|
"engines": {
|
|
22
24
|
"homebridge": "^1.8.0 || ^2.0.0",
|
|
@@ -33,7 +35,7 @@
|
|
|
33
35
|
"license": "Apache-2.0",
|
|
34
36
|
"scripts": {
|
|
35
37
|
"test": "node --test test/*.test.js",
|
|
36
|
-
"check": "node --check index.js && node --check lib/accessory.js && node --check lib/platform.js && node --check lib/protocol.js && node --check lib/transport.js",
|
|
38
|
+
"check": "node --check index.js && node --check lib/accessory.js && node --check lib/platform.js && node --check lib/protocol.js && node --check lib/transport.js && node --check lib/co2-clear.js",
|
|
37
39
|
"audit:release": "npm audit --audit-level=high && npm audit --omit=dev --audit-level=high",
|
|
38
40
|
"check:native": "node scripts/check-native.cjs"
|
|
39
41
|
},
|
|
@@ -57,7 +59,8 @@
|
|
|
57
59
|
"scripts/check-native.cjs",
|
|
58
60
|
"docs/BETA1-LIVE-DIAGNOSTIC.md",
|
|
59
61
|
"docs/pi-live-control-evidence-20261025.jsonl",
|
|
60
|
-
"docs/STABLE-PROMOTION.md"
|
|
62
|
+
"docs/STABLE-PROMOTION.md",
|
|
63
|
+
"docs/CO2-CLEAR-BETA.md"
|
|
61
64
|
],
|
|
62
65
|
"dependencies": {
|
|
63
66
|
"@stoprocent/noble": "2.8.0"
|