homebridge-biocat 0.1.1
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/LICENSE +21 -0
- package/README.md +228 -0
- package/config.schema.json +98 -0
- package/dist/biocat-accessory.js +104 -0
- package/dist/biocat-client.js +96 -0
- package/dist/config.js +89 -0
- package/dist/index.js +6 -0
- package/dist/normalizer.js +429 -0
- package/dist/platform.js +149 -0
- package/dist/settings.js +11 -0
- package/dist/statistics-logger.js +166 -0
- package/package.json +35 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AliM
|
|
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
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# homebridge-biocat
|
|
2
|
+
|
|
3
|
+
Ein Homebridge-Plugin fuer WATERCryst BIOCAT-Anlagen auf Basis der offiziellen myBIOCAT REST-API.
|
|
4
|
+
|
|
5
|
+
Das Plugin liest den Geraetestatus ueber `GET /state`, kann den Abwesenheitsmodus schalten, die Wasserzufuhr schliessen und optional auch wieder oeffnen. Zusaetzlich werden Tagesstatistiken als JSONL protokolliert.
|
|
6
|
+
|
|
7
|
+
## Funktionsumfang
|
|
8
|
+
|
|
9
|
+
- Dynamisches Homebridge-Platform-Plugin
|
|
10
|
+
- Native HomeKit-Services fuer BIOCAT-Funktionen
|
|
11
|
+
- `LeakSensor` fuer Leckage- und Stoerungsanzeige
|
|
12
|
+
- `Valve` fuer die Wasserzufuhr
|
|
13
|
+
- `Switch` fuer `Absence Mode`
|
|
14
|
+
- `FilterMaintenance` fuer Wartungs- und Wechselhinweise
|
|
15
|
+
- Offizielle WATERCryst API mit `X-API-KEY`
|
|
16
|
+
- JSONL-Statistiklogging mit Duplikatschutz
|
|
17
|
+
- Persistenz des zuletzt geloggten Statistikdatums ueber Neustarts
|
|
18
|
+
|
|
19
|
+
## Voraussetzungen
|
|
20
|
+
|
|
21
|
+
- Node.js `>= 20.0.0`
|
|
22
|
+
- Homebridge `^1.8.0`
|
|
23
|
+
- Eine aktive myBIOCAT REST-API fuer dein Geraet
|
|
24
|
+
- Ein API-Key aus `app.watercryst.com`
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
Ueber die Homebridge UI nach `homebridge-biocat` suchen und das Plugin installieren.
|
|
29
|
+
|
|
30
|
+
Alternativ per npm:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
sudo npm install -g homebridge-biocat
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Danach das Plugin in der Homebridge UI konfigurieren oder den folgenden Eintrag in `config.json` ergaenzen.
|
|
37
|
+
|
|
38
|
+
## Entwicklung
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npm install
|
|
42
|
+
npm run build
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Homebridge-Konfiguration
|
|
46
|
+
|
|
47
|
+
Beispiel fuer `config.json`:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"platforms": [
|
|
52
|
+
{
|
|
53
|
+
"platform": "BiocatPlatform",
|
|
54
|
+
"name": "BIOCAT",
|
|
55
|
+
"apiBaseUrl": "https://appapi.watercryst.com/v1",
|
|
56
|
+
"apiKey": "YOUR_API_KEY",
|
|
57
|
+
"pollIntervalSeconds": 60,
|
|
58
|
+
"requestTimeoutMs": 15000,
|
|
59
|
+
"allowWaterSupplyOpen": false,
|
|
60
|
+
"statistics": {
|
|
61
|
+
"enabled": true,
|
|
62
|
+
"directory": "biocat",
|
|
63
|
+
"fileName": "statistics.jsonl",
|
|
64
|
+
"stateFileName": ".statistics-state.json"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Konfigurationsoptionen
|
|
72
|
+
|
|
73
|
+
| Feld | Typ | Standard | Beschreibung |
|
|
74
|
+
| --- | --- | --- | --- |
|
|
75
|
+
| `platform` | `string` | - | Muss `BiocatPlatform` sein |
|
|
76
|
+
| `name` | `string` | `BIOCAT` | Anzeigename in Homebridge und HomeKit |
|
|
77
|
+
| `apiBaseUrl` | `string` | `https://appapi.watercryst.com/v1` | Basis-URL der WATERCryst REST-API |
|
|
78
|
+
| `apiKey` | `string` | - | API-Key fuer den Header `X-API-KEY` |
|
|
79
|
+
| `headers` | `object` | `{}` | Optionale zusaetzliche HTTP-Header |
|
|
80
|
+
| `pollIntervalSeconds` | `number` | `60` | Polling-Intervall, intern auf `15` bis `86400` begrenzt |
|
|
81
|
+
| `requestTimeoutMs` | `number` | `15000` | Timeout pro API-Aufruf, intern auf `1000` bis `120000` begrenzt |
|
|
82
|
+
| `allowWaterSupplyOpen` | `boolean` | `false` | Erlaubt das Oeffnen der Wasserzufuhr aus HomeKit heraus |
|
|
83
|
+
| `statistics.enabled` | `boolean` | `true` | Aktiviert das Tagesstatistik-Logging |
|
|
84
|
+
| `statistics.directory` | `string` | `biocat` | Relativer Ordner unterhalb des Homebridge-Storage-Pfads |
|
|
85
|
+
| `statistics.fileName` | `string` | `statistics.jsonl` | Dateiname fuer JSONL-Statistiken |
|
|
86
|
+
| `statistics.stateFileName` | `string` | `.statistics-state.json` | Dateiname fuer den letzten Log-Zustand |
|
|
87
|
+
|
|
88
|
+
Hinweis:
|
|
89
|
+
|
|
90
|
+
- `statusUrl` wird weiterhin als Legacy-Eingabe akzeptiert. Wenn sie auf `/state` endet, wird automatisch die API-Basis-URL daraus abgeleitet.
|
|
91
|
+
- `authToken` wird als Fallback ebenfalls als API-Key akzeptiert.
|
|
92
|
+
|
|
93
|
+
## HomeKit-Abbildung
|
|
94
|
+
|
|
95
|
+
Das Plugin legt ein dynamisches Accessory mit diesen nativen HomeKit-Services an:
|
|
96
|
+
|
|
97
|
+
- `LeakSensor`: zeigt Leckage oder relevante Stoerungen an
|
|
98
|
+
- `Valve`: zeigt den Zustand der Wasserzufuhr und kann sie schliessen
|
|
99
|
+
- `Switch`: `Absence Mode` ein- oder ausschalten
|
|
100
|
+
- `FilterMaintenance`: Wartungs- bzw. Granulatwechsel-Hinweise
|
|
101
|
+
- `AccessoryInformation`: Hersteller, Modell, Seriennummer, Firmware
|
|
102
|
+
|
|
103
|
+
## Schaltfunktionen in HomeKit
|
|
104
|
+
|
|
105
|
+
### Absence Mode
|
|
106
|
+
|
|
107
|
+
Der BIOCAT-Abwesenheitsmodus wird als nativer `Switch` exponiert. Dadurch kannst du in der Home-App ganz normale Automationen verwenden, zum Beispiel:
|
|
108
|
+
|
|
109
|
+
- Wenn die letzte Person das Haus verlaesst, `Absence Mode` einschalten
|
|
110
|
+
- Wenn die erste Person nach Hause kommt, `Absence Mode` ausschalten
|
|
111
|
+
|
|
112
|
+
Intern nutzt das Plugin:
|
|
113
|
+
|
|
114
|
+
- `GET /absence/enable`
|
|
115
|
+
- `GET /absence/disable`
|
|
116
|
+
|
|
117
|
+
### Water Supply Valve
|
|
118
|
+
|
|
119
|
+
Die Wasserzufuhr wird als HomeKit-`Valve` exponiert.
|
|
120
|
+
|
|
121
|
+
Damit sind Automationen moeglich wie:
|
|
122
|
+
|
|
123
|
+
- Wenn ein HomeKit-Wassersensor ein Leck erkennt, `Water Supply` ausschalten
|
|
124
|
+
|
|
125
|
+
Intern nutzt das Plugin:
|
|
126
|
+
|
|
127
|
+
- `GET /watersupply/close`
|
|
128
|
+
- `GET /watersupply/open`
|
|
129
|
+
|
|
130
|
+
Sicherheitsverhalten:
|
|
131
|
+
|
|
132
|
+
- Schliessen ist immer erlaubt
|
|
133
|
+
- Oeffnen ist standardmaessig gesperrt
|
|
134
|
+
- Wenn du das Oeffnen aus HomeKit erlauben willst, setze `allowWaterSupplyOpen` auf `true`
|
|
135
|
+
|
|
136
|
+
## Verwendete BIOCAT-Endpunkte
|
|
137
|
+
|
|
138
|
+
- `GET /state`
|
|
139
|
+
- `GET /statistics/daily/direct`
|
|
140
|
+
- Fallback: `GET /statistics/cumulative/daily`
|
|
141
|
+
- `GET /absence/enable`
|
|
142
|
+
- `GET /absence/disable`
|
|
143
|
+
- `GET /watersupply/close`
|
|
144
|
+
- Optional: `GET /watersupply/open`
|
|
145
|
+
|
|
146
|
+
## Erwartete State-Antwort
|
|
147
|
+
|
|
148
|
+
Das Plugin ist auf die offizielle API-Struktur ausgelegt. Wichtige Felder sind:
|
|
149
|
+
|
|
150
|
+
- `online`
|
|
151
|
+
- `mode.id`
|
|
152
|
+
- `mode.name`
|
|
153
|
+
- `event`
|
|
154
|
+
- `waterProtection.absenceModeEnabled`
|
|
155
|
+
- `waterProtection.pauseLeakageProtectionUntilUTC`
|
|
156
|
+
- `mlState`
|
|
157
|
+
|
|
158
|
+
Beispiel:
|
|
159
|
+
|
|
160
|
+
```json
|
|
161
|
+
{
|
|
162
|
+
"online": true,
|
|
163
|
+
"mode": {
|
|
164
|
+
"id": "WT",
|
|
165
|
+
"name": "Water Treatment"
|
|
166
|
+
},
|
|
167
|
+
"event": {},
|
|
168
|
+
"waterProtection": {
|
|
169
|
+
"absenceModeEnabled": false,
|
|
170
|
+
"pauseLeakageProtectionUntilUTC": "2026-03-15T10:00:00Z"
|
|
171
|
+
},
|
|
172
|
+
"mlState": "idle"
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Statistiklogging
|
|
177
|
+
|
|
178
|
+
Wenn `statistics.enabled` aktiv ist, schreibt das Plugin Tagesstatistiken als JSONL.
|
|
179
|
+
|
|
180
|
+
Speicherorte:
|
|
181
|
+
|
|
182
|
+
- JSONL-Datei: `<homebridge-storage>/<statistics.directory>/<statistics.fileName>`
|
|
183
|
+
- State-Datei: `<homebridge-storage>/<statistics.directory>/<statistics.stateFileName>`
|
|
184
|
+
|
|
185
|
+
Eigenschaften:
|
|
186
|
+
|
|
187
|
+
- Verzeichnisse werden automatisch angelegt
|
|
188
|
+
- Schreiben erfolgt append-sicher
|
|
189
|
+
- Doppelte Statistik-Eintraege werden verhindert
|
|
190
|
+
- Das zuletzt geloggte Datum bleibt ueber Neustarts erhalten
|
|
191
|
+
- Falls die State-Datei fehlt, wird der letzte Stand aus der JSONL-Datei rekonstruiert
|
|
192
|
+
|
|
193
|
+
## Projektstruktur
|
|
194
|
+
|
|
195
|
+
- `src/index.ts`: Homebridge-Registrierung
|
|
196
|
+
- `src/platform.ts`: Plattform-Lebenszyklus, Polling und Command-Ausfuehrung
|
|
197
|
+
- `src/biocat-client.ts`: REST-Client fuer die offizielle API
|
|
198
|
+
- `src/normalizer.ts`: Defensive Normalisierung von `state` und Statistikantworten
|
|
199
|
+
- `src/biocat-accessory.ts`: HomeKit-Service-Mapping und `onSet`-Handler
|
|
200
|
+
- `src/statistics-logger.ts`: JSONL-Logging mit Zustands-Persistenz
|
|
201
|
+
- `src/config.ts`: Konfig-Aufbereitung und Defaultwerte
|
|
202
|
+
- `config.schema.json`: Homebridge UI-Konfigurationsformular
|
|
203
|
+
|
|
204
|
+
Build:
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
npm install
|
|
208
|
+
npm run build
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Paket pruefen:
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
npm pack --dry-run
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Veroeffentlichen:
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
npm publish
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## Hinweise
|
|
224
|
+
|
|
225
|
+
- Ohne `apiKey` bleibt die Plattform absichtlich inaktiv.
|
|
226
|
+
- Laut offizieller API sind maximal 10 Requests pro Sekunde sowie 200 Requests in 15 Minuten pro Kunde und Geraet erlaubt.
|
|
227
|
+
- Das Plugin behandelt fehlende oder leere `event`- und `waterProtection`-Objekte defensiv.
|
|
228
|
+
- Leckage wird in der offiziellen API nicht als eigenes boolesches Feld geliefert. Das Plugin leitet sie deshalb aus `mlState`, Eventdaten und bekannten Textmustern ab.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
{
|
|
2
|
+
"pluginAlias": "BiocatPlatform",
|
|
3
|
+
"pluginType": "platform",
|
|
4
|
+
"singular": true,
|
|
5
|
+
"strictValidation": false,
|
|
6
|
+
"schema": {
|
|
7
|
+
"type": "object",
|
|
8
|
+
"required": [
|
|
9
|
+
"name",
|
|
10
|
+
"apiKey"
|
|
11
|
+
],
|
|
12
|
+
"additionalProperties": false,
|
|
13
|
+
"properties": {
|
|
14
|
+
"name": {
|
|
15
|
+
"title": "Name",
|
|
16
|
+
"type": "string",
|
|
17
|
+
"default": "BIOCAT",
|
|
18
|
+
"minLength": 1,
|
|
19
|
+
"required": true,
|
|
20
|
+
"description": "Display name used by Homebridge and HomeKit."
|
|
21
|
+
},
|
|
22
|
+
"apiBaseUrl": {
|
|
23
|
+
"title": "API Base URL",
|
|
24
|
+
"type": "string",
|
|
25
|
+
"format": "uri",
|
|
26
|
+
"default": "https://appapi.watercryst.com/v1",
|
|
27
|
+
"description": "Base URL of the WATERCryst myBIOCAT REST API."
|
|
28
|
+
},
|
|
29
|
+
"apiKey": {
|
|
30
|
+
"title": "API Key",
|
|
31
|
+
"type": "string",
|
|
32
|
+
"format": "password",
|
|
33
|
+
"minLength": 1,
|
|
34
|
+
"required": true,
|
|
35
|
+
"description": "API key from app.watercryst.com. It is sent as the X-API-KEY header."
|
|
36
|
+
},
|
|
37
|
+
"pollIntervalSeconds": {
|
|
38
|
+
"title": "Polling Interval",
|
|
39
|
+
"type": "integer",
|
|
40
|
+
"default": 60,
|
|
41
|
+
"minimum": 15,
|
|
42
|
+
"maximum": 86400,
|
|
43
|
+
"description": "Seconds between BIOCAT status refreshes."
|
|
44
|
+
},
|
|
45
|
+
"requestTimeoutMs": {
|
|
46
|
+
"title": "Request Timeout",
|
|
47
|
+
"type": "integer",
|
|
48
|
+
"default": 15000,
|
|
49
|
+
"minimum": 1000,
|
|
50
|
+
"maximum": 120000,
|
|
51
|
+
"description": "Maximum time in milliseconds for one API request."
|
|
52
|
+
},
|
|
53
|
+
"allowWaterSupplyOpen": {
|
|
54
|
+
"title": "Allow Opening Water Supply",
|
|
55
|
+
"type": "boolean",
|
|
56
|
+
"default": false,
|
|
57
|
+
"description": "Allows HomeKit to reopen the water supply. Closing remains available regardless of this setting."
|
|
58
|
+
},
|
|
59
|
+
"statistics": {
|
|
60
|
+
"title": "Statistics Logging",
|
|
61
|
+
"type": "object",
|
|
62
|
+
"additionalProperties": false,
|
|
63
|
+
"default": {
|
|
64
|
+
"enabled": true,
|
|
65
|
+
"directory": "biocat",
|
|
66
|
+
"fileName": "statistics.jsonl",
|
|
67
|
+
"stateFileName": ".statistics-state.json"
|
|
68
|
+
},
|
|
69
|
+
"properties": {
|
|
70
|
+
"enabled": {
|
|
71
|
+
"title": "Enabled",
|
|
72
|
+
"type": "boolean",
|
|
73
|
+
"default": true,
|
|
74
|
+
"description": "Writes daily statistics to a JSONL file in the Homebridge storage directory."
|
|
75
|
+
},
|
|
76
|
+
"directory": {
|
|
77
|
+
"title": "Directory",
|
|
78
|
+
"type": "string",
|
|
79
|
+
"default": "biocat",
|
|
80
|
+
"description": "Relative directory below the Homebridge storage path."
|
|
81
|
+
},
|
|
82
|
+
"fileName": {
|
|
83
|
+
"title": "File Name",
|
|
84
|
+
"type": "string",
|
|
85
|
+
"default": "statistics.jsonl",
|
|
86
|
+
"description": "JSONL file name for daily statistics."
|
|
87
|
+
},
|
|
88
|
+
"stateFileName": {
|
|
89
|
+
"title": "State File Name",
|
|
90
|
+
"type": "string",
|
|
91
|
+
"default": ".statistics-state.json",
|
|
92
|
+
"description": "File name used to remember the last logged statistics entry."
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BiocatAccessory = void 0;
|
|
4
|
+
class BiocatAccessory {
|
|
5
|
+
platform;
|
|
6
|
+
accessory;
|
|
7
|
+
informationService;
|
|
8
|
+
leakSensorService;
|
|
9
|
+
valveService;
|
|
10
|
+
absenceSwitchService;
|
|
11
|
+
maintenanceService;
|
|
12
|
+
currentSnapshot;
|
|
13
|
+
constructor(platform, accessory) {
|
|
14
|
+
this.platform = platform;
|
|
15
|
+
this.accessory = accessory;
|
|
16
|
+
this.informationService = this.accessory.getService(this.platform.Service.AccessoryInformation)
|
|
17
|
+
?? this.accessory.addService(this.platform.Service.AccessoryInformation);
|
|
18
|
+
this.leakSensorService = this.accessory.getServiceById(this.platform.Service.LeakSensor, 'leak-sensor')
|
|
19
|
+
?? this.accessory.addService(this.platform.Service.LeakSensor, `${this.platform.accessoryName} Leak Protection`, 'leak-sensor');
|
|
20
|
+
this.valveService = this.accessory.getServiceById(this.platform.Service.Valve, 'water-supply')
|
|
21
|
+
?? this.accessory.addService(this.platform.Service.Valve, `${this.platform.accessoryName} Water Supply`, 'water-supply');
|
|
22
|
+
this.absenceSwitchService = this.accessory.getServiceById(this.platform.Service.Switch, 'absence-mode')
|
|
23
|
+
?? this.accessory.addService(this.platform.Service.Switch, `${this.platform.accessoryName} Absence Mode`, 'absence-mode');
|
|
24
|
+
this.maintenanceService = this.accessory.getServiceById(this.platform.Service.FilterMaintenance, 'maintenance')
|
|
25
|
+
?? this.accessory.addService(this.platform.Service.FilterMaintenance, `${this.platform.accessoryName} Maintenance`, 'maintenance');
|
|
26
|
+
this.leakSensorService.setCharacteristic(this.platform.Characteristic.Name, `${this.platform.accessoryName} Leak Protection`);
|
|
27
|
+
this.absenceSwitchService.setCharacteristic(this.platform.Characteristic.Name, `${this.platform.accessoryName} Absence Mode`);
|
|
28
|
+
this.maintenanceService.setCharacteristic(this.platform.Characteristic.Name, `${this.platform.accessoryName} Maintenance`);
|
|
29
|
+
this.valveService
|
|
30
|
+
.setCharacteristic(this.platform.Characteristic.Name, `${this.platform.accessoryName} Water Supply`)
|
|
31
|
+
.setCharacteristic(this.platform.Characteristic.ValveType, this.platform.Characteristic.ValveType.GENERIC_VALVE);
|
|
32
|
+
this.absenceSwitchService
|
|
33
|
+
.getCharacteristic(this.platform.Characteristic.On)
|
|
34
|
+
.onGet(() => this.currentSnapshot?.waterProtection.absenceModeEnabled ?? false)
|
|
35
|
+
.onSet(async (value) => {
|
|
36
|
+
await this.platform.setAbsenceMode(Boolean(value));
|
|
37
|
+
});
|
|
38
|
+
this.valveService
|
|
39
|
+
.getCharacteristic(this.platform.Characteristic.Active)
|
|
40
|
+
.onGet(() => this.isWaterSupplyOpen()
|
|
41
|
+
? this.platform.Characteristic.Active.ACTIVE
|
|
42
|
+
: this.platform.Characteristic.Active.INACTIVE)
|
|
43
|
+
.onSet(async (value) => {
|
|
44
|
+
const desiredOpen = Number(value) === this.platform.Characteristic.Active.ACTIVE;
|
|
45
|
+
await this.platform.setWaterSupplyOpen(desiredOpen);
|
|
46
|
+
});
|
|
47
|
+
this.valveService
|
|
48
|
+
.getCharacteristic(this.platform.Characteristic.InUse)
|
|
49
|
+
.onGet(() => this.isWaterSupplyOpen()
|
|
50
|
+
? this.platform.Characteristic.InUse.IN_USE
|
|
51
|
+
: this.platform.Characteristic.InUse.NOT_IN_USE);
|
|
52
|
+
}
|
|
53
|
+
update(snapshot) {
|
|
54
|
+
this.currentSnapshot = snapshot;
|
|
55
|
+
const leakServiceFault = !snapshot.online ||
|
|
56
|
+
snapshot.waterProtection.faultActive ||
|
|
57
|
+
snapshot.event.severity === 'error' ||
|
|
58
|
+
snapshot.event.severity === 'alarm';
|
|
59
|
+
this.informationService
|
|
60
|
+
.setCharacteristic(this.platform.Characteristic.Manufacturer, snapshot.manufacturer)
|
|
61
|
+
.setCharacteristic(this.platform.Characteristic.Model, snapshot.model)
|
|
62
|
+
.setCharacteristic(this.platform.Characteristic.SerialNumber, snapshot.serialNumber)
|
|
63
|
+
.setCharacteristic(this.platform.Characteristic.FirmwareRevision, snapshot.firmwareVersion);
|
|
64
|
+
this.leakSensorService
|
|
65
|
+
.updateCharacteristic(this.platform.Characteristic.LeakDetected, snapshot.waterProtection.leakDetected
|
|
66
|
+
? this.platform.Characteristic.LeakDetected.LEAK_DETECTED
|
|
67
|
+
: this.platform.Characteristic.LeakDetected.LEAK_NOT_DETECTED)
|
|
68
|
+
.updateCharacteristic(this.platform.Characteristic.StatusFault, leakServiceFault
|
|
69
|
+
? this.platform.Characteristic.StatusFault.GENERAL_FAULT
|
|
70
|
+
: this.platform.Characteristic.StatusFault.NO_FAULT)
|
|
71
|
+
.updateCharacteristic(this.platform.Characteristic.StatusActive, snapshot.waterProtection.protectionActive);
|
|
72
|
+
this.absenceSwitchService.updateCharacteristic(this.platform.Characteristic.On, snapshot.waterProtection.absenceModeEnabled);
|
|
73
|
+
this.valveService
|
|
74
|
+
.updateCharacteristic(this.platform.Characteristic.Active, this.isWaterSupplyOpen()
|
|
75
|
+
? this.platform.Characteristic.Active.ACTIVE
|
|
76
|
+
: this.platform.Characteristic.Active.INACTIVE)
|
|
77
|
+
.updateCharacteristic(this.platform.Characteristic.InUse, this.isWaterSupplyOpen()
|
|
78
|
+
? this.platform.Characteristic.InUse.IN_USE
|
|
79
|
+
: this.platform.Characteristic.InUse.NOT_IN_USE);
|
|
80
|
+
this.maintenanceService.updateCharacteristic(this.platform.Characteristic.FilterChangeIndication, snapshot.maintenance.changeRequired
|
|
81
|
+
? this.platform.Characteristic.FilterChangeIndication.CHANGE_FILTER
|
|
82
|
+
: this.platform.Characteristic.FilterChangeIndication.FILTER_OK);
|
|
83
|
+
if (snapshot.maintenance.filterLifeLevel !== undefined) {
|
|
84
|
+
this.maintenanceService.updateCharacteristic(this.platform.Characteristic.FilterLifeLevel, snapshot.maintenance.filterLifeLevel);
|
|
85
|
+
}
|
|
86
|
+
this.accessory.context.lastSnapshot = {
|
|
87
|
+
refreshedAt: snapshot.refreshedAt,
|
|
88
|
+
online: snapshot.online,
|
|
89
|
+
modeId: snapshot.modeId,
|
|
90
|
+
eventSeverity: snapshot.event.severity,
|
|
91
|
+
absenceModeEnabled: snapshot.waterProtection.absenceModeEnabled,
|
|
92
|
+
leakDetected: snapshot.waterProtection.leakDetected,
|
|
93
|
+
valveClosed: snapshot.waterProtection.valveClosed,
|
|
94
|
+
statisticsLogDate: snapshot.statistics?.logDate,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
markConnectionFault() {
|
|
98
|
+
this.leakSensorService.updateCharacteristic(this.platform.Characteristic.StatusFault, this.platform.Characteristic.StatusFault.GENERAL_FAULT);
|
|
99
|
+
}
|
|
100
|
+
isWaterSupplyOpen() {
|
|
101
|
+
return !(this.currentSnapshot?.waterProtection.valveClosed ?? false);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
exports.BiocatAccessory = BiocatAccessory;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BiocatClient = void 0;
|
|
4
|
+
class BiocatApiError extends Error {
|
|
5
|
+
status;
|
|
6
|
+
constructor(message, status) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.name = 'BiocatApiError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
class BiocatClient {
|
|
13
|
+
config;
|
|
14
|
+
constructor(config) {
|
|
15
|
+
this.config = config;
|
|
16
|
+
}
|
|
17
|
+
async fetchState() {
|
|
18
|
+
return this.getJson('/state');
|
|
19
|
+
}
|
|
20
|
+
async fetchDailyStatistics() {
|
|
21
|
+
try {
|
|
22
|
+
return await this.getJson('/statistics/daily/direct');
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
if (!(error instanceof BiocatApiError) || error.status !== 400) {
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const dailyConsumption = await this.getJson('/statistics/cumulative/daily');
|
|
30
|
+
return {
|
|
31
|
+
type: 'statistics',
|
|
32
|
+
entries: [
|
|
33
|
+
{
|
|
34
|
+
consumption: typeof dailyConsumption === 'number' ? dailyConsumption : Number(dailyConsumption),
|
|
35
|
+
date: new Date().toISOString(),
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
async setAbsenceMode(enabled) {
|
|
41
|
+
await this.invokeCommand(enabled ? '/absence/enable' : '/absence/disable');
|
|
42
|
+
}
|
|
43
|
+
async setWaterSupplyOpen(open) {
|
|
44
|
+
await this.invokeCommand(open ? '/watersupply/open' : '/watersupply/close');
|
|
45
|
+
}
|
|
46
|
+
async invokeCommand(path) {
|
|
47
|
+
await this.request(path);
|
|
48
|
+
}
|
|
49
|
+
async getJson(path, query) {
|
|
50
|
+
const responseText = await this.request(path, query);
|
|
51
|
+
if (responseText.trim() === '') {
|
|
52
|
+
throw new Error(`BIOCAT response was empty for ${path}.`);
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(responseText);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
throw new Error(`BIOCAT response was not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async request(path, query) {
|
|
62
|
+
if (!this.config.apiKey) {
|
|
63
|
+
throw new Error('No BIOCAT apiKey configured.');
|
|
64
|
+
}
|
|
65
|
+
const controller = new AbortController();
|
|
66
|
+
const timeout = setTimeout(() => controller.abort(), this.config.requestTimeoutMs);
|
|
67
|
+
try {
|
|
68
|
+
const headers = new Headers(this.config.headers);
|
|
69
|
+
headers.set('Accept', 'application/json');
|
|
70
|
+
headers.set('X-API-KEY', this.config.apiKey);
|
|
71
|
+
const url = new URL(path.replace(/^\//, ''), `${this.config.apiBaseUrl}/`);
|
|
72
|
+
if (query) {
|
|
73
|
+
for (const [key, value] of Object.entries(query)) {
|
|
74
|
+
if (value === undefined) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
url.searchParams.set(key, String(value));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const response = await fetch(url, {
|
|
81
|
+
method: 'GET',
|
|
82
|
+
headers,
|
|
83
|
+
signal: controller.signal,
|
|
84
|
+
});
|
|
85
|
+
const responseText = await response.text();
|
|
86
|
+
if (!response.ok) {
|
|
87
|
+
throw new BiocatApiError(`BIOCAT request failed for ${path} with HTTP ${response.status} ${response.statusText}`, response.status);
|
|
88
|
+
}
|
|
89
|
+
return responseText;
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
clearTimeout(timeout);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
exports.BiocatClient = BiocatClient;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.resolvePlatformConfig = resolvePlatformConfig;
|
|
7
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
8
|
+
const settings_1 = require("./settings");
|
|
9
|
+
function clampInt(value, fallback, min, max) {
|
|
10
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
11
|
+
return Math.min(max, Math.max(min, Math.round(value)));
|
|
12
|
+
}
|
|
13
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
14
|
+
const parsed = Number(value);
|
|
15
|
+
if (Number.isFinite(parsed)) {
|
|
16
|
+
return Math.min(max, Math.max(min, Math.round(parsed)));
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return fallback;
|
|
20
|
+
}
|
|
21
|
+
function sanitizeRelativeDirectory(input, fallback) {
|
|
22
|
+
if (typeof input !== 'string' || input.trim() === '') {
|
|
23
|
+
return fallback;
|
|
24
|
+
}
|
|
25
|
+
const normalized = node_path_1.default
|
|
26
|
+
.normalize(input.trim())
|
|
27
|
+
.replace(/^([/\\])+/, '')
|
|
28
|
+
.replace(/^(\.\.(\/|\\|$))+/, '');
|
|
29
|
+
if (normalized === '' || normalized === '.') {
|
|
30
|
+
return fallback;
|
|
31
|
+
}
|
|
32
|
+
return normalized;
|
|
33
|
+
}
|
|
34
|
+
function sanitizeFileName(input, fallback) {
|
|
35
|
+
if (typeof input !== 'string' || input.trim() === '') {
|
|
36
|
+
return fallback;
|
|
37
|
+
}
|
|
38
|
+
const sanitized = node_path_1.default.basename(input.trim());
|
|
39
|
+
return sanitized === '' || sanitized === '.' ? fallback : sanitized;
|
|
40
|
+
}
|
|
41
|
+
function normalizeHeaders(headers) {
|
|
42
|
+
if (!headers) {
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
const normalized = {};
|
|
46
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
47
|
+
if (typeof key !== 'string' || key.trim() === '') {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
51
|
+
normalized[key] = value;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
55
|
+
normalized[key] = String(value);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return normalized;
|
|
59
|
+
}
|
|
60
|
+
function normalizeApiBaseUrl(apiBaseUrl, statusUrl) {
|
|
61
|
+
const configuredValue = typeof apiBaseUrl === 'string' && apiBaseUrl.trim() !== ''
|
|
62
|
+
? apiBaseUrl.trim()
|
|
63
|
+
: typeof statusUrl === 'string' && statusUrl.trim() !== ''
|
|
64
|
+
? statusUrl.trim()
|
|
65
|
+
: settings_1.DEFAULT_API_BASE_URL;
|
|
66
|
+
const trimmed = configuredValue.replace(/\/+$/, '');
|
|
67
|
+
return trimmed.endsWith('/state') ? trimmed.slice(0, -'/state'.length) : trimmed;
|
|
68
|
+
}
|
|
69
|
+
function resolvePlatformConfig(config) {
|
|
70
|
+
return {
|
|
71
|
+
name: typeof config.name === 'string' && config.name.trim() !== '' ? config.name.trim() : 'BIOCAT',
|
|
72
|
+
apiBaseUrl: normalizeApiBaseUrl(config.apiBaseUrl, config.statusUrl),
|
|
73
|
+
apiKey: typeof config.apiKey === 'string' && config.apiKey.trim() !== ''
|
|
74
|
+
? config.apiKey.trim()
|
|
75
|
+
: typeof config.authToken === 'string' && config.authToken.trim() !== ''
|
|
76
|
+
? config.authToken.trim()
|
|
77
|
+
: undefined,
|
|
78
|
+
allowWaterSupplyOpen: config.allowWaterSupplyOpen ?? false,
|
|
79
|
+
headers: normalizeHeaders(config.headers),
|
|
80
|
+
pollIntervalSeconds: clampInt(config.pollIntervalSeconds, settings_1.DEFAULT_POLL_INTERVAL_SECONDS, 15, 86_400),
|
|
81
|
+
requestTimeoutMs: clampInt(config.requestTimeoutMs, settings_1.DEFAULT_REQUEST_TIMEOUT_MS, 1_000, 120_000),
|
|
82
|
+
statistics: {
|
|
83
|
+
enabled: config.statistics?.enabled ?? true,
|
|
84
|
+
directory: sanitizeRelativeDirectory(config.statistics?.directory, settings_1.DEFAULT_LOG_DIRECTORY),
|
|
85
|
+
fileName: sanitizeFileName(config.statistics?.fileName, settings_1.DEFAULT_STATISTICS_FILE_NAME),
|
|
86
|
+
stateFileName: sanitizeFileName(config.statistics?.stateFileName, settings_1.DEFAULT_STATISTICS_STATE_FILE_NAME),
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|