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 +68 -0
- package/index.js +410 -0
- package/inject-camera.py +121 -0
- package/package.json +23 -0
- package/scripts/postinstall.js +67 -0
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
|
+
}
|
package/inject-camera.py
ADDED
|
@@ -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
|
+
}
|