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 +18 -0
- package/README.md +16 -1
- package/lib/camera.js +152 -20
- package/lib/isapi.js +6 -0
- package/package.json +1 -1
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 =
|
|
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
|
|
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'.
|
|
168
|
-
// encoder can wedge (no output, ever) after a kernel driver error, so only auto-
|
|
169
|
-
|
|
170
|
-
|
|
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 :
|
|
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 ${
|
|
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', '
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
210
|
-
|
|
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
|
|
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
|
-
|
|
238
|
-
|
|
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)
|
|
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.
|
|
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",
|