diff --git a/Program.cs b/Program.cs index 3b6b9ec..7633502 100644 --- a/Program.cs +++ b/Program.cs @@ -32,6 +32,7 @@ int rtspPort = ArgParser.GetArg(args, "--rtsp-port", 8554); int httpPort = ArgParser.GetArg(args, "--http-port", 8080); bool debug = ArgParser.GetArg(args, "--debug", false); + bool noVideo = ArgParser.GetArg(args, "--no-video", false); if (source.Equals("lan", StringComparison.OrdinalIgnoreCase) && string.IsNullOrEmpty(ip)) { @@ -96,7 +97,7 @@ WebServer webServer = null; if (outputMode == OutputMode.Rtsp) { - rtsp = new(rtspPort); + rtsp = new(rtspPort, enableVideoTranscode: !noVideo); rtsp.Start(); webServer = new WebServer(httpPort, rtspPort, client, enableApi, enableOnvif); @@ -212,6 +213,9 @@ Tested with Onvif Device Manager (ODM) OTHER OPTIONS: --discover Find camera devices on the local network --debug Enable debug logging (default: false) + --no-video Skip the HEVC->H264 transcoder entirely (rtsp mode only). + Use when this instance is only ever consumed for its + audio track, to avoid wasting CPU on an unused encode. --help Show this help message EXAMPLES: diff --git a/README.md b/README.md index 09f51ce..7786b3c 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,21 @@ This project is based on reverse engineering of the V380 protocol and is a C#/.N - Firmware version: `Hw_HsXMQQFC_WF_QQ_20240806` - Notes: reported by user and added as an additional known-working/newer device reference for this project +### Camera D + +- Software version: `AppEV3L_V2_V1.0.4.1_20241029` +- Firmware version: `Hw_HsAkQQVL_WF_QQ_20240412` +- Notes: dual-cam (2-lens PTZ) setup, confirmed working for both video and audio on + two units of this camera model simultaneously. Reference product image below. + Audio codec on this model is IMA-ADPCM (auto-detected via `audioBits==16` from the + login handshake); Cameras A/B/C use classic 8-bit G.711 audio and are handled by the + original code path, untouched by the ADPCM-specific fixes below. +- **Disclaimer**: support for this camera (video/audio decode fixes, protocol + corrections, and performance tuning) was added with AI assistance. Review the + relevant changes before relying on this in a security-critical deployment. + +![Tested dual-lens camera](demo/tested-camera-dual-lens.jpg) + ## Quick Start With Docker Build the image: @@ -315,6 +330,12 @@ WantedBy=multi-user.target ## Acknowledgements -- [prsyahmi/v380](https://github.com/prsyahmi/v380) for the original reverse engineering work -- [Cyberlink Security](https://cyberlinksecurity.ie/vulnerabilities-to-exploit-a-chinese-ip-camera/) for protocol and vulnerability research around V380 devices -- Tooling used in the broader reverse engineering workflow: Wireshark, PacketSender, JADX, Ghidra, and Frida +| Source | Contribution | +|---|---| +| [felipemarques/camera-v380decoder](https://github.com/felipemarques/camera-v380decoder) | The repo this project is maintained as/cloned from | +| [PyanSofyan/V380Decoder](https://github.com/PyanSofyan/V380Decoder) | The C#/.NET codebase this fork descends from, extended for newer/3-lens H.265 cameras | +| [prsyahmi/v380](https://github.com/prsyahmi/v380) | Original V380 protocol reverse engineering (video/H.264 extraction) | +| [jericjan/v380-audio-player](https://github.com/jericjan/v380-audio-player) | Reference used to identify the real V380 audio codec (IMA-ADPCM, not G.711 A-law) | +| [acida/pyima](https://github.com/acida/pyima) | Original IMA-ADPCM encoder/decoder that the audio fix is ported from | +| [Cyberlink Security](https://cyberlinksecurity.ie/vulnerabilities-to-exploit-a-chinese-ip-camera/) | Protocol and vulnerability research around V380 devices | +| Wireshark, PacketSender, JADX, Ghidra, Frida | Tooling used in the broader reverse engineering workflow | diff --git a/demo/tested-camera-dual-lens.jpg b/demo/tested-camera-dual-lens.jpg new file mode 100644 index 0000000..3cd8096 Binary files /dev/null and b/demo/tested-camera-dual-lens.jpg differ diff --git a/src/RtspServer.cs b/src/RtspServer.cs index 924a46b..083c157 100644 --- a/src/RtspServer.cs +++ b/src/RtspServer.cs @@ -19,10 +19,15 @@ public class RtspServer private byte[] cachedPps; private readonly object sdpLock = new(); - public RtspServer(int port) + public RtspServer(int port, bool enableVideoTranscode = true) { this.port = port; - transcoder = new H264Transcoder(PushVideoDirect); + // The HEVC->H264 transcode (libx264) is the expensive part of this whole + // process. When this instance is only ever consumed for its audio track + // (e.g. go2rtc pulling "#audio=aac" while video comes from the camera's + // own native RTSP instead), running it is pure wasted CPU - skip it. + if (enableVideoTranscode) + transcoder = new H264Transcoder(PushVideoDirect); } public void Start() @@ -55,6 +60,8 @@ void AcceptLoop() public void PushVideo(FrameData f) { + if (transcoder == null) return; // video output disabled - audio-only instance + if (f.Codec == VideoCodec.H265 && transcoder.IsAvailable) { transcoder.PushFrame(f); @@ -165,7 +172,7 @@ public void Dispose() running = false; try { listener?.Stop(); } catch { } foreach (var s in sessions.Values) s.Close(); - transcoder.Dispose(); + transcoder?.Dispose(); } } } diff --git a/src/RtspSession.cs b/src/RtspSession.cs index ff431c2..6c73378 100644 --- a/src/RtspSession.cs +++ b/src/RtspSession.cs @@ -20,7 +20,7 @@ public class RtspSession private ushort audioSeq; private uint videoSsrc = (uint)new Random().Next(); private uint audioSsrc = (uint)new Random().Next(); - private uint generatedVideoTimestamp; + private long videoStartTicks; public event Action OnClose; @@ -156,24 +156,34 @@ public void PushVideo(FrameData f) { if (!playing) return; - uint rts = f.Timestamp > 0 - ? (uint)(f.Timestamp * 90) - : (generatedVideoTimestamp += 3600); + // f.Timestamp is the camera's raw device clock (a large, arbitrary-origin + // value, not milliseconds-since-stream-start), so f.Timestamp*90 produces + // wild jumps into the billions and clients reject/DTS-discontinuity-abort + // the stream. Use real elapsed wall-clock time since this session started + // playing instead - matches how PCM/RTP clocks are supposed to behave. + if (videoStartTicks == 0) videoStartTicks = Environment.TickCount64; + uint rts = (uint)((Environment.TickCount64 - videoStartTicks) * 90); RtspServer.ParseNals(f.Payload, VideoCodec.H264, (nalType, nal) => { const int mtu = 1400; + // Per RFC 6184, the marker bit must be set only on the last packet + // of an access unit (the actual VCL slice), not on every NAL. Setting + // it on AUD/SPS/PPS too makes receivers treat each as its own access + // unit, corrupting frame boundary detection downstream. + bool isLastNalOfAccessUnit = nalType is 1 or 5; + if (nal.Length <= mtu) { - SendRtp(videoCh, 96, videoSeq++, rts, videoSsrc, nal, 0, nal.Length, marker: true); + SendRtp(videoCh, 96, videoSeq++, rts, videoSsrc, nal, 0, nal.Length, marker: isLastNalOfAccessUnit); return; } - SendH264Fragmented(nal, rts, mtu); + SendH264Fragmented(nal, rts, mtu, isLastNalOfAccessUnit); }); } - void SendH264Fragmented(byte[] nal, uint rts, int mtu) + void SendH264Fragmented(byte[] nal, uint rts, int mtu, bool isLastNalOfAccessUnit) { byte nalHdr = nal[0]; byte fuInd = (byte)((nalHdr & 0xE0) | 28); @@ -194,7 +204,7 @@ void SendH264Fragmented(byte[] nal, uint rts, int mtu) frag[1] = fuHdr; Array.Copy(nal, offset, frag, 2, chunk); - SendRtp(videoCh, 96, videoSeq++, rts, videoSsrc, frag, 0, frag.Length, marker: last); + SendRtp(videoCh, 96, videoSeq++, rts, videoSsrc, frag, 0, frag.Length, marker: last && isLastNalOfAccessUnit); offset += chunk; first = false; } diff --git a/src/V380Client.cs b/src/V380Client.cs index ff915bf..9402d63 100644 --- a/src/V380Client.cs +++ b/src/V380Client.cs @@ -20,8 +20,10 @@ public class V380Client : IDisposable private ushort deviceVersion, communicationVersion; private int frameWidth = 1280; private int frameheight = 720; + private int audioBits = 8; private byte[] aesKey = new byte[16]; private bool needReconnect = false; + private double audioClockMs = 0; public V380Client(string ip, int port, uint deviceId, string username, string password, SourceStream source, OutputMode mode, int streamQuality) { @@ -282,6 +284,7 @@ public bool StreamLogin() communicationVersion = version; frameWidth = (int)width; frameheight = (int)height; + this.audioBits = audioBits; LogUtils.debug($"[STREAM] login result: {result}"); LogUtils.debug($"[STREAM] login version: {version}"); LogUtils.debug($"[STREAM] login width: {width}"); @@ -474,13 +477,20 @@ private bool HandleAsMediaFrame(byte rawType, byte[] full, bool needDecrypt, Out } else if (mode == OutputMode.Rtsp) { + // PCMA/8000/1: 1 byte == 1 sample == 1/8 ms. Timestamp must keep + // increasing across frames, otherwise RTP ts resets to 0 every + // frame and downstream players (ffmpeg/browser) drop the stream + // as non-monotonic after the first packet. + ulong ts = (ulong)audioClockMs; + audioClockMs += audioPayload.Length / 8.0; + rtsp?.PushAudio(new FrameData { RawType = rawType, FrameId = 0, FrameType = 0, FrameRate = 0, - Timestamp = 0, + Timestamp = ts, Payload = audioPayload }); } @@ -527,12 +537,20 @@ private bool HandleAsMediaFrame(byte rawType, byte[] full, bool needDecrypt, Out private byte[] ExtractAudioPayload(byte[] full, bool needDecrypt) { + // audioBits==16 (from the login handshake) identifies the newer devices + // whose outer per-packet header is 16 bytes, followed by a 256-byte + // IMA-ADPCM block (4-byte block header + 252 bytes of packed nibbles = + // 505 samples) - confirmed against github.com/jericjan/v380-audio-player + // (pyima.py). Older 8-bit G.711 devices (audioBits==8, e.g. Cameras A/B/C + // in the README) keep the original 20-byte header offset untouched, so + // this fix doesn't risk misaligning their audio. + int headerLen = audioBits == 16 ? 16 : 20; byte[] payload; - if (full.Length > 20) + if (full.Length > headerLen) { - payload = new byte[full.Length - 20]; - Array.Copy(full, 20, payload, 0, payload.Length); + payload = new byte[full.Length - headerLen]; + Array.Copy(full, headerLen, payload, 0, payload.Length); } else { @@ -547,9 +565,118 @@ private byte[] ExtractAudioPayload(byte[] full, bool needDecrypt) DecryptAudioFrame(payload, payload.Length); } + // The RTSP SDP always advertises PCMA/8000/1 (G.711 A-law, 8 bits/sample). + // This device's audio is actually IMA-ADPCM (audioBits==16 from the login + // handshake reflects the ADPCM predictor width, not raw PCM sample width). + // Decode the ADPCM block to linear PCM, then encode to real A-law so the + // rest of the pipeline (SDP, go2rtc, Frigate) needs no changes. + if (audioBits == 16 && payload.Length == 256) + { + short[] pcm = DecodeImaAdpcmBlock(payload); + var outBytes = new byte[pcm.Length]; + for (int i = 0; i < pcm.Length; i++) + outBytes[i] = LinearToALaw(pcm[i]); + payload = outBytes; + } + return payload; } + private static readonly int[] ImaIndexTable = + { + -1, -1, -1, -1, 2, 4, 6, 8, + -1, -1, -1, -1, 2, 4, 6, 8 + }; + + private static readonly int[] ImaStepTable = + { + 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 19, 21, 23, 25, 28, 31, + 34, 37, 41, 45, 50, 55, 60, 66, 73, 80, 88, 97, 107, 118, 130, 143, + 157, 173, 190, 209, 230, 253, 279, 307, 337, 371, 408, 449, 494, 544, 598, 658, + 724, 796, 876, 963, 1060, 1166, 1282, 1411, 1552, 1707, 1878, 2066, 2272, 2499, 2749, 3024, + 3327, 3660, 4026, 4428, 4871, 5358, 5894, 6484, 7132, 7845, 8630, 9493, 10442, 11487, 12635, 13899, + 15289, 16818, 18500, 20350, 22385, 24623, 27086, 29794, 32767 + }; + + // Decodes one 256-byte IMA-ADPCM block: 2-byte initial predictor (LE), + // 1-byte initial step index, 1-byte reserved, then 252 bytes of nibbles + // (low nibble first, high nibble second) -> 505 16-bit PCM samples. + private static short[] DecodeImaAdpcmBlock(byte[] block) + { + var samples = new short[505]; + int predicted = (short)(block[0] | (block[1] << 8)); + int index = Math.Clamp((int)block[2], 0, 88); + int step = ImaStepTable[index]; + samples[0] = (short)predicted; + + int outPos = 1; + for (int i = 4; i < block.Length; i++) + { + int b = block[i]; + int first = b & 0xF; + int second = b >> 4; + + predicted = DecodeImaNibble(first, ref index, ref step, predicted); + samples[outPos++] = (short)predicted; + predicted = DecodeImaNibble(second, ref index, ref step, predicted); + samples[outPos++] = (short)predicted; + } + + return samples; + } + + private static int DecodeImaNibble(int nibble, ref int index, ref int step, int predicted) + { + int diff = step >> 3; + if ((nibble & 4) != 0) diff += step; + if ((nibble & 2) != 0) diff += step >> 1; + if ((nibble & 1) != 0) diff += step >> 2; + + predicted += (nibble & 8) != 0 ? -diff : diff; + predicted = Math.Clamp(predicted, -32767, 32767); + + index += ImaIndexTable[nibble]; + index = Math.Clamp(index, 0, 88); + step = ImaStepTable[index]; + + return predicted; + } + + private static readonly short[] AlawSegEnd = { 0x1F, 0x3F, 0x7F, 0xFF, 0x1FF, 0x3FF, 0x7FF, 0xFFF }; + + private static int AlawSearch(int val, short[] table) + { + for (int i = 0; i < table.Length; i++) + if (val <= table[i]) return i; + return table.Length; + } + + // Standard ITU-T G.711 linear-to-A-law encoder. + private static byte LinearToALaw(short pcmVal) + { + int mask; + int val = pcmVal >> 3; + + if (val >= 0) + { + mask = 0xD5; + } + else + { + mask = 0x55; + val = -val - 1; + } + + int seg = AlawSearch(val, AlawSegEnd); + + if (seg >= 8) + return (byte)(0x7F ^ mask); + + byte aval = (byte)(seg << 4); + aval |= (byte)(seg < 2 ? (val >> 1) & 0xF : (val >> seg) & 0xF); + return (byte)(aval ^ mask); + } + private bool TryExtractVideoPayload(byte[] full, bool needDecrypt, out byte[] normalizedPayload) { int bestScore = int.MinValue; @@ -580,25 +707,28 @@ private bool TryExtractVideoPayload(byte[] full, bool needDecrypt, out byte[] no private IEnumerable EnumerateVideoCandidates(byte[] full, bool needDecrypt) { - foreach (int offset in new[] { 0, 4, 8, 12, 16, 20 }) + // The outer per-frame header is always 16 bytes (frameId:4, frameType:2, + // frameRate:2, timestamp:8 - the same fields HandleAsMediaFrame already + // reads as outerFrameId/outerFrameType/outerFrameRate/outerTimestamp). + // Confirmed against the audio path (IMA-ADPCM block starts at byte 16), + // so there's no need to brute-force multiple offsets here. + const int offset = 16; + if (full.Length <= offset) yield break; + + byte[] direct = new byte[full.Length - offset]; + Array.Copy(full, offset, direct, 0, direct.Length); + + if (needDecrypt && direct.Length >= 16) { - if (full.Length <= offset) continue; - - byte[] direct = new byte[full.Length - offset]; - Array.Copy(full, offset, direct, 0, direct.Length); - - if (needDecrypt && direct.Length >= 16) - { - byte[] decrypted = (byte[])direct.Clone(); - if (communicationVersion == 21) - DecryptMediaPre2k(decrypted, decrypted.Length, 1); - else - DecryptVideoFrame(decrypted, decrypted.Length); - yield return decrypted; - } - - yield return direct; + byte[] decrypted = (byte[])direct.Clone(); + if (communicationVersion == 21) + DecryptMediaPre2k(decrypted, decrypted.Length, 1); + else + DecryptVideoFrame(decrypted, decrypted.Length); + yield return decrypted; } + + yield return direct; } private bool TryNormalizeVideoPayload(byte[] payload, out byte[] normalizedPayload) @@ -744,15 +874,35 @@ private VideoCodec DetectVideoCodec(byte[] payload) int offset = payload[2] == 1 ? 3 : 4; if (payload.Length <= offset) return VideoCodec.Unknown; + // H264 nal_unit_type (lower 5 bits) and H265 nal_unit_type (bits 1-6) + // are different slices of the *same* header byte, so a plain range check + // is ambiguous: many real H265 headers coincidentally satisfy the "valid + // H264 type" range too, and vice versa. Resolve this in stages, from most + // to least specific, so older 8-bit-audio/H264-only devices (Cameras A/B/C) + // keep matching exactly as before while newer H265 3-lens devices (Camera D) + // are also detected correctly: + // + // 1. Actual H264 VCL slice types (1 or 5) are checked first and are + // effectively unambiguous - real HEVC VCL headers essentially never + // produce these two specific values under the H264 5-bit reading, so + // this cannot regress older H264 devices. + // 2. H265's type range next, to catch real H265 streams that would + // otherwise be misclassified as H264 by the broad range in step 3. + // 3. The original broad H264 range (parameter sets, SEI, AUD, etc.) as a + // last resort, matching original pre-fix behavior for anything not + // already resolved above. byte nalHeader = payload[offset]; int h264NalType = nalHeader & 0x1F; - if (h264NalType is > 0 and < 24) + if (h264NalType is 1 or 5) return VideoCodec.H264; int h265NalType = (nalHeader >> 1) & 0x3F; if (h265NalType is > 0 and < 48) return VideoCodec.H265; + if (h264NalType is > 0 and < 24) + return VideoCodec.H264; + return VideoCodec.Unknown; }