Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
{
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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:
Expand Down
27 changes: 24 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 |
Binary file added demo/tested-camera-dual-lens.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 10 additions & 3 deletions src/RtspServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
}
}
}
26 changes: 18 additions & 8 deletions src/RtspSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
Expand Down
Loading