@lv426/homebridge-systemair-enhanced 1.0.5 → 2.0.0

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 ADDED
@@ -0,0 +1,30 @@
1
+ # Changelog
2
+
3
+ ## 2.0.0
4
+
5
+ A rewrite: faster, a switch for every mode, timers, and the fan's real level.
6
+
7
+ ### New
8
+
9
+ - **A switch per mode** for scenes and automations: Auto, Visitors (the unit's Crowded), Refresh, Fireplace, Away and Holiday. Only one is on at a time; turning the one that's on off goes back to what it interrupted (Auto, or the manual level you had), as the unit itself does when a mode's time is up.
10
+ - **Refresh and Fireplace timers**: set how long the mode lasts from the Home app, and watch it count down.
11
+ - **The fan shows the level it really runs at**, Auto included: Low 16%, Normal 50%, High 83%, Maximum 100%. The top of the slider starts Refresh, the only way to Maximum. The fan's Auto setting switches back to Auto.
12
+ - **Outdoor temperature** and **filter life** tiles.
13
+ - **Settings in Homebridge's web interface**: which modes and timers to show, your own names for the modes, how often the unit is read.
14
+
15
+ ### Changed
16
+
17
+ - **The unit is read in one request every 10 seconds** and HomeKit is told about each change. 1.x asked the unit separately for every value the Home app wanted, waiting up to 20 seconds each, so tiles could show "No Response".
18
+ - **The Refresh switch stays on while Refresh runs**, and turning it off ends Refresh. In 1.x it turned itself off after a second.
19
+ - **The thermostat's range is the unit's**, 12–30 °C in half degrees.
20
+ - The Timer tile (time left shown as a battery level) is gone: the timers count down instead.
21
+ - Written in TypeScript, with tests; needs Homebridge 1.8 or 2.x and Node 20 or newer.
22
+
23
+ ### Fixed
24
+
25
+ - **Refresh no longer changes your settings.** 1.x sent the unit's whole Home form to start it, which also set the temperature to 18 °C, the manual level to Low and ECO mode off.
26
+ - **During Refresh the fan showed 16%** (the manual Low setting) while the unit ran at Maximum.
27
+
28
+ ## 1.0.6 and earlier
29
+
30
+ See [npm](https://www.npmjs.com/package/@lv426/homebridge-systemair-enhanced?activeTab=versions).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 foxhoundn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,94 +1,134 @@
1
- # Homebridge Systemair Ventilator Plugin
1
+ <p align="center">
2
+ <img src="icon.png" width="120" alt="Systemair Ventilator for Homebridge" />
3
+ </p>
4
+
5
+ <h1 align="center">Systemair Ventilator for Homebridge</h1>
2
6
 
3
- This is a Homebridge plugin for controlling a Systemair Ventilator through its web interface then into HomeKit. You must have a SAVEConnect WIFI module connected for this to work. You will need the IP address of the SAVEConnect device and include it in the plugin settings.
7
+ <p align="center">
8
+ Your Systemair SAVE ventilation in Apple Home: modes for scenes, timers, the fan's real level,<br />
9
+ temperature, humidity, the outdoor temperature and the filter.
10
+ </p>
4
11
 
5
- This plugin provides comprehensive control of your Systemair ventilator including:
12
+ <p align="center">
13
+ <a href="https://www.npmjs.com/package/@lv426/homebridge-systemair-enhanced"><img src="https://img.shields.io/npm/v/@lv426/homebridge-systemair-enhanced?label=npm" alt="npm version" /></a>
14
+ <a href="https://github.com/Foxhoundn/homebridge-systemair-enhanced/actions/workflows/ci.yml"><img src="https://github.com/Foxhoundn/homebridge-systemair-enhanced/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
15
+ <img src="https://img.shields.io/badge/homebridge-1.8%20%7C%202.x-blueviolet" alt="Homebridge 1.8 and 2.x" />
16
+ <a href="LICENSE"><img src="https://img.shields.io/npm/l/@lv426/homebridge-systemair-enhanced" alt="MIT licence" /></a>
17
+ </p>
6
18
 
7
- - **Fan Speed Control**: Adjust ventilation speeds with multiple settings
8
- - **Temperature Control**: Set target temperature and view current temperature
9
- - **Humidity Monitoring**: Monitor current humidity levels
10
- - **Thermostat Interface**: Control heating/cooling/auto modes through HomeKit's thermostat interface
19
+ ---
11
20
 
12
- # Installation:
21
+ It talks to the unit's **SAVE Connect** module the same way the module's own web page does, so there's no cloud, no account and no Modbus wiring: if you can open the unit's web page in a browser, this plugin can run it.
13
22
 
14
- If Homebridge is not already installed, use the following command to install it globally:
23
+ ## What you get in the Home app
15
24
 
16
- 1. Install Homebridge:
25
+ | Tile | What it does |
26
+ | --- | --- |
27
+ | **Fan** | Shows the level the fans really run at, Auto included. Drag it to run them manually at Low, Normal or High; drag it to the top to start Refresh; switch it back to Auto, or off. |
28
+ | **Auto, Visitors, Refresh, Fireplace, Away, Holiday** | A switch per mode, for scenes and automations. Only one is on at a time. Turning the one that's on off goes back to what it interrupted: Auto, or the manual level you had. |
29
+ | **Refresh timer, Fireplace timer** | Set how long the mode lasts (the tile's Duration), turn it on, and watch it count down. |
30
+ | **Thermostat** | The temperature in the house and the setpoint (12–30 °C). |
31
+ | **Eco Mode** | The unit's ECO mode. |
32
+ | **Humidity** | Relative humidity of the extract air. |
33
+ | **Outdoor** | The unit's outdoor temperature sensor. |
34
+ | **Filter** | How much filter life is left; the Home app tells you when it's time to change it. |
17
35
 
18
- ```bash
19
- npm install -g homebridge
36
+ The unit is read in one request every 10 seconds, and HomeKit is told about each change, so the Home app opens straight away and automations see changes within seconds.
20
37
 
21
- ```
38
+ ### The fan slider
22
39
 
23
- 2. Install the Plugin
40
+ The slider shows and sets the unit's airflow levels:
24
41
 
25
- ```bash
26
- npm install -g @lv426/homebridge-systemair-enhanced
27
- Run the following command to install the Homebridge Systemair Ventilator plugin:
42
+ | Slider | The unit runs | Shown as |
43
+ | --- | --- | --- |
44
+ | 0% | Off (Manual) | 0% |
45
+ | 1–33% | Manual, **Low** | 16% |
46
+ | 34–66% | Manual, **Normal** | 50% |
47
+ | 67–91% | Manual, **High** | 83% |
48
+ | 92–100% | **Refresh**, at Maximum for its set time, then back to what it was doing | 100% |
28
49
 
29
- ```
50
+ Manual mode can't go above High: Maximum is reached only by modes like Refresh. Turning the fans off needs **Manual fan stop** allowed on the unit.
30
51
 
31
- 3. Configure the Plugin
32
- Edit the Homebridge config.json file to include the plugin. Add the following under "accessories":
52
+ ### The modes
33
53
 
34
- ```yaml
35
- {
36
- "accessories": [
37
- {
38
- "accessory": "SystemairVentilatorEnhanced",
39
- "name": "Living Room Ventilator",
40
- "ip": "x.x.x.x"
41
- }
42
- ]
43
- }
44
- Replace add your Systemair IP with the actual IP address of your Systemair SAVEConnect device.
54
+ | Mode | What the unit does | How long |
55
+ | --- | --- | --- |
56
+ | **Auto** | Follows its week schedule and demand control. | — |
57
+ | **Visitors** (the unit's *Crowded*) | More air for a full house. | 1–8 hours |
58
+ | **Refresh** | As much air as it can, to air the house out. | 1–240 minutes |
59
+ | **Fireplace** | Slight overpressure, so the fireplace draws well. | 1–60 minutes |
60
+ | **Away** | Less air while you're out. | 1–72 hours |
61
+ | **Holiday** | Less air for longer, while you're away for days. | 1–365 days |
45
62
 
46
- ```
63
+ How long each mode lasts is the unit's own setting: the timer tiles change it for Refresh and Fireplace, and the unit's web page for the rest. When a mode's time is up, the unit goes back to the mode it interrupted. Changing a timer's minutes while its mode runs saves them for next time; the running mode keeps its countdown.
47
64
 
48
- 4. Restart Homebridge
49
- Restart Homebridge for the changes to take effect:
50
- ```bash
51
- sudo systemctl restart homebridge
52
- ```
65
+ *Cooker hood* and *Vacuum cleaner* modes are started by switches wired to the unit, so HomeKit can't start them.
53
66
 
54
- # Features:
67
+ ## Install
55
68
 
56
- Control fan speeds with three settings: Low, Medium, and High.
57
- Adjust the target temperature directly from HomeKit.
58
- View current temperature and humidity readings.
59
- Control heating/cooling/auto modes through the thermostat interface.
60
- Automatically handles fan activation and speed synchronization.
61
- Create scenes for Low, Normal, and High with the following mappings:
69
+ Search for **Systemair Ventilator** under **Plugins** in Homebridge's web interface and install it, or:
62
70
 
63
- speed === 1 maps to 0% (Off)
64
- speed === 2 maps to 16% (Low)
65
- speed === 3 maps to 50% (Normal)
66
- speed === 4 maps to 83% (High)
71
+ ```bash
72
+ npm install -g @lv426/homebridge-systemair-enhanced
73
+ ```
67
74
 
68
- # Troubleshooting:
75
+ You need a Systemair SAVE unit with a **SAVE Connect** module on your network, and its address (the one you open its web page at). Give it a fixed address in your router so it doesn't move.
69
76
 
70
- If the ventilator doesn't respond, check the following:
77
+ ## Settings
71
78
 
72
- Ensure the IP address in the configuration is correct.
73
- Verify that the SAVEConnect WIFI module is online.
74
- Check Homebridge logs for error messages.
79
+ Fill them in under the plugin's **Settings** in Homebridge's web interface, or add this to `config.json`:
75
80
 
76
- **Note**: The plugin creates multiple HomeKit accessories:
81
+ ```json
82
+ {
83
+ "accessories": [
84
+ {
85
+ "accessory": "SystemairVentilatorEnhanced",
86
+ "name": "Ventilation",
87
+ "ip": "192.168.1.50"
88
+ }
89
+ ]
90
+ }
91
+ ```
77
92
 
78
- - **Fan**: Controls ventilation speed and on/off state
79
- - **Thermostat**: Controls target temperature and heating/cooling modes
80
- - **Humidity Sensor**: Displays current humidity levels
81
- - **Refresh Switch**: Triggers refresh mode
82
- - **Timer Display**: Shows remaining timer (displayed as battery level)
93
+ | Setting | What it does | Default |
94
+ | --- | --- | --- |
95
+ | `name` | Each tile is named after it: "Ventilation Fan", "Ventilation Refresh". | `Ventilation` |
96
+ | `ip` | The SAVE Connect module's address. | — |
97
+ | `modeSwitches` | The modes shown as switches: `auto`, `crowded`, `refresh`, `fireplace`, `away`, `holiday`. | all six |
98
+ | `timers` | The modes with a timer tile: `refresh`, `fireplace`. | both |
99
+ | `modeNames` | Your own names, for example `{ "refresh": "Air out" }`. | Crowded is `Visitors`; the rest as on the unit |
100
+ | `eco` | Show the ECO mode switch. | `true` |
101
+ | `outdoorTemperature` | Show the outdoor temperature. | `true` |
102
+ | `filter` | Show the filter. | `true` |
103
+ | `pollSeconds` | How often the unit is read (at least 5). | `10` |
83
104
 
84
- ## Parameter IDs:
105
+ ## Upgrading from 1.x
85
106
 
86
- The plugin uses the following Systemair SAVEConnect parameter IDs:
107
+ The accessory and the tiles 1.x had (the fan, the Refresh and Eco Mode switches, the thermostat and humidity) stay the same to HomeKit, so scenes and automations that use them keep working. The new tiles appear next to them. See the [changelog](CHANGELOG.md) for what changed.
87
108
 
88
- - **1130**: Fan speed control (0=off, 2=low, 3=medium, 4=high)
89
- - **1110**: Timer remaining time
90
- - **1120**: Current temperature sensor reading
91
- - **2000**: Target temperature setting
92
- - **1125**: Current humidity sensor reading
109
+ ## How it talks to the unit
93
110
 
94
- **Important**: These parameter IDs are based on common Systemair configurations but may vary by device model. If temperature or humidity readings are not working correctly, you may need to modify the parameter IDs in the code to match your specific device's API configuration.
111
+ Like the unit's own web page: `GET /mread?{"1160":1}` reads registers and `GET /mwrite?{"1161":4}` writes them, one request at a time. The registers and modes are listed in [`src/registers.ts`](src/registers.ts) and [`src/modes.ts`](src/modes.ts), each as the web page names it.
112
+
113
+ ## Development
114
+
115
+ ```bash
116
+ npm install
117
+ npm run check # typecheck and tests
118
+ npm run build
119
+ ```
120
+
121
+ The tests cover the register mapping (from a real unit's replies), the client and the accessory on Homebridge's own HomeKit library.
122
+
123
+ **Releasing:** bump the version and push to `master`; GitHub Actions publishes it to npm, tags it, creates the GitHub release from the [changelog](CHANGELOG.md) and posts to Discord.
124
+
125
+ ```bash
126
+ npm version patch # or minor, major, or prerelease --preid beta
127
+ git push --follow-tags
128
+ ```
129
+
130
+ A prerelease (`2.1.0-beta.1`) is published under npm's `beta` tag.
131
+
132
+ ## Licence
133
+
134
+ [MIT](LICENSE)
@@ -0,0 +1,98 @@
1
+ {
2
+ "pluginAlias": "SystemairVentilatorEnhanced",
3
+ "pluginType": "accessory",
4
+ "singular": false,
5
+ "headerDisplay": "Systemair SAVE ventilation through its SAVE Connect module: the fan, a switch per mode for scenes, timers for Refresh and Fireplace, temperature, humidity and the filter.",
6
+ "schema": {
7
+ "type": "object",
8
+ "properties": {
9
+ "name": {
10
+ "title": "Name",
11
+ "type": "string",
12
+ "default": "Ventilation",
13
+ "required": true,
14
+ "description": "Each tile is named after it: \"Ventilation Fan\", \"Ventilation Refresh\"…"
15
+ },
16
+ "ip": {
17
+ "title": "SAVE Connect address",
18
+ "type": "string",
19
+ "required": true,
20
+ "placeholder": "192.168.68.57",
21
+ "description": "The address you open the unit's web page at."
22
+ },
23
+ "pollSeconds": {
24
+ "title": "Read the unit every (seconds)",
25
+ "type": "integer",
26
+ "default": 10,
27
+ "minimum": 5
28
+ },
29
+ "modeSwitches": {
30
+ "title": "Modes to show as switches",
31
+ "description": "For scenes and automations. Only one is on at a time; turning the one that's on off goes back to Auto.",
32
+ "type": "array",
33
+ "uniqueItems": true,
34
+ "default": ["auto", "crowded", "refresh", "fireplace", "away", "holiday"],
35
+ "items": {
36
+ "type": "string",
37
+ "enum": ["auto", "crowded", "refresh", "fireplace", "away", "holiday"],
38
+ "enumNames": ["Auto", "Visitors (Crowded)", "Refresh", "Fireplace", "Away", "Holiday"]
39
+ }
40
+ },
41
+ "timers": {
42
+ "title": "Modes with a timer",
43
+ "description": "A timer tile sets how long the mode lasts (the Home app's Duration, up to an hour) and counts down while it runs.",
44
+ "type": "array",
45
+ "uniqueItems": true,
46
+ "default": ["refresh", "fireplace"],
47
+ "items": {
48
+ "type": "string",
49
+ "enum": ["refresh", "fireplace"],
50
+ "enumNames": ["Refresh", "Fireplace"]
51
+ }
52
+ },
53
+ "modeNames": {
54
+ "title": "Your own names for the modes",
55
+ "type": "object",
56
+ "properties": {
57
+ "auto": { "title": "Auto", "type": "string", "placeholder": "Auto" },
58
+ "crowded": { "title": "Crowded", "type": "string", "placeholder": "Visitors" },
59
+ "refresh": { "title": "Refresh", "type": "string", "placeholder": "Refresh" },
60
+ "fireplace": { "title": "Fireplace", "type": "string", "placeholder": "Fireplace" },
61
+ "away": { "title": "Away", "type": "string", "placeholder": "Away" },
62
+ "holiday": { "title": "Holiday", "type": "string", "placeholder": "Holiday" }
63
+ }
64
+ },
65
+ "eco": {
66
+ "title": "Show the ECO mode switch",
67
+ "type": "boolean",
68
+ "default": true
69
+ },
70
+ "outdoorTemperature": {
71
+ "title": "Show the outdoor temperature",
72
+ "type": "boolean",
73
+ "default": true
74
+ },
75
+ "filter": {
76
+ "title": "Show the filter (time to change it)",
77
+ "type": "boolean",
78
+ "default": true
79
+ }
80
+ }
81
+ },
82
+ "layout": [
83
+ "name",
84
+ "ip",
85
+ {
86
+ "type": "fieldset",
87
+ "title": "Modes",
88
+ "expandable": true,
89
+ "items": ["modeSwitches", "timers", "modeNames"]
90
+ },
91
+ {
92
+ "type": "fieldset",
93
+ "title": "More",
94
+ "expandable": true,
95
+ "items": ["eco", "outdoorTemperature", "filter", "pollSeconds"]
96
+ }
97
+ ]
98
+ }
package/dist/config.js ADDED
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TIMER_MAX_SECONDS = exports.DEFAULT_MODE_NAMES = exports.DEFAULT_TIMERS = exports.DEFAULT_MODE_SWITCHES = void 0;
4
+ exports.readConfig = readConfig;
5
+ const modes_js_1 = require("./modes.js");
6
+ exports.DEFAULT_MODE_SWITCHES = [
7
+ 'auto',
8
+ 'crowded',
9
+ 'refresh',
10
+ 'fireplace',
11
+ 'away',
12
+ 'holiday',
13
+ ];
14
+ exports.DEFAULT_TIMERS = ['refresh', 'fireplace'];
15
+ /** What people call the unit's Crowded mode. */
16
+ exports.DEFAULT_MODE_NAMES = { crowded: 'Visitors' };
17
+ /** The Home app counts a valve's duration down from at most an hour. */
18
+ exports.TIMER_MAX_SECONDS = 3600;
19
+ const MIN_POLL_SECONDS = 5;
20
+ const isRecord = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
21
+ function modeList(value, allowed, fallback) {
22
+ if (!Array.isArray(value))
23
+ return fallback;
24
+ return allowed.filter((id) => value.includes(id));
25
+ }
26
+ /** The plugin's settings from Homebridge's config, with defaults for what's left out. */
27
+ function readConfig(raw) {
28
+ const config = isRecord(raw) ? raw : {};
29
+ const ip = typeof config.ip === 'string' ? config.ip.trim() : '';
30
+ if (!ip)
31
+ throw new Error('Set "ip" to the SAVE Connect module\'s address in the plugin settings');
32
+ const poll = Number(config.pollSeconds);
33
+ const names = isRecord(config.modeNames) ? config.modeNames : {};
34
+ const settable = modes_js_1.SETTABLE_MODES.map((mode) => mode.id).filter((id) => id !== 'manual');
35
+ const timed = modes_js_1.MODES.filter((mode) => mode.duration?.unit === 'minutes').map((mode) => mode.id);
36
+ return {
37
+ name: typeof config.name === 'string' && config.name.trim() ? config.name.trim() : 'Ventilation',
38
+ ip,
39
+ pollSeconds: Number.isFinite(poll) && poll >= MIN_POLL_SECONDS ? poll : 10,
40
+ modeSwitches: modeList(config.modeSwitches, settable, exports.DEFAULT_MODE_SWITCHES),
41
+ timers: modeList(config.timers, timed, exports.DEFAULT_TIMERS),
42
+ modeNames: {
43
+ ...exports.DEFAULT_MODE_NAMES,
44
+ ...Object.fromEntries(modes_js_1.MODES.flatMap((mode) => {
45
+ const name = names[mode.id];
46
+ return typeof name === 'string' && name.trim() ? [[mode.id, name.trim()]] : [];
47
+ })),
48
+ },
49
+ eco: config.eco !== false,
50
+ outdoorTemperature: config.outdoorTemperature !== false,
51
+ filter: config.filter !== false,
52
+ };
53
+ }
54
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ "use strict";
2
+ const ventilator_js_1 = require("./ventilator.js");
3
+ /** The accessory's name in Homebridge's config (`"accessory": "SystemairVentilatorEnhanced"`). */
4
+ const ACCESSORY_NAME = 'SystemairVentilatorEnhanced';
5
+ module.exports = (api) => {
6
+ api.registerAccessory(ACCESSORY_NAME, ventilator_js_1.SystemairVentilator);
7
+ };
8
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA,mDAAsD;AAEtD,kGAAkG;AAClG,MAAM,cAAc,GAAG,6BAA6B,CAAC;AAErD,iBAAS,CAAC,GAAQ,EAAQ,EAAE;IAC1B,GAAG,CAAC,iBAAiB,CAAC,cAAc,EAAE,mCAAmB,CAAC,CAAC;AAC7D,CAAC,CAAC"}
package/dist/modes.js ADDED
@@ -0,0 +1,79 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SETTABLE_MODES = exports.MODES = void 0;
4
+ exports.modeById = modeById;
5
+ exports.modeByCurrent = modeByCurrent;
6
+ exports.durationSeconds = durationSeconds;
7
+ exports.durationValue = durationValue;
8
+ /**
9
+ * The unit's modes. Requests and durations are the web page's own (its mode menu and timer
10
+ * pop-up); Cooker hood and Vacuum cleaner come from Systemair's Modbus list: they're started by
11
+ * switches wired to the unit, so they can be seen but not set.
12
+ */
13
+ exports.MODES = [
14
+ { id: 'auto', name: 'Auto', current: 0, request: 1 },
15
+ { id: 'manual', name: 'Manual', current: 1, request: 2 },
16
+ {
17
+ id: 'crowded',
18
+ name: 'Crowded',
19
+ current: 2,
20
+ request: 3,
21
+ duration: { register: 1104, unit: 'hours', min: 1, max: 8 },
22
+ },
23
+ {
24
+ id: 'refresh',
25
+ name: 'Refresh',
26
+ current: 3,
27
+ request: 4,
28
+ duration: { register: 1103, unit: 'minutes', min: 1, max: 240 },
29
+ },
30
+ {
31
+ id: 'fireplace',
32
+ name: 'Fireplace',
33
+ current: 4,
34
+ request: 5,
35
+ duration: { register: 1102, unit: 'minutes', min: 1, max: 60 },
36
+ },
37
+ {
38
+ id: 'away',
39
+ name: 'Away',
40
+ current: 5,
41
+ request: 6,
42
+ duration: { register: 1101, unit: 'hours', min: 1, max: 72 },
43
+ },
44
+ {
45
+ id: 'holiday',
46
+ name: 'Holiday',
47
+ current: 6,
48
+ request: 7,
49
+ duration: { register: 1100, unit: 'days', min: 1, max: 365 },
50
+ },
51
+ { id: 'cookerHood', name: 'Cooker hood', current: 7 },
52
+ { id: 'vacuumCleaner', name: 'Vacuum cleaner', current: 8 },
53
+ ];
54
+ function modeById(id) {
55
+ const mode = exports.MODES.find((item) => item.id === id);
56
+ if (!mode)
57
+ throw new Error(`Unknown mode ${id}`);
58
+ return mode;
59
+ }
60
+ function modeByCurrent(value) {
61
+ return exports.MODES.find((mode) => mode.current === value);
62
+ }
63
+ /** The modes HomeKit can switch on (everything but those only a wired input starts). */
64
+ exports.SETTABLE_MODES = exports.MODES.filter((mode) => mode.request !== undefined);
65
+ const UNIT_SECONDS = {
66
+ minutes: 60,
67
+ hours: 3600,
68
+ days: 86_400,
69
+ };
70
+ /** A duration in the unit's own terms (35 minutes) as seconds. */
71
+ function durationSeconds(duration, value) {
72
+ return value * UNIT_SECONDS[duration.unit];
73
+ }
74
+ /** Seconds as the unit keeps the duration: whole minutes, hours or days, within its limits. */
75
+ function durationValue(duration, seconds) {
76
+ const value = Math.round(seconds / UNIT_SECONDS[duration.unit]);
77
+ return Math.min(duration.max, Math.max(duration.min, value));
78
+ }
79
+ //# sourceMappingURL=modes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"modes.js","sourceRoot":"","sources":["../src/modes.ts"],"names":[],"mappings":";;;;;;;AA+BA;;;;GAIG;AACU,QAAA,KAAK,GAAqB;IACrC,EAAE,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE;IACpD,EAAE,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE;IACxD;QACE,EAAE,EAAE,SAAS;QACb,IAAI,EAAE,SAAS;QACf,OAAO,EAAE,CAAC;QACV,OAAO,EAAE,CAAC;QACV,QAAQ,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE;KAC5D;IACD;QACE,EAAE,EAAE,SAAS;QACb,IAAI,EAAE,SAAS;QACf,OAAO,EAAE,CAAC;QACV,OAAO,EAAE,CAAC;QACV,QAAQ,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,IAAI,EAAE,SAAS,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,EAAE,GAAG,EAAE;KAChE;IACD;QACE,EAAE,EAAE,WAAW;QACf,IAAI,EAAE,WAAW;QACjB,OAAO,EAAE,CAAC;QACV,OAAO,EAAE,CAAC;QACV,QAAQ,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,IAAI,EAAE,SAAS,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,EAAE,EAAE,EAAE;KAC/D;IACD;QACE,EAAE,EAAE,MAAM;QACV,IAAI,EAAE,MAAM;QACZ,OAAO,EAAE,CAAC;QACV,OAAO,EAAE,CAAC;QACV,QAAQ,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,EAAE,EAAE,EAAE;KAC7D;IACD;QACE,EAAE,EAAE,SAAS;QACb,IAAI,EAAE,SAAS;QACf,OAAO,EAAE,CAAC;QACV,OAAO,EAAE,CAAC;QACV,QAAQ,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,EAAE,GAAG,EAAE;KAC7D;IACD,EAAE,EAAE,EAAE,YAAY,EAAE,IAAI,EAAE,aAAa,EAAE,OAAO,EAAE,CAAC,EAAE;IACrD,EAAE,EAAE,EAAE,eAAe,EAAE,IAAI,EAAE,gBAAgB,EAAE,OAAO,EAAE,CAAC,EAAE;CAC5D,CAAC;AAEF,kBAAyB,EAAW;IAClC,MAAM,IAAI,GAAG,QAAA,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC;IAClD,IAAI,CAAC,IAAI;QAAE,MAAM,IAAI,KAAK,CAAC,gBAAgB,EAAE,EAAE,CAAC,CAAC;IACjD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,uBAA8B,KAAa;IACzC,OAAO,QAAA,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,KAAK,KAAK,CAAC,CAAC;AACtD,CAAC;AAED,wFAAwF;AAC3E,QAAA,cAAc,GAAqB,QAAA,KAAK,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC;AAEnG,MAAM,YAAY,GAA4C;IAC5D,OAAO,EAAE,EAAE;IACX,KAAK,EAAE,IAAI;IACX,IAAI,EAAE,MAAM;CACb,CAAC;AAEF,kEAAkE;AAClE,yBAAgC,QAAuB,EAAE,KAAa;IACpE,OAAO,KAAK,GAAG,YAAY,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;AAC7C,CAAC;AAED,+FAA+F;AAC/F,uBAA8B,QAAuB,EAAE,OAAe;IACpE,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,GAAG,YAAY,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC;IAChE,OAAO,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,GAAG,EAAE,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC,CAAC;AAC/D,CAAC"}
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SETPOINT_RANGE = exports.FAN_LEVELS = exports.REGISTER = void 0;
4
+ exports.fanLevel = fanLevel;
5
+ /**
6
+ * Systemair SAVE Connect registers, as the unit's own web page uses them (its `scripts.js`):
7
+ * `GET /mread?{"1160":1}` reads (register → how many registers), `GET /mwrite?{"1161":4}` writes.
8
+ * Temperatures are tenths of a degree; a time split over two registers is seconds (low, high).
9
+ */
10
+ exports.REGISTER = {
11
+ /** Write to change the mode: see `MODES[].request`. */
12
+ modeRequest: 1161,
13
+ /** The mode it's in now: see `MODES[].current`. */
14
+ modeCurrent: 1160,
15
+ /** Manual mode's airflow: 0 off, 2 low, 3 normal, 4 high. */
16
+ manualLevel: 1130,
17
+ /** Time left in a timed mode, seconds (with 1111). */
18
+ remaining: 1110,
19
+ /** Whether Manual may stop the fans altogether (1 yes). */
20
+ manualStopAllowed: 1352,
21
+ /** What the fans actually run at, percent. */
22
+ supplyFanOutput: 14000,
23
+ extractFanOutput: 14001,
24
+ /** Each airflow level's supply and extract percentages, Minimum to Maximum (1400–1409). */
25
+ fanLevelPercents: 1400,
26
+ /** Extract air temperature (what the house is at), tenths of °C. */
27
+ extractTemperature: 12543,
28
+ outdoorTemperature: 12101,
29
+ supplyTemperature: 12102,
30
+ /** Temperature setpoint, tenths of °C (12–30 °C on the unit). */
31
+ setpoint: 2000,
32
+ /** Relative humidity, percent. */
33
+ humidity: 12135,
34
+ /** ECO mode: 1 on. */
35
+ eco: 2504,
36
+ /** How often the filter is changed, months. */
37
+ filterPeriod: 7000,
38
+ /** Time left until the filter change, seconds (with 7005). */
39
+ filterRemaining: 7004,
40
+ };
41
+ /** The unit's airflow levels, lowest first. */
42
+ exports.FAN_LEVELS = [
43
+ { id: 'off', name: 'Off', value: 0, speed: 0, manual: true },
44
+ { id: 'minimum', name: 'Minimum', value: 1, speed: 8, supplyRegister: 1400, manual: false },
45
+ { id: 'low', name: 'Low', value: 2, speed: 16, supplyRegister: 1402, manual: true },
46
+ { id: 'normal', name: 'Normal', value: 3, speed: 50, supplyRegister: 1404, manual: true },
47
+ { id: 'high', name: 'High', value: 4, speed: 83, supplyRegister: 1406, manual: true },
48
+ { id: 'maximum', name: 'Maximum', value: 5, speed: 100, supplyRegister: 1408, manual: false },
49
+ ];
50
+ function fanLevel(id) {
51
+ const level = exports.FAN_LEVELS.find((item) => item.id === id);
52
+ if (!level)
53
+ throw new Error(`Unknown fan level ${id}`);
54
+ return level;
55
+ }
56
+ /** The unit's setpoint range, °C. */
57
+ exports.SETPOINT_RANGE = { min: 12, max: 30 };
58
+ //# sourceMappingURL=registers.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registers.js","sourceRoot":"","sources":["../src/registers.ts"],"names":[],"mappings":";;;;AAAA;;;;GAIG;AACU,QAAA,QAAQ,GAAG;IACtB,uDAAuD;IACvD,WAAW,EAAE,IAAI;IACjB,mDAAmD;IACnD,WAAW,EAAE,IAAI;IACjB,6DAA6D;IAC7D,WAAW,EAAE,IAAI;IACjB,sDAAsD;IACtD,SAAS,EAAE,IAAI;IACf,2DAA2D;IAC3D,iBAAiB,EAAE,IAAI;IACvB,8CAA8C;IAC9C,eAAe,EAAE,KAAK;IACtB,gBAAgB,EAAE,KAAK;IACvB,2FAA2F;IAC3F,gBAAgB,EAAE,IAAI;IACtB,oEAAoE;IACpE,kBAAkB,EAAE,KAAK;IACzB,kBAAkB,EAAE,KAAK;IACzB,iBAAiB,EAAE,KAAK;IACxB,iEAAiE;IACjE,QAAQ,EAAE,IAAI;IACd,kCAAkC;IAClC,QAAQ,EAAE,KAAK;IACf,sBAAsB;IACtB,GAAG,EAAE,IAAI;IACT,+CAA+C;IAC/C,YAAY,EAAE,IAAI;IAClB,8DAA8D;IAC9D,eAAe,EAAE,IAAI;CACb,CAAC;AAgBX,+CAA+C;AAClC,QAAA,UAAU,GAAyB;IAC9C,EAAE,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE;IAC5D,EAAE,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,cAAc,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE;IAC3F,EAAE,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,cAAc,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE;IACnF,EAAE,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,cAAc,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE;IACzF,EAAE,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,cAAc,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE;IACrF,EAAE,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE,GAAG,EAAE,cAAc,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE;CAC9F,CAAC;AAEF,kBAAyB,EAAa;IACpC,MAAM,KAAK,GAAG,QAAA,UAAU,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC;IACxD,IAAI,CAAC,KAAK;QAAE,MAAM,IAAI,KAAK,CAAC,qBAAqB,EAAE,EAAE,CAAC,CAAC;IACvD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,qCAAqC;AACxB,QAAA,cAAc,GAAG,EAAE,GAAG,EAAE,EAAE,EAAE,GAAG,EAAE,EAAE,EAAW,CAAC"}
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SaveConnect = exports.SaveConnectError = void 0;
4
+ class SaveConnectError extends Error {
5
+ }
6
+ exports.SaveConnectError = SaveConnectError;
7
+ /**
8
+ * Talks to a SAVE Connect module the way its own web page does. Requests go one at a time: the
9
+ * module answers one request at a time, and a read racing a write could return the old value.
10
+ */
11
+ class SaveConnect {
12
+ options;
13
+ queue = Promise.resolve();
14
+ fetch;
15
+ timeoutMs;
16
+ constructor(options) {
17
+ this.options = options;
18
+ this.fetch = options.fetch ?? globalThis.fetch;
19
+ this.timeoutMs = options.timeoutMs ?? 8000;
20
+ }
21
+ /** Reads registers (register → how many) in one request. */
22
+ async read(registers) {
23
+ const text = await this.request('mread', registers);
24
+ let parsed;
25
+ try {
26
+ parsed = JSON.parse(text);
27
+ }
28
+ catch {
29
+ throw new SaveConnectError(`The unit's answer wasn't JSON: ${text.slice(0, 80)}`);
30
+ }
31
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
32
+ throw new SaveConnectError(`The unit's answer wasn't a set of registers: ${text.slice(0, 80)}`);
33
+ const values = {};
34
+ for (const [register, raw] of Object.entries(parsed)) {
35
+ const number = typeof raw === 'number' ? raw : Number(raw);
36
+ if (Number.isFinite(number))
37
+ values[register] = number;
38
+ }
39
+ return values;
40
+ }
41
+ /** Writes registers in one request (lowest register first, as JSON orders them). */
42
+ async write(values) {
43
+ await this.request('mwrite', values);
44
+ }
45
+ request(path, body) {
46
+ const run = async () => {
47
+ const url = `http://${this.options.host}/${path}?${JSON.stringify(body)}`;
48
+ let response;
49
+ try {
50
+ response = await this.fetch(url, { signal: AbortSignal.timeout(this.timeoutMs) });
51
+ }
52
+ catch (error) {
53
+ throw new SaveConnectError(`Couldn't reach the unit at ${this.options.host}: ${error.message}`);
54
+ }
55
+ if (!response.ok)
56
+ throw new SaveConnectError(`The unit answered ${path} with HTTP ${response.status}`);
57
+ return response.text();
58
+ };
59
+ const next = this.queue.then(run, run);
60
+ this.queue = next.catch(() => undefined);
61
+ return next;
62
+ }
63
+ }
64
+ exports.SaveConnect = SaveConnect;
65
+ //# sourceMappingURL=saveConnect.js.map