homebridge-hikvision-dvr 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.0
4
+
5
+ Smoother live video when Homebridge runs on a PC or server (anything that isn't a Raspberry Pi).
6
+
7
+ - On a machine with 4 or more cores and no Pi hardware encoder, software-encoded streams keep the DVR's own frame timing instead of being forced to exactly 25 fps, and use the `veryfast` x264 preset instead of `ultrafast` for a cleaner picture. Forcing 25 fps duplicated and dropped frames irregularly, because the DVR's frames arrive unevenly, which showed as judder.
8
+ - Raspberry Pi behaviour is unchanged.
9
+
10
+ ## 1.2.2
11
+
12
+ Fixes video freezing and high load on a Raspberry Pi when several cameras are opened at once (for example the 4-camera grid on an Apple TV).
13
+
14
+ - With *Video processing* on Automatic, the plugin now chooses how to serve each stream by how many are being viewed at the same time. One viewer gets the main stream at up to 1280x720; two get the main stream at up to 960x540; three or more get the DVR's sub stream scaled to 640x360 at 800 kbps (the Pi hardware encoder runs four of these at 25 fps; a fifth is passed through unchanged). The DVR can only deliver about 5.5 Mbit/s in total, so several main streams at once gave about 13 fps anyway.
15
+ - The choice is made for each stream when it starts and the log says which path was used and why. When the grid is closed, the next single view is back on the best path.
16
+ - A failing hardware encode no longer switches the whole plugin to libx264 until restart. Only that stream falls back (to libx264 at up to 640 px wide, or pass-through if two are already running). Only if nothing else is encoding in hardware is the encoder treated as wedged, and then it is avoided for 10 minutes and tried again, not for good.
17
+ - Waits up to 8 s (was 6 s) for the hardware encoder's first output before falling back.
18
+ - Live view starts much faster on a Pi: the first picture now arrives in about 0.5 s for a single view and 0.7-0.9 s per tile in the grid (was 2.5 s and 2.6-5 s). The hardware path was throwing away its first keyframe (about 2 s) and probing its input for longer than needed. When the DVR joins a sub stream mid-picture-group (which used to delay a tile by 2-3 s), the plugin now asks it for a keyframe.
19
+ - Hardware *decoding* on the Pi was tested and is not used: it started slower, used more CPU and dropped frames.
20
+
3
21
  ## 1.2.1
4
22
 
5
23
  - The plugin's settings page in the Homebridge UI shows an **Open web console** button and the console's address, instead of trying to embed the console (the DVR's pages don't work well inside the Homebridge UI, which also blocks their dialogs).
package/README.md CHANGED
@@ -31,7 +31,7 @@ Only the IP address, username and password are needed. Everything else is option
31
31
  | Motion sensors | on | A motion sensor per camera, from the DVR's own motion detection (see below) |
32
32
  | Motion reset | 30 s | How long motion stays on after the DVR's last motion event |
33
33
  | Verification code | read from DVR | Only needed if the plugin can't read it. Use the 6-letter code on the DVR's sticker, or the one set in Hik-Connect / HiLook |
34
- | Video processing | Automatic | Uses the Raspberry Pi hardware encoder when available (for views wider than 640 px; smaller views use libx264, which starts faster), otherwise libx264. *No transcoding* passes the DVR's stream through untouched (lowest CPU, experimental) |
34
+ | Video processing | Automatic | Uses the Raspberry Pi hardware encoder when available (for views wider than 640 px; smaller views use libx264, which starts faster), otherwise libx264. With several viewers at once (e.g. an Apple TV camera grid) it automatically uses smaller output and the DVR's sub stream so a Pi keeps up (see below). *No transcoding* passes the DVR's stream through untouched (lowest CPU, experimental) |
35
35
  | Use sub stream up to width | 0 (never) | e.g. `320` serves Apple Watch views from the low-res sub stream |
36
36
  | Minimum bitrate | automatic | iOS often asks for only ~300 kbps; the plugin uses at least 1200 kbps for 720p+. `0` = exactly what iOS asks |
37
37
  | Max viewers per camera | 2 | |
@@ -79,6 +79,21 @@ which keeps the load on the DVR and the Homebridge machine down. Limitations:
79
79
  - It is plain HTTP, like the DVR itself, so **don't port-forward it** or expose it to the internet.
80
80
  - Tested only on a HiLook DVR-104G-F1.
81
81
 
82
+ ## Several cameras at once
83
+
84
+ An Apple TV camera grid starts four streams at the same moment. On a Raspberry Pi (Automatic video processing) the plugin
85
+ picks a path for every stream as it starts, depending on how many are being viewed, and logs the choice:
86
+
87
+ | Viewers | Source | Output |
88
+ |---|---|---|
89
+ | 1 | main stream | up to 1280x720 (hardware encoder; libx264 if the view is 640 px or less) |
90
+ | 2 | main stream | up to 960x540, hardware encoder |
91
+ | 3 or more | sub stream | 640x360, 800 kbps, hardware encoder; a fifth and later stream is passed through unchanged |
92
+
93
+ If the hardware encoder fails for one stream, only that stream falls back to libx264. If it is wedged (nothing is encoding in hardware),
94
+ it is avoided for 10 minutes and then tried again. The DVR itself delivers only a few Mbit/s in total, so several main streams at once
95
+ would be slow even with a faster computer. Hardware video *decoding* on the Pi was tried and made things slower.
96
+
82
97
  ## How it works
83
98
 
84
99
  - Discovers cameras via ISAPI: analog DVR inputs, NVR IP channels, or a single IP camera.
package/lib/camera.js CHANGED
@@ -6,12 +6,35 @@ const { RtspClient } = require('./rtsp');
6
6
  const { HikDecryptor } = require('./hikcrypt');
7
7
  const { FlvWriter } = require('./flv');
8
8
  const { run, spawn, AnnexBSplitter } = require('./ffmpeg');
9
+ const fs = require('fs');
10
+ const os = require('os');
9
11
 
10
12
  const START_CODE = Buffer.from([0, 0, 0, 1]);
11
13
  const PROFILES = ['baseline', 'main', 'high'];
12
14
  const LEVELS = ['3.1', '3.2', '4.0'];
13
15
  const SOFTWARE_MAX_WIDTH = 640; // auto mode: views up to this width use libx264 even on a Pi
14
- const HW_TIMEOUT = 6000; // ms to wait for the hardware encoder's first output
16
+ const HW_TIMEOUT = 8000; // ms to wait for the hardware encoder's first output
17
+ const HW_RETRY = 10 * 60 * 1000; // after a real hardware-encoder failure, use software only this long
18
+ const PREPARE_TTL = 10000; // a prepared-but-not-started session counts towards "how many viewers are coming" this long
19
+ // Concurrency tiers for auto mode on a Pi (measured on a Pi 3 A+, one stream = ffmpeg decode of the DVR's 960x1080 main stream,
20
+ // scale, hardware encode: 1280x720 ~100% of a core, 960x540 ~87%; sub stream to 640x360 ~25%; the DVR itself delivers
21
+ // only ~5.5 Mbit/s in total, so 3+ main streams drop to ~13 fps; the Pi codec fails to open an encoder for the 4th 720p session).
22
+ const MAIN_MAX_VIEWERS = 2; // up to this many concurrent viewers get the main stream
23
+ const SMALL = { width: 640, height: 360, bitrate: 800 }; // light path: sub stream scaled up, hardware encoded
24
+ const MAX_HW_SESSIONS = 4; // hardware encodes at once; further viewers get the sub stream passed through
25
+ // A PC or server (not a Pi: no V4L2 M2M encoder node, 4+ cores) has CPU to spare for software encoding.
26
+ const FAST_HOST = os.cpus().length >= 4 && !fs.existsSync('/dev/video11');
27
+ const MAX_SW_SESSIONS = 2; // libx264 encodes at once while the hardware encoder is marked broken or one hardware encode failed
28
+ // Hardware DECODE (-c:v h264_v4l2m2m before -i) was measured on the Pi 3 A+ and is deliberately not used: with the DVR's FLV pipe
29
+ // input it was slower to start (2-7 s vs 1.5 s), used 2.5-3.3 cores instead of ~1 (ffmpeg copies every frame out of the codec's
30
+ // buffers) and delivered only a fraction of the frames when combined with the hardware encoder, while software decoding of the
31
+ // 960x1080 main stream only costs about half a core.
32
+
33
+ /** Per-platform streaming state shared by all cameras (kept on the platform object). */
34
+ function streamState(platform) {
35
+ if (!platform.streamState) platform.streamState = { active: new Set(), prepared: new Map(), hwBrokenUntil: 0 };
36
+ return platform.streamState;
37
+ }
15
38
 
16
39
  /**
17
40
  * HomeKit camera streaming delegate for one DVR channel.
@@ -123,6 +146,7 @@ class HikCamera {
123
146
  });
124
147
  socket.on('error', (e) => this.log.debug(`[${this.name}] RTCP socket error: ${e.message}`));
125
148
  const ssrc = crypto.randomBytes(4).readUInt32BE(0) & 0x7fffffff || 1;
149
+ streamState(this.platform).prepared.set(request.sessionID, Date.now());
126
150
  this.pending.set(request.sessionID, {
127
151
  address: request.targetAddress,
128
152
  port: request.video.port,
@@ -160,35 +184,98 @@ class HikCamera {
160
184
  }
161
185
  }
162
186
 
187
+ /**
188
+ * Decides how to serve one viewer: which DVR stream, which encoder and what output size.
189
+ * On a Pi (auto mode) this depends on how many viewers there are in total, since 4 camera tiles on an Apple TV
190
+ * all start at once and the Pi cannot transcode four main streams.
191
+ */
192
+ plan(sessionID, video) {
193
+ const p = this.platform;
194
+ const st = streamState(p);
195
+ st.prepared.delete(sessionID);
196
+ const now = Date.now();
197
+ for (const [id, t] of st.prepared) if (now - t > PREPARE_TTL) st.prepared.delete(id);
198
+ const viewers = st.active.size + st.prepared.size + 1;
199
+ const userSub = p.subStreamMaxWidth > 0 && video.width <= p.subStreamMaxWidth;
200
+ let sub = userSub;
201
+ let mode = p.encoder;
202
+ let cap = null; // [maxWidth, maxHeight, maxBitrate] of the output
203
+ let why = '';
204
+ const count = (m) => [...st.active].filter((x) => x.mode === m).length;
205
+ if (mode === 'h264_v4l2m2m' && p.encoderAuto) {
206
+ const hwOk = now >= st.hwBrokenUntil;
207
+ if (!hwOk) {
208
+ sub = true;
209
+ mode = count('libx264') < MAX_SW_SESSIONS ? 'libx264' : 'copy';
210
+ cap = [SMALL.width, SMALL.height, SMALL.bitrate];
211
+ why = 'hardware encoder marked broken, sub stream';
212
+ } else if (viewers <= 1) {
213
+ if (video.width <= SOFTWARE_MAX_WIDTH) mode = 'libx264';
214
+ cap = [1280, 720, Infinity];
215
+ why = 'single viewer, main stream';
216
+ } else if (viewers <= MAIN_MAX_VIEWERS) {
217
+ cap = [960, 540, Infinity];
218
+ why = `${viewers} viewers, main stream at reduced size`;
219
+ } else {
220
+ sub = true;
221
+ cap = [SMALL.width, SMALL.height, SMALL.bitrate];
222
+ why = `${viewers} viewers, light sub-stream path`;
223
+ if (count('h264_v4l2m2m') >= MAX_HW_SESSIONS) {
224
+ mode = 'copy';
225
+ why = `${viewers} viewers, sub stream passed through`;
226
+ }
227
+ }
228
+ } else if (mode === 'libx264' && p.encoderAuto && viewers > MAIN_MAX_VIEWERS) {
229
+ // No hardware encoder at all (e.g. a Mac or a PC): keep CPU use bounded the same way.
230
+ sub = true;
231
+ cap = [SMALL.width, SMALL.height, SMALL.bitrate];
232
+ why = `${viewers} viewers, light sub-stream path`;
233
+ }
234
+ if (!why) why = 'configured';
235
+ let width = video.width;
236
+ let height = video.height;
237
+ if (cap && width > cap[0]) {
238
+ width = cap[0];
239
+ height = Math.round((video.height * width) / video.width / 2) * 2;
240
+ }
241
+ return { sub, mode, width, height, maxBitrate: cap ? cap[2] : Infinity, why, viewers };
242
+ }
243
+
163
244
  startStream(sessionID, info, video, callback) {
164
245
  const p = this.platform;
165
- const useSub = p.subStreamMaxWidth > 0 && video.width <= p.subStreamMaxWidth;
246
+ const st = streamState(p);
247
+ const plan = this.plan(sessionID, video);
248
+ const useSub = plan.sub;
166
249
  const stream = useSub ? 2 : 1;
167
- // 'h264_v4l2m2m' | 'libx264' | 'copy'. Small views are cheap in software and start faster, and the Pi's hardware
168
- // encoder can wedge (no output, ever) after a kernel driver error, so only auto-pick it for larger views.
169
- let mode = p.encoder;
170
- if (mode === 'h264_v4l2m2m' && (p.hwBroken || (p.encoderAuto && video.width <= SOFTWARE_MAX_WIDTH))) mode = 'libx264';
250
+ // 'h264_v4l2m2m' | 'libx264' | 'copy'. A single small view is cheap in software and starts faster, and the Pi's
251
+ // hardware encoder can wedge (no output, ever) after a kernel driver error, so it is only auto-picked for larger views.
252
+ const mode = plan.mode;
253
+ let outW = plan.width;
254
+ let outH = plan.height;
171
255
  const fps = Math.min(video.fps, 25);
172
256
  // iOS often asks for ~300 kbps even on Wi-Fi, which looks poor; apply a resolution-based floor.
173
- const floor = p.minBitrate !== undefined ? p.minBitrate : video.width >= 1280 ? 1200 : video.width >= 640 ? 600 : 300;
174
- const bitrate = Math.max(video.max_bit_rate, floor, 100);
257
+ const floor = p.minBitrate !== undefined ? p.minBitrate : outW >= 1280 ? 1200 : outW >= 640 ? 600 : 300;
258
+ const bitrate = Math.min(Math.max(video.max_bit_rate, floor, 100), Math.max(plan.maxBitrate, floor));
175
259
  const log = this.log;
176
260
  const tag = `[${this.name}]`;
177
261
 
178
- log.info(`${tag} Starting ${video.width}x${video.height}@${fps} ${bitrate}kbps from ${useSub ? 'sub' : 'main'} stream (${mode})`);
262
+ log.info(`${tag} Starting ${outW}x${outH}@${fps} ${bitrate}kbps from ${useSub ? 'sub' : 'main'} stream (${mode}): ${plan.why}${outW !== video.width ? `; viewer asked for ${video.width}x${video.height}` : ''}`);
179
263
 
180
- const session = { procs: [], rtsp: null, socket: info.socket, timer: null, stopped: false, hwTimer: null, hwFallback: null, hwFirst: null, armHw: null };
264
+ const session = { mode, procs: [], rtsp: null, socket: info.socket, timer: null, stopped: false, hwTimer: null, hwFallback: null, hwFirst: null, armHw: null };
181
265
  this.sessions.set(sessionID, session);
266
+ st.active.add(session);
182
267
 
183
268
  const srtpOut = ['-payload_type', String(video.pt), '-ssrc', String(info.ssrc), '-f', 'rtp',
184
269
  '-srtp_out_suite', 'AES_CM_128_HMAC_SHA1_80', '-srtp_out_params', info.srtp,
185
270
  `srtp://${info.address}:${info.port}?rtcpport=${info.port}&pkt_size=${video.mtu}`];
186
271
  const logLevel = ['-hide_banner', '-loglevel', p.debug ? 'warning' : 'error'];
187
272
  // Decrypted camera video arrives as FLV carrying the DVR's own timestamps.
273
+ // probesize: the FLV already carries SPS/PPS, so only the first frame is needed; a larger value makes ffmpeg wait for that many
274
+ // bytes of (low bitrate) sub stream to arrive in real time, ~0.4 s.
188
275
  // showall: the DVR's keyframes carry no recovery point, so without it the decoder drops ~1 s of frames before the first output.
189
276
  const input = [...logLevel, '-fflags', '+nobuffer+discardcorrupt', '-flags', 'low_delay', '-flags2', '+showall',
190
- '-probesize', '32768', '-analyzeduration', '0', '-f', 'flv', '-i', 'pipe:0', '-an', '-sn', '-dn'];
191
- const scale = `scale=${video.width}:${video.height}:flags=fast_bilinear,format=yuv420p`;
277
+ '-probesize', '4096', '-analyzeduration', '0', '-f', 'flv', '-i', 'pipe:0', '-an', '-sn', '-dn'];
278
+ let scale = `scale=${outW}:${outH}:flags=fast_bilinear,format=yuv420p`;
192
279
 
193
280
  let first; // the ffmpeg process fed by the RTSP stream
194
281
  let flv;
@@ -198,16 +285,23 @@ class HikCamera {
198
285
  // DVR main streams are often anamorphic (e.g. 960x1080 "1080P Lite"); tell the player the display aspect is 16:9.
199
286
  first = this.spawn(session, [...input, '-c:v', 'copy', '-bsf:v', `h264_metadata=sample_aspect_ratio=${useSub ? '16/11' : '2/1'}`, ...srtpOut]);
200
287
  } else if (mode === 'libx264') {
201
- first = this.spawn(session, [...input, '-vf', scale, '-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency',
288
+ // On a fast host: a better preset, and frames go out with the DVR's own timing. Forcing -r 25 duplicates and drops
289
+ // frames irregularly because the DVR's frames arrive unevenly, which shows as judder. On a Pi nothing changes.
290
+ const timing = FAST_HOST ? ['-fps_mode', 'passthrough'] : ['-r', String(fps)];
291
+ first = this.spawn(session, [...input, '-vf', scale, '-c:v', 'libx264', '-preset', FAST_HOST ? 'veryfast' : 'ultrafast', '-tune', 'zerolatency',
202
292
  '-profile:v', PROFILES[video.profile] || 'main', '-level:v', LEVELS[video.level] || '3.1', '-bf', '0',
203
- '-r', String(fps), '-g', String(fps * 2), '-b:v', `${bitrate}k`, '-maxrate', `${bitrate}k`, '-bufsize', `${bitrate * 2}k`,
293
+ ...timing, '-g', String(fps * 2), '-b:v', `${bitrate}k`, '-maxrate', `${bitrate}k`, '-bufsize', `${bitrate * 2}k`,
204
294
  ...srtpOut]);
205
295
  } else {
206
296
  // Pi hardware encoder doesn't repeat SPS/PPS before keyframes, which HomeKit needs.
207
297
  // Encode to a pipe, re-insert SPS/PPS in Node, and let a second (copy-only) ffmpeg do SRTP.
208
298
  // The encoder output is constant frame rate, so the sender can number frames itself.
209
- const sender = this.spawn(session, [...logLevel, '-fflags', '+nobuffer', '-probesize', '32768', '-analyzeduration', '0',
210
- '-f', 'h264', '-framerate', String(fps), '-i', 'pipe:0', '-c:v', 'copy', ...srtpOut]);
299
+ // -copyinkf: ffmpeg's raw h264 demuxer does not flag the encoder's first IDR as a keyframe, so a plain stream copy discards
300
+ // everything up to the second IDR (~2 s); the stream always starts with an IDR here, so keep the first packet.
301
+ // Probing must stay minimal (one packet, 32 bytes): with larger values ffmpeg decodes more of the input first, and under
302
+ // startup load (several tiles at once) that sometimes took 0.4-1 s. SPS/PPS are in the stream and rtp output needs nothing else.
303
+ const sender = this.spawn(session, [...logLevel, '-fflags', '+nobuffer', '-probesize', '32', '-analyzeduration', '0', '-max_probe_packets', '1',
304
+ '-f', 'h264', '-framerate', String(fps), '-i', 'pipe:0', '-c:v', 'copy', '-copyinkf', ...srtpOut]);
211
305
  first = this.spawn(session, [...input, '-vf', scale, '-r', String(fps), '-c:v', 'h264_v4l2m2m', '-g', String(fps * 2),
212
306
  '-b:v', `${bitrate}k`, '-f', 'h264', 'pipe:1']);
213
307
  let sps = null;
@@ -227,21 +321,42 @@ class HikCamera {
227
321
  splitter.push(chunk);
228
322
  clearTimeout(session.hwTimer);
229
323
  session.hwFallback = null;
324
+ session.hwOutput = true;
230
325
  });
231
- // No encoded output at all means the hardware encoder is wedged (or failed to open): switch this session to libx264 (and later ones).
326
+ // No encoded output at all: the hardware encoder failed to open, is overloaded or (rarely) wedged. Switch this session to
327
+ // libx264 at a small size. Only when no other hardware encode is working is the driver considered broken for everyone,
328
+ // and then only for HW_RETRY.
232
329
  session.hwFirst = first;
233
330
  session.hwFallback = (why) => {
234
331
  session.hwFallback = null;
235
332
  clearTimeout(session.hwTimer);
236
333
  if (session.stopped) return;
237
- p.hwBroken = true;
238
- log.warn(`${tag} Hardware encoder ${why} (the Pi's video driver may need a reboot), using libx264`);
334
+ const othersWorking = [...st.active].some((x) => x !== session && x.mode === 'h264_v4l2m2m' && x.hwOutput);
335
+ // Only a wedge (nothing else is encoding in hardware) disables the hardware encoder for everyone, and only for HW_RETRY.
336
+ // An ffmpeg that merely exited may have failed for other reasons (DVR stream gone), so it needs a V4L2 error to count.
337
+ const wedge = !othersWorking && (why.startsWith('produced') || /v4l2|video\d+|encoder|codec/i.test(session.lastErr || ''));
338
+ if (wedge) st.hwBrokenUntil = Date.now() + HW_RETRY;
339
+ const light = [...st.active].filter((x) => x !== session && x.mode === 'libx264').length < MAX_SW_SESSIONS;
340
+ log.warn(`${tag} Hardware encoder ${why}; this stream switches to ${light ? 'libx264' : 'pass-through'}${wedge ? ` and the hardware encoder is not used for ${HW_RETRY / 60000} min (the Pi's video driver may need a reboot)` : ' (other streams keep using the hardware encoder)'}`);
239
341
  for (const proc of session.procs.splice(0)) {
240
342
  proc.replaced = true;
241
343
  proc.kill('SIGTERM');
242
344
  setTimeout(() => proc.exitCode === null && proc.kill('SIGKILL'), 5000).unref();
243
345
  }
244
346
  started = false;
347
+ keyRequested = false;
348
+ requestKeyFrame();
349
+ if (!light) {
350
+ session.mode = 'copy';
351
+ launch('copy');
352
+ return;
353
+ }
354
+ session.mode = 'libx264';
355
+ if (outW > SOFTWARE_MAX_WIDTH) { // libx264 at 720p takes 2+ cores of a Pi
356
+ outH = Math.round((outH * SOFTWARE_MAX_WIDTH) / outW / 2) * 2;
357
+ outW = SOFTWARE_MAX_WIDTH;
358
+ scale = `scale=${outW}:${outH}:flags=fast_bilinear,format=yuv420p`;
359
+ }
245
360
  launch('libx264');
246
361
  };
247
362
  // The clock starts when the first keyframe is fed to the encoder, not while RTSP is still connecting.
@@ -261,8 +376,17 @@ class HikCamera {
261
376
  };
262
377
 
263
378
  let started = false;
379
+ let keyRequested = false;
264
380
  launch(mode);
265
381
 
382
+ // The DVR usually sends a keyframe right after PLAY, but sometimes (mostly sub streams, soon after another session on the
383
+ // same stream) it joins mid-GOP and the next keyframe is 2+ s away. Ask for one (ISAPI, harmless if unsupported), once.
384
+ const requestKeyFrame = () => {
385
+ if (keyRequested || !p.isapi || !p.isapi.requestKeyFrame) return;
386
+ keyRequested = true;
387
+ p.isapi.requestKeyFrame(this.channel, stream).then(() => log.debug(`${tag} Requested a keyframe`), (e) => log.debug(`${tag} Keyframe request failed: ${e.message}`));
388
+ };
389
+
266
390
  // RTSP -> decrypt -> ffmpeg
267
391
  const decryptor = new HikDecryptor(p.verificationCode);
268
392
  const rtsp = new RtspClient(this.rtspUrl(stream), { username: p.username, password: p.password });
@@ -280,7 +404,10 @@ class HikCamera {
280
404
  const plain = decryptor.process(nal, flagged);
281
405
  const type = plain[0] & 0x1f;
282
406
  if (!started) {
283
- if (type !== 7 && type !== 8 && type !== 5) return; // start on a keyframe
407
+ if (type !== 7 && type !== 8 && type !== 5) { // start on a keyframe
408
+ requestKeyFrame();
409
+ return;
410
+ }
284
411
  started = true;
285
412
  }
286
413
  flv.push(plain, ts, marker);
@@ -293,6 +420,7 @@ class HikCamera {
293
420
 
294
421
  rtsp.start().then(() => {
295
422
  log.debug(`${tag} RTSP playing ${this.rtspUrl(stream)}`);
423
+ session.kfTimer = setTimeout(() => { if (!started) requestKeyFrame(); }, 400);
296
424
  callback();
297
425
  }).catch((e) => {
298
426
  log.error(`${tag} Could not open DVR stream: ${e.message}`);
@@ -320,6 +448,7 @@ class HikCamera {
320
448
  proc.stdin.on('error', () => { /* EPIPE when ffmpeg exits */ });
321
449
  proc.stderr.on('data', (d) => {
322
450
  const text = d.toString().trim();
451
+ if (text) session.lastErr = text.slice(-500);
323
452
  if (text) this.log[this.platform.debug ? 'info' : 'debug'](`${tag} ffmpeg: ${text}`);
324
453
  });
325
454
  proc.on('error', (e) => this.log.error(`${tag} Failed to start ffmpeg (${this.platform.ffmpeg}): ${e.message}`));
@@ -346,6 +475,7 @@ class HikCamera {
346
475
  }
347
476
 
348
477
  stopStream(sessionID) {
478
+ streamState(this.platform).prepared.delete(sessionID);
349
479
  const pending = this.pending.get(sessionID);
350
480
  if (pending) {
351
481
  this.pending.delete(sessionID);
@@ -354,8 +484,10 @@ class HikCamera {
354
484
  const session = this.sessions.get(sessionID);
355
485
  if (!session) return;
356
486
  this.sessions.delete(sessionID);
487
+ streamState(this.platform).active.delete(session);
357
488
  session.stopped = true;
358
489
  clearTimeout(session.timer);
490
+ clearTimeout(session.kfTimer);
359
491
  if (session.rtsp) session.rtsp.stop();
360
492
  for (const proc of session.procs) {
361
493
  try { proc.stdin.end(); } catch (e) { /* ignore */ }
package/lib/isapi.js CHANGED
@@ -158,6 +158,12 @@ class Isapi {
158
158
  }
159
159
  }
160
160
 
161
+ /** Asks the DVR to send an IDR frame on a stream (Hikvision ISAPI; harmless on devices that lack it). */
162
+ async requestKeyFrame(channel, stream = 1) {
163
+ const res = await this.request('PUT', `/ISAPI/Streaming/channels/${channel * 100 + stream}/requestKeyFrame`);
164
+ if (res.status !== 200) throw new Error(`HTTP ${res.status}`);
165
+ }
166
+
161
167
  async snapshot(channel, stream = 1) {
162
168
  return this.get(`/ISAPI/Streaming/channels/${channel * 100 + stream}/picture`);
163
169
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "homebridge-hikvision-dvr",
3
3
  "displayName": "Hikvision DVR / NVR Cameras",
4
- "version": "1.2.1",
4
+ "version": "1.3.0",
5
5
  "description": "Hikvision, HiLook and other Hik-based DVR/NVR cameras in HomeKit, with automatic camera discovery and support for encrypted (Hik-Connect / HiLook) streams.",
6
6
  "main": "index.js",
7
7
  "license": "MIT",