From b9e4824b25f831a613c79b4e198659e52f395c6d Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Wed, 1 Oct 2025 00:05:25 -0700 Subject: [PATCH 01/11] got it working --- src/framegrab/cli/balena_rtsp_tunnel.py | 22 +++++++++ src/framegrab/cli/balena_rtsp_tunnel.sh | 60 +++++++++++++++++++++++++ src/framegrab/cli/main.py | 3 +- 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 src/framegrab/cli/balena_rtsp_tunnel.py create mode 100755 src/framegrab/cli/balena_rtsp_tunnel.sh diff --git a/src/framegrab/cli/balena_rtsp_tunnel.py b/src/framegrab/cli/balena_rtsp_tunnel.py new file mode 100644 index 0000000..811d68a --- /dev/null +++ b/src/framegrab/cli/balena_rtsp_tunnel.py @@ -0,0 +1,22 @@ +import subprocess +from pathlib import Path + +import click + + +@click.command("balena-rtsp-tunnel") +@click.argument("device_id") +@click.argument("rtsp_ip") +@click.argument("pem_file", required=False) +def balena_rtsp_tunnel(device_id: str, rtsp_ip: str, pem_file: str = None): + """Tunnel RTSP stream from Balena device via SSH tunneling. + + DEVICE_ID: Balena device ID + RTSP_IP: IP address of RTSP camera (e.g., 192.168.2.219) + PEM_FILE: Optional path to PEM file for SSH authentication + """ + script_path = Path(__file__).parent / "balena_rtsp_tunnel.sh" + cmd = [str(script_path), device_id, rtsp_ip] + if pem_file: + cmd.append(pem_file) + subprocess.run(cmd) diff --git a/src/framegrab/cli/balena_rtsp_tunnel.sh b/src/framegrab/cli/balena_rtsp_tunnel.sh new file mode 100755 index 0000000..59eebb0 --- /dev/null +++ b/src/framegrab/cli/balena_rtsp_tunnel.sh @@ -0,0 +1,60 @@ +#!/bin/bash + +# Simple RTSP tunnel script for Balena devices + +# Check arguments +if [ $# -lt 2 ]; then + echo "Usage: $0 [pem-file]" + echo "Example: $0 abc123def456 192.168.2.219 ~/.ssh/id_ed25519_balena" + exit 1 +fi + +DEVICE_ID="$1" +RTSP_IP="$2" +PEM_FILE="${3:-}" + +# Check balena CLI +if ! command -v balena &>/dev/null; then + echo "Error: Balena CLI not found" + exit 1 +fi + +# Check login and get username +WHOAMI_OUTPUT=$(balena whoami 2>&1) +if [ $? -ne 0 ]; then + echo "$WHOAMI_OUTPUT" + exit 1 +fi + +USERNAME=$(echo "$WHOAMI_OUTPUT" | grep "USERNAME:" | awk '{print $2}') + +# Cleanup function +cleanup() { + echo "Shutting down..." + pkill -f "balena device tunnel.*$DEVICE_ID" 2>/dev/null || true + pkill -f "ssh.*$USERNAME@localhost.*4321" 2>/dev/null || true + exit 0 +} +trap cleanup SIGINT SIGTERM EXIT + +# Start tunnels +echo "Starting tunnel to $DEVICE_ID..." +balena device tunnel "$DEVICE_ID" -p 22222:4321 & + +sleep 3 + +# Build SSH command +SSH_CMD="ssh -N -L 8554:$RTSP_IP:554 $USERNAME@localhost -p 4321 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" +if [ -n "$PEM_FILE" ]; then + SSH_CMD="$SSH_CMD -i $PEM_FILE" +fi + +$SSH_CMD & + +echo "RTSP stream available" +echo "Use your camera's credentials and stream path, e.g.:" +echo " rtsp://admin:123456@localhost:8554/stream0" +echo "Press Ctrl+C to stop" + +# Wait +wait diff --git a/src/framegrab/cli/main.py b/src/framegrab/cli/main.py index 5cac31f..1b2556c 100755 --- a/src/framegrab/cli/main.py +++ b/src/framegrab/cli/main.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 import click -from . import autodiscover, preview +from . import autodiscover, balena_rtsp_tunnel, preview @click.group() @@ -11,6 +11,7 @@ def climain(): climain.add_command(autodiscover.autodiscover) +climain.add_command(balena_rtsp_tunnel.balena_rtsp_tunnel) climain.add_command(preview.preview) if __name__ == "__main__": From bfe0f396a71f0f1ac9b380bda3f0070bc2cdb09d Mon Sep 17 00:00:00 2001 From: Auto-format Bot Date: Wed, 1 Oct 2025 07:06:21 +0000 Subject: [PATCH 02/11] Automatically reformatting code with black and isort --- src/framegrab/cli/balena_rtsp_tunnel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/framegrab/cli/balena_rtsp_tunnel.py b/src/framegrab/cli/balena_rtsp_tunnel.py index 811d68a..1846c1b 100644 --- a/src/framegrab/cli/balena_rtsp_tunnel.py +++ b/src/framegrab/cli/balena_rtsp_tunnel.py @@ -10,7 +10,7 @@ @click.argument("pem_file", required=False) def balena_rtsp_tunnel(device_id: str, rtsp_ip: str, pem_file: str = None): """Tunnel RTSP stream from Balena device via SSH tunneling. - + DEVICE_ID: Balena device ID RTSP_IP: IP address of RTSP camera (e.g., 192.168.2.219) PEM_FILE: Optional path to PEM file for SSH authentication From 03ec9f611200aab6ea90f593813f7c23b8700fc3 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Wed, 1 Oct 2025 00:14:01 -0700 Subject: [PATCH 03/11] making the local port configurable --- src/framegrab/cli/balena_rtsp_tunnel.py | 7 +++---- src/framegrab/cli/balena_rtsp_tunnel.sh | 7 ++++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/framegrab/cli/balena_rtsp_tunnel.py b/src/framegrab/cli/balena_rtsp_tunnel.py index 811d68a..1496aa2 100644 --- a/src/framegrab/cli/balena_rtsp_tunnel.py +++ b/src/framegrab/cli/balena_rtsp_tunnel.py @@ -8,7 +8,8 @@ @click.argument("device_id") @click.argument("rtsp_ip") @click.argument("pem_file", required=False) -def balena_rtsp_tunnel(device_id: str, rtsp_ip: str, pem_file: str = None): +@click.option("-l", "--local-port", default=8554, help="Local port for RTSP stream") +def balena_rtsp_tunnel(device_id: str, rtsp_ip: str, pem_file: str = None, local_port: int = 8554): """Tunnel RTSP stream from Balena device via SSH tunneling. DEVICE_ID: Balena device ID @@ -16,7 +17,5 @@ def balena_rtsp_tunnel(device_id: str, rtsp_ip: str, pem_file: str = None): PEM_FILE: Optional path to PEM file for SSH authentication """ script_path = Path(__file__).parent / "balena_rtsp_tunnel.sh" - cmd = [str(script_path), device_id, rtsp_ip] - if pem_file: - cmd.append(pem_file) + cmd = [str(script_path), device_id, rtsp_ip, pem_file or "", str(local_port)] subprocess.run(cmd) diff --git a/src/framegrab/cli/balena_rtsp_tunnel.sh b/src/framegrab/cli/balena_rtsp_tunnel.sh index 59eebb0..8a18a47 100755 --- a/src/framegrab/cli/balena_rtsp_tunnel.sh +++ b/src/framegrab/cli/balena_rtsp_tunnel.sh @@ -11,7 +11,8 @@ fi DEVICE_ID="$1" RTSP_IP="$2" -PEM_FILE="${3:-}" +PEM_FILE="$3" +LOCAL_PORT="${4:-8554}" # Check balena CLI if ! command -v balena &>/dev/null; then @@ -44,7 +45,7 @@ balena device tunnel "$DEVICE_ID" -p 22222:4321 & sleep 3 # Build SSH command -SSH_CMD="ssh -N -L 8554:$RTSP_IP:554 $USERNAME@localhost -p 4321 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" +SSH_CMD="ssh -N -L $LOCAL_PORT:$RTSP_IP:554 $USERNAME@localhost -p 4321 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" if [ -n "$PEM_FILE" ]; then SSH_CMD="$SSH_CMD -i $PEM_FILE" fi @@ -53,7 +54,7 @@ $SSH_CMD & echo "RTSP stream available" echo "Use your camera's credentials and stream path, e.g.:" -echo " rtsp://admin:123456@localhost:8554/stream0" +echo " rtsp://admin:123456@localhost:$LOCAL_PORT/stream0" echo "Press Ctrl+C to stop" # Wait From a8bd51f03ddcf113afa0eaec0f1d5b457780ffb7 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Wed, 1 Oct 2025 10:25:10 -0700 Subject: [PATCH 04/11] making some refinements --- src/framegrab/cli/balena_rtsp_tunnel.py | 11 ++++++----- src/framegrab/cli/balena_rtsp_tunnel.sh | 9 +++++---- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/framegrab/cli/balena_rtsp_tunnel.py b/src/framegrab/cli/balena_rtsp_tunnel.py index a9e14d7..9da364f 100644 --- a/src/framegrab/cli/balena_rtsp_tunnel.py +++ b/src/framegrab/cli/balena_rtsp_tunnel.py @@ -3,19 +3,20 @@ import click - @click.command("balena-rtsp-tunnel") @click.argument("device_id") @click.argument("rtsp_ip") @click.argument("pem_file", required=False) @click.option("-l", "--local-port", default=8554, help="Local port for RTSP stream") -def balena_rtsp_tunnel(device_id: str, rtsp_ip: str, pem_file: str = None, local_port: int = 8554): - """Tunnel RTSP stream from Balena device via SSH tunneling. +@click.option("-r", "--remote-port", default=554, help="Remote RTSP port") +def balena_rtsp_tunnel(device_id: str, rtsp_ip: str, pem_file: str = None, local_port: int = 8554, remote_port: int = 554): + """Tunnel an RTSP stream from a Balena device via SSH tunneling + \b DEVICE_ID: Balena device ID - RTSP_IP: IP address of RTSP camera (e.g., 192.168.2.219) + RTSP_IP: IP address of RTSP camera (e.g. 192.168.2.219) PEM_FILE: Optional path to PEM file for SSH authentication """ script_path = Path(__file__).parent / "balena_rtsp_tunnel.sh" - cmd = [str(script_path), device_id, rtsp_ip, pem_file or "", str(local_port)] + cmd = [str(script_path), device_id, rtsp_ip, pem_file or "", str(local_port), str(remote_port)] subprocess.run(cmd) diff --git a/src/framegrab/cli/balena_rtsp_tunnel.sh b/src/framegrab/cli/balena_rtsp_tunnel.sh index 8a18a47..5aad7f9 100755 --- a/src/framegrab/cli/balena_rtsp_tunnel.sh +++ b/src/framegrab/cli/balena_rtsp_tunnel.sh @@ -13,6 +13,7 @@ DEVICE_ID="$1" RTSP_IP="$2" PEM_FILE="$3" LOCAL_PORT="${4:-8554}" +REMOTE_PORT="${5:-554}" # Check balena CLI if ! command -v balena &>/dev/null; then @@ -45,16 +46,16 @@ balena device tunnel "$DEVICE_ID" -p 22222:4321 & sleep 3 # Build SSH command -SSH_CMD="ssh -N -L $LOCAL_PORT:$RTSP_IP:554 $USERNAME@localhost -p 4321 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" +SSH_CMD="ssh -N -L $LOCAL_PORT:$RTSP_IP:$REMOTE_PORT $USERNAME@localhost -p 4321 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" if [ -n "$PEM_FILE" ]; then SSH_CMD="$SSH_CMD -i $PEM_FILE" fi $SSH_CMD & -echo "RTSP stream available" -echo "Use your camera's credentials and stream path, e.g.:" -echo " rtsp://admin:123456@localhost:$LOCAL_PORT/stream0" +echo "Tunnel established successfully from $RTSP_IP:$REMOTE_PORT!" +echo "RTSP should now be available at localhost:$LOCAL_PORT" +echo "Example: rtsp://username:password@localhost:$LOCAL_PORT/your-stream-path-here" echo "Press Ctrl+C to stop" # Wait From 3111558f44442e9d703597543d59cffebb57f455 Mon Sep 17 00:00:00 2001 From: Auto-format Bot Date: Wed, 1 Oct 2025 17:25:36 +0000 Subject: [PATCH 05/11] Automatically reformatting code with black and isort --- src/framegrab/cli/balena_rtsp_tunnel.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/framegrab/cli/balena_rtsp_tunnel.py b/src/framegrab/cli/balena_rtsp_tunnel.py index 9da364f..ee59867 100644 --- a/src/framegrab/cli/balena_rtsp_tunnel.py +++ b/src/framegrab/cli/balena_rtsp_tunnel.py @@ -3,13 +3,16 @@ import click + @click.command("balena-rtsp-tunnel") @click.argument("device_id") @click.argument("rtsp_ip") @click.argument("pem_file", required=False) @click.option("-l", "--local-port", default=8554, help="Local port for RTSP stream") @click.option("-r", "--remote-port", default=554, help="Remote RTSP port") -def balena_rtsp_tunnel(device_id: str, rtsp_ip: str, pem_file: str = None, local_port: int = 8554, remote_port: int = 554): +def balena_rtsp_tunnel( + device_id: str, rtsp_ip: str, pem_file: str = None, local_port: int = 8554, remote_port: int = 554 +): """Tunnel an RTSP stream from a Balena device via SSH tunneling \b From ca97293d43deae6212b67d1beec7ff02d17dda40 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Wed, 1 Oct 2025 10:30:55 -0700 Subject: [PATCH 06/11] adjusting some comments --- src/framegrab/cli/balena_rtsp_tunnel.py | 2 +- src/framegrab/cli/balena_rtsp_tunnel.sh | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/framegrab/cli/balena_rtsp_tunnel.py b/src/framegrab/cli/balena_rtsp_tunnel.py index 9da364f..4685258 100644 --- a/src/framegrab/cli/balena_rtsp_tunnel.py +++ b/src/framegrab/cli/balena_rtsp_tunnel.py @@ -17,6 +17,6 @@ def balena_rtsp_tunnel(device_id: str, rtsp_ip: str, pem_file: str = None, local RTSP_IP: IP address of RTSP camera (e.g. 192.168.2.219) PEM_FILE: Optional path to PEM file for SSH authentication """ - script_path = Path(__file__).parent / "balena_rtsp_tunnel.sh" + script_path = Path(__file__).parent / "balena_rtsp_tunnel.sh" # Poetry packages .sh files alongside .py files, so this works cmd = [str(script_path), device_id, rtsp_ip, pem_file or "", str(local_port), str(remote_port)] subprocess.run(cmd) diff --git a/src/framegrab/cli/balena_rtsp_tunnel.sh b/src/framegrab/cli/balena_rtsp_tunnel.sh index 5aad7f9..dfd23ac 100755 --- a/src/framegrab/cli/balena_rtsp_tunnel.sh +++ b/src/framegrab/cli/balena_rtsp_tunnel.sh @@ -1,7 +1,5 @@ #!/bin/bash -# Simple RTSP tunnel script for Balena devices - # Check arguments if [ $# -lt 2 ]; then echo "Usage: $0 [pem-file]" From a4bbfbb8719291570dcbf5605dacba037ed6e575 Mon Sep 17 00:00:00 2001 From: Auto-format Bot Date: Wed, 1 Oct 2025 17:31:29 +0000 Subject: [PATCH 07/11] Automatically reformatting code with black and isort --- src/framegrab/cli/balena_rtsp_tunnel.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/framegrab/cli/balena_rtsp_tunnel.py b/src/framegrab/cli/balena_rtsp_tunnel.py index e9944c2..f0ecbbb 100644 --- a/src/framegrab/cli/balena_rtsp_tunnel.py +++ b/src/framegrab/cli/balena_rtsp_tunnel.py @@ -20,6 +20,8 @@ def balena_rtsp_tunnel( RTSP_IP: IP address of RTSP camera (e.g. 192.168.2.219) PEM_FILE: Optional path to PEM file for SSH authentication """ - script_path = Path(__file__).parent / "balena_rtsp_tunnel.sh" # Poetry packages .sh files alongside .py files, so this works + script_path = ( + Path(__file__).parent / "balena_rtsp_tunnel.sh" + ) # Poetry packages .sh files alongside .py files, so this works cmd = [str(script_path), device_id, rtsp_ip, pem_file or "", str(local_port), str(remote_port)] subprocess.run(cmd) From 64d5c65559fbfc7b497a596a267918cea635da3a Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Wed, 1 Oct 2025 15:23:51 -0700 Subject: [PATCH 08/11] raising an exception for Windows users --- src/framegrab/cli/balena_rtsp_tunnel.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/framegrab/cli/balena_rtsp_tunnel.py b/src/framegrab/cli/balena_rtsp_tunnel.py index e9944c2..3a68381 100644 --- a/src/framegrab/cli/balena_rtsp_tunnel.py +++ b/src/framegrab/cli/balena_rtsp_tunnel.py @@ -1,10 +1,12 @@ import subprocess from pathlib import Path +import os import click +COMMAND_NAME = "balena-rtsp-tunnel" -@click.command("balena-rtsp-tunnel") +@click.command(COMMAND_NAME) @click.argument("device_id") @click.argument("rtsp_ip") @click.argument("pem_file", required=False) @@ -20,6 +22,9 @@ def balena_rtsp_tunnel( RTSP_IP: IP address of RTSP camera (e.g. 192.168.2.219) PEM_FILE: Optional path to PEM file for SSH authentication """ + if os.name == 'nt': + raise RuntimeError(f"{COMMAND_NAME} is not supported on Windows. Try running this command on a Unix-like machine.") + script_path = Path(__file__).parent / "balena_rtsp_tunnel.sh" # Poetry packages .sh files alongside .py files, so this works cmd = [str(script_path), device_id, rtsp_ip, pem_file or "", str(local_port), str(remote_port)] subprocess.run(cmd) From 505e864a361a616a7b65e490b89d846f861e1767 Mon Sep 17 00:00:00 2001 From: Auto-format Bot Date: Wed, 1 Oct 2025 22:27:16 +0000 Subject: [PATCH 09/11] Automatically reformatting code with black and isort --- src/framegrab/cli/balena_rtsp_tunnel.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/framegrab/cli/balena_rtsp_tunnel.py b/src/framegrab/cli/balena_rtsp_tunnel.py index e354b6e..df97264 100644 --- a/src/framegrab/cli/balena_rtsp_tunnel.py +++ b/src/framegrab/cli/balena_rtsp_tunnel.py @@ -1,11 +1,12 @@ +import os import subprocess from pathlib import Path -import os import click COMMAND_NAME = "balena-rtsp-tunnel" + @click.command(COMMAND_NAME) @click.argument("device_id") @click.argument("rtsp_ip") From f5517c0eeb2ed91e1ac870279f9d7599680e9dc2 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Wed, 1 Oct 2025 15:29:37 -0700 Subject: [PATCH 10/11] adding an exception for Windows users --- src/framegrab/cli/balena_rtsp_tunnel.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/framegrab/cli/balena_rtsp_tunnel.py b/src/framegrab/cli/balena_rtsp_tunnel.py index df97264..990c955 100644 --- a/src/framegrab/cli/balena_rtsp_tunnel.py +++ b/src/framegrab/cli/balena_rtsp_tunnel.py @@ -23,6 +23,11 @@ def balena_rtsp_tunnel( RTSP_IP: IP address of RTSP camera (e.g. 192.168.2.219) PEM_FILE: Optional path to PEM file for SSH authentication """ + if os.name == 'nt': + raise RuntimeError( + f'{COMMAND_NAME} is not supported on Windows. Try running this command on a Unix-like system.' + ) + script_path = ( Path(__file__).parent / "balena_rtsp_tunnel.sh" ) # Poetry packages .sh files alongside .py files, so this works From 68e2fb98058724b3552cc33e169dcc42d990fa62 Mon Sep 17 00:00:00 2001 From: Auto-format Bot Date: Tue, 7 Oct 2025 16:50:48 +0000 Subject: [PATCH 11/11] Automatically reformatting code with black and isort --- src/framegrab/cli/balena_rtsp_tunnel.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/framegrab/cli/balena_rtsp_tunnel.py b/src/framegrab/cli/balena_rtsp_tunnel.py index 990c955..ffc2fd3 100644 --- a/src/framegrab/cli/balena_rtsp_tunnel.py +++ b/src/framegrab/cli/balena_rtsp_tunnel.py @@ -23,11 +23,11 @@ def balena_rtsp_tunnel( RTSP_IP: IP address of RTSP camera (e.g. 192.168.2.219) PEM_FILE: Optional path to PEM file for SSH authentication """ - if os.name == 'nt': + if os.name == "nt": raise RuntimeError( - f'{COMMAND_NAME} is not supported on Windows. Try running this command on a Unix-like system.' + f"{COMMAND_NAME} is not supported on Windows. Try running this command on a Unix-like system." ) - + script_path = ( Path(__file__).parent / "balena_rtsp_tunnel.sh" ) # Poetry packages .sh files alongside .py files, so this works