homebridge-windy-camera 1.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/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # homebridge-windy-camera
2
+
3
+ Captures a live Windy.com radar map screenshot on a timer and keeps it
4
+ registered as a camera accessory inside
5
+ [homebridge-camera-ui](https://www.npmjs.com/package/homebridge-camera-ui).
6
+
7
+ This plugin does not implement HomeKit camera streaming itself — it
8
+ relies on `homebridge-camera-ui` (which must already be installed and
9
+ configured) to handle the actual HomeKit video pipeline. Instead, it:
10
+
11
+ 1. Periodically launches headless Chromium (via `playwright-core`) to
12
+ screenshot the Windy.com embed map for a configured location, and
13
+ saves it to disk.
14
+ 2. On every Homebridge start, makes sure a matching camera entry exists
15
+ in `homebridge-camera-ui`'s configuration and database — recreating
16
+ it automatically if it's missing (for example after a partial
17
+ backup restore).
18
+
19
+ ## Requirements
20
+
21
+ - `homebridge-camera-ui` already installed and configured.
22
+ - A system-installed Chromium browser (e.g. `/usr/bin/chromium` on
23
+ Raspberry Pi OS / Debian).
24
+
25
+ ## Installation
26
+
27
+ ```
28
+ sudo npm install -g homebridge-windy-camera
29
+ ```
30
+
31
+ The plugin's `postinstall` script will automatically create a
32
+ Homebridge `prestart` hook (under `/etc/hb-service/homebridge/prestart.d/`)
33
+ that keeps the camera-ui configuration in sync **before** Homebridge
34
+ starts. This is necessary because `homebridge-camera-ui` overwrites its
35
+ own configuration from an internal database on every startup, before
36
+ any regular plugin gets a chance to run.
37
+
38
+ ## Configuration
39
+
40
+ Add a platform block to your Homebridge `config.json`:
41
+
42
+ ```json
43
+ {
44
+ "platform": "WindyCamera",
45
+ "name": "מכ״ם גשם",
46
+ "lat": 32.3167,
47
+ "lon": 34.9351,
48
+ "width": 1300,
49
+ "height": 900,
50
+ "captureIntervalMs": 120000
51
+ }
52
+ ```
53
+
54
+ | Option | Default | Description |
55
+ |---------------------|-----------------------|----------------------------------------------------------|
56
+ | `name` | `מכ״ם גשם` | Camera name shown in HomeKit / used to match the entry. |
57
+ | `lat` / `lon` | Tel Aviv area | Map center coordinates. |
58
+ | `width` / `height` | 1300 / 900 | Screenshot resolution. |
59
+ | `captureIntervalMs` | 120000 (2 minutes) | How often to refresh the radar image. |
60
+ | `chromiumPath` | `/usr/bin/chromium` | Path to the system Chromium binary. |
61
+
62
+ ## How it works
63
+
64
+ The plugin does not register any HomeKit accessories of its own — its
65
+ only job is to (a) keep a radar screenshot fresh on disk and (b) make
66
+ sure `homebridge-camera-ui` has a `videoConfig` entry pointing at that
67
+ image. The actual HomeKit camera protocol is entirely handled by
68
+ `homebridge-camera-ui`.
package/index.js ADDED
@@ -0,0 +1,410 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const PLATFORM_NAME = 'WindyCamera';
7
+ const CAMERA_UI_PLATFORM_TYPE = 'CameraUI';
8
+
9
+ const DEFAULT_NAME = 'מכ״ם גשם';
10
+ const DEFAULT_LAT = 32.3167;
11
+ const DEFAULT_LON = 34.9351;
12
+ const DEFAULT_WIDTH = 1300;
13
+ const DEFAULT_HEIGHT = 900;
14
+ const DEFAULT_CAPTURE_INTERVAL_MS = 120000;
15
+ const DEFAULT_CHROMIUM_PATH = '/usr/bin/chromium';
16
+
17
+ module.exports = (api) => {
18
+ api.registerPlatform(PLATFORM_NAME, WindyCameraPlatform);
19
+ };
20
+
21
+ class WindyCameraPlatform {
22
+ constructor(log, config, api) {
23
+ this.log = log;
24
+ this.config = config || {};
25
+ this.api = api;
26
+
27
+ this.name = this.config.name || DEFAULT_NAME;
28
+ this.lat = this.numberOr(this.config.lat, DEFAULT_LAT);
29
+ this.lon = this.numberOr(this.config.lon, DEFAULT_LON);
30
+ this.width = this.numberOr(this.config.width, DEFAULT_WIDTH);
31
+ this.height = this.numberOr(this.config.height, DEFAULT_HEIGHT);
32
+ this.captureIntervalMs = this.numberOr(
33
+ this.config.captureIntervalMs,
34
+ DEFAULT_CAPTURE_INTERVAL_MS
35
+ );
36
+ this.chromiumPath = this.config.chromiumPath || DEFAULT_CHROMIUM_PATH;
37
+
38
+ const storageDir = path.join(
39
+ this.api.user.storagePath(),
40
+ 'windy-camera'
41
+ );
42
+
43
+ this.imageDir = storageDir;
44
+ this.imagePath = this.config.imagePath ||
45
+ path.join(storageDir, 'rain.jpg');
46
+
47
+ this.captureUrl = this.buildCaptureUrl();
48
+
49
+ this.cameraUIDatabasePath = this.config.cameraUIDatabasePath ||
50
+ path.join(
51
+ this.api.user.storagePath(),
52
+ 'camera.ui',
53
+ 'database',
54
+ 'database.json'
55
+ );
56
+
57
+ this.captureTimer = null;
58
+ this.capturing = false;
59
+
60
+ this.log.info(
61
+ `Windy Camera platform loaded. Image path: ${this.imagePath}`
62
+ );
63
+
64
+ this.api.on('didFinishLaunching', () => {
65
+ this.onLaunched();
66
+ });
67
+
68
+ this.api.on('shutdown', () => {
69
+ this.stopCaptureLoop();
70
+ });
71
+ }
72
+
73
+ configureAccessory() {}
74
+
75
+ numberOr(value, fallback) {
76
+ const n = Number(value);
77
+ return Number.isFinite(n) ? n : fallback;
78
+ }
79
+
80
+ buildCaptureUrl() {
81
+ const params = new URLSearchParams({
82
+ lat: String(this.lat),
83
+ lon: String(this.lon),
84
+ detailLat: String(this.lat),
85
+ detailLon: String(this.lon),
86
+ width: String(this.width),
87
+ height: String(this.height),
88
+ zoom: '9',
89
+ level: 'surface',
90
+ overlay: 'radar',
91
+ product: 'radar',
92
+ menu: '',
93
+ message: 'true',
94
+ marker: '',
95
+ calendar: 'now',
96
+ pressure: 'false',
97
+ type: 'map',
98
+ location: 'coordinates',
99
+ detail: '',
100
+ metricWind: 'default',
101
+ metricTemp: 'default',
102
+ radarRange: '-1'
103
+ });
104
+
105
+ return `https://embed.windy.com/embed2.html?${params.toString()}`;
106
+ }
107
+
108
+ async onLaunched() {
109
+ try {
110
+ fs.mkdirSync(this.imageDir, { recursive: true });
111
+ } catch (error) {
112
+ this.log.error(
113
+ `Windy Camera: could not create image directory: ${error.message}`
114
+ );
115
+ }
116
+
117
+ this.ensureCameraUIConfig();
118
+ this.ensureCameraUIDatabase();
119
+
120
+ await this.capture();
121
+
122
+ this.captureTimer = setInterval(() => {
123
+ this.capture();
124
+ }, this.captureIntervalMs);
125
+ }
126
+
127
+ stopCaptureLoop() {
128
+ if (this.captureTimer) {
129
+ clearInterval(this.captureTimer);
130
+ this.captureTimer = null;
131
+ }
132
+ }
133
+
134
+ async capture() {
135
+ if (this.capturing) {
136
+ return;
137
+ }
138
+
139
+ this.capturing = true;
140
+
141
+ let chromium;
142
+
143
+ try {
144
+ ({ chromium } = require('playwright-core'));
145
+ } catch (error) {
146
+ this.log.error(
147
+ `Windy Camera: playwright-core is not installed: ${error.message}`
148
+ );
149
+ this.capturing = false;
150
+ return;
151
+ }
152
+
153
+ let browser;
154
+
155
+ try {
156
+ browser = await chromium.launch({
157
+ executablePath: this.chromiumPath,
158
+ headless: true,
159
+ args: [
160
+ '--no-sandbox',
161
+ '--disable-dev-shm-usage',
162
+ '--hide-scrollbars',
163
+ '--disable-gpu'
164
+ ]
165
+ });
166
+
167
+ const context = await browser.newContext({
168
+ viewport: {
169
+ width: this.width,
170
+ height: this.height
171
+ },
172
+ deviceScaleFactor: 1,
173
+ geolocation: {
174
+ latitude: this.lat,
175
+ longitude: this.lon
176
+ },
177
+ permissions: ['geolocation']
178
+ });
179
+
180
+ const page = await context.newPage();
181
+
182
+ await page.goto(this.captureUrl, {
183
+ waitUntil: 'domcontentloaded',
184
+ timeout: 60000
185
+ });
186
+
187
+ await page.waitForTimeout(15000);
188
+
189
+ const tempPath = `${this.imagePath}.tmp`;
190
+
191
+ await page.screenshot({
192
+ path: tempPath,
193
+ type: 'jpeg',
194
+ quality: 95
195
+ });
196
+
197
+ fs.renameSync(tempPath, this.imagePath);
198
+
199
+ this.log.debug('Windy Camera: radar frame updated');
200
+
201
+ await browser.close();
202
+ browser = null;
203
+ } catch (error) {
204
+ this.log.error(
205
+ `Windy Camera: capture failed: ${error.message}`
206
+ );
207
+
208
+ if (browser) {
209
+ try {
210
+ await browser.close();
211
+ } catch {
212
+ // ignore
213
+ }
214
+ }
215
+ } finally {
216
+ this.capturing = false;
217
+ }
218
+ }
219
+
220
+ ensureCameraUIConfig() {
221
+ const configPath = this.api.user.configPath();
222
+
223
+ let raw;
224
+
225
+ try {
226
+ raw = fs.readFileSync(configPath, 'utf8');
227
+ } catch (error) {
228
+ this.log.error(
229
+ `Windy Camera: could not read config.json: ${error.message}`
230
+ );
231
+ return;
232
+ }
233
+
234
+ let root;
235
+
236
+ try {
237
+ root = JSON.parse(raw);
238
+ } catch (error) {
239
+ this.log.error(
240
+ `Windy Camera: config.json is not valid JSON, skipping injection: ${error.message}`
241
+ );
242
+ return;
243
+ }
244
+
245
+ const platforms = Array.isArray(root.platforms) ?
246
+ root.platforms :
247
+ [];
248
+
249
+ const cameraUIPlatform = platforms.find(
250
+ (p) => p && p.platform === CAMERA_UI_PLATFORM_TYPE
251
+ );
252
+
253
+ if (!cameraUIPlatform) {
254
+ this.log.warn(
255
+ 'Windy Camera: no CameraUI platform found in config.json; ' +
256
+ 'nothing to inject into.'
257
+ );
258
+ return;
259
+ }
260
+
261
+ if (!Array.isArray(cameraUIPlatform.cameras)) {
262
+ cameraUIPlatform.cameras = [];
263
+ }
264
+
265
+ const existing = cameraUIPlatform.cameras.find(
266
+ (c) => c && c.name === this.name
267
+ );
268
+
269
+ if (existing) {
270
+ this.log.info(
271
+ `Windy Camera: camera entry "${this.name}" already present ` +
272
+ 'in camera-ui config.'
273
+ );
274
+ return;
275
+ }
276
+
277
+ const cameraEntry = this.buildCameraUIEntry();
278
+
279
+ cameraUIPlatform.cameras.push(cameraEntry);
280
+
281
+ try {
282
+ fs.writeFileSync(
283
+ configPath,
284
+ JSON.stringify(root, null, 4),
285
+ 'utf8'
286
+ );
287
+
288
+ this.log.warn(
289
+ `Windy Camera: camera entry "${this.name}" was missing and ` +
290
+ 'has been re-created in config.json. Restart Homebridge ' +
291
+ 'for camera-ui to pick it up.'
292
+ );
293
+ } catch (error) {
294
+ this.log.error(
295
+ `Windy Camera: failed to write config.json: ${error.message}`
296
+ );
297
+ }
298
+ }
299
+
300
+ ensureCameraUIDatabase() {
301
+ let raw;
302
+
303
+ try {
304
+ raw = fs.readFileSync(this.cameraUIDatabasePath, 'utf8');
305
+ } catch (error) {
306
+ this.log.warn(
307
+ 'Windy Camera: could not read camera.ui database.json ' +
308
+ `(${error.message}); skipping database-level injection.`
309
+ );
310
+ return;
311
+ }
312
+
313
+ let db;
314
+
315
+ try {
316
+ db = JSON.parse(raw);
317
+ } catch (error) {
318
+ this.log.error(
319
+ 'Windy Camera: camera.ui database.json is not valid JSON, ' +
320
+ `skipping injection: ${error.message}`
321
+ );
322
+ return;
323
+ }
324
+
325
+ if (!Array.isArray(db.cameras)) {
326
+ db.cameras = [];
327
+ }
328
+
329
+ const existing = db.cameras.find(
330
+ (c) => c && c.name === this.name
331
+ );
332
+
333
+ if (existing) {
334
+ this.log.info(
335
+ `Windy Camera: camera entry "${this.name}" already present ` +
336
+ 'in camera.ui database.'
337
+ );
338
+ return;
339
+ }
340
+
341
+ db.cameras.push(this.buildCameraUIEntry());
342
+
343
+ try {
344
+ fs.writeFileSync(
345
+ this.cameraUIDatabasePath,
346
+ JSON.stringify(db, null, 4),
347
+ 'utf8'
348
+ );
349
+
350
+ this.log.warn(
351
+ `Windy Camera: camera entry "${this.name}" was missing and ` +
352
+ 'has been re-created in camera.ui database.json. Restart ' +
353
+ 'Homebridge for camera-ui to pick it up.'
354
+ );
355
+ } catch (error) {
356
+ this.log.error(
357
+ 'Windy Camera: failed to write camera.ui database.json: ' +
358
+ error.message
359
+ );
360
+ }
361
+ }
362
+
363
+ buildCameraUIEntry() {
364
+ const source = `-loop 1 -i ${this.imagePath}`;
365
+ const stillImageSource = `-i ${this.imagePath}`;
366
+
367
+ return {
368
+ disable: false,
369
+ name: this.name,
370
+ excludeSwitch: false,
371
+ privacySwitch: false,
372
+ motion: false,
373
+ doorbell: false,
374
+ switches: false,
375
+ useInterfaceTimer: false,
376
+ motionTimeout: 15,
377
+ unbridge: true,
378
+ hsv: false,
379
+ prebuffering: false,
380
+ hksvConfig: {
381
+ audio: false
382
+ },
383
+ videoConfig: {
384
+ source,
385
+ subSource: source,
386
+ stillImageSource,
387
+ readRate: false,
388
+ maxStreams: 2,
389
+ maxWidth: this.width,
390
+ maxHeight: this.height,
391
+ maxFPS: 1,
392
+ maxBitrate: 6000,
393
+ forceMax: true,
394
+ vcodec: 'libx264',
395
+ acodec: 'libfdk_aac',
396
+ audio: false,
397
+ debug: false,
398
+ debugReturn: false
399
+ },
400
+ smtp: {
401
+ email: this.name
402
+ },
403
+ videoanalysis: {
404
+ active: false
405
+ },
406
+ prebufferLength: 4,
407
+ mqtt: {}
408
+ };
409
+ }
410
+ }
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env python3
2
+ import json
3
+ import sys
4
+
5
+ CONFIG_PATH = '/var/lib/homebridge/config.json'
6
+ DB_PATH = '/var/lib/homebridge/camera.ui/database/database.json'
7
+ STORAGE_PATH = '/var/lib/homebridge'
8
+ DEFAULT_IMAGE_PATH = STORAGE_PATH + '/windy-camera/rain.jpg'
9
+
10
+
11
+ def build_entry(name, image_path, width, height):
12
+ source = f'-loop 1 -i {image_path}'
13
+ still = f'-i {image_path}'
14
+ return {
15
+ "disable": False,
16
+ "name": name,
17
+ "excludeSwitch": False,
18
+ "privacySwitch": False,
19
+ "motion": False,
20
+ "doorbell": False,
21
+ "switches": False,
22
+ "useInterfaceTimer": False,
23
+ "motionTimeout": 15,
24
+ "unbridge": True,
25
+ "hsv": False,
26
+ "prebuffering": False,
27
+ "hksvConfig": {"audio": False},
28
+ "videoConfig": {
29
+ "source": source,
30
+ "subSource": source,
31
+ "stillImageSource": still,
32
+ "readRate": False,
33
+ "maxStreams": 2,
34
+ "maxWidth": width,
35
+ "maxHeight": height,
36
+ "maxFPS": 1,
37
+ "maxBitrate": 6000,
38
+ "forceMax": True,
39
+ "vcodec": "libx264",
40
+ "acodec": "libfdk_aac",
41
+ "audio": False,
42
+ "debug": False,
43
+ "debugReturn": False
44
+ },
45
+ "smtp": {"email": name},
46
+ "videoanalysis": {"active": False},
47
+ "prebufferLength": 4,
48
+ "mqtt": {}
49
+ }
50
+
51
+
52
+ def main():
53
+ try:
54
+ with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
55
+ config = json.load(f)
56
+ except Exception as e:
57
+ print(f"windy-camera-inject: cannot read config.json: {e}")
58
+ return
59
+
60
+ platforms = config.get('platforms', [])
61
+
62
+ windy_platform = next(
63
+ (p for p in platforms if p.get('platform') == 'WindyCamera'),
64
+ None
65
+ )
66
+
67
+ if not windy_platform:
68
+ print("windy-camera-inject: no WindyCamera platform configured, skipping.")
69
+ return
70
+
71
+ name = windy_platform.get('name', 'מכ״ם גשם')
72
+ image_path = windy_platform.get('imagePath', DEFAULT_IMAGE_PATH)
73
+ width = windy_platform.get('width', 1300)
74
+ height = windy_platform.get('height', 900)
75
+
76
+ entry = build_entry(name, image_path, width, height)
77
+
78
+ camera_ui_platform = next(
79
+ (p for p in platforms if p.get('platform') == 'CameraUI'),
80
+ None
81
+ )
82
+
83
+ changed_config = False
84
+
85
+ if camera_ui_platform is not None:
86
+ cams = camera_ui_platform.setdefault('cameras', [])
87
+ if not any(c.get('name') == name for c in cams):
88
+ cams.append(entry)
89
+ changed_config = True
90
+
91
+ if changed_config:
92
+ with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
93
+ json.dump(config, f, indent=4, ensure_ascii=False)
94
+ print(f'windy-camera-inject: added "{name}" to config.json')
95
+ else:
96
+ print(f'windy-camera-inject: "{name}" already present in config.json')
97
+
98
+ try:
99
+ with open(DB_PATH, 'r', encoding='utf-8') as f:
100
+ db = json.load(f)
101
+ except Exception as e:
102
+ print(f"windy-camera-inject: cannot read camera.ui database.json: {e}")
103
+ return
104
+
105
+ db_cams = db.setdefault('cameras', [])
106
+ changed_db = False
107
+
108
+ if not any(c.get('name') == name for c in db_cams):
109
+ db_cams.append(entry)
110
+ changed_db = True
111
+
112
+ if changed_db:
113
+ with open(DB_PATH, 'w', encoding='utf-8') as f:
114
+ json.dump(db, f, indent=4, ensure_ascii=False)
115
+ print(f'windy-camera-inject: added "{name}" to camera.ui database.json')
116
+ else:
117
+ print(f'windy-camera-inject: "{name}" already present in camera.ui database.json')
118
+
119
+
120
+ if __name__ == '__main__':
121
+ main()
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "homebridge-windy-camera",
3
+ "version": "1.0.0",
4
+ "description": "Captures a live Windy.com radar map screenshot and keeps it registered as a camera inside homebridge-camera-ui",
5
+ "main": "index.js",
6
+ "keywords": ["homebridge-plugin"],
7
+ "files": [
8
+ "index.js",
9
+ "inject-camera.py",
10
+ "scripts/postinstall.js",
11
+ "README.md"
12
+ ],
13
+ "scripts": {
14
+ "postinstall": "node scripts/postinstall.js || true"
15
+ },
16
+ "engines": {
17
+ "node": ">=18.0.0",
18
+ "homebridge": "^1.6.0 || ^2.0.0"
19
+ },
20
+ "dependencies": {
21
+ "playwright-core": "^1.40.0"
22
+ }
23
+ }
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const PRESTART_DIR = '/etc/hb-service/homebridge/prestart.d';
7
+ const PRESTART_FILE = path.join(PRESTART_DIR, '20-windy-camera-inject');
8
+ const INJECT_SCRIPT = path.join(__dirname, '..', 'inject-camera.py');
9
+
10
+ function log(message) {
11
+ console.log(`[homebridge-windy-camera] postinstall: ${message}`);
12
+ }
13
+
14
+ function main() {
15
+ if (!fs.existsSync(PRESTART_DIR)) {
16
+ log(
17
+ `${PRESTART_DIR} does not exist (not running under hb-service?). ` +
18
+ 'Skipping prestart hook installation — you will need to set ' +
19
+ 'this up manually. See the plugin README.'
20
+ );
21
+ return;
22
+ }
23
+
24
+ if (!fs.existsSync(INJECT_SCRIPT)) {
25
+ log(
26
+ `expected helper script not found at ${INJECT_SCRIPT}; ` +
27
+ 'skipping prestart hook installation.'
28
+ );
29
+ return;
30
+ }
31
+
32
+ const scriptContents =
33
+ '#!/bin/sh\n' +
34
+ `python3 ${INJECT_SCRIPT}\n`;
35
+
36
+ try {
37
+ fs.writeFileSync(PRESTART_FILE, scriptContents, {
38
+ mode: 0o755
39
+ });
40
+
41
+ fs.chmodSync(PRESTART_FILE, 0o755);
42
+
43
+ log(`prestart hook installed at ${PRESTART_FILE}`);
44
+ } catch (error) {
45
+ log(
46
+ `could not write prestart hook (${error.message}). ` +
47
+ `You may need to create it manually: sudo tee ${PRESTART_FILE}`
48
+ );
49
+ return;
50
+ }
51
+
52
+ try {
53
+ const { execFileSync } = require('child_process');
54
+ execFileSync('python3', [INJECT_SCRIPT], { stdio: 'inherit' });
55
+ } catch (error) {
56
+ log(
57
+ `initial camera injection run failed (${error.message}). ` +
58
+ 'It will still run automatically on the next Homebridge start.'
59
+ );
60
+ }
61
+ }
62
+
63
+ try {
64
+ main();
65
+ } catch (error) {
66
+ log(`unexpected error, continuing anyway: ${error.message}`);
67
+ }