homekit/amcrest: dynamic streaming

This commit is contained in:
Koushik Dutta
2022-03-11 11:07:13 -08:00
parent a076bad5fa
commit c6d83c66bb
9 changed files with 156 additions and 78 deletions

View File

@@ -18,7 +18,6 @@ The optimal/reliable codec settings can be found in the documentation for the [H
* Specify `Type` is `Doorbell` (at top under device Name)
* `Username` admin
* `Password` (see below)
* `Default Stream` set to properly configured video codec stream (Main Stream = `Stream 1`; Sub Stream 1 = `Stream 2`; Sub Stream 2 = `Stream 3`; and so on)
* `Doorbell Type` is `Amcrest Doorbell`
The `admin` user account credentials is required to (1) add doorbell to Scrypted or (2) change codec settings with `IP Config Software` or `Amcrest Surveillance Pro` applications.
@@ -36,8 +35,6 @@ Each 'Channel' or (camera) Device attached to the NVR must be configured as sepa
* `IP Address` NVR's IP Address
* `Snapshot URL Override` camera's IP address (preferred) or specific port number of NVR for that camera (may work). That is: `http://<camera IP address>/cgi-bin/snapshot.cgi` or `http://<NVR IP address>:<NVR port # for camera>/cgi-bin/snapshot.cgi`
* `Channel Number Override` camera's channel number as known to DVR
* `Default Stream` Properly configured video codec stream (Main Stream = `Stream 1`; Sub Stream 1 = `Stream 2`; Sub Stream 2 = `Stream 3`; and so on)
# Troubleshooting
@@ -45,7 +42,6 @@ Each 'Channel' or (camera) Device attached to the NVR must be configured as sepa
* Is the URL attempting to use HTTPS? Try disabling HTTPS on the device to see if that resolves issue (do not use self-signed certs).
* Does your account (`Username`) have proper permissions ("Authority" in Amcrest speak)? Try granting all Authority for testing. See below `User Account Authority (Camera or NVR)`.
* Amcrest Doorbell: `Username` is **admin** and `Password` is the device/camera password -- not Amcrest Smart Home (Cloud) account password.
* Check that you have specified the correct `Default Stream` number in device (in Scrypted).
* Check that you have configured the correct Stream number's codec settings (in Amcrest admin page (Main Stream or Sub Stream(s)).
## User Account Authority (Camera or NVR)

View File

@@ -1,15 +1,16 @@
{
"name": "@scrypted/amcrest",
"version": "0.0.86",
"version": "0.0.89",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/amcrest",
"version": "0.0.86",
"version": "0.0.89",
"license": "Apache",
"dependencies": {
"@koush/axios-digest-auth": "^0.8.5",
"@scrypted/common": "file:../../common",
"@scrypted/sdk": "file:../../sdk",
"@types/multiparty": "^0.0.33",
"multiparty": "^4.2.2"
@@ -18,18 +19,27 @@
"@types/node": "^16.11.0"
}
},
"../../sdk": {
"name": "@scrypted/sdk",
"version": "0.0.134",
"../../common": {
"name": "@scrypted/common",
"version": "1.0.1",
"license": "ISC",
"dependencies": {
"@babel/plugin-proposal-class-properties": "^7.14.5",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.14.5",
"@babel/plugin-proposal-numeric-separator": "^7.14.5",
"@babel/plugin-proposal-optional-chaining": "^7.14.5",
"@babel/plugin-transform-modules-commonjs": "^7.15.4",
"@babel/plugin-transform-typescript": "^7.15.8",
"@babel/preset-typescript": "^7.15.0",
"@koush/werift": "file:../external/werift/packages/webrtc",
"@scrypted/sdk": "file:../sdk",
"http-auth-utils": "^3.0.2",
"node-fetch-commonjs": "^3.1.1",
"typescript": "^4.4.3"
},
"devDependencies": {
"@types/node": "^16.9.0"
}
},
"../../sdk": {
"name": "@scrypted/sdk",
"version": "0.0.174",
"license": "ISC",
"dependencies": {
"@babel/preset-typescript": "^7.16.7",
"adm-zip": "^0.4.13",
"axios": "^0.21.4",
"babel-loader": "^8.2.3",
@@ -39,7 +49,6 @@
"raw-loader": "^4.0.2",
"rimraf": "^3.0.2",
"tmp": "^0.2.1",
"ts-loader": "^9.2.6",
"webpack": "^5.59.0"
},
"bin": {
@@ -56,7 +65,8 @@
"stringify-object": "^3.3.0",
"ts-node": "^10.4.0",
"typedoc": "^0.22.8",
"typescript-json-schema": "^0.50.1"
"typescript-json-schema": "^0.50.1",
"webpack-bundle-analyzer": "^4.5.0"
}
},
"../sdk": {
@@ -71,6 +81,10 @@
"axios": "^0.21.4"
}
},
"node_modules/@scrypted/common": {
"resolved": "../../common",
"link": true
},
"node_modules/@scrypted/sdk": {
"resolved": "../../sdk",
"link": true
@@ -110,9 +124,9 @@
}
},
"node_modules/follow-redirects": {
"version": "1.14.4",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.4.tgz",
"integrity": "sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g==",
"version": "1.14.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz",
"integrity": "sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w==",
"funding": [
{
"type": "individual",
@@ -231,16 +245,21 @@
"axios": "^0.21.4"
}
},
"@scrypted/common": {
"version": "file:../../common",
"requires": {
"@koush/werift": "file:../external/werift/packages/webrtc",
"@scrypted/sdk": "file:../sdk",
"@types/node": "^16.9.0",
"http-auth-utils": "^3.0.2",
"node-fetch-commonjs": "^3.1.1",
"typescript": "^4.4.3"
}
},
"@scrypted/sdk": {
"version": "file:../../sdk",
"requires": {
"@babel/plugin-proposal-class-properties": "^7.14.5",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.14.5",
"@babel/plugin-proposal-numeric-separator": "^7.14.5",
"@babel/plugin-proposal-optional-chaining": "^7.14.5",
"@babel/plugin-transform-modules-commonjs": "^7.15.4",
"@babel/plugin-transform-typescript": "^7.15.8",
"@babel/preset-typescript": "^7.15.0",
"@babel/preset-typescript": "^7.16.7",
"@types/node": "^16.11.1",
"@types/stringify-object": "^4.0.0",
"adm-zip": "^0.4.13",
@@ -253,11 +272,11 @@
"rimraf": "^3.0.2",
"stringify-object": "^3.3.0",
"tmp": "^0.2.1",
"ts-loader": "^9.2.6",
"ts-node": "^10.4.0",
"typedoc": "^0.22.8",
"typescript-json-schema": "^0.50.1",
"webpack": "^5.59.0"
"webpack": "^5.59.0",
"webpack-bundle-analyzer": "^4.5.0"
}
},
"@types/multiparty": {
@@ -292,9 +311,9 @@
"integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak="
},
"follow-redirects": {
"version": "1.14.4",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.4.tgz",
"integrity": "sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g=="
"version": "1.14.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz",
"integrity": "sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w=="
},
"http-errors": {
"version": "1.8.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/amcrest",
"version": "0.0.86",
"version": "0.0.89",
"description": "Amcrest Plugin for Scrypted",
"author": "Scrypted",
"license": "Apache",
@@ -35,6 +35,7 @@
"dependencies": {
"@koush/axios-digest-auth": "^0.8.5",
"@scrypted/sdk": "file:../../sdk",
"@scrypted/common": "file:../../common",
"@types/multiparty": "^0.0.33",
"multiparty": "^4.2.2"
},

View File

@@ -4,22 +4,34 @@ import { AmcrestCameraClient, AmcrestEvent, amcrestHttpsAgent } from "./amcrest-
import { RtspSmartCamera, RtspProvider, Destroyable, UrlMediaStreamOptions } from "../../rtsp/src/rtsp";
import { EventEmitter } from "stream";
import child_process, { ChildProcess } from 'child_process';
import { ffmpegLogInitialOutput } from '../../../common/src/media-helpers';
import { ffmpegLogInitialOutput } from '@scrypted/common/src/media-helpers';
import net from 'net';
import { listenZero } from "../../../common/src/listen-cluster";
import { readLength } from "../../../common/src/read-stream";
import { listenZero } from "@scrypted/common/src/listen-cluster";
import { readLength } from "@scrypted/common/src/read-stream";
import { OnvifIntercom } from "../../onvif/src/onvif-intercom";
import { parse } from "path";
const { mediaManager } = sdk;
const AMCREST_DOORBELL_TYPE = 'Amcrest Doorbell';
const DAHUA_DOORBELL_TYPE = 'Dahua Doorbell';
function findValue(blob: string, prefix: string, key: string) {
const lines = blob.split('\n');
const value = lines.find(line => line.startsWith(`${prefix}.${key}`));
if (!value)
return;
const parts = value.split('=');
return parts[1];
}
class AmcrestCamera extends RtspSmartCamera implements VideoCameraConfiguration, Camera, Intercom {
eventStream: Stream;
cp: ChildProcess;
client: AmcrestCameraClient;
maxExtraStreams: number;
maxExtraStreams: Promise<number>;
videoStreamOptions: Promise<UrlMediaStreamOptions[]>;
onvifIntercom = new OnvifIntercom(this);
constructor(nativeId: string, provider: RtspProvider) {
@@ -189,7 +201,7 @@ class AmcrestCamera extends RtspSmartCamera implements VideoCameraConfiguration,
description: 'Amcrest cameras may support both Amcrest and ONVIF two way audio protocols. ONVIF generally performs better when supported.',
choices,
},
)
);
return ret;
}
@@ -223,27 +235,88 @@ class AmcrestCamera extends RtspSmartCamera implements VideoCameraConfiguration,
return ret;
}
async getConstructedVideoStreamOptions(): Promise<UrlMediaStreamOptions[]> {
let mas = this.maxExtraStreams;
const client = this.getClient();
if (!this.maxExtraStreams) {
const client = this.getClient();
try {
const response = await client.digestAuth.request({
url: `http://${this.getHttpAddress()}/cgi-bin/magicBox.cgi?action=getProductDefinition&name=MaxExtraStream`,
responseType: 'text',
httpsAgent: amcrestHttpsAgent,
})
this.maxExtraStreams = parseInt(response.data.split('=')[1].trim());
mas = this.maxExtraStreams;
}
catch (e) {
this.console.error('error retrieving max extra streams', e);
}
this.maxExtraStreams = (async () => {
let mas: string;
try {
const response = await client.digestAuth.request({
url: `http://${this.getHttpAddress()}/cgi-bin/magicBox.cgi?action=getProductDefinition&name=MaxExtraStream`,
responseType: 'text',
httpsAgent: amcrestHttpsAgent,
})
mas = response.data.split('=')[1].trim();
this.storage.setItem('maxExtraStreams', mas.toString());
}
catch (e) {
this.console.error('error retrieving max extra streams', e);
mas = this.storage.getItem('maxExtraStreams');
this.maxExtraStreams = undefined;
}
return parseInt(mas) || 1;
})();
}
mas = mas || 1;
const channel = this.getRtspChannel() || '1';
return [...Array(mas + 1).keys()].map(subtype => this.createRtspMediaStreamOptions(`rtsp://${this.getRtspAddress()}/cam/realmonitor?channel=${channel}&subtype=${subtype}`, subtype));
if (!this.videoStreamOptions) {
this.videoStreamOptions = (async () => {
const mas = await this.maxExtraStreams;
const channel = parseInt(this.getRtspChannel()) || 1;
const vsos = [...Array(mas + 1).keys()].map(subtype => this.createRtspMediaStreamOptions(`rtsp://${this.getRtspAddress()}/cam/realmonitor?channel=${channel}&subtype=${subtype}`, subtype));
try {
const capResponse = await client.digestAuth.request({
url: `http://${this.getHttpAddress()}/cgi-bin/encode.cgi?action=getConfigCaps&channel=0`,
responseType: 'text',
httpsAgent: amcrestHttpsAgent,
});
this.console.log(capResponse.data);
const encodeResponse = await client.digestAuth.request({
url: `http://${this.getHttpAddress()}/cgi-bin/configManager.cgi?action=getConfig&name=Encode`,
responseType: 'text',
httpsAgent: amcrestHttpsAgent,
});
this.console.log(encodeResponse.data);
for (let i = 0; i < vsos.length; i++) {
const vso = vsos[i];
let capName: string;
let encName: string;
if (i === 0) {
capName = `caps[${channel - 1}].MainFormat[0]`;
encName = `table.Encode[${channel - 1}].MainFormat[0]`;
}
else {
capName = `caps[${channel - 1}].ExtraFormat[${i - 1}]`;
encName = `table.Encode[${channel - 1}].ExtraFormat[${i - 1}]`;
}
const bitrateOptions = findValue(capResponse.data, capName, 'Video.BitRateOptions');
if (!bitrateOptions)
continue;
const encodeOptions = findValue(encodeResponse.data, encName, 'Video.BitRate');
if (!encodeOptions)
continue;
const [min, max] = bitrateOptions.split(',');
if (!min || !max)
continue;
vso.video.bitrate = parseInt(encodeOptions) * 1000;
vso.video.maxBitrate = parseInt(max) * 1000;
vso.video.minBitrate = parseInt(min) * 1000;
}
}
catch (e) {
this.console.error('error retrieving stream configurations', e);
}
return vsos;
})();
}
return this.videoStreamOptions;
}
async putSetting(key: string, value: string) {

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/homekit",
"version": "0.0.224",
"version": "0.0.226",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/homekit",
"version": "0.0.224",
"version": "0.0.226",
"dependencies": {
"@koush/qrcode-terminal": "^0.12.0",
"hap-nodejs": "file:../../external/HAP-NodeJS",

View File

@@ -39,5 +39,5 @@
"@types/node": "^14.17.9",
"@types/url-parse": "^1.4.3"
},
"version": "0.0.224"
"version": "0.0.226"
}

View File

@@ -1,19 +1,4 @@
import sdk, { Camera, Intercom, MediaStreamOptions, ScryptedDevice, ScryptedInterface, VideoCamera, VideoCameraConfiguration } from '@scrypted/sdk';
import dgram, { SocketType } from 'dgram';
import { once } from 'events';
import os from 'os';
import { RtcpRrPacket } from '../../../../../external/werift/packages/rtp/src/rtcp/rr';
import { RtcpPacketConverter } from '../../../../../external/werift/packages/rtp/src/rtcp/rtcp';
import { RtpPacket } from '../../../../../external/werift/packages/rtp/src/rtp/rtp';
import { ProtectionProfileAes128CmHmacSha1_80 } from '../../../../../external/werift/packages/rtp/src/srtp/const';
import { SrtcpSession } from '../../../../../external/werift/packages/rtp/src/srtp/srtcp';
import { HomeKitSession } from '../../common';
import { CameraController, CameraStreamingDelegate, PrepareStreamCallback, PrepareStreamRequest, PrepareStreamResponse, StartStreamRequest, StreamingRequest, StreamRequestCallback, StreamRequestTypes } from '../../hap';
import { startRtpSink } from '../../rtp/rtp-ffmpeg-input';
import { createSnapshotHandler } from '../camera/camera-snapshot';
import { startCameraStreamFfmpeg } from './camera-streaming-ffmpeg';
import { CameraStreamingSession } from './camera-streaming-session';
import { startCameraStreamSrtp } from './camera-streaming-srtp';
export class DynamicBitrateSession {
currentBitrate: number;
@@ -21,7 +6,7 @@ export class DynamicBitrateSession {
lastPerfectBitrate: number;
lastTotalPacketsLost = 0;
constructor(initialBitrate: number, public minBitrate: number, public maxBitrate: number) {
constructor(initialBitrate: number, public minBitrate: number, public maxBitrate: number, public console?: Console) {
this.currentBitrate = initialBitrate;
this.lastPerfectBitrate = initialBitrate;
}
@@ -72,7 +57,7 @@ export class DynamicBitrateSession {
this.currentBitrate = Math.max(this.minBitrate, this.currentBitrate);
this.currentBitrate = Math.min(this.maxBitrate, this.currentBitrate);
console.log('Packets lost:', packetsLost);
this.console?.log('Packets lost:', packetsLost);
return true;
};
}

View File

@@ -102,6 +102,10 @@ export async function* handleFragmentsRequests(device: ScryptedDevice & VideoCam
['-acodec', aacLowEncoder, '-profile:a', 'aac_low'] :
['-acodec', 'libfdk_aac', '-profile:a', 'aac_eld']),
'-ar', `${AudioRecordingSamplerateValues[configuration.audioCodec.samplerate]}k`,
// technically, this should be used for VBR (which this plugin offers).
// will see about changing it later.
// '-q:a', '3',
// this is used for CBR.
'-b:a', `${configuration.audioCodec.bitrate}k`,
'-ac', `${configuration.audioCodec.audioChannels}`
];

View File

@@ -192,7 +192,7 @@ export function createCameraStreamingDelegate(device: ScryptedDevice & VideoCame
if (dynamicBitrate) {
const initialBitrate = request.video.max_bit_rate * 1000;
const dynamicBitrateSession = new DynamicBitrateSession(initialBitrate, minBitrate, maxBitrate);
const dynamicBitrateSession = new DynamicBitrateSession(initialBitrate, minBitrate, maxBitrate, console);
session.tryReconfigureBitrate = (reason: string, bitrate: number) => {
dynamicBitrateSession.onBitrateReconfigured(bitrate);