From 9868b29dee27171527a6dfc9691b5c6be3c068d5 Mon Sep 17 00:00:00 2001
From: kyle-fitzp
Date: Tue, 30 Jun 2026 09:31:01 -0500
Subject: [PATCH 1/4] Initial unfinished RTMP rework
---
.../impl/sensor/rtmp/RtmpPortSingleton.java | 65 --
.../sensor/rtmp/config/ConnectionConfig.java | 43 -
.../impl/sensor/rtmp/config/HostType.java | 26 -
.../sensor/{rtmp => rtmpcam}/Activator.java | 2 +-
.../{rtmp => rtmpcam}/RtmpDescriptor.java | 4 +-
.../sensor/{rtmp => rtmpcam}/RtmpDriver.java | 41 +-
.../rtmpcam/config/ConnectionConfig.java | 70 ++
.../{rtmp => rtmpcam}/config/RtmpConfig.java | 2 +-
.../connection/RtmpConnectionContext.java | 9 +
.../connection/RtmpConnectionHandler.java | 157 ++++
.../rtmpcam/connection/RtmpListener.java | 36 +
.../connection/RtmpListenerManager.java | 97 +++
.../rtmpcam/connection/RtmpNegotiator.java | 815 ++++++++++++++++++
.../rtmpcam/connection/RtmpPortServer.java | 60 ++
.../rtmpcam/event/RtmpConnectEvent.java | 17 +
.../rtmpcam/event/RtmpDisconnectEvent.java | 8 +
.../impl/sensor/rtmpcam/event/RtmpEvent.java | 5 +
.../rtmpcam/event/RtmpEventCallback.java | 5 +
.../rtmpcam/event/RtmpReconnectEvent.java | 8 +
.../sensor/rtmpcam/event/RtmpStreamEvent.java | 16 +
.../sensor/rtmpcam/helpers/RtmpClientSet.java | 92 ++
.../rtmpcam/helpers/RtmpStringParser.java | 49 ++
.../sensor/rtmpcam/stream/StreamInfo.java | 8 +
.../org.sensorhub.api.module.IModuleProvider | 2 +-
24 files changed, 1492 insertions(+), 145 deletions(-)
delete mode 100644 sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmp/RtmpPortSingleton.java
delete mode 100644 sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmp/config/ConnectionConfig.java
delete mode 100644 sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmp/config/HostType.java
rename sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/{rtmp => rtmpcam}/Activator.java (95%)
rename sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/{rtmp => rtmpcam}/RtmpDescriptor.java (91%)
rename sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/{rtmp => rtmpcam}/RtmpDriver.java (94%)
create mode 100644 sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/config/ConnectionConfig.java
rename sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/{rtmp => rtmpcam}/config/RtmpConfig.java (97%)
create mode 100644 sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/connection/RtmpConnectionContext.java
create mode 100644 sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/connection/RtmpConnectionHandler.java
create mode 100644 sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/connection/RtmpListener.java
create mode 100644 sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/connection/RtmpListenerManager.java
create mode 100644 sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/connection/RtmpNegotiator.java
create mode 100644 sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/connection/RtmpPortServer.java
create mode 100644 sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/event/RtmpConnectEvent.java
create mode 100644 sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/event/RtmpDisconnectEvent.java
create mode 100644 sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/event/RtmpEvent.java
create mode 100644 sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/event/RtmpEventCallback.java
create mode 100644 sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/event/RtmpReconnectEvent.java
create mode 100644 sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/event/RtmpStreamEvent.java
create mode 100644 sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/helpers/RtmpClientSet.java
create mode 100644 sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/helpers/RtmpStringParser.java
create mode 100644 sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/stream/StreamInfo.java
diff --git a/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmp/RtmpPortSingleton.java b/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmp/RtmpPortSingleton.java
deleted file mode 100644
index 1f1bc4098..000000000
--- a/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmp/RtmpPortSingleton.java
+++ /dev/null
@@ -1,65 +0,0 @@
-/***************************** BEGIN LICENSE BLOCK ***************************
- The contents of this file are subject to the Mozilla Public License, v. 2.0.
- If a copy of the MPL was not distributed with this file, You can obtain one
- at http://mozilla.org/MPL/2.0/.
-
- Software distributed under the License is distributed on an "AS IS" basis,
- WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- for the specific language governing rights and limitations under the License.
-
- Copyright (C) 2026 GeoRobotix Innovative Research, Inc. All Rights Reserved.
- ******************************* END LICENSE BLOCK ***************************/
-
-package org.sensorhub.impl.sensor.rtmp;
-
-import java.util.HashMap;
-import java.util.Map;
-
-/**
- * Tracks RTMP listener ports currently reserved by RTMP driver modules.
- *
- * The singleton prevents multiple RTMP driver instances from attempting to listen
- * on the same port at the same time. All public methods are synchronized to
- * provide simple thread-safe access to the port reservation map.
- *
- */
-public final class RtmpPortSingleton {
- private static final RtmpPortSingleton instance = new RtmpPortSingleton();
-
- private final Map urls = new HashMap<>();
-
- public static RtmpPortSingleton getInstance() {
- return instance;
- }
-
- /**
- * Attempts to reserve a port for the specified module.
- *
- * If the port is not already reserved, this method records the module unique
- * identifier and returns {@code null}. If the port is already reserved, this
- * method returns the unique identifier of the module that currently owns it.
- *
- *
- * @param url RTMP listener port to reserve
- * @param moduleUid unique identifier of the module requesting the port
- * @return {@code null} if the reservation succeeded; otherwise the unique
- * identifier of the module currently using the port
- */
- public synchronized String addConnection(int url, String moduleUid) {
- if (urls.containsKey(url)) {
- return urls.get(url);
- } else {
- urls.put(url, moduleUid);
- return null;
- }
- }
-
- /**
- * Releases a previously reserved RTMP listener port.
- *
- * @param url RTMP listener port to release
- */
- public synchronized void removeConnection(int url) {
- urls.remove(url);
- }
-}
diff --git a/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmp/config/ConnectionConfig.java b/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmp/config/ConnectionConfig.java
deleted file mode 100644
index 5f8900d5f..000000000
--- a/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmp/config/ConnectionConfig.java
+++ /dev/null
@@ -1,43 +0,0 @@
-/***************************** BEGIN LICENSE BLOCK ***************************
- The contents of this file are subject to the Mozilla Public License, v. 2.0.
- If a copy of the MPL was not distributed with this file, You can obtain one
- at http://mozilla.org/MPL/2.0/.
-
- Software distributed under the License is distributed on an "AS IS" basis,
- WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- for the specific language governing rights and limitations under the License.
-
- Copyright (C) 2026 GeoRobotix Innovative Research, Inc. All Rights Reserved.
- ******************************* END LICENSE BLOCK ***************************/
-
-package org.sensorhub.impl.sensor.rtmp.config;
-
-import org.sensorhub.api.config.DisplayInfo;
-
-public class ConnectionConfig {
-
- /*
- @DisplayInfo.Required
- @DisplayInfo(label = "Generate Random Stream Key", desc = "Enable to generate and append a random hex string to the path. " +
- "Recommended for security. Only enable on first init, otherwise path will include multiple keys. ")
- public boolean generateRandomStreamKey = true;
-
- */
-
- @DisplayInfo.Required
- @DisplayInfo(label = "Host", desc = "Domain listening for an RTMP connection request. Unspecified should work " +
- "for most cases.")
- public HostType host = HostType.UNSPECIFIED;
-
- @DisplayInfo.Required
- @DisplayInfo(label = "Port", desc = "Port listening for an RTMP connection request.")
- @DisplayInfo.ValueRange(min = 1, max = 65535)
- public int port = 1935;
-
- /*
- @DisplayInfo(label = "Path", desc = "(Optional) Path to listen for an RTMP connection request. I.e. everything in the URL " +
- "after the port.")
- public String path = "";
-
- */
-}
\ No newline at end of file
diff --git a/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmp/config/HostType.java b/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmp/config/HostType.java
deleted file mode 100644
index cd72f7f5b..000000000
--- a/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmp/config/HostType.java
+++ /dev/null
@@ -1,26 +0,0 @@
-/***************************** BEGIN LICENSE BLOCK ***************************
- The contents of this file are subject to the Mozilla Public License, v. 2.0.
- If a copy of the MPL was not distributed with this file, You can obtain one
- at http://mozilla.org/MPL/2.0/.
-
- Software distributed under the License is distributed on an "AS IS" basis,
- WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
- for the specific language governing rights and limitations under the License.
-
- Copyright (C) 2026 GeoRobotix Innovative Research, Inc. All Rights Reserved.
- ******************************* END LICENSE BLOCK ***************************/
-
-package org.sensorhub.impl.sensor.rtmp.config;
-
-public enum HostType {
- UNSPECIFIED("0.0.0.0"),
- LOCALHOST("localhost"),
- DOCKER_INTERNAL("host.docker.internal")/*,
- OVERRIDE("")*/;
-
- public final String host;
-
- HostType(String host) {
- this.host = host;
- }
-}
diff --git a/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmp/Activator.java b/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/Activator.java
similarity index 95%
rename from sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmp/Activator.java
rename to sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/Activator.java
index 8f3900c58..935857b5f 100644
--- a/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmp/Activator.java
+++ b/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/Activator.java
@@ -10,7 +10,7 @@
Copyright (C) 2026 GeoRobotix Innovative Research, Inc. All Rights Reserved.
******************************* END LICENSE BLOCK ***************************/
-package org.sensorhub.impl.sensor.rtmp;
+package org.sensorhub.impl.sensor.rtmpcam;
import org.osgi.framework.BundleActivator;
import org.sensorhub.utils.OshBundleActivator;
diff --git a/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmp/RtmpDescriptor.java b/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/RtmpDescriptor.java
similarity index 91%
rename from sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmp/RtmpDescriptor.java
rename to sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/RtmpDescriptor.java
index 85396aed1..3f3af6b82 100644
--- a/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmp/RtmpDescriptor.java
+++ b/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/RtmpDescriptor.java
@@ -10,13 +10,13 @@
Copyright (C) 2026 GeoRobotix Innovative Research, Inc. All Rights Reserved.
******************************* END LICENSE BLOCK ***************************/
-package org.sensorhub.impl.sensor.rtmp;
+package org.sensorhub.impl.sensor.rtmpcam;
import org.sensorhub.api.module.IModule;
import org.sensorhub.api.module.IModuleProvider;
import org.sensorhub.api.module.ModuleConfig;
import org.sensorhub.impl.module.JarModuleProvider;
-import org.sensorhub.impl.sensor.rtmp.config.RtmpConfig;
+import org.sensorhub.impl.sensor.rtmpcam.config.RtmpConfig;
public class RtmpDescriptor extends JarModuleProvider implements IModuleProvider
diff --git a/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmp/RtmpDriver.java b/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/RtmpDriver.java
similarity index 94%
rename from sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmp/RtmpDriver.java
rename to sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/RtmpDriver.java
index f28a7f409..9d055997f 100644
--- a/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmp/RtmpDriver.java
+++ b/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/RtmpDriver.java
@@ -10,7 +10,7 @@
Copyright (C) 2026 GeoRobotix Innovative Research, Inc. All Rights Reserved.
******************************* END LICENSE BLOCK ***************************/
-package org.sensorhub.impl.sensor.rtmp;
+package org.sensorhub.impl.sensor.rtmpcam;
import org.sensorhub.api.common.SensorHubException;
import org.sensorhub.api.module.ModuleEvent;
@@ -18,7 +18,11 @@
import org.sensorhub.impl.sensor.AbstractSensorModule;
import org.sensorhub.impl.sensor.ffmpeg.outputs.AudioOutput;
import org.sensorhub.impl.sensor.ffmpeg.outputs.VideoOutput;
-import org.sensorhub.impl.sensor.rtmp.config.RtmpConfig;
+import org.sensorhub.impl.sensor.rtmpcam.config.ConnectionConfig;
+import org.sensorhub.impl.sensor.rtmpcam.config.RtmpConfig;
+import org.sensorhub.impl.sensor.rtmpcam.connection.RtmpConnectionContext;
+import org.sensorhub.impl.sensor.rtmpcam.connection.RtmpListener;
+import org.sensorhub.impl.sensor.rtmpcam.connection.RtmpListenerManager;
import org.sensorhub.mpegts.MpegTsProcessor;
import org.sensorhub.utils.Async;
@@ -39,10 +43,10 @@
*
*
* Only one RTMP driver instance may use a given port at a time. Port ownership
- * is tracked through a shared {@link RtmpPortSingleton}.
+ * is tracked through a shared {@link RtmpListenerManager}.
*
*/
-public class RtmpDriver extends AbstractSensorModule {
+public class RtmpDriver extends AbstractSensorModule implements RtmpListener {
private static final String COMMAND_LINE_ARGS = "-timeout 0 -listen 1 -username test -password test";
private static final int EXECUTOR_JOIN_TIMEOUT = 10;
private static final TimeUnit EXECUTOR_JOIN_TIME_UNIT = TimeUnit.SECONDS;
@@ -50,13 +54,12 @@ public class RtmpDriver extends AbstractSensorModule {
private static final TimeUnit HEARTBEAT_TIME_UNIT = TimeUnit.SECONDS;
private static final int MAX_STARTUP_WAIT_TIME_MS = 5000;
- private final RtmpPortSingleton portSingleton = RtmpPortSingleton.getInstance();
+ private final RtmpListenerManager portSingleton = RtmpListenerManager.getInstance();
private ExecutorService executorService;
private ExecutorService videoExecutorService;
private ExecutorService audioExecutorService;
private ScheduledExecutorService heartbeatExecutorService;
- final AtomicReference mpegTsProcessor = new AtomicReference<>();
final AtomicReference> videoOutput = new AtomicReference<>();
final AtomicReference> audioOutput = new AtomicReference<>();
@@ -94,6 +97,8 @@ protected void doInit() throws SensorHubException {
generateXmlID("RTMP_", config.serialNumber);
}
+
+
portSingleton.removeConnection(connectionPort);
setConnectionUrl();
@@ -501,4 +506,28 @@ public void cleanup() throws SensorHubException {
public boolean isConnected() {
return isConnected;
}
+
+ @Override
+ public ConnectionConfig config() {
+ return config.connectionConfig;
+ }
+
+ @Override
+ public void publish(byte[] data, int streamIndex, long pts, boolean isVideo) {
+ if (isVideo) {
+ videoOutput.get().
+ }
+ }
+
+ @Override
+ public void onStreamConnected(RtmpConnectionContext ctx) {
+
+ }
+
+ public void onStreamInfo()
+
+ @Override
+ public void onStreamDisconnected() {
+
+ }
}
\ No newline at end of file
diff --git a/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/config/ConnectionConfig.java b/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/config/ConnectionConfig.java
new file mode 100644
index 000000000..86cda0855
--- /dev/null
+++ b/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/config/ConnectionConfig.java
@@ -0,0 +1,70 @@
+/***************************** BEGIN LICENSE BLOCK ***************************
+ The contents of this file are subject to the Mozilla Public License, v. 2.0.
+ If a copy of the MPL was not distributed with this file, You can obtain one
+ at http://mozilla.org/MPL/2.0/.
+
+ Software distributed under the License is distributed on an "AS IS" basis,
+ WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ for the specific language governing rights and limitations under the License.
+
+ Copyright (C) 2026 GeoRobotix Innovative Research, Inc. All Rights Reserved.
+ ******************************* END LICENSE BLOCK ***************************/
+
+package org.sensorhub.impl.sensor.rtmpcam.config;
+
+import org.sensorhub.api.config.DisplayInfo;
+
+public class ConnectionConfig {
+
+ /*
+ @DisplayInfo.Required
+ @DisplayInfo(label = "Generate Random Stream Key", desc = "Enable to generate and append a random hex string to the path. " +
+ "Recommended for security. Only enable on first init, otherwise path will include multiple keys. ")
+ public boolean generateRandomStreamKey = true;
+
+ */
+
+ @DisplayInfo.Required
+ @DisplayInfo(label = "Username")
+ public String username = "";
+
+ @DisplayInfo.Required
+ @DisplayInfo(label = "Password")
+ public String password = "";
+
+ @DisplayInfo.Required
+ @DisplayInfo(label = "Port", desc = "Port listening for an RTMP connection request.")
+ @DisplayInfo.ValueRange(min = 1, max = 65535)
+ public int port = 1935;
+
+ @DisplayInfo.Required
+ @DisplayInfo(label = "Path")
+ public String path = "";
+
+ @DisplayInfo(label = "Stream Key", desc = "(Optional) Stream key to use for the RTMP connection.")
+ public String streamKey = "";
+
+ @DisplayInfo(label = "Generate Random Stream Key", desc = "Overwrite the stream key field with a random string of characters.")
+ public boolean generateRandomKey = false;
+
+ /** Key for this config's exact fields — used as the map key on registration. */
+ public String compositeKey() {
+ return compositeKey(username, password, port, path, streamKey);
+ }
+
+ /**
+ * Static form used by the router to generate wildcard candidate keys
+ * (null fields become empty strings, producing a distinct key per specificity level).
+ *
+ * Format: "username:password:port:path:streamKey"
+ * Example: "alice:secret:1935:live:cam1"
+ * Catch-all: "::1935::"
+ */
+ public static String compositeKey(String username, String password, int port, String path, String streamKey) {
+ return (username != null ? username : "") + ":" +
+ (password != null ? password : "") + ":" +
+ port + ":" +
+ (path != null ? path : "") + ":" +
+ (streamKey != null ? streamKey : "");
+ }
+}
\ No newline at end of file
diff --git a/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmp/config/RtmpConfig.java b/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/config/RtmpConfig.java
similarity index 97%
rename from sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmp/config/RtmpConfig.java
rename to sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/config/RtmpConfig.java
index d466a4ba0..43ae02853 100644
--- a/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmp/config/RtmpConfig.java
+++ b/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/config/RtmpConfig.java
@@ -10,7 +10,7 @@
Copyright (C) 2026 GeoRobotix Innovative Research, Inc. All Rights Reserved.
******************************* END LICENSE BLOCK ***************************/
-package org.sensorhub.impl.sensor.rtmp.config;
+package org.sensorhub.impl.sensor.rtmpcam.config;
import org.sensorhub.api.config.DisplayInfo;
import org.sensorhub.api.sensor.PositionConfig;
diff --git a/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/connection/RtmpConnectionContext.java b/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/connection/RtmpConnectionContext.java
new file mode 100644
index 000000000..eb38056fd
--- /dev/null
+++ b/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/connection/RtmpConnectionContext.java
@@ -0,0 +1,9 @@
+package org.sensorhub.impl.sensor.rtmpcam.connection;
+
+public record RtmpConnectionContext(
+ int port,
+ String username,
+ String password,
+ String path,
+ String streamKey
+) {}
diff --git a/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/connection/RtmpConnectionHandler.java b/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/connection/RtmpConnectionHandler.java
new file mode 100644
index 000000000..34ae623f3
--- /dev/null
+++ b/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/connection/RtmpConnectionHandler.java
@@ -0,0 +1,157 @@
+package org.sensorhub.impl.sensor.rtmpcam.connection;
+
+import org.bytedeco.ffmpeg.avcodec.AVPacket;
+import org.bytedeco.ffmpeg.avformat.*;
+import org.bytedeco.ffmpeg.avutil.AVDictionary;
+import org.bytedeco.javacpp.BytePointer;
+import org.bytedeco.javacpp.Pointer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.*;
+import java.net.Socket;
+import java.util.Optional;
+
+import static org.bytedeco.ffmpeg.global.avcodec.*;
+import static org.bytedeco.ffmpeg.global.avformat.*;
+import static org.bytedeco.ffmpeg.global.avutil.*;
+
+/**
+ * Handles one RTMP connection:
+ * 1. Handshake
+ * 2. AMF0 negotiation → {@link RtmpConnectionContext}
+ * 3. Route to a matching {@link RtmpListener}
+ * 4. RTMP chunks → FLV pipe → FFmpeg custom AVIO
+ * 5. Deliver encoded packets via {@link RtmpListener#publish}
+ */
+class RtmpConnectionHandler {
+
+ private static final int AVIO_BUF = 64 * 1024;
+ private static final Logger logger = LoggerFactory.getLogger(RtmpConnectionHandler.class);
+
+ private final Socket socket;
+ private final int port;
+ private final RtmpListenerManager manager;
+
+ RtmpConnectionHandler(Socket socket, int port, RtmpListenerManager manager) {
+ this.socket = socket;
+ this.port = port;
+ this.manager = manager;
+ }
+
+ void handle() {
+ try (socket) {
+ var in = new DataInputStream(socket.getInputStream());
+ var out = new DataOutputStream(socket.getOutputStream());
+
+ // One negotiator instance owns all state across all three phases
+ RtmpNegotiator negotiator = new RtmpNegotiator(in, out, port);
+
+ negotiator.doHandshake();
+ RtmpConnectionContext ctx = negotiator.negotiate();
+
+ Optional match = manager.route(ctx);
+ if (match.isEmpty()) {
+ // No listener registered for this combination — drop silently
+ System.out.printf("[RTMP:%d] No listener for path='%s' key='%s'%n",
+ port, ctx.path(), ctx.streamKey());
+ return;
+ }
+
+ RtmpListener listener = match.get();
+ listener.onConnected(ctx);
+ try {
+ pipeToFfmpeg(negotiator.buildFlvStream(), listener);
+ } finally {
+ listener.onDisconnected();
+ }
+
+ } catch (Exception e) {
+ System.err.printf("[RTMP:%d] connection fault: %s%n", port, e.getMessage());
+ }
+ }
+
+ // ── FFmpeg pipeline ────────────────────────────────────────────────────
+
+ private void pipeToFfmpeg(InputStream flvStream, RtmpListener listener) {
+
+ // Both must stay reachable for the pipeline's lifetime:
+ // readCb — stored as a raw native function pointer inside AVIOContext
+ // avioBuf — FFmpeg takes ownership; free via ctx.buffer(), not this reference
+ Read_packet_Pointer_BytePointer_int readCb = buildReadCb(flvStream);
+ BytePointer avioBuf = new BytePointer(av_malloc(AVIO_BUF)).capacity(AVIO_BUF);
+
+ AVIOContext avioCtx = avio_alloc_context(
+ avioBuf, AVIO_BUF,
+ 0, // read-only
+ (Pointer) null, // opaque
+ (Read_packet_Pointer_BytePointer_int) readCb, (Write_packet_Pointer_BytePointer_int) null, (Seek_Pointer_long_int) null); // no write, no seek (live stream)
+
+ AVFormatContext fmtCtx = avformat_alloc_context();
+ fmtCtx.pb(avioCtx); // must be set before avformat_open_input
+
+ int ret = avformat_open_input(fmtCtx, (String) null,
+ av_find_input_format("flv"), null);
+
+ if (ret < 0) { logError("avformat_open_input", ret); freeAVIO(avioCtx); return; }
+
+ avformat_find_stream_info(fmtCtx, (AVDictionary) null);
+ packetLoop(fmtCtx, listener);
+
+ avformat_close_input(fmtCtx);
+ freeAVIO(avioCtx);
+ // readCb and avioBuf are now safe to collect
+ }
+
+ private void packetLoop(AVFormatContext fmtCtx, RtmpListener listener) {
+ AVPacket pkt = av_packet_alloc();
+ try {
+ int ret;
+ while ((ret = av_read_frame(fmtCtx, pkt)) >= 0) {
+ AVStream stream = fmtCtx.streams(pkt.stream_index());
+ int mediaType = stream.codecpar().codec_type();
+ if (mediaType == AVMEDIA_TYPE_VIDEO || mediaType == AVMEDIA_TYPE_AUDIO) {
+ byte[] data = new byte[pkt.size()];
+ pkt.data().get(data);
+ listener.publish(data, pkt.stream_index(), pkt.pts(),
+ mediaType == AVMEDIA_TYPE_VIDEO);
+ }
+ av_packet_unref(pkt);
+ }
+ if (ret != AVERROR_EOF) logError("av_read_frame", ret);
+ } finally {
+ av_packet_free(pkt);
+ }
+ }
+
+ private Read_packet_Pointer_BytePointer_int buildReadCb(InputStream src) {
+ byte[] tmp = new byte[AVIO_BUF];
+ return new Read_packet_Pointer_BytePointer_int() {
+ @Override
+ public int call(Pointer opaque, BytePointer dst, int requested) {
+ try {
+ int n = src.read(tmp, 0, Math.min(requested, tmp.length));
+ if (n <= 0) return AVERROR_EOF;
+ dst.put(tmp, 0, n);
+ return n;
+ } catch (IOException e) {
+ return AVERROR_EOF;
+ }
+ }
+ };
+ }
+
+ private static void freeAVIO(AVIOContext ctx) {
+ if (ctx == null || ctx.isNull()) return;
+ BytePointer buf = ctx.buffer();
+ if (buf != null && !buf.isNull()) av_freep(buf);
+ avio_context_free(ctx);
+ }
+
+ private static void logError(String fn, int code) {
+ try (BytePointer buf = new BytePointer(128)) {
+ av_strerror(code, buf, buf.capacity());
+ logger.warn("FFmpeg returned error code {} from {}: {}", code, fn, buf.getString());
+ }
+ }
+}
\ No newline at end of file
diff --git a/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/connection/RtmpListener.java b/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/connection/RtmpListener.java
new file mode 100644
index 000000000..6bf2de14a
--- /dev/null
+++ b/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/connection/RtmpListener.java
@@ -0,0 +1,36 @@
+package org.sensorhub.impl.sensor.rtmpcam.connection;
+
+import org.sensorhub.impl.sensor.rtmpcam.config.ConnectionConfig;
+import org.sensorhub.impl.sensor.rtmpcam.event.RtmpConnectEvent;
+import org.sensorhub.impl.sensor.rtmpcam.event.RtmpDisconnectEvent;
+import org.sensorhub.impl.sensor.rtmpcam.event.RtmpReconnectEvent;
+import org.sensorhub.impl.sensor.rtmpcam.event.RtmpStreamEvent;
+
+/**
+ * Extend this class and register it with {@link RtmpListenerManager} to
+ * receive encoded packets from a matched RTMP stream.
+ */
+public interface RtmpListener {
+
+ public ConnectionConfig config();
+
+ /**
+ * Receives one encoded packet demuxed from the RTMP stream.
+ *
+ * @param data raw encoded bytes (H.264 Annex B, AAC, etc.)
+ * @param streamIndex FFmpeg stream index within the FLV container
+ * @param pts presentation timestamp in stream timebase units
+ * @param isVideo true for video, false for audio
+ */
+ public void publish(byte[] data, int streamIndex, long pts, boolean isVideo);
+
+ /** Called once after negotiation succeeds and this listener is selected. */
+ public void onConnected(RtmpConnectEvent event);
+
+ /** Called once when the client disconnects or the pipeline faults. */
+ public void onDisconnected(RtmpDisconnectEvent event);
+
+ public void onReconnected(RtmpReconnectEvent event);
+
+ public void onStreamInfo(RtmpStreamEvent event);
+}
\ No newline at end of file
diff --git a/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/connection/RtmpListenerManager.java b/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/connection/RtmpListenerManager.java
new file mode 100644
index 000000000..36aefebc1
--- /dev/null
+++ b/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/connection/RtmpListenerManager.java
@@ -0,0 +1,97 @@
+package org.sensorhub.impl.sensor.rtmpcam.connection;
+
+import org.sensorhub.impl.sensor.rtmpcam.config.ConnectionConfig;
+
+import java.util.Comparator;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.stream.IntStream;
+
+public class RtmpListenerManager {
+
+ private static final RtmpListenerManager INSTANCE = new RtmpListenerManager();
+
+ public static RtmpListenerManager getInstance() { return INSTANCE; }
+
+ /**
+ * All 16 bitmasks for the 4 optional fields (username, password, path, streamKey),
+ * pre-sorted from most specific (0b1111 = all four) to least (0b0000 = none).
+ * The router iterates these in order so the first map hit is always the best match.
+ *
+ * Bit layout: bit3=username, bit2=password, bit1=path, bit0=streamKey
+ */
+ private static final int[] CANDIDATE_MASKS = IntStream.range(0, 16)
+ .boxed()
+ .sorted(Comparator.comparingInt(Integer::bitCount).reversed())
+ .mapToInt(Integer::intValue)
+ .toArray();
+
+ // One entry per unique composite key — second registration with the same
+ // config overwrites the first (unlike the old CopyOnWriteArrayList).
+ private final ConcurrentHashMap listeners =
+ new ConcurrentHashMap<>();
+
+ private final ConcurrentHashMap portServers =
+ new ConcurrentHashMap<>();
+
+ private final Object portLock = new Object();
+
+ // ── Registration ───────────────────────────────────────────────────────
+
+ public void addListener(RtmpListener listener) {
+ listeners.put(listener.config().compositeKey(), listener);
+
+ synchronized (portLock) {
+ portServers.computeIfAbsent(listener.config().port, port -> {
+ RtmpPortServer srv = new RtmpPortServer(port, this);
+ srv.start();
+ return srv;
+ });
+ }
+ }
+
+ public void removeListener(RtmpListener listener) {
+ // Two-arg remove: only deletes if the value still matches this exact listener,
+ // so a replacement registered under the same key isn't accidentally removed.
+ listeners.remove(listener.config().compositeKey(), listener);
+
+ int port = listener.config().port;
+ synchronized (portLock) {
+ boolean anyRemaining = listeners.values().stream()
+ .anyMatch(l -> l.config().port == port);
+ if (!anyRemaining) {
+ RtmpPortServer srv = portServers.remove(port);
+ if (srv != null) srv.stop();
+ }
+ }
+ }
+
+ // ── Routing ────────────────────────────────────────────────────────────
+
+ /**
+ * Tries all 16 composite key variants for the connection in specificity
+ * order (most fields → fewest fields) and returns the first map hit.
+ *
+ * A listener registered as "::1935::" (catch-all, specificity 0) is only
+ * reached if no more-specific listener claims the connection.
+ */
+ Optional route(RtmpConnectionContext ctx) {
+ String u = ctx.username();
+ String pw = ctx.password();
+ String pa = ctx.path();
+ String sk = ctx.streamKey();
+
+ for (int mask : CANDIDATE_MASKS) {
+ String key = ConnectionConfig.compositeKey(
+ (mask & 0b1000) != 0 ? u : null,
+ (mask & 0b0100) != 0 ? pw : null,
+ ctx.port(),
+ (mask & 0b0010) != 0 ? pa : null,
+ (mask & 0b0001) != 0 ? sk : null);
+
+ RtmpListener found = listeners.get(key);
+ if (found != null) return Optional.of(found);
+ }
+ return Optional.empty();
+ }
+}
\ No newline at end of file
diff --git a/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/connection/RtmpNegotiator.java b/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/connection/RtmpNegotiator.java
new file mode 100644
index 000000000..b2a7b9469
--- /dev/null
+++ b/sensors/video/sensorhub-driver-rtmp/src/main/java/org/sensorhub/impl/sensor/rtmpcam/connection/RtmpNegotiator.java
@@ -0,0 +1,815 @@
+package org.sensorhub.impl.sensor.rtmpcam.connection;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.*;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+import java.util.concurrent.ThreadLocalRandom;
+
+/**
+ * Owns the complete RTMP protocol for one publisher connection.
+ *
+ * Phase 1 doHandshake() — C0/C1/C2 ↔ S0/S1/S2
+ * Phase 2 negotiate() — AMF0 command loop → RtmpConnectionContext
+ * Phase 3 buildFlvStream() — chunk → FLV pump on a virtual thread → InputStream
+ *
+ * All three phases share chunk-size and per-stream reassembly state.
+ */
+public class RtmpNegotiator {
+
+ // ── Protocol constants ─────────────────────────────────────────────────
+
+ private static final int RTMP_VERSION = 3;
+ private static final int HANDSHAKE_SIZE = 1536;
+ private static final int DEFAULT_CHUNK_SIZE = 128;
+ private static final int SERVER_CHUNK_SIZE = 4096;
+ private static final int WINDOW_ACK_SIZE = 2_500_000;
+
+ // Inbound message type IDs
+ private static final int MSG_SET_CHUNK_SIZE = 1;
+ private static final int MSG_ABORT = 2;
+ private static final int MSG_ACK = 3;
+ private static final int MSG_USER_CONTROL = 4;
+ private static final int MSG_WINDOW_ACK_SIZE = 5;
+ private static final int MSG_SET_PEER_BANDWIDTH = 6;
+ private static final int MSG_AUDIO = 8;
+ private static final int MSG_VIDEO = 9;
+ private static final int MSG_DATA_AMF3 = 15;
+ private static final int MSG_COMMAND_AMF3 = 17;
+ private static final int MSG_DATA_AMF0 = 18;
+ private static final int MSG_COMMAND_AMF0 = 20;
+
+ // User control event types
+ private static final int EVENT_PING_REQUEST = 6;
+
+ // Chunk stream IDs used for server-sent messages (all < 64 → 1-byte basic header)
+ private static final int CS_PROTOCOL = 2; // protocol control
+ private static final int CS_COMMAND = 3; // AMF0 command responses
+ private static final int CS_STREAM = 5; // per-stream status (onStatus)
+
+ private static final byte[] FLV_HEADER = {
+ 'F', 'L', 'V',
+ 0x01, // version
+ 0x05, // flags: audio | video
+ 0x00, 0x00, 0x00, 0x09, // data offset = 9
+ 0x00, 0x00, 0x00, 0x00 // PreviousTagSize0 = 0
+ };
+ private static final Logger logger = LoggerFactory.getLogger(RtmpNegotiator.class);
+
+ // ── Instance state ─────────────────────────────────────────────────────
+
+ private final DataInputStream in;
+ private final DataOutputStream out;
+ private final int port;
+
+ private int readChunkSize = DEFAULT_CHUNK_SIZE;
+ private int windowAckSize = WINDOW_ACK_SIZE;
+ private long bytesReceived = 0;
+ private long bytesAcked = 0;
+
+ private final Map chunkStreams = new HashMap<>();
+
+ public RtmpNegotiator(DataInputStream in, DataOutputStream out, int port) {
+ this.in = in;
+ this.out = out;
+ this.port = port;
+ }
+
+ // ── Phase 1: Handshake ─────────────────────────────────────────────────
+
+ /**
+ * Performs the C0/C1/C2 ↔ S0/S1/S2 handshake (simple mode).
+ * Modern publishers (OBS, FFmpeg, etc.) accept the simple echo handshake.
+ */
+ public void doHandshake() throws IOException {
+ // C0: version byte
+ int c0 = in.readUnsignedByte();
+ if (c0 != RTMP_VERSION) throw new IOException("Unsupported RTMP version: " + c0);
+
+ // C1: [4 time] [4 zeros] [1528 random]
+ byte[] c1 = new byte[HANDSHAKE_SIZE];
+ in.readFully(c1);
+
+ // S0
+ out.writeByte(RTMP_VERSION);
+
+ // S1: [4 time=0] [4 zeros] [1528 random]
+ byte[] s1 = new byte[HANDSHAKE_SIZE];
+ ThreadLocalRandom.current().nextBytes(s1);
+ Arrays.fill(s1, 0, 8, (byte) 0); // zero time and reserved fields
+
+ // S2: echo of C1 — [4 C1-time] [4 server-time] [1528 C1-random]
+ byte[] s2 = Arrays.copyOf(c1, HANDSHAKE_SIZE);
+ long now = System.currentTimeMillis();
+ s2[4] = (byte)(now >> 24);
+ s2[5] = (byte)(now >> 16);
+ s2[6] = (byte)(now >> 8);
+ s2[7] = (byte) now;
+
+ out.write(s1);
+ out.write(s2);
+ out.flush();
+
+ // C2: echo of S1 — read and discard (no validation for simple mode)
+ in.readFully(new byte[HANDSHAKE_SIZE]);
+ }
+
+ // ── Phase 2: AMF0 Negotiation ──────────────────────────────────────────
+
+ /**
+ * Sends server control messages then loops over incoming RTMP chunks,
+ * processing AMF0 commands until a {@code publish} command is confirmed.
+ *
+ * Handles: connect, releaseStream, FCPublish, createStream, publish.
+ * Ignores: getStreamLength, FCSubscribe, and any unknown commands.
+ */
+ public RtmpConnectionContext negotiate() throws IOException {
+ // Server control sent immediately — expected before any AMF0 exchange
+ sendWindowAckSize(WINDOW_ACK_SIZE);
+ sendSetPeerBandwidth(WINDOW_ACK_SIZE);
+ sendSetChunkSize(SERVER_CHUNK_SIZE);
+
+ String username = null;
+ String password = null;
+ String path = null;
+ String streamKey = null;
+
+ negotiateLoop:
+ while (streamKey == null) {
+ RtmpMessage msg = readMessage();
+
+ switch (msg.type()) {
+ case MSG_SET_CHUNK_SIZE -> readChunkSize = parseUInt32(msg.data());
+ case MSG_WINDOW_ACK_SIZE -> windowAckSize = parseUInt32(msg.data());
+ case MSG_ACK -> { /* client ack — no action needed */ }
+ case MSG_ABORT -> chunkStreams.remove(parseUInt32(msg.data()));
+ case MSG_USER_CONTROL -> handleUserControl(msg.data());
+ case MSG_SET_PEER_BANDWIDTH -> { /* ignore */ }
+ case MSG_DATA_AMF0 -> { /* @setDataFrame before publish — ignore */ }
+ case MSG_DATA_AMF3 -> { /* ignore */ }
+
+ case MSG_COMMAND_AMF0 -> {
+ DataInputStream amf = wrapBytes(msg.data());
+ Object nameObj = readAmf0Value(amf);
+ if (!(nameObj instanceof String cmdName)) continue;
+
+ switch (cmdName) {
+ case "connect" -> {
+ double txId = asDouble(readAmf0Value(amf));
+
+ @SuppressWarnings("unchecked")
+ Map info =
+ (Map) readAmf0Value(amf);
+
+ path = getString(info, "app");
+
+ // Credentials: prefer tcUrl embed, then explicit fields
+ String tcUrl = getString(info, "tcUrl");
+ if (tcUrl != null) {
+ String[] creds = extractCredentials(tcUrl);
+ username = creds[0];
+ password = creds[1];
+ }
+ if (username == null)
+ username = coalesce(getString(info, "user"),
+ getString(info, "username"));
+ if (password == null)
+ password = coalesce(getString(info, "pass"),
+ getString(info, "password"));
+
+ sendConnectResult(txId);
+ }
+
+ case "releaseStream", "getStreamLength", "FCSubscribe" -> { /* no-op */ }
+
+ case "FCPublish" -> {
+ double txId = asDouble(readAmf0Value(amf)); // txId
+ readAmf0Value(amf); // null
+ String name = readOptionalString(amf);
+ sendFCPublishResult(txId, name);
+ }
+
+ case "createStream" -> {
+ double txId = asDouble(readAmf0Value(amf));
+ sendCreateStreamResult(txId, 1); // always stream ID 1
+ }
+
+ case "publish" -> {
+ readAmf0Value(amf); // txId (0 for publish commands)
+ readAmf0Value(amf); // null command object
+ streamKey = (String) readAmf0Value(amf); // stream name
+ // publish type ("live", "record", "append") — ignored
+ sendPublishStart(1, streamKey);
+ break negotiateLoop;
+ }
+
+ default ->
+ System.out.printf("[RTMP:%d] Unknown negotiate command: %s%n",
+ port, cmdName);
+ }
+ }
+
+ case MSG_COMMAND_AMF3 -> {
+ // AMF3 command: skip leading 0x00 compatibility byte, then read name
+ byte[] d = msg.data();
+ if (d.length > 1) {
+ Object nameObj = readAmf0Value(wrapBytes(d, 1));
+ System.out.printf("[RTMP:%d] AMF3 command ignored: %s%n", port, nameObj);
+ }
+ }
+ }
+ }
+
+ return new RtmpConnectionContext(
+ port,
+ username != null ? username : "",
+ password != null ? password : "",
+ path != null ? path : "",
+ streamKey);
+ }
+
+ // ── Phase 3: FLV pump ──────────────────────────────────────────────────
+
+ /**
+ * Starts a virtual thread that reads RTMP chunks and writes FLV-framed
+ * bytes into a pipe. The returned {@link InputStream} is consumed by
+ * FFmpeg via custom AVIO.
+ *
+ * The background thread exits cleanly when the publisher disconnects
+ * (IOException from the socket) or sends deleteStream / FCUnpublish.
+ */
+ public InputStream buildFlvStream() throws IOException {
+ PipedOutputStream pipeOut = new PipedOutputStream();
+ PipedInputStream pipeIn = new PipedInputStream(pipeOut, 1 << 20);
+
+ Thread pumpThread = new Thread(() -> {
+ try (pipeOut) {
+ pipeOut.write(FLV_HEADER);
+ pumpFlv(pipeOut);
+ } catch (IOException ignored) {
+ // Normal exit: publisher dropped or pipe closed
+ }
+ }, "rtmp-flv-pump-" + port);
+ pumpThread.setDaemon(true);
+ pumpThread.start();
+
+ return pipeIn;
+ }
+
+ private void pumpFlv(OutputStream flvOut) throws IOException {
+ while (true) {
+ RtmpMessage msg = readMessage();
+ switch (msg.type()) {
+ case MSG_AUDIO, MSG_VIDEO -> writeFlvMediaTag(flvOut, msg);
+ case MSG_DATA_AMF0 -> writeFlvScriptTag(flvOut, msg);
+ case MSG_SET_CHUNK_SIZE -> readChunkSize = parseUInt32(msg.data());
+ case MSG_WINDOW_ACK_SIZE -> windowAckSize = parseUInt32(msg.data());
+ case MSG_ACK -> { /* ignore */ }
+ case MSG_ABORT -> chunkStreams.remove(parseUInt32(msg.data()));
+ case MSG_USER_CONTROL -> handleUserControl(msg.data());
+
+ case MSG_COMMAND_AMF0 -> {
+ Object nameObj = readAmf0Value(wrapBytes(msg.data()));
+ if (nameObj instanceof String cmdName) {
+ switch (cmdName) {
+ case "deleteStream",
+ "closeStream",
+ "FCUnpublish" -> { return; }
+ default -> { /* ignore mid-stream commands */ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // ── FLV tag emission ───────────────────────────────────────────────────
+
+ private void writeFlvMediaTag(OutputStream out, RtmpMessage msg) throws IOException {
+ writeFlvTag(out, msg.type(), msg.data(), (int) msg.timestamp());
+ }
+
+ /**
+ * Data messages carry "@setDataFrame" + "onMetaData" + ECMA array.
+ * FLV script tags expect "onMetaData" + ECMA array — strip the prefix.
+ */
+ private void writeFlvScriptTag(OutputStream out, RtmpMessage msg) throws IOException {
+ DataInputStream src = wrapBytes(msg.data());
+ Object first = readAmf0Value(src);
+
+ byte[] payload = "@setDataFrame".equals(first)
+ ? src.readAllBytes() // remaining bytes start with "onMetaData"
+ : msg.data(); // already starts with event name
+
+ writeFlvTag(out, 0x12 /* script */, payload, (int) msg.timestamp());
+ }
+
+ /**
+ * Writes one complete FLV tag:
+ * [1 type][3 dataSize][3 ts_low][1 ts_high][3 streamId=0][N data][4 prevTagSize]
+ */
+ private static void writeFlvTag(OutputStream out, int tagType,
+ byte[] payload, int ts) throws IOException {
+ int dataSize = payload.length;
+ int tagTotal = 11 + dataSize;
+
+ out.write(tagType);
+ writeUInt24(out, dataSize);
+ writeUInt24(out, ts & 0x00FFFFFF); // lower 24 bits
+ out.write((ts >> 24) & 0xFF); // TimestampExtended (upper 8 bits)
+ writeUInt24(out, 0); // StreamID always 0 in FLV
+ out.write(payload);
+ writeUInt32(out, tagTotal); // PreviousTagSize
+ }
+
+ // ── Chunk reassembly ───────────────────────────────────────────────────
+
+ /**
+ * Reads and reassembles RTMP chunks until a complete RTMP message is ready.
+ *
+ * Chunk wire format (per Adobe spec):
+ * [basic header 1-3B] [message header 0/3/7/11B] [ext timestamp 0/4B] [payload ≤ chunkSize]
+ *
+ * fmt=0: full 11-byte header → new message with absolute timestamp
+ * fmt=1: 7-byte header → new message, inherits stream ID, delta timestamp
+ * fmt=2: 3-byte header → continues with same type+length, delta timestamp
+ * fmt=3: no header → continuation chunk, same everything
+ */
+ private RtmpMessage readMessage() throws IOException {
+ while (true) {
+
+ // ── Basic header ───────────────────────────────────────────────
+ int byte0 = readByte();
+ int fmt = (byte0 >> 6) & 0x3;
+ int csId = byte0 & 0x3F;
+
+ // Extended chunk stream IDs
+ if (csId == 0) csId = readByte() + 64;
+ else if (csId == 1) csId = readByte() * 256 + readByte() + 64;
+
+ ChunkStream cs = chunkStreams.computeIfAbsent(csId, k -> new ChunkStream());
+ boolean prevDone = cs.payload == null || cs.bytesRead >= cs.messageLength;
+
+ // ── Message header ─────────────────────────────────────────────
+ switch (fmt) {
+ case 0 -> {
+ // New message — absolute timestamp, full header
+ long ts = readUInt24();
+ cs.messageLength = (int) readUInt24();
+ cs.messageType = readByte();
+ cs.messageStreamId = readLittleEndianInt();
+ cs.hasExtTimestamp = (ts >= 0xFFFFFF);
+ cs.timestamp = cs.hasExtTimestamp ? readUInt32() : ts;
+ cs.timestampDelta = 0;
+ cs.payload = new byte[cs.messageLength];
+ cs.bytesRead = 0;
+ }
+ case 1 -> {
+ // New message — inherits stream ID, delta timestamp
+ long delta = readUInt24();
+ cs.messageLength = (int) readUInt24();
+ cs.messageType = readByte();
+ cs.hasExtTimestamp = (delta >= 0xFFFFFF);
+ cs.timestampDelta = cs.hasExtTimestamp ? readUInt32() : delta;
+ cs.timestamp += cs.timestampDelta;
+ cs.payload = new byte[cs.messageLength];
+ cs.bytesRead = 0;
+ }
+ case 2 -> {
+ // Delta timestamp only — inherits type and length
+ long delta = readUInt24();
+ cs.hasExtTimestamp = (delta >= 0xFFFFFF);
+ cs.timestampDelta = cs.hasExtTimestamp ? readUInt32() : delta;
+ cs.timestamp += cs.timestampDelta;
+ if (prevDone) {
+ cs.payload = new byte[cs.messageLength];
+ cs.bytesRead = 0;
+ }
+ }
+ case 3 -> {
+ // Spec: re-read 4-byte ext timestamp if the last fmt 0/1/2 had one
+ if (cs.hasExtTimestamp) readUInt32();
+ if (prevDone) {
+ cs.timestamp += cs.timestampDelta;
+ cs.payload = new byte[cs.messageLength];
+ cs.bytesRead = 0;
+ }
+ }
+ }
+
+ if (cs.payload == null) cs.payload = new byte[cs.messageLength]; // safety
+
+ // ── Payload bytes for this chunk ───────────────────────────────
+ int remaining = cs.messageLength - cs.bytesRead;
+ int toRead = Math.min(readChunkSize, remaining);
+ in.readFully(cs.payload, cs.bytesRead, toRead);
+ cs.bytesRead += toRead;
+ bytesReceived += toRead;
+ maybeAck();
+
+ if (cs.bytesRead >= cs.messageLength) {
+ byte[] data = cs.payload.clone();
+ cs.payload = null; // ready for next message on this chunk stream
+ cs.bytesRead = 0;
+ return new RtmpMessage(cs.messageType, cs.messageStreamId, cs.timestamp, data);
+ }
+ // Message spans more chunks — keep looping
+ }
+ }
+
+ // ── Server → Client: protocol control ─────────────────────────────────
+
+ private void sendWindowAckSize(int size) throws IOException {
+ sendMessage(CS_PROTOCOL, MSG_WINDOW_ACK_SIZE, 0, 0, encodeUInt32(size));
+ }
+
+ private void sendSetPeerBandwidth(int bandwidth) throws IOException {
+ byte[] b = Arrays.copyOf(encodeUInt32(bandwidth), 5);
+ b[4] = 0x02; // limit type: dynamic
+ sendMessage(CS_PROTOCOL, MSG_SET_PEER_BANDWIDTH, 0, 0, b);
+ }
+
+ private void sendSetChunkSize(int size) throws IOException {
+ sendMessage(CS_PROTOCOL, MSG_SET_CHUNK_SIZE, 0, 0, encodeUInt32(size));
+ }
+
+ private void sendStreamBegin(int streamId) throws IOException {
+ byte[] payload = new byte[6];
+ payload[0] = 0x00; payload[1] = 0x00; // event type: StreamBegin
+ payload[2] = (byte)(streamId >> 24); payload[3] = (byte)(streamId >> 16);
+ payload[4] = (byte)(streamId >> 8); payload[5] = (byte) streamId;
+ sendMessage(CS_PROTOCOL, MSG_USER_CONTROL, 0, 0, payload);
+ }
+
+ private void sendAck(long seq) throws IOException {
+ sendMessage(CS_PROTOCOL, MSG_ACK, 0, 0, encodeUInt32((int) seq));
+ }
+
+ private void sendPingResponse(long token) throws IOException {
+ byte[] payload = new byte[6];
+ payload[0] = 0x00; payload[1] = 0x07; // event type: PingResponse
+ payload[2] = (byte)(token >> 24); payload[3] = (byte)(token >> 16);
+ payload[4] = (byte)(token >> 8); payload[5] = (byte) token;
+ sendMessage(CS_PROTOCOL, MSG_USER_CONTROL, 0, 0, payload);
+ }
+
+ // ── Server → Client: AMF0 command responses ────────────────────────────
+
+ private void sendConnectResult(double txId) throws IOException {
+ ByteArrayOutputStream buf = new ByteArrayOutputStream(256);
+ DataOutputStream d = new DataOutputStream(buf);
+
+ writeAmf0Str(d, "_result");
+ writeAmf0Num(d, txId);
+
+ writeAmf0ObjStart(d);
+ writeAmf0Field(d, "fmsVer", "FMS/3,0,1,123");
+ writeAmf0Field(d, "capabilities", 31.0);
+ writeAmf0Field(d, "mode", 1.0);
+ writeAmf0ObjEnd(d);
+
+ writeAmf0ObjStart(d);
+ writeAmf0Field(d, "level", "status");
+ writeAmf0Field(d, "code", "NetConnection.Connect.Success");
+ writeAmf0Field(d, "description", "Connection succeeded.");
+ writeAmf0Field(d, "objectEncoding", 0.0);
+ writeAmf0ObjEnd(d);
+
+ sendMessage(CS_COMMAND, MSG_COMMAND_AMF0, 0, 0, buf.toByteArray());
+
+ // onBWDone — expected by OBS and other publishers after _result
+ buf.reset();
+ writeAmf0Str(d, "onBWDone");
+ writeAmf0Num(d, 0);
+ writeAmf0Null(d);
+ sendMessage(CS_COMMAND, MSG_COMMAND_AMF0, 0, 0, buf.toByteArray());
+ }
+
+ private void sendFCPublishResult(double txId, String streamKey) throws IOException {
+ ByteArrayOutputStream buf = new ByteArrayOutputStream();
+ DataOutputStream d = new DataOutputStream(buf);
+
+ writeAmf0Str(d, "onFCPublish");
+ writeAmf0Num(d, txId);
+ writeAmf0Null(d);
+ writeAmf0ObjStart(d);
+ writeAmf0Field(d, "code", "NetStream.Publish.Start");
+ writeAmf0Field(d, "description", streamKey + " is now published.");
+ writeAmf0ObjEnd(d);
+
+ sendMessage(CS_COMMAND, MSG_COMMAND_AMF0, 0, 0, buf.toByteArray());
+ }
+
+ private void sendCreateStreamResult(double txId, int streamId) throws IOException {
+ ByteArrayOutputStream buf = new ByteArrayOutputStream();
+ DataOutputStream d = new DataOutputStream(buf);
+
+ writeAmf0Str(d, "_result");
+ writeAmf0Num(d, txId);
+ writeAmf0Null(d);
+ writeAmf0Num(d, streamId);
+
+ sendMessage(CS_COMMAND, MSG_COMMAND_AMF0, 0, 0, buf.toByteArray());
+ }
+
+ private void sendPublishStart(int streamId, String streamKey) throws IOException {
+ sendStreamBegin(streamId);
+
+ ByteArrayOutputStream buf = new ByteArrayOutputStream();
+ DataOutputStream d = new DataOutputStream(buf);
+
+ writeAmf0Str(d, "onStatus");
+ writeAmf0Num(d, 0);
+ writeAmf0Null(d);
+ writeAmf0ObjStart(d);
+ writeAmf0Field(d, "level", "status");
+ writeAmf0Field(d, "code", "NetStream.Publish.Start");
+ writeAmf0Field(d, "description", streamKey + " is now published.");
+ writeAmf0Field(d, "details", streamKey);
+ writeAmf0ObjEnd(d);
+
+ sendMessage(CS_STREAM, MSG_COMMAND_AMF0, streamId, 0, buf.toByteArray());
+ }
+
+ // ── Low-level RTMP message write ───────────────────────────────────────
+
+ /**
+ * Serialises one RTMP message. Uses fmt=0 for the first chunk (full
+ * header) and fmt=3 (no header) for any continuation chunks.
+ * Assumes csId < 64 (1-byte basic header) for all server-sent chunk streams.
+ */
+ private void sendMessage(int csId, int msgType, int msgStreamId,
+ long timestamp, byte[] payload) throws IOException {
+ boolean extTs = (timestamp >= 0xFFFFFF);
+
+ // Basic header (fmt=00 | csId)
+ out.writeByte(csId & 0x3F);
+
+ // 11-byte message header
+ writeUInt24(out, extTs ? 0xFFFFFF : (int) timestamp);
+ writeUInt24(out, payload.length);
+ out.writeByte(msgType);
+ // Stream ID is little-endian in the RTMP spec
+ out.writeByte( msgStreamId & 0xFF);
+ out.writeByte((msgStreamId >> 8) & 0xFF);
+ out.writeByte((msgStreamId >> 16) & 0xFF);
+ out.writeByte((msgStreamId >> 24) & 0xFF);
+
+ if (extTs) out.writeInt((int) timestamp);
+
+ // Payload — split into SERVER_CHUNK_SIZE-byte chunks
+ int offset = 0;
+ while (offset < payload.length) {
+ if (offset > 0) out.writeByte(0xC0 | (csId & 0x3F)); // fmt=11 continuation
+ int n = Math.min(SERVER_CHUNK_SIZE, payload.length - offset);
+ out.write(payload, offset, n);
+ offset += n;
+ }
+ out.flush();
+ }
+
+ // ── User control ───────────────────────────────────────────────────────
+
+ private void handleUserControl(byte[] data) throws IOException {
+ if (data.length < 2) return;
+ int eventType = ((data[0] & 0xFF) << 8) | (data[1] & 0xFF);
+ if (eventType == EVENT_PING_REQUEST && data.length >= 6) {
+ long token = ((long)(data[2] & 0xFF) << 24)
+ | ((long)(data[3] & 0xFF) << 16)
+ | ((long)(data[4] & 0xFF) << 8)
+ | (long)(data[5] & 0xFF);
+ sendPingResponse(token);
+ }
+ }
+
+ // ── Acknowledgement ────────────────────────────────────────────────────
+
+ private void maybeAck() throws IOException {
+ if (windowAckSize > 0 && (bytesReceived - bytesAcked) >= windowAckSize) {
+ bytesAcked = bytesReceived;
+ sendAck(bytesReceived);
+ }
+ }
+
+ // ── AMF0 read ──────────────────────────────────────────────────────────
+
+ private Object readAmf0Value(DataInputStream src) throws IOException {
+ int type = src.readUnsignedByte();
+ return switch (type) {
+ case 0 -> src.readDouble();
+ case 1 -> src.readUnsignedByte() != 0;
+ case 2 -> readAmf0Utf8(src);
+ case 3 -> readAmf0Object(src);
+ case 5, 6 -> null; // Null, Undefined
+ case 8 -> readAmf0EcmaArray(src);
+ case 9 -> null; // ObjectEnd (context-terminator)
+ case 10 -> readAmf0StrictArray(src);
+ case 11 -> { src.skipBytes(10); yield null; } // Date — skip
+ case 12 -> readAmf0LongString(src);
+ default -> throw new IOException("Unknown AMF0 type: 0x" + Integer.toHexString(type));
+ };
+ }
+
+ private String readAmf0Utf8(DataInputStream src) throws IOException {
+ byte[] b = new byte[src.readUnsignedShort()];
+ src.readFully(b);
+ return new String(b, StandardCharsets.UTF_8);
+ }
+
+ private String readAmf0LongString(DataInputStream src) throws IOException {
+ byte[] b = new byte[src.readInt()];
+ src.readFully(b);
+ return new String(b, StandardCharsets.UTF_8);
+ }
+
+ private Map readAmf0Object(DataInputStream src) throws IOException {
+ Map map = new LinkedHashMap<>();
+ while (true) {
+ int keyLen = src.readUnsignedShort();
+ if (keyLen == 0) { src.readUnsignedByte(); break; } // consume 0x09 end marker
+ byte[] kb = new byte[keyLen];
+ src.readFully(kb);
+ map.put(new String(kb, StandardCharsets.UTF_8), readAmf0Value(src));
+ }
+ return map;
+ }
+
+ private Map readAmf0EcmaArray(DataInputStream src) throws IOException {
+ src.readInt(); // array count — informational only
+ return readAmf0Object(src); // same layout as Object after the count
+ }
+
+ private List