Multiplexes client connections onto a single TCP connection to the Oppo Blu-ray Player, broadcasting unsolicited updates to all clients.
Requires Rust.
cargo build --release
The binary will be at target/release/oppo-multiplexer.
oppo-multiplexer <listen_port> <backend_host:backend_port> <timeout_seconds> [max_consecutive_timeouts] [--protocol udp20x|magnetar]
listen_port- port to accept incoming client connections onbackend_host:backend_port- address of the Oppo playertimeout_seconds- how long to wait for a response from the player before giving upmax_consecutive_timeouts- optional; reconnect backend only after this many consecutive timed-out requests (default:3, must be in the range1-100)--protocol- optional; the player's control protocol (default:udp20x). One instance serves one player, so this is fixed per instance:-
udp20x- Oppo UDP-203/205 IP protocol:#CODE\rcommands,\r-terminated@…responses, and@U??unsolicited updates broadcast to all clients. -
magnetar- Magnetar network protocol:#CODE\r\ncommands. The player answers each with a bareackcarrying no state, so the proxy acks each client immediately without waiting for it. It still multiplexes because Magnetar, like the Oppo players, accepts only one control connection.On each backend connect, the proxy also sends
#APP\r\n(undocumented handshake), which switches the player into pushing unsolicited<message>...</message>XML blocks with playback/volume state on that connection. These are broadcast to all clients, same as Oppo's@U??updates.Liveness note: because ordinary Magnetar commands get no meaningful response, there is nothing to time out on, so the
max_consecutive_timeoutsreconnect does not apply to them. A backend that dies cleanly (FIN/RST) is detected and reconnected, but a silently black-holed player cannot be detected at the application layer from command traffic alone - commands are ack'd even if the player never received them. This is inherent to a fire-and-forget command protocol; the metadata push, once active, does at least reveal real player state.
-
Example:
oppo-multiplexer 23 192.168.1.50:23 10 5Magnetar (listens on port 8102):
oppo-multiplexer 8102 192.168.1.50:8102 10 --protocol magnetarA prebuilt multi-arch image (linux/amd64, linux/arm64) is published to Docker Hub as
henrikwidlund/oppo-multiplexer
on each v*.*.* tag. It is a static scratch image that runs as a non-root user.
Ports below 1024: the container runs as a non-root user (UID
65532), which cannot bind privileged ports inside the container. Have the app listen on a high port (e.g.1024) and use Docker's port mapping to expose it on a low host port if needed - e.g.-p 23:1024withcommandlistening on1024. Thelisten_portargument is the container port, and the left side of-p host:containeris what clients connect to.
Arguments are passed the same way as the binary:
docker run --rm -p 23:1024 henrikwidlund/oppo-multiplexer \
1024 192.168.1.50:23 10 5Run detached:
docker run -d --name oppo -p 23:1024 --restart unless-stopped \
henrikwidlund/oppo-multiplexer 1024 192.168.1.50:23 10 5Or with Docker Compose:
services:
oppo-multiplexer:
image: henrikwidlund/oppo-multiplexer
ports: ["23:1024"]
command: ["1024", "192.168.1.50:23", "10", "5"]
restart: unless-stopped
environment:
RUST_LOG: infoLogs go to stderr (journald is not available in the container).
docker build -t oppo-multiplexer:local .
docker run --rm -p 23:1024 oppo-multiplexer:local 1024 192.168.1.50:23 10 5Download install_linux.sh and follow the instructions. If you want to run on ports below 1024,
you will need to run as root.
You can use the CAP_NET_BIND_SERVICE capability to avoid root on ports below 1024,
but the script does not take this into consideration.
Log level is controlled via the RUST_LOG environment variable. Defaults to info. On Linux, logs are sent to journald.
RUST_LOG=debug oppo-multiplexer 23 192.168.1.50:23 10