Skip to content

feat: receive plain RTP from Majestic outgoing over UDP, unicast and multicast (#68) - #72

Merged
compot-dev merged 4 commits into
v2from
feat/rtp-udp
Oct 12, 2026
Merged

compot-dev merged 4 commits into
v2from
feat/rtp-udp

Conversation

@compot-dev

Copy link
Copy Markdown
Collaborator

Closes #68. Stacked on #70 (UDP for RTSP), whose reorder buffer it reuses.

What changes

A slot URL udp://[source@][address]:port[?codec=h264|h265&pt=N] receives the stream a camera pushes, with no RTSP. rtp:// works too, and the syntax is VLC's. This is what Majestic sends to outgoing destinations: RTP with payload type 96 (H.264) or 97 (H.265), with SPS/PPS (and VPS) repeated in band before every IDR. So the stream can be joined at any time without an SDP.

URL Meaning
udp://@:5600 listen on port 5600 (Majestic outgoing: udp://<decoder ip>:5600)
udp://@239.1.2.3:5600 join multicast group 239.1.2.3
udp://192.168.1.10@239.1.2.3:5600 the same, accepting only that sender
udp://@:5600?codec=h265&pt=100 another sender with its own payload type
  • RtspSession runs it in place of the RTSP handshake. The watchdog (no video for 8 s → rebind), reconnection with back-off and the status overlay are the same as for RTSP slots. The transport setting of feat: RTSP over UDP unicast, with fallback to TCP (#67) #70 does not apply.
  • The codec follows the payload type. A switch between 96 and 97 on the camera (video0.codec) produces a new session description, and the decoder is recreated as for an RTSP codec change. Other payload types (audio on the same port, for example) are not taken for video.
  • RtpListener uses a MulticastSocket, because DatagramChannel.join needs API 24 and minSdk is 21. Unicast ports are bound exclusively, so a second slot on the same port fails visibly instead of receiving nothing. Multicast ports are shared, so several cells may show one group.
  • Wi‑Fi drivers drop multicast unless an app holds a MulticastLock. It is held while any slot receives a group (CHANGE_WIFI_MULTICAST_STATE, a normal permission). App.context gives the lock an application context.

Not in this PR: audio from outgoing (the codec and PT are not described anywhere, so it needs its own setting), and choosing the network interface for a group (the default route is used).

Tests

RtpListenerTest:

  • URL forms: listen, group, source, IPv6 group, codec/pt, bad URLs.
  • Receiving on a port: H.264, then a switch to H.265.
  • Other payload types ignored; forced codec and pt.
  • Wrong sender ignored; a busy port is retried, not shared; stop() frees the port.

Device test (when the hardware is free)

  • Majestic outgoing → udp://<AG-216>:5600, H.264 and H.265, single view and grid
  • Check that Majestic sends to a multicast group (udp://239.x.x.x:5600), and receive it on the AG-216 (Ethernet) and over Wi‑Fi
  • Codec switch on the camera while playing; camera restart; joining mid-stream

@compot-dev

Copy link
Copy Markdown
Collaborator Author

/review

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (1) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Audio on the same port drops video frames ✓ Resolved
Description
RtpListener.run() sends every accepted datagram through one RtpReorder before the session
identifies its video payload type. When audio and video have independent RTP sequence numbers on the
same port, an audio packet can advance the reorderer's expected sequence past a video packet, which
is then dropped as late.
Code

android/app/src/main/java/com/openipc/decoder/RtpListener.java[203]

+                    reorder.push(buf, p.getOffset(), p.getLength(), System.nanoTime(), sink);
Evidence
The listener owns one reorder buffer and pushes all accepted packets into it. The reorderer tracks
only one expected sequence number and drops packets behind it; payload-type handling happens later
in the session callback, too late to prevent an audio packet from consuming a video sequence number.

android/app/src/main/java/com/openipc/decoder/RtpListener.java[146-150]
android/app/src/main/java/com/openipc/decoder/RtpListener.java[194-207]
android/app/src/main/java/com/openipc/decoder/RtpReorder.java[42-66]
android/app/src/main/java/com/openipc/decoder/RtpReorder.java[108-118]
android/app/src/main/java/com/openipc/decoder/RtspSession.java[433-451]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The plain-RTP listener mixes independent audio and video RTP sequence numbers in one reorder buffer, allowing audio packets to displace video packets.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/RtpListener.java[149-150]
- android/app/src/main/java/com/openipc/decoder/RtpListener.java[194-207]
- android/app/src/main/java/com/openipc/decoder/RtspSession.java[433-451]
## Recommended Fix
Select the video payload type before reordering and exclude other payload types from the video buffer; keep sequence domains separate if more than one stream must be received. Add a test with interleaved audio and video using independent sequence numbers.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Required device playback is untested 📎 Requirement gap ☼ Reliability
Description
RtpListenerTest parses a multicast address but receives only synthetic loopback unicast packets,
while RtpListener.joinGroup() and MulticastLock.acquire() have no device or traffic
verification. On group-A hardware, this leaves untested H.264/H.265 unicast and multicast playback
in single and grid views, mid-stream joins, camera restarts, Wi-Fi lock lifetime, Ethernet handling,
and Majestic multicast destination and TTL.
Code

android/app/src/test/java/com/openipc/decoder/RtpListenerTest.java[R161-178]

+    @Test
+    public void receivesH264AndFollowsASwitchToH265() throws Exception {
+        int port = freePort();
+        listen("udp://@:" + port);
+        send(port, 96, 3);
+        assertTrue(rec.latch.await(3, TimeUnit.SECONDS));
+        assertEquals(1, rec.sdps.size());
+        assertEquals(96, rec.sdps.get(0).videoPt);
+        assertFalse(rec.sdps.get(0).videoH265);
+        assertTrue(rec.statuses.contains("connected"));
+
+        // The camera switched to H.265: a new session description, then the packets
+        rec.latch = new CountDownLatch(2);
+        send(port, 97, 2);
+        assertTrue(rec.latch.await(3, TimeUnit.SECONDS));
+        assertEquals(2, rec.sdps.size());
+        assertTrue(rec.sdps.get(1).videoH265);
+    }
Evidence
Compliance ID 8 calls for device validation of both transports and codecs, single and multi-view
playback, mid-stream joins, and camera restarts; the added tests instead use synthetic loopback
packets and check a payload-type switch. Compliance ID 3 calls for multicast permission, lock
lifetime, and Ethernet verification: the permission and lock path exist, but the tests provide no
Wi-Fi or Ethernet device evidence. Compliance ID 7 calls for multicast group delivery and TTL
verification: the receiver joins the configured group and the tests parse a group address, but
packet reception uses loopback unicast and no outgoing destination or TTL is verified.

Validate playback on devices
android/app/src/test/java/com/openipc/decoder/RtpListenerTest.java[161-178]
android/app/src/test/java/com/openipc/decoder/RtpListenerTest.java[203-237]
android/app/src/test/java/com/openipc/decoder/RtpListenerTest.java[161-165]
android/app/src/main/java/com/openipc/decoder/RtpListener.java[238-264]
android/app/src/main/java/com/openipc/decoder/RtpListener.java[160-165]
android/app/src/test/java/com/openipc/decoder/RtpListenerTest.java[48-58]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The PR adds loopback receiver tests and multicast join and lock handling, but lacks device and traffic verification of the required playback scenarios, Wi-Fi lock lifetime, Ethernet behavior, and Majestic multicast delivery and TTL.
## Fix Focus Areas
- android/app/src/test/java/com/openipc/decoder/RtpListenerTest.java[161-178]
- android/app/src/main/java/com/openipc/decoder/RtspSession.java[414-466]
- android/app/src/test/java/com/openipc/decoder/RtpListenerTest.java[161-165]
- android/app/src/main/java/com/openipc/decoder/RtpListener.java[238-264]
- android/app/src/main/java/com/openipc/decoder/RtpListener.java[160-165]
- android/app/src/test/java/com/openipc/decoder/RtpListenerTest.java[48-58]
## Recommended Fix
Execute and record group-A device tests for unicast and multicast H.264/H.265 playback in single and grid views, mid-stream joins, and recovery after camera restarts. Test a multicast slot over Wi-Fi and Ethernet, confirming that the multicast lock is acquired during playback and released when the session stops. Capture or otherwise verify Majestic outgoing traffic to establish its multicast destination and TTL, and add the observed behavior and a multicast receive test to the PR documentation or test coverage.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Audio arriving first blocks video playback ✓ Resolved
Description
With ?codec= but no pt=, listen() treats the first received payload type as video and retains
that type in sdp.videoPt. If an audio packet arrives first on a shared port, subsequent video
packets have a different payload type and cannot establish a new session description or reach the
video assembler.
Code

android/app/src/main/java/com/openipc/decoder/RtspSession.java[R438-440]

+                boolean video = u.pt >= 0 ? pt == u.pt
+                        : u.codec != null ? sdp == null || pt == sdp.videoPt
+                        : pt == Sdp.PT_H264 || pt == Sdp.PT_H265;
Evidence
The codec-only branch accepts any payload type while sdp is null, but accepts only the recorded
type afterward. Both playback consumers reject packets whose type differs from the recorded video
type.

android/app/src/main/java/com/openipc/decoder/RtspSession.java[436-451]
android/app/src/main/java/com/openipc/decoder/RtspClient.java[125-133]
android/app/src/main/java/com/openipc/decoder/QuadCell.java[211-226]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A codec-only URL makes the first received RTP payload type the permanent video type, even when that packet is audio.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/RtspSession.java[436-451]
- android/app/src/main/java/com/openipc/decoder/RtpListener.java[104-123]
## Recommended Fix
Do not infer an arbitrary video payload type from the first packet when only codec is specified. Require an explicit payload type for nonstandard video types, or select only known video types without that override. Test audio arriving before video.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. An absent sender causes repeated rebinds ✓ Resolved
Description
listen() returns true after its receive loop exits even when no usable video packet arrived.
When the watchdog closes an idle listener, networkLoop() interprets that return value as a
successful session and resets the retry delay instead of backing off.
Code

android/app/src/main/java/com/openipc/decoder/RtspSession.java[460]

+            return true;
Evidence
The watchdog ends an inactive session by clearing active and closing its listener. That makes the
receive loop exit normally, after which listen() returns true; networkLoop() resets its retry
state for every true result.

android/app/src/main/java/com/openipc/decoder/RtspSession.java[233-245]
android/app/src/main/java/com/openipc/decoder/RtpListener.java[189-218]
android/app/src/main/java/com/openipc/decoder/RtspSession.java[451-465]
android/app/src/main/java/com/openipc/decoder/RtspSession.java[116-136]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
An idle plain-RTP listener reports success, so watchdog-triggered reconnects never increase their retry delay.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/RtspSession.java[431-460]
- android/app/src/main/java/com/openipc/decoder/RtspSession.java[116-136]
## Recommended Fix
Track whether usable video arrived during the listening attempt. Return an unsuccessful result when the watchdog ends an attempt with no usable video, so the existing reconnect loop applies back-off.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View review recommended (1)
5. An old keepalive can stop a new listener ✓ Resolved
Description
listen() marks a new plain-RTP listener active without clearing the previous RTSP connection's
keepalive. If the same session is stopped and immediately restarted with a plain-RTP URL before
the old network thread clears that state, the new watchdog can send on the closed RTSP connection
and close the new listener when the send fails.
Code

android/app/src/main/java/com/openipc/decoder/RtspSession.java[R421-425]

+            listener = l;
+            lastPacketMs = System.nanoTime() / 1_000_000;
+            streamBytes = 0;
+            bitrateMarkNs = System.nanoTime();
+            active = true;
Evidence
Stopping does not clear keepalive, and the RTSP connection clears it only in its network thread's
finally block. The new listener sets active before that thread is guaranteed to finish; the
watchdog uses any non-null keepalive while active and closes the current listener on a send failure.

android/app/src/main/java/com/openipc/decoder/RtspSession.java[143-174]
android/app/src/main/java/com/openipc/decoder/RtspSession.java[215-226]
android/app/src/main/java/com/openipc/decoder/RtspSession.java[373-403]
android/app/src/main/java/com/openipc/decoder/RtspSession.java[414-426]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A newly active plain-RTP session can briefly inherit keepalive state from a stopped RTSP session and have its listener closed by a failed send.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/RtspSession.java[414-426]
- android/app/src/main/java/com/openipc/decoder/RtspSession.java[143-174]
- android/app/src/main/java/com/openipc/decoder/RtspSession.java[215-226]
## Recommended Fix
Invalidate the previous connection's keepalive state during the synchronized handoff to a plain-RTP listener. Ensure an old generation cannot clear keepalive state belonging to a newer connection.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can commit Qodo's fix in one click with committable suggestions (GitHub & GitLab)

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment on lines +161 to +178
@Test
public void receivesH264AndFollowsASwitchToH265() throws Exception {
int port = freePort();
listen("udp://@:" + port);
send(port, 96, 3);
assertTrue(rec.latch.await(3, TimeUnit.SECONDS));
assertEquals(1, rec.sdps.size());
assertEquals(96, rec.sdps.get(0).videoPt);
assertFalse(rec.sdps.get(0).videoH265);
assertTrue(rec.statuses.contains("connected"));

// The camera switched to H.265: a new session description, then the packets
rec.latch = new CountDownLatch(2);
send(port, 97, 2);
assertTrue(rec.latch.await(3, TimeUnit.SECONDS));
assertEquals(2, rec.sdps.size());
assertTrue(rec.sdps.get(1).videoH265);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Required device playback is untested 📎 Requirement gap ☼ Reliability

RtpListenerTest parses a multicast address but receives only synthetic loopback unicast packets,
while RtpListener.joinGroup() and MulticastLock.acquire() have no device or traffic
verification. On group-A hardware, this leaves untested H.264/H.265 unicast and multicast playback
in single and grid views, mid-stream joins, camera restarts, Wi-Fi lock lifetime, Ethernet handling,
and Majestic multicast destination and TTL.
Agent Prompt
## Issue description
The PR adds loopback receiver tests and multicast join and lock handling, but lacks device and traffic verification of the required playback scenarios, Wi-Fi lock lifetime, Ethernet behavior, and Majestic multicast delivery and TTL.

## Fix Focus Areas
- android/app/src/test/java/com/openipc/decoder/RtpListenerTest.java[161-178]
- android/app/src/main/java/com/openipc/decoder/RtspSession.java[414-466]
- android/app/src/test/java/com/openipc/decoder/RtpListenerTest.java[161-165]
- android/app/src/main/java/com/openipc/decoder/RtpListener.java[238-264]
- android/app/src/main/java/com/openipc/decoder/RtpListener.java[160-165]
- android/app/src/test/java/com/openipc/decoder/RtpListenerTest.java[48-58]

## Recommended Fix
Execute and record group-A device tests for unicast and multicast H.264/H.265 playback in single and grid views, mid-stream joins, and recovery after camera restarts. Test a multicast slot over Wi-Fi and Ethernet, confirming that the multicast lock is acquired during playback and released when the session stops. Capture or otherwise verify Majestic outgoing traffic to establish its multicast destination and TTL, and add the observed behavior and a multicast receive test to the PR documentation or test coverage.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed that it needs the device; it is the "Device test" checklist in the PR description (Majestic outgoing unicast and multicast, H.264/H.265, single view and grid, Wi‑Fi and Ethernet, codec switch, camera restart). The test hardware is busy for now, so the PR stays a draft until that round is done; this thread stays open until then.

Comment thread android/app/src/main/java/com/openipc/decoder/RtpListener.java Outdated
Comment thread android/app/src/main/java/com/openipc/decoder/RtspSession.java Outdated
Comment thread android/app/src/main/java/com/openipc/decoder/RtspSession.java
Comment thread android/app/src/main/java/com/openipc/decoder/RtspSession.java
@compot-dev compot-dev added the awaiting device test PR ready, merge only after testing on a real device label Oct 12, 2026
@compot-dev

Copy link
Copy Markdown
Collaborator Author

Code done, CI green, Qodo findings addressed. Waiting for the device round in #74.

…multicast (#68)

A slot URL of the form udp://[source@][address]:port[?codec=h264|h265&pt=N]
(rtp:// works too, the syntax is VLC's) receives the stream a camera pushes,
with no RTSP: Majestic "outgoing" destinations send RTP with payload type 96
(H.264) or 97 (H.265) and repeat the parameter sets before every IDR, so the
stream can be joined at any time without an SDP.

- udp://@:5600 listens on a port; udp://@239.1.2.3:5600 joins the group;
  udp://192.168.1.10@239.1.2.3:5600 accepts that sender only.
- RtspSession runs it in place of the RTSP handshake, so the watchdog,
  reconnection and status are the same as for RTSP slots.
- The codec follows the payload type and a switch between 96 and 97 on
  the camera is followed; codec= and pt= force them for other senders.
- RtpListener uses a MulticastSocket (DatagramChannel.join needs API 24)
  and the reorder buffer of #67. Unicast ports are bound exclusively, so a
  second slot on the same port fails instead of receiving nothing.
- Wi-Fi drivers drop multicast unless an app holds a MulticastLock: it is
  held while any slot receives a group (CHANGE_WIFI_MULTICAST_STATE).
- One reorder buffer per payload type: audio on the same port, with its
  own sequence numbers, no longer pushes video packets out as late ones.
- Video is payload type 96 or 97 unless pt= names another; codec= alone
  only overrides the codec, so audio arriving first is not taken for video.
- A quiet sender is reported (disconnected, then connected again with the
  next packet) and the port stays bound: a rebind only lost packets and,
  being counted as a played session, never backed off.
- stop() and a starting listener clear the keepalive of an older RTSP
  connection, so the watchdog cannot send on it and close the listener.
- android.hardware.wifi is not required: the multicast permission must
  not hide the app on Ethernet-only TV boxes (lint LeanbackUsesWifi).
On Linux a socket bound to the wildcard address receives every group
joined on that port on the device, so two slots on different groups with
the same port would mix their streams (found by the review of #73).
@compot-dev
compot-dev marked this pull request as ready for review October 12, 2026 20:25
@compot-dev compot-dev removed the awaiting device test PR ready, merge only after testing on a real device label Oct 12, 2026
@compot-dev

Copy link
Copy Markdown
Collaborator Author

Device test: all #72 items pass except a silent failure for two udp:// slots on one port in the grid (#80, not a blocker). Majestic does send outgoing RTP to a multicast group, and the slot joins and plays it. See #74 run 1.

@compot-dev
compot-dev changed the base branch from feat/rtsp-udp to v2 October 12, 2026 20:26
@compot-dev
compot-dev merged commit 834a94a into v2 Oct 12, 2026
4 checks passed
@compot-dev
compot-dev deleted the feat/rtp-udp branch October 13, 2026 17:16
compot-dev added a commit that referenced this pull request Oct 15, 2026
feat: receive plain RTP from Majestic outgoing over UDP, unicast and multicast (#68)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Receive plain RTP from Majestic outgoing over UDP (unicast and multicast)

1 participant