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 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
+ }
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ "use strict";
2
+ const settings_1 = require("./settings");
3
+ const platform_1 = require("./platform");
4
+ module.exports = (api) => {
5
+ api.registerPlatform(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, platform_1.BiocatPlatform);
6
+ };