From ccf88187b8ffb061f6e427bde323eb95e59fb453 Mon Sep 17 00:00:00 2001 From: julien Date: Sat, 4 Apr 2026 19:36:14 +0200 Subject: [PATCH] install go2rtc on bob --- installs_on_host/go2rtc/.gitignore | 24 + installs_on_host/go2rtc/LICENSE | 21 + installs_on_host/go2rtc/README.md | 538 +++++++ installs_on_host/go2rtc/docker/Dockerfile | 55 + installs_on_host/go2rtc/docker/README.md | 54 + .../go2rtc/docker/hardware.Dockerfile | 60 + .../go2rtc/docker/rockchip.Dockerfile | 51 + .../go2rtc/examples/go2rtc_hass/main.go | 20 + .../go2rtc/examples/go2rtc_mjpeg/main.go | 26 + .../go2rtc/examples/go2rtc_rtsp/main.go | 17 + .../go2rtc/examples/homekit_info/main.go | 123 ++ installs_on_host/go2rtc/examples/mdns/main.go | 39 + .../go2rtc/examples/mod_pinggy/go.mod | 9 + .../go2rtc/examples/mod_pinggy/go.sum | 39 + .../go2rtc/examples/mod_pinggy/main.go | 41 + .../go2rtc/examples/onvif_client/README.md | 5 + .../go2rtc/examples/onvif_client/main.go | 75 + .../go2rtc/examples/rtsp_client/main.go | 39 + .../go2rtc/examples/tutk_decoder/README.md | 5 + .../go2rtc/examples/tutk_decoder/main.go | 82 ++ installs_on_host/go2rtc/go.mod | 52 + installs_on_host/go2rtc/go.sum | 152 ++ installs_on_host/go2rtc/internal/README.md | 113 ++ .../go2rtc/internal/alsa/README.md | 12 + installs_on_host/go2rtc/internal/alsa/alsa.go | 7 + .../go2rtc/internal/alsa/alsa_linux.go | 83 ++ .../go2rtc/internal/api/README.md | 45 + installs_on_host/go2rtc/internal/api/api.go | 321 +++++ .../go2rtc/internal/api/config.go | 101 ++ .../go2rtc/internal/api/static.go | 27 + .../go2rtc/internal/api/ws/README.md | 69 + installs_on_host/go2rtc/internal/api/ws/ws.go | 227 +++ .../go2rtc/internal/app/README.md | 97 ++ installs_on_host/go2rtc/internal/app/app.go | 122 ++ .../go2rtc/internal/app/config.go | 117 ++ installs_on_host/go2rtc/internal/app/log.go | 191 +++ .../go2rtc/internal/app/storage.go | 56 + .../go2rtc/internal/bubble/README.md | 15 + .../go2rtc/internal/bubble/bubble.go | 13 + .../go2rtc/internal/debug/README.md | 3 + .../go2rtc/internal/debug/debug.go | 9 + .../go2rtc/internal/debug/stack.go | 60 + .../go2rtc/internal/doorbird/README.md | 21 + .../go2rtc/internal/doorbird/doorbird.go | 36 + .../go2rtc/internal/dvrip/README.md | 21 + .../go2rtc/internal/dvrip/dvrip.go | 161 +++ .../go2rtc/internal/echo/README.md | 48 + installs_on_host/go2rtc/internal/echo/echo.go | 46 + .../go2rtc/internal/eseecloud/README.md | 12 + .../go2rtc/internal/eseecloud/eseecloud.go | 10 + .../go2rtc/internal/exec/README.md | 48 + installs_on_host/go2rtc/internal/exec/exec.go | 279 ++++ .../go2rtc/internal/expr/README.md | 153 ++ installs_on_host/go2rtc/internal/expr/expr.go | 29 + .../go2rtc/internal/ffmpeg/README.md | 62 + .../go2rtc/internal/ffmpeg/api.go | 51 + .../go2rtc/internal/ffmpeg/device/README.md | 22 + .../internal/ffmpeg/device/device_bsd.go | 99 ++ .../internal/ffmpeg/device/device_darwin.go | 88 ++ .../internal/ffmpeg/device/device_unix.go | 101 ++ .../internal/ffmpeg/device/device_windows.go | 90 ++ .../go2rtc/internal/ffmpeg/device/devices.go | 46 + .../go2rtc/internal/ffmpeg/ffmpeg.go | 388 ++++++ .../go2rtc/internal/ffmpeg/ffmpeg_test.go | 396 ++++++ .../go2rtc/internal/ffmpeg/hardware/README.md | 106 ++ .../internal/ffmpeg/hardware/hardware.go | 210 +++ .../internal/ffmpeg/hardware/hardware_bsd.go | 62 + .../ffmpeg/hardware/hardware_darwin.go | 39 + .../internal/ffmpeg/hardware/hardware_unix.go | 124 ++ .../ffmpeg/hardware/hardware_windows.go | 63 + .../go2rtc/internal/ffmpeg/jpeg.go | 83 ++ .../go2rtc/internal/ffmpeg/jpeg_test.go | 23 + .../go2rtc/internal/ffmpeg/producer.go | 122 ++ .../go2rtc/internal/ffmpeg/version.go | 46 + .../go2rtc/internal/ffmpeg/virtual/virtual.go | 79 ++ .../internal/ffmpeg/virtual/virtual_test.go | 20 + .../go2rtc/internal/flussonic/README.md | 5 + .../go2rtc/internal/flussonic/flussonic.go | 10 + .../go2rtc/internal/gopro/README.md | 29 + .../go2rtc/internal/gopro/gopro.go | 28 + .../go2rtc/internal/hass/README.md | 41 + installs_on_host/go2rtc/internal/hass/api.go | 104 ++ installs_on_host/go2rtc/internal/hass/hass.go | 219 +++ .../go2rtc/internal/hls/README.md | 19 + installs_on_host/go2rtc/internal/hls/hls.go | 217 +++ .../go2rtc/internal/hls/session.go | 127 ++ installs_on_host/go2rtc/internal/hls/ws.go | 52 + .../go2rtc/internal/homekit/README.md | 97 ++ .../go2rtc/internal/homekit/api.go | 181 +++ .../go2rtc/internal/homekit/homekit.go | 211 +++ .../go2rtc/internal/homekit/server.go | 405 ++++++ .../go2rtc/internal/http/README.md | 47 + installs_on_host/go2rtc/internal/http/http.go | 134 ++ .../go2rtc/internal/isapi/README.md | 14 + .../go2rtc/internal/isapi/init.go | 13 + .../go2rtc/internal/ivideon/README.md | 10 + .../go2rtc/internal/ivideon/ivideon.go | 10 + .../go2rtc/internal/kasa/README.md | 15 + installs_on_host/go2rtc/internal/kasa/kasa.go | 13 + .../go2rtc/internal/mjpeg/README.md | 108 ++ .../go2rtc/internal/mjpeg/mjpeg.go | 237 ++++ .../go2rtc/internal/mp4/README.md | 66 + installs_on_host/go2rtc/internal/mp4/mp4.go | 146 ++ installs_on_host/go2rtc/internal/mp4/ws.go | 74 + .../go2rtc/internal/mpeg/README.md | 25 + installs_on_host/go2rtc/internal/mpeg/mpeg.go | 92 ++ .../go2rtc/internal/multitrans/README.md | 22 + .../go2rtc/internal/multitrans/multitrans.go | 10 + .../go2rtc/internal/nest/README.md | 11 + installs_on_host/go2rtc/internal/nest/init.go | 52 + .../go2rtc/internal/ngrok/README.md | 54 + .../go2rtc/internal/ngrok/ngrok.go | 84 ++ .../go2rtc/internal/onvif/README.md | 42 + .../go2rtc/internal/onvif/onvif.go | 238 ++++ .../go2rtc/internal/pinggy/README.md | 54 + .../go2rtc/internal/pinggy/pinggy.go | 60 + .../go2rtc/internal/ring/README.md | 17 + installs_on_host/go2rtc/internal/ring/ring.go | 106 ++ .../go2rtc/internal/roborock/README.md | 15 + .../go2rtc/internal/roborock/roborock.go | 92 ++ .../go2rtc/internal/rtmp/README.md | 118 ++ installs_on_host/go2rtc/internal/rtmp/rtmp.go | 199 +++ .../go2rtc/internal/rtsp/README.md | 93 ++ installs_on_host/go2rtc/internal/rtsp/rtsp.go | 312 +++++ .../go2rtc/internal/srtp/README.md | 13 + installs_on_host/go2rtc/internal/srtp/srtp.go | 29 + .../go2rtc/internal/streams/README.md | 141 ++ .../go2rtc/internal/streams/add_consumer.go | 166 +++ .../go2rtc/internal/streams/api.go | 181 +++ .../go2rtc/internal/streams/api_test.go | 66 + .../go2rtc/internal/streams/dot.go | 176 +++ .../go2rtc/internal/streams/handlers.go | 134 ++ .../go2rtc/internal/streams/helpers.go | 22 + .../go2rtc/internal/streams/play.go | 163 +++ .../go2rtc/internal/streams/preload.go | 69 + .../go2rtc/internal/streams/producer.go | 270 ++++ .../go2rtc/internal/streams/publish.go | 38 + .../go2rtc/internal/streams/stream.go | 130 ++ .../go2rtc/internal/streams/stream_test.go | 42 + .../go2rtc/internal/streams/streams.go | 176 +++ .../go2rtc/internal/tapo/README.md | 61 + installs_on_host/go2rtc/internal/tapo/tapo.go | 22 + .../go2rtc/internal/tuya/README.md | 39 + installs_on_host/go2rtc/internal/tuya/tuya.go | 248 ++++ .../go2rtc/internal/v4l2/README.md | 41 + installs_on_host/go2rtc/internal/v4l2/v4l2.go | 7 + .../go2rtc/internal/v4l2/v4l2_linux.go | 91 ++ .../go2rtc/internal/webrtc/README.md | 263 ++++ .../go2rtc/internal/webrtc/candidates.go | 149 ++ .../go2rtc/internal/webrtc/client.go | 257 ++++ .../go2rtc/internal/webrtc/client_creality.go | 152 ++ .../go2rtc/internal/webrtc/kinesis.go | 235 ++++ .../go2rtc/internal/webrtc/milestone.go | 220 +++ .../go2rtc/internal/webrtc/openipc.go | 170 +++ .../go2rtc/internal/webrtc/server.go | 235 ++++ .../go2rtc/internal/webrtc/switchbot.go | 44 + .../go2rtc/internal/webrtc/webrtc.go | 321 +++++ .../go2rtc/internal/webrtc/webrtc_test.go | 73 + .../go2rtc/internal/webtorrent/README.md | 45 + .../go2rtc/internal/webtorrent/init.go | 176 +++ .../go2rtc/internal/webtorrent/tracker.go | 108 ++ .../go2rtc/internal/wyoming/README.md | 284 ++++ .../go2rtc/internal/wyoming/wyoming.go | 106 ++ .../go2rtc/internal/wyze/README.md | 108 ++ installs_on_host/go2rtc/internal/wyze/wyze.go | 202 +++ .../go2rtc/internal/xiaomi/README.md | 64 + .../go2rtc/internal/xiaomi/xiaomi.go | 362 +++++ .../go2rtc/internal/yandex/README.md | 22 + .../go2rtc/internal/yandex/goloom.go | 152 ++ .../go2rtc/internal/yandex/yandex.go | 44 + installs_on_host/go2rtc/main.go | 125 ++ installs_on_host/go2rtc/package.json | 47 + installs_on_host/go2rtc/pkg/README.md | 114 ++ installs_on_host/go2rtc/pkg/aac/README.md | 20 + installs_on_host/go2rtc/pkg/aac/aac.go | 126 ++ installs_on_host/go2rtc/pkg/aac/aac_test.go | 52 + installs_on_host/go2rtc/pkg/aac/adts.go | 148 ++ installs_on_host/go2rtc/pkg/aac/consumer.go | 59 + installs_on_host/go2rtc/pkg/aac/producer.go | 85 ++ installs_on_host/go2rtc/pkg/aac/rtp.go | 154 +++ installs_on_host/go2rtc/pkg/aac/rtp_test.go | 33 + installs_on_host/go2rtc/pkg/alsa/README.md | 23 + .../go2rtc/pkg/alsa/capture_linux.go | 90 ++ .../go2rtc/pkg/alsa/device/asound_32bit.go | 148 ++ .../go2rtc/pkg/alsa/device/asound_64bit.go | 148 ++ .../go2rtc/pkg/alsa/device/asound_arch.c | 164 +++ .../go2rtc/pkg/alsa/device/asound_mipsle.go | 146 ++ .../go2rtc/pkg/alsa/device/device_linux.go | 231 ++++ .../go2rtc/pkg/alsa/device/ioctl_linux.go | 26 + .../go2rtc/pkg/alsa/open_linux.go | 44 + .../go2rtc/pkg/alsa/playback_linux.go | 84 ++ installs_on_host/go2rtc/pkg/ascii/README.md | 6 + installs_on_host/go2rtc/pkg/ascii/ascii.go | 173 +++ installs_on_host/go2rtc/pkg/bits/reader.go | 143 ++ installs_on_host/go2rtc/pkg/bits/writer.go | 95 ++ installs_on_host/go2rtc/pkg/bubble/client.go | 266 ++++ .../go2rtc/pkg/bubble/producer.go | 80 ++ installs_on_host/go2rtc/pkg/core/README.md | 40 + installs_on_host/go2rtc/pkg/core/codec.go | 284 ++++ .../go2rtc/pkg/core/connection.go | 144 ++ installs_on_host/go2rtc/pkg/core/core.go | 97 ++ installs_on_host/go2rtc/pkg/core/core_test.go | 134 ++ installs_on_host/go2rtc/pkg/core/helpers.go | 94 ++ installs_on_host/go2rtc/pkg/core/listener.go | 18 + installs_on_host/go2rtc/pkg/core/media.go | 211 +++ .../go2rtc/pkg/core/media_test.go | 64 + installs_on_host/go2rtc/pkg/core/node.go | 88 ++ .../go2rtc/pkg/core/readbuffer.go | 114 ++ .../go2rtc/pkg/core/readbuffer_test.go | 64 + installs_on_host/go2rtc/pkg/core/slices.go | 43 + installs_on_host/go2rtc/pkg/core/track.go | 217 +++ .../go2rtc/pkg/core/track_test.go | 53 + installs_on_host/go2rtc/pkg/core/waiter.go | 74 + installs_on_host/go2rtc/pkg/core/worker.go | 52 + .../go2rtc/pkg/core/writebuffer.go | 114 ++ installs_on_host/go2rtc/pkg/creds/README.md | 7 + installs_on_host/go2rtc/pkg/creds/creds.go | 79 ++ installs_on_host/go2rtc/pkg/creds/secrets.go | 94 ++ .../go2rtc/pkg/creds/secrets_test.go | 15 + installs_on_host/go2rtc/pkg/debug/conn.go | 47 + installs_on_host/go2rtc/pkg/debug/debug.go | 58 + .../go2rtc/pkg/doorbird/backchannel.go | 95 ++ .../go2rtc/pkg/dvrip/backchannel.go | 79 ++ installs_on_host/go2rtc/pkg/dvrip/client.go | 247 ++++ installs_on_host/go2rtc/pkg/dvrip/dvrip.go | 39 + installs_on_host/go2rtc/pkg/dvrip/producer.go | 262 ++++ .../go2rtc/pkg/eseecloud/eseecloud.go | 180 +++ installs_on_host/go2rtc/pkg/expr/expr.go | 166 +++ installs_on_host/go2rtc/pkg/expr/expr_test.go | 17 + installs_on_host/go2rtc/pkg/ffmpeg/README.md | 68 + installs_on_host/go2rtc/pkg/ffmpeg/ffmpeg.go | 123 ++ .../go2rtc/pkg/flussonic/flussonic.go | 176 +++ installs_on_host/go2rtc/pkg/flv/amf/amf.go | 239 ++++ .../go2rtc/pkg/flv/amf/amf_test.go | 281 ++++ installs_on_host/go2rtc/pkg/flv/consumer.go | 94 ++ installs_on_host/go2rtc/pkg/flv/flv_test.go | 21 + installs_on_host/go2rtc/pkg/flv/muxer.go | 174 +++ installs_on_host/go2rtc/pkg/flv/producer.go | 312 +++++ .../go2rtc/pkg/gopro/discovery.go | 43 + installs_on_host/go2rtc/pkg/gopro/producer.go | 124 ++ installs_on_host/go2rtc/pkg/h264/README.md | 16 + .../go2rtc/pkg/h264/annexb/annexb.go | 156 +++ .../go2rtc/pkg/h264/annexb/annexb_test.go | 97 ++ installs_on_host/go2rtc/pkg/h264/avc.go | 122 ++ installs_on_host/go2rtc/pkg/h264/avcc.go | 120 ++ installs_on_host/go2rtc/pkg/h264/h264.go | 145 ++ installs_on_host/go2rtc/pkg/h264/h264_test.go | 110 ++ installs_on_host/go2rtc/pkg/h264/mpeg4.go | 101 ++ installs_on_host/go2rtc/pkg/h264/payloader.go | 195 +++ installs_on_host/go2rtc/pkg/h264/rtp.go | 137 ++ installs_on_host/go2rtc/pkg/h264/sps.go | 366 +++++ installs_on_host/go2rtc/pkg/h265/README.md | 8 + installs_on_host/go2rtc/pkg/h265/avc.go | 54 + installs_on_host/go2rtc/pkg/h265/avcc.go | 61 + installs_on_host/go2rtc/pkg/h265/h265_test.go | 30 + installs_on_host/go2rtc/pkg/h265/helper.go | 76 + installs_on_host/go2rtc/pkg/h265/mpeg4.go | 98 ++ installs_on_host/go2rtc/pkg/h265/payloader.go | 301 ++++ installs_on_host/go2rtc/pkg/h265/rtp.go | 221 +++ installs_on_host/go2rtc/pkg/h265/sps.go | 126 ++ installs_on_host/go2rtc/pkg/hap/README.md | 54 + installs_on_host/go2rtc/pkg/hap/accessory.go | 176 +++ .../go2rtc/pkg/hap/camera/README.md | 3 + .../go2rtc/pkg/hap/camera/accessory.go | 149 ++ .../go2rtc/pkg/hap/camera/accessory_test.go | 254 ++++ .../pkg/hap/camera/ch114_supported_video.go | 46 + .../pkg/hap/camera/ch115_supported_audio.go | 46 + .../pkg/hap/camera/ch116_supported_rtp.go | 14 + .../pkg/hap/camera/ch117_selected_stream.go | 32 + .../pkg/hap/camera/ch118_setup_endpoints.go | 33 + .../pkg/hap/camera/ch120_streaming_status.go | 14 + .../hap/camera/ch130_data_stream_transport.go | 11 + .../pkg/hap/camera/ch131_data_stream.go | 17 + .../go2rtc/pkg/hap/camera/ch205.go | 18 + .../go2rtc/pkg/hap/camera/ch206.go | 20 + .../go2rtc/pkg/hap/camera/ch207.go | 19 + .../go2rtc/pkg/hap/camera/ch209.go | 9 + .../go2rtc/pkg/hap/camera/stream.go | 184 +++ .../hap/chacha20poly1305/chacha20poly1305.go | 51 + installs_on_host/go2rtc/pkg/hap/character.go | 149 ++ installs_on_host/go2rtc/pkg/hap/client.go | 375 +++++ .../go2rtc/pkg/hap/client_http.go | 101 ++ .../go2rtc/pkg/hap/client_pairing.go | 403 ++++++ installs_on_host/go2rtc/pkg/hap/conn.go | 173 +++ .../go2rtc/pkg/hap/curve25519/curve25519.go | 18 + .../go2rtc/pkg/hap/ed25519/ed25519.go | 24 + installs_on_host/go2rtc/pkg/hap/hds/hds.go | 176 +++ .../go2rtc/pkg/hap/hds/hds_test.go | 35 + installs_on_host/go2rtc/pkg/hap/helpers.go | 130 ++ installs_on_host/go2rtc/pkg/hap/hkdf/hkdf.go | 17 + installs_on_host/go2rtc/pkg/hap/server.go | 396 ++++++ .../go2rtc/pkg/hap/setup/setup.go | 32 + .../go2rtc/pkg/hap/setup/setup_test.go | 18 + installs_on_host/go2rtc/pkg/hap/tlv8/tlv8.go | 386 ++++++ .../go2rtc/pkg/hap/tlv8/tlv8_test.go | 156 +++ installs_on_host/go2rtc/pkg/hass/api.go | 144 ++ installs_on_host/go2rtc/pkg/hass/client.go | 118 ++ installs_on_host/go2rtc/pkg/hls/producer.go | 22 + installs_on_host/go2rtc/pkg/hls/reader.go | 165 +++ .../go2rtc/pkg/homekit/consumer.go | 204 +++ .../go2rtc/pkg/homekit/helpers.go | 147 ++ .../go2rtc/pkg/homekit/log/debug.go | 45 + .../go2rtc/pkg/homekit/producer.go | 243 ++++ installs_on_host/go2rtc/pkg/homekit/proxy.go | 218 +++ installs_on_host/go2rtc/pkg/homekit/server.go | 194 +++ installs_on_host/go2rtc/pkg/image/producer.go | 92 ++ installs_on_host/go2rtc/pkg/ioctl/README.md | 3 + installs_on_host/go2rtc/pkg/ioctl/ioctl.go | 28 + installs_on_host/go2rtc/pkg/ioctl/ioctl_be.go | 8 + installs_on_host/go2rtc/pkg/ioctl/ioctl_le.go | 8 + .../go2rtc/pkg/ioctl/ioctl_linux.go | 14 + .../go2rtc/pkg/ioctl/ioctl_test.go | 16 + .../go2rtc/pkg/isapi/backchannel.go | 69 + installs_on_host/go2rtc/pkg/isapi/client.go | 164 +++ installs_on_host/go2rtc/pkg/iso/atoms.go | 339 +++++ installs_on_host/go2rtc/pkg/iso/codecs.go | 181 +++ installs_on_host/go2rtc/pkg/iso/iso.go | 91 ++ installs_on_host/go2rtc/pkg/iso/reader.go | 203 +++ .../go2rtc/pkg/ivideon/ivideon.go | 187 +++ installs_on_host/go2rtc/pkg/kasa/producer.go | 215 +++ .../go2rtc/pkg/magic/bitstream/producer.go | 95 ++ installs_on_host/go2rtc/pkg/magic/keyframe.go | 116 ++ .../go2rtc/pkg/magic/mjpeg/producer.go | 78 ++ installs_on_host/go2rtc/pkg/magic/producer.go | 69 + installs_on_host/go2rtc/pkg/mdns/README.md | 3 + installs_on_host/go2rtc/pkg/mdns/client.go | 374 +++++ installs_on_host/go2rtc/pkg/mdns/mdns_test.go | 17 + installs_on_host/go2rtc/pkg/mdns/server.go | 162 +++ installs_on_host/go2rtc/pkg/mdns/syscall.go | 15 + .../go2rtc/pkg/mdns/syscall_bsd.go | 26 + .../go2rtc/pkg/mdns/syscall_windows.go | 13 + installs_on_host/go2rtc/pkg/mjpeg/README.md | 5 + installs_on_host/go2rtc/pkg/mjpeg/consumer.go | 59 + installs_on_host/go2rtc/pkg/mjpeg/helpers.go | 100 ++ installs_on_host/go2rtc/pkg/mjpeg/jpeg.go | 10 + .../go2rtc/pkg/mjpeg/mjpeg_test.go | 13 + installs_on_host/go2rtc/pkg/mjpeg/rfc2435.go | 211 +++ installs_on_host/go2rtc/pkg/mjpeg/rtp.go | 220 +++ installs_on_host/go2rtc/pkg/mjpeg/writer.go | 38 + installs_on_host/go2rtc/pkg/mp4/README.md | 36 + installs_on_host/go2rtc/pkg/mp4/consumer.go | 189 +++ installs_on_host/go2rtc/pkg/mp4/demuxer.go | 116 ++ installs_on_host/go2rtc/pkg/mp4/helpers.go | 166 +++ installs_on_host/go2rtc/pkg/mp4/keyframe.go | 104 ++ installs_on_host/go2rtc/pkg/mp4/mime.go | 45 + installs_on_host/go2rtc/pkg/mp4/muxer.go | 172 +++ installs_on_host/go2rtc/pkg/mpegts/README.md | 35 + .../go2rtc/pkg/mpegts/checksum.go | 57 + .../go2rtc/pkg/mpegts/consumer.go | 124 ++ installs_on_host/go2rtc/pkg/mpegts/demuxer.go | 434 ++++++ installs_on_host/go2rtc/pkg/mpegts/muxer.go | 226 +++ installs_on_host/go2rtc/pkg/mpegts/opus.go | 66 + .../go2rtc/pkg/mpegts/producer.go | 177 +++ .../go2rtc/pkg/mpjpeg/multipart.go | 60 + .../go2rtc/pkg/mpjpeg/producer.go | 65 + installs_on_host/go2rtc/pkg/mqtt/client.go | 112 ++ installs_on_host/go2rtc/pkg/mqtt/message.go | 122 ++ .../go2rtc/pkg/multitrans/client.go | 203 +++ installs_on_host/go2rtc/pkg/nest/api.go | 486 +++++++ installs_on_host/go2rtc/pkg/nest/client.go | 197 +++ installs_on_host/go2rtc/pkg/ngrok/ngrok.go | 80 ++ installs_on_host/go2rtc/pkg/onvif/README.md | 38 + installs_on_host/go2rtc/pkg/onvif/client.go | 197 +++ installs_on_host/go2rtc/pkg/onvif/envelope.go | 73 + installs_on_host/go2rtc/pkg/onvif/helpers.go | 162 +++ .../go2rtc/pkg/onvif/onvif_test.go | 227 +++ installs_on_host/go2rtc/pkg/onvif/server.go | 301 ++++ installs_on_host/go2rtc/pkg/opus/README.md | 5 + installs_on_host/go2rtc/pkg/opus/homekit.go | 96 ++ installs_on_host/go2rtc/pkg/opus/opus.go | 118 ++ .../go2rtc/pkg/pcm/backchannel.go | 69 + installs_on_host/go2rtc/pkg/pcm/flac.go | 151 ++ installs_on_host/go2rtc/pkg/pcm/handlers.go | 109 ++ installs_on_host/go2rtc/pkg/pcm/pcm.go | 220 +++ installs_on_host/go2rtc/pkg/pcm/pcm_test.go | 79 ++ installs_on_host/go2rtc/pkg/pcm/pcma.go | 53 + installs_on_host/go2rtc/pkg/pcm/pcmu.go | 51 + installs_on_host/go2rtc/pkg/pcm/producer.go | 55 + .../go2rtc/pkg/pcm/producer_sync.go | 96 ++ .../go2rtc/pkg/pcm/s16le/s16le.go | 42 + installs_on_host/go2rtc/pkg/pcm/v1/pcm.go | 155 +++ .../go2rtc/pkg/pcm/v1/pcm_test.go | 40 + installs_on_host/go2rtc/pkg/pinggy/pinggy.go | 137 ++ installs_on_host/go2rtc/pkg/probe/consumer.go | 53 + installs_on_host/go2rtc/pkg/ring/api.go | 702 ++++++++++ installs_on_host/go2rtc/pkg/ring/client.go | 355 +++++ installs_on_host/go2rtc/pkg/ring/snapshot.go | 61 + installs_on_host/go2rtc/pkg/ring/ws.go | 265 ++++ installs_on_host/go2rtc/pkg/roborock/api.go | 167 +++ .../go2rtc/pkg/roborock/client.go | 381 +++++ .../go2rtc/pkg/roborock/iot/client.go | 173 +++ .../go2rtc/pkg/roborock/iot/crypto.go | 115 ++ .../go2rtc/pkg/roborock/producer.go | 46 + installs_on_host/go2rtc/pkg/rtmp/README.md | 27 + installs_on_host/go2rtc/pkg/rtmp/client.go | 161 +++ installs_on_host/go2rtc/pkg/rtmp/conn.go | 376 +++++ installs_on_host/go2rtc/pkg/rtmp/flv.go | 96 ++ installs_on_host/go2rtc/pkg/rtmp/server.go | 201 +++ installs_on_host/go2rtc/pkg/rtsp/README.md | 3 + installs_on_host/go2rtc/pkg/rtsp/client.go | 456 ++++++ .../go2rtc/pkg/rtsp/client_test.go | 95 ++ installs_on_host/go2rtc/pkg/rtsp/conn.go | 409 ++++++ installs_on_host/go2rtc/pkg/rtsp/consumer.go | 198 +++ installs_on_host/go2rtc/pkg/rtsp/helpers.go | 154 +++ installs_on_host/go2rtc/pkg/rtsp/producer.go | 143 ++ installs_on_host/go2rtc/pkg/rtsp/rtsp_test.go | 275 ++++ installs_on_host/go2rtc/pkg/rtsp/server.go | 235 ++++ installs_on_host/go2rtc/pkg/shell/command.go | 59 + installs_on_host/go2rtc/pkg/shell/procattr.go | 7 + .../go2rtc/pkg/shell/procattr_linux.go | 6 + installs_on_host/go2rtc/pkg/shell/shell.go | 43 + .../go2rtc/pkg/shell/shell_test.go | 18 + installs_on_host/go2rtc/pkg/srtp/server.go | 102 ++ installs_on_host/go2rtc/pkg/srtp/session.go | 160 +++ .../go2rtc/pkg/tapo/backchannel.go | 62 + installs_on_host/go2rtc/pkg/tapo/client.go | 407 ++++++ installs_on_host/go2rtc/pkg/tapo/producer.go | 94 ++ installs_on_host/go2rtc/pkg/tcp/auth.go | 140 ++ installs_on_host/go2rtc/pkg/tcp/dial.go | 64 + installs_on_host/go2rtc/pkg/tcp/request.go | 172 +++ installs_on_host/go2rtc/pkg/tcp/textproto.go | 150 ++ .../go2rtc/pkg/tcp/textproto_test.go | 30 + .../go2rtc/pkg/tcp/websocket/client.go | 130 ++ .../go2rtc/pkg/tcp/websocket/dial.go | 65 + installs_on_host/go2rtc/pkg/tutk/codec.go | 59 + installs_on_host/go2rtc/pkg/tutk/conn.go | 264 ++++ installs_on_host/go2rtc/pkg/tutk/crypto.go | 279 ++++ .../go2rtc/pkg/tutk/crypto_test.go | 14 + installs_on_host/go2rtc/pkg/tutk/dtls/auth.go | 35 + .../go2rtc/pkg/tutk/dtls/cipher.go | 218 +++ .../go2rtc/pkg/tutk/dtls/conn_dtls.go | 987 +++++++++++++ installs_on_host/go2rtc/pkg/tutk/dtls/dtls.go | 146 ++ installs_on_host/go2rtc/pkg/tutk/frame.go | 571 ++++++++ installs_on_host/go2rtc/pkg/tutk/helpers.go | 71 + installs_on_host/go2rtc/pkg/tutk/session0.go | 157 +++ installs_on_host/go2rtc/pkg/tutk/session16.go | 381 +++++ installs_on_host/go2rtc/pkg/tutk/session25.go | 337 +++++ installs_on_host/go2rtc/pkg/tuya/README.md | 9 + installs_on_host/go2rtc/pkg/tuya/client.go | 555 ++++++++ installs_on_host/go2rtc/pkg/tuya/cloud_api.go | 322 +++++ installs_on_host/go2rtc/pkg/tuya/helper.go | 69 + installs_on_host/go2rtc/pkg/tuya/interface.go | 270 ++++ installs_on_host/go2rtc/pkg/tuya/mqtt.go | 436 ++++++ installs_on_host/go2rtc/pkg/tuya/smart_api.go | 597 ++++++++ .../go2rtc/pkg/v4l2/device/README.md | 21 + .../go2rtc/pkg/v4l2/device/device.go | 252 ++++ .../go2rtc/pkg/v4l2/device/formats.go | 62 + .../go2rtc/pkg/v4l2/device/videodev2_386.go | 149 ++ .../go2rtc/pkg/v4l2/device/videodev2_arch.c | 164 +++ .../go2rtc/pkg/v4l2/device/videodev2_arm.go | 149 ++ .../pkg/v4l2/device/videodev2_mipsle.go | 149 ++ .../go2rtc/pkg/v4l2/device/videodev2_x64.go | 151 ++ installs_on_host/go2rtc/pkg/v4l2/producer.go | 142 ++ .../go2rtc/pkg/wav/backchannel.go | 67 + installs_on_host/go2rtc/pkg/wav/producer.go | 83 ++ installs_on_host/go2rtc/pkg/wav/wav.go | 103 ++ installs_on_host/go2rtc/pkg/webrtc/README.md | 11 + installs_on_host/go2rtc/pkg/webrtc/api.go | 316 +++++ installs_on_host/go2rtc/pkg/webrtc/client.go | 145 ++ .../go2rtc/pkg/webrtc/client_test.go | 118 ++ installs_on_host/go2rtc/pkg/webrtc/conn.go | 220 +++ .../go2rtc/pkg/webrtc/consumer.go | 90 ++ installs_on_host/go2rtc/pkg/webrtc/helpers.go | 348 +++++ .../go2rtc/pkg/webrtc/producer.go | 49 + installs_on_host/go2rtc/pkg/webrtc/server.go | 123 ++ installs_on_host/go2rtc/pkg/webrtc/track.go | 83 ++ .../go2rtc/pkg/webrtc/webrtc_test.go | 71 + .../go2rtc/pkg/webtorrent/client.go | 94 ++ .../go2rtc/pkg/webtorrent/crypto.go | 72 + .../go2rtc/pkg/webtorrent/server.go | 228 +++ installs_on_host/go2rtc/pkg/wyoming/README.md | 14 + installs_on_host/go2rtc/pkg/wyoming/api.go | 99 ++ .../go2rtc/pkg/wyoming/backchannel.go | 63 + installs_on_host/go2rtc/pkg/wyoming/expr.go | 138 ++ installs_on_host/go2rtc/pkg/wyoming/mic.go | 35 + .../go2rtc/pkg/wyoming/producer.go | 65 + .../go2rtc/pkg/wyoming/satellite.go | 275 ++++ installs_on_host/go2rtc/pkg/wyoming/snd.go | 40 + .../go2rtc/pkg/wyoming/wakeword.go | 120 ++ .../go2rtc/pkg/wyoming/wyoming.go | 26 + .../go2rtc/pkg/wyze/backchannel.go | 55 + installs_on_host/go2rtc/pkg/wyze/client.go | 618 +++++++++ installs_on_host/go2rtc/pkg/wyze/cloud.go | 337 +++++ installs_on_host/go2rtc/pkg/wyze/producer.go | 277 ++++ installs_on_host/go2rtc/pkg/xiaomi/cloud.go | 568 ++++++++ .../go2rtc/pkg/xiaomi/crypto/crypto.go | 68 + .../go2rtc/pkg/xiaomi/legacy/client.go | 271 ++++ .../go2rtc/pkg/xiaomi/legacy/producer.go | 216 +++ .../go2rtc/pkg/xiaomi/miss/backchannel.go | 74 + .../go2rtc/pkg/xiaomi/miss/client.go | 338 +++++ .../go2rtc/pkg/xiaomi/miss/cs2/conn.go | 506 +++++++ .../go2rtc/pkg/xiaomi/miss/producer.go | 204 +++ .../go2rtc/pkg/xiaomi/producer.go | 23 + installs_on_host/go2rtc/pkg/xnet/net.go | 64 + installs_on_host/go2rtc/pkg/xnet/tls/tls.go | 63 + installs_on_host/go2rtc/pkg/y4m/README.md | 19 + installs_on_host/go2rtc/pkg/y4m/consumer.go | 65 + installs_on_host/go2rtc/pkg/y4m/producer.go | 83 ++ installs_on_host/go2rtc/pkg/y4m/y4m.go | 149 ++ installs_on_host/go2rtc/pkg/yaml/yaml.go | 230 +++ installs_on_host/go2rtc/pkg/yaml/yaml_test.go | 109 ++ installs_on_host/go2rtc/pkg/yandex/session.go | 203 +++ installs_on_host/go2rtc/scripts/README.md | 104 ++ installs_on_host/go2rtc/scripts/build.cmd | 68 + installs_on_host/go2rtc/scripts/build.sh | 47 + .../go2rtc/website/.vitepress/config.js | 188 +++ installs_on_host/go2rtc/website/README.md | 9 + .../go2rtc/website/api/index.html | 19 + .../go2rtc/website/api/openapi.yaml | 1197 ++++++++++++++++ installs_on_host/go2rtc/website/favicon.ico | Bin 0 -> 15086 bytes .../website/icons/android-chrome-192x192.png | Bin 0 -> 6442 bytes .../website/icons/android-chrome-512x512.png | Bin 0 -> 18796 bytes .../icons/apple-touch-icon-180x180.png | Bin 0 -> 6005 bytes .../go2rtc/website/icons/favicon.ico | Bin 0 -> 15086 bytes .../go2rtc/website/images/go2rtc.png | Bin 0 -> 157571 bytes .../go2rtc/website/images/logo.gif | Bin 0 -> 158116 bytes .../go2rtc/website/images/logo.png | Bin 0 -> 38124 bytes .../go2rtc/website/images/webui-config.png | Bin 0 -> 224443 bytes .../go2rtc/website/images/webui-net.png | Bin 0 -> 127216 bytes installs_on_host/go2rtc/website/manifest.json | 18 + .../go2rtc/website/webtorrent/index.html | 189 +++ installs_on_host/go2rtc/www/README.md | 69 + installs_on_host/go2rtc/www/add.html | 569 ++++++++ installs_on_host/go2rtc/www/config.html | 1230 +++++++++++++++++ installs_on_host/go2rtc/www/hls.html | 37 + installs_on_host/go2rtc/www/index.html | 157 +++ installs_on_host/go2rtc/www/links.html | 268 ++++ installs_on_host/go2rtc/www/log.html | 145 ++ installs_on_host/go2rtc/www/main.js | 135 ++ installs_on_host/go2rtc/www/net.html | 74 + installs_on_host/go2rtc/www/schema.json | 750 ++++++++++ installs_on_host/go2rtc/www/static.go | 8 + installs_on_host/go2rtc/www/stream.html | 67 + installs_on_host/go2rtc/www/video-rtc.js | 695 ++++++++++ installs_on_host/go2rtc/www/video-stream.js | 103 ++ installs_on_host/go2rtc/www/webrtc-sync.html | 66 + installs_on_host/go2rtc/www/webrtc.html | 107 ++ 537 files changed, 69213 insertions(+) create mode 100644 installs_on_host/go2rtc/.gitignore create mode 100644 installs_on_host/go2rtc/LICENSE create mode 100644 installs_on_host/go2rtc/README.md create mode 100644 installs_on_host/go2rtc/docker/Dockerfile create mode 100644 installs_on_host/go2rtc/docker/README.md create mode 100644 installs_on_host/go2rtc/docker/hardware.Dockerfile create mode 100644 installs_on_host/go2rtc/docker/rockchip.Dockerfile create mode 100644 installs_on_host/go2rtc/examples/go2rtc_hass/main.go create mode 100644 installs_on_host/go2rtc/examples/go2rtc_mjpeg/main.go create mode 100644 installs_on_host/go2rtc/examples/go2rtc_rtsp/main.go create mode 100644 installs_on_host/go2rtc/examples/homekit_info/main.go create mode 100644 installs_on_host/go2rtc/examples/mdns/main.go create mode 100644 installs_on_host/go2rtc/examples/mod_pinggy/go.mod create mode 100644 installs_on_host/go2rtc/examples/mod_pinggy/go.sum create mode 100644 installs_on_host/go2rtc/examples/mod_pinggy/main.go create mode 100644 installs_on_host/go2rtc/examples/onvif_client/README.md create mode 100644 installs_on_host/go2rtc/examples/onvif_client/main.go create mode 100644 installs_on_host/go2rtc/examples/rtsp_client/main.go create mode 100644 installs_on_host/go2rtc/examples/tutk_decoder/README.md create mode 100644 installs_on_host/go2rtc/examples/tutk_decoder/main.go create mode 100644 installs_on_host/go2rtc/go.mod create mode 100644 installs_on_host/go2rtc/go.sum create mode 100644 installs_on_host/go2rtc/internal/README.md create mode 100644 installs_on_host/go2rtc/internal/alsa/README.md create mode 100644 installs_on_host/go2rtc/internal/alsa/alsa.go create mode 100644 installs_on_host/go2rtc/internal/alsa/alsa_linux.go create mode 100644 installs_on_host/go2rtc/internal/api/README.md create mode 100644 installs_on_host/go2rtc/internal/api/api.go create mode 100644 installs_on_host/go2rtc/internal/api/config.go create mode 100644 installs_on_host/go2rtc/internal/api/static.go create mode 100644 installs_on_host/go2rtc/internal/api/ws/README.md create mode 100644 installs_on_host/go2rtc/internal/api/ws/ws.go create mode 100644 installs_on_host/go2rtc/internal/app/README.md create mode 100644 installs_on_host/go2rtc/internal/app/app.go create mode 100644 installs_on_host/go2rtc/internal/app/config.go create mode 100644 installs_on_host/go2rtc/internal/app/log.go create mode 100644 installs_on_host/go2rtc/internal/app/storage.go create mode 100644 installs_on_host/go2rtc/internal/bubble/README.md create mode 100644 installs_on_host/go2rtc/internal/bubble/bubble.go create mode 100644 installs_on_host/go2rtc/internal/debug/README.md create mode 100644 installs_on_host/go2rtc/internal/debug/debug.go create mode 100644 installs_on_host/go2rtc/internal/debug/stack.go create mode 100644 installs_on_host/go2rtc/internal/doorbird/README.md create mode 100644 installs_on_host/go2rtc/internal/doorbird/doorbird.go create mode 100644 installs_on_host/go2rtc/internal/dvrip/README.md create mode 100644 installs_on_host/go2rtc/internal/dvrip/dvrip.go create mode 100644 installs_on_host/go2rtc/internal/echo/README.md create mode 100644 installs_on_host/go2rtc/internal/echo/echo.go create mode 100644 installs_on_host/go2rtc/internal/eseecloud/README.md create mode 100644 installs_on_host/go2rtc/internal/eseecloud/eseecloud.go create mode 100644 installs_on_host/go2rtc/internal/exec/README.md create mode 100644 installs_on_host/go2rtc/internal/exec/exec.go create mode 100644 installs_on_host/go2rtc/internal/expr/README.md create mode 100644 installs_on_host/go2rtc/internal/expr/expr.go create mode 100644 installs_on_host/go2rtc/internal/ffmpeg/README.md create mode 100644 installs_on_host/go2rtc/internal/ffmpeg/api.go create mode 100644 installs_on_host/go2rtc/internal/ffmpeg/device/README.md create mode 100644 installs_on_host/go2rtc/internal/ffmpeg/device/device_bsd.go create mode 100644 installs_on_host/go2rtc/internal/ffmpeg/device/device_darwin.go create mode 100644 installs_on_host/go2rtc/internal/ffmpeg/device/device_unix.go create mode 100644 installs_on_host/go2rtc/internal/ffmpeg/device/device_windows.go create mode 100644 installs_on_host/go2rtc/internal/ffmpeg/device/devices.go create mode 100644 installs_on_host/go2rtc/internal/ffmpeg/ffmpeg.go create mode 100644 installs_on_host/go2rtc/internal/ffmpeg/ffmpeg_test.go create mode 100644 installs_on_host/go2rtc/internal/ffmpeg/hardware/README.md create mode 100644 installs_on_host/go2rtc/internal/ffmpeg/hardware/hardware.go create mode 100644 installs_on_host/go2rtc/internal/ffmpeg/hardware/hardware_bsd.go create mode 100644 installs_on_host/go2rtc/internal/ffmpeg/hardware/hardware_darwin.go create mode 100644 installs_on_host/go2rtc/internal/ffmpeg/hardware/hardware_unix.go create mode 100644 installs_on_host/go2rtc/internal/ffmpeg/hardware/hardware_windows.go create mode 100644 installs_on_host/go2rtc/internal/ffmpeg/jpeg.go create mode 100644 installs_on_host/go2rtc/internal/ffmpeg/jpeg_test.go create mode 100644 installs_on_host/go2rtc/internal/ffmpeg/producer.go create mode 100644 installs_on_host/go2rtc/internal/ffmpeg/version.go create mode 100644 installs_on_host/go2rtc/internal/ffmpeg/virtual/virtual.go create mode 100644 installs_on_host/go2rtc/internal/ffmpeg/virtual/virtual_test.go create mode 100644 installs_on_host/go2rtc/internal/flussonic/README.md create mode 100644 installs_on_host/go2rtc/internal/flussonic/flussonic.go create mode 100644 installs_on_host/go2rtc/internal/gopro/README.md create mode 100644 installs_on_host/go2rtc/internal/gopro/gopro.go create mode 100644 installs_on_host/go2rtc/internal/hass/README.md create mode 100644 installs_on_host/go2rtc/internal/hass/api.go create mode 100644 installs_on_host/go2rtc/internal/hass/hass.go create mode 100644 installs_on_host/go2rtc/internal/hls/README.md create mode 100644 installs_on_host/go2rtc/internal/hls/hls.go create mode 100644 installs_on_host/go2rtc/internal/hls/session.go create mode 100644 installs_on_host/go2rtc/internal/hls/ws.go create mode 100644 installs_on_host/go2rtc/internal/homekit/README.md create mode 100644 installs_on_host/go2rtc/internal/homekit/api.go create mode 100644 installs_on_host/go2rtc/internal/homekit/homekit.go create mode 100644 installs_on_host/go2rtc/internal/homekit/server.go create mode 100644 installs_on_host/go2rtc/internal/http/README.md create mode 100644 installs_on_host/go2rtc/internal/http/http.go create mode 100644 installs_on_host/go2rtc/internal/isapi/README.md create mode 100644 installs_on_host/go2rtc/internal/isapi/init.go create mode 100644 installs_on_host/go2rtc/internal/ivideon/README.md create mode 100644 installs_on_host/go2rtc/internal/ivideon/ivideon.go create mode 100644 installs_on_host/go2rtc/internal/kasa/README.md create mode 100644 installs_on_host/go2rtc/internal/kasa/kasa.go create mode 100644 installs_on_host/go2rtc/internal/mjpeg/README.md create mode 100644 installs_on_host/go2rtc/internal/mjpeg/mjpeg.go create mode 100644 installs_on_host/go2rtc/internal/mp4/README.md create mode 100644 installs_on_host/go2rtc/internal/mp4/mp4.go create mode 100644 installs_on_host/go2rtc/internal/mp4/ws.go create mode 100644 installs_on_host/go2rtc/internal/mpeg/README.md create mode 100644 installs_on_host/go2rtc/internal/mpeg/mpeg.go create mode 100644 installs_on_host/go2rtc/internal/multitrans/README.md create mode 100644 installs_on_host/go2rtc/internal/multitrans/multitrans.go create mode 100644 installs_on_host/go2rtc/internal/nest/README.md create mode 100644 installs_on_host/go2rtc/internal/nest/init.go create mode 100644 installs_on_host/go2rtc/internal/ngrok/README.md create mode 100644 installs_on_host/go2rtc/internal/ngrok/ngrok.go create mode 100644 installs_on_host/go2rtc/internal/onvif/README.md create mode 100644 installs_on_host/go2rtc/internal/onvif/onvif.go create mode 100644 installs_on_host/go2rtc/internal/pinggy/README.md create mode 100644 installs_on_host/go2rtc/internal/pinggy/pinggy.go create mode 100644 installs_on_host/go2rtc/internal/ring/README.md create mode 100644 installs_on_host/go2rtc/internal/ring/ring.go create mode 100644 installs_on_host/go2rtc/internal/roborock/README.md create mode 100644 installs_on_host/go2rtc/internal/roborock/roborock.go create mode 100644 installs_on_host/go2rtc/internal/rtmp/README.md create mode 100644 installs_on_host/go2rtc/internal/rtmp/rtmp.go create mode 100644 installs_on_host/go2rtc/internal/rtsp/README.md create mode 100644 installs_on_host/go2rtc/internal/rtsp/rtsp.go create mode 100644 installs_on_host/go2rtc/internal/srtp/README.md create mode 100644 installs_on_host/go2rtc/internal/srtp/srtp.go create mode 100644 installs_on_host/go2rtc/internal/streams/README.md create mode 100644 installs_on_host/go2rtc/internal/streams/add_consumer.go create mode 100644 installs_on_host/go2rtc/internal/streams/api.go create mode 100644 installs_on_host/go2rtc/internal/streams/api_test.go create mode 100644 installs_on_host/go2rtc/internal/streams/dot.go create mode 100644 installs_on_host/go2rtc/internal/streams/handlers.go create mode 100644 installs_on_host/go2rtc/internal/streams/helpers.go create mode 100644 installs_on_host/go2rtc/internal/streams/play.go create mode 100644 installs_on_host/go2rtc/internal/streams/preload.go create mode 100644 installs_on_host/go2rtc/internal/streams/producer.go create mode 100644 installs_on_host/go2rtc/internal/streams/publish.go create mode 100644 installs_on_host/go2rtc/internal/streams/stream.go create mode 100644 installs_on_host/go2rtc/internal/streams/stream_test.go create mode 100644 installs_on_host/go2rtc/internal/streams/streams.go create mode 100644 installs_on_host/go2rtc/internal/tapo/README.md create mode 100644 installs_on_host/go2rtc/internal/tapo/tapo.go create mode 100644 installs_on_host/go2rtc/internal/tuya/README.md create mode 100644 installs_on_host/go2rtc/internal/tuya/tuya.go create mode 100644 installs_on_host/go2rtc/internal/v4l2/README.md create mode 100644 installs_on_host/go2rtc/internal/v4l2/v4l2.go create mode 100644 installs_on_host/go2rtc/internal/v4l2/v4l2_linux.go create mode 100644 installs_on_host/go2rtc/internal/webrtc/README.md create mode 100644 installs_on_host/go2rtc/internal/webrtc/candidates.go create mode 100644 installs_on_host/go2rtc/internal/webrtc/client.go create mode 100644 installs_on_host/go2rtc/internal/webrtc/client_creality.go create mode 100644 installs_on_host/go2rtc/internal/webrtc/kinesis.go create mode 100644 installs_on_host/go2rtc/internal/webrtc/milestone.go create mode 100644 installs_on_host/go2rtc/internal/webrtc/openipc.go create mode 100644 installs_on_host/go2rtc/internal/webrtc/server.go create mode 100644 installs_on_host/go2rtc/internal/webrtc/switchbot.go create mode 100644 installs_on_host/go2rtc/internal/webrtc/webrtc.go create mode 100644 installs_on_host/go2rtc/internal/webrtc/webrtc_test.go create mode 100644 installs_on_host/go2rtc/internal/webtorrent/README.md create mode 100644 installs_on_host/go2rtc/internal/webtorrent/init.go create mode 100644 installs_on_host/go2rtc/internal/webtorrent/tracker.go create mode 100644 installs_on_host/go2rtc/internal/wyoming/README.md create mode 100644 installs_on_host/go2rtc/internal/wyoming/wyoming.go create mode 100644 installs_on_host/go2rtc/internal/wyze/README.md create mode 100644 installs_on_host/go2rtc/internal/wyze/wyze.go create mode 100644 installs_on_host/go2rtc/internal/xiaomi/README.md create mode 100644 installs_on_host/go2rtc/internal/xiaomi/xiaomi.go create mode 100644 installs_on_host/go2rtc/internal/yandex/README.md create mode 100644 installs_on_host/go2rtc/internal/yandex/goloom.go create mode 100644 installs_on_host/go2rtc/internal/yandex/yandex.go create mode 100644 installs_on_host/go2rtc/main.go create mode 100644 installs_on_host/go2rtc/package.json create mode 100644 installs_on_host/go2rtc/pkg/README.md create mode 100644 installs_on_host/go2rtc/pkg/aac/README.md create mode 100644 installs_on_host/go2rtc/pkg/aac/aac.go create mode 100644 installs_on_host/go2rtc/pkg/aac/aac_test.go create mode 100644 installs_on_host/go2rtc/pkg/aac/adts.go create mode 100644 installs_on_host/go2rtc/pkg/aac/consumer.go create mode 100644 installs_on_host/go2rtc/pkg/aac/producer.go create mode 100644 installs_on_host/go2rtc/pkg/aac/rtp.go create mode 100644 installs_on_host/go2rtc/pkg/aac/rtp_test.go create mode 100644 installs_on_host/go2rtc/pkg/alsa/README.md create mode 100644 installs_on_host/go2rtc/pkg/alsa/capture_linux.go create mode 100644 installs_on_host/go2rtc/pkg/alsa/device/asound_32bit.go create mode 100644 installs_on_host/go2rtc/pkg/alsa/device/asound_64bit.go create mode 100644 installs_on_host/go2rtc/pkg/alsa/device/asound_arch.c create mode 100644 installs_on_host/go2rtc/pkg/alsa/device/asound_mipsle.go create mode 100644 installs_on_host/go2rtc/pkg/alsa/device/device_linux.go create mode 100644 installs_on_host/go2rtc/pkg/alsa/device/ioctl_linux.go create mode 100644 installs_on_host/go2rtc/pkg/alsa/open_linux.go create mode 100644 installs_on_host/go2rtc/pkg/alsa/playback_linux.go create mode 100644 installs_on_host/go2rtc/pkg/ascii/README.md create mode 100644 installs_on_host/go2rtc/pkg/ascii/ascii.go create mode 100644 installs_on_host/go2rtc/pkg/bits/reader.go create mode 100644 installs_on_host/go2rtc/pkg/bits/writer.go create mode 100644 installs_on_host/go2rtc/pkg/bubble/client.go create mode 100644 installs_on_host/go2rtc/pkg/bubble/producer.go create mode 100644 installs_on_host/go2rtc/pkg/core/README.md create mode 100644 installs_on_host/go2rtc/pkg/core/codec.go create mode 100644 installs_on_host/go2rtc/pkg/core/connection.go create mode 100644 installs_on_host/go2rtc/pkg/core/core.go create mode 100644 installs_on_host/go2rtc/pkg/core/core_test.go create mode 100644 installs_on_host/go2rtc/pkg/core/helpers.go create mode 100644 installs_on_host/go2rtc/pkg/core/listener.go create mode 100644 installs_on_host/go2rtc/pkg/core/media.go create mode 100644 installs_on_host/go2rtc/pkg/core/media_test.go create mode 100644 installs_on_host/go2rtc/pkg/core/node.go create mode 100644 installs_on_host/go2rtc/pkg/core/readbuffer.go create mode 100644 installs_on_host/go2rtc/pkg/core/readbuffer_test.go create mode 100644 installs_on_host/go2rtc/pkg/core/slices.go create mode 100644 installs_on_host/go2rtc/pkg/core/track.go create mode 100644 installs_on_host/go2rtc/pkg/core/track_test.go create mode 100644 installs_on_host/go2rtc/pkg/core/waiter.go create mode 100644 installs_on_host/go2rtc/pkg/core/worker.go create mode 100644 installs_on_host/go2rtc/pkg/core/writebuffer.go create mode 100644 installs_on_host/go2rtc/pkg/creds/README.md create mode 100644 installs_on_host/go2rtc/pkg/creds/creds.go create mode 100644 installs_on_host/go2rtc/pkg/creds/secrets.go create mode 100644 installs_on_host/go2rtc/pkg/creds/secrets_test.go create mode 100644 installs_on_host/go2rtc/pkg/debug/conn.go create mode 100644 installs_on_host/go2rtc/pkg/debug/debug.go create mode 100644 installs_on_host/go2rtc/pkg/doorbird/backchannel.go create mode 100644 installs_on_host/go2rtc/pkg/dvrip/backchannel.go create mode 100644 installs_on_host/go2rtc/pkg/dvrip/client.go create mode 100644 installs_on_host/go2rtc/pkg/dvrip/dvrip.go create mode 100644 installs_on_host/go2rtc/pkg/dvrip/producer.go create mode 100644 installs_on_host/go2rtc/pkg/eseecloud/eseecloud.go create mode 100644 installs_on_host/go2rtc/pkg/expr/expr.go create mode 100644 installs_on_host/go2rtc/pkg/expr/expr_test.go create mode 100644 installs_on_host/go2rtc/pkg/ffmpeg/README.md create mode 100644 installs_on_host/go2rtc/pkg/ffmpeg/ffmpeg.go create mode 100644 installs_on_host/go2rtc/pkg/flussonic/flussonic.go create mode 100644 installs_on_host/go2rtc/pkg/flv/amf/amf.go create mode 100644 installs_on_host/go2rtc/pkg/flv/amf/amf_test.go create mode 100644 installs_on_host/go2rtc/pkg/flv/consumer.go create mode 100644 installs_on_host/go2rtc/pkg/flv/flv_test.go create mode 100644 installs_on_host/go2rtc/pkg/flv/muxer.go create mode 100644 installs_on_host/go2rtc/pkg/flv/producer.go create mode 100644 installs_on_host/go2rtc/pkg/gopro/discovery.go create mode 100644 installs_on_host/go2rtc/pkg/gopro/producer.go create mode 100644 installs_on_host/go2rtc/pkg/h264/README.md create mode 100644 installs_on_host/go2rtc/pkg/h264/annexb/annexb.go create mode 100644 installs_on_host/go2rtc/pkg/h264/annexb/annexb_test.go create mode 100644 installs_on_host/go2rtc/pkg/h264/avc.go create mode 100644 installs_on_host/go2rtc/pkg/h264/avcc.go create mode 100644 installs_on_host/go2rtc/pkg/h264/h264.go create mode 100644 installs_on_host/go2rtc/pkg/h264/h264_test.go create mode 100644 installs_on_host/go2rtc/pkg/h264/mpeg4.go create mode 100644 installs_on_host/go2rtc/pkg/h264/payloader.go create mode 100644 installs_on_host/go2rtc/pkg/h264/rtp.go create mode 100644 installs_on_host/go2rtc/pkg/h264/sps.go create mode 100644 installs_on_host/go2rtc/pkg/h265/README.md create mode 100644 installs_on_host/go2rtc/pkg/h265/avc.go create mode 100644 installs_on_host/go2rtc/pkg/h265/avcc.go create mode 100644 installs_on_host/go2rtc/pkg/h265/h265_test.go create mode 100644 installs_on_host/go2rtc/pkg/h265/helper.go create mode 100644 installs_on_host/go2rtc/pkg/h265/mpeg4.go create mode 100644 installs_on_host/go2rtc/pkg/h265/payloader.go create mode 100644 installs_on_host/go2rtc/pkg/h265/rtp.go create mode 100644 installs_on_host/go2rtc/pkg/h265/sps.go create mode 100644 installs_on_host/go2rtc/pkg/hap/README.md create mode 100644 installs_on_host/go2rtc/pkg/hap/accessory.go create mode 100644 installs_on_host/go2rtc/pkg/hap/camera/README.md create mode 100644 installs_on_host/go2rtc/pkg/hap/camera/accessory.go create mode 100644 installs_on_host/go2rtc/pkg/hap/camera/accessory_test.go create mode 100644 installs_on_host/go2rtc/pkg/hap/camera/ch114_supported_video.go create mode 100644 installs_on_host/go2rtc/pkg/hap/camera/ch115_supported_audio.go create mode 100644 installs_on_host/go2rtc/pkg/hap/camera/ch116_supported_rtp.go create mode 100644 installs_on_host/go2rtc/pkg/hap/camera/ch117_selected_stream.go create mode 100644 installs_on_host/go2rtc/pkg/hap/camera/ch118_setup_endpoints.go create mode 100644 installs_on_host/go2rtc/pkg/hap/camera/ch120_streaming_status.go create mode 100644 installs_on_host/go2rtc/pkg/hap/camera/ch130_data_stream_transport.go create mode 100644 installs_on_host/go2rtc/pkg/hap/camera/ch131_data_stream.go create mode 100644 installs_on_host/go2rtc/pkg/hap/camera/ch205.go create mode 100644 installs_on_host/go2rtc/pkg/hap/camera/ch206.go create mode 100644 installs_on_host/go2rtc/pkg/hap/camera/ch207.go create mode 100644 installs_on_host/go2rtc/pkg/hap/camera/ch209.go create mode 100644 installs_on_host/go2rtc/pkg/hap/camera/stream.go create mode 100644 installs_on_host/go2rtc/pkg/hap/chacha20poly1305/chacha20poly1305.go create mode 100644 installs_on_host/go2rtc/pkg/hap/character.go create mode 100644 installs_on_host/go2rtc/pkg/hap/client.go create mode 100644 installs_on_host/go2rtc/pkg/hap/client_http.go create mode 100644 installs_on_host/go2rtc/pkg/hap/client_pairing.go create mode 100644 installs_on_host/go2rtc/pkg/hap/conn.go create mode 100644 installs_on_host/go2rtc/pkg/hap/curve25519/curve25519.go create mode 100644 installs_on_host/go2rtc/pkg/hap/ed25519/ed25519.go create mode 100644 installs_on_host/go2rtc/pkg/hap/hds/hds.go create mode 100644 installs_on_host/go2rtc/pkg/hap/hds/hds_test.go create mode 100644 installs_on_host/go2rtc/pkg/hap/helpers.go create mode 100644 installs_on_host/go2rtc/pkg/hap/hkdf/hkdf.go create mode 100644 installs_on_host/go2rtc/pkg/hap/server.go create mode 100644 installs_on_host/go2rtc/pkg/hap/setup/setup.go create mode 100644 installs_on_host/go2rtc/pkg/hap/setup/setup_test.go create mode 100644 installs_on_host/go2rtc/pkg/hap/tlv8/tlv8.go create mode 100644 installs_on_host/go2rtc/pkg/hap/tlv8/tlv8_test.go create mode 100644 installs_on_host/go2rtc/pkg/hass/api.go create mode 100644 installs_on_host/go2rtc/pkg/hass/client.go create mode 100644 installs_on_host/go2rtc/pkg/hls/producer.go create mode 100644 installs_on_host/go2rtc/pkg/hls/reader.go create mode 100644 installs_on_host/go2rtc/pkg/homekit/consumer.go create mode 100644 installs_on_host/go2rtc/pkg/homekit/helpers.go create mode 100644 installs_on_host/go2rtc/pkg/homekit/log/debug.go create mode 100644 installs_on_host/go2rtc/pkg/homekit/producer.go create mode 100644 installs_on_host/go2rtc/pkg/homekit/proxy.go create mode 100644 installs_on_host/go2rtc/pkg/homekit/server.go create mode 100644 installs_on_host/go2rtc/pkg/image/producer.go create mode 100644 installs_on_host/go2rtc/pkg/ioctl/README.md create mode 100644 installs_on_host/go2rtc/pkg/ioctl/ioctl.go create mode 100644 installs_on_host/go2rtc/pkg/ioctl/ioctl_be.go create mode 100644 installs_on_host/go2rtc/pkg/ioctl/ioctl_le.go create mode 100644 installs_on_host/go2rtc/pkg/ioctl/ioctl_linux.go create mode 100644 installs_on_host/go2rtc/pkg/ioctl/ioctl_test.go create mode 100644 installs_on_host/go2rtc/pkg/isapi/backchannel.go create mode 100644 installs_on_host/go2rtc/pkg/isapi/client.go create mode 100644 installs_on_host/go2rtc/pkg/iso/atoms.go create mode 100644 installs_on_host/go2rtc/pkg/iso/codecs.go create mode 100644 installs_on_host/go2rtc/pkg/iso/iso.go create mode 100644 installs_on_host/go2rtc/pkg/iso/reader.go create mode 100644 installs_on_host/go2rtc/pkg/ivideon/ivideon.go create mode 100644 installs_on_host/go2rtc/pkg/kasa/producer.go create mode 100644 installs_on_host/go2rtc/pkg/magic/bitstream/producer.go create mode 100644 installs_on_host/go2rtc/pkg/magic/keyframe.go create mode 100644 installs_on_host/go2rtc/pkg/magic/mjpeg/producer.go create mode 100644 installs_on_host/go2rtc/pkg/magic/producer.go create mode 100644 installs_on_host/go2rtc/pkg/mdns/README.md create mode 100644 installs_on_host/go2rtc/pkg/mdns/client.go create mode 100644 installs_on_host/go2rtc/pkg/mdns/mdns_test.go create mode 100644 installs_on_host/go2rtc/pkg/mdns/server.go create mode 100644 installs_on_host/go2rtc/pkg/mdns/syscall.go create mode 100644 installs_on_host/go2rtc/pkg/mdns/syscall_bsd.go create mode 100644 installs_on_host/go2rtc/pkg/mdns/syscall_windows.go create mode 100644 installs_on_host/go2rtc/pkg/mjpeg/README.md create mode 100644 installs_on_host/go2rtc/pkg/mjpeg/consumer.go create mode 100644 installs_on_host/go2rtc/pkg/mjpeg/helpers.go create mode 100644 installs_on_host/go2rtc/pkg/mjpeg/jpeg.go create mode 100644 installs_on_host/go2rtc/pkg/mjpeg/mjpeg_test.go create mode 100644 installs_on_host/go2rtc/pkg/mjpeg/rfc2435.go create mode 100644 installs_on_host/go2rtc/pkg/mjpeg/rtp.go create mode 100644 installs_on_host/go2rtc/pkg/mjpeg/writer.go create mode 100644 installs_on_host/go2rtc/pkg/mp4/README.md create mode 100644 installs_on_host/go2rtc/pkg/mp4/consumer.go create mode 100644 installs_on_host/go2rtc/pkg/mp4/demuxer.go create mode 100644 installs_on_host/go2rtc/pkg/mp4/helpers.go create mode 100644 installs_on_host/go2rtc/pkg/mp4/keyframe.go create mode 100644 installs_on_host/go2rtc/pkg/mp4/mime.go create mode 100644 installs_on_host/go2rtc/pkg/mp4/muxer.go create mode 100644 installs_on_host/go2rtc/pkg/mpegts/README.md create mode 100644 installs_on_host/go2rtc/pkg/mpegts/checksum.go create mode 100644 installs_on_host/go2rtc/pkg/mpegts/consumer.go create mode 100644 installs_on_host/go2rtc/pkg/mpegts/demuxer.go create mode 100644 installs_on_host/go2rtc/pkg/mpegts/muxer.go create mode 100644 installs_on_host/go2rtc/pkg/mpegts/opus.go create mode 100644 installs_on_host/go2rtc/pkg/mpegts/producer.go create mode 100644 installs_on_host/go2rtc/pkg/mpjpeg/multipart.go create mode 100644 installs_on_host/go2rtc/pkg/mpjpeg/producer.go create mode 100644 installs_on_host/go2rtc/pkg/mqtt/client.go create mode 100644 installs_on_host/go2rtc/pkg/mqtt/message.go create mode 100644 installs_on_host/go2rtc/pkg/multitrans/client.go create mode 100644 installs_on_host/go2rtc/pkg/nest/api.go create mode 100644 installs_on_host/go2rtc/pkg/nest/client.go create mode 100644 installs_on_host/go2rtc/pkg/ngrok/ngrok.go create mode 100644 installs_on_host/go2rtc/pkg/onvif/README.md create mode 100644 installs_on_host/go2rtc/pkg/onvif/client.go create mode 100644 installs_on_host/go2rtc/pkg/onvif/envelope.go create mode 100644 installs_on_host/go2rtc/pkg/onvif/helpers.go create mode 100644 installs_on_host/go2rtc/pkg/onvif/onvif_test.go create mode 100644 installs_on_host/go2rtc/pkg/onvif/server.go create mode 100644 installs_on_host/go2rtc/pkg/opus/README.md create mode 100644 installs_on_host/go2rtc/pkg/opus/homekit.go create mode 100644 installs_on_host/go2rtc/pkg/opus/opus.go create mode 100644 installs_on_host/go2rtc/pkg/pcm/backchannel.go create mode 100644 installs_on_host/go2rtc/pkg/pcm/flac.go create mode 100644 installs_on_host/go2rtc/pkg/pcm/handlers.go create mode 100644 installs_on_host/go2rtc/pkg/pcm/pcm.go create mode 100644 installs_on_host/go2rtc/pkg/pcm/pcm_test.go create mode 100644 installs_on_host/go2rtc/pkg/pcm/pcma.go create mode 100644 installs_on_host/go2rtc/pkg/pcm/pcmu.go create mode 100644 installs_on_host/go2rtc/pkg/pcm/producer.go create mode 100644 installs_on_host/go2rtc/pkg/pcm/producer_sync.go create mode 100644 installs_on_host/go2rtc/pkg/pcm/s16le/s16le.go create mode 100644 installs_on_host/go2rtc/pkg/pcm/v1/pcm.go create mode 100644 installs_on_host/go2rtc/pkg/pcm/v1/pcm_test.go create mode 100644 installs_on_host/go2rtc/pkg/pinggy/pinggy.go create mode 100644 installs_on_host/go2rtc/pkg/probe/consumer.go create mode 100644 installs_on_host/go2rtc/pkg/ring/api.go create mode 100644 installs_on_host/go2rtc/pkg/ring/client.go create mode 100644 installs_on_host/go2rtc/pkg/ring/snapshot.go create mode 100644 installs_on_host/go2rtc/pkg/ring/ws.go create mode 100644 installs_on_host/go2rtc/pkg/roborock/api.go create mode 100644 installs_on_host/go2rtc/pkg/roborock/client.go create mode 100644 installs_on_host/go2rtc/pkg/roborock/iot/client.go create mode 100644 installs_on_host/go2rtc/pkg/roborock/iot/crypto.go create mode 100644 installs_on_host/go2rtc/pkg/roborock/producer.go create mode 100644 installs_on_host/go2rtc/pkg/rtmp/README.md create mode 100644 installs_on_host/go2rtc/pkg/rtmp/client.go create mode 100644 installs_on_host/go2rtc/pkg/rtmp/conn.go create mode 100644 installs_on_host/go2rtc/pkg/rtmp/flv.go create mode 100644 installs_on_host/go2rtc/pkg/rtmp/server.go create mode 100644 installs_on_host/go2rtc/pkg/rtsp/README.md create mode 100644 installs_on_host/go2rtc/pkg/rtsp/client.go create mode 100644 installs_on_host/go2rtc/pkg/rtsp/client_test.go create mode 100644 installs_on_host/go2rtc/pkg/rtsp/conn.go create mode 100644 installs_on_host/go2rtc/pkg/rtsp/consumer.go create mode 100644 installs_on_host/go2rtc/pkg/rtsp/helpers.go create mode 100644 installs_on_host/go2rtc/pkg/rtsp/producer.go create mode 100644 installs_on_host/go2rtc/pkg/rtsp/rtsp_test.go create mode 100644 installs_on_host/go2rtc/pkg/rtsp/server.go create mode 100644 installs_on_host/go2rtc/pkg/shell/command.go create mode 100644 installs_on_host/go2rtc/pkg/shell/procattr.go create mode 100644 installs_on_host/go2rtc/pkg/shell/procattr_linux.go create mode 100644 installs_on_host/go2rtc/pkg/shell/shell.go create mode 100644 installs_on_host/go2rtc/pkg/shell/shell_test.go create mode 100644 installs_on_host/go2rtc/pkg/srtp/server.go create mode 100644 installs_on_host/go2rtc/pkg/srtp/session.go create mode 100644 installs_on_host/go2rtc/pkg/tapo/backchannel.go create mode 100644 installs_on_host/go2rtc/pkg/tapo/client.go create mode 100644 installs_on_host/go2rtc/pkg/tapo/producer.go create mode 100644 installs_on_host/go2rtc/pkg/tcp/auth.go create mode 100644 installs_on_host/go2rtc/pkg/tcp/dial.go create mode 100644 installs_on_host/go2rtc/pkg/tcp/request.go create mode 100644 installs_on_host/go2rtc/pkg/tcp/textproto.go create mode 100644 installs_on_host/go2rtc/pkg/tcp/textproto_test.go create mode 100644 installs_on_host/go2rtc/pkg/tcp/websocket/client.go create mode 100644 installs_on_host/go2rtc/pkg/tcp/websocket/dial.go create mode 100644 installs_on_host/go2rtc/pkg/tutk/codec.go create mode 100644 installs_on_host/go2rtc/pkg/tutk/conn.go create mode 100644 installs_on_host/go2rtc/pkg/tutk/crypto.go create mode 100644 installs_on_host/go2rtc/pkg/tutk/crypto_test.go create mode 100644 installs_on_host/go2rtc/pkg/tutk/dtls/auth.go create mode 100644 installs_on_host/go2rtc/pkg/tutk/dtls/cipher.go create mode 100644 installs_on_host/go2rtc/pkg/tutk/dtls/conn_dtls.go create mode 100644 installs_on_host/go2rtc/pkg/tutk/dtls/dtls.go create mode 100644 installs_on_host/go2rtc/pkg/tutk/frame.go create mode 100644 installs_on_host/go2rtc/pkg/tutk/helpers.go create mode 100644 installs_on_host/go2rtc/pkg/tutk/session0.go create mode 100644 installs_on_host/go2rtc/pkg/tutk/session16.go create mode 100644 installs_on_host/go2rtc/pkg/tutk/session25.go create mode 100644 installs_on_host/go2rtc/pkg/tuya/README.md create mode 100644 installs_on_host/go2rtc/pkg/tuya/client.go create mode 100644 installs_on_host/go2rtc/pkg/tuya/cloud_api.go create mode 100644 installs_on_host/go2rtc/pkg/tuya/helper.go create mode 100644 installs_on_host/go2rtc/pkg/tuya/interface.go create mode 100644 installs_on_host/go2rtc/pkg/tuya/mqtt.go create mode 100644 installs_on_host/go2rtc/pkg/tuya/smart_api.go create mode 100644 installs_on_host/go2rtc/pkg/v4l2/device/README.md create mode 100644 installs_on_host/go2rtc/pkg/v4l2/device/device.go create mode 100644 installs_on_host/go2rtc/pkg/v4l2/device/formats.go create mode 100644 installs_on_host/go2rtc/pkg/v4l2/device/videodev2_386.go create mode 100644 installs_on_host/go2rtc/pkg/v4l2/device/videodev2_arch.c create mode 100644 installs_on_host/go2rtc/pkg/v4l2/device/videodev2_arm.go create mode 100644 installs_on_host/go2rtc/pkg/v4l2/device/videodev2_mipsle.go create mode 100644 installs_on_host/go2rtc/pkg/v4l2/device/videodev2_x64.go create mode 100644 installs_on_host/go2rtc/pkg/v4l2/producer.go create mode 100644 installs_on_host/go2rtc/pkg/wav/backchannel.go create mode 100644 installs_on_host/go2rtc/pkg/wav/producer.go create mode 100644 installs_on_host/go2rtc/pkg/wav/wav.go create mode 100644 installs_on_host/go2rtc/pkg/webrtc/README.md create mode 100644 installs_on_host/go2rtc/pkg/webrtc/api.go create mode 100644 installs_on_host/go2rtc/pkg/webrtc/client.go create mode 100644 installs_on_host/go2rtc/pkg/webrtc/client_test.go create mode 100644 installs_on_host/go2rtc/pkg/webrtc/conn.go create mode 100644 installs_on_host/go2rtc/pkg/webrtc/consumer.go create mode 100644 installs_on_host/go2rtc/pkg/webrtc/helpers.go create mode 100644 installs_on_host/go2rtc/pkg/webrtc/producer.go create mode 100644 installs_on_host/go2rtc/pkg/webrtc/server.go create mode 100644 installs_on_host/go2rtc/pkg/webrtc/track.go create mode 100644 installs_on_host/go2rtc/pkg/webrtc/webrtc_test.go create mode 100644 installs_on_host/go2rtc/pkg/webtorrent/client.go create mode 100644 installs_on_host/go2rtc/pkg/webtorrent/crypto.go create mode 100644 installs_on_host/go2rtc/pkg/webtorrent/server.go create mode 100644 installs_on_host/go2rtc/pkg/wyoming/README.md create mode 100644 installs_on_host/go2rtc/pkg/wyoming/api.go create mode 100644 installs_on_host/go2rtc/pkg/wyoming/backchannel.go create mode 100644 installs_on_host/go2rtc/pkg/wyoming/expr.go create mode 100644 installs_on_host/go2rtc/pkg/wyoming/mic.go create mode 100644 installs_on_host/go2rtc/pkg/wyoming/producer.go create mode 100644 installs_on_host/go2rtc/pkg/wyoming/satellite.go create mode 100644 installs_on_host/go2rtc/pkg/wyoming/snd.go create mode 100644 installs_on_host/go2rtc/pkg/wyoming/wakeword.go create mode 100644 installs_on_host/go2rtc/pkg/wyoming/wyoming.go create mode 100644 installs_on_host/go2rtc/pkg/wyze/backchannel.go create mode 100644 installs_on_host/go2rtc/pkg/wyze/client.go create mode 100644 installs_on_host/go2rtc/pkg/wyze/cloud.go create mode 100644 installs_on_host/go2rtc/pkg/wyze/producer.go create mode 100644 installs_on_host/go2rtc/pkg/xiaomi/cloud.go create mode 100644 installs_on_host/go2rtc/pkg/xiaomi/crypto/crypto.go create mode 100644 installs_on_host/go2rtc/pkg/xiaomi/legacy/client.go create mode 100644 installs_on_host/go2rtc/pkg/xiaomi/legacy/producer.go create mode 100644 installs_on_host/go2rtc/pkg/xiaomi/miss/backchannel.go create mode 100644 installs_on_host/go2rtc/pkg/xiaomi/miss/client.go create mode 100644 installs_on_host/go2rtc/pkg/xiaomi/miss/cs2/conn.go create mode 100644 installs_on_host/go2rtc/pkg/xiaomi/miss/producer.go create mode 100644 installs_on_host/go2rtc/pkg/xiaomi/producer.go create mode 100644 installs_on_host/go2rtc/pkg/xnet/net.go create mode 100644 installs_on_host/go2rtc/pkg/xnet/tls/tls.go create mode 100644 installs_on_host/go2rtc/pkg/y4m/README.md create mode 100644 installs_on_host/go2rtc/pkg/y4m/consumer.go create mode 100644 installs_on_host/go2rtc/pkg/y4m/producer.go create mode 100644 installs_on_host/go2rtc/pkg/y4m/y4m.go create mode 100644 installs_on_host/go2rtc/pkg/yaml/yaml.go create mode 100644 installs_on_host/go2rtc/pkg/yaml/yaml_test.go create mode 100644 installs_on_host/go2rtc/pkg/yandex/session.go create mode 100644 installs_on_host/go2rtc/scripts/README.md create mode 100644 installs_on_host/go2rtc/scripts/build.cmd create mode 100755 installs_on_host/go2rtc/scripts/build.sh create mode 100644 installs_on_host/go2rtc/website/.vitepress/config.js create mode 100644 installs_on_host/go2rtc/website/README.md create mode 100644 installs_on_host/go2rtc/website/api/index.html create mode 100644 installs_on_host/go2rtc/website/api/openapi.yaml create mode 100644 installs_on_host/go2rtc/website/favicon.ico create mode 100644 installs_on_host/go2rtc/website/icons/android-chrome-192x192.png create mode 100644 installs_on_host/go2rtc/website/icons/android-chrome-512x512.png create mode 100644 installs_on_host/go2rtc/website/icons/apple-touch-icon-180x180.png create mode 100644 installs_on_host/go2rtc/website/icons/favicon.ico create mode 100644 installs_on_host/go2rtc/website/images/go2rtc.png create mode 100644 installs_on_host/go2rtc/website/images/logo.gif create mode 100644 installs_on_host/go2rtc/website/images/logo.png create mode 100644 installs_on_host/go2rtc/website/images/webui-config.png create mode 100644 installs_on_host/go2rtc/website/images/webui-net.png create mode 100644 installs_on_host/go2rtc/website/manifest.json create mode 100644 installs_on_host/go2rtc/website/webtorrent/index.html create mode 100644 installs_on_host/go2rtc/www/README.md create mode 100644 installs_on_host/go2rtc/www/add.html create mode 100644 installs_on_host/go2rtc/www/config.html create mode 100644 installs_on_host/go2rtc/www/hls.html create mode 100644 installs_on_host/go2rtc/www/index.html create mode 100644 installs_on_host/go2rtc/www/links.html create mode 100644 installs_on_host/go2rtc/www/log.html create mode 100644 installs_on_host/go2rtc/www/main.js create mode 100644 installs_on_host/go2rtc/www/net.html create mode 100644 installs_on_host/go2rtc/www/schema.json create mode 100644 installs_on_host/go2rtc/www/static.go create mode 100644 installs_on_host/go2rtc/www/stream.html create mode 100644 installs_on_host/go2rtc/www/video-rtc.js create mode 100644 installs_on_host/go2rtc/www/video-stream.js create mode 100644 installs_on_host/go2rtc/www/webrtc-sync.html create mode 100644 installs_on_host/go2rtc/www/webrtc.html diff --git a/installs_on_host/go2rtc/.gitignore b/installs_on_host/go2rtc/.gitignore new file mode 100644 index 0000000..5d53907 --- /dev/null +++ b/installs_on_host/go2rtc/.gitignore @@ -0,0 +1,24 @@ +.idea/ +.tmp/ + +go2rtc.yaml +go2rtc.json + +go2rtc_freebsd* +go2rtc_linux* +go2rtc_mac* +go2rtc_win* + +/go2rtc +/go2rtc.exe + +0_test.go + +.DS_Store + +website/.vitepress/cache +website/.vitepress/dist + +node_modules +package-lock.json +CLAUDE.md \ No newline at end of file diff --git a/installs_on_host/go2rtc/LICENSE b/installs_on_host/go2rtc/LICENSE new file mode 100644 index 0000000..beb8fca --- /dev/null +++ b/installs_on_host/go2rtc/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Alexey Khit + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/installs_on_host/go2rtc/README.md b/installs_on_host/go2rtc/README.md new file mode 100644 index 0000000..b15d57a --- /dev/null +++ b/installs_on_host/go2rtc/README.md @@ -0,0 +1,538 @@ +

+ + go2rtc - GitHub + +

+

+ + go2rtc - GitHub Stars + + + go2rtc - Docker Pulls + + + go2rtc - GitHub Downloads + +

+

+ + go2rtc - Trendshift + +

+ +Ultimate camera streaming application with support for dozens formats and protocols. + +- zero-dependency [small app](#go2rtc-binary) for all OS (Windows, macOS, Linux, FreeBSD) +- zero-delay for many [supported protocols](#codecs-madness) (lowest possible streaming latency) +- [streaming input](#streaming-input) from dozens formats and protocols +- [streaming output](#streaming-output) in all popular formats +- [streaming ingest](#streaming-ingest) in a number of popular formats +- [publish](#publish-stream) any source to popular streaming services (YouTube, Telegram) +- on-the-fly transcoding only if necessary via [FFmpeg](internal/ffmpeg/README.md) +- [two-way audio](#two-way-audio) support for many formats +- [streaming audio](#stream-to-camera) to all cameras with [two-way audio](#two-way-audio) support +- mixing tracks from different sources to single stream +- [auto-match](www/README.md#javascript-api) client-supported streaming formats and codecs +- [streaming stats](#streaming-stats) for all active connections +- can be [integrated to any project](#projects-using-go2rtc) or be used as [standalone app](#go2rtc-binary) + +#### Inspired by + +- series of streaming projects from [@deepch](https://github.com/deepch) +- [webrtc](https://github.com/pion/webrtc) go library and whole [@pion](https://github.com/pion) team +- [rtsp-simple-server](https://github.com/aler9/rtsp-simple-server) idea from [@aler9](https://github.com/aler9) +- [GStreamer](https://gstreamer.freedesktop.org/) framework pipeline idea +- [MediaSoup](https://mediasoup.org/) framework routing idea +- HomeKit Accessory Protocol from [@brutella](https://github.com/brutella/hap) +- creator of the project's logo [@v_novoseltsev](https://www.instagram.com/v_novoseltsev) + +
+
+Table of Contents + +- [Installation](#installation) + - [go2rtc: Binary](#go2rtc-binary) + - [go2rtc: Docker](#go2rtc-docker) + - [go2rtc: Home Assistant add-on](#go2rtc-home-assistant-add-on) + - [go2rtc: Home Assistant Integration](#go2rtc-home-assistant-integration) + - [go2rtc: Master version](#go2rtc-master-version) +- [Configuration](#configuration) +- [Features](#features) + - [Streaming input](#streaming-input) + - [Streaming output](#streaming-output) + - [Streaming ingest](#streaming-ingest) + - [Two-way audio](#two-way-audio) + - [Stream to camera](#stream-to-camera) + - [Publish stream](#publish-stream) + - [Preload stream](#preload-stream) + - [Streaming stats](#streaming-stats) +- [Codecs](#codecs) + - [Codecs filters](#codecs-filters) + - [Codecs madness](#codecs-madness) + - [Built-in transcoding](#built-in-transcoding) + - [Codecs negotiation](#codecs-negotiation) +- [Security](#security) +- [Projects using go2rtc](#projects-using-go2rtc) +- [Camera experience](#camera-experience) +- [Tips](#tips) + +
+ +## Installation + +1. Download [binary](#go2rtc-binary) or use [Docker](#go2rtc-docker) or Home Assistant [add-on](#go2rtc-home-assistant-add-on) or [integration](#go2rtc-home-assistant-integration) +2. Open web interface: `http://localhost:1984/` +3. Add [streams](#streaming-input) to [config](#configuration) + +**Developers:** integrate [HTTP API](internal/api/README.md) into your smart home platform. + +### go2rtc: Binary + +Download binary for your OS from [latest release](https://github.com/AlexxIT/go2rtc/releases/): + +| name | description | +|-----------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------| +| [go2rtc_win64.zip](https://github.com/AlexxIT/go2rtc/releases/latest/download/go2rtc_win64.zip) | Windows 10+ 64-bit | +| [go2rtc_win32.zip](https://github.com/AlexxIT/go2rtc/releases/latest/download/go2rtc_win32.zip) | Windows 10+ 32-bit | +| [go2rtc_win_arm64.zip](https://github.com/AlexxIT/go2rtc/releases/latest/download/go2rtc_win_arm64.zip) | Windows ARM 64-bit | +| [go2rtc_linux_amd64](https://github.com/AlexxIT/go2rtc/releases/latest/download/go2rtc_linux_amd64) | Linux 64-bit | +| [go2rtc_linux_i386](https://github.com/AlexxIT/go2rtc/releases/latest/download/go2rtc_linux_i386) | Linux 32-bit | +| [go2rtc_linux_arm64](https://github.com/AlexxIT/go2rtc/releases/latest/download/go2rtc_linux_arm64) | Linux ARM 64-bit (ex. Raspberry 64-bit OS) | +| [go2rtc_linux_arm](https://github.com/AlexxIT/go2rtc/releases/latest/download/go2rtc_linux_arm) | Linux ARM 32-bit (ex. Raspberry 32-bit OS) | +| [go2rtc_linux_armv6](https://github.com/AlexxIT/go2rtc/releases/latest/download/go2rtc_linux_armv6) | Linux ARMv6 (for old Raspberry 1 and Zero) | +| [go2rtc_linux_mipsel](https://github.com/AlexxIT/go2rtc/releases/latest/download/go2rtc_linux_mipsel) | Linux MIPS (ex. [Xiaomi Gateway 3](https://github.com/AlexxIT/XiaomiGateway3), [Wyze cameras](https://github.com/gtxaspec/wz_mini_hacks)) | +| [go2rtc_mac_amd64.zip](https://github.com/AlexxIT/go2rtc/releases/latest/download/go2rtc_mac_amd64.zip) | macOS 11+ Intel 64-bit | +| [go2rtc_mac_arm64.zip](https://github.com/AlexxIT/go2rtc/releases/latest/download/go2rtc_mac_arm64.zip) | macOS ARM 64-bit | +| [go2rtc_freebsd_amd64.zip](https://github.com/AlexxIT/go2rtc/releases/latest/download/go2rtc_freebsd_amd64.zip) | FreeBSD 64-bit | +| [go2rtc_freebsd_arm64.zip](https://github.com/AlexxIT/go2rtc/releases/latest/download/go2rtc_freebsd_arm64.zip) | FreeBSD ARM 64-bit | + +Don't forget to fix the rights `chmod +x go2rtc_xxx_xxx` on Linux and Mac. + +PS. The application is compiled with the latest versions of the Go language for maximum speed and security. Therefore, the [minimum OS versions](https://go.dev/wiki/MinimumRequirements) depend on the Go language. + +### go2rtc: Docker + +The Docker containers [`alexxit/go2rtc`](https://hub.docker.com/r/alexxit/go2rtc) and [`ghcr.io/alexxit/go2rtc`](https://github.com/AlexxIT/go2rtc/pkgs/container/go2rtc) support multiple architectures including `386`, `amd64`, `arm/v6`, `arm/v7` and `arm64`. +These containers offer the same functionality as the Home Assistant [add-on](#go2rtc-home-assistant-add-on) but are designed to operate independently of Home Assistant. +It comes preinstalled with [FFmpeg](internal/ffmpeg/README.md) and [Python](internal/echo/README.md). + +### go2rtc: Home Assistant add-on + +[![Open your Home Assistant instance and show the add add-on repository dialog with a specific repository URL pre-filled.](https://my.home-assistant.io/badges/supervisor_add_addon_repository.svg)](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https%3A%2F%2Fgithub.com%2FAlexxIT%2Fhassio-addons) + +1. Settings > Add-ons > Plus > Repositories > Add + ``` + https://github.com/AlexxIT/hassio-addons + ``` +2. go2rtc > Install > Start + +### go2rtc: Home Assistant Integration + +[WebRTC Camera](https://github.com/AlexxIT/WebRTC) custom component can be used on any Home Assistant [installation](https://www.home-assistant.io/installation/), including [HassWP](https://github.com/AlexxIT/HassWP) on Windows. It can automatically download and use the latest version of go2rtc. Or it can connect to an existing version of go2rtc. Addon installation in this case is optional. + +### go2rtc: Master version + +Latest, but maybe unstable version: + +- Binary: [latest master build](https://nightly.link/AlexxIT/go2rtc/workflows/build/master) +- Docker: `alexxit/go2rtc:master` or `alexxit/go2rtc:master-hardware` versions +- Home Assistant add-on: `go2rtc master` or `go2rtc master hardware` versions + +## Configuration + +This is the `go2rtc.yaml` file in [YAML-format](https://en.wikipedia.org/wiki/YAML). +The configuration can be changed in the [WebUI](www/README.md) at `http://localhost:1984`. +The editor provides syntax highlighting and checking. + +![go2rtc webui config](website/images/webui-config.png) + +The simplest config looks like this: + +```yaml +streams: + hall-camera: rtsp://admin:password@192.168.1.123/cam/realmonitor?channel=1&subtype=0 +``` + +- by default go2rtc will search `go2rtc.yaml` in the current work directory +- `api` server will start on default **1984 port** (TCP) +- `rtsp` server will start on default **8554 port** (TCP) +- `webrtc` will use port **8555** (TCP/UDP) for connections + +More information can be [found here](internal/app/README.md). + +## Features + +A summary table of all modules and features can be found [here](internal/README.md). + +**Core modules** + +- [`app`](internal/app/README.md) - Reading [configs](internal/app/README.md) and setting up [logs](internal/app/README.md#log). +- [`api`](internal/api/README.md) - Handle [HTTP](internal/api/README.md) and [WebSocket](internal/api/ws/README.md) API. +- [`streams`](internal/streams/README.md) - Handle a list of streams. + +### Streaming input + +#### public protocols + +- [`mpjpeg`](internal/mjpeg/README.md#mjpeg-client) - The legacy but still used [MJPEG](https://en.wikipedia.org/wiki/Motion_JPEG) protocol for real-time media transmission. +- [`onvif`](internal/onvif/README.md#onvif-client) - A popular [ONVIF](https://en.wikipedia.org/wiki/ONVIF) protocol for receiving media in RTSP format. +- [`rtmp`](internal/rtmp/README.md#rtmp-client) - The legacy but still used [RTMP](https://en.wikipedia.org/wiki/Real-Time_Messaging_Protocol) protocol for real-time media transmission. +- [`rtsp`](internal/rtsp/README.md#rtsp-client) - The most common [RTSP](https://en.wikipedia.org/wiki/Real-Time_Streaming_Protocol) protocol for real-time media transmission. +- [`webrtc`](internal/webrtc/README.md#webrtc-client) - [WebRTC](https://en.wikipedia.org/wiki/WebRTC) web-compatible protocol for real-time media transmission. +- [`yuv4mpegpipe`](internal/http/README.md#tcp) - Raw [YUV](https://en.wikipedia.org/wiki/Y%E2%80%B2UV) frame stream with [YUV4MPEG](https://manned.org/yuv4mpeg) header. + +#### private protocols + +- [`bubble`](internal/bubble/README.md) - Some NVR from [dvr163.com](http://help.dvr163.com/) and [eseecloud.com](http://www.eseecloud.com/). +- [`doorbird`](internal/doorbird/README.md) - [Doorbird](https://www.doorbird.com/) devices with two-way audio. +- [`dvrip`](internal/dvrip/README.md) - DVR-IP NVR, NetSurveillance, Sofia protocol (XMeye SDK). +- [`eseecloud`](internal/eseecloud/README.md) - Some NVR from [dvr163.com](http://help.dvr163.com/) and [eseecloud.com](http://www.eseecloud.com/). +- [`gopro`](internal/gopro/README.md) - [GoPro](https://gopro.com/) cameras, connected via USB or Wi-Fi. +- [`hass`](internal/hass/README.md) - Import cameras from [Home Assistant](https://www.home-assistant.io/) config files. +- [`homekit`](internal/homekit/README.md) - Cameras with [Apple HomeKit](https://www.apple.com/home-app/accessories/) protocol. +- [`isapi`](internal/isapi/README.md) - Two-way audio for [Hikvision ISAPI](https://tpp.hikvision.com/download/ISAPI_OTAP) protocol. +- [`kasa`](internal/kasa/README.md) - [TP-Link Kasa](https://www.kasasmart.com/) cameras. +- [`multitrans`](internal/multitrans/README.md) - Two-way audio for Chinese version of [TP-Link](https://www.tp-link.com.cn/) cameras. +- [`nest`](internal/nest/README.md) - [Google Nest](https://developers.google.com/nest/device-access/supported-devices) cameras through user-unfriendly and paid APIs. +- [`ring`](internal/ring/README.md) - Ring cameras with two-way audio support. +- [`roborock`](internal/roborock/README.md) - [Roborock](https://roborock.com/) vacuums with cameras with two-way audio support. +- [`tapo`](internal/tapo/README.md) - [TP-Link Tapo](https://www.tapo.com/) cameras with two-way audio support. +- [`vigi`](internal/tapo/README.md#tp-link-vigi) - TP-Link Vigi cameras. +- [`tuya`](internal/tuya/README.md) - [Tuya](https://www.tuya.com/) ecosystem cameras with two-way audio support. +- [`webtorrent`](internal/webtorrent/README.md) - Stream from another go2rtc via [WebTorrent](https://en.wikipedia.org/wiki/WebTorrent) protocol. +- [`wyze`](internal/wyze/README.md) - [Wyze](https://wyze.com/) cameras using native P2P protocol +- [`xiaomi`](internal/xiaomi/README.md) - [Xiaomi Mi Home](https://home.mi.com/) ecosystem cameras with two-way audio support. + +#### devices + +- [`alsa`](internal/alsa/README.md) - A [framework](https://en.wikipedia.org/wiki/Advanced_Linux_Sound_Architecture) for receiving audio from devices on Linux OS. +- [`v4l2`](internal/v4l2/README.md) - A [framework](https://en.wikipedia.org/wiki/Video4Linux) for receiving video from devices on Linux OS. + +#### files + +- [`adts`](internal/http/README.md#tcp) - Audio stream in [AAC](https://en.wikipedia.org/wiki/Advanced_Audio_Coding) codec with Audio Data Transport Stream headers. +- [`flv`](internal/http/README.md#tcp) - The legacy but still used [Flash Video](https://en.wikipedia.org/wiki/Flash_Video) format. +- [`h264`](internal/http/README.md#tcp) - AVC/H.264 bitstream. +- [`hevc`](internal/http/README.md#tcp) - HEVC/H.265 bitstream. +- [`hls`](internal/http/README.md) - A popular [HTTP Live Streaming](https://en.wikipedia.org/wiki/HTTP_Live_Streaming) format. +- [`mjpeg`](internal/http/README.md#tcp) - A continuous sequence of JPEG frames (without HTTP headers). +- [`mpegts`](internal/http/README.md#tcp) - The legacy [MPEG transport stream](https://en.wikipedia.org/wiki/MPEG_transport_stream) format. +- [`wav`](internal/http/README.md#tcp) - Audio stream in [Waveform Audio File](https://en.wikipedia.org/wiki/WAV) format. + +#### scripts + +- [`echo`](internal/echo/README.md) - If the source has a dynamic link, you can use a bash or python script to get it. +- [`exec`](internal/exec/README.md) - You can run an external application (`ffmpeg`, `gstreamer`, `rpicam`, etc.) and receive a media stream from it. +- [`expr`](internal/expr/README.md) - If the source has a dynamic link, you can use [Expr](https://github.com/expr-lang/expr) language to get it. +- [`ffmpeg`](internal/ffmpeg/README.md) - Use [FFmpeg](https://ffmpeg.org/) as a stream source. Hardware-accelerated transcoding and streaming from USB devices are supported. + +#### webrtc + +- [`creality`](internal/webrtc/README.md#creality) - [Creality](https://www.creality.com/) 3D printer cameras. +- [`kinesis`](internal/webrtc/README.md#kinesis) - [Amazon Kinesis](https://aws.amazon.com/kinesis/video-streams/) video streams. +- [`openipc`](internal/webrtc/README.md#openipc) - Cameras on open-source [OpenIPC](https://openipc.org/) firmware. +- [`switchbot`](internal/webrtc/README.md#switchbot) - [SwitchBot](https://us.switch-bot.com/) cameras. +- [`whep`](internal/webrtc/README.md#whep) - [WebRTC/WHEP](https://datatracker.ietf.org/doc/draft-murillo-whep/) is replaced by [WebRTC/WISH](https://datatracker.ietf.org/doc/charter-ietf-wish/02/) standard for WebRTC video/audio viewers. +- [`wyze`](internal/webrtc/README.md#wyze) - Legacy method to connect to [Wyze](https://www.wyze.com/) cameras via [docker-wyze-bridge](https://github.com/mrlt8/docker-wyze-bridge). + +### Streaming output + +- [`adts`](internal/mpeg/README.md) - Output stream in ADTS format with [AAC](https://en.wikipedia.org/wiki/Advanced_Audio_Coding) audio. +- [`ascii`](internal/mjpeg/README.md#ascii) - Just for fun stream as [ASCII to Terminal](https://www.youtube.com/watch?v=sHj_3h_sX7M). +- [`flv`](internal/rtmp/README.md) - Output stream in [Flash Video](https://en.wikipedia.org/wiki/Flash_Video) format. +- [`hls`](internal/hls/README.md) - Output stream in [HTTP Live Streaming](https://en.wikipedia.org/wiki/HTTP_Live_Streaming) format. +- [`homekit`](internal/homekit/README.md#homekit-server) - Output stream to [Apple Home](https://www.apple.com/home-app/) using [HomeKit](https://en.wikipedia.org/wiki/Apple_Home) protocol. +- [`jpeg`](internal/mjpeg/README.md#jpeg) - Output snapshots in [JPEG](https://en.wikipedia.org/wiki/JPEG) format. +- [`mpjpeg`](internal/mjpeg/README.md#mpjpeg) - Output a stream in [MJPEG](https://en.wikipedia.org/wiki/Motion_JPEG) format. +- [`mp4`](internal/mp4/README.md) - Output as [MP4 stream](https://en.wikipedia.org/wiki/Progressive_download) or [Media Source Extensions](https://developer.mozilla.org/en-US/docs/Web/API/Media_Source_Extensions_API) (MSE) compatible format. +- [`mpegts`](internal/mpeg/README.md) - Output stream in [MPEG transport stream](https://en.wikipedia.org/wiki/MPEG_transport_stream) format. +- [`onvif`](internal/onvif/README.md#onvif-server) - Output stream using [ONVIF](https://en.wikipedia.org/wiki/ONVIF) protocol. +- [`rtmp`](internal/rtmp/README.md#rtmp-server) - Output stream using [Real-Time Messaging](https://en.wikipedia.org/wiki/Real-Time_Messaging_Protocol) protocol. +- [`rtsp`](internal/rtsp/README.md#rtsp-server) - Output stream using [Real-Time Streaming](https://en.wikipedia.org/wiki/Real-Time_Streaming_Protocol) protocol. +- [`webrtc`](internal/webrtc/README.md#webrtc-server) - Output stream using [Web Real-Time Communication](https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API) API. +- [`webtorrent`](internal/webtorrent/README.md#webtorrent-server) - Output stream using [WebTorrent](https://en.wikipedia.org/wiki/WebTorrent) protocol. +- [`yuv4mpegpipe`](internal/mjpeg/README.md#yuv4mpegpipe) - Output in raw [YUV](https://en.wikipedia.org/wiki/Y%E2%80%B2UV) frame stream with [YUV4MPEG](https://manned.org/yuv4mpeg) header. + +### Streaming ingest + +Supported for: +[`flv`](internal/rtmp/README.md#flv-server), +[`mjpeg`](internal/mjpeg/README.md#streaming-ingest), +[`mpegts`](internal/mpeg/README.md#streaming-ingest), +[`rtmp`](internal/rtmp/README.md#rtmp-server), +[`rtsp`](internal/rtsp/README.md#streaming-ingest), +[`webrtc`](internal/webrtc/README.md#streaming-ingest). + +This is a feature when go2rtc expects to receive an incoming stream from an external application. The stream transmission is started and stopped by an external application. + +- You can push data only to an existing stream (create a stream with empty source in config). +- You can push multiple incoming sources to the same stream. +- You can push data to a non-empty stream, so it will have additional codecs inside. + +### Two-way audio + +Supported for: +[`doorbird`](internal/doorbird/README.md), +[`dvrip`](internal/dvrip/README.md), +[`exec`](internal/exec/README.md), +[`isapi`](internal/isapi/README.md), +[`multitrans`](internal/multitrans/README.md), +[`ring`](internal/ring/README.md), +[`roborock`](internal/roborock/README.md), +[`rtsp`](internal/rtsp/README.md#two-way-audio), +[`tapo`](internal/tapo/README.md), +[`tuya`](internal/tuya/README.md), +[`webrtc`](internal/webrtc/README.md), +[`wyze`](internal/wyze/README.md), +[`xiaomi`](internal/xiaomi/README.md). + +Two-way audio can be used in browser with [WebRTC](internal/webrtc/README.md) technology. The browser will give access to the microphone only for HTTPS sites ([read more](https://stackoverflow.com/questions/52759992/how-to-access-camera-and-microphone-in-chrome-without-https)). + +### Stream to camera + +You can play audio files or live streams on any camera with [two-way audio](#two-way-audio) support. + +[read more](internal/streams/README.md#stream-to-camera) + +### Publish stream + +You can publish any stream to streaming services (YouTube, Telegram, etc.) via RTMP/RTMPS. + +[read more](internal/streams/README.md#publish-stream) + +### Preload stream + +You can preload any stream on go2rtc start. This is useful for cameras that take a long time to start up. + +[read more](internal/streams/README.md#preload-stream) + +### Streaming stats + +[WebUI](www/README.md) provides detailed information about all active connections, including IP-addresses, formats, protocols, number of packets and bytes transferred. +Via the [HTTP API](internal/api/README.md) in [`json`](https://en.wikipedia.org/wiki/JSON) or [`dot`](https://en.wikipedia.org/wiki/DOT_(graph_description_language)) format on an interactive connection map. + +![go2rtc webui net](website/images/webui-net.png) + +## Codecs + +If you have questions about why video or audio is not displayed, you need to read the following sections. + +| Name | FFmpeg | RTSP | Aliases | +|------------------------------|----------|---------------|-------------| +| Advanced Audio Coding | `aac` | MPEG4-GENERIC | | +| Advanced Video Coding | `h264` | H264 | AVC, H.264 | +| G.711 PCM (A-law) | `alaw` | PCMA | G711A | +| G.711 PCM (µ-law) | `mulaw` | PCMU | G711u | +| High Efficiency Video Coding | `hevc` | H265 | HEVC, H.265 | +| Motion JPEG | `mpjpeg` | JPEG | | +| MPEG-1 Audio Layer III | `mp3` | MPA | | +| Opus Codec | `opus` | OPUS | | +| PCM signed 16-bit big-endian | `s16be` | L16 | | + +### Codecs filters + +go2rtc can automatically detect which codecs your device supports for [WebRTC](internal/webrtc/README.md) and [MSE](internal/mp4/README.md) technologies. + +But it cannot be done for [RTSP](internal/rtsp/README.md), [HTTP progressive streaming](internal/mp4/README.md), [HLS](internal/hls/README.md) technologies. +You can manually add a codec filter when you create a link to a stream. +The filters work the same for all three technologies. +Filters do not create a new codec, they only select the suitable codec from existing sources. +You can add new codecs to the stream using the [FFmpeg transcoding](internal/ffmpeg/README.md). + +Without filters: + +- RTSP will provide only the first video and only the first audio (any codec) +- MP4 will include only compatible codecs (H264, H265, AAC) +- HLS will output in the legacy TS format (H264 without audio) + +Some examples: + +- `rtsp://192.168.1.123:8554/camera1?mp4` - useful for recording as MP4 files (e.g. Home Assistant or Frigate) +- `rtsp://192.168.1.123:8554/camera1?video=h264,h265&audio=aac` - full version of the filter above +- `rtsp://192.168.1.123:8554/camera1?video=h264&audio=aac&audio=opus` - H264 video codec and two separate audio tracks +- `rtsp://192.168.1.123:8554/camera1?video&audio=all` - any video codec and all audio codecs as separate tracks +- `http://192.168.1.123:1984/api/stream.m3u8?src=camera1&mp4` - HLS stream with MP4 compatible codecs (HLS/fMP4) +- `http://192.168.1.123:1984/api/stream.m3u8?src=camera1&mp4=flac` - HLS stream with PCMA/PCMU/PCM audio support (HLS/fMP4), won't work on old devices +- `http://192.168.1.123:1984/api/stream.mp4?src=camera1&mp4=flac` - MP4 file with PCMA/PCMU/PCM audio support, won't work on old devices (ex. iOS 12) +- `http://192.168.1.123:1984/api/stream.mp4?src=camera1&mp4=all` - MP4 file with non-standard audio codecs, won't work on some players + +### Codecs madness + +`AVC/H.264` video can be played almost anywhere. But `HEVC/H.265` has many limitations in supporting different devices and browsers. + +| Device | WebRTC | MSE | HTTP* | HLS | +|--------------------------------------------------------------------|-----------------------------------------|-----------------------------------------|----------------------------------------------|-----------------------------| +| *latency* | best | medium | bad | bad | +| Desktop Chrome 136+
Desktop Edge
Android Chrome 136+ | H264, H265*
PCMU, PCMA
OPUS | H264, H265*
AAC, FLAC*
OPUS | H264, H265*
AAC, FLAC*
OPUS, MP3 | no | +| Desktop Firefox | H264
PCMU, PCMA
OPUS | H264
AAC, FLAC*
OPUS | H264
AAC, FLAC*
OPUS | no | +| Desktop Safari 14+
iPad Safari 14+
iPhone Safari 17.1+ | H264, H265*
PCMU, PCMA
OPUS | H264, H265
AAC, FLAC* | **no!** | H264, H265
AAC, FLAC* | +| iPhone Safari 14+ | H264, H265*
PCMU, PCMA
OPUS | **no!** | **no!** | H264, H265
AAC, FLAC* | +| macOS [Hass App][1] | no | no | no | H264, H265
AAC, FLAC* | + +[1]: https://apps.apple.com/app/home-assistant/id1099568401 + +- `HTTP*` - HTTP Progressive Streaming, not related to [progressive download](https://en.wikipedia.org/wiki/Progressive_download), because the file has no size and no end +- `WebRTC H265` - supported in [Chrome 136+](https://developer.chrome.com/release-notes/136), supported in [Safari 18+](https://developer.apple.com/documentation/safari-release-notes/safari-18-release-notes) +- `MSE iPhone` - supported in [iOS 17.1+](https://webkit.org/blog/14735/webkit-features-in-safari-17-1/) + +**Audio** + +- go2rtc supports [automatic repackaging](#built-in-transcoding) of `PCMA/PCMU/PCM` codecs into `FLAC` for MSE/MP4/HLS so they'll work almost anywhere +- **WebRTC** audio codecs: `PCMU/8000`, `PCMA/8000`, `OPUS/48000/2` +- `OPUS` and `MP3` inside **MP4** are part of the standard, but some players do not support them anyway (especially Apple) + +**Apple devices** + +- all Apple devices don't support HTTP progressive streaming +- old iPhone firmwares don't support MSE technology because it competes with the HTTP Live Streaming (HLS) technology, invented by Apple +- HLS is the worst technology for **live** streaming, it still exists only because of iPhones + +### Built-in transcoding + +There are no plans to embed complex transcoding algorithms inside go2rtc. +[FFmpeg source](internal/ffmpeg/README.md) does a great job with this. +Including [hardware acceleration](https://github.com/AlexxIT/go2rtc/wiki/Hardware-acceleration) support. + +But go2rtc has some simple algorithms. They are turned on automatically; you do not need to set them up additionally. + +**PCM for MSE/MP4/HLS** + +Go2rtc can pack `PCMA`, `PCMU` and `PCM` codecs into an MP4 container so that they work in all browsers and all built-in players on modern devices. Including Apple QuickTime: + +```text +PCMA/PCMU => PCM => FLAC => MSE/MP4/HLS +``` + +**Resample PCMA/PCMU for WebRTC** + +By default WebRTC supports only `PCMA/8000` and `PCMU/8000`. But go2rtc can automatically resample PCMA and PCMU codecs with a different sample rate. Also, go2rtc can transcode `PCM` codec to `PCMA/8000`, so WebRTC can play it: + +```text +PCM/xxx => PCMA/8000 => WebRTC +PCMA/xxx => PCMA/8000 => WebRTC +PCMU/xxx => PCMU/8000 => WebRTC +``` + +**Important** + +- FLAC codec not supported in an RTSP stream. If you are using Frigate or Home Assistant for recording MP4 files with PCMA/PCMU/PCM audio, you should set up transcoding to the AAC codec. +- PCMA and PCMU are VERY low-quality codecs. They support only 256! different sounds. Use them only when you have no other options. + +### Codecs negotiation + +For example, you want to watch an RTSP stream from a [Dahua IPC-K42](https://www.dahuasecurity.com/fr/products/All-Products/Network-Cameras/Wireless-Series/Wi-Fi-Series/4MP/IPC-K42) camera in your Chrome browser. + +- this camera supports two-way audio standard **ONVIF Profile T** +- this camera supports codecs **H264, H265** for sending video, and you select `H264` in camera settings +- this camera supports codecs **AAC, PCMU, PCMA** for sending audio (from mic), and you select `AAC/16000` in camera settings +- this camera supports codecs **AAC, PCMU, PCMA** for receiving audio (to speaker), you don't need to select them +- your browser supports codecs **H264, VP8, VP9, AV1** for receiving video, you don't need to select them +- your browser supports codecs **OPUS, PCMU, PCMA** for sending and receiving audio, you don't need to select them +- you can't get the camera audio directly because its audio codecs don't match your browser's codecs + - so you decide to use transcoding via FFmpeg and add this setting to the config YAML file + - you have chosen `OPUS/48000/2` codec, because it is higher quality than the `PCMU/8000` or `PCMA/8000` + +Now you have a stream with two sources - **RTSP and FFmpeg**: + +```yaml +streams: + dahua: + - rtsp://admin:password@192.168.1.123/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif + - ffmpeg:rtsp://admin:password@192.168.1.123/cam/realmonitor?channel=1&subtype=0#audio=opus +``` + +**go2rtc** automatically matches codecs for your browser across all of your stream sources. This is called **multi-source two-way codec negotiation**, and it's one of the main features of this app. + +**PS.** You can select `PCMU` or `PCMA` codec in camera settings and not use transcoding at all. Or you can select `AAC` codec for main stream and `PCMU` codec for second stream and add both RTSP to YAML config, this also will work fine. + +## Security + +> [!IMPORTANT] +> If an attacker gains access to the API, you are in danger. Through the API, an attacker can use insecure sources such as echo and exec. And get full access to your server. + +For maximum (paranoid) security, go2rtc has special settings: + +```yaml +app: + # use only allowed modules + modules: [api, rtsp, webrtc, exec, ffmpeg, mjpeg] + +api: + # use only allowed API paths + allow_paths: [/api, /api/streams, /api/webrtc, /api/frame.jpeg] + # enable auth for localhost (used together with username and password) + local_auth: true + +exec: + # use only allowed exec paths + allow_paths: [ffmpeg] +``` + +By default, `go2rtc` starts the Web interface on port `1984` and RTSP on port `8554`, as well as uses port `8555` for WebRTC connections. The three ports are accessible from your local network. So anyone on your local network can watch video from your cameras without authorization. The same rule applies to the Home Assistant add-on. + +This is not a problem if you trust your local network as much as I do. But you can change this behaviour with a `go2rtc.yaml` config: + +```yaml +api: + listen: "127.0.0.1:1984" # localhost + +rtsp: + listen: "127.0.0.1:8554" # localhost + +webrtc: + listen: ":8555" # external TCP/UDP port +``` + +- local access to RTSP is not a problem for [FFmpeg](internal/ffmpeg/README.md) integration, because it runs locally on your server +- local access to API is not a problem for the [Home Assistant add-on](#go2rtc-home-assistant-add-on), because Home Assistant runs locally on the same server, and the add-on web UI is protected with Home Assistant authorization ([Ingress feature](https://www.home-assistant.io/blog/2019/04/15/hassio-ingress/)) +- external access to WebRTC TCP port is not a problem, because it is used only for transmitting encrypted media data + - anyway you need to open this port to your local network and to the Internet for WebRTC to work + +If you need web interface protection without the Home Assistant add-on, you need to use a reverse proxy, like [Nginx](https://nginx.org/), [Caddy](https://caddyserver.com/), etc. + +PS. Additionally, WebRTC will try to use the 8555 UDP port to transmit encrypted media. It works without problems on the local network, and sometimes also works for external access, even if you haven't opened this port on your router ([read more](https://en.wikipedia.org/wiki/UDP_hole_punching)). But for stable external WebRTC access, you need to open the 8555 port on your router for both TCP and UDP. + +## Projects using go2rtc + +- [Home Assistant](https://www.home-assistant.io/) [2024.11+](https://www.home-assistant.io/integrations/go2rtc/) - top open-source smart home project +- [Frigate](https://frigate.video/) [0.12+](https://docs.frigate.video/guides/configuring_go2rtc/) - open-source NVR built around real-time AI object detection +- [Advanced Camera Card](https://github.com/dermotduffy/advanced-camera-card) - custom card for Home Assistant +- [OpenIPC](https://github.com/OpenIPC/firmware/tree/master/general/package/go2rtc) - alternative IP camera firmware from an open community +- [wz_mini_hacks](https://github.com/gtxaspec/wz_mini_hacks) - custom firmware for Wyze cameras +- [EufyP2PStream](https://github.com/oischinger/eufyp2pstream) - a small project that provides a video/audio stream from Eufy cameras that don't directly support RTSP +- [ioBroker.euSec](https://github.com/bropat/ioBroker.eusec) - [ioBroker](https://www.iobroker.net/) adapter for controlling Eufy security devices +- [MMM-go2rtc](https://github.com/Anonym-tsk/MMM-go2rtc) - MagicMirror² module +- [ring-mqtt](https://github.com/tsightler/ring-mqtt) - Ring-to-MQTT bridge +- [lightNVR](https://github.com/opensensor/lightNVR) + +**Distributions** + +- [Alpine Linux](https://pkgs.alpinelinux.org/packages?name=go2rtc) +- [Arch User Repository](https://linux-packages.com/aur/package/go2rtc) +- [Gentoo](https://github.com/inode64/inode64-overlay/tree/main/media-video/go2rtc) +- [NixOS](https://search.nixos.org/packages?query=go2rtc) +- [Proxmox Helper Scripts](https://github.com/community-scripts/ProxmoxVE/) +- [QNAP](https://www.myqnap.org/product/go2rtc/) +- [Synology NAS](https://synocommunity.com/package/go2rtc) +- [Unraid](https://unraid.net/community/apps?q=go2rtc) + +## Camera experience + +- [Dahua](https://www.dahuasecurity.com/) - reference implementation streaming protocols, a lot of settings, high stream quality, multiple streaming clients +- [EZVIZ](https://www.ezviz.com/) - awful RTSP protocol implementation, many bugs in SDP +- [Hikvision](https://www.hikvision.com/) - a lot of proprietary streaming technologies +- [Reolink](https://reolink.com/) - some models have an awful, unusable RTSP implementation and not the best RTMP alternative (I recommend that you contact Reolink support for new firmware), few settings +- [Sonoff](https://sonoff.tech/) - very low stream quality, no settings, not the best protocol implementation +- [TP-Link](https://www.tp-link.com/) - few streaming clients, packet loss? +- Cheap noname cameras, Wyze Cams, Xiaomi cameras with hacks (usually have `/live/ch00_1` in RTSP URL) - awful but usable RTSP protocol implementation, low stream quality, few settings, packet loss? + +## Tips + +**Using apps for low RTSP delay** + +- `ffplay -fflags nobuffer -flags low_delay "rtsp://192.168.1.123:8554/camera1"` +- VLC > Preferences > Input / Codecs > Default Caching Level: Lowest Latency + +**Snapshots to Telegram** + +[read more](https://github.com/AlexxIT/go2rtc/wiki/Snapshot-to-Telegram) diff --git a/installs_on_host/go2rtc/docker/Dockerfile b/installs_on_host/go2rtc/docker/Dockerfile new file mode 100644 index 0000000..9efded4 --- /dev/null +++ b/installs_on_host/go2rtc/docker/Dockerfile @@ -0,0 +1,55 @@ +# syntax=docker/dockerfile:labs + +# 0. Prepare images +ARG PYTHON_VERSION="3.13" +ARG GO_VERSION="1.25" + + +# 1. Build go2rtc binary +FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine AS build +ARG TARGETPLATFORM +ARG TARGETOS +ARG TARGETARCH + +ENV GOOS=${TARGETOS} +ENV GOARCH=${TARGETARCH} + +WORKDIR /build + +RUN apk add git + +# Cache dependencies +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/root/.cache/go-build go mod download + +COPY . . +RUN --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 go build -ldflags "-s -w" -trimpath + + +# 2. Final image +FROM python:${PYTHON_VERSION}-alpine AS base + +# Install ffmpeg, tini (for signal handling), +# and other common tools for the echo source. +# alsa-plugins-pulse for ALSA support (+0MB) +# font-droid for FFmpeg drawtext filter (+2MB) +RUN apk add --no-cache tini ffmpeg ffplay bash curl jq alsa-plugins-pulse font-droid + +# Hardware Acceleration for Intel CPU (+50MB) +ARG TARGETARCH + +RUN if [ "${TARGETARCH}" = "amd64" ]; then apk add --no-cache libva-intel-driver intel-media-driver; fi + +# Hardware: AMD and NVidia VAAPI (not sure about this) +# RUN libva-glx mesa-va-gallium +# Hardware: AMD and NVidia VDPAU (not sure about this) +# RUN libva-vdpau-driver mesa-vdpau-gallium (+150MB total) + +COPY --from=build /build/go2rtc /usr/local/bin/ + +EXPOSE 1984 8554 8555 8555/udp +ENTRYPOINT ["/sbin/tini", "--"] +VOLUME /config +WORKDIR /config + +CMD ["go2rtc", "-config", "/config/go2rtc.yaml"] diff --git a/installs_on_host/go2rtc/docker/README.md b/installs_on_host/go2rtc/docker/README.md new file mode 100644 index 0000000..41069ba --- /dev/null +++ b/installs_on_host/go2rtc/docker/README.md @@ -0,0 +1,54 @@ +# Docker + +Images are built automatically via [GitHub actions](https://github.com/AlexxIT/go2rtc/actions) and published on [Docker Hub](https://hub.docker.com/r/alexxit/go2rtc) and [GitHub](https://github.com/AlexxIT/go2rtc/pkgs/container/go2rtc). + +## Versions + +- `alexxit/go2rtc:latest` - latest release based on `alpine` (`amd64`, `386`, `arm/v6`, `arm/v7`, `arm64`) with support for hardware transcoding for Intel iGPU and Raspberry +- `alexxit/go2rtc:latest-hardware` - latest release based on `debian 13` (`amd64`) with support for hardware transcoding for Intel iGPU, AMD GPU and NVidia GPU +- `alexxit/go2rtc:latest-rockchip` - latest release based on `debian 12` (`arm64`) with support for hardware transcoding for Rockchip RK35xx +- `alexxit/go2rtc:master` - latest unstable version based on `alpine` +- `alexxit/go2rtc:master-hardware` - latest unstable version based on `debian 13` (`amd64`) +- `alexxit/go2rtc:master-rockchip` - latest unstable version based on `debian 12` (`arm64`) + +## Docker compose + +```yaml +services: + go2rtc: + image: alexxit/go2rtc + network_mode: host # important for WebRTC, HomeKit, UDP cameras + privileged: true # only for FFmpeg hardware transcoding + restart: unless-stopped # autorestart on fail or config change from WebUI + environment: + - TZ=Atlantic/Bermuda # timezone in logs + volumes: + - "~/go2rtc:/config" # folder for go2rtc.yaml file (edit from WebUI) +``` + +## Basic Deployment + +```bash +docker run -d \ + --name go2rtc \ + --network host \ + --privileged \ + --restart unless-stopped \ + -e TZ=Atlantic/Bermuda \ + -v ~/go2rtc:/config \ + alexxit/go2rtc +``` + +## Deployment with GPU Acceleration + +```bash +docker run -d \ + --name go2rtc \ + --network host \ + --privileged \ + --restart unless-stopped \ + -e TZ=Atlantic/Bermuda \ + --gpus all \ + -v ~/go2rtc:/config \ + alexxit/go2rtc:latest-hardware +``` diff --git a/installs_on_host/go2rtc/docker/hardware.Dockerfile b/installs_on_host/go2rtc/docker/hardware.Dockerfile new file mode 100644 index 0000000..563843b --- /dev/null +++ b/installs_on_host/go2rtc/docker/hardware.Dockerfile @@ -0,0 +1,60 @@ +# syntax=docker/dockerfile:labs + +# 0. Prepare images +# only debian 13 (trixie) has latest ffmpeg +# https://packages.debian.org/trixie/ffmpeg +ARG DEBIAN_VERSION="trixie-slim" +ARG GO_VERSION="1.25-bookworm" + + +# 1. Build go2rtc binary +FROM --platform=$BUILDPLATFORM golang:${GO_VERSION} AS build +ARG TARGETPLATFORM +ARG TARGETOS +ARG TARGETARCH + +ENV GOOS=${TARGETOS} +ENV GOARCH=${TARGETARCH} + +WORKDIR /build + +# Cache dependencies +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/root/.cache/go-build go mod download + +COPY . . +RUN --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 go build -ldflags "-s -w" -trimpath + + +# 2. Final image +FROM debian:${DEBIAN_VERSION} + +# Prepare apt for buildkit cache +RUN rm -f /etc/apt/apt.conf.d/docker-clean \ + && echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' >/etc/apt/apt.conf.d/keep-cache + +# Install ffmpeg, tini (for signal handling), +# and other common tools for the echo source. +# non-free for Intel QSV support (not used by go2rtc, just for tests) +# mesa-va-drivers for AMD APU +# libasound2-plugins for ALSA support +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked --mount=type=cache,target=/var/lib/apt,sharing=locked \ + echo 'deb http://deb.debian.org/debian trixie non-free' > /etc/apt/sources.list.d/debian-non-free.list && \ + apt-get -y update && apt-get -y install ffmpeg tini \ + python3 curl jq \ + intel-media-va-driver-non-free \ + mesa-va-drivers \ + libasound2-plugins && \ + apt-get clean && rm -rf /var/lib/apt/lists/* + +COPY --from=build /build/go2rtc /usr/local/bin/ + +EXPOSE 1984 8554 8555 8555/udp +ENTRYPOINT ["/usr/bin/tini", "--"] +VOLUME /config +WORKDIR /config +# https://github.com/NVIDIA/nvidia-docker/wiki/Installation-(Native-GPU-Support) +ENV NVIDIA_VISIBLE_DEVICES all +ENV NVIDIA_DRIVER_CAPABILITIES compute,video,utility + +CMD ["go2rtc", "-config", "/config/go2rtc.yaml"] diff --git a/installs_on_host/go2rtc/docker/rockchip.Dockerfile b/installs_on_host/go2rtc/docker/rockchip.Dockerfile new file mode 100644 index 0000000..6ab924e --- /dev/null +++ b/installs_on_host/go2rtc/docker/rockchip.Dockerfile @@ -0,0 +1,51 @@ +# syntax=docker/dockerfile:labs + +# 0. Prepare images +ARG PYTHON_VERSION="3.13-slim-bookworm" +ARG GO_VERSION="1.25-bookworm" + + +# 1. Build go2rtc binary +FROM --platform=$BUILDPLATFORM golang:${GO_VERSION} AS build +ARG TARGETPLATFORM +ARG TARGETOS +ARG TARGETARCH + +ENV GOOS=${TARGETOS} +ENV GOARCH=${TARGETARCH} + +WORKDIR /build + +# Cache dependencies +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/root/.cache/go-build go mod download + +COPY . . +RUN --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 go build -ldflags "-s -w" -trimpath + + +# 2. Final image +FROM python:${PYTHON_VERSION} + +# Prepare apt for buildkit cache +RUN rm -f /etc/apt/apt.conf.d/docker-clean \ + && echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' >/etc/apt/apt.conf.d/keep-cache + +# Install ffmpeg, tini (for signal handling), +# and other common tools for the echo source. +# libasound2-plugins for ALSA support +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get -y update && apt-get -y install tini \ + curl jq \ + libasound2-plugins && \ + apt-get clean && rm -rf /var/lib/apt/lists/* + +COPY --from=build /build/go2rtc /usr/local/bin/ +ADD --chmod=755 https://github.com/MarcA711/Rockchip-FFmpeg-Builds/releases/download/6.1-8-no_extra_dump/ffmpeg /usr/local/bin + +EXPOSE 1984 8554 8555 8555/udp +ENTRYPOINT ["/usr/bin/tini", "--"] +VOLUME /config +WORKDIR /config + +CMD ["go2rtc", "-config", "/config/go2rtc.yaml"] diff --git a/installs_on_host/go2rtc/examples/go2rtc_hass/main.go b/installs_on_host/go2rtc/examples/go2rtc_hass/main.go new file mode 100644 index 0000000..42c2d15 --- /dev/null +++ b/installs_on_host/go2rtc/examples/go2rtc_hass/main.go @@ -0,0 +1,20 @@ +package main + +import ( + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/hass" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/shell" +) + +func main() { + app.Init() + streams.Init() + + api.Init() + + hass.Init() + + shell.RunUntilSignal() +} diff --git a/installs_on_host/go2rtc/examples/go2rtc_mjpeg/main.go b/installs_on_host/go2rtc/examples/go2rtc_mjpeg/main.go new file mode 100644 index 0000000..3c915b3 --- /dev/null +++ b/installs_on_host/go2rtc/examples/go2rtc_mjpeg/main.go @@ -0,0 +1,26 @@ +package main + +import ( + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/api/ws" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/ffmpeg" + "github.com/AlexxIT/go2rtc/internal/mjpeg" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/internal/v4l2" + "github.com/AlexxIT/go2rtc/pkg/shell" +) + +func main() { + app.Init() + streams.Init() + + api.Init() + ws.Init() + + ffmpeg.Init() + mjpeg.Init() + v4l2.Init() + + shell.RunUntilSignal() +} diff --git a/installs_on_host/go2rtc/examples/go2rtc_rtsp/main.go b/installs_on_host/go2rtc/examples/go2rtc_rtsp/main.go new file mode 100644 index 0000000..07d3256 --- /dev/null +++ b/installs_on_host/go2rtc/examples/go2rtc_rtsp/main.go @@ -0,0 +1,17 @@ +package main + +import ( + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/rtsp" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/shell" +) + +func main() { + app.Init() + streams.Init() + + rtsp.Init() + + shell.RunUntilSignal() +} diff --git a/installs_on_host/go2rtc/examples/homekit_info/main.go b/installs_on_host/go2rtc/examples/homekit_info/main.go new file mode 100644 index 0000000..8527042 --- /dev/null +++ b/installs_on_host/go2rtc/examples/homekit_info/main.go @@ -0,0 +1,123 @@ +package main + +import ( + "encoding/json" + "os" + + "github.com/AlexxIT/go2rtc/pkg/hap" +) + +var servs = map[string]string{ + "3E": "Accessory Information", + "7E": "Security System", + "85": "Motion Sensor", + "96": "Battery", + "A2": "Protocol Information", + "110": "Camera RTP Stream Management", + "112": "Microphone", + "113": "Speaker", + "121": "Doorbell", + "129": "Data Stream Transport Management", + "204": "Camera Recording Management", + "21A": "Camera Operating Mode", + "22A": "Wi-Fi Transport", + "239": "Accessory Runtime Information", +} + +var chars = map[string]string{ + "14": "Identify", + "20": "Manufacturer", + "21": "Model", + "23": "Name", + "30": "Serial Number", + "52": "Firmware Revision", + "53": "Hardware Revision", + "220": "Product Data", + "A6": "Accessory Flags", + + "22": "Motion Detected", + "75": "Status Active", + + "11A": "Mute", + "119": "Volume", + + "B0": "Active", + "209": "Selected Camera Recording Configuration", + "207": "Supported Audio Recording Configuration", + "205": "Supported Camera Recording Configuration", + "206": "Supported Video Recording Configuration", + "226": "Recording Audio Active", + + "223": "Event Snapshots Active", + "225": "Periodic Snapshots Active", + "21B": "HomeKit Camera Active", + "21C": "Third Party Camera Active", + "21D": "Camera Operating Mode Indicator", + "11B": "Night Vision", + //"129": "Supported Data Stream Transport Configuration", + "37": "Version", + "131": "Setup Data Stream Transport", + "130": "Supported Data Stream Transport Configuration", + + "120": "Streaming Status", + "115": "Supported Audio Stream Configuration", + "116": "Supported RTP Configuration", + "114": "Supported Video Stream Configuration", + "117": "Selected RTP Stream Configuration", + "118": "Setup Endpoints", + + "22B": "Current Transport", + "22C": "Wi-Fi Capabilities", + "22D": "Wi-Fi Configuration Control", + + "23C": "Ping", + + "68": "Battery Level", + "79": "Status Low Battery", + "8F": "Charging State", + + "73": "Programmable Switch Event", + "232": "Operating State Response", + + "66": "Security System Current State", + "67": "Security System Target State", +} + +func main() { + src := os.Args[1] + dst := os.Args[2] + + f, err := os.Open(src) + if err != nil { + panic(err) + } + + var v hap.JSONAccessories + if err = json.NewDecoder(f).Decode(&v); err != nil { + panic(err) + } + + for _, acc := range v.Value { + for _, srv := range acc.Services { + if srv.Desc == "" { + srv.Desc = servs[srv.Type] + } + for _, chr := range srv.Characters { + if chr.Desc == "" { + chr.Desc = chars[chr.Type] + } + } + } + } + + f, err = os.Create(dst) + if err != nil { + panic(err) + } + + enc := json.NewEncoder(f) + enc.SetIndent("", " ") + if err = enc.Encode(v); err != nil { + panic(err) + } +} diff --git a/installs_on_host/go2rtc/examples/mdns/main.go b/installs_on_host/go2rtc/examples/mdns/main.go new file mode 100644 index 0000000..52f065a --- /dev/null +++ b/installs_on_host/go2rtc/examples/mdns/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "log" + "os" + + "github.com/AlexxIT/go2rtc/pkg/mdns" +) + +func main() { + var service = mdns.ServiceHAP + + if len(os.Args) >= 2 { + service = os.Args[1] + } + + onentry := func(entry *mdns.ServiceEntry) bool { + log.Printf("name=%s, addr=%s, info=%s\n", entry.Name, entry.Addr(), entry.Info) + return false + } + + var err error + + if len(os.Args) >= 3 { + host := os.Args[2] + + log.Printf("run discovery service=%s host=%s\n", service, host) + + err = mdns.QueryOrDiscovery(host, service, onentry) + } else { + log.Printf("run discovery service=%s\n", service) + + err = mdns.Discovery(service, onentry) + } + + if err != nil { + log.Println(err) + } +} diff --git a/installs_on_host/go2rtc/examples/mod_pinggy/go.mod b/installs_on_host/go2rtc/examples/mod_pinggy/go.mod new file mode 100644 index 0000000..893e601 --- /dev/null +++ b/installs_on_host/go2rtc/examples/mod_pinggy/go.mod @@ -0,0 +1,9 @@ +module pinggy + +go 1.25 + +require ( + github.com/Pinggy-io/pinggy-go/pinggy v0.6.9 // indirect + golang.org/x/crypto v0.8.0 // indirect + golang.org/x/sys v0.7.0 // indirect +) diff --git a/installs_on_host/go2rtc/examples/mod_pinggy/go.sum b/installs_on_host/go2rtc/examples/mod_pinggy/go.sum new file mode 100644 index 0000000..05298fb --- /dev/null +++ b/installs_on_host/go2rtc/examples/mod_pinggy/go.sum @@ -0,0 +1,39 @@ +github.com/Pinggy-io/pinggy-go/pinggy v0.6.9 h1:lzZ00JK6BUGQXnpkJZ+cVj8kIkXsmiVBUci9uEkSwEY= +github.com/Pinggy-io/pinggy-go/pinggy v0.6.9/go.mod h1:V1Sxb+4zyr36o9atZiqtT4XhsKtW1RSb2GvsbTbTJYw= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.8.0 h1:pd9TJtTueMTVQXzk8E2XESSMQDj/U7OUu0PqJqPXQjQ= +golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/installs_on_host/go2rtc/examples/mod_pinggy/main.go b/installs_on_host/go2rtc/examples/mod_pinggy/main.go new file mode 100644 index 0000000..f494263 --- /dev/null +++ b/installs_on_host/go2rtc/examples/mod_pinggy/main.go @@ -0,0 +1,41 @@ +package main + +import ( + "log" + "os" + + "github.com/Pinggy-io/pinggy-go/pinggy" +) + +func main() { + tunType := os.Args[1] + address := os.Args[2] + + log.SetFlags(log.Llongfile | log.LstdFlags) + + config := pinggy.Config{ + Type: pinggy.TunnelType(tunType), + TcpForwardingAddr: address, + + //SshOverSsl: true, + //Stdout: os.Stderr, + //Stderr: os.Stderr, + } + + if tunType == "http" { + hman := pinggy.CreateHeaderManipulationAndAuthConfig() + //hman.SetReverseProxy(address) + //hman.SetPassPreflight(true) + //hman.SetNoReverseProxy() + config.HeaderManipulationAndAuth = hman + } + + pl, err := pinggy.ConnectWithConfig(config) + if err != nil { + log.Panicln(err) + } + log.Println("Addrs: ", pl.RemoteUrls()) + //err = pl.InitiateWebDebug("localhost:3424") + //log.Println(err) + pl.StartForwarding() +} diff --git a/installs_on_host/go2rtc/examples/onvif_client/README.md b/installs_on_host/go2rtc/examples/onvif_client/README.md new file mode 100644 index 0000000..1fda07f --- /dev/null +++ b/installs_on_host/go2rtc/examples/onvif_client/README.md @@ -0,0 +1,5 @@ +## ONVIF Client + +```shell +go run examples/onvif_client/main.go http://admin:password@192.168.10.90 GetAudioEncoderConfigurations +``` \ No newline at end of file diff --git a/installs_on_host/go2rtc/examples/onvif_client/main.go b/installs_on_host/go2rtc/examples/onvif_client/main.go new file mode 100644 index 0000000..724d325 --- /dev/null +++ b/installs_on_host/go2rtc/examples/onvif_client/main.go @@ -0,0 +1,75 @@ +package main + +import ( + "log" + "net/url" + "os" + + "github.com/AlexxIT/go2rtc/pkg/onvif" +) + +func main() { + var rawURL = os.Args[1] + var operation = os.Args[2] + var token string + if len(os.Args) > 3 { + token = os.Args[3] + } + + client, err := onvif.NewClient(rawURL) + if err != nil { + log.Panic(err) + } + + var b []byte + + switch operation { + case onvif.ServiceGetServiceCapabilities: + b, err = client.MediaRequest(operation) + case onvif.DeviceGetCapabilities, + onvif.DeviceGetDeviceInformation, + onvif.DeviceGetDiscoveryMode, + onvif.DeviceGetDNS, + onvif.DeviceGetHostname, + onvif.DeviceGetNetworkDefaultGateway, + onvif.DeviceGetNetworkInterfaces, + onvif.DeviceGetNetworkProtocols, + onvif.DeviceGetNTP, + onvif.DeviceGetScopes, + onvif.DeviceGetServices, + onvif.DeviceGetSystemDateAndTime, + onvif.DeviceSystemReboot: + b, err = client.DeviceRequest(operation) + case onvif.MediaGetProfiles, + onvif.MediaGetVideoEncoderConfigurations, + onvif.MediaGetVideoSources, + onvif.MediaGetVideoSourceConfigurations, + onvif.MediaGetAudioEncoderConfigurations, + onvif.MediaGetAudioSources, + onvif.MediaGetAudioSourceConfigurations: + b, err = client.MediaRequest(operation) + case onvif.MediaGetProfile: + b, err = client.GetProfile(token) + case onvif.MediaGetVideoSourceConfiguration: + b, err = client.GetVideoSourceConfiguration(token) + case onvif.MediaGetStreamUri: + b, err = client.GetStreamUri(token) + case onvif.MediaGetSnapshotUri: + b, err = client.GetSnapshotUri(token) + default: + log.Printf("unknown action\n") + } + + if err != nil { + log.Printf("%s\n", err) + } + + u, err := url.Parse(rawURL) + if err != nil { + log.Fatal(err) + } + + if err = os.WriteFile(u.Hostname()+"_"+operation+".xml", b, 0644); err != nil { + log.Printf("%s\n", err) + } +} diff --git a/installs_on_host/go2rtc/examples/rtsp_client/main.go b/installs_on_host/go2rtc/examples/rtsp_client/main.go new file mode 100644 index 0000000..9c2112d --- /dev/null +++ b/installs_on_host/go2rtc/examples/rtsp_client/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "log" + "os" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/rtsp" + "github.com/AlexxIT/go2rtc/pkg/shell" +) + +func main() { + client := rtsp.NewClient(os.Args[1]) + if err := client.Dial(); err != nil { + log.Panic(err) + } + + client.Medias = []*core.Media{ + { + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + {Name: core.CodecPCMU, ClockRate: 8000}, + }, + ID: "streamid=0", + }, + } + if err := client.Announce(); err != nil { + log.Panic(err) + } + if _, err := client.SetupMedia(client.Medias[0]); err != nil { + log.Panic(err) + } + if err := client.Record(); err != nil { + log.Panic(err) + } + + shell.RunUntilSignal() +} diff --git a/installs_on_host/go2rtc/examples/tutk_decoder/README.md b/installs_on_host/go2rtc/examples/tutk_decoder/README.md new file mode 100644 index 0000000..197bd82 --- /dev/null +++ b/installs_on_host/go2rtc/examples/tutk_decoder/README.md @@ -0,0 +1,5 @@ +# tutk_decoder + +1. Wireshark > Select any packet > Follow > UDP Stream +2. Wireshark > File > Export Packet Dissections > As JSON > Displayed, Values +3. `tutk_decoder wireshark.json decoded.txt` diff --git a/installs_on_host/go2rtc/examples/tutk_decoder/main.go b/installs_on_host/go2rtc/examples/tutk_decoder/main.go new file mode 100644 index 0000000..0b6d90a --- /dev/null +++ b/installs_on_host/go2rtc/examples/tutk_decoder/main.go @@ -0,0 +1,82 @@ +package main + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "log" + "os" + "strings" + + "github.com/AlexxIT/go2rtc/pkg/tutk" +) + +func main() { + if len(os.Args) != 3 { + fmt.Println("Usage: tutk_decoder wireshark.json decoded.txt") + return + } + + src, err := os.Open(os.Args[1]) + if err != nil { + log.Fatal(err) + } + defer src.Close() + + dst, err := os.Create(os.Args[2]) + if err != nil { + log.Fatal(err) + } + defer dst.Close() + + var items []item + if err = json.NewDecoder(src).Decode(&items); err != nil { + log.Fatal(err) + } + + var b []byte + + for _, v := range items { + if v.Source.Layers.Data.DataData == "" { + continue + } + + s := strings.ReplaceAll(v.Source.Layers.Data.DataData, ":", "") + b, err = hex.DecodeString(s) + if err != nil { + log.Fatal(err) + } + + tutk.ReverseTransCodePartial(b, b) + + ts := v.Source.Layers.Frame.FrameTimeRelative + + _, _ = fmt.Fprintf(dst, "%8s: %s -> %s [%4d] %x\n", + ts[:len(ts)-6], + v.Source.Layers.Ip.IpSrc, v.Source.Layers.Ip.IpDst, + len(b), b) + } +} + +type item struct { + Source struct { + Layers struct { + Frame struct { + FrameTimeRelative string `json:"frame.time_relative"` + FrameNumber string `json:"frame.number"` + } `json:"frame"` + Ip struct { + IpSrc string `json:"ip.src"` + IpDst string `json:"ip.dst"` + } `json:"ip"` + Udp struct { + UdpSrcport string `json:"udp.srcport"` + UdpDstport string `json:"udp.dstport"` + } `json:"udp"` + Data struct { + DataData string `json:"data.data"` + DataLen string `json:"data.len"` + } `json:"data"` + } `json:"layers"` + } `json:"_source"` +} diff --git a/installs_on_host/go2rtc/go.mod b/installs_on_host/go2rtc/go.mod new file mode 100644 index 0000000..485509e --- /dev/null +++ b/installs_on_host/go2rtc/go.mod @@ -0,0 +1,52 @@ +module github.com/AlexxIT/go2rtc + +go 1.24.0 + +require ( + github.com/asticode/go-astits v1.14.0 + github.com/eclipse/paho.mqtt.golang v1.5.1 + github.com/expr-lang/expr v1.17.7 + github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 + github.com/mattn/go-isatty v0.0.20 + github.com/miekg/dns v1.1.70 + github.com/pion/dtls/v3 v3.0.10 + github.com/pion/ice/v4 v4.2.0 + github.com/pion/interceptor v0.1.43 + github.com/pion/rtcp v1.2.16 + github.com/pion/rtp v1.10.0 + github.com/pion/sdp/v3 v3.0.17 + github.com/pion/srtp/v3 v3.0.10 + github.com/pion/stun/v3 v3.1.1 + github.com/pion/webrtc/v4 v4.2.3 + github.com/rs/zerolog v1.34.0 + github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1 + github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f + github.com/stretchr/testify v1.11.1 + github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 + golang.org/x/crypto v0.47.0 + golang.org/x/net v0.49.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/asticode/go-astikit v0.57.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/pion/datachannel v1.6.0 // indirect + github.com/pion/logging v0.2.4 // indirect + github.com/pion/mdns/v2 v2.1.0 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/sctp v1.9.2 // indirect + github.com/pion/transport/v3 v3.1.1 // indirect + github.com/pion/transport/v4 v4.0.1 // indirect + github.com/pion/turn/v4 v4.1.4 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/wlynxg/anet v0.0.5 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/time v0.14.0 // indirect + golang.org/x/tools v0.41.0 // indirect +) diff --git a/installs_on_host/go2rtc/go.sum b/installs_on_host/go2rtc/go.sum new file mode 100644 index 0000000..897bb8a --- /dev/null +++ b/installs_on_host/go2rtc/go.sum @@ -0,0 +1,152 @@ +github.com/asticode/go-astikit v0.30.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0= +github.com/asticode/go-astikit v0.57.1 h1:fEykwH98Nny08kcRbk4uer+S8h0rKveCIpG9F6NVLuA= +github.com/asticode/go-astikit v0.57.1/go.mod h1:fV43j20UZYfXzP9oBn33udkvCvDvCDhzjVqoLFuuYZE= +github.com/asticode/go-astits v1.14.0 h1:zkgnZzipx2XX5mWycqsSBeEyDH58+i4HtyF4j2ROb00= +github.com/asticode/go-astits v1.14.0/go.mod h1:QSHmknZ51pf6KJdHKZHJTLlMegIrhega3LPWz3ND/iI= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE= +github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= +github.com/expr-lang/expr v1.17.6 h1:1h6i8ONk9cexhDmowO/A64VPxHScu7qfSl2k8OlINec= +github.com/expr-lang/expr v1.17.6/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= +github.com/expr-lang/expr v1.17.7 h1:Q0xY/e/2aCIp8g9s/LGvMDCC5PxYlvHgDZRQ4y16JX8= +github.com/expr-lang/expr v1.17.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/miekg/dns v1.1.69 h1:Kb7Y/1Jo+SG+a2GtfoFUfDkG//csdRPwRLkCsxDG9Sc= +github.com/miekg/dns v1.1.69/go.mod h1:7OyjD9nEba5OkqQ/hB4fy3PIoxafSZJtducccIelz3g= +github.com/miekg/dns v1.1.70 h1:DZ4u2AV35VJxdD9Fo9fIWm119BsQL5cZU1cQ9s0LkqA= +github.com/miekg/dns v1.1.70/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o= +github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= +github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0= +github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk= +github.com/pion/dtls/v3 v3.0.9 h1:4AijfFRm8mAjd1gfdlB1wzJF3fjjR/VPIpJgkEtvYmM= +github.com/pion/dtls/v3 v3.0.9/go.mod h1:abApPjgadS/ra1wvUzHLc3o2HvoxppAh+NZkyApL4Os= +github.com/pion/dtls/v3 v3.0.10 h1:k9ekkq1kaZoxnNEbyLKI8DI37j/Nbk1HWmMuywpQJgg= +github.com/pion/dtls/v3 v3.0.10/go.mod h1:YEmmBYIoBsY3jmG56dsziTv/Lca9y4Om83370CXfqJ8= +github.com/pion/ice/v4 v4.1.0 h1:YlxIii2bTPWyC08/4hdmtYq4srbrY0T9xcTsTjldGqU= +github.com/pion/ice/v4 v4.1.0/go.mod h1:5gPbzYxqenvn05k7zKPIZFuSAufolygiy6P1U9HzvZ4= +github.com/pion/ice/v4 v4.2.0 h1:jJC8S+CvXCCvIQUgx+oNZnoUpt6zwc34FhjWwCU4nlw= +github.com/pion/ice/v4 v4.2.0/go.mod h1:EgjBGxDgmd8xB0OkYEVFlzQuEI7kWSCFu+mULqaisy4= +github.com/pion/interceptor v0.1.42 h1:0/4tvNtruXflBxLfApMVoMubUMik57VZ+94U0J7cmkQ= +github.com/pion/interceptor v0.1.42/go.mod h1:g6XYTChs9XyolIQFhRHOOUS+bGVGLRfgTCUzH29EfVU= +github.com/pion/interceptor v0.1.43 h1:6hmRfnmjogSs300xfkR0JxYFZ9k5blTEvCD7wxEDuNQ= +github.com/pion/interceptor v0.1.43/go.mod h1:BSiC1qKIJt1XVr3l3xQ2GEmCFStk9tx8fwtCZxxgR7M= +github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= +github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= +github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY= +github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A= +github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= +github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo= +github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo= +github.com/pion/rtp v1.8.26 h1:VB+ESQFQhBXFytD+Gk8cxB6dXeVf2WQzg4aORvAvAAc= +github.com/pion/rtp v1.8.26/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM= +github.com/pion/rtp v1.10.0 h1:XN/xca4ho6ZEcijpdF2VGFbwuHUfiIMf3ew8eAAE43w= +github.com/pion/rtp v1.10.0/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM= +github.com/pion/sctp v1.8.41 h1:20R4OHAno4Vky3/iE4xccInAScAa83X6nWUfyc65MIs= +github.com/pion/sctp v1.8.41/go.mod h1:2wO6HBycUH7iCssuGyc2e9+0giXVW0pyCv3ZuL8LiyY= +github.com/pion/sctp v1.9.2 h1:HxsOzEV9pWoeggv7T5kewVkstFNcGvhMPx0GvUOUQXo= +github.com/pion/sctp v1.9.2/go.mod h1:OTOlsQ5EDQ6mQ0z4MUGXt2CgQmKyafBEXhUVqLRB6G8= +github.com/pion/sdp/v3 v3.0.16 h1:0dKzYO6gTAvuLaAKQkC02eCPjMIi4NuAr/ibAwrGDCo= +github.com/pion/sdp/v3 v3.0.16/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo= +github.com/pion/sdp/v3 v3.0.17 h1:9SfLAW/fF1XC8yRqQ3iWGzxkySxup4k4V7yN8Fs8nuo= +github.com/pion/sdp/v3 v3.0.17/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo= +github.com/pion/srtp/v3 v3.0.9 h1:lRGF4G61xxj+m/YluB3ZnBpiALSri2lTzba0kGZMrQY= +github.com/pion/srtp/v3 v3.0.9/go.mod h1:E+AuWd7Ug2Fp5u38MKnhduvpVkveXJX6J4Lq4rxUYt8= +github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ= +github.com/pion/srtp/v3 v3.0.10/go.mod h1:3mOTIB0cq9qlbn59V4ozvv9ClW/BSEbRp4cY0VtaR7M= +github.com/pion/stun/v3 v3.0.2 h1:BJuGEN2oLrJisiNEJtUTJC4BGbzbfp37LizfqswblFU= +github.com/pion/stun/v3 v3.0.2/go.mod h1:JFJKfIWvt178MCF5H/YIgZ4VX3LYE77vca4b9HP60SA= +github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw= +github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM= +github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM= +github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= +github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o= +github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM= +github.com/pion/turn/v4 v4.1.3 h1:jVNW0iR05AS94ysEtvzsrk3gKs9Zqxf6HmnsLfRvlzA= +github.com/pion/turn/v4 v4.1.3/go.mod h1:TD/eiBUf5f5LwXbCJa35T7dPtTpCHRJ9oJWmyPLVT3A= +github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ= +github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ= +github.com/pion/webrtc/v4 v4.1.8 h1:ynkjfiURDQ1+8EcJsoa60yumHAmyeYjz08AaOuor+sk= +github.com/pion/webrtc/v4 v4.1.8/go.mod h1:KVaARG2RN0lZx0jc7AWTe38JpPv+1/KicOZ9jN52J/s= +github.com/pion/webrtc/v4 v4.2.3 h1:RtdWDnkenNQGxUrZqWa5gSkTm5ncsLg5d+zu0M4cXt4= +github.com/pion/webrtc/v4 v4.2.3/go.mod h1:7vsyFzRzaKP5IELUnj8zLcglPyIT6wWwqTppBZ1k6Kc= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1 h1:NVK+OqnavpyFmUiKfUMHrpvbCi2VFoWTrcpI7aDaJ2I= +github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1/go.mod h1:9/etS5gpQq9BJsJMWg1wpLbfuSnkm8dPF6FdW2JXVhA= +github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f h1:1R9KdKjCNSd7F8iGTxIpoID9prlYH8nuNYKt0XvweHA= +github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f/go.mod h1:vQhwQ4meQEDfahT5kd61wLAF5AAeh5ZPLVI4JJ/tYo8= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 h1:aeN+ghOV0b2VCmKKO3gqnDQ8mLbpABZgRR2FVYx4ouI= +github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9/go.mod h1:roo6cZ/uqpwKMuvPG0YmzI5+AmUiMWfjCBZpGXqbTxE= +github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= +github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/installs_on_host/go2rtc/internal/README.md b/installs_on_host/go2rtc/internal/README.md new file mode 100644 index 0000000..f3e9f3b --- /dev/null +++ b/installs_on_host/go2rtc/internal/README.md @@ -0,0 +1,113 @@ +# Modules + +go2rtc tries to name formats, protocols and codecs the same way they are named in FFmpeg. +Some formats and protocols go2rtc supports exclusively. They have no equivalent in FFmpeg. + +- The [`echo`], [`expr`], [`hass`] and [`onvif`] modules receive a link to a stream. They don't know the protocol in advance. +- The [`exec`] and [`ffmpeg`] modules support many formats. They are identical to the [`http`] module. +- The [`api`], [`app`], [`debug`], [`ngrok`], [`pinggy`], [`srtp`], [`streams`] are supporting modules. + +**Modules** implement communication APIs: authorization, encryption, command set, structure of media packets. + +**Formats** describe the structure of the data being transmitted. + +**Protocols** implement transport for data transmission. + +| module | formats | protocols | input | output | ingest | two-way | +|----------------|-----------------|------------------|-------|--------|--------|---------| +| [`alsa`] | `pcm` | `ioctl` | yes | | | | +| [`bubble`] | - | `http` | yes | | | | +| [`doorbird`] | `mulaw` | `http` | yes | | | yes | +| [`dvrip`] | - | `tcp` | yes | | | yes | +| [`echo`] | * | * | yes | | | | +| [`eseecloud`] | `rtp` | `http` | yes | | | | +| [`exec`] | * | `pipe`, `rtsp` | yes | | | yes | +| [`expr`] | * | * | yes | | | | +| [`ffmpeg`] | * | `pipe`, `rtsp` | yes | | | | +| [`flussonic`] | `mp4` | `ws` | yes | | | | +| [`gopro`] | `mpegts` | `udp` | yes | | | | +| [`hass`] | * | * | yes | | | | +| [`hls`] | `mpegts`, `mp4` | `http` | | yes | | | +| [`homekit`] | `srtp` | `hap` | yes | yes | | no | +| [`http`] | `adts` | `http`, `tcp` | yes | | | | +| [`http`] | `flv` | `http`, `tcp` | yes | | | | +| [`http`] | `h264` | `http`, `tcp` | yes | | | | +| [`http`] | `hevc` | `http`, `tcp` | yes | | | | +| [`http`] | `hls` | `http`, `tcp` | yes | | | | +| [`http`] | `mjpeg` | `http`, `tcp` | yes | | | | +| [`http`] | `mpjpeg` | `http` | yes | | | | +| [`http`] | `mpegts` | `http`, `tcp` | yes | | | | +| [`http`] | `wav` | `http`, `tcp` | yes | | | | +| [`http`] | `yuv4mpegpipe` | `http`, `tcp` | yes | | | | +| [`isapi`] | `alaw`, `mulaw` | `http` | | | | yes | +| [`ivideon`] | `mp4` | `ws` | yes | | | | +| [`kasa`] | `h264`, `mulaw` | `http` | yes | | | | +| [`mjpeg`] | `ascii` | `http` | | yes | | | +| [`mjpeg`] | `jpeg` | `http` | | yes | | | +| [`mjpeg`] | `mpjpeg` | `http` | | yes | yes | | +| [`mjpeg`] | `yuv4mpegpipe` | `http` | | yes | | | +| [`mp4`] | `mp4` | `http`, `ws` | | yes | | | +| [`mpeg`] | `adts` | `http` | | yes | | | +| [`mpeg`] | `mpegts` | `http` | | yes | yes | | +| [`multitrans`] | `rtp` | `tcp` | | | | yes | +| [`nest`] | `srtp` | `rtsp`, `webrtc` | yes | | | no | +| [`onvif`] | `rtp` | * | yes | yes | | | +| [`ring`] | `srtp` | `webrtc` | yes | | | yes | +| [`roborock`] | `srtp` | `webrtc` | yes | | | yes | +| [`rtmp`] | `flv` | `rtmp` | yes | yes | yes | | +| [`rtmp`] | `flv` | `http` | | yes | yes | | +| [`rtsp`] | `rtsp` | `rtsp` | yes | yes | yes | yes | +| [`tapo`] | `mpegts` | `http` | yes | | | yes | +| [`tuya`] | `srtp` | `webrtc` | yes | | | yes | +| [`v4l2`] | `rawvideo` | `ioctl` | yes | | | | +| [`webrtc`] | `srtp` | `webrtc` | yes | yes | yes | yes | +| [`webtorrent`] | `srtp` | `webrtc` | yes | yes | | | +| [`wyoming`] | `pcm` | `tcp` | | yes | | | +| [`wyze`] | - | `tutk` | yes | | | yes | +| [`xiaomi`] | - | `cs2`, `tutk` | yes | | | yes | +| [`yandex`] | `srtp` | `webrtc` | yes | | | | + +[`alsa`]: alsa/README.md +[`api`]: api/README.md +[`app`]: app/README.md +[`bubble`]: bubble/README.md +[`debug`]: debug/README.md +[`doorbird`]: doorbird/README.md +[`dvrip`]: dvrip/README.md +[`echo`]: echo/README.md +[`eseecloud`]: eseecloud/README.md +[`exec`]: exec/README.md +[`expr`]: expr/README.md +[`ffmpeg`]: ffmpeg/README.md +[`flussonic`]: flussonic/README.md +[`gopro`]: gopro/README.md +[`hass`]: hass/README.md +[`hls`]: hls/README.md +[`homekit`]: homekit/README.md +[`http`]: http/README.md +[`isapi`]: isapi/README.md +[`ivideon`]: ivideon/README.md +[`kasa`]: kasa/README.md +[`mjpeg`]: mjpeg/README.md +[`mp4`]: mp4/README.md +[`mpeg`]: mpeg/README.md +[`multitrans`]: multitrans/README.md +[`nest`]: nest/README.md +[`ngrok`]: ngrok/README.md +[`onvif`]: onvif/README.md +[`pinggy`]: pinggy/README.md +[`ring`]: ring/README.md +[`roborock`]: roborock/README.md +[`rtmp`]: rtmp/README.md +[`rtsp`]: rtsp/README.md +[`srtp`]: srtp/README.md +[`streams`]: streams/README.md +[`tapo`]: tapo/README.md +[`tuya`]: tuya/README.md +[`v4l2`]: v4l2/README.md +[`webrtc`]: webrtc/README.md +[`webtorrent`]: webtorrent/README.md +[`wyoming`]: wyze/README.md +[`wyze`]: wyze/README.md +[`xiaomi`]: xiaomi/README.md +[`yandex`]: yandex/README.md diff --git a/installs_on_host/go2rtc/internal/alsa/README.md b/installs_on_host/go2rtc/internal/alsa/README.md new file mode 100644 index 0000000..3e0b853 --- /dev/null +++ b/installs_on_host/go2rtc/internal/alsa/README.md @@ -0,0 +1,12 @@ +# ALSA + +[`new in v1.9.10`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.10) + +> [!WARNING] +> This source is under development and does not always work well. + +[Advanced Linux Sound Architecture](https://en.wikipedia.org/wiki/Advanced_Linux_Sound_Architecture) - a framework for receiving audio from devices on Linux OS. + +Easy to add via **WebUI > add > ALSA**. + +Alternatively, you can use FFmpeg source. diff --git a/installs_on_host/go2rtc/internal/alsa/alsa.go b/installs_on_host/go2rtc/internal/alsa/alsa.go new file mode 100644 index 0000000..7886c74 --- /dev/null +++ b/installs_on_host/go2rtc/internal/alsa/alsa.go @@ -0,0 +1,7 @@ +//go:build !(linux && (386 || amd64 || arm || arm64 || mipsle)) + +package alsa + +func Init() { + // not supported +} diff --git a/installs_on_host/go2rtc/internal/alsa/alsa_linux.go b/installs_on_host/go2rtc/internal/alsa/alsa_linux.go new file mode 100644 index 0000000..316a759 --- /dev/null +++ b/installs_on_host/go2rtc/internal/alsa/alsa_linux.go @@ -0,0 +1,83 @@ +//go:build linux && (386 || amd64 || arm || arm64 || mipsle) + +package alsa + +import ( + "fmt" + "net/http" + "os" + "strconv" + "strings" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/alsa" + "github.com/AlexxIT/go2rtc/pkg/alsa/device" +) + +func Init() { + streams.HandleFunc("alsa", alsa.Open) + + api.HandleFunc("api/alsa", apiAlsa) +} + +func apiAlsa(w http.ResponseWriter, r *http.Request) { + files, err := os.ReadDir("/dev/snd/") + if err != nil { + return + } + + var sources []*api.Source + + for _, file := range files { + if !strings.HasPrefix(file.Name(), "pcm") { + continue + } + + path := "/dev/snd/" + file.Name() + + dev, err := device.Open(path) + if err != nil { + continue + } + + info, err := dev.Info() + if err == nil { + formats := formatsToString(dev.ListFormats()) + r1, r2 := dev.RangeRates() + c1, c2 := dev.RangeChannels() + source := &api.Source{ + Name: info.ID, + Info: fmt.Sprintf("Formats: %s, Rates: %d-%d, Channels: %d-%d", formats, r1, r2, c1, c2), + URL: "alsa:device?audio=" + path, + } + if !strings.Contains(source.Name, info.Name) { + source.Name += ", " + info.Name + } + sources = append(sources, source) + } + + _ = dev.Close() + } + + api.ResponseSources(w, sources) +} + +func formatsToString(formats []byte) string { + var s string + for i, format := range formats { + if i > 0 { + s += " " + } + switch format { + case 2: + s += "s16le" + case 10: + s += "s32le" + default: + s += strconv.Itoa(int(format)) + } + + } + return s +} diff --git a/installs_on_host/go2rtc/internal/api/README.md b/installs_on_host/go2rtc/internal/api/README.md new file mode 100644 index 0000000..ca01fb6 --- /dev/null +++ b/installs_on_host/go2rtc/internal/api/README.md @@ -0,0 +1,45 @@ +# HTTP API + +The HTTP API is the main part for interacting with the application. Default address: `http://localhost:1984/`. + +The HTTP API is described in [OpenAPI](../../website/api/openapi.yaml) format. It can be explored in [interactive viewer](https://go2rtc.org/api/). WebSocket API described [here](ws/README.md). + +The project's static HTML and JS files are located in the [www](../../www/README.md) folder. An external developer can use them as a basis for integrating go2rtc into their project or for developing a custom web interface for go2rtc. + +The contents of `www` folder are built into go2rtc when building, but you can use configuration to specify an external folder as the source of static files. + +## Configuration + +**Important!** go2rtc passes requests from localhost and Unix sockets without HTTP authorization, even if you have it configured. It is your responsibility to set up secure external access to the API. If not properly configured, an attacker can gain access to your cameras and even your server. + +- you can disable HTTP API with `listen: ""` and use, for example, only RTSP client/server protocol +- you can enable HTTP API only on localhost with `listen: "127.0.0.1:1984"` setting +- you can change the API `base_path` and host go2rtc on your main app webserver suburl +- all files from `static_dir` hosted on root path: `/` +- you can use raw TLS cert/key content or path to files + +```yaml +api: + listen: ":1984" # default ":1984", HTTP API port ("" - disabled) + username: "admin" # default "", Basic auth for WebUI + password: "pass" # default "", Basic auth for WebUI + local_auth: true # default false, Enable auth check for localhost requests + base_path: "/rtc" # default "", API prefix for serving on suburl (/api => /rtc/api) + static_dir: "www" # default "", folder for static files (custom web interface) + origin: "*" # default "", allow CORS requests (only * supported) + tls_listen: ":443" # default "", enable HTTPS server + tls_cert: | # default "", PEM-encoded fullchain certificate for HTTPS + -----BEGIN CERTIFICATE----- + ... + -----END CERTIFICATE----- + tls_key: | # default "", PEM-encoded private key for HTTPS + -----BEGIN PRIVATE KEY----- + ... + -----END PRIVATE KEY----- + unix_listen: "/tmp/go2rtc.sock" # default "", unix socket listener for API +``` + +**PS:** + +- MJPEG over WebSocket plays better than native MJPEG because Chrome [bug](https://bugs.chromium.org/p/chromium/issues/detail?id=527446) +- MP4 over WebSocket was created only for Apple iOS because it doesn't support file streaming diff --git a/installs_on_host/go2rtc/internal/api/api.go b/installs_on_host/go2rtc/internal/api/api.go new file mode 100644 index 0000000..dfb6511 --- /dev/null +++ b/installs_on_host/go2rtc/internal/api/api.go @@ -0,0 +1,321 @@ +package api + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "slices" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/rs/zerolog" +) + +func Init() { + var cfg struct { + Mod struct { + Listen string `yaml:"listen"` + Username string `yaml:"username"` + Password string `yaml:"password"` + LocalAuth bool `yaml:"local_auth"` + BasePath string `yaml:"base_path"` + StaticDir string `yaml:"static_dir"` + Origin string `yaml:"origin"` + TLSListen string `yaml:"tls_listen"` + TLSCert string `yaml:"tls_cert"` + TLSKey string `yaml:"tls_key"` + UnixListen string `yaml:"unix_listen"` + + AllowPaths []string `yaml:"allow_paths"` + } `yaml:"api"` + } + + // default config + cfg.Mod.Listen = ":1984" + + // load config from YAML + app.LoadConfig(&cfg) + + if cfg.Mod.Listen == "" && cfg.Mod.UnixListen == "" && cfg.Mod.TLSListen == "" { + return + } + + allowPaths = cfg.Mod.AllowPaths + basePath = cfg.Mod.BasePath + log = app.GetLogger("api") + + initStatic(cfg.Mod.StaticDir) + + HandleFunc("api", apiHandler) + HandleFunc("api/config", configHandler) + HandleFunc("api/exit", exitHandler) + HandleFunc("api/restart", restartHandler) + HandleFunc("api/log", logHandler) + + Handler = http.DefaultServeMux // 4th + + if cfg.Mod.Origin == "*" { + Handler = middlewareCORS(Handler) // 3rd + } + + if cfg.Mod.Username != "" { + Handler = middlewareAuth(cfg.Mod.Username, cfg.Mod.Password, cfg.Mod.LocalAuth, Handler) // 2nd + } + + if log.Trace().Enabled() { + Handler = middlewareLog(Handler) // 1st + } + + if cfg.Mod.Listen != "" { + _, port, _ := net.SplitHostPort(cfg.Mod.Listen) + Port, _ = strconv.Atoi(port) + go listen("tcp", cfg.Mod.Listen) + } + + if cfg.Mod.UnixListen != "" { + _ = syscall.Unlink(cfg.Mod.UnixListen) + go listen("unix", cfg.Mod.UnixListen) + } + + // Initialize the HTTPS server + if cfg.Mod.TLSListen != "" && cfg.Mod.TLSCert != "" && cfg.Mod.TLSKey != "" { + go tlsListen("tcp", cfg.Mod.TLSListen, cfg.Mod.TLSCert, cfg.Mod.TLSKey) + } +} + +func listen(network, address string) { + ln, err := net.Listen(network, address) + if err != nil { + log.Error().Err(err).Msg("[api] listen") + return + } + + log.Info().Str("addr", address).Msg("[api] listen") + + server := http.Server{ + Handler: Handler, + ReadHeaderTimeout: 5 * time.Second, // Example: Set to 5 seconds + } + if err = server.Serve(ln); err != nil { + log.Fatal().Err(err).Msg("[api] serve") + } +} + +func tlsListen(network, address, certFile, keyFile string) { + var cert tls.Certificate + var err error + if strings.IndexByte(certFile, '\n') < 0 && strings.IndexByte(keyFile, '\n') < 0 { + // check if file path + cert, err = tls.LoadX509KeyPair(certFile, keyFile) + } else { + // if text file content + cert, err = tls.X509KeyPair([]byte(certFile), []byte(keyFile)) + } + if err != nil { + log.Error().Err(err).Caller().Send() + return + } + + ln, err := net.Listen(network, address) + if err != nil { + log.Error().Err(err).Msg("[api] tls listen") + return + } + + log.Info().Str("addr", address).Msg("[api] tls listen") + + server := &http.Server{ + Handler: Handler, + TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}}, + ReadHeaderTimeout: 5 * time.Second, + } + if err = server.ServeTLS(ln, "", ""); err != nil { + log.Fatal().Err(err).Msg("[api] tls serve") + } +} + +var Port int + +const ( + MimeJSON = "application/json" + MimeText = "text/plain" +) + +var Handler http.Handler + +// HandleFunc handle pattern with relative path: +// - "api/streams" => "{basepath}/api/streams" +// - "/streams" => "/streams" +func HandleFunc(pattern string, handler http.HandlerFunc) { + if len(pattern) == 0 || pattern[0] != '/' { + pattern = basePath + "/" + pattern + } + if allowPaths != nil && !slices.Contains(allowPaths, pattern) { + log.Trace().Str("path", pattern).Msg("[api] ignore path not in allow_paths") + return + } + log.Trace().Str("path", pattern).Msg("[api] register path") + http.HandleFunc(pattern, handler) +} + +// ResponseJSON important always add Content-Type +// so go won't need to call http.DetectContentType +func ResponseJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", MimeJSON) + _ = json.NewEncoder(w).Encode(v) +} + +func ResponsePrettyJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", MimeJSON) + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + _ = enc.Encode(v) +} + +func Response(w http.ResponseWriter, body any, contentType string) { + w.Header().Set("Content-Type", contentType) + + switch v := body.(type) { + case []byte: + _, _ = w.Write(v) + case string: + _, _ = w.Write([]byte(v)) + default: + _, _ = fmt.Fprint(w, body) + } +} + +const StreamNotFound = "stream not found" + +var allowPaths []string +var basePath string +var log zerolog.Logger + +func middlewareLog(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Trace().Msgf("[api] %s %s %s", r.Method, r.URL, r.RemoteAddr) + next.ServeHTTP(w, r) + }) +} + +func isLoopback(remoteAddr string) bool { + return strings.HasPrefix(remoteAddr, "127.") || strings.HasPrefix(remoteAddr, "[::1]") || remoteAddr == "@" +} + +func middlewareAuth(username, password string, localAuth bool, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if localAuth || !isLoopback(r.RemoteAddr) { + user, pass, ok := r.BasicAuth() + if !ok || user != username || pass != password { + w.Header().Set("Www-Authenticate", `Basic realm="go2rtc"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + } + + next.ServeHTTP(w, r) + }) +} + +func middlewareCORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") + next.ServeHTTP(w, r) + }) +} + +var mu sync.Mutex + +func apiHandler(w http.ResponseWriter, r *http.Request) { + mu.Lock() + app.Info["host"] = r.Host + mu.Unlock() + + ResponseJSON(w, app.Info) +} + +func exitHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Error(w, "", http.StatusBadRequest) + return + } + + s := r.URL.Query().Get("code") + code, err := strconv.Atoi(s) + + // https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_08_02 + if err != nil || code < 0 || code > 125 { + http.Error(w, "Code must be in the range [0, 125]", http.StatusBadRequest) + return + } + + os.Exit(code) +} + +func restartHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Error(w, "", http.StatusBadRequest) + return + } + + path, err := os.Executable() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + log.Debug().Msgf("[api] restart %s", path) + + go syscall.Exec(path, os.Args, os.Environ()) +} + +func logHandler(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + // Send current state of the log file immediately + w.Header().Set("Content-Type", "application/jsonlines") + _, _ = app.MemoryLog.WriteTo(w) + case "DELETE": + app.MemoryLog.Reset() + Response(w, "OK", "text/plain") + default: + http.Error(w, "Method not allowed", http.StatusBadRequest) + } +} + +type Source struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Info string `json:"info,omitempty"` + URL string `json:"url,omitempty"` + Location string `json:"location,omitempty"` +} + +func ResponseSources(w http.ResponseWriter, sources []*Source) { + if len(sources) == 0 { + http.Error(w, "no sources", http.StatusNotFound) + return + } + + var response = struct { + Sources []*Source `json:"sources"` + }{ + Sources: sources, + } + ResponseJSON(w, response) +} + +func Error(w http.ResponseWriter, err error) { + log.Error().Err(err).Caller(1).Send() + + http.Error(w, err.Error(), http.StatusInsufficientStorage) +} diff --git a/installs_on_host/go2rtc/internal/api/config.go b/installs_on_host/go2rtc/internal/api/config.go new file mode 100644 index 0000000..9072e8d --- /dev/null +++ b/installs_on_host/go2rtc/internal/api/config.go @@ -0,0 +1,101 @@ +package api + +import ( + "io" + "net/http" + "os" + + "github.com/AlexxIT/go2rtc/internal/app" + "gopkg.in/yaml.v3" +) + +func configHandler(w http.ResponseWriter, r *http.Request) { + if app.ConfigPath == "" { + http.Error(w, "", http.StatusGone) + return + } + + switch r.Method { + case "GET": + data, err := os.ReadFile(app.ConfigPath) + if err != nil { + http.Error(w, "", http.StatusNotFound) + return + } + // https://www.ietf.org/archive/id/draft-ietf-httpapi-yaml-mediatypes-00.html + Response(w, data, "application/yaml") + + case "POST", "PATCH": + data, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if r.Method == "PATCH" { + // no need to validate after merge + data, err = mergeYAML(app.ConfigPath, data) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + } else { + // validate config + if err = yaml.Unmarshal(data, map[string]any{}); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + + if err = os.WriteFile(app.ConfigPath, data, 0644); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } +} + +func mergeYAML(file1 string, yaml2 []byte) ([]byte, error) { + // Read the contents of the first YAML file + data1, err := os.ReadFile(file1) + if err != nil { + return nil, err + } + + // Unmarshal the first YAML file into a map + var config1 map[string]any + if err = yaml.Unmarshal(data1, &config1); err != nil { + return nil, err + } + + // Unmarshal the second YAML document into a map + var config2 map[string]any + if err = yaml.Unmarshal(yaml2, &config2); err != nil { + return nil, err + } + + // Merge the two maps + config1 = merge(config1, config2) + + // Marshal the merged map into YAML + return yaml.Marshal(&config1) +} + +func merge(dst, src map[string]any) map[string]any { + for k, v := range src { + if vv, ok := dst[k]; ok { + switch vv := vv.(type) { + case map[string]any: + v := v.(map[string]any) + dst[k] = merge(vv, v) + case []any: + v := v.([]any) + dst[k] = v + default: + dst[k] = v + } + } else { + dst[k] = v + } + } + return dst +} diff --git a/installs_on_host/go2rtc/internal/api/static.go b/installs_on_host/go2rtc/internal/api/static.go new file mode 100644 index 0000000..8de4007 --- /dev/null +++ b/installs_on_host/go2rtc/internal/api/static.go @@ -0,0 +1,27 @@ +package api + +import ( + "net/http" + + "github.com/AlexxIT/go2rtc/www" +) + +func initStatic(staticDir string) { + var root http.FileSystem + if staticDir != "" { + log.Info().Str("dir", staticDir).Msg("[api] serve static") + root = http.Dir(staticDir) + } else { + root = http.FS(www.Static) + } + + base := len(basePath) + fileServer := http.FileServer(root) + + HandleFunc("", func(w http.ResponseWriter, r *http.Request) { + if base > 0 { + r.URL.Path = r.URL.Path[base:] + } + fileServer.ServeHTTP(w, r) + }) +} diff --git a/installs_on_host/go2rtc/internal/api/ws/README.md b/installs_on_host/go2rtc/internal/api/ws/README.md new file mode 100644 index 0000000..5599ff8 --- /dev/null +++ b/installs_on_host/go2rtc/internal/api/ws/README.md @@ -0,0 +1,69 @@ +# WebSocket + +Endpoint: `/api/ws` + +Query parameters: + +- `src` (required) - Stream name + +### WebRTC + +Request SDP: + +```json +{"type":"webrtc/offer","value":"v=0\r\n..."} +``` + +Response SDP: + +```json +{"type":"webrtc/answer","value":"v=0\r\n..."} +``` + +Request/response candidate: + +- empty value also allowed and optional + +```json +{"type":"webrtc/candidate","value":"candidate:3277516026 1 udp 2130706431 192.168.1.123 54321 typ host"} +``` + +### MSE + +Request: + +- codecs list optional + +```json +{"type":"mse","value":"avc1.640029,avc1.64002A,avc1.640033,hvc1.1.6.L153.B0,mp4a.40.2,mp4a.40.5,flac,opus"} +``` + +Response: + +```json +{"type":"mse","value":"video/mp4; codecs=\"avc1.64001F,mp4a.40.2\""} +``` + +### HLS + +Request: + +```json +{"type":"hls","value":"avc1.640029,avc1.64002A,avc1.640033,hvc1.1.6.L153.B0,mp4a.40.2,mp4a.40.5,flac"} +``` + +Response: + +- you MUST rewrite full HTTP path to `http://192.168.1.123:1984/api/hls/playlist.m3u8` + +```json +{"type":"hls","value":"#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000000,CODECS=\"avc1.64001F,mp4a.40.2\"\nhls/playlist.m3u8?id=DvmHdd9w"} +``` + +### MJPEG + +Request/response: + +```json +{"type":"mjpeg"} +``` diff --git a/installs_on_host/go2rtc/internal/api/ws/ws.go b/installs_on_host/go2rtc/internal/api/ws/ws.go new file mode 100644 index 0000000..02c2f90 --- /dev/null +++ b/installs_on_host/go2rtc/internal/api/ws/ws.go @@ -0,0 +1,227 @@ +package ws + +import ( + "encoding/json" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/pkg/creds" + "github.com/gorilla/websocket" + "github.com/rs/zerolog" +) + +func Init() { + var cfg struct { + Mod struct { + Origin string `yaml:"origin"` + } `yaml:"api"` + } + + app.LoadConfig(&cfg) + + log = app.GetLogger("api") + + initWS(cfg.Mod.Origin) + + api.HandleFunc("api/ws", apiWS) +} + +var log zerolog.Logger + +// Message - struct for data exchange in Web API +type Message struct { + Type string `json:"type"` + Value any `json:"value,omitempty"` +} + +func (m *Message) String() (value string) { + if s, ok := m.Value.(string); ok { + return s + } + return +} + +func (m *Message) Unmarshal(v any) error { + b, err := json.Marshal(m.Value) + if err != nil { + return err + } + return json.Unmarshal(b, v) +} + +type WSHandler func(tr *Transport, msg *Message) error + +func HandleFunc(msgType string, handler WSHandler) { + wsHandlers[msgType] = handler +} + +var wsHandlers = make(map[string]WSHandler) + +func initWS(origin string) { + wsUp = &websocket.Upgrader{ + ReadBufferSize: 4096, // for SDP + WriteBufferSize: 512 * 1024, // 512K + } + + switch origin { + case "": + // same origin + ignore port + wsUp.CheckOrigin = func(r *http.Request) bool { + origin := r.Header["Origin"] + if len(origin) == 0 { + return true + } + o, err := url.Parse(origin[0]) + if err != nil { + return false + } + if o.Host == r.Host { + return true + } + log.Trace().Msgf("[api] ws origin=%s, host=%s", o.Host, r.Host) + // https://github.com/AlexxIT/go2rtc/issues/118 + if i := strings.IndexByte(o.Host, ':'); i > 0 { + return o.Host[:i] == r.Host + } + return false + } + case "*": + // any origin + wsUp.CheckOrigin = func(r *http.Request) bool { + return true + } + } +} + +func apiWS(w http.ResponseWriter, r *http.Request) { + ws, err := wsUp.Upgrade(w, r, nil) + if err != nil { + origin := r.Header.Get("Origin") + log.Error().Err(err).Caller().Msgf("host=%s origin=%s", r.Host, origin) + return + } + + tr := &Transport{Request: r} + tr.OnWrite(func(msg any) error { + _ = ws.SetWriteDeadline(time.Now().Add(time.Second * 5)) + + if data, ok := msg.([]byte); ok { + return ws.WriteMessage(websocket.BinaryMessage, data) + } else { + return ws.WriteJSON(msg) + } + }) + + for { + msg := new(Message) + if err = ws.ReadJSON(msg); err != nil { + if !websocket.IsCloseError(err, websocket.CloseNoStatusReceived) { + log.Trace().Err(err).Caller().Send() + } + _ = ws.Close() + break + } + + log.Trace().Str("type", msg.Type).Msg("[api] ws msg") + + if handler := wsHandlers[msg.Type]; handler != nil { + go func() { + if err = handler(tr, msg); err != nil { + errMsg := creds.SecretString(err.Error()) + tr.Write(&Message{Type: "error", Value: msg.Type + ": " + errMsg}) + } + }() + } + } + + tr.Close() +} + +var wsUp *websocket.Upgrader + +type Transport struct { + Request *http.Request + + ctx map[any]any + + closed bool + mx sync.Mutex + wrmx sync.Mutex + + onChange func() + onWrite func(msg any) error + onClose []func() +} + +func (t *Transport) OnWrite(f func(msg any) error) { + t.mx.Lock() + if t.onChange != nil { + t.onChange() + } + t.onWrite = f + t.mx.Unlock() +} + +func (t *Transport) Write(msg any) { + t.wrmx.Lock() + _ = t.onWrite(msg) + t.wrmx.Unlock() +} + +func (t *Transport) Close() { + t.mx.Lock() + for _, f := range t.onClose { + f() + } + t.closed = true + t.mx.Unlock() +} + +func (t *Transport) OnChange(f func()) { + t.mx.Lock() + t.onChange = f + t.mx.Unlock() +} + +func (t *Transport) OnClose(f func()) { + t.mx.Lock() + if t.closed { + f() + } else { + t.onClose = append(t.onClose, f) + } + t.mx.Unlock() +} + +// WithContext - run function with Context variable +func (t *Transport) WithContext(f func(ctx map[any]any)) { + t.mx.Lock() + if t.ctx == nil { + t.ctx = map[any]any{} + } + f(t.ctx) + t.mx.Unlock() +} + +func (t *Transport) Writer() io.Writer { + return &writer{t: t} +} + +type writer struct { + t *Transport +} + +func (w *writer) Write(p []byte) (n int, err error) { + w.t.wrmx.Lock() + if err = w.t.onWrite(p); err == nil { + n = len(p) + } + w.t.wrmx.Unlock() + return +} diff --git a/installs_on_host/go2rtc/internal/app/README.md b/installs_on_host/go2rtc/internal/app/README.md new file mode 100644 index 0000000..1fa9923 --- /dev/null +++ b/installs_on_host/go2rtc/internal/app/README.md @@ -0,0 +1,97 @@ +# App + +The application module is responsible for reading configuration files, running other modules and setting up [logs](#log). + +The configuration can be edited through the application's WebUI with code highlighting, syntax and specification checking. + +- By default, go2rtc will search for the `go2rtc.yaml` config file in the current working directory +- go2rtc supports multiple config files: + - `go2rtc -c config1.yaml -c config2.yaml -c config3.yaml` +- go2rtc supports inline config in multiple formats from the command line: + - **YAML**: `go2rtc -c '{log: {format: text}}'` + - **JSON**: `go2rtc -c '{"log":{"format":"text"}}'` + - **key=value**: `go2rtc -c log.format=text` +- Each subsequent config will overwrite the previous one (but only for defined params) + +``` +go2rtc -config "{log: {format: text}}" -config /config/go2rtc.yaml -config "{rtsp: {listen: ''}}" -config /usr/local/go2rtc/go2rtc.yaml +``` + +or a simpler version + +``` +go2rtc -c log.format=text -c /config/go2rtc.yaml -c rtsp.listen='' -c /usr/local/go2rtc/go2rtc.yaml +``` + +## Environment variables + +There is support for loading external variables into the config. First, they will be loaded from [credential files](https://systemd.io/CREDENTIALS). If `CREDENTIALS_DIRECTORY` is not set, then the key will be loaded from an environment variable. If no environment variable is set, then the string will be left as-is. + +```yaml +streams: + camera1: rtsp://rtsp:${CAMERA_PASSWORD}@192.168.1.123/av_stream/ch0 + +rtsp: + username: ${RTSP_USER:admin} # "admin" if "RTSP_USER" not set + password: ${RTSP_PASS:secret} # "secret" if "RTSP_PASS" not set +``` + +## JSON Schema + +Editors like [GoLand](https://www.jetbrains.com/go/) and [VS Code](https://code.visualstudio.com/) support autocomplete and syntax validation. + +```yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/AlexxIT/go2rtc/master/www/schema.json +``` + +or from a running go2rtc: + +```yaml +# yaml-language-server: $schema=http://localhost:1984/schema.json +``` + +## Defaults + +- Default values may change in updates +- FFmpeg module has many presets, they are not listed here because they may also change in updates + +```yaml +api: + listen: ":1984" # default public port for WebUI and HTTP API + +ffmpeg: + bin: "ffmpeg" # default binary path for FFmpeg + +log: + level: "info" # default log level + output: "stdout" + time: "UNIXMS" + +rtsp: + listen: ":8554" # default public port for RTSP server + default_query: "video&audio" + +srtp: + listen: ":8443" # default public port for SRTP server (used for HomeKit) + +webrtc: + listen: ":8555" # default public port for WebRTC server (TCP and UDP) + ice_servers: + - urls: [ "stun:stun.cloudflare.com:3478", "stun:stun.l.google.com:19302" ] +``` + +## Log + +You can set different log levels for different modules. + +```yaml +log: + format: "" # empty (default, autodetect color support), color, json, text + level: "info" # disabled, trace, debug, info (default), warn, error + output: "stdout" # empty (only to memory), stderr, stdout (default) + time: "UNIXMS" # empty (disable timestamp), UNIXMS (default), UNIXMICRO, UNIXNANO + + api: trace # module name: log level +``` + +Modules: `api`, `streams`, `rtsp`, `webrtc`, `mp4`, `hls`, `mjpeg`, `hass`, `homekit`, `onvif`, `rtmp`, `webtorrent`, `wyoming`, `echo`, `exec`, `expr`, `ffmpeg`, `wyze`, `xiaomi`. diff --git a/installs_on_host/go2rtc/internal/app/app.go b/installs_on_host/go2rtc/internal/app/app.go new file mode 100644 index 0000000..cbce37e --- /dev/null +++ b/installs_on_host/go2rtc/internal/app/app.go @@ -0,0 +1,122 @@ +package app + +import ( + "flag" + "fmt" + "os" + "os/exec" + "runtime" + "runtime/debug" +) + +var ( + Version string + Modules []string + UserAgent string + ConfigPath string + Info = make(map[string]any) +) + +const usage = `Usage of go2rtc: + + -c, --config Path to config file or config string as YAML or JSON, support multiple + -d, --daemon Run in background + -v, --version Print version and exit +` + +func Init() { + var config flagConfig + var daemon bool + var version bool + + flag.Var(&config, "config", "") + flag.Var(&config, "c", "") + flag.BoolVar(&daemon, "daemon", false, "") + flag.BoolVar(&daemon, "d", false, "") + flag.BoolVar(&version, "version", false, "") + flag.BoolVar(&version, "v", false, "") + + flag.Usage = func() { fmt.Print(usage) } + flag.Parse() + + revision, vcsTime := readRevisionTime() + + if version { + fmt.Printf("go2rtc version %s (%s) %s/%s\n", Version, revision, runtime.GOOS, runtime.GOARCH) + os.Exit(0) + } + + if daemon && os.Getppid() != 1 { + if runtime.GOOS == "windows" { + fmt.Println("Daemon mode is not supported on Windows") + os.Exit(1) + } + + // Re-run the program in background and exit + cmd := exec.Command(os.Args[0], os.Args[1:]...) + if err := cmd.Start(); err != nil { + fmt.Println("Failed to start daemon:", err) + os.Exit(1) + } + fmt.Println("Running in daemon mode with PID:", cmd.Process.Pid) + os.Exit(0) + } + + UserAgent = "go2rtc/" + Version + + Info["version"] = Version + Info["revision"] = revision + + initConfig(config) + initLogger() + + platform := fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH) + Logger.Info().Str("version", Version).Str("platform", platform).Str("revision", revision).Msg("go2rtc") + Logger.Debug().Str("version", runtime.Version()).Str("vcs.time", vcsTime).Msg("build") + + if ConfigPath != "" { + Logger.Info().Str("path", ConfigPath).Msg("config") + } + + var cfg struct { + Mod struct { + Modules []string `yaml:"modules"` + } `yaml:"app"` + } + + LoadConfig(&cfg) + + Modules = cfg.Mod.Modules +} + +func readRevisionTime() (revision, vcsTime string) { + if info, ok := debug.ReadBuildInfo(); ok { + for _, setting := range info.Settings { + switch setting.Key { + case "vcs.revision": + if len(setting.Value) > 7 { + revision = setting.Value[:7] + } else { + revision = setting.Value + } + case "vcs.time": + vcsTime = setting.Value + case "vcs.modified": + if setting.Value == "true" { + revision += ".dirty" + } + } + } + + // Check version from -buildvcs info + // Format for tagged version : v1.9.13 + // Format for modified code: v1.9.14-0.20251215184105-753d6617ab58+dirty + if info.Main.Version != "v"+Version { + // Format: 1.9.13+dev.753d661[.dirty] + // Compatible with "awesomeversion" and "packaging.version" from python. + // Version will be larger than the previous release, but smaller than the next release. + Version += "+dev." + revision + } + } + return +} diff --git a/installs_on_host/go2rtc/internal/app/config.go b/installs_on_host/go2rtc/internal/app/config.go new file mode 100644 index 0000000..0f95894 --- /dev/null +++ b/installs_on_host/go2rtc/internal/app/config.go @@ -0,0 +1,117 @@ +package app + +import ( + "errors" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/AlexxIT/go2rtc/pkg/creds" + "github.com/AlexxIT/go2rtc/pkg/yaml" +) + +func LoadConfig(v any) { + for _, data := range configs { + if err := yaml.Unmarshal(data, v); err != nil { + Logger.Warn().Err(err).Send() + } + } +} + +var configMu sync.Mutex + +func PatchConfig(path []string, value any) error { + if ConfigPath == "" { + return errors.New("config file disabled") + } + + configMu.Lock() + defer configMu.Unlock() + + // empty config is OK + b, _ := os.ReadFile(ConfigPath) + + b, err := yaml.Patch(b, path, value) + if err != nil { + return err + } + + return os.WriteFile(ConfigPath, b, 0644) +} + +type flagConfig []string + +func (c *flagConfig) String() string { + return strings.Join(*c, " ") +} + +func (c *flagConfig) Set(value string) error { + *c = append(*c, value) + return nil +} + +var configs [][]byte + +func initConfig(confs flagConfig) { + if confs == nil { + confs = []string{"go2rtc.yaml"} + } + + for _, conf := range confs { + if len(conf) == 0 { + continue + } + if conf[0] == '{' { + // config as raw YAML or JSON + configs = append(configs, []byte(conf)) + } else if data := parseConfString(conf); data != nil { + configs = append(configs, data) + } else { + // config as file + if ConfigPath == "" { + ConfigPath = conf + initStorage() + } + + if data, _ = os.ReadFile(conf); data == nil { + continue + } + + loadEnv(data) + data = creds.ReplaceVars(data) + configs = append(configs, data) + } + } + + if ConfigPath != "" { + if !filepath.IsAbs(ConfigPath) { + if cwd, err := os.Getwd(); err == nil { + ConfigPath = filepath.Join(cwd, ConfigPath) + } + } + Info["config_path"] = ConfigPath + } +} + +func parseConfString(s string) []byte { + i := strings.IndexByte(s, '=') + if i < 0 { + return nil + } + + items := strings.Split(s[:i], ".") + if len(items) < 2 { + return nil + } + + // `log.level=trace` => `{log: {level: trace}}` + var pre string + var suf = s[i+1:] + for _, item := range items { + pre += "{" + item + ": " + suf += "}" + } + + return []byte(pre + suf) +} diff --git a/installs_on_host/go2rtc/internal/app/log.go b/installs_on_host/go2rtc/internal/app/log.go new file mode 100644 index 0000000..61fd474 --- /dev/null +++ b/installs_on_host/go2rtc/internal/app/log.go @@ -0,0 +1,191 @@ +package app + +import ( + "io" + "os" + "strings" + "sync" + + "github.com/AlexxIT/go2rtc/pkg/creds" + "github.com/mattn/go-isatty" + "github.com/rs/zerolog" +) + +var MemoryLog = newBuffer() + +func GetLogger(module string) zerolog.Logger { + Logger.Trace().Str("module", module).Msgf("[log] init") + + if s, ok := modules[module]; ok { + lvl, err := zerolog.ParseLevel(s) + if err == nil { + return Logger.Level(lvl) + } + Logger.Warn().Err(err).Caller().Send() + } + + return Logger +} + +// initLogger support: +// - output: empty (only to memory), stderr, stdout +// - format: empty (autodetect color support), color, json, text +// - time: empty (disable timestamp), UNIXMS, UNIXMICRO, UNIXNANO +// - level: disabled, trace, debug, info, warn, error... +func initLogger() { + var cfg struct { + Mod map[string]string `yaml:"log"` + } + + cfg.Mod = modules // defaults + + LoadConfig(&cfg) + + var writer io.Writer + + switch output, path, _ := strings.Cut(modules["output"], ":"); output { + case "stderr": + writer = os.Stderr + case "stdout": + writer = os.Stdout + case "file": + if path == "" { + path = "go2rtc.log" + } + // if fail - only MemoryLog will be available + writer, _ = os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + } + + timeFormat := modules["time"] + + if writer != nil { + if format := modules["format"]; format != "json" { + console := &zerolog.ConsoleWriter{Out: writer} + + switch format { + case "text": + console.NoColor = true + case "color": + console.NoColor = false // useless, but anyway + default: + // autodetection if output support color + // go-isatty - dependency for go-colorable - dependency for ConsoleWriter + console.NoColor = !isatty.IsTerminal(writer.(*os.File).Fd()) + } + + if timeFormat != "" { + console.TimeFormat = "15:04:05.000" + } else { + console.PartsOrder = []string{ + zerolog.LevelFieldName, + zerolog.CallerFieldName, + zerolog.MessageFieldName, + } + } + + writer = console + } + + writer = zerolog.MultiLevelWriter(writer, MemoryLog) + } else { + writer = MemoryLog + } + + writer = creds.SecretWriter(writer) + + lvl, _ := zerolog.ParseLevel(modules["level"]) + Logger = zerolog.New(writer).Level(lvl) + + if timeFormat != "" { + zerolog.TimeFieldFormat = timeFormat + Logger = Logger.With().Timestamp().Logger() + } +} + +var Logger zerolog.Logger + +// modules log levels +var modules = map[string]string{ + "format": "", // useless, but anyway + "level": "info", + "output": "stdout", // TODO: change to stderr someday + "time": zerolog.TimeFormatUnixMs, +} + +const ( + chunkCount = 16 + chunkSize = 1 << 16 +) + +type circularBuffer struct { + chunks [][]byte + r, w int + mu sync.Mutex +} + +func newBuffer() *circularBuffer { + b := &circularBuffer{chunks: make([][]byte, 0, chunkCount)} + // create first chunk + b.chunks = append(b.chunks, make([]byte, 0, chunkSize)) + return b +} + +func (b *circularBuffer) Write(p []byte) (n int, err error) { + n = len(p) + + b.mu.Lock() + // check if chunk has size + if len(b.chunks[b.w])+n > chunkSize { + // increase write chunk index + if b.w++; b.w == chunkCount { + b.w = 0 + } + // check overflow + if b.r == b.w { + // increase read chunk index + if b.r++; b.r == chunkCount { + b.r = 0 + } + } + // check if current chunk exists + if b.w == len(b.chunks) { + // allocate new chunk + b.chunks = append(b.chunks, make([]byte, 0, chunkSize)) + } else { + // reset len of current chunk + b.chunks[b.w] = b.chunks[b.w][:0] + } + } + + b.chunks[b.w] = append(b.chunks[b.w], p...) + b.mu.Unlock() + return +} + +func (b *circularBuffer) WriteTo(w io.Writer) (n int64, err error) { + buf := make([]byte, 0, chunkCount*chunkSize) + + // use temp buffer inside mutex because w.Write can take some time + b.mu.Lock() + for i := b.r; ; { + buf = append(buf, b.chunks[i]...) + if i == b.w { + break + } + if i++; i == chunkCount { + i = 0 + } + } + b.mu.Unlock() + + nn, err := w.Write(buf) + return int64(nn), err +} + +func (b *circularBuffer) Reset() { + b.mu.Lock() + b.chunks[0] = b.chunks[0][:0] + b.r = 0 + b.w = 0 + b.mu.Unlock() +} diff --git a/installs_on_host/go2rtc/internal/app/storage.go b/installs_on_host/go2rtc/internal/app/storage.go new file mode 100644 index 0000000..cfa1ca9 --- /dev/null +++ b/installs_on_host/go2rtc/internal/app/storage.go @@ -0,0 +1,56 @@ +package app + +import ( + "sync" + + "github.com/AlexxIT/go2rtc/pkg/creds" + "github.com/AlexxIT/go2rtc/pkg/yaml" +) + +func initStorage() { + storage = &envStorage{data: make(map[string]string)} + creds.SetStorage(storage) +} + +func loadEnv(data []byte) { + var cfg struct { + Env map[string]string `yaml:"env"` + } + + if err := yaml.Unmarshal(data, &cfg); err != nil { + return + } + + storage.mu.Lock() + for name, value := range cfg.Env { + storage.data[name] = value + creds.AddSecret(value) + } + storage.mu.Unlock() +} + +var storage *envStorage + +type envStorage struct { + data map[string]string + mu sync.Mutex +} + +func (s *envStorage) SetValue(name, value string) error { + if err := PatchConfig([]string{"env", name}, value); err != nil { + return err + } + + s.mu.Lock() + s.data[name] = value + s.mu.Unlock() + + return nil +} + +func (s *envStorage) GetValue(name string) (value string, ok bool) { + s.mu.Lock() + value, ok = s.data[name] + s.mu.Unlock() + return +} diff --git a/installs_on_host/go2rtc/internal/bubble/README.md b/installs_on_host/go2rtc/internal/bubble/README.md new file mode 100644 index 0000000..30ebad9 --- /dev/null +++ b/installs_on_host/go2rtc/internal/bubble/README.md @@ -0,0 +1,15 @@ +# Bubble + +[`new in v1.6.1`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.6.1) + +Private format in some cameras from [dvr163.com](http://help.dvr163.com/) and [eseecloud.com](http://www.eseecloud.com/). + +## Configuration + +- you can skip `username`, `password`, `port`, `ch` and `stream` if they are default +- set up separate streams for different channels and streams + +```yaml +streams: + camera1: bubble://username:password@192.168.1.123:34567/bubble/live?ch=0&stream=0 +``` diff --git a/installs_on_host/go2rtc/internal/bubble/bubble.go b/installs_on_host/go2rtc/internal/bubble/bubble.go new file mode 100644 index 0000000..6c526fc --- /dev/null +++ b/installs_on_host/go2rtc/internal/bubble/bubble.go @@ -0,0 +1,13 @@ +package bubble + +import ( + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/bubble" + "github.com/AlexxIT/go2rtc/pkg/core" +) + +func Init() { + streams.HandleFunc("bubble", func(source string) (core.Producer, error) { + return bubble.Dial(source) + }) +} diff --git a/installs_on_host/go2rtc/internal/debug/README.md b/installs_on_host/go2rtc/internal/debug/README.md new file mode 100644 index 0000000..e03b2f8 --- /dev/null +++ b/installs_on_host/go2rtc/internal/debug/README.md @@ -0,0 +1,3 @@ +# Debug + +This module provides `GET /api/stack`, with which you can find hanging goroutines diff --git a/installs_on_host/go2rtc/internal/debug/debug.go b/installs_on_host/go2rtc/internal/debug/debug.go new file mode 100644 index 0000000..fc7d245 --- /dev/null +++ b/installs_on_host/go2rtc/internal/debug/debug.go @@ -0,0 +1,9 @@ +package debug + +import ( + "github.com/AlexxIT/go2rtc/internal/api" +) + +func Init() { + api.HandleFunc("api/stack", stackHandler) +} diff --git a/installs_on_host/go2rtc/internal/debug/stack.go b/installs_on_host/go2rtc/internal/debug/stack.go new file mode 100644 index 0000000..6bc735a --- /dev/null +++ b/installs_on_host/go2rtc/internal/debug/stack.go @@ -0,0 +1,60 @@ +package debug + +import ( + "bytes" + "fmt" + "net/http" + "runtime" + + "github.com/AlexxIT/go2rtc/internal/api" +) + +var stackSkip = [][]byte{ + // main.go + []byte("main.main()"), + []byte("created by os/signal.Notify"), + + // api/stack.go + []byte("github.com/AlexxIT/go2rtc/internal/api.stackHandler"), + + // api/api.go + []byte("created by github.com/AlexxIT/go2rtc/internal/api.Init"), + []byte("created by net/http.(*connReader).startBackgroundRead"), + []byte("created by net/http.(*Server).Serve"), // TODO: why two? + + []byte("created by github.com/AlexxIT/go2rtc/internal/rtsp.Init"), + []byte("created by github.com/AlexxIT/go2rtc/internal/srtp.Init"), + + // homekit + []byte("created by github.com/AlexxIT/go2rtc/internal/homekit.Init"), + + // webrtc/api.go + []byte("created by github.com/pion/ice/v4.NewTCPMuxDefault"), + []byte("created by github.com/pion/ice/v4.NewUDPMuxDefault"), +} + +func stackHandler(w http.ResponseWriter, r *http.Request) { + sep := []byte("\n\n") + buf := make([]byte, 65535) + i := 0 + n := runtime.Stack(buf, true) + skipped := 0 + for _, item := range bytes.Split(buf[:n], sep) { + for _, skip := range stackSkip { + if bytes.Contains(item, skip) { + item = nil + skipped++ + break + } + } + if item != nil { + i += copy(buf[i:], item) + i += copy(buf[i:], sep) + } + } + i += copy(buf[i:], fmt.Sprintf( + "Total: %d, Skipped: %d", runtime.NumGoroutine(), skipped), + ) + + api.Response(w, buf[:i], api.MimeText) +} diff --git a/installs_on_host/go2rtc/internal/doorbird/README.md b/installs_on_host/go2rtc/internal/doorbird/README.md new file mode 100644 index 0000000..0273b52 --- /dev/null +++ b/installs_on_host/go2rtc/internal/doorbird/README.md @@ -0,0 +1,21 @@ +# Doorbird + +[`new in v1.9.8`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.8) + +This source type supports [Doorbird](https://www.doorbird.com/) devices including MJPEG stream, audio stream as well as two-way audio. + +It is recommended to create a separate user within your doorbird setup for go2rtc. Minimum permissions for the user are: + +- Watch always +- API operator + +## Configuration + +```yaml +streams: + doorbird1: + - rtsp://admin:password@192.168.1.123:8557/mpeg/720p/media.amp # RTSP stream + - doorbird://admin:password@192.168.1.123?media=video # MJPEG stream + - doorbird://admin:password@192.168.1.123?media=audio # audio stream + - doorbird://admin:password@192.168.1.123 # two-way audio +``` diff --git a/installs_on_host/go2rtc/internal/doorbird/doorbird.go b/installs_on_host/go2rtc/internal/doorbird/doorbird.go new file mode 100644 index 0000000..c56fc0f --- /dev/null +++ b/installs_on_host/go2rtc/internal/doorbird/doorbird.go @@ -0,0 +1,36 @@ +package doorbird + +import ( + "net/url" + + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/doorbird" +) + +func Init() { + streams.RedirectFunc("doorbird", func(rawURL string) (string, error) { + u, err := url.Parse(rawURL) + if err != nil { + return "", err + } + + // https://www.doorbird.com/downloads/api_lan.pdf + switch u.Query().Get("media") { + case "video": + u.Path = "/bha-api/video.cgi" + case "audio": + u.Path = "/bha-api/audio-receive.cgi" + default: + return "", nil + } + + u.Scheme = "http" + + return u.String(), nil + }) + + streams.HandleFunc("doorbird", func(source string) (core.Producer, error) { + return doorbird.Dial(source) + }) +} diff --git a/installs_on_host/go2rtc/internal/dvrip/README.md b/installs_on_host/go2rtc/internal/dvrip/README.md new file mode 100644 index 0000000..792834c --- /dev/null +++ b/installs_on_host/go2rtc/internal/dvrip/README.md @@ -0,0 +1,21 @@ +# DVR-IP + +[`new in v1.2.0`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.2.0) + +Private format from DVR-IP NVR, NetSurveillance, Sofia protocol (NETsurveillance ActiveX plugin XMeye SDK). + +## Configuration + +- you can skip `username`, `password`, `port`, `channel` and `subtype` if they are default +- set up separate streams for different channels +- use `subtype=0` for Main stream, and `subtype=1` for Extra1 stream +- only the TCP protocol is supported + +```yaml +streams: + only_stream: dvrip://username:password@192.168.1.123:34567?channel=0&subtype=0 + only_tts: dvrip://username:password@192.168.1.123:34567?backchannel=1 + two_way_audio: + - dvrip://username:password@192.168.1.123:34567?channel=0&subtype=0 + - dvrip://username:password@192.168.1.123:34567?backchannel=1 +``` diff --git a/installs_on_host/go2rtc/internal/dvrip/dvrip.go b/installs_on_host/go2rtc/internal/dvrip/dvrip.go new file mode 100644 index 0000000..db1c60d --- /dev/null +++ b/installs_on_host/go2rtc/internal/dvrip/dvrip.go @@ -0,0 +1,161 @@ +package dvrip + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "net" + "net/http" + "time" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/dvrip" +) + +func Init() { + streams.HandleFunc("dvrip", dvrip.Dial) + + // DVRIP client autodiscovery + api.HandleFunc("api/dvrip", apiDvrip) +} + +const Port = 34569 // UDP port number for dvrip discovery + +func apiDvrip(w http.ResponseWriter, r *http.Request) { + items, err := discover() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + api.ResponseSources(w, items) +} + +func discover() ([]*api.Source, error) { + addr := &net.UDPAddr{ + Port: Port, + IP: net.IP{239, 255, 255, 250}, + } + + conn, err := net.ListenUDP("udp4", addr) + if err != nil { + return nil, err + } + + defer conn.Close() + + go sendBroadcasts(conn) + + var items []*api.Source + + for _, info := range getResponses(conn) { + if info.HostIP == "" || info.HostName == "" { + continue + } + + host, err := hexToDecimalBytes(info.HostIP) + if err != nil { + continue + } + + items = append(items, &api.Source{ + Name: info.HostName, + URL: "dvrip://user:pass@" + host + "?channel=0&subtype=0", + }) + } + + return items, nil +} + +func sendBroadcasts(conn *net.UDPConn) { + // broadcasting the same multiple times because the devies some times don't answer + data, err := hex.DecodeString("ff00000000000000000000000000fa0500000000") + if err != nil { + return + } + + addr := &net.UDPAddr{ + Port: Port, + IP: net.IP{255, 255, 255, 255}, + } + + for i := 0; i < 3; i++ { + time.Sleep(100 * time.Millisecond) + _, _ = conn.WriteToUDP(data, addr) + } +} + +type Message struct { + NetCommon NetCommon `json:"NetWork.NetCommon"` + Ret int `json:"Ret"` + SessionID string `json:"SessionID"` +} + +type NetCommon struct { + BuildDate string `json:"BuildDate"` + ChannelNum int `json:"ChannelNum"` + DeviceType int `json:"DeviceType"` + GateWay string `json:"GateWay"` + HostIP string `json:"HostIP"` + HostName string `json:"HostName"` + HttpPort int `json:"HttpPort"` + MAC string `json:"MAC"` + MonMode string `json:"MonMode"` + NetConnectState int `json:"NetConnectState"` + OtherFunction string `json:"OtherFunction"` + SN string `json:"SN"` + SSLPort int `json:"SSLPort"` + Submask string `json:"Submask"` + TCPMaxConn int `json:"TCPMaxConn"` + TCPPort int `json:"TCPPort"` + UDPPort int `json:"UDPPort"` + UseHSDownLoad bool `json:"UseHSDownLoad"` + Version string `json:"Version"` +} + +func getResponses(conn *net.UDPConn) (infos []*NetCommon) { + if err := conn.SetReadDeadline(time.Now().Add(time.Second * 2)); err != nil { + return + } + + var ips []net.IP // processed IPs + + b := make([]byte, 4096) +loop: + for { + n, addr, err := conn.ReadFromUDP(b) + if err != nil { + break + } + + for _, ip := range ips { + if ip.Equal(addr.IP) { + continue loop + } + } + + if n <= 20+1 { + continue + } + + var msg Message + + if err = json.Unmarshal(b[20:n-1], &msg); err != nil { + continue + } + + infos = append(infos, &msg.NetCommon) + ips = append(ips, addr.IP) + } + + return +} + +func hexToDecimalBytes(hexIP string) (string, error) { + b, err := hex.DecodeString(hexIP[2:]) // remove the '0x' prefix + if err != nil { + return "", err + } + return fmt.Sprintf("%d.%d.%d.%d", b[3], b[2], b[1], b[0]), nil +} diff --git a/installs_on_host/go2rtc/internal/echo/README.md b/installs_on_host/go2rtc/internal/echo/README.md new file mode 100644 index 0000000..1dbd7dc --- /dev/null +++ b/installs_on_host/go2rtc/internal/echo/README.md @@ -0,0 +1,48 @@ +# Echo + +Some sources may have a dynamic link. And you will need to get it using a Bash or Python script. Your script should echo a link to the source. RTSP, FFmpeg or any of the supported sources. + +**Docker** and **Home Assistant add-on** users have preinstalled `python3`, `curl`, `jq`. + +## Configuration + +```yaml +streams: + apple_hls: echo:python3 hls.py https://developer.apple.com/streaming/examples/basic-stream-osx-ios5.html +``` + +## Install python libraries + +**Docker** and **Hass Add-on** users have preinstalled `python3` without any additional libraries, like [requests](https://requests.readthedocs.io/) or others. If you need some additional libraries - you need to install them to folder with your script: + +1. Install [SSH & Web Terminal](https://github.com/hassio-addons/addon-ssh) +2. Goto Add-on Web UI +3. Install library: `pip install requests -t /config/echo` +4. Add your script to `/config/echo/myscript.py` +5. Use your script as source `echo:python3 /config/echo/myscript.py` + +## Example: Apple HLS + +```yaml +streams: + apple_hls: echo:python3 hls.py https://developer.apple.com/streaming/examples/basic-stream-osx-ios5.html +``` + +**hls.py** + +```python +import re +import sys +from urllib.parse import urljoin +from urllib.request import urlopen + +html = urlopen(sys.argv[1]).read().decode("utf-8") +url = re.search(r"https.+?m3u8", html)[0] + +html = urlopen(url).read().decode("utf-8") +m = re.search(r"^[a-z0-1/_]+\.m3u8$", html, flags=re.MULTILINE) +url = urljoin(url, m[0]) + +# ffmpeg:https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_16x9/gear1/prog_index.m3u8#video=copy +print("ffmpeg:" + url + "#video=copy") +``` diff --git a/installs_on_host/go2rtc/internal/echo/echo.go b/installs_on_host/go2rtc/internal/echo/echo.go new file mode 100644 index 0000000..f33982f --- /dev/null +++ b/installs_on_host/go2rtc/internal/echo/echo.go @@ -0,0 +1,46 @@ +package echo + +import ( + "bytes" + "errors" + "os/exec" + "slices" + + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/shell" +) + +func Init() { + var cfg struct { + Mod struct { + AllowPaths []string `yaml:"allow_paths"` + } `yaml:"echo"` + } + + app.LoadConfig(&cfg) + + allowPaths := cfg.Mod.AllowPaths + + log := app.GetLogger("echo") + + streams.RedirectFunc("echo", func(url string) (string, error) { + args := shell.QuoteSplit(url[5:]) + + if allowPaths != nil && !slices.Contains(allowPaths, args[0]) { + return "", errors.New("echo: bin not in allow_paths: " + args[0]) + } + + b, err := exec.Command(args[0], args[1:]...).Output() + if err != nil { + return "", err + } + + b = bytes.TrimSpace(b) + + log.Debug().Str("url", url).Msgf("[echo] %s", b) + + return string(b), nil + }) + streams.MarkInsecure("echo") +} diff --git a/installs_on_host/go2rtc/internal/eseecloud/README.md b/installs_on_host/go2rtc/internal/eseecloud/README.md new file mode 100644 index 0000000..30db840 --- /dev/null +++ b/installs_on_host/go2rtc/internal/eseecloud/README.md @@ -0,0 +1,12 @@ +# EseeCloud + +[`new in v1.9.10`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.10) + +This source is for cameras with a link like this `http://admin:@192.168.1.123:80/livestream/12`. Related [issue](https://github.com/AlexxIT/go2rtc/issues/1690). + +## Configuration + +```yaml +streams: + camera1: eseecloud://user:pass@192.168.1.123:80/livestream/12 +``` diff --git a/installs_on_host/go2rtc/internal/eseecloud/eseecloud.go b/installs_on_host/go2rtc/internal/eseecloud/eseecloud.go new file mode 100644 index 0000000..bb4d9d3 --- /dev/null +++ b/installs_on_host/go2rtc/internal/eseecloud/eseecloud.go @@ -0,0 +1,10 @@ +package eseecloud + +import ( + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/eseecloud" +) + +func Init() { + streams.HandleFunc("eseecloud", eseecloud.Dial) +} diff --git a/installs_on_host/go2rtc/internal/exec/README.md b/installs_on_host/go2rtc/internal/exec/README.md new file mode 100644 index 0000000..aba1c55 --- /dev/null +++ b/installs_on_host/go2rtc/internal/exec/README.md @@ -0,0 +1,48 @@ +# Exec + +Exec source can run any external application and expect data from it. Two transports are supported - **pipe** ([`new in v1.5.0`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.5.0)) and **RTSP**. + +If you want to use **RTSP** transport, the command must contain the `{output}` argument in any place. On launch, it will be replaced by the local address of the RTSP server. + +**pipe** reads data from app stdout in different formats: **MJPEG**, **H.264/H.265 bitstream**, **MPEG-TS**. Also pipe can write data to app stdin in two formats: **PCMA** and **PCM/48000**. + +The source can be used with: + +- [FFmpeg](https://ffmpeg.org/) - go2rtc ffmpeg source is just a shortcut to exec source +- [FFplay](https://ffmpeg.org/ffplay.html) - play audio on your server +- [GStreamer](https://gstreamer.freedesktop.org/) +- [Raspberry Pi Cameras](https://www.raspberrypi.com/documentation/computers/camera_software.html) +- any of your own software + +## Configuration + +Pipe commands support parameters (format: `exec:{command}#{param1}#{param2}`): + +- `killsignal` - signal which will be sent to stop the process (numeric form) +- `killtimeout` - time in seconds for forced termination with sigkill +- `backchannel` - enable backchannel for two-way audio +- `starttimeout` - time in seconds for waiting first byte from RTSP + +```yaml +streams: + stream: exec:ffmpeg -re -i /media/BigBuckBunny.mp4 -c copy -rtsp_transport tcp -f rtsp {output} + picam_h264: exec:libcamera-vid -t 0 --inline -o - + picam_mjpeg: exec:libcamera-vid -t 0 --codec mjpeg -o - + pi5cam_h264: exec:libcamera-vid -t 0 --libav-format h264 -o - + canon: exec:gphoto2 --capture-movie --stdout#killsignal=2#killtimeout=5 + play_pcma: exec:ffplay -fflags nobuffer -f alaw -ar 8000 -i -#backchannel=1 + play_pcm48k: exec:ffplay -fflags nobuffer -f s16be -ar 48000 -i -#backchannel=1 +``` + +## Backchannel + +- You can check audio card names in the **Go2rtc > WebUI > Add** +- You can specify multiple backchannel lines with different codecs + +```yaml +sources: + two_way_audio_win: + - exec:ffmpeg -hide_banner -f dshow -i "audio=Microphone (High Definition Audio Device)" -c pcm_s16le -ar 16000 -ac 1 -f wav - + - exec:ffplay -nodisp -probesize 32 -f s16le -ar 16000 -#backchannel=1#audio=s16le/16000 + - exec:ffplay -nodisp -probesize 32 -f alaw -ar 8000 -#backchannel=1#audio=alaw/8000 +``` diff --git a/installs_on_host/go2rtc/internal/exec/exec.go b/installs_on_host/go2rtc/internal/exec/exec.go new file mode 100644 index 0000000..e428aef --- /dev/null +++ b/installs_on_host/go2rtc/internal/exec/exec.go @@ -0,0 +1,279 @@ +package exec + +import ( + "bufio" + "crypto/md5" + "encoding/hex" + "errors" + "fmt" + "io" + "net/url" + "os" + "slices" + "strings" + "sync" + "syscall" + "time" + + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/rtsp" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/magic" + "github.com/AlexxIT/go2rtc/pkg/pcm" + pkg "github.com/AlexxIT/go2rtc/pkg/rtsp" + "github.com/AlexxIT/go2rtc/pkg/shell" + "github.com/rs/zerolog" +) + +func Init() { + var cfg struct { + Mod struct { + AllowPaths []string `yaml:"allow_paths"` + } `yaml:"exec"` + } + + app.LoadConfig(&cfg) + + allowPaths = cfg.Mod.AllowPaths + + rtsp.HandleFunc(func(conn *pkg.Conn) bool { + waitersMu.Lock() + waiter := waiters[conn.URL.Path] + waitersMu.Unlock() + + if waiter == nil { + return false + } + + // unblocking write to channel + select { + case waiter <- conn: + return true + default: + return false + } + }) + + streams.HandleFunc("exec", execHandle) + streams.MarkInsecure("exec") + + log = app.GetLogger("exec") +} + +var allowPaths []string + +func execHandle(rawURL string) (prod core.Producer, err error) { + rawURL, rawQuery, _ := strings.Cut(rawURL, "#") + query := streams.ParseQuery(rawQuery) + + var path string + + // RTSP flow should have `{output}` inside URL + // pipe flow may have `#{params}` inside URL + if i := strings.Index(rawURL, "{output}"); i > 0 { + if rtsp.Port == "" { + return nil, errors.New("exec: rtsp module disabled") + } + + sum := md5.Sum([]byte(rawURL)) + path = "/" + hex.EncodeToString(sum[:]) + rawURL = rawURL[:i] + "rtsp://127.0.0.1:" + rtsp.Port + path + rawURL[i+8:] + } + + cmd := shell.NewCommand(rawURL[5:]) // remove `exec:` + cmd.Stderr = &logWriter{ + buf: make([]byte, 512), + debug: log.Debug().Enabled(), + } + + if allowPaths != nil && !slices.Contains(allowPaths, cmd.Args[0]) { + _ = cmd.Close() + return nil, errors.New("exec: bin not in allow_paths: " + cmd.Args[0]) + } + + if s := query.Get("killsignal"); s != "" { + sig := syscall.Signal(core.Atoi(s)) + cmd.Cancel = func() error { + log.Debug().Msgf("[exec] kill with signal=%d", sig) + return cmd.Process.Signal(sig) + } + } + + if s := query.Get("killtimeout"); s != "" { + cmd.WaitDelay = time.Duration(core.Atoi(s)) * time.Second + } + + if query.Get("backchannel") == "1" { + return pcm.NewBackchannel(cmd, query.Get("audio")) + } + + var timeout time.Duration + if s := query.Get("starttimeout"); s != "" { + timeout = time.Duration(core.Atoi(s)) * time.Second + } else { + timeout = 30 * time.Second + } + + if path == "" { + prod, err = handlePipe(rawURL, cmd) + } else { + prod, err = handleRTSP(rawURL, cmd, path, timeout) + } + + if err != nil { + _ = cmd.Close() + } + + return +} + +func handlePipe(source string, cmd *shell.Command) (core.Producer, error) { + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + + rd := struct { + io.Reader + io.Closer + }{ + // add buffer for pipe reader to reduce syscall + bufio.NewReaderSize(stdout, core.BufferSize), + // stop cmd on close pipe call + cmd, + } + + log.Debug().Strs("args", cmd.Args).Msg("[exec] run pipe") + + ts := time.Now() + + if err = cmd.Start(); err != nil { + return nil, err + } + + prod, err := magic.Open(rd) + if err != nil { + return nil, fmt.Errorf("exec/pipe: %w\n%s", err, cmd.Stderr) + } + + if info, ok := prod.(core.Info); ok { + info.SetProtocol("pipe") + setRemoteInfo(info, source, cmd.Args) + } + + log.Debug().Stringer("launch", time.Since(ts)).Msg("[exec] run pipe") + + return prod, nil +} + +func handleRTSP(source string, cmd *shell.Command, path string, timeout time.Duration) (core.Producer, error) { + if log.Trace().Enabled() { + cmd.Stdout = os.Stdout + } + + waiter := make(chan *pkg.Conn, 1) + + waitersMu.Lock() + waiters[path] = waiter + waitersMu.Unlock() + + defer func() { + waitersMu.Lock() + delete(waiters, path) + waitersMu.Unlock() + }() + + log.Debug().Strs("args", cmd.Args).Msg("[exec] run rtsp") + + ts := time.Now() + + if err := cmd.Start(); err != nil { + log.Error().Err(err).Str("source", source).Msg("[exec]") + return nil, err + } + + timer := time.NewTimer(timeout) + defer timer.Stop() + + select { + case <-timer.C: + // haven't received data from app in timeout + log.Error().Str("source", source).Msg("[exec] timeout") + return nil, errors.New("exec: timeout") + case <-cmd.Done(): + // app fail before we receive any data + return nil, fmt.Errorf("exec/rtsp\n%s", cmd.Stderr) + case prod := <-waiter: + // app started successfully + log.Debug().Stringer("launch", time.Since(ts)).Msg("[exec] run rtsp") + setRemoteInfo(prod, source, cmd.Args) + prod.OnClose = cmd.Close + return prod, nil + } +} + +// internal + +var ( + log zerolog.Logger + waiters = make(map[string]chan *pkg.Conn) + waitersMu sync.Mutex +) + +type logWriter struct { + buf []byte + debug bool + n int +} + +func (l *logWriter) String() string { + if l.n == len(l.buf) { + return string(l.buf) + "..." + } + return string(l.buf[:l.n]) +} + +func (l *logWriter) Write(p []byte) (n int, err error) { + if l.n < cap(l.buf) { + l.n += copy(l.buf[l.n:], p) + } + n = len(p) + if l.debug { + if p = trimSpace(p); p != nil { + log.Debug().Msgf("[exec] %s", p) + } + } + return +} + +func trimSpace(b []byte) []byte { + start := 0 + stop := len(b) + for ; start < stop; start++ { + if b[start] >= ' ' { + break // trim all ASCII before 0x20 + } + } + for ; ; stop-- { + if stop == start { + return nil // skip empty output + } + if b[stop-1] > ' ' { + break // trim all ASCII before 0x21 + } + } + return b[start:stop] +} + +func setRemoteInfo(info core.Info, source string, args []string) { + info.SetSource(source) + + if i := core.Index(args, "-i"); i > 0 && i < len(args)-1 { + rawURL := args[i+1] + if u, err := url.Parse(rawURL); err == nil && u.Host != "" { + info.SetRemoteAddr(u.Host) + info.SetURL(rawURL) + } + } +} diff --git a/installs_on_host/go2rtc/internal/expr/README.md b/installs_on_host/go2rtc/internal/expr/README.md new file mode 100644 index 0000000..424f4d0 --- /dev/null +++ b/installs_on_host/go2rtc/internal/expr/README.md @@ -0,0 +1,153 @@ +# Expr + +[`new in v1.8.2`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.2) + +[Expr](https://github.com/antonmedv/expr) - expression language and expression evaluation for Go. + +- [language definition](https://expr.medv.io/docs/Language-Definition) - takes best from JS, Python, Jinja2 syntax +- your expression should return a link of any supported source +- expression supports multiple operation, but: + - all operations must be separated by a semicolon + - all operations, except the last one, must declare a new variable (`let s = "abc";`) + - the last operation should return a string +- go2rtc supports additional functions: + - `fetch` - JS-like HTTP requests + - `match` - JS-like RegExp queries + +## Fetch examples + +Multiple fetch requests are executed within a single session. They share the same cookie. + +**HTTP GET** + +```js +var r = fetch('https://example.org/products.json'); +``` + +**HTTP POST JSON** + +```js +var r = fetch('https://example.org/post', { + method: 'POST', + // Content-Type: application/json will be set automatically + json: {username: 'example'} +}); +``` + +**HTTP POST Form** + +```js +var r = fetch('https://example.org/post', { + method: 'POST', + // Content-Type: application/x-www-form-urlencoded will be set automatically + data: {username: 'example', password: 'password'} +}); +``` + +## Script examples + +**Two way audio for Dahua VTO** + +```yaml +streams: + dahua_vto: | + expr: + let host = 'admin:password@192.168.1.123'; + + var r = fetch('http://' + host + '/cgi-bin/configManager.cgi?action=setConfig&Encode[0].MainFormat[0].Audio.Compression=G.711A&Encode[0].MainFormat[0].Audio.Frequency=8000'); + + 'rtsp://' + host + '/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif' +``` + +**dom.ru** + +You can get credentials from https://github.com/ad/domru + +```yaml +streams: + dom_ru: | + expr: + let camera = '***'; + let token = '***'; + let operator = '***'; + + fetch('https://myhome.proptech.ru/rest/v1/forpost/cameras/' + camera + '/video', { + headers: { + 'Authorization': 'Bearer ' + token, + 'User-Agent': 'Google sdkgphone64x8664 | Android 14 | erth | 8.26.0 (82600010) | 0 | 0 | 0', + 'Operator': operator + } + }).json().data.URL +``` + +**dom.ufanet.ru** + +```yaml +streams: + ufanet_ru: | + expr: + let username = '***'; + let password = '***'; + let cameraid = '***'; + + let r1 = fetch('https://ucams.ufanet.ru/api/internal/login/', { + method: 'POST', + data: {username: username, password: password} + }); + let r2 = fetch('https://ucams.ufanet.ru/api/v0/cameras/this/?lang=ru', { + method: 'POST', + json: {'fields': ['token_l', 'server'], 'token_l_ttl': 3600, 'numbers': [cameraid]}, + }).json().results[0]; + + 'rtsp://' + r2.server.domain + '/' + r2.number + '?token=' + r2.token_l +``` + +**Parse HLS files from Apple** + +Same example in two languages - python and expr. + +```yaml +streams: + example_python: | + echo:python -c 'from urllib.request import urlopen; import re + + # url1 = "https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_16x9/bipbop_16x9_variant.m3u8" + html1 = urlopen("https://developer.apple.com/streaming/examples/basic-stream-osx-ios5.html").read().decode("utf-8") + url1 = re.search(r"https.+?m3u8", html1)[0] + + # url2 = "gear1/prog_index.m3u8" + html2 = urlopen(url1).read().decode("utf-8") + url2 = re.search(r"^[a-z0-1/_]+\.m3u8$", html2, flags=re.MULTILINE)[0] + + # url3 = "https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_16x9/gear1/prog_index.m3u8" + url3 = url1[:url1.rindex("/")+1] + url2 + + print("ffmpeg:" + url3 + "#video=copy")' + + example_expr: | + expr: + + let html1 = fetch("https://developer.apple.com/streaming/examples/basic-stream-osx-ios5.html").text; + let url1 = match(html1, "https.+?m3u8")[0]; + + let html2 = fetch(url1).text; + let url2 = match(html2, "^[a-z0-1/_]+\\.m3u8$", "m")[0]; + + let url3 = url1[:lastIndexOf(url1, "/")+1] + url2; + + "ffmpeg:" + url3 + "#video=copy" +``` + +## Comparison + +| expr | python | js | +|------------------------------|----------------------------|--------------------------------| +| let x = 1; | x = 1 | let x = 1 | +| {a: 1, b: 2} | {"a": 1, "b": 2} | {a: 1, b: 2} | +| let r = fetch(url, {method}) | r = request(method, url) | r = await fetch(url, {method}) | +| r.ok | r.ok | r.ok | +| r.status | r.status_code | r.status | +| r.text | r.text | await r.text() | +| r.json() | r.json() | await r.json() | +| r.headers | r.headers | r.headers | +| let m = match(text, "abc") | m = re.search("abc", text) | let m = text.match(/abc/) | diff --git a/installs_on_host/go2rtc/internal/expr/expr.go b/installs_on_host/go2rtc/internal/expr/expr.go new file mode 100644 index 0000000..60d32a8 --- /dev/null +++ b/installs_on_host/go2rtc/internal/expr/expr.go @@ -0,0 +1,29 @@ +package expr + +import ( + "errors" + + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/expr" +) + +func Init() { + log := app.GetLogger("expr") + + streams.RedirectFunc("expr", func(url string) (string, error) { + v, err := expr.Eval(url[5:], nil) + if err != nil { + return "", err + } + + log.Debug().Msgf("[expr] url=%s", url) + + if url = v.(string); url == "" { + return "", errors.New("expr: result is empty") + } + + return url, nil + }) + streams.MarkInsecure("expr") +} diff --git a/installs_on_host/go2rtc/internal/ffmpeg/README.md b/installs_on_host/go2rtc/internal/ffmpeg/README.md new file mode 100644 index 0000000..03f2921 --- /dev/null +++ b/installs_on_host/go2rtc/internal/ffmpeg/README.md @@ -0,0 +1,62 @@ +# FFmpeg + +You can get any stream, file or device via FFmpeg and push it to go2rtc. The app will automatically start FFmpeg with the proper arguments when someone starts watching the stream. + +- FFmpeg preinstalled for **Docker** and **Home Assistant add-on** users +- **Home Assistant add-on** users can target files from [/media](https://www.home-assistant.io/more-info/local-media/setup-media/) folder + +## Configuration + +Format: `ffmpeg:{input}#{param1}#{param2}#{param3}`. Examples: + +```yaml +streams: + # [FILE] all tracks will be copied without transcoding codecs + file1: ffmpeg:/media/BigBuckBunny.mp4 + + # [FILE] video will be transcoded to H264, audio will be skipped + file2: ffmpeg:/media/BigBuckBunny.mp4#video=h264 + + # [FILE] video will be copied, audio will be transcoded to PCMU + file3: ffmpeg:/media/BigBuckBunny.mp4#video=copy#audio=pcmu + + # [HLS] video will be copied, audio will be skipped + hls: ffmpeg:https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_16x9/gear5/prog_index.m3u8#video=copy + + # [MJPEG] video will be transcoded to H264 + mjpeg: ffmpeg:http://185.97.122.128/cgi-bin/faststream.jpg#video=h264 + + # [RTSP] video with rotation, should be transcoded, so select H264 + rotate: ffmpeg:rtsp://12345678@192.168.1.123/av_stream/ch0#video=h264#rotate=90 +``` + +All transcoding formats have [built-in templates](ffmpeg.go): `h264`, `h265`, `opus`, `pcmu`, `pcmu/16000`, `pcmu/48000`, `pcma`, `pcma/16000`, `pcma/48000`, `aac`, `aac/16000`. + +But you can override them via YAML config. You can also add your own formats to the config and use them with source params. + +```yaml +ffmpeg: + bin: ffmpeg # path to ffmpeg binary + global: "-hide_banner" + timeout: 5 # default timeout in seconds for rtsp inputs + h264: "-codec:v libx264 -g:v 30 -preset:v superfast -tune:v zerolatency -profile:v main -level:v 4.1" + mycodec: "-any args that supported by ffmpeg..." + myinput: "-fflags nobuffer -flags low_delay -timeout {timeout} -i {input}" + myraw: "-ss 00:00:20" +``` + +- You can use go2rtc stream name as ffmpeg input (ex. `ffmpeg:camera1#video=h264`) +- You can use `video` and `audio` params multiple times (ex. `#video=copy#audio=copy#audio=pcmu`) +- You can use `rotate` param with `90`, `180`, `270` or `-90` values, important with transcoding (ex. `#video=h264#rotate=90`) +- You can use `width` and/or `height` params, important with transcoding (ex. `#video=h264#width=1280`) +- You can use `drawtext` to add a timestamp (ex. `drawtext=x=2:y=2:fontsize=12:fontcolor=white:box=1:boxcolor=black`) + - This will greatly increase the CPU of the server, even with hardware acceleration +- You can use `timeout` param to set RTSP input timeout in seconds (ex. `#timeout=10`) +- You can use `raw` param for any additional FFmpeg arguments (ex. `#raw=-vf transpose=1`) +- You can use `input` param to override default input template (ex. `#input=rtsp/udp` will change RTSP transport from TCP to UDP+TCP) + - You can use raw input value (ex. `#input=-timeout {timeout} -i {input}`) + - You can add your own input templates + +Read more about [hardware acceleration](hardware/README.md). + +**PS.** It is recommended to check the available hardware in the WebUI add page. diff --git a/installs_on_host/go2rtc/internal/ffmpeg/api.go b/installs_on_host/go2rtc/internal/ffmpeg/api.go new file mode 100644 index 0000000..d802f87 --- /dev/null +++ b/installs_on_host/go2rtc/internal/ffmpeg/api.go @@ -0,0 +1,51 @@ +package ffmpeg + +import ( + "net/http" + "strings" + + "github.com/AlexxIT/go2rtc/internal/streams" +) + +func apiFFmpeg(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Error(w, "", http.StatusMethodNotAllowed) + return + } + + query := r.URL.Query() + dst := query.Get("dst") + stream := streams.Get(dst) + if stream == nil { + http.Error(w, "", http.StatusNotFound) + return + } + + var src string + if s := query.Get("file"); s != "" { + if streams.Validate(s) == nil { + src = "ffmpeg:" + s + "#audio=auto#input=file" + } + } else if s = query.Get("live"); s != "" { + if streams.Validate(s) == nil { + src = "ffmpeg:" + s + "#audio=auto" + } + } else if s = query.Get("text"); s != "" { + if strings.IndexAny(s, `'"&%$`) < 0 { + src = "ffmpeg:tts?text=" + s + if s = query.Get("voice"); s != "" { + src += "&voice=" + s + } + src += "#audio=auto" + } + } + + if src == "" { + http.Error(w, "", http.StatusBadRequest) + return + } + + if err := stream.Play(src); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} diff --git a/installs_on_host/go2rtc/internal/ffmpeg/device/README.md b/installs_on_host/go2rtc/internal/ffmpeg/device/README.md new file mode 100644 index 0000000..0188b6f --- /dev/null +++ b/installs_on_host/go2rtc/internal/ffmpeg/device/README.md @@ -0,0 +1,22 @@ +# FFmpeg Device + +You can get video from any USB camera or Webcam as RTSP or WebRTC stream. This is part of FFmpeg integration. + +- check available devices in web interface +- `video_size` and `framerate` must be supported by your camera! +- for Linux supported only video for now +- for macOS you can stream FaceTime camera or whole desktop! +- for macOS important to set right framerate + +## Configuration + +Format: `ffmpeg:device?{input-params}#{param1}#{param2}#{param3}` + +```yaml +streams: + linux_usbcam: ffmpeg:device?video=0&video_size=1280x720#video=h264 + windows_webcam: ffmpeg:device?video=0#video=h264 + macos_facetime: ffmpeg:device?video=0&audio=1&video_size=1280x720&framerate=30#video=h264#audio=pcma +``` + +**PS.** It is recommended to check the available devices in the WebUI add page. diff --git a/installs_on_host/go2rtc/internal/ffmpeg/device/device_bsd.go b/installs_on_host/go2rtc/internal/ffmpeg/device/device_bsd.go new file mode 100644 index 0000000..27d5b61 --- /dev/null +++ b/installs_on_host/go2rtc/internal/ffmpeg/device/device_bsd.go @@ -0,0 +1,99 @@ +//go:build freebsd || netbsd || openbsd || dragonfly + +package device + +import ( + "net/url" + "os" + "os/exec" + "regexp" + "strings" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/pkg/core" +) + +func queryToInput(query url.Values) string { + if video := query.Get("video"); video != "" { + // https://ffmpeg.org/ffmpeg-devices.html#video4linux2_002c-v4l2 + input := "-f v4l2" + + for key, value := range query { + switch key { + case "resolution": + input += " -video_size " + value[0] + case "video_size", "pixel_format", "input_format", "framerate", "use_libv4l2": + input += " -" + key + " " + value[0] + } + } + + return input + " -i " + indexToItem(videos, video) + } + + if audio := query.Get("audio"); audio != "" { + input := "-f oss" + + for key, value := range query { + switch key { + case "channels", "sample_rate": + input += " -" + key + " " + value[0] + } + } + + return input + " -i " + indexToItem(audios, audio) + } + + return "" +} + +func initDevices() { + files, err := os.ReadDir("/dev") + if err != nil { + return + } + + for _, file := range files { + if !strings.HasPrefix(file.Name(), core.KindVideo) { + continue + } + + name := "/dev/" + file.Name() + + cmd := exec.Command( + Bin, "-hide_banner", "-f", "v4l2", "-list_formats", "all", "-i", name, + ) + b, _ := cmd.CombinedOutput() + + // [video4linux2,v4l2 @ 0x860b92280] Raw : yuyv422 : YUYV 4:2:2 : 640x480 160x120 176x144 320x176 320x240 352x288 432x240 544x288 640x360 752x416 800x448 800x600 864x480 960x544 960x720 1024x576 1184x656 1280x720 1280x960 + // [video4linux2,v4l2 @ 0x860b92280] Compressed: mjpeg : Motion-JPEG : 640x480 160x120 176x144 320x176 320x240 352x288 432x240 544x288 640x360 752x416 800x448 800x600 864x480 960x544 960x720 1024x576 1184x656 1280x720 1280x960 + re := regexp.MustCompile("(Raw *|Compressed): +(.+?) : +(.+?) : (.+)") + m := re.FindAllStringSubmatch(string(b), -1) + for _, i := range m { + size, _, _ := strings.Cut(i[4], " ") + stream := &api.Source{ + Name: i[3], + Info: i[4], + URL: "ffmpeg:device?video=" + name + "&input_format=" + i[2] + "&video_size=" + size, + } + + if i[1] != "Compressed" { + stream.URL += "#video=h264#hardware" + } + + videos = append(videos, name) + streams = append(streams, stream) + } + } + + err = exec.Command(Bin, "-f", "oss", "-i", "/dev/dsp", "-t", "1", "-f", "null", "-").Run() + if err == nil { + stream := &api.Source{ + Name: "OSS default", + Info: " ", + URL: "ffmpeg:device?audio=default&channels=1&sample_rate=16000&#audio=opus", + } + + audios = append(audios, "default") + streams = append(streams, stream) + } +} diff --git a/installs_on_host/go2rtc/internal/ffmpeg/device/device_darwin.go b/installs_on_host/go2rtc/internal/ffmpeg/device/device_darwin.go new file mode 100644 index 0000000..ba97c0a --- /dev/null +++ b/installs_on_host/go2rtc/internal/ffmpeg/device/device_darwin.go @@ -0,0 +1,88 @@ +//go:build darwin || ios + +package device + +import ( + "net/url" + "os/exec" + "regexp" + "strings" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/pkg/core" +) + +func queryToInput(query url.Values) string { + video := query.Get("video") + audio := query.Get("audio") + + if video == "" && audio == "" { + return "" + } + + // https://ffmpeg.org/ffmpeg-devices.html#avfoundation + input := "-f avfoundation" + + if video != "" { + video = indexToItem(videos, video) + + for key, value := range query { + switch key { + case "resolution": + input += " -video_size " + value[0] + case "pixel_format", "framerate", "video_size", "capture_cursor", "capture_mouse_clicks", "capture_raw_data": + input += " -" + key + " " + value[0] + } + } + } + + if audio != "" { + audio = indexToItem(audios, audio) + } + + return input + ` -i "` + video + `:` + audio + `"` +} + +func initDevices() { + // [AVFoundation indev @ 0x147f04510] AVFoundation video devices: + // [AVFoundation indev @ 0x147f04510] [0] FaceTime HD Camera + // [AVFoundation indev @ 0x147f04510] [1] Capture screen 0 + // [AVFoundation indev @ 0x147f04510] AVFoundation audio devices: + // [AVFoundation indev @ 0x147f04510] [0] MacBook Pro Microphone + cmd := exec.Command( + Bin, "-hide_banner", "-list_devices", "true", "-f", "avfoundation", "-i", "", + ) + b, _ := cmd.CombinedOutput() + + re := regexp.MustCompile(`\[\d+] (.+)`) + + var kind string + for _, line := range strings.Split(string(b), "\n") { + switch { + case strings.HasSuffix(line, "video devices:"): + kind = core.KindVideo + continue + case strings.HasSuffix(line, "audio devices:"): + kind = core.KindAudio + continue + } + + m := re.FindStringSubmatch(line) + if m == nil { + continue + } + + name := m[1] + + switch kind { + case core.KindVideo: + videos = append(videos, name) + case core.KindAudio: + audios = append(audios, name) + } + + streams = append(streams, &api.Source{ + Name: name, URL: "ffmpeg:device?" + kind + "=" + name, + }) + } +} diff --git a/installs_on_host/go2rtc/internal/ffmpeg/device/device_unix.go b/installs_on_host/go2rtc/internal/ffmpeg/device/device_unix.go new file mode 100644 index 0000000..7b62187 --- /dev/null +++ b/installs_on_host/go2rtc/internal/ffmpeg/device/device_unix.go @@ -0,0 +1,101 @@ +//go:build unix && !darwin && !freebsd && !netbsd && !openbsd && !dragonfly + +package device + +import ( + "net/url" + "os" + "os/exec" + "regexp" + "strings" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/pkg/core" +) + +func queryToInput(query url.Values) string { + if video := query.Get("video"); video != "" { + // https://ffmpeg.org/ffmpeg-devices.html#video4linux2_002c-v4l2 + input := "-f v4l2" + + for key, value := range query { + switch key { + case "resolution": + input += " -video_size " + value[0] + case "video_size", "pixel_format", "input_format", "framerate", "use_libv4l2": + input += " -" + key + " " + value[0] + } + } + + return input + " -i " + indexToItem(videos, video) + } + + if audio := query.Get("audio"); audio != "" { + // https://trac.ffmpeg.org/wiki/Capture/ALSA + input := "-f alsa" + + for key, value := range query { + switch key { + case "channels", "sample_rate": + input += " -" + key + " " + value[0] + } + } + + return input + " -i " + indexToItem(audios, audio) + } + + return "" +} + +func initDevices() { + files, err := os.ReadDir("/dev") + if err != nil { + return + } + + for _, file := range files { + if !strings.HasPrefix(file.Name(), core.KindVideo) { + continue + } + + name := "/dev/" + file.Name() + + cmd := exec.Command( + Bin, "-hide_banner", "-f", "v4l2", "-list_formats", "all", "-i", name, + ) + b, _ := cmd.CombinedOutput() + + // [video4linux2,v4l2 @ 0x204e1c0] Compressed: mjpeg : Motion-JPEG : 640x360 1280x720 1920x1080 + // [video4linux2,v4l2 @ 0x204e1c0] Raw : yuyv422 : YUYV 4:2:2 : 640x360 1280x720 1920x1080 + // [video4linux2,v4l2 @ 0x204e1c0] Compressed: h264 : H.264 : 640x360 1280x720 1920x1080 + re := regexp.MustCompile("(Raw *|Compressed): +(.+?) : +(.+?) : (.+)") + m := re.FindAllStringSubmatch(string(b), -1) + for _, i := range m { + size, _, _ := strings.Cut(i[4], " ") + stream := &api.Source{ + Name: i[3], + Info: i[4], + URL: "ffmpeg:device?video=" + name + "&input_format=" + i[2] + "&video_size=" + size, + } + + if i[1] != "Compressed" { + stream.URL += "#video=h264#hardware" + } + + videos = append(videos, name) + streams = append(streams, stream) + } + } + + err = exec.Command(Bin, "-f", "alsa", "-i", "default", "-t", "1", "-f", "null", "-").Run() + if err == nil { + stream := &api.Source{ + Name: "ALSA default", + Info: " ", + URL: "ffmpeg:device?audio=default&channels=1&sample_rate=16000&#audio=opus", + } + + audios = append(audios, "default") + streams = append(streams, stream) + } +} diff --git a/installs_on_host/go2rtc/internal/ffmpeg/device/device_windows.go b/installs_on_host/go2rtc/internal/ffmpeg/device/device_windows.go new file mode 100644 index 0000000..ff32831 --- /dev/null +++ b/installs_on_host/go2rtc/internal/ffmpeg/device/device_windows.go @@ -0,0 +1,90 @@ +//go:build windows + +package device + +import ( + "net/url" + "os/exec" + "regexp" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/pkg/core" +) + +func queryToInput(query url.Values) string { + video := query.Get("video") + audio := query.Get("audio") + + if video == "" && audio == "" { + return "" + } + + // https://ffmpeg.org/ffmpeg-devices.html#dshow + input := "-f dshow" + + if video != "" { + video = indexToItem(videos, video) + + for key, value := range query { + switch key { + case "resolution": + input += " -video_size " + value[0] + case "video_size", "framerate", "pixel_format": + input += " -" + key + " " + value[0] + } + } + } + + if audio != "" { + audio = indexToItem(audios, audio) + + for key, value := range query { + switch key { + case "sample_rate", "sample_size", "channels", "audio_buffer_size": + input += " -" + key + " " + value[0] + } + } + } + + if video != "" { + input += ` -i "video=` + video + + if audio != "" { + input += `:audio=` + audio + } + + input += `"` + } else { + input += ` -i "audio=` + audio + `"` + } + + return input +} + +func initDevices() { + cmd := exec.Command( + Bin, "-hide_banner", "-list_devices", "true", "-f", "dshow", "-i", "", + ) + b, _ := cmd.CombinedOutput() + + re := regexp.MustCompile(`"([^"]+)" \((video|audio)\)`) + for _, m := range re.FindAllStringSubmatch(string(b), -1) { + name := m[1] + kind := m[2] + + stream := &api.Source{ + Name: name, URL: "ffmpeg:device?" + kind + "=" + name, + } + + switch kind { + case core.KindVideo: + videos = append(videos, name) + stream.URL += "#video=h264#hardware" + case core.KindAudio: + audios = append(audios, name) + stream.URL += "&channels=1&sample_rate=16000&audio_buffer_size=10" + } + + streams = append(streams, stream) + } +} diff --git a/installs_on_host/go2rtc/internal/ffmpeg/device/devices.go b/installs_on_host/go2rtc/internal/ffmpeg/device/devices.go new file mode 100644 index 0000000..69b1344 --- /dev/null +++ b/installs_on_host/go2rtc/internal/ffmpeg/device/devices.go @@ -0,0 +1,46 @@ +package device + +import ( + "net/http" + "net/url" + "strconv" + "sync" + + "github.com/AlexxIT/go2rtc/internal/api" +) + +func Init(bin string) { + Bin = bin + + api.HandleFunc("api/ffmpeg/devices", apiDevices) +} + +func GetInput(src string) string { + query, err := url.ParseQuery(src) + if err != nil { + return "" + } + + runonce.Do(initDevices) + + return queryToInput(query) +} + +var Bin string + +var videos, audios []string +var streams []*api.Source +var runonce sync.Once + +func apiDevices(w http.ResponseWriter, r *http.Request) { + runonce.Do(initDevices) + + api.ResponseSources(w, streams) +} + +func indexToItem(items []string, index string) string { + if i, err := strconv.Atoi(index); err == nil && i < len(items) { + return items[i] + } + return index +} diff --git a/installs_on_host/go2rtc/internal/ffmpeg/ffmpeg.go b/installs_on_host/go2rtc/internal/ffmpeg/ffmpeg.go new file mode 100644 index 0000000..a3d589b --- /dev/null +++ b/installs_on_host/go2rtc/internal/ffmpeg/ffmpeg.go @@ -0,0 +1,388 @@ +package ffmpeg + +import ( + "net/url" + "strings" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/ffmpeg/device" + "github.com/AlexxIT/go2rtc/internal/ffmpeg/hardware" + "github.com/AlexxIT/go2rtc/internal/ffmpeg/virtual" + "github.com/AlexxIT/go2rtc/internal/rtsp" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/ffmpeg" + "github.com/rs/zerolog" +) + +func Init() { + var cfg struct { + Mod map[string]string `yaml:"ffmpeg"` + Log struct { + Level string `yaml:"ffmpeg"` + } `yaml:"log"` + } + + cfg.Mod = defaults // will be overriden from yaml + cfg.Log.Level = "error" + + app.LoadConfig(&cfg) + + log = app.GetLogger("ffmpeg") + + // zerolog levels: trace debug info warn error fatal panic disabled + // FFmpeg levels: trace debug verbose info warning error fatal panic quiet + if cfg.Log.Level == "warn" { + cfg.Log.Level = "warning" + } + defaults["global"] += " -v " + cfg.Log.Level + + streams.RedirectFunc("ffmpeg", func(url string) (string, error) { + if _, err := Version(); err != nil { + return "", err + } + args := parseArgs(url[7:]) + if core.Contains(args.Codecs, "auto") { + return "", nil // force call streams.HandleFunc("ffmpeg") + } + return "exec:" + args.String(), nil + }) + + streams.HandleFunc("ffmpeg", NewProducer) + + api.HandleFunc("api/ffmpeg", apiFFmpeg) + + device.Init(defaults["bin"]) + hardware.Init(defaults["bin"]) +} + +var defaults = map[string]string{ + "bin": "ffmpeg", + "global": "-hide_banner", + "timeout": "5", + + // inputs + "file": "-re -i {input}", + "http": "-fflags nobuffer -flags low_delay -i {input}", + "rtsp": "-fflags nobuffer -flags low_delay -timeout {timeout} -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i {input}", + "rtsp/udp": "-fflags nobuffer -flags low_delay -timeout {timeout} -user_agent go2rtc/ffmpeg -i {input}", + + // output + "output": "-user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}", + "output/mjpeg": "-f mjpeg -", + "output/raw": "-f yuv4mpegpipe -", + "output/aac": "-f adts -", + "output/wav": "-f wav -", + + // `-preset superfast` - we can't use ultrafast because it doesn't support `-profile main -level 4.1` + // `-tune zerolatency` - for minimal latency + // `-profile high -level 4.1` - most used streaming profile + // `-pix_fmt:v yuv420p` - important for Telegram + "h264": "-c:v libx264 -g 50 -profile:v high -level:v 4.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p", + "h265": "-c:v libx265 -g 50 -profile:v main -x265-params level=5.1:high-tier=0 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p", + "mjpeg": "-c:v mjpeg", + //"mjpeg": "-c:v mjpeg -force_duplicated_matrix:v 1 -huffman:v 0 -pix_fmt:v yuvj420p", + + "raw": "-c:v rawvideo", + "raw/gray8": "-c:v rawvideo -pix_fmt:v gray8", + "raw/yuv420p": "-c:v rawvideo -pix_fmt:v yuv420p", + "raw/yuv422p": "-c:v rawvideo -pix_fmt:v yuv422p", + "raw/yuv444p": "-c:v rawvideo -pix_fmt:v yuv444p", + + // https://ffmpeg.org/ffmpeg-codecs.html#libopus-1 + // https://github.com/pion/webrtc/issues/1514 + // https://ffmpeg.org/ffmpeg-resampler.html + // `-async 1` or `-min_comp 0` - force resampling for static timestamp inc, important for WebRTC audio quality + "opus": "-c:a libopus -application:a lowdelay -min_comp 0", + "opus/16000": "-c:a libopus -application:a lowdelay -min_comp 0 -ar:a 16000 -ac:a 1", + "pcmu": "-c:a pcm_mulaw -ar:a 8000 -ac:a 1", + "pcmu/8000": "-c:a pcm_mulaw -ar:a 8000 -ac:a 1", + "pcmu/16000": "-c:a pcm_mulaw -ar:a 16000 -ac:a 1", + "pcmu/48000": "-c:a pcm_mulaw -ar:a 48000 -ac:a 1", + "pcma": "-c:a pcm_alaw -ar:a 8000 -ac:a 1", + "pcma/8000": "-c:a pcm_alaw -ar:a 8000 -ac:a 1", + "pcma/16000": "-c:a pcm_alaw -ar:a 16000 -ac:a 1", + "pcma/48000": "-c:a pcm_alaw -ar:a 48000 -ac:a 1", + "aac": "-c:a aac", // keep sample rate and channels + "aac/16000": "-c:a aac -ar:a 16000 -ac:a 1", + "mp3": "-c:a libmp3lame -q:a 8", + "pcm": "-c:a pcm_s16be -ar:a 8000 -ac:a 1", + "pcm/8000": "-c:a pcm_s16be -ar:a 8000 -ac:a 1", + "pcm/16000": "-c:a pcm_s16be -ar:a 16000 -ac:a 1", + "pcm/48000": "-c:a pcm_s16be -ar:a 48000 -ac:a 1", + "pcml": "-c:a pcm_s16le -ar:a 8000 -ac:a 1", + "pcml/8000": "-c:a pcm_s16le -ar:a 8000 -ac:a 1", + "pcml/16000": "-c:a pcm_s16le -ar:a 16000 -ac:a 1", + "pcml/44100": "-c:a pcm_s16le -ar:a 44100 -ac:a 1", + + // hardware Intel and AMD on Linux + // better not to set `-async_depth:v 1` like for QSV, because framedrops + // `-bf 0` - disable B-frames is very important + "h264/vaapi": "-c:v h264_vaapi -g 50 -bf 0 -profile:v high -level:v 4.1 -sei:v 0", + "h265/vaapi": "-c:v hevc_vaapi -g 50 -bf 0 -profile:v main -level:v 5.1 -sei:v 0", + "mjpeg/vaapi": "-c:v mjpeg_vaapi", + + // hardware Raspberry + "h264/v4l2m2m": "-c:v h264_v4l2m2m -g 50 -bf 0", + "h265/v4l2m2m": "-c:v hevc_v4l2m2m -g 50 -bf 0", + + // hardware Rockchip + // important to use custom ffmpeg https://github.com/AlexxIT/go2rtc/issues/768 + // hevc - doesn't have a profile setting + "h264/rkmpp": "-c:v h264_rkmpp -g 50 -bf 0 -profile:v high -level:v 4.1", + "h265/rkmpp": "-c:v hevc_rkmpp -g 50 -bf 0 -profile:v main -level:v 5.1", + "mjpeg/rkmpp": "-c:v mjpeg_rkmpp", + + // hardware NVidia on Linux and Windows + // preset=p2 - faster, tune=ll - low latency + "h264/cuda": "-c:v h264_nvenc -g 50 -bf 0 -profile:v high -level:v auto -preset:v p2 -tune:v ll", + "h265/cuda": "-c:v hevc_nvenc -g 50 -bf 0 -profile:v main -level:v auto", + + // hardware Intel on Windows + "h264/dxva2": "-c:v h264_qsv -g 50 -bf 0 -profile:v high -level:v 4.1 -async_depth:v 1", + "h265/dxva2": "-c:v hevc_qsv -g 50 -bf 0 -profile:v main -level:v 5.1 -async_depth:v 1", + "mjpeg/dxva2": "-c:v mjpeg_qsv", + + // hardware macOS + "h264/videotoolbox": "-c:v h264_videotoolbox -g 50 -bf 0 -profile:v high -level:v 4.1", + "h265/videotoolbox": "-c:v hevc_videotoolbox -g 50 -bf 0 -profile:v main -level:v 5.1", +} + +var log zerolog.Logger + +// configTemplate - return template from config (defaults) if exist or return raw template +func configTemplate(template string) string { + if s := defaults[template]; s != "" { + return s + } + return template +} + +// inputTemplate - select input template from YAML config by template name +// if query has input param - select another template by this name +// if there is no another template - use input param as template +func inputTemplate(name, s string, query url.Values) string { + var template string + if input := query.Get("input"); input != "" { + template = configTemplate(input) + } else { + template = defaults[name] + } + if strings.Contains(template, "{timeout}") { + timeout := query.Get("timeout") + if timeout == "" { + timeout = defaults["timeout"] + } + template = strings.Replace(template, "{timeout}", timeout+"000000", 1) + } + return strings.Replace(template, "{input}", s, 1) +} + +func parseArgs(s string) *ffmpeg.Args { + // init FFmpeg arguments + args := &ffmpeg.Args{ + Bin: defaults["bin"], + Global: defaults["global"], + Output: defaults["output"], + Version: verAV, + } + + var source = s + var query url.Values + if i := strings.IndexByte(s, '#'); i >= 0 { + query = streams.ParseQuery(s[i+1:]) + args.Video = len(query["video"]) + args.Audio = len(query["audio"]) + s = s[:i] + } + + // Parse input: + // 1. Input as xxxx:// link (http or rtsp or any other) + // 2. Input as stream name + // 3. Input as FFmpeg device (local USB camera) + if i := strings.Index(s, "://"); i > 0 { + switch s[:i] { + case "http", "https", "rtmp": + args.Input = inputTemplate("http", s, query) + case "rtsp", "rtsps": + // https://ffmpeg.org/ffmpeg-protocols.html#rtsp + // skip unnecessary input tracks + switch { + case (args.Video > 0 && args.Audio > 0) || (args.Video == 0 && args.Audio == 0): + args.Input = "-allowed_media_types video+audio " + case args.Video > 0: + args.Input = "-allowed_media_types video " + case args.Audio > 0: + args.Input = "-allowed_media_types audio " + } + + args.Input += inputTemplate("rtsp", s, query) + default: + args.Input = "-i " + s + } + } else if streams.Get(s) != nil { + s = "rtsp://127.0.0.1:" + rtsp.Port + "/" + s + switch { + case args.Video > 0 && args.Audio == 0: + s += "?video" + case args.Audio > 0 && args.Video == 0: + s += "?audio" + default: + s += "?video&audio" + } + s += "&source=ffmpeg:" + url.QueryEscape(source) + for _, v := range query["query"] { + s += "&" + v + } + args.Input = inputTemplate("rtsp", s, query) + } else if i = strings.Index(s, "?"); i > 0 { + switch s[:i] { + case "device": + args.Input = device.GetInput(s[i+1:]) + case "virtual": + args.Input = virtual.GetInput(s[i+1:]) + case "tts": + args.Input = virtual.GetInputTTS(s[i+1:]) + } + } else { + args.Input = inputTemplate("file", s, query) + } + + if query["async"] != nil { + args.Input = "-use_wallclock_as_timestamps 1 -async 1 " + args.Input + } + + // Parse query params: + // 1. `width`/`height` params + // 2. `rotate` param + // 3. `video` params (support multiple) + // 4. `audio` params (support multiple) + // 5. `hardware` param + if query != nil { + // 1. Process raw params for FFmpeg + for _, raw := range query["raw"] { + // support templates https://github.com/AlexxIT/go2rtc/issues/487 + raw = configTemplate(raw) + args.AddCodec(raw) + } + + // 2. Process video filters (resize and rotation) + if query["width"] != nil || query["height"] != nil { + filter := "scale=" + if query["width"] != nil { + filter += query["width"][0] + } else { + filter += "-1" + } + filter += ":" + if query["height"] != nil { + filter += query["height"][0] + } else { + filter += "-1" + } + args.AddFilter(filter) + } + + if query["rotate"] != nil { + var filter string + switch query["rotate"][0] { + case "90": + filter = "transpose=1" // 90 degrees clockwise + case "180": + filter = "transpose=1,transpose=1" + case "-90", "270": + filter = "transpose=2" // 90 degrees counterclockwise + } + if filter != "" { + args.AddFilter(filter) + } + } + + for _, drawtext := range query["drawtext"] { + // support templates https://github.com/AlexxIT/go2rtc/issues/487 + drawtext = configTemplate(drawtext) + + // support default timestamp format + if !strings.Contains(drawtext, "text=") { + drawtext += `:text='%{localtime\:%Y-%m-%d %X}'` + } + + args.AddFilter("drawtext=" + drawtext) + } + + // 3. Process video codecs + if args.Video > 0 { + for _, video := range query["video"] { + if video != "copy" { + if codec := defaults[video]; codec != "" { + args.AddCodec(codec) + } else { + args.AddCodec(video) + } + } else { + args.AddCodec("-c:v copy") + } + } + } + + if query["bitrate"] != nil { + // https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate + b := query["bitrate"][0] + args.AddCodec("-b:v " + b + " -maxrate " + b + " -bufsize " + b) + } + + // 4. Process audio codecs + if args.Audio > 0 { + for _, audio := range query["audio"] { + if audio != "copy" { + if codec := defaults[audio]; codec != "" { + args.AddCodec(codec) + } else { + args.AddCodec(audio) + } + } else { + args.AddCodec("-c:a copy") + } + } + } + + if query["hardware"] != nil { + hardware.MakeHardware(args, query["hardware"][0], defaults) + } + } + + switch { + case args.Video == 0 && args.Audio == 0: + args.AddCodec("-c copy") + case args.Video == 0: + args.AddCodec("-vn") + case args.Audio == 0: + args.AddCodec("-an") + } + + // change otput from RTSP to some other pipe format + switch { + case args.Video == 0 && args.Audio == 0: + // no transcoding from mjpeg input (ffmpeg device with support output as raw MJPEG) + if strings.Contains(args.Input, " mjpeg ") { + args.Output = defaults["output/mjpeg"] + } + case args.Video == 1 && args.Audio == 0: + switch core.Before(query.Get("video"), "/") { + case "mjpeg": + args.Output = defaults["output/mjpeg"] + case "raw": + args.Output = defaults["output/raw"] + } + case args.Video == 0 && args.Audio == 1: + switch core.Before(query.Get("audio"), "/") { + case "aac": + args.Output = defaults["output/aac"] + case "pcma", "pcmu", "pcml": + args.Output = defaults["output/wav"] + } + } + + return args +} diff --git a/installs_on_host/go2rtc/internal/ffmpeg/ffmpeg_test.go b/installs_on_host/go2rtc/internal/ffmpeg/ffmpeg_test.go new file mode 100644 index 0000000..b9d0218 --- /dev/null +++ b/installs_on_host/go2rtc/internal/ffmpeg/ffmpeg_test.go @@ -0,0 +1,396 @@ +package ffmpeg + +import ( + "testing" + + "github.com/AlexxIT/go2rtc/pkg/ffmpeg" + "github.com/stretchr/testify/require" +) + +func TestParseArgsFile(t *testing.T) { + tests := []struct { + name string + source string + expect string + }{ + { + name: "[FILE] all tracks will be copied without transcoding codecs", + source: "/media/bbb.mp4", + expect: `ffmpeg -hide_banner -re -i /media/bbb.mp4 -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + { + name: "[FILE] video will be transcoded to H264, audio will be skipped", + source: "/media/bbb.mp4#video=h264", + expect: `ffmpeg -hide_banner -re -i /media/bbb.mp4 -c:v libx264 -g 50 -profile:v high -level:v 4.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + { + name: "[FILE] video will be copied, audio will be transcoded to pcmu", + source: "/media/bbb.mp4#video=copy#audio=pcmu", + expect: `ffmpeg -hide_banner -re -i /media/bbb.mp4 -c:v copy -c:a pcm_mulaw -ar:a 8000 -ac:a 1 -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + { + name: "[FILE] video will be transcoded to H265 and rotate 270º, audio will be skipped", + source: "/media/bbb.mp4#video=h265#rotate=-90", + expect: `ffmpeg -hide_banner -re -i /media/bbb.mp4 -c:v libx265 -g 50 -profile:v main -level:v 5.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p -an -vf "transpose=2" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + { + name: "[FILE] video will be output for MJPEG to pipe, audio will be skipped", + source: "/media/bbb.mp4#video=mjpeg", + expect: `ffmpeg -hide_banner -re -i /media/bbb.mp4 -c:v mjpeg -an -f mjpeg -`, + }, + { + name: "https://github.com/AlexxIT/go2rtc/issues/509", + source: "ffmpeg:test.mp4#raw=-ss 00:00:20", + expect: `ffmpeg -hide_banner -re -i ffmpeg:test.mp4 -ss 00:00:20 -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + args := parseArgs(test.source) + require.Equal(t, test.expect, args.String()) + }) + } +} + +func TestParseArgsDevice(t *testing.T) { + tests := []struct { + name string + source string + expect string + }{ + { + name: "[DEVICE] video will be output for MJPEG to pipe, with size 1920x1080", + source: "device?video=0&video_size=1920x1080", + expect: `ffmpeg -hide_banner -f dshow -video_size 1920x1080 -i "video=0" -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + { + name: "[DEVICE] video will be transcoded to H265 with framerate 20, audio will be skipped", + source: "device?video=0&framerate=20#video=h265", + expect: `ffmpeg -hide_banner -f dshow -framerate 20 -i "video=0" -c:v libx265 -g 50 -profile:v main -level:v 5.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + { + name: "[DEVICE] video/audio", + source: "device?video=FaceTime HD Camera&audio=Microphone (High Definition Audio Device)", + expect: `ffmpeg -hide_banner -f dshow -i "video=FaceTime HD Camera:audio=Microphone (High Definition Audio Device)" -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + args := parseArgs(test.source) + require.Equal(t, test.expect, args.String()) + }) + } +} + +func TestParseArgsIpCam(t *testing.T) { + tests := []struct { + name string + source string + expect string + }{ + { + name: "[HTTP] video will be copied", + source: "http://example.com", + expect: `ffmpeg -hide_banner -fflags nobuffer -flags low_delay -i http://example.com -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + { + name: "[HTTP-MJPEG] video will be transcoded to H264", + source: "http://example.com#video=h264", + expect: `ffmpeg -hide_banner -fflags nobuffer -flags low_delay -i http://example.com -c:v libx264 -g 50 -profile:v high -level:v 4.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + { + name: "[HLS] video will be copied, audio will be skipped", + source: "https://example.com#video=copy", + expect: `ffmpeg -hide_banner -fflags nobuffer -flags low_delay -i https://example.com -c:v copy -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + { + name: "[RTSP] video will be copied without transcoding codecs", + source: "rtsp://example.com", + expect: `ffmpeg -hide_banner -allowed_media_types video+audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + { + name: "[RTSP] video with resize to 1280x720, should be transcoded, so select H265", + source: "rtsp://example.com#video=h265#width=1280#height=720", + expect: `ffmpeg -hide_banner -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:v libx265 -g 50 -profile:v main -level:v 5.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p -an -vf "scale=1280:720" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + { + name: "[RTSP] video will be copied, changing RTSP transport from TCP to UDP+TCP", + source: "rtsp://example.com#input=rtsp/udp", + expect: `ffmpeg -hide_banner -allowed_media_types video+audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -i rtsp://example.com -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + { + name: "[RTMP] video will be copied, changing RTSP transport from TCP to UDP+TCP", + source: "rtmp://example.com#input=rtsp/udp", + expect: `ffmpeg -hide_banner -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -i rtmp://example.com -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + { + name: "[RTSP] custom timeout", + source: "rtsp://example.com#timeout=10", + expect: `ffmpeg -hide_banner -allowed_media_types video+audio -fflags nobuffer -flags low_delay -timeout 10000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + args := parseArgs(test.source) + require.Equal(t, test.expect, args.String()) + }) + } +} + +func TestParseArgsAudio(t *testing.T) { + tests := []struct { + name string + source string + expect string + }{ + { + name: "[AUDIO] audio will be transcoded to AAC, video will be skipped", + source: "rtsp://example.com#audio=aac", + expect: `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:a aac -vn -f adts -`, + }, + { + name: "[AUDIO] audio will be transcoded to AAC/16000, video will be skipped", + source: "rtsp://example.com#audio=aac/16000", + expect: `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:a aac -ar:a 16000 -ac:a 1 -vn -f adts -`, + }, + { + name: "[AUDIO] audio will be transcoded to OPUS, video will be skipped", + source: "rtsp://example.com#audio=opus", + expect: `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:a libopus -application:a lowdelay -min_comp 0 -vn -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + { + name: "[AUDIO] audio will be transcoded to PCMU, video will be skipped", + source: "rtsp://example.com#audio=pcmu", + expect: `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:a pcm_mulaw -ar:a 8000 -ac:a 1 -vn -f wav -`, + }, + { + name: "[AUDIO] audio will be transcoded to PCMU/16000, video will be skipped", + source: "rtsp://example.com#audio=pcmu/16000", + expect: `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:a pcm_mulaw -ar:a 16000 -ac:a 1 -vn -f wav -`, + }, + { + name: "[AUDIO] audio will be transcoded to PCMU/48000, video will be skipped", + source: "rtsp://example.com#audio=pcmu/48000", + expect: `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:a pcm_mulaw -ar:a 48000 -ac:a 1 -vn -f wav -`, + }, + { + name: "[AUDIO] audio will be transcoded to PCMA, video will be skipped", + source: "rtsp://example.com#audio=pcma", + expect: `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:a pcm_alaw -ar:a 8000 -ac:a 1 -vn -f wav -`, + }, + { + name: "[AUDIO] audio will be transcoded to PCMA/16000, video will be skipped", + source: "rtsp://example.com#audio=pcma/16000", + expect: `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:a pcm_alaw -ar:a 16000 -ac:a 1 -vn -f wav -`, + }, + { + name: "[AUDIO] audio will be transcoded to PCMA/48000, video will be skipped", + source: "rtsp://example.com#audio=pcma/48000", + expect: `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:a pcm_alaw -ar:a 48000 -ac:a 1 -vn -f wav -`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + args := parseArgs(test.source) + require.Equal(t, test.expect, args.String()) + }) + } +} + +func TestParseArgsHwVaapi(t *testing.T) { + tests := []struct { + name string + source string + expect string + }{ + { + name: "[HTTP-MJPEG] video will be transcoded to H264", + source: "http:///example.com#video=h264#hardware=vaapi", + expect: `ffmpeg -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_flags allow_profile_mismatch -fflags nobuffer -flags low_delay -i http:///example.com -c:v h264_vaapi -g 50 -bf 0 -profile:v high -level:v 4.1 -sei:v 0 -an -vf "format=vaapi|nv12,hwupload,scale_vaapi=out_color_matrix=bt709:out_range=tv:format=nv12" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + { + name: "[RTSP] video with rotation, should be transcoded, so select H264", + source: "rtsp://example.com#video=h264#rotate=180#hardware=vaapi", + expect: `ffmpeg -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_flags allow_profile_mismatch -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:v h264_vaapi -g 50 -bf 0 -profile:v high -level:v 4.1 -sei:v 0 -an -vf "format=vaapi|nv12,hwupload,transpose_vaapi=4,scale_vaapi=out_color_matrix=bt709:out_range=tv:format=nv12" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + { + name: "[RTSP] video with resize to 1280x720, should be transcoded, so select H265", + source: "rtsp://example.com#video=h265#width=1280#height=720#hardware=vaapi", + expect: `ffmpeg -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_flags allow_profile_mismatch -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:v hevc_vaapi -g 50 -bf 0 -profile:v main -level:v 5.1 -sei:v 0 -an -vf "format=vaapi|nv12,hwupload,scale_vaapi=1280:720" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + { + name: "[FILE] video will be output for MJPEG to pipe, audio will be skipped", + source: "/media/bbb.mp4#video=mjpeg#hardware=vaapi", + expect: `ffmpeg -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_flags allow_profile_mismatch -re -i /media/bbb.mp4 -c:v mjpeg_vaapi -an -vf "format=vaapi|nv12,hwupload" -f mjpeg -`, + }, + { + name: "[DEVICE] MJPEG video with size 1920x1080 will be transcoded to H265", + source: "device?video=0&video_size=1920x1080#video=h265#hardware=vaapi", + expect: `ffmpeg -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_flags allow_profile_mismatch -f dshow -video_size 1920x1080 -i "video=0" -c:v hevc_vaapi -g 50 -bf 0 -profile:v main -level:v 5.1 -sei:v 0 -an -vf "format=vaapi|nv12,hwupload" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + args := parseArgs(test.source) + require.Equal(t, test.expect, args.String()) + }) + } +} + +func _TestParseArgsHwV4l2m2m(t *testing.T) { + // [HTTP-MJPEG] video will be transcoded to H264 + args := parseArgs("http:///example.com#video=h264#hardware=v4l2m2m") + require.Equal(t, `ffmpeg -hide_banner -fflags nobuffer -flags low_delay -i http:///example.com -c:v h264_v4l2m2m -g 50 -bf 0 -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) + + // [RTSP] video with rotation, should be transcoded, so select H264 + args = parseArgs("rtsp://example.com#video=h264#rotate=180#hardware=v4l2m2m") + require.Equal(t, `ffmpeg -hide_banner -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:v h264_v4l2m2m -g 50 -bf 0 -an -vf "transpose=1,transpose=1" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) + + // [RTSP] video with resize to 1280x720, should be transcoded, so select H265 + args = parseArgs("rtsp://example.com#video=h265#width=1280#height=720#hardware=v4l2m2m") + require.Equal(t, `ffmpeg -hide_banner -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:v hevc_v4l2m2m -g 50 -bf 0 -an -vf "scale=1280:720" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) + + // [DEVICE] MJPEG video with size 1920x1080 will be transcoded to H265 + args = parseArgs("device?video=0&video_size=1920x1080#video=h265#hardware=v4l2m2m") + require.Equal(t, `ffmpeg -hide_banner -f dshow -video_size 1920x1080 -i video="0" -c:v hevc_v4l2m2m -g 50 -bf 0 -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) +} + +func TestParseArgsHwRKMPP(t *testing.T) { + tests := []struct { + name string + source string + expect string + }{ + { + name: "[FILE] transcoding to H264", + source: "bbb.mp4#video=h264#hardware=rkmpp", + expect: `ffmpeg -hide_banner -hwaccel rkmpp -hwaccel_output_format drm_prime -afbc rga -re -i bbb.mp4 -c:v h264_rkmpp -g 50 -bf 0 -profile:v high -level:v 4.1 -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + { + name: "[FILE] transcoding with rotation", + source: "bbb.mp4#video=h264#rotate=180#hardware=rkmpp", + expect: `ffmpeg -hide_banner -hwaccel rkmpp -hwaccel_output_format drm_prime -afbc rga -re -i bbb.mp4 -c:v h264_rkmpp -g 50 -bf 0 -profile:v high -level:v 4.1 -an -vf "format=drm_prime|nv12,hwupload,vpp_rkrga=transpose=4" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + { + name: "[FILE] transcoding with scaling", + source: "bbb.mp4#video=h264#height=320#hardware=rkmpp", + expect: `ffmpeg -hide_banner -hwaccel rkmpp -hwaccel_output_format drm_prime -afbc rga -re -i bbb.mp4 -c:v h264_rkmpp -g 50 -bf 0 -profile:v high -level:v 4.1 -an -vf "format=drm_prime|nv12,hwupload,scale_rkrga=-1:320:force_original_aspect_ratio=0" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + args := parseArgs(test.source) + require.Equal(t, test.expect, args.String()) + }) + } +} + +func _TestParseArgsHwCuda(t *testing.T) { + // [HTTP-MJPEG] video will be transcoded to H264 + args := parseArgs("http:///example.com#video=h264#hardware=cuda") + require.Equal(t, `ffmpeg -hide_banner -hwaccel cuda -hwaccel_output_format cuda -fflags nobuffer -flags low_delay -i http:///example.com -c:v h264_nvenc -g 50 -bf 0 -profile:v high -level:v auto -preset:v p2 -tune:v ll -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) + + // [RTSP] video with rotation, should be transcoded, so select H264 + args = parseArgs("rtsp://example.com#video=h264#rotate=180#hardware=cuda") + require.Equal(t, `ffmpeg -hide_banner -hwaccel cuda -hwaccel_output_format nv12 -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:v h264_nvenc -g 50 -bf 0 -profile:v high -level:v auto -preset:v p2 -tune:v ll -an -vf "transpose=1,transpose=1,hwupload" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) + + // [RTSP] video with resize to 1280x720, should be transcoded, so select H265 + args = parseArgs("rtsp://example.com#video=h265#width=1280#height=720#hardware=cuda") + require.Equal(t, `ffmpeg -hide_banner -hwaccel cuda -hwaccel_output_format cuda -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:v hevc_nvenc -g 50 -bf 0 -profile:v high -level:v auto -an -vf "scale_cuda=1280:720" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) + + // [DEVICE] MJPEG video with size 1920x1080 will be transcoded to H265 + args = parseArgs("device?video=0&video_size=1920x1080#video=h265#hardware=cuda") + require.Equal(t, `ffmpeg -hide_banner -hwaccel cuda -hwaccel_output_format cuda -f dshow -video_size 1920x1080 -i video="0" -c:v hevc_nvenc -g 50 -bf 0 -profile:v high -level:v auto -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) +} + +func _TestParseArgsHwDxva2(t *testing.T) { + // [HTTP-MJPEG] video will be transcoded to H264 + args := parseArgs("http:///example.com#video=h264#hardware=dxva2") + require.Equal(t, `ffmpeg -hide_banner -hwaccel dxva2 -hwaccel_output_format dxva2_vld -fflags nobuffer -flags low_delay -i http:///example.com -c:v h264_qsv -g 50 -bf 0 -profile:v high -level:v 4.1 -async_depth:v 1 -an -vf "hwmap=derive_device=qsv,format=qsv" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) + + // [RTSP] video with rotation, should be transcoded, so select H264 + args = parseArgs("rtsp://example.com#video=h264#rotate=180#hardware=dxva2") + require.Equal(t, `ffmpeg -hide_banner -hwaccel dxva2 -hwaccel_output_format dxva2_vld -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:v h264_qsv -g 50 -bf 0 -profile:v high -level:v 4.1 -async_depth:v 1 -an -vf "hwmap=derive_device=qsv,format=qsv,transpose=1,transpose=1" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) + + // [RTSP] video with resize to 1280x720, should be transcoded, so select H265 + args = parseArgs("rtsp://example.com#video=h265#width=1280#height=720#hardware=dxva2") + require.Equal(t, `ffmpeg -hide_banner -hwaccel dxva2 -hwaccel_output_format dxva2_vld -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:v hevc_qsv -g 50 -bf 0 -profile:v high -level:v 5.1 -async_depth:v 1 -an -vf "hwmap=derive_device=qsv,format=qsv,scale_qsv=1280:720" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) + + // [FILE] video will be output for MJPEG to pipe, audio will be skipped + args = parseArgs("/media/bbb.mp4#video=mjpeg#hardware=dxva2") + require.Equal(t, `ffmpeg -hide_banner -hwaccel dxva2 -hwaccel_output_format dxva2_vld -re -i /media/bbb.mp4 -c:v mjpeg_qsv -profile:v high -level:v 5.1 -an -vf "hwmap=derive_device=qsv,format=qsv" -f mjpeg -`, args.String()) + + // [DEVICE] MJPEG video with size 1920x1080 will be transcoded to H265 + args = parseArgs("device?video=0&video_size=1920x1080#video=h265#hardware=dxva2") + require.Equal(t, `ffmpeg -hide_banner -hwaccel dxva2 -hwaccel_output_format dxva2_vld -f dshow -video_size 1920x1080 -i video="0" -c:v hevc_qsv -g 50 -bf 0 -profile:v high -level:v 5.1 -async_depth:v 1 -an -vf "hwmap=derive_device=qsv,format=qsv" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) +} + +func _TestParseArgsHwVideotoolbox(t *testing.T) { + // [HTTP-MJPEG] video will be transcoded to H264 + args := parseArgs("http:///example.com#video=h264#hardware=videotoolbox") + require.Equal(t, `ffmpeg -hide_banner -hwaccel videotoolbox -hwaccel_output_format videotoolbox_vld -fflags nobuffer -flags low_delay -i http:///example.com -c:v h264_videotoolbox -g 50 -bf 0 -profile:v high -level:v 4.1 -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) + + // [RTSP] video with rotation, should be transcoded, so select H264 + args = parseArgs("rtsp://example.com#video=h264#rotate=180#hardware=videotoolbox") + require.Equal(t, `ffmpeg -hide_banner -hwaccel videotoolbox -hwaccel_output_format videotoolbox_vld -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:v h264_videotoolbox -g 50 -bf 0 -profile:v high -level:v 4.1 -an -vf "transpose=1,transpose=1" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) + + // [RTSP] video with resize to 1280x720, should be transcoded, so select H265 + args = parseArgs("rtsp://example.com#video=h265#width=1280#height=720#hardware=videotoolbox") + require.Equal(t, `ffmpeg -hide_banner -hwaccel videotoolbox -hwaccel_output_format videotoolbox_vld -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:v hevc_videotoolbox -g 50 -bf 0 -profile:v high -level:v 5.1 -an -vf "scale=1280:720" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) + + // [DEVICE] MJPEG video with size 1920x1080 will be transcoded to H265 + args = parseArgs("device?video=0&video_size=1920x1080#video=h265#hardware=videotoolbox") + require.Equal(t, `ffmpeg -hide_banner -hwaccel videotoolbox -hwaccel_output_format videotoolbox_vld -f dshow -video_size 1920x1080 -i video="0" -c:v hevc_videotoolbox -g 50 -bf 0 -profile:v high -level:v 5.1 -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) +} + +func TestDeckLink(t *testing.T) { + args := parseArgs(`DeckLink SDI (2)#video=h264#hardware=vaapi#input=-format_code Hp29 -f decklink -i "{input}"`) + require.Equal(t, `ffmpeg -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_flags allow_profile_mismatch -format_code Hp29 -f decklink -i "DeckLink SDI (2)" -c:v h264_vaapi -g 50 -bf 0 -profile:v high -level:v 4.1 -sei:v 0 -an -vf "format=vaapi|nv12,hwupload,scale_vaapi=out_color_matrix=bt709:out_range=tv:format=nv12" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) +} + +func TestDrawText(t *testing.T) { + tests := []struct { + name string + source string + expect string + }{ + { + source: "http:///example.com#video=h264#drawtext=fontsize=12", + expect: `ffmpeg -hide_banner -fflags nobuffer -flags low_delay -i http:///example.com -c:v libx264 -g 50 -profile:v high -level:v 4.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p -an -vf "drawtext=fontsize=12:text='%{localtime\:%Y-%m-%d %X}'" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + { + source: "http:///example.com#video=h264#width=640#drawtext=fontsize=12", + expect: `ffmpeg -hide_banner -fflags nobuffer -flags low_delay -i http:///example.com -c:v libx264 -g 50 -profile:v high -level:v 4.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p -an -vf "scale=640:-1,drawtext=fontsize=12:text='%{localtime\:%Y-%m-%d %X}'" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + { + source: "http:///example.com#video=h264#width=640#drawtext=fontsize=12#hardware=vaapi", + expect: `ffmpeg -hide_banner -hwaccel vaapi -hwaccel_output_format nv12 -hwaccel_flags allow_profile_mismatch -fflags nobuffer -flags low_delay -i http:///example.com -c:v h264_vaapi -g 50 -bf 0 -profile:v high -level:v 4.1 -sei:v 0 -an -vf "scale=640:-1,drawtext=fontsize=12:text='%{localtime\:%Y-%m-%d %X}',hwupload" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + args := parseArgs(test.source) + require.Equal(t, test.expect, args.String()) + }) + } +} + +func TestVersion(t *testing.T) { + verAV = ffmpeg.Version61 + tests := []struct { + name string + source string + expect string + }{ + { + source: "/media/bbb.mp4", + expect: `ffmpeg -hide_banner -readrate_initial_burst 0.001 -re -i /media/bbb.mp4 -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + args := parseArgs(test.source) + require.Equal(t, test.expect, args.String()) + }) + } +} diff --git a/installs_on_host/go2rtc/internal/ffmpeg/hardware/README.md b/installs_on_host/go2rtc/internal/ffmpeg/hardware/README.md new file mode 100644 index 0000000..6cad4c2 --- /dev/null +++ b/installs_on_host/go2rtc/internal/ffmpeg/hardware/README.md @@ -0,0 +1,106 @@ +# Hardware + +You **DON'T** need hardware acceleration if: + +- you're not using the [FFmpeg source](../README.md) +- you're using only `#video=copy` for the FFmpeg source +- you're using only `#audio=...` (any audio) transcoding for the FFmpeg source + +You **NEED** hardware acceleration if you're using `#video=h264`, `#video=h265`, `#video=mjpeg` (video) transcoding. + +## Important + +- Acceleration is disabled by default because it can be unstable (this may change in the future) +- go2rtc can automatically detect supported hardware acceleration if enabled +- go2rtc will enable hardware decoding only if hardware encoding is supported +- go2rtc will use the same GPU for decoder and encoder +- Intel and AMD will switch to a software decoder if the input codec isn't supported by the hardware decoder +- NVIDIA will fail if the input codec isn't supported by the hardware decoder +- Raspberry Pi always uses a software decoder + +```yaml +streams: + # auto select hardware encoder + camera1_hw: ffmpeg:rtsp://rtsp:12345678@192.168.1.123/av_stream/ch0#video=h264#hardware + + # manual select hardware encoder (vaapi, cuda, v4l2m2m, dxva2, videotoolbox) + camera1_vaapi: ffmpeg:rtsp://rtsp:12345678@192.168.1.123/av_stream/ch0#video=h264#hardware=vaapi +``` + +## Docker and Hass Addon + +There are two versions of the Docker container and Hass Add-on: + +- Latest (Alpine) supports hardware acceleration for Intel iGPU (CPU with graphics) and Raspberry Pi. +- Hardware (Debian 12) supports Intel iGPU, AMD GPU, NVIDIA GPU. + +## Intel iGPU + +**Supported on:** Windows binary, Linux binary, Docker, Hass Addon. + +If you have an Intel Sandy Bridge (2011) CPU with graphics, you already have hardware decoding/encoding support for `AVC/H.264`. + +If you have an Intel Skylake (2015) CPU with graphics, you already have hardware decoding/encoding support for `AVC/H.264`, `HEVC/H.265` and `MJPEG`. + +Read more [here](https://en.wikipedia.org/wiki/Intel_Quick_Sync_Video#Hardware_decoding_and_encoding) and [here](https://en.wikipedia.org/wiki/Intel_Graphics_Technology#Capabilities_(GPU_video_acceleration)). + +Linux and Docker: + +- It may be important to have a recent OS and Linux kernel. For example, on my **Debian 10 (kernel 4.19)** it did not work, but after updating to **Debian 11 (kernel 5.10)** everything was fine. +- If you run into trouble, check that you have the `/dev/dri/` folder on your host. + +Docker users should add the `--privileged` option to the container for access to the hardware. + +**PS.** Supported via [VAAPI](https://trac.ffmpeg.org/wiki/Hardware/VAAPI) engine on Linux and [DXVA2+QSV](https://trac.ffmpeg.org/wiki/Hardware/QuickSync) engine on Windows. + +## AMD GPU + +*I don't have the hardware to test this!!!* + +**Supported on:** Linux binary, Docker, Hass Addon. + +Docker users should install: `alexxit/go2rtc:master-hardware`. Docker users should add the `--privileged` option to the container for access to the hardware. + +Hass Addon users should install **go2rtc master hardware** version. + +**PS.** Supported via [VAAPI](https://trac.ffmpeg.org/wiki/Hardware/VAAPI) engine. + +## NVIDIA GPU + +**Supported on:** Windows binary, Linux binary, Docker. + +Docker users should install: `alexxit/go2rtc:master-hardware`. + +Read more [here](https://docs.frigate.video/configuration/hardware_acceleration) and [here](https://jellyfin.org/docs/general/administration/hardware-acceleration/#nvidia-hardware-acceleration-on-docker-linux). + +**PS.** Supported via [CUDA](https://trac.ffmpeg.org/wiki/HWAccelIntro#CUDANVENCNVDEC) engine. + +## Raspberry Pi 3 + +**Supported on:** Linux binary, Docker, Hass Addon. + +I don't recommend using transcoding on the Raspberry Pi 3. It's extremely slow, even with hardware acceleration. Also, it may fail when transcoding a 2K+ stream. + +## Raspberry Pi 4 + +*I don't have the hardware to test this!!!* + +**Supported on:** Linux binary, Docker, Hass Addon. + +**PS.** Supported via [v4l2m2m](https://lalitm.com/hw-encoding-raspi/) engine. + +## macOS + +In my tests, transcoding is faster on the M1 CPU than on the M1 GPU. Transcoding time on the M1 CPU is better than any Intel iGPU and comparable to an NVIDIA RTX 2070. + +**PS.** Supported via [videotoolbox](https://trac.ffmpeg.org/wiki/HWAccelIntro#VideoToolbox) engine. + +## Rockchip + +- It's important to use a custom FFmpeg build with Rockchip support from [@nyanmisaka](https://github.com/nyanmisaka/ffmpeg-rockchip) + - Static binaries from [@MarcA711](https://github.com/MarcA711/Rockchip-FFmpeg-Builds/releases/) +- It's important to have Linux kernel 5.10 or 6.1 + +**Tested** + +- [Orange Pi 3B](https://www.armbian.com/orangepi3b/) with Armbian 6.1, supports transcoding H.264, H.265, MJPEG diff --git a/installs_on_host/go2rtc/internal/ffmpeg/hardware/hardware.go b/installs_on_host/go2rtc/internal/ffmpeg/hardware/hardware.go new file mode 100644 index 0000000..8016689 --- /dev/null +++ b/installs_on_host/go2rtc/internal/ffmpeg/hardware/hardware.go @@ -0,0 +1,210 @@ +package hardware + +import ( + "net/http" + "os/exec" + "strings" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/pkg/ffmpeg" +) + +const ( + EngineSoftware = "software" + EngineVAAPI = "vaapi" // Intel iGPU and AMD GPU + EngineV4L2M2M = "v4l2m2m" // Raspberry Pi 3 and 4 + EngineCUDA = "cuda" // NVidia on Windows and Linux + EngineDXVA2 = "dxva2" // Intel on Windows + EngineVideoToolbox = "videotoolbox" // macOS + EngineRKMPP = "rkmpp" // Rockchip +) + +func Init(bin string) { + api.HandleFunc("api/ffmpeg/hardware", func(w http.ResponseWriter, r *http.Request) { + api.ResponseSources(w, ProbeAll(bin)) + }) +} + +// MakeHardware converts software FFmpeg args to hardware args +// empty engine for autoselect +func MakeHardware(args *ffmpeg.Args, engine string, defaults map[string]string) { + for i, codec := range args.Codecs { + if len(codec) < 10 { + continue // skip short line (-c:v mjpeg...) + } + + // get current codec name + name := cut(codec, ' ', 1) + switch name { + case "libx264": + name = "h264" + case "libx265": + name = "h265" + case "mjpeg": + default: + continue // skip unsupported codec + } + + // temporary disable probe for H265 + if engine == "" && name != "h265" { + if engine = cache[name]; engine == "" { + engine = ProbeHardware(args.Bin, name) + cache[name] = engine + } + } + + switch engine { + case EngineVAAPI: + args.Codecs[i] = defaults[name+"/"+engine] + + if !args.HasFilters("drawtext=") { + args.Input = "-hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_flags allow_profile_mismatch " + args.Input + + if name == "h264" { + fixPixelFormat(args) + } + + for i, filter := range args.Filters { + if strings.HasPrefix(filter, "scale=") { + args.Filters[i] = "scale_vaapi=" + filter[6:] + } + if strings.HasPrefix(filter, "transpose=") { + if filter == "transpose=1,transpose=1" { // 180 degrees half-turn + args.Filters[i] = "transpose_vaapi=4" // reversal + } else { + args.Filters[i] = "transpose_vaapi=" + filter[10:] + } + } + } + + // fix if input doesn't support hwaccel, do nothing when support + // insert as first filter before hardware scale and transpose + args.InsertFilter("format=vaapi|nv12,hwupload") + } else { + // enable software pixel for drawtext, scale and transpose + args.Input = "-hwaccel vaapi -hwaccel_output_format nv12 -hwaccel_flags allow_profile_mismatch " + args.Input + + args.AddFilter("hwupload") + } + + case EngineCUDA: + args.Codecs[i] = defaults[name+"/"+engine] + + // CUDA doesn't support hardware transpose + // https://github.com/AlexxIT/go2rtc/issues/389 + if !args.HasFilters("drawtext=", "transpose=") { + args.Input = "-hwaccel cuda -hwaccel_output_format cuda " + args.Input + + for i, filter := range args.Filters { + if strings.HasPrefix(filter, "scale=") { + args.Filters[i] = "scale_cuda=" + filter[6:] + } + } + } else { + args.Input = "-hwaccel cuda -hwaccel_output_format nv12 " + args.Input + + args.AddFilter("hwupload") + } + + case EngineDXVA2: + args.Input = "-hwaccel dxva2 -hwaccel_output_format dxva2_vld " + args.Input + args.Codecs[i] = defaults[name+"/"+engine] + + for i, filter := range args.Filters { + if strings.HasPrefix(filter, "scale=") { + args.Filters[i] = "scale_qsv=" + filter[6:] + } + } + + args.InsertFilter("hwmap=derive_device=qsv,format=qsv") + + case EngineVideoToolbox: + args.Input = "-hwaccel videotoolbox -hwaccel_output_format videotoolbox_vld " + args.Input + args.Codecs[i] = defaults[name+"/"+engine] + + case EngineV4L2M2M: + args.Codecs[i] = defaults[name+"/"+engine] + + case EngineRKMPP: + args.Codecs[i] = defaults[name+"/"+engine] + + if !args.HasFilters("drawtext=") { + args.Input = "-hwaccel rkmpp -hwaccel_output_format drm_prime -afbc rga " + args.Input + + for i, filter := range args.Filters { + if strings.HasPrefix(filter, "scale=") { + args.Filters[i] = "scale_rkrga=" + filter[6:] + ":force_original_aspect_ratio=0" + } + if strings.HasPrefix(filter, "transpose=") { + if filter == "transpose=1,transpose=1" { // 180 degrees half-turn + args.Filters[i] = "vpp_rkrga=transpose=4" // reversal + } else { + args.Filters[i] = "vpp_rkrga=transpose=" + filter[10:] + } + } + } + + if len(args.Filters) > 0 { + // fix if input doesn't support hwaccel, do nothing when support + // insert as first filter before hardware scale and transpose + args.InsertFilter("format=drm_prime|nv12,hwupload") + } + } else { + // enable software pixel for drawtext, scale and transpose + args.Input = "-hwaccel rkmpp -hwaccel_output_format nv12 -afbc rga " + args.Input + + args.AddFilter("hwupload") + } + } + } +} + +var cache = map[string]string{} + +func run(bin string, args string) bool { + err := exec.Command(bin, strings.Split(args, " ")...).Run() + return err == nil +} + +func runToString(bin string, args string) string { + if run(bin, args) { + return "OK" + } else { + return "ERROR" + } +} + +func cut(s string, sep byte, pos int) string { + for n := 0; n < pos; n++ { + if i := strings.IndexByte(s, sep); i > 0 { + s = s[i+1:] + } else { + return "" + } + } + if i := strings.IndexByte(s, sep); i > 0 { + return s[:i] + } + return s +} + +// fixPixelFormat: +// - good h264 pixel: yuv420p(tv, bt709) == yuv420p (mpeg/limited/tv) +// - bad h264 pixel: yuvj420p(pc, bt709) == yuvj420p (jpeg/full/pc) +// - bad jpeg pixel: yuvj422p(pc, bt470bg) +func fixPixelFormat(args *ffmpeg.Args) { + // in my tests this filters has same CPU/GPU load: + // - "hwupload" + // - "hwupload,scale_vaapi=out_color_matrix=bt709:out_range=tv" + // - "hwupload,scale_vaapi=out_color_matrix=bt709:out_range=tv:format=nv12" + const fixPixFmt = "out_color_matrix=bt709:out_range=tv:format=nv12" + + for i, filter := range args.Filters { + if strings.HasPrefix(filter, "scale=") { + args.Filters[i] = filter + ":" + fixPixFmt + return + } + } + + args.Filters = append(args.Filters, "scale="+fixPixFmt) +} diff --git a/installs_on_host/go2rtc/internal/ffmpeg/hardware/hardware_bsd.go b/installs_on_host/go2rtc/internal/ffmpeg/hardware/hardware_bsd.go new file mode 100644 index 0000000..de24ac5 --- /dev/null +++ b/installs_on_host/go2rtc/internal/ffmpeg/hardware/hardware_bsd.go @@ -0,0 +1,62 @@ +//go:build freebsd || netbsd || openbsd || dragonfly + +package hardware + +import ( + "runtime" + + "github.com/AlexxIT/go2rtc/internal/api" +) + +const ( + ProbeV4L2M2MH264 = "-f lavfi -i testsrc2 -t 1 -c h264_v4l2m2m -f null -" + ProbeV4L2M2MH265 = "-f lavfi -i testsrc2 -t 1 -c hevc_v4l2m2m -f null -" + ProbeRKMPPH264 = "-f lavfi -i testsrc2 -t 1 -c h264_rkmpp_encoder -f null -" + ProbeRKMPPH265 = "-f lavfi -i testsrc2 -t 1 -c hevc_rkmpp_encoder -f null -" +) + +func ProbeAll(bin string) []*api.Source { + return []*api.Source{ + { + Name: runToString(bin, ProbeV4L2M2MH264), + URL: "ffmpeg:...#video=h264#hardware=" + EngineV4L2M2M, + }, + { + Name: runToString(bin, ProbeV4L2M2MH265), + URL: "ffmpeg:...#video=h265#hardware=" + EngineV4L2M2M, + }, + { + Name: runToString(bin, ProbeRKMPPH264), + URL: "ffmpeg:...#video=h264#hardware=" + EngineRKMPP, + }, + { + Name: runToString(bin, ProbeRKMPPH265), + URL: "ffmpeg:...#video=h265#hardware=" + EngineRKMPP, + }, + } +} + +func ProbeHardware(bin, name string) string { + if runtime.GOARCH == "arm64" || runtime.GOARCH == "arm" { + switch name { + case "h264": + if run(bin, ProbeV4L2M2MH264) { + return EngineV4L2M2M + } + if run(bin, ProbeRKMPPH264) { + return EngineRKMPP + } + case "h265": + if run(bin, ProbeV4L2M2MH265) { + return EngineV4L2M2M + } + if run(bin, ProbeRKMPPH265) { + return EngineRKMPP + } + } + + return EngineSoftware + } + + return EngineSoftware +} diff --git a/installs_on_host/go2rtc/internal/ffmpeg/hardware/hardware_darwin.go b/installs_on_host/go2rtc/internal/ffmpeg/hardware/hardware_darwin.go new file mode 100644 index 0000000..b150551 --- /dev/null +++ b/installs_on_host/go2rtc/internal/ffmpeg/hardware/hardware_darwin.go @@ -0,0 +1,39 @@ +//go:build darwin || ios + +package hardware + +import ( + "github.com/AlexxIT/go2rtc/internal/api" +) + +const ProbeVideoToolboxH264 = "-f lavfi -i testsrc2=size=svga -t 1 -c h264_videotoolbox -f null -" +const ProbeVideoToolboxH265 = "-f lavfi -i testsrc2=size=svga -t 1 -c hevc_videotoolbox -f null -" + +func ProbeAll(bin string) []*api.Source { + return []*api.Source{ + { + Name: runToString(bin, ProbeVideoToolboxH264), + URL: "ffmpeg:...#video=h264#hardware=" + EngineVideoToolbox, + }, + { + Name: runToString(bin, ProbeVideoToolboxH265), + URL: "ffmpeg:...#video=h265#hardware=" + EngineVideoToolbox, + }, + } +} + +func ProbeHardware(bin, name string) string { + switch name { + case "h264": + if run(bin, ProbeVideoToolboxH264) { + return EngineVideoToolbox + } + + case "h265": + if run(bin, ProbeVideoToolboxH265) { + return EngineVideoToolbox + } + } + + return EngineSoftware +} diff --git a/installs_on_host/go2rtc/internal/ffmpeg/hardware/hardware_unix.go b/installs_on_host/go2rtc/internal/ffmpeg/hardware/hardware_unix.go new file mode 100644 index 0000000..e8000e1 --- /dev/null +++ b/installs_on_host/go2rtc/internal/ffmpeg/hardware/hardware_unix.go @@ -0,0 +1,124 @@ +//go:build unix && !darwin && !freebsd && !netbsd && !openbsd && !dragonfly + +package hardware + +import ( + "runtime" + + "github.com/AlexxIT/go2rtc/internal/api" +) + +const ( + ProbeV4L2M2MH264 = "-f lavfi -i testsrc2 -t 1 -c h264_v4l2m2m -f null -" + ProbeV4L2M2MH265 = "-f lavfi -i testsrc2 -t 1 -c hevc_v4l2m2m -f null -" + ProbeRKMPPH264 = "-f lavfi -i testsrc2 -t 1 -c h264_rkmpp -f null -" + ProbeRKMPPH265 = "-f lavfi -i testsrc2 -t 1 -c hevc_rkmpp -f null -" + ProbeRKMPPJPEG = "-f lavfi -i testsrc2 -t 1 -c mjpeg_rkmpp -f null -" + ProbeVAAPIH264 = "-init_hw_device vaapi -f lavfi -i testsrc2 -t 1 -vf format=nv12,hwupload -c h264_vaapi -f null -" + ProbeVAAPIH265 = "-init_hw_device vaapi -f lavfi -i testsrc2 -t 1 -vf format=nv12,hwupload -c hevc_vaapi -f null -" + ProbeVAAPIJPEG = "-init_hw_device vaapi -f lavfi -i testsrc2 -t 1 -vf format=nv12,hwupload -c mjpeg_vaapi -f null -" + ProbeCUDAH264 = "-init_hw_device cuda -f lavfi -i testsrc2 -t 1 -c h264_nvenc -f null -" + ProbeCUDAH265 = "-init_hw_device cuda -f lavfi -i testsrc2 -t 1 -c hevc_nvenc -f null -" +) + +func ProbeAll(bin string) []*api.Source { + if runtime.GOARCH == "arm64" || runtime.GOARCH == "arm" { + return []*api.Source{ + { + Name: runToString(bin, ProbeV4L2M2MH264), + URL: "ffmpeg:...#video=h264#hardware=" + EngineV4L2M2M, + }, + { + Name: runToString(bin, ProbeV4L2M2MH265), + URL: "ffmpeg:...#video=h265#hardware=" + EngineV4L2M2M, + }, + { + Name: runToString(bin, ProbeRKMPPH264), + URL: "ffmpeg:...#video=h264#hardware=" + EngineRKMPP, + }, + { + Name: runToString(bin, ProbeRKMPPH265), + URL: "ffmpeg:...#video=h265#hardware=" + EngineRKMPP, + }, + { + Name: runToString(bin, ProbeRKMPPJPEG), + URL: "ffmpeg:...#video=mjpeg#hardware=" + EngineRKMPP, + }, + } + } + + return []*api.Source{ + { + Name: runToString(bin, ProbeVAAPIH264), + URL: "ffmpeg:...#video=h264#hardware=" + EngineVAAPI, + }, + { + Name: runToString(bin, ProbeVAAPIH265), + URL: "ffmpeg:...#video=h265#hardware=" + EngineVAAPI, + }, + { + Name: runToString(bin, ProbeVAAPIJPEG), + URL: "ffmpeg:...#video=mjpeg#hardware=" + EngineVAAPI, + }, + { + Name: runToString(bin, ProbeCUDAH264), + URL: "ffmpeg:...#video=h264#hardware=" + EngineCUDA, + }, + { + Name: runToString(bin, ProbeCUDAH265), + URL: "ffmpeg:...#video=h265#hardware=" + EngineCUDA, + }, + } +} + +func ProbeHardware(bin, name string) string { + if runtime.GOARCH == "arm64" || runtime.GOARCH == "arm" { + switch name { + case "h264": + if run(bin, ProbeV4L2M2MH264) { + return EngineV4L2M2M + } + if run(bin, ProbeRKMPPH264) { + return EngineRKMPP + } + case "h265": + if run(bin, ProbeV4L2M2MH265) { + return EngineV4L2M2M + } + if run(bin, ProbeRKMPPH265) { + return EngineRKMPP + } + case "mjpeg": + if run(bin, ProbeRKMPPJPEG) { + return EngineRKMPP + } + } + + return EngineSoftware + } + + switch name { + case "h264": + if run(bin, ProbeCUDAH264) { + return EngineCUDA + } + if run(bin, ProbeVAAPIH264) { + return EngineVAAPI + } + + case "h265": + if run(bin, ProbeCUDAH265) { + return EngineCUDA + } + if run(bin, ProbeVAAPIH265) { + return EngineVAAPI + } + + case "mjpeg": + if run(bin, ProbeVAAPIJPEG) { + return EngineVAAPI + } + } + + return EngineSoftware +} diff --git a/installs_on_host/go2rtc/internal/ffmpeg/hardware/hardware_windows.go b/installs_on_host/go2rtc/internal/ffmpeg/hardware/hardware_windows.go new file mode 100644 index 0000000..cdf0e12 --- /dev/null +++ b/installs_on_host/go2rtc/internal/ffmpeg/hardware/hardware_windows.go @@ -0,0 +1,63 @@ +//go:build windows + +package hardware + +import "github.com/AlexxIT/go2rtc/internal/api" + +const ProbeDXVA2H264 = "-init_hw_device dxva2 -f lavfi -i testsrc2 -t 1 -c h264_qsv -f null -" +const ProbeDXVA2H265 = "-init_hw_device dxva2 -f lavfi -i testsrc2 -t 1 -c hevc_qsv -f null -" +const ProbeDXVA2JPEG = "-init_hw_device dxva2 -f lavfi -i testsrc2 -t 1 -c mjpeg_qsv -f null -" +const ProbeCUDAH264 = "-init_hw_device cuda -f lavfi -i testsrc2 -t 1 -c h264_nvenc -f null -" +const ProbeCUDAH265 = "-init_hw_device cuda -f lavfi -i testsrc2 -t 1 -c hevc_nvenc -f null -" + +func ProbeAll(bin string) []*api.Source { + return []*api.Source{ + { + Name: runToString(bin, ProbeDXVA2H264), + URL: "ffmpeg:...#video=h264#hardware=" + EngineDXVA2, + }, + { + Name: runToString(bin, ProbeDXVA2H265), + URL: "ffmpeg:...#video=h265#hardware=" + EngineDXVA2, + }, + { + Name: runToString(bin, ProbeDXVA2JPEG), + URL: "ffmpeg:...#video=mjpeg#hardware=" + EngineDXVA2, + }, + { + Name: runToString(bin, ProbeCUDAH264), + URL: "ffmpeg:...#video=h264#hardware=" + EngineCUDA, + }, + { + Name: runToString(bin, ProbeCUDAH265), + URL: "ffmpeg:...#video=h265#hardware=" + EngineCUDA, + }, + } +} + +func ProbeHardware(bin, name string) string { + switch name { + case "h264": + if run(bin, ProbeCUDAH264) { + return EngineCUDA + } + if run(bin, ProbeDXVA2H264) { + return EngineDXVA2 + } + + case "h265": + if run(bin, ProbeCUDAH265) { + return EngineCUDA + } + if run(bin, ProbeDXVA2H265) { + return EngineDXVA2 + } + + case "mjpeg": + if run(bin, ProbeDXVA2JPEG) { + return EngineDXVA2 + } + } + + return EngineSoftware +} diff --git a/installs_on_host/go2rtc/internal/ffmpeg/jpeg.go b/installs_on_host/go2rtc/internal/ffmpeg/jpeg.go new file mode 100644 index 0000000..63d886d --- /dev/null +++ b/installs_on_host/go2rtc/internal/ffmpeg/jpeg.go @@ -0,0 +1,83 @@ +package ffmpeg + +import ( + "bytes" + "fmt" + "net/url" + "os/exec" + + "github.com/AlexxIT/go2rtc/internal/ffmpeg/hardware" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/ffmpeg" + "github.com/AlexxIT/go2rtc/pkg/shell" +) + +func JPEGWithQuery(b []byte, query url.Values) ([]byte, error) { + args := parseQuery(query) + return transcode(b, args.String()) +} + +func JPEGWithScale(b []byte, width, height int) ([]byte, error) { + args := defaultArgs() + args.AddFilter(fmt.Sprintf("scale=%d:%d", width, height)) + return transcode(b, args.String()) +} + +func transcode(b []byte, args string) ([]byte, error) { + cmdArgs := shell.QuoteSplit(args) + cmd := exec.Command(cmdArgs[0], cmdArgs[1:]...) + cmd.Stdin = bytes.NewBuffer(b) + return cmd.Output() +} + +func defaultArgs() *ffmpeg.Args { + return &ffmpeg.Args{ + Bin: defaults["bin"], + Global: defaults["global"], + Input: "-i -", + Codecs: []string{defaults["mjpeg"]}, + Output: defaults["output/mjpeg"], + } +} + +func parseQuery(query url.Values) *ffmpeg.Args { + args := defaultArgs() + + var width = -1 + var height = -1 + var r, hw string + + for k, v := range query { + switch k { + case "width", "w": + width = core.Atoi(v[0]) + case "height", "h": + height = core.Atoi(v[0]) + case "rotate": + r = v[0] + case "hardware", "hw": + hw = v[0] + } + } + + if width > 0 || height > 0 { + args.AddFilter(fmt.Sprintf("scale=%d:%d", width, height)) + } + + if r != "" { + switch r { + case "90": + args.AddFilter("transpose=1") // 90 degrees clockwise + case "180": + args.AddFilter("transpose=1,transpose=1") + case "-90", "270": + args.AddFilter("transpose=2") // 90 degrees counterclockwise + } + } + + if hw != "" { + hardware.MakeHardware(args, hw, defaults) + } + + return args +} diff --git a/installs_on_host/go2rtc/internal/ffmpeg/jpeg_test.go b/installs_on_host/go2rtc/internal/ffmpeg/jpeg_test.go new file mode 100644 index 0000000..299d225 --- /dev/null +++ b/installs_on_host/go2rtc/internal/ffmpeg/jpeg_test.go @@ -0,0 +1,23 @@ +package ffmpeg + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseQuery(t *testing.T) { + args := parseQuery(nil) + require.Equal(t, `ffmpeg -hide_banner -i - -c:v mjpeg -f mjpeg -`, args.String()) + + query, err := url.ParseQuery("h=480") + require.Nil(t, err) + args = parseQuery(query) + require.Equal(t, `ffmpeg -hide_banner -i - -c:v mjpeg -vf "scale=-1:480" -f mjpeg -`, args.String()) + + query, err = url.ParseQuery("hw=vaapi") + require.Nil(t, err) + args = parseQuery(query) + require.Equal(t, `ffmpeg -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_flags allow_profile_mismatch -i - -c:v mjpeg_vaapi -vf "format=vaapi|nv12,hwupload" -f mjpeg -`, args.String()) +} diff --git a/installs_on_host/go2rtc/internal/ffmpeg/producer.go b/installs_on_host/go2rtc/internal/ffmpeg/producer.go new file mode 100644 index 0000000..fb04446 --- /dev/null +++ b/installs_on_host/go2rtc/internal/ffmpeg/producer.go @@ -0,0 +1,122 @@ +package ffmpeg + +import ( + "encoding/json" + "errors" + "net/url" + "strconv" + "strings" + + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/aac" + "github.com/AlexxIT/go2rtc/pkg/core" +) + +type Producer struct { + core.Connection + url string + query url.Values + ffmpeg core.Producer +} + +// NewProducer - FFmpeg producer with auto selection video/audio codec based on client capabilities +func NewProducer(url string) (core.Producer, error) { + p := &Producer{} + + i := strings.IndexByte(url, '#') + p.url, p.query = url[:i], streams.ParseQuery(url[i+1:]) + + // ffmpeg.NewProducer support only one audio + if len(p.query["video"]) != 0 || len(p.query["audio"]) != 1 { + return nil, errors.New("ffmpeg: unsupported params: " + url[i:]) + } + + p.ID = core.NewID() + p.FormatName = "ffmpeg" + p.Medias = []*core.Media{ + { + // we can support only audio, because don't know FmtpLine for H264 and PayloadType for MJPEG + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + // codecs in order from best to worst + Codecs: []*core.Codec{ + // OPUS will always marked as OPUS/48000/2 + {Name: core.CodecOpus, ClockRate: 48000, Channels: 2}, + {Name: core.CodecPCML, ClockRate: 16000}, + {Name: core.CodecPCM, ClockRate: 16000}, + {Name: core.CodecPCMA, ClockRate: 16000}, + {Name: core.CodecPCMU, ClockRate: 16000}, + {Name: core.CodecPCML, ClockRate: 8000}, + {Name: core.CodecPCM, ClockRate: 8000}, + {Name: core.CodecPCMA, ClockRate: 8000}, + {Name: core.CodecPCMU, ClockRate: 8000}, + // AAC has unknown problems on Dahua two way + {Name: core.CodecAAC, ClockRate: 16000, FmtpLine: aac.FMTP + "1408"}, + }, + }, + } + return p, nil +} + +func (p *Producer) Start() error { + var err error + if p.ffmpeg, err = streams.GetProducer(p.newURL()); err != nil { + return err + } + + for i, media := range p.ffmpeg.GetMedias() { + track, err := p.ffmpeg.GetTrack(media, media.Codecs[0]) + if err != nil { + return err + } + p.Receivers[i].Replace(track) + } + + return p.ffmpeg.Start() +} + +func (p *Producer) Stop() error { + if p.ffmpeg == nil { + return nil + } + return p.ffmpeg.Stop() +} + +func (p *Producer) MarshalJSON() ([]byte, error) { + if p.ffmpeg == nil { + return json.Marshal(p.Connection) + } + return json.Marshal(p.ffmpeg) +} + +func (p *Producer) newURL() string { + s := p.url + // rewrite codecs in url from auto to known presets from defaults + for _, receiver := range p.Receivers { + codec := receiver.Codec + switch codec.Name { + case core.CodecOpus: + s += "#audio=opus/16000" + case core.CodecAAC: + s += "#audio=aac/16000" + case core.CodecPCML: + s += "#audio=pcml/" + strconv.Itoa(int(codec.ClockRate)) + case core.CodecPCM: + s += "#audio=pcm/" + strconv.Itoa(int(codec.ClockRate)) + case core.CodecPCMA: + s += "#audio=pcma/" + strconv.Itoa(int(codec.ClockRate)) + case core.CodecPCMU: + s += "#audio=pcmu/" + strconv.Itoa(int(codec.ClockRate)) + } + } + // add other params + for key, values := range p.query { + if key != "audio" { + for _, value := range values { + s += "#" + key + "=" + value + } + } + } + + return s +} diff --git a/installs_on_host/go2rtc/internal/ffmpeg/version.go b/installs_on_host/go2rtc/internal/ffmpeg/version.go new file mode 100644 index 0000000..717e08a --- /dev/null +++ b/installs_on_host/go2rtc/internal/ffmpeg/version.go @@ -0,0 +1,46 @@ +package ffmpeg + +import ( + "errors" + "os/exec" + "sync" + + "github.com/AlexxIT/go2rtc/pkg/ffmpeg" +) + +var verMu sync.Mutex +var verErr error +var verFF string +var verAV string + +func Version() (string, error) { + verMu.Lock() + defer verMu.Unlock() + + if verFF != "" { + return verFF, verErr + } + + cmd := exec.Command(defaults["bin"], "-version") + b, err := cmd.Output() + if err != nil { + verFF = "-" + verErr = err + return verFF, verErr + } + + verFF, verAV = ffmpeg.ParseVersion(b) + + if verFF == "" { + verFF = "?" + } + + // better to compare libavformat, because nightly/master builds + if verAV != "" && verAV < ffmpeg.Version50 { + verErr = errors.New("ffmpeg: unsupported version: " + verFF) + } + + log.Debug().Str("version", verFF).Str("libavformat", verAV).Msgf("[ffmpeg] bin") + + return verFF, verErr +} diff --git a/installs_on_host/go2rtc/internal/ffmpeg/virtual/virtual.go b/installs_on_host/go2rtc/internal/ffmpeg/virtual/virtual.go new file mode 100644 index 0000000..4dc3b02 --- /dev/null +++ b/installs_on_host/go2rtc/internal/ffmpeg/virtual/virtual.go @@ -0,0 +1,79 @@ +package virtual + +import ( + "net/url" +) + +func GetInput(src string) string { + query, err := url.ParseQuery(src) + if err != nil { + return "" + } + + input := "-re" + + for _, video := range query["video"] { + // https://ffmpeg.org/ffmpeg-filters.html + sep := "=" // first separator + + if video == "" { + video = "testsrc=decimals=2" // default video + sep = ":" + } + + input += " -f lavfi -i " + video + + // set defaults (using Add instead of Set) + query.Add("size", "1920x1080") + + for key, values := range query { + value := values[0] + + // https://ffmpeg.org/ffmpeg-utils.html#video-size-syntax + switch key { + case "color", "rate", "duration", "sar", "decimals": + case "size": + switch value { + case "720": + value = "1280x720" // crf=1 -> 12 Mbps + case "1080": + value = "1920x1080" // crf=1 -> 25 Mbps + case "2K": + value = "2560x1440" // crf=1 -> 43 Mbps + case "4K": + value = "3840x2160" // crf=1 -> 103 Mbps + case "8K": + value = "7680x4230" // https://reolink.com/blog/8k-resolution/ + } + default: + continue + } + + input += sep + key + "=" + value + sep = ":" // next separator + } + + if s := query.Get("format"); s != "" { + input += ",format=" + s + } + } + + return input +} + +func GetInputTTS(src string) string { + query, err := url.ParseQuery(src) + if err != nil { + return "" + } + + input := `-re -f lavfi -i "flite=text='` + query.Get("text") + `'` + + // ffmpeg -f lavfi -i flite=list_voices=1 + // awb, kal, kal16, rms, slt + if voice := query.Get("voice"); voice != "" { + input += ":voice" + voice + } + + return input + `"` +} diff --git a/installs_on_host/go2rtc/internal/ffmpeg/virtual/virtual_test.go b/installs_on_host/go2rtc/internal/ffmpeg/virtual/virtual_test.go new file mode 100644 index 0000000..b648a9c --- /dev/null +++ b/installs_on_host/go2rtc/internal/ffmpeg/virtual/virtual_test.go @@ -0,0 +1,20 @@ +package virtual + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGetInput(t *testing.T) { + s := GetInput("video") + require.Equal(t, "-re -f lavfi -i testsrc=decimals=2:size=1920x1080", s) + + s = GetInput("video=testsrc2&size=4K") + require.Equal(t, "-re -f lavfi -i testsrc2=size=3840x2160", s) +} + +func TestGetInputTTS(t *testing.T) { + s := GetInputTTS("text=hello world&voice=slt") + require.Equal(t, `-re -f lavfi -i "flite=text='hello world':voiceslt"`, s) +} diff --git a/installs_on_host/go2rtc/internal/flussonic/README.md b/installs_on_host/go2rtc/internal/flussonic/README.md new file mode 100644 index 0000000..d4f4291 --- /dev/null +++ b/installs_on_host/go2rtc/internal/flussonic/README.md @@ -0,0 +1,5 @@ +# Flussonic + +[`new in v1.9.10`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.10) + +Support streams from [Flussonic](https://flussonic.com/) server. Related [issue](https://github.com/AlexxIT/go2rtc/issues/1678). diff --git a/installs_on_host/go2rtc/internal/flussonic/flussonic.go b/installs_on_host/go2rtc/internal/flussonic/flussonic.go new file mode 100644 index 0000000..6e87428 --- /dev/null +++ b/installs_on_host/go2rtc/internal/flussonic/flussonic.go @@ -0,0 +1,10 @@ +package flussonic + +import ( + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/flussonic" +) + +func Init() { + streams.HandleFunc("flussonic", flussonic.Dial) +} diff --git a/installs_on_host/go2rtc/internal/gopro/README.md b/installs_on_host/go2rtc/internal/gopro/README.md new file mode 100644 index 0000000..d55e31e --- /dev/null +++ b/installs_on_host/go2rtc/internal/gopro/README.md @@ -0,0 +1,29 @@ +# GoPro + +[`new in v1.8.3`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.3) + +Support streaming from [GoPro](https://gopro.com/) cameras, connected via USB or Wi-Fi to Linux, Mac, Windows. + +Supported models: HERO9, HERO10, HERO11, HERO12. +Supported OS: Linux, Mac, Windows, [HassOS](https://www.home-assistant.io/installation/) + +Other camera models have different APIs. I will try to add them in future versions. + +## Configuration + +- USB-connected cameras create a new network interface in the system +- Linux users do not need to install anything +- Windows users should install the [network driver](https://community.gopro.com/s/article/GoPro-Webcam) +- if the camera is detected but the stream does not start, you need to disable the firewall + +1. Discover camera address: WebUI > Add > GoPro +2. Add camera to config + +```yaml +streams: + hero12: gopro://172.20.100.51 +``` + +## Useful links + +- https://gopro.github.io/OpenGoPro/ diff --git a/installs_on_host/go2rtc/internal/gopro/gopro.go b/installs_on_host/go2rtc/internal/gopro/gopro.go new file mode 100644 index 0000000..ee57804 --- /dev/null +++ b/installs_on_host/go2rtc/internal/gopro/gopro.go @@ -0,0 +1,28 @@ +package gopro + +import ( + "net/http" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/gopro" +) + +func Init() { + streams.HandleFunc("gopro", func(source string) (core.Producer, error) { + return gopro.Dial(source) + }) + + api.HandleFunc("api/gopro", apiGoPro) +} + +func apiGoPro(w http.ResponseWriter, r *http.Request) { + var items []*api.Source + + for _, host := range gopro.Discovery() { + items = append(items, &api.Source{Name: host, URL: "gopro://" + host}) + } + + api.ResponseSources(w, items) +} diff --git a/installs_on_host/go2rtc/internal/hass/README.md b/installs_on_host/go2rtc/internal/hass/README.md new file mode 100644 index 0000000..0495c05 --- /dev/null +++ b/installs_on_host/go2rtc/internal/hass/README.md @@ -0,0 +1,41 @@ +# Hass + +Support import camera links from [Home Assistant](https://www.home-assistant.io/) config files: + +- [Generic Camera](https://www.home-assistant.io/integrations/generic/), setup via GUI +- [HomeKit Camera](https://www.home-assistant.io/integrations/homekit_controller/) +- [ONVIF](https://www.home-assistant.io/integrations/onvif/) +- [Roborock](https://github.com/humbertogontijo/homeassistant-roborock) vacuums with camera + +## Configuration + +```yaml +hass: + config: "/config" # skip this setting if you are a Home Assistant add-on user + +streams: + generic_camera: hass:Camera1 # Settings > Integrations > Integration Name + aqara_g3: hass:Camera-Hub-G3-AB12 +``` + +### WebRTC Cameras + +[`new in v1.6.0`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.6.0) + +Any cameras in WebRTC format are supported. But at the moment Home Assistant only supports some [Nest](https://www.home-assistant.io/integrations/nest/) cameras in this format. + +**Important.** The Nest API only allows you to get a link to a stream for 5 minutes. +Do not use this with Frigate! If the stream expires, Frigate will consume all available RAM on your machine within seconds. +It's recommended to use [Nest source](../nest/README.md) - it supports extending the stream. + +```yaml +streams: + # link to Home Assistant Supervised + hass-webrtc1: hass://supervisor?entity_id=camera.nest_doorbell + # link to external Home Assistant with Long-Lived Access Tokens + hass-webrtc2: hass://192.168.1.123:8123?entity_id=camera.nest_doorbell&token=eyXYZ... +``` + +### RTSP Cameras + +By default, the Home Assistant API does not allow you to get a dynamic RTSP link to a camera stream. [This method](https://github.com/felipecrs/hass-expose-camera-stream-source#importing-cameras-from-home-assistant-to-go2rtc-or-frigate) can work around it. diff --git a/installs_on_host/go2rtc/internal/hass/api.go b/installs_on_host/go2rtc/internal/hass/api.go new file mode 100644 index 0000000..9f110fc --- /dev/null +++ b/installs_on_host/go2rtc/internal/hass/api.go @@ -0,0 +1,104 @@ +package hass + +import ( + "encoding/base64" + "encoding/json" + "net" + "net/http" + "strings" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/internal/webrtc" +) + +func apiOK(w http.ResponseWriter, r *http.Request) { + api.Response(w, `{"status":1,"payload":{}}`, api.MimeJSON) +} + +func apiStream(w http.ResponseWriter, r *http.Request) { + switch { + // /stream/{id}/add + case strings.HasSuffix(r.RequestURI, "/add"): + var v addJSON + if err := json.NewDecoder(r.Body).Decode(&v); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // we can get three types of links: + // 1. link to go2rtc stream: rtsp://...:8554/{stream_name} + // 2. static link to Hass camera + // 3. dynamic link to Hass camera + if _, err := streams.Patch(v.Name, v.Channels.First.Url); err == nil { + apiOK(w, r) + } else { + http.Error(w, err.Error(), http.StatusBadRequest) + } + + // /stream/{id}/channel/0/webrtc + default: + i := strings.IndexByte(r.RequestURI[8:], '/') + if i <= 0 { + http.Error(w, "", http.StatusBadRequest) + return + } + + name := r.RequestURI[8 : 8+i] + stream := streams.Get(name) + if stream == nil { + http.Error(w, api.StreamNotFound, http.StatusNotFound) + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + s := r.FormValue("data") + offer, err := base64.StdEncoding.DecodeString(s) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + s, err = webrtc.ExchangeSDP(stream, string(offer), "hass/webrtc", r.UserAgent()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + s = base64.StdEncoding.EncodeToString([]byte(s)) + _, _ = w.Write([]byte(s)) + } +} + +func HassioAddr() string { + ints, _ := net.Interfaces() + + for _, i := range ints { + if i.Name != "hassio" { + continue + } + + addrs, _ := i.Addrs() + for _, addr := range addrs { + if addr, ok := addr.(*net.IPNet); ok { + return addr.IP.String() + } + } + } + + return "" +} + +type addJSON struct { + Name string `json:"name"` + Channels struct { + First struct { + //Name string `json:"name"` + Url string `json:"url"` + } `json:"0"` + } `json:"channels"` +} diff --git a/installs_on_host/go2rtc/internal/hass/hass.go b/installs_on_host/go2rtc/internal/hass/hass.go new file mode 100644 index 0000000..99c6369 --- /dev/null +++ b/installs_on_host/go2rtc/internal/hass/hass.go @@ -0,0 +1,219 @@ +package hass + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "path" + "strings" + "sync" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/roborock" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/hass" + "github.com/rs/zerolog" +) + +func Init() { + var conf struct { + API struct { + Listen string `yaml:"listen"` + } `yaml:"api"` + Mod struct { + Config string `yaml:"config"` + } `yaml:"hass"` + } + + app.LoadConfig(&conf) + + log = app.GetLogger("hass") + + // support API for https://www.home-assistant.io/integrations/rtsp_to_webrtc/ + api.HandleFunc("/static", apiOK) + api.HandleFunc("/streams", apiOK) + api.HandleFunc("/stream/", apiStream) + + streams.RedirectFunc("hass", func(rawURL string) (string, error) { + rawURL, rawQuery, _ := strings.Cut(rawURL, "#") + + if location := entities[rawURL[5:]]; location != "" { + if rawQuery != "" { + return location + "#" + rawQuery, nil + } + return location, nil + } + + return "", nil + }) + + streams.HandleFunc("hass", func(source string) (core.Producer, error) { + // support hass://supervisor?entity_id=camera.driveway_doorbell + return hass.NewClient(source) + }) + + // load static entries from Hass config + if err := importConfig(conf.Mod.Config); err != nil { + log.Trace().Msgf("[hass] can't import config: %s", err) + + api.HandleFunc("api/hass", func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "no hass config", http.StatusNotFound) + }) + return + } + + api.HandleFunc("api/hass", func(w http.ResponseWriter, _ *http.Request) { + once.Do(func() { + // load WebRTC entities from Hass API, works only for add-on version + if token := hass.SupervisorToken(); token != "" { + if err := importWebRTC(token); err != nil { + log.Warn().Err(err).Caller().Send() + } + } + }) + + var items []*api.Source + for name, url := range entities { + items = append(items, &api.Source{ + Name: name, URL: "hass:" + name, Location: url, + }) + } + api.ResponseSources(w, items) + }) + + // for Addon listen on hassio interface, so WebUI feature will work + if conf.API.Listen == "127.0.0.1:1984" { + if addr := HassioAddr(); addr != "" { + addr += ":1984" + go func() { + log.Info().Str("addr", addr).Msg("[hass] listen") + if err := http.ListenAndServe(addr, api.Handler); err != nil { + log.Error().Err(err).Caller().Send() + } + }() + } + } +} + +func importConfig(config string) error { + // support load cameras from Hass config file + filename := path.Join(config, ".storage/core.config_entries") + b, err := os.ReadFile(filename) + if err != nil { + return err + } + + var storage struct { + Data struct { + Entries []struct { + Title string `json:"title"` + Domain string `json:"domain"` + Data json.RawMessage `json:"data"` + Options json.RawMessage `json:"options"` + } `json:"entries"` + } `json:"data"` + } + + if err = json.Unmarshal(b, &storage); err != nil { + return err + } + + for _, entrie := range storage.Data.Entries { + switch entrie.Domain { + case "generic": + var options struct { + StreamSource string `json:"stream_source"` + } + if err = json.Unmarshal(entrie.Options, &options); err != nil { + continue + } + entities[entrie.Title] = options.StreamSource + + case "homekit_controller": + if !bytes.Contains(entrie.Data, []byte("iOSPairingId")) { + continue + } + + var data struct { + ClientID string `json:"iOSPairingId"` + ClientPrivate string `json:"iOSDeviceLTSK"` + ClientPublic string `json:"iOSDeviceLTPK"` + DeviceID string `json:"AccessoryPairingID"` + DevicePublic string `json:"AccessoryLTPK"` + DeviceHost string `json:"AccessoryIP"` + DevicePort uint16 `json:"AccessoryPort"` + } + if err = json.Unmarshal(entrie.Data, &data); err != nil { + continue + } + entities[entrie.Title] = fmt.Sprintf( + "homekit://%s:%d?client_id=%s&client_private=%s%s&device_id=%s&device_public=%s", + data.DeviceHost, data.DevicePort, + data.ClientID, data.ClientPrivate, data.ClientPublic, + data.DeviceID, data.DevicePublic, + ) + + case "roborock": + _ = json.Unmarshal(entrie.Data, &roborock.Auth) + + case "onvif": + var data struct { + Host string `json:"host" json:"host"` + Port uint16 `json:"port" json:"port"` + Username string `json:"username" json:"username"` + Password string `json:"password" json:"password"` + } + if err = json.Unmarshal(entrie.Data, &data); err != nil { + continue + } + + if data.Username != "" && data.Password != "" { + entities[entrie.Title] = fmt.Sprintf( + "onvif://%s:%s@%s:%d", data.Username, data.Password, data.Host, data.Port, + ) + } else { + entities[entrie.Title] = fmt.Sprintf("onvif://%s:%d", data.Host, data.Port) + } + + default: + continue + } + + log.Debug().Str("url", "hass:"+entrie.Title).Msg("[hass] load config") + //streams.Get("hass:" + entrie.Title) + } + + return nil +} + +func importWebRTC(token string) error { + hassAPI, err := hass.NewAPI("ws://supervisor/core/websocket", token) + if err != nil { + return err + } + + webrtcEntities, err := hassAPI.GetWebRTCEntities() + if err != nil { + return err + } + + if len(webrtcEntities) == 0 { + log.Debug().Msg("[hass] webrtc cameras not found") + } + + for name, entityID := range webrtcEntities { + entities[name] = "hass://supervisor?entity_id=" + entityID + + log.Debug().Msgf("[hass] load webrtc name=%s entity_id=%s", name, entityID) + } + + return nil +} + +var entities = map[string]string{} +var log zerolog.Logger +var once sync.Once diff --git a/installs_on_host/go2rtc/internal/hls/README.md b/installs_on_host/go2rtc/internal/hls/README.md new file mode 100644 index 0000000..64471bd --- /dev/null +++ b/installs_on_host/go2rtc/internal/hls/README.md @@ -0,0 +1,19 @@ +# HLS + +[`new in v1.1.0`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.1.0) + +[HLS](https://en.wikipedia.org/wiki/HTTP_Live_Streaming) is the worst technology for real-time streaming. +It can only be useful on devices that do not support more modern technology, like [WebRTC](../webrtc/README.md), [MP4](../mp4/README.md). + +The go2rtc implementation differs from the standards and may not work with all players. + +API examples: + +- HLS/TS stream: `http://192.168.1.123:1984/api/stream.m3u8?src=camera1` (H264) +- HLS/fMP4 stream: `http://192.168.1.123:1984/api/stream.m3u8?src=camera1&mp4` (H264, H265, AAC) + +Read more about [codecs filters](../../README.md#codecs-filters). + +## Useful links + +- https://walterebert.com/playground/video/hls/ diff --git a/installs_on_host/go2rtc/internal/hls/hls.go b/installs_on_host/go2rtc/internal/hls/hls.go new file mode 100644 index 0000000..5c13645 --- /dev/null +++ b/installs_on_host/go2rtc/internal/hls/hls.go @@ -0,0 +1,217 @@ +package hls + +import ( + "net/http" + "sync" + "time" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/api/ws" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/mp4" + "github.com/AlexxIT/go2rtc/pkg/mpegts" + "github.com/rs/zerolog" +) + +func Init() { + log = app.GetLogger("hls") + + api.HandleFunc("api/stream.m3u8", handlerStream) + api.HandleFunc("api/hls/playlist.m3u8", handlerPlaylist) + + // HLS (TS) + api.HandleFunc("api/hls/segment.ts", handlerSegmentTS) + + // HLS (fMP4) + api.HandleFunc("api/hls/init.mp4", handlerInit) + api.HandleFunc("api/hls/segment.m4s", handlerSegmentMP4) + + ws.HandleFunc("hls", handlerWSHLS) +} + +var log zerolog.Logger + +const keepalive = 5 * time.Second + +// once I saw 404 on MP4 segment, so better to use mutex +var sessions = map[string]*Session{} +var sessionsMu sync.RWMutex + +func handlerStream(w http.ResponseWriter, r *http.Request) { + // CORS important for Chromecast + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Content-Type", "application/vnd.apple.mpegurl") + + if r.Method == "OPTIONS" { + w.Header().Set("Access-Control-Allow-Methods", "GET") + return + } + + src := r.URL.Query().Get("src") + stream := streams.Get(src) + if stream == nil { + http.Error(w, api.StreamNotFound, http.StatusNotFound) + return + } + + var cons core.Consumer + + // use fMP4 with codecs filter and TS without + medias := mp4.ParseQuery(r.URL.Query()) + if medias != nil { + c := mp4.NewConsumer(medias) + c.FormatName = "hls/fmp4" + c.WithRequest(r) + cons = c + } else { + c := mpegts.NewConsumer() + c.FormatName = "hls/mpegts" + c.WithRequest(r) + cons = c + } + + if err := stream.AddConsumer(cons); err != nil { + log.Error().Err(err).Caller().Send() + return + } + + session := NewSession(cons) + session.alive = time.AfterFunc(keepalive, func() { + sessionsMu.Lock() + delete(sessions, session.id) + sessionsMu.Unlock() + + stream.RemoveConsumer(cons) + }) + + sessionsMu.Lock() + sessions[session.id] = session + sessionsMu.Unlock() + + go session.Run() + + if _, err := w.Write(session.Main()); err != nil { + log.Error().Err(err).Caller().Send() + } +} + +func handlerPlaylist(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Content-Type", "application/vnd.apple.mpegurl") + + if r.Method == "OPTIONS" { + w.Header().Set("Access-Control-Allow-Methods", "GET") + return + } + + sid := r.URL.Query().Get("id") + sessionsMu.RLock() + session := sessions[sid] + sessionsMu.RUnlock() + if session == nil { + http.NotFound(w, r) + return + } + + if _, err := w.Write(session.Playlist()); err != nil { + log.Error().Err(err).Caller().Send() + } +} + +func handlerSegmentTS(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Content-Type", "video/mp2t") + + if r.Method == "OPTIONS" { + w.Header().Set("Access-Control-Allow-Methods", "GET") + return + } + + sid := r.URL.Query().Get("id") + sessionsMu.RLock() + session := sessions[sid] + sessionsMu.RUnlock() + if session == nil { + http.NotFound(w, r) + return + } + + session.alive.Reset(keepalive) + + data := session.Segment() + if data == nil { + log.Warn().Msgf("[hls] can't get segment %s", r.URL.RawQuery) + http.NotFound(w, r) + return + } + + if _, err := w.Write(data); err != nil { + log.Error().Err(err).Caller().Send() + } +} + +func handlerInit(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Add("Content-Type", "video/mp4") + + if r.Method == "OPTIONS" { + w.Header().Set("Access-Control-Allow-Methods", "GET") + return + } + + sid := r.URL.Query().Get("id") + sessionsMu.RLock() + session := sessions[sid] + sessionsMu.RUnlock() + if session == nil { + http.NotFound(w, r) + return + } + + data := session.Init() + if data == nil { + log.Warn().Msgf("[hls] can't get init %s", r.URL.RawQuery) + http.NotFound(w, r) + return + } + + if _, err := w.Write(data); err != nil { + log.Error().Err(err).Caller().Send() + } +} + +func handlerSegmentMP4(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Add("Content-Type", "video/iso.segment") + + if r.Method == "OPTIONS" { + w.Header().Set("Access-Control-Allow-Methods", "GET") + return + } + + query := r.URL.Query() + + sid := query.Get("id") + sessionsMu.RLock() + session := sessions[sid] + sessionsMu.RUnlock() + if session == nil { + http.NotFound(w, r) + return + } + + session.alive.Reset(keepalive) + + data := session.Segment() + if data == nil { + log.Warn().Msgf("[hls] can't get segment %s", r.URL.RawQuery) + http.NotFound(w, r) + return + } + + if _, err := w.Write(data); err != nil { + log.Error().Err(err).Caller().Send() + } +} diff --git a/installs_on_host/go2rtc/internal/hls/session.go b/installs_on_host/go2rtc/internal/hls/session.go new file mode 100644 index 0000000..9f07ad4 --- /dev/null +++ b/installs_on_host/go2rtc/internal/hls/session.go @@ -0,0 +1,127 @@ +package hls + +import ( + "fmt" + "io" + "strings" + "sync" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/mp4" +) + +type Session struct { + cons core.Consumer + id string + template string + init []byte + buffer []byte + seq int + alive *time.Timer + mu sync.Mutex +} + +func NewSession(cons core.Consumer) *Session { + s := &Session{ + id: core.RandString(8, 62), + cons: cons, + } + + // two segments important for Chromecast + if _, ok := cons.(*mp4.Consumer); ok { + s.template = `#EXTM3U +#EXT-X-VERSION:6 +#EXT-X-TARGETDURATION:1 +#EXT-X-MEDIA-SEQUENCE:%d +#EXT-X-MAP:URI="init.mp4?id=` + s.id + `" +#EXTINF:0.500, +segment.m4s?id=` + s.id + `&n=%d +#EXTINF:0.500, +segment.m4s?id=` + s.id + `&n=%d` + } else { + s.template = `#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:1 +#EXT-X-MEDIA-SEQUENCE:%d +#EXTINF:0.500, +segment.ts?id=` + s.id + `&n=%d +#EXTINF:0.500, +segment.ts?id=` + s.id + `&n=%d` + } + + return s +} + +func (s *Session) Write(p []byte) (n int, err error) { + s.mu.Lock() + if s.init == nil { + s.init = p + } else { + s.buffer = append(s.buffer, p...) + } + s.mu.Unlock() + return len(p), nil +} + +func (s *Session) Run() { + _, _ = s.cons.(io.WriterTo).WriteTo(s) +} + +func (s *Session) Main() []byte { + type withCodecs interface { + Codecs() []*core.Codec + } + + codecs := mp4.MimeCodecs(s.cons.(withCodecs).Codecs()) + codecs = strings.Replace(codecs, mp4.MimeFlac, "fLaC", 1) + + // bandwidth important for Safari, codecs useful for smooth playback + return []byte(`#EXTM3U +#EXT-X-STREAM-INF:BANDWIDTH=192000,CODECS="` + codecs + `" +hls/playlist.m3u8?id=` + s.id) +} + +func (s *Session) Playlist() []byte { + return []byte(fmt.Sprintf(s.template, s.seq, s.seq, s.seq+1)) +} + +func (s *Session) Init() (init []byte) { + for i := 0; i < 60 && init == nil; i++ { + if i > 0 { + time.Sleep(50 * time.Millisecond) + } + + s.mu.Lock() + // return init only when have some buffer + if len(s.buffer) > 0 { + init = s.init + } + s.mu.Unlock() + } + + return +} + +func (s *Session) Segment() (segment []byte) { + for i := 0; i < 60 && segment == nil; i++ { + if i > 0 { + time.Sleep(50 * time.Millisecond) + } + + s.mu.Lock() + if len(s.buffer) > 0 { + segment = s.buffer + if _, ok := s.cons.(*mp4.Consumer); ok { + s.buffer = nil + } else { + // for TS important to start new segment with init + s.buffer = s.init + } + s.seq++ + } + s.mu.Unlock() + } + + return +} diff --git a/installs_on_host/go2rtc/internal/hls/ws.go b/installs_on_host/go2rtc/internal/hls/ws.go new file mode 100644 index 0000000..00eedfe --- /dev/null +++ b/installs_on_host/go2rtc/internal/hls/ws.go @@ -0,0 +1,52 @@ +package hls + +import ( + "errors" + "time" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/api/ws" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/mp4" +) + +func handlerWSHLS(tr *ws.Transport, msg *ws.Message) error { + stream, _ := streams.GetOrPatch(tr.Request.URL.Query()) + if stream == nil { + return errors.New(api.StreamNotFound) + } + + codecs := msg.String() + medias := mp4.ParseCodecs(codecs, true) + cons := mp4.NewConsumer(medias) + cons.FormatName = "hls/fmp4" + cons.WithRequest(tr.Request) + + log.Trace().Msgf("[hls] new ws consumer codecs=%s", codecs) + + if err := stream.AddConsumer(cons); err != nil { + log.Error().Err(err).Caller().Send() + return err + } + + session := NewSession(cons) + + session.alive = time.AfterFunc(keepalive, func() { + sessionsMu.Lock() + delete(sessions, session.id) + sessionsMu.Unlock() + + stream.RemoveConsumer(cons) + }) + + sessionsMu.Lock() + sessions[session.id] = session + sessionsMu.Unlock() + + go session.Run() + + main := session.Main() + tr.Write(&ws.Message{Type: "hls", Value: string(main)}) + + return nil +} diff --git a/installs_on_host/go2rtc/internal/homekit/README.md b/installs_on_host/go2rtc/internal/homekit/README.md new file mode 100644 index 0000000..0e78fcc --- /dev/null +++ b/installs_on_host/go2rtc/internal/homekit/README.md @@ -0,0 +1,97 @@ +# Apple HomeKit + +This module supports both client and server for the [Apple HomeKit](https://www.apple.com/home-app/accessories/) protocol. + +## HomeKit Client + +**Important:** + +- You can use HomeKit Cameras **without Apple devices** (iPhone, iPad, etc.), it's just a yet another protocol +- HomeKit device can be paired with only one ecosystem. So, if you have paired it to an iPhone (Apple Home), you can't pair it with Home Assistant or go2rtc. Or if you have paired it to go2rtc, you can't pair it with an iPhone +- HomeKit device should be on the same network with working [mDNS](https://en.wikipedia.org/wiki/Multicast_DNS) between the device and go2rtc + +go2rtc supports importing paired HomeKit devices from [Home Assistant](../hass/README.md). +So you can use HomeKit camera with Home Assistant and go2rtc simultaneously. +If you are using Home Assistant, I recommend pairing devices with it; it will give you more options. + +You can pair device with go2rtc on the HomeKit page. If you can't see your devices, reload the page. +Also, try rebooting your HomeKit device (power off). If you still can't see it, you have a problem with mDNS. + +If you see a device but it does not have a pairing button, it is paired to some ecosystem (Apple Home, Home Assistant, HomeBridge, etc.). You need to delete the device from that ecosystem, and it will be available for pairing. If you cannot unpair the device, you will have to reset it. + +**Important:** + +- HomeKit audio uses very non-standard **AAC-ELD** codec with very non-standard params and specification violations +- Audio can't be played in `VLC` and probably any other player +- Audio should be transcoded for use with MSE, WebRTC, etc. + +### Client Configuration + +Recommended settings for using HomeKit Camera with WebRTC, MSE, MP4, RTSP: + +```yaml +streams: + aqara_g3: + - hass:Camera-Hub-G3-AB12 + - ffmpeg:aqara_g3#audio=aac#audio=opus +``` + +RTSP link with "normal" audio for any player: `rtsp://192.168.1.123:8554/aqara_g3?video&audio=aac` + +**This source is in active development!** Tested only with [Aqara Camera Hub G3](https://www.aqara.com/eu/product/camera-hub-g3) (both EU and CN versions). + +## HomeKit Server + +[`new in v1.7.0`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.7.0) + +HomeKit module can work in two modes: + +- export any H264 camera to Apple HomeKit +- transparent proxy any Apple HomeKit camera (Aqara, Eve, Eufy, etc.) back to Apple HomeKit, so you will have all camera features in Apple Home and also will have RTSP/WebRTC/MP4/etc. from your HomeKit camera + +**Important** + +- HomeKit cameras support only H264 video and OPUS audio + +### Server Configuration + +**Minimal config** + +```yaml +streams: + dahua1: rtsp://admin:password@192.168.1.123/cam/realmonitor?channel=1&subtype=0 +homekit: + dahua1: # same stream ID from streams list, default PIN - 19550224 +``` + +**Full config** + +```yaml +streams: + dahua1: + - rtsp://admin:password@192.168.1.123/cam/realmonitor?channel=1&subtype=0 + - ffmpeg:dahua1#video=h264#hardware # if your camera doesn't support H264, important for HomeKit + - ffmpeg:dahua1#audio=opus # only OPUS audio supported by HomeKit + +homekit: + dahua1: # same stream ID from streams list + pin: 12345678 # custom PIN, default: 19550224 + name: Dahua camera # custom camera name, default: generated from stream ID + device_id: dahua1 # custom ID, default: generated from stream ID + device_private: dahua1 # custom key, default: generated from stream ID +``` + +**Proxy HomeKit camera** + +- Video stream from HomeKit camera to Apple device (iPhone, Apple TV) will be transmitted directly +- Video stream from HomeKit camera to RTSP/WebRTC/MP4/etc. will be transmitted via go2rtc + +```yaml +streams: + aqara1: + - homekit://... + - ffmpeg:aqara1#audio=aac#audio=opus # optional audio transcoding + +homekit: + aqara1: # same stream ID from streams list +``` diff --git a/installs_on_host/go2rtc/internal/homekit/api.go b/installs_on_host/go2rtc/internal/homekit/api.go new file mode 100644 index 0000000..885a40f --- /dev/null +++ b/installs_on_host/go2rtc/internal/homekit/api.go @@ -0,0 +1,181 @@ +package homekit + +import ( + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/hap" + "github.com/AlexxIT/go2rtc/pkg/mdns" +) + +func apiDiscovery(w http.ResponseWriter, r *http.Request) { + sources, err := discovery() + if err != nil { + api.Error(w, err) + return + } + + urls := findHomeKitURLs() + for id, u := range urls { + deviceID := u.Query().Get("device_id") + for _, source := range sources { + if strings.Contains(source.URL, deviceID) { + source.Location = id + break + } + } + } + + for _, source := range sources { + if source.Location == "" { + source.Location = " " + } + } + + api.ResponseSources(w, sources) +} + +func apiHomekit(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + switch r.Method { + case "GET": + if id := r.Form.Get("id"); id != "" { + if srv := servers[id]; srv != nil { + api.ResponsePrettyJSON(w, srv) + } else { + http.Error(w, "server not found", http.StatusNotFound) + } + } else { + api.ResponsePrettyJSON(w, servers) + } + + case "POST": + id := r.Form.Get("id") + rawURL := r.Form.Get("src") + "&pin=" + r.Form.Get("pin") + if err := apiPair(id, rawURL); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + + case "DELETE": + id := r.Form.Get("id") + if err := apiUnpair(id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } +} + +func apiHomekitAccessories(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + stream := streams.Get(id) + if stream == nil { + http.Error(w, "", http.StatusNotFound) + return + } + + rawURL := findHomeKitURL(stream.Sources()) + if rawURL == "" { + http.Error(w, "", http.StatusBadRequest) + return + } + + client, err := hap.Dial(rawURL) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer client.Close() + + res, err := client.Get(hap.PathAccessories) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", api.MimeJSON) + _, _ = io.Copy(w, res.Body) +} + +func discovery() ([]*api.Source, error) { + var sources []*api.Source + + // 1. Get streams from Discovery + err := mdns.Discovery(mdns.ServiceHAP, func(entry *mdns.ServiceEntry) bool { + log.Trace().Msgf("[homekit] mdns=%s", entry) + + category := entry.Info[hap.TXTCategory] + if entry.Complete() && (category == hap.CategoryCamera || category == hap.CategoryDoorbell) { + source := &api.Source{ + Name: entry.Name, + Info: entry.Info[hap.TXTModel], + URL: fmt.Sprintf( + "homekit://%s:%d?device_id=%s&feature=%s&status=%s", + entry.IP, entry.Port, entry.Info[hap.TXTDeviceID], + entry.Info[hap.TXTFeatureFlags], entry.Info[hap.TXTStatusFlags], + ), + } + + sources = append(sources, source) + } + return false + }) + + if err != nil { + return nil, err + } + + return sources, nil +} + +func apiPair(id, url string) error { + conn, err := hap.Pair(url) + if err != nil { + return err + } + + streams.New(id, conn.URL()) + + return app.PatchConfig([]string{"streams", id}, conn.URL()) +} + +func apiUnpair(id string) error { + stream := streams.Get(id) + if stream == nil { + return errors.New(api.StreamNotFound) + } + + rawURL := findHomeKitURL(stream.Sources()) + if rawURL == "" { + return errors.New("not homekit source") + } + + if err := hap.Unpair(rawURL); err != nil { + return err + } + + streams.Delete(id) + + return app.PatchConfig([]string{"streams", id}, nil) +} + +func findHomeKitURLs() map[string]*url.URL { + urls := map[string]*url.URL{} + for name, sources := range streams.GetAllSources() { + if rawURL := findHomeKitURL(sources); rawURL != "" { + if u, err := url.Parse(rawURL); err == nil { + urls[name] = u + } + } + } + return urls +} diff --git a/installs_on_host/go2rtc/internal/homekit/homekit.go b/installs_on_host/go2rtc/internal/homekit/homekit.go new file mode 100644 index 0000000..59b84b3 --- /dev/null +++ b/installs_on_host/go2rtc/internal/homekit/homekit.go @@ -0,0 +1,211 @@ +package homekit + +import ( + "errors" + "net/http" + "strings" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/srtp" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/hap" + "github.com/AlexxIT/go2rtc/pkg/hap/camera" + "github.com/AlexxIT/go2rtc/pkg/homekit" + "github.com/AlexxIT/go2rtc/pkg/mdns" + "github.com/rs/zerolog" +) + +func Init() { + var cfg struct { + Mod map[string]struct { + Pin string `yaml:"pin"` + Name string `yaml:"name"` + DeviceID string `yaml:"device_id"` + DevicePrivate string `yaml:"device_private"` + CategoryID string `yaml:"category_id"` + Pairings []string `yaml:"pairings"` + } `yaml:"homekit"` + } + app.LoadConfig(&cfg) + + log = app.GetLogger("homekit") + + streams.HandleFunc("homekit", streamHandler) + + api.HandleFunc("api/homekit", apiHomekit) + api.HandleFunc("api/homekit/accessories", apiHomekitAccessories) + api.HandleFunc("api/discovery/homekit", apiDiscovery) + + if cfg.Mod == nil { + return + } + + hosts = map[string]*server{} + servers = map[string]*server{} + var entries []*mdns.ServiceEntry + + for id, conf := range cfg.Mod { + stream := streams.Get(id) + if stream == nil { + log.Warn().Msgf("[homekit] missing stream: %s", id) + continue + } + + if conf.Pin == "" { + conf.Pin = "19550224" // default PIN + } + + pin, err := hap.SanitizePin(conf.Pin) + if err != nil { + log.Error().Err(err).Caller().Send() + continue + } + + deviceID := calcDeviceID(conf.DeviceID, id) // random MAC-address + name := calcName(conf.Name, deviceID) + setupID := calcSetupID(id) + + srv := &server{ + stream: id, + pairings: conf.Pairings, + setupID: setupID, + } + + srv.hap = &hap.Server{ + Pin: pin, + DeviceID: deviceID, + DevicePrivate: calcDevicePrivate(conf.DevicePrivate, id), + GetClientPublic: srv.GetPair, + } + + srv.mdns = &mdns.ServiceEntry{ + Name: name, + Port: uint16(api.Port), + Info: map[string]string{ + hap.TXTConfigNumber: "1", + hap.TXTFeatureFlags: "0", + hap.TXTDeviceID: deviceID, + hap.TXTModel: app.UserAgent, + hap.TXTProtoVersion: "1.1", + hap.TXTStateNumber: "1", + hap.TXTStatusFlags: hap.StatusNotPaired, + hap.TXTCategory: calcCategoryID(conf.CategoryID), + hap.TXTSetupHash: hap.SetupHash(setupID, deviceID), + }, + } + entries = append(entries, srv.mdns) + + srv.UpdateStatus() + + if url := findHomeKitURL(stream.Sources()); url != "" { + // 1. Act as transparent proxy for HomeKit camera + srv.proxyURL = url + } else { + // 2. Act as basic HomeKit camera + srv.accessory = camera.NewAccessory("AlexxIT", "go2rtc", name, "-", app.Version) + } + + host := srv.mdns.Host(mdns.ServiceHAP) + hosts[host] = srv + servers[id] = srv + + log.Trace().Msgf("[homekit] new server: %s", srv.mdns) + } + + api.HandleFunc(hap.PathPairSetup, hapHandler) + api.HandleFunc(hap.PathPairVerify, hapHandler) + + go func() { + if err := mdns.Serve(mdns.ServiceHAP, entries); err != nil { + log.Error().Err(err).Caller().Send() + } + }() +} + +var log zerolog.Logger +var hosts map[string]*server +var servers map[string]*server + +func streamHandler(rawURL string) (core.Producer, error) { + if srtp.Server == nil { + return nil, errors.New("homekit: can't work without SRTP server") + } + + rawURL, rawQuery, _ := strings.Cut(rawURL, "#") + client, err := homekit.Dial(rawURL, srtp.Server) + if client != nil && rawQuery != "" { + query := streams.ParseQuery(rawQuery) + client.MaxWidth = core.Atoi(query.Get("maxwidth")) + client.MaxHeight = core.Atoi(query.Get("maxheight")) + client.Bitrate = parseBitrate(query.Get("bitrate")) + } + + return client, err +} + +func resolve(host string) *server { + if len(hosts) == 1 { + for _, srv := range hosts { + return srv + } + } + if srv, ok := hosts[host]; ok { + return srv + } + return nil +} + +func hapHandler(w http.ResponseWriter, r *http.Request) { + // Can support multiple HomeKit cameras on single port ONLY for Apple devices. + // Doesn't support Home Assistant and any other open source projects + // because they don't send the host header in requests. + srv := resolve(r.Host) + if srv == nil { + log.Error().Msg("[homekit] unknown host: " + r.Host) + return + } + srv.Handle(w, r) +} + +func findHomeKitURL(sources []string) string { + if len(sources) == 0 { + return "" + } + + url := sources[0] + if strings.HasPrefix(url, "homekit") { + return url + } + + if strings.HasPrefix(url, "hass") { + location, _ := streams.Location(url) + if strings.HasPrefix(location, "homekit") { + return location + } + } + + return "" +} + +func parseBitrate(s string) int { + n := len(s) + if n == 0 { + return 0 + } + + var k int + switch n--; s[n] { + case 'K': + k = 1024 + s = s[:n] + case 'M': + k = 1024 * 1024 + s = s[:n] + default: + k = 1 + } + + return k * core.Atoi(s) +} diff --git a/installs_on_host/go2rtc/internal/homekit/server.go b/installs_on_host/go2rtc/internal/homekit/server.go new file mode 100644 index 0000000..86cfbc1 --- /dev/null +++ b/installs_on_host/go2rtc/internal/homekit/server.go @@ -0,0 +1,405 @@ +package homekit + +import ( + "crypto/ed25519" + "crypto/sha512" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "slices" + "strings" + "sync" + + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/ffmpeg" + srtp2 "github.com/AlexxIT/go2rtc/internal/srtp" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/hap" + "github.com/AlexxIT/go2rtc/pkg/hap/camera" + "github.com/AlexxIT/go2rtc/pkg/hap/hds" + "github.com/AlexxIT/go2rtc/pkg/hap/tlv8" + "github.com/AlexxIT/go2rtc/pkg/homekit" + "github.com/AlexxIT/go2rtc/pkg/magic" + "github.com/AlexxIT/go2rtc/pkg/mdns" +) + +type server struct { + hap *hap.Server // server for HAP connection and encryption + mdns *mdns.ServiceEntry + + pairings []string // pairings list + conns []any + mu sync.Mutex + + accessory *hap.Accessory // HAP accessory + consumer *homekit.Consumer + proxyURL string + setupID string + stream string // stream name from YAML +} + +func (s *server) MarshalJSON() ([]byte, error) { + v := struct { + Name string `json:"name"` + DeviceID string `json:"device_id"` + Paired int `json:"paired,omitempty"` + CategoryID string `json:"category_id,omitempty"` + SetupCode string `json:"setup_code,omitempty"` + SetupID string `json:"setup_id,omitempty"` + Conns []any `json:"connections,omitempty"` + }{ + Name: s.mdns.Name, + DeviceID: s.mdns.Info[hap.TXTDeviceID], + CategoryID: s.mdns.Info[hap.TXTCategory], + Paired: len(s.pairings), + Conns: s.conns, + } + if v.Paired == 0 { + v.SetupCode = s.hap.Pin + v.SetupID = s.setupID + } + return json.Marshal(v) +} + +func (s *server) Handle(w http.ResponseWriter, r *http.Request) { + conn, rw, err := w.(http.Hijacker).Hijack() + if err != nil { + return + } + + defer conn.Close() + + // Fix reading from Body after Hijack. + r.Body = io.NopCloser(rw) + + switch r.RequestURI { + case hap.PathPairSetup: + id, key, err := s.hap.PairSetup(r, rw) + if err != nil { + log.Error().Err(err).Caller().Send() + return + } + + s.AddPair(id, key, hap.PermissionAdmin) + + case hap.PathPairVerify: + id, key, err := s.hap.PairVerify(r, rw) + if err != nil { + log.Debug().Err(err).Caller().Send() + return + } + + log.Debug().Str("stream", s.stream).Str("client_id", id).Msgf("[homekit] %s: new conn", conn.RemoteAddr()) + + controller, err := hap.NewConn(conn, rw, key, false) + if err != nil { + log.Error().Err(err).Caller().Send() + return + } + + s.AddConn(controller) + defer s.DelConn(controller) + + var handler homekit.HandlerFunc + + switch { + case s.accessory != nil: + handler = homekit.ServerHandler(s) + case s.proxyURL != "": + client, err := hap.Dial(s.proxyURL) + if err != nil { + log.Error().Err(err).Caller().Send() + return + } + handler = homekit.ProxyHandler(s, client.Conn) + } + + // If your iPhone goes to sleep, it will be an EOF error. + if err = handler(controller); err != nil && !errors.Is(err, io.EOF) { + log.Error().Err(err).Caller().Send() + return + } + } +} + +type logger struct { + v any +} + +func (l logger) String() string { + switch v := l.v.(type) { + case *hap.Conn: + return "hap " + v.RemoteAddr().String() + case *hds.Conn: + return "hds " + v.RemoteAddr().String() + case *homekit.Consumer: + return "rtp " + v.RemoteAddr + } + return "unknown" +} + +func (s *server) AddConn(v any) { + log.Trace().Str("stream", s.stream).Msgf("[homekit] add conn %s", logger{v}) + s.mu.Lock() + s.conns = append(s.conns, v) + s.mu.Unlock() +} + +func (s *server) DelConn(v any) { + log.Trace().Str("stream", s.stream).Msgf("[homekit] del conn %s", logger{v}) + s.mu.Lock() + if i := slices.Index(s.conns, v); i >= 0 { + s.conns = slices.Delete(s.conns, i, i+1) + } + s.mu.Unlock() +} + +func (s *server) UpdateStatus() { + // true status is important, or device may be offline in Apple Home + if len(s.pairings) == 0 { + s.mdns.Info[hap.TXTStatusFlags] = hap.StatusNotPaired + } else { + s.mdns.Info[hap.TXTStatusFlags] = hap.StatusPaired + } +} + +func (s *server) pairIndex(id string) int { + id = "client_id=" + id + for i, pairing := range s.pairings { + if strings.HasPrefix(pairing, id) { + return i + } + } + return -1 +} + +func (s *server) GetPair(id string) []byte { + s.mu.Lock() + defer s.mu.Unlock() + + if i := s.pairIndex(id); i >= 0 { + query, _ := url.ParseQuery(s.pairings[i]) + b, _ := hex.DecodeString(query.Get("client_public")) + return b + } + return nil +} + +func (s *server) AddPair(id string, public []byte, permissions byte) { + log.Debug().Str("stream", s.stream).Msgf("[homekit] add pair id=%s public=%x perm=%d", id, public, permissions) + + s.mu.Lock() + if s.pairIndex(id) < 0 { + s.pairings = append(s.pairings, fmt.Sprintf( + "client_id=%s&client_public=%x&permissions=%d", id, public, permissions, + )) + s.UpdateStatus() + s.PatchConfig() + } + s.mu.Unlock() +} + +func (s *server) DelPair(id string) { + log.Debug().Str("stream", s.stream).Msgf("[homekit] del pair id=%s", id) + + s.mu.Lock() + if i := s.pairIndex(id); i >= 0 { + s.pairings = append(s.pairings[:i], s.pairings[i+1:]...) + s.UpdateStatus() + s.PatchConfig() + } + s.mu.Unlock() +} + +func (s *server) PatchConfig() { + if err := app.PatchConfig([]string{"homekit", s.stream, "pairings"}, s.pairings); err != nil { + log.Error().Err(err).Msgf( + "[homekit] can't save %s pairings=%v", s.stream, s.pairings, + ) + } +} + +func (s *server) GetAccessories(_ net.Conn) []*hap.Accessory { + return []*hap.Accessory{s.accessory} +} + +func (s *server) GetCharacteristic(conn net.Conn, aid uint8, iid uint64) any { + log.Trace().Str("stream", s.stream).Msgf("[homekit] get char aid=%d iid=0x%x", aid, iid) + + char := s.accessory.GetCharacterByID(iid) + if char == nil { + log.Warn().Msgf("[homekit] get unknown characteristic: %d", iid) + return nil + } + + switch char.Type { + case camera.TypeSetupEndpoints: + consumer := s.consumer + if consumer == nil { + return nil + } + + answer := consumer.GetAnswer() + v, err := tlv8.MarshalBase64(answer) + if err != nil { + return nil + } + + return v + } + + return char.Value +} + +func (s *server) SetCharacteristic(conn net.Conn, aid uint8, iid uint64, value any) { + log.Trace().Str("stream", s.stream).Msgf("[homekit] set char aid=%d iid=0x%x value=%v", aid, iid, value) + + char := s.accessory.GetCharacterByID(iid) + if char == nil { + log.Warn().Msgf("[homekit] set unknown characteristic: %d", iid) + return + } + + switch char.Type { + case camera.TypeSetupEndpoints: + var offer camera.SetupEndpointsRequest + if err := tlv8.UnmarshalBase64(value, &offer); err != nil { + return + } + + consumer := homekit.NewConsumer(conn, srtp2.Server) + consumer.SetOffer(&offer) + s.consumer = consumer + + case camera.TypeSelectedStreamConfiguration: + var conf camera.SelectedStreamConfiguration + if err := tlv8.UnmarshalBase64(value, &conf); err != nil { + return + } + + log.Trace().Str("stream", s.stream).Msgf("[homekit] stream id=%x cmd=%d", conf.Control.SessionID, conf.Control.Command) + + switch conf.Control.Command { + case camera.SessionCommandEnd: + for _, consumer := range s.conns { + if consumer, ok := consumer.(*homekit.Consumer); ok { + if consumer.SessionID() == conf.Control.SessionID { + _ = consumer.Stop() + return + } + } + } + + case camera.SessionCommandStart: + consumer := s.consumer + if consumer == nil { + return + } + + if !consumer.SetConfig(&conf) { + log.Warn().Msgf("[homekit] wrong config") + return + } + + s.AddConn(consumer) + + stream := streams.Get(s.stream) + if err := stream.AddConsumer(consumer); err != nil { + return + } + + go func() { + _, _ = consumer.WriteTo(nil) + stream.RemoveConsumer(consumer) + + s.DelConn(consumer) + }() + } + } +} + +func (s *server) GetImage(conn net.Conn, width, height int) []byte { + log.Trace().Str("stream", s.stream).Msgf("[homekit] get image width=%d height=%d", width, height) + + stream := streams.Get(s.stream) + cons := magic.NewKeyframe() + + if err := stream.AddConsumer(cons); err != nil { + return nil + } + + once := &core.OnceBuffer{} // init and first frame + _, _ = cons.WriteTo(once) + b := once.Buffer() + + stream.RemoveConsumer(cons) + + switch cons.CodecName() { + case core.CodecH264, core.CodecH265: + var err error + if b, err = ffmpeg.JPEGWithScale(b, width, height); err != nil { + return nil + } + } + + return b +} + +func calcName(name, seed string) string { + if name != "" { + return name + } + b := sha512.Sum512([]byte(seed)) + return fmt.Sprintf("go2rtc-%02X%02X", b[0], b[2]) +} + +func calcDeviceID(deviceID, seed string) string { + if deviceID != "" { + if len(deviceID) >= 17 { + // 1. Returd device_id as is (ex. AA:BB:CC:DD:EE:FF) + return deviceID + } + // 2. Use device_id as seed if not zero + seed = deviceID + } + b := sha512.Sum512([]byte(seed)) + return fmt.Sprintf("%02X:%02X:%02X:%02X:%02X:%02X", b[32], b[34], b[36], b[38], b[40], b[42]) +} + +func calcDevicePrivate(private, seed string) []byte { + if private != "" { + // 1. Decode private from HEX string + if b, _ := hex.DecodeString(private); len(b) == ed25519.PrivateKeySize { + // 2. Return if OK + return b + } + // 3. Use private as seed if not zero + seed = private + } + b := sha512.Sum512([]byte(seed)) + return ed25519.NewKeyFromSeed(b[:ed25519.SeedSize]) +} + +func calcSetupID(seed string) string { + b := sha512.Sum512([]byte(seed)) + return fmt.Sprintf("%02X%02X", b[44], b[46]) +} + +func calcCategoryID(categoryID string) string { + switch categoryID { + case "bridge": + return hap.CategoryBridge + case "doorbell": + return hap.CategoryDoorbell + } + if core.Atoi(categoryID) > 0 { + return categoryID + } + return hap.CategoryCamera +} diff --git a/installs_on_host/go2rtc/internal/http/README.md b/installs_on_host/go2rtc/internal/http/README.md new file mode 100644 index 0000000..b8147b6 --- /dev/null +++ b/installs_on_host/go2rtc/internal/http/README.md @@ -0,0 +1,47 @@ +# HTTP + +This source supports receiving a stream via an HTTP link. + +It can determine the source format from the`Content-Type` HTTP header: + +- **HTTP-JPEG** (`image/jpeg`) - camera snapshot link, can be converted by go2rtc to MJPEG stream +- **HTTP-MJPEG** (`multipart/x-mixed-replace`) - A continuous sequence of JPEG frames (with HTTP headers). +- **HLS** (`application/vnd.apple.mpegurl`) - A popular [HTTP Live Streaming](https://en.wikipedia.org/wiki/HTTP_Live_Streaming) (HLS) format, which is not designed for real-time media transmission. + +> [!WARNING] +> The HLS format is not designed for real time and is supported quite poorly. It is recommended to use it via ffmpeg source with buffering enabled (disabled by default). + +## TCP + +Source also supports HTTP and TCP streams with autodetection for different formats: + +- `adts` - Audio stream in [AAC](https://en.wikipedia.org/wiki/Advanced_Audio_Coding) codec with Audio Data Transport Stream (ADTS) headers. +- `flv` - The legacy but still used [Flash Video](https://en.wikipedia.org/wiki/Flash_Video) format. +- `h264` - AVC/H.264 bitstream. +- `hevc` - HEVC/H.265 bitstream. +- `mjpeg` - A continuous sequence of JPEG frames (without HTTP headers). +- `mpegts` - The legacy [MPEG transport stream](https://en.wikipedia.org/wiki/MPEG_transport_stream) format. +- `wav` - Audio stream in [WAV](https://en.wikipedia.org/wiki/WAV) format. +- `yuv4mpegpipe` - Raw YUV frame stream with YUV4MPEG header. + +## Configuration + +```yaml +streams: + # [HTTP-FLV] stream in video/x-flv format + http_flv: http://192.168.1.123:20880/api/camera/stream/780900131155/657617 + + # [JPEG] snapshots from Dahua camera, will be converted to MJPEG stream + dahua_snap: http://admin:password@192.168.1.123/cgi-bin/snapshot.cgi?channel=1 + + # [MJPEG] stream will be proxied without modification + http_mjpeg: https://mjpeg.sanford.io/count.mjpeg + + # [MJPEG or H.264/H.265 bitstream or MPEG-TS] + tcp_magic: tcp://192.168.1.123:12345 + + # Add custom header + custom_header: "https://mjpeg.sanford.io/count.mjpeg#header=Authorization: Bearer XXX" +``` + +**PS.** Dahua camera has a bug: if you select MJPEG codec for RTSP second stream, snapshot won't work. diff --git a/installs_on_host/go2rtc/internal/http/http.go b/installs_on_host/go2rtc/internal/http/http.go new file mode 100644 index 0000000..4b0560c --- /dev/null +++ b/installs_on_host/go2rtc/internal/http/http.go @@ -0,0 +1,134 @@ +package http + +import ( + "errors" + "net" + "net/http" + "net/url" + "strings" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/hls" + "github.com/AlexxIT/go2rtc/pkg/image" + "github.com/AlexxIT/go2rtc/pkg/magic" + "github.com/AlexxIT/go2rtc/pkg/mpjpeg" + "github.com/AlexxIT/go2rtc/pkg/pcm" + "github.com/AlexxIT/go2rtc/pkg/tcp" +) + +func Init() { + streams.HandleFunc("http", handleHTTP) + streams.HandleFunc("https", handleHTTP) + streams.HandleFunc("httpx", handleHTTP) + + streams.HandleFunc("tcp", handleTCP) + + api.HandleFunc("api/stream", apiStream) +} + +func handleHTTP(rawURL string) (core.Producer, error) { + rawURL, rawQuery, _ := strings.Cut(rawURL, "#") + + // first we get the Content-Type to define supported producer + req, err := http.NewRequest("GET", rawURL, nil) + if err != nil { + return nil, err + } + + if rawQuery != "" { + query := streams.ParseQuery(rawQuery) + + for _, header := range query["header"] { + key, value, _ := strings.Cut(header, ":") + req.Header.Add(key, strings.TrimSpace(value)) + } + } + + prod, err := do(req) + if err != nil { + return nil, err + } + + if info, ok := prod.(core.Info); ok { + info.SetProtocol("http") + info.SetRemoteAddr(req.URL.Host) // TODO: rewrite to net.Conn + info.SetURL(rawURL) + } + + return prod, nil +} + +func do(req *http.Request) (core.Producer, error) { + res, err := tcp.Do(req) + if err != nil { + return nil, err + } + + if res.StatusCode != http.StatusOK { + return nil, errors.New(res.Status) + } + + // 1. Guess format from content type + ct := res.Header.Get("Content-Type") + if i := strings.IndexByte(ct, ';'); i > 0 { + ct = ct[:i] + } + + var ext string + if i := strings.LastIndexByte(req.URL.Path, '.'); i > 0 { + ext = req.URL.Path[i+1:] + } + + switch { + case ct == "application/vnd.apple.mpegurl" || ext == "m3u8": + return hls.OpenURL(req.URL, res.Body) + case ct == "image/jpeg": + return image.Open(res) + case ct == "multipart/x-mixed-replace": + return mpjpeg.Open(res.Body) + //https://www.iana.org/assignments/media-types/audio/basic + case ct == "audio/basic": + return pcm.Open(res.Body) + } + + return magic.Open(res.Body) +} + +func handleTCP(rawURL string) (core.Producer, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + conn, err := net.DialTimeout("tcp", u.Host, core.ConnDialTimeout) + if err != nil { + return nil, err + } + + return magic.Open(conn) +} + +func apiStream(w http.ResponseWriter, r *http.Request) { + dst := r.URL.Query().Get("dst") + stream := streams.Get(dst) + if stream == nil { + http.Error(w, api.StreamNotFound, http.StatusNotFound) + return + } + + client, err := magic.Open(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + stream.AddProducer(client) + defer stream.RemoveProducer(client) + + if err = client.Start(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} diff --git a/installs_on_host/go2rtc/internal/isapi/README.md b/installs_on_host/go2rtc/internal/isapi/README.md new file mode 100644 index 0000000..63892f7 --- /dev/null +++ b/installs_on_host/go2rtc/internal/isapi/README.md @@ -0,0 +1,14 @@ +# Hikvision ISAPI + +[`new in v1.3.0`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0) + +This source type supports only backchannel audio for the [Hikvision ISAPI](https://tpp.hikvision.com/download/ISAPI_OTAP) protocol. So it should be used as a second source in addition to the RTSP protocol. + +## Configuration + +```yaml +streams: + hikvision1: + - rtsp://admin:password@192.168.1.123:554/Streaming/Channels/101 + - isapi://admin:password@192.168.1.123:80/ +``` diff --git a/installs_on_host/go2rtc/internal/isapi/init.go b/installs_on_host/go2rtc/internal/isapi/init.go new file mode 100644 index 0000000..887a674 --- /dev/null +++ b/installs_on_host/go2rtc/internal/isapi/init.go @@ -0,0 +1,13 @@ +package isapi + +import ( + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/isapi" +) + +func Init() { + streams.HandleFunc("isapi", func(source string) (core.Producer, error) { + return isapi.Dial(source) + }) +} diff --git a/installs_on_host/go2rtc/internal/ivideon/README.md b/installs_on_host/go2rtc/internal/ivideon/README.md new file mode 100644 index 0000000..9974d0d --- /dev/null +++ b/installs_on_host/go2rtc/internal/ivideon/README.md @@ -0,0 +1,10 @@ +# Ivideon + +Support public cameras from the service [Ivideon](https://tv.ivideon.com/). + +## Configuration + +```yaml +streams: + quailcam: ivideon:100-tu5dkUPct39cTp9oNEN2B6/0 +``` diff --git a/installs_on_host/go2rtc/internal/ivideon/ivideon.go b/installs_on_host/go2rtc/internal/ivideon/ivideon.go new file mode 100644 index 0000000..51ddb89 --- /dev/null +++ b/installs_on_host/go2rtc/internal/ivideon/ivideon.go @@ -0,0 +1,10 @@ +package ivideon + +import ( + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/ivideon" +) + +func Init() { + streams.HandleFunc("ivideon", ivideon.Dial) +} diff --git a/installs_on_host/go2rtc/internal/kasa/README.md b/installs_on_host/go2rtc/internal/kasa/README.md new file mode 100644 index 0000000..0ce115e --- /dev/null +++ b/installs_on_host/go2rtc/internal/kasa/README.md @@ -0,0 +1,15 @@ +# TP-Link Kasa + +[`new in v1.7.0`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.7.0) + +[TP-Link Kasa](https://www.kasasmart.com/) non-standard protocol [more info](https://medium.com/@hu3vjeen/reverse-engineering-tp-link-kc100-bac4641bf1cd). + +- `username` - urlsafe email, `alex@gmail.com` -> `alex%40gmail.com` +- `password` - base64password, `secret1` -> `c2VjcmV0MQ==` + +```yaml +streams: + kc401: kasa://username:password@192.168.1.123:19443/https/stream/mixed +``` + +Tested: KD110, KC200, KC401, KC420WS, EC71. diff --git a/installs_on_host/go2rtc/internal/kasa/kasa.go b/installs_on_host/go2rtc/internal/kasa/kasa.go new file mode 100644 index 0000000..11f5c98 --- /dev/null +++ b/installs_on_host/go2rtc/internal/kasa/kasa.go @@ -0,0 +1,13 @@ +package kasa + +import ( + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/kasa" +) + +func Init() { + streams.HandleFunc("kasa", func(source string) (core.Producer, error) { + return kasa.Dial(source) + }) +} diff --git a/installs_on_host/go2rtc/internal/mjpeg/README.md b/installs_on_host/go2rtc/internal/mjpeg/README.md new file mode 100644 index 0000000..4258c41 --- /dev/null +++ b/installs_on_host/go2rtc/internal/mjpeg/README.md @@ -0,0 +1,108 @@ +# Motion JPEG + +- This module can provide and receive streams in MJPEG format. +- This module is also responsible for receiving snapshots in JPEG format. +- This module also supports streaming to the server console (terminal) in the **animated ASCII art** format. + +## MJPEG Client + +**Important.** For a stream in MJPEG format, your source MUST contain the MJPEG codec. If your stream has the MJPEG codec, you can receive an **MJPEG stream** or **JPEG snapshots** via the API. + +You can receive an MJPEG stream in several ways: + +- some cameras support MJPEG codec inside [RTSP stream](../rtsp/README.md) (ex. second stream for Dahua cameras) +- some cameras have an HTTP link with [MJPEG stream](../http/README.md) +- some cameras have an HTTP link with snapshots - go2rtc can convert them to [MJPEG stream](../http/README.md) +- you can convert an H264/H265 stream from your camera via [FFmpeg integration](../ffmpeg/README.md) + +With this example, your stream will have both H264 and MJPEG codecs: + +```yaml +streams: + camera1: + - rtsp://rtsp:12345678@192.168.1.123/av_stream/ch0 + - ffmpeg:camera1#video=mjpeg +``` + +## MJPEG Server + +### mpjpeg + +Output a stream in [MJPEG](https://en.wikipedia.org/wiki/Motion_JPEG) format. In [FFmpeg](https://ffmpeg.org/), this format is called `mpjpeg` because it contains HTTP headers. + +``` +ffplay http://192.168.1.123:1984/api/stream.mjpeg?src=camera1 +``` + +### jpeg + +Receiving a JPEG snapshot. + +``` +curl http://192.168.1.123:1984/api/frame.jpeg?src=camera1 +``` + +- You can use `width`/`w` and/or `height`/`h` parameters. +- You can use `rotate` param with `90`, `180`, `270` or `-90` values. +- You can use `hardware`/`hw` param [read more](https://github.com/AlexxIT/go2rtc/wiki/Hardware-acceleration). +- You can use `cache` param (`1m`, `10s`, etc.) to get a cached snapshot. + - The snapshot is cached only when requested with the `cache` parameter. + - A cached snapshot will be used if its time is not older than the time specified in the `cache` parameter. + - The `cache` parameter does not check the image dimensions from the cache and those specified in the query. + +### ascii + +Stream as ASCII to Terminal. This format is just for fun. You can boast to your friends that you can stream cameras even to the server console without a GUI. + +[![](https://img.youtube.com/vi/sHj_3h_sX7M/mqdefault.jpg)](https://www.youtube.com/watch?v=sHj_3h_sX7M) + +> The demo video features a combination of several settings for this format with added audio. Of course, the format doesn't support audio out of the box. + +**Tips** + +- this feature works only with MJPEG codec (use transcoding) +- choose a low frame rate (FPS) +- choose the width and height to fit in your terminal +- different terminals support different numbers of colors (8, 256, rgb) +- URL-encode the `text` parameter +- you can stream any camera or file from disk + +**go2rtc.yaml** - transcoding to MJPEG, terminal size - 210x59 (16/9), fps - 10 + +```yaml +streams: + gamazda: ffmpeg:gamazda.mp4#video=mjpeg#hardware#width=210#height=59#raw=-r 10 +``` + +**API params** + +- `color` - foreground color, values: empty, `8`, `256`, `rgb`, [SGR](https://en.wikipedia.org/wiki/ANSI_escape_code) + - example: `30` (black), `37` (white), `38;5;226` (yellow) +- `back` - background color, values: empty, `8`, `256`, `rgb`, [SGR](https://en.wikipedia.org/wiki/ANSI_escape_code) + - example: `40` (black), `47` (white), `48;5;226` (yellow) +- `text` - character set, values: empty, one character, `block`, list of chars (in order of brightness) + - example: `%20` (space), `block` (keyword for block elements), `ox` (two chars) + +**Examples** + +```bash +% curl "http://192.168.1.123:1984/api/stream.ascii?src=gamazda" +% curl "http://192.168.1.123:1984/api/stream.ascii?src=gamazda&color=256" +% curl "http://192.168.1.123:1984/api/stream.ascii?src=gamazda&back=256&text=%20" +% curl "http://192.168.1.123:1984/api/stream.ascii?src=gamazda&back=8&text=%20%20" +% curl "http://192.168.1.123:1984/api/stream.ascii?src=gamazda&text=helloworld" +``` + +### yuv4mpegpipe + +Raw [YUV](https://en.wikipedia.org/wiki/Y%E2%80%B2UV) frame stream with [YUV4MPEG](https://manned.org/yuv4mpeg) header. + +``` +ffplay http://192.168.1.123:1984/api/stream.y4m?src=camera1 +``` + +## Streaming ingest + +```shell +ffmpeg -re -i BigBuckBunny.mp4 -c mjpeg -f mpjpeg http://localhost:1984/api/stream.mjpeg?dst=camera1 +``` diff --git a/installs_on_host/go2rtc/internal/mjpeg/mjpeg.go b/installs_on_host/go2rtc/internal/mjpeg/mjpeg.go new file mode 100644 index 0000000..e9f973a --- /dev/null +++ b/installs_on_host/go2rtc/internal/mjpeg/mjpeg.go @@ -0,0 +1,237 @@ +package mjpeg + +import ( + "errors" + "io" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/api/ws" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/ffmpeg" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/ascii" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/magic" + "github.com/AlexxIT/go2rtc/pkg/mjpeg" + "github.com/AlexxIT/go2rtc/pkg/mpjpeg" + "github.com/AlexxIT/go2rtc/pkg/y4m" + "github.com/rs/zerolog" +) + +func Init() { + api.HandleFunc("api/frame.jpeg", handlerKeyframe) + api.HandleFunc("api/stream.mjpeg", handlerStream) + api.HandleFunc("api/stream.ascii", handlerStream) + api.HandleFunc("api/stream.y4m", apiStreamY4M) + + ws.HandleFunc("mjpeg", handlerWS) + + log = app.GetLogger("mjpeg") +} + +var log zerolog.Logger + +func handlerKeyframe(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + stream, _ := streams.GetOrPatch(query) + if stream == nil { + http.Error(w, api.StreamNotFound, http.StatusNotFound) + return + } + + var b []byte + + if s := query.Get("cache"); s != "" { + if timeout, err := time.ParseDuration(s); err == nil { + src := query.Get("src") + + cacheMu.Lock() + entry, found := cache[src] + cacheMu.Unlock() + + if found && time.Since(entry.timestamp) < timeout { + writeJPEGResponse(w, entry.payload) + return + } + + defer func() { + if b == nil { + return + } + entry = cacheEntry{payload: b, timestamp: time.Now()} + cacheMu.Lock() + if cache == nil { + cache = map[string]cacheEntry{src: entry} + } else { + cache[src] = entry + } + cacheMu.Unlock() + }() + } + } + + cons := magic.NewKeyframe() + cons.WithRequest(r) + + if err := stream.AddConsumer(cons); err != nil { + log.Error().Err(err).Caller().Send() + return + } + + once := &core.OnceBuffer{} // init and first frame + _, _ = cons.WriteTo(once) + b = once.Buffer() + + stream.RemoveConsumer(cons) + + switch cons.CodecName() { + case core.CodecH264, core.CodecH265: + ts := time.Now() + var err error + if b, err = ffmpeg.JPEGWithQuery(b, query); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + log.Debug().Msgf("[mjpeg] transcoding time=%s", time.Since(ts)) + case core.CodecJPEG: + b = mjpeg.FixJPEG(b) + } + + writeJPEGResponse(w, b) +} + +var cache map[string]cacheEntry +var cacheMu sync.Mutex + +// cacheEntry represents a cached keyframe with its timestamp +type cacheEntry struct { + payload []byte + timestamp time.Time +} + +func writeJPEGResponse(w http.ResponseWriter, b []byte) { + h := w.Header() + h.Set("Content-Type", "image/jpeg") + h.Set("Content-Length", strconv.Itoa(len(b))) + h.Set("Cache-Control", "no-cache") + h.Set("Connection", "close") + h.Set("Pragma", "no-cache") + + if _, err := w.Write(b); err != nil { + log.Error().Err(err).Caller().Send() + } +} + +func handlerStream(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + outputMjpeg(w, r) + } else { + inputMjpeg(w, r) + } +} + +func outputMjpeg(w http.ResponseWriter, r *http.Request) { + src := r.URL.Query().Get("src") + stream := streams.Get(src) + if stream == nil { + http.Error(w, api.StreamNotFound, http.StatusNotFound) + return + } + + cons := mjpeg.NewConsumer() + cons.WithRequest(r) + + if err := stream.AddConsumer(cons); err != nil { + log.Error().Err(err).Msg("[api.mjpeg] add consumer") + return + } + + h := w.Header() + h.Set("Cache-Control", "no-cache") + h.Set("Connection", "close") + h.Set("Pragma", "no-cache") + + if strings.HasSuffix(r.URL.Path, "mjpeg") { + wr := mjpeg.NewWriter(w) + _, _ = cons.WriteTo(wr) + } else { + cons.FormatName = "ascii" + + query := r.URL.Query() + wr := ascii.NewWriter(w, query.Get("color"), query.Get("back"), query.Get("text")) + _, _ = cons.WriteTo(wr) + } + + stream.RemoveConsumer(cons) +} + +func inputMjpeg(w http.ResponseWriter, r *http.Request) { + dst := r.URL.Query().Get("dst") + stream := streams.Get(dst) + if stream == nil { + http.Error(w, api.StreamNotFound, http.StatusNotFound) + return + } + + prod, _ := mpjpeg.Open(r.Body) + prod.WithRequest(r) + + stream.AddProducer(prod) + + if err := prod.Start(); err != nil && err != io.EOF { + log.Warn().Err(err).Caller().Send() + } + + stream.RemoveProducer(prod) +} + +func handlerWS(tr *ws.Transport, _ *ws.Message) error { + stream, _ := streams.GetOrPatch(tr.Request.URL.Query()) + if stream == nil { + return errors.New(api.StreamNotFound) + } + + cons := mjpeg.NewConsumer() + cons.WithRequest(tr.Request) + + if err := stream.AddConsumer(cons); err != nil { + log.Debug().Err(err).Msg("[mjpeg] add consumer") + return err + } + + tr.Write(&ws.Message{Type: "mjpeg"}) + + go cons.WriteTo(tr.Writer()) + + tr.OnClose(func() { + stream.RemoveConsumer(cons) + }) + + return nil +} + +func apiStreamY4M(w http.ResponseWriter, r *http.Request) { + src := r.URL.Query().Get("src") + stream := streams.Get(src) + if stream == nil { + http.Error(w, api.StreamNotFound, http.StatusNotFound) + return + } + + cons := y4m.NewConsumer() + cons.WithRequest(r) + + if err := stream.AddConsumer(cons); err != nil { + log.Error().Err(err).Caller().Send() + return + } + + _, _ = cons.WriteTo(w) + + stream.RemoveConsumer(cons) +} diff --git a/installs_on_host/go2rtc/internal/mp4/README.md b/installs_on_host/go2rtc/internal/mp4/README.md new file mode 100644 index 0000000..f2e3222 --- /dev/null +++ b/installs_on_host/go2rtc/internal/mp4/README.md @@ -0,0 +1,66 @@ +# MP4 + +This module provides several features: + +1. MSE stream (fMP4 over WebSocket) +2. Camera snapshots in MP4 format (single frame), can be sent to [Telegram](#snapshot-to-telegram) +3. HTTP progressive streaming (MP4 file stream) - bad format for streaming because of high start delay. This format doesn't work in all Safari browsers, but go2rtc will automatically redirect it to HLS/fMP4 in this case. + +## API examples + +- MP4 snapshot: `http://192.168.1.123:1984/api/frame.mp4?src=camera1` (H264, H265) +- MP4 stream: `http://192.168.1.123:1984/api/stream.mp4?src=camera1` (H264, H265, AAC) +- MP4 file: `http://192.168.1.123:1984/api/stream.mp4?src=camera1` (H264, H265*, AAC, OPUS, MP3, PCMA, PCMU, PCM) + - You can use `mp4`, `mp4=flac` and `mp4=all` param for codec filters + - You can use `duration` param in seconds (ex. `duration=15`) + - You can use `filename` param (ex. `filename=record.mp4`) + - You can use `rotate` param with `90`, `180` or `270` values + - You can use `scale` param with positive integer values (ex. `scale=4:3`) + +Read more about [codecs filters](../../README.md#codecs-filters). + +**PS.** Rotate and scale params don't use transcoding and change video using metadata. + +## Snapshot to Telegram + +This examples for Home Assistant [Telegram Bot](https://www.home-assistant.io/integrations/telegram_bot/) integration. + +- change `url` to your go2rtc web API (`http://localhost:1984/` for most users) +- change `target` to your Telegram chat ID (support list) +- change `src=camera1` to your stream name from go2rtc config + +**Important.** Snapshot will be near instant for most cameras and many sources, except `ffmpeg` source. Because it takes a long time for ffmpeg to start streaming with video, even when you use `#video=copy`. Also the delay can be with cameras that do not start the stream with a keyframe. + +### Snapshot from H264 or H265 camera + +```yaml +service: telegram_bot.send_video +data: + url: http://localhost:1984/api/frame.mp4?src=camera1 + target: 123456789 +``` + +### Record from H264 or H265 camera + +Record from service call to the future. Doesn't support loopback. + +- `mp4=flac` - adds support PCM audio family +- `filename=record.mp4` - set name for downloaded file + +```yaml +service: telegram_bot.send_video +data: + url: http://localhost:1984/api/stream.mp4?src=camera1&mp4=flac&duration=5&filename=record.mp4 # duration in seconds + target: 123456789 +``` + +### Snapshot from JPEG or MJPEG camera + +This example works via the [mjpeg](../mjpeg/README.md) module. + +```yaml +service: telegram_bot.send_photo +data: + url: http://localhost:1984/api/frame.jpeg?src=camera1 + target: 123456789 +``` diff --git a/installs_on_host/go2rtc/internal/mp4/mp4.go b/installs_on_host/go2rtc/internal/mp4/mp4.go new file mode 100644 index 0000000..d0a6d97 --- /dev/null +++ b/installs_on_host/go2rtc/internal/mp4/mp4.go @@ -0,0 +1,146 @@ +package mp4 + +import ( + "context" + "net/http" + "strconv" + "strings" + "time" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/api/ws" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/mp4" + "github.com/rs/zerolog" +) + +func Init() { + log = app.GetLogger("mp4") + + ws.HandleFunc("mse", handlerWSMSE) + ws.HandleFunc("mp4", handlerWSMP4) + + api.HandleFunc("api/frame.mp4", handlerKeyframe) + api.HandleFunc("api/stream.mp4", handlerMP4) +} + +var log zerolog.Logger + +func handlerKeyframe(w http.ResponseWriter, r *http.Request) { + // Chrome 105 does two requests: without Range and with `Range: bytes=0-` + ua := r.UserAgent() + if strings.Contains(ua, " Chrome/") { + if r.Header.Values("Range") == nil { + w.Header().Set("Content-Type", "video/mp4") + w.WriteHeader(http.StatusOK) + return + } + } + + query := r.URL.Query() + src := query.Get("src") + stream := streams.Get(src) + if stream == nil { + http.Error(w, api.StreamNotFound, http.StatusNotFound) + return + } + + cons := mp4.NewKeyframe(nil) + + if err := stream.AddConsumer(cons); err != nil { + log.Error().Err(err).Caller().Send() + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + once := &core.OnceBuffer{} // init and first frame + _, _ = cons.WriteTo(once) + + stream.RemoveConsumer(cons) + + // Apple Safari won't show frame without length + header := w.Header() + header.Set("Content-Length", strconv.Itoa(once.Len())) + header.Set("Content-Type", mp4.ContentType(cons.Codecs())) + + if filename := query.Get("filename"); filename != "" { + header.Set("Content-Disposition", `attachment; filename="`+filename+`"`) + } + + if _, err := once.WriteTo(w); err != nil { + log.Error().Err(err).Caller().Send() + } +} + +func handlerMP4(w http.ResponseWriter, r *http.Request) { + log.Trace().Msgf("[mp4] %s %+v", r.Method, r.Header) + + query := r.URL.Query() + + ua := r.UserAgent() + if strings.Contains(ua, " Safari/") && !strings.Contains(ua, " Chrome/") && !query.Has("duration") { + // auto redirect to HLS/fMP4 format, because Safari not support MP4 stream + url := "stream.m3u8?" + r.URL.RawQuery + if !query.Has("mp4") { + url += "&mp4" + } + + http.Redirect(w, r, url, http.StatusMovedPermanently) + return + } + + stream, _ := streams.GetOrPatch(query) + if stream == nil { + http.Error(w, api.StreamNotFound, http.StatusNotFound) + return + } + + medias := mp4.ParseQuery(r.URL.Query()) + cons := mp4.NewConsumer(medias) + cons.FormatName = "mp4" + cons.Protocol = "http" + cons.WithRequest(r) + + if err := stream.AddConsumer(cons); err != nil { + log.Error().Err(err).Caller().Send() + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if rotate := query.Get("rotate"); rotate != "" { + cons.Rotate = core.Atoi(rotate) + } + + if scale := query.Get("scale"); scale != "" { + if sx, sy, ok := strings.Cut(scale, ":"); ok { + cons.ScaleX = core.Atoi(sx) + cons.ScaleY = core.Atoi(sy) + } + } + + header := w.Header() + header.Set("Content-Type", mp4.ContentType(cons.Codecs())) + + if filename := query.Get("filename"); filename != "" { + header.Set("Content-Disposition", `attachment; filename="`+filename+`"`) + } + + ctx := r.Context() // handle when the client drops the connection + + if i := core.Atoi(query.Get("duration")); i > 0 { + timeout := time.Second * time.Duration(i) + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, timeout) + defer cancel() + } + + go func() { + <-ctx.Done() + _ = cons.Stop() + stream.RemoveConsumer(cons) + }() + + _, _ = cons.WriteTo(w) +} diff --git a/installs_on_host/go2rtc/internal/mp4/ws.go b/installs_on_host/go2rtc/internal/mp4/ws.go new file mode 100644 index 0000000..c1afac2 --- /dev/null +++ b/installs_on_host/go2rtc/internal/mp4/ws.go @@ -0,0 +1,74 @@ +package mp4 + +import ( + "errors" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/api/ws" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/mp4" +) + +func handlerWSMSE(tr *ws.Transport, msg *ws.Message) error { + stream, _ := streams.GetOrPatch(tr.Request.URL.Query()) + if stream == nil { + return errors.New(api.StreamNotFound) + } + + var medias []*core.Media + if codecs := msg.String(); codecs != "" { + log.Trace().Str("codecs", codecs).Msgf("[mp4] new WS/MSE consumer") + medias = mp4.ParseCodecs(codecs, true) + } + + cons := mp4.NewConsumer(medias) + cons.FormatName = "mse/fmp4" + cons.WithRequest(tr.Request) + + if err := stream.AddConsumer(cons); err != nil { + log.Debug().Err(err).Msg("[mp4] add consumer") + return err + } + + tr.Write(&ws.Message{Type: "mse", Value: mp4.ContentType(cons.Codecs())}) + + go cons.WriteTo(tr.Writer()) + + tr.OnClose(func() { + stream.RemoveConsumer(cons) + }) + + return nil +} + +func handlerWSMP4(tr *ws.Transport, msg *ws.Message) error { + stream, _ := streams.GetOrPatch(tr.Request.URL.Query()) + if stream == nil { + return errors.New(api.StreamNotFound) + } + + var medias []*core.Media + if codecs := msg.String(); codecs != "" { + log.Trace().Str("codecs", codecs).Msgf("[mp4] new WS/MP4 consumer") + medias = mp4.ParseCodecs(codecs, false) + } + + cons := mp4.NewKeyframe(medias) + cons.WithRequest(tr.Request) + + if err := stream.AddConsumer(cons); err != nil { + log.Error().Err(err).Caller().Send() + return err + } + + tr.Write(&ws.Message{Type: "mse", Value: mp4.ContentType(cons.Codecs())}) + + go cons.WriteTo(tr.Writer()) + + tr.OnClose(func() { + stream.RemoveConsumer(cons) + }) + + return nil +} diff --git a/installs_on_host/go2rtc/internal/mpeg/README.md b/installs_on_host/go2rtc/internal/mpeg/README.md new file mode 100644 index 0000000..e1c444b --- /dev/null +++ b/installs_on_host/go2rtc/internal/mpeg/README.md @@ -0,0 +1,25 @@ +# MPEG + +This module provides an [HTTP API](../api/README.md) for: + +- Streaming output in `mpegts` format. +- Streaming output in `adts` format. +- Streaming ingest in `mpegts` format. + +## MPEG-TS Server + +```shell +ffplay http://localhost:1984/api/stream.ts?src=camera1 +``` + +## ADTS Server + +```shell +ffplay http://localhost:1984/api/stream.aac?src=camera1 +``` + +## Streaming ingest + +```shell +ffmpeg -re -i BigBuckBunny.mp4 -c copy -f mpegts http://localhost:1984/api/stream.ts?dst=camera1 +``` diff --git a/installs_on_host/go2rtc/internal/mpeg/mpeg.go b/installs_on_host/go2rtc/internal/mpeg/mpeg.go new file mode 100644 index 0000000..0a55299 --- /dev/null +++ b/installs_on_host/go2rtc/internal/mpeg/mpeg.go @@ -0,0 +1,92 @@ +package mpeg + +import ( + "net/http" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/aac" + "github.com/AlexxIT/go2rtc/pkg/mpegts" +) + +func Init() { + api.HandleFunc("api/stream.ts", apiHandle) + api.HandleFunc("api/stream.aac", apiStreamAAC) +} + +func apiHandle(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + outputMpegTS(w, r) + } else { + inputMpegTS(w, r) + } +} + +func outputMpegTS(w http.ResponseWriter, r *http.Request) { + src := r.URL.Query().Get("src") + stream := streams.Get(src) + if stream == nil { + http.Error(w, api.StreamNotFound, http.StatusNotFound) + return + } + + cons := mpegts.NewConsumer() + cons.WithRequest(r) + + if err := stream.AddConsumer(cons); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Add("Content-Type", "video/mp2t") + + _, _ = cons.WriteTo(w) + + stream.RemoveConsumer(cons) +} + +func inputMpegTS(w http.ResponseWriter, r *http.Request) { + dst := r.URL.Query().Get("dst") + stream := streams.Get(dst) + if stream == nil { + http.Error(w, api.StreamNotFound, http.StatusNotFound) + return + } + + client, err := mpegts.Open(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + stream.AddProducer(client) + defer stream.RemoveProducer(client) + + if err = client.Start(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +func apiStreamAAC(w http.ResponseWriter, r *http.Request) { + src := r.URL.Query().Get("src") + stream := streams.Get(src) + if stream == nil { + http.Error(w, api.StreamNotFound, http.StatusNotFound) + return + } + + cons := aac.NewConsumer() + cons.WithRequest(r) + + if err := stream.AddConsumer(cons); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Add("Content-Type", "audio/aac") + + _, _ = cons.WriteTo(w) + + stream.RemoveConsumer(cons) +} diff --git a/installs_on_host/go2rtc/internal/multitrans/README.md b/installs_on_host/go2rtc/internal/multitrans/README.md new file mode 100644 index 0000000..ab3c383 --- /dev/null +++ b/installs_on_host/go2rtc/internal/multitrans/README.md @@ -0,0 +1,22 @@ +# TP-Link MULTITRANS + +[`new in v1.9.14`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.14) by [@forrestsocool](https://github.com/forrestsocool) + +Two-way audio support for Chinese version of [TP-Link](https://www.tp-link.com.cn/) cameras. + +## Configuration + +```yaml +streams: + tplink_cam: + # video uses standard RTSP + - rtsp://admin:admin@192.168.1.202:554/stream1 + # two-way audio uses MULTITRANS schema + - multitrans://admin:admin@192.168.1.202:554 +``` + +## Useful links + +- https://www.tp-link.com.cn/list_2549.html +- https://github.com/AlexxIT/go2rtc/issues/1724 +- https://github.com/bingooo/hass-tplink-ipc/ diff --git a/installs_on_host/go2rtc/internal/multitrans/multitrans.go b/installs_on_host/go2rtc/internal/multitrans/multitrans.go new file mode 100644 index 0000000..31e6a9a --- /dev/null +++ b/installs_on_host/go2rtc/internal/multitrans/multitrans.go @@ -0,0 +1,10 @@ +package multitrans + +import ( + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/multitrans" +) + +func Init() { + streams.HandleFunc("multitrans", multitrans.Dial) +} diff --git a/installs_on_host/go2rtc/internal/nest/README.md b/installs_on_host/go2rtc/internal/nest/README.md new file mode 100644 index 0000000..d8e24d3 --- /dev/null +++ b/installs_on_host/go2rtc/internal/nest/README.md @@ -0,0 +1,11 @@ +# Google Nest + +[`new in v1.6.0`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.6.0) + +For simplicity, it is recommended to connect the Nest/WebRTC camera to the [Home Assistant](../hass/README.md). +But if you can somehow get the below parameters, Nest/WebRTC source will work without Home Assistant. + +```yaml +streams: + nest-doorbell: nest:?client_id=***&client_secret=***&refresh_token=***&project_id=***&device_id=*** +``` diff --git a/installs_on_host/go2rtc/internal/nest/init.go b/installs_on_host/go2rtc/internal/nest/init.go new file mode 100644 index 0000000..8289af7 --- /dev/null +++ b/installs_on_host/go2rtc/internal/nest/init.go @@ -0,0 +1,52 @@ +package nest + +import ( + "net/http" + "strings" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/nest" +) + +func Init() { + streams.HandleFunc("nest", func(source string) (core.Producer, error) { + return nest.Dial(source) + }) + + api.HandleFunc("api/nest", apiNest) +} + +func apiNest(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + cliendID := query.Get("client_id") + cliendSecret := query.Get("client_secret") + refreshToken := query.Get("refresh_token") + projectID := query.Get("project_id") + + nestAPI, err := nest.NewAPI(cliendID, cliendSecret, refreshToken) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + devices, err := nestAPI.GetDevices(projectID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + var items []*api.Source + + for _, device := range devices { + query.Set("device_id", device.DeviceID) + query.Set("protocols", strings.Join(device.Protocols, ",")) + + items = append(items, &api.Source{ + Name: device.Name, URL: "nest:?" + query.Encode(), + }) + } + + api.ResponseSources(w, items) +} diff --git a/installs_on_host/go2rtc/internal/ngrok/README.md b/installs_on_host/go2rtc/internal/ngrok/README.md new file mode 100644 index 0000000..bc5539e --- /dev/null +++ b/installs_on_host/go2rtc/internal/ngrok/README.md @@ -0,0 +1,54 @@ +# ngrok + +With the ngrok integration, you can get external access to your streams when your Internet connection is behind a private IP address. + +- you may need external access for two different things: + - WebRTC streams (tunnel the WebRTC TCP port, e.g. 8555) + - go2rtc web interface (tunnel the API HTTP port, e.g. 1984) +- ngrok supports authorization for your web interface +- ngrok automatically adds HTTPS to your web interface + +The ngrok free subscription has the following limitations: + +- You can reserve a free domain for serving the web interface, but the TCP address you get will always be random and will change with each restart of the ngrok agent (not a problem for WebRTC streams) +- You can forward multiple ports from a single agent, but you can only run one ngrok agent on the free plan + +go2rtc will automatically get your external TCP address (if you enable it in the ngrok config) and use it for WebRTC connections (if you enable it in the WebRTC config). + +You need to manually download the [ngrok agent](https://ngrok.com/download) for your OS and register with the [ngrok service](https://ngrok.com/signup). + +**Tunnel for only WebRTC Stream** + +You need to add your [ngrok authtoken](https://dashboard.ngrok.com/get-started/your-authtoken) and WebRTC TCP port to YAML: + +```yaml +ngrok: + command: ngrok tcp 8555 --authtoken eW91IHNoYWxsIG5vdCBwYXNzCnlvdSBzaGFsbCBub3QgcGFzcw +``` + +**Tunnel for WebRTC and Web interface** + +You need to create `ngrok.yaml` config file and add it to the go2rtc config: + +```yaml +ngrok: + command: ngrok start --all --config ngrok.yaml +``` + +ngrok config example: + +```yaml +version: "2" +authtoken: eW91IHNoYWxsIG5vdCBwYXNzCnlvdSBzaGFsbCBub3QgcGFzcw +tunnels: + api: + addr: 1984 # use the same port as in the go2rtc config + proto: http + basic_auth: + - admin:password # you can set login/pass for your web interface + webrtc: + addr: 8555 # use the same port as in the go2rtc config + proto: tcp +``` + +See the [ngrok agent documentation](https://ngrok.com/docs/agent/config/) for more details on the ngrok configuration file. diff --git a/installs_on_host/go2rtc/internal/ngrok/ngrok.go b/installs_on_host/go2rtc/internal/ngrok/ngrok.go new file mode 100644 index 0000000..28b5564 --- /dev/null +++ b/installs_on_host/go2rtc/internal/ngrok/ngrok.go @@ -0,0 +1,84 @@ +package ngrok + +import ( + "fmt" + "net" + "strings" + + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/webrtc" + "github.com/AlexxIT/go2rtc/pkg/ngrok" + "github.com/rs/zerolog" +) + +func Init() { + var cfg struct { + Mod struct { + Cmd string `yaml:"command"` + } `yaml:"ngrok"` + } + + app.LoadConfig(&cfg) + + if cfg.Mod.Cmd == "" { + return + } + + log = app.GetLogger("ngrok") + + ngr, err := ngrok.NewNgrok(cfg.Mod.Cmd) + if err != nil { + log.Error().Err(err).Msg("[ngrok] start") + } + + ngr.Listen(func(msg any) { + if msg := msg.(*ngrok.Message); msg != nil { + if strings.HasPrefix(msg.Line, "ERROR:") { + log.Warn().Msg("[ngrok] " + msg.Line) + } else { + log.Debug().Msg("[ngrok] " + msg.Line) + } + + // Addr: "//localhost:8555", URL: "tcp://1.tcp.eu.ngrok.io:12345" + if strings.HasPrefix(msg.Addr, "//localhost:") && strings.HasPrefix(msg.URL, "tcp://") { + // don't know if really necessary use IP + address, err := ConvertHostToIP(msg.URL[6:]) + if err != nil { + log.Warn().Err(err).Msg("[ngrok] add candidate") + return + } + + log.Info().Str("addr", address).Msg("[ngrok] add external candidate for WebRTC") + + webrtc.AddCandidate("tcp", address) + } + } + }) + + go func() { + if err = ngr.Serve(); err != nil { + log.Error().Err(err).Msg("[ngrok] run") + } + }() + +} + +var log zerolog.Logger + +func ConvertHostToIP(address string) (string, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return "", err + } + + ip, err := net.LookupIP(host) + if err != nil { + return "", err + } + + if len(ip) == 0 { + return "", fmt.Errorf("can't resolve: %s", host) + } + + return ip[0].String() + ":" + port, nil +} diff --git a/installs_on_host/go2rtc/internal/onvif/README.md b/installs_on_host/go2rtc/internal/onvif/README.md new file mode 100644 index 0000000..63f8b1a --- /dev/null +++ b/installs_on_host/go2rtc/internal/onvif/README.md @@ -0,0 +1,42 @@ +# ONVIF + +## ONVIF Client + +[`new in v1.5.0`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.5.0) + +The source is not very useful if you already know RTSP and snapshot links for your camera. But it can be useful if you don't. + +**WebUI > Add** webpage supports ONVIF autodiscovery. Your server must be on the same subnet as the camera. If you use Docker, you must use "network host". + +```yaml +streams: + dahua1: onvif://admin:password@192.168.1.123 + reolink1: onvif://admin:password@192.168.1.123:8000 + tapo1: onvif://admin:password@192.168.1.123:2020 +``` + +## ONVIF Server + +A regular camera has a single video source (`GetVideoSources`) and two profiles (`GetProfiles`). + +Go2rtc has one video source and one profile per stream. + +## Tested clients + +Go2rtc works as ONVIF server: + +- Happytime onvif client (windows) +- Home Assistant ONVIF integration (linux) +- Onvier (android) +- ONVIF Device Manager (windows) + +PS. Supports only TCP transport for RTSP protocol. UDP and HTTP transports - unsupported yet. + +## Tested cameras + +Go2rtc works as ONVIF client: + +- Dahua IPC-K42 +- OpenIPC +- Reolink RLC-520A +- TP-Link Tapo TC60 diff --git a/installs_on_host/go2rtc/internal/onvif/onvif.go b/installs_on_host/go2rtc/internal/onvif/onvif.go new file mode 100644 index 0000000..c305b70 --- /dev/null +++ b/installs_on_host/go2rtc/internal/onvif/onvif.go @@ -0,0 +1,238 @@ +package onvif + +import ( + "io" + "net" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/rtsp" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/onvif" + "github.com/rs/zerolog" +) + +func Init() { + log = app.GetLogger("onvif") + + streams.HandleFunc("onvif", streamOnvif) + + // ONVIF server on all suburls + api.HandleFunc("/onvif/", onvifDeviceService) + + // ONVIF client autodiscovery + api.HandleFunc("api/onvif", apiOnvif) +} + +var log zerolog.Logger + +func streamOnvif(rawURL string) (core.Producer, error) { + client, err := onvif.NewClient(rawURL) + if err != nil { + return nil, err + } + + uri, err := client.GetURI() + if err != nil { + return nil, err + } + + // Append hash-based arguments to the retrieved URI + if i := strings.IndexByte(rawURL, '#'); i > 0 { + uri += rawURL[i:] + } + + log.Debug().Msgf("[onvif] new uri=%s", uri) + + if err = streams.Validate(uri); err != nil { + return nil, err + } + + return streams.GetProducer(uri) +} + +func onvifDeviceService(w http.ResponseWriter, r *http.Request) { + b, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + operation := onvif.GetRequestAction(b) + if operation == "" { + http.Error(w, "malformed request body", http.StatusBadRequest) + return + } + + log.Trace().Msgf("[onvif] server request %s %s:\n%s", r.Method, r.RequestURI, b) + + switch operation { + case onvif.ServiceGetServiceCapabilities, // important for Hass + onvif.DeviceGetNetworkInterfaces, // important for Hass + onvif.DeviceGetSystemDateAndTime, // important for Hass + onvif.DeviceSetSystemDateAndTime, // return just OK + onvif.DeviceGetDiscoveryMode, + onvif.DeviceGetDNS, + onvif.DeviceGetHostname, + onvif.DeviceGetNetworkDefaultGateway, + onvif.DeviceGetNetworkProtocols, + onvif.DeviceGetNTP, + onvif.DeviceGetScopes, + onvif.MediaGetVideoEncoderConfiguration, + onvif.MediaGetVideoEncoderConfigurations, + onvif.MediaGetAudioEncoderConfigurations, + onvif.MediaGetVideoEncoderConfigurationOptions, + onvif.MediaGetAudioSources, + onvif.MediaGetAudioSourceConfigurations: + b = onvif.StaticResponse(operation) + + case onvif.DeviceGetCapabilities: + // important for Hass: Media section + b = onvif.GetCapabilitiesResponse(r.Host) + + case onvif.DeviceGetServices: + b = onvif.GetServicesResponse(r.Host) + + case onvif.DeviceGetDeviceInformation: + // important for Hass: SerialNumber (unique server ID) + b = onvif.GetDeviceInformationResponse("", "go2rtc", app.Version, r.Host) + + case onvif.DeviceSystemReboot: + b = onvif.StaticResponse(operation) + + time.AfterFunc(time.Second, func() { + os.Exit(0) + }) + + case onvif.MediaGetVideoSources: + b = onvif.GetVideoSourcesResponse(streams.GetAllNames()) + + case onvif.MediaGetProfiles: + // important for Hass: H264 codec, width, height + b = onvif.GetProfilesResponse(streams.GetAllNames()) + + case onvif.MediaGetProfile: + token := onvif.FindTagValue(b, "ProfileToken") + b = onvif.GetProfileResponse(token) + + case onvif.MediaGetVideoSourceConfigurations: + // important for Happytime Onvif Client + b = onvif.GetVideoSourceConfigurationsResponse(streams.GetAllNames()) + + case onvif.MediaGetVideoSourceConfiguration: + token := onvif.FindTagValue(b, "ConfigurationToken") + b = onvif.GetVideoSourceConfigurationResponse(token) + + case onvif.MediaGetStreamUri: + host, _, err := net.SplitHostPort(r.Host) + if err != nil { + host = r.Host // in case of Host without port + } + + uri := "rtsp://" + host + ":" + rtsp.Port + "/" + onvif.FindTagValue(b, "ProfileToken") + b = onvif.GetStreamUriResponse(uri) + + case onvif.MediaGetSnapshotUri: + uri := "http://" + r.Host + "/api/frame.jpeg?src=" + onvif.FindTagValue(b, "ProfileToken") + b = onvif.GetSnapshotUriResponse(uri) + + default: + http.Error(w, "unsupported operation", http.StatusBadRequest) + log.Warn().Msgf("[onvif] unsupported operation: %s", operation) + log.Debug().Msgf("[onvif] unsupported request:\n%s", b) + return + } + + log.Trace().Msgf("[onvif] server response:\n%s", b) + + w.Header().Set("Content-Type", "application/soap+xml; charset=utf-8") + if _, err = w.Write(b); err != nil { + log.Error().Err(err).Caller().Send() + } +} + +func apiOnvif(w http.ResponseWriter, r *http.Request) { + src := r.URL.Query().Get("src") + + var items []*api.Source + + if src == "" { + devices, err := onvif.DiscoveryStreamingDevices() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + for _, device := range devices { + u, err := url.Parse(device.URL) + if err != nil { + log.Warn().Str("url", device.URL).Msg("[onvif] broken") + continue + } + + if u.Scheme != "http" { + log.Warn().Str("url", device.URL).Msg("[onvif] unsupported") + continue + } + + u.Scheme = "onvif" + u.User = url.UserPassword("user", "pass") + + if u.Path == onvif.PathDevice { + u.Path = "" + } + + items = append(items, &api.Source{ + Name: u.Host, + URL: u.String(), + Info: device.Name + " " + device.Hardware, + }) + } + } else { + client, err := onvif.NewClient(src) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if l := log.Trace(); l.Enabled() { + b, _ := client.MediaRequest(onvif.MediaGetProfiles) + l.Msgf("[onvif] src=%s profiles:\n%s", src, b) + } + + name, err := client.GetName() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + tokens, err := client.GetProfilesTokens() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + for i, token := range tokens { + items = append(items, &api.Source{ + Name: name + " stream" + strconv.Itoa(i), + URL: src + "?subtype=" + token, + }) + } + + if len(tokens) > 0 && client.HasSnapshots() { + items = append(items, &api.Source{ + Name: name + " snapshot", + URL: src + "?subtype=" + tokens[0] + "&snapshot", + }) + } + } + + api.ResponseSources(w, items) +} diff --git a/installs_on_host/go2rtc/internal/pinggy/README.md b/installs_on_host/go2rtc/internal/pinggy/README.md new file mode 100644 index 0000000..49afa5a --- /dev/null +++ b/installs_on_host/go2rtc/internal/pinggy/README.md @@ -0,0 +1,54 @@ +# Pinggy + +[Pinggy](https://pinggy.io/) - nice service for public tunnels to your local services. + +**Features:** + +- A free account does not require registration. +- It does not require downloading third-party binaries and works over the SSH protocol. +- Works with HTTP, TCP and UDP protocols. +- Creates HTTPS for your HTTP services. + +> [!IMPORTANT] +> A free account creates a tunnel with a random address that only works for an hour. It is suitable for testing purposes ONLY. + +> [!CAUTION] +> Public access to go2rtc without authorization puts your entire home network at risk. Use with caution. + +**Why:** + +- It's easy to set up HTTPS for testing two-way audio. +- It's easy to check whether external access via WebRTC technology will work. +- It's easy to share direct access to your RTSP or HTTP camera with the go2rtc developer. If such access is necessary to debug your problem. + +## Configuration + +You will find public links in the go2rtc log after startup. + +**Tunnel to go2rtc WebUI.** + +```yaml +pinggy: + tunnel: http://localhost:1984 +``` + +**Tunnel to RTSP camera.** + +For example, you have camera: `rtsp://admin:password@192.168.1.123/cam/realmonitor?channel=1&subtype=0` + +```yaml +pinggy: + tunnel: tcp://192.168.10.91:554 +``` + +In go2rtc logs you will get similar output: + +``` +16:17:43.167 INF [pinggy] proxy url=tcp://abcde-123-123-123-123.a.free.pinggy.link:12345 +``` + +Now you have a working stream: + +``` +rtsp://admin:password@abcde-123-123-123-123.a.free.pinggy.link:12345/cam/realmonitor?channel=1&subtype=0 +``` diff --git a/installs_on_host/go2rtc/internal/pinggy/pinggy.go b/installs_on_host/go2rtc/internal/pinggy/pinggy.go new file mode 100644 index 0000000..2e7258e --- /dev/null +++ b/installs_on_host/go2rtc/internal/pinggy/pinggy.go @@ -0,0 +1,60 @@ +package pinggy + +import ( + "net/url" + + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/pkg/pinggy" + "github.com/rs/zerolog" +) + +func Init() { + var cfg struct { + Mod struct { + Tunnel string `yaml:"tunnel"` + } `yaml:"pinggy"` + } + + app.LoadConfig(&cfg) + + if cfg.Mod.Tunnel == "" { + return + } + + log = app.GetLogger("pinggy") + + u, err := url.Parse(cfg.Mod.Tunnel) + if err != nil { + log.Error().Err(err).Send() + return + } + + go proxy(u.Scheme, u.Host) +} + +var log zerolog.Logger + +func proxy(proto, address string) { + client, err := pinggy.NewClient(proto) + if err != nil { + log.Error().Err(err).Send() + return + } + defer client.Close() + + urls, err := client.GetURLs() + if err != nil { + log.Error().Err(err).Send() + return + } + + for _, s := range urls { + log.Info().Str("url", s).Msgf("[pinggy] proxy") + } + + err = client.Proxy(address) + if err != nil { + log.Error().Err(err).Send() + return + } +} diff --git a/installs_on_host/go2rtc/internal/ring/README.md b/installs_on_host/go2rtc/internal/ring/README.md new file mode 100644 index 0000000..a3464a7 --- /dev/null +++ b/installs_on_host/go2rtc/internal/ring/README.md @@ -0,0 +1,17 @@ +# Ring + +[`new in v1.9.13`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.13) by [@seydx](https://github.com/seydx) + +This source type supports Ring cameras with [two-way audio](../../README.md#two-way-audio) support. + +## Configuration + +If you have a `refresh_token` and `device_id`, you can use them in the `go2rtc.yaml` config file. + +Otherwise, you can use the go2rtc web interface and add your Ring account (WebUI > Add > Ring). Once added, it will list all your Ring cameras. + +```yaml +streams: + ring: ring:?device_id=XXX&refresh_token=XXX + ring_snapshot: ring:?device_id=XXX&refresh_token=XXX&snapshot +``` diff --git a/installs_on_host/go2rtc/internal/ring/ring.go b/installs_on_host/go2rtc/internal/ring/ring.go new file mode 100644 index 0000000..7fdb284 --- /dev/null +++ b/installs_on_host/go2rtc/internal/ring/ring.go @@ -0,0 +1,106 @@ +package ring + +import ( + "net/http" + "net/url" + + "fmt" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/ring" +) + +func Init() { + streams.HandleFunc("ring", func(source string) (core.Producer, error) { + return ring.Dial(source) + }) + + api.HandleFunc("api/ring", apiRing) +} + +func apiRing(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + var ringAPI *ring.RingApi + + // Check auth method + if email := query.Get("email"); email != "" { + // Email/Password Flow + password := query.Get("password") + code := query.Get("code") + + var err error + ringAPI, err = ring.NewRestClient(ring.EmailAuth{ + Email: email, + Password: password, + }, nil) + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Try authentication (this will trigger 2FA if needed) + if _, err = ringAPI.GetAuth(code); err != nil { + if ringAPI.Using2FA { + // Return 2FA prompt + api.ResponseJSON(w, map[string]interface{}{ + "needs_2fa": true, + "prompt": ringAPI.PromptFor2FA, + }) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } else if refreshToken := query.Get("refresh_token"); refreshToken != "" { + // Refresh Token Flow + if refreshToken == "" { + http.Error(w, "either email/password or refresh_token is required", http.StatusBadRequest) + return + } + + var err error + ringAPI, err = ring.NewRestClient(ring.RefreshTokenAuth{ + RefreshToken: refreshToken, + }, nil) + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } else { + http.Error(w, "either email/password or refresh token is required", http.StatusBadRequest) + return + } + + devices, err := ringAPI.FetchRingDevices() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + cleanQuery := url.Values{} + cleanQuery.Set("refresh_token", ringAPI.RefreshToken) + + var items []*api.Source + for _, camera := range devices.AllCameras { + cleanQuery.Set("camera_id", fmt.Sprint(camera.ID)) + cleanQuery.Set("device_id", camera.DeviceID) + + // Stream source + items = append(items, &api.Source{ + Name: camera.Description, + URL: "ring:?" + cleanQuery.Encode(), + }) + + // Snapshot source + items = append(items, &api.Source{ + Name: camera.Description + " Snapshot", + URL: "ring:?" + cleanQuery.Encode() + "&snapshot", + }) + } + + api.ResponseSources(w, items) +} diff --git a/installs_on_host/go2rtc/internal/roborock/README.md b/installs_on_host/go2rtc/internal/roborock/README.md new file mode 100644 index 0000000..3361200 --- /dev/null +++ b/installs_on_host/go2rtc/internal/roborock/README.md @@ -0,0 +1,15 @@ +# Roborock + +[`new in v1.3.0`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0) + +This source type supports Roborock vacuums with cameras. Known working models: + +- **Roborock S6 MaxV** - only video (the vacuum has no microphone) +- **Roborock S7 MaxV** - video and two-way audio +- **Roborock Qrevo MaxV** - video and two-way audio + +## Configuration + +This source supports loading Roborock credentials from the Home Assistant [custom integration](https://github.com/humbertogontijo/homeassistant-roborock) or the [core integration](https://www.home-assistant.io/integrations/roborock). Otherwise, you need to log in to your Roborock account (MiHome account is not supported). Go to go2rtc WebUI > Add webpage. Copy the `roborock://...` source for your vacuum and paste it into your `go2rtc.yaml` config. + +If you have a pattern PIN for your vacuum, add it as a numeric PIN (lines: 123, 456, 789) to the end of the `roborock` link. diff --git a/installs_on_host/go2rtc/internal/roborock/roborock.go b/installs_on_host/go2rtc/internal/roborock/roborock.go new file mode 100644 index 0000000..32a436d --- /dev/null +++ b/installs_on_host/go2rtc/internal/roborock/roborock.go @@ -0,0 +1,92 @@ +package roborock + +import ( + "fmt" + "net/http" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/roborock" +) + +func Init() { + streams.HandleFunc("roborock", func(source string) (core.Producer, error) { + return roborock.Dial(source) + }) + + api.HandleFunc("api/roborock", apiHandle) +} + +var Auth struct { + UserData *roborock.UserInfo `json:"user_data"` + BaseURL string `json:"base_url"` +} + +func apiHandle(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + if Auth.UserData == nil { + http.Error(w, "no auth", http.StatusNotFound) + return + } + + case "POST": + if err := r.ParseMultipartForm(1024); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + username := r.Form.Get("username") + password := r.Form.Get("password") + if username == "" || password == "" { + http.Error(w, "empty username or password", http.StatusBadRequest) + return + } + + base, err := roborock.GetBaseURL(username) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + ui, err := roborock.Login(base, username, password) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + Auth.BaseURL = base + Auth.UserData = ui + + default: + http.Error(w, "", http.StatusMethodNotAllowed) + return + } + + homeID, err := roborock.GetHomeID(Auth.BaseURL, Auth.UserData.Token) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + devices, err := roborock.GetDevices(Auth.UserData, homeID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + var items []*api.Source + + for _, device := range devices { + source := fmt.Sprintf( + "roborock://%s?u=%s&s=%s&k=%s&did=%s&key=%s&pin=", + Auth.UserData.IoT.URL.MQTT[6:], + Auth.UserData.IoT.User, Auth.UserData.IoT.Pass, Auth.UserData.IoT.Domain, + device.DID, device.Key, + ) + items = append(items, &api.Source{Name: device.Name, URL: source}) + } + + api.ResponseSources(w, items) +} diff --git a/installs_on_host/go2rtc/internal/rtmp/README.md b/installs_on_host/go2rtc/internal/rtmp/README.md new file mode 100644 index 0000000..6667238 --- /dev/null +++ b/installs_on_host/go2rtc/internal/rtmp/README.md @@ -0,0 +1,118 @@ +# Real-Time Messaging Protocol + +This module provides the following features for the RTMP protocol: + +- Streaming input - [RTMP client](#rtmp-client) +- Streaming output and ingest in `rtmp` format - [RTMP server](#rtmp-server) +- Streaming output and ingest in `flv` format - [FLV server](#flv-server) + +## RTMP Client + +You can get a stream from an RTMP server, for example [Nginx with nginx-rtmp-module](https://github.com/arut/nginx-rtmp-module). + +### Client Configuration + +```yaml +streams: + rtmp_stream: rtmp://192.168.1.123/live/camera1 +``` + +## RTMP Server + +[`new in v1.8.0`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.0) + +Streaming output stream in `rtmp` format: + +```shell +ffplay rtmp://localhost:1935/camera1 +``` + +Streaming ingest stream in `rtmp` format: + +```shell +ffmpeg -re -i BigBuckBunny.mp4 -c copy -f flv rtmp://localhost:1935/camera1 +``` + +### Server Configuration + +By default, the RTMP server is disabled. + +```yaml +rtmp: + listen: ":1935" # by default - disabled! +``` + +## FLV Server + +Streaming output in `flv` format. + +```shell +ffplay http://localhost:1984/stream.flv?src=camera1 +``` + +Streaming ingest in `flv` format. + +```shell +ffmpeg -re -i BigBuckBunny.mp4 -c copy -f flv http://localhost:1984/api/stream.flv?dst=camera1 +``` + +## Tested clients + +| From | To | Comment | +|--------|---------------------------------|---------| +| go2rtc | Reolink RLC-520A fw. v3.1.0.801 | OK | + +**go2rtc.yaml** + +```yaml +streams: + rtmp-reolink1: rtmp://192.168.10.92/bcs/channel0_main.bcs?channel=0&stream=0&user=admin&password=password + rtmp-reolink2: rtmp://192.168.10.92/bcs/channel0_sub.bcs?channel=0&stream=1&user=admin&password=password + rtmp-reolink3: rtmp://192.168.10.92/bcs/channel0_ext.bcs?channel=0&stream=1&user=admin&password=password +``` + +## Tested server + +| From | To | Comment | +|------------------------|--------|---------------------| +| OBS 31.0.2 | go2rtc | OK | +| OpenIPC 2.5.03.02-lite | go2rtc | OK | +| FFmpeg 6.1 | go2rtc | OK | +| GoPro Black 12 | go2rtc | OK, 1080p, 5000kbps | + +**go2rtc.yaml** + +```yaml +rtmp: + listen: :1935 +streams: + tmp: +``` + +**OBS** + +Settings > Stream: + +- Service: Custom +- Server: rtmp://192.168.10.101/tmp +- Stream Key: `` +- Use auth: `` + +**OpenIPC** + +WebUI > Majestic > Settings > Outgoing + +- Enable +- Address: rtmp://192.168.10.101/tmp +- Save +- Restart + +**FFmpeg** + +```shell +ffmpeg -re -i bbb.mp4 -c copy -f flv rtmp://192.168.10.101/tmp +``` + +**GoPro** + +GoPro Quik > Camera > Translation > Other diff --git a/installs_on_host/go2rtc/internal/rtmp/rtmp.go b/installs_on_host/go2rtc/internal/rtmp/rtmp.go new file mode 100644 index 0000000..b3d7f93 --- /dev/null +++ b/installs_on_host/go2rtc/internal/rtmp/rtmp.go @@ -0,0 +1,199 @@ +package rtmp + +import ( + "errors" + "io" + "net" + "net/http" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/flv" + "github.com/AlexxIT/go2rtc/pkg/rtmp" + "github.com/rs/zerolog" +) + +func Init() { + var conf struct { + Mod struct { + Listen string `yaml:"listen" json:"listen"` + } `yaml:"rtmp"` + } + + app.LoadConfig(&conf) + + log = app.GetLogger("rtmp") + + streams.HandleFunc("rtmp", streamsHandle) + streams.HandleFunc("rtmps", streamsHandle) + streams.HandleFunc("rtmpx", streamsHandle) + + api.HandleFunc("api/stream.flv", apiHandle) + + streams.HandleConsumerFunc("rtmp", streamsConsumerHandle) + streams.HandleConsumerFunc("rtmps", streamsConsumerHandle) + streams.HandleConsumerFunc("rtmpx", streamsConsumerHandle) + + address := conf.Mod.Listen + if address == "" { + return + } + + ln, err := net.Listen("tcp", address) + if err != nil { + log.Error().Err(err).Caller().Send() + return + } + + log.Info().Str("addr", address).Msg("[rtmp] listen") + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + + go func() { + if err = tcpHandle(conn); err != nil { + log.Error().Err(err).Caller().Send() + } + }() + } + }() +} + +func tcpHandle(netConn net.Conn) error { + rtmpConn, err := rtmp.NewServer(netConn) + if err != nil { + return err + } + + if err = rtmpConn.ReadCommands(); err != nil { + return err + } + + switch rtmpConn.Intent { + case rtmp.CommandPlay: + stream := streams.Get(rtmpConn.App) + if stream == nil { + return errors.New("stream not found: " + rtmpConn.App) + } + + cons := flv.NewConsumer() + if err = stream.AddConsumer(cons); err != nil { + return err + } + + defer stream.RemoveConsumer(cons) + + if err = rtmpConn.WriteStart(); err != nil { + return err + } + + _, _ = cons.WriteTo(rtmpConn) + + return nil + + case rtmp.CommandPublish: + stream := streams.Get(rtmpConn.App) + if stream == nil { + return errors.New("stream not found: " + rtmpConn.App) + } + + if err = rtmpConn.WriteStart(); err != nil { + return err + } + + prod, err := rtmpConn.Producer() + if err != nil { + return err + } + + stream.AddProducer(prod) + + defer stream.RemoveProducer(prod) + + _ = prod.Start() + + return nil + } + + return errors.New("rtmp: unknown command: " + rtmpConn.Intent) +} + +var log zerolog.Logger + +func streamsHandle(url string) (core.Producer, error) { + return rtmp.DialPlay(url) +} + +func streamsConsumerHandle(url string) (core.Consumer, func(), error) { + cons := flv.NewConsumer() + run := func() { + wr, err := rtmp.DialPublish(url, cons) + if err != nil { + return + } + _, err = cons.WriteTo(wr) + } + + return cons, run, nil +} + +func apiHandle(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + outputFLV(w, r) + } else { + inputFLV(w, r) + } +} + +func outputFLV(w http.ResponseWriter, r *http.Request) { + src := r.URL.Query().Get("src") + stream := streams.Get(src) + if stream == nil { + http.Error(w, api.StreamNotFound, http.StatusNotFound) + return + } + + cons := flv.NewConsumer() + cons.WithRequest(r) + + if err := stream.AddConsumer(cons); err != nil { + log.Error().Err(err).Caller().Send() + return + } + + h := w.Header() + h.Set("Content-Type", "video/x-flv") + + _, _ = cons.WriteTo(w) + + stream.RemoveConsumer(cons) +} + +func inputFLV(w http.ResponseWriter, r *http.Request) { + dst := r.URL.Query().Get("dst") + stream := streams.Get(dst) + if stream == nil { + http.Error(w, api.StreamNotFound, http.StatusNotFound) + return + } + + client, err := flv.Open(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + stream.AddProducer(client) + + if err = client.Start(); err != nil && err != io.EOF { + log.Warn().Err(err).Caller().Send() + } + + stream.RemoveProducer(client) +} diff --git a/installs_on_host/go2rtc/internal/rtsp/README.md b/installs_on_host/go2rtc/internal/rtsp/README.md new file mode 100644 index 0000000..cd7c84a --- /dev/null +++ b/installs_on_host/go2rtc/internal/rtsp/README.md @@ -0,0 +1,93 @@ +# Real Time Streaming Protocol + +This module provides the following features for the RTSP protocol: + + - Streaming input - [RTSP client](#rtsp-client) + - Streaming output - [RTSP server](#rtsp-server) + - [Streaming ingest](#streaming-ingest) + - [Two-way audio](#two-way-audio) + +## RTSP Client + +### Configuration + +```yaml +streams: + sonoff_camera: rtsp://rtsp:12345678@192.168.1.123/av_stream/ch0 + dahua_camera: + - rtsp://admin:password@192.168.1.123/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif + - rtsp://admin:password@192.168.1.123/cam/realmonitor?channel=1&subtype=1#backchannel=0 + amcrest_doorbell: + - rtsp://username:password@192.168.1.123:554/cam/realmonitor?channel=1&subtype=0#backchannel=0 + unifi_camera: rtspx://192.168.1.123:7441/fD6ouM72bWoFijxK + glichy_camera: ffmpeg:rtsp://username:password@192.168.1.123/live/ch00_1 +``` + +### Recommendations + +- **Amcrest Doorbell** users may want to disable two-way audio, because with an active stream, you won't have a working call button. You need to add `#backchannel=0` to the end of your RTSP link in YAML config file +- **Dahua Doorbell** users may want to change [audio codec](https://github.com/AlexxIT/go2rtc/issues/49#issuecomment-2127107379) for proper two-way audio. Make sure not to request backchannel multiple times by adding `#backchannel=0` to other stream sources of the same doorbell. The `unicast=true&proto=Onvif` is preferred for two-way audio as this makes the doorbell accept multiple codecs for the incoming audio +- **Reolink** users may want NOT to use RTSP protocol at all, some camera models have a very awful, unusable stream implementation +- **Ubiquiti UniFi** users may want to disable HTTPS verification. Use `rtspx://` prefix instead of `rtsps://`. And don't use `?enableSrtp` [suffix](https://github.com/AlexxIT/go2rtc/issues/81) +- **TP-Link Tapo** users may skip login and password, because go2rtc supports login [without them](https://drmnsamoliu.github.io/video.html) +- If your camera has two RTSP links, you can add both as sources. This is useful when streams have different codecs, for example AAC audio with main stream and PCMU/PCMA audio with second stream +- If the stream from your camera is glitchy, try using [ffmpeg source](../ffmpeg/README.md). It will not add CPU load if you don't use transcoding +- If the stream from your camera is very glitchy, try to use transcoding with [ffmpeg source](../ffmpeg/README.md) + +### Other options + +Format: `rtsp...#{param1}#{param2}#{param3}` + +- Add custom timeout `#timeout=30` (in seconds) +- Ignore audio - `#media=video` or ignore video - `#media=audio` +- Ignore two-way audio API `#backchannel=0` - important for some glitchy cameras +- Use WebSocket transport `#transport=ws...` + +### RTSP over WebSocket + +```yaml +streams: + # WebSocket with authorization, RTSP - without + axis-rtsp-ws: rtsp://192.168.1.123:4567/axis-media/media.amp?overview=0&camera=1&resolution=1280x720&videoframeskipmode=empty&Axis-Orig-Sw=true#transport=ws://user:pass@192.168.1.123:4567/rtsp-over-websocket + # WebSocket without authorization, RTSP - with + dahua-rtsp-ws: rtsp://user:pass@192.168.1.123/cam/realmonitor?channel=1&subtype=1&proto=Private3#transport=ws://192.168.1.123/rtspoverwebsocket +``` + +## RTSP Server + +You can get any stream as RTSP-stream: `rtsp://192.168.1.123:8554/{stream_name}` + +You can enable external password protection for your RTSP streams. Password protection is always disabled for localhost calls (ex. FFmpeg or Home Assistant on the same server). + +### Configuration + +```yaml +rtsp: + listen: ":8554" # RTSP Server TCP port, default - 8554 + username: "admin" # optional, default - disabled + password: "pass" # optional, default - disabled + default_query: "video&audio" # optional, default codecs filters +``` + +By default go2rtc provide RTSP-stream with only one first video and only one first audio. You can change it with the `default_query` setting: + +- `default_query: "mp4"` - MP4 compatible codecs (H264, H265, AAC) +- `default_query: "video=all&audio=all"` - all tracks from all source (not all players can handle this) +- `default_query: "video=h264,h265"` - only one video track (H264 or H265) +- `default_query: "video&audio=all"` - only one first any video and all audio as separate tracks + +Read more about [codecs filters](../../README.md#codecs-filters). + +## Streaming ingest + +```shell +ffmpeg -re -i BigBuckBunny.mp4 -c copy -rtsp_transport tcp -f rtsp rtsp://localhost:8554/camera1 +``` + +## Two-way audio + +Before purchasing, it is difficult to understand whether the camera supports two-way audio via the RTSP protocol or not. This isn't usually mentioned in a camera's description. You can only find out by reading reviews from real buyers. + +A camera is considered to support two-way audio if it supports the ONVIF Profile T protocol. But in reality, this isn't always the case. And the ONVIF protocol has no connection with the camera's RTSP implementation. + +In go2rtc you can find out if the camera supports two-way audio via WebUI > stream probe. diff --git a/installs_on_host/go2rtc/internal/rtsp/rtsp.go b/installs_on_host/go2rtc/internal/rtsp/rtsp.go new file mode 100644 index 0000000..31c2c5d --- /dev/null +++ b/installs_on_host/go2rtc/internal/rtsp/rtsp.go @@ -0,0 +1,312 @@ +package rtsp + +import ( + "errors" + "io" + "net" + "net/url" + "strings" + + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/rtsp" + "github.com/AlexxIT/go2rtc/pkg/tcp" + "github.com/rs/zerolog" +) + +func Init() { + var conf struct { + Mod struct { + Listen string `yaml:"listen" json:"listen"` + Username string `yaml:"username" json:"-"` + Password string `yaml:"password" json:"-"` + DefaultQuery string `yaml:"default_query" json:"default_query"` + PacketSize uint16 `yaml:"pkt_size" json:"pkt_size,omitempty"` + } `yaml:"rtsp"` + } + + // default config + conf.Mod.Listen = ":8554" + conf.Mod.DefaultQuery = "video&audio" + + app.LoadConfig(&conf) + app.Info["rtsp"] = conf.Mod + + log = app.GetLogger("rtsp") + + // RTSP client support + streams.HandleFunc("rtsp", rtspHandler) + streams.HandleFunc("rtsps", rtspHandler) + streams.HandleFunc("rtspx", rtspHandler) + + // RTSP server support + address := conf.Mod.Listen + if address == "" { + return + } + + ln, err := net.Listen("tcp", address) + if err != nil { + log.Error().Err(err).Msg("[rtsp] listen") + return + } + + _, Port, _ = net.SplitHostPort(address) + + log.Info().Str("addr", address).Msg("[rtsp] listen") + + if query, err := url.ParseQuery(conf.Mod.DefaultQuery); err == nil { + defaultMedias = ParseQuery(query) + } + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + + c := rtsp.NewServer(conn) + c.PacketSize = conf.Mod.PacketSize + // skip check auth for localhost + if conf.Mod.Username != "" && !conn.RemoteAddr().(*net.TCPAddr).IP.IsLoopback() { + c.Auth(conf.Mod.Username, conf.Mod.Password) + } + go tcpHandler(c) + } + }() +} + +type Handler func(conn *rtsp.Conn) bool + +func HandleFunc(handler Handler) { + handlers = append(handlers, handler) +} + +var Port string + +// internal + +var log zerolog.Logger +var handlers []Handler +var defaultMedias []*core.Media + +func rtspHandler(rawURL string) (core.Producer, error) { + rawURL, rawQuery, _ := strings.Cut(rawURL, "#") + + conn := rtsp.NewClient(rawURL) + conn.Backchannel = true + conn.UserAgent = app.UserAgent + + if rawQuery != "" { + query := streams.ParseQuery(rawQuery) + conn.Backchannel = query.Get("backchannel") == "1" + conn.Media = query.Get("media") + conn.Timeout = core.Atoi(query.Get("timeout")) + conn.Transport = query.Get("transport") + } + + if log.Trace().Enabled() { + conn.Listen(func(msg any) { + switch msg := msg.(type) { + case *tcp.Request: + log.Trace().Msgf("[rtsp] client request:\n%s", msg) + case *tcp.Response: + log.Trace().Msgf("[rtsp] client response:\n%s", msg) + case string: + log.Trace().Msgf("[rtsp] client msg: %s", msg) + } + }) + } + + if err := conn.Dial(); err != nil { + return nil, err + } + + if err := conn.Describe(); err != nil { + if !conn.Backchannel { + return nil, err + } + log.Trace().Msgf("[rtsp] describe (backchannel=%t) err: %v", conn.Backchannel, err) + + // second try without backchannel, we need to reconnect + conn.Backchannel = false + if err = conn.Dial(); err != nil { + return nil, err + } + if err = conn.Describe(); err != nil { + return nil, err + } + } + + return conn, nil +} + +func tcpHandler(conn *rtsp.Conn) { + var name string + var closer func() + + trace := log.Trace().Enabled() + level := zerolog.WarnLevel + + conn.Listen(func(msg any) { + if trace { + switch msg := msg.(type) { + case *tcp.Request: + log.Trace().Msgf("[rtsp] server request:\n%s", msg) + case *tcp.Response: + log.Trace().Msgf("[rtsp] server response:\n%s", msg) + } + } + + switch msg { + case rtsp.MethodDescribe: + if len(conn.URL.Path) == 0 { + log.Warn().Msg("[rtsp] server empty URL on DESCRIBE") + return + } + + name = conn.URL.Path[1:] + + stream := streams.Get(name) + if stream == nil { + return + } + + log.Debug().Str("stream", name).Msg("[rtsp] new consumer") + + conn.SessionName = app.UserAgent + + query := conn.URL.Query() + conn.Medias = ParseQuery(query) + if conn.Medias == nil { + for _, media := range defaultMedias { + conn.Medias = append(conn.Medias, media.Clone()) + } + } + + if query.Get("backchannel") == "1" { + conn.Medias = append(conn.Medias, &core.Media{ + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + {Name: core.CodecOpus, ClockRate: 48000, Channels: 2}, + {Name: core.CodecPCM, ClockRate: 16000}, + {Name: core.CodecPCMA, ClockRate: 16000}, + {Name: core.CodecPCMU, ClockRate: 16000}, + {Name: core.CodecPCM, ClockRate: 8000}, + {Name: core.CodecPCMA, ClockRate: 8000}, + {Name: core.CodecPCMU, ClockRate: 8000}, + {Name: core.CodecAAC, ClockRate: 8000}, + {Name: core.CodecAAC, ClockRate: 16000}, + }, + }) + } + + if s := query.Get("pkt_size"); s != "" { + conn.PacketSize = uint16(core.Atoi(s)) + } + + // param name like ffmpeg style https://ffmpeg.org/ffmpeg-protocols.html + if s := query.Get("log_level"); s != "" { + if lvl, err := zerolog.ParseLevel(s); err == nil { + level = lvl + } + } + + // will help to protect looping requests to same source + conn.Connection.Source = query.Get("source") + + if err := stream.AddConsumer(conn); err != nil { + log.WithLevel(level).Err(err).Str("stream", name).Msg("[rtsp]") + return + } + + closer = func() { + stream.RemoveConsumer(conn) + } + + case rtsp.MethodAnnounce: + if len(conn.URL.Path) == 0 { + log.Warn().Msg("[rtsp] server empty URL on ANNOUNCE") + return + } + + name = conn.URL.Path[1:] + + stream := streams.Get(name) + if stream == nil { + return + } + + query := conn.URL.Query() + if s := query.Get("timeout"); s != "" { + conn.Timeout = core.Atoi(s) + } + + log.Debug().Str("stream", name).Msg("[rtsp] new producer") + + stream.AddProducer(conn) + + closer = func() { + stream.RemoveProducer(conn) + } + } + }) + + if err := conn.Accept(); err != nil { + if errors.Is(err, rtsp.FailedAuth) { + log.Warn().Str("remote_addr", conn.Connection.RemoteAddr).Msg("[rtsp] failed authentication") + } else if err != io.EOF { + log.WithLevel(level).Err(err).Caller().Send() + } + if closer != nil { + closer() + } + _ = conn.Close() + return + } + + for _, handler := range handlers { + if handler(conn) { + return + } + } + + if closer != nil { + if err := conn.Handle(); err != nil { + log.Debug().Err(err).Msg("[rtsp] handle") + } + + closer() + + log.Debug().Str("stream", name).Msg("[rtsp] disconnect") + } + + _ = conn.Close() +} + +func ParseQuery(query map[string][]string) []*core.Media { + if v := query["mp4"]; v != nil { + return []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecH264}, + {Name: core.CodecH265}, + }, + }, + { + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecAAC}, + }, + }, + } + } + + return core.ParseQuery(query) +} diff --git a/installs_on_host/go2rtc/internal/srtp/README.md b/installs_on_host/go2rtc/internal/srtp/README.md new file mode 100644 index 0000000..9bc36d0 --- /dev/null +++ b/installs_on_host/go2rtc/internal/srtp/README.md @@ -0,0 +1,13 @@ +# SRTP + +This is a support module for the [HomeKit](../homekit/README.md) module. + +> [!NOTE] +> This module can be removed and its functionality transferred to the homekit module. + +## Configuration + +```yaml +srtp: + listen: :8443 # enabled by default +``` diff --git a/installs_on_host/go2rtc/internal/srtp/srtp.go b/installs_on_host/go2rtc/internal/srtp/srtp.go new file mode 100644 index 0000000..2cf7b6e --- /dev/null +++ b/installs_on_host/go2rtc/internal/srtp/srtp.go @@ -0,0 +1,29 @@ +package srtp + +import ( + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/pkg/srtp" +) + +func Init() { + var cfg struct { + Mod struct { + Listen string `yaml:"listen"` + } `yaml:"srtp"` + } + + // default config + cfg.Mod.Listen = ":8443" + + // load config from YAML + app.LoadConfig(&cfg) + + if cfg.Mod.Listen == "" { + return + } + + // create SRTP server (endpoint) for receiving video from HomeKit cameras + Server = srtp.NewServer(cfg.Mod.Listen) +} + +var Server *srtp.Server diff --git a/installs_on_host/go2rtc/internal/streams/README.md b/installs_on_host/go2rtc/internal/streams/README.md new file mode 100644 index 0000000..2f3eb39 --- /dev/null +++ b/installs_on_host/go2rtc/internal/streams/README.md @@ -0,0 +1,141 @@ +# Streams + +This core module is responsible for managing the stream list. + +## Stream to camera + +[`new in v1.3.0`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0) + +go2rtc supports playing audio files (ex. music or [TTS](https://www.home-assistant.io/integrations/#text-to-speech)) and live streams (ex. radio) on cameras with [two-way audio](../../README.md#two-way-audio) support. + +API example: + +```text +POST http://localhost:1984/api/streams?dst=camera1&src=ffmpeg:http://example.com/song.mp3#audio=pcma#input=file +``` + +- you can stream: local files, web files, live streams or any format, supported by FFmpeg +- you should use [ffmpeg source](../ffmpeg/README.md) for transcoding audio to codec, that your camera supports +- you can check camera codecs on the go2rtc WebUI info page when the stream is active +- some cameras support only low quality `PCMA/8000` codec (ex. [Tapo](../tapo/README.md)) +- it is recommended to choose higher quality formats if your camera supports them (ex. `PCMA/48000` for some Dahua cameras) +- if you play files over `http` link, you need to add `#input=file` params for transcoding, so the file will be transcoded and played in real time +- if you play live streams, you should skip `#input` param, because it is already in real time +- you can stop active playback by calling the API with the empty `src` parameter +- you will see one active producer and one active consumer in go2rtc WebUI info page during streaming + +## Publish stream + +[`new in v1.8.0`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.0) + +You can publish any stream to streaming services (YouTube, Telegram, etc.) via RTMP/RTMPS. Important: + +- Supported codecs: H264 for video and AAC for audio +- AAC audio is required for YouTube; videos without audio will not work +- You don't need to enable [RTMP module](../rtmp/README.md) listening for this task + +You can use the API: + +```text +POST http://localhost:1984/api/streams?src=camera1&dst=rtmps://... +``` + +Or config file: + +```yaml +publish: + # publish stream "video_audio_transcode" to Telegram + video_audio_transcode: + - rtmps://xxx-x.rtmp.t.me/s/xxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxx + # publish stream "audio_transcode" to Telegram and YouTube + audio_transcode: + - rtmps://xxx-x.rtmp.t.me/s/xxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxx + - rtmp://xxx.rtmp.youtube.com/live2/xxxx-xxxx-xxxx-xxxx-xxxx + +streams: + video_audio_transcode: + - ffmpeg:rtsp://user:pass@192.168.1.123/stream1#video=h264#hardware#audio=aac + audio_transcode: + - ffmpeg:rtsp://user:pass@192.168.1.123/stream1#video=copy#audio=aac +``` + +- **Telegram Desktop App** > Any public or private channel or group (where you admin) > Live stream > Start with... > Start streaming. +- **YouTube** > Create > Go live > Stream latency: Ultra low-latency > Copy: Stream URL + Stream key. + +## Preload stream + +[`new in v1.9.11`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.11) + +You can preload any stream on go2rtc start. This is useful for cameras that take a long time to start up. + +```yaml +preload: + camera1: # default: video&audio = ANY + camera2: "video" # preload only video track + camera3: "video=h264&audio=opus" # preload H264 video and OPUS audio + +streams: + camera1: + - rtsp://192.168.1.100/stream + camera2: + - rtsp://192.168.1.101/stream + camera3: + - rtsp://192.168.1.102/h265stream + - ffmpeg:camera3#video=h264#audio=opus#hardware +``` + +## Examples + +```yaml +streams: + # known RTSP sources + rtsp-dahua1: rtsp://admin:password@192.168.10.90/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif + rtsp-dahua2: rtsp://admin:password@192.168.10.90/cam/realmonitor?channel=1&subtype=1 + rtsp-tplink1: rtsp://admin:password@192.168.10.91/stream1 + rtsp-tplink2: rtsp://admin:password@192.168.10.91/stream2 + rtsp-reolink1: rtsp://admin:password@192.168.10.92/h264Preview_01_main + rtsp-reolink2: rtsp://admin:password@192.168.10.92/h264Preview_01_sub + rtsp-sonoff1: rtsp://admin:password@192.168.10.93/av_stream/ch0 + rtsp-sonoff2: rtsp://admin:password@192.168.10.93/av_stream/ch1 + + # known RTMP sources + rtmp-reolink1: rtmp://192.168.10.92/bcs/channel0_main.bcs?channel=0&stream=0&user=admin&password=password + rtmp-reolink2: rtmp://192.168.10.92/bcs/channel0_sub.bcs?channel=0&stream=1&user=admin&password=password + rtmp-reolink3: rtmp://192.168.10.92/bcs/channel0_ext.bcs?channel=0&stream=1&user=admin&password=password + + # known HTTP sources + http-reolink1: http://192.168.10.92/flv?port=1935&app=bcs&stream=channel0_main.bcs&user=admin&password=password + http-reolink2: http://192.168.10.92/flv?port=1935&app=bcs&stream=channel0_sub.bcs&user=admin&password=password + http-reolink3: http://192.168.10.92/flv?port=1935&app=bcs&stream=channel0_ext.bcs&user=admin&password=password + + # known ONVIF sources + onvif-dahua1: onvif://admin:password@192.168.10.90?subtype=MediaProfile00000 + onvif-dahua2: onvif://admin:password@192.168.10.90?subtype=MediaProfile00001 + onvif-dahua3: onvif://admin:password@192.168.10.90?subtype=MediaProfile00000&snapshot + onvif-tplink1: onvif://admin:password@192.168.10.91:2020?subtype=profile_1 + onvif-tplink2: onvif://admin:password@192.168.10.91:2020?subtype=profile_2 + onvif-reolink1: onvif://admin:password@192.168.10.92:8000?subtype=000 + onvif-reolink2: onvif://admin:password@192.168.10.92:8000?subtype=001 + onvif-reolink3: onvif://admin:password@192.168.10.92:8000?subtype=000&snapshot + onvif-openipc1: onvif://admin:password@192.168.10.95:80?subtype=PROFILE_000 + onvif-openipc2: onvif://admin:password@192.168.10.95:80?subtype=PROFILE_001 + + # some EXEC examples + exec-h264-pipe: exec:ffmpeg -re -i bbb.mp4 -c copy -f h264 - + exec-flv-pipe: exec:ffmpeg -re -i bbb.mp4 -c copy -f flv - + exec-mpegts-pipe: exec:ffmpeg -re -i bbb.mp4 -c copy -f mpegts - + exec-adts-pipe: exec:ffmpeg -re -i bbb.mp4 -c copy -f adts - + exec-mjpeg-pipe: exec:ffmpeg -re -i bbb.mp4 -c mjpeg -f mjpeg - + exec-hevc-pipe: exec:ffmpeg -re -i bbb.mp4 -c libx265 -preset superfast -tune zerolatency -f hevc - + exec-wav-pipe: exec:ffmpeg -re -i bbb.mp4 -c pcm_alaw -ar 8000 -ac 1 -f wav - + exec-y4m-pipe: exec:ffmpeg -re -i bbb.mp4 -c rawvideo -f yuv4mpegpipe - + exec-pcma-pipe: exec:ffmpeg -re -i numb.mp3 -c:a pcm_alaw -ar:a 8000 -ac:a 1 -f wav - + exec-pcmu-pipe: exec:ffmpeg -re -i numb.mp3 -c:a pcm_mulaw -ar:a 8000 -ac:a 1 -f wav - + exec-s16le-pipe: exec:ffmpeg -re -i numb.mp3 -c:a pcm_s16le -ar:a 16000 -ac:a 1 -f wav - + + # some FFmpeg examples + ffmpeg-video-h264: ffmpeg:virtual?video#video=h264 + ffmpeg-video-4K: ffmpeg:virtual?video&size=4K#video=h264 + ffmpeg-video-10s: ffmpeg:virtual?video&duration=10#video=h264 + ffmpeg-video-src2: ffmpeg:virtual?video=testsrc2&size=2K#video=h264 +``` diff --git a/installs_on_host/go2rtc/internal/streams/add_consumer.go b/installs_on_host/go2rtc/internal/streams/add_consumer.go new file mode 100644 index 0000000..7400ce6 --- /dev/null +++ b/installs_on_host/go2rtc/internal/streams/add_consumer.go @@ -0,0 +1,166 @@ +package streams + +import ( + "errors" + "strings" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +func (s *Stream) AddConsumer(cons core.Consumer) (err error) { + // support for multiple simultaneous pending from different consumers + consN := s.pending.Add(1) - 1 + + var prodErrors = make([]error, len(s.producers)) + var prodMedias []*core.Media + var prodStarts []*Producer + + // Step 1. Get consumer medias + consMedias := cons.GetMedias() + for _, consMedia := range consMedias { + log.Trace().Msgf("[streams] check cons=%d media=%s", consN, consMedia) + + producers: + for prodN, prod := range s.producers { + // check for loop request, ex. `camera1: ffmpeg:camera1` + if info, ok := cons.(core.Info); ok && prod.url == info.GetSource() { + log.Trace().Msgf("[streams] skip cons=%d prod=%d", consN, prodN) + continue + } + + if prodErrors[prodN] != nil { + log.Trace().Msgf("[streams] skip cons=%d prod=%d", consN, prodN) + continue + } + + if err = prod.Dial(); err != nil { + log.Trace().Err(err).Msgf("[streams] dial cons=%d prod=%d", consN, prodN) + prodErrors[prodN] = err + continue + } + + // Step 2. Get producer medias (not tracks yet) + for _, prodMedia := range prod.GetMedias() { + log.Trace().Msgf("[streams] check cons=%d prod=%d media=%s", consN, prodN, prodMedia) + prodMedias = append(prodMedias, prodMedia) + + // Step 3. Match consumer/producer codecs list + prodCodec, consCodec := prodMedia.MatchMedia(consMedia) + if prodCodec == nil { + continue + } + + var track *core.Receiver + + switch prodMedia.Direction { + case core.DirectionRecvonly: + log.Trace().Msgf("[streams] match cons=%d <= prod=%d", consN, prodN) + + // Step 4. Get recvonly track from producer + if track, err = prod.GetTrack(prodMedia, prodCodec); err != nil { + log.Info().Err(err).Msg("[streams] can't get track") + prodErrors[prodN] = err + continue + } + // Step 5. Add track to consumer + if err = cons.AddTrack(consMedia, consCodec, track); err != nil { + log.Info().Err(err).Msg("[streams] can't add track") + continue + } + + case core.DirectionSendonly: + log.Trace().Msgf("[streams] match cons=%d => prod=%d", consN, prodN) + + // Step 4. Get recvonly track from consumer (backchannel) + if track, err = cons.(core.Producer).GetTrack(consMedia, consCodec); err != nil { + log.Info().Err(err).Msg("[streams] can't get track") + continue + } + // Step 5. Add track to producer + if err = prod.AddTrack(prodMedia, prodCodec, track); err != nil { + log.Info().Err(err).Msg("[streams] can't add track") + prodErrors[prodN] = err + continue + } + } + + prodStarts = append(prodStarts, prod) + + if !consMedia.MatchAll() { + break producers + } + } + } + } + + // stop producers if they don't have readers + if s.pending.Add(-1) == 0 { + s.stopProducers() + } + + if len(prodStarts) == 0 { + return formatError(consMedias, prodMedias, prodErrors) + } + + s.mu.Lock() + s.consumers = append(s.consumers, cons) + s.mu.Unlock() + + // there may be duplicates, but that's not a problem + for _, prod := range prodStarts { + prod.start() + } + + return nil +} + +func formatError(consMedias, prodMedias []*core.Media, prodErrors []error) error { + // 1. Return errors if any not nil + var text string + + for _, err := range prodErrors { + if err != nil { + text = appendString(text, err.Error()) + } + } + + if len(text) != 0 { + return errors.New("streams: " + text) + } + + // 2. Return "codecs not matched" + if prodMedias != nil { + var prod, cons string + + for _, media := range prodMedias { + if media.Direction == core.DirectionRecvonly { + for _, codec := range media.Codecs { + prod = appendString(prod, media.Kind+":"+codec.PrintName()) + } + } + } + + for _, media := range consMedias { + if media.Direction == core.DirectionSendonly { + for _, codec := range media.Codecs { + cons = appendString(cons, media.Kind+":"+codec.PrintName()) + } + } + } + + return errors.New("streams: codecs not matched: " + prod + " => " + cons) + } + + // 3. Return unknown error + return errors.New("streams: unknown error") +} + +func appendString(s, elem string) string { + if strings.Contains(s, elem) { + return s + } + if len(s) == 0 { + return elem + } + return s + ", " + elem +} diff --git a/installs_on_host/go2rtc/internal/streams/api.go b/installs_on_host/go2rtc/internal/streams/api.go new file mode 100644 index 0000000..d6142eb --- /dev/null +++ b/installs_on_host/go2rtc/internal/streams/api.go @@ -0,0 +1,181 @@ +package streams + +import ( + "net/http" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/creds" + "github.com/AlexxIT/go2rtc/pkg/probe" +) + +func apiStreams(w http.ResponseWriter, r *http.Request) { + w = creds.SecretResponse(w) + + query := r.URL.Query() + src := query.Get("src") + + // without source - return all streams list + if src == "" && r.Method != "POST" { + api.ResponseJSON(w, streams) + return + } + + // Not sure about all this API. Should be rewrited... + switch r.Method { + case "GET": + stream := Get(src) + if stream == nil { + http.Error(w, "", http.StatusNotFound) + return + } + + cons := probe.Create("probe", query) + if len(cons.Medias) != 0 { + cons.WithRequest(r) + if err := stream.AddConsumer(cons); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + api.ResponsePrettyJSON(w, stream) + + stream.RemoveConsumer(cons) + } else { + api.ResponsePrettyJSON(w, streams[src]) + } + + case "PUT": + name := query.Get("name") + if name == "" { + name = src + } + + if _, err := New(name, query["src"]...); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := app.PatchConfig([]string{"streams", name}, query["src"]); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + } + + case "PATCH": + name := query.Get("name") + if name == "" { + http.Error(w, "", http.StatusBadRequest) + return + } + + // support {input} templates: https://github.com/AlexxIT/go2rtc#module-hass + if _, err := Patch(name, src); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + } + + case "POST": + // with dst - redirect source to dst + if dst := query.Get("dst"); dst != "" { + if stream := Get(dst); stream != nil { + if err := Validate(src); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + } else if err = stream.Play(src); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } else { + api.ResponseJSON(w, stream) + } + } else if stream = Get(src); stream != nil { + if err := Validate(dst); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + } else if err = stream.Publish(dst); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } else { + http.Error(w, "", http.StatusNotFound) + } + } else { + http.Error(w, "", http.StatusBadRequest) + } + + case "DELETE": + delete(streams, src) + + if err := app.PatchConfig([]string{"streams", src}, nil); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } +} + +func apiStreamsDOT(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + + dot := make([]byte, 0, 1024) + dot = append(dot, "digraph {\n"...) + if query.Has("src") { + for _, name := range query["src"] { + if stream := streams[name]; stream != nil { + dot = AppendDOT(dot, stream) + } + } + } else { + for _, stream := range streams { + dot = AppendDOT(dot, stream) + } + } + dot = append(dot, '}') + + dot = []byte(creds.SecretString(string(dot))) + + api.Response(w, dot, "text/vnd.graphviz") +} + +func apiPreload(w http.ResponseWriter, r *http.Request) { + // GET - return all preloads + if r.Method == "GET" { + api.ResponseJSON(w, GetPreloads()) + return + } + + query := r.URL.Query() + src := query.Get("src") + + switch r.Method { + case "PUT": + // it's safe to delete from map while iterating + for k := range query { + switch k { + case core.KindVideo, core.KindAudio, "microphone": + default: + delete(query, k) + } + } + + rawQuery := query.Encode() + + if err := AddPreload(src, rawQuery); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if err := app.PatchConfig([]string{"preload", src}, rawQuery); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + + case "DELETE": + if err := DelPreload(src); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if err := app.PatchConfig([]string{"preload", src}, nil); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + + default: + http.Error(w, "", http.StatusMethodNotAllowed) + } +} + +func apiSchemes(w http.ResponseWriter, r *http.Request) { + api.ResponseJSON(w, SupportedSchemes()) +} diff --git a/installs_on_host/go2rtc/internal/streams/api_test.go b/installs_on_host/go2rtc/internal/streams/api_test.go new file mode 100644 index 0000000..2cb93d2 --- /dev/null +++ b/installs_on_host/go2rtc/internal/streams/api_test.go @@ -0,0 +1,66 @@ +package streams + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/stretchr/testify/require" +) + +func TestApiSchemes(t *testing.T) { + // Setup: Register some test handlers and redirects + HandleFunc("rtsp", func(url string) (core.Producer, error) { return nil, nil }) + HandleFunc("rtmp", func(url string) (core.Producer, error) { return nil, nil }) + RedirectFunc("http", func(url string) (string, error) { return "", nil }) + + t.Run("GET request returns schemes", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/schemes", nil) + w := httptest.NewRecorder() + + apiSchemes(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, "application/json", w.Header().Get("Content-Type")) + + var schemes []string + err := json.Unmarshal(w.Body.Bytes(), &schemes) + require.NoError(t, err) + require.NotEmpty(t, schemes) + + // Check that our test schemes are in the response + require.Contains(t, schemes, "rtsp") + require.Contains(t, schemes, "rtmp") + require.Contains(t, schemes, "http") + }) +} + +func TestApiSchemesNoDuplicates(t *testing.T) { + // Setup: Register a scheme in both handlers and redirects + HandleFunc("duplicate", func(url string) (core.Producer, error) { return nil, nil }) + RedirectFunc("duplicate", func(url string) (string, error) { return "", nil }) + + req := httptest.NewRequest("GET", "/api/schemes", nil) + w := httptest.NewRecorder() + + apiSchemes(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var schemes []string + err := json.Unmarshal(w.Body.Bytes(), &schemes) + require.NoError(t, err) + + // Count occurrences of "duplicate" + count := 0 + for _, scheme := range schemes { + if scheme == "duplicate" { + count++ + } + } + + // Should only appear once + require.Equal(t, 1, count, "scheme 'duplicate' should appear exactly once") +} diff --git a/installs_on_host/go2rtc/internal/streams/dot.go b/installs_on_host/go2rtc/internal/streams/dot.go new file mode 100644 index 0000000..e041797 --- /dev/null +++ b/installs_on_host/go2rtc/internal/streams/dot.go @@ -0,0 +1,176 @@ +package streams + +import ( + "encoding/json" + "fmt" + "strings" +) + +func AppendDOT(dot []byte, stream *Stream) []byte { + for _, prod := range stream.producers { + if prod.conn == nil { + continue + } + c, err := marshalConn(prod.conn) + if err != nil { + continue + } + dot = c.appendDOT(dot, "producer") + } + for _, cons := range stream.consumers { + c, err := marshalConn(cons) + if err != nil { + continue + } + dot = c.appendDOT(dot, "consumer") + } + return dot +} + +func marshalConn(v any) (*conn, error) { + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + var c conn + if err = json.Unmarshal(b, &c); err != nil { + return nil, err + } + return &c, nil +} + +const bytesK = "KMGTP" + +func humanBytes(i int) string { + if i < 1000 { + return fmt.Sprintf("%d B", i) + } + + f := float64(i) / 1000 + var n uint8 + for f >= 1000 && n < 5 { + f /= 1000 + n++ + } + return fmt.Sprintf("%.2f %cB", f, bytesK[n]) +} + +type node struct { + ID uint32 `json:"id"` + Codec map[string]any `json:"codec"` + Parent uint32 `json:"parent"` + Childs []uint32 `json:"childs"` + Bytes int `json:"bytes"` + //Packets uint32 `json:"packets"` + //Drops uint32 `json:"drops"` +} + +var codecKeys = []string{"codec_name", "sample_rate", "channels", "profile", "level"} + +func (n *node) name() string { + if name, ok := n.Codec["codec_name"].(string); ok { + return name + } + return "unknown" +} + +func (n *node) codec() []byte { + b := make([]byte, 0, 128) + for _, k := range codecKeys { + if v := n.Codec[k]; v != nil { + b = fmt.Appendf(b, "%s=%v\n", k, v) + } + } + if l := len(b); l > 0 { + return b[:l-1] + } + return b +} + +func (n *node) appendDOT(dot []byte, group string) []byte { + dot = fmt.Appendf(dot, "%d [group=%s, label=%q, title=%q];\n", n.ID, group, n.name(), n.codec()) + //for _, sink := range n.Childs { + // dot = fmt.Appendf(dot, "%d -> %d;\n", n.ID, sink) + //} + return dot +} + +type conn struct { + ID uint32 `json:"id"` + FormatName string `json:"format_name"` + Protocol string `json:"protocol"` + RemoteAddr string `json:"remote_addr"` + Source string `json:"source"` + URL string `json:"url"` + UserAgent string `json:"user_agent"` + Receivers []node `json:"receivers"` + Senders []node `json:"senders"` + BytesRecv int `json:"bytes_recv"` + BytesSend int `json:"bytes_send"` +} + +func (c *conn) appendDOT(dot []byte, group string) []byte { + host := c.host() + dot = fmt.Appendf(dot, "%s [group=host];\n", host) + dot = fmt.Appendf(dot, "%d [group=%s, label=%q, title=%q];\n", c.ID, group, c.FormatName, c.label()) + if group == "producer" { + dot = fmt.Appendf(dot, "%s -> %d [label=%q];\n", host, c.ID, humanBytes(c.BytesRecv)) + } else { + dot = fmt.Appendf(dot, "%d -> %s [label=%q];\n", c.ID, host, humanBytes(c.BytesSend)) + } + + for _, recv := range c.Receivers { + dot = fmt.Appendf(dot, "%d -> %d [label=%q];\n", c.ID, recv.ID, humanBytes(recv.Bytes)) + dot = recv.appendDOT(dot, "node") + } + for _, send := range c.Senders { + dot = fmt.Appendf(dot, "%d -> %d [label=%q];\n", send.Parent, c.ID, humanBytes(send.Bytes)) + //dot = fmt.Appendf(dot, "%d -> %d [label=%q];\n", send.ID, c.ID, humanBytes(send.Bytes)) + //dot = send.appendDOT(dot, "node") + } + return dot +} + +func (c *conn) host() (s string) { + if c.Protocol == "pipe" { + return "127.0.0.1" + } + + if s = c.RemoteAddr; s == "" { + return "unknown" + } + + if i := strings.Index(s, "forwarded"); i > 0 { + s = s[i+10:] + } + + if s[0] == '[' { + if i := strings.Index(s, "]"); i > 0 { + return s[1:i] + } + } + + if i := strings.IndexAny(s, " ,:"); i > 0 { + return s[:i] + } + return +} + +func (c *conn) label() string { + var sb strings.Builder + sb.WriteString("format_name=" + c.FormatName) + if c.Protocol != "" { + sb.WriteString("\nprotocol=" + c.Protocol) + } + if c.Source != "" { + sb.WriteString("\nsource=" + c.Source) + } + if c.URL != "" { + sb.WriteString("\nurl=" + c.URL) + } + if c.UserAgent != "" { + sb.WriteString("\nuser_agent=" + c.UserAgent) + } + // escape quotes https://github.com/AlexxIT/go2rtc/issues/1603 + return strings.ReplaceAll(sb.String(), `"`, `'`) +} diff --git a/installs_on_host/go2rtc/internal/streams/handlers.go b/installs_on_host/go2rtc/internal/streams/handlers.go new file mode 100644 index 0000000..9433044 --- /dev/null +++ b/installs_on_host/go2rtc/internal/streams/handlers.go @@ -0,0 +1,134 @@ +package streams + +import ( + "errors" + "regexp" + "strings" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +type Handler func(source string) (core.Producer, error) + +var handlers = map[string]Handler{} + +func HandleFunc(scheme string, handler Handler) { + handlers[scheme] = handler +} + +func SupportedSchemes() []string { + uniqueKeys := make(map[string]struct{}, len(handlers)+len(redirects)) + for scheme := range handlers { + uniqueKeys[scheme] = struct{}{} + } + for scheme := range redirects { + uniqueKeys[scheme] = struct{}{} + } + resultKeys := make([]string, 0, len(uniqueKeys)) + for key := range uniqueKeys { + resultKeys = append(resultKeys, key) + } + return resultKeys +} + +func HasProducer(url string) bool { + if i := strings.IndexByte(url, ':'); i > 0 { + scheme := url[:i] + + if _, ok := handlers[scheme]; ok { + return true + } + + if _, ok := redirects[scheme]; ok { + return true + } + } + + return false +} + +func GetProducer(url string) (core.Producer, error) { + if i := strings.IndexByte(url, ':'); i > 0 { + scheme := url[:i] + + if redirect, ok := redirects[scheme]; ok { + location, err := redirect(url) + if err != nil { + return nil, err + } + if location != "" { + return GetProducer(location) + } + } + + if handler, ok := handlers[scheme]; ok { + return handler(url) + } + } + + return nil, errors.New("streams: unsupported scheme: " + url) +} + +// Redirect can return: location URL or error or empty URL and error +type Redirect func(url string) (string, error) + +var redirects = map[string]Redirect{} + +func RedirectFunc(scheme string, redirect Redirect) { + redirects[scheme] = redirect +} + +func Location(url string) (string, error) { + if i := strings.IndexByte(url, ':'); i > 0 { + scheme := url[:i] + + if redirect, ok := redirects[scheme]; ok { + return redirect(url) + } + } + + return "", nil +} + +// TODO: rework + +type ConsumerHandler func(url string) (core.Consumer, func(), error) + +var consumerHandlers = map[string]ConsumerHandler{} + +func HandleConsumerFunc(scheme string, handler ConsumerHandler) { + consumerHandlers[scheme] = handler +} + +func GetConsumer(url string) (core.Consumer, func(), error) { + if i := strings.IndexByte(url, ':'); i > 0 { + scheme := url[:i] + + if handler, ok := consumerHandlers[scheme]; ok { + return handler(url) + } + } + + return nil, nil, errors.New("streams: unsupported scheme: " + url) +} + +var insecure = map[string]bool{} + +func MarkInsecure(scheme string) { + insecure[scheme] = true +} + +var sanitize = regexp.MustCompile(`\s`) + +func Validate(source string) error { + // TODO: Review the entire logic of insecure sources + if i := strings.IndexByte(source, ':'); i > 0 { + if insecure[source[:i]] { + return errors.New("streams: source from insecure producer") + } + } + if sanitize.MatchString(source) { + return errors.New("streams: source with spaces may be insecure") + } + return nil +} diff --git a/installs_on_host/go2rtc/internal/streams/helpers.go b/installs_on_host/go2rtc/internal/streams/helpers.go new file mode 100644 index 0000000..2ead1aa --- /dev/null +++ b/installs_on_host/go2rtc/internal/streams/helpers.go @@ -0,0 +1,22 @@ +package streams + +import ( + "net/url" + "strings" +) + +func ParseQuery(s string) url.Values { + if len(s) == 0 { + return nil + } + params := url.Values{} + for _, key := range strings.Split(s, "#") { + var value string + i := strings.IndexByte(key, '=') + if i > 0 { + key, value = key[:i], key[i+1:] + } + params[key] = append(params[key], value) + } + return params +} diff --git a/installs_on_host/go2rtc/internal/streams/play.go b/installs_on_host/go2rtc/internal/streams/play.go new file mode 100644 index 0000000..1f8c4ad --- /dev/null +++ b/installs_on_host/go2rtc/internal/streams/play.go @@ -0,0 +1,163 @@ +package streams + +import ( + "errors" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +func (s *Stream) Play(urlOrProd any) error { + s.mu.Lock() + for _, producer := range s.producers { + if producer.state == stateInternal && producer.conn != nil { + _ = producer.conn.Stop() + } + } + s.mu.Unlock() + + var source string + var src core.Producer + + switch urlOrProd.(type) { + case string: + if source = urlOrProd.(string); source == "" { + return nil + } + case core.Producer: + src = urlOrProd.(core.Producer) + } + + for _, producer := range s.producers { + if producer.conn == nil { + continue + } + + cons, ok := producer.conn.(core.Consumer) + if !ok { + continue + } + + if src == nil { + var err error + if src, err = GetProducer(source); err != nil { + return err + } + } + + if !matchMedia(src, cons) { + continue + } + + s.AddInternalProducer(src) + + go func() { + _ = src.Start() + s.RemoveProducer(src) + }() + + return nil + } + + for _, producer := range s.producers { + // start new client + dst, err := GetProducer(producer.url) + if err != nil { + continue + } + + // check if client support consumer interface + cons, ok := dst.(core.Consumer) + if !ok { + _ = dst.Stop() + continue + } + + // start new producer + if src == nil { + if src, err = GetProducer(source); err != nil { + return err + } + } + + if !matchMedia(src, cons) { + _ = dst.Stop() + continue + } + + s.AddInternalProducer(src) + s.AddInternalConsumer(cons) + + go func() { + _ = dst.Start() + _ = src.Stop() + s.RemoveInternalConsumer(cons) + }() + + go func() { + _ = src.Start() + // little timeout before stop dst, so the buffer can be transferred + time.Sleep(time.Second) + _ = dst.Stop() + s.RemoveProducer(src) + }() + + return nil + } + + return errors.New("can't find consumer") +} + +func (s *Stream) AddInternalProducer(conn core.Producer) { + producer := &Producer{conn: conn, state: stateInternal, url: "internal"} + s.mu.Lock() + s.producers = append(s.producers, producer) + s.mu.Unlock() +} + +func (s *Stream) AddInternalConsumer(conn core.Consumer) { + s.mu.Lock() + s.consumers = append(s.consumers, conn) + s.mu.Unlock() +} + +func (s *Stream) RemoveInternalConsumer(conn core.Consumer) { + s.mu.Lock() + for i, consumer := range s.consumers { + if consumer == conn { + s.consumers = append(s.consumers[:i], s.consumers[i+1:]...) + break + } + } + s.mu.Unlock() +} + +func matchMedia(prod core.Producer, cons core.Consumer) bool { + for _, consMedia := range cons.GetMedias() { + for _, prodMedia := range prod.GetMedias() { + if prodMedia.Direction != core.DirectionRecvonly { + continue + } + + prodCodec, consCodec := prodMedia.MatchMedia(consMedia) + if prodCodec == nil { + continue + } + + track, err := prod.GetTrack(prodMedia, prodCodec) + if err != nil { + log.Warn().Err(err).Msg("[streams] can't get track") + continue + } + + if err = cons.AddTrack(consMedia, consCodec, track); err != nil { + log.Warn().Err(err).Msg("[streams] can't add track") + continue + } + + return true + } + } + + return false +} diff --git a/installs_on_host/go2rtc/internal/streams/preload.go b/installs_on_host/go2rtc/internal/streams/preload.go new file mode 100644 index 0000000..ac4403d --- /dev/null +++ b/installs_on_host/go2rtc/internal/streams/preload.go @@ -0,0 +1,69 @@ +package streams + +import ( + "fmt" + "maps" + "net/url" + "sync" + + "github.com/AlexxIT/go2rtc/pkg/probe" +) + +type Preload struct { + stream *Stream // Don't include the stream in JSON to avoid leaking secrets. + Cons *probe.Probe `json:"consumer"` + Query string `json:"query"` +} + +var preloads = map[string]*Preload{} +var preloadsMu sync.Mutex + +func AddPreload(name, rawQuery string) error { + if rawQuery == "" { + rawQuery = "video&audio" + } + + query, err := url.ParseQuery(rawQuery) + if err != nil { + return err + } + + preloadsMu.Lock() + defer preloadsMu.Unlock() + + if p := preloads[name]; p != nil { + p.stream.RemoveConsumer(p.Cons) + } + + stream := Get(name) + if stream == nil { + return fmt.Errorf("streams: stream not found: %s", name) + } + cons := probe.Create("preload", query) + + if err = stream.AddConsumer(cons); err != nil { + return err + } + + preloads[name] = &Preload{stream: stream, Cons: cons, Query: rawQuery} + return nil +} + +func DelPreload(name string) error { + preloadsMu.Lock() + defer preloadsMu.Unlock() + + if p := preloads[name]; p != nil { + p.stream.RemoveConsumer(p.Cons) + delete(preloads, name) + return nil + } + + return fmt.Errorf("streams: preload not found: %s", name) +} + +func GetPreloads() map[string]*Preload { + preloadsMu.Lock() + defer preloadsMu.Unlock() + return maps.Clone(preloads) +} diff --git a/installs_on_host/go2rtc/internal/streams/producer.go b/installs_on_host/go2rtc/internal/streams/producer.go new file mode 100644 index 0000000..09e2dcc --- /dev/null +++ b/installs_on_host/go2rtc/internal/streams/producer.go @@ -0,0 +1,270 @@ +package streams + +import ( + "encoding/json" + "errors" + "strings" + "sync" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +type state byte + +const ( + stateNone state = iota + stateMedias + stateTracks + stateStart + stateExternal + stateInternal +) + +type Producer struct { + core.Listener + + url string + template string + + conn core.Producer + receivers []*core.Receiver + senders []*core.Receiver + + state state + mu sync.Mutex + workerID int +} + +const SourceTemplate = "{input}" + +func NewProducer(source string) *Producer { + if strings.Contains(source, SourceTemplate) { + return &Producer{template: source} + } + + return &Producer{url: source} +} + +func (p *Producer) SetSource(s string) { + if p.template == "" { + p.url = s + } else { + p.url = strings.Replace(p.template, SourceTemplate, s, 1) + } +} + +func (p *Producer) Dial() error { + p.mu.Lock() + defer p.mu.Unlock() + + if p.state == stateNone { + conn, err := GetProducer(p.url) + if err != nil { + return err + } + + p.conn = conn + p.state = stateMedias + } + + return nil +} + +func (p *Producer) GetMedias() []*core.Media { + p.mu.Lock() + defer p.mu.Unlock() + + if p.conn == nil { + return nil + } + + return p.conn.GetMedias() +} + +func (p *Producer) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + p.mu.Lock() + defer p.mu.Unlock() + + if p.state == stateNone { + return nil, errors.New("get track from none state") + } + + for _, track := range p.receivers { + if track.Codec == codec { + return track, nil + } + } + + track, err := p.conn.GetTrack(media, codec) + if err != nil { + return nil, err + } + + p.receivers = append(p.receivers, track) + + if p.state == stateMedias { + p.state = stateTracks + } + + return track, nil +} + +func (p *Producer) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + p.mu.Lock() + defer p.mu.Unlock() + + if p.state == stateNone { + return errors.New("add track from none state") + } + + if err := p.conn.(core.Consumer).AddTrack(media, codec, track); err != nil { + return err + } + + p.senders = append(p.senders, track) + + if p.state == stateMedias { + p.state = stateTracks + } + + return nil +} + +func (p *Producer) MarshalJSON() ([]byte, error) { + if conn := p.conn; conn != nil { + return json.Marshal(conn) + } + info := map[string]string{"url": p.url} + return json.Marshal(info) +} + +// internals + +func (p *Producer) start() { + p.mu.Lock() + defer p.mu.Unlock() + + if p.state != stateTracks { + return + } + + log.Debug().Msgf("[streams] start producer url=%s", p.url) + + p.state = stateStart + p.workerID++ + + go p.worker(p.conn, p.workerID) +} + +func (p *Producer) worker(conn core.Producer, workerID int) { + if err := conn.Start(); err != nil { + p.mu.Lock() + closed := p.workerID != workerID + p.mu.Unlock() + + if closed { + return + } + + log.Warn().Err(err).Str("url", p.url).Caller().Send() + } + + p.reconnect(workerID, 0) +} + +func (p *Producer) reconnect(workerID, retry int) { + p.mu.Lock() + defer p.mu.Unlock() + + if p.workerID != workerID { + log.Trace().Msgf("[streams] stop reconnect url=%s", p.url) + return + } + + log.Debug().Msgf("[streams] retry=%d to url=%s", retry, p.url) + + conn, err := GetProducer(p.url) + if err != nil { + log.Debug().Msgf("[streams] producer=%s", err) + + timeout := time.Minute + if retry < 5 { + timeout = time.Second + } else if retry < 10 { + timeout = time.Second * 5 + } else if retry < 20 { + timeout = time.Second * 10 + } + + time.AfterFunc(timeout, func() { + p.reconnect(workerID, retry+1) + }) + return + } + + for _, media := range conn.GetMedias() { + switch media.Direction { + case core.DirectionRecvonly: + for i, receiver := range p.receivers { + codec := media.MatchCodec(receiver.Codec) + if codec == nil { + continue + } + + track, err := conn.GetTrack(media, codec) + if err != nil { + continue + } + + receiver.Replace(track) + p.receivers[i] = track + break + } + + case core.DirectionSendonly: + for _, sender := range p.senders { + codec := media.MatchCodec(sender.Codec) + if codec == nil { + continue + } + + _ = conn.(core.Consumer).AddTrack(media, codec, sender) + } + } + } + + // stop previous connection after moving tracks (fix ghost exec/ffmpeg) + _ = p.conn.Stop() + // swap connections + p.conn = conn + + go p.worker(conn, workerID) +} + +func (p *Producer) stop() { + p.mu.Lock() + defer p.mu.Unlock() + + switch p.state { + case stateExternal: + log.Trace().Msgf("[streams] skip stop external producer") + return + case stateNone: + log.Trace().Msgf("[streams] skip stop none producer") + return + case stateStart: + p.workerID++ + } + + log.Debug().Msgf("[streams] stop producer url=%s", p.url) + + if p.conn != nil { + _ = p.conn.Stop() + p.conn = nil + } + + p.state = stateNone + p.receivers = nil + p.senders = nil +} diff --git a/installs_on_host/go2rtc/internal/streams/publish.go b/installs_on_host/go2rtc/internal/streams/publish.go new file mode 100644 index 0000000..a16dcec --- /dev/null +++ b/installs_on_host/go2rtc/internal/streams/publish.go @@ -0,0 +1,38 @@ +package streams + +import "time" + +func (s *Stream) Publish(url string) error { + cons, run, err := GetConsumer(url) + if err != nil { + return err + } + + if err = s.AddConsumer(cons); err != nil { + return err + } + + go func() { + run() + s.RemoveConsumer(cons) + + // TODO: more smart retry + time.Sleep(5 * time.Second) + _ = s.Publish(url) + }() + + return nil +} + +func Publish(stream *Stream, destination any) { + switch v := destination.(type) { + case string: + if err := stream.Publish(v); err != nil { + log.Error().Err(err).Caller().Send() + } + case []any: + for _, v := range v { + Publish(stream, v) + } + } +} diff --git a/installs_on_host/go2rtc/internal/streams/stream.go b/installs_on_host/go2rtc/internal/streams/stream.go new file mode 100644 index 0000000..984c73e --- /dev/null +++ b/installs_on_host/go2rtc/internal/streams/stream.go @@ -0,0 +1,130 @@ +package streams + +import ( + "encoding/json" + "sync" + "sync/atomic" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +type Stream struct { + producers []*Producer + consumers []core.Consumer + mu sync.Mutex + pending atomic.Int32 +} + +func NewStream(source any) *Stream { + switch source := source.(type) { + case string: + return &Stream{ + producers: []*Producer{NewProducer(source)}, + } + case []string: + s := new(Stream) + for _, str := range source { + s.producers = append(s.producers, NewProducer(str)) + } + return s + case []any: + s := new(Stream) + for _, src := range source { + str, ok := src.(string) + if !ok { + log.Error().Msgf("[stream] NewStream: Expected string, got %v", src) + continue + } + s.producers = append(s.producers, NewProducer(str)) + } + return s + case map[string]any: + return NewStream(source["url"]) + case nil: + return new(Stream) + default: + panic(core.Caller()) + } +} + +func (s *Stream) Sources() []string { + sources := make([]string, 0, len(s.producers)) + for _, prod := range s.producers { + sources = append(sources, prod.url) + } + return sources +} + +func (s *Stream) SetSource(source string) { + for _, prod := range s.producers { + prod.SetSource(source) + } +} + +func (s *Stream) RemoveConsumer(cons core.Consumer) { + _ = cons.Stop() + + s.mu.Lock() + for i, consumer := range s.consumers { + if consumer == cons { + s.consumers = append(s.consumers[:i], s.consumers[i+1:]...) + break + } + } + s.mu.Unlock() + + s.stopProducers() +} + +func (s *Stream) AddProducer(prod core.Producer) { + producer := &Producer{conn: prod, state: stateExternal, url: "external"} + s.mu.Lock() + s.producers = append(s.producers, producer) + s.mu.Unlock() +} + +func (s *Stream) RemoveProducer(prod core.Producer) { + s.mu.Lock() + for i, producer := range s.producers { + if producer.conn == prod { + s.producers = append(s.producers[:i], s.producers[i+1:]...) + break + } + } + s.mu.Unlock() +} + +func (s *Stream) stopProducers() { + if s.pending.Load() > 0 { + log.Trace().Msg("[streams] skip stop pending producer") + return + } + + s.mu.Lock() +producers: + for _, producer := range s.producers { + for _, track := range producer.receivers { + if len(track.Senders()) > 0 { + continue producers + } + } + for _, track := range producer.senders { + if len(track.Senders()) > 0 { + continue producers + } + } + producer.stop() + } + s.mu.Unlock() +} + +func (s *Stream) MarshalJSON() ([]byte, error) { + var info = struct { + Producers []*Producer `json:"producers"` + Consumers []core.Consumer `json:"consumers"` + }{ + Producers: s.producers, + Consumers: s.consumers, + } + return json.Marshal(info) +} diff --git a/installs_on_host/go2rtc/internal/streams/stream_test.go b/installs_on_host/go2rtc/internal/streams/stream_test.go new file mode 100644 index 0000000..bc4c18b --- /dev/null +++ b/installs_on_host/go2rtc/internal/streams/stream_test.go @@ -0,0 +1,42 @@ +package streams + +import ( + "net/url" + "testing" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/stretchr/testify/require" +) + +func TestRecursion(t *testing.T) { + // create stream with some source + stream1, err := New("from_yaml", "does_not_matter") + require.NoError(t, err) + require.Len(t, streams, 1) + + // ask another unnamed stream that links go2rtc + query, err := url.ParseQuery("src=rtsp://localhost:8554/from_yaml?video") + require.NoError(t, err) + stream2, err := GetOrPatch(query) + require.NoError(t, err) + + // check stream is same + require.Equal(t, stream1, stream2) + // check stream urls is same + require.Equal(t, stream1.producers[0].url, stream2.producers[0].url) + require.Len(t, streams, 2) +} + +func TestTempate(t *testing.T) { + HandleFunc("rtsp", func(url string) (core.Producer, error) { return nil, nil }) // bypass HasProducer + + // config from yaml + stream1, err := New("camera.from_hass", "ffmpeg:{input}#video=copy") + require.NoError(t, err) + // request from hass + stream2, err := Patch("camera.from_hass", "rtsp://example.com") + require.NoError(t, err) + + require.Equal(t, stream1, stream2) + require.Equal(t, "ffmpeg:rtsp://example.com#video=copy", stream1.producers[0].url) +} diff --git a/installs_on_host/go2rtc/internal/streams/streams.go b/installs_on_host/go2rtc/internal/streams/streams.go new file mode 100644 index 0000000..f3b8df0 --- /dev/null +++ b/installs_on_host/go2rtc/internal/streams/streams.go @@ -0,0 +1,176 @@ +package streams + +import ( + "errors" + "net/url" + "sync" + "time" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/rs/zerolog" +) + +func Init() { + var cfg struct { + Streams map[string]any `yaml:"streams"` + Publish map[string]any `yaml:"publish"` + Preload map[string]string `yaml:"preload"` + } + + app.LoadConfig(&cfg) + + log = app.GetLogger("streams") + + for name, item := range cfg.Streams { + streams[name] = NewStream(item) + } + + api.HandleFunc("api/streams", apiStreams) + api.HandleFunc("api/streams.dot", apiStreamsDOT) + api.HandleFunc("api/preload", apiPreload) + api.HandleFunc("api/schemes", apiSchemes) + + if cfg.Publish == nil && cfg.Preload == nil { + return + } + + time.AfterFunc(time.Second, func() { + // range for nil map is OK + for name, dst := range cfg.Publish { + if stream := Get(name); stream != nil { + Publish(stream, dst) + } + } + for name, rawQuery := range cfg.Preload { + if err := AddPreload(name, rawQuery); err != nil { + log.Error().Err(err).Caller().Send() + } + } + }) +} + +func New(name string, sources ...string) (*Stream, error) { + for _, source := range sources { + if !HasProducer(source) { + return nil, errors.New("streams: source not supported") + } + + if err := Validate(source); err != nil { + return nil, err + } + } + + stream := NewStream(sources) + + streamsMu.Lock() + streams[name] = stream + streamsMu.Unlock() + + return stream, nil +} + +func Patch(name string, source string) (*Stream, error) { + streamsMu.Lock() + defer streamsMu.Unlock() + + // check if source links to some stream name from go2rtc + if u, err := url.Parse(source); err == nil && u.Scheme == "rtsp" && len(u.Path) > 1 { + rtspName := u.Path[1:] + if stream, ok := streams[rtspName]; ok { + if streams[name] != stream { + // link (alias) streams[name] to streams[rtspName] + streams[name] = stream + } + return stream, nil + } + } + + if stream, ok := streams[source]; ok { + if name != source { + // link (alias) streams[name] to streams[source] + streams[name] = stream + } + return stream, nil + } + + // check if src has supported scheme + if !HasProducer(source) { + return nil, errors.New("streams: source not supported") + } + + if err := Validate(source); err != nil { + return nil, err + } + + // check an existing stream with this name + if stream, ok := streams[name]; ok { + stream.SetSource(source) + return stream, nil + } + + // create new stream with this name + stream := NewStream(source) + streams[name] = stream + return stream, nil +} + +func GetOrPatch(query url.Values) (*Stream, error) { + // check if src param exists + source := query.Get("src") + if source == "" { + return nil, errors.New("streams: source empty") + } + + // check if src is stream name + if stream := Get(source); stream != nil { + return stream, nil + } + + // check if name param provided + if name := query.Get("name"); name != "" { + return Patch(name, source) + } + + // return new stream with src as name + return Patch(source, source) +} + +var log zerolog.Logger + +// streams map + +var streams = map[string]*Stream{} +var streamsMu sync.Mutex + +func Get(name string) *Stream { + streamsMu.Lock() + defer streamsMu.Unlock() + return streams[name] +} + +func Delete(name string) { + streamsMu.Lock() + defer streamsMu.Unlock() + delete(streams, name) +} + +func GetAllNames() []string { + streamsMu.Lock() + names := make([]string, 0, len(streams)) + for name := range streams { + names = append(names, name) + } + streamsMu.Unlock() + return names +} + +func GetAllSources() map[string][]string { + streamsMu.Lock() + sources := make(map[string][]string, len(streams)) + for name, stream := range streams { + sources[name] = stream.Sources() + } + streamsMu.Unlock() + return sources +} diff --git a/installs_on_host/go2rtc/internal/tapo/README.md b/installs_on_host/go2rtc/internal/tapo/README.md new file mode 100644 index 0000000..e8723d2 --- /dev/null +++ b/installs_on_host/go2rtc/internal/tapo/README.md @@ -0,0 +1,61 @@ +# TP-Link Tapo + +[`new in v1.2.0`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.2.0) + +[TP-Link Tapo](https://www.tapo.com/) proprietary camera protocol with **two-way audio** support. + +- stream quality is the same as [RTSP protocol](https://www.tapo.com/en/faq/34/) +- use the **cloud password**, this is not the RTSP password! you do not need to add a login! +- you can also use **UPPERCASE** MD5 hash from your cloud password with `admin` username +- some new camera firmwares require SHA256 instead of MD5 + +## Configuration + +```yaml +streams: + # cloud password without username + camera1: tapo://cloud-password@192.168.1.123 + # admin username and UPPERCASE MD5 cloud-password hash + camera2: tapo://admin:UPPERCASE-MD5@192.168.1.123 + # admin username and UPPERCASE SHA256 cloud-password hash + camera3: tapo://admin:UPPERCASE-SHA256@192.168.1.123 + # VGA stream (the so called substream, the lower resolution one) + camera4: tapo://cloud-password@192.168.1.123?subtype=1 + # HD stream (default) + camera5: tapo://cloud-password@192.168.1.123?subtype=0 +``` + +```bash +echo -n "cloud password" | md5 | awk '{print toupper($0)}' +echo -n "cloud password" | shasum -a 256 | awk '{print toupper($0)}' +``` + +## TP-Link Kasa + +[`new in v1.7.0`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.7.0) + +> [!NOTE] +> This source should be moved to separate module. Because it's source code not related to Tapo. + +[TP-Link Kasa](https://www.kasasmart.com/) non-standard protocol [more info](https://medium.com/@hu3vjeen/reverse-engineering-tp-link-kc100-bac4641bf1cd). + +- `username` - urlsafe email, `alex@gmail.com` -> `alex%40gmail.com` +- `password` - base64password, `secret1` -> `c2VjcmV0MQ==` + +```yaml +streams: + kc401: kasa://username:password@192.168.1.123:19443/https/stream/mixed +``` + +Tested: KD110, KC200, KC401, KC420WS, EC71. + +## TP-Link Vigi + +[`new in v1.9.8`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.8) + +[TP-Link VIGI](https://www.vigi.com/) cameras. These are cameras from a different sub-brand, but the format is very similar to Tapo. Only the authorization is different. Read more [here](https://github.com/AlexxIT/go2rtc/issues/1470). + +```yaml +streams: + camera1: vigi://admin:{password}@192.168.1.123 +``` diff --git a/installs_on_host/go2rtc/internal/tapo/tapo.go b/installs_on_host/go2rtc/internal/tapo/tapo.go new file mode 100644 index 0000000..88eff5c --- /dev/null +++ b/installs_on_host/go2rtc/internal/tapo/tapo.go @@ -0,0 +1,22 @@ +package tapo + +import ( + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/kasa" + "github.com/AlexxIT/go2rtc/pkg/tapo" +) + +func Init() { + streams.HandleFunc("kasa", func(source string) (core.Producer, error) { + return kasa.Dial(source) + }) + + streams.HandleFunc("tapo", func(source string) (core.Producer, error) { + return tapo.Dial(source) + }) + + streams.HandleFunc("vigi", func(source string) (core.Producer, error) { + return tapo.Dial(source) + }) +} diff --git a/installs_on_host/go2rtc/internal/tuya/README.md b/installs_on_host/go2rtc/internal/tuya/README.md new file mode 100644 index 0000000..e9295ee --- /dev/null +++ b/installs_on_host/go2rtc/internal/tuya/README.md @@ -0,0 +1,39 @@ +# Tuya + +[`new in v1.9.13`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.13) by [@seydx](https://github.com/seydx) + +[Tuya](https://www.tuya.com/) is a proprietary camera protocol with **two-way audio** support. go2rtc supports `Tuya Smart API` and `Tuya Cloud API`. + +**Tuya Smart API (recommended)**: +- **Smart Life accounts are NOT supported**, you need to create a Tuya Smart account. If the cameras are already added to the Smart Life app, you need to remove them and add them again to the [Tuya Smart](https://play.google.com/store/apps/details?id=com.tuya.smart) app. +- Cameras can be discovered through the go2rtc web interface via Tuya Smart account (Add > Tuya > Select region and fill in email and password > Login). + +**Tuya Cloud API**: +- Requires setting up a cloud project in the Tuya Developer Platform. +- Obtain `device_id`, `client_id`, `client_secret`, and `uid` from [Tuya IoT Platform](https://iot.tuya.com/). [Here's a guide](https://xzetsubou.github.io/hass-localtuya/cloud_api/). +- Please ensure that you have subscribed to the `IoT Video Live Stream` service (Free Trial) in the Tuya Developer Platform, otherwise the stream will not work (Tuya Developer Platform > Service API > Authorize > IoT Video Live Stream). + +## Configuration + +Use the `resolution` parameter to select the stream (not all cameras support an `hd` stream through WebRTC even if the camera supports it): +- `hd` - HD stream (default) +- `sd` - SD stream + +```yaml +streams: + # Tuya Smart API: WebRTC main stream (use Add > Tuya to discover the URL) + tuya_main: + - tuya://protect-us.ismartlife.me?device_id=XXX&email=XXX&password=XXX + + # Tuya Smart API: WebRTC sub stream (use Add > Tuya to discover the URL) + tuya_sub: + - tuya://protect-us.ismartlife.me?device_id=XXX&email=XXX&password=XXX&resolution=sd + + # Tuya Cloud API: WebRTC main stream + tuya_webrtc: + - tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX + + # Tuya Cloud API: WebRTC sub stream + tuya_webrtc_sd: + - tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX&resolution=sd +``` diff --git a/installs_on_host/go2rtc/internal/tuya/tuya.go b/installs_on_host/go2rtc/internal/tuya/tuya.go new file mode 100644 index 0000000..9dcf272 --- /dev/null +++ b/installs_on_host/go2rtc/internal/tuya/tuya.go @@ -0,0 +1,248 @@ +package tuya + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/tuya" +) + +func Init() { + streams.HandleFunc("tuya", func(source string) (core.Producer, error) { + return tuya.Dial(source) + }) + + api.HandleFunc("api/tuya", apiTuya) +} + +func apiTuya(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + region := query.Get("region") + email := query.Get("email") + password := query.Get("password") + + if email == "" || password == "" || region == "" { + http.Error(w, "email, password and region are required", http.StatusBadRequest) + return + } + + var tuyaRegion *tuya.Region + for _, r := range tuya.AvailableRegions { + if r.Host == region { + tuyaRegion = &r + break + } + } + + if tuyaRegion == nil { + http.Error(w, fmt.Sprintf("invalid region: %s", region), http.StatusBadRequest) + return + } + + httpClient := tuya.CreateHTTPClientWithSession() + + _, err := login(httpClient, tuyaRegion.Host, email, password, tuyaRegion.Continent) + if err != nil { + http.Error(w, fmt.Sprintf("login failed: %v", err), http.StatusInternalServerError) + return + } + + tuyaAPI, err := tuya.NewTuyaSmartApiClient( + httpClient, + tuyaRegion.Host, + email, + password, + "", + ) + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + var devices []tuya.Device + + homes, _ := tuyaAPI.GetHomeList() + if homes != nil && len(homes.Result) > 0 { + for _, home := range homes.Result { + roomList, err := tuyaAPI.GetRoomList(strconv.Itoa(home.Gid)) + if err != nil { + continue + } + + for _, room := range roomList.Result { + for _, device := range room.DeviceList { + if (device.Category == "sp" || device.Category == "dghsxj") && !containsDevice(devices, device.DeviceId) { + devices = append(devices, device) + } + } + } + } + } + + sharedHomes, _ := tuyaAPI.GetSharedHomeList() + if sharedHomes != nil && len(sharedHomes.Result.SecurityWebCShareInfoList) > 0 { + for _, sharedHome := range sharedHomes.Result.SecurityWebCShareInfoList { + for _, device := range sharedHome.DeviceInfoList { + if (device.Category == "sp" || device.Category == "dghsxj") && !containsDevice(devices, device.DeviceId) { + devices = append(devices, device) + } + } + } + } + + if len(devices) == 0 { + http.Error(w, "no cameras found", http.StatusNotFound) + return + } + + var items []*api.Source + for _, device := range devices { + cleanQuery := url.Values{} + cleanQuery.Set("device_id", device.DeviceId) + cleanQuery.Set("email", email) + cleanQuery.Set("password", password) + url := fmt.Sprintf("tuya://%s?%s", tuyaRegion.Host, cleanQuery.Encode()) + + items = append(items, &api.Source{ + Name: device.DeviceName, + URL: url, + }) + } + + api.ResponseSources(w, items) +} + +func login(client *http.Client, serverHost, email, password, countryCode string) (*tuya.LoginResult, error) { + tokenResp, err := getLoginToken(client, serverHost, email, countryCode) + if err != nil { + return nil, err + } + + encryptedPassword, err := tuya.EncryptPassword(password, tokenResp.Result.PbKey) + if err != nil { + return nil, fmt.Errorf("failed to encrypt password: %v", err) + } + + var loginResp *tuya.PasswordLoginResponse + var url string + + loginReq := tuya.PasswordLoginRequest{ + CountryCode: countryCode, + Passwd: encryptedPassword, + Token: tokenResp.Result.Token, + IfEncrypt: 1, + Options: `{"group":1}`, + } + + if tuya.IsEmailAddress(email) { + url = fmt.Sprintf("https://%s/api/private/email/login", serverHost) + loginReq.Email = email + } else { + url = fmt.Sprintf("https://%s/api/private/phone/login", serverHost) + loginReq.Mobile = email + } + + loginResp, err = performLogin(client, url, loginReq, serverHost) + + if err != nil { + return nil, err + } + + if !loginResp.Success { + return nil, errors.New(loginResp.ErrorMsg) + } + + return &loginResp.Result, nil +} + +func getLoginToken(client *http.Client, serverHost, username, countryCode string) (*tuya.LoginTokenResponse, error) { + url := fmt.Sprintf("https://%s/api/login/token", serverHost) + + tokenReq := tuya.LoginTokenRequest{ + CountryCode: countryCode, + Username: username, + IsUid: false, + } + + jsonData, err := json.Marshal(tokenReq) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("Accept", "*/*") + req.Header.Set("Origin", fmt.Sprintf("https://%s", serverHost)) + req.Header.Set("Referer", fmt.Sprintf("https://%s/login", serverHost)) + req.Header.Set("X-Requested-With", "XMLHttpRequest") + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var tokenResp tuya.LoginTokenResponse + if err = json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + return nil, err + } + + if !tokenResp.Success { + return nil, errors.New("tuya: " + tokenResp.Msg) + } + + return &tokenResp, nil +} + +func performLogin(client *http.Client, url string, loginReq tuya.PasswordLoginRequest, serverHost string) (*tuya.PasswordLoginResponse, error) { + jsonData, err := json.Marshal(loginReq) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("Accept", "*/*") + req.Header.Set("Origin", fmt.Sprintf("https://%s", serverHost)) + req.Header.Set("Referer", fmt.Sprintf("https://%s/login", serverHost)) + req.Header.Set("X-Requested-With", "XMLHttpRequest") + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var loginResp tuya.PasswordLoginResponse + if err := json.NewDecoder(resp.Body).Decode(&loginResp); err != nil { + return nil, err + } + + return &loginResp, nil +} + +func containsDevice(devices []tuya.Device, deviceID string) bool { + for _, device := range devices { + if device.DeviceId == deviceID { + return true + } + } + return false +} diff --git a/installs_on_host/go2rtc/internal/v4l2/README.md b/installs_on_host/go2rtc/internal/v4l2/README.md new file mode 100644 index 0000000..8731cf4 --- /dev/null +++ b/installs_on_host/go2rtc/internal/v4l2/README.md @@ -0,0 +1,41 @@ +# Video4Linux + +[`new in v1.9.9`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.9) + +What you should to know about [V4L2](https://en.wikipedia.org/wiki/Video4Linux): + +- V4L2 (Video for Linux API version 2) works only in Linux +- supports USB cameras and other similar devices +- one device can only be connected to one software simultaneously +- cameras support a fixed list of formats, resolutions and frame rates +- basic cameras supports only RAW (non-compressed) pixel formats +- regular cameras supports MJPEG format (series of JPEG frames) +- advances cameras support H264 format (MSE/MP4, WebRTC compatible) +- using MJPEG and H264 formats (if the camera supports them) won't cost you the CPU usage +- transcoding RAW format to MJPEG or H264 - will cost you a significant CPU usage +- H265 (HEVC) format is also supported (if the camera supports it) + +Tests show that the basic Keenetic router with MIPS processor can broadcast three MJPEG cameras in the following resolutions: 1600х1200 + 640х480 + 640х480. The USB bus bandwidth is no more enough for larger resolutions. CPU consumption is no more than 5%. + +Supported formats for your camera can be found here: **Go2rtc > WebUI > Add > V4L2**. + +## RAW format + +Example: + +```yaml +streams: + camera1: v4l2:device?video=/dev/video0&input_format=yuyv422&video_size=1280x720&framerate=10 +``` + +Go2rtc supports built-in transcoding of RAW to MJPEG format. This does not need to be additionally configured. + +``` +ffplay http://localhost:1984/api/stream.mjpeg?src=camera1 +``` + +**Important.** You don't have to transcode the RAW format to transmit it over the network. You can stream it in `y4m` format, which is perfectly supported by ffmpeg. It won't cost you a CPU usage. But will require high network bandwidth. + +``` +ffplay http://localhost:1984/api/stream.y4m?src=camera1 +``` diff --git a/installs_on_host/go2rtc/internal/v4l2/v4l2.go b/installs_on_host/go2rtc/internal/v4l2/v4l2.go new file mode 100644 index 0000000..3f2e62e --- /dev/null +++ b/installs_on_host/go2rtc/internal/v4l2/v4l2.go @@ -0,0 +1,7 @@ +//go:build !(linux && (386 || arm || mipsle || amd64 || arm64)) + +package v4l2 + +func Init() { + // not supported +} diff --git a/installs_on_host/go2rtc/internal/v4l2/v4l2_linux.go b/installs_on_host/go2rtc/internal/v4l2/v4l2_linux.go new file mode 100644 index 0000000..0bb0547 --- /dev/null +++ b/installs_on_host/go2rtc/internal/v4l2/v4l2_linux.go @@ -0,0 +1,91 @@ +//go:build linux && (386 || arm || mipsle || amd64 || arm64) + +package v4l2 + +import ( + "encoding/binary" + "fmt" + "net/http" + "os" + "strings" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/v4l2" + "github.com/AlexxIT/go2rtc/pkg/v4l2/device" +) + +func Init() { + streams.HandleFunc("v4l2", func(source string) (core.Producer, error) { + return v4l2.Open(source) + }) + + api.HandleFunc("api/v4l2", apiV4L2) +} + +func apiV4L2(w http.ResponseWriter, r *http.Request) { + files, err := os.ReadDir("/dev") + if err != nil { + return + } + + var sources []*api.Source + + for _, file := range files { + if !strings.HasPrefix(file.Name(), core.KindVideo) { + continue + } + + path := "/dev/" + file.Name() + + dev, err := device.Open(path) + if err != nil { + continue + } + + formats, _ := dev.ListFormats() + for _, fourCC := range formats { + name, ffmpeg := findFormat(fourCC) + source := &api.Source{Name: name} + + sizes, _ := dev.ListSizes(fourCC) + for _, wh := range sizes { + if source.Info != "" { + source.Info += " " + } + + source.Info += fmt.Sprintf("%dx%d", wh[0], wh[1]) + + frameRates, _ := dev.ListFrameRates(fourCC, wh[0], wh[1]) + for _, fr := range frameRates { + source.Info += fmt.Sprintf("@%d", fr) + + if source.URL == "" && ffmpeg != "" { + source.URL = fmt.Sprintf( + "v4l2:device?video=%s&input_format=%s&video_size=%dx%d&framerate=%d", + path, ffmpeg, wh[0], wh[1], fr, + ) + } + } + } + + if source.Info != "" { + sources = append(sources, source) + } + } + + _ = dev.Close() + } + + api.ResponseSources(w, sources) +} + +func findFormat(fourCC uint32) (name, ffmpeg string) { + for _, format := range device.Formats { + if format.FourCC == fourCC { + return format.Name, format.FFmpeg + } + } + return string(binary.LittleEndian.AppendUint32(nil, fourCC)), "" +} diff --git a/installs_on_host/go2rtc/internal/webrtc/README.md b/installs_on_host/go2rtc/internal/webrtc/README.md new file mode 100644 index 0000000..5a7e1e7 --- /dev/null +++ b/installs_on_host/go2rtc/internal/webrtc/README.md @@ -0,0 +1,263 @@ +# WebRTC + +## WebRTC Client + +[`new in v1.3.0`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0) + +This source type supports four connection formats. + +### Creality + +[`new in v1.9.10`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.10) + +[Creality](https://www.creality.com/) 3D printer camera. Read more [here](https://github.com/AlexxIT/go2rtc/issues/1600). + +```yaml +streams: + creality_k2p: webrtc:http://192.168.1.123:8000/call/webrtc_local#format=creality +``` + +### go2rtc + +This format is only supported in go2rtc. Unlike WHEP, it supports asynchronous WebRTC connections and two-way audio. + +```yaml +streams: + webrtc-go2rtc: webrtc:ws://192.168.1.123:1984/api/ws?src=camera1 +``` + +### Kinesis + +[`new in v1.6.1`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.6.1) + +Supports [Amazon Kinesis Video Streams](https://aws.amazon.com/kinesis/video-streams/), using WebRTC protocol. You need to specify the signaling WebSocket URL with all credentials in query params, `client_id` and `ice_servers` list in [JSON format](https://developer.mozilla.org/en-US/docs/Web/API/RTCIceServer). + +```yaml +streams: + webrtc-kinesis: webrtc:wss://...amazonaws.com/?...#format=kinesis#client_id=...#ice_servers=[{...},{...}] +``` + +**PS.** For `kinesis` sources, you can use [echo](../echo/README.md) to get connection params using `bash`, `python` or any other script language. + +### OpenIPC + +[`new in v1.7.0`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.7.0) + +Cameras on open-source [OpenIPC](https://openipc.org/) firmware. + +```yaml +streams: + webrtc-openipc: webrtc:ws://192.168.1.123/webrtc_ws#format=openipc#ice_servers=[{"urls":"stun:stun.kinesisvideo.eu-north-1.amazonaws.com:443"}] +``` + +### SwitchBot + +Support connection to [SwitchBot](https://us.switch-bot.com/) cameras that are based on Kinesis Video Streams. Specifically, this includes [Pan/Tilt Cam Plus 2K](https://us.switch-bot.com/pages/switchbot-pan-tilt-cam-plus-2k) and [Pan/Tilt Cam Plus 3K](https://us.switch-bot.com/pages/switchbot-pan-tilt-cam-plus-3k) and [Smart Video Doorbell](https://www.switchbot.jp/products/switchbot-smart-video-doorbell). `Outdoor Spotlight Cam 1080P`, `Outdoor Spotlight Cam 2K`, `Pan/Tilt Cam`, `Pan/Tilt Cam 2K`, `Indoor Cam` are based on Tuya, so this feature is not available. + +```yaml +streams: + webrtc-switchbot: webrtc:wss://...amazonaws.com/?...#format=switchbot#resolution=hd#play_type=0#client_id=...#ice_servers=[{...},{...}] +``` + +### WHEP + +[WebRTC/WHEP](https://datatracker.ietf.org/doc/draft-murillo-whep/) is replaced by [WebRTC/WISH](https://datatracker.ietf.org/doc/charter-ietf-wish/02/) standard for WebRTC video/audio viewers. But it may already be supported in some third-party software. It is supported in go2rtc. + +```yaml +streams: + webrtc-whep: webrtc:http://192.168.1.123:1984/api/webrtc?src=camera1 +``` + +### Wyze + +[`new in v1.6.1`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.6.1) + +Legacy method to connect to [Wyze](https://www.wyze.com/) cameras using WebRTC protocol via [docker-wyze-bridge](https://github.com/mrlt8/docker-wyze-bridge). For native P2P support without docker-wyze-bridge, see [Source: Wyze](../wyze/README.md). + +```yaml +streams: + webrtc-wyze: webrtc:http://192.168.1.123:5000/signaling/camera1?kvs#format=wyze +``` + +## WebRTC Server + +What you should know about WebRTC: + +- It's almost always a **direct [peer-to-peer](https://en.wikipedia.org/wiki/Peer-to-peer) connection** from your browser to the go2rtc app +- When you use Home Assistant, Frigate, Nginx, Nabu Casa, Cloudflare, and other software, they are only **involved in establishing** the connection; they are **not involved in transferring** media data +- WebRTC media cannot be transferred inside an HTTP connection +- Usually, WebRTC uses random UDP ports on the client and server to establish a connection +- Usually, WebRTC uses public [STUN](https://en.wikipedia.org/wiki/STUN) servers to establish a connection outside the LAN; these servers are only needed to establish a connection and are not involved in data transfer +- Usually, WebRTC will automatically discover all of your local and public addresses and try to establish a connection + +If an external connection via STUN is used: + +- Uses [UDP hole punching](https://en.wikipedia.org/wiki/UDP_hole_punching) technology to bypass NAT even if you haven't opened your server to the world +- For about 20% of users, the technology will not work because of the [Symmetric NAT](https://tomchen.github.io/symmetric-nat-test/) +- UDP is not suitable for transmitting 2K and 4K high bit rate video over open networks because of the high loss rate: + - https://habr.com/ru/companies/flashphoner/articles/480006/ + - https://www.youtube.com/watch?v=FXVg2ckuKfs + +### Configuration suggestions + +- by default, WebRTC uses both TCP and UDP on port 8555 for connections +- you can use this port for external access +- you can change the port in YAML config: + +```yaml +webrtc: + listen: ":8555" # address of your local server and port (TCP/UDP) +``` + +#### Static public IP + +- forward the port 8555 on your router (you can use the same 8555 port or any other as external port) +- add your external IP address and external port to the YAML config + +```yaml +webrtc: + candidates: + - 216.58.210.174:8555 # if you have a static public IP address +``` + +#### Dynamic public IP + +- forward the port 8555 on your router (you can use the same 8555 port or any other as the external port) +- add `stun` word and external port to YAML config + - go2rtc automatically detects your external address with STUN server + +```yaml +webrtc: + candidates: + - stun:8555 # if you have a dynamic public IP address +``` + +#### Hard tech way 1. Own TCP-tunnel + +If you have a personal [VPS](https://en.wikipedia.org/wiki/Virtual_private_server), you can create a TCP tunnel and setup in the same way as "Static public IP". But use your VPS IP address in the YAML config. + +#### Hard tech way 2. Using TURN-server + +If you have personal [VPS](https://en.wikipedia.org/wiki/Virtual_private_server), you can install TURN server (e.g. [coturn](https://github.com/coturn/coturn), config [example](https://github.com/AlexxIT/WebRTC/wiki/Coturn-Example)). + +```yaml +webrtc: + ice_servers: + - urls: [stun:stun.l.google.com:19302] + - urls: [turn:123.123.123.123:3478] + username: your_user + credential: your_pass +``` + +### Full configuration + +**Important!** This example is not for copy/pasting! + +```yaml +webrtc: + # fix local TCP or UDP or both ports for WebRTC media + listen: ":8555" # address of your local server + + # add additional host candidates manually + # order is important, the first will have a higher priority + candidates: + - 216.58.210.174:8555 # if you have static public IP-address + - stun:8555 # if you have dynamic public IP-address + - home.duckdns.org:8555 # if you have domain + + # add custom STUN and TURN servers + # use `ice_servers: []` to remove defaults and leave it empty + ice_servers: + - urls: [ stun:stun1.l.google.com:19302 ] + - urls: [ turn:123.123.123.123:3478 ] + username: your_user + credential: your_pass + + # optional filter list for auto-discovery logic + # some settings only make sense if you don't specify a fixed UDP port + filters: + # list of host candidates from auto-discovery to be sent + # includes candidates from the `listen` option + # use `candidates: []` to remove all auto-discovery candidates + candidates: [ 192.168.1.123 ] + + # enable localhost candidates + loopback: true + + # list of network types to be used for the connection + # includes candidates from the `listen` option + networks: [ udp4, udp6, tcp4, tcp6 ] + + # list of interfaces to be used for the connection + # includes interfaces from unspecified `listen` option (empty host) + interfaces: [ eno1 ] + + # list of host IP addresses to be used for the connection + # includes IPs from unspecified `listen` option (empty host) + ips: [ 192.168.1.123 ] + + # range for random UDP ports [min, max] to be used for connection + # not related to the `listen` option + udp_ports: [ 50000, 50100 ] +``` + +By default, go2rtc uses a **fixed TCP** port and **fixed UDP** ports for each **direct** WebRTC connection: `listen: ":8555"`. + +You can set a **fixed TCP** port and a **random UDP** port for all connections: `listen: ":8555/tcp"`. + +You can also disable the TCP port and leave only random UDP ports: `listen: ""`. + +### Configuration filters + +**Important!** By default, go2rtc excludes all Docker-like candidates (`172.16.0.0/12`). This cannot be disabled. + +Filters allow you to exclude unnecessary candidates. Extra candidates don't make your connection worse or better. But the wrong filter settings can break everything. Skip this setting if you don't understand it. + +For example, go2rtc is installed on the host system. And there are unnecessary interfaces. You can keep only the relevant via `interfaces` or `ips` options. You can also exclude IPv6 candidates if your server supports them but your home network does not. + +```yaml +webrtc: + listen: ":8555/tcp" # use fixed TCP port and random UDP ports + filters: + ips: [ 192.168.1.2 ] # IP-address of your server + networks: [ udp4, tcp4 ] # skip IPv6, if it's not supported for you +``` + +For example, go2rtc is inside a closed Docker container (e.g. [Frigate](https://frigate.video/)). You shouldn't filter Docker interfaces; otherwise, go2rtc won't be able to connect anywhere. But you can filter the Docker candidates because no one can connect to them. + +```yaml +webrtc: + listen: ":8555" # use fixed TCP and UDP ports + candidates: [ 192.168.1.2:8555 ] # add manual host candidate (use docker port forwarding) +``` + +## Streaming ingest + +### Ingest: Browser + +[`new in v1.3.0`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0) + +You can turn the browser of any PC or mobile into an IP camera with support for video and two-way audio. Or even broadcast your PC screen: + +1. Create empty stream in the `go2rtc.yaml` +2. Go to go2rtc WebUI +3. Open `links` page for your stream +4. Select `camera+microphone` or `display+speaker` option +5. Open `webrtc` local page (your go2rtc **should work over HTTPS!**) or `share link` via [WebTorrent](../webtorrent/README.md) technology (work over HTTPS by default) + +### Ingest: WHIP + +[`new in v1.3.0`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0) + +You can use **OBS Studio** or any other broadcast software with [WHIP](https://www.ietf.org/archive/id/draft-ietf-wish-whip-01.html) protocol support. This standard has not yet been approved. But you can download OBS Studio [dev version](https://github.com/obsproject/obs-studio/actions/runs/3969201209): + +- Settings > Stream > Service: WHIP > `http://192.168.1.123:1984/api/webrtc?dst=camera1` + +## Useful links + +- https://www.ietf.org/archive/id/draft-ietf-wish-whip-01.html +- https://www.ietf.org/id/draft-murillo-whep-01.html +- https://github.com/Glimesh/broadcast-box/ +- https://github.com/obsproject/obs-studio/pull/7926 +- https://misi.github.io/webrtc-c0d3l4b/ +- https://github.com/webtorrent/webtorrent/blob/master/docs/faq.md diff --git a/installs_on_host/go2rtc/internal/webrtc/candidates.go b/installs_on_host/go2rtc/internal/webrtc/candidates.go new file mode 100644 index 0000000..d378022 --- /dev/null +++ b/installs_on_host/go2rtc/internal/webrtc/candidates.go @@ -0,0 +1,149 @@ +package webrtc + +import ( + "net" + "strings" + + "github.com/AlexxIT/go2rtc/internal/api/ws" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/webrtc" + "github.com/AlexxIT/go2rtc/pkg/xnet" + pion "github.com/pion/webrtc/v4" +) + +type Address struct { + host string + Port string + Network string + Priority uint32 +} + +var stuns []string + +func (a *Address) Host() string { + if a.host == "stun" { + ip, err := webrtc.GetCachedPublicIP(stuns...) + if err != nil { + return "" + } + return ip.String() + } + return a.host +} + +func (a *Address) Marshal() string { + if host := a.Host(); host != "" { + return webrtc.CandidateICE(a.Network, host, a.Port, a.Priority) + } + return "" +} + +var addresses []*Address +var filters webrtc.Filters + +func AddCandidate(network, address string) { + if network == "" { + AddCandidate("tcp", address) + AddCandidate("udp", address) + return + } + + host, port, err := net.SplitHostPort(address) + if err != nil { + return + } + + // start from 1, so manual candidates will be lower than built-in + // and every next candidate will have a lower priority + candidateIndex := 1 + len(addresses) + + priority := webrtc.CandidateHostPriority(network, candidateIndex) + addresses = append(addresses, &Address{host, port, network, priority}) +} + +func GetCandidates() (candidates []string) { + for _, address := range addresses { + if candidate := address.Marshal(); candidate != "" { + candidates = append(candidates, candidate) + } + } + return +} + +// FilterCandidate return true if candidate passed the check +func FilterCandidate(candidate *pion.ICECandidate) bool { + if candidate == nil { + return false + } + + // remove any Docker-like IP from candidates + if ip := net.ParseIP(candidate.Address); ip != nil && xnet.Docker.Contains(ip) { + return false + } + + // host candidate should be in the hosts list + if candidate.Typ == pion.ICECandidateTypeHost && filters.Candidates != nil { + if !core.Contains(filters.Candidates, candidate.Address) { + return false + } + } + + if filters.Networks != nil { + networkType := NetworkType(candidate.Protocol.String(), candidate.Address) + if !core.Contains(filters.Networks, networkType) { + return false + } + } + + return true +} + +// NetworkType convert tcp/udp network to tcp4/tcp6/udp4/udp6 +func NetworkType(network, host string) string { + if strings.IndexByte(host, ':') >= 0 { + return network + "6" + } else { + return network + "4" + } +} + +func asyncCandidates(tr *ws.Transport, cons *webrtc.Conn) { + tr.WithContext(func(ctx map[any]any) { + if candidates, ok := ctx["candidate"].([]string); ok { + // process candidates that receive before this moment + for _, candidate := range candidates { + _ = cons.AddCandidate(candidate) + } + + // remove already processed candidates + delete(ctx, "candidate") + } + + // set variable for process candidates after this moment + ctx["webrtc"] = cons + }) + + for _, candidate := range GetCandidates() { + log.Trace().Str("candidate", candidate).Msg("[webrtc] config") + tr.Write(&ws.Message{Type: "webrtc/candidate", Value: candidate}) + } +} + +func candidateHandler(tr *ws.Transport, msg *ws.Message) error { + // process incoming candidate in sync function + tr.WithContext(func(ctx map[any]any) { + candidate := msg.String() + log.Trace().Str("candidate", candidate).Msg("[webrtc] remote") + + if cons, ok := ctx["webrtc"].(*webrtc.Conn); ok { + // if webrtc.Server already initialized - process candidate + _ = cons.AddCandidate(candidate) + } else { + // or collect candidate and process it later + list, _ := ctx["candidate"].([]string) + ctx["candidate"] = append(list, candidate) + } + }) + + return nil +} diff --git a/installs_on_host/go2rtc/internal/webrtc/client.go b/installs_on_host/go2rtc/internal/webrtc/client.go new file mode 100644 index 0000000..5fbf217 --- /dev/null +++ b/installs_on_host/go2rtc/internal/webrtc/client.go @@ -0,0 +1,257 @@ +package webrtc + +import ( + "encoding/base64" + "errors" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/AlexxIT/go2rtc/internal/api/ws" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/webrtc" + "github.com/gorilla/websocket" + pion "github.com/pion/webrtc/v4" +) + +// streamsHandler supports: +// 1. WHEP: webrtc:http://192.168.1.123:1984/api/webrtc?src=camera1 +// 2. go2rtc: webrtc:ws://192.168.1.123:1984/api/ws?src=camera1 +// 3. Wyze: webrtc:http://192.168.1.123:5000/signaling/camera1?kvs#format=wyze +// 4. Kinesis: webrtc:wss://...amazonaws.com/?...#format=kinesis#client_id=...#ice_servers=[{...},{...}] +func streamsHandler(rawURL string) (core.Producer, error) { + var query url.Values + if i := strings.IndexByte(rawURL, '#'); i > 0 { + query = streams.ParseQuery(rawURL[i+1:]) + rawURL = rawURL[:i] + } + + rawURL = rawURL[7:] // remove webrtc: + if i := strings.IndexByte(rawURL, ':'); i > 0 { + scheme := rawURL[:i] + format := query.Get("format") + + switch scheme { + case "ws", "wss": + if format == "kinesis" { + // https://aws.amazon.com/kinesis/video-streams/ + // https://docs.aws.amazon.com/kinesisvideostreams-webrtc-dg/latest/devguide/what-is-kvswebrtc.html + // https://github.com/orgs/awslabs/repositories?q=kinesis+webrtc + return kinesisClient(rawURL, query, "webrtc/kinesis", nil) + } else if format == "openipc" { + return openIPCClient(rawURL, query) + } else if format == "switchbot" { + return switchbotClient(rawURL, query) + } else { + return go2rtcClient(rawURL) + } + + case "http", "https": + if format == "milestone" { + return milestoneClient(rawURL, query) + } else if format == "wyze" { + // https://github.com/mrlt8/docker-wyze-bridge + return wyzeClient(rawURL) + } else if format == "creality" { + return crealityClient(rawURL) + } else { + return whepClient(rawURL) + } + } + } + return nil, errors.New("unsupported url: " + rawURL) +} + +// go2rtcClient can connect only to go2rtc server +// ex: ws://localhost:1984/api/ws?src=camera1 +func go2rtcClient(url string) (core.Producer, error) { + // 1. Connect to signalign server + conn, _, err := Dial(url) + if err != nil { + return nil, err + } + + // close websocket when we ready return Producer or connection error + defer conn.Close() + + // 2. Create PeerConnection + pc, err := PeerConnection(true) + if err != nil { + return nil, err + } + + defer func() { + if err != nil { + _ = pc.Close() + } + }() + + // waiter will wait PC error or WS error or nil (connection OK) + var connState core.Waiter + var connMu sync.Mutex + + prod := webrtc.NewConn(pc) + prod.Mode = core.ModeActiveProducer + prod.Protocol = "ws" + prod.URL = url + prod.Listen(func(msg any) { + switch msg := msg.(type) { + case *pion.ICECandidate: + s := msg.ToJSON().Candidate + log.Trace().Str("candidate", s).Msg("[webrtc] local ") + connMu.Lock() + _ = conn.WriteJSON(&ws.Message{Type: "webrtc/candidate", Value: s}) + connMu.Unlock() + + case pion.PeerConnectionState: + switch msg { + case pion.PeerConnectionStateConnecting: + case pion.PeerConnectionStateConnected: + connState.Done(nil) + default: + connState.Done(errors.New("webrtc: " + msg.String())) + } + } + }) + + medias := []*core.Media{ + {Kind: core.KindVideo, Direction: core.DirectionRecvonly}, + {Kind: core.KindAudio, Direction: core.DirectionRecvonly}, + {Kind: core.KindAudio, Direction: core.DirectionSendonly}, + } + + // 3. Create offer + offer, err := prod.CreateOffer(medias) + if err != nil { + return nil, err + } + + // 4. Send offer + msg := &ws.Message{Type: "webrtc/offer", Value: offer} + connMu.Lock() + _ = conn.WriteJSON(msg) + connMu.Unlock() + + // 5. Get answer + if err = conn.ReadJSON(msg); err != nil { + return nil, err + } + + if msg.Type != "webrtc/answer" { + err = errors.New("wrong answer: " + msg.String()) + return nil, err + } + + answer := msg.String() + if err = prod.SetAnswer(answer); err != nil { + return nil, err + } + + // 6. Continue to receiving candidates + go func() { + var err error + + for { + // receive data from remote + var msg ws.Message + if err = conn.ReadJSON(&msg); err != nil { + break + } + + switch msg.Type { + case "webrtc/candidate": + if msg.Value != nil { + _ = prod.AddCandidate(msg.String()) + } + } + } + + connState.Done(err) + }() + + if err = connState.Wait(); err != nil { + return nil, err + } + + return prod, nil +} + +// whepClient - support WebRTC-HTTP Egress Protocol (WHEP) +// ex: http://localhost:1984/api/webrtc?src=camera1 +func whepClient(url string) (core.Producer, error) { + // 2. Create PeerConnection + pc, err := PeerConnection(true) + if err != nil { + log.Error().Err(err).Caller().Send() + return nil, err + } + + prod := webrtc.NewConn(pc) + prod.Mode = core.ModeActiveProducer + prod.Protocol = "http" + prod.URL = url + + medias := []*core.Media{ + {Kind: core.KindVideo, Direction: core.DirectionRecvonly}, + {Kind: core.KindAudio, Direction: core.DirectionRecvonly}, + } + + // 3. Create offer + offer, err := prod.CreateCompleteOffer(medias) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", url, strings.NewReader(offer)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", MimeSDP) + + client := http.Client{Timeout: time.Second * 5000} + defer client.CloseIdleConnections() + + res, err := client.Do(req) + if err != nil { + return nil, err + } + + answer, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if err = prod.SetAnswer(string(answer)); err != nil { + return nil, err + } + + return prod, nil +} + +// Dial - websocket.Dial with Basic auth support +func Dial(rawURL string) (*websocket.Conn, *http.Response, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, nil, err + } + + if u.User == nil { + return websocket.DefaultDialer.Dial(rawURL, nil) + } + + user := u.User.Username() + pass, _ := u.User.Password() + u.User = nil + + header := http.Header{ + "Authorization": []string{ + "Basic " + base64.StdEncoding.EncodeToString([]byte(user+":"+pass)), + }, + } + + return websocket.DefaultDialer.Dial(u.String(), header) +} diff --git a/installs_on_host/go2rtc/internal/webrtc/client_creality.go b/installs_on_host/go2rtc/internal/webrtc/client_creality.go new file mode 100644 index 0000000..4618044 --- /dev/null +++ b/installs_on_host/go2rtc/internal/webrtc/client_creality.go @@ -0,0 +1,152 @@ +package webrtc + +import ( + "encoding/base64" + "encoding/json" + "io" + "net/http" + "strings" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/webrtc" + "github.com/pion/sdp/v3" +) + +// https://github.com/AlexxIT/go2rtc/issues/1600 +func crealityClient(url string) (core.Producer, error) { + pc, err := PeerConnection(true) + if err != nil { + return nil, err + } + + prod := webrtc.NewConn(pc) + prod.FormatName = "webrtc/creality" + prod.Mode = core.ModeActiveProducer + prod.Protocol = "http" + prod.URL = url + + medias := []*core.Media{ + {Kind: core.KindVideo, Direction: core.DirectionRecvonly}, + } + + // TODO: return webrtc.SessionDescription + offer, err := prod.CreateCompleteOffer(medias) + if err != nil { + return nil, err + } + + log.Trace().Msgf("[webrtc] offer:\n%s", offer) + + body, err := offerToB64(offer) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", url, body) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "plain/text") + + // TODO: change http.DefaultClient settings + client := http.Client{Timeout: time.Second * 5000} + defer client.CloseIdleConnections() + + res, err := client.Do(req) + if err != nil { + return nil, err + } + + answer, err := answerFromB64(res.Body) + if err != nil { + return nil, err + } + + log.Trace().Msgf("[webrtc] answer:\n%s", answer) + + if answer, err = fixCrealitySDP(answer); err != nil { + return nil, err + } + + if err = prod.SetAnswer(answer); err != nil { + return nil, err + } + + return prod, nil +} + +func offerToB64(sdp string) (io.Reader, error) { + // JS object + v := map[string]string{ + "type": "offer", + "sdp": sdp, + } + + // bytes + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + + // base64, why? who knows... + s := base64.StdEncoding.EncodeToString(b) + + return strings.NewReader(s), nil +} + +func answerFromB64(r io.Reader) (string, error) { + // base64 + b, err := io.ReadAll(r) + if err != nil { + return "", err + } + + // bytes + if b, err = base64.StdEncoding.DecodeString(string(b)); err != nil { + return "", err + } + + // JS object + var v map[string]string + if err = json.Unmarshal(b, &v); err != nil { + return "", err + } + + // string "v=0..." + return v["sdp"], nil +} + +func fixCrealitySDP(value string) (string, error) { + var sd sdp.SessionDescription + if err := sd.UnmarshalString(value); err != nil { + return "", err + } + + md := sd.MediaDescriptions[0] + + // important to skip first codec, because second codec will be used + skip := md.MediaName.Formats[0] + md.MediaName.Formats = md.MediaName.Formats[1:] + + attrs := make([]sdp.Attribute, 0, len(md.Attributes)) + for _, attr := range md.Attributes { + switch attr.Key { + case "fmtp", "rtpmap": + // important to skip fmtp with x-google, because this is second fmtp for same codec + // and pion library will fail parsing this SDP + if strings.HasPrefix(attr.Value, skip) || strings.Contains(attr.Value, "x-google") { + continue + } + } + attrs = append(attrs, attr) + } + + md.Attributes = attrs + + b, err := sd.Marshal() + if err != nil { + return "", err + } + return string(b), nil +} diff --git a/installs_on_host/go2rtc/internal/webrtc/kinesis.go b/installs_on_host/go2rtc/internal/webrtc/kinesis.go new file mode 100644 index 0000000..8bfaeb9 --- /dev/null +++ b/installs_on_host/go2rtc/internal/webrtc/kinesis.go @@ -0,0 +1,235 @@ +package webrtc + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/webrtc" + "github.com/gorilla/websocket" + pion "github.com/pion/webrtc/v4" +) + +type kinesisRequest struct { + Action string `json:"action"` + ClientID string `json:"recipientClientId"` + Payload []byte `json:"messagePayload"` +} + +func (k kinesisRequest) String() string { + return fmt.Sprintf("action=%s, payload=%s", k.Action, k.Payload) +} + +type kinesisResponse struct { + Payload []byte `json:"messagePayload"` + Type string `json:"messageType"` +} + +func (k kinesisResponse) String() string { + return fmt.Sprintf("type=%s, payload=%s", k.Type, k.Payload) +} + +func kinesisClient( + rawURL string, query url.Values, format string, + sdpOffer func(prod *webrtc.Conn, query url.Values) (any, error), +) (core.Producer, error) { + // 1. Connect to signalign server + conn, _, err := websocket.DefaultDialer.Dial(rawURL, nil) + if err != nil { + return nil, err + } + + // 2. Load ICEServers from query param (base64 json) + conf := pion.Configuration{} + + if s := query.Get("ice_servers"); s != "" { + conf.ICEServers, err = webrtc.UnmarshalICEServers([]byte(s)) + if err != nil { + log.Warn().Err(err).Caller().Send() + } + } + + // close websocket when we ready return Producer or connection error + defer conn.Close() + + // 3. Create Peer Connection + api, err := webrtc.NewAPI() + if err != nil { + return nil, err + } + + pc, err := api.NewPeerConnection(conf) + if err != nil { + return nil, err + } + + // protect from sending ICE candidate before Offer + var sendOffer core.Waiter + + // protect from blocking on errors + defer sendOffer.Done(nil) + + // waiter will wait PC error or WS error or nil (connection OK) + var connState core.Waiter + + req := kinesisRequest{ + ClientID: query.Get("client_id"), + } + + prod := webrtc.NewConn(pc) + prod.FormatName = format + prod.Mode = core.ModeActiveProducer + prod.Protocol = "ws" + prod.URL = rawURL + prod.Listen(func(msg any) { + switch msg := msg.(type) { + case *pion.ICECandidate: + _ = sendOffer.Wait() + + req.Action = "ICE_CANDIDATE" + req.Payload, _ = json.Marshal(msg.ToJSON()) + if err = conn.WriteJSON(&req); err != nil { + connState.Done(err) + return + } + + log.Trace().Msgf("[webrtc] kinesis send: %s", req) + + case pion.PeerConnectionState: + switch msg { + case pion.PeerConnectionStateConnecting: + case pion.PeerConnectionStateConnected: + connState.Done(nil) + default: + connState.Done(errors.New("webrtc: " + msg.String())) + } + } + }) + + var payload any + + if sdpOffer == nil { + medias := []*core.Media{ + {Kind: core.KindVideo, Direction: core.DirectionRecvonly}, + {Kind: core.KindAudio, Direction: core.DirectionRecvonly}, + } + + // 4. Create offer + var offer string + if offer, err = prod.CreateOffer(medias); err != nil { + return nil, err + } + + // 5. Send offer + payload = pion.SessionDescription{ + Type: pion.SDPTypeOffer, + SDP: offer, + } + } else { + if payload, err = sdpOffer(prod, query); err != nil { + return nil, err + } + } + + req.Action = "SDP_OFFER" + req.Payload, _ = json.Marshal(payload) + if err = conn.WriteJSON(req); err != nil { + return nil, err + } + + log.Trace().Msgf("[webrtc] kinesis send: %s", req) + + sendOffer.Done(nil) + + go func() { + var err error + + // will be closed when conn will be closed + for { + var res kinesisResponse + if err = conn.ReadJSON(&res); err != nil { + // some buggy messages from Amazon servers + if errors.Is(err, io.ErrUnexpectedEOF) { + continue + } + break + } + + log.Trace().Msgf("[webrtc] kinesis recv: %s", res) + + switch res.Type { + case "SDP_ANSWER": + // 6. Get answer + var sd pion.SessionDescription + if err = json.Unmarshal(res.Payload, &sd); err != nil { + break + } + + if err = prod.SetAnswer(sd.SDP); err != nil { + break + } + + case "ICE_CANDIDATE": + // 7. Continue to receiving candidates + var ci pion.ICECandidateInit + if err = json.Unmarshal(res.Payload, &ci); err != nil { + break + } + + if err = prod.AddCandidate(ci.Candidate); err != nil { + break + } + } + } + + connState.Done(err) + }() + + if err = connState.Wait(); err != nil { + return nil, err + } + + return prod, nil +} + +type wyzeKVS struct { + ClientId string `json:"ClientId"` + Cam string `json:"cam"` + Result string `json:"result"` + Servers json.RawMessage `json:"servers"` + URL string `json:"signalingUrl"` +} + +func wyzeClient(rawURL string) (core.Producer, error) { + client := http.Client{Timeout: 5 * time.Second} + res, err := client.Get(rawURL) + if err != nil { + return nil, err + } + + b, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + var kvs wyzeKVS + if err = json.Unmarshal(b, &kvs); err != nil { + return nil, err + } + + if kvs.Result != "ok" { + return nil, errors.New("wyse: wrong result: " + kvs.Result) + } + + query := url.Values{ + "client_id": []string{kvs.ClientId}, + "ice_servers": []string{string(kvs.Servers)}, + } + + return kinesisClient(kvs.URL, query, "webrtc/wyze", nil) +} diff --git a/installs_on_host/go2rtc/internal/webrtc/milestone.go b/installs_on_host/go2rtc/internal/webrtc/milestone.go new file mode 100644 index 0000000..fe1cedc --- /dev/null +++ b/installs_on_host/go2rtc/internal/webrtc/milestone.go @@ -0,0 +1,220 @@ +package webrtc + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/tcp" + "github.com/AlexxIT/go2rtc/pkg/webrtc" + pion "github.com/pion/webrtc/v4" +) + +// This package handles the Milestone WebRTC session lifecycle, including authentication, +// session creation, and session update with an SDP answer. It is designed to be used with +// a specific URL format that encodes session parameters. For example: +// webrtc:https://milestone-host/api#format=milestone#username=User#password=TestPassword#cameraId=a539f254-af05-4d67-a1bb-cd9b3c74d122 +// +// https://github.com/milestonesys/mipsdk-samples-protocol/tree/main/WebRTC_JavaScript + +type milestoneAPI struct { + url string + query url.Values + token string + sessionID string +} + +func (m *milestoneAPI) GetToken() error { + data := url.Values{ + "client_id": {"GrantValidatorClient"}, + "grant_type": {"password"}, + "username": m.query["username"], + "password": m.query["password"], + } + + req, err := http.NewRequest("POST", m.url+"/IDP/connect/token", strings.NewReader(data.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + // support httpx protocol + res, err := tcp.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return errors.New("milesone: authentication failed: " + res.Status) + } + + var payload map[string]interface{} + if err = json.NewDecoder(res.Body).Decode(&payload); err != nil { + return err + } + + token, ok := payload["access_token"].(string) + if !ok { + return errors.New("milesone: token not found in the response") + } + + m.token = token + + return nil +} + +func parseFloat(s string) float64 { + if s == "" { + return 0 + } + f, _ := strconv.ParseFloat(s, 64) + return f +} + +func (m *milestoneAPI) GetOffer() (string, error) { + request := struct { + CameraId string `json:"cameraId"` + StreamId string `json:"streamId,omitempty"` + PlaybackTimeNode struct { + PlaybackTime string `json:"playbackTime,omitempty"` + SkipGaps bool `json:"skipGaps,omitempty"` + Speed float64 `json:"speed,omitempty"` + } `json:"playbackTimeNode,omitempty"` + //ICEServers []string `json:"iceServers,omitempty"` + //Resolution string `json:"resolution,omitempty"` + }{ + CameraId: m.query.Get("cameraId"), + StreamId: m.query.Get("streamId"), + } + request.PlaybackTimeNode.PlaybackTime = m.query.Get("playbackTime") + request.PlaybackTimeNode.SkipGaps = m.query.Has("skipGaps") + request.PlaybackTimeNode.Speed = parseFloat(m.query.Get("speed")) + + data, err := json.Marshal(request) + if err != nil { + return "", err + } + + req, err := http.NewRequest("POST", m.url+"/REST/v1/WebRTC/Session", bytes.NewBuffer(data)) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+m.token) + req.Header.Set("Content-Type", "application/json") + + res, err := tcp.Do(req) + if err != nil { + return "", err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return "", errors.New("milesone: create session: " + res.Status) + } + + var response struct { + SessionId string `json:"sessionId"` + OfferSDP string `json:"offerSDP"` + } + if err = json.NewDecoder(res.Body).Decode(&response); err != nil { + return "", err + } + + var offer pion.SessionDescription + if err = json.Unmarshal([]byte(response.OfferSDP), &offer); err != nil { + return "", err + } + + m.sessionID = response.SessionId + + return offer.SDP, nil +} + +func (m *milestoneAPI) SetAnswer(sdp string) error { + answer := pion.SessionDescription{ + Type: pion.SDPTypeAnswer, + SDP: sdp, + } + data, err := json.Marshal(answer) + if err != nil { + return err + } + + request := struct { + AnswerSDP string `json:"answerSDP"` + }{ + AnswerSDP: string(data), + } + if data, err = json.Marshal(request); err != nil { + return err + } + + req, err := http.NewRequest("PATCH", m.url+"/REST/v1/WebRTC/Session/"+m.sessionID, bytes.NewBuffer(data)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+m.token) + req.Header.Set("Content-Type", "application/json") + + res, err := tcp.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return errors.New("milesone: patch session: " + res.Status) + } + + return nil +} + +func milestoneClient(rawURL string, query url.Values) (core.Producer, error) { + mc := &milestoneAPI{url: rawURL, query: query} + if err := mc.GetToken(); err != nil { + return nil, err + } + + api, err := webrtc.NewAPI() + if err != nil { + return nil, err + } + + conf := pion.Configuration{} + pc, err := api.NewPeerConnection(conf) + if err != nil { + return nil, err + } + + prod := webrtc.NewConn(pc) + prod.FormatName = "webrtc/milestone" + prod.Mode = core.ModeActiveProducer + prod.Protocol = "http" + prod.URL = rawURL + + offer, err := mc.GetOffer() + if err != nil { + return nil, err + } + + if err = prod.SetOffer(offer); err != nil { + return nil, err + } + + answer, err := prod.GetAnswer() + if err != nil { + return nil, err + } + + if err = mc.SetAnswer(answer); err != nil { + return nil, err + } + + return prod, nil +} diff --git a/installs_on_host/go2rtc/internal/webrtc/openipc.go b/installs_on_host/go2rtc/internal/webrtc/openipc.go new file mode 100644 index 0000000..2f2db11 --- /dev/null +++ b/installs_on_host/go2rtc/internal/webrtc/openipc.go @@ -0,0 +1,170 @@ +package webrtc + +import ( + "encoding/json" + "errors" + "io" + "net/url" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/webrtc" + "github.com/gorilla/websocket" + pion "github.com/pion/webrtc/v4" +) + +func openIPCClient(rawURL string, query url.Values) (core.Producer, error) { + // 1. Connect to signalign server + conn, _, err := websocket.DefaultDialer.Dial(rawURL, nil) + if err != nil { + return nil, err + } + + // 2. Load ICEServers from query param (base64 json) + var conf pion.Configuration + + if s := query.Get("ice_servers"); s != "" { + conf.ICEServers, err = webrtc.UnmarshalICEServers([]byte(s)) + if err != nil { + log.Warn().Err(err).Caller().Send() + } + } + + // close websocket when we ready return Producer or connection error + defer conn.Close() + + // 3. Create Peer Connection + api, err := webrtc.NewAPI() + if err != nil { + return nil, err + } + + pc, err := api.NewPeerConnection(conf) + if err != nil { + return nil, err + } + + // protect from sending ICE candidate before Offer + var sendAnswer core.Waiter + + // protect from blocking on errors + defer sendAnswer.Done(nil) + + // waiter will wait PC error or WS error or nil (connection OK) + var connState core.Waiter + + prod := webrtc.NewConn(pc) + prod.FormatName = "webrtc/openipc" + prod.Mode = core.ModeActiveProducer + prod.Protocol = "ws" + prod.URL = rawURL + prod.Listen(func(msg any) { + switch msg := msg.(type) { + case *pion.ICECandidate: + _ = sendAnswer.Wait() + + req := openIPCReq{ + Data: msg.ToJSON().Candidate, + Req: "candidate", + } + if err = conn.WriteJSON(&req); err != nil { + connState.Done(err) + return + } + + log.Trace().Msgf("[webrtc] openipc send: %s", req) + + case pion.PeerConnectionState: + switch msg { + case pion.PeerConnectionStateConnecting: + case pion.PeerConnectionStateConnected: + connState.Done(nil) + default: + connState.Done(errors.New("webrtc: " + msg.String())) + } + } + }) + + go func() { + var err error + + // will be closed when conn will be closed + for err == nil { + var rep openIPCReply + if err = conn.ReadJSON(&rep); err != nil { + // some buggy messages from Amazon servers + if errors.Is(err, io.ErrUnexpectedEOF) { + continue + } + break + } + + log.Trace().Msgf("[webrtc] openipc recv: %s", rep) + + switch rep.Reply { + case "webrtc_answer": + // 6. Get answer + var sd pion.SessionDescription + if err = json.Unmarshal(rep.Data, &sd); err != nil { + break + } + + if err = prod.SetOffer(sd.SDP); err != nil { + break + } + + var answer string + if answer, err = prod.GetAnswer(); err != nil { + break + } + + req := openIPCReq{Data: answer, Req: "answer"} + if err = conn.WriteJSON(req); err != nil { + break + } + + log.Trace().Msgf("[webrtc] kinesis send: %s", req) + + sendAnswer.Done(nil) + + case "webrtc_candidate": + // 7. Continue to receiving candidates + var ci pion.ICECandidateInit + if err = json.Unmarshal(rep.Data, &ci); err != nil { + break + } + + if err = prod.AddCandidate(ci.Candidate); err != nil { + break + } + } + } + + connState.Done(err) + }() + + if err = connState.Wait(); err != nil { + return nil, err + } + + return prod, nil +} + +type openIPCReply struct { + Data json.RawMessage `json:"data"` + Reply string `json:"reply"` +} + +func (r openIPCReply) String() string { + b, _ := json.Marshal(r) + return string(b) +} + +type openIPCReq struct { + Data string `json:"data"` + Req string `json:"req"` +} + +func (r openIPCReq) String() string { + b, _ := json.Marshal(r) + return string(b) +} diff --git a/installs_on_host/go2rtc/internal/webrtc/server.go b/installs_on_host/go2rtc/internal/webrtc/server.go new file mode 100644 index 0000000..48bd538 --- /dev/null +++ b/installs_on_host/go2rtc/internal/webrtc/server.go @@ -0,0 +1,235 @@ +package webrtc + +import ( + "encoding/base64" + "encoding/json" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/webrtc" + pion "github.com/pion/webrtc/v4" +) + +const MimeSDP = "application/sdp" + +var sessions = map[string]*webrtc.Conn{} + +func syncHandler(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "POST": + query := r.URL.Query() + if query.Get("src") != "" { + // WHEP or JSON SDP or raw SDP exchange + outputWebRTC(w, r) + } else if query.Get("dst") != "" { + // WHIP SDP exchange + inputWebRTC(w, r) + } else { + http.Error(w, "", http.StatusBadRequest) + } + + case "PATCH": + // TODO: WHEP/WHIP + http.Error(w, "", http.StatusMethodNotAllowed) + + case "DELETE": + if id := r.URL.Query().Get("id"); id != "" { + if conn, ok := sessions[id]; ok { + delete(sessions, id) + _ = conn.Close() + } else { + http.Error(w, "", http.StatusNotFound) + } + } else { + http.Error(w, "", http.StatusBadRequest) + } + + case "OPTIONS": + w.WriteHeader(http.StatusNoContent) + + default: + http.Error(w, "", http.StatusMethodNotAllowed) + } +} + +// outputWebRTC support API depending on Content-Type: +// 1. application/json - receive {"type":"offer","sdp":"v=0\r\n..."} and response {"type":"answer","sdp":"v=0\r\n..."} +// 2. application/sdp - receive/response SDP via WebRTC-HTTP Egress Protocol (WHEP) +// 3. other - receive/response raw SDP +func outputWebRTC(w http.ResponseWriter, r *http.Request) { + u := r.URL.Query().Get("src") + stream := streams.Get(u) + if stream == nil { + http.Error(w, api.StreamNotFound, http.StatusNotFound) + return + } + + mediaType := r.Header.Get("Content-Type") + if mediaType != "" { + mediaType, _, _ = strings.Cut(mediaType, ";") + mediaType = strings.ToLower(strings.TrimSpace(mediaType)) + } + + var offer string + + switch mediaType { + case "application/json": + var desc pion.SessionDescription + if err := json.NewDecoder(r.Body).Decode(&desc); err != nil { + log.Error().Err(err).Caller().Send() + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + offer = desc.SDP + + case "application/x-www-form-urlencoded": + if err := r.ParseForm(); err != nil { + log.Error().Err(err).Caller().Send() + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + offerB64 := r.Form.Get("data") + b, err := base64.StdEncoding.DecodeString(offerB64) + if err != nil { + log.Error().Err(err).Caller().Send() + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + offer = string(b) + + default: + body, err := io.ReadAll(r.Body) + if err != nil { + log.Error().Err(err).Caller().Send() + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + offer = string(body) + } + + var desc string + + switch mediaType { + case "application/json": + desc = "webrtc/json" + case MimeSDP: + desc = "webrtc/whep" + default: + desc = "webrtc/post" + } + + answer, err := ExchangeSDP(stream, offer, desc, r.UserAgent()) + if err != nil { + log.Error().Err(err).Caller().Send() + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + switch mediaType { + case "application/json": + w.Header().Set("Content-Type", mediaType) + + v := pion.SessionDescription{ + Type: pion.SDPTypeAnswer, SDP: answer, + } + err = json.NewEncoder(w).Encode(v) + + case "application/x-www-form-urlencoded": + w.Header().Set("Content-Type", mediaType) + answerB64 := base64.StdEncoding.EncodeToString([]byte(answer)) + _, err = w.Write([]byte(answerB64)) + + case MimeSDP: + w.Header().Set("Content-Type", mediaType) + w.WriteHeader(http.StatusCreated) + + _, err = w.Write([]byte(answer)) + + default: + w.Header().Set("Content-Type", mediaType) + + _, err = w.Write([]byte(answer)) + } + + if err != nil { + log.Error().Err(err).Caller().Send() + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +func inputWebRTC(w http.ResponseWriter, r *http.Request) { + dst := r.URL.Query().Get("dst") + stream := streams.Get(dst) + if stream == nil { + http.Error(w, api.StreamNotFound, http.StatusNotFound) + return + } + + // 1. Get offer + offer, err := io.ReadAll(r.Body) + if err != nil { + log.Error().Err(err).Caller().Send() + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + log.Trace().Msgf("[webrtc] WHIP offer\n%s", offer) + + pc, err := PeerConnection(false) + if err != nil { + log.Error().Err(err).Caller().Send() + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // create new webrtc instance + prod := webrtc.NewConn(pc) + prod.Mode = core.ModePassiveProducer + prod.Protocol = "http" + prod.UserAgent = r.UserAgent() + + if err = prod.SetOffer(string(offer)); err != nil { + log.Warn().Err(err).Caller().Send() + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + answer, err := prod.GetCompleteAnswer(GetCandidates(), FilterCandidate) + if err != nil { + log.Warn().Err(err).Caller().Send() + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + log.Trace().Msgf("[webrtc] WHIP answer\n%s", answer) + + id := strconv.FormatInt(time.Now().UnixNano(), 36) + sessions[id] = prod + + prod.Listen(func(msg any) { + switch msg := msg.(type) { + case pion.PeerConnectionState: + if msg == pion.PeerConnectionStateClosed { + stream.RemoveProducer(prod) + delete(sessions, id) + } + } + }) + + stream.AddProducer(prod) + + w.Header().Set("Content-Type", MimeSDP) + w.Header().Set("Location", "webrtc?id="+id) + w.WriteHeader(http.StatusCreated) + + if _, err = w.Write([]byte(answer)); err != nil { + log.Warn().Err(err).Caller().Send() + return + } +} diff --git a/installs_on_host/go2rtc/internal/webrtc/switchbot.go b/installs_on_host/go2rtc/internal/webrtc/switchbot.go new file mode 100644 index 0000000..6f72e55 --- /dev/null +++ b/installs_on_host/go2rtc/internal/webrtc/switchbot.go @@ -0,0 +1,44 @@ +package webrtc + +import ( + "net/url" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/webrtc" +) + +func switchbotClient(rawURL string, query url.Values) (core.Producer, error) { + return kinesisClient(rawURL, query, "webrtc/switchbot", func(prod *webrtc.Conn, query url.Values) (any, error) { + medias := []*core.Media{ + {Kind: core.KindVideo, Direction: core.DirectionRecvonly}, + } + + offer, err := prod.CreateOffer(medias) + if err != nil { + return nil, err + } + + v := struct { + Type string `json:"type"` + SDP string `json:"sdp"` + Resolution int `json:"resolution"` + PlayType int `json:"play_type"` + }{ + Type: "offer", + SDP: offer, + } + + switch query.Get("resolution") { + case "hd": + v.Resolution = 0 + case "sd": + v.Resolution = 1 + case "auto": + v.Resolution = 2 + } + + v.PlayType = core.Atoi(query.Get("play_type")) // zero by default + + return v, nil + }) +} diff --git a/installs_on_host/go2rtc/internal/webrtc/webrtc.go b/installs_on_host/go2rtc/internal/webrtc/webrtc.go new file mode 100644 index 0000000..2a5b4ad --- /dev/null +++ b/installs_on_host/go2rtc/internal/webrtc/webrtc.go @@ -0,0 +1,321 @@ +package webrtc + +import ( + "errors" + "net" + "strings" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/api/ws" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/webrtc" + pion "github.com/pion/webrtc/v4" + "github.com/rs/zerolog" +) + +func Init() { + var cfg struct { + Mod struct { + Listen string `yaml:"listen"` + Candidates []string `yaml:"candidates"` + IceServers []pion.ICEServer `yaml:"ice_servers"` + Filters webrtc.Filters `yaml:"filters"` + } `yaml:"webrtc"` + } + + cfg.Mod.Listen = ":8555" + cfg.Mod.IceServers = []pion.ICEServer{ + {URLs: []string{"stun:stun.cloudflare.com:3478", "stun:stun.l.google.com:19302"}}, + } + + app.LoadConfig(&cfg) + + log = app.GetLogger("webrtc") + + if log.Debug().Enabled() { + itfs, _ := net.Interfaces() + for _, itf := range itfs { + addrs, _ := itf.Addrs() + log.Debug().Msgf("[webrtc] interface %+v addrs %v", itf, addrs) + } + } + + address, network, _ := strings.Cut(cfg.Mod.Listen, "/") + for _, candidate := range cfg.Mod.Candidates { + AddCandidate(network, candidate) + + if strings.HasPrefix(candidate, "stun:") && stuns == nil { + for _, ice := range cfg.Mod.IceServers { + for _, url := range ice.URLs { + if strings.HasPrefix(url, "stun:") { + stuns = append(stuns, url[5:]) + } + } + } + } + } + + webrtc.OnNewListener = func(ln any) { + switch ln := ln.(type) { + case *net.TCPListener: + log.Info().Stringer("addr", ln.Addr()).Msg("[webrtc] listen tcp") + case *net.UDPConn: + log.Info().Stringer("addr", ln.LocalAddr()).Msg("[webrtc] listen udp") + } + } + + var err error + + // create pionAPI with custom codecs list and custom network settings + serverAPI, err = webrtc.NewServerAPI(network, address, &cfg.Mod.Filters) + if err != nil { + log.Error().Err(err).Caller().Send() + return + } + + // use same API for WebRTC server and client if no address + clientAPI = serverAPI + + if address != "" { + clientAPI, _ = webrtc.NewAPI() + } + + pionConf := pion.Configuration{ + ICEServers: cfg.Mod.IceServers, + SDPSemantics: pion.SDPSemanticsUnifiedPlanWithFallback, + } + + PeerConnection = func(active bool) (*pion.PeerConnection, error) { + // active - client, passive - server + if active { + return clientAPI.NewPeerConnection(pionConf) + } else { + return serverAPI.NewPeerConnection(pionConf) + } + } + + // async WebRTC server (two API versions) + ws.HandleFunc("webrtc", asyncHandler) + ws.HandleFunc("webrtc/offer", asyncHandler) + ws.HandleFunc("webrtc/candidate", candidateHandler) + + // sync WebRTC server (two API versions) + api.HandleFunc("api/webrtc", syncHandler) + + // WebRTC client + streams.HandleFunc("webrtc", streamsHandler) +} + +var serverAPI, clientAPI *pion.API + +var log zerolog.Logger + +var PeerConnection func(active bool) (*pion.PeerConnection, error) + +func asyncHandler(tr *ws.Transport, msg *ws.Message) (err error) { + var stream *streams.Stream + var mode core.Mode + + query := tr.Request.URL.Query() + if name := query.Get("src"); name != "" { + stream, _ = streams.GetOrPatch(query) + mode = core.ModePassiveConsumer + log.Debug().Str("src", name).Msg("[webrtc] new consumer") + } else if name = query.Get("dst"); name != "" { + stream = streams.Get(name) + mode = core.ModePassiveProducer + log.Debug().Str("src", name).Msg("[webrtc] new producer") + } + + if stream == nil { + return errors.New(api.StreamNotFound) + } + + var offer struct { + Type string `json:"type"` + SDP string `json:"sdp"` + ICEServers []pion.ICEServer `json:"ice_servers"` + } + + // V2 - json/object exchange, V1 - raw SDP exchange + apiV2 := msg.Type == "webrtc" + + if apiV2 { + if err = msg.Unmarshal(&offer); err != nil { + return err + } + } else { + offer.SDP = msg.String() + } + + // create new PeerConnection instance + var pc *pion.PeerConnection + if offer.ICEServers == nil { + pc, err = PeerConnection(false) + } else { + pc, err = serverAPI.NewPeerConnection(pion.Configuration{ICEServers: offer.ICEServers}) + } + if err != nil { + log.Error().Err(err).Caller().Send() + return err + } + + var sendAnswer core.Waiter + + // protect from blocking on errors + defer sendAnswer.Done(nil) + + conn := webrtc.NewConn(pc) + conn.Mode = mode + conn.Protocol = "ws" + conn.UserAgent = tr.Request.UserAgent() + conn.Listen(func(msg any) { + switch msg := msg.(type) { + case pion.PeerConnectionState: + if msg != pion.PeerConnectionStateClosed { + return + } + switch mode { + case core.ModePassiveConsumer: + stream.RemoveConsumer(conn) + case core.ModePassiveProducer: + stream.RemoveProducer(conn) + } + + case *pion.ICECandidate: + if !FilterCandidate(msg) { + return + } + _ = sendAnswer.Wait() + + s := msg.ToJSON().Candidate + log.Trace().Str("candidate", s).Msg("[webrtc] local ") + tr.Write(&ws.Message{Type: "webrtc/candidate", Value: s}) + } + }) + + log.Trace().Msgf("[webrtc] offer:\n%s", offer.SDP) + + // 1. SetOffer, so we can get remote client codecs + if err = conn.SetOffer(offer.SDP); err != nil { + log.Warn().Err(err).Caller().Send() + return err + } + + switch mode { + case core.ModePassiveConsumer: + // 2. AddConsumer, so we get new tracks + if err = stream.AddConsumer(conn); err != nil { + log.Debug().Err(err).Msg("[webrtc] add consumer") + _ = conn.Close() + return err + } + case core.ModePassiveProducer: + stream.AddProducer(conn) + } + + // 3. Exchange SDP without waiting all candidates + answer, err := conn.GetAnswer() + log.Trace().Msgf("[webrtc] answer\n%s", answer) + + if err != nil { + log.Error().Err(err).Caller().Send() + return err + } + + if apiV2 { + desc := pion.SessionDescription{Type: pion.SDPTypeAnswer, SDP: answer} + tr.Write(&ws.Message{Type: "webrtc", Value: desc}) + } else { + tr.Write(&ws.Message{Type: "webrtc/answer", Value: answer}) + } + + sendAnswer.Done(nil) + + asyncCandidates(tr, conn) + + return nil +} + +func ExchangeSDP(stream *streams.Stream, offer, desc, userAgent string) (answer string, err error) { + pc, err := PeerConnection(false) + if err != nil { + log.Error().Err(err).Caller().Send() + return + } + + // create new webrtc instance + conn := webrtc.NewConn(pc) + conn.FormatName = desc + conn.UserAgent = userAgent + conn.Protocol = "http" + conn.Listen(func(msg any) { + switch msg := msg.(type) { + case pion.PeerConnectionState: + if msg != pion.PeerConnectionStateClosed { + return + } + if conn.Mode == core.ModePassiveConsumer { + stream.RemoveConsumer(conn) + } else { + stream.RemoveProducer(conn) + } + } + }) + + // 1. SetOffer, so we can get remote client codecs + log.Trace().Msgf("[webrtc] offer:\n%s", offer) + + if err = conn.SetOffer(offer); err != nil { + log.Warn().Err(err).Caller().Send() + return + } + + if IsConsumer(conn) { + conn.Mode = core.ModePassiveConsumer + + // 2. AddConsumer, so we get new tracks + if err = stream.AddConsumer(conn); err != nil { + log.Warn().Err(err).Caller().Send() + _ = conn.Close() + return + } + } else { + conn.Mode = core.ModePassiveProducer + + stream.AddProducer(conn) + } + + answer, err = conn.GetCompleteAnswer(GetCandidates(), FilterCandidate) + log.Trace().Msgf("[webrtc] answer\n%s", answer) + + if err != nil { + log.Error().Err(err).Caller().Send() + } + + return +} + +func IsConsumer(conn *webrtc.Conn) bool { + // if wants get video - consumer + for _, media := range conn.GetMedias() { + if media.Kind == core.KindVideo && media.Direction == core.DirectionSendonly { + return true + } + } + // if wants send video - producer + for _, media := range conn.GetMedias() { + if media.Kind == core.KindVideo && media.Direction == core.DirectionRecvonly { + return false + } + } + // if wants something - consumer + for _, media := range conn.GetMedias() { + if media.Direction == core.DirectionSendonly { + return true + } + } + return false +} diff --git a/installs_on_host/go2rtc/internal/webrtc/webrtc_test.go b/installs_on_host/go2rtc/internal/webrtc/webrtc_test.go new file mode 100644 index 0000000..1f82a0a --- /dev/null +++ b/installs_on_host/go2rtc/internal/webrtc/webrtc_test.go @@ -0,0 +1,73 @@ +package webrtc + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/AlexxIT/go2rtc/internal/api/ws" + pion "github.com/pion/webrtc/v4" + "github.com/stretchr/testify/require" +) + +func TestWebRTCAPIv1(t *testing.T) { + raw := `{"type":"webrtc/offer","value":"v=0\n..."}` + msg := new(ws.Message) + err := json.Unmarshal([]byte(raw), msg) + require.Nil(t, err) + + require.Equal(t, "v=0\n...", msg.String()) +} + +func TestWebRTCAPIv2(t *testing.T) { + raw := `{"type":"webrtc","value":{"type":"offer","sdp":"v=0\n...","ice_servers":[{"urls":["stun:stun.l.google.com:19302"]}]}}` + msg := new(ws.Message) + err := json.Unmarshal([]byte(raw), msg) + require.Nil(t, err) + + var offer struct { + Type string `json:"type"` + SDP string `json:"sdp"` + ICEServers []pion.ICEServer `json:"ice_servers"` + } + err = msg.Unmarshal(&offer) + require.Nil(t, err) + + require.Equal(t, "offer", offer.Type) + require.Equal(t, "v=0\n...", offer.SDP) + require.Equal(t, "stun:stun.l.google.com:19302", offer.ICEServers[0].URLs[0]) +} + +func TestCrealitySDP(t *testing.T) { + sdp := `v=0 +o=- 1495799811084970 1495799811084970 IN IP4 0.0.0.0 +s=- +t=0 0 +a=msid-semantic:WMS * +a=group:BUNDLE 0 +m=video 9 UDP/TLS/RTP/SAVPF 96 98 +a=rtcp-fb:98 nack +a=rtcp-fb:98 nack pli +a=fmtp:96 profile-level-id=42e01f;level-asymmetry-allowed=1 +a=fmtp:98 profile-level-id=42e01f;packetization-mode=1;level-asymmetry-allowed=1 +a=fmtp:98 x-google-max-bitrate=6000;x-google-min-bitrate=2000;x-google-start-bitrate=4000 +a=rtpmap:96 H264/90000 +a=rtpmap:98 H264/90000 +a=ssrc:1 cname:pear +c=IN IP4 0.0.0.0 +a=sendonly +a=mid:0 +a=rtcp-mux +a=ice-ufrag:7AVa +a=ice-pwd:T+F/5y05Paw+mtG5Jrd8N3 +a=ice-options:trickle +a=fingerprint:sha-256 A5:AB:C0:4E:29:5B:BD:3B:7D:88:24:6C:56:6B:2A:79:A3:76:99:35:57:75:AD:C8:5A:A6:34:20:88:1B:68:EF +a=setup:passive +a=candidate:1 1 UDP 2015363327 172.22.233.10 48929 typ host +a=candidate:2 1 TCP 1015021823 172.22.233.10 0 typ host tcptype active +a=candidate:3 1 TCP 1010827519 172.22.233.10 60677 typ host tcptype passive +` + sdp, err := fixCrealitySDP(sdp) + require.Nil(t, err) + require.False(t, strings.Contains(sdp, "x-google-max-bitrate")) +} diff --git a/installs_on_host/go2rtc/internal/webtorrent/README.md b/installs_on_host/go2rtc/internal/webtorrent/README.md new file mode 100644 index 0000000..1c9811a --- /dev/null +++ b/installs_on_host/go2rtc/internal/webtorrent/README.md @@ -0,0 +1,45 @@ +# WebTorrent + +> [!NOTE] +> This section needs some improvement. + +## WebTorrent Client + +[`new in v1.3.0`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0) + +This source can get a stream from another go2rtc via [WebTorrent](https://en.wikipedia.org/wiki/WebTorrent) protocol. + +### Client Configuration + +```yaml +streams: + webtorrent1: webtorrent:?share=huofssuxaty00izc&pwd=k3l2j9djeg8v8r7e +``` + +## WebTorrent Server + +[`new in v1.3.0`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.3.0) + +This module supports: + +- Share any local stream via [WebTorrent](https://webtorrent.io/) technology +- Get any [incoming stream](../webrtc/README.md#ingest-browser) from PC or mobile via [WebTorrent](https://webtorrent.io/) technology +- Get any remote go2rtc source via [WebTorrent](https://webtorrent.io/) technology + +Securely and freely. You do not need to open a public access to the go2rtc server. But in some cases (Symmetric NAT), you may need to set up external access to [WebRTC module](../webrtc/README.md). + +To generate a sharing link or incoming link, go to the go2rtc WebUI (stream links page). This link is **temporary** and will stop working after go2rtc is restarted! + +### Server Configuration + +You can create permanent external links in the go2rtc config: + +```yaml +webtorrent: + shares: + super-secret-share: # share name, should be unique among all go2rtc users! + pwd: super-secret-password + src: rtsp-dahua1 # stream name from streams section +``` + +Link example: `https://go2rtc.org/webtorrent/#share=02SNtgjKXY&pwd=wznEQqznxW&media=video+audio` diff --git a/installs_on_host/go2rtc/internal/webtorrent/init.go b/installs_on_host/go2rtc/internal/webtorrent/init.go new file mode 100644 index 0000000..b1c25c7 --- /dev/null +++ b/installs_on_host/go2rtc/internal/webtorrent/init.go @@ -0,0 +1,176 @@ +package webtorrent + +import ( + "errors" + "fmt" + "net/http" + "net/url" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/internal/webrtc" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/webtorrent" + "github.com/rs/zerolog" +) + +func Init() { + var cfg struct { + Mod struct { + Trackers []string `yaml:"trackers"` + Shares map[string]struct { + Pwd string `yaml:"pwd"` + Src string `yaml:"src"` + } `yaml:"shares"` + } `yaml:"webtorrent"` + } + + cfg.Mod.Trackers = []string{"wss://tracker.openwebtorrent.com"} + + app.LoadConfig(&cfg) + + if len(cfg.Mod.Trackers) == 0 { + return + } + + log = app.GetLogger("webtorrent") + + streams.HandleFunc("webtorrent", streamHandle) + + api.HandleFunc("api/webtorrent", apiHandle) + + srv = &webtorrent.Server{ + URL: cfg.Mod.Trackers[0], + Exchange: func(src, offer string) (answer string, err error) { + stream := streams.Get(src) + if stream == nil { + return "", errors.New(api.StreamNotFound) + } + return webrtc.ExchangeSDP(stream, offer, "webtorrent", "") + }, + } + + if log.Debug().Enabled() { + srv.Listen(func(msg any) { + switch msg.(type) { + case string, error: + log.Debug().Msgf("[webtorrent] %s", msg) + case *webtorrent.Message: + log.Trace().Any("msg", msg).Msgf("[webtorrent]") + } + }) + } + + for name, share := range cfg.Mod.Shares { + if len(name) < 8 { + log.Warn().Str("name", name).Msgf("min share name len - 8 symbols") + continue + } + if len(share.Pwd) < 4 { + log.Warn().Str("name", name).Str("pwd", share.Pwd).Msgf("min share pwd len - 4 symbols") + continue + } + if streams.Get(share.Src) == nil { + log.Warn().Str("stream", share.Src).Msgf("stream not exists") + continue + } + + srv.AddShare(name, share.Pwd, share.Src) + + // adds to GET /api/webtorrent + shares[name] = name + } +} + +var log zerolog.Logger + +var shares = map[string]string{} +var srv *webtorrent.Server + +func apiHandle(w http.ResponseWriter, r *http.Request) { + src := r.URL.Query().Get("src") + share, ok := shares[src] + + switch r.Method { + case "GET": + // support act as WebTorrent tracker (for testing purposes) + if r.Header.Get("Connection") == "Upgrade" { + tracker(w, r) + return + } + + if src != "" { + // response one share + if ok { + pwd := srv.GetSharePwd(share) + data := fmt.Sprintf(`{"share":%q,"pwd":%q}`, share, pwd) + _, _ = w.Write([]byte(data)) + } else { + http.Error(w, "", http.StatusNotFound) + } + } else { + // response all shares + var items []*api.Source + for src, share := range shares { + pwd := srv.GetSharePwd(share) + source := fmt.Sprintf("webtorrent:?share=%s&pwd=%s", share, pwd) + items = append(items, &api.Source{ID: src, URL: source}) + } + api.ResponseSources(w, items) + } + + case "POST": + // check if share already exist + if ok { + http.Error(w, "", http.StatusBadRequest) + return + } + + // check if stream exists + if stream := streams.Get(src); stream == nil { + http.Error(w, "", http.StatusNotFound) + return + } + + // create new random share + share = core.RandString(10, 62) + pwd := core.RandString(10, 62) + srv.AddShare(share, pwd, src) + + shares[src] = share + + w.WriteHeader(http.StatusCreated) + data := fmt.Sprintf(`{"share":%q,"pwd":%q}`, share, pwd) + api.Response(w, data, api.MimeJSON) + + case "DELETE": + if ok { + srv.RemoveShare(share) + delete(shares, src) + } else { + http.Error(w, "", http.StatusNotFound) + } + } +} + +func streamHandle(rawURL string) (core.Producer, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + query := u.Query() + share := query.Get("share") + pwd := query.Get("pwd") + if len(share) < 8 || len(pwd) < 4 { + return nil, errors.New("wrong URL: " + rawURL) + } + + pc, err := webrtc.PeerConnection(true) + if err != nil { + return nil, err + } + + return webtorrent.NewClient(srv.URL, share, pwd, pc) +} diff --git a/installs_on_host/go2rtc/internal/webtorrent/tracker.go b/installs_on_host/go2rtc/internal/webtorrent/tracker.go new file mode 100644 index 0000000..e504d80 --- /dev/null +++ b/installs_on_host/go2rtc/internal/webtorrent/tracker.go @@ -0,0 +1,108 @@ +package webtorrent + +import ( + "fmt" + "net/http" + + "github.com/AlexxIT/go2rtc/pkg/webtorrent" + "github.com/gorilla/websocket" +) + +var upgrader *websocket.Upgrader +var hashes map[string]map[string]*websocket.Conn + +func tracker(w http.ResponseWriter, r *http.Request) { + if upgrader == nil { + upgrader = &websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 2028, + } + upgrader.CheckOrigin = func(r *http.Request) bool { + return true + } + } + + ws, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Warn().Err(err).Send() + return + } + + defer ws.Close() + + for { + var msg webtorrent.Message + if err = ws.ReadJSON(&msg); err != nil { + return + } + + //log.Trace().Msgf("[webtorrent] message=%v", msg) + + if msg.InfoHash == "" || msg.PeerId == "" { + continue + } + + if hashes == nil { + hashes = map[string]map[string]*websocket.Conn{} + } + + // new or old client with offers + clients := hashes[msg.InfoHash] + if clients == nil { + clients = map[string]*websocket.Conn{ + msg.PeerId: ws, + } + hashes[msg.InfoHash] = clients + } else { + clients[msg.PeerId] = ws + } + + switch { + case msg.Offers != nil: + // ask for ping + raw := fmt.Sprintf( + `{"action":"announce","interval":120,"info_hash":"%s","complete":0,"incomplete":1}`, + msg.InfoHash, + ) + if err = ws.WriteMessage(websocket.TextMessage, []byte(raw)); err != nil { + log.Warn().Err(err).Send() + return + } + + // skip if no offers (server) + if len(msg.Offers) == 0 { + continue + } + + // get and check only first offer + offer := msg.Offers[0] + if offer.OfferId == "" || offer.Offer.Type != "offer" || offer.Offer.SDP == "" { + continue + } + + // send offer to all clients (one of them - server) + raw = fmt.Sprintf( + `{"action":"announce","info_hash":"%s","peer_id":"%s","offer_id":"%s","offer":{"type":"offer","sdp":"%s"}}`, + msg.InfoHash, msg.PeerId, offer.OfferId, offer.Offer.SDP, + ) + + for _, server := range clients { + if server != ws { + _ = server.WriteMessage(websocket.TextMessage, []byte(raw)) + } + } + + case msg.OfferId != "" && msg.ToPeerId != "" && msg.Answer != nil: + ws1, ok := clients[msg.ToPeerId] + if !ok { + continue + } + + raw := fmt.Sprintf( + `{"action":"announce","info_hash":"%s","peer_id":"%s","offer_id":"%s","answer":{"type":"answer","sdp":"%s"}}`, + msg.InfoHash, msg.PeerId, msg.OfferId, msg.Answer.SDP, + ) + _ = ws1.WriteMessage(websocket.TextMessage, []byte(raw)) + } + } +} diff --git a/installs_on_host/go2rtc/internal/wyoming/README.md b/installs_on_host/go2rtc/internal/wyoming/README.md new file mode 100644 index 0000000..ed02c9b --- /dev/null +++ b/installs_on_host/go2rtc/internal/wyoming/README.md @@ -0,0 +1,284 @@ +# Wyoming + +> [!NOTE] +> The format is under development and does not yet work stably. + +This module provide [Wyoming Protocol](https://www.home-assistant.io/integrations/wyoming/) support to create local voice assistants using [Home Assistant](https://www.home-assistant.io/). + +- go2rtc can act as [Wyoming Satellite](https://github.com/rhasspy/wyoming-satellite) +- go2rtc can act as [Wyoming External Microphone](https://github.com/rhasspy/wyoming-mic-external) +- go2rtc can act as [Wyoming External Sound](https://github.com/rhasspy/wyoming-snd-external) +- any supported audio source with PCM codec can be used as audio input +- any supported two-way audio source with PCM codec can be used as audio output +- any desktop/server microphone/speaker can be used as two-way audio source + - supported any OS via FFmpeg or any similar software + - supported Linux via alsa source +- you can change the behavior using the built-in scripting engine + +## Typical Voice Pipeline + +1. Audio stream (MIC) + - any audio source with PCM codec support (include PCMA/PCMU) +2. Voice Activity Detector (VAD) +3. Wake Word (WAKE) + - [OpenWakeWord](https://www.home-assistant.io/voice_control/create_wake_word/) +4. Speech-to-Text (STT) + - [Whisper](https://github.com/home-assistant/addons/blob/master/whisper/) + - [Vosk](https://github.com/rhasspy/hassio-addons/blob/master/vosk/) +5. Conversation agent (INTENT) + - [Home Assistant](https://www.home-assistant.io/integrations/conversation/) +6. Text-to-speech (TTS) + - [Google Translate](https://www.home-assistant.io/integrations/google_translate/) + - [Piper](https://github.com/home-assistant/addons/blob/master/piper/) +7. Audio stream (SND) + - any source with two-way audio (backchannel) and PCM codec support (include PCMA/PCMU) + +You can use a large number of different projects for WAKE, STT, INTENT and TTS thanks to the Home Assistant. + +And you can use a large number of different technologies for MIC and SND thanks to Go2rtc. + +## Configuration + +You can optionally specify WAKE service. So go2rtc will start transmitting audio to Home Assistant only after WAKE word. If the WAKE service cannot be connected to or not specified - go2rtc will pass all audio to Home Assistant. In this case WAKE service must be configured in your Voice Assistant pipeline. + +You can optionally specify VAD threshold. So go2rtc will start transmitting audio to WAKE service only after some audio noise. + +Your stream must support audio transmission in PCM codec (include PCMA/PCMU). + +```yaml +wyoming: + stream_name_from_streams_section: + listen: :10700 + name: "My Satellite" # optional name + wake_uri: tcp://192.168.1.23:10400 # optional WAKE service + vad_threshold: 1 # optional VAD threshold (from 0.1 to 3.5) +``` + +Home Assistant -> Settings -> Integrations -> Add -> Wyoming Protocol -> Host + Port from `go2rtc.yaml` + +Select one or multiple wake words: +```yaml +wake_uri: tcp://192.168.1.23:10400?name=alexa_v0.1&name=hey_jarvis_v0.1&name=hey_mycroft_v0.1&name=hey_rhasspy_v0.1&name=ok_nabu_v0.1 +``` + +## Events + +You can add wyoming event handling using the [expr](../expr/README.md) language. For example, to pronounce TTS on some media player from HA. + +Turn on the logs to see what kind of events happens. + +This is what the default scripts look like: + +```yaml +wyoming: + script_example: + event: + run-satellite: Detect() + pause-satellite: Stop() + voice-stopped: Pause() + audio-stop: PlayAudio() && WriteEvent("played") && Detect() + error: Detect() + internal-run: WriteEvent("run-pipeline", '{"start_stage":"wake","end_stage":"tts"}') && Stream() + internal-detection: WriteEvent("run-pipeline", '{"start_stage":"asr","end_stage":"tts"}') && Stream() +``` + +Supported functions and variables: + +- `Detect()` - start the VAD and WAKE word detection process +- `Stream()` - start transmission of audio data to the client (Home Assistant) +- `Stop()` - stop and disconnect stream without disconnecting client (Home Assistant) +- `Pause()` - temporary pause of audio transfer, without disconnecting the stream +- `PlayAudio()` - playing the last audio that was sent from client (Home Assistant) +- `WriteEvent(type, data)` - send event to client (Home Assistant) +- `Sleep(duration)` - temporary script pause (ex. `Sleep('1.5s')`) +- `PlayFile(path)` - play audio from `wav` file +- `Type` - type (name) of event +- `Data` - event data in JSON format (ex. `{"text":"how are you"}`) +- also available other functions from [expr](../expr/README.md) module (ex. `fetch`) + +If you write a script for an event - the default action is no longer executed. You need to repeat the necessary steps yourself. + +In addition to the standard events, there are two additional events: + +- `internal-run` - called after `Detect()` when VAD detected, but WAKE service unavailable +- `internal-detection` - called after `Detect()` when WAKE word detected + +**Example 1.** You want to play a sound file when a wake word detected (only `wav` supported): + +- `PlayFile` and `PlayAudio` functions are executed synchronously, the following steps will be executed only after they are completed + +```yaml +wyoming: + script_example: + event: + internal-detection: PlayFile('/media/beep.wav') && WriteEvent("run-pipeline", '{"start_stage":"asr","end_stage":"tts"}') && Stream() +``` + +**Example 2.** You want to play TTS on a Home Assistant media player: + +Each event has a `Type` and `Data` in JSON format. You can use their values in scripts. + +- in the `synthesize` step, we get the value of the `text` and call the HA REST API +- in the `audio-stop` step we get the duration of the TTS in seconds, wait for this time and start the pipeline again + +```yaml +wyoming: + script_example: + event: + synthesize: | + let text = fromJSON(Data).text; + let token = 'eyJhbGci...'; + fetch('http://localhost:8123/api/services/tts/speak', { + method: 'POST', + headers: {'Authorization': 'Bearer '+token,'Content-Type': 'application/json'}, + body: toJSON({ + entity_id: 'tts.google_translate_com', + media_player_entity_id: 'media_player.google_nest', + message: text, + language: 'en', + }), + }).ok + audio-stop: | + let timestamp = fromJSON(Data).timestamp; + let delay = string(timestamp)+'s'; + Sleep(delay) && WriteEvent("played") && Detect() +``` + +## Config examples + +Satellite on Windows server using FFmpeg and FFplay. + +```yaml +streams: + satellite_win: + - exec:ffmpeg -hide_banner -f dshow -i "audio=Microphone (High Definition Audio Device)" -c pcm_s16le -ar 16000 -ac 1 -f wav - + - exec:ffplay -hide_banner -nodisp -probesize 32 -f s16le -ar 22050 -#backchannel=1#audio=s16le/22050 + +wyoming: + satellite_win: + listen: :10700 + name: "Windows Satellite" + wake_uri: tcp://192.168.1.23:10400 + vad_threshold: 1 +``` + +Satellite on Dahua camera with two-way audio support. + +```yaml +streams: + dahua_camera: + - rtsp://admin:password@192.168.1.123/cam/realmonitor?channel=1&subtype=1&unicast=true&proto=Onvif + +wyoming: + dahua_camera: + listen: :10700 + name: "Dahua Satellite" + wake_uri: tcp://192.168.1.23:10400 + vad_threshold: 1 +``` + +Satellite on external wyoming Microphone and Sound. + +```yaml +streams: + wyoming_external: + - wyoming://192.168.1.23:10600 # wyoming-mic-external + - wyoming://192.168.1.23:10601?backchannel=1 # wyoming-snd-external + +wyoming: + wyoming_external: + listen: :10700 + name: "Wyoming Satellite" + wake_uri: tcp://192.168.1.23:10400 + vad_threshold: 1 +``` + +## Wyoming External Microphone and Sound + +Advanced users, who want to enjoy the [Wyoming Satellite](https://github.com/rhasspy/wyoming-satellite) project, can use go2rtc as a [Wyoming External Microphone](https://github.com/rhasspy/wyoming-mic-external) or [Wyoming External Sound](https://github.com/rhasspy/wyoming-snd-external). + +**go2rtc.yaml** + +```yaml +streams: + wyoming_mic_external: + - exec:ffmpeg -hide_banner -f dshow -i "audio=Microphone (High Definition Audio Device)" -c pcm_s16le -ar 16000 -ac 1 -f wav - + wyoming_snd_external: + - exec:ffplay -hide_banner -nodisp -probesize 32 -f s16le -ar 22050 -#backchannel=1#audio=s16le/22050 + +wyoming: + wyoming_mic_external: + listen: :10600 + mode: mic + wyoming_snd_external: + listen: :10601 + mode: snd +``` + +**docker-compose.yml** + +```yaml +version: "3.8" +services: + satellite: + build: wyoming-satellite # https://github.com/rhasspy/wyoming-satellite + ports: + - "10700:10700" + command: + - "--name" + - "my satellite" + - "--mic-uri" + - "tcp://192.168.1.23:10600" + - "--snd-uri" + - "tcp://192.168.1.23:10601" + - "--debug" +``` + +## Wyoming External Source + +**go2rtc.yaml** + +```yaml +streams: + wyoming_external: + - wyoming://192.168.1.23:10600 + - wyoming://192.168.1.23:10601?backchannel=1 +``` + +**docker-compose.yml** + +```yaml +version: "3.8" +services: + microphone: + build: wyoming-mic-external # https://github.com/rhasspy/wyoming-mic-external + ports: + - "10600:10600" + devices: + - /dev/snd:/dev/snd + group_add: + - audio + command: + - "--device" + - "sysdefault" + - "--debug" + playback: + build: wyoming-snd-external # https://github.com/rhasspy/wyoming-snd-external + ports: + - "10601:10601" + devices: + - /dev/snd:/dev/snd + group_add: + - audio + command: + - "--device" + - "sysdefault" + - "--debug" +``` + +## Debug + +```yaml +log: + wyoming: trace +``` diff --git a/installs_on_host/go2rtc/internal/wyoming/wyoming.go b/installs_on_host/go2rtc/internal/wyoming/wyoming.go new file mode 100644 index 0000000..065275c --- /dev/null +++ b/installs_on_host/go2rtc/internal/wyoming/wyoming.go @@ -0,0 +1,106 @@ +package wyoming + +import ( + "net" + + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/wyoming" + "github.com/rs/zerolog" +) + +func Init() { + streams.HandleFunc("wyoming", wyoming.Dial) + + // server + var cfg struct { + Mod map[string]struct { + Listen string `yaml:"listen"` + Name string `yaml:"name"` + Mode string `yaml:"mode"` + Event map[string]string `yaml:"event"` + WakeURI string `yaml:"wake_uri"` + VADThreshold float32 `yaml:"vad_threshold"` + } `yaml:"wyoming"` + } + app.LoadConfig(&cfg) + + log = app.GetLogger("wyoming") + + for name, cfg := range cfg.Mod { + stream := streams.Get(name) + if stream == nil { + log.Warn().Msgf("[wyoming] missing stream: %s", name) + continue + } + + if cfg.Name == "" { + cfg.Name = name + } + + srv := &wyoming.Server{ + Name: cfg.Name, + Event: cfg.Event, + VADThreshold: int16(1000 * cfg.VADThreshold), // 1.0 => 1000 + WakeURI: cfg.WakeURI, + MicHandler: func(cons core.Consumer) error { + if err := stream.AddConsumer(cons); err != nil { + return err + } + // not best solution + if i, ok := cons.(interface{ OnClose(func()) }); ok { + i.OnClose(func() { + stream.RemoveConsumer(cons) + }) + } + return nil + }, + SndHandler: func(prod core.Producer) error { + return stream.Play(prod) + }, + Trace: func(format string, v ...any) { + log.Trace().Msgf("[wyoming] "+format, v...) + }, + Error: func(format string, v ...any) { + log.Error().Msgf("[wyoming] "+format, v...) + }, + } + go serve(srv, cfg.Mode, cfg.Listen) + } +} + +var log zerolog.Logger + +func serve(srv *wyoming.Server, mode, address string) { + ln, err := net.Listen("tcp", address) + if err != nil { + log.Warn().Err(err).Msgf("[wyoming] listen") + } + + for { + conn, err := ln.Accept() + if err != nil { + return + } + + go handle(srv, mode, conn) + } +} + +func handle(srv *wyoming.Server, mode string, conn net.Conn) { + addr := conn.RemoteAddr() + + log.Trace().Msgf("[wyoming] %s connected", addr) + + switch mode { + case "mic": + srv.HandleMic(conn) + case "snd": + srv.HandleSnd(conn) + default: + srv.Handle(conn) + } + + log.Trace().Msgf("[wyoming] %s disconnected", addr) +} diff --git a/installs_on_host/go2rtc/internal/wyze/README.md b/installs_on_host/go2rtc/internal/wyze/README.md new file mode 100644 index 0000000..22b3d35 --- /dev/null +++ b/installs_on_host/go2rtc/internal/wyze/README.md @@ -0,0 +1,108 @@ +# Wyze + +[`new in v1.9.14`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.14) by [@seydx](https://github.com/seydx) + +This source allows you to stream from [Wyze](https://wyze.com/) cameras using native P2P protocol without the Wyze app or SDK. + +**Important:** + +1. **Requires Wyze account**. You need to login once via the WebUI to load your cameras. +2. **Requires firmware with DTLS**. Only cameras with DTLS-enabled firmware are supported. +3. Internet access is only needed when loading cameras from your account. After that, all streaming is local P2P. +4. Connection to the camera is local only (direct P2P to camera IP). + +**Features:** + +- H.264 and H.265 video codec support +- AAC, G.711, PCM, and Opus audio codec support +- Two-way audio (intercom) support +- Resolution switching (HD/SD) + +## Setup + +1. Get your API Key from [Wyze Developer Portal](https://support.wyze.com/hc/en-us/articles/16129834216731) +2. Go to go2rtc WebUI > Add > Wyze +3. Enter your API ID, API Key, email, and password +4. Select cameras to add - stream URLs are generated automatically + +**Example Config** + +```yaml +wyze: + user@email.com: + api_id: "your-api-id" + api_key: "your-api-key" + password: "yourpassword" # or MD5 triple-hash with "md5:" prefix + +streams: + wyze_cam: wyze://192.168.1.123?uid=WYZEUID1234567890AB&enr=xxx&mac=AABBCCDDEEFF&model=HL_CAM4&dtls=true +``` + +## Stream URL Format + +The stream URL is automatically generated when you add cameras via the WebUI: + +``` +wyze://[IP]?uid=[P2P_ID]&enr=[ENR]&mac=[MAC]&model=[MODEL]&subtype=[hd|sd]&dtls=true +``` + +| Parameter | Description | +|-----------|-------------------------------------------------| +| `IP` | Camera's local IP address | +| `uid` | P2P identifier (20 chars) | +| `enr` | Encryption key for DTLS | +| `mac` | Device MAC address | +| `model` | Camera model (e.g., HL_CAM4) | +| `dtls` | Enable DTLS encryption (default: true) | +| `subtype` | Camera resolution: `hd` or `sd` (default: `hd`) | + +## Configuration + +### Resolution + +You can change the camera's resolution using the `subtype` parameter: + +```yaml +streams: + wyze_hd: wyze://...&subtype=hd + wyze_sd: wyze://...&subtype=sd +``` + +### Two-Way Audio + +Two-way audio (intercom) is supported automatically. When a consumer sends audio to the stream, it will be transmitted to the camera's speaker. + +## Camera Compatibility + +| Name | Model | Firmware | Protocol | Encryption | Codecs | +|-----------------------------|----------------|--------------|----------|------------|------------| +| Wyze Cam v4 | HL_CAM4 | 4.52.9.4188 | TUTK | TransCode | h264, aac | +| | | 4.52.9.5332 | TUTK | HMAC-SHA1 | h264, aac | +| Wyze Cam v3 Pro | | | TUTK | | | +| Wyze Cam v3 | WYZE_CAKP2JFUS | 4.36.14.3497 | TUTK | TransCode | h264, pcm | +| Wyze Cam v2 | WYZEC1-JZ | 4.9.9.3006 | TUTK | TransCode | h264, pcmu | +| Wyze Cam v1 | | | TUTK | | | +| Wyze Cam Pan v4 | | | Gwell* | | | +| Wyze Cam Pan v3 | | | TUTK | | | +| Wyze Cam Pan v2 | | | TUTK | | | +| Wyze Cam Pan v1 | | | TUTK | | | +| Wyze Cam OG | | | Gwell* | | | +| Wyze Cam OG Telephoto | | | Gwell* | | | +| Wyze Cam OG (2025) | | | Gwell* | | | +| Wyze Cam Outdoor v2 | | | TUTK | | | +| Wyze Cam Outdoor v1 | | | TUTK | | | +| Wyze Cam Floodlight Pro | | | ? | | | +| Wyze Cam Floodlight v2 | | | TUTK | | | +| Wyze Cam Floodlight | | | TUTK | | | +| Wyze Video Doorbell v2 | HL_DB2 | 4.51.3.4992 | TUTK | TransCode | h264, pcm | +| Wyze Video Doorbell v1 | | | TUTK | | | +| Wyze Video Doorbell Pro | | | ? | | | +| Wyze Battery Video Doorbell | | | ? | | | +| Wyze Duo Cam Doorbell | | | ? | | | +| Wyze Battery Cam Pro | | | ? | | | +| Wyze Solar Cam Pan | | | ? | | | +| Wyze Duo Cam Pan | | | ? | | | +| Wyze Window Cam | | | ? | | | +| Wyze Bulb Cam | | | ? | | | + +_* Gwell based protocols are not yet supported._ diff --git a/installs_on_host/go2rtc/internal/wyze/wyze.go b/installs_on_host/go2rtc/internal/wyze/wyze.go new file mode 100644 index 0000000..982a16e --- /dev/null +++ b/installs_on_host/go2rtc/internal/wyze/wyze.go @@ -0,0 +1,202 @@ +package wyze + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/wyze" +) + +type AccountConfig struct { + APIKey string `yaml:"api_key"` + APIID string `yaml:"api_id"` + Password string `yaml:"password"` +} + +var accounts map[string]AccountConfig + +func Init() { + var v struct { + Cfg map[string]AccountConfig `yaml:"wyze"` + } + app.LoadConfig(&v) + + accounts = v.Cfg + + log := app.GetLogger("wyze") + + streams.HandleFunc("wyze", func(rawURL string) (core.Producer, error) { + log.Debug().Msgf("wyze: dial %s", rawURL) + return wyze.NewProducer(rawURL) + }) + + api.HandleFunc("api/wyze", apiWyze) +} + +func getCloud(email string) (*wyze.Cloud, error) { + cfg, ok := accounts[email] + if !ok { + return nil, fmt.Errorf("wyze: account not found: %s", email) + } + + if cfg.APIKey == "" || cfg.APIID == "" { + return nil, fmt.Errorf("wyze: api_key and api_id required for account: %s", email) + } + + cloud := wyze.NewCloud(cfg.APIKey, cfg.APIID) + + if err := cloud.Login(email, cfg.Password); err != nil { + return nil, err + } + + return cloud, nil +} + +func apiWyze(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + apiDeviceList(w, r) + case "POST": + apiAuth(w, r) + } +} + +func apiDeviceList(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + + email := query.Get("id") + if email == "" { + accountList := make([]string, 0, len(accounts)) + for id := range accounts { + accountList = append(accountList, id) + } + api.ResponseJSON(w, accountList) + return + } + + err := func() error { + cloud, err := getCloud(email) + if err != nil { + return err + } + + cameras, err := cloud.GetCameraList() + if err != nil { + return err + } + + var items []*api.Source + for _, cam := range cameras { + items = append(items, &api.Source{ + Name: cam.Nickname, + Info: fmt.Sprintf("%s | %s | %s", cam.ProductModel, cam.MAC, cam.IP), + URL: buildStreamURL(cam), + }) + } + + api.ResponseSources(w, items) + return nil + }() + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +func apiAuth(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + email := r.Form.Get("email") + password := r.Form.Get("password") + apiKey := r.Form.Get("api_key") + apiID := r.Form.Get("api_id") + + if email == "" || password == "" || apiKey == "" || apiID == "" { + http.Error(w, "email, password, api_key and api_id required", http.StatusBadRequest) + return + } + + // Try to login + cloud := wyze.NewCloud(apiKey, apiID) + + if err := cloud.Login(email, password); err != nil { + // Check for MFA error + var authErr *wyze.AuthError + if ok := isAuthError(err, &authErr); ok { + w.Header().Set("Content-Type", api.MimeJSON) + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(authErr) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + cfg := map[string]string{ + "password": password, + "api_key": apiKey, + "api_id": apiID, + } + + if err := app.PatchConfig([]string{"wyze", email}, cfg); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if accounts == nil { + accounts = make(map[string]AccountConfig) + } + accounts[email] = AccountConfig{ + APIKey: apiKey, + APIID: apiID, + Password: password, + } + + cameras, err := cloud.GetCameraList() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + var items []*api.Source + for _, cam := range cameras { + items = append(items, &api.Source{ + Name: cam.Nickname, + Info: fmt.Sprintf("%s | %s | %s", cam.ProductModel, cam.MAC, cam.IP), + URL: buildStreamURL(cam), + }) + } + + api.ResponseSources(w, items) +} + +func buildStreamURL(cam *wyze.Camera) string { + query := url.Values{} + query.Set("uid", cam.P2PID) + query.Set("enr", cam.ENR) + query.Set("mac", cam.MAC) + query.Set("model", cam.ProductModel) + + if cam.DTLS == 1 { + query.Set("dtls", "true") + } + + return fmt.Sprintf("wyze://%s?%s", cam.IP, query.Encode()) +} + +func isAuthError(err error, target **wyze.AuthError) bool { + if e, ok := err.(*wyze.AuthError); ok { + *target = e + return true + } + return false +} diff --git a/installs_on_host/go2rtc/internal/xiaomi/README.md b/installs_on_host/go2rtc/internal/xiaomi/README.md new file mode 100644 index 0000000..330183e --- /dev/null +++ b/installs_on_host/go2rtc/internal/xiaomi/README.md @@ -0,0 +1,64 @@ +# Xiaomi Mi Home + +[`new in v1.9.13`](https://github.com/AlexxIT/go2rtc/releases/tag/v1.9.13) + +This source allows you to view cameras from the [Xiaomi Mi Home](https://home.mi.com/) ecosystem. + +Since 2020, Xiaomi has introduced a unified protocol for cameras called `miss`. I think it means **Mi Secure Streaming**. Until this point, the camera protocols were in chaos. Almost every model had different authorization, encryption, command lists, and media packet formats. + +go2rtc supports two formats: `xiaomi/mess` and `xiaomi/legacy`. +And multiple P2P protocols: `cs2+udp`, `cs2+tcp`, several versions of `tutk+udp`. + +Almost all cameras in the `xiaomi/mess` format and the `cs2` protocol work well. +Older `xiaomi/legacy` format cameras may have support issues. +The `tutk` protocol is the worst thing that's ever happened to the P2P world. It works terribly. + +**Important:** + +1. **Not all cameras are supported**. The list of supported cameras is collected in [this issue](https://github.com/AlexxIT/go2rtc/issues/1982). +2. Each time you connect to the camera, you need Internet access to obtain encryption keys. +3. Connection to the camera is local only. + +**Features:** + +- Multiple Xiaomi accounts supported +- Cameras from multiple regions are supported for a single account +- Two-way audio is supported +- Cameras with multiple lenses are supported + +## Setup + +1. Go to go2rtc WebUI > Add > Xiaomi > Login with username and password +2. Receive verification code by email or phone if required. +3. Complete the captcha if required. +4. If everything is OK, your account will be added, and you can load cameras from it. + +**Example** + +```yaml +xiaomi: + 1234567890: V1:*** + +streams: + xiaomi1: xiaomi://1234567890:cn@192.168.1.123?did=9876543210&model=isa.camera.hlc7 +``` + +## Configuration + +Quality in the `miss` protocol is specified by a number from 0 to 5. Usually 0 means auto, 1 - sd, 2 - hd. +Go2rtc by default sets quality to 2. But some new cameras have HD quality at number 3. +Old cameras may have broken codec settings at number 3, so this number should not be set for all cameras. + +You can change camera quality: `subtype=hd/sd/auto/0-5`. + +```yaml +streams: + xiaomi1: xiaomi://***&subtype=sd +``` + +You can use a second channel for dual cameras: `channel=2`. + +```yaml +streams: + xiaomi1: xiaomi://***&channel=2 +``` diff --git a/installs_on_host/go2rtc/internal/xiaomi/xiaomi.go b/installs_on_host/go2rtc/internal/xiaomi/xiaomi.go new file mode 100644 index 0000000..1e57842 --- /dev/null +++ b/installs_on_host/go2rtc/internal/xiaomi/xiaomi.go @@ -0,0 +1,362 @@ +package xiaomi + +import ( + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/xiaomi" + "github.com/AlexxIT/go2rtc/pkg/xiaomi/crypto" + "github.com/rs/zerolog" +) + +func Init() { + var v struct { + Cfg map[string]string `yaml:"xiaomi"` + } + app.LoadConfig(&v) + + tokens = v.Cfg + + log = app.GetLogger("xiaomi") + + streams.HandleFunc("xiaomi", func(rawURL string) (core.Producer, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + if u.User != nil { + rawURL, err = getCameraURL(u) + if err != nil { + return nil, err + } + } + + log.Debug().Msgf("xiaomi: dial %s", rawURL) + + return xiaomi.Dial(rawURL) + }) + + api.HandleFunc("api/xiaomi", apiXiaomi) +} + +var log zerolog.Logger + +var tokens map[string]string +var clouds map[string]*xiaomi.Cloud +var cloudsMu sync.Mutex + +func getCloud(userID string) (*xiaomi.Cloud, error) { + cloudsMu.Lock() + defer cloudsMu.Unlock() + + if cloud := clouds[userID]; cloud != nil { + return cloud, nil + } + + cloud := xiaomi.NewCloud(AppXiaomiHome) + if err := cloud.LoginWithToken(userID, tokens[userID]); err != nil { + return nil, err + } + if clouds == nil { + clouds = map[string]*xiaomi.Cloud{userID: cloud} + } else { + clouds[userID] = cloud + } + return cloud, nil +} + +func cloudRequest(userID, region, apiURL, params string) ([]byte, error) { + cloud, err := getCloud(userID) + if err != nil { + return nil, err + } + return cloud.Request(GetBaseURL(region), apiURL, params, nil) +} + +func cloudUserRequest(user *url.Userinfo, apiURL, params string) ([]byte, error) { + userID := user.Username() + region, _ := user.Password() + return cloudRequest(userID, region, apiURL, params) +} + +func getCameraURL(url *url.URL) (string, error) { + model := url.Query().Get("model") + + // It is not known which models need to be awakened. + // Probably all the doorbells and all the battery cameras. + if strings.Contains(model, ".cateye.") { + _ = wakeUpCamera(url) + } + + // The getMissURL request has a fallback to getP2PURL. + // But for known models we can save one request to the cloud. + if xiaomi.IsLegacy(model) { + return getLegacyURL(url) + } + return getMissURL(url) +} + +func getLegacyURL(url *url.URL) (string, error) { + query := url.Query() + + clientPublic, clientPrivate, err := crypto.GenerateKey() + if err != nil { + return "", err + } + + params := fmt.Sprintf(`{"did":"%s","toSignAppData":"%x"}`, query.Get("did"), clientPublic) + + userID := url.User.Username() + region, _ := url.User.Password() + res, err := cloudRequest(userID, region, "/device/devicepass", params) + if err != nil { + return "", err + } + + var v struct { + UID string `json:"p2p_id"` + Password string `json:"password"` + PublicKey string `json:"p2p_dev_public_key"` + Sign string `json:"signForAppData"` + } + if err = json.Unmarshal(res, &v); err != nil { + return "", err + } + + query.Set("uid", v.UID) + + if v.Sign != "" { + query.Set("client_public", hex.EncodeToString(clientPublic)) + query.Set("client_private", hex.EncodeToString(clientPrivate)) + query.Set("device_public", v.PublicKey) + query.Set("sign", v.Sign) + } else { + query.Set("password", v.Password) + } + + url.RawQuery = query.Encode() + return url.String(), nil +} + +func getMissURL(url *url.URL) (string, error) { + clientPublic, clientPrivate, err := crypto.GenerateKey() + if err != nil { + return "", err + } + + query := url.Query() + params := fmt.Sprintf( + `{"app_pubkey":"%x","did":"%s","support_vendors":"TUTK_CS2_MTP"}`, + clientPublic, query.Get("did"), + ) + + res, err := cloudUserRequest(url.User, "/v2/device/miss_get_vendor", params) + if err != nil { + if strings.Contains(err.Error(), "no available vendor support") { + return getLegacyURL(url) + } + return "", err + } + + var v struct { + Vendor struct { + ID byte `json:"vendor"` + Params struct { + UID string `json:"p2p_id"` + } `json:"vendor_params"` + } `json:"vendor"` + PublicKey string `json:"public_key"` + Sign string `json:"sign"` + } + if err = json.Unmarshal(res, &v); err != nil { + return "", err + } + + query.Set("client_public", hex.EncodeToString(clientPublic)) + query.Set("client_private", hex.EncodeToString(clientPrivate)) + query.Set("device_public", v.PublicKey) + query.Set("sign", v.Sign) + query.Set("vendor", getVendorName(v.Vendor.ID)) + + if v.Vendor.ID == 1 { + query.Set("uid", v.Vendor.Params.UID) + } + + url.RawQuery = query.Encode() + return url.String(), nil +} + +func getVendorName(i byte) string { + switch i { + case 1: + return "tutk" + case 3: + return "agora" + case 4: + return "cs2" + case 6: + return "mtp" + } + return fmt.Sprintf("%d", i) +} + +func wakeUpCamera(url *url.URL) error { + const params = `{"id":1,"method":"wakeup","params":{"video":"1"}}` + did := url.Query().Get("did") + _, err := cloudUserRequest(url.User, "/home/rpc/"+did, params) + return err +} + +func apiXiaomi(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + apiDeviceList(w, r) + case "POST": + apiAuth(w, r) + } +} + +func apiDeviceList(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + + user := query.Get("id") + if user == "" { + cloudsMu.Lock() + users := make([]string, 0, len(tokens)) + for s := range tokens { + users = append(users, s) + } + cloudsMu.Unlock() + + api.ResponseJSON(w, users) + return + } + + err := func() error { + region := query.Get("region") + res, err := cloudRequest(user, region, "/v2/home/device_list_page", "{}") + if err != nil { + return err + } + var v struct { + List []*Device `json:"list"` + } + + log.Trace().Str("user", user).Msgf("[xiaomi] devices list: %s", res) + + if err = json.Unmarshal(res, &v); err != nil { + return err + } + + var items []*api.Source + + for _, device := range v.List { + if !device.HasCamera() { + continue + } + items = append(items, &api.Source{ + Name: device.Name, + Info: fmt.Sprintf("ip: %s, mac: %s", device.IP, device.MAC), + URL: fmt.Sprintf("xiaomi://%s:%s@%s?did=%s&model=%s", user, region, device.IP, device.Did, device.Model), + }) + } + + api.ResponseSources(w, items) + return nil + }() + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +type Device struct { + Did string `json:"did"` + Name string `json:"name"` + Model string `json:"model"` + MAC string `json:"mac"` + IP string `json:"localip"` +} + +func (d *Device) HasCamera() bool { + return strings.Contains(d.Model, ".camera.") || + strings.Contains(d.Model, ".cateye.") || + strings.Contains(d.Model, ".feeder.") +} + +var auth *xiaomi.Cloud + +func apiAuth(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + username := r.Form.Get("username") + password := r.Form.Get("password") + captcha := r.Form.Get("captcha") + verify := r.Form.Get("verify") + + var err error + + switch { + case username != "" || password != "": + auth = xiaomi.NewCloud(AppXiaomiHome) + err = auth.Login(username, password) + case captcha != "": + err = auth.LoginWithCaptcha(captcha) + case verify != "": + err = auth.LoginWithVerify(verify) + default: + http.Error(w, "wrong request", http.StatusBadRequest) + return + } + + if err == nil { + userID, token := auth.UserToken() + auth = nil + + cloudsMu.Lock() + if tokens == nil { + tokens = map[string]string{userID: token} + } else { + tokens[userID] = token + } + cloudsMu.Unlock() + + err = app.PatchConfig([]string{"xiaomi", userID}, token) + } + + if err != nil { + var login *xiaomi.LoginError + if errors.As(err, &login) { + w.Header().Set("Content-Type", api.MimeJSON) + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(err) + return + } + + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +const AppXiaomiHome = "xiaomiio" + +func GetBaseURL(region string) string { + switch region { + case "de", "i2", "ru", "sg", "us": + return "https://" + region + ".api.io.mi.com/app" + } + return "https://api.io.mi.com/app" +} diff --git a/installs_on_host/go2rtc/internal/yandex/README.md b/installs_on_host/go2rtc/internal/yandex/README.md new file mode 100644 index 0000000..69d4739 --- /dev/null +++ b/installs_on_host/go2rtc/internal/yandex/README.md @@ -0,0 +1,22 @@ +# Yandex + +Source for receiving stream from new [Yandex IP camera](https://alice.yandex.ru/smart-home/security/ipcamera). + +## Get Yandex token + +1. Install HomeAssistant integration [YandexStation](https://github.com/AlexxIT/YandexStation). +2. Copy token from HomeAssistant config folder: `/config/.storage/core.config_entries`, key: `"x_token"`. + +## Get device ID + +1. Open this link in any browser: https://iot.quasar.yandex.ru/m/v3/user/devices +2. Copy ID of your camera, key: `"id"`. + +## Configuration + +```yaml +streams: + yandex_stream: yandex:?x_token=XXXX&device_id=XXXX + yandex_snapshot: yandex:?x_token=XXXX&device_id=XXXX&snapshot + yandex_snapshot_custom_size: yandex:?x_token=XXXX&device_id=XXXX&snapshot=h=540 +``` diff --git a/installs_on_host/go2rtc/internal/yandex/goloom.go b/installs_on_host/go2rtc/internal/yandex/goloom.go new file mode 100644 index 0000000..6bccb75 --- /dev/null +++ b/installs_on_host/go2rtc/internal/yandex/goloom.go @@ -0,0 +1,152 @@ +package yandex + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/AlexxIT/go2rtc/internal/webrtc" + "github.com/AlexxIT/go2rtc/pkg/core" + xwebrtc "github.com/AlexxIT/go2rtc/pkg/webrtc" + "github.com/google/uuid" + "github.com/gorilla/websocket" + pion "github.com/pion/webrtc/v4" +) + +func goloomClient(serviceURL, serviceName, roomId, participantId, credentials string) (core.Producer, error) { + conn, _, err := websocket.DefaultDialer.Dial(serviceURL, nil) + if err != nil { + return nil, err + } + defer func() { + time.Sleep(time.Second) + _ = conn.Close() + }() + + s := fmt.Sprintf(`{"hello": { +"credentials":"%s","participantId":"%s","roomId":"%s","serviceName":"%s","sdkInitializationId":"%s", +"capabilitiesOffer":{},"sendAudio":false,"sendSharing":false,"sendVideo":false, +"sdkInfo":{"hwConcurrency":4,"implementation":"browser","version":"5.4.0"}, +"participantAttributes":{"description":"","name":"mike","role":"SPEAKER"}, +"participantMeta":{"description":"","name":"mike","role":"SPEAKER","sendAudio":false,"sendVideo":false} +},"uid":"%s"}`, + credentials, participantId, roomId, serviceName, + uuid.NewString(), uuid.NewString(), + ) + + err = conn.WriteMessage(websocket.TextMessage, []byte(s)) + if err != nil { + return nil, err + } + + if _, _, err = conn.ReadMessage(); err != nil { + return nil, err + } + + pc, err := webrtc.PeerConnection(true) + if err != nil { + return nil, err + } + + prod := xwebrtc.NewConn(pc) + prod.FormatName = "yandex" + prod.Mode = core.ModeActiveProducer + prod.Protocol = "wss" + + var connState core.Waiter + + prod.Listen(func(msg any) { + switch msg := msg.(type) { + case pion.PeerConnectionState: + switch msg { + case pion.PeerConnectionStateConnecting: + case pion.PeerConnectionStateConnected: + connState.Done(nil) + default: + connState.Done(errors.New("webrtc: " + msg.String())) + } + } + }) + + go func() { + for { + var msg map[string]json.RawMessage + if err = conn.ReadJSON(&msg); err != nil { + return + } + + for k, v := range msg { + switch k { + case "uid": + continue + case "serverHello": + case "subscriberSdpOffer": + var sdp subscriberSdp + if err = json.Unmarshal(v, &sdp); err != nil { + return + } + //log.Trace().Msgf("offer:\n%s", sdp.Sdp) + if err = prod.SetOffer(sdp.Sdp); err != nil { + return + } + if sdp.Sdp, err = prod.GetAnswer(); err != nil { + return + } + //log.Trace().Msgf("answer:\n%s", sdp.Sdp) + + var raw []byte + if raw, err = json.Marshal(sdp); err != nil { + return + } + s = fmt.Sprintf(`{"uid":"%s","subscriberSdpAnswer":%s}`, uuid.NewString(), raw) + if err = conn.WriteMessage(websocket.TextMessage, []byte(s)); err != nil { + return + } + case "webrtcIceCandidate": + var candidate webrtcIceCandidate + if err = json.Unmarshal(v, &candidate); err != nil { + return + } + if err = prod.AddCandidate(candidate.Candidate); err != nil { + return + } + } + //log.Trace().Msgf("%s : %s", k, v) + } + + if msg["ack"] != nil { + continue + } + + s = fmt.Sprintf(`{"uid":%s,"ack":{"status":{"code":"OK"}}}`, msg["uid"]) + if err = conn.WriteMessage(websocket.TextMessage, []byte(s)); err != nil { + return + } + } + }() + + if err = connState.Wait(); err != nil { + return nil, err + } + + s = fmt.Sprintf(`{"uid":"%s","setSlots":{"slots":[{"width":0,"height":0}],"audioSlotsCount":0,"key":1,"shutdownAllVideo":false,"withSelfView":false,"selfViewVisibility":"ON_LOADING_THEN_HIDE","gridConfig":{}}}`, uuid.NewString()) + if err = conn.WriteMessage(websocket.TextMessage, []byte(s)); err != nil { + return nil, err + } + + return prod, nil +} + +type subscriberSdp struct { + PcSeq int `json:"pcSeq"` + Sdp string `json:"sdp"` +} + +type webrtcIceCandidate struct { + PcSeq int `json:"pcSeq"` + Target string `json:"target"` + Candidate string `json:"candidate"` + SdpMid string `json:"sdpMid"` + SdpMlineIndex int `json:"sdpMlineIndex"` +} diff --git a/installs_on_host/go2rtc/internal/yandex/yandex.go b/installs_on_host/go2rtc/internal/yandex/yandex.go new file mode 100644 index 0000000..05680b3 --- /dev/null +++ b/installs_on_host/go2rtc/internal/yandex/yandex.go @@ -0,0 +1,44 @@ +package yandex + +import ( + "net/url" + + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/yandex" +) + +func Init() { + streams.HandleFunc("yandex", func(source string) (core.Producer, error) { + u, err := url.Parse(source) + if err != nil { + return nil, err + } + + query := u.Query() + token := query.Get("x_token") + + session, err := yandex.GetSession(token) + if err != nil { + return nil, err + } + + deviceID := query.Get("device_id") + + if query.Has("snapshot") { + rawURL, err := session.GetSnapshotURL(deviceID) + if err != nil { + return nil, err + } + rawURL += "/current.jpg?" + query.Get("snapshot") + "#header=Cookie:" + session.GetCookieString(rawURL) + return streams.GetProducer(rawURL) + } + + room, err := session.WebrtcCreateRoom(deviceID) + if err != nil { + return nil, err + } + + return goloomClient(room.ServiceUrl, room.ServiceName, room.RoomId, room.ParticipantId, room.Credentials) + }) +} diff --git a/installs_on_host/go2rtc/main.go b/installs_on_host/go2rtc/main.go new file mode 100644 index 0000000..00c059e --- /dev/null +++ b/installs_on_host/go2rtc/main.go @@ -0,0 +1,125 @@ +package main + +import ( + "slices" + + "github.com/AlexxIT/go2rtc/internal/alsa" + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/api/ws" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/bubble" + "github.com/AlexxIT/go2rtc/internal/debug" + "github.com/AlexxIT/go2rtc/internal/doorbird" + "github.com/AlexxIT/go2rtc/internal/dvrip" + "github.com/AlexxIT/go2rtc/internal/echo" + "github.com/AlexxIT/go2rtc/internal/eseecloud" + "github.com/AlexxIT/go2rtc/internal/exec" + "github.com/AlexxIT/go2rtc/internal/expr" + "github.com/AlexxIT/go2rtc/internal/ffmpeg" + "github.com/AlexxIT/go2rtc/internal/flussonic" + "github.com/AlexxIT/go2rtc/internal/gopro" + "github.com/AlexxIT/go2rtc/internal/hass" + "github.com/AlexxIT/go2rtc/internal/hls" + "github.com/AlexxIT/go2rtc/internal/homekit" + "github.com/AlexxIT/go2rtc/internal/http" + "github.com/AlexxIT/go2rtc/internal/isapi" + "github.com/AlexxIT/go2rtc/internal/ivideon" + "github.com/AlexxIT/go2rtc/internal/kasa" + "github.com/AlexxIT/go2rtc/internal/mjpeg" + "github.com/AlexxIT/go2rtc/internal/mp4" + "github.com/AlexxIT/go2rtc/internal/mpeg" + "github.com/AlexxIT/go2rtc/internal/multitrans" + "github.com/AlexxIT/go2rtc/internal/nest" + "github.com/AlexxIT/go2rtc/internal/ngrok" + "github.com/AlexxIT/go2rtc/internal/onvif" + "github.com/AlexxIT/go2rtc/internal/pinggy" + "github.com/AlexxIT/go2rtc/internal/ring" + "github.com/AlexxIT/go2rtc/internal/roborock" + "github.com/AlexxIT/go2rtc/internal/rtmp" + "github.com/AlexxIT/go2rtc/internal/rtsp" + "github.com/AlexxIT/go2rtc/internal/srtp" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/internal/tapo" + "github.com/AlexxIT/go2rtc/internal/tuya" + "github.com/AlexxIT/go2rtc/internal/v4l2" + "github.com/AlexxIT/go2rtc/internal/webrtc" + "github.com/AlexxIT/go2rtc/internal/webtorrent" + "github.com/AlexxIT/go2rtc/internal/wyoming" + "github.com/AlexxIT/go2rtc/internal/wyze" + "github.com/AlexxIT/go2rtc/internal/xiaomi" + "github.com/AlexxIT/go2rtc/internal/yandex" + "github.com/AlexxIT/go2rtc/pkg/shell" +) + +func main() { + // version will be set later from -buildvcs info, this used only as fallback + app.Version = "1.9.14" + + type module struct { + name string + init func() + } + + modules := []module{ + {"", app.Init}, // init config and logs + {"api", api.Init}, // init API before all others + {"ws", ws.Init}, // init WS API endpoint + {"", streams.Init}, + // Main sources and servers + {"http", http.Init}, // rtsp source, HTTP server + {"rtsp", rtsp.Init}, // rtsp source, RTSP server + {"webrtc", webrtc.Init}, // webrtc source, WebRTC server + // Main API + {"mp4", mp4.Init}, // MP4 API + {"hls", hls.Init}, // HLS API + {"mjpeg", mjpeg.Init}, // MJPEG API + // Other sources and servers + {"hass", hass.Init}, // hass source, Hass API server + {"homekit", homekit.Init}, // homekit source, HomeKit server + {"onvif", onvif.Init}, // onvif source, ONVIF API server + {"rtmp", rtmp.Init}, // rtmp source, RTMP server + {"webtorrent", webtorrent.Init}, // webtorrent source, WebTorrent module + {"wyoming", wyoming.Init}, + // Exec and script sources + {"echo", echo.Init}, + {"exec", exec.Init}, + {"expr", expr.Init}, + {"ffmpeg", ffmpeg.Init}, + // Hardware sources + {"alsa", alsa.Init}, + {"v4l2", v4l2.Init}, + // Other sources + {"bubble", bubble.Init}, + {"doorbird", doorbird.Init}, + {"dvrip", dvrip.Init}, + {"eseecloud", eseecloud.Init}, + {"flussonic", flussonic.Init}, + {"gopro", gopro.Init}, + {"isapi", isapi.Init}, + {"ivideon", ivideon.Init}, + {"kasa", kasa.Init}, + {"mpegts", mpeg.Init}, + {"multitrans", multitrans.Init}, + {"nest", nest.Init}, + {"ring", ring.Init}, + {"roborock", roborock.Init}, + {"tapo", tapo.Init}, + {"tuya", tuya.Init}, + {"wyze", wyze.Init}, + {"xiaomi", xiaomi.Init}, + {"yandex", yandex.Init}, + // Helper modules + {"debug", debug.Init}, + {"ngrok", ngrok.Init}, + {"pinggy", pinggy.Init}, + {"srtp", srtp.Init}, + } + + for _, m := range modules { + if app.Modules == nil || m.name == "" || slices.Contains(app.Modules, m.name) { + m.init() + } + } + + shell.RunUntilSignal() +} diff --git a/installs_on_host/go2rtc/package.json b/installs_on_host/go2rtc/package.json new file mode 100644 index 0000000..03377f4 --- /dev/null +++ b/installs_on_host/go2rtc/package.json @@ -0,0 +1,47 @@ +{ + "devDependencies": { + "@types/node": "^25.2.0", + "eslint": "^9.39.2", + "eslint-plugin-html": "^8.1.4", + "vitepress": "^2.0.0-alpha.16" + }, + "scripts": { + "docs:dev": "vitepress dev website --host", + "docs:build": "vitepress build website", + "docs:preview": "vitepress preview website" + }, + "eslintConfig": { + "env": { + "browser": true, + "es6": true + }, + "parserOptions": { + "ecmaVersion": 2017, + "sourceType": "module" + }, + "rules": { + "no-var": "error", + "no-undef": "error", + "no-unused-vars": "warn", + "prefer-const": "error", + "quotes": [ + "error", + "single" + ], + "semi": "error" + }, + "plugins": [ + "html" + ], + "overrides": [ + { + "files": [ + "*.html" + ], + "parserOptions": { + "sourceType": "script" + } + } + ] + } +} diff --git a/installs_on_host/go2rtc/pkg/README.md b/installs_on_host/go2rtc/pkg/README.md new file mode 100644 index 0000000..89c1aa6 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/README.md @@ -0,0 +1,114 @@ +# Notes + +go2rtc tries to name formats, protocols and codecs the same way they are named in FFmpeg. +Some formats and protocols go2rtc supports exclusively. They have no equivalent in FFmpeg. + +## Producers (input) + +- The initiator of the connection can be go2rtc - **Source protocols** +- The initiator of the connection can be an external program - **Ingress protocols** +- Codecs can be incoming - **Receiver codecs** +- Codecs can be outgoing (two way audio) - **Sender codecs** + +| Group | Format | Protocols | Ingress | Receiver codecs | Sender codecs | Example | +|------------|--------------|-----------------|---------|---------------------------------|---------------------|---------------| +| Devices | alsa | pipe | | | pcm | `alsa:` | +| Devices | v4l2 | pipe | | | | `v4l2:` | +| Files | adts | http, tcp, pipe | http | aac | | `http:` | +| Files | flv | http, tcp, pipe | http | h264, aac | | `http:` | +| Files | h264 | http, tcp, pipe | http | h264 | | `http:` | +| Files | hevc | http, tcp, pipe | http | hevc | | `http:` | +| Files | hls | http | | h264, h265, aac, opus | | `http:` | +| Files | mjpeg | http, tcp, pipe | http | mjpeg | | `http:` | +| Files | mpegts | http, tcp, pipe | http | h264, hevc, aac, opus | | `http:` | +| Files | wav | http, tcp, pipe | http | pcm_alaw, pcm_mulaw | | `http:` | +| Net (pub) | mpjpeg | http, tcp, pipe | http | mjpeg | | `http:` | +| Net (pub) | onvif | rtsp | | | | `onvif:` | +| Net (pub) | rtmp | rtmp | rtmp | h264, aac | | `rtmp:` | +| Net (pub) | rtsp | rtsp, ws | rtsp | h264, hevc, aac, pcm*, opus | pcm*, opus | `rtsp:` | +| Net (pub) | webrtc* | webrtc | webrtc | h264, pcm_alaw, pcm_mulaw, opus | pcm_alaw, pcm_mulaw | `webrtc:` | +| Net (pub) | yuv4mpegpipe | http, tcp, pipe | http | rawvideo | | `http:` | +| Net (priv) | bubble | http | | h264, hevc, pcm_alaw | | `bubble:` | +| Net (priv) | doorbird | http | | | | `doorbird:` | +| Net (priv) | dvrip | tcp | | h264, hevc, pcm_alaw, pcm_mulaw | pcm_alaw | `dvrip:` | +| Net (priv) | eseecloud | http | | | | `eseecloud:` | +| Net (priv) | gopro | udp | | TODO | | `gopro:` | +| Net (priv) | hass | webrtc | | TODO | | `hass:` | +| Net (priv) | homekit | hap | | h264, eld* | | `homekit:` | +| Net (priv) | isapi | http | | | pcm_alaw, pcm_mulaw | `isapi:` | +| Net (priv) | kasa | http | | h264, pcm_mulaw | | `kasa:` | +| Net (priv) | nest | rtsp, webrtc | | TODO | | `nest:` | +| Net (priv) | ring | webrtc | | | | `ring:` | +| Net (priv) | roborock | webrtc | | h264, opus | opus | `roborock:` | +| Net (priv) | tapo | http | | h264, pcma | pcm_alaw | `tapo:` | +| Net (priv) | tuya | webrtc | | | | `tuya:` | +| Net (priv) | vigi | http | | | | `vigi:` | +| Net (priv) | webtorrent | webrtc | TODO | TODO | TODO | `webtorrent:` | +| Net (priv) | xiaomi* | cs2, tutk | | | | `xiaomi:` | +| Services | flussonic | ws | | | | `flussonic:` | +| Services | ivideon | ws | | h264 | | `ivideon:` | +| Services | yandex | webrtc | | | | `yandex:` | +| Other | echo | * | | | | `echo:` | +| Other | exec | pipe, rtsp | | | | `exec:` | +| Other | expr | * | | | | `expr:` | +| Other | ffmpeg | pipe, rtsp | | | | `ffmpeg:` | +| Other | stdin | pipe | | | pcm_alaw, pcm_mulaw | `stdin:` | + +- **eld** - rare variant of aac codec +- **pcm** - pcm_alaw pcm_mulaw pcm_s16be pcm_s16le +- **webrtc** - webrtc/kinesis, webrtc/openipc, webrtc/milestone, webrtc/wyze, webrtc/whep + +## Consumers (output) + +| Format | Protocol | Send codecs | Recv codecs | Example | +|--------------|----------|---------------------------------|---------------------------|---------------------------------------| +| adts | http | aac | | `GET /api/stream.adts` | +| ascii | http | mjpeg | | `GET /api/stream.ascii` | +| flv | http | h264, aac | | `GET /api/stream.flv` | +| hls/mpegts | http | h264, hevc, aac | | `GET /api/stream.m3u8` | +| hls/fmp4 | http | h264, hevc, aac, pcm*, opus | | `GET /api/stream.m3u8?mp4` | +| homekit | hap | h264, opus | | Apple HomeKit app | +| mjpeg | ws | mjpeg | | `{"type":"mjpeg"}` -> `/api/ws` | +| mpjpeg | http | mjpeg | | `GET /api/stream.mjpeg` | +| mp4 | http | h264, hevc, aac, pcm*, opus | | `GET /api/stream.mp4` | +| mse/fmp4 | ws | h264, hevc, aac, pcm*, opus | | `{"type":"mse"}` -> `/api/ws` | +| mpegts | http | h264, hevc, aac | | `GET /api/stream.ts` | +| rtmp | rtmp | h264, aac | | `rtmp://localhost:1935/{stream_name}` | +| rtsp | rtsp | h264, hevc, aac, pcm*, opus | | `rtsp://localhost:8554/{stream_name}` | +| webrtc | webrtc | h264, pcm_alaw, pcm_mulaw, opus | pcm_alaw, pcm_mulaw, opus | `{"type":"webrtc"}` -> `/api/ws` | +| yuv4mpegpipe | http | rawvideo | | `GET /api/stream.y4m` | + +- **pcm** - pcm_alaw pcm_mulaw pcm_s16be pcm_s16le + +## Snapshots + +| Format | Protocol | Send codecs | Example | +|--------|----------|-------------|-----------------------| +| jpeg | http | mjpeg | `GET /api/frame.jpeg` | +| mp4 | http | h264,hevc | `GET /api/frame.mp4` | + +## Developers + +**File naming:** + +- `pkg/{format}/producer.go` - producer for this format (also if support backchannel) +- `pkg/{format}/consumer.go` - consumer for this format +- `pkg/{format}/backchannel.go` - producer with only backchannel func + +**Mentioning modules:** + +- [`main.go`](../main.go) +- [`README.md`](../README.md) +- [`internal/README.md`](../internal/README.md) +- [`website/.vitepress/config.js`](../website/.vitepress/config.js) +- [`website/api/openapi.yaml`](../website/api/openapi.yaml) +- [`www/schema.json`](../www/schema.json) + +## Useful links + +- https://www.wowza.com/blog/streaming-protocols +- https://vimeo.com/blog/post/rtmp-stream/ +- https://sanjeev-pandey.medium.com/understanding-the-mpeg-4-moov-atom-pseudo-streaming-in-mp4-93935e1b9e9a +- [Android Supported media formats](https://developer.android.com/guide/topics/media/media-formats) +- [THEOplayer](https://www.theoplayer.com/test-your-stream-hls-dash-hesp) +- [How Generate DTS/PTS](https://www.ramugedia.com/how-generate-dts-pts-from-elementary-stream) diff --git a/installs_on_host/go2rtc/pkg/aac/README.md b/installs_on_host/go2rtc/pkg/aac/README.md new file mode 100644 index 0000000..8e56a9a --- /dev/null +++ b/installs_on_host/go2rtc/pkg/aac/README.md @@ -0,0 +1,20 @@ +## AAC-LD and AAC-ELD + +| Codec | Rate | QuickTime | ffmpeg | VLC | +|---------|-------|-----------|--------|-----| +| AAC-LD | 8000 | yes | no | no | +| AAC-LD | 16000 | yes | no | no | +| AAC-LD | 22050 | yes | yes | no | +| AAC-LD | 24000 | yes | yes | no | +| AAC-LD | 32000 | yes | yes | no | +| AAC-ELD | 8000 | yes | no | no | +| AAC-ELD | 16000 | yes | no | no | +| AAC-ELD | 22050 | yes | yes | yes | +| AAC-ELD | 24000 | yes | yes | yes | +| AAC-ELD | 32000 | yes | yes | yes | + +## Useful links + +- [4.6.20 Enhanced Low Delay Codec](https://csclub.uwaterloo.ca/~ehashman/ISO14496-3-2009.pdf) +- https://stackoverflow.com/questions/40014508/aac-adts-for-aacobject-eld-packets +- https://code.videolan.org/videolan/vlc/-/blob/master/modules/packetizer/mpeg4audio.c diff --git a/installs_on_host/go2rtc/pkg/aac/aac.go b/installs_on_host/go2rtc/pkg/aac/aac.go new file mode 100644 index 0000000..dc961fc --- /dev/null +++ b/installs_on_host/go2rtc/pkg/aac/aac.go @@ -0,0 +1,126 @@ +package aac + +import ( + "encoding/hex" + "fmt" + + "github.com/AlexxIT/go2rtc/pkg/bits" + "github.com/AlexxIT/go2rtc/pkg/core" +) + +const ( + TypeAACMain = 1 + TypeAACLC = 2 // Low Complexity + TypeAACLD = 23 // Low Delay (48000, 44100, 32000, 24000, 22050) + TypeESCAPE = 31 + TypeAACELD = 39 // Enhanced Low Delay + + AUTime = 1024 + + // FMTP streamtype=5 - audio stream + FMTP = "streamtype=5;profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3;config=" +) + +var sampleRates = [16]uint32{ + 96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350, + 0, 0, 0, // protection from request sampleRates[15] +} + +func ConfigToCodec(conf []byte) *core.Codec { + // https://en.wikipedia.org/wiki/MPEG-4_Part_3#MPEG-4_Audio_Object_Types + rd := bits.NewReader(conf) + + codec := &core.Codec{ + FmtpLine: FMTP + hex.EncodeToString(conf), + PayloadType: core.PayloadTypeRAW, + } + + objType := rd.ReadBits(5) + if objType == TypeESCAPE { + objType = 32 + rd.ReadBits(6) + } + + switch objType { + case TypeAACLC, TypeAACLD, TypeAACELD: + codec.Name = core.CodecAAC + default: + codec.Name = fmt.Sprintf("AAC-%X", objType) + } + + if sampleRateIdx := rd.ReadBits8(4); sampleRateIdx < 0x0F { + codec.ClockRate = sampleRates[sampleRateIdx] + } else { + codec.ClockRate = rd.ReadBits(24) + } + + codec.Channels = rd.ReadBits8(4) + + return codec +} + +func DecodeConfig(b []byte) (objType, sampleFreqIdx, channels byte, sampleRate uint32) { + rd := bits.NewReader(b) + + objType = rd.ReadBits8(5) + if objType == 0b11111 { + objType = 32 + rd.ReadBits8(6) + } + + sampleFreqIdx = rd.ReadBits8(4) + if sampleFreqIdx == 0b1111 { + sampleRate = rd.ReadBits(24) + } else { + sampleRate = sampleRates[sampleFreqIdx] + } + + channels = rd.ReadBits8(4) + return +} + +func EncodeConfig(objType byte, sampleRate uint32, channels byte, shortFrame bool) []byte { + wr := bits.NewWriter(nil) + + if objType < TypeESCAPE { + wr.WriteBits8(objType, 5) + } else { + wr.WriteBits8(TypeESCAPE, 5) + wr.WriteBits8(objType-32, 6) + } + + i := indexUint32(sampleRates[:], sampleRate) + if i >= 0 { + wr.WriteBits8(byte(i), 4) + } else { + wr.WriteBits8(0xF, 4) + wr.WriteBits(sampleRate, 24) + } + + wr.WriteBits8(channels, 4) + + switch objType { + case TypeAACLD: + // https://github.com/FFmpeg/FFmpeg/blob/67d392b97941bb51fb7af3a3c9387f5ab895fa46/libavcodec/aacdec_template.c#L841 + wr.WriteBool(shortFrame) + wr.WriteBit(0) // dependsOnCoreCoder + wr.WriteBit(0) // extension_flag + wr.WriteBits8(0, 2) // ep_config + case TypeAACELD: + // https://github.com/FFmpeg/FFmpeg/blob/67d392b97941bb51fb7af3a3c9387f5ab895fa46/libavcodec/aacdec_template.c#L922 + wr.WriteBool(shortFrame) + wr.WriteBits8(0, 3) // res_flags + wr.WriteBit(0) // ldSbrPresentFlag + wr.WriteBits8(0, 4) // ELDEXT_TERM + wr.WriteBits8(0, 2) // ep_config + } + + return wr.Bytes() +} + +func indexUint32(s []uint32, v uint32) int { + for i := range s { + if v == s[i] { + return i + } + } + return -1 +} diff --git a/installs_on_host/go2rtc/pkg/aac/aac_test.go b/installs_on_host/go2rtc/pkg/aac/aac_test.go new file mode 100644 index 0000000..d1af6e5 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/aac/aac_test.go @@ -0,0 +1,52 @@ +package aac + +import ( + "encoding/hex" + "testing" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/stretchr/testify/require" +) + +func TestConfigToCodec(t *testing.T) { + s := "profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3;config=F8EC3000" + s = core.Between(s, "config=", ";") + src, err := hex.DecodeString(s) + require.Nil(t, err) + + codec := ConfigToCodec(src) + require.Equal(t, core.CodecAAC, codec.Name) + require.Equal(t, uint32(24000), codec.ClockRate) + require.Equal(t, uint16(1), codec.Channels) + + dst := EncodeConfig(TypeAACELD, 24000, 1, true) + require.Equal(t, src, dst) +} + +func TestADTS(t *testing.T) { + // FFmpeg MPEG-TS AAC (one packet) + s := "fff15080021ffc210049900219002380fff15080021ffc212049900219002380" //... + src, err := hex.DecodeString(s) + require.Nil(t, err) + + codec := ADTSToCodec(src) + require.Equal(t, uint32(44100), codec.ClockRate) + require.Equal(t, uint16(2), codec.Channels) + + size := ReadADTSSize(src) + require.Equal(t, uint16(16), size) + + dst := CodecToADTS(codec) + WriteADTSSize(dst, size) + + require.Equal(t, src[:len(dst)], dst) +} + +func TestEncodeConfig(t *testing.T) { + conf := EncodeConfig(TypeAACLC, 48000, 1, false) + require.Equal(t, "1188", hex.EncodeToString(conf)) + conf = EncodeConfig(TypeAACLC, 16000, 1, false) + require.Equal(t, "1408", hex.EncodeToString(conf)) + conf = EncodeConfig(TypeAACLC, 8000, 1, false) + require.Equal(t, "1588", hex.EncodeToString(conf)) +} diff --git a/installs_on_host/go2rtc/pkg/aac/adts.go b/installs_on_host/go2rtc/pkg/aac/adts.go new file mode 100644 index 0000000..140b1ba --- /dev/null +++ b/installs_on_host/go2rtc/pkg/aac/adts.go @@ -0,0 +1,148 @@ +package aac + +import ( + "encoding/hex" + + "github.com/AlexxIT/go2rtc/pkg/bits" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +const ADTSHeaderSize = 7 + +func ADTSHeaderLen(b []byte) int { + if HasCRC(b) { + return 9 // 7 bytes header + 2 bytes CRC + } + return ADTSHeaderSize +} + +func IsADTS(b []byte) bool { + // AAAAAAAA AAAABCCD EEFFFFGH HHIJKLMM MMMMMMMM MMMOOOOO OOOOOOPP (QQQQQQQQ QQQQQQQQ) + // A 12 Syncword, all bits must be set to 1. + // C 2 Layer, always set to 0. + return len(b) >= ADTSHeaderSize && b[0] == 0xFF && b[1]&0b1111_0110 == 0xF0 +} + +func HasCRC(b []byte) bool { + // AAAAAAAA AAAABCCD EEFFFFGH HHIJKLMM MMMMMMMM MMMOOOOO OOOOOOPP (QQQQQQQQ QQQQQQQQ) + // D 1 Protection absence, set to 1 if there is no CRC and 0 if there is CRC. + return b[1]&0b1 == 0 +} + +func ADTSToCodec(b []byte) *core.Codec { + // 1. Check ADTS header + if !IsADTS(b) { + return nil + } + + // 2. Decode ADTS params + // https://wiki.multimedia.cx/index.php/ADTS + rd := bits.NewReader(b) + _ = rd.ReadBits(12) // Syncword, all bits must be set to 1 + _ = rd.ReadBit() // MPEG Version, set to 0 for MPEG-4 and 1 for MPEG-2 + _ = rd.ReadBits(2) // Layer, always set to 0 + _ = rd.ReadBit() // Protection absence, set to 1 if there is no CRC and 0 if there is CRC + objType := rd.ReadBits8(2) + 1 // Profile, the MPEG-4 Audio Object Type minus 1 + sampleRateIdx := rd.ReadBits8(4) // MPEG-4 Sampling Frequency Index + _ = rd.ReadBit() // Private bit, guaranteed never to be used by MPEG, set to 0 when encoding, ignore when decoding + channels := rd.ReadBits8(3) // MPEG-4 Channel Configuration + + //_ = rd.ReadBit() // Originality, set to 1 to signal originality of the audio and 0 otherwise + //_ = rd.ReadBit() // Home, set to 1 to signal home usage of the audio and 0 otherwise + //_ = rd.ReadBit() // Copyright ID bit + //_ = rd.ReadBit() // Copyright ID start + //_ = rd.ReadBits(13) // Frame length + //_ = rd.ReadBits(11) // Buffer fullness + //_ = rd.ReadBits(2) // Number of AAC frames (Raw Data Blocks) in ADTS frame minus 1 + //_ = rd.ReadBits(16) // CRC check + + // 3. Encode RTP config + wr := bits.NewWriter(nil) + wr.WriteBits8(objType, 5) + wr.WriteBits8(sampleRateIdx, 4) + wr.WriteBits8(channels, 4) + conf := wr.Bytes() + + codec := &core.Codec{ + Name: core.CodecAAC, + ClockRate: sampleRates[sampleRateIdx], + Channels: channels, + FmtpLine: FMTP + hex.EncodeToString(conf), + } + return codec +} + +func ReadADTSSize(b []byte) uint16 { + // AAAAAAAA AAAABCCD EEFFFFGH HHIJKLMM MMMMMMMM MMMOOOOO OOOOOOPP (QQQQQQQQ QQQQQQQQ) + _ = b[5] // bounds + return uint16(b[3]&0b11)<<11 | uint16(b[4])<<3 | uint16(b[5]>>5) +} + +func WriteADTSSize(b []byte, size uint16) { + // AAAAAAAA AAAABCCD EEFFFFGH HHIJKLMM MMMMMMMM MMMOOOOO OOOOOOPP (QQQQQQQQ QQQQQQQQ) + _ = b[5] // bounds + b[3] |= byte(size >> (8 + 3)) + b[4] = byte(size >> 3) + b[5] |= byte(size << 5) + return +} + +func ADTSTimeSize(b []byte) uint32 { + var units uint32 + for len(b) > ADTSHeaderSize { + auSize := ReadADTSSize(b) + b = b[auSize:] + units++ + } + return units * AUTime +} + +func CodecToADTS(codec *core.Codec) []byte { + s := core.Between(codec.FmtpLine, "config=", ";") + conf, err := hex.DecodeString(s) + if err != nil { + return nil + } + + objType, sampleFreqIdx, channels, _ := DecodeConfig(conf) + profile := objType - 1 + + wr := bits.NewWriter(nil) + wr.WriteAllBits(1, 12) // Syncword, all bits must be set to 1 + wr.WriteBit(0) // MPEG Version, set to 0 for MPEG-4 and 1 for MPEG-2 + wr.WriteBits8(0, 2) // Layer, always set to 0 + wr.WriteBit(1) // Protection absence, set to 1 if there is no CRC and 0 if there is CRC + wr.WriteBits8(profile, 2) // Profile, the MPEG-4 Audio Object Type minus 1 + wr.WriteBits8(sampleFreqIdx, 4) // MPEG-4 Sampling Frequency Index + wr.WriteBit(0) // Private bit, guaranteed never to be used by MPEG, set to 0 when encoding, ignore when decoding + wr.WriteBits8(channels, 3) // MPEG-4 Channel Configuration + wr.WriteBit(0) // Originality, set to 1 to signal originality of the audio and 0 otherwise + wr.WriteBit(0) // Home, set to 1 to signal home usage of the audio and 0 otherwise + wr.WriteBit(0) // Copyright ID bit + wr.WriteBit(0) // Copyright ID start + wr.WriteBits16(0, 13) // Frame length + wr.WriteAllBits(1, 11) // Buffer fullness (variable bitrate) + wr.WriteBits8(0, 2) // Number of AAC frames (Raw Data Blocks) in ADTS frame minus 1 + + return wr.Bytes() +} + +func EncodeToADTS(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc { + adts := CodecToADTS(codec) + + return func(packet *rtp.Packet) { + if !IsADTS(packet.Payload) { + b := make([]byte, ADTSHeaderSize+len(packet.Payload)) + copy(b, adts) + copy(b[ADTSHeaderSize:], packet.Payload) + WriteADTSSize(b, uint16(len(b))) + + clone := *packet + clone.Payload = b + handler(&clone) + } else { + handler(packet) + } + } +} diff --git a/installs_on_host/go2rtc/pkg/aac/consumer.go b/installs_on_host/go2rtc/pkg/aac/consumer.go new file mode 100644 index 0000000..fc67d2a --- /dev/null +++ b/installs_on_host/go2rtc/pkg/aac/consumer.go @@ -0,0 +1,59 @@ +package aac + +import ( + "io" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +type Consumer struct { + core.Connection + wr *core.WriteBuffer +} + +func NewConsumer() *Consumer { + medias := []*core.Media{ + { + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecAAC}, + }, + }, + } + wr := core.NewWriteBuffer(nil) + return &Consumer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "adts", + Medias: medias, + Transport: wr, + }, + wr: wr, + } +} + +func (c *Consumer) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + sender := core.NewSender(media, track.Codec) + + sender.Handler = func(pkt *rtp.Packet) { + if n, err := c.wr.Write(pkt.Payload); err == nil { + c.Send += n + } + } + + if track.Codec.IsRTP() { + sender.Handler = RTPToADTS(track.Codec, sender.Handler) + } else { + sender.Handler = EncodeToADTS(track.Codec, sender.Handler) + } + + sender.HandleRTP(track) + c.Senders = append(c.Senders, sender) + return nil +} + +func (c *Consumer) WriteTo(wr io.Writer) (int64, error) { + return c.wr.WriteTo(wr) +} diff --git a/installs_on_host/go2rtc/pkg/aac/producer.go b/installs_on_host/go2rtc/pkg/aac/producer.go new file mode 100644 index 0000000..a2c73f9 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/aac/producer.go @@ -0,0 +1,85 @@ +package aac + +import ( + "bufio" + "errors" + "io" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +type Producer struct { + core.Connection + rd *bufio.Reader +} + +func Open(r io.Reader) (*Producer, error) { + rd := bufio.NewReader(r) + + b, err := rd.Peek(ADTSHeaderSize) + if err != nil { + return nil, err + } + + codec := ADTSToCodec(b) + if codec == nil { + return nil, errors.New("adts: wrong header") + } + codec.PayloadType = core.PayloadTypeRAW + + medias := []*core.Media{ + { + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{codec}, + }, + } + return &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "adts", + Medias: medias, + Transport: r, + }, + rd: rd, + }, nil +} + +func (c *Producer) Start() error { + for { + // read ADTS header + adts := make([]byte, ADTSHeaderSize) + if _, err := io.ReadFull(c.rd, adts); err != nil { + return err + } + + auSize := ReadADTSSize(adts) - ADTSHeaderSize + + if HasCRC(adts) { + // skip CRC after header + if _, err := c.rd.Discard(2); err != nil { + return err + } + auSize -= 2 + } + + // read AAC payload after header + payload := make([]byte, auSize) + if _, err := io.ReadFull(c.rd, payload); err != nil { + return err + } + + c.Recv += int(auSize) + + if len(c.Receivers) == 0 { + continue + } + + pkt := &rtp.Packet{ + Header: rtp.Header{Timestamp: core.Now90000()}, + Payload: payload, + } + c.Receivers[0].WriteRTP(pkt) + } +} diff --git a/installs_on_host/go2rtc/pkg/aac/rtp.go b/installs_on_host/go2rtc/pkg/aac/rtp.go new file mode 100644 index 0000000..08846c0 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/aac/rtp.go @@ -0,0 +1,154 @@ +package aac + +import ( + "encoding/binary" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +const RTPPacketVersionAAC = 0 + +func RTPDepay(handler core.HandlerFunc) core.HandlerFunc { + var timestamp uint32 + + return func(packet *rtp.Packet) { + // support ONLY 2 bytes header size! + // streamtype=5;profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3;config=1408 + // https://datatracker.ietf.org/doc/html/rfc3640 + headersSize := binary.BigEndian.Uint16(packet.Payload) >> 3 + + //log.Printf("[RTP/AAC] units: %d, size: %4d, ts: %10d, %t", headersSize/2, len(packet.Payload), packet.Timestamp, packet.Marker) + + if len(packet.Payload) < int(2+headersSize) { + // In very rare cases noname cameras may send data not according to the standard + // https://github.com/AlexxIT/go2rtc/issues/1328 + if IsADTS(packet.Payload) { + clone := *packet + clone.Version = RTPPacketVersionAAC + clone.Timestamp = timestamp + clone.Payload = clone.Payload[ADTSHeaderSize:] + handler(&clone) + } + return + } + + headers := packet.Payload[2 : 2+headersSize] + units := packet.Payload[2+headersSize:] + + for len(headers) >= 2 { + unitSize := binary.BigEndian.Uint16(headers) >> 3 + + if len(units) < int(unitSize) { + return + } + + unit := units[:unitSize] + + headers = headers[2:] + units = units[unitSize:] + + timestamp += AUTime + + clone := *packet + clone.Version = RTPPacketVersionAAC + clone.Timestamp = timestamp + if IsADTS(unit) { + clone.Payload = unit[ADTSHeaderSize:] + } else { + clone.Payload = unit + } + handler(&clone) + } + } +} + +func RTPPay(handler core.HandlerFunc) core.HandlerFunc { + var seq uint16 + var ts uint32 + + return func(packet *rtp.Packet) { + if packet.Version != RTPPacketVersionAAC { + handler(packet) + return + } + + // support ONLY one unit in payload + auSize := uint16(len(packet.Payload)) + // 2 bytes header size + 2 bytes first payload size + payload := make([]byte, 2+2+auSize) + payload[1] = 16 // header size in bits + binary.BigEndian.PutUint16(payload[2:], auSize<<3) + copy(payload[4:], packet.Payload) + + clone := rtp.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: true, + SequenceNumber: seq, + Timestamp: ts, + }, + Payload: payload, + } + handler(&clone) + + seq++ + ts += AUTime + } +} + +func ADTStoRTP(src []byte) (dst []byte) { + dst = make([]byte, 2) // header bytes + for i, n := 0, len(src)-ADTSHeaderSize; i < n; { + auSize := ReadADTSSize(src[i:]) + dst = append(dst, byte(auSize>>5), byte(auSize<<3)) // size in bits + i += int(auSize) + } + hdrSize := uint16(len(dst) - 2) + binary.BigEndian.PutUint16(dst, hdrSize<<3) // size in bits + return append(dst, src...) +} + +func RTPTimeSize(b []byte) uint32 { + // convert RTP header size to units count + units := binary.BigEndian.Uint16(b) >> 4 + return uint32(units) * AUTime +} + +func RTPToADTS(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc { + adts := CodecToADTS(codec) + + return func(packet *rtp.Packet) { + src := packet.Payload + dst := make([]byte, 0, len(src)) + + headersSize := binary.BigEndian.Uint16(src) >> 3 + headers := src[2 : 2+headersSize] + units := src[2+headersSize:] + + for len(headers) > 0 { + unitSize := binary.BigEndian.Uint16(headers) >> 3 + headers = headers[2:] + unit := units[:unitSize] + units = units[unitSize:] + + if !IsADTS(unit) { + i := len(dst) + dst = append(dst, adts...) + WriteADTSSize(dst[i:], ADTSHeaderSize+uint16(len(unit))) + } + + dst = append(dst, unit...) + } + + clone := *packet + clone.Version = RTPPacketVersionAAC + clone.Payload = dst + handler(&clone) + } +} + +func RTPToCodec(b []byte) *core.Codec { + hdrSize := binary.BigEndian.Uint16(b) / 8 + return ADTSToCodec(b[2+hdrSize:]) +} diff --git a/installs_on_host/go2rtc/pkg/aac/rtp_test.go b/installs_on_host/go2rtc/pkg/aac/rtp_test.go new file mode 100644 index 0000000..c541b25 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/aac/rtp_test.go @@ -0,0 +1,33 @@ +package aac + +import ( + "encoding/hex" + "testing" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" + "github.com/stretchr/testify/require" +) + +func TestBuggy_RTSP_AAC(t *testing.T) { + // https: //github.com/AlexxIT/go2rtc/issues/1328 + payload, _ := hex.DecodeString("fff16080431ffc211ad4458aa309a1c0a8761a230502b7c74b2b5499252a010555e32e460128303c8ace4fd3260d654a424f7e7c65eddc96735fc6f1ac0edf94fdefa0e0bd6370da1c07b9c0e77a9d6e86b196a1ac7439dcafadcffcf6d89f60ac67f8884868e931383ad3e40cf5495470d1f606ef6f7624d285b951ebfa0e42641ab98f1371182b237d14f1bd16ad714fa2f1c6a7d23ebde7a0e34a2eca156a608a4caec49d9dca4b6fe2a09e9cdbf762c5b4148a3914abb7959c991228b0837b5988334b9fc18b8fac689b5ca1e4661573bbb8b253a86cae7ec14ace49969a9a76fd571ab6e650764cb59114d61dcedf07ac61b39e4ac66adebfd0d0ab45d518dd3c161049823f150864d977cf0855172ac8482e4b25fe911325d19617558c5405af74aff5492e4599bee53f2dbdf0503730af37078550f84c956b7ee89aae83c154fa2fa6e6792c5ddd5cd5cf6bb96bf055fee7f93bed59ffb039daee5ea7e5593cb194e9091e417c67d8f73026a6a6ae056e808f7c65c03d1b9197d3709ceb63bc7b979f7ba71df5e7c6395d99d6ea229000a6bc16fb4346d6b27d32f5d8d1200736d9366d59c0c9547210813b602473da9c46f9015bbb37594c1dd90cd6a36e96bd5d6a1445ab93c9e65505ec2c722bb4cc27a10600139a48c83594dde145253c386f6627d8c6e5102fe3828a590c709bc87f55b37e97d1ae72b017b09c6bb2c13299817bb45cc67318e10b6822075b97c6a03ec1c0") + packet := &rtp.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: true, + SequenceNumber: 36944, + Timestamp: 4217191328, + SSRC: 12892774, + }, + Payload: payload, + } + + var size int + + RTPDepay(func(packet *core.Packet) { + size = len(packet.Payload) + })(packet) + + require.Equal(t, len(payload), size+ADTSHeaderSize) +} diff --git a/installs_on_host/go2rtc/pkg/alsa/README.md b/installs_on_host/go2rtc/pkg/alsa/README.md new file mode 100644 index 0000000..b644af1 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/alsa/README.md @@ -0,0 +1,23 @@ +## Build + +```shell +x86_64-linux-gnu-gcc -w -static asound_arch.c -o asound_amd64 +i686-linux-gnu-gcc -w -static asound_arch.c -o asound_i386 +aarch64-linux-gnu-gcc -w -static asound_arch.c -o asound_arm64 +arm-linux-gnueabihf-gcc -w -static asound_arch.c -o asound_arm +mipsel-linux-gnu-gcc -w -static asound_arch.c -o asound_mipsle -D_TIME_BITS=32 +``` + +## Useful links + +- https://github.com/torvalds/linux/blob/master/include/uapi/sound/asound.h +- https://github.com/yobert/alsa +- https://github.com/Narsil/alsa-go +- https://github.com/alsa-project/alsa-lib +- https://github.com/anisse/alsa +- https://github.com/tinyalsa/tinyalsa + +**Broken pipe** + +- https://stackoverflow.com/questions/26545139/alsa-cannot-recovery-from-underrun-prepare-failed-broken-pipe +- https://klipspringer.avadeaux.net/alsa-broken-pipe-errors/ diff --git a/installs_on_host/go2rtc/pkg/alsa/capture_linux.go b/installs_on_host/go2rtc/pkg/alsa/capture_linux.go new file mode 100644 index 0000000..54a7d67 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/alsa/capture_linux.go @@ -0,0 +1,90 @@ +package alsa + +import ( + "github.com/AlexxIT/go2rtc/pkg/alsa/device" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/pcm" + "github.com/pion/rtp" +) + +type Capture struct { + core.Connection + dev *device.Device + closed core.Waiter +} + +func newCapture(dev *device.Device) (*Capture, error) { + medias := []*core.Media{ + { + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + {Name: core.CodecPCML, ClockRate: 16000}, + }, + }, + } + return &Capture{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "alsa", + Medias: medias, + Transport: dev, + }, + dev: dev, + }, nil +} + +func (c *Capture) Start() error { + dst := c.Medias[0].Codecs[0] + src := &core.Codec{ + Name: dst.Name, + ClockRate: c.dev.GetRateNear(dst.ClockRate), + Channels: c.dev.GetChannelsNear(dst.Channels), + } + + if err := c.dev.SetHWParams(device.SNDRV_PCM_FORMAT_S16_LE, src.ClockRate, src.Channels); err != nil { + return err + } + + transcode := transcodeFunc(dst, src) + frameBytes := int(pcm.BytesPerFrame(src)) + + var ts uint32 + + // readBufferSize for 20ms interval + readBufferSize := 20 * frameBytes * int(src.ClockRate) / 1000 + b := make([]byte, readBufferSize) + for { + n, err := c.dev.Read(b) + if err != nil { + return err + } + + c.Recv += n + + if len(c.Receivers) == 0 { + continue + } + + pkt := &rtp.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: true, + Timestamp: ts, + }, + Payload: transcode(b[:n]), + } + c.Receivers[0].WriteRTP(pkt) + + ts += uint32(n / frameBytes) + } +} + +func transcodeFunc(dst, src *core.Codec) func([]byte) []byte { + if dst.ClockRate == src.ClockRate && dst.Channels == src.Channels { + return func(b []byte) []byte { + return b + } + } + return pcm.Transcode(dst, src) +} diff --git a/installs_on_host/go2rtc/pkg/alsa/device/asound_32bit.go b/installs_on_host/go2rtc/pkg/alsa/device/asound_32bit.go new file mode 100644 index 0000000..428c876 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/alsa/device/asound_32bit.go @@ -0,0 +1,148 @@ +//go:build 386 || arm + +package device + +type unsigned_char = byte +type signed_int = int32 +type unsigned_int = uint32 +type signed_long = int64 +type unsigned_long = uint64 +type __u32 = uint32 +type void__user = uintptr + +const ( + SNDRV_PCM_STREAM_PLAYBACK = 0 + SNDRV_PCM_STREAM_CAPTURE = 1 + + SNDRV_PCM_ACCESS_MMAP_INTERLEAVED = 0 + SNDRV_PCM_ACCESS_MMAP_NONINTERLEAVED = 1 + SNDRV_PCM_ACCESS_MMAP_COMPLEX = 2 + SNDRV_PCM_ACCESS_RW_INTERLEAVED = 3 + SNDRV_PCM_ACCESS_RW_NONINTERLEAVED = 4 + + SNDRV_PCM_FORMAT_S8 = 0 + SNDRV_PCM_FORMAT_U8 = 1 + SNDRV_PCM_FORMAT_S16_LE = 2 + SNDRV_PCM_FORMAT_S16_BE = 3 + SNDRV_PCM_FORMAT_U16_LE = 4 + SNDRV_PCM_FORMAT_U16_BE = 5 + SNDRV_PCM_FORMAT_S24_LE = 6 + SNDRV_PCM_FORMAT_S24_BE = 7 + SNDRV_PCM_FORMAT_U24_LE = 8 + SNDRV_PCM_FORMAT_U24_BE = 9 + SNDRV_PCM_FORMAT_S32_LE = 10 + SNDRV_PCM_FORMAT_S32_BE = 11 + SNDRV_PCM_FORMAT_U32_LE = 12 + SNDRV_PCM_FORMAT_U32_BE = 13 + SNDRV_PCM_FORMAT_FLOAT_LE = 14 + SNDRV_PCM_FORMAT_FLOAT_BE = 15 + SNDRV_PCM_FORMAT_FLOAT64_LE = 16 + SNDRV_PCM_FORMAT_FLOAT64_BE = 17 + SNDRV_PCM_FORMAT_MU_LAW = 20 + SNDRV_PCM_FORMAT_A_LAW = 21 + SNDRV_PCM_FORMAT_MPEG = 23 + + SNDRV_PCM_IOCTL_PVERSION = 0x80044100 + SNDRV_PCM_IOCTL_INFO = 0x81204101 + SNDRV_PCM_IOCTL_HW_REFINE = 0xc25c4110 + SNDRV_PCM_IOCTL_HW_PARAMS = 0xc25c4111 + SNDRV_PCM_IOCTL_SW_PARAMS = 0xc0684113 + SNDRV_PCM_IOCTL_PREPARE = 0x00004140 + SNDRV_PCM_IOCTL_WRITEI_FRAMES = 0x400c4150 + SNDRV_PCM_IOCTL_READI_FRAMES = 0x800c4151 +) + +type snd_pcm_info struct { // size 288 + device unsigned_int // offset 0, size 4 + subdevice unsigned_int // offset 4, size 4 + stream signed_int // offset 8, size 4 + card signed_int // offset 12, size 4 + id [64]unsigned_char // offset 16, size 64 + name [80]unsigned_char // offset 80, size 80 + subname [32]unsigned_char // offset 160, size 32 + dev_class signed_int // offset 192, size 4 + dev_subclass signed_int // offset 196, size 4 + subdevices_count unsigned_int // offset 200, size 4 + subdevices_avail unsigned_int // offset 204, size 4 + pad1 [16]unsigned_char + reserved [64]unsigned_char // offset 224, size 64 +} + +type snd_pcm_uframes_t = unsigned_long +type snd_pcm_sframes_t = signed_long + +type snd_xferi struct { // size 12 + result snd_pcm_sframes_t // offset 0, size 4 + buf void__user // offset 4, size 4 + frames snd_pcm_uframes_t // offset 8, size 4 +} + +const ( + SNDRV_PCM_HW_PARAM_ACCESS = 0 + SNDRV_PCM_HW_PARAM_FORMAT = 1 + SNDRV_PCM_HW_PARAM_SUBFORMAT = 2 + SNDRV_PCM_HW_PARAM_FIRST_MASK = 0 + SNDRV_PCM_HW_PARAM_LAST_MASK = 2 + + SNDRV_PCM_HW_PARAM_SAMPLE_BITS = 8 + SNDRV_PCM_HW_PARAM_FRAME_BITS = 9 + SNDRV_PCM_HW_PARAM_CHANNELS = 10 + SNDRV_PCM_HW_PARAM_RATE = 11 + SNDRV_PCM_HW_PARAM_PERIOD_TIME = 12 + SNDRV_PCM_HW_PARAM_PERIOD_SIZE = 13 + SNDRV_PCM_HW_PARAM_PERIOD_BYTES = 14 + SNDRV_PCM_HW_PARAM_PERIODS = 15 + SNDRV_PCM_HW_PARAM_BUFFER_TIME = 16 + SNDRV_PCM_HW_PARAM_BUFFER_SIZE = 17 + SNDRV_PCM_HW_PARAM_BUFFER_BYTES = 18 + SNDRV_PCM_HW_PARAM_TICK_TIME = 19 + SNDRV_PCM_HW_PARAM_FIRST_INTERVAL = 8 + SNDRV_PCM_HW_PARAM_LAST_INTERVAL = 19 + + SNDRV_MASK_MAX = 256 + + SNDRV_PCM_TSTAMP_NONE = 0 + SNDRV_PCM_TSTAMP_ENABLE = 1 +) + +type snd_mask struct { // size 32 + bits [(SNDRV_MASK_MAX + 31) / 32]__u32 // offset 0, size 32 +} + +type snd_interval struct { // size 12 + min unsigned_int // offset 0, size 4 + max unsigned_int // offset 4, size 4 + bit unsigned_int +} + +type snd_pcm_hw_params struct { // size 604 + flags unsigned_int // offset 0, size 4 + masks [SNDRV_PCM_HW_PARAM_LAST_MASK - SNDRV_PCM_HW_PARAM_FIRST_MASK + 1]snd_mask // offset 4, size 96 + mres [5]snd_mask // offset 100, size 160 + intervals [SNDRV_PCM_HW_PARAM_LAST_INTERVAL - SNDRV_PCM_HW_PARAM_FIRST_INTERVAL + 1]snd_interval // offset 260, size 144 + ires [9]snd_interval // offset 404, size 108 + rmask unsigned_int // offset 512, size 4 + cmask unsigned_int // offset 516, size 4 + info unsigned_int // offset 520, size 4 + msbits unsigned_int // offset 524, size 4 + rate_num unsigned_int // offset 528, size 4 + rate_den unsigned_int // offset 532, size 4 + fifo_size snd_pcm_uframes_t // offset 536, size 4 + reserved [64]unsigned_char // offset 540, size 64 +} + +type snd_pcm_sw_params struct { // size 104 + tstamp_mode signed_int // offset 0, size 4 + period_step unsigned_int // offset 4, size 4 + sleep_min unsigned_int // offset 8, size 4 + avail_min snd_pcm_uframes_t // offset 12, size 4 + xfer_align snd_pcm_uframes_t // offset 16, size 4 + start_threshold snd_pcm_uframes_t // offset 20, size 4 + stop_threshold snd_pcm_uframes_t // offset 24, size 4 + silence_threshold snd_pcm_uframes_t // offset 28, size 4 + silence_size snd_pcm_uframes_t // offset 32, size 4 + boundary snd_pcm_uframes_t // offset 36, size 4 + proto unsigned_int // offset 40, size 4 + tstamp_type unsigned_int // offset 44, size 4 + reserved [56]unsigned_char // offset 48, size 56 +} diff --git a/installs_on_host/go2rtc/pkg/alsa/device/asound_64bit.go b/installs_on_host/go2rtc/pkg/alsa/device/asound_64bit.go new file mode 100644 index 0000000..14d0069 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/alsa/device/asound_64bit.go @@ -0,0 +1,148 @@ +//go:build amd64 || arm64 + +package device + +type unsigned_char = byte +type signed_int = int32 +type unsigned_int = uint32 +type signed_long = int64 +type unsigned_long = uint64 +type __u32 = uint32 +type void__user = uintptr + +const ( + SNDRV_PCM_STREAM_PLAYBACK = 0 + SNDRV_PCM_STREAM_CAPTURE = 1 + + SNDRV_PCM_ACCESS_MMAP_INTERLEAVED = 0 + SNDRV_PCM_ACCESS_MMAP_NONINTERLEAVED = 1 + SNDRV_PCM_ACCESS_MMAP_COMPLEX = 2 + SNDRV_PCM_ACCESS_RW_INTERLEAVED = 3 + SNDRV_PCM_ACCESS_RW_NONINTERLEAVED = 4 + + SNDRV_PCM_FORMAT_S8 = 0 + SNDRV_PCM_FORMAT_U8 = 1 + SNDRV_PCM_FORMAT_S16_LE = 2 + SNDRV_PCM_FORMAT_S16_BE = 3 + SNDRV_PCM_FORMAT_U16_LE = 4 + SNDRV_PCM_FORMAT_U16_BE = 5 + SNDRV_PCM_FORMAT_S24_LE = 6 + SNDRV_PCM_FORMAT_S24_BE = 7 + SNDRV_PCM_FORMAT_U24_LE = 8 + SNDRV_PCM_FORMAT_U24_BE = 9 + SNDRV_PCM_FORMAT_S32_LE = 10 + SNDRV_PCM_FORMAT_S32_BE = 11 + SNDRV_PCM_FORMAT_U32_LE = 12 + SNDRV_PCM_FORMAT_U32_BE = 13 + SNDRV_PCM_FORMAT_FLOAT_LE = 14 + SNDRV_PCM_FORMAT_FLOAT_BE = 15 + SNDRV_PCM_FORMAT_FLOAT64_LE = 16 + SNDRV_PCM_FORMAT_FLOAT64_BE = 17 + SNDRV_PCM_FORMAT_MU_LAW = 20 + SNDRV_PCM_FORMAT_A_LAW = 21 + SNDRV_PCM_FORMAT_MPEG = 23 + + SNDRV_PCM_IOCTL_PVERSION = 0x80044100 + SNDRV_PCM_IOCTL_INFO = 0x81204101 + SNDRV_PCM_IOCTL_HW_REFINE = 0xc2604110 + SNDRV_PCM_IOCTL_HW_PARAMS = 0xc2604111 + SNDRV_PCM_IOCTL_SW_PARAMS = 0xc0884113 + SNDRV_PCM_IOCTL_PREPARE = 0x00004140 + SNDRV_PCM_IOCTL_WRITEI_FRAMES = 0x40184150 + SNDRV_PCM_IOCTL_READI_FRAMES = 0x80184151 +) + +type snd_pcm_info struct { // size 288 + device unsigned_int // offset 0, size 4 + subdevice unsigned_int // offset 4, size 4 + stream signed_int // offset 8, size 4 + card signed_int // offset 12, size 4 + id [64]unsigned_char // offset 16, size 64 + name [80]unsigned_char // offset 80, size 80 + subname [32]unsigned_char // offset 160, size 32 + dev_class signed_int // offset 192, size 4 + dev_subclass signed_int // offset 196, size 4 + subdevices_count unsigned_int // offset 200, size 4 + subdevices_avail unsigned_int // offset 204, size 4 + pad1 [16]unsigned_char + reserved [64]unsigned_char // offset 224, size 64 +} + +type snd_pcm_uframes_t = unsigned_long +type snd_pcm_sframes_t = signed_long + +type snd_xferi struct { // size 24 + result snd_pcm_sframes_t // offset 0, size 8 + buf void__user // offset 8, size 8 + frames snd_pcm_uframes_t // offset 16, size 8 +} + +const ( + SNDRV_PCM_HW_PARAM_ACCESS = 0 + SNDRV_PCM_HW_PARAM_FORMAT = 1 + SNDRV_PCM_HW_PARAM_SUBFORMAT = 2 + SNDRV_PCM_HW_PARAM_FIRST_MASK = 0 + SNDRV_PCM_HW_PARAM_LAST_MASK = 2 + + SNDRV_PCM_HW_PARAM_SAMPLE_BITS = 8 + SNDRV_PCM_HW_PARAM_FRAME_BITS = 9 + SNDRV_PCM_HW_PARAM_CHANNELS = 10 + SNDRV_PCM_HW_PARAM_RATE = 11 + SNDRV_PCM_HW_PARAM_PERIOD_TIME = 12 + SNDRV_PCM_HW_PARAM_PERIOD_SIZE = 13 + SNDRV_PCM_HW_PARAM_PERIOD_BYTES = 14 + SNDRV_PCM_HW_PARAM_PERIODS = 15 + SNDRV_PCM_HW_PARAM_BUFFER_TIME = 16 + SNDRV_PCM_HW_PARAM_BUFFER_SIZE = 17 + SNDRV_PCM_HW_PARAM_BUFFER_BYTES = 18 + SNDRV_PCM_HW_PARAM_TICK_TIME = 19 + SNDRV_PCM_HW_PARAM_FIRST_INTERVAL = 8 + SNDRV_PCM_HW_PARAM_LAST_INTERVAL = 19 + + SNDRV_MASK_MAX = 256 + + SNDRV_PCM_TSTAMP_NONE = 0 + SNDRV_PCM_TSTAMP_ENABLE = 1 +) + +type snd_mask struct { // size 32 + bits [(SNDRV_MASK_MAX + 31) / 32]__u32 // offset 0, size 32 +} + +type snd_interval struct { // size 12 + min unsigned_int // offset 0, size 4 + max unsigned_int // offset 4, size 4 + bit unsigned_int +} + +type snd_pcm_hw_params struct { // size 608 + flags unsigned_int // offset 0, size 4 + masks [SNDRV_PCM_HW_PARAM_LAST_MASK - SNDRV_PCM_HW_PARAM_FIRST_MASK + 1]snd_mask // offset 4, size 96 + mres [5]snd_mask // offset 100, size 160 + intervals [SNDRV_PCM_HW_PARAM_LAST_INTERVAL - SNDRV_PCM_HW_PARAM_FIRST_INTERVAL + 1]snd_interval // offset 260, size 144 + ires [9]snd_interval // offset 404, size 108 + rmask unsigned_int // offset 512, size 4 + cmask unsigned_int // offset 516, size 4 + info unsigned_int // offset 520, size 4 + msbits unsigned_int // offset 524, size 4 + rate_num unsigned_int // offset 528, size 4 + rate_den unsigned_int // offset 532, size 4 + fifo_size snd_pcm_uframes_t // offset 536, size 8 + reserved [64]unsigned_char // offset 544, size 64 +} + +type snd_pcm_sw_params struct { // size 136 + tstamp_mode signed_int // offset 0, size 4 + period_step unsigned_int // offset 4, size 4 + sleep_min unsigned_int // offset 8, size 4 + avail_min snd_pcm_uframes_t // offset 16, size 8 + xfer_align snd_pcm_uframes_t // offset 24, size 8 + start_threshold snd_pcm_uframes_t // offset 32, size 8 + stop_threshold snd_pcm_uframes_t // offset 40, size 8 + silence_threshold snd_pcm_uframes_t // offset 48, size 8 + silence_size snd_pcm_uframes_t // offset 56, size 8 + boundary snd_pcm_uframes_t // offset 64, size 8 + proto unsigned_int // offset 72, size 4 + tstamp_type unsigned_int // offset 76, size 4 + reserved [56]unsigned_char // offset 80, size 56 +} diff --git a/installs_on_host/go2rtc/pkg/alsa/device/asound_arch.c b/installs_on_host/go2rtc/pkg/alsa/device/asound_arch.c new file mode 100644 index 0000000..902475a --- /dev/null +++ b/installs_on_host/go2rtc/pkg/alsa/device/asound_arch.c @@ -0,0 +1,164 @@ +//go:build ignore +#include +#include +#include +#include + +#define print_line(text) printf("%s\n", text) +#define print_hex_const(name) printf("\t%s = 0x%08lx\n", #name, name) +#define print_int_const(con) printf("\t%s = %d\n", #con, con) + +#define print_struct_header(str) printf("type %s struct { // size %lu\n", #str, sizeof(struct str)) +#define print_struct_member(str, mem, typ) printf("\t%s %s // offset %lu, size %lu\n", #mem == "type" ? "typ" : #mem, typ, offsetof(struct str, mem), sizeof((struct str){0}.mem)) + +// https://github.com/torvalds/linux/blob/master/include/uapi/sound/asound.h +int main() { + print_line("package device\n"); + + print_line("type unsigned_char = byte"); + print_line("type signed_int = int32"); + print_line("type unsigned_int = uint32"); + print_line("type signed_long = int64"); + print_line("type unsigned_long = uint64"); + print_line("type __u32 = uint32"); + print_line("type void__user = uintptr\n"); + + print_line("const ("); + print_int_const(SNDRV_PCM_STREAM_PLAYBACK); + print_int_const(SNDRV_PCM_STREAM_CAPTURE); + print_line(""); + print_int_const(SNDRV_PCM_ACCESS_MMAP_INTERLEAVED); + print_int_const(SNDRV_PCM_ACCESS_MMAP_NONINTERLEAVED); + print_int_const(SNDRV_PCM_ACCESS_MMAP_COMPLEX); + print_int_const(SNDRV_PCM_ACCESS_RW_INTERLEAVED); + print_int_const(SNDRV_PCM_ACCESS_RW_NONINTERLEAVED); + print_line(""); + print_int_const(SNDRV_PCM_FORMAT_S8); + print_int_const(SNDRV_PCM_FORMAT_U8); + print_int_const(SNDRV_PCM_FORMAT_S16_LE); + print_int_const(SNDRV_PCM_FORMAT_S16_BE); + print_int_const(SNDRV_PCM_FORMAT_U16_LE); + print_int_const(SNDRV_PCM_FORMAT_U16_BE); + print_int_const(SNDRV_PCM_FORMAT_S24_LE); + print_int_const(SNDRV_PCM_FORMAT_S24_BE); + print_int_const(SNDRV_PCM_FORMAT_U24_LE); + print_int_const(SNDRV_PCM_FORMAT_U24_BE); + print_int_const(SNDRV_PCM_FORMAT_S32_LE); + print_int_const(SNDRV_PCM_FORMAT_S32_BE); + print_int_const(SNDRV_PCM_FORMAT_U32_LE); + print_int_const(SNDRV_PCM_FORMAT_U32_BE); + print_int_const(SNDRV_PCM_FORMAT_FLOAT_LE); + print_int_const(SNDRV_PCM_FORMAT_FLOAT_BE); + print_int_const(SNDRV_PCM_FORMAT_FLOAT64_LE); + print_int_const(SNDRV_PCM_FORMAT_FLOAT64_BE); + print_int_const(SNDRV_PCM_FORMAT_MU_LAW); + print_int_const(SNDRV_PCM_FORMAT_A_LAW); + print_int_const(SNDRV_PCM_FORMAT_MPEG); + print_line(""); + print_hex_const(SNDRV_PCM_IOCTL_PVERSION); // A 0x00 + print_hex_const(SNDRV_PCM_IOCTL_INFO); // A 0x01 + print_hex_const(SNDRV_PCM_IOCTL_HW_REFINE); // A 0x10 + print_hex_const(SNDRV_PCM_IOCTL_HW_PARAMS); // A 0x11 + print_hex_const(SNDRV_PCM_IOCTL_SW_PARAMS); // A 0x13 + print_hex_const(SNDRV_PCM_IOCTL_PREPARE); // A 0x40 + print_hex_const(SNDRV_PCM_IOCTL_WRITEI_FRAMES); // A 0x50 + print_hex_const(SNDRV_PCM_IOCTL_READI_FRAMES); // A 0x51 + print_line(")\n"); + + print_struct_header(snd_pcm_info); + print_struct_member(snd_pcm_info, device, "unsigned_int"); + print_struct_member(snd_pcm_info, subdevice, "unsigned_int"); + print_struct_member(snd_pcm_info, stream, "signed_int"); + print_struct_member(snd_pcm_info, card, "signed_int"); + print_struct_member(snd_pcm_info, id, "[64]unsigned_char"); + print_struct_member(snd_pcm_info, name, "[80]unsigned_char"); + print_struct_member(snd_pcm_info, subname, "[32]unsigned_char"); + print_struct_member(snd_pcm_info, dev_class, "signed_int"); + print_struct_member(snd_pcm_info, dev_subclass, "signed_int"); + print_struct_member(snd_pcm_info, subdevices_count, "unsigned_int"); + print_struct_member(snd_pcm_info, subdevices_avail, "unsigned_int"); + print_line("\tpad1 [16]unsigned_char"); + print_struct_member(snd_pcm_info, reserved, "[64]unsigned_char"); + print_line("}\n"); + + print_line("type snd_pcm_uframes_t = unsigned_long"); + print_line("type snd_pcm_sframes_t = signed_long\n"); + + print_struct_header(snd_xferi); + print_struct_member(snd_xferi, result, "snd_pcm_sframes_t"); + print_struct_member(snd_xferi, buf, "void__user"); + print_struct_member(snd_xferi, frames, "snd_pcm_uframes_t"); + print_line("}\n"); + + print_line("const ("); + print_int_const(SNDRV_PCM_HW_PARAM_ACCESS); + print_int_const(SNDRV_PCM_HW_PARAM_FORMAT); + print_int_const(SNDRV_PCM_HW_PARAM_SUBFORMAT); + print_int_const(SNDRV_PCM_HW_PARAM_FIRST_MASK); + print_int_const(SNDRV_PCM_HW_PARAM_LAST_MASK); + print_line(""); + print_int_const(SNDRV_PCM_HW_PARAM_SAMPLE_BITS); + print_int_const(SNDRV_PCM_HW_PARAM_FRAME_BITS); + print_int_const(SNDRV_PCM_HW_PARAM_CHANNELS); + print_int_const(SNDRV_PCM_HW_PARAM_RATE); + print_int_const(SNDRV_PCM_HW_PARAM_PERIOD_TIME); + print_int_const(SNDRV_PCM_HW_PARAM_PERIOD_SIZE); + print_int_const(SNDRV_PCM_HW_PARAM_PERIOD_BYTES); + print_int_const(SNDRV_PCM_HW_PARAM_PERIODS); + print_int_const(SNDRV_PCM_HW_PARAM_BUFFER_TIME); + print_int_const(SNDRV_PCM_HW_PARAM_BUFFER_SIZE); + print_int_const(SNDRV_PCM_HW_PARAM_BUFFER_BYTES); + print_int_const(SNDRV_PCM_HW_PARAM_TICK_TIME); + print_int_const(SNDRV_PCM_HW_PARAM_FIRST_INTERVAL); + print_int_const(SNDRV_PCM_HW_PARAM_LAST_INTERVAL); + print_line(""); + print_int_const(SNDRV_MASK_MAX); + print_line(""); + print_int_const(SNDRV_PCM_TSTAMP_NONE); + print_int_const(SNDRV_PCM_TSTAMP_ENABLE); + print_line(")\n"); + + print_struct_header(snd_mask); + print_struct_member(snd_mask, bits, "[(SNDRV_MASK_MAX+31)/32]__u32"); + print_line("}\n"); + + print_struct_header(snd_interval); + print_struct_member(snd_interval, min, "unsigned_int"); + print_struct_member(snd_interval, max, "unsigned_int"); + print_line("\tbit unsigned_int"); + print_line("}\n"); + + print_struct_header(snd_pcm_hw_params); + print_struct_member(snd_pcm_hw_params, flags, "unsigned_int"); + print_struct_member(snd_pcm_hw_params, masks, "[SNDRV_PCM_HW_PARAM_LAST_MASK-SNDRV_PCM_HW_PARAM_FIRST_MASK+1]snd_mask"); + print_struct_member(snd_pcm_hw_params, mres, "[5]snd_mask"); + print_struct_member(snd_pcm_hw_params, intervals, "[SNDRV_PCM_HW_PARAM_LAST_INTERVAL-SNDRV_PCM_HW_PARAM_FIRST_INTERVAL+1]snd_interval"); + print_struct_member(snd_pcm_hw_params, ires, "[9]snd_interval"); + print_struct_member(snd_pcm_hw_params, rmask, "unsigned_int"); + print_struct_member(snd_pcm_hw_params, cmask, "unsigned_int"); + print_struct_member(snd_pcm_hw_params, info, "unsigned_int"); + print_struct_member(snd_pcm_hw_params, msbits, "unsigned_int"); + print_struct_member(snd_pcm_hw_params, rate_num, "unsigned_int"); + print_struct_member(snd_pcm_hw_params, rate_den, "unsigned_int"); + print_struct_member(snd_pcm_hw_params, fifo_size, "snd_pcm_uframes_t"); + print_struct_member(snd_pcm_hw_params, reserved, "[64]unsigned_char"); + print_line("}\n"); + + print_struct_header(snd_pcm_sw_params); + print_struct_member(snd_pcm_sw_params, tstamp_mode, "signed_int"); + print_struct_member(snd_pcm_sw_params, period_step, "unsigned_int"); + print_struct_member(snd_pcm_sw_params, sleep_min, "unsigned_int"); + print_struct_member(snd_pcm_sw_params, avail_min, "snd_pcm_uframes_t"); + print_struct_member(snd_pcm_sw_params, xfer_align, "snd_pcm_uframes_t"); + print_struct_member(snd_pcm_sw_params, start_threshold, "snd_pcm_uframes_t"); + print_struct_member(snd_pcm_sw_params, stop_threshold, "snd_pcm_uframes_t"); + print_struct_member(snd_pcm_sw_params, silence_threshold, "snd_pcm_uframes_t"); + print_struct_member(snd_pcm_sw_params, silence_size, "snd_pcm_uframes_t"); + print_struct_member(snd_pcm_sw_params, boundary, "snd_pcm_uframes_t"); + print_struct_member(snd_pcm_sw_params, proto, "unsigned_int"); + print_struct_member(snd_pcm_sw_params, tstamp_type, "unsigned_int"); + print_struct_member(snd_pcm_sw_params, reserved, "[56]unsigned_char"); + print_line("}\n"); + + return 0; +} \ No newline at end of file diff --git a/installs_on_host/go2rtc/pkg/alsa/device/asound_mipsle.go b/installs_on_host/go2rtc/pkg/alsa/device/asound_mipsle.go new file mode 100644 index 0000000..743c89d --- /dev/null +++ b/installs_on_host/go2rtc/pkg/alsa/device/asound_mipsle.go @@ -0,0 +1,146 @@ +package device + +type unsigned_char = byte +type signed_int = int32 +type unsigned_int = uint32 +type signed_long = int64 +type unsigned_long = uint64 +type __u32 = uint32 +type void__user = uintptr + +const ( + SNDRV_PCM_STREAM_PLAYBACK = 0 + SNDRV_PCM_STREAM_CAPTURE = 1 + + SNDRV_PCM_ACCESS_MMAP_INTERLEAVED = 0 + SNDRV_PCM_ACCESS_MMAP_NONINTERLEAVED = 1 + SNDRV_PCM_ACCESS_MMAP_COMPLEX = 2 + SNDRV_PCM_ACCESS_RW_INTERLEAVED = 3 + SNDRV_PCM_ACCESS_RW_NONINTERLEAVED = 4 + + SNDRV_PCM_FORMAT_S8 = 0 + SNDRV_PCM_FORMAT_U8 = 1 + SNDRV_PCM_FORMAT_S16_LE = 2 + SNDRV_PCM_FORMAT_S16_BE = 3 + SNDRV_PCM_FORMAT_U16_LE = 4 + SNDRV_PCM_FORMAT_U16_BE = 5 + SNDRV_PCM_FORMAT_S24_LE = 6 + SNDRV_PCM_FORMAT_S24_BE = 7 + SNDRV_PCM_FORMAT_U24_LE = 8 + SNDRV_PCM_FORMAT_U24_BE = 9 + SNDRV_PCM_FORMAT_S32_LE = 10 + SNDRV_PCM_FORMAT_S32_BE = 11 + SNDRV_PCM_FORMAT_U32_LE = 12 + SNDRV_PCM_FORMAT_U32_BE = 13 + SNDRV_PCM_FORMAT_FLOAT_LE = 14 + SNDRV_PCM_FORMAT_FLOAT_BE = 15 + SNDRV_PCM_FORMAT_FLOAT64_LE = 16 + SNDRV_PCM_FORMAT_FLOAT64_BE = 17 + SNDRV_PCM_FORMAT_MU_LAW = 20 + SNDRV_PCM_FORMAT_A_LAW = 21 + SNDRV_PCM_FORMAT_MPEG = 23 + + SNDRV_PCM_IOCTL_PVERSION = 0x40044100 + SNDRV_PCM_IOCTL_INFO = 0x41204101 + SNDRV_PCM_IOCTL_HW_REFINE = 0xc25c4110 + SNDRV_PCM_IOCTL_HW_PARAMS = 0xc25c4111 + SNDRV_PCM_IOCTL_SW_PARAMS = 0xc0684113 + SNDRV_PCM_IOCTL_PREPARE = 0x20004140 + SNDRV_PCM_IOCTL_WRITEI_FRAMES = 0x800c4150 + SNDRV_PCM_IOCTL_READI_FRAMES = 0x400c4151 +) + +type snd_pcm_info struct { // size 288 + device unsigned_int // offset 0, size 4 + subdevice unsigned_int // offset 4, size 4 + stream signed_int // offset 8, size 4 + card signed_int // offset 12, size 4 + id [64]unsigned_char // offset 16, size 64 + name [80]unsigned_char // offset 80, size 80 + subname [32]unsigned_char // offset 160, size 32 + dev_class signed_int // offset 192, size 4 + dev_subclass signed_int // offset 196, size 4 + subdevices_count unsigned_int // offset 200, size 4 + subdevices_avail unsigned_int // offset 204, size 4 + pad1 [16]unsigned_char + reserved [64]unsigned_char // offset 224, size 64 +} + +type snd_pcm_uframes_t = unsigned_long +type snd_pcm_sframes_t = signed_long + +type snd_xferi struct { // size 12 + result snd_pcm_sframes_t // offset 0, size 4 + buf void__user // offset 4, size 4 + frames snd_pcm_uframes_t // offset 8, size 4 +} + +const ( + SNDRV_PCM_HW_PARAM_ACCESS = 0 + SNDRV_PCM_HW_PARAM_FORMAT = 1 + SNDRV_PCM_HW_PARAM_SUBFORMAT = 2 + SNDRV_PCM_HW_PARAM_FIRST_MASK = 0 + SNDRV_PCM_HW_PARAM_LAST_MASK = 2 + + SNDRV_PCM_HW_PARAM_SAMPLE_BITS = 8 + SNDRV_PCM_HW_PARAM_FRAME_BITS = 9 + SNDRV_PCM_HW_PARAM_CHANNELS = 10 + SNDRV_PCM_HW_PARAM_RATE = 11 + SNDRV_PCM_HW_PARAM_PERIOD_TIME = 12 + SNDRV_PCM_HW_PARAM_PERIOD_SIZE = 13 + SNDRV_PCM_HW_PARAM_PERIOD_BYTES = 14 + SNDRV_PCM_HW_PARAM_PERIODS = 15 + SNDRV_PCM_HW_PARAM_BUFFER_TIME = 16 + SNDRV_PCM_HW_PARAM_BUFFER_SIZE = 17 + SNDRV_PCM_HW_PARAM_BUFFER_BYTES = 18 + SNDRV_PCM_HW_PARAM_TICK_TIME = 19 + SNDRV_PCM_HW_PARAM_FIRST_INTERVAL = 8 + SNDRV_PCM_HW_PARAM_LAST_INTERVAL = 19 + + SNDRV_MASK_MAX = 256 + + SNDRV_PCM_TSTAMP_NONE = 0 + SNDRV_PCM_TSTAMP_ENABLE = 1 +) + +type snd_mask struct { // size 32 + bits [(SNDRV_MASK_MAX + 31) / 32]__u32 // offset 0, size 32 +} + +type snd_interval struct { // size 12 + min unsigned_int // offset 0, size 4 + max unsigned_int // offset 4, size 4 + bit unsigned_int +} + +type snd_pcm_hw_params struct { // size 604 + flags unsigned_int // offset 0, size 4 + masks [SNDRV_PCM_HW_PARAM_LAST_MASK - SNDRV_PCM_HW_PARAM_FIRST_MASK + 1]snd_mask // offset 4, size 96 + mres [5]snd_mask // offset 100, size 160 + intervals [SNDRV_PCM_HW_PARAM_LAST_INTERVAL - SNDRV_PCM_HW_PARAM_FIRST_INTERVAL + 1]snd_interval // offset 260, size 144 + ires [9]snd_interval // offset 404, size 108 + rmask unsigned_int // offset 512, size 4 + cmask unsigned_int // offset 516, size 4 + info unsigned_int // offset 520, size 4 + msbits unsigned_int // offset 524, size 4 + rate_num unsigned_int // offset 528, size 4 + rate_den unsigned_int // offset 532, size 4 + fifo_size snd_pcm_uframes_t // offset 536, size 4 + reserved [64]unsigned_char // offset 540, size 64 +} + +type snd_pcm_sw_params struct { // size 104 + tstamp_mode signed_int // offset 0, size 4 + period_step unsigned_int // offset 4, size 4 + sleep_min unsigned_int // offset 8, size 4 + avail_min snd_pcm_uframes_t // offset 12, size 4 + xfer_align snd_pcm_uframes_t // offset 16, size 4 + start_threshold snd_pcm_uframes_t // offset 20, size 4 + stop_threshold snd_pcm_uframes_t // offset 24, size 4 + silence_threshold snd_pcm_uframes_t // offset 28, size 4 + silence_size snd_pcm_uframes_t // offset 32, size 4 + boundary snd_pcm_uframes_t // offset 36, size 4 + proto unsigned_int // offset 40, size 4 + tstamp_type unsigned_int // offset 44, size 4 + reserved [56]unsigned_char // offset 48, size 56 +} diff --git a/installs_on_host/go2rtc/pkg/alsa/device/device_linux.go b/installs_on_host/go2rtc/pkg/alsa/device/device_linux.go new file mode 100644 index 0000000..ecccc17 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/alsa/device/device_linux.go @@ -0,0 +1,231 @@ +package device + +import ( + "fmt" + "syscall" + "unsafe" +) + +type Device struct { + fd uintptr + path string + + hwparams snd_pcm_hw_params + frameBytes int // sample size * channels +} + +func Open(path string) (*Device, error) { + // important to use nonblock because can get lock + fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_NONBLOCK, 0) + if err != nil { + return nil, err + } + + // important to remove nonblock because better to handle reads and writes + if err = syscall.SetNonblock(fd, false); err != nil { + return nil, err + } + + d := &Device{fd: uintptr(fd), path: path} + d.init() + + // load all supported formats, channels, rates, etc. + if err = ioctl(d.fd, SNDRV_PCM_IOCTL_HW_REFINE, &d.hwparams); err != nil { + _ = d.Close() + return nil, err + } + + d.setMask(SNDRV_PCM_HW_PARAM_ACCESS, SNDRV_PCM_ACCESS_RW_INTERLEAVED) + + return d, nil +} + +func (d *Device) Close() error { + return syscall.Close(int(d.fd)) +} + +func (d *Device) IsCapture() bool { + // path: /dev/snd/pcmC0D0c, where p - playback, c - capture + return d.path[len(d.path)-1] == 'c' +} + +type Info struct { + Card int + Device int + SubDevice int + Stream int + ID string + Name string + SubName string +} + +func (d *Device) Info() (*Info, error) { + var info snd_pcm_info + if err := ioctl(d.fd, SNDRV_PCM_IOCTL_INFO, &info); err != nil { + return nil, err + } + return &Info{ + Card: int(info.card), + Device: int(info.device), + SubDevice: int(info.subdevice), + Stream: int(info.stream), + ID: str(info.id[:]), + Name: str(info.name[:]), + SubName: str(info.subname[:]), + }, nil +} + +func (d *Device) CheckFormat(format byte) bool { + return d.checkMask(SNDRV_PCM_HW_PARAM_FORMAT, uint32(format)) +} + +func (d *Device) ListFormats() (formats []byte) { + for i := byte(0); i <= 28; i++ { + if d.CheckFormat(i) { + formats = append(formats, i) + } + } + return +} + +func (d *Device) RangeRates() (uint32, uint32) { + return d.getInterval(SNDRV_PCM_HW_PARAM_RATE) +} + +func (d *Device) RangeChannels() (byte, byte) { + minCh, maxCh := d.getInterval(SNDRV_PCM_HW_PARAM_CHANNELS) + return byte(minCh), byte(maxCh) +} + +func (d *Device) GetRateNear(rate uint32) uint32 { + r1, r2 := d.RangeRates() + if rate < r1 { + return r1 + } + if rate > r2 { + return r2 + } + return rate +} + +func (d *Device) GetChannelsNear(channels byte) byte { + c1, c2 := d.RangeChannels() + if channels < c1 { + return c1 + } + if channels > c2 { + return c2 + } + return channels +} + +const bufferSize = 4096 + +func (d *Device) SetHWParams(format byte, rate uint32, channels byte) error { + d.setInterval(SNDRV_PCM_HW_PARAM_CHANNELS, uint32(channels)) + d.setInterval(SNDRV_PCM_HW_PARAM_RATE, rate) + d.setMask(SNDRV_PCM_HW_PARAM_FORMAT, uint32(format)) + //d.setMask(SNDRV_PCM_HW_PARAM_SUBFORMAT, 0) + + // important for smooth playback + d.setInterval(SNDRV_PCM_HW_PARAM_BUFFER_SIZE, bufferSize) + //d.setInterval(SNDRV_PCM_HW_PARAM_PERIOD_SIZE, 2000) + + if err := ioctl(d.fd, SNDRV_PCM_IOCTL_HW_PARAMS, &d.hwparams); err != nil { + return fmt.Errorf("[alsa] set hw_params: %w", err) + } + + _, i := d.getInterval(SNDRV_PCM_HW_PARAM_FRAME_BITS) + d.frameBytes = int(i / 8) + + _, periods := d.getInterval(SNDRV_PCM_HW_PARAM_PERIODS) + _, periodSize := d.getInterval(SNDRV_PCM_HW_PARAM_PERIOD_SIZE) + threshold := snd_pcm_uframes_t(periods * periodSize) // same as bufferSize + + swparams := snd_pcm_sw_params{ + //tstamp_mode: SNDRV_PCM_TSTAMP_ENABLE, + period_step: 1, + avail_min: 1, // start as soon as possible + stop_threshold: threshold, + } + + if d.IsCapture() { + swparams.start_threshold = 1 + } else { + swparams.start_threshold = threshold + } + + if err := ioctl(d.fd, SNDRV_PCM_IOCTL_SW_PARAMS, &swparams); err != nil { + return fmt.Errorf("[alsa] set sw_params: %w", err) + } + + if err := ioctl(d.fd, SNDRV_PCM_IOCTL_PREPARE, nil); err != nil { + return fmt.Errorf("[alsa] prepare: %w", err) + } + + return nil +} + +func (d *Device) Write(b []byte) (n int, err error) { + xfer := &snd_xferi{ + buf: uintptr(unsafe.Pointer(&b[0])), + frames: snd_pcm_uframes_t(len(b) / d.frameBytes), + } + err = ioctl(d.fd, SNDRV_PCM_IOCTL_WRITEI_FRAMES, xfer) + if err == syscall.EPIPE { + // auto handle underrun state + // https://stackoverflow.com/questions/59396728/how-to-properly-handle-xrun-in-alsa-programming-when-playing-audio-with-snd-pcm + err = ioctl(d.fd, SNDRV_PCM_IOCTL_PREPARE, nil) + } + n = int(xfer.result) * d.frameBytes + return +} + +func (d *Device) Read(b []byte) (n int, err error) { + xfer := &snd_xferi{ + buf: uintptr(unsafe.Pointer(&b[0])), + frames: snd_pcm_uframes_t(len(b) / d.frameBytes), + } + err = ioctl(d.fd, SNDRV_PCM_IOCTL_READI_FRAMES, xfer) + n = int(xfer.result) * d.frameBytes + return +} + +func (d *Device) init() { + for i := range d.hwparams.masks { + d.hwparams.masks[i].bits[0] = 0xFFFFFFFF + d.hwparams.masks[i].bits[1] = 0xFFFFFFFF + } + for i := range d.hwparams.intervals { + d.hwparams.intervals[i].max = 0xFFFFFFFF + } + + d.hwparams.rmask = 0xFFFFFFFF + d.hwparams.cmask = 0 + d.hwparams.info = 0xFFFFFFFF +} + +func (d *Device) setInterval(param, val uint32) { + d.hwparams.intervals[param-SNDRV_PCM_HW_PARAM_FIRST_INTERVAL].min = val + d.hwparams.intervals[param-SNDRV_PCM_HW_PARAM_FIRST_INTERVAL].max = val + d.hwparams.intervals[param-SNDRV_PCM_HW_PARAM_FIRST_INTERVAL].bit = 0b0100 // integer +} + +func (d *Device) setIntervalMin(param, val uint32) { + d.hwparams.intervals[param-SNDRV_PCM_HW_PARAM_FIRST_INTERVAL].min = val +} + +func (d *Device) getInterval(param uint32) (uint32, uint32) { + return d.hwparams.intervals[param-SNDRV_PCM_HW_PARAM_FIRST_INTERVAL].min, + d.hwparams.intervals[param-SNDRV_PCM_HW_PARAM_FIRST_INTERVAL].max +} + +func (d *Device) setMask(mask, val uint32) { + d.hwparams.masks[mask].bits[0] = 0 + d.hwparams.masks[mask].bits[1] = 0 + d.hwparams.masks[mask].bits[val>>5] = 1 << (val & 0x1F) +} + +func (d *Device) checkMask(mask, val uint32) bool { + return d.hwparams.masks[mask].bits[val>>5]&(1<<(val&0x1F)) > 0 +} diff --git a/installs_on_host/go2rtc/pkg/alsa/device/ioctl_linux.go b/installs_on_host/go2rtc/pkg/alsa/device/ioctl_linux.go new file mode 100644 index 0000000..1277a60 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/alsa/device/ioctl_linux.go @@ -0,0 +1,26 @@ +package device + +import ( + "bytes" + "reflect" + "syscall" +) + +func ioctl(fd, req uintptr, arg any) error { + var ptr uintptr + if arg != nil { + ptr = reflect.ValueOf(arg).Pointer() + } + _, _, err := syscall.Syscall(syscall.SYS_IOCTL, fd, req, ptr) + if err != 0 { + return err + } + return nil +} + +func str(b []byte) string { + if i := bytes.IndexByte(b, 0); i >= 0 { + return string(b[:i]) + } + return string(b) +} diff --git a/installs_on_host/go2rtc/pkg/alsa/open_linux.go b/installs_on_host/go2rtc/pkg/alsa/open_linux.go new file mode 100644 index 0000000..2e4c57b --- /dev/null +++ b/installs_on_host/go2rtc/pkg/alsa/open_linux.go @@ -0,0 +1,44 @@ +package alsa + +import ( + "errors" + "fmt" + "net/url" + + "github.com/AlexxIT/go2rtc/pkg/alsa/device" + "github.com/AlexxIT/go2rtc/pkg/core" +) + +func Open(rawURL string) (core.Producer, error) { + // Example (ffmpeg source compatible): + // alsa:device?audio=/dev/snd/pcmC0D0p + // TODO: ?audio=default + // TODO: ?audio=hw:0,0 + // TODO: &sample_rate=48000&channels=2 + // TODO: &backchannel=1 + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + path := u.Query().Get("audio") + dev, err := device.Open(path) + if err != nil { + return nil, err + } + + if !dev.CheckFormat(device.SNDRV_PCM_FORMAT_S16_LE) { + _ = dev.Close() + return nil, errors.New("alsa: format S16LE not supported") + } + + switch path[len(path)-1] { + case 'p': // playback + return newPlayback(dev) + case 'c': // capture + return newCapture(dev) + } + + _ = dev.Close() + return nil, fmt.Errorf("alsa: unknown path: %s", path) +} diff --git a/installs_on_host/go2rtc/pkg/alsa/playback_linux.go b/installs_on_host/go2rtc/pkg/alsa/playback_linux.go new file mode 100644 index 0000000..7fb214d --- /dev/null +++ b/installs_on_host/go2rtc/pkg/alsa/playback_linux.go @@ -0,0 +1,84 @@ +package alsa + +import ( + "fmt" + + "github.com/AlexxIT/go2rtc/pkg/alsa/device" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/pcm" + "github.com/pion/rtp" +) + +type Playback struct { + core.Connection + dev *device.Device + closed core.Waiter +} + +func newPlayback(dev *device.Device) (*Playback, error) { + medias := []*core.Media{ + { + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecPCML}, // support ffmpeg producer (auto transcode) + {Name: core.CodecPCMA, ClockRate: 8000}, // support webrtc producer + }, + }, + } + return &Playback{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "alsa", + Medias: medias, + Transport: dev, + }, + dev: dev, + }, nil +} + +func (p *Playback) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + return nil, core.ErrCantGetTrack +} + +func (p *Playback) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + src := track.Codec + dst := &core.Codec{ + Name: core.CodecPCML, + ClockRate: p.dev.GetRateNear(src.ClockRate), + Channels: p.dev.GetChannelsNear(src.Channels), + } + sender := core.NewSender(media, dst) + + sender.Handler = func(pkt *rtp.Packet) { + if n, err := p.dev.Write(pkt.Payload); err == nil { + p.Send += n + } + } + + if sender.Handler = pcm.TranscodeHandler(dst, src, sender.Handler); sender.Handler == nil { + return fmt.Errorf("alsa: can't convert %s to %s", src, dst) + } + + // typical card support: + // - Formats: S16_LE, S32_LE + // - ClockRates: 8000 - 192000 + // - Channels: 2 - 10 + err := p.dev.SetHWParams(device.SNDRV_PCM_FORMAT_S16_LE, dst.ClockRate, byte(dst.Channels)) + if err != nil { + return err + } + + sender.HandleRTP(track) + p.Senders = append(p.Senders, sender) + return nil +} + +func (p *Playback) Start() (err error) { + return p.closed.Wait() +} + +func (p *Playback) Stop() error { + p.closed.Done(nil) + return p.Connection.Stop() +} diff --git a/installs_on_host/go2rtc/pkg/ascii/README.md b/installs_on_host/go2rtc/pkg/ascii/README.md new file mode 100644 index 0000000..bc9ef9d --- /dev/null +++ b/installs_on_host/go2rtc/pkg/ascii/README.md @@ -0,0 +1,6 @@ +## Useful links + +- https://en.wikipedia.org/wiki/ANSI_escape_code +- https://paulbourke.net/dataformats/asciiart/ +- https://github.com/kutuluk/xterm-color-chart +- https://github.com/hugomd/parrot.live diff --git a/installs_on_host/go2rtc/pkg/ascii/ascii.go b/installs_on_host/go2rtc/pkg/ascii/ascii.go new file mode 100644 index 0000000..6636e27 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/ascii/ascii.go @@ -0,0 +1,173 @@ +package ascii + +import ( + "bytes" + "fmt" + "image/jpeg" + "io" + "net/http" + "unicode/utf8" +) + +func NewWriter(w io.Writer, foreground, background, text string) io.Writer { + // once clear screen + _, _ = w.Write([]byte(csiClear)) + + // every frame - move to home + a := &writer{wr: w, buf: []byte(csiHome)} + + // https://en.wikipedia.org/wiki/ANSI_escape_code + switch foreground { + case "": + case "8": + a.color = func(r, g, b uint8) { + idx := xterm256color(r, g, b, 8) + a.appendEsc(fmt.Sprintf("\033[%dm", 30+idx)) + + } + case "256": + a.color = func(r, g, b uint8) { + idx := xterm256color(r, g, b, 255) + a.appendEsc(fmt.Sprintf("\033[38;5;%dm", idx)) + } + case "rgb": + a.color = func(r, g, b uint8) { + a.appendEsc(fmt.Sprintf("\033[38;2;%d;%d;%dm", r, g, b)) + } + default: + a.buf = append(a.buf, "\033["+foreground+"m"...) + } + + switch background { + case "": + case "8": + a.color = func(r, g, b uint8) { + idx := xterm256color(r, g, b, 8) + a.appendEsc(fmt.Sprintf("\033[%dm", 40+idx)) + } + case "256": + a.color = func(r, g, b uint8) { + idx := xterm256color(r, g, b, 255) + a.appendEsc(fmt.Sprintf("\033[48;5;%dm", idx)) + } + case "rgb": + a.color = func(r, g, b uint8) { + a.appendEsc(fmt.Sprintf("\033[48;2;%d;%d;%dm", r, g, b)) + } + default: + a.buf = append(a.buf, "\033["+background+"m"...) + } + + a.pre = len(a.buf) // save prefix size + + if len(text) == 1 { + // fast 1 symbol version + a.text = func(_, _, _ uint32) { + a.buf = append(a.buf, text[0]) + } + } else { + switch text { + case "": + text = ` .::--~~==++**##%%$@` // default for empty text + case "block": + text = " ░░▒▒▓▓█" // https://en.wikipedia.org/wiki/Block_Elements + } + + if runes := []rune(text); len(runes) != len(text) { + k := float32(len(runes)-1) / 255 + a.text = func(r, g, b uint32) { + i := gray(r, g, b, k) + a.buf = utf8.AppendRune(a.buf, runes[i]) + } + } else { + k := float32(len(text)-1) / 255 + a.text = func(r, g, b uint32) { + i := gray(r, g, b, k) + a.buf = append(a.buf, text[i]) + } + } + } + + return a +} + +type writer struct { + wr io.Writer + buf []byte + pre int + esc string + color func(r, g, b uint8) + text func(r, g, b uint32) +} + +// https://stackoverflow.com/questions/37774983/clearing-the-screen-by-printing-a-character +const csiClear = "\033[2J" +const csiHome = "\033[H" + +func (a *writer) Write(p []byte) (n int, err error) { + img, err := jpeg.Decode(bytes.NewReader(p)) + if err != nil { + return 0, err + } + + a.buf = a.buf[:a.pre] // restore prefix + + w := img.Bounds().Dx() + h := img.Bounds().Dy() + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + r, g, b, _ := img.At(x, y).RGBA() + if a.color != nil { + a.color(uint8(r>>8), uint8(g>>8), uint8(b>>8)) + } + a.text(r, g, b) + } + a.buf = append(a.buf, '\n') + } + + a.appendEsc("\033[0m") + + if _, err = a.wr.Write(a.buf); err != nil { + return 0, err + } + + a.wr.(http.Flusher).Flush() + + return len(p), nil +} + +// appendEsc - append ESC code to buffer, and skip duplicates +func (a *writer) appendEsc(s string) { + if a.esc != s { + a.esc = s + a.buf = append(a.buf, s...) + } +} + +func gray(r, g, b uint32, k float32) uint8 { + gr := (19595*r + 38470*g + 7471*b + 1<<15) >> 24 // uint8 + return uint8(float32(gr) * k) +} + +const x256r = "\x00\x80\x00\x80\x00\x80\x00\xc0\x80\xff\x00\xff\x00\xff\x00\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x08\x12\x1c\x26\x30\x3a\x44\x4e\x58\x60\x66\x76\x80\x8a\x94\x9e\xa8\xb2\xbc\xc6\xd0\xda\xe4\xee" +const x256g = "\x00\x00\x80\x80\x00\x00\x80\xc0\x80\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x5f\x5f\x5f\x5f\x5f\x5f\x87\x87\x87\x87\x87\x87\xaf\xaf\xaf\xaf\xaf\xaf\xd7\xd7\xd7\xd7\xd7\xd7\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x5f\x5f\x5f\x5f\x5f\x5f\x87\x87\x87\x87\x87\x87\xaf\xaf\xaf\xaf\xaf\xaf\xd7\xd7\xd7\xd7\xd7\xd7\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x5f\x5f\x5f\x5f\x5f\x5f\x87\x87\x87\x87\x87\x87\xaf\xaf\xaf\xaf\xaf\xaf\xd7\xd7\xd7\xd7\xd7\xd7\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x5f\x5f\x5f\x5f\x5f\x5f\x87\x87\x87\x87\x87\x87\xaf\xaf\xaf\xaf\xaf\xaf\xd7\xd7\xd7\xd7\xd7\xd7\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x5f\x5f\x5f\x5f\x5f\x5f\x87\x87\x87\x87\x87\x87\xaf\xaf\xaf\xaf\xaf\xaf\xd7\xd7\xd7\xd7\xd7\xd7\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x5f\x5f\x5f\x5f\x5f\x5f\x87\x87\x87\x87\x87\x87\xaf\xaf\xaf\xaf\xaf\xaf\xd7\xd7\xd7\xd7\xd7\xd7\xff\xff\xff\xff\xff\xff\x08\x12\x1c\x26\x30\x3a\x44\x4e\x58\x60\x66\x76\x80\x8a\x94\x9e\xa8\xb2\xbc\xc6\xd0\xda\xe4\xee" +const x256b = "\x00\x00\x00\x00\x80\x80\x80\xc0\x80\x00\x00\x00\xff\xff\xff\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x08\x12\x1c\x26\x30\x3a\x44\x4e\x58\x60\x66\x76\x80\x8a\x94\x9e\xa8\xb2\xbc\xc6\xd0\xda\xe4\xee" + +func xterm256color(r, g, b uint8, n int) (index uint8) { + best := uint16(0xFFFF) + for i := 0; i < n; i++ { + diff := sqDiff(r, x256r[i]) + sqDiff(g, x256g[i]) + sqDiff(b, x256b[i]) + if diff < best { + best = diff + index = uint8(i) + } + } + return +} + +// sqDiff - just like from image/color/color.go +func sqDiff(x, y uint8) uint16 { + d := uint16(x - y) + //return d + return (d * d) >> 2 +} diff --git a/installs_on_host/go2rtc/pkg/bits/reader.go b/installs_on_host/go2rtc/pkg/bits/reader.go new file mode 100644 index 0000000..2a95740 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/bits/reader.go @@ -0,0 +1,143 @@ +package bits + +type Reader struct { + EOF bool // if end of buffer raised during reading + + buf []byte // total buf + byte byte // current byte + bits byte // bits left in byte + pos int // current pos in buf +} + +func NewReader(b []byte) *Reader { + return &Reader{buf: b} +} + +//goland:noinspection GoStandardMethods +func (r *Reader) ReadByte() byte { + if r.bits != 0 { + return r.ReadBits8(8) + } + + if r.pos >= len(r.buf) { + r.EOF = true + return 0 + } + + b := r.buf[r.pos] + r.pos++ + return b +} + +func (r *Reader) ReadUint16() uint16 { + if r.bits != 0 { + return r.ReadBits16(16) + } + return uint16(r.ReadByte())<<8 | uint16(r.ReadByte()) +} + +func (r *Reader) ReadUint24() uint32 { + if r.bits != 0 { + return r.ReadBits(24) + } + return uint32(r.ReadByte())<<16 | uint32(r.ReadByte())<<8 | uint32(r.ReadByte()) +} + +func (r *Reader) ReadUint32() uint32 { + if r.bits != 0 { + return r.ReadBits(32) + } + return uint32(r.ReadByte())<<24 | uint32(r.ReadByte())<<16 | uint32(r.ReadByte())<<8 | uint32(r.ReadByte()) +} + +func (r *Reader) ReadBit() byte { + if r.bits == 0 { + r.byte = r.ReadByte() + r.bits = 7 + } else { + r.bits-- + } + + return (r.byte >> r.bits) & 0b1 +} + +func (r *Reader) ReadBits(n byte) (res uint32) { + for i := n - 1; i != 255; i-- { + res |= uint32(r.ReadBit()) << i + } + return +} + +func (r *Reader) ReadBits8(n byte) (res uint8) { + for i := n - 1; i != 255; i-- { + res |= r.ReadBit() << i + } + return +} + +func (r *Reader) ReadBits16(n byte) (res uint16) { + for i := n - 1; i != 255; i-- { + res |= uint16(r.ReadBit()) << i + } + return +} + +func (r *Reader) ReadBits64(n byte) (res uint64) { + for i := n - 1; i != 255; i-- { + res |= uint64(r.ReadBit()) << i + } + return +} + +func (r *Reader) ReadFloat32() float64 { + i := r.ReadUint16() + f := r.ReadUint16() + return float64(i) + float64(f)/65536 +} + +func (r *Reader) ReadBytes(n int) (b []byte) { + if r.bits == 0 { + if r.pos+n > len(r.buf) { + r.EOF = true + return nil + } + + b = r.buf[r.pos : r.pos+n] + r.pos += n + } else { + b = make([]byte, n) + for i := 0; i < n; i++ { + b[i] = r.ReadByte() + } + } + + return +} + +// ReadUEGolomb - ReadExponentialGolomb (unsigned) +func (r *Reader) ReadUEGolomb() uint32 { + var size byte + for size = 0; size < 32; size++ { + if b := r.ReadBit(); b != 0 || r.EOF { + break + } + } + return r.ReadBits(size) + (1 << size) - 1 +} + +// ReadSEGolomb - ReadSignedExponentialGolomb +func (r *Reader) ReadSEGolomb() int32 { + if b := r.ReadUEGolomb(); b%2 == 0 { + return -int32(b / 2) + } else { + return int32((b + 1) / 2) + } +} + +func (r *Reader) Left() []byte { + return r.buf[r.pos:] +} + +func (r *Reader) Pos() (int, byte) { + return r.pos - 1, r.bits +} diff --git a/installs_on_host/go2rtc/pkg/bits/writer.go b/installs_on_host/go2rtc/pkg/bits/writer.go new file mode 100644 index 0000000..307166b --- /dev/null +++ b/installs_on_host/go2rtc/pkg/bits/writer.go @@ -0,0 +1,95 @@ +package bits + +type Writer struct { + buf []byte // total buf + byte *byte // pointer to current byte + bits byte // bits left in byte +} + +func NewWriter(buf []byte) *Writer { + return &Writer{buf: buf} +} + +//goland:noinspection GoStandardMethods +func (w *Writer) WriteByte(b byte) { + if w.bits != 0 { + w.WriteBits8(b, 8) + } + + w.buf = append(w.buf, b) +} + +func (w *Writer) WriteBit(b byte) { + if w.bits == 0 { + w.buf = append(w.buf, 0) + w.byte = &w.buf[len(w.buf)-1] + w.bits = 7 + } else { + w.bits-- + } + + *w.byte |= (b & 1) << w.bits +} + +func (w *Writer) WriteBits(v uint32, n byte) { + for i := n - 1; i != 255; i-- { + w.WriteBit(byte(v>>i) & 0b1) + } +} + +func (w *Writer) WriteBits16(v uint16, n byte) { + for i := n - 1; i != 255; i-- { + w.WriteBit(byte(v>>i) & 0b1) + } +} + +func (w *Writer) WriteBits8(v, n byte) { + for i := n - 1; i != 255; i-- { + w.WriteBit((v >> i) & 0b1) + } +} + +func (w *Writer) WriteAllBits(bit, n byte) { + for i := byte(0); i < n; i++ { + w.WriteBit(bit) + } +} + +func (w *Writer) WriteBool(b bool) { + if b { + w.WriteBit(1) + } else { + w.WriteBit(0) + } +} + +func (w *Writer) WriteUint16(v uint16) { + if w.bits != 0 { + w.WriteBits16(v, 16) + } + + w.buf = append(w.buf, byte(v>>8), byte(v)) +} + +func (w *Writer) WriteBytes(bytes ...byte) { + if w.bits != 0 { + for _, b := range bytes { + w.WriteByte(b) + } + } + + w.buf = append(w.buf, bytes...) +} + +func (w *Writer) Bytes() []byte { + return w.buf +} + +func (w *Writer) Len() int { + return len(w.buf) +} + +func (w *Writer) Reset() { + w.buf = w.buf[:0] + w.bits = 0 +} diff --git a/installs_on_host/go2rtc/pkg/bubble/client.go b/installs_on_host/go2rtc/pkg/bubble/client.go new file mode 100644 index 0000000..7a71d55 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/bubble/client.go @@ -0,0 +1,266 @@ +// Package bubble, because: +// Request URL: /bubble/live?ch=0&stream=0 +// Response Conten-Type: video/bubble +// https://github.com/Lynch234ok/lynch-git/blob/master/app_rebulid/src/bubble.c +package bubble + +import ( + "bufio" + "encoding/binary" + "errors" + "io" + "net" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264/annexb" + "github.com/AlexxIT/go2rtc/pkg/tcp" + "github.com/pion/rtp" +) + +// Deprecated: should be rewritten to core.Connection +type Client struct { + core.Listener + + url string + conn net.Conn + + videoCodec string + channel int + stream int + + r *bufio.Reader + + medias []*core.Media + receivers []*core.Receiver + + videoTrack *core.Receiver + audioTrack *core.Receiver + + recv int +} + +func Dial(rawURL string) (*Client, error) { + client := &Client{url: rawURL} + if err := client.Dial(); err != nil { + return nil, err + } + return client, nil +} + +const ( + SyncByte = 0xAA + PacketAuth = 0x00 + PacketMedia = 0x01 + PacketStart = 0x0A +) + +const Timeout = time.Second * 5 + +func (c *Client) Dial() (err error) { + u, err := url.Parse(c.url) + if err != nil { + return + } + + if c.conn, err = net.DialTimeout("tcp", u.Host, Timeout); err != nil { + return + } + + if err = c.conn.SetDeadline(time.Now().Add(Timeout)); err != nil { + return + } + + req := &tcp.Request{Method: "GET", URL: &url.URL{Path: u.Path, RawQuery: u.RawQuery}, Proto: "HTTP/1.1"} + if err = req.Write(c.conn); err != nil { + return + } + + c.r = bufio.NewReader(c.conn) + res, err := tcp.ReadResponse(c.r) + if err != nil { + return + } + + if res.StatusCode != http.StatusOK { + return errors.New("wrong response: " + res.Status) + } + + // 1. Read 1024 bytes with XML, some cameras returns exact 1024, but some - 923 + xml := make([]byte, 1024) + if _, err = c.r.Read(xml); err != nil { + return + } + + // 2. Write size uint32 + unknown 4b + user 20b + pass 20b + b := make([]byte, 48) + binary.BigEndian.PutUint32(b, 44) + + if u.User != nil { + copy(b[8:], u.User.Username()) + pass, _ := u.User.Password() + copy(b[28:], pass) + } else { + copy(b[8:], "admin") + } + + if err = c.Write(PacketAuth, 0x0E16C271, b); err != nil { + return + } + + // 3. Read response + cmd, b, err := c.Read() + if err != nil { + return + } + + if cmd != PacketAuth || len(b) != 44 || b[4] != 3 || b[8] != 1 { + return errors.New("wrong auth response") + } + + // 4. Parse XML (from 1) + query := u.Query() + + stream := query.Get("stream") + if stream != "" { + c.stream = core.Atoi(stream) + } else { + stream = "0" + } + + // + // + // + // + // + re := regexp.MustCompile("]+") + stream = re.FindString(string(xml)) + if strings.Contains(stream, ".265") { + c.videoCodec = core.CodecH265 + } else { + c.videoCodec = core.CodecH264 + } + + if ch := query.Get("ch"); ch != "" { + c.channel = core.Atoi(ch) + } + + return +} + +func (c *Client) Write(command byte, timestamp uint32, payload []byte) error { + if err := c.conn.SetWriteDeadline(time.Now().Add(Timeout)); err != nil { + return err + } + + // 0xAA + size uint32 + cmd byte + ts uint32 + payload + b := make([]byte, 14+len(payload)) + b[0] = SyncByte + binary.BigEndian.PutUint32(b[1:], uint32(5+len(payload))) + b[5] = command + binary.BigEndian.PutUint32(b[6:], timestamp) + copy(b[10:], payload) + + _, err := c.conn.Write(b) + return err +} + +func (c *Client) Read() (byte, []byte, error) { + if err := c.conn.SetReadDeadline(time.Now().Add(Timeout)); err != nil { + return 0, nil, err + } + + // 0xAA + size uint32 + cmd byte + ts uint32 + payload + b := make([]byte, 10) + if _, err := io.ReadFull(c.r, b); err != nil { + return 0, nil, err + } + + if b[0] != SyncByte { + return 0, nil, errors.New("wrong start byte") + } + + size := binary.BigEndian.Uint32(b[1:]) + payload := make([]byte, size-1-4) + if _, err := io.ReadFull(c.r, payload); err != nil { + return 0, nil, err + } + + //timestamp := binary.BigEndian.Uint32(b[6:]) // in ms + + return b[5], payload, nil +} + +func (c *Client) Play() error { + // yeah, there's no mistake about the little endian + b := make([]byte, 16) + binary.LittleEndian.PutUint32(b, uint32(c.channel)) + binary.LittleEndian.PutUint32(b[4:], uint32(c.stream)) + binary.LittleEndian.PutUint32(b[8:], 1) // opened + return c.Write(PacketStart, 0x0E16C2DF, b) +} + +func (c *Client) Handle() error { + var audioTS uint32 + + for { + cmd, b, err := c.Read() + if err != nil { + return err + } + + c.recv += len(b) + + if cmd != PacketMedia { + continue + } + + // size uint32 + type 1b + channel 1b + // type = 1 for keyframe, 2 for other frame, 0 for audio + + if b[4] > 0 { + if c.videoTrack == nil { + continue + } + + pkt := &rtp.Packet{ + Header: rtp.Header{ + Timestamp: core.Now90000(), + }, + Payload: annexb.EncodeToAVCC(b[6:]), + } + c.videoTrack.WriteRTP(pkt) + } else { + if c.audioTrack == nil { + continue + } + + //binary.LittleEndian.Uint32(b[6:]) // entries (always 1) + //size := binary.LittleEndian.Uint32(b[10:]) // size + //mk := binary.LittleEndian.Uint64(b[14:]) // pts (uint64_t) + //binary.LittleEndian.Uint32(b[22:]) // gtime (time_t) + //name := b[26:34] // g711 + //rate := binary.LittleEndian.Uint32(b[34:]) // sample rate + //width := binary.LittleEndian.Uint32(b[38:]) // samplewidth + + pkt := &rtp.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: true, + Timestamp: audioTS, + }, + Payload: b[6+36:], + } + audioTS += uint32(len(pkt.Payload)) + c.audioTrack.WriteRTP(pkt) + } + } +} + +func (c *Client) Close() error { + return c.conn.Close() +} diff --git a/installs_on_host/go2rtc/pkg/bubble/producer.go b/installs_on_host/go2rtc/pkg/bubble/producer.go new file mode 100644 index 0000000..9fa18f2 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/bubble/producer.go @@ -0,0 +1,80 @@ +package bubble + +import ( + "encoding/json" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +func (c *Client) GetMedias() []*core.Media { + if c.medias == nil { + c.medias = []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + {Name: c.videoCodec, ClockRate: 90000, PayloadType: core.PayloadTypeRAW}, + }, + }, + { + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + {Name: core.CodecPCMA, ClockRate: 8000, PayloadType: 8}, + }, + }, + } + } + + return c.medias +} + +func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + for _, track := range c.receivers { + if track.Codec == codec { + return track, nil + } + } + + track := core.NewReceiver(media, codec) + + switch media.Kind { + case core.KindVideo: + c.videoTrack = track + case core.KindAudio: + c.audioTrack = track + } + + c.receivers = append(c.receivers, track) + + return track, nil +} + +func (c *Client) Start() error { + if err := c.Play(); err != nil { + return err + } + return c.Handle() +} + +func (c *Client) Stop() error { + for _, receiver := range c.receivers { + receiver.Close() + } + return c.Close() +} + +func (c *Client) MarshalJSON() ([]byte, error) { + info := &core.Connection{ + ID: core.ID(c), + FormatName: "bubble", + Protocol: "http", + Medias: c.medias, + Recv: c.recv, + Receivers: c.receivers, + } + if c.conn != nil { + info.RemoteAddr = c.conn.RemoteAddr().String() + } + return json.Marshal(info) +} diff --git a/installs_on_host/go2rtc/pkg/core/README.md b/installs_on_host/go2rtc/pkg/core/README.md new file mode 100644 index 0000000..7f6faca --- /dev/null +++ b/installs_on_host/go2rtc/pkg/core/README.md @@ -0,0 +1,40 @@ +## PCM + +**RTSP** + +- PayloadType=10 - L16/44100/2 - Linear PCM 16-bit big endian +- PayloadType=11 - L16/44100/1 - Linear PCM 16-bit big endian + +https://en.wikipedia.org/wiki/RTP_payload_formats + +**Apple QuickTime** + +- `raw` - 16-bit data is stored in little endian format +- `twos` - 16-bit data is stored in big endian format +- `sowt` - 16-bit data is stored in little endian format +- `in24` - denotes 24-bit, big endian +- `in32` - denotes 32-bit, big endian +- `fl32` - denotes 32-bit floating point PCM +- `fl64` - denotes 64-bit floating point PCM +- `alaw` - denotes A-law logarithmic PCM +- `ulaw` - denotes mu-law logarithmic PCM + +https://wiki.multimedia.cx/index.php/PCM + +**FFmpeg RTSP** + +``` +pcm_s16be, 44100 Hz, stereo => 10 +pcm_s16be, 48000 Hz, stereo => 96 L16/48000/2 +pcm_s16be, 44100 Hz, mono => 11 + +pcm_s16le, 48000 Hz, stereo => 96 (b=AS:1536) +pcm_s16le, 44100 Hz, stereo => 96 (b=AS:1411) +pcm_s16le, 16000 Hz, stereo => 96 (b=AS:512) +pcm_s16le, 8000 Hz, stereo => 96 (b=AS:256) + +pcm_s16le, 48000 Hz, mono => 96 (b=AS:768) +pcm_s16le, 44100 Hz, mono => 96 (b=AS:705) +pcm_s16le, 16000 Hz, mono => 96 (b=AS:256) +pcm_s16le, 8000 Hz, mono => 96 (b=AS:128) +``` \ No newline at end of file diff --git a/installs_on_host/go2rtc/pkg/core/codec.go b/installs_on_host/go2rtc/pkg/core/codec.go new file mode 100644 index 0000000..11276bc --- /dev/null +++ b/installs_on_host/go2rtc/pkg/core/codec.go @@ -0,0 +1,284 @@ +package core + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "unicode" + + "github.com/pion/sdp/v3" +) + +type Codec struct { + Name string // H264, PCMU, PCMA, opus... + ClockRate uint32 // 90000, 8000, 16000... + Channels uint8 // 0, 1, 2 + FmtpLine string + PayloadType uint8 +} + +// MarshalJSON - return FFprobe compatible output +func (c *Codec) MarshalJSON() ([]byte, error) { + info := map[string]any{} + if name := FFmpegCodecName(c.Name); name != "" { + info["codec_name"] = name + info["codec_type"] = c.Kind() + } + if c.Name == CodecH264 { + profile, level := DecodeH264(c.FmtpLine) + if profile != "" { + info["profile"] = profile + info["level"] = level + } + } + if c.ClockRate != 0 && c.ClockRate != 90000 { + info["sample_rate"] = c.ClockRate + } + if c.Channels > 0 { + info["channels"] = c.Channels + } + return json.Marshal(info) +} + +func FFmpegCodecName(name string) string { + switch name { + case CodecH264: + return "h264" + case CodecH265: + return "hevc" + case CodecJPEG: + return "mjpeg" + case CodecRAW: + return "rawvideo" + case CodecPCMA: + return "pcm_alaw" + case CodecPCMU: + return "pcm_mulaw" + case CodecPCM: + return "pcm_s16be" + case CodecPCML: + return "pcm_s16le" + case CodecAAC: + return "aac" + case CodecOpus: + return "opus" + case CodecVP8: + return "vp8" + case CodecVP9: + return "vp9" + case CodecAV1: + return "av1" + case CodecELD: + return "aac/eld" + case CodecFLAC: + return "flac" + case CodecMP3: + return "mp3" + } + return name +} + +func (c *Codec) String() (s string) { + s = c.Name + if c.ClockRate != 0 && c.ClockRate != 90000 { + s += fmt.Sprintf("/%d", c.ClockRate) + } + if c.Channels > 0 { + s += fmt.Sprintf("/%d", c.Channels) + } + return +} + +func (c *Codec) IsRTP() bool { + return c.PayloadType != PayloadTypeRAW +} + +func (c *Codec) IsVideo() bool { + return c.Kind() == KindVideo +} + +func (c *Codec) IsAudio() bool { + return c.Kind() == KindAudio +} + +func (c *Codec) Kind() string { + return GetKind(c.Name) +} + +func (c *Codec) PrintName() string { + switch c.Name { + case CodecAAC: + return "AAC" + case CodecPCM: + return "S16B" + case CodecPCML: + return "S16L" + } + return c.Name +} + +func (c *Codec) Clone() *Codec { + clone := *c + return &clone +} + +func (c *Codec) Match(remote *Codec) bool { + switch remote.Name { + case CodecAll, CodecAny: + return true + } + + return c.Name == remote.Name && + (c.ClockRate == remote.ClockRate || remote.ClockRate == 0) && + (c.Channels == remote.Channels || remote.Channels == 0) +} + +func UnmarshalCodec(md *sdp.MediaDescription, payloadType string) *Codec { + c := &Codec{PayloadType: byte(Atoi(payloadType))} + + for _, attr := range md.Attributes { + switch { + case c.Name == "" && attr.Key == "rtpmap" && strings.HasPrefix(attr.Value, payloadType): + i := strings.IndexByte(attr.Value, ' ') + ss := strings.Split(attr.Value[i+1:], "/") + + c.Name = strings.ToUpper(ss[0]) + // fix tailing space: `a=rtpmap:96 H264/90000 ` + c.ClockRate = uint32(Atoi(strings.TrimRightFunc(ss[1], unicode.IsSpace))) + + if len(ss) == 3 && ss[2] == "2" { + c.Channels = 2 + } + case c.FmtpLine == "" && attr.Key == "fmtp" && strings.HasPrefix(attr.Value, payloadType): + if i := strings.IndexByte(attr.Value, ' '); i > 0 { + c.FmtpLine = attr.Value[i+1:] + } + } + } + + switch c.Name { + case "PCM": + // https://www.reddit.com/r/Hikvision/comments/17elxex/comment/k642g2r/ + // check pkg/rtsp/rtsp_test.go TestHikvisionPCM + c.Name = CodecPCML + case "": + // https://en.wikipedia.org/wiki/RTP_payload_formats + switch payloadType { + case "0": + c.Name = CodecPCMU + c.ClockRate = 8000 + case "8": + c.Name = CodecPCMA + c.ClockRate = 8000 + case "10": + c.Name = CodecPCM + c.ClockRate = 44100 + c.Channels = 2 + case "11": + c.Name = CodecPCM + c.ClockRate = 44100 + case "14": + c.Name = CodecMP3 + c.ClockRate = 90000 // it's not real sample rate + case "26": + c.Name = CodecJPEG + c.ClockRate = 90000 + case "96", "97", "98": + if len(md.Bandwidth) == 0 { + c.Name = payloadType + break + } + + // FFmpeg + RTSP + pcm_s16le = doesn't pass info about codec name and params + // so try to guess the codec based on bitrate + // https://github.com/AlexxIT/go2rtc/issues/523 + switch md.Bandwidth[0].Bandwidth { + case 128: + c.ClockRate = 8000 + case 256: + c.ClockRate = 16000 + case 384: + c.ClockRate = 24000 + case 512: + c.ClockRate = 32000 + case 705: + c.ClockRate = 44100 + case 768: + c.ClockRate = 48000 + case 1411: + // default Windows DShow + c.ClockRate = 44100 + c.Channels = 2 + case 1536: + // default Linux ALSA + c.ClockRate = 48000 + c.Channels = 2 + default: + c.Name = payloadType + break + } + + c.Name = CodecPCML + default: + c.Name = payloadType + } + } + + return c +} + +func DecodeH264(fmtp string) (profile string, level byte) { + if ps := Between(fmtp, "sprop-parameter-sets=", ","); ps != "" { + if sps, _ := base64.StdEncoding.DecodeString(ps); len(sps) >= 4 { + switch sps[1] { + case 0x42: + profile = "Baseline" + case 0x4D: + profile = "Main" + case 0x58: + profile = "Extended" + case 0x64: + profile = "High" + default: + profile = fmt.Sprintf("0x%02X", sps[1]) + } + + level = sps[3] + } + } + return +} + +func ParseCodecString(s string) *Codec { + var codec Codec + + ss := strings.Split(s, "/") + switch strings.ToLower(ss[0]) { + case "pcm_s16be", "s16be", "pcm": + codec.Name = CodecPCM + case "pcm_s16le", "s16le", "pcml": + codec.Name = CodecPCML + case "pcm_alaw", "alaw", "pcma", "g711a": + codec.Name = CodecPCMA + case "pcm_mulaw", "mulaw", "pcmu", "g711u": + codec.Name = CodecPCMU + case "aac", "mpeg4-generic": + codec.Name = CodecAAC + case "opus": + codec.Name = CodecOpus + case "flac": + codec.Name = CodecFLAC + default: + return nil + } + + if len(ss) >= 2 { + codec.ClockRate = uint32(Atoi(ss[1])) + } + if len(ss) >= 3 { + codec.Channels = uint8(Atoi(ss[2])) + } + + return &codec +} diff --git a/installs_on_host/go2rtc/pkg/core/connection.go b/installs_on_host/go2rtc/pkg/core/connection.go new file mode 100644 index 0000000..cc0f43e --- /dev/null +++ b/installs_on_host/go2rtc/pkg/core/connection.go @@ -0,0 +1,144 @@ +package core + +import ( + "io" + "net/http" + "reflect" + "sync/atomic" +) + +func NewID() uint32 { + return id.Add(1) +} + +// Deprecated: use NewID instead +func ID(v any) uint32 { + p := uintptr(reflect.ValueOf(v).UnsafePointer()) + return 0x8000_0000 | uint32(p) +} + +var id atomic.Uint32 + +type Info interface { + SetProtocol(string) + SetRemoteAddr(string) + SetSource(string) + SetURL(string) + WithRequest(*http.Request) + GetSource() string +} + +// Connection just like webrtc.PeerConnection +// - ID and RemoteAddr used for building Connection(s) graph +// - FormatName, Protocol, RemoteAddr, Source, URL, SDP, UserAgent used for info about Connection +// - FormatName and Protocol has FFmpeg compatible names +// - Transport used for auto closing on Stop +type Connection struct { + ID uint32 `json:"id,omitempty"` + FormatName string `json:"format_name,omitempty"` // rtsp, webrtc, mp4, mjpeg, mpjpeg... + Protocol string `json:"protocol,omitempty"` // tcp, udp, http, ws, pipe... + RemoteAddr string `json:"remote_addr,omitempty"` // host:port other info + Source string `json:"source,omitempty"` + URL string `json:"url,omitempty"` + SDP string `json:"sdp,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + + Medias []*Media `json:"medias,omitempty"` + Receivers []*Receiver `json:"receivers,omitempty"` + Senders []*Sender `json:"senders,omitempty"` + Recv int `json:"bytes_recv,omitempty"` + Send int `json:"bytes_send,omitempty"` + + Transport any `json:"-"` +} + +func (c *Connection) GetMedias() []*Media { + return c.Medias +} + +func (c *Connection) GetTrack(media *Media, codec *Codec) (*Receiver, error) { + for _, receiver := range c.Receivers { + if receiver.Codec == codec { + return receiver, nil + } + } + receiver := NewReceiver(media, codec) + c.Receivers = append(c.Receivers, receiver) + return receiver, nil +} + +func (c *Connection) Stop() error { + for _, receiver := range c.Receivers { + receiver.Close() + } + for _, sender := range c.Senders { + sender.Close() + } + if closer, ok := c.Transport.(io.Closer); ok { + return closer.Close() + } + return nil +} + +// Deprecated: +func (c *Connection) Codecs() []*Codec { + codecs := make([]*Codec, len(c.Senders)) + for i, sender := range c.Senders { + codecs[i] = sender.Codec + } + return codecs +} + +func (c *Connection) SetProtocol(s string) { + c.Protocol = s +} + +func (c *Connection) SetRemoteAddr(s string) { + if c.RemoteAddr == "" { + c.RemoteAddr = s + } else { + c.RemoteAddr += " forwarded " + s + } +} + +func (c *Connection) SetSource(s string) { + c.Source = s +} + +func (c *Connection) SetURL(s string) { + c.URL = s +} + +func (c *Connection) WithRequest(r *http.Request) { + if r.Header.Get("Upgrade") == "websocket" { + c.Protocol = "ws" + } else { + c.Protocol = "http" + } + + c.RemoteAddr = r.RemoteAddr + if remote := r.Header.Get("X-Forwarded-For"); remote != "" { + c.RemoteAddr += " forwarded " + remote + } + + c.UserAgent = r.UserAgent() +} + +func (c *Connection) GetSource() string { + return c.Source +} + +// Create like os.Create, init Consumer with existing Transport +func Create(w io.Writer) (*Connection, error) { + return &Connection{Transport: w}, nil +} + +// Open like os.Open, init Producer from existing Transport +func Open(r io.Reader) (*Connection, error) { + return &Connection{Transport: r}, nil +} + +// Dial like net.Dial, init Producer via Dialing +func Dial(rawURL string) (*Connection, error) { + return &Connection{}, nil +} diff --git a/installs_on_host/go2rtc/pkg/core/core.go b/installs_on_host/go2rtc/pkg/core/core.go new file mode 100644 index 0000000..9555ecf --- /dev/null +++ b/installs_on_host/go2rtc/pkg/core/core.go @@ -0,0 +1,97 @@ +package core + +import "encoding/json" + +const ( + DirectionRecvonly = "recvonly" + DirectionSendonly = "sendonly" + DirectionSendRecv = "sendrecv" +) + +const ( + KindVideo = "video" + KindAudio = "audio" +) + +const ( + CodecH264 = "H264" // payloadType: 96 + CodecH265 = "H265" + CodecVP8 = "VP8" + CodecVP9 = "VP9" + CodecAV1 = "AV1" + CodecJPEG = "JPEG" // payloadType: 26 + CodecRAW = "RAW" + + CodecPCMU = "PCMU" // payloadType: 0 + CodecPCMA = "PCMA" // payloadType: 8 + CodecAAC = "MPEG4-GENERIC" + CodecOpus = "OPUS" // payloadType: 111 + CodecG722 = "G722" + CodecMP3 = "MPA" // payload: 14, aka MPEG-1 Layer III + CodecPCM = "L16" // Linear PCM (big endian) + + CodecPCML = "PCML" // Linear PCM (little endian) + + CodecELD = "ELD" // AAC-ELD + CodecFLAC = "FLAC" + + CodecAll = "ALL" + CodecAny = "ANY" +) + +const PayloadTypeRAW byte = 255 + +type Producer interface { + // GetMedias - return Media(s) with local Media.Direction: + // - recvonly for Producer Video/Audio + // - sendonly for Producer backchannel + GetMedias() []*Media + + // GetTrack - return Receiver, that can only produce rtp.Packet(s) + GetTrack(media *Media, codec *Codec) (*Receiver, error) + + // Deprecated: rename to Run() + Start() error + + // Deprecated: rename to Close() + Stop() error +} + +type Consumer interface { + // GetMedias - return Media(s) with local Media.Direction: + // - sendonly for Consumer Video/Audio + // - recvonly for Consumer backchannel + GetMedias() []*Media + + AddTrack(media *Media, codec *Codec, track *Receiver) error + + // Deprecated: rename to Close() + Stop() error +} + +type Mode byte + +const ( + ModeActiveProducer Mode = iota + 1 // typical source (client) + ModePassiveConsumer + ModePassiveProducer + ModeActiveConsumer +) + +func (m Mode) String() string { + switch m { + case ModeActiveProducer: + return "active producer" + case ModePassiveConsumer: + return "passive consumer" + case ModePassiveProducer: + return "passive producer" + case ModeActiveConsumer: + return "active consumer" + } + return "unknown" +} + +func (m Mode) MarshalJSON() ([]byte, error) { + return json.Marshal(m.String()) +} diff --git a/installs_on_host/go2rtc/pkg/core/core_test.go b/installs_on_host/go2rtc/pkg/core/core_test.go new file mode 100644 index 0000000..e7845ca --- /dev/null +++ b/installs_on_host/go2rtc/pkg/core/core_test.go @@ -0,0 +1,134 @@ +package core + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +type producer struct { + Medias []*Media + Receivers []*Receiver + + id byte +} + +func (p *producer) GetMedias() []*Media { + return p.Medias +} + +func (p *producer) GetTrack(_ *Media, codec *Codec) (*Receiver, error) { + for _, receiver := range p.Receivers { + if receiver.Codec == codec { + return receiver, nil + } + } + receiver := NewReceiver(nil, codec) + p.Receivers = append(p.Receivers, receiver) + return receiver, nil +} + +func (p *producer) Start() error { + pkt := &Packet{Payload: []byte{p.id}} + p.Receivers[0].Input(pkt) + return nil +} + +func (p *producer) Stop() error { + for _, receiver := range p.Receivers { + receiver.Close() + } + return nil +} + +type consumer struct { + Medias []*Media + Senders []*Sender + + cache chan byte +} + +func (c *consumer) GetMedias() []*Media { + return c.Medias +} + +func (c *consumer) AddTrack(_ *Media, _ *Codec, track *Receiver) error { + c.cache = make(chan byte, 1) + sender := NewSender(nil, track.Codec) + sender.Output = func(packet *Packet) { + c.cache <- packet.Payload[0] + } + sender.HandleRTP(track) + c.Senders = append(c.Senders, sender) + return nil +} + +func (c *consumer) Stop() error { + for _, sender := range c.Senders { + sender.Close() + } + return nil +} + +func (c *consumer) read() byte { + return <-c.cache +} + +func TestName(t *testing.T) { + GetProducer := func(b byte) Producer { + return &producer{ + Medias: []*Media{ + { + Kind: KindVideo, + Direction: DirectionRecvonly, + Codecs: []*Codec{ + {Name: CodecH264}, + }, + }, + }, + id: b, + } + } + + // stage1 + prod1 := GetProducer(1) + cons2 := &consumer{} + + media1 := prod1.GetMedias()[0] + track1, _ := prod1.GetTrack(media1, media1.Codecs[0]) + + _ = cons2.AddTrack(nil, nil, track1) + + _ = prod1.Start() + require.Equal(t, byte(1), cons2.read()) + + // stage2 + prod2 := GetProducer(2) + media2 := prod2.GetMedias()[0] + require.NotEqual(t, fmt.Sprintf("%p", media1), fmt.Sprintf("%p", media2)) + track2, _ := prod2.GetTrack(media2, media2.Codecs[0]) + track1.Replace(track2) + + _ = prod1.Stop() + + _ = prod2.Start() + require.Equal(t, byte(2), cons2.read()) + + // stage3 + _ = prod2.Stop() +} + +func TestStripUserinfo(t *testing.T) { + s := `streams: + test: + - ffmpeg:rtsp://username:password@10.1.2.3:554/stream1 + - ffmpeg:rtsp://10.1.2.3:554/stream1@#video=copy +` + s = StripUserinfo(s) + require.Equal(t, `streams: + test: + - ffmpeg:rtsp://***@10.1.2.3:554/stream1 + - ffmpeg:rtsp://10.1.2.3:554/stream1@#video=copy +`, s) +} diff --git a/installs_on_host/go2rtc/pkg/core/helpers.go b/installs_on_host/go2rtc/pkg/core/helpers.go new file mode 100644 index 0000000..45bbd0d --- /dev/null +++ b/installs_on_host/go2rtc/pkg/core/helpers.go @@ -0,0 +1,94 @@ +package core + +import ( + "crypto/rand" + "runtime" + "strconv" + "strings" + "time" +) + +const ( + BufferSize = 64 * 1024 // 64K + ConnDialTimeout = 5 * time.Second + ConnDeadline = 5 * time.Second + ProbeTimeout = 5 * time.Second +) + +// Now90000 - timestamp for Video (clock rate = 90000 samples per second) +func Now90000() uint32 { + return uint32(time.Duration(time.Now().UnixNano()) * 90000 / time.Second) +} + +const symbols = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_" + +// RandString base10 - numbers, base16 - hex, base36 - digits+letters +// base64 - URL safe symbols, base0 - crypto random +func RandString(size, base byte) string { + b := make([]byte, size) + if _, err := rand.Read(b); err != nil { + panic(err) + } + if base == 0 { + return string(b) + } + for i := byte(0); i < size; i++ { + b[i] = symbols[b[i]%base] + } + return string(b) +} + +func Before(s, sep string) string { + if i := strings.Index(s, sep); i > 0 { + return s[:i] + } + return s +} + +func Between(s, sub1, sub2 string) string { + i := strings.Index(s, sub1) + if i < 0 { + return "" + } + s = s[i+len(sub1):] + + if i = strings.Index(s, sub2); i >= 0 { + return s[:i] + } + + return s +} + +func Atoi(s string) (i int) { + if s != "" { + i, _ = strconv.Atoi(s) + } + return +} + +// ParseByte - fast parsing string to byte function +func ParseByte(s string) (b byte) { + for i, ch := range []byte(s) { + ch -= '0' + if ch > 9 { + return 0 + } + if i > 0 { + b *= 10 + } + b += ch + } + return +} + +func Assert(ok bool) { + if !ok { + _, file, line, _ := runtime.Caller(1) + panic(file + ":" + strconv.Itoa(line)) + } +} + +func Caller() string { + _, file, line, _ := runtime.Caller(1) + return file + ":" + strconv.Itoa(line) +} diff --git a/installs_on_host/go2rtc/pkg/core/listener.go b/installs_on_host/go2rtc/pkg/core/listener.go new file mode 100644 index 0000000..75d9202 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/core/listener.go @@ -0,0 +1,18 @@ +package core + +type EventFunc func(msg any) + +// Listener base struct for all classes with support feedback +type Listener struct { + events []EventFunc +} + +func (l *Listener) Listen(f EventFunc) { + l.events = append(l.events, f) +} + +func (l *Listener) Fire(msg any) { + for _, f := range l.events { + f(msg) + } +} diff --git a/installs_on_host/go2rtc/pkg/core/media.go b/installs_on_host/go2rtc/pkg/core/media.go new file mode 100644 index 0000000..367d8cb --- /dev/null +++ b/installs_on_host/go2rtc/pkg/core/media.go @@ -0,0 +1,211 @@ +package core + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/pion/sdp/v3" +) + +// Media take best from: +// - deepch/vdk/format/rtsp/sdp.Media +// - pion/sdp.MediaDescription +type Media struct { + Kind string `json:"kind,omitempty"` // video or audio + Direction string `json:"direction,omitempty"` // sendonly, recvonly + Codecs []*Codec `json:"codecs,omitempty"` + + ID string `json:"id,omitempty"` // MID for WebRTC, Control for RTSP +} + +func (m *Media) String() string { + s := fmt.Sprintf("%s, %s", m.Kind, m.Direction) + for _, codec := range m.Codecs { + name := codec.String() + + if strings.Contains(s, name) { + continue + } + + s += ", " + name + } + return s +} + +func (m *Media) MarshalJSON() ([]byte, error) { + return json.Marshal(m.String()) +} + +func (m *Media) Clone() *Media { + clone := *m + clone.Codecs = make([]*Codec, len(m.Codecs)) + for i, codec := range m.Codecs { + clone.Codecs[i] = codec.Clone() + } + return &clone +} + +func (m *Media) MatchMedia(remote *Media) (codec, remoteCodec *Codec) { + // check same kind and opposite dirrection + if m.Kind != remote.Kind || + m.Direction == DirectionSendonly && remote.Direction != DirectionRecvonly || + m.Direction == DirectionRecvonly && remote.Direction != DirectionSendonly { + return nil, nil + } + + for _, codec = range m.Codecs { + for _, remoteCodec = range remote.Codecs { + if codec.Match(remoteCodec) { + return + } + } + } + + return nil, nil +} + +func (m *Media) MatchCodec(remote *Codec) *Codec { + for _, codec := range m.Codecs { + if codec.Match(remote) { + return codec + } + } + return nil +} + +func (m *Media) MatchAll() bool { + for _, codec := range m.Codecs { + if codec.Name == CodecAll { + return true + } + } + return false +} + +func (m *Media) Equal(media *Media) bool { + if media.ID != "" { + return m.ID == media.ID + } + return m.String() == media.String() +} + +func GetKind(name string) string { + switch name { + case CodecH264, CodecH265, CodecVP8, CodecVP9, CodecAV1, CodecJPEG, CodecRAW: + return KindVideo + case CodecPCMU, CodecPCMA, CodecAAC, CodecOpus, CodecG722, CodecMP3, CodecPCM, CodecPCML, CodecELD, CodecFLAC: + return KindAudio + } + return "" +} + +func MarshalSDP(name string, medias []*Media) ([]byte, error) { + sd := &sdp.SessionDescription{ + Origin: sdp.Origin{ + Username: "-", SessionID: 1, SessionVersion: 1, + NetworkType: "IN", AddressType: "IP4", UnicastAddress: "0.0.0.0", + }, + SessionName: sdp.SessionName(name), + ConnectionInformation: &sdp.ConnectionInformation{ + NetworkType: "IN", AddressType: "IP4", Address: &sdp.Address{ + Address: "0.0.0.0", + }, + }, + TimeDescriptions: []sdp.TimeDescription{ + {Timing: sdp.Timing{}}, + }, + } + + for _, media := range medias { + if media.Codecs == nil { + continue + } + + codec := media.Codecs[0] + + switch codec.Name { + case CodecELD: + name = CodecAAC + case CodecPCML: + name = CodecPCM // beacuse we using pcm.LittleToBig for RTSP server + default: + name = codec.Name + } + + md := &sdp.MediaDescription{ + MediaName: sdp.MediaName{ + Media: media.Kind, + Protos: []string{"RTP", "AVP"}, + }, + } + md.WithCodec(codec.PayloadType, name, codec.ClockRate, uint16(codec.Channels), codec.FmtpLine) + + if media.Direction != "" { + md.WithPropertyAttribute(media.Direction) + } + + if media.ID != "" { + md.WithValueAttribute("control", media.ID) + } + + sd.MediaDescriptions = append(sd.MediaDescriptions, md) + } + + return sd.Marshal() +} + +func UnmarshalMedia(md *sdp.MediaDescription) *Media { + m := &Media{ + Kind: md.MediaName.Media, + } + + for _, attr := range md.Attributes { + switch attr.Key { + case DirectionSendonly, DirectionRecvonly, DirectionSendRecv: + m.Direction = attr.Key + case "control", "mid": + m.ID = attr.Value + } + } + + for _, format := range md.MediaName.Formats { + m.Codecs = append(m.Codecs, UnmarshalCodec(md, format)) + } + + return m +} + +func ParseQuery(query map[string][]string) (medias []*Media) { + // set media candidates from query list + for key, values := range query { + switch key { + case KindVideo, KindAudio: + for _, value := range values { + media := &Media{Kind: key, Direction: DirectionSendonly} + + for _, name := range strings.Split(value, ",") { + name = strings.ToUpper(name) + + // check aliases + switch name { + case "", "COPY": + name = CodecAny + case "MJPEG": + name = CodecJPEG + case "AAC": + name = CodecAAC + case "MP3": + name = CodecMP3 + } + + media.Codecs = append(media.Codecs, &Codec{Name: name}) + } + + medias = append(medias, media) + } + } + } + + return +} diff --git a/installs_on_host/go2rtc/pkg/core/media_test.go b/installs_on_host/go2rtc/pkg/core/media_test.go new file mode 100644 index 0000000..f2f05e6 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/core/media_test.go @@ -0,0 +1,64 @@ +package core + +import ( + "fmt" + "net/url" + "testing" + + "github.com/pion/sdp/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSDP(t *testing.T) { + medias := []*Media{{ + Kind: KindAudio, Direction: DirectionSendonly, + Codecs: []*Codec{ + {Name: CodecPCMU, ClockRate: 8000}, + }, + }} + + data, err := MarshalSDP("go2rtc/1.0.0", medias) + assert.Empty(t, err) + + sd := &sdp.SessionDescription{} + err = sd.Unmarshal(data) + assert.Empty(t, err) +} + +func TestParseQuery(t *testing.T) { + u, _ := url.Parse("rtsp://localhost:8554/camera1") + medias := ParseQuery(u.Query()) + assert.Nil(t, medias) + + for _, rawULR := range []string{ + "rtsp://localhost:8554/camera1?video", + "rtsp://localhost:8554/camera1?video=copy", + "rtsp://localhost:8554/camera1?video=any", + } { + u, _ = url.Parse(rawULR) + medias = ParseQuery(u.Query()) + assert.Equal(t, []*Media{ + {Kind: KindVideo, Direction: DirectionSendonly, Codecs: []*Codec{{Name: CodecAny}}}, + }, medias) + } +} + +func TestClone(t *testing.T) { + media1 := &Media{ + Kind: KindVideo, + Direction: DirectionRecvonly, + Codecs: []*Codec{ + {Name: CodecPCMU, ClockRate: 8000}, + }, + } + media2 := media1.Clone() + + p1 := fmt.Sprintf("%p", media1) + p2 := fmt.Sprintf("%p", media2) + require.NotEqualValues(t, p1, p2) + + p3 := fmt.Sprintf("%p", media1.Codecs[0]) + p4 := fmt.Sprintf("%p", media2.Codecs[0]) + require.NotEqualValues(t, p3, p4) +} diff --git a/installs_on_host/go2rtc/pkg/core/node.go b/installs_on_host/go2rtc/pkg/core/node.go new file mode 100644 index 0000000..a9959c3 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/core/node.go @@ -0,0 +1,88 @@ +package core + +import ( + "sync" + + "github.com/pion/rtp" +) + +//type Packet struct { +// Payload []byte +// Timestamp uint32 // PTS if DTS == 0 else DTS +// Composition uint32 // CTS = PTS-DTS (for support B-frames) +// Sequence uint16 +//} + +type Packet = rtp.Packet + +// HandlerFunc - process input packets (just like http.HandlerFunc) +type HandlerFunc func(packet *Packet) + +// Filter - a decorator for any HandlerFunc +type Filter func(handler HandlerFunc) HandlerFunc + +// Node - Receiver or Sender or Filter (transform) +type Node struct { + Codec *Codec + Input HandlerFunc + Output HandlerFunc + + id uint32 + childs []*Node + parent *Node + + mu sync.Mutex +} + +func (n *Node) WithParent(parent *Node) *Node { + parent.AppendChild(n) + return n +} + +func (n *Node) AppendChild(child *Node) { + n.mu.Lock() + n.childs = append(n.childs, child) + n.mu.Unlock() + + child.parent = n +} + +func (n *Node) RemoveChild(child *Node) { + n.mu.Lock() + for i, ch := range n.childs { + if ch == child { + n.childs = append(n.childs[:i], n.childs[i+1:]...) + break + } + } + n.mu.Unlock() +} + +func (n *Node) Close() { + if parent := n.parent; parent != nil { + parent.RemoveChild(n) + + if len(parent.childs) == 0 { + parent.Close() + } + } else { + for _, childs := range n.childs { + childs.Close() + } + } +} + +func MoveNode(dst, src *Node) { + src.mu.Lock() + childs := src.childs + src.childs = nil + src.mu.Unlock() + + dst.mu.Lock() + dst.childs = childs + dst.mu.Unlock() + + for _, child := range childs { + child.parent = dst + } +} diff --git a/installs_on_host/go2rtc/pkg/core/readbuffer.go b/installs_on_host/go2rtc/pkg/core/readbuffer.go new file mode 100644 index 0000000..960f187 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/core/readbuffer.go @@ -0,0 +1,114 @@ +package core + +import ( + "errors" + "io" +) + +// ProbeSize +// in my tests MPEG-TS 40Mbit/s 4K-video require more than 1MB for probe +const ProbeSize = 5 * 1024 * 1024 // 5MB + +const ( + BufferDisable = 0 + BufferDrainAndClear = -1 +) + +// ReadBuffer support buffering and Seek over buffer +// positive BufferSize will enable buffering mode +// Seek to negative offset will clear buffer +// Seek with a positive BufferSize will continue buffering after the last read from the buffer +// Seek with a negative BufferSize will clear buffer after the last read from the buffer +// Read more than BufferSize will raise error +type ReadBuffer struct { + io.Reader + + BufferSize int + + buf []byte + pos int +} + +func NewReadBuffer(rd io.Reader) *ReadBuffer { + if rs, ok := rd.(*ReadBuffer); ok { + return rs + } + return &ReadBuffer{Reader: rd} +} + +func (r *ReadBuffer) Read(p []byte) (n int, err error) { + // with zero buffer - read as usual + if r.BufferSize == BufferDisable { + return r.Reader.Read(p) + } + + // if buffer not empty - read from it + if r.pos < len(r.buf) { + n = copy(p, r.buf[r.pos:]) + r.pos += n + return + } + + // with negative buffer - empty it and read as usual + if r.BufferSize < 0 { + r.BufferSize = BufferDisable + r.buf = nil + r.pos = 0 + + return r.Reader.Read(p) + } + + n, err = r.Reader.Read(p) + if len(r.buf)+n > r.BufferSize { + return 0, errors.New("probe reader overflow") + } + r.buf = append(r.buf, p[:n]...) + r.pos += n + return +} + +func (r *ReadBuffer) Close() error { + if closer, ok := r.Reader.(io.Closer); ok { + return closer.Close() + } + return nil +} + +func (r *ReadBuffer) Seek(offset int64, whence int) (int64, error) { + var pos int + switch whence { + case io.SeekStart: + pos = int(offset) + case io.SeekCurrent: + pos = r.pos + int(offset) + case io.SeekEnd: + pos = len(r.buf) + int(offset) + } + + // negative offset - empty buffer + if pos < 0 { + r.buf = nil + r.pos = 0 + } else if pos >= len(r.buf) { + r.pos = len(r.buf) + } else { + r.pos = pos + } + + return int64(r.pos), nil +} + +func (r *ReadBuffer) Peek(n int) ([]byte, error) { + r.BufferSize = n + b := make([]byte, n) + if _, err := io.ReadAtLeast(r, b, n); err != nil { + return nil, err + } + r.Reset() + return b, nil +} + +func (r *ReadBuffer) Reset() { + r.BufferSize = BufferDrainAndClear + r.pos = 0 +} diff --git a/installs_on_host/go2rtc/pkg/core/readbuffer_test.go b/installs_on_host/go2rtc/pkg/core/readbuffer_test.go new file mode 100644 index 0000000..0147217 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/core/readbuffer_test.go @@ -0,0 +1,64 @@ +package core + +import ( + "bytes" + "io" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestReadSeeker(t *testing.T) { + b := []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9} + buf := bytes.NewReader(b) + + rd := NewReadBuffer(buf) + rd.BufferSize = ProbeSize + + // 1. Read to buffer + b = make([]byte, 3) + n, err := rd.Read(b) + require.Nil(t, err) + require.Equal(t, []byte{0, 1, 2}, b[:n]) + + // 2. Seek to start + _, err = rd.Seek(0, io.SeekStart) + require.Nil(t, err) + + // 3. Read from buffer + b = make([]byte, 2) + n, err = rd.Read(b) + require.Nil(t, err) + require.Equal(t, []byte{0, 1}, b[:n]) + + // 4. Read from buffer + n, err = rd.Read(b) + require.Nil(t, err) + require.Equal(t, []byte{2}, b[:n]) + + // 5. Read to buffer + n, err = rd.Read(b) + require.Nil(t, err) + require.Equal(t, []byte{3, 4}, b[:n]) + + // 6. Seek to start + _, err = rd.Seek(0, io.SeekStart) + require.Nil(t, err) + + // 7. Disable buffer + rd.BufferSize = -1 + + // 8. Read from buffer + b = make([]byte, 10) + n, err = rd.Read(b) + require.Nil(t, err) + require.Equal(t, []byte{0, 1, 2, 3, 4}, b[:n]) + + // 9. Direct read + n, err = rd.Read(b) + require.Nil(t, err) + require.Equal(t, []byte{5, 6, 7, 8, 9}, b[:n]) + + // 10. Check buffer empty + require.Nil(t, rd.buf) +} diff --git a/installs_on_host/go2rtc/pkg/core/slices.go b/installs_on_host/go2rtc/pkg/core/slices.go new file mode 100644 index 0000000..747d813 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/core/slices.go @@ -0,0 +1,43 @@ +package core + +// This code copied from go1.21 for backward support in go1.20. +// We need to support go1.20 for Windows 7 + +// Index returns the index of the first occurrence of v in s, +// or -1 if not present. +func Index[S ~[]E, E comparable](s S, v E) int { + for i := range s { + if v == s[i] { + return i + } + } + return -1 +} + +// Contains reports whether v is present in s. +func Contains[S ~[]E, E comparable](s S, v E) bool { + return Index(s, v) >= 0 +} + +type Ordered interface { + ~int | ~int8 | ~int16 | ~int32 | ~int64 | + ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr | + ~float32 | ~float64 | + ~string +} + +// Max returns the maximal value in x. It panics if x is empty. +// For floating-point E, Max propagates NaNs (any NaN value in x +// forces the output to be NaN). +func Max[S ~[]E, E Ordered](x S) E { + if len(x) < 1 { + panic("slices.Max: empty list") + } + m := x[0] + for i := 1; i < len(x); i++ { + if x[i] > m { + m = x[i] + } + } + return m +} diff --git a/installs_on_host/go2rtc/pkg/core/track.go b/installs_on_host/go2rtc/pkg/core/track.go new file mode 100644 index 0000000..f363a9f --- /dev/null +++ b/installs_on_host/go2rtc/pkg/core/track.go @@ -0,0 +1,217 @@ +package core + +import ( + "encoding/json" + "errors" + + "github.com/pion/rtp" +) + +var ErrCantGetTrack = errors.New("can't get track") + +type Receiver struct { + Node + + // Deprecated: should be removed + Media *Media `json:"-"` + // Deprecated: should be removed + ID byte `json:"-"` // Channel for RTSP, PayloadType for MPEG-TS + + Bytes int `json:"bytes,omitempty"` + Packets int `json:"packets,omitempty"` +} + +func NewReceiver(media *Media, codec *Codec) *Receiver { + r := &Receiver{ + Node: Node{id: NewID(), Codec: codec}, + Media: media, + } + r.Input = func(packet *Packet) { + r.Bytes += len(packet.Payload) + r.Packets++ + for _, child := range r.childs { + child.Input(packet) + } + } + return r +} + +// Deprecated: should be removed +func (r *Receiver) WriteRTP(packet *rtp.Packet) { + r.Input(packet) +} + +// Deprecated: should be removed +func (r *Receiver) Senders() []*Sender { + if len(r.childs) > 0 { + return []*Sender{{}} + } else { + return nil + } +} + +// Deprecated: should be removed +func (r *Receiver) Replace(target *Receiver) { + MoveNode(&target.Node, &r.Node) +} + +func (r *Receiver) Close() { + r.Node.Close() +} + +type Sender struct { + Node + + // Deprecated: + Media *Media `json:"-"` + // Deprecated: + Handler HandlerFunc `json:"-"` + + Bytes int `json:"bytes,omitempty"` + Packets int `json:"packets,omitempty"` + Drops int `json:"drops,omitempty"` + + buf chan *Packet + done chan struct{} +} + +func NewSender(media *Media, codec *Codec) *Sender { + var bufSize uint16 + + if GetKind(codec.Name) == KindVideo { + if codec.IsRTP() { + // in my tests 40Mbit/s 4K-video can generate up to 1500 items + // for the h264.RTPDepay => RTPPay queue + bufSize = 4096 + } else { + bufSize = 64 + } + } else { + bufSize = 128 + } + + buf := make(chan *Packet, bufSize) + s := &Sender{ + Node: Node{id: NewID(), Codec: codec}, + Media: media, + buf: buf, + } + s.Input = func(packet *Packet) { + s.mu.Lock() + // unblock write to nil chan - OK, write to closed chan - panic + select { + case s.buf <- packet: + s.Bytes += len(packet.Payload) + s.Packets++ + default: + s.Drops++ + } + s.mu.Unlock() + } + s.Output = func(packet *Packet) { + s.Handler(packet) + } + return s +} + +// Deprecated: should be removed +func (s *Sender) HandleRTP(parent *Receiver) { + s.WithParent(parent) + s.Start() +} + +// Deprecated: should be removed +func (s *Sender) Bind(parent *Receiver) { + s.WithParent(parent) +} + +func (s *Sender) WithParent(parent *Receiver) *Sender { + s.Node.WithParent(&parent.Node) + return s +} + +func (s *Sender) Start() { + s.mu.Lock() + defer s.mu.Unlock() + + if s.buf == nil || s.done != nil { + return + } + s.done = make(chan struct{}) + + // pass buf directly so that it's impossible for buf to be nil + go func(buf chan *Packet) { + for packet := range buf { + s.Output(packet) + } + close(s.done) + }(s.buf) +} + +func (s *Sender) Wait() { + if done := s.done; done != nil { + <-done + } +} + +func (s *Sender) State() string { + if s.buf == nil { + return "closed" + } + if s.done == nil { + return "new" + } + return "connected" +} + +func (s *Sender) Close() { + // close buffer if exists + s.mu.Lock() + if s.buf != nil { + close(s.buf) // exit from for range loop + s.buf = nil // prevent writing to closed chan + } + s.mu.Unlock() + + s.Node.Close() +} + +func (r *Receiver) MarshalJSON() ([]byte, error) { + v := struct { + ID uint32 `json:"id"` + Codec *Codec `json:"codec"` + Childs []uint32 `json:"childs,omitempty"` + Bytes int `json:"bytes,omitempty"` + Packets int `json:"packets,omitempty"` + }{ + ID: r.Node.id, + Codec: r.Node.Codec, + Bytes: r.Bytes, + Packets: r.Packets, + } + for _, child := range r.childs { + v.Childs = append(v.Childs, child.id) + } + return json.Marshal(v) +} + +func (s *Sender) MarshalJSON() ([]byte, error) { + v := struct { + ID uint32 `json:"id"` + Codec *Codec `json:"codec"` + Parent uint32 `json:"parent,omitempty"` + Bytes int `json:"bytes,omitempty"` + Packets int `json:"packets,omitempty"` + Drops int `json:"drops,omitempty"` + }{ + ID: s.Node.id, + Codec: s.Node.Codec, + Bytes: s.Bytes, + Packets: s.Packets, + Drops: s.Drops, + } + if s.parent != nil { + v.Parent = s.parent.id + } + return json.Marshal(v) +} diff --git a/installs_on_host/go2rtc/pkg/core/track_test.go b/installs_on_host/go2rtc/pkg/core/track_test.go new file mode 100644 index 0000000..cf877d4 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/core/track_test.go @@ -0,0 +1,53 @@ +package core + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSenser(t *testing.T) { + recv := make(chan *Packet) // blocking receiver + + sender := NewSender(nil, &Codec{}) + sender.Output = func(packet *Packet) { + recv <- packet + } + require.Equal(t, "new", sender.State()) + + sender.Start() + require.Equal(t, "connected", sender.State()) + + sender.Input(&Packet{}) + sender.Input(&Packet{}) + + require.Equal(t, 2, sender.Packets) + require.Equal(t, 0, sender.Drops) + + // important to read one before close + // because goroutine in Start() can run with nil chan + // it's OK in real life, but bad for test + _, ok := <-recv + require.True(t, ok) + + sender.Close() + require.Equal(t, "closed", sender.State()) + + sender.Input(&Packet{}) + + require.Equal(t, 2, sender.Packets) + require.Equal(t, 1, sender.Drops) + + // read 2nd + _, ok = <-recv + require.True(t, ok) + + // read 3rd + select { + case <-recv: + ok = true + default: + ok = false + } + require.False(t, ok) +} diff --git a/installs_on_host/go2rtc/pkg/core/waiter.go b/installs_on_host/go2rtc/pkg/core/waiter.go new file mode 100644 index 0000000..c61e3be --- /dev/null +++ b/installs_on_host/go2rtc/pkg/core/waiter.go @@ -0,0 +1,74 @@ +package core + +import ( + "sync" +) + +// Waiter support: +// - autotart on first Wait +// - block new waiters after last Done +// - safe Done after finish +type Waiter struct { + sync.WaitGroup + mu sync.Mutex + state int // state < 0 means finish + err error +} + +func (w *Waiter) Add(delta int) { + w.mu.Lock() + if w.state >= 0 { + w.state += delta + w.WaitGroup.Add(delta) + } + w.mu.Unlock() +} + +func (w *Waiter) Wait() error { + w.mu.Lock() + // first wait auto start waiter + if w.state == 0 { + w.state++ + w.WaitGroup.Add(1) + } + w.mu.Unlock() + + w.WaitGroup.Wait() + + return w.err +} + +func (w *Waiter) Done(err error) { + w.mu.Lock() + + // safe run Done only when have tasks + if w.state > 0 { + w.state-- + w.WaitGroup.Done() + } + + // block waiter for any operations after last done + if w.state == 0 { + w.state = -1 + w.err = err + } + + w.mu.Unlock() +} + +func (w *Waiter) WaitChan() <-chan error { + var ch chan error + + w.mu.Lock() + + if w.state >= 0 { + ch = make(chan error) + go func() { + ch <- w.Wait() + }() + } + + w.mu.Unlock() + + return ch +} diff --git a/installs_on_host/go2rtc/pkg/core/worker.go b/installs_on_host/go2rtc/pkg/core/worker.go new file mode 100644 index 0000000..f013875 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/core/worker.go @@ -0,0 +1,52 @@ +package core + +import ( + "time" +) + +type Worker struct { + timer *time.Timer + done chan struct{} +} + +// NewWorker run f after d +func NewWorker(d time.Duration, f func() time.Duration) *Worker { + timer := time.NewTimer(d) + done := make(chan struct{}) + + go func() { + for { + select { + case <-timer.C: + if d = f(); d > 0 { + timer.Reset(d) + continue + } + case <-done: + timer.Stop() + } + break + } + }() + + return &Worker{timer: timer, done: done} +} + +// Do - instant timer run +func (w *Worker) Do() { + if w == nil { + return + } + w.timer.Reset(0) +} + +func (w *Worker) Stop() { + if w == nil { + return + } + + select { + case w.done <- struct{}{}: + default: + } +} diff --git a/installs_on_host/go2rtc/pkg/core/writebuffer.go b/installs_on_host/go2rtc/pkg/core/writebuffer.go new file mode 100644 index 0000000..50ad013 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/core/writebuffer.go @@ -0,0 +1,114 @@ +package core + +import ( + "bytes" + "io" + "net/http" + "sync" +) + +// WriteBuffer by defaul Write(s) to bytes.Buffer. +// But after WriteTo to new io.Writer - calls Reset. +// Reset will flush current buffer data to new writer and starts to Write to new io.Writer +// WriteTo will be locked until Write fails or Close will be called. +type WriteBuffer struct { + io.Writer + err error + mu sync.Mutex + wg sync.WaitGroup + state byte +} + +func NewWriteBuffer(wr io.Writer) *WriteBuffer { + if wr == nil { + wr = bytes.NewBuffer(nil) + } + return &WriteBuffer{Writer: wr} +} + +func (w *WriteBuffer) Write(p []byte) (n int, err error) { + w.mu.Lock() + if w.err != nil { + err = w.err + } else if n, err = w.Writer.Write(p); err != nil { + w.err = err + w.done() + } else if f, ok := w.Writer.(http.Flusher); ok { + f.Flush() + } + w.mu.Unlock() + return +} + +func (w *WriteBuffer) WriteTo(wr io.Writer) (n int64, err error) { + w.Reset(wr) + w.wg.Wait() + return 0, w.err // TODO: fix counter +} + +func (w *WriteBuffer) Close() error { + if closer, ok := w.Writer.(io.Closer); ok { + return closer.Close() + } + w.mu.Lock() + w.done() + w.mu.Unlock() + return nil +} + +func (w *WriteBuffer) Reset(wr io.Writer) { + w.mu.Lock() + w.add() + if buf, ok := w.Writer.(*bytes.Buffer); ok && buf.Len() != 0 { + if _, err := io.Copy(wr, buf); err != nil { + w.err = err + w.done() + } + } + w.Writer = wr + w.mu.Unlock() +} + +const ( + none = iota + start + end +) + +func (w *WriteBuffer) add() { + if w.state == none { + w.state = start + w.wg.Add(1) + } +} + +func (w *WriteBuffer) done() { + if w.state == start { + w.state = end + w.wg.Done() + } +} + +// OnceBuffer will catch only first message +type OnceBuffer struct { + buf []byte +} + +func (o *OnceBuffer) Write(p []byte) (n int, err error) { + if o.buf == nil { + o.buf = p + } + return 0, io.EOF +} + +func (o *OnceBuffer) WriteTo(w io.Writer) (n int64, err error) { + return io.Copy(w, bytes.NewReader(o.buf)) +} + +func (o *OnceBuffer) Buffer() []byte { + return o.buf +} + +func (o *OnceBuffer) Len() int { + return len(o.buf) +} diff --git a/installs_on_host/go2rtc/pkg/creds/README.md b/installs_on_host/go2rtc/pkg/creds/README.md new file mode 100644 index 0000000..1909a20 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/creds/README.md @@ -0,0 +1,7 @@ +# Credentials + +This module allows you to get variables: + +- from custom storage (ex. config file) +- from [credential files](https://systemd.io/CREDENTIALS/) +- from environment variables diff --git a/installs_on_host/go2rtc/pkg/creds/creds.go b/installs_on_host/go2rtc/pkg/creds/creds.go new file mode 100644 index 0000000..84bc275 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/creds/creds.go @@ -0,0 +1,79 @@ +package creds + +import ( + "errors" + "os" + "path/filepath" + "regexp" + "strings" +) + +type Storage interface { + SetValue(name, value string) error + GetValue(name string) (string, bool) +} + +var storage Storage + +func SetStorage(s Storage) { + storage = s +} + +func SetValue(name, value string) error { + if storage == nil { + return errors.New("credentials: storage not initialized") + } + if err := storage.SetValue(name, value); err != nil { + return err + } + AddSecret(value) + return nil +} + +func GetValue(name string) (value string, ok bool) { + value, ok = getValue(name) + AddSecret(value) + return +} + +func getValue(name string) (string, bool) { + if storage != nil { + if value, ok := storage.GetValue(name); ok { + return value, true + } + } + + if dir, ok := os.LookupEnv("CREDENTIALS_DIRECTORY"); ok { + if value, _ := os.ReadFile(filepath.Join(dir, name)); value != nil { + return strings.TrimSpace(string(value)), true + } + } + + return os.LookupEnv(name) +} + +// ReplaceVars - support format ${CAMERA_PASSWORD} and ${RTSP_USER:admin} +func ReplaceVars(data []byte) []byte { + re := regexp.MustCompile(`\${([^}{]+)}`) + return re.ReplaceAllFunc(data, func(match []byte) []byte { + key := string(match[2 : len(match)-1]) + + var def string + var defok bool + + if i := strings.IndexByte(key, ':'); i > 0 { + key, def = key[:i], key[i+1:] + defok = true + } + + if value, ok := GetValue(key); ok { + return []byte(value) + } + + if defok { + return []byte(def) + } + + return match + }) +} diff --git a/installs_on_host/go2rtc/pkg/creds/secrets.go b/installs_on_host/go2rtc/pkg/creds/secrets.go new file mode 100644 index 0000000..95ab482 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/creds/secrets.go @@ -0,0 +1,94 @@ +package creds + +import ( + "io" + "net/http" + "regexp" + "slices" + "strings" + "sync" +) + +func AddSecret(value string) { + if value == "" { + return + } + + secretsMu.Lock() + defer secretsMu.Unlock() + + if slices.Contains(secrets, value) { + return + } + + secrets = append(secrets, value) + secretsReplacer = nil +} + +var secrets []string +var secretsMu sync.Mutex +var secretsReplacer *strings.Replacer +var userinfoRegexp *regexp.Regexp + +func getReplacer() *strings.Replacer { + secretsMu.Lock() + defer secretsMu.Unlock() + + if secretsReplacer == nil { + oldnew := make([]string, 0, 2*len(secrets)) + for _, s := range secrets { + oldnew = append(oldnew, s, "***") + } + secretsReplacer = strings.NewReplacer(oldnew...) + } + + if userinfoRegexp == nil { + userinfoRegexp = regexp.MustCompile(`://[` + userinfo + `]+@`) + } + + return secretsReplacer +} + +// Uniform Resource Identifier (URI) +// https://datatracker.ietf.org/doc/html/rfc3986 +const ( + unreserved = `A-Za-z0-9-._~` + subdelims = `!$&'()*+,;=` + userinfo = unreserved + subdelims + `%:` +) + +func SecretString(s string) string { + re := getReplacer() + s = userinfoRegexp.ReplaceAllString(s, `://***@`) + return re.Replace(s) +} + +func SecretWrite(w io.Writer, s string) (n int, err error) { + re := getReplacer() + s = userinfoRegexp.ReplaceAllString(s, `://***@`) + return re.WriteString(w, s) +} + +func SecretWriter(w io.Writer) io.Writer { + return &secretWriter{w} +} + +type secretWriter struct { + w io.Writer +} + +func (s *secretWriter) Write(b []byte) (int, error) { + return SecretWrite(s.w, string(b)) +} + +func SecretResponse(w http.ResponseWriter) http.ResponseWriter { + return &secretResponse{w} +} + +type secretResponse struct { + http.ResponseWriter +} + +func (s *secretResponse) Write(b []byte) (int, error) { + return SecretWrite(s.ResponseWriter, string(b)) +} diff --git a/installs_on_host/go2rtc/pkg/creds/secrets_test.go b/installs_on_host/go2rtc/pkg/creds/secrets_test.go new file mode 100644 index 0000000..83f1908 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/creds/secrets_test.go @@ -0,0 +1,15 @@ +package creds + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestString(t *testing.T) { + AddSecret("admin") + AddSecret("pa$$word") + + s := SecretString("rtsp://admin:pa$$word@192.168.1.123/stream1") + require.Equal(t, "rtsp://***:***@192.168.1.123/stream1", s) +} diff --git a/installs_on_host/go2rtc/pkg/debug/conn.go b/installs_on_host/go2rtc/pkg/debug/conn.go new file mode 100644 index 0000000..6261cb7 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/debug/conn.go @@ -0,0 +1,47 @@ +package debug + +import ( + "bytes" + "math/rand" + "net" +) + +type badConn struct { + net.Conn + delay int + buf []byte +} + +func NewBadConn(conn net.Conn) net.Conn { + return &badConn{Conn: conn} +} + +const ( + missChance = 0.05 + delayChance = 0.1 +) + +func (c *badConn) Read(b []byte) (n int, err error) { + if rand.Float32() < missChance { + if _, err = c.Conn.Read(b); err != nil { + return + } + //log.Printf("bad conn: miss") + } + + if c.delay > 0 { + if c.delay--; c.delay == 0 { + n = copy(b, c.buf) + return + } + } else if rand.Float32() < delayChance { + if n, err = c.Conn.Read(b); err != nil { + return + } + c.delay = 1 + rand.Intn(5) + c.buf = bytes.Clone(b[:n]) + //log.Printf("bad conn: delay %d", c.delay) + } + + return c.Conn.Read(b) +} diff --git a/installs_on_host/go2rtc/pkg/debug/debug.go b/installs_on_host/go2rtc/pkg/debug/debug.go new file mode 100644 index 0000000..ff6ccce --- /dev/null +++ b/installs_on_host/go2rtc/pkg/debug/debug.go @@ -0,0 +1,58 @@ +package debug + +import ( + "fmt" + "time" + + "github.com/pion/rtp" +) + +func Logger(include func(packet *rtp.Packet) bool) func(packet *rtp.Packet) { + var lastTime = time.Now() + var lastTS uint32 + + var secCnt int + var secSize int + var secTS uint32 + var secTime time.Time + + return func(packet *rtp.Packet) { + if include != nil && !include(packet) { + return + } + + now := time.Now() + + fmt.Printf( + "%s: size=%6d ts=%10d type=%2d ssrc=%d seq=%5d mark=%t dts=%4d dtime=%3dms\n", + now.Format("15:04:05.000"), + len(packet.Payload), packet.Timestamp, packet.PayloadType, packet.SSRC, packet.SequenceNumber, packet.Marker, + packet.Timestamp-lastTS, now.Sub(lastTime).Milliseconds(), + ) + + lastTS = packet.Timestamp + lastTime = now + + if secTS == 0 { + secTS = lastTS + secTime = now + return + } + + if dt := now.Sub(secTime); dt > time.Second { + fmt.Printf( + "%s: size=%6d cnt=%d dts=%d dtime=%3dms\n", + now.Format("15:04:05.000"), + secSize, secCnt, lastTS-secTS, dt.Milliseconds(), + ) + + secCnt = 0 + secSize = 0 + secTS = lastTS + secTime = now + } + + secCnt++ + secSize += len(packet.Payload) + } +} diff --git a/installs_on_host/go2rtc/pkg/doorbird/backchannel.go b/installs_on_host/go2rtc/pkg/doorbird/backchannel.go new file mode 100644 index 0000000..4d25222 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/doorbird/backchannel.go @@ -0,0 +1,95 @@ +package doorbird + +import ( + "fmt" + "net" + "net/url" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +type Client struct { + core.Connection + conn net.Conn +} + +func Dial(rawURL string) (*Client, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + user := u.User.Username() + pass, _ := u.User.Password() + + if u.Port() == "" { + u.Host += ":80" + } + + conn, err := net.DialTimeout("tcp", u.Host, core.ConnDialTimeout) + if err != nil { + return nil, err + } + + s := fmt.Sprintf("POST /bha-api/audio-transmit.cgi?http-user=%s&http-password=%s HTTP/1.0\r\n", user, pass) + + "Content-Type: audio/basic\r\n" + + "Content-Length: 9999999\r\n" + + "Connection: Keep-Alive\r\n" + + "Cache-Control: no-cache\r\n" + + "\r\n" + + _ = conn.SetWriteDeadline(time.Now().Add(core.ConnDeadline)) + if _, err = conn.Write([]byte(s)); err != nil { + return nil, err + } + + medias := []*core.Media{ + { + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecPCMU, ClockRate: 8000}, + }, + }, + } + + return &Client{ + core.Connection{ + ID: core.NewID(), + FormatName: "doorbird", + Protocol: "http", + URL: rawURL, + Medias: medias, + Transport: conn, + }, + conn, + }, nil +} + +func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + return nil, core.ErrCantGetTrack +} + +func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + sender := core.NewSender(media, track.Codec) + + sender.Handler = func(pkt *rtp.Packet) { + _ = c.conn.SetWriteDeadline(time.Now().Add(core.ConnDeadline)) + if n, err := c.conn.Write(pkt.Payload); err == nil { + c.Send += n + } + } + + sender.HandleRTP(track) + c.Senders = append(c.Senders, sender) + return nil +} + +func (c *Client) Start() (err error) { + // just block until c.conn closed + b := make([]byte, 1) + _, err = c.conn.Read(b) + return +} diff --git a/installs_on_host/go2rtc/pkg/dvrip/backchannel.go b/installs_on_host/go2rtc/pkg/dvrip/backchannel.go new file mode 100644 index 0000000..0424e96 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/dvrip/backchannel.go @@ -0,0 +1,79 @@ +package dvrip + +import ( + "encoding/binary" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +type Backchannel struct { + core.Connection + client *Client +} + +func (c *Backchannel) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + return nil, core.ErrCantGetTrack +} + +func (c *Backchannel) Start() error { + if err := c.client.conn.SetReadDeadline(time.Time{}); err != nil { + return err + } + + b := make([]byte, 4096) + for { + if _, err := c.client.rd.Read(b); err != nil { + return err + } + } +} + +func (c *Backchannel) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error { + if err := c.client.Talk(); err != nil { + return err + } + + const PacketSize = 320 + + buf := make([]byte, 8+PacketSize) + binary.BigEndian.PutUint32(buf, 0x1FA) + + switch track.Codec.Name { + case core.CodecPCMU: + buf[4] = 10 + case core.CodecPCMA: + buf[4] = 14 + } + + //for i, rate := range sampleRates { + // if rate == track.Codec.ClockRate { + // buf[5] = byte(i) + 1 + // break + // } + //} + buf[5] = 2 // ClockRate=8000 + + binary.LittleEndian.PutUint16(buf[6:], PacketSize) + + var payload []byte + + sender := core.NewSender(media, track.Codec) + sender.Handler = func(packet *rtp.Packet) { + payload = append(payload, packet.Payload...) + + for len(payload) >= PacketSize { + buf = append(buf[:8], payload[:PacketSize]...) + if n, err := c.client.WriteCmd(OPTalkData, buf); err != nil { + c.Send += n + } + + payload = payload[PacketSize:] + } + } + + sender.HandleRTP(track) + c.Senders = append(c.Senders, sender) + return nil +} diff --git a/installs_on_host/go2rtc/pkg/dvrip/client.go b/installs_on_host/go2rtc/pkg/dvrip/client.go new file mode 100644 index 0000000..0b48545 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/dvrip/client.go @@ -0,0 +1,247 @@ +package dvrip + +import ( + "bufio" + "bytes" + "crypto/md5" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/url" + "time" +) + +const ( + Login = 1000 + OPMonitorClaim = 1413 + OPMonitorStart = 1410 + OPTalkClaim = 1434 + OPTalkStart = 1430 + OPTalkData = 1432 +) + +type Client struct { + conn net.Conn + session uint32 + seq uint32 + stream string + + rd io.Reader + buf []byte +} + +func (c *Client) Dial(rawURL string) (err error) { + u, err := url.Parse(rawURL) + if err != nil { + return + } + + if u.Port() == "" { + // add default TCP port + u.Host += ":34567" + } + + c.conn, err = net.DialTimeout("tcp", u.Host, time.Second*3) + if err != nil { + return + } + + if query := u.Query(); query.Get("backchannel") != "1" { + channel := query.Get("channel") + if channel == "" { + channel = "0" + } + + subtype := query.Get("subtype") + switch subtype { + case "", "0": + subtype = "Main" + case "1": + subtype = "Extra1" + } + + c.stream = fmt.Sprintf( + `{"Channel":%s,"CombinMode":"NONE","StreamType":"%s","TransMode":"TCP"}`, + channel, subtype, + ) + } + + c.rd = bufio.NewReader(c.conn) + + if u.User != nil { + pass, _ := u.User.Password() + return c.Login(u.User.Username(), pass) + } else { + return c.Login("admin", "admin") + } +} + +func (c *Client) Close() error { + return c.conn.Close() +} + +func (c *Client) Login(user, pass string) (err error) { + data := fmt.Sprintf( + `{"EncryptType":"MD5","LoginType":"DVRIP-Web","PassWord":"%s","UserName":"%s"}`+"\x0A\x00", + SofiaHash(pass), user, + ) + + if _, err = c.WriteCmd(Login, []byte(data)); err != nil { + return + } + + _, err = c.ReadJSON() + return +} + +func (c *Client) Play() error { + format := `{"Name":"OPMonitor","SessionID":"0x%08X","OPMonitor":{"Action":"%s","Parameter":%s}}` + "\x0A\x00" + + data := fmt.Sprintf(format, c.session, "Claim", c.stream) + if _, err := c.WriteCmd(OPMonitorClaim, []byte(data)); err != nil { + return err + } + if _, err := c.ReadJSON(); err != nil { + return err + } + + data = fmt.Sprintf(format, c.session, "Start", c.stream) + _, err := c.WriteCmd(OPMonitorStart, []byte(data)) + return err +} + +func (c *Client) Talk() error { + format := `{"Name":"OPTalk","SessionID":"0x%08X","OPTalk":{"Action":"%s","AudioFormat":{"EncodeType":"G711_ALAW"}}}` + "\x0A\x00" + + data := fmt.Sprintf(format, c.session, "Claim") + if _, err := c.WriteCmd(OPTalkClaim, []byte(data)); err != nil { + return err + } + if _, err := c.ReadJSON(); err != nil { + return err + } + + data = fmt.Sprintf(format, c.session, "Start") + _, err := c.WriteCmd(OPTalkStart, []byte(data)) + return err +} + +func (c *Client) WriteCmd(cmd uint16, payload []byte) (n int, err error) { + b := make([]byte, 20, 128) + b[0] = 255 + binary.LittleEndian.PutUint32(b[4:], c.session) + binary.LittleEndian.PutUint32(b[8:], c.seq) + binary.LittleEndian.PutUint16(b[14:], cmd) + binary.LittleEndian.PutUint32(b[16:], uint32(len(payload))) + b = append(b, payload...) + + c.seq++ + + if err = c.conn.SetWriteDeadline(time.Now().Add(time.Second * 5)); err != nil { + return 0, err + } + + return c.conn.Write(b) +} + +func (c *Client) ReadChunk() (b []byte, err error) { + if err = c.conn.SetReadDeadline(time.Now().Add(time.Second * 5)); err != nil { + return + } + + b = make([]byte, 20) + if _, err = io.ReadFull(c.rd, b); err != nil { + return + } + + if b[0] != 255 { + return nil, errors.New("read error") + } + + c.session = binary.LittleEndian.Uint32(b[4:]) + size := binary.LittleEndian.Uint32(b[16:]) + + b = make([]byte, size) + if _, err = io.ReadFull(c.rd, b); err != nil { + return + } + + return +} + +func (c *Client) ReadPacket() (pType byte, payload []byte, err error) { + var b []byte + + // many cameras may split packet to multiple chunks + // some rare cameras may put multiple packets to single chunk + for len(c.buf) < 16 { + if b, err = c.ReadChunk(); err != nil { + return 0, nil, err + } + c.buf = append(c.buf, b...) + } + + if !bytes.HasPrefix(c.buf, []byte{0, 0, 1}) { + return 0, nil, fmt.Errorf("dvrip: wrong packet: %0.16x", c.buf) + } + + var size int + + switch pType = c.buf[3]; pType { + case 0xFC, 0xFE: + size = int(binary.LittleEndian.Uint32(c.buf[12:])) + 16 + case 0xFD: // PFrame + size = int(binary.LittleEndian.Uint32(c.buf[4:])) + 8 + case 0xFA, 0xF9: + size = int(binary.LittleEndian.Uint16(c.buf[6:])) + 8 + default: + return 0, nil, fmt.Errorf("dvrip: unknown packet type: %X", pType) + } + + for len(c.buf) < size { + if b, err = c.ReadChunk(); err != nil { + return 0, nil, err + } + c.buf = append(c.buf, b...) + } + + payload = c.buf[:size] + c.buf = c.buf[size:] + + return +} + +type Response map[string]any + +func (c *Client) ReadJSON() (res Response, err error) { + b, err := c.ReadChunk() + if err != nil { + return + } + + res = Response{} + if err = json.Unmarshal(b[:len(b)-2], &res); err != nil { + return + } + + if v, ok := res["Ret"].(float64); !ok || (v != 100 && v != 515) { + err = fmt.Errorf("wrong response: %s", b) + } + return +} + +func SofiaHash(password string) string { + const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + + sofia := make([]byte, 0, 8) + hash := md5.Sum([]byte(password)) + for i := 0; i < md5.Size; i += 2 { + j := uint16(hash[i]) + uint16(hash[i+1]) + sofia = append(sofia, chars[j%62]) + } + + return string(sofia) +} diff --git a/installs_on_host/go2rtc/pkg/dvrip/dvrip.go b/installs_on_host/go2rtc/pkg/dvrip/dvrip.go new file mode 100644 index 0000000..c4980a8 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/dvrip/dvrip.go @@ -0,0 +1,39 @@ +package dvrip + +import "github.com/AlexxIT/go2rtc/pkg/core" + +func Dial(url string) (core.Producer, error) { + client := &Client{} + if err := client.Dial(url); err != nil { + return nil, err + } + + conn := core.Connection{ + ID: core.NewID(), + FormatName: "dvrip", + Protocol: "tcp", + RemoteAddr: client.conn.RemoteAddr().String(), + Transport: client.conn, + } + + if client.stream != "" { + prod := &Producer{Connection: conn, client: client} + if err := prod.probe(); err != nil { + return nil, err + } + return prod, nil + } else { + conn.Medias = []*core.Media{ + { + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + // leave only one codec here for better compatibility with cameras + // https://github.com/AlexxIT/go2rtc/issues/1111 + {Name: core.CodecPCMA, ClockRate: 8000, PayloadType: 8}, + }, + }, + } + return &Backchannel{Connection: conn, client: client}, nil + } +} diff --git a/installs_on_host/go2rtc/pkg/dvrip/producer.go b/installs_on_host/go2rtc/pkg/dvrip/producer.go new file mode 100644 index 0000000..4f49da1 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/dvrip/producer.go @@ -0,0 +1,262 @@ +package dvrip + +import ( + "encoding/base64" + "encoding/binary" + "errors" + "fmt" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/h264/annexb" + "github.com/AlexxIT/go2rtc/pkg/h265" + "github.com/pion/rtp" +) + +type Producer struct { + core.Connection + + client *Client + + video, audio *core.Receiver + + videoTS uint32 + videoDT uint32 + audioTS uint32 + audioSeq uint16 +} + +func (c *Producer) Start() error { + for { + pType, b, err := c.client.ReadPacket() + if err != nil { + return err + } + + //log.Printf("[DVR] type: %d, len: %d", dataType, len(b)) + + switch pType { + case 0xFC, 0xFE, 0xFD: + if c.video == nil { + continue + } + + var payload []byte + if pType != 0xFD { + payload = b[16:] // iframe + } else { + payload = b[8:] // pframe + } + + c.videoTS += c.videoDT + + packet := &rtp.Packet{ + Header: rtp.Header{Timestamp: c.videoTS}, + Payload: annexb.EncodeToAVCC(payload), + } + + //log.Printf("[AVC] %v, len: %d, ts: %10d", h265.Types(payload), len(payload), packet.Timestamp) + + c.video.WriteRTP(packet) + + case 0xFA: // audio + if c.audio == nil { + continue + } + + payload := b[8:] + + c.audioTS += uint32(len(payload)) + c.audioSeq++ + + packet := &rtp.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: true, + SequenceNumber: c.audioSeq, + Timestamp: c.audioTS, + }, + Payload: payload, + } + + //log.Printf("[DVR] len: %d, ts: %10d", len(packet.Payload), packet.Timestamp) + + c.audio.WriteRTP(packet) + + case 0xF9: // unknown + + default: + println(fmt.Sprintf("dvrip: unknown packet type: %d", pType)) + } + } +} + +func (c *Producer) probe() error { + if err := c.client.Play(); err != nil { + return err + } + + rd := core.NewReadBuffer(c.client.rd) + rd.BufferSize = core.ProbeSize + defer func() { + c.client.buf = nil + rd.Reset() + }() + + c.client.rd = rd + + // some awful cameras has VERY rare keyframes + // so we wait video+audio for default probe time + // and wait anything for 15 seconds + timeoutBoth := time.Now().Add(core.ProbeTimeout) + timeoutAny := time.Now().Add(time.Second * 15) + + for { + if now := time.Now(); now.Before(timeoutBoth) { + if c.video != nil && c.audio != nil { + return nil + } + } else if now.Before(timeoutAny) { + if c.video != nil || c.audio != nil { + return nil + } + } else { + return errors.New("dvrip: can't probe medias") + } + + tag, b, err := c.client.ReadPacket() + if err != nil { + return err + } + + switch tag { + case 0xFC, 0xFE: // video + if c.video != nil { + continue + } + + fps := b[5] + //width := uint16(b[6]) * 8 + //height := uint16(b[7]) * 8 + //println(width, height) + ts := b[8:] + + // the exact value of the start TS does not matter + c.videoTS = binary.LittleEndian.Uint32(ts) + c.videoDT = 90000 / uint32(fps) + + payload := annexb.EncodeToAVCC(b[16:]) + c.addVideoTrack(b[4], payload) + + case 0xFA: // audio + if c.audio != nil { + continue + } + + // the exact value of the start TS does not matter + c.audioTS = c.videoTS + + c.addAudioTrack(b[4], b[5]) + } + } +} + +func (c *Producer) addVideoTrack(mediaCode byte, payload []byte) { + var codec *core.Codec + switch mediaCode { + case 0x02, 0x12: + codec = &core.Codec{ + Name: core.CodecH264, + ClockRate: 90000, + PayloadType: core.PayloadTypeRAW, + FmtpLine: h264.GetFmtpLine(payload), + } + + case 0x03, 0x13, 0x43, 0x53: + codec = &core.Codec{ + Name: core.CodecH265, + ClockRate: 90000, + PayloadType: core.PayloadTypeRAW, + FmtpLine: "profile-id=1", + } + + for { + size := 4 + int(binary.BigEndian.Uint32(payload)) + + switch h265.NALUType(payload) { + case h265.NALUTypeVPS: + codec.FmtpLine += ";sprop-vps=" + base64.StdEncoding.EncodeToString(payload[4:size]) + case h265.NALUTypeSPS: + codec.FmtpLine += ";sprop-sps=" + base64.StdEncoding.EncodeToString(payload[4:size]) + case h265.NALUTypePPS: + codec.FmtpLine += ";sprop-pps=" + base64.StdEncoding.EncodeToString(payload[4:size]) + } + + if size < len(payload) { + payload = payload[size:] + } else { + break + } + } + default: + println("[DVRIP] unsupported video codec:", mediaCode) + return + } + + media := &core.Media{ + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{codec}, + } + c.Medias = append(c.Medias, media) + + c.video = core.NewReceiver(media, codec) + c.Receivers = append(c.Receivers, c.video) +} + +var sampleRates = []uint32{4000, 8000, 11025, 16000, 20000, 22050, 32000, 44100, 48000} + +func (c *Producer) addAudioTrack(mediaCode byte, sampleRate byte) { + // https://github.com/vigoss30611/buildroot-ltc/blob/master/system/qm/ipc/ProtocolService/src/ZhiNuo/inc/zn_dh_base_type.h + // PCM8 = 7, G729, IMA_ADPCM, G711U, G721, PCM8_VWIS, MS_ADPCM, G711A, PCM16 + var codec *core.Codec + switch mediaCode { + case 10: // G711U + codec = &core.Codec{ + Name: core.CodecPCMU, + } + case 14: // G711A + codec = &core.Codec{ + Name: core.CodecPCMA, + } + default: + println("[DVRIP] unsupported audio codec:", mediaCode) + return + } + + if sampleRate <= byte(len(sampleRates)) { + codec.ClockRate = sampleRates[sampleRate-1] + } + + media := &core.Media{ + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{codec}, + } + c.Medias = append(c.Medias, media) + + c.audio = core.NewReceiver(media, codec) + c.Receivers = append(c.Receivers, c.audio) +} + +//func (c *Client) MarshalJSON() ([]byte, error) { +// info := &core.Info{ +// Type: "DVRIP active producer", +// RemoteAddr: c.conn.RemoteAddr().String(), +// Medias: c.Medias, +// Receivers: c.Receivers, +// Recv: c.Recv, +// } +// return json.Marshal(info) +//} diff --git a/installs_on_host/go2rtc/pkg/eseecloud/eseecloud.go b/installs_on_host/go2rtc/pkg/eseecloud/eseecloud.go new file mode 100644 index 0000000..05209d2 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/eseecloud/eseecloud.go @@ -0,0 +1,180 @@ +package eseecloud + +import ( + "bytes" + "encoding/binary" + "errors" + "io" + "net/http" + "regexp" + "strings" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264/annexb" + "github.com/pion/rtp" +) + +type Producer struct { + core.Connection + rd *core.ReadBuffer + + videoPT, audioPT uint8 +} + +func Dial(rawURL string) (core.Producer, error) { + rawURL, _ = strings.CutPrefix(rawURL, "eseecloud") + res, err := http.Get("http" + rawURL) + if err != nil { + return nil, err + } + + prod, err := Open(res.Body) + if err != nil { + return nil, err + } + + if info, ok := prod.(core.Info); ok { + info.SetProtocol("http") + info.SetURL(rawURL) + } + + return prod, nil +} + +func Open(r io.Reader) (core.Producer, error) { + prod := &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "eseecloud", + Transport: r, + }, + rd: core.NewReadBuffer(r), + } + + if err := prod.probe(); err != nil { + return nil, err + } + + return prod, nil +} + +func (p *Producer) probe() error { + b, err := p.rd.Peek(1024) + if err != nil { + return err + } + + i := bytes.Index(b, []byte("\r\n\r\n")) + if i == -1 { + return io.EOF + } + + b = make([]byte, i+4) + _, _ = p.rd.Read(b) + + re := regexp.MustCompile(`m=(video|audio) (\d+) (\w+)/(\d+)\S*`) + for _, item := range re.FindAllStringSubmatch(string(b), 2) { + p.SDP += item[0] + "\n" + + switch item[3] { + case "H264", "H265": + p.Medias = append(p.Medias, &core.Media{ + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + { + Name: item[3], + ClockRate: 90000, + PayloadType: core.PayloadTypeRAW, + }, + }, + }) + p.videoPT = byte(core.Atoi(item[2])) + + case "G711": + p.Medias = append(p.Medias, &core.Media{ + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + { + Name: core.CodecPCMA, + ClockRate: 8000, + }, + }, + }) + p.audioPT = byte(core.Atoi(item[2])) + } + } + + return nil +} + +func (p *Producer) Start() error { + receivers := make(map[uint8]*core.Receiver) + + for _, receiver := range p.Receivers { + switch receiver.Codec.Kind() { + case core.KindVideo: + receivers[p.videoPT] = receiver + case core.KindAudio: + receivers[p.audioPT] = receiver + } + } + + for { + pkt, err := p.readPacket() + if err != nil { + return err + } + + if recv := receivers[pkt.PayloadType]; recv != nil { + switch recv.Codec.Name { + case core.CodecH264, core.CodecH265: + // timestamp = seconds x 1000000 + pkt = &rtp.Packet{ + Header: rtp.Header{ + Timestamp: uint32(uint64(pkt.Timestamp) * 90000 / 1000000), + }, + Payload: annexb.EncodeToAVCC(pkt.Payload), + } + case core.CodecPCMA: + pkt = &rtp.Packet{ + Header: rtp.Header{ + Version: 2, + SequenceNumber: pkt.SequenceNumber, + Timestamp: uint32(uint64(pkt.Timestamp) * 8000 / 1000000), + }, + Payload: pkt.Payload, + } + } + recv.WriteRTP(pkt) + } + } +} + +func (p *Producer) readPacket() (*core.Packet, error) { + b := make([]byte, 8) + + if _, err := io.ReadFull(p.rd, b); err != nil { + return nil, err + } + + if b[0] != '$' { + return nil, errors.New("eseecloud: wrong start byte") + } + + size := binary.BigEndian.Uint32(b[4:]) + b = make([]byte, size) + if _, err := io.ReadFull(p.rd, b); err != nil { + return nil, err + } + + pkt := &core.Packet{} + if err := pkt.Unmarshal(b); err != nil { + return nil, err + } + + p.Recv += int(size) + + return pkt, nil +} diff --git a/installs_on_host/go2rtc/pkg/expr/expr.go b/installs_on_host/go2rtc/pkg/expr/expr.go new file mode 100644 index 0000000..e82551b --- /dev/null +++ b/installs_on_host/go2rtc/pkg/expr/expr.go @@ -0,0 +1,166 @@ +package expr + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "regexp" + "strings" + "time" + + "github.com/expr-lang/expr" + "github.com/expr-lang/expr/vm" +) + +func newRequest(rawURL string, options map[string]any) (*http.Request, error) { + var method, contentType string + var rd io.Reader + + // method from js fetch + if s, ok := options["method"].(string); ok { + method = s + } else { + method = "GET" + } + + // params key from python requests + if kv, ok := options["params"].(map[string]any); ok { + rawURL += "?" + url.Values(kvToString(kv)).Encode() + } + + // json key from python requests + // data key from python requests + // body key from js fetch + if v, ok := options["json"]; ok { + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + contentType = "application/json" + rd = bytes.NewReader(b) + } else if kv, ok := options["data"].(map[string]any); ok { + contentType = "application/x-www-form-urlencoded" + rd = strings.NewReader(url.Values(kvToString(kv)).Encode()) + } else if s, ok := options["body"].(string); ok { + rd = strings.NewReader(s) + } + + req, err := http.NewRequest(method, rawURL, rd) + if err != nil { + return nil, err + } + + if kv, ok := options["headers"].(map[string]any); ok { + req.Header = kvToString(kv) + } + + if contentType != "" && req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", contentType) + } + + return req, nil +} + +func kvToString(kv map[string]any) map[string][]string { + dst := make(map[string][]string, len(kv)) + for k, v := range kv { + dst[k] = []string{fmt.Sprintf("%v", v)} + } + return dst +} + +func regExp(params ...any) (*regexp.Regexp, error) { + exp := params[0].(string) + if len(params) >= 2 { + // support: + // i case-insensitive (default false) + // m multi-line mode: ^ and $ match begin/end line (default false) + // s let . match \n (default false) + // https://pkg.go.dev/regexp/syntax + flags := params[1].(string) + exp = "(?" + flags + ")" + exp + } + return regexp.Compile(exp) +} + +func Compile(input string) (*vm.Program, error) { + // support http sessions + jar, _ := cookiejar.New(nil) + client := http.Client{ + Jar: jar, + Timeout: 5 * time.Second, + } + + return expr.Compile( + input, + expr.Function( + "fetch", + func(params ...any) (any, error) { + var req *http.Request + var err error + + rawURL := params[0].(string) + + if len(params) == 2 { + options := params[1].(map[string]any) + req, err = newRequest(rawURL, options) + } else { + req, err = http.NewRequest("GET", rawURL, nil) + } + + if err != nil { + return nil, err + } + + res, err := client.Do(req) + if err != nil { + return nil, err + } + + b, _ := io.ReadAll(res.Body) + + return map[string]any{ + "ok": res.StatusCode < 400, + "status": res.Status, + "text": string(b), + "json": func() (v any) { + _ = json.Unmarshal(b, &v) + return + }, + }, nil + }, + //new(func(url string) map[string]any), + //new(func(url string, options map[string]any) map[string]any), + ), + expr.Function( + "match", + func(params ...any) (any, error) { + re, err := regExp(params[1:]...) + if err != nil { + return nil, err + } + str := params[0].(string) + return re.FindStringSubmatch(str), nil + }, + //new(func(str, expr string) []string), + //new(func(str, expr, flags string) []string), + ), + ) +} + +func Eval(input string, env any) (any, error) { + program, err := Compile(input) + if err != nil { + return nil, err + } + + return expr.Run(program, env) +} + +func Run(program *vm.Program, env any) (any, error) { + return vm.Run(program, env) +} diff --git a/installs_on_host/go2rtc/pkg/expr/expr_test.go b/installs_on_host/go2rtc/pkg/expr/expr_test.go new file mode 100644 index 0000000..096afcd --- /dev/null +++ b/installs_on_host/go2rtc/pkg/expr/expr_test.go @@ -0,0 +1,17 @@ +package expr + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMatchHost(t *testing.T) { + v, err := Eval(` +let url = "rtsp://user:pass@192.168.1.123/cam/realmonitor?..."; +let host = match(url, "//[^/]+")[0][2:]; +host +`, nil) + require.Nil(t, err) + require.Equal(t, "user:pass@192.168.1.123", v) +} diff --git a/installs_on_host/go2rtc/pkg/ffmpeg/README.md b/installs_on_host/go2rtc/pkg/ffmpeg/README.md new file mode 100644 index 0000000..157cfdf --- /dev/null +++ b/installs_on_host/go2rtc/pkg/ffmpeg/README.md @@ -0,0 +1,68 @@ +## FFplay output + +[FFplay](https://stackoverflow.com/questions/27778678/what-are-mv-fd-aq-vq-sq-and-f-in-a-video-stream) `7.11 A-V: 0.003 fd= 1 aq= 21KB vq= 321KB sq= 0B f=0/0`: + +- `7.11` - master clock, is the time from start of the stream/video +- `A-V` - av_diff, difference between audio and video timestamps +- `fd` - frames dropped +- `aq` - audio queue (0 - no delay) +- `vq` - video queue (0 - no delay) +- `sq` - subtitle queue +- `f` - timestamp error correction rate (Not 100% sure) + +`M-V`, `M-A` means video stream only, audio stream only respectively. + +## Devices Windows + +``` +>ffmpeg -hide_banner -f dshow -list_options true -i video="VMware Virtual USB Video Device" +[dshow @ 0000025695e52900] DirectShow video device options (from video devices) +[dshow @ 0000025695e52900] Pin "Record" (alternative pin name "0") +[dshow @ 0000025695e52900] pixel_format=yuyv422 min s=1280x720 fps=1 max s=1280x720 fps=10 +[dshow @ 0000025695e52900] pixel_format=yuyv422 min s=1280x720 fps=1 max s=1280x720 fps=10 (tv, bt470bg/bt709/unknown, topleft) +[dshow @ 0000025695e52900] pixel_format=nv12 min s=1280x720 fps=1 max s=1280x720 fps=23 +[dshow @ 0000025695e52900] pixel_format=nv12 min s=1280x720 fps=1 max s=1280x720 fps=23 (tv, bt470bg/bt709/unknown, topleft) +``` + +## Devices Mac + +``` +% ./ffmpeg -hide_banner -f avfoundation -list_devices true -i "" +[AVFoundation indev @ 0x7f8b1f504d80] AVFoundation video devices: +[AVFoundation indev @ 0x7f8b1f504d80] [0] FaceTime HD Camera +[AVFoundation indev @ 0x7f8b1f504d80] [1] Capture screen 0 +[AVFoundation indev @ 0x7f8b1f504d80] AVFoundation audio devices: +[AVFoundation indev @ 0x7f8b1f504d80] [0] Soundflower (2ch) +[AVFoundation indev @ 0x7f8b1f504d80] [1] Built-in Microphone +[AVFoundation indev @ 0x7f8b1f504d80] [2] Soundflower (64ch) +``` + +## Devices Linux + +``` +# ffmpeg -hide_banner -f v4l2 -list_formats all -i /dev/video0 +[video4linux2,v4l2 @ 0x7f7de7c58bc0] Raw : yuyv422 : YUYV 4:2:2 : 640x480 160x120 176x144 320x176 320x240 352x288 432x240 544x288 640x360 752x416 800x448 800x600 864x480 960x544 960x720 1024x576 1184x656 1280x720 1280x960 +[video4linux2,v4l2 @ 0x7f7de7c58bc0] Compressed: mjpeg : Motion-JPEG : 640x480 160x120 176x144 320x176 320x240 352x288 432x240 544x288 640x360 752x416 800x448 800x600 864x480 960x544 960x720 1024x576 1184x656 1280x720 1280x960 +``` + +## TTS + +```yaml +streams: + tts: ffmpeg:#input=-readrate 1 -readrate_initial_burst 0.001 -f lavfi -i "flite=text='1 2 3 4 5 6 7 8 9 0'"#audio=pcma +``` + +## Useful links + +- https://superuser.com/questions/564402/explanation-of-x264-tune +- https://stackoverflow.com/questions/33624016/why-sliced-thread-affect-so-much-on-realtime-encoding-using-ffmpeg-x264 +- https://codec.fandom.com/ru/wiki/X264_-_описание_ключей_кодирования +- https://html5test.com/ +- https://trac.ffmpeg.org/wiki/Capture/Webcam +- https://trac.ffmpeg.org/wiki/DirectShow +- https://stackoverflow.com/questions/53207692/libav-mjpeg-encoding-and-huffman-table +- https://github.com/tuupola/esp_video/blob/master/README.md +- https://github.com/leandromoreira/ffmpeg-libav-tutorial +- https://www.reddit.com/user/VeritablePornocopium/comments/okw130/ffmpeg_with_libfdk_aac_for_windows_x64/ +- https://slhck.info/video/2017/02/24/vbr-settings.html +- [HomeKit audio samples problem](https://superuser.com/questions/1290996/non-monotonous-dts-with-igndts-flag) diff --git a/installs_on_host/go2rtc/pkg/ffmpeg/ffmpeg.go b/installs_on_host/go2rtc/pkg/ffmpeg/ffmpeg.go new file mode 100644 index 0000000..a7ca71c --- /dev/null +++ b/installs_on_host/go2rtc/pkg/ffmpeg/ffmpeg.go @@ -0,0 +1,123 @@ +package ffmpeg + +import ( + "bytes" + "strconv" + "strings" +) + +// correlation of libavformat versions with ffmpeg versions +const ( + Version50 = "59. 16" + Version51 = "59. 27" + Version60 = "60. 3" + Version61 = "60. 16" + Version70 = "61. 1" +) + +type Args struct { + Bin string // ffmpeg + Global string // -hide_banner -v error + Input string // -re -stream_loop -1 -i /media/bunny.mp4 + Codecs []string // -c:v libx264 -g:v 30 -preset:v ultrafast -tune:v zerolatency + Filters []string // scale=1920:1080 + Output string // -f rtsp {output} + Version string // libavformat version, it's more reliable than the ffmpeg version + + Video, Audio int // count of Video and Audio params +} + +func (a *Args) AddCodec(codec string) { + a.Codecs = append(a.Codecs, codec) +} + +func (a *Args) AddFilter(filter string) { + a.Filters = append(a.Filters, filter) +} + +func (a *Args) InsertFilter(filter string) { + a.Filters = append([]string{filter}, a.Filters...) +} + +func (a *Args) HasFilters(filters ...string) bool { + for _, f1 := range a.Filters { + for _, f2 := range filters { + if strings.HasPrefix(f1, f2) { + return true + } + } + } + + return false +} + +func (a *Args) String() string { + b := bytes.NewBuffer(make([]byte, 0, 512)) + + b.WriteString(a.Bin) + + if a.Global != "" { + b.WriteByte(' ') + b.WriteString(a.Global) + } + + b.WriteByte(' ') + // starting from FFmpeg 6.1 readrate=1 has default initial bust 0.5 sec + // it might make us miss the first couple seconds of the file + if strings.HasPrefix(a.Input, "-re ") && a.Version >= Version61 { + b.WriteString("-readrate_initial_burst 0.001 ") + } + b.WriteString(a.Input) + + multimode := a.Video > 1 || a.Audio > 1 + var iv, ia int + + for _, codec := range a.Codecs { + // support multiple video and/or audio codecs + if multimode && len(codec) >= 5 { + switch codec[:5] { + case "-c:v ": + codec = "-map 0:v:0? " + strings.ReplaceAll(codec, ":v ", ":v:"+strconv.Itoa(iv)+" ") + iv++ + case "-c:a ": + codec = "-map 0:a:0? " + strings.ReplaceAll(codec, ":a ", ":a:"+strconv.Itoa(ia)+" ") + ia++ + } + } + + b.WriteByte(' ') + b.WriteString(codec) + } + + if len(a.Filters) > 0 { + for i, filter := range a.Filters { + if i == 0 { + b.WriteString(` -vf "`) + } else { + b.WriteByte(',') + } + b.WriteString(filter) + } + b.WriteByte('"') + } + + b.WriteByte(' ') + b.WriteString(a.Output) + + return b.String() +} + +func ParseVersion(b []byte) (ffmpeg string, libavformat string) { + if len(b) > 100 { + // ffmpeg version n7.0-30-g8b0fe91754-20240520 Copyright (c) 2000-2024 the FFmpeg developers + if i := bytes.IndexByte(b[15:], ' '); i > 0 { + ffmpeg = string(b[15 : 15+i]) + } + + // libavformat 60. 16.100 / 60. 16.100 + if i := strings.Index(string(b), "libavformat"); i > 0 { + libavformat = string(b[i+15 : i+25]) + } + } + return +} diff --git a/installs_on_host/go2rtc/pkg/flussonic/flussonic.go b/installs_on_host/go2rtc/pkg/flussonic/flussonic.go new file mode 100644 index 0000000..70b6e9d --- /dev/null +++ b/installs_on_host/go2rtc/pkg/flussonic/flussonic.go @@ -0,0 +1,176 @@ +package flussonic + +import ( + "strings" + + "github.com/AlexxIT/go2rtc/pkg/aac" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/iso" + "github.com/gorilla/websocket" + "github.com/pion/rtp" +) + +type Producer struct { + core.Connection + conn *websocket.Conn + + videoTrackID, audioTrackID uint32 + videoTimeScale, audioTimeScale float32 +} + +func Dial(source string) (core.Producer, error) { + url, _ := strings.CutPrefix(source, "flussonic:") + conn, _, err := websocket.DefaultDialer.Dial(url, nil) + if err != nil { + return nil, err + } + + prod := &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "flussonic", + Protocol: core.Before(url, ":"), // wss + RemoteAddr: conn.RemoteAddr().String(), + URL: url, + Transport: conn, + }, + conn: conn, + } + + if err = prod.probe(); err != nil { + _ = conn.Close() + return nil, err + } + + return prod, nil +} + +func (p *Producer) probe() error { + var init struct { + //Metadata struct { + // Tracks []struct { + // Width int `json:"width,omitempty"` + // Height int `json:"height,omitempty"` + // Fps int `json:"fps,omitempty"` + // Content string `json:"content"` + // TrackId string `json:"trackId"` + // Bitrate int `json:"bitrate"` + // } `json:"tracks"` + //} `json:"metadata"` + Tracks []struct { + Content string `json:"content"` + Id uint32 `json:"id"` + Payload []byte `json:"payload"` + } `json:"tracks"` + //Type string `json:"type"` + } + + if err := p.conn.ReadJSON(&init); err != nil { + return err + } + + var timeScale uint32 + + for _, track := range init.Tracks { + atoms, _ := iso.DecodeAtoms(track.Payload) + for _, atom := range atoms { + switch atom := atom.(type) { + case *iso.AtomMdhd: + timeScale = atom.TimeScale + case *iso.AtomVideo: + switch atom.Name { + case "avc1": + codec := h264.AVCCToCodec(atom.Config) + p.Medias = append(p.Medias, &core.Media{ + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{codec}, + }) + p.videoTrackID = track.Id + p.videoTimeScale = float32(codec.ClockRate) / float32(timeScale) + } + case *iso.AtomAudio: + switch atom.Name { + case "mp4a": + codec := aac.ConfigToCodec(atom.Config) + p.Medias = append(p.Medias, &core.Media{ + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{codec}, + }) + p.audioTrackID = track.Id + p.audioTimeScale = float32(codec.ClockRate) / float32(timeScale) + } + } + } + } + + return nil +} + +func (p *Producer) Start() error { + if err := p.conn.WriteMessage(websocket.TextMessage, []byte("resume")); err != nil { + return err + } + + receivers := make(map[uint32]*core.Receiver) + timeScales := make(map[uint32]float32) + + for _, receiver := range p.Receivers { + switch receiver.Codec.Kind() { + case core.KindVideo: + receivers[p.videoTrackID] = receiver + timeScales[p.videoTrackID] = p.videoTimeScale + case core.KindAudio: + receivers[p.audioTrackID] = receiver + timeScales[p.audioTrackID] = p.audioTimeScale + } + } + + ch := make(chan []byte, 10) + defer close(ch) + + go func() { + for b := range ch { + atoms, err := iso.DecodeAtoms(b) + if err != nil { + continue + } + + var trackID uint32 + var decodeTime uint64 + + for _, atom := range atoms { + switch atom := atom.(type) { + case *iso.AtomTfhd: + trackID = atom.TrackID + case *iso.AtomTfdt: + decodeTime = atom.DecodeTime + case *iso.AtomMdat: + b = atom.Data + } + } + + if recv := receivers[trackID]; recv != nil { + timestamp := uint32(float32(decodeTime) * timeScales[trackID]) + packet := &rtp.Packet{ + Header: rtp.Header{Timestamp: timestamp}, + Payload: b, + } + recv.WriteRTP(packet) + } + } + }() + + for { + mType, b, err := p.conn.ReadMessage() + if err != nil { + return err + } + if mType == websocket.BinaryMessage { + p.Recv += len(b) + ch <- b + } + } +} diff --git a/installs_on_host/go2rtc/pkg/flv/amf/amf.go b/installs_on_host/go2rtc/pkg/flv/amf/amf.go new file mode 100644 index 0000000..eae05fc --- /dev/null +++ b/installs_on_host/go2rtc/pkg/flv/amf/amf.go @@ -0,0 +1,239 @@ +package amf + +import ( + "encoding/binary" + "errors" + "math" +) + +const ( + TypeNumber byte = iota + TypeBoolean + TypeString + TypeObject + TypeNull = 5 + TypeEcmaArray = 8 + TypeObjectEnd = 9 +) + +// AMF spec: http://download.macromedia.com/pub/labs/amf/amf0_spec_121207.pdf +type AMF struct { + buf []byte + pos int +} + +var ErrRead = errors.New("amf: read error") + +func NewReader(b []byte) *AMF { + return &AMF{buf: b} +} + +func (a *AMF) ReadItems() ([]any, error) { + var items []any + for a.pos < len(a.buf) { + v, err := a.ReadItem() + if err != nil { + return nil, err + } + items = append(items, v) + } + return items, nil +} + +func (a *AMF) ReadItem() (any, error) { + dataType, err := a.ReadByte() + if err != nil { + return nil, err + } + + switch dataType { + case TypeNumber: + return a.ReadNumber() + + case TypeBoolean: + b, err := a.ReadByte() + return b != 0, err + + case TypeString: + return a.ReadString() + + case TypeObject: + return a.ReadObject() + + case TypeEcmaArray: + return a.ReadEcmaArray() + + case TypeNull: + return nil, nil + + case TypeObjectEnd: + return nil, nil + } + + return nil, ErrRead +} + +func (a *AMF) ReadByte() (byte, error) { + if a.pos >= len(a.buf) { + return 0, ErrRead + } + + v := a.buf[a.pos] + a.pos++ + return v, nil +} + +func (a *AMF) ReadNumber() (float64, error) { + if a.pos+8 > len(a.buf) { + return 0, ErrRead + } + + v := binary.BigEndian.Uint64(a.buf[a.pos : a.pos+8]) + a.pos += 8 + return math.Float64frombits(v), nil +} + +func (a *AMF) ReadString() (string, error) { + if a.pos+2 > len(a.buf) { + return "", ErrRead + } + + size := int(binary.BigEndian.Uint16(a.buf[a.pos:])) + a.pos += 2 + + if a.pos+size > len(a.buf) { + return "", ErrRead + } + + s := string(a.buf[a.pos : a.pos+size]) + a.pos += size + + return s, nil +} + +func (a *AMF) ReadObject() (map[string]any, error) { + obj := make(map[string]any) + + for { + k, err := a.ReadString() + if err != nil { + return nil, err + } + + v, err := a.ReadItem() + if err != nil { + return nil, err + } + + if k == "" { + break + } + + obj[k] = v + } + + return obj, nil +} + +func (a *AMF) ReadEcmaArray() (map[string]any, error) { + if a.pos+4 > len(a.buf) { + return nil, ErrRead + } + a.pos += 4 // skip size + + return a.ReadObject() +} + +func NewWriter() *AMF { + return &AMF{} +} + +func (a *AMF) Bytes() []byte { + return a.buf +} + +func (a *AMF) WriteNumber(n float64) { + b := math.Float64bits(n) + a.buf = append( + a.buf, TypeNumber, + byte(b>>56), byte(b>>48), byte(b>>40), byte(b>>32), + byte(b>>24), byte(b>>16), byte(b>>8), byte(b), + ) +} + +func (a *AMF) WriteBool(b bool) { + if b { + a.buf = append(a.buf, TypeBoolean, 1) + } else { + a.buf = append(a.buf, TypeBoolean, 0) + } +} + +func (a *AMF) WriteString(s string) { + n := len(s) + a.buf = append(a.buf, TypeString, byte(n>>8), byte(n)) + a.buf = append(a.buf, s...) +} + +func (a *AMF) WriteObject(obj map[string]any) { + a.buf = append(a.buf, TypeObject) + a.writeKV(obj) + a.buf = append(a.buf, 0, 0, TypeObjectEnd) +} + +func (a *AMF) WriteEcmaArray(obj map[string]any) { + n := len(obj) + a.buf = append(a.buf, TypeEcmaArray, byte(n>>24), byte(n>>16), byte(n>>8), byte(n)) + a.writeKV(obj) + a.buf = append(a.buf, 0, 0, TypeObjectEnd) +} + +func (a *AMF) writeKV(obj map[string]any) { + for k, v := range obj { + n := len(k) + a.buf = append(a.buf, byte(n>>8), byte(n)) + a.buf = append(a.buf, k...) + + switch v := v.(type) { + case string: + a.WriteString(v) + case int: + a.WriteNumber(float64(v)) + case uint16: + a.WriteNumber(float64(v)) + case uint32: + a.WriteNumber(float64(v)) + case float64: + a.WriteNumber(v) + case bool: + a.WriteBool(v) + default: + panic(v) + } + } +} + +func (a *AMF) WriteNull() { + a.buf = append(a.buf, TypeNull) +} + +func EncodeItems(items ...any) []byte { + a := &AMF{} + for _, item := range items { + switch v := item.(type) { + case float64: + a.WriteNumber(v) + case int: + a.WriteNumber(float64(v)) + case string: + a.WriteString(v) + case map[string]any: + a.WriteObject(v) + case nil: + a.WriteNull() + default: + panic(v) + } + } + return a.Bytes() +} diff --git a/installs_on_host/go2rtc/pkg/flv/amf/amf_test.go b/installs_on_host/go2rtc/pkg/flv/amf/amf_test.go new file mode 100644 index 0000000..81e506d --- /dev/null +++ b/installs_on_host/go2rtc/pkg/flv/amf/amf_test.go @@ -0,0 +1,281 @@ +package amf + +import ( + "encoding/hex" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewReader(t *testing.T) { + tests := []struct { + name string + actual string + expect []any + }{ + { + name: "ffmpeg-http", + actual: "02000a6f6e4d65746144617461080000001000086475726174696f6e000000000000000000000577696474680040940000000000000006686569676874004086800000000000000d766964656f646174617261746500409e62770000000000096672616d6572617465004038000000000000000c766964656f636f646563696400401c000000000000000d617564696f646174617261746500405ea93000000000000f617564696f73616d706c65726174650040e5888000000000000f617564696f73616d706c6573697a65004030000000000000000673746572656f0101000c617564696f636f6465636964004024000000000000000b6d616a6f725f6272616e640200046d703432000d6d696e6f725f76657273696f6e020001300011636f6d70617469626c655f6272616e647302000c69736f6d617663316d7034320007656e636f64657202000c4c61766636302e352e313030000866696c6573697a65000000000000000000000009", + expect: []any{ + "onMetaData", + map[string]any{ + "compatible_brands": "isomavc1mp42", + "major_brand": "mp42", + "minor_version": "0", + "encoder": "Lavf60.5.100", + + "filesize": float64(0), + "duration": float64(0), + + "videocodecid": float64(7), + "width": float64(1280), + "height": float64(720), + "framerate": float64(24), + "videodatarate": 1944.6162109375, + + "audiocodecid": float64(10), + "audiosamplerate": float64(44100), + "stereo": true, + "audiosamplesize": float64(16), + "audiodatarate": 122.6435546875, + }, + }, + }, + { + name: "ffmpeg-file", + actual: "02000a6f6e4d65746144617461080000000800086475726174696f6e004000000000000000000577696474680040940000000000000006686569676874004086800000000000000d766964656f646174617261746500000000000000000000096672616d6572617465004039000000000000000c766964656f636f646563696400401c0000000000000007656e636f64657202000c4c61766636302e352e313030000866696c6573697a6500411f541400000000000009", + expect: []any{ + "onMetaData", + map[string]any{ + "encoder": "Lavf60.5.100", + + "filesize": float64(513285), + "duration": float64(2), + + "videocodecid": float64(7), + "width": float64(1280), + "height": float64(720), + "framerate": float64(25), + "videodatarate": float64(0), + }, + }, + }, + { + name: "reolink-1", + actual: "0200075f726573756c74003ff0000000000000030006666d7356657202000d464d532f332c302c312c313233000c6361706162696c697469657300403f0000000000000000090300056c6576656c0200067374617475730004636f646502001d4e6574436f6e6e656374696f6e2e436f6e6e6563742e53756363657373000b6465736372697074696f6e020015436f6e6e656374696f6e207375636365656465642e000e6f626a656374456e636f64696e67000000000000000000000009", + expect: []any{ + "_result", float64(1), + map[string]any{ + "capabilities": float64(31), + "fmsVer": "FMS/3,0,1,123", + }, + map[string]any{ + "code": "NetConnection.Connect.Success", + "description": "Connection succeeded.", + "level": "status", + "objectEncoding": float64(0), + }, + }, + }, + { + name: "reolink-2", + actual: "0200075f726573756c7400400000000000000005003ff0000000000000", + expect: []any{ + "_result", float64(2), nil, float64(1), + }, + }, + { + name: "reolink-3", + actual: "0200086f6e537461747573000000000000000000050300056c6576656c0200067374617475730004636f64650200144e657453747265616d2e506c61792e5374617274000b6465736372697074696f6e020015537461727420766964656f206f6e2064656d616e64000009", + expect: []any{ + "onStatus", float64(0), nil, + map[string]any{ + "code": "NetStream.Play.Start", + "description": "Start video on demand", + "level": "status", + }, + }, + }, + { + name: "reolink-4", + actual: "0200117c52746d7053616d706c6541636365737301010101", + expect: []any{ + "|RtmpSampleAccess", true, true, + }, + }, + { + name: "reolink-5", + actual: "02000a6f6e4d6574614461746103000577696474680040a4000000000000000668656967687400409e000000000000000c646973706c617957696474680040a4000000000000000d646973706c617948656967687400409e00000000000000086475726174696f6e000000000000000000000c766964656f636f646563696400401c000000000000000c617564696f636f6465636964004024000000000000000f617564696f73616d706c65726174650040cf40000000000000096672616d657261746500403e000000000000000009", + expect: []any{ + "onMetaData", + map[string]any{ + "duration": float64(0), + + "videocodecid": float64(7), + "width": float64(2560), + "height": float64(1920), + "displayWidth": float64(2560), + "displayHeight": float64(1920), + "framerate": float64(30), + + "audiocodecid": float64(10), + "audiosamplerate": float64(16000), + }, + }, + }, + { + name: "mediamtx", + actual: "02000d40736574446174614672616d6502000a6f6e4d6574614461746103000d766964656f6461746172617465000000000000000000000c766964656f636f646563696400401c000000000000000d617564696f6461746172617465000000000000000000000c617564696f636f6465636964004024000000000000000009", + expect: []any{ + "@setDataFrame", + "onMetaData", + map[string]any{ + "videocodecid": float64(7), + "videodatarate": float64(0), + "audiocodecid": float64(10), + "audiodatarate": float64(0), + }, + }, + }, + { + name: "mediamtx", + actual: "0200075f726573756c74003ff0000000000000030006666d7356657202000d4c4e5820392c302c3132342c32000c6361706162696c697469657300403f0000000000000000090300056c6576656c0200067374617475730004636f646502001d4e6574436f6e6e656374696f6e2e436f6e6e6563742e53756363657373000b6465736372697074696f6e020015436f6e6e656374696f6e207375636365656465642e000e6f626a656374456e636f64696e67000000000000000000000009", + expect: []any{ + "_result", float64(1), map[string]any{ + "capabilities": float64(31), + "fmsVer": "LNX 9,0,124,2", + }, map[string]any{ + "code": "NetConnection.Connect.Success", + "description": "Connection succeeded.", + "level": "status", + "objectEncoding": float64(0), + }, + }, + }, + { + name: "mediamtx", + actual: "0200075f726573756c7400401000000000000005003ff0000000000000", + expect: []any{"_result", float64(4), any(nil), float64(1)}, + }, + { + name: "mediamtx", + actual: "0200086f6e537461747573004014000000000000050300056c6576656c0200067374617475730004636f64650200144e657453747265616d2e506c61792e5265736574000b6465736372697074696f6e02000a706c6179207265736574000009", + expect: []any{ + "onStatus", float64(5), any(nil), map[string]any{ + "code": "NetStream.Play.Reset", + "description": "play reset", + "level": "status", + }, + }, + }, + { + name: "mediamtx", + actual: "0200086f6e537461747573004014000000000000050300056c6576656c0200067374617475730004636f64650200144e657453747265616d2e506c61792e5374617274000b6465736372697074696f6e02000a706c6179207374617274000009", + expect: []any{ + "onStatus", float64(5), any(nil), map[string]any{ + "code": "NetStream.Play.Start", + "description": "play start", + "level": "status", + }, + }, + }, + { + name: "mediamtx", + actual: "0200086f6e537461747573004014000000000000050300056c6576656c0200067374617475730004636f64650200144e657453747265616d2e446174612e5374617274000b6465736372697074696f6e02000a64617461207374617274000009", + expect: []any{ + "onStatus", float64(5), any(nil), map[string]any{ + "code": "NetStream.Data.Start", + "description": "data start", + "level": "status", + }, + }, + }, + { + name: "mediamtx", + actual: "0200086f6e537461747573004014000000000000050300056c6576656c0200067374617475730004636f646502001c4e657453747265616d2e506c61792e5075626c6973684e6f74696679000b6465736372697074696f6e02000e7075626c697368206e6f74696679000009", + expect: []any{ + "onStatus", float64(5), any(nil), map[string]any{ + "code": "NetStream.Play.PublishNotify", + "description": "publish notify", + "level": "status", + }, + }, + }, + { + name: "obs-connect", + actual: "020007636f6e6e656374003ff000000000000003000361707002000c617070312f73747265616d3100047479706502000a6e6f6e70726976617465000e737570706f727473476f4177617901010008666c61736856657202001f464d4c452f332e302028636f6d70617469626c653b20464d53632f312e3029000673776655726c02002272746d703a2f2f3139322e3136382e31302e3130312f617070312f73747265616d310005746355726c02002272746d703a2f2f3139322e3136382e31302e3130312f617070312f73747265616d31000009", + expect: []any{ + "connect", float64(1), + map[string]any{ + "app": "app1/stream1", + "flashVer": "FMLE/3.0 (compatible; FMSc/1.0)", + "supportsGoAway": true, + "swfUrl": "rtmp://192.168.10.101/app1/stream1", + "tcUrl": "rtmp://192.168.10.101/app1/stream1", + "type": "nonprivate", + }, + }, + }, + { + name: "obs-key", + actual: "02000d72656c6561736553747265616d004000000000000000050200046b657931", + expect: []any{ + "releaseStream", float64(2), nil, "key1", + }, + }, + { + name: "obs", + actual: "02000d40736574446174614672616d6502000a6f6e4d65746144617461080000001400086475726174696f6e000000000000000000000866696c6553697a65000000000000000000000577696474680040840000000000000006686569676874004076800000000000000c766964656f636f646563696400401c000000000000000d766964656f64617461726174650040a388000000000000096672616d6572617465004039000000000000000c617564696f636f6465636964004024000000000000000d617564696f6461746172617465004064000000000000000f617564696f73616d706c65726174650040e5888000000000000f617564696f73616d706c6573697a65004030000000000000000d617564696f6368616e6e656c73004000000000000000000673746572656f01010003322e3101000003332e3101000003342e3001000003342e3101000003352e3101000003372e3101000007656e636f6465720200376f62732d6f7574707574206d6f64756c6520286c69626f62732076657273696f6e2032392e302e302d36322d6739303031323131663829000009", + expect: []any{ + "@setDataFrame", "onMetaData", map[string]any{ + "2.1": false, + "3.1": false, + "4.0": false, + "4.1": false, + "5.1": false, + "7.1": false, + "audiochannels": float64(2), + "audiocodecid": float64(10), + "audiodatarate": float64(160), + "audiosamplerate": float64(44100), + "audiosamplesize": float64(16), + "duration": float64(0), + "encoder": "obs-output module (libobs version 29.0.0-62-g9001211f8)", + "fileSize": float64(0), + "framerate": float64(25), + "height": float64(360), + "stereo": true, + "videocodecid": float64(7), + "videodatarate": float64(2500), + "width": float64(640), + }, + }, + }, + { + name: "telegram-2", + actual: "0200075f726573756c7400400000000000000005", + expect: []any{ + "_result", float64(2), nil, + }, + }, + { + name: "telegram-4", + actual: "0200075f726573756c7400401000000000000005003ff0000000000000", + expect: []any{ + "_result", float64(4), nil, float64(1), + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + b, err := hex.DecodeString(test.actual) + require.Nil(t, err) + + rd := NewReader(b) + v, err := rd.ReadItems() + require.Nil(t, err) + + require.Equal(t, test.expect, v) + }) + } +} diff --git a/installs_on_host/go2rtc/pkg/flv/consumer.go b/installs_on_host/go2rtc/pkg/flv/consumer.go new file mode 100644 index 0000000..fe966bf --- /dev/null +++ b/installs_on_host/go2rtc/pkg/flv/consumer.go @@ -0,0 +1,94 @@ +package flv + +import ( + "io" + + "github.com/AlexxIT/go2rtc/pkg/aac" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/pion/rtp" +) + +type Consumer struct { + core.Connection + wr *core.WriteBuffer + muxer *Muxer +} + +func NewConsumer() *Consumer { + medias := []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecH264}, + }, + }, + { + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecAAC}, + }, + }, + } + wr := core.NewWriteBuffer(nil) + return &Consumer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "flv", + Medias: medias, + Transport: wr, + }, + wr: wr, + muxer: &Muxer{}, + } +} + +func (c *Consumer) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + sender := core.NewSender(media, track.Codec) + + switch track.Codec.Name { + case core.CodecH264: + payload := c.muxer.GetPayloader(track.Codec) + + sender.Handler = func(pkt *rtp.Packet) { + b := payload(pkt) + if n, err := c.wr.Write(b); err == nil { + c.Send += n + } + } + + if track.Codec.IsRTP() { + sender.Handler = h264.RTPDepay(track.Codec, sender.Handler) + } else { + sender.Handler = h264.RepairAVCC(track.Codec, sender.Handler) + } + + case core.CodecAAC: + payload := c.muxer.GetPayloader(track.Codec) + + sender.Handler = func(pkt *rtp.Packet) { + b := payload(pkt) + if n, err := c.wr.Write(b); err == nil { + c.Send += n + } + } + + if track.Codec.IsRTP() { + sender.Handler = aac.RTPDepay(sender.Handler) + } + } + + sender.HandleRTP(track) + c.Senders = append(c.Senders, sender) + return nil +} + +func (c *Consumer) WriteTo(wr io.Writer) (int64, error) { + b := c.muxer.GetInit() + if _, err := wr.Write(b); err != nil { + return 0, err + } + return c.wr.WriteTo(wr) +} diff --git a/installs_on_host/go2rtc/pkg/flv/flv_test.go b/installs_on_host/go2rtc/pkg/flv/flv_test.go new file mode 100644 index 0000000..389272b --- /dev/null +++ b/installs_on_host/go2rtc/pkg/flv/flv_test.go @@ -0,0 +1,21 @@ +package flv + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestTimeToRTP(t *testing.T) { + // Reolink camera has 20 FPS + // Video timestamp increases by 50ms, SampleRate 90000, RTP timestamp increases by 4500 + // Audio timestamp increases by 64ms, SampleRate 16000, RTP timestamp increases by 1024 + frameN := 1 + for i := 0; i < 32; i++ { + // 1000ms/(90000/4500) = 50ms + require.Equal(t, uint32(frameN*4500), TimeToRTP(uint32(frameN*50), 90000)) + // 1000ms/(16000/1024) = 64ms + require.Equal(t, uint32(frameN*1024), TimeToRTP(uint32(frameN*64), 16000)) + frameN *= 2 + } +} diff --git a/installs_on_host/go2rtc/pkg/flv/muxer.go b/installs_on_host/go2rtc/pkg/flv/muxer.go new file mode 100644 index 0000000..b04d898 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/flv/muxer.go @@ -0,0 +1,174 @@ +package flv + +import ( + "encoding/binary" + "encoding/hex" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/flv/amf" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/pion/rtp" +) + +type Muxer struct { + codecs []*core.Codec +} + +const ( + FlagsVideo = 0b001 + FlagsAudio = 0b100 +) + +func (m *Muxer) GetInit() []byte { + b := []byte{ + 'F', 'L', 'V', // signature + 1, // version + 0, // flags (has video/audio) + 0, 0, 0, 9, // header size + 0, 0, 0, 0, // tag 0 size + } + + obj := map[string]any{} + + for _, codec := range m.codecs { + switch codec.Name { + case core.CodecH264: + b[4] |= FlagsVideo + obj["videocodecid"] = CodecH264 + + case core.CodecAAC: + b[4] |= FlagsAudio + obj["audiocodecid"] = CodecAAC + obj["audiosamplerate"] = codec.ClockRate + obj["audiosamplesize"] = 16 + obj["stereo"] = codec.Channels == 2 + } + } + + data := amf.EncodeItems("@setDataFrame", "onMetaData", obj) + b = append(b, EncodeTag(TagData, 0, data)...) + + for _, codec := range m.codecs { + switch codec.Name { + case core.CodecH264: + sps, pps := h264.GetParameterSet(codec.FmtpLine) + if len(sps) == 0 { + sps = []byte{0x67, 0x42, 0x00, 0x0a, 0xf8, 0x41, 0xa2} + } else { + h264.FixPixFmt(sps) + } + if len(pps) == 0 { + pps = []byte{0x68, 0xce, 0x38, 0x80} + } + + config := h264.EncodeConfig(sps, pps) + video := append(encodeAVData(codec, 0), config...) + b = append(b, EncodeTag(TagVideo, 0, video)...) + + case core.CodecAAC: + s := core.Between(codec.FmtpLine, "config=", ";") + config, _ := hex.DecodeString(s) + audio := append(encodeAVData(codec, 0), config...) + b = append(b, EncodeTag(TagAudio, 0, audio)...) + } + } + + return b +} + +func (m *Muxer) GetPayloader(codec *core.Codec) func(packet *rtp.Packet) []byte { + m.codecs = append(m.codecs, codec) + + var ts0 uint32 + var k = codec.ClockRate / 1000 + + switch codec.Name { + case core.CodecH264: + buf := encodeAVData(codec, 1) + + return func(packet *rtp.Packet) []byte { + if h264.IsKeyframe(packet.Payload) { + buf[0] = 1<<4 | 7 + } else { + buf[0] = 2<<4 | 7 + } + + buf = append(buf[:5], packet.Payload...) // reset buffer to previous place + + if ts0 == 0 { + ts0 = packet.Timestamp + } + + timeMS := (packet.Timestamp - ts0) / k + return EncodeTag(TagVideo, timeMS, buf) + } + + case core.CodecAAC: + buf := encodeAVData(codec, 1) + + return func(packet *rtp.Packet) []byte { + buf = append(buf[:2], packet.Payload...) + + if ts0 == 0 { + ts0 = packet.Timestamp + } + + timeMS := (packet.Timestamp - ts0) / k + return EncodeTag(TagAudio, timeMS, buf) + } + } + + return nil +} + +func EncodeTag(tagType byte, timeMS uint32, payload []byte) []byte { + payloadSize := uint32(len(payload)) + tagSize := payloadSize + 11 + + b := make([]byte, tagSize+4) + b[0] = tagType + b[1] = byte(payloadSize >> 16) + b[2] = byte(payloadSize >> 8) + b[3] = byte(payloadSize) + b[4] = byte(timeMS >> 16) + b[5] = byte(timeMS >> 8) + b[6] = byte(timeMS) + b[7] = byte(timeMS >> 24) + copy(b[11:], payload) + + binary.BigEndian.PutUint32(b[tagSize:], tagSize) + return b +} + +func encodeAVData(codec *core.Codec, isFrame byte) []byte { + switch codec.Name { + case core.CodecH264: + return []byte{ + 1<<4 | 7, // keyframe + AVC + isFrame, // 0 - config, 1 - frame + 0, 0, 0, // composition time = 0 + } + + case core.CodecAAC: + var b0 byte = 10 << 4 // AAC + + switch codec.ClockRate { + case 11025: + b0 |= 1 << 2 + case 22050: + b0 |= 2 << 2 + case 44100: + b0 |= 3 << 2 + } + + b0 |= 1 << 1 // 16 bits + + if codec.Channels == 2 { + b0 |= 1 + } + + return []byte{b0, isFrame} // 0 - config, 1 - frame + } + + return nil +} diff --git a/installs_on_host/go2rtc/pkg/flv/producer.go b/installs_on_host/go2rtc/pkg/flv/producer.go new file mode 100644 index 0000000..38c601d --- /dev/null +++ b/installs_on_host/go2rtc/pkg/flv/producer.go @@ -0,0 +1,312 @@ +package flv + +import ( + "bytes" + "encoding/binary" + "errors" + "io" + "time" + + "github.com/AlexxIT/go2rtc/pkg/aac" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/h265" + "github.com/pion/rtp" +) + +type Producer struct { + core.Connection + rd *core.ReadBuffer + + video, audio *core.Receiver +} + +func Open(rd io.Reader) (*Producer, error) { + prod := &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "flv", + Transport: rd, + }, + rd: core.NewReadBuffer(rd), + } + if err := prod.probe(); err != nil { + return nil, err + } + return prod, nil +} + +const ( + Signature = "FLV" + + TagAudio = 8 + TagVideo = 9 + TagData = 18 + + CodecAAC = 10 + + CodecH264 = 7 + CodecHEVC = 12 +) + +const ( + PacketTypeAVCHeader = iota + PacketTypeAVCNALU + PacketTypeAVCEnd +) + +const ( + PacketTypeSequenceStart = iota + PacketTypeCodedFrames + PacketTypeSequenceEnd + PacketTypeCodedFramesX + PacketTypeMetadata + PacketTypeMPEG2TSSequenceStart +) + +func (c *Producer) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + receiver, _ := c.Connection.GetTrack(media, codec) + if media.Kind == core.KindVideo { + c.video = receiver + } else { + c.audio = receiver + } + return receiver, nil +} + +func (c *Producer) Start() error { + for { + pkt, err := c.readPacket() + if err != nil { + return err + } + + c.Recv += len(pkt.Payload) + + switch pkt.PayloadType { + case TagAudio: + if c.audio == nil || pkt.Payload[1] == 0 { + continue + } + + pkt.Timestamp = TimeToRTP(pkt.Timestamp, c.audio.Codec.ClockRate) + pkt.Payload = pkt.Payload[2:] + c.audio.WriteRTP(pkt) + + case TagVideo: + if c.video == nil { + continue + } + + if isExHeader(pkt.Payload) { + switch packetType := pkt.Payload[0] & 0b1111; packetType { + case PacketTypeCodedFrames: + // frame type 4b, packet type 4b, fourCC 32b, composition time 24b + pkt.Payload = pkt.Payload[8:] + case PacketTypeCodedFramesX: + // frame type 4b, packet type 4b, fourCC 32b + pkt.Payload = pkt.Payload[5:] + default: + continue + } + } else { + switch pkt.Payload[1] { + case PacketTypeAVCNALU: + // frame type 4b, codecID 4b, avc packet type 8b, composition time 24b + pkt.Payload = pkt.Payload[5:] + default: + continue + } + } + + pkt.Timestamp = TimeToRTP(pkt.Timestamp, c.video.Codec.ClockRate) + c.video.WriteRTP(pkt) + } + } +} + +func (c *Producer) probe() error { + if err := c.readHeader(); err != nil { + return err + } + + c.rd.BufferSize = core.ProbeSize + defer c.rd.Reset() + + // Normal software sends: + // 1. Video/audio flag in header + // 2. MetaData as first tag (with video/audio codec info) + // 3. Video/audio headers in 2nd and 3rd tag + + // Reolink camera sends: + // 1. Empty video/audio flag + // 2. MedaData without stereo key for AAC + // 3. Audio header after Video keyframe tag + + // OpenIPC camera (on old firmwares) sends: + // 1. Empty video/audio flag + // 2. No MetaData packet + // 3. Sends a video packet in more than 3 seconds + waitVideo := true + waitAudio := true + timeout := time.Now().Add(time.Second * 5) + + for (waitVideo || waitAudio) && time.Now().Before(timeout) { + pkt, err := c.readPacket() + if err != nil { + return err + } + + //log.Printf("%d %0.20s", pkt.PayloadType, pkt.Payload) + + switch pkt.PayloadType { + case TagAudio: + if !waitAudio { + continue + } + + _ = pkt.Payload[1] // bounds + + codecID := pkt.Payload[0] >> 4 // SoundFormat + _ = pkt.Payload[0] & 0b1100 // SoundRate + _ = pkt.Payload[0] & 0b0010 // SoundSize + _ = pkt.Payload[0] & 0b0001 // SoundType + + if codecID != CodecAAC { + continue + } + + if pkt.Payload[1] != 0 { // check if header + continue + } + + codec := aac.ConfigToCodec(pkt.Payload[2:]) + media := &core.Media{ + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{codec}, + } + c.Medias = append(c.Medias, media) + waitAudio = false + + case TagVideo: + if !waitVideo { + continue + } + + var codec *core.Codec + + if isExHeader(pkt.Payload) { + if string(pkt.Payload[1:5]) != "hvc1" { + continue + } + + if packetType := pkt.Payload[0] & 0b1111; packetType != PacketTypeSequenceStart { + continue + } + + codec = h265.ConfigToCodec(pkt.Payload[5:]) + } else { + _ = pkt.Payload[0] >> 4 // FrameType + + if packetType := pkt.Payload[1]; packetType != PacketTypeAVCHeader { // check if header + continue + } + + switch codecID := pkt.Payload[0] & 0b1111; codecID { + case CodecH264: + codec = h264.ConfigToCodec(pkt.Payload[5:]) + case CodecHEVC: + codec = h265.ConfigToCodec(pkt.Payload[5:]) + default: + continue + } + } + + media := &core.Media{ + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{codec}, + } + c.Medias = append(c.Medias, media) + waitVideo = false + + case TagData: + if !bytes.Contains(pkt.Payload, []byte("onMetaData")) { + continue + } + // Dahua cameras doesn't send videocodecid + if !bytes.Contains(pkt.Payload, []byte("videocodecid")) && + !bytes.Contains(pkt.Payload, []byte("width")) && + !bytes.Contains(pkt.Payload, []byte("framerate")) { + waitVideo = false + } + if !bytes.Contains(pkt.Payload, []byte("audiocodecid")) { + waitAudio = false + } + } + } + + return nil +} + +func (c *Producer) readHeader() error { + b := make([]byte, 9) + if _, err := io.ReadFull(c.rd, b); err != nil { + return err + } + + if string(b[:3]) != Signature { + return errors.New("flv: wrong header") + } + + _ = b[4] // flags (skip because unsupported by Reolink cameras) + + if skip := binary.BigEndian.Uint32(b[5:]) - 9; skip > 0 { + if _, err := io.ReadFull(c.rd, make([]byte, skip)); err != nil { + return err + } + } + + return nil +} + +func (c *Producer) readPacket() (*rtp.Packet, error) { + // https://rtmp.veriskope.com/pdf/video_file_format_spec_v10.pdf + b := make([]byte, 4+11) + if _, err := io.ReadFull(c.rd, b); err != nil { + return nil, err + } + + b = b[4 : 4+11] // skip previous tag size + + size := uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3]) + + pkt := &rtp.Packet{ + Header: rtp.Header{ + PayloadType: b[0], + Timestamp: uint32(b[4])<<16 | uint32(b[5])<<8 | uint32(b[6]) | uint32(b[7])<<24, + }, + Payload: make([]byte, size), + } + + if _, err := io.ReadFull(c.rd, pkt.Payload); err != nil { + return nil, err + } + + //log.Printf("[FLV] %d %.40x", pkt.PayloadType, pkt.Payload) + + return pkt, nil +} + +// TimeToRTP convert time in milliseconds to RTP time +func TimeToRTP(timeMS, clockRate uint32) uint32 { + // for clockRates 90000, 16000, 8000, etc. - we can use: + // return timeMS * (clockRate / 1000) + // but for clockRates 44100, 22050, 11025 - we should use: + return uint32(uint64(timeMS) * uint64(clockRate) / 1000) +} + +func isExHeader(data []byte) bool { + return data[0]&0b1000_0000 != 0 +} diff --git a/installs_on_host/go2rtc/pkg/gopro/discovery.go b/installs_on_host/go2rtc/pkg/gopro/discovery.go new file mode 100644 index 0000000..19ed802 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/gopro/discovery.go @@ -0,0 +1,43 @@ +package gopro + +import ( + "net" + "net/http" + "regexp" +) + +func Discovery() (urls []string) { + ints, err := net.Interfaces() + if err != nil { + return nil + } + + // The socket address for USB connections is 172.2X.1YZ.51:8080 + // https://gopro.github.io/OpenGoPro/http_2_0#socket-address + re := regexp.MustCompile(`^172\.2\d\.1\d\d\.`) + + for _, itf := range ints { + addrs, err := itf.Addrs() + if err != nil { + continue + } + + for _, addr := range addrs { + host := addr.String() + if !re.MatchString(host) { + continue + } + + host = host[:11] + "51" // 172.2x.1xx.xxx + res, err := http.Get("http://" + host + ":8080/gopro/webcam/status") + if err != nil { + continue + } + _ = res.Body.Close() + + urls = append(urls, host) + } + } + + return +} diff --git a/installs_on_host/go2rtc/pkg/gopro/producer.go b/installs_on_host/go2rtc/pkg/gopro/producer.go new file mode 100644 index 0000000..1873159 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/gopro/producer.go @@ -0,0 +1,124 @@ +package gopro + +import ( + "errors" + "io" + "net" + "net/http" + "net/url" + "time" + + "github.com/AlexxIT/go2rtc/pkg/mpegts" +) + +func Dial(rawURL string) (*mpegts.Producer, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + r := &listener{host: u.Host} + + if err = r.command("/gopro/webcam/stop"); err != nil { + return nil, err + } + + if err = r.listen(); err != nil { + return nil, err + } + + if err = r.command("/gopro/webcam/start"); err != nil { + return nil, err + } + + prod, err := mpegts.Open(r) + if err != nil { + return nil, err + } + + prod.FormatName = "gopro" + prod.RemoteAddr = u.Host + + return prod, nil +} + +type listener struct { + conn net.PacketConn + host string + packet []byte + packets chan []byte +} + +func (r *listener) Read(p []byte) (n int, err error) { + if r.packet == nil { + var ok bool + if r.packet, ok = <-r.packets; !ok { + return 0, io.EOF // channel closed + } + } + + n = copy(p, r.packet) + + if n < len(r.packet) { + r.packet = r.packet[n:] + } else { + r.packet = nil + } + + return +} + +func (r *listener) Close() error { + return r.conn.Close() +} + +func (r *listener) command(api string) error { + client := &http.Client{Timeout: 5 * time.Second} + + res, err := client.Get("http://" + r.host + ":8080" + api) + if err != nil { + return err + } + + _ = res.Body.Close() + + if res.StatusCode != http.StatusOK { + return errors.New("gopro: wrong response: " + res.Status) + } + + return nil +} + +func (r *listener) listen() (err error) { + if r.conn, err = net.ListenPacket("udp4", ":8554"); err != nil { + return + } + + r.packets = make(chan []byte, 1024) + go r.worker() + + return +} + +func (r *listener) worker() { + b := make([]byte, 1500) + for { + if err := r.conn.SetReadDeadline(time.Now().Add(3 * time.Second)); err != nil { + break + } + + n, _, err := r.conn.ReadFrom(b) + if err != nil { + break + } + + packet := make([]byte, n) + copy(packet, b) + + r.packets <- packet + } + + close(r.packets) + + _ = r.command("/gopro/webcam/stop") +} diff --git a/installs_on_host/go2rtc/pkg/h264/README.md b/installs_on_host/go2rtc/pkg/h264/README.md new file mode 100644 index 0000000..65c2363 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/h264/README.md @@ -0,0 +1,16 @@ +# H264 + +Payloader code taken from [pion](https://github.com/pion/rtp) library and changed to AVC packets support. + +## Useful Links + +- [RTP Payload Format for H.264 Video](https://datatracker.ietf.org/doc/html/rfc6184) +- [The H264 Sequence parameter set](https://www.cardinalpeak.com/blog/the-h-264-sequence-parameter-set) +- [H.264 Video Types (Microsoft)](https://docs.microsoft.com/en-us/windows/win32/directshow/h-264-video-types) +- [Automatic Generation of H.264 Parameter Sets to Recover Video File Fragments](https://arxiv.org/pdf/2104.14522.pdf) +- [Chromium sources](https://chromium.googlesource.com/external/webrtc/+/HEAD/common_video/h264) +- [AVC levels](https://en.wikipedia.org/wiki/Advanced_Video_Coding#Levels) +- [AVC profiles table](https://developer.mozilla.org/ru/docs/Web/Media/Formats/codecs_parameter) +- [Supported Media for Google Cast](https://developers.google.com/cast/docs/media) +- [Two stream formats, Annex-B, AVCC (H.264) and HVCC (H.265)](https://www.programmersought.com/article/3901815022/) +- https://docs.aws.amazon.com/kinesisvideostreams/latest/dg/producer-reference-nal.html diff --git a/installs_on_host/go2rtc/pkg/h264/annexb/annexb.go b/installs_on_host/go2rtc/pkg/h264/annexb/annexb.go new file mode 100644 index 0000000..26614a8 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/h264/annexb/annexb.go @@ -0,0 +1,156 @@ +// Package annexb - universal for H264 and H265 +package annexb + +import ( + "bytes" + "encoding/binary" +) + +const StartCode = "\x00\x00\x00\x01" +const startAUD = StartCode + "\x09\xF0" +const startAUDstart = startAUD + StartCode + +// EncodeToAVCC +// +// FFmpeg MPEG-TS: 00000001 AUD 00000001 SPS 00000001 PPS 000001 IFrame +// FFmpeg H264: 00000001 SPS 00000001 PPS 000001 IFrame 00000001 PFrame +// Reolink: 000001 AUD 000001 VPS 00000001 SPS 00000001 PPS 00000001 IDR 00000001 IDR +func EncodeToAVCC(annexb []byte) (avc []byte) { + var start int + + avc = make([]byte, 0, len(annexb)+4) // init memory with little overhead + + for i := 0; ; i++ { + var offset int + + if i+3 < len(annexb) { + // search next separator + if annexb[i] == 0 && annexb[i+1] == 0 { + if annexb[i+2] == 1 { + offset = 3 // 00 00 01 + } else if annexb[i+2] == 0 && annexb[i+3] == 1 { + offset = 4 // 00 00 00 01 + } else { + continue + } + } else { + continue + } + } else { + i = len(annexb) // move i to data end + } + + if start != 0 { + size := uint32(i - start) + avc = binary.BigEndian.AppendUint32(avc, size) + avc = append(avc, annexb[start:i]...) + } + + // sometimes FFmpeg put separator at the end + if i += offset; i == len(annexb) { + break + } + + if isAUD(annexb[i]) { + start = 0 // skip this NALU + } else { + start = i // save this position + } + } + + return +} + +func isAUD(b byte) bool { + const h264 = 9 + const h265 = 35 << 1 + return b&0b0001_1111 == h264 || b&0b0111_1110 == h265 +} + +func DecodeAVCC(b []byte, safeClone bool) []byte { + if safeClone { + b = bytes.Clone(b) + } + for i := 0; i < len(b); { + size := int(binary.BigEndian.Uint32(b[i:])) + b[i] = 0 + b[i+1] = 0 + b[i+2] = 0 + b[i+3] = 1 + i += 4 + size + } + return b +} + +// DecodeAVCCWithAUD - AUD doesn't important for FFmpeg, but important for Safari +func DecodeAVCCWithAUD(src []byte) []byte { + dst := make([]byte, len(startAUD)+len(src)) + copy(dst, startAUD) + copy(dst[len(startAUD):], src) + DecodeAVCC(dst[len(startAUD):], false) + return dst +} + +const ( + h264PFrame = 1 + h264IFrame = 5 + h264SPS = 7 + h264PPS = 8 + + h265VPS = 32 + h265PFrame = 1 +) + +// IndexFrame - get new frame start position in the AnnexB stream +func IndexFrame(b []byte) int { + if len(b) < len(startAUDstart) { + return -1 + } + + for i := len(startAUDstart); ; { + if di := bytes.Index(b[i:], []byte(StartCode)); di < 0 { + break + } else { + i += di + 4 // move to NALU start + } + + if i >= len(b) { + break + } + + h264Type := b[i] & 0b1_1111 + switch h264Type { + case h264PFrame, h264SPS: + return i - 4 // move to start code + case h264IFrame, h264PPS: + continue + } + + h265Type := (b[i] >> 1) & 0b11_1111 + switch h265Type { + case h265PFrame, h265VPS: + return i - 4 // move to start code + } + } + + return -1 +} + +func FixAnnexBInAVCC(b []byte) []byte { + for i := 0; i < len(b); { + if i+4 >= len(b) { + break + } + + size := bytes.Index(b[i+4:], []byte{0, 0, 0, 1}) + if size < 0 { + size = len(b) - (i + 4) + } + + binary.BigEndian.PutUint32(b[i:], uint32(size)) + + i += size + 4 + } + + return b +} diff --git a/installs_on_host/go2rtc/pkg/h264/annexb/annexb_test.go b/installs_on_host/go2rtc/pkg/h264/annexb/annexb_test.go new file mode 100644 index 0000000..cbc382f --- /dev/null +++ b/installs_on_host/go2rtc/pkg/h264/annexb/annexb_test.go @@ -0,0 +1,97 @@ +package annexb + +import ( + "bytes" + "encoding/binary" + "encoding/hex" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func decode(s string) []byte { + b, _ := hex.DecodeString(strings.ReplaceAll(s, " ", "")) + return b +} + +func naluTypes(avcc []byte) (types []byte) { + for { + types = append(types, avcc[4]) + + size := 4 + binary.BigEndian.Uint32(avcc) + if size < uint32(len(avcc)) { + avcc = avcc[size:] + } else { + break + } + } + return +} + +func TestFFmpegH264(t *testing.T) { + // ffmpeg -re -i bbb.mp4 -c copy -f h264 - + s := "000000016764001fac2484014016ec0440000003004000000c23c60c92 0000000168ee32c8b0 00000165888080033ffef5f8454f32cb1bb4203f854dd69bc2ca91b2bce1fb3527440000030000030000030000030050999841d1afd324aea000000300000f600011c0001b40004e40011f0003b80010800059000238000be0005e000220001100000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300004041 00000001" + b := EncodeToAVCC(decode(s)) + require.True(t, bytes.HasSuffix(b, []byte{0x40, 0x41})) + n := naluTypes(b) + require.Equal(t, []byte{0x67, 0x68, 0x65}, n) +} + +func TestFFmpegMPEGTSH264(t *testing.T) { + // ffmpeg -re -i bbb.mp4 -c copy -f mpegts - + s := "00000001 09f0 000000016764001fac2484014016ec0440000003004000000c23c60c92 0000000168ee32c8b0 00000165888080033ffef5f8454f32cb1bb4203f854dd69bc2ca91b2bce1fb3527440000030000030000030000030050999841d1afd324aea000000300000f600011c0001b40004e40011f0003b80010800059000238000be0005e000220001100000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300004041" + b := EncodeToAVCC(decode(s)) + n := naluTypes(b) + require.Equal(t, []byte{0x67, 0x68, 0x65}, n) +} + +func TestFFmpegHEVC(t *testing.T) { + // ffmpeg -re -i bbb.mp4 -c libx265 -preset superfast -tune zerolatency -f hevc - + s := "0000000140010c01ffff01600000030090000003000003005dba0240 0000000142010101600000030090000003000003005da00280802d165bab930bc05a7080000003008000000c04 000000014401c1718312 0000014e0105ffffffffffffffffff2b2ca2de09b51747dbbb55a4fe7fc2fc4e7832363520286275696c642032303829202d20332e352b3131312d6330616631386464353a5b57696e646f77735d5b4743432031332e322e305d5b3634206269745d20386269742b31306269742b3132626974202d20482e3236352f4845564320636f646563202d20436f7079726967687420323031332d3230313820286329204d756c7469636f7265776172" + b := EncodeToAVCC(decode(s)) + n := naluTypes(b) + require.Equal(t, []byte{0x40, 0x42, 0x44, 0x4e}, n) +} + +func TestFFmpegHEVC2(t *testing.T) { + // ffmpeg -re -i bbb.mp4 -c libx265 -preset superfast -tune zerolatency -f hevc - + s := "0000000140010c01ffff01600000030090000003000003005dba0240 0000000142010101600000030090000003000003005da00280802d165bab930bc05a7080000003008000000c04 000000014401c1718312 0000014e0105ffffffffffffffffff2b2ca2de09b51747dbbb55a4fe7fc2fc4e7832363520286275696c642032303829202d20332e352b3131312d6330616631386464353a5b57696e646f77735d5b4743432031332e322e305d5b3634206269745d20386269742b31306269742b3132626974202d20482e3236352f4845564320636f646563202d20436f7079726967687420323031332d3230313820286329204d756c7469636f7265776172652c20496e63202d20687474703a2f2f783236352e6f7267202d206f7074696f6e733a2063707569643d31303439303731206672616d652d746872656164733d32206e756d612d706f6f6c733d3420777070206e6f2d706d6f6465206e6f2d706d65206e6f2d70736e72206e6f2d7373696d206c6f672d6c6576656c3d322062697464657074683d3820696e7075742d6373703d31206670733d32342f3120696e7075742d7265733d313238307837323020696e7465726c6163653d3020746f74616c2d6672616d65733d30206c6576656c2d6964633d3020686967682d746965723d31207568642d62643d30207265663d31206e6f2d616c6c6f772d6e6f6e2d636f6e666f726d616e6365207265706561742d6865616465727320616e6e657862206e6f2d617564206e6f2d656f62206e6f2d656f73206e6f2d68726420696e666f20686173683d302074656d706f72616c2d6c61796572733d30206f70656e2d676f70206d696e2d6b6579696e743d3234206b6579696e743d32353020676f702d6c6f6f6b61686561643d3020626672616d65733d3020622d61646170743d30206e6f2d622d707972616d696420626672616d652d626961733d302072632d6c6f6f6b61686561643d30206c6f6f6b61686561642d736c696365733d34207363656e656375743d30206e6f2d686973742d7363656e65637574207261646c3d30206e6f2d73706c696365206e6f2d696e7472612d72656672657368206374753d3332206d696e2d63752d73697a653d38206e6f2d72656374206e6f2d616d70206d61782d74752d73697a653d33322074752d696e7465722d64657074683d312074752d696e7472612d64657074683d31206c696d69742d74753d302072646f712d6c6576656c3d302064796e616d69632d72643d302e3030206e6f2d7373696d2d7264207369676e68696465206e6f2d74736b6970206e722d696e7472613d30206e722d696e7465723d30206e6f2d636f6e73747261696e65642d696e747261207374726f6e672d696e7472612d736d6f6f7468696e67206d61782d6d657267653d32206c696d69742d726566733d30206e6f2d6c696d69742d6d6f646573206d653d31207375626d653d31206d6572616e67653d35372074656d706f72616c2d6d7670206e6f2d6672616d652d647570206e6f2d686d65206e6f2d77656967687470206e6f2d77656967687462206e6f2d616e616c797a652d7372632d70696373206465626c6f636b3d303a30206e6f2d73616f206e6f2d73616f2d6e6f6e2d6465626c6f636b2072643d322073656c6563746976652d73616f3d30206561726c792d736b69702072736b697020666173742d696e747261206e6f2d74736b69702d66617374206e6f2d63752d6c6f73736c657373206e6f2d622d696e747261206e6f2d73706c697472642d736b697020726470656e616c74793d30207073792d72643d322e3030207073792d72646f713d302e3030206e6f2d72642d726566696e65206e6f2d6c6f73736c65737320636271706f6666733d3020637271706f6666733d302072633d637266206372663d32382e302071636f6d703d302e3630207170737465703d342073746174732d77726974653d302073746174732d726561643d30206970726174696f3d312e34302061712d6d6f64653d302061712d737472656e6774683d302e3030206e6f2d637574726565207a6f6e652d636f756e743d30206e6f2d7374726963742d6362722071672d73697a653d3332206e6f2d72632d677261696e2071706d61783d36392071706d696e3d30206e6f2d636f6e73742d766276207361723d31206f7665727363616e3d3020766964656f666f726d61743d352072616e67653d3020636f6c6f727072696d3d32207472616e736665723d3220636f6c6f726d61747269783d32206368726f6d616c6f633d31206368726f6d616c6f632d746f703d30206368726f6d616c6f632d626f74746f6d3d3020646973706c61792d77696e646f773d3020636c6c3d302c30206d696e2d6c756d613d30206d61782d6c756d613d323535206c6f67322d6d61782d706f632d6c73623d38207675692d74696d696e672d696e666f207675692d6872642d696e666f20736c696365733d31206e6f2d6f70742d71702d707073206e6f2d6f70742d7265662d6c6973742d6c656e6774682d707073206e6f2d6d756c74692d706173732d6f70742d727073207363656e656375742d626961733d302e3035206e6f2d6f70742d63752d64656c74612d7170206e6f2d61712d6d6f74696f6e206e6f2d6864723130206e6f2d68647231302d6f7074206e6f2d6468647231302d6f7074206e6f2d6964722d7265636f766572792d73656920616e616c797369732d72657573652d6c6576656c3d3020616e616c797369732d736176652d72657573652d6c6576656c3d3020616e616c797369732d6c6f61642d72657573652d6c6576656c3d30207363616c652d666163746f723d3020726566696e652d696e7472613d3020726566696e652d696e7465723d3020726566696e652d6d763d3120726566696e652d6374752d646973746f7274696f6e3d30206e6f2d6c696d69742d73616f206374752d696e666f3d30206e6f2d6c6f77706173732d64637420726566696e652d616e616c797369732d747970653d3020636f70792d7069633d31206d61782d617573697a652d666163746f723d312e30206e6f2d64796e616d69632d726566696e65206e6f2d73696e676c652d736569206e6f2d686576632d6171206e6f2d737674206e6f2d6669656c642071702d61646170746174696f6e2d72616e67653d312e3030207363656e656375742d61776172652d71703d30636f6e666f726d616e63652d77696e646f772d6f6666736574732072696768743d3020626f74746f6d3d30206465636f6465722d6d61782d726174653d30206e6f2d7662762d6c6976652d6d756c74692d70617373206e6f2d6d63737466206e6f2d7362726380 0000012801adc2e5bca307b9ce6b18b5ad6a525294a6d117ffd3917322eebaeda718a0000003000003000003021207706824da718a00000300000300000300044408d5db4e31400000030000030000030012500c2725a000000300000300000300002a600e4880000003000003000003000019301180000003000003000003000007d400000300000300000300000300010b000003000003000003000003001810000003000003000003000003019100000300000300000300000d38000003000003000003000067c000000300000300000300025e000003000003000003000c58000003000003000003002b60000003000003000003007f80000003000003000003016300000300000300000303b2000003000003000006e400000300000300000e18000003000003000018d00000030000030000292000000300000300003ce00000030000030000030000030000030000bb80" + b := EncodeToAVCC(decode(s)) + n := naluTypes(b) + require.Equal(t, []byte{0x40, 0x42, 0x44, 0x4e, 0x28}, n) +} + +func TestFFmpegMPEGTSHEVC(t *testing.T) { + // ffmpeg -re -i bbb.mp4 -c libx265 -preset superfast -tune zerolatency -an -f mpegts - + s := "00000001460150 0000000140010c01ffff01600000030090000003000003005dba0240 0000000142010101600000030090000003000003005da00280802d165bab930bc05a7080000003008000000c04 000000014401c1718312 0000014e0105ffffffffffffffffff2b2ca2de09b51747dbbb55a4fe7fc2fc4e7832363520286275696c642032303829202d20332e352b3131312d6330616631386464353a5b57696e646f77735d5b4743432031332e322e305d5b3634206269745d20386269742b31306269742b3132626974202d20482e3236352f4845564320636f646563202d20436f7079726967687420323031332d3230313820286329204d756c7469636f7265776172652c20496e63202d20687474703a2f2f783236352e6f7267202d206f7074696f6e733a2063707569643d31303439303731206672616d652d746872656164733d32206e756d612d706f6f6c733d3420777070206e6f2d706d6f6465206e6f2d706d65206e6f2d70736e72206e6f2d7373696d206c6f672d6c6576656c3d322062697464657074683d3820696e7075742d6373703d31206670733d32342f3120696e7075742d7265733d313238307837323020696e7465726c6163653d3020746f74616c2d6672616d65733d30206c6576656c2d6964633d3020686967682d746965723d31207568642d62643d30207265663d31206e6f2d616c6c6f772d6e6f6e2d636f6e666f726d616e6365207265706561742d6865616465727320616e6e657862206e6f2d617564206e6f2d656f62206e6f2d656f73206e6f2d68726420696e666f20686173683d302074656d706f72616c2d6c61796572733d30206f70656e2d676f70206d696e2d6b6579696e743d3234206b6579696e743d32353020676f702d6c6f6f6b61686561643d3020626672616d65733d3020622d61646170743d30206e6f2d622d707972616d696420626672616d652d626961733d302072632d6c6f6f6b61686561643d30206c6f6f6b61686561642d736c696365733d34207363656e656375743d30206e6f2d686973742d7363656e65637574207261646c3d30206e6f2d73706c696365206e6f2d696e7472612d72656672657368206374753d3332206d696e2d63752d73697a653d38206e6f2d72656374206e6f2d616d70206d61782d74752d73697a653d33322074752d696e7465722d64657074683d312074752d696e7472612d64657074683d31206c696d69742d74753d302072646f712d6c6576656c3d302064796e616d69632d72643d302e3030206e6f2d7373696d2d7264207369676e68696465206e6f2d74736b6970206e722d696e7472613d30206e722d696e7465723d30206e6f2d636f6e73747261696e65642d696e747261207374726f6e672d696e7472612d736d6f6f7468696e67206d61782d6d657267653d32206c696d69742d726566733d30206e6f2d6c696d69742d6d6f646573206d653d31207375626d653d31206d6572616e67653d35372074656d706f72616c2d6d7670206e6f2d6672616d652d647570206e6f2d686d65206e6f2d77656967687470206e6f2d77656967687462206e6f2d616e616c797a652d7372632d70696373206465626c6f636b3d303a30206e6f2d73616f206e6f2d73616f2d6e6f6e2d6465626c6f636b2072643d322073656c6563746976652d73616f3d30206561726c792d736b69702072736b697020666173742d696e747261206e6f2d74736b69702d66617374206e6f2d63752d6c6f73736c657373206e6f2d622d696e747261206e6f2d73706c697472642d736b697020726470656e616c74793d30207073792d72643d322e3030207073792d72646f713d302e3030206e6f2d72642d726566696e65206e6f2d6c6f73736c65737320636271706f6666733d3020637271706f6666733d302072633d637266206372663d32382e302071636f6d703d302e3630207170737465703d342073746174732d77726974653d302073746174732d726561643d30206970726174696f3d312e34302061712d6d6f64653d302061712d737472656e6774683d302e3030206e6f2d637574726565207a6f6e652d636f756e743d30206e6f2d7374726963742d6362722071672d73697a653d3332206e6f2d72632d677261696e2071706d61783d36392071706d696e3d30206e6f2d636f6e73742d766276207361723d31206f7665727363616e3d3020766964656f666f726d61743d352072616e67653d3020636f6c6f727072696d3d32207472616e736665723d3220636f6c6f726d61747269783d32206368726f6d616c6f633d31206368726f6d616c6f632d746f703d30206368726f6d616c6f632d626f74746f6d3d3020646973706c61792d77696e646f773d3020636c6c3d302c30206d696e2d6c756d613d30206d61782d6c756d613d323535206c6f67322d6d61782d706f632d6c73623d38207675692d74696d696e672d696e666f207675692d6872642d696e666f20736c696365733d31206e6f2d6f70742d71702d707073206e6f2d6f70742d7265662d6c6973742d6c656e6774682d707073206e6f2d6d756c74692d706173732d6f70742d727073207363656e656375742d626961733d302e3035206e6f2d6f70742d63752d64656c74612d7170206e6f2d61712d6d6f74696f6e206e6f2d6864723130206e6f2d68647231302d6f7074206e6f2d6468647231302d6f7074206e6f2d6964722d7265636f766572792d73656920616e616c797369732d72657573652d6c6576656c3d3020616e616c797369732d736176652d72657573652d6c6576656c3d3020616e616c797369732d6c6f61642d72657573652d6c6576656c3d30207363616c652d666163746f723d3020726566696e652d696e7472613d3020726566696e652d696e7465723d3020726566696e652d6d763d3120726566696e652d6374752d646973746f7274696f6e3d30206e6f2d6c696d69742d73616f206374752d696e666f3d30206e6f2d6c6f77706173732d64637420726566696e652d616e616c797369732d747970653d3020636f70792d7069633d31206d61782d617573697a652d666163746f723d312e30206e6f2d64796e616d69632d726566696e65206e6f2d73696e676c652d736569206e6f2d686576632d6171206e6f2d737674206e6f2d6669656c642071702d61646170746174696f6e2d72616e67653d312e3030207363656e656375742d61776172652d71703d30636f6e666f726d616e63652d77696e646f772d6f6666736574732072696768743d3020626f74746f6d3d30206465636f6465722d6d61782d726174653d30206e6f2d7662762d6c6976652d6d756c74692d70617373206e6f2d6d63737466206e6f2d7362726380 0000012801adc2e5bca307b9ce6b18b5ad6a525294a6d117ffd3917322eebaeda718a0000003000003000003021207706824da718a00000300000300000300044408d5db4e31400000030000030000030012500c2725a000000300000300000300002a600e4880000003000003000003000019301180000003000003000003000007d400000300000300000300000300010b000003000003000003000003001810000003000003000003000003019100000300000300000300000d38000003000003000003000067c000000300000300000300025e000003000003000003000c58000003000003000003002b60000003000003000003007f80000003000003000003016300000300000300000303b2000003000003000006e400000300000300000e18000003000003000018d00000030000030000292000000300000300003ce00000030000030000030000030000030000bb80" + b := EncodeToAVCC(decode(s)) + n := naluTypes(b) + require.Equal(t, []byte{0x40, 0x42, 0x44, 0x4e, 0x28}, n) +} + +func TestReolink(t *testing.T) { + s := "000001460150 00000140010C01FFFF01600000030000030000030000030096AC09 0000000142010101600000030000030000030000030096A001E020021C7F8AAD3BA24BB804000013D800018CE008 000000014401C072F0941E3648 000000012601" + b := EncodeToAVCC(decode(s)) + n := naluTypes(b) + require.Equal(t, []byte{0x40, 0x42, 0x44, 0x26}, n) +} + +func TestDahua(t *testing.T) { + s := "00000001460150 00000140010c01ffff01400000030000030000030000030099ac0900 0000000142010101400000030000030000030000030099a001402005a1fe5aee46c1ae550400 000000014401c073c04c9000 000000012601" + b := EncodeToAVCC(decode(s)) + n := naluTypes(b) + require.Equal(t, []byte{0x40, 0x42, 0x44, 0x26}, n) +} + +func TestUSB(t *testing.T) { + s := "00 00 00 01 67 4D 00 1F 8D 8D 40 28 02 DD 37 01 01 01 40 00 01 C2 00 00 57 E4 01 00 00 00 01 68 EE 3C 80 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 01 65 88 80 00" + b := EncodeToAVCC(decode(s)) + n := naluTypes(b) + require.Equal(t, []byte{0x67, 0x68, 0x65}, n) + + s = "00 00 00 00 00 00 00 00 00 00 00 00 00 00 01 41 9A 00 4C" + b = EncodeToAVCC(decode(s)) + n = naluTypes(b) + require.Equal(t, []byte{0x41}, n) +} diff --git a/installs_on_host/go2rtc/pkg/h264/avc.go b/installs_on_host/go2rtc/pkg/h264/avc.go new file mode 100644 index 0000000..e6a294c --- /dev/null +++ b/installs_on_host/go2rtc/pkg/h264/avc.go @@ -0,0 +1,122 @@ +package h264 + +import ( + "bytes" + "encoding/binary" +) + +const forbiddenZeroBit = 0x80 +const nalUnitType = 0x1F + +// Deprecated: DecodeStream - find and return first AU in AVC format +// useful for processing live streams with unknown separator size +func DecodeStream(annexb []byte) ([]byte, int) { + startPos := -1 + + i := 0 + for { + // search next separator + if i = IndexFrom(annexb, []byte{0, 0, 1}, i); i < 0 { + break + } + + // move i to next AU + if i += 3; i >= len(annexb) { + break + } + + // check if AU type valid + octet := annexb[i] + if octet&forbiddenZeroBit != 0 { + continue + } + + // 0 => AUD => SPS/IF/PF => AUD + // 0 => SPS/PF => SPS/PF + nalType := octet & nalUnitType + if startPos >= 0 { + switch nalType { + case NALUTypeAUD, NALUTypeSPS, NALUTypePFrame: + if annexb[i-4] == 0 { + return DecodeAnnexB(annexb[startPos : i-4]), i - 4 + } else { + return DecodeAnnexB(annexb[startPos : i-3]), i - 3 + } + } + } else { + switch nalType { + case NALUTypeSPS, NALUTypePFrame: + if i >= 4 && annexb[i-4] == 0 { + startPos = i - 4 + } else { + startPos = i - 3 + } + } + } + } + + return nil, 0 +} + +// DecodeAnnexB - convert AnnexB to AVC format +// support unknown separator size +func DecodeAnnexB(b []byte) []byte { + if b[2] == 1 { + // convert: 0 0 1 => 0 0 0 1 + b = append([]byte{0}, b...) + } + + startPos := 0 + + i := 4 + for { + // search next separato + if i = IndexFrom(b, []byte{0, 0, 1}, i); i < 0 { + break + } + + // move i to next AU + if i += 3; i >= len(b) { + break + } + + // check if AU type valid + octet := b[i] + if octet&forbiddenZeroBit != 0 { + continue + } + + switch octet & nalUnitType { + case NALUTypePFrame, NALUTypeIFrame, NALUTypeSPS, NALUTypePPS: + if b[i-4] != 0 { + // prefix: 0 0 1 + binary.BigEndian.PutUint32(b[startPos:], uint32(i-startPos-7)) + tmp := make([]byte, 0, len(b)+1) + tmp = append(tmp, b[:i]...) + tmp = append(tmp, 0) + b = append(tmp, b[i:]...) + startPos = i - 3 + } else { + // prefix: 0 0 0 1 + binary.BigEndian.PutUint32(b[startPos:], uint32(i-startPos-8)) + startPos = i - 4 + } + } + } + + binary.BigEndian.PutUint32(b[startPos:], uint32(len(b)-startPos-4)) + return b +} + +func IndexFrom(b []byte, sep []byte, from int) int { + if from > 0 { + if from < len(b) { + if i := bytes.Index(b[from:], sep); i >= 0 { + return from + i + } + } + return -1 + } + + return bytes.Index(b, sep) +} diff --git a/installs_on_host/go2rtc/pkg/h264/avcc.go b/installs_on_host/go2rtc/pkg/h264/avcc.go new file mode 100644 index 0000000..dd3a568 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/h264/avcc.go @@ -0,0 +1,120 @@ +// Package h264 - AVCC format related functions +package h264 + +import ( + "bytes" + "encoding/base64" + "encoding/binary" + "encoding/hex" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +func RepairAVCC(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc { + sps, pps := GetParameterSet(codec.FmtpLine) + ps := JoinNALU(sps, pps) + + return func(packet *rtp.Packet) { + // this can happen for FLV from FFmpeg + if NALUType(packet.Payload) == NALUTypeSEI { + size := int(binary.BigEndian.Uint32(packet.Payload)) + 4 + packet.Payload = packet.Payload[size:] + } + if NALUType(packet.Payload) == NALUTypeIFrame { + packet.Payload = Join(ps, packet.Payload) + } + handler(packet) + } +} + +func JoinNALU(nalus ...[]byte) (avcc []byte) { + var i, n int + + for _, nalu := range nalus { + if i = len(nalu); i > 0 { + n += 4 + i + } + } + + avcc = make([]byte, n) + + n = 0 + for _, nal := range nalus { + if i = len(nal); i > 0 { + binary.BigEndian.PutUint32(avcc[n:], uint32(i)) + n += 4 + copy(avcc[n+4:], nal) + } + } + + return +} + +func SplitNALU(avcc []byte) [][]byte { + var nals [][]byte + for { + // get AVC length + size := int(binary.BigEndian.Uint32(avcc)) + 4 + + // check if multiple items in one packet + if size < len(avcc) { + nals = append(nals, avcc[:size]) + avcc = avcc[size:] + } else { + nals = append(nals, avcc) + break + } + } + return nals +} + +func NALUTypes(avcc []byte) []byte { + var types []byte + for { + types = append(types, NALUType(avcc)) + + size := 4 + int(binary.BigEndian.Uint32(avcc)) + if size < len(avcc) { + avcc = avcc[size:] + } else { + break + } + } + return types +} + +func AVCCToCodec(avcc []byte) *core.Codec { + buf := bytes.NewBufferString("packetization-mode=1") + + for { + n := len(avcc) + if n < 4 { + break + } + + size := 4 + int(binary.BigEndian.Uint32(avcc)) + if n < size { + break + } + + switch NALUType(avcc) { + case NALUTypeSPS: + buf.WriteString(";profile-level-id=") + buf.WriteString(hex.EncodeToString(avcc[5:8])) + buf.WriteString(";sprop-parameter-sets=") + buf.WriteString(base64.StdEncoding.EncodeToString(avcc[4:size])) + case NALUTypePPS: + buf.WriteString(",") + buf.WriteString(base64.StdEncoding.EncodeToString(avcc[4:size])) + } + + avcc = avcc[size:] + } + + return &core.Codec{ + Name: core.CodecH264, + ClockRate: 90000, + FmtpLine: buf.String(), + PayloadType: core.PayloadTypeRAW, + } +} diff --git a/installs_on_host/go2rtc/pkg/h264/h264.go b/installs_on_host/go2rtc/pkg/h264/h264.go new file mode 100644 index 0000000..1223953 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/h264/h264.go @@ -0,0 +1,145 @@ +package h264 + +import ( + "encoding/base64" + "encoding/binary" + "encoding/hex" + "fmt" + "strings" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +const ( + NALUTypePFrame = 1 // Coded slice of a non-IDR picture + NALUTypeIFrame = 5 // Coded slice of an IDR picture + NALUTypeSEI = 6 // Supplemental enhancement information (SEI) + NALUTypeSPS = 7 // Sequence parameter set + NALUTypePPS = 8 // Picture parameter set + NALUTypeAUD = 9 // Access unit delimiter +) + +func NALUType(b []byte) byte { + return b[4] & 0x1F +} + +// IsKeyframe - check if any NALU in one AU is Keyframe +func IsKeyframe(b []byte) bool { + for { + switch NALUType(b) { + case NALUTypePFrame: + return false + case NALUTypeIFrame: + return true + } + + size := int(binary.BigEndian.Uint32(b)) + 4 + if size < len(b) { + b = b[size:] + continue + } else { + return false + } + } +} + +func Join(ps, iframe []byte) []byte { + b := make([]byte, len(ps)+len(iframe)) + i := copy(b, ps) + copy(b[i:], iframe) + return b +} + +// https://developers.google.com/cast/docs/media +const ( + ProfileBaseline = 0x42 + ProfileMain = 0x4D + ProfileHigh = 0x64 + CapabilityBaseline = 0xE0 + CapabilityMain = 0x40 +) + +// GetProfileLevelID - get profile from fmtp line +// Some devices won't play video with high level, so limit max profile and max level. +// And return some profile even if fmtp line is empty. +func GetProfileLevelID(fmtp string) string { + // avc1.640029 - H.264 high 4.1 (Chromecast 1st and 2nd Gen) + profile := byte(ProfileHigh) + capab := byte(0) + level := byte(41) + + if fmtp != "" { + var conf []byte + // some cameras has wrong profile-level-id + // https://github.com/AlexxIT/go2rtc/issues/155 + if s := core.Between(fmtp, "sprop-parameter-sets=", ","); s != "" { + if sps, _ := base64.StdEncoding.DecodeString(s); len(sps) >= 4 { + conf = sps[1:4] + } + } else if s = core.Between(fmtp, "profile-level-id=", ";"); s != "" { + conf, _ = hex.DecodeString(s) + } + + if len(conf) == 3 { + // sanitize profile, capab and level to supported values + switch conf[0] { + case ProfileBaseline, ProfileMain: + profile = conf[0] + } + switch conf[1] { + case CapabilityBaseline, CapabilityMain: + capab = conf[1] + } + switch conf[2] { + case 30, 31, 40: + level = conf[2] + } + } + } + + return fmt.Sprintf("%02X%02X%02X", profile, capab, level) +} + +func GetParameterSet(fmtp string) (sps, pps []byte) { + if fmtp == "" { + return + } + + s := core.Between(fmtp, "sprop-parameter-sets=", ";") + if s == "" { + return + } + + i := strings.IndexByte(s, ',') + if i < 0 { + return + } + + sps, _ = base64.StdEncoding.DecodeString(s[:i]) + pps, _ = base64.StdEncoding.DecodeString(s[i+1:]) + + return +} + +// GetFmtpLine from SPS+PPS+IFrame in AVC format +func GetFmtpLine(avc []byte) string { + s := "packetization-mode=1" + + for { + size := 4 + int(binary.BigEndian.Uint32(avc)) + + switch NALUType(avc) { + case NALUTypeSPS: + s += ";profile-level-id=" + hex.EncodeToString(avc[5:8]) + s += ";sprop-parameter-sets=" + base64.StdEncoding.EncodeToString(avc[4:size]) + case NALUTypePPS: + s += "," + base64.StdEncoding.EncodeToString(avc[4:size]) + } + + if size < len(avc) { + avc = avc[size:] + } else { + return s + } + } +} diff --git a/installs_on_host/go2rtc/pkg/h264/h264_test.go b/installs_on_host/go2rtc/pkg/h264/h264_test.go new file mode 100644 index 0000000..9f02b62 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/h264/h264_test.go @@ -0,0 +1,110 @@ +package h264 + +import ( + "encoding/base64" + "encoding/hex" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDecodeConfig(t *testing.T) { + s := "01640033ffe1000c67640033ac1514a02800f19001000468ee3cb0" + src, err := hex.DecodeString(s) + require.Nil(t, err) + + profile, sps, pps := DecodeConfig(src) + require.NotNil(t, profile) + require.NotNil(t, sps) + require.NotNil(t, pps) + + dst := EncodeConfig(sps, pps) + require.Equal(t, src, dst) +} + +func TestDecodeSPS(t *testing.T) { + s := "Z0IAMukAUAHjQgAAB9IAAOqcCAA=" // Amcrest AD410 + b, err := base64.StdEncoding.DecodeString(s) + require.Nil(t, err) + + sps := DecodeSPS(b) + require.Equal(t, uint16(2560), sps.Width()) + require.Equal(t, uint16(1920), sps.Height()) + + s = "R00AKZmgHgCJ+WEAAAMD6AAATiCE" // Sonoff + b, err = base64.StdEncoding.DecodeString(s) + require.Nil(t, err) + + sps = DecodeSPS(b) + require.Equal(t, uint16(1920), sps.Width()) + require.Equal(t, uint16(1080), sps.Height()) + + s = "Z01AMqaAKAC1kAA=" // Dahua + b, err = base64.StdEncoding.DecodeString(s) + require.Nil(t, err) + + sps = DecodeSPS(b) + require.Equal(t, uint16(2560), sps.Width()) + require.Equal(t, uint16(1440), sps.Height()) + + s = "Z2QAM6wVFKAoAPGQ" // Reolink + b, err = base64.StdEncoding.DecodeString(s) + require.Nil(t, err) + + sps = DecodeSPS(b) + require.Equal(t, uint16(2560), sps.Width()) + require.Equal(t, uint16(1920), sps.Height()) + + s = "Z2QAKKwa0AoAt03AQEBQAAADABAAAAMB6PFCKg==" // TP-Link + b, err = base64.StdEncoding.DecodeString(s) + require.Nil(t, err) + + sps = DecodeSPS(b) + require.Equal(t, uint16(1280), sps.Width()) + require.Equal(t, uint16(720), sps.Height()) + + s = "Z2QAFqwa0BQF/yzcBAQFAAADAAEAAAMAHo8UIqA=" // TP-Link sub + b, err = base64.StdEncoding.DecodeString(s) + require.Nil(t, err) + + sps = DecodeSPS(b) + require.Equal(t, uint16(640), sps.Width()) + require.Equal(t, uint16(360), sps.Height()) +} + +func TestGetProfileLevelID(t *testing.T) { + // OpenIPC https://github.com/OpenIPC + s := "profile-level-id=0033e7; packetization-mode=1; " + profile := GetProfileLevelID(s) + require.Equal(t, "640029", profile) + + // Eufy T8400 https://github.com/AlexxIT/go2rtc/issues/155 + s = "packetization-mode=1;profile-level-id=276400" + profile = GetProfileLevelID(s) + require.Equal(t, "640029", profile) +} + +func TestDecodeSPS2(t *testing.T) { + s := "6764001fad84010c20086100430802184010c200843b50740932" + b, err := hex.DecodeString(s) + require.Nil(t, err) + + sps := DecodeSPS(b) + require.Equal(t, uint16(928), sps.Width()) + require.Equal(t, uint16(576), sps.Height()) + + s = "Z2QAHq2EAQwgCGEAQwgCGEAQwgCEO1BQF/yzcBAQFAAAD6AAAXcCEA==" // unknown + b, err = base64.StdEncoding.DecodeString(s) + require.Nil(t, err) + + sps = DecodeSPS(b) + require.Equal(t, uint16(640), sps.Width()) + require.Equal(t, uint16(360), sps.Height()) +} + +func TestAVCCToCodec(t *testing.T) { + s := "000000196764001fac2484014016ec0440000003004000000c23c60c920000000568ee32c8b0000000d365" + b, _ := hex.DecodeString(s) + codec := AVCCToCodec(b) + require.Equal(t, "packetization-mode=1;profile-level-id=64001f;sprop-parameter-sets=Z2QAH6wkhAFAFuwEQAAAAwBAAAAMI8YMkg==,aO4yyLA=", codec.FmtpLine) +} diff --git a/installs_on_host/go2rtc/pkg/h264/mpeg4.go b/installs_on_host/go2rtc/pkg/h264/mpeg4.go new file mode 100644 index 0000000..c49e0e8 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/h264/mpeg4.go @@ -0,0 +1,101 @@ +// Package h264 - MPEG4 format related functions +package h264 + +import ( + "bytes" + "encoding/base64" + "encoding/binary" + "encoding/hex" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +// DecodeConfig - extract profile, SPS and PPS from MPEG4 config +func DecodeConfig(conf []byte) (profile []byte, sps []byte, pps []byte) { + if len(conf) < 6 || conf[0] != 1 { + return + } + + profile = conf[1:4] + + count := conf[5] & 0x1F + conf = conf[6:] + for i := byte(0); i < count; i++ { + if len(conf) < 2 { + return + } + size := 2 + int(binary.BigEndian.Uint16(conf)) + if len(conf) < size { + return + } + if sps == nil { + sps = conf[2:size] + } + conf = conf[size:] + } + + count = conf[0] + conf = conf[1:] + for i := byte(0); i < count; i++ { + if len(conf) < 2 { + return + } + size := 2 + int(binary.BigEndian.Uint16(conf)) + if len(conf) < size { + return + } + if pps == nil { + pps = conf[2:size] + } + conf = conf[size:] + } + + return +} + +func EncodeConfig(sps, pps []byte) []byte { + spsSize := uint16(len(sps)) + ppsSize := uint16(len(pps)) + + buf := make([]byte, 5+3+spsSize+3+ppsSize) + buf[0] = 1 + copy(buf[1:], sps[1:4]) // profile + buf[4] = 3 | 0xFC // ? LengthSizeMinusOne + + b := buf[5:] + _ = b[3] + b[0] = 1 | 0xE0 // ? sps count + binary.BigEndian.PutUint16(b[1:], spsSize) + copy(b[3:], sps) + + b = buf[5+3+spsSize:] + _ = b[3] + b[0] = 1 // pps count + binary.BigEndian.PutUint16(b[1:], ppsSize) + copy(b[3:], pps) + + return buf +} + +func ConfigToCodec(conf []byte) *core.Codec { + buf := bytes.NewBufferString("packetization-mode=1") + + profile, sps, pps := DecodeConfig(conf) + if profile != nil { + buf.WriteString(";profile-level-id=") + buf.WriteString(hex.EncodeToString(profile)) + } + if sps != nil && pps != nil { + buf.WriteString(";sprop-parameter-sets=") + buf.WriteString(base64.StdEncoding.EncodeToString(sps)) + buf.WriteString(",") + buf.WriteString(base64.StdEncoding.EncodeToString(pps)) + } + + return &core.Codec{ + Name: core.CodecH264, + ClockRate: 90000, + FmtpLine: buf.String(), + PayloadType: core.PayloadTypeRAW, + } +} diff --git a/installs_on_host/go2rtc/pkg/h264/payloader.go b/installs_on_host/go2rtc/pkg/h264/payloader.go new file mode 100644 index 0000000..efc8998 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/h264/payloader.go @@ -0,0 +1,195 @@ +package h264 + +import "encoding/binary" + +// Payloader payloads H264 packets +type Payloader struct { + IsAVC bool + stapANalu []byte +} + +const ( + stapaNALUType = 24 + fuaNALUType = 28 + fubNALUType = 29 + spsNALUType = 7 + ppsNALUType = 8 + audNALUType = 9 + fillerNALUType = 12 + + fuaHeaderSize = 2 + //stapaHeaderSize = 1 + //stapaNALULengthSize = 2 + + naluTypeBitmask = 0x1F + naluRefIdcBitmask = 0x60 + //fuStartBitmask = 0x80 + //fuEndBitmask = 0x40 + + outputStapAHeader = 0x78 +) + +//func annexbNALUStartCode() []byte { return []byte{0x00, 0x00, 0x00, 0x01} } + +func EmitNalus(nals []byte, isAVC bool, emit func([]byte)) { + if !isAVC { + nextInd := func(nalu []byte, start int) (indStart int, indLen int) { + zeroCount := 0 + + for i, b := range nalu[start:] { + if b == 0 { + zeroCount++ + continue + } else if b == 1 { + if zeroCount >= 2 { + return start + i - zeroCount, zeroCount + 1 + } + } + zeroCount = 0 + } + return -1, -1 + } + + nextIndStart, nextIndLen := nextInd(nals, 0) + if nextIndStart == -1 { + emit(nals) + } else { + for nextIndStart != -1 { + prevStart := nextIndStart + nextIndLen + nextIndStart, nextIndLen = nextInd(nals, prevStart) + if nextIndStart != -1 { + emit(nals[prevStart:nextIndStart]) + } else { + // Emit until end of stream, no end indicator found + emit(nals[prevStart:]) + } + } + } + } else { + for { + n := uint32(len(nals)) + if n < 4 { + break + } + end := 4 + binary.BigEndian.Uint32(nals) + if n < end { + break + } + emit(nals[4:end]) + nals = nals[end:] + } + } +} + +// Payload fragments a H264 packet across one or more byte arrays +func (p *Payloader) Payload(mtu uint16, payload []byte) [][]byte { + var payloads [][]byte + if len(payload) == 0 { + return payloads + } + + EmitNalus(payload, p.IsAVC, func(nalu []byte) { + if len(nalu) == 0 { + return + } + + naluType := nalu[0] & naluTypeBitmask + naluRefIdc := nalu[0] & naluRefIdcBitmask + + switch naluType { + case audNALUType, fillerNALUType: + return + case spsNALUType, ppsNALUType: + if p.stapANalu == nil { + p.stapANalu = []byte{outputStapAHeader} + } + p.stapANalu = append(p.stapANalu, byte(len(nalu)>>8), byte(len(nalu))) + p.stapANalu = append(p.stapANalu, nalu...) + return + } + + if p.stapANalu != nil { + // Pack current NALU with SPS and PPS as STAP-A + // Supports multiple PPS in a row + if len(p.stapANalu) <= int(mtu) { + payloads = append(payloads, p.stapANalu) + } + p.stapANalu = nil + } + + // Single NALU + if len(nalu) <= int(mtu) { + out := make([]byte, len(nalu)) + copy(out, nalu) + payloads = append(payloads, out) + return + } + + // FU-A + maxFragmentSize := int(mtu) - fuaHeaderSize + + // The FU payload consists of fragments of the payload of the fragmented + // NAL unit so that if the fragmentation unit payloads of consecutive + // FUs are sequentially concatenated, the payload of the fragmented NAL + // unit can be reconstructed. The NAL unit type octet of the fragmented + // NAL unit is not included as such in the fragmentation unit payload, + // but rather the information of the NAL unit type octet of the + // fragmented NAL unit is conveyed in the F and NRI fields of the FU + // indicator octet of the fragmentation unit and in the type field of + // the FU header. An FU payload MAY have any number of octets and MAY + // be empty. + + naluData := nalu + // According to the RFC, the first octet is skipped due to redundant information + naluDataIndex := 1 + naluDataLength := len(nalu) - naluDataIndex + naluDataRemaining := naluDataLength + + if min(maxFragmentSize, naluDataRemaining) <= 0 { + return + } + + for naluDataRemaining > 0 { + currentFragmentSize := min(maxFragmentSize, naluDataRemaining) + out := make([]byte, fuaHeaderSize+currentFragmentSize) + + // +---------------+ + // |0|1|2|3|4|5|6|7| + // +-+-+-+-+-+-+-+-+ + // |F|NRI| Type | + // +---------------+ + out[0] = fuaNALUType + out[0] |= naluRefIdc + + // +---------------+ + // |0|1|2|3|4|5|6|7| + // +-+-+-+-+-+-+-+-+ + // |S|E|R| Type | + // +---------------+ + + out[1] = naluType + if naluDataRemaining == naluDataLength { + // Set start bit + out[1] |= 1 << 7 + } else if naluDataRemaining-currentFragmentSize == 0 { + // Set end bit + out[1] |= 1 << 6 + } + + copy(out[fuaHeaderSize:], naluData[naluDataIndex:naluDataIndex+currentFragmentSize]) + payloads = append(payloads, out) + + naluDataRemaining -= currentFragmentSize + naluDataIndex += currentFragmentSize + } + }) + + return payloads +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/installs_on_host/go2rtc/pkg/h264/rtp.go b/installs_on_host/go2rtc/pkg/h264/rtp.go new file mode 100644 index 0000000..bb5fe47 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/h264/rtp.go @@ -0,0 +1,137 @@ +package h264 + +import ( + "encoding/binary" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264/annexb" + "github.com/pion/rtp" + "github.com/pion/rtp/codecs" +) + +const RTPPacketVersionAVC = 0 + +const PSMaxSize = 128 // the biggest SPS I've seen is 48 (EZVIZ CS-CV210) + +func RTPDepay(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc { + depack := &codecs.H264Packet{IsAVC: true} + + sps, pps := GetParameterSet(codec.FmtpLine) + ps := JoinNALU(sps, pps) + + buf := make([]byte, 0, 512*1024) // 512K + + return func(packet *rtp.Packet) { + //log.Printf("[RTP] codec: %s, nalu: %2d, size: %6d, ts: %10d, pt: %2d, ssrc: %d, seq: %d, %v", codec.Name, packet.Payload[0]&0x1F, len(packet.Payload), packet.Timestamp, packet.PayloadType, packet.SSRC, packet.SequenceNumber, packet.Marker) + + payload, err := depack.Unmarshal(packet.Payload) + if len(payload) == 0 || err != nil { + return + } + + // Memory overflow protection. Can happen if we miss a lot of packets with the marker. + // https://github.com/AlexxIT/go2rtc/issues/675 + if len(buf) > 5*1024*1024 { + buf = buf[: 0 : 512*1024] + } + + // Fix TP-Link Tapo TC70: sends SPS and PPS with packet.Marker = true + // Reolink Duo 2: sends SPS with Marker and PPS without + if packet.Marker && len(payload) < PSMaxSize { + switch NALUType(payload) { + case NALUTypeSPS, NALUTypePPS: + buf = append(buf, payload...) + return + case NALUTypeSEI: + // RtspServer https://github.com/AlexxIT/go2rtc/issues/244 + // sends, marked SPS, marked PPS, marked SEI, marked IFrame + return + } + } + + if len(buf) == 0 { + for { + // Amcrest IP4M-1051: 9, 7, 8, 6, 28... + // Amcrest IP4M-1051: 9, 6, 1 + switch NALUType(payload) { + case NALUTypeIFrame: + // fix IFrame without SPS,PPS + buf = append(buf, ps...) + case NALUTypeSEI, NALUTypeAUD: + // fix ffmpeg with transcoding first frame + i := int(4 + binary.BigEndian.Uint32(payload)) + + // check if only one NAL (fix ffmpeg transcoding for Reolink RLC-510A) + if i == len(payload) { + return + } + + payload = payload[i:] + continue + case NALUTypePFrame, NALUTypeSPS, NALUTypePPS: // pass + default: + return // skip any unknown NAL unit type + } + break + } + } + + // collect all NALs for Access Unit + if !packet.Marker { + buf = append(buf, payload...) + return + } + + if len(buf) > 0 { + payload = append(buf, payload...) + buf = buf[:0] + } + + // should not be that huge SPS + if NALUType(payload) == NALUTypeSPS && binary.BigEndian.Uint32(payload) >= PSMaxSize { + // some Chinese buggy cameras have a single packet with SPS+PPS+IFrame separated by 00 00 00 01 + // https://github.com/AlexxIT/WebRTC/issues/391 + // https://github.com/AlexxIT/WebRTC/issues/392 + payload = annexb.FixAnnexBInAVCC(payload) + } + + //log.Printf("[AVC] %v, len: %d, ts: %10d, seq: %d", NALUTypes(payload), len(payload), packet.Timestamp, packet.SequenceNumber) + + clone := *packet + clone.Version = RTPPacketVersionAVC + clone.Payload = payload + handler(&clone) + } +} + +func RTPPay(mtu uint16, handler core.HandlerFunc) core.HandlerFunc { + if mtu == 0 { + mtu = 1472 + } + + payloader := &Payloader{IsAVC: true} + sequencer := rtp.NewRandomSequencer() + mtu -= 12 // rtp.Header size + + return func(packet *rtp.Packet) { + if packet.Version != RTPPacketVersionAVC { + handler(packet) + return + } + + payloads := payloader.Payload(mtu, packet.Payload) + last := len(payloads) - 1 + for i, payload := range payloads { + clone := rtp.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: i == last, + SequenceNumber: sequencer.NextSequenceNumber(), + Timestamp: packet.Timestamp, + }, + Payload: payload, + } + handler(&clone) + } + } +} diff --git a/installs_on_host/go2rtc/pkg/h264/sps.go b/installs_on_host/go2rtc/pkg/h264/sps.go new file mode 100644 index 0000000..1ac7394 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/h264/sps.go @@ -0,0 +1,366 @@ +package h264 + +import ( + "fmt" + + "github.com/AlexxIT/go2rtc/pkg/bits" +) + +// http://www.itu.int/rec/T-REC-H.264 +// https://webrtc.googlesource.com/src/+/refs/heads/main/common_video/h264/sps_parser.cc + +//goland:noinspection GoSnakeCaseUsage +type SPS struct { + profile_idc uint8 + profile_iop uint8 + level_idc uint8 + + seq_parameter_set_id uint32 + + chroma_format_idc uint32 + separate_colour_plane_flag byte + bit_depth_luma_minus8 uint32 + bit_depth_chroma_minus8 uint32 + qpprime_y_zero_transform_bypass_flag byte + seq_scaling_matrix_present_flag byte + + log2_max_frame_num_minus4 uint32 + pic_order_cnt_type uint32 + log2_max_pic_order_cnt_lsb_minus4 uint32 + delta_pic_order_always_zero_flag byte + offset_for_non_ref_pic int32 + offset_for_top_to_bottom_field int32 + num_ref_frames_in_pic_order_cnt_cycle uint32 + num_ref_frames uint32 + gaps_in_frame_num_value_allowed_flag byte + + pic_width_in_mbs_minus_1 uint32 + pic_height_in_map_units_minus_1 uint32 + frame_mbs_only_flag byte + mb_adaptive_frame_field_flag byte + direct_8x8_inference_flag byte + + frame_cropping_flag byte + frame_crop_left_offset uint32 + frame_crop_right_offset uint32 + frame_crop_top_offset uint32 + frame_crop_bottom_offset uint32 + + vui_parameters_present_flag byte + aspect_ratio_info_present_flag byte + aspect_ratio_idc byte + sar_width uint16 + sar_height uint16 + + overscan_info_present_flag byte + overscan_appropriate_flag byte + + video_signal_type_present_flag byte + video_format uint8 + video_full_range_flag byte + + colour_description_present_flag byte + colour_description uint32 + + chroma_loc_info_present_flag byte + chroma_sample_loc_type_top_field uint32 + chroma_sample_loc_type_bottom_field uint32 + + timing_info_present_flag byte + num_units_in_tick uint32 + time_scale uint32 + fixed_frame_rate_flag byte +} + +func (s *SPS) Width() uint16 { + width := 16 * (s.pic_width_in_mbs_minus_1 + 1) + crop := 2 * (s.frame_crop_left_offset + s.frame_crop_right_offset) + return uint16(width - crop) +} + +func (s *SPS) Height() uint16 { + height := 16 * (s.pic_height_in_map_units_minus_1 + 1) + crop := 2 * (s.frame_crop_top_offset + s.frame_crop_bottom_offset) + if s.frame_mbs_only_flag == 0 { + height *= 2 + } + return uint16(height - crop) +} + +func DecodeSPS(sps []byte) *SPS { + // https://developer.ridgerun.com/wiki/index.php/H264_Analysis_Tools + // ffmpeg -i file.h264 -c copy -bsf:v trace_headers -f null - + r := bits.NewReader(sps) + + hdr := r.ReadByte() + if hdr&0x1F != NALUTypeSPS { + return nil + } + + s := &SPS{ + profile_idc: r.ReadByte(), + profile_iop: r.ReadByte(), + level_idc: r.ReadByte(), + seq_parameter_set_id: r.ReadUEGolomb(), + } + + switch s.profile_idc { + case 100, 110, 122, 244, 44, 83, 86, 118, 128, 138, 139, 134, 135: + n := byte(8) + + s.chroma_format_idc = r.ReadUEGolomb() + if s.chroma_format_idc == 3 { + s.separate_colour_plane_flag = r.ReadBit() + n = 12 + } + + s.bit_depth_luma_minus8 = r.ReadUEGolomb() + s.bit_depth_chroma_minus8 = r.ReadUEGolomb() + s.qpprime_y_zero_transform_bypass_flag = r.ReadBit() + + s.seq_scaling_matrix_present_flag = r.ReadBit() + if s.seq_scaling_matrix_present_flag != 0 { + for i := byte(0); i < n; i++ { + //goland:noinspection GoSnakeCaseUsage + seq_scaling_list_present_flag := r.ReadBit() + if seq_scaling_list_present_flag != 0 { + if i < 6 { + s.scaling_list(r, 16) + } else { + s.scaling_list(r, 64) + } + } + } + } + } + + s.log2_max_frame_num_minus4 = r.ReadUEGolomb() + + s.pic_order_cnt_type = r.ReadUEGolomb() + switch s.pic_order_cnt_type { + case 0: + s.log2_max_pic_order_cnt_lsb_minus4 = r.ReadUEGolomb() + case 1: + s.delta_pic_order_always_zero_flag = r.ReadBit() + s.offset_for_non_ref_pic = r.ReadSEGolomb() + s.offset_for_top_to_bottom_field = r.ReadSEGolomb() + + s.num_ref_frames_in_pic_order_cnt_cycle = r.ReadUEGolomb() + for i := uint32(0); i < s.num_ref_frames_in_pic_order_cnt_cycle; i++ { + _ = r.ReadSEGolomb() // offset_for_ref_frame[i] + } + } + + s.num_ref_frames = r.ReadUEGolomb() + s.gaps_in_frame_num_value_allowed_flag = r.ReadBit() + + s.pic_width_in_mbs_minus_1 = r.ReadUEGolomb() + s.pic_height_in_map_units_minus_1 = r.ReadUEGolomb() + + s.frame_mbs_only_flag = r.ReadBit() + if s.frame_mbs_only_flag == 0 { + s.mb_adaptive_frame_field_flag = r.ReadBit() + } + + s.direct_8x8_inference_flag = r.ReadBit() + + s.frame_cropping_flag = r.ReadBit() + if s.frame_cropping_flag != 0 { + s.frame_crop_left_offset = r.ReadUEGolomb() + s.frame_crop_right_offset = r.ReadUEGolomb() + s.frame_crop_top_offset = r.ReadUEGolomb() + s.frame_crop_bottom_offset = r.ReadUEGolomb() + } + + s.vui_parameters_present_flag = r.ReadBit() + if s.vui_parameters_present_flag != 0 { + s.aspect_ratio_info_present_flag = r.ReadBit() + if s.aspect_ratio_info_present_flag != 0 { + s.aspect_ratio_idc = r.ReadByte() + if s.aspect_ratio_idc == 255 { + s.sar_width = r.ReadUint16() + s.sar_height = r.ReadUint16() + } + } + + s.overscan_info_present_flag = r.ReadBit() + if s.overscan_info_present_flag != 0 { + s.overscan_appropriate_flag = r.ReadBit() + } + + s.video_signal_type_present_flag = r.ReadBit() + if s.video_signal_type_present_flag != 0 { + s.video_format = r.ReadBits8(3) + s.video_full_range_flag = r.ReadBit() + + s.colour_description_present_flag = r.ReadBit() + if s.colour_description_present_flag != 0 { + s.colour_description = r.ReadUint24() + } + } + + s.chroma_loc_info_present_flag = r.ReadBit() + if s.chroma_loc_info_present_flag != 0 { + s.chroma_sample_loc_type_top_field = r.ReadUEGolomb() + s.chroma_sample_loc_type_bottom_field = r.ReadUEGolomb() + } + + s.timing_info_present_flag = r.ReadBit() + if s.timing_info_present_flag != 0 { + s.num_units_in_tick = r.ReadUint32() + s.time_scale = r.ReadUint32() + s.fixed_frame_rate_flag = r.ReadBit() + } + //... + } + + if r.EOF { + return nil + } + + return s +} + +//goland:noinspection GoSnakeCaseUsage +func (s *SPS) scaling_list(r *bits.Reader, sizeOfScalingList int) { + lastScale := int32(8) + nextScale := int32(8) + for j := 0; j < sizeOfScalingList; j++ { + if nextScale != 0 { + delta_scale := r.ReadSEGolomb() + nextScale = (lastScale + delta_scale + 256) % 256 + } + if nextScale != 0 { + lastScale = nextScale + } + } +} + +func (s *SPS) Profile() string { + switch s.profile_idc { + case 0x42: + return "Baseline" + case 0x4D: + return "Main" + case 0x58: + return "Extended" + case 0x64: + return "High" + } + return fmt.Sprintf("0x%02X", s.profile_idc) +} + +func (s *SPS) PixFmt() string { + if s.bit_depth_luma_minus8 == 0 { + switch s.chroma_format_idc { + case 1: + if s.video_full_range_flag == 1 { + return "yuvj420p" + } + return "yuv420p" + case 2: + return "yuv422p" + case 3: + return "yuv444p" + } + } + return "" +} + +func (s *SPS) String() string { + return fmt.Sprintf( + "%s %d.%d, %s, %dx%d", + s.Profile(), s.level_idc/10, s.level_idc%10, s.PixFmt(), s.Width(), s.Height(), + ) +} + +// FixPixFmt - change yuvj420p to yuv420p in SPS +// same as "-c:v copy -bsf:v h264_metadata=video_full_range_flag=0" +func FixPixFmt(sps []byte) { + r := bits.NewReader(sps) + + _ = r.ReadByte() + + profile := r.ReadByte() + _ = r.ReadByte() + _ = r.ReadByte() + _ = r.ReadUEGolomb() + + switch profile { + case 100, 110, 122, 244, 44, 83, 86, 118, 128, 138, 139, 134, 135: + n := byte(8) + + if r.ReadUEGolomb() == 3 { + _ = r.ReadBit() + n = 12 + } + + _ = r.ReadUEGolomb() + _ = r.ReadUEGolomb() + _ = r.ReadBit() + + if r.ReadBit() != 0 { + for i := byte(0); i < n; i++ { + if r.ReadBit() != 0 { + return // skip + } + } + } + } + + _ = r.ReadUEGolomb() + + switch r.ReadUEGolomb() { + case 0: + _ = r.ReadUEGolomb() + case 1: + _ = r.ReadBit() + _ = r.ReadSEGolomb() + _ = r.ReadSEGolomb() + + n := r.ReadUEGolomb() + for i := uint32(0); i < n; i++ { + _ = r.ReadSEGolomb() + } + } + + _ = r.ReadUEGolomb() + _ = r.ReadBit() + + _ = r.ReadUEGolomb() + _ = r.ReadUEGolomb() + + if r.ReadBit() == 0 { + _ = r.ReadBit() + } + + _ = r.ReadBit() + + if r.ReadBit() != 0 { + _ = r.ReadUEGolomb() + _ = r.ReadUEGolomb() + _ = r.ReadUEGolomb() + _ = r.ReadUEGolomb() + } + + if r.ReadBit() != 0 { + if r.ReadBit() != 0 { + if r.ReadByte() == 255 { + _ = r.ReadUint16() + _ = r.ReadUint16() + } + } + + if r.ReadBit() != 0 { + _ = r.ReadBit() + } + + if r.ReadBit() != 0 { + _ = r.ReadBits8(3) + if r.ReadBit() == 1 { + pos, bit := r.Pos() + sps[pos] &= ^byte(1 << bit) + } + } + } +} diff --git a/installs_on_host/go2rtc/pkg/h265/README.md b/installs_on_host/go2rtc/pkg/h265/README.md new file mode 100644 index 0000000..a021c9d --- /dev/null +++ b/installs_on_host/go2rtc/pkg/h265/README.md @@ -0,0 +1,8 @@ +# H265 + +Payloader code taken from [pion](https://github.com/pion/rtp) library branch [h265](https://github.com/pion/rtp/tree/h265), because it's still not in release. Thanks to [@kevmo314](https://github.com/kevmo314). + +## Useful links + +- https://datatracker.ietf.org/doc/html/rfc7798 +- [Add initial support for WebRTC HEVC](https://trac.webkit.org/changeset/259452/webkit) diff --git a/installs_on_host/go2rtc/pkg/h265/avc.go b/installs_on_host/go2rtc/pkg/h265/avc.go new file mode 100644 index 0000000..8bab9a1 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/h265/avc.go @@ -0,0 +1,54 @@ +package h265 + +import "github.com/AlexxIT/go2rtc/pkg/h264" + +const forbiddenZeroBit = 0x80 +const nalUnitType = 0x3F + +// Deprecated: DecodeStream - find and return first AU in AVC format +// useful for processing live streams with unknown separator size +func DecodeStream(annexb []byte) ([]byte, int) { + startPos := -1 + + i := 0 + for { + // search next separator + if i = h264.IndexFrom(annexb, []byte{0, 0, 1}, i); i < 0 { + break + } + + // move i to next AU + if i += 3; i >= len(annexb) { + break + } + + // check if AU type valid + octet := annexb[i] + if octet&forbiddenZeroBit != 0 { + continue + } + + nalType := (octet >> 1) & nalUnitType + if startPos >= 0 { + switch nalType { + case NALUTypeVPS, NALUTypePFrame: + if annexb[i-4] == 0 { + return h264.DecodeAnnexB(annexb[startPos : i-4]), i - 4 + } else { + return h264.DecodeAnnexB(annexb[startPos : i-3]), i - 3 + } + } + } else { + switch nalType { + case NALUTypeVPS, NALUTypePFrame: + if i >= 4 && annexb[i-4] == 0 { + startPos = i - 4 + } else { + startPos = i - 3 + } + } + } + } + + return nil, 0 +} diff --git a/installs_on_host/go2rtc/pkg/h265/avcc.go b/installs_on_host/go2rtc/pkg/h265/avcc.go new file mode 100644 index 0000000..9c257a1 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/h265/avcc.go @@ -0,0 +1,61 @@ +// Package h265 - AVCC format related functions +package h265 + +import ( + "bytes" + "encoding/base64" + "encoding/binary" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/pion/rtp" +) + +func RepairAVCC(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc { + vds, sps, pps := GetParameterSet(codec.FmtpLine) + ps := h264.JoinNALU(vds, sps, pps) + + return func(packet *rtp.Packet) { + switch NALUType(packet.Payload) { + case NALUTypeIFrame, NALUTypeIFrame2, NALUTypeIFrame3: + clone := *packet + clone.Payload = h264.Join(ps, packet.Payload) + handler(&clone) + default: + handler(packet) + } + } +} + +func AVCCToCodec(avcc []byte) *core.Codec { + buf := bytes.NewBufferString("profile-id=1") + + for { + size := 4 + int(binary.BigEndian.Uint32(avcc)) + + switch NALUType(avcc) { + case NALUTypeVPS: + buf.WriteString(";sprop-vps=") + buf.WriteString(base64.StdEncoding.EncodeToString(avcc[4:size])) + case NALUTypeSPS: + buf.WriteString(";sprop-sps=") + buf.WriteString(base64.StdEncoding.EncodeToString(avcc[4:size])) + case NALUTypePPS: + buf.WriteString(";sprop-pps=") + buf.WriteString(base64.StdEncoding.EncodeToString(avcc[4:size])) + } + + if size < len(avcc) { + avcc = avcc[size:] + } else { + break + } + } + + return &core.Codec{ + Name: core.CodecH265, + ClockRate: 90000, + FmtpLine: buf.String(), + PayloadType: core.PayloadTypeRAW, + } +} diff --git a/installs_on_host/go2rtc/pkg/h265/h265_test.go b/installs_on_host/go2rtc/pkg/h265/h265_test.go new file mode 100644 index 0000000..278e09a --- /dev/null +++ b/installs_on_host/go2rtc/pkg/h265/h265_test.go @@ -0,0 +1,30 @@ +package h265 + +import ( + "encoding/base64" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDecodeSPS(t *testing.T) { + s := "QgEBAWAAAAMAAAMAAAMAAAMAmaAAoAgBaH+KrTuiS7/8AAQABbAgApMuADN/mAE=" + b, err := base64.StdEncoding.DecodeString(s) + require.Nil(t, err) + + sps := DecodeSPS(b) + require.NotNil(t, sps) + require.Equal(t, uint16(5120), sps.Width()) + require.Equal(t, uint16(1440), sps.Height()) +} + +func TestDecodeSPS2(t *testing.T) { + s := "QgEBIUAAAAMAkAAAAwAAAwCWoAUCAWlnpbkShc1AQIC4QAAAAwBAAAAFFEn/eEAOpgAV+V8IBBA=" + b, err := base64.StdEncoding.DecodeString(s) + require.Nil(t, err) + + sps := DecodeSPS(b) + require.NotNil(t, sps) + require.Equal(t, uint16(640), sps.Width()) + require.Equal(t, uint16(360), sps.Height()) +} diff --git a/installs_on_host/go2rtc/pkg/h265/helper.go b/installs_on_host/go2rtc/pkg/h265/helper.go new file mode 100644 index 0000000..c2ad94d --- /dev/null +++ b/installs_on_host/go2rtc/pkg/h265/helper.go @@ -0,0 +1,76 @@ +package h265 + +import ( + "encoding/base64" + "encoding/binary" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +const ( + NALUTypePFrame = 1 + NALUTypeIFrame = 19 + NALUTypeIFrame2 = 20 + NALUTypeIFrame3 = 21 + NALUTypeVPS = 32 + NALUTypeSPS = 33 + NALUTypePPS = 34 + NALUTypePrefixSEI = 39 + NALUTypeSuffixSEI = 40 + NALUTypeFU = 49 +) + +func NALUType(b []byte) byte { + return (b[4] >> 1) & 0x3F +} + +func IsKeyframe(b []byte) bool { + for { + switch NALUType(b) { + case NALUTypePFrame: + return false + case NALUTypeIFrame, NALUTypeIFrame2, NALUTypeIFrame3: + return true + } + + size := int(binary.BigEndian.Uint32(b)) + 4 + if size < len(b) { + b = b[size:] + continue + } else { + return false + } + } +} + +func Types(data []byte) []byte { + var types []byte + for { + types = append(types, NALUType(data)) + + size := 4 + int(binary.BigEndian.Uint32(data)) + if size < len(data) { + data = data[size:] + } else { + break + } + } + return types +} + +func GetParameterSet(fmtp string) (vps, sps, pps []byte) { + if fmtp == "" { + return + } + + s := core.Between(fmtp, "sprop-vps=", ";") + vps, _ = base64.StdEncoding.DecodeString(s) + + s = core.Between(fmtp, "sprop-sps=", ";") + sps, _ = base64.StdEncoding.DecodeString(s) + + s = core.Between(fmtp, "sprop-pps=", ";") + pps, _ = base64.StdEncoding.DecodeString(s) + + return +} diff --git a/installs_on_host/go2rtc/pkg/h265/mpeg4.go b/installs_on_host/go2rtc/pkg/h265/mpeg4.go new file mode 100644 index 0000000..e06d083 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/h265/mpeg4.go @@ -0,0 +1,98 @@ +// Package h265 - MPEG4 format related functions +package h265 + +import ( + "bytes" + "encoding/base64" + "encoding/binary" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +func DecodeConfig(conf []byte) (profile, vps, sps, pps []byte) { + profile = conf[1:4] + + b := conf[23:] + if binary.BigEndian.Uint16(b[1:]) != 1 { + return + } + vpsSize := binary.BigEndian.Uint16(b[3:]) + vps = b[5 : 5+vpsSize] + + b = conf[23+5+vpsSize:] + if binary.BigEndian.Uint16(b[1:]) != 1 { + return + } + spsSize := binary.BigEndian.Uint16(b[3:]) + sps = b[5 : 5+spsSize] + + b = conf[23+5+vpsSize+5+spsSize:] + if binary.BigEndian.Uint16(b[1:]) != 1 { + return + } + ppsSize := binary.BigEndian.Uint16(b[3:]) + pps = b[5 : 5+ppsSize] + + return +} + +func EncodeConfig(vps, sps, pps []byte) []byte { + vpsSize := uint16(len(vps)) + spsSize := uint16(len(sps)) + ppsSize := uint16(len(pps)) + + buf := make([]byte, 23+5+vpsSize+5+spsSize+5+ppsSize) + + buf[0] = 1 + copy(buf[1:], sps[3:6]) // profile + buf[21] = 3 // ? + buf[22] = 3 // ? + + b := buf[23:] + _ = b[5] + b[0] = (vps[0] >> 1) & 0x3F + binary.BigEndian.PutUint16(b[1:], 1) // VPS count + binary.BigEndian.PutUint16(b[3:], vpsSize) + copy(b[5:], vps) + + b = buf[23+5+vpsSize:] + _ = b[5] + b[0] = (sps[0] >> 1) & 0x3F + binary.BigEndian.PutUint16(b[1:], 1) // SPS count + binary.BigEndian.PutUint16(b[3:], spsSize) + copy(b[5:], sps) + + b = buf[23+5+vpsSize+5+spsSize:] + _ = b[5] + b[0] = (pps[0] >> 1) & 0x3F + binary.BigEndian.PutUint16(b[1:], 1) // PPS count + binary.BigEndian.PutUint16(b[3:], ppsSize) + copy(b[5:], pps) + + return buf +} + +func ConfigToCodec(conf []byte) *core.Codec { + buf := bytes.NewBufferString("profile-id=1") + + _, vps, sps, pps := DecodeConfig(conf) + if vps != nil { + buf.WriteString(";sprop-vps=") + buf.WriteString(base64.StdEncoding.EncodeToString(vps)) + } + if sps != nil { + buf.WriteString(";sprop-sps=") + buf.WriteString(base64.StdEncoding.EncodeToString(sps)) + } + if pps != nil { + buf.WriteString(";sprop-pps=") + buf.WriteString(base64.StdEncoding.EncodeToString(pps)) + } + + return &core.Codec{ + Name: core.CodecH265, + ClockRate: 90000, + FmtpLine: buf.String(), + PayloadType: core.PayloadTypeRAW, + } +} diff --git a/installs_on_host/go2rtc/pkg/h265/payloader.go b/installs_on_host/go2rtc/pkg/h265/payloader.go new file mode 100644 index 0000000..e488411 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/h265/payloader.go @@ -0,0 +1,301 @@ +package h265 + +import ( + "encoding/binary" + "math" + + "github.com/AlexxIT/go2rtc/pkg/h264" +) + +// +// Network Abstraction Unit Header implementation +// + +const ( + // sizeof(uint16) + h265NaluHeaderSize = 2 + // https://datatracker.ietf.org/doc/html/rfc7798#section-4.4.2 + h265NaluAggregationPacketType = 48 + // https://datatracker.ietf.org/doc/html/rfc7798#section-4.4.3 + h265NaluFragmentationUnitType = 49 + // https://datatracker.ietf.org/doc/html/rfc7798#section-4.4.4 + h265NaluPACIPacketType = 50 +) + +// H265NALUHeader is a H265 NAL Unit Header +// https://datatracker.ietf.org/doc/html/rfc7798#section-1.1.4 +// +---------------+---------------+ +// +// |0|1|2|3|4|5|6|7|0|1|2|3|4|5|6|7| +// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +// |F| Type | LayerID | TID | +// +-------------+-----------------+ +type H265NALUHeader uint16 + +func newH265NALUHeader(highByte, lowByte uint8) H265NALUHeader { + return H265NALUHeader((uint16(highByte) << 8) | uint16(lowByte)) +} + +// F is the forbidden bit, should always be 0. +func (h H265NALUHeader) F() bool { + return (uint16(h) >> 15) != 0 +} + +// Type of NAL Unit. +func (h H265NALUHeader) Type() uint8 { + // 01111110 00000000 + const mask = 0b01111110 << 8 + return uint8((uint16(h) & mask) >> (8 + 1)) +} + +// IsTypeVCLUnit returns whether or not the NAL Unit type is a VCL NAL unit. +func (h H265NALUHeader) IsTypeVCLUnit() bool { + // Type is coded on 6 bits + const msbMask = 0b00100000 + return (h.Type() & msbMask) == 0 +} + +// LayerID should always be 0 in non-3D HEVC context. +func (h H265NALUHeader) LayerID() uint8 { + // 00000001 11111000 + const mask = (0b00000001 << 8) | 0b11111000 + return uint8((uint16(h) & mask) >> 3) +} + +// TID is the temporal identifier of the NAL unit +1. +func (h H265NALUHeader) TID() uint8 { + const mask = 0b00000111 + return uint8(uint16(h) & mask) +} + +// IsAggregationPacket returns whether or not the packet is an Aggregation packet. +func (h H265NALUHeader) IsAggregationPacket() bool { + return h.Type() == h265NaluAggregationPacketType +} + +// IsFragmentationUnit returns whether or not the packet is a Fragmentation Unit packet. +func (h H265NALUHeader) IsFragmentationUnit() bool { + return h.Type() == h265NaluFragmentationUnitType +} + +// IsPACIPacket returns whether or not the packet is a PACI packet. +func (h H265NALUHeader) IsPACIPacket() bool { + return h.Type() == h265NaluPACIPacketType +} + +// +// Fragmentation Unit implementation +// + +const ( + // sizeof(uint8) + h265FragmentationUnitHeaderSize = 1 +) + +// H265FragmentationUnitHeader is a H265 FU Header +// +---------------+ +// |0|1|2|3|4|5|6|7| +// +-+-+-+-+-+-+-+-+ +// |S|E| FuType | +// +---------------+ +type H265FragmentationUnitHeader uint8 + +// S represents the start of a fragmented NAL unit. +func (h H265FragmentationUnitHeader) S() bool { + const mask = 0b10000000 + return ((h & mask) >> 7) != 0 +} + +// E represents the end of a fragmented NAL unit. +func (h H265FragmentationUnitHeader) E() bool { + const mask = 0b01000000 + return ((h & mask) >> 6) != 0 +} + +// FuType MUST be equal to the field Type of the fragmented NAL unit. +func (h H265FragmentationUnitHeader) FuType() uint8 { + const mask = 0b00111111 + return uint8(h) & mask +} + +// Payloader payloads H265 packets +type Payloader struct { + AddDONL bool + SkipAggregation bool + donl uint16 +} + +// Payload fragments a H265 packet across one or more byte arrays +func (p *Payloader) Payload(mtu uint16, payload []byte) [][]byte { + var payloads [][]byte + if len(payload) == 0 { + return payloads + } + + bufferedNALUs := make([][]byte, 0) + aggregationBufferSize := 0 + + flushBufferedNals := func() { + if len(bufferedNALUs) == 0 { + return + } + if len(bufferedNALUs) == 1 { + // emit this as a single NALU packet + nalu := bufferedNALUs[0] + + if p.AddDONL { + buf := make([]byte, len(nalu)+2) + + // copy the NALU header to the payload header + copy(buf[0:h265NaluHeaderSize], nalu[0:h265NaluHeaderSize]) + + // copy the DONL into the header + binary.BigEndian.PutUint16(buf[h265NaluHeaderSize:h265NaluHeaderSize+2], p.donl) + + // write the payload + copy(buf[h265NaluHeaderSize+2:], nalu[h265NaluHeaderSize:]) + + p.donl++ + + payloads = append(payloads, buf) + } else { + // write the nalu directly to the payload + payloads = append(payloads, nalu) + } + } else { + // construct an aggregation packet + aggregationPacketSize := aggregationBufferSize + 2 + buf := make([]byte, aggregationPacketSize) + + layerID := uint8(math.MaxUint8) + tid := uint8(math.MaxUint8) + for _, nalu := range bufferedNALUs { + header := newH265NALUHeader(nalu[0], nalu[1]) + headerLayerID := header.LayerID() + headerTID := header.TID() + if headerLayerID < layerID { + layerID = headerLayerID + } + if headerTID < tid { + tid = headerTID + } + } + + binary.BigEndian.PutUint16(buf[0:2], (uint16(h265NaluAggregationPacketType)<<9)|(uint16(layerID)<<3)|uint16(tid)) + + index := 2 + for i, nalu := range bufferedNALUs { + if p.AddDONL { + if i == 0 { + binary.BigEndian.PutUint16(buf[index:index+2], p.donl) + index += 2 + } else { + buf[index] = byte(i - 1) + index++ + } + } + binary.BigEndian.PutUint16(buf[index:index+2], uint16(len(nalu))) + index += 2 + index += copy(buf[index:], nalu) + } + payloads = append(payloads, buf) + } + // clear the buffered NALUs + bufferedNALUs = make([][]byte, 0) + aggregationBufferSize = 0 + } + + h264.EmitNalus(payload, true, func(nalu []byte) { + if len(nalu) == 0 { + return + } + + if len(nalu) <= int(mtu) { + // this nalu fits into a single packet, either it can be emitted as + // a single nalu or appended to the previous aggregation packet + + marginalAggregationSize := len(nalu) + 2 + if p.AddDONL { + marginalAggregationSize += 1 + } + + if aggregationBufferSize+marginalAggregationSize > int(mtu) { + flushBufferedNals() + } + bufferedNALUs = append(bufferedNALUs, nalu) + aggregationBufferSize += marginalAggregationSize + if p.SkipAggregation { + // emit this immediately. + flushBufferedNals() + } + } else { + // if this nalu doesn't fit in the current mtu, it needs to be fragmented + fuPacketHeaderSize := h265FragmentationUnitHeaderSize + 2 /* payload header size */ + if p.AddDONL { + fuPacketHeaderSize += 2 + } + + // then, fragment the nalu + maxFUPayloadSize := int(mtu) - fuPacketHeaderSize + + naluHeader := newH265NALUHeader(nalu[0], nalu[1]) + + // the nalu header is omitted from the fragmentation packet payload + nalu = nalu[h265NaluHeaderSize:] + + if maxFUPayloadSize == 0 || len(nalu) == 0 { + return + } + + // flush any buffered aggregation packets. + flushBufferedNals() + + fullNALUSize := len(nalu) + for len(nalu) > 0 { + curentFUPayloadSize := len(nalu) + if curentFUPayloadSize > maxFUPayloadSize { + curentFUPayloadSize = maxFUPayloadSize + } + + out := make([]byte, fuPacketHeaderSize+curentFUPayloadSize) + + // write the payload header + binary.BigEndian.PutUint16(out[0:2], uint16(naluHeader)) + out[0] = (out[0] & 0b10000001) | h265NaluFragmentationUnitType<<1 + + // write the fragment header + out[2] = byte(H265FragmentationUnitHeader(naluHeader.Type())) + if len(nalu) == fullNALUSize { + // Set start bit + out[2] |= 1 << 7 + } else if len(nalu)-curentFUPayloadSize == 0 { + // Set end bit + out[2] |= 1 << 6 + } + + if p.AddDONL { + // write the DONL header + binary.BigEndian.PutUint16(out[3:5], p.donl) + + p.donl++ + + // copy the fragment payload + copy(out[5:], nalu[0:curentFUPayloadSize]) + } else { + // copy the fragment payload + copy(out[3:], nalu[0:curentFUPayloadSize]) + } + + // append the fragment to the payload + payloads = append(payloads, out) + + // advance the nalu data pointer + nalu = nalu[curentFUPayloadSize:] + } + } + }) + + flushBufferedNals() + + return payloads +} diff --git a/installs_on_host/go2rtc/pkg/h265/rtp.go b/installs_on_host/go2rtc/pkg/h265/rtp.go new file mode 100644 index 0000000..9c571ec --- /dev/null +++ b/installs_on_host/go2rtc/pkg/h265/rtp.go @@ -0,0 +1,221 @@ +package h265 + +import ( + "encoding/binary" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/pion/rtp" +) + +func RTPDepay(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc { + vps, sps, pps := GetParameterSet(codec.FmtpLine) + ps := h264.JoinNALU(vps, sps, pps) + + buf := make([]byte, 0, 512*1024) // 512K + var nuStart int + var seqNum uint16 + + return func(packet *rtp.Packet) { + data := packet.Payload + if len(data) < 3 { + return + } + + nuType := (data[0] >> 1) & 0x3F + //log.Printf("[RTP] codec: %s, nalu: %2d, size: %6d, ts: %10d, pt: %2d, ssrc: %d, seq: %d, %v", track.Codec.Name, nuType, len(packet.Payload), packet.Timestamp, packet.PayloadType, packet.SSRC, packet.SequenceNumber, packet.Marker) + + // Fix for RtspServer https://github.com/AlexxIT/go2rtc/issues/244 + if packet.Marker && len(data) < h264.PSMaxSize { + switch nuType { + case NALUTypeVPS, NALUTypeSPS, NALUTypePPS: + packet.Marker = false + case NALUTypePrefixSEI, NALUTypeSuffixSEI: + return + } + } + + // when we collect data into one buffer, we need to make sure + // that all of it falls into the same sequence + if len(buf) > 0 && packet.SequenceNumber-seqNum != 1 { + //log.Printf("broken H265 sequence") + buf = buf[:0] // drop data + return + } + + seqNum = packet.SequenceNumber + + if nuType == NALUTypeFU { + switch data[2] >> 6 { + case 0b10: // begin + nuType = data[2] & 0x3F + + // push PS data before keyframe + if len(buf) == 0 && nuType >= 19 && nuType <= 21 { + buf = append(buf, ps...) + } + + nuStart = len(buf) + buf = append(buf, 0, 0, 0, 0) // NAL unit size + buf = append(buf, (data[0]&0x81)|(nuType<<1), data[1]) + buf = append(buf, data[3:]...) + return + case 0b00: // continue + if len(buf) == 0 { + //log.Printf("broken H265 fragment") + return + } + + buf = append(buf, data[3:]...) + return + case 0b01: // end + if len(buf) == 0 { + //log.Printf("broken H265 fragment") + return + } + + buf = append(buf, data[3:]...) + + if nuStart > len(buf)+4 { + //log.Printf("broken H265 fragment") + buf = buf[:0] // drop data + return + } + + binary.BigEndian.PutUint32(buf[nuStart:], uint32(len(buf)-nuStart-4)) + case 0b11: // wrong RFC 7798 realisation from OpenIPC project + // A non-fragmented NAL unit MUST NOT be transmitted in one FU; i.e., + // the Start bit and End bit must not both be set to 1 in the same FU + // header. + nuType = data[2] & 0x3F + buf = binary.BigEndian.AppendUint32(buf, uint32(len(data))-1) // NAL unit size + buf = append(buf, (data[0]&0x81)|(nuType<<1), data[1]) + buf = append(buf, data[3:]...) + } + } else { + buf = binary.BigEndian.AppendUint32(buf, uint32(len(data))) // NAL unit size + buf = append(buf, data...) + } + + // collect all NAL Units for Access Unit + if !packet.Marker { + return + } + + //log.Printf("[HEVC] %v, len: %d", Types(buf), len(buf)) + + clone := *packet + clone.Version = h264.RTPPacketVersionAVC + clone.Payload = buf + + buf = buf[:0] + + handler(&clone) + } +} + +func RTPPay(mtu uint16, handler core.HandlerFunc) core.HandlerFunc { + if mtu == 0 { + mtu = 1472 + } + + payloader := &Payloader{} + sequencer := rtp.NewRandomSequencer() + mtu -= 12 // rtp.Header size + + return func(packet *rtp.Packet) { + if packet.Version != h264.RTPPacketVersionAVC { + handler(packet) + return + } + + payloads := payloader.Payload(mtu, packet.Payload) + last := len(payloads) - 1 + for i, payload := range payloads { + clone := rtp.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: i == last, + SequenceNumber: sequencer.NextSequenceNumber(), + Timestamp: packet.Timestamp, + }, + Payload: payload, + } + handler(&clone) + } + } +} + +// SafariPay - generate Safari friendly payload for H265 +// https://github.com/AlexxIT/Blog/issues/5 +func SafariPay(mtu uint16, handler core.HandlerFunc) core.HandlerFunc { + sequencer := rtp.NewRandomSequencer() + size := int(mtu - 12) // rtp.Header size + + return func(packet *rtp.Packet) { + if packet.Version != h264.RTPPacketVersionAVC { + handler(packet) + return + } + + // protect original packets from modification + au := make([]byte, len(packet.Payload)) + copy(au, packet.Payload) + + var start byte + + for i := 0; i < len(au); { + size := int(binary.BigEndian.Uint32(au[i:])) + 4 + + // convert AVC to Annex-B + au[i] = 0 + au[i+1] = 0 + au[i+2] = 0 + au[i+3] = 1 + + switch NALUType(au[i:]) { + case NALUTypeIFrame, NALUTypeIFrame2, NALUTypeIFrame3: + start = 3 + default: + if start == 0 { + start = 2 + } + } + + i += size + } + + // rtp.Packet payload + b := make([]byte, 1, size) + size-- // minus header byte + + for au != nil { + b[0] = start + + if start > 1 { + start -= 2 + } + + if len(au) > size { + b = append(b, au[:size]...) + au = au[size:] + } else { + b = append(b, au...) + au = nil + } + + clone := rtp.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: au == nil, + SequenceNumber: sequencer.NextSequenceNumber(), + Timestamp: packet.Timestamp, + }, + Payload: b, + } + handler(&clone) + + b = b[:1] // clear buffer + } + } +} diff --git a/installs_on_host/go2rtc/pkg/h265/sps.go b/installs_on_host/go2rtc/pkg/h265/sps.go new file mode 100644 index 0000000..5f61363 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/h265/sps.go @@ -0,0 +1,126 @@ +package h265 + +import ( + "bytes" + + "github.com/AlexxIT/go2rtc/pkg/bits" +) + +// http://www.itu.int/rec/T-REC-H.265 + +//goland:noinspection GoSnakeCaseUsage +type SPS struct { + sps_video_parameter_set_id uint8 + sps_max_sub_layers_minus1 uint8 + sps_temporal_id_nesting_flag byte + + general_profile_space uint8 + general_tier_flag byte + general_profile_idc uint8 + general_profile_compatibility_flags uint32 + + general_level_idc uint8 + sub_layer_profile_present_flag []byte + sub_layer_level_present_flag []byte + + sps_seq_parameter_set_id uint32 + chroma_format_idc uint32 + separate_colour_plane_flag byte + + pic_width_in_luma_samples uint32 + pic_height_in_luma_samples uint32 +} + +func (s *SPS) Width() uint16 { + return uint16(s.pic_width_in_luma_samples) +} + +func (s *SPS) Height() uint16 { + return uint16(s.pic_height_in_luma_samples) +} + +func DecodeSPS(nalu []byte) *SPS { + rbsp := bytes.ReplaceAll(nalu[2:], []byte{0, 0, 3}, []byte{0, 0}) + + r := bits.NewReader(rbsp) + s := &SPS{} + + s.sps_video_parameter_set_id = r.ReadBits8(4) + s.sps_max_sub_layers_minus1 = r.ReadBits8(3) + s.sps_temporal_id_nesting_flag = r.ReadBit() + + if !s.profile_tier_level(r) { + return nil + } + + s.sps_seq_parameter_set_id = r.ReadUEGolomb() + s.chroma_format_idc = r.ReadUEGolomb() + if s.chroma_format_idc == 3 { + s.separate_colour_plane_flag = r.ReadBit() + } + + s.pic_width_in_luma_samples = r.ReadUEGolomb() + s.pic_height_in_luma_samples = r.ReadUEGolomb() + + //... + + if r.EOF { + return nil + } + + return s +} + +// profile_tier_level supports ONLY general_profile_idc == 1 +// over variants very complicated... +// +//goland:noinspection GoSnakeCaseUsage +func (s *SPS) profile_tier_level(r *bits.Reader) bool { + s.general_profile_space = r.ReadBits8(2) + s.general_tier_flag = r.ReadBit() + s.general_profile_idc = r.ReadBits8(5) + + s.general_profile_compatibility_flags = r.ReadBits(32) + _ = r.ReadBits64(48) // other flags + + if s.general_profile_idc != 1 { + return false + } + + s.general_level_idc = r.ReadBits8(8) + + s.sub_layer_profile_present_flag = make([]byte, s.sps_max_sub_layers_minus1) + s.sub_layer_level_present_flag = make([]byte, s.sps_max_sub_layers_minus1) + + for i := byte(0); i < s.sps_max_sub_layers_minus1; i++ { + s.sub_layer_profile_present_flag[i] = r.ReadBit() + s.sub_layer_level_present_flag[i] = r.ReadBit() + } + + if s.sps_max_sub_layers_minus1 > 0 { + for i := s.sps_max_sub_layers_minus1; i < 8; i++ { + _ = r.ReadBits8(2) // reserved_zero_2bits + } + } + + for i := byte(0); i < s.sps_max_sub_layers_minus1; i++ { + if s.sub_layer_profile_present_flag[i] != 0 { + _ = r.ReadBits8(2) // sub_layer_profile_space + _ = r.ReadBit() // sub_layer_tier_flag + sub_layer_profile_idc := r.ReadBits8(5) // sub_layer_profile_idc + + _ = r.ReadBits(32) // sub_layer_profile_compatibility_flag + _ = r.ReadBits64(48) // other flags + + if sub_layer_profile_idc != 1 { + return false + } + } + + if s.sub_layer_level_present_flag[i] != 0 { + _ = r.ReadBits8(8) + } + } + + return true +} diff --git a/installs_on_host/go2rtc/pkg/hap/README.md b/installs_on_host/go2rtc/pkg/hap/README.md new file mode 100644 index 0000000..9d7fbf5 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hap/README.md @@ -0,0 +1,54 @@ +# Home Accessory Protocol + +> PS. Character = Characteristic + +**Device** - HomeKit end device (swith, camera, etc) + +- mDNS name: `MyCamera._hap._tcp.local.` +- DeviceID - mac-like: `0E:AA:CE:2B:35:71` +- HomeKit device is described by: + - one or more `Accessories` - has `AID` and `Services` + - `Services` - has `IID`, `Type` and `Characters` + - `Characters` - has `IID`, `Type`, `Format` and `Value` + +**Client** - HomeKit client (iPhone, iPad, MacBook or opensource library) + +- ClientID - static random UUID +- ClientPublic/ClientPrivate - static random 32 byte keypair +- can pair with Device (exchange ClientID/ClientPublic, ServerID/ServerPublic using Pin) +- can auth to Device using ClientPrivate +- holding persistant Secure connection to device +- can read device Accessories +- can read and write device Characters +- can subscribe on device Characters change (Event) + +**Server** - HomeKit server (soft on end device or opensource library) + +- ServerID - same as DeviceID (using for Client auth) +- ServerPublic/ServerPrivate - static random 32 byte keypair + +## AAC ELD + +Requires ffmpeg built with `--enable-libfdk-aac` + +``` +-acodec libfdk_aac -aprofile aac_eld +``` + +| SampleRate | RTPTime | constantDuration | objectType | +|------------|---------|--------------------|--------------| +| 8000 | 60 | =8000/1000*60=480 | 39 (AAC ELD) | +| 16000 | 30 | =16000/1000*30=480 | 39 (AAC ELD) | +| 24000 | 20 | =24000/1000*20=480 | 39 (AAC ELD) | +| 16000 | 60 | =16000/1000*60=960 | 23 (AAC LD) | +| 24000 | 40 | =24000/1000*40=960 | 23 (AAC LD) | + +## Useful links + +- https://github.com/apple/HomeKitADK/blob/master/Documentation/crypto.md +- https://github.com/apple/HomeKitADK/blob/master/HAP/HAPPairingPairSetup.c +- [Extracting HomeKit Pairing Keys](https://pvieito.com/2019/12/extract-homekit-pairing-keys) +- [HAP in AirPlay2 receiver](https://github.com/openairplay/airplay2-receiver/blob/master/ap2/pairing/hap.py) +- [HomeKit Secure Video Unofficial Specification](https://github.com/Supereg/secure-video-specification) +- [Homebridge Camera FFmpeg](https://sunoo.github.io/homebridge-camera-ffmpeg/configs/) +- https://github.com/ljezny/Particle-HAP/blob/master/HAP-Specification-Non-Commercial-Version.pdf \ No newline at end of file diff --git a/installs_on_host/go2rtc/pkg/hap/accessory.go b/installs_on_host/go2rtc/pkg/hap/accessory.go new file mode 100644 index 0000000..c659a24 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hap/accessory.go @@ -0,0 +1,176 @@ +package hap + +import ( + "fmt" + "strconv" +) + +const ( + FormatString = "string" + FormatBool = "bool" + FormatFloat = "float" + FormatUInt8 = "uint8" + FormatUInt16 = "uint16" + FormatUInt32 = "uint32" + FormatInt32 = "int32" + FormatUInt64 = "uint64" + FormatData = "data" + FormatTLV8 = "tlv8" + + UnitPercentage = "percentage" +) + +var PR = []string{"pr"} +var PW = []string{"pw"} +var PRPW = []string{"pr", "pw"} +var EVPRPW = []string{"ev", "pr", "pw"} +var EVPR = []string{"ev", "pr"} + +type Accessory struct { + AID uint8 `json:"aid"` // 150 unique accessories per bridge + Services []*Service `json:"services"` +} + +func (a *Accessory) InitIID() { + serviceN := map[string]byte{} + for _, service := range a.Services { + if len(service.Type) > 3 { + panic(service.Type) + } + + n := serviceN[service.Type] + 1 + serviceN[service.Type] = n + + if n > 15 { + panic(n) + } + + // ServiceID = ANSSS000 + s := fmt.Sprintf("%x%x%03s000", a.AID, n, service.Type) + service.IID, _ = strconv.ParseUint(s, 16, 64) + + for _, character := range service.Characters { + if len(character.Type) > 3 { + panic(character.Type) + } + + // CharacterID = ANSSSCCC + character.IID, _ = strconv.ParseUint(character.Type, 16, 64) + character.IID += service.IID + } + } +} + +func (a *Accessory) GetService(servType string) *Service { + for _, serv := range a.Services { + if serv.Type == servType { + return serv + } + } + return nil +} + +func (a *Accessory) GetCharacter(charType string) *Character { + for _, serv := range a.Services { + for _, char := range serv.Characters { + if char.Type == charType { + return char + } + } + } + return nil +} + +func (a *Accessory) GetCharacterByID(iid uint64) *Character { + for _, serv := range a.Services { + for _, char := range serv.Characters { + if char.IID == iid { + return char + } + } + } + return nil +} + +type Service struct { + Desc string `json:"description,omitempty"` + + Type string `json:"type"` + IID uint64 `json:"iid"` + Primary bool `json:"primary,omitempty"` + Characters []*Character `json:"characteristics"` + Linked []int `json:"linked,omitempty"` +} + +func (s *Service) GetCharacter(charType string) *Character { + for _, char := range s.Characters { + if char.Type == charType { + return char + } + } + return nil +} + +func ServiceAccessoryInformation(manuf, model, name, serial, firmware string) *Service { + return &Service{ + Type: "3E", // AccessoryInformation + Characters: []*Character{ + { + Type: "14", + Format: FormatBool, + Perms: PW, + //Descr: "Identify", + }, { + Type: "20", + Format: FormatString, + Value: manuf, + Perms: PR, + //Descr: "Manufacturer", + //MaxLen: 64, + }, { + Type: "21", + Format: FormatString, + Value: model, + Perms: PR, + //Descr: "Model", + //MaxLen: 64, + }, { + Type: "23", + Format: FormatString, + Value: name, + Perms: PR, + //Descr: "Name", + //MaxLen: 64, + }, { + Type: "30", + Format: FormatString, + Value: serial, + Perms: PR, + //Descr: "Serial Number", + //MaxLen: 64, + }, { + Type: "52", + Format: FormatString, + Value: firmware, + Perms: PR, + //Descr: "Firmware Revision", + }, + }, + } +} + +func ServiceHAPProtocolInformation() *Service { + return &Service{ + Type: "A2", // 'HAPProtocolInformation' + Characters: []*Character{ + { + Type: "37", + Format: FormatString, + Value: "1.1.0", + Perms: PR, + //Descr: "Version", + //MaxLen: 64, + }, + }, + } +} diff --git a/installs_on_host/go2rtc/pkg/hap/camera/README.md b/installs_on_host/go2rtc/pkg/hap/camera/README.md new file mode 100644 index 0000000..c6c6f23 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hap/camera/README.md @@ -0,0 +1,3 @@ +## Useful links + +- https://github.com/bauer-andreas/secure-video-specification diff --git a/installs_on_host/go2rtc/pkg/hap/camera/accessory.go b/installs_on_host/go2rtc/pkg/hap/camera/accessory.go new file mode 100644 index 0000000..3772449 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hap/camera/accessory.go @@ -0,0 +1,149 @@ +package camera + +import ( + "github.com/AlexxIT/go2rtc/pkg/hap" + "github.com/AlexxIT/go2rtc/pkg/hap/tlv8" +) + +func NewAccessory(manuf, model, name, serial, firmware string) *hap.Accessory { + acc := &hap.Accessory{ + AID: hap.DeviceAID, + Services: []*hap.Service{ + hap.ServiceAccessoryInformation(manuf, model, name, serial, firmware), + ServiceCameraRTPStreamManagement(), + //hap.ServiceHAPProtocolInformation(), + ServiceMicrophone(), + }, + } + acc.InitIID() + return acc +} + +func ServiceMicrophone() *hap.Service { + return &hap.Service{ + Type: "112", // 'Microphone' + Characters: []*hap.Character{ + { + Type: "11A", + Format: hap.FormatBool, + Value: 0, + Perms: hap.EVPRPW, + //Descr: "Mute", + }, + //{ + // Type: "119", + // Format: hap.FormatUInt8, + // Value: 100, + // Perms: hap.EVPRPW, + // //Descr: "Volume", + // //Unit: hap.UnitPercentage, + // //MinValue: 0, + // //MaxValue: 100, + // //MinStep: 1, + //}, + }, + } +} + +func ServiceCameraRTPStreamManagement() *hap.Service { + val120, _ := tlv8.MarshalBase64(StreamingStatus{ + Status: StreamingStatusAvailable, + }) + val114, _ := tlv8.MarshalBase64(SupportedVideoStreamConfiguration{ + Codecs: []VideoCodecConfiguration{ + { + CodecType: VideoCodecTypeH264, + CodecParams: []VideoCodecParameters{ + { + ProfileID: []byte{VideoCodecProfileMain}, + Level: []byte{VideoCodecLevel31, VideoCodecLevel40}, + }, + }, + VideoAttrs: []VideoCodecAttributes{ + {Width: 1920, Height: 1080, Framerate: 30}, + {Width: 1280, Height: 720, Framerate: 30}, // important for iPhones + {Width: 320, Height: 240, Framerate: 15}, // apple watch + }, + }, + }, + }) + val115, _ := tlv8.MarshalBase64(SupportedAudioStreamConfiguration{ + Codecs: []AudioCodecConfiguration{ + { + CodecType: AudioCodecTypeOpus, + CodecParams: []AudioCodecParameters{ + { + Channels: 1, + BitrateMode: AudioCodecBitrateVariable, + SampleRate: []byte{AudioCodecSampleRate16Khz}, + }, + }, + }, + }, + ComfortNoiseSupport: 0, + }) + val116, _ := tlv8.MarshalBase64(SupportedRTPConfiguration{ + SRTPCryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80}, + }) + + service := &hap.Service{ + Type: "110", // 'CameraRTPStreamManagement' + Characters: []*hap.Character{ + { + Type: TypeStreamingStatus, + Format: hap.FormatTLV8, + Value: val120, + Perms: hap.EVPR, + //Descr: "Streaming Status", + }, + { + Type: TypeSupportedVideoStreamConfiguration, + Format: hap.FormatTLV8, + Value: val114, + Perms: hap.PR, + //Descr: "Supported Video Stream Configuration", + }, + { + Type: TypeSupportedAudioStreamConfiguration, + Format: hap.FormatTLV8, + Value: val115, + Perms: hap.PR, + //Descr: "Supported Audio Stream Configuration", + }, + { + Type: TypeSupportedRTPConfiguration, + Format: hap.FormatTLV8, + Value: val116, + Perms: hap.PR, + //Descr: "Supported RTP Configuration", + }, + { + Type: "B0", + Format: hap.FormatUInt8, + Value: 1, + Perms: hap.EVPRPW, + //Descr: "Active", + //MinValue: 0, + //MaxValue: 1, + //MinStep: 1, + //ValidVal: []any{0, 1}, + }, + { + Type: TypeSelectedStreamConfiguration, + Format: hap.FormatTLV8, + Value: "", // important empty + Perms: hap.PRPW, + //Descr: "Selected RTP Stream Configuration", + }, + { + Type: TypeSetupEndpoints, + Format: hap.FormatTLV8, + Value: "", // important empty + Perms: hap.PRPW, + //Descr: "Setup Endpoints", + }, + }, + } + + return service +} diff --git a/installs_on_host/go2rtc/pkg/hap/camera/accessory_test.go b/installs_on_host/go2rtc/pkg/hap/camera/accessory_test.go new file mode 100644 index 0000000..53c99a4 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hap/camera/accessory_test.go @@ -0,0 +1,254 @@ +package camera + +import ( + "encoding/base64" + "strings" + "testing" + + "github.com/AlexxIT/go2rtc/pkg/hap" + "github.com/stretchr/testify/require" +) + +func TestNilCharacter(t *testing.T) { + var res SetupEndpoints + char := &hap.Character{} + err := char.ReadTLV8(&res) + require.NotNil(t, err) + require.NotNil(t, strings.Contains(err.Error(), "can't read value")) +} + +type testTLV8 struct { + name string + value string + actual any + expect any + noequal bool +} + +func (test testTLV8) run(t *testing.T) { + if test.actual == nil { + return + } + + src := &hap.Character{Value: test.value, Format: hap.FormatTLV8} + err := src.ReadTLV8(test.actual) + require.Nil(t, err) + + require.Equal(t, test.expect, test.actual) + + dst := &hap.Character{Format: hap.FormatTLV8} + err = dst.Write(test.actual) + require.Nil(t, err) + + a, _ := base64.StdEncoding.DecodeString(test.value) + b, _ := base64.StdEncoding.DecodeString(dst.Value.(string)) + t.Logf("%x\n", a) + t.Logf("%x\n", b) + + if !test.noequal { + require.Equal(t, test.value, dst.Value) + } +} + +func TestAqaraG3(t *testing.T) { + tests := []testTLV8{ + { + name: "120", + value: "AQEA", + actual: &StreamingStatus{}, + expect: &StreamingStatus{ + Status: StreamingStatusAvailable, + }, + }, + { + name: "114", + value: "AaoBAQACEQEBAQIBAAAAAgECAwEABAEAAwsBAoAHAgI4BAMBHgAAAwsBAgAFAgLQAgMBHgAAAwsBAoACAgJoAQMBHgAAAwsBAuABAgIOAQMBHgAAAwsBAkABAgK0AAMBHgAAAwsBAgAFAgLAAwMBHgAAAwsBAgAEAgIAAwMBHgAAAwsBAoACAgLgAQMBHgAAAwsBAuABAgJoAQMBHgAAAwsBAkABAgLwAAMBHg==", + actual: &SupportedVideoStreamConfiguration{}, + expect: &SupportedVideoStreamConfiguration{ + Codecs: []VideoCodecConfiguration{ + { + CodecType: VideoCodecTypeH264, + CodecParams: []VideoCodecParameters{ + { + ProfileID: []byte{VideoCodecProfileMain}, + Level: []byte{VideoCodecLevel31, VideoCodecLevel40}, + CVOEnabled: []byte{0}, + }, + }, + VideoAttrs: []VideoCodecAttributes{ + {Width: 1920, Height: 1080, Framerate: 30}, + {Width: 1280, Height: 720, Framerate: 30}, + {Width: 640, Height: 360, Framerate: 30}, + {Width: 480, Height: 270, Framerate: 30}, + {Width: 320, Height: 180, Framerate: 30}, + {Width: 1280, Height: 960, Framerate: 30}, + {Width: 1024, Height: 768, Framerate: 30}, + {Width: 640, Height: 480, Framerate: 30}, + {Width: 480, Height: 360, Framerate: 30}, + {Width: 320, Height: 240, Framerate: 30}, + }, + }, + }, + }, + }, + { + name: "115", + value: "AQ4BAQICCQEBAQIBAAMBAQIBAA==", + actual: &SupportedAudioStreamConfiguration{}, + expect: &SupportedAudioStreamConfiguration{ + Codecs: []AudioCodecConfiguration{ + { + CodecType: AudioCodecTypeAACELD, + CodecParams: []AudioCodecParameters{ + { + Channels: 1, + BitrateMode: AudioCodecBitrateVariable, + SampleRate: []byte{AudioCodecSampleRate16Khz}, + }, + }, + }, + }, + ComfortNoiseSupport: 0, + }, + }, + { + name: "116", + value: "AgEAAAACAQEAAAIBAg==", + actual: &SupportedRTPConfiguration{}, + expect: &SupportedRTPConfiguration{ + SRTPCryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80, CryptoAES_CM_256_HMAC_SHA1_80, CryptoDisabled}, + }, + }, + } + for _, test := range tests { + t.Run(test.name, test.run) + } +} + +func TestHomebridge(t *testing.T) { + tests := []testTLV8{ + { + name: "114", + value: "AcUBAQACHQEBAAAAAQEBAAABAQICAQAAAAIBAQAAAgECAwEAAwsBAkABAgK0AAMBHgAAAwsBAkABAgLwAAMBDwAAAwsBAkABAgLwAAMBHgAAAwsBAuABAgIOAQMBHgAAAwsBAuABAgJoAQMBHgAAAwsBAoACAgJoAQMBHgAAAwsBAoACAgLgAQMBHgAAAwsBAgAFAgLQAgMBHgAAAwsBAgAFAgLAAwMBHgAAAwsBAoAHAgI4BAMBHgAAAwsBAkAGAgKwBAMBHg==", + actual: &SupportedVideoStreamConfiguration{}, + expect: &SupportedVideoStreamConfiguration{ + Codecs: []VideoCodecConfiguration{ + { + CodecType: VideoCodecTypeH264, + CodecParams: []VideoCodecParameters{ + { + ProfileID: []byte{VideoCodecProfileConstrainedBaseline, VideoCodecProfileMain, VideoCodecProfileHigh}, + Level: []byte{VideoCodecLevel31, VideoCodecLevel32, VideoCodecLevel40}, + }, + }, + VideoAttrs: []VideoCodecAttributes{ + + {Width: 320, Height: 180, Framerate: 30}, + {Width: 320, Height: 240, Framerate: 15}, + {Width: 320, Height: 240, Framerate: 30}, + {Width: 480, Height: 270, Framerate: 30}, + {Width: 480, Height: 360, Framerate: 30}, + {Width: 640, Height: 360, Framerate: 30}, + {Width: 640, Height: 480, Framerate: 30}, + {Width: 1280, Height: 720, Framerate: 30}, + {Width: 1280, Height: 960, Framerate: 30}, + {Width: 1920, Height: 1080, Framerate: 30}, + {Width: 1600, Height: 1200, Framerate: 30}, + }, + }, + }, + }, + }, + { + name: "116", + value: "AgEA", + actual: &SupportedRTPConfiguration{}, + expect: &SupportedRTPConfiguration{ + SRTPCryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80}, + }, + }, + } + for _, test := range tests { + t.Run(test.name, test.run) + } +} + +func TestScrypted(t *testing.T) { + tests := []testTLV8{ + { + name: "114", + value: "AVIBAQACEwEBAQIBAAAAAgEBAAACAQIDAQADCwECAA8CAnAIAwEeAAADCwECgAcCAjgEAwEeAAADCwECAAUCAtACAwEeAAADCwECQAECAvAAAwEP", + actual: &SupportedVideoStreamConfiguration{}, + expect: &SupportedVideoStreamConfiguration{ + Codecs: []VideoCodecConfiguration{ + { + CodecType: VideoCodecTypeH264, + CodecParams: []VideoCodecParameters{ + { + ProfileID: []byte{VideoCodecProfileMain}, + Level: []byte{VideoCodecLevel31, VideoCodecLevel32, VideoCodecLevel40}, + }, + }, + VideoAttrs: []VideoCodecAttributes{ + {Width: 3840, Height: 2160, Framerate: 30}, + {Width: 1920, Height: 1080, Framerate: 30}, + {Width: 1280, Height: 720, Framerate: 30}, + {Width: 320, Height: 240, Framerate: 15}, + }, + }, + }, + }, + }, + { + name: "115", + value: "AScBAQMCIgEBAQIBAAMBAAAAAwEAAAADAQEAAAMBAQAAAwECAAADAQICAQA=", + actual: &SupportedAudioStreamConfiguration{}, + expect: &SupportedAudioStreamConfiguration{ + Codecs: []AudioCodecConfiguration{ + { + CodecType: AudioCodecTypeOpus, + CodecParams: []AudioCodecParameters{ + { + Channels: 1, + BitrateMode: AudioCodecBitrateVariable, + SampleRate: []byte{ + AudioCodecSampleRate8Khz, AudioCodecSampleRate8Khz, + AudioCodecSampleRate16Khz, AudioCodecSampleRate16Khz, + AudioCodecSampleRate24Khz, AudioCodecSampleRate24Khz, + }, + }, + }, + }, + }, + ComfortNoiseSupport: 0, + }, + }, + { + name: "116", + value: "AgEAAAACAQI=", + actual: &SupportedRTPConfiguration{}, + expect: &SupportedRTPConfiguration{ + SRTPCryptoType: []byte{CryptoAES_CM_128_HMAC_SHA1_80, CryptoDisabled}, + }, + }, + } + for _, test := range tests { + t.Run(test.name, test.run) + } +} + +func TestHass(t *testing.T) { + tests := []testTLV8{ + { + name: "114", + value: "AdABAQACFQMBAAEBAAEBAQEBAgIBAAIBAQIBAgMMAQJAAQICtAADAg8AAwwBAkABAgLwAAMCDwADDAECQAECArQAAwIeAAMMAQJAAQIC8AADAh4AAwwBAuABAgIOAQMCHgADDAEC4AECAmgBAwIeAAMMAQKAAgICaAEDAh4AAwwBAoACAgLgAQMCHgADDAECAAQCAkACAwIeAAMMAQIABAICAAMDAh4AAwwBAgAFAgLQAgMCHgADDAECAAUCAsADAwIeAAMMAQKABwICOAQDAh4A", + }, + { + name: "115", + value: "AQ4BAQMCCQEBAQIBAAMBAgEOAQEDAgkBAQECAQADAQECAQA=", + }, + } + for _, test := range tests { + t.Run(test.name, test.run) + } +} diff --git a/installs_on_host/go2rtc/pkg/hap/camera/ch114_supported_video.go b/installs_on_host/go2rtc/pkg/hap/camera/ch114_supported_video.go new file mode 100644 index 0000000..ec70dc6 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hap/camera/ch114_supported_video.go @@ -0,0 +1,46 @@ +package camera + +const TypeSupportedVideoStreamConfiguration = "114" + +type SupportedVideoStreamConfiguration struct { + Codecs []VideoCodecConfiguration `tlv8:"1"` +} + +type VideoCodecConfiguration struct { + CodecType byte `tlv8:"1"` + CodecParams []VideoCodecParameters `tlv8:"2"` + VideoAttrs []VideoCodecAttributes `tlv8:"3"` + RTPParams []RTPParams `tlv8:"4"` +} + +//goland:noinspection ALL +const ( + VideoCodecTypeH264 = 0 + + VideoCodecProfileConstrainedBaseline = 0 + VideoCodecProfileMain = 1 + VideoCodecProfileHigh = 2 + + VideoCodecLevel31 = 0 + VideoCodecLevel32 = 1 + VideoCodecLevel40 = 2 + + VideoCodecPacketizationModeNonInterleaved = 0 + + VideoCodecCvoNotSuppported = 0 + VideoCodecCvoSuppported = 1 +) + +type VideoCodecParameters struct { + ProfileID []byte `tlv8:"1"` // 0 - baseline, 1 - main, 2 - high + Level []byte `tlv8:"2"` // 0 - 3.1, 1 - 3.2, 2 - 4.0 + PacketizationMode byte `tlv8:"3"` // only 0 - non interleaved + CVOEnabled []byte `tlv8:"4"` // 0 - not supported, 1 - supported + CVOID []byte `tlv8:"5"` // ID for CVO RTP extensio +} + +type VideoCodecAttributes struct { + Width uint16 `tlv8:"1"` + Height uint16 `tlv8:"2"` + Framerate uint8 `tlv8:"3"` +} diff --git a/installs_on_host/go2rtc/pkg/hap/camera/ch115_supported_audio.go b/installs_on_host/go2rtc/pkg/hap/camera/ch115_supported_audio.go new file mode 100644 index 0000000..f7ba9b4 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hap/camera/ch115_supported_audio.go @@ -0,0 +1,46 @@ +package camera + +const TypeSupportedAudioStreamConfiguration = "115" + +type SupportedAudioStreamConfiguration struct { + Codecs []AudioCodecConfiguration `tlv8:"1"` + ComfortNoiseSupport byte `tlv8:"2"` +} + +//goland:noinspection ALL +const ( + AudioCodecTypePCMU = 0 + AudioCodecTypePCMA = 1 + AudioCodecTypeAACELD = 2 + AudioCodecTypeOpus = 3 + AudioCodecTypeMSBC = 4 + AudioCodecTypeAMR = 5 + AudioCodecTypeARMWB = 6 + + AudioCodecBitrateVariable = 0 + AudioCodecBitrateConstant = 1 + + AudioCodecSampleRate8Khz = 0 + AudioCodecSampleRate16Khz = 1 + AudioCodecSampleRate24Khz = 2 + + RTPTimeAACELD8 = 60 // 8000/1000*60=480 + RTPTimeAACELD16 = 30 // 16000/1000*30=480 + RTPTimeAACELD24 = 20 // 24000/1000*20=480 + RTPTimeAACLD16 = 60 // 16000/1000*60=960 + RTPTimeAACLD24 = 40 // 24000/1000*40=960 +) + +type AudioCodecConfiguration struct { + CodecType byte `tlv8:"1"` + CodecParams []AudioCodecParameters `tlv8:"2"` + RTPParams []RTPParams `tlv8:"3"` + ComfortNoise []byte `tlv8:"4"` +} + +type AudioCodecParameters struct { + Channels uint8 `tlv8:"1"` + BitrateMode byte `tlv8:"2"` // 0 - variable, 1 - constant + SampleRate []byte `tlv8:"3"` // 0 - 8000, 1 - 16000, 2 - 24000 + RTPTime []uint8 `tlv8:"4"` // 20, 30, 40, 60 +} diff --git a/installs_on_host/go2rtc/pkg/hap/camera/ch116_supported_rtp.go b/installs_on_host/go2rtc/pkg/hap/camera/ch116_supported_rtp.go new file mode 100644 index 0000000..f0ca0db --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hap/camera/ch116_supported_rtp.go @@ -0,0 +1,14 @@ +package camera + +const TypeSupportedRTPConfiguration = "116" + +//goland:noinspection ALL +const ( + CryptoAES_CM_128_HMAC_SHA1_80 = 0 + CryptoAES_CM_256_HMAC_SHA1_80 = 1 + CryptoDisabled = 2 +) + +type SupportedRTPConfiguration struct { + SRTPCryptoType []byte `tlv8:"2"` +} diff --git a/installs_on_host/go2rtc/pkg/hap/camera/ch117_selected_stream.go b/installs_on_host/go2rtc/pkg/hap/camera/ch117_selected_stream.go new file mode 100644 index 0000000..d94ba96 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hap/camera/ch117_selected_stream.go @@ -0,0 +1,32 @@ +package camera + +const TypeSelectedStreamConfiguration = "117" + +type SelectedStreamConfiguration struct { + Control SessionControl `tlv8:"1"` + VideoCodec VideoCodecConfiguration `tlv8:"2"` + AudioCodec AudioCodecConfiguration `tlv8:"3"` +} + +//goland:noinspection ALL +const ( + SessionCommandEnd = 0 + SessionCommandStart = 1 + SessionCommandSuspend = 2 + SessionCommandResume = 3 + SessionCommandReconfigure = 4 +) + +type SessionControl struct { + SessionID string `tlv8:"1"` + Command byte `tlv8:"2"` +} + +type RTPParams struct { + PayloadType uint8 `tlv8:"1"` + SSRC uint32 `tlv8:"2"` + MaxBitrate uint16 `tlv8:"3"` + RTCPInterval float32 `tlv8:"4"` + MaxMTU []uint16 `tlv8:"5"` + ComfortNoisePayloadType []uint8 `tlv8:"6"` +} diff --git a/installs_on_host/go2rtc/pkg/hap/camera/ch118_setup_endpoints.go b/installs_on_host/go2rtc/pkg/hap/camera/ch118_setup_endpoints.go new file mode 100644 index 0000000..e0f426c --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hap/camera/ch118_setup_endpoints.go @@ -0,0 +1,33 @@ +package camera + +const TypeSetupEndpoints = "118" + +type SetupEndpointsRequest struct { + SessionID string `tlv8:"1"` + Address Address `tlv8:"3"` + VideoCrypto SRTPCryptoSuite `tlv8:"4"` + AudioCrypto SRTPCryptoSuite `tlv8:"5"` +} + +type SetupEndpointsResponse struct { + SessionID string `tlv8:"1"` + Status byte `tlv8:"2"` + Address Address `tlv8:"3"` + VideoCrypto SRTPCryptoSuite `tlv8:"4"` + AudioCrypto SRTPCryptoSuite `tlv8:"5"` + VideoSSRC uint32 `tlv8:"6"` + AudioSSRC uint32 `tlv8:"7"` +} + +type Address struct { + IPVersion byte `tlv8:"1"` + IPAddr string `tlv8:"2"` + VideoRTPPort uint16 `tlv8:"3"` + AudioRTPPort uint16 `tlv8:"4"` +} + +type SRTPCryptoSuite struct { + CryptoSuite byte `tlv8:"1"` + MasterKey string `tlv8:"2"` // 16 (AES_CM_128) or 32 (AES_256_CM) + MasterSalt string `tlv8:"3"` // 14 byte +} diff --git a/installs_on_host/go2rtc/pkg/hap/camera/ch120_streaming_status.go b/installs_on_host/go2rtc/pkg/hap/camera/ch120_streaming_status.go new file mode 100644 index 0000000..e617df2 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hap/camera/ch120_streaming_status.go @@ -0,0 +1,14 @@ +package camera + +const TypeStreamingStatus = "120" + +type StreamingStatus struct { + Status byte `tlv8:"1"` +} + +//goland:noinspection ALL +const ( + StreamingStatusAvailable = 0 + StreamingStatusInUse = 1 + StreamingStatusUnavailable = 2 +) diff --git a/installs_on_host/go2rtc/pkg/hap/camera/ch130_data_stream_transport.go b/installs_on_host/go2rtc/pkg/hap/camera/ch130_data_stream_transport.go new file mode 100644 index 0000000..808f822 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hap/camera/ch130_data_stream_transport.go @@ -0,0 +1,11 @@ +package camera + +const TypeSupportedDataStreamTransportConfiguration = "130" + +type SupportedDataStreamTransportConfiguration struct { + Configs []TransferTransportConfiguration `tlv8:"1"` +} + +type TransferTransportConfiguration struct { + TransportType byte `tlv8:"1"` +} diff --git a/installs_on_host/go2rtc/pkg/hap/camera/ch131_data_stream.go b/installs_on_host/go2rtc/pkg/hap/camera/ch131_data_stream.go new file mode 100644 index 0000000..4f4ab49 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hap/camera/ch131_data_stream.go @@ -0,0 +1,17 @@ +package camera + +const TypeSetupDataStreamTransport = "131" + +type SetupDataStreamTransportRequest struct { + SessionCommandType byte `tlv8:"1"` + TransportType byte `tlv8:"2"` + ControllerKeySalt string `tlv8:"3"` +} + +type SetupDataStreamTransportResponse struct { + Status byte `tlv8:"1"` + TransportTypeSessionParameters struct { + TCPListeningPort uint16 `tlv8:"1"` + } `tlv8:"2"` + AccessoryKeySalt string `tlv8:"3"` +} diff --git a/installs_on_host/go2rtc/pkg/hap/camera/ch205.go b/installs_on_host/go2rtc/pkg/hap/camera/ch205.go new file mode 100644 index 0000000..431db7b --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hap/camera/ch205.go @@ -0,0 +1,18 @@ +package camera + +const TypeSupportedCameraRecordingConfiguration = "205" + +type SupportedCameraRecordingConfiguration struct { + PrebufferLength uint32 `tlv8:"1"` + EventTriggerOptions uint64 `tlv8:"2"` + MediaContainerConfigurations `tlv8:"3"` +} + +type MediaContainerConfigurations struct { + MediaContainerType uint8 `tlv8:"1"` + MediaContainerParameters `tlv8:"2"` +} + +type MediaContainerParameters struct { + FragmentLength uint32 `tlv8:"1"` +} diff --git a/installs_on_host/go2rtc/pkg/hap/camera/ch206.go b/installs_on_host/go2rtc/pkg/hap/camera/ch206.go new file mode 100644 index 0000000..89219fa --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hap/camera/ch206.go @@ -0,0 +1,20 @@ +package camera + +const TypeSupportedVideoRecordingConfiguration = "206" + +type SupportedVideoRecordingConfiguration struct { + CodecConfigs []VideoRecordingCodecConfiguration `tlv8:"1"` +} + +type VideoRecordingCodecConfiguration struct { + CodecType uint8 `tlv8:"1"` + CodecParams VideoRecordingCodecParameters `tlv8:"2"` + CodecAttrs VideoCodecAttributes `tlv8:"3"` +} + +type VideoRecordingCodecParameters struct { + ProfileID uint8 `tlv8:"1"` + Level uint8 `tlv8:"2"` + Bitrate uint32 `tlv8:"3"` + IFrameInterval uint32 `tlv8:"4"` +} diff --git a/installs_on_host/go2rtc/pkg/hap/camera/ch207.go b/installs_on_host/go2rtc/pkg/hap/camera/ch207.go new file mode 100644 index 0000000..5d38992 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hap/camera/ch207.go @@ -0,0 +1,19 @@ +package camera + +const TypeSupportedAudioRecordingConfiguration = "207" + +type SupportedAudioRecordingConfiguration struct { + CodecConfigs []AudioRecordingCodecConfiguration `tlv8:"1"` +} + +type AudioRecordingCodecConfiguration struct { + CodecType byte `tlv8:"1"` + CodecParams []AudioRecordingCodecParameters `tlv8:"2"` +} + +type AudioRecordingCodecParameters struct { + Channels uint8 `tlv8:"1"` + BitrateMode []byte `tlv8:"2"` + SampleRate []byte `tlv8:"3"` + MaxAudioBitrate []uint32 `tlv8:"4"` +} diff --git a/installs_on_host/go2rtc/pkg/hap/camera/ch209.go b/installs_on_host/go2rtc/pkg/hap/camera/ch209.go new file mode 100644 index 0000000..c51359f --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hap/camera/ch209.go @@ -0,0 +1,9 @@ +package camera + +const TypeSelectedCameraRecordingConfiguration = "209" + +type SelectedCameraRecordingConfiguration struct { + GeneralConfig SupportedCameraRecordingConfiguration `tlv8:"1"` + VideoConfig SupportedVideoRecordingConfiguration `tlv8:"2"` + AudioConfig SupportedAudioRecordingConfiguration `tlv8:"3"` +} diff --git a/installs_on_host/go2rtc/pkg/hap/camera/stream.go b/installs_on_host/go2rtc/pkg/hap/camera/stream.go new file mode 100644 index 0000000..bda6792 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hap/camera/stream.go @@ -0,0 +1,184 @@ +package camera + +import ( + "errors" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/hap" + "github.com/AlexxIT/go2rtc/pkg/srtp" +) + +type Stream struct { + id string + client *hap.Client + service *hap.Service +} + +func NewStream( + client *hap.Client, videoCodec *VideoCodecConfiguration, audioCodec *AudioCodecConfiguration, + videoSession, audioSession *srtp.Session, bitrate int, +) (*Stream, error) { + stream := &Stream{ + id: core.RandString(16, 0), + client: client, + } + + if err := stream.GetFreeStream(); err != nil { + return nil, err + } + + if err := stream.ExchangeEndpoints(videoSession, audioSession); err != nil { + return nil, err + } + + if bitrate != 0 { + bitrate /= 1024 // convert bps to kbps + } else { + bitrate = 4096 // default kbps for general FullHD camera + } + + videoCodec.RTPParams = []RTPParams{ + { + PayloadType: 99, + SSRC: videoSession.Local.SSRC, + MaxBitrate: uint16(bitrate), // iPhone query 299Kbps, iPad/AppleTV query 802Kbps + RTCPInterval: 0.5, + MaxMTU: []uint16{1378}, + }, + } + audioCodec.RTPParams = []RTPParams{ + { + PayloadType: 110, + SSRC: audioSession.Local.SSRC, + MaxBitrate: 24, // any iDevice query 24Kbps (this is OK for 16KHz and 1 channel) + RTCPInterval: 5, + + ComfortNoisePayloadType: []uint8{13}, + }, + } + audioCodec.ComfortNoise = []byte{0} + + config := &SelectedStreamConfiguration{ + Control: SessionControl{ + SessionID: stream.id, + Command: SessionCommandStart, + }, + VideoCodec: *videoCodec, + AudioCodec: *audioCodec, + } + + if err := stream.SetStreamConfig(config); err != nil { + return nil, err + } + + return stream, nil +} + +// GetFreeStream search free streaming service. +// Usual every HomeKit camera can stream only to two clients simultaniosly. +// So it has two similar services for streaming. +func (s *Stream) GetFreeStream() error { + acc, err := s.client.GetFirstAccessory() + if err != nil { + return err + } + + for _, srv := range acc.Services { + for _, char := range srv.Characters { + if char.Type == TypeStreamingStatus { + var status StreamingStatus + if err = char.ReadTLV8(&status); err != nil { + return err + } + + if status.Status == StreamingStatusAvailable { + s.service = srv + return nil + } + } + } + } + + return errors.New("hap: no free streams") +} + +func (s *Stream) ExchangeEndpoints(videoSession, audioSession *srtp.Session) error { + req := SetupEndpointsRequest{ + SessionID: s.id, + Address: Address{ + IPVersion: 0, + IPAddr: videoSession.Local.Addr, + VideoRTPPort: videoSession.Local.Port, + AudioRTPPort: audioSession.Local.Port, + }, + VideoCrypto: SRTPCryptoSuite{ + MasterKey: string(videoSession.Local.MasterKey), + MasterSalt: string(videoSession.Local.MasterSalt), + }, + AudioCrypto: SRTPCryptoSuite{ + MasterKey: string(audioSession.Local.MasterKey), + MasterSalt: string(audioSession.Local.MasterSalt), + }, + } + + char := s.service.GetCharacter(TypeSetupEndpoints) + if err := char.Write(&req); err != nil { + return err + } + if err := s.client.PutCharacters(char); err != nil { + return err + } + + var res SetupEndpointsResponse + if err := s.client.GetCharacter(char); err != nil { + return err + } + if err := char.ReadTLV8(&res); err != nil { + return err + } + + videoSession.Remote = &srtp.Endpoint{ + Addr: res.Address.IPAddr, + Port: res.Address.VideoRTPPort, + MasterKey: []byte(res.VideoCrypto.MasterKey), + MasterSalt: []byte(res.VideoCrypto.MasterSalt), + SSRC: res.VideoSSRC, + } + + audioSession.Remote = &srtp.Endpoint{ + Addr: res.Address.IPAddr, + Port: res.Address.AudioRTPPort, + MasterKey: []byte(res.AudioCrypto.MasterKey), + MasterSalt: []byte(res.AudioCrypto.MasterSalt), + SSRC: res.AudioSSRC, + } + + return nil +} + +func (s *Stream) SetStreamConfig(config *SelectedStreamConfiguration) error { + char := s.service.GetCharacter(TypeSelectedStreamConfiguration) + if err := char.Write(config); err != nil { + return err + } + if err := s.client.PutCharacters(char); err != nil { + return err + } + + return s.client.GetCharacter(char) +} + +func (s *Stream) Close() error { + config := &SelectedStreamConfiguration{ + Control: SessionControl{ + SessionID: s.id, + Command: SessionCommandEnd, + }, + } + + char := s.service.GetCharacter(TypeSelectedStreamConfiguration) + if err := char.Write(config); err != nil { + return err + } + return s.client.PutCharacters(char) +} diff --git a/installs_on_host/go2rtc/pkg/hap/chacha20poly1305/chacha20poly1305.go b/installs_on_host/go2rtc/pkg/hap/chacha20poly1305/chacha20poly1305.go new file mode 100644 index 0000000..27a35d4 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hap/chacha20poly1305/chacha20poly1305.go @@ -0,0 +1,51 @@ +package chacha20poly1305 + +import ( + "errors" + + "golang.org/x/crypto/chacha20poly1305" +) + +var ErrInvalidParams = errors.New("chacha20poly1305: invalid params") + +// Decrypt - decrypt without verify +func Decrypt(key32 []byte, nonce8 string, ciphertext []byte) ([]byte, error) { + return DecryptAndVerify(key32, nil, []byte(nonce8), ciphertext, nil) +} + +// Encrypt - encrypt without seal +func Encrypt(key32 []byte, nonce8 string, plaintext []byte) ([]byte, error) { + return EncryptAndSeal(key32, nil, []byte(nonce8), plaintext, nil) +} + +func DecryptAndVerify(key32, dst, nonce8, ciphertext, verify []byte) ([]byte, error) { + if len(key32) != chacha20poly1305.KeySize || len(nonce8) != 8 { + return nil, ErrInvalidParams + } + + aead, err := chacha20poly1305.New(key32) + if err != nil { + return nil, err + } + + nonce := make([]byte, chacha20poly1305.NonceSize) + copy(nonce[4:], nonce8) + + return aead.Open(dst, nonce, ciphertext, verify) +} + +func EncryptAndSeal(key32, dst, nonce8, plaintext, verify []byte) ([]byte, error) { + if len(key32) != chacha20poly1305.KeySize || len(nonce8) != 8 { + return nil, ErrInvalidParams + } + + aead, err := chacha20poly1305.New(key32) + if err != nil { + return nil, err + } + + nonce := make([]byte, chacha20poly1305.NonceSize) + copy(nonce[4:], nonce8) + + return aead.Seal(dst, nonce, plaintext, verify), nil +} diff --git a/installs_on_host/go2rtc/pkg/hap/character.go b/installs_on_host/go2rtc/pkg/hap/character.go new file mode 100644 index 0000000..afa321e --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hap/character.go @@ -0,0 +1,149 @@ +package hap + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/AlexxIT/go2rtc/pkg/hap/tlv8" +) + +// Character - Aqara props order +// Value should be omit for PW +// Value may be empty for PR +type Character struct { + Desc string `json:"description,omitempty"` + + IID uint64 `json:"iid"` + Type string `json:"type"` + Format string `json:"format"` + Value any `json:"value,omitempty"` + Perms []string `json:"perms"` + + //MaxLen int `json:"maxLen,omitempty"` + //Unit string `json:"unit,omitempty"` + //MinValue any `json:"minValue,omitempty"` + //MaxValue any `json:"maxValue,omitempty"` + //MinStep any `json:"minStep,omitempty"` + //ValidVal []any `json:"valid-values,omitempty"` + + listeners map[io.Writer]bool +} + +func (c *Character) AddListener(w io.Writer) { + // TODO: sync.Mutex + if c.listeners == nil { + c.listeners = map[io.Writer]bool{} + } + c.listeners[w] = true +} + +func (c *Character) RemoveListener(w io.Writer) { + delete(c.listeners, w) + + if len(c.listeners) == 0 { + c.listeners = nil + } +} + +func (c *Character) NotifyListeners(ignore io.Writer) error { + if c.listeners == nil { + return nil + } + + data, err := c.GenerateEvent() + if err != nil { + return err + } + + for w := range c.listeners { + if w == ignore { + continue + } + if _, err = w.Write(data); err != nil { + // error not a problem - just remove listener + c.RemoveListener(w) + } + } + + return nil +} + +// GenerateEvent with raw HTTP headers +func (c *Character) GenerateEvent() (data []byte, err error) { + v := JSONCharacters{ + Value: []JSONCharacter{ + {AID: DeviceAID, IID: c.IID, Value: c.Value}, + }, + } + if data, err = json.Marshal(v); err != nil { + return + } + + res := http.Response{ + StatusCode: http.StatusOK, + ProtoMajor: 1, + ProtoMinor: 0, + Header: http.Header{"Content-Type": []string{MimeJSON}}, + ContentLength: int64(len(data)), + Body: io.NopCloser(bytes.NewReader(data)), + } + + buf := bytes.NewBuffer([]byte{0}) + if err = res.Write(buf); err != nil { + return + } + copy(buf.Bytes(), "EVENT") + + return buf.Bytes(), err +} + +// Set new value and NotifyListeners +func (c *Character) Set(v any) (err error) { + if err = c.Write(v); err != nil { + return + } + return c.NotifyListeners(nil) +} + +// Write new value with right format +func (c *Character) Write(v any) (err error) { + switch c.Format { + case "tlv8": + c.Value, err = tlv8.MarshalBase64(v) + + case "bool": + switch v := v.(type) { + case bool: + c.Value = v + case float64: + c.Value = v != 0 + } + } + return +} + +// ReadTLV8 value to right struct +func (c *Character) ReadTLV8(v any) (err error) { + if s, ok := c.Value.(string); ok { + return tlv8.UnmarshalBase64(s, v) + } + return fmt.Errorf("hap: can't read value: %v", v) +} + +func (c *Character) ReadBool() (bool, error) { + if v, ok := c.Value.(bool); ok { + return v, nil + } + return false, fmt.Errorf("hap: can't read value: %v", c.Value) +} + +func (c *Character) String() string { + data, err := json.Marshal(c) + if err != nil { + return "ERROR" + } + return string(data) +} diff --git a/installs_on_host/go2rtc/pkg/hap/client.go b/installs_on_host/go2rtc/pkg/hap/client.go new file mode 100644 index 0000000..ed4faa0 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hap/client.go @@ -0,0 +1,375 @@ +package hap + +import ( + "bufio" + "bytes" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305" + "github.com/AlexxIT/go2rtc/pkg/hap/curve25519" + "github.com/AlexxIT/go2rtc/pkg/hap/ed25519" + "github.com/AlexxIT/go2rtc/pkg/hap/hkdf" + "github.com/AlexxIT/go2rtc/pkg/hap/tlv8" + "github.com/AlexxIT/go2rtc/pkg/mdns" +) + +const ( + ConnDialTimeout = time.Second * 3 + ConnDeadline = time.Second * 3 +) + +// Client for HomeKit. DevicePublic can be null. +type Client struct { + DeviceAddress string // including port + DeviceID string // aka. Accessory + DevicePublic []byte + ClientID string // aka. Controller + ClientPrivate []byte + + OnEvent func(res *http.Response) + //Output func(msg any) + + Conn net.Conn + reader *bufio.Reader + + res chan *http.Response + err error +} + +func Dial(rawURL string) (*Client, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + query := u.Query() + c := &Client{ + DeviceAddress: u.Host, + DeviceID: query.Get("device_id"), + DevicePublic: DecodeKey(query.Get("device_public")), + ClientID: query.Get("client_id"), + ClientPrivate: DecodeKey(query.Get("client_private")), + } + + if err = c.Dial(); err != nil { + return nil, err + } + + return c, nil +} + +func (c *Client) ClientPublic() []byte { + return c.ClientPrivate[32:] +} + +func (c *Client) URL() string { + return fmt.Sprintf( + "homekit://%s?device_id=%s&device_public=%16x&client_id=%s&client_private=%32x", + c.DeviceAddress, c.DeviceID, c.DevicePublic, c.ClientID, c.ClientPrivate, + ) +} + +func (c *Client) DeviceHost() string { + if i := strings.IndexByte(c.DeviceAddress, ':'); i > 0 { + return c.DeviceAddress[:i] + } + return c.DeviceAddress +} + +func (c *Client) Dial() (err error) { + if len(c.ClientID) == 0 || len(c.ClientPrivate) == 0 { + return errors.New("hap: can't dial witout client_id or client_private") + } + + // update device address (host and/or port) before dial + _ = mdns.QueryOrDiscovery(c.DeviceHost(), mdns.ServiceHAP, func(entry *mdns.ServiceEntry) bool { + if entry.Complete() && entry.Info["id"] == c.DeviceID { + c.DeviceAddress = entry.Addr() + return true + } + return false + }) + + // TODO: close conn on error + if c.Conn, err = net.DialTimeout("tcp", c.DeviceAddress, ConnDialTimeout); err != nil { + return + } + + c.reader = bufio.NewReader(c.Conn) + + // STEP M1: send our session public to device + sessionPublic, sessionPrivate := curve25519.GenerateKeyPair() + + // 1. Send sessionPublic + plainM1 := struct { + PublicKey string `tlv8:"3"` + State byte `tlv8:"6"` + }{ + PublicKey: string(sessionPublic), + State: StateM1, + } + res, err := c.Post(PathPairVerify, MimeTLV8, tlv8.MarshalReader(plainM1)) + if err != nil { + return + } + + // STEP M2: unpack deviceID from response + var cipherM2 struct { + PublicKey string `tlv8:"3"` + EncryptedData string `tlv8:"5"` + State byte `tlv8:"6"` + } + if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &cipherM2); err != nil { + return err + } + if cipherM2.State != StateM2 { + return newResponseError(plainM1, cipherM2) + } + + // 1. generate session shared key + sessionShared, err := curve25519.SharedSecret(sessionPrivate, []byte(cipherM2.PublicKey)) + if err != nil { + return + } + + sessionKey, err := hkdf.Sha512( + sessionShared, "Pair-Verify-Encrypt-Salt", "Pair-Verify-Encrypt-Info", + ) + if err != nil { + return + } + + // 2. decrypt M2 response with session key + b, err := chacha20poly1305.Decrypt(sessionKey, "PV-Msg02", []byte(cipherM2.EncryptedData)) + if err != nil { + return + } + + // 3. unpack payload from TLV8 + var plainM2 struct { + Identifier string `tlv8:"1"` + Signature string `tlv8:"10"` + } + if err = tlv8.Unmarshal(b, &plainM2); err != nil { + return + } + + // 4. verify signature for M2 response with device public + // device session + device id + our session + if c.DevicePublic != nil { + b = Append(cipherM2.PublicKey, plainM2.Identifier, sessionPublic) + if !ed25519.ValidateSignature(c.DevicePublic, b, []byte(plainM2.Signature)) { + return errors.New("hap: ValidateSignature") + } + } + + // STEP M3: send our clientID to device + // 1. generate signature with our private key + // (our session + our ID + device session) + b = Append(sessionPublic, c.ClientID, cipherM2.PublicKey) + if b, err = ed25519.Signature(c.ClientPrivate, b); err != nil { + return + } + + // 2. generate payload + plainM3 := struct { + Identifier string `tlv8:"1"` + Signature string `tlv8:"10"` + }{ + Identifier: c.ClientID, + Signature: string(b), + } + if b, err = tlv8.Marshal(plainM3); err != nil { + return + } + + // 4. encrypt payload with session key + if b, err = chacha20poly1305.Encrypt(sessionKey, "PV-Msg03", b); err != nil { + return + } + + // 4. generate request + cipherM3 := struct { + EncryptedData string `tlv8:"5"` + State byte `tlv8:"6"` + }{ + State: StateM3, + EncryptedData: string(b), + } + if res, err = c.Post(PathPairVerify, MimeTLV8, tlv8.MarshalReader(cipherM3)); err != nil { + return + } + + // STEP M4. Read response + var plainM4 struct { + State byte `tlv8:"6"` + } + if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &plainM4); err != nil { + return + } + if plainM4.State != StateM4 { + return newResponseError(cipherM3, plainM4) + } + + rw := bufio.NewReadWriter(c.reader, bufio.NewWriter(c.Conn)) + + // like tls.Client wrapper over net.Conn + if c.Conn, err = NewConn(c.Conn, rw, sessionShared, true); err != nil { + return + } + // new reader for new conn + c.reader = bufio.NewReader(c.Conn) + + return +} + +func (c *Client) Close() error { + if c.Conn == nil { + return nil + } + return c.Conn.Close() +} + +func (c *Client) eventsReader() { + c.res = make(chan *http.Response) + + for { + var res *http.Response + if res, c.err = ReadResponse(c.reader, nil); c.err != nil { + break + } + + var body []byte + if body, c.err = io.ReadAll(res.Body); c.err != nil { + break + } + + res.Body = io.NopCloser(bytes.NewReader(body)) + + if res.Proto != ProtoEvent { + c.res <- res + } else if c.OnEvent != nil { + c.OnEvent(res) + } + } + + close(c.res) +} + +func (c *Client) GetAccessories() ([]*Accessory, error) { + res, err := c.Get(PathAccessories) + if err != nil { + return nil, err + } + + var v JSONAccessories + if err = json.NewDecoder(res.Body).Decode(&v); err != nil { + return nil, err + } + + return v.Value, nil +} + +func (c *Client) GetFirstAccessory() (*Accessory, error) { + accs, err := c.GetAccessories() + if err != nil { + return nil, err + } + if len(accs) == 0 { + return nil, errors.New("hap: GetAccessories zero answer") + } + return accs[0], nil +} + +func (c *Client) GetCharacters(query string) ([]JSONCharacter, error) { + res, err := c.Get(PathCharacteristics + "?id=" + query) + if err != nil { + return nil, err + } + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + var v JSONCharacters + if err = json.Unmarshal(data, &v); err != nil { + return nil, err + } + return v.Value, nil +} + +func (c *Client) GetCharacter(char *Character) error { + query := fmt.Sprintf("%d.%d", DeviceAID, char.IID) + chars, err := c.GetCharacters(query) + if err != nil { + return err + } + char.Value = chars[0].Value + return nil +} + +func (c *Client) PutCharacters(characters ...*Character) error { + var v JSONCharacters + for i, char := range characters { + v.Value = append(v.Value, JSONCharacter{ + AID: 1, + IID: char.IID, + Value: char.Value, + }) + characters[i] = char + } + body, err := json.Marshal(v) + if err != nil { + return err + } + + res, err := c.Put(PathCharacteristics, MimeJSON, bytes.NewReader(body)) + if err != nil { + return err + } + + _, _ = io.ReadAll(res.Body) // important to "clear" body + + return nil +} + +func (c *Client) GetImage(width, height int) ([]byte, error) { + s := fmt.Sprintf( + `{"image-width":%d,"image-height":%d,"resource-type":"image","reason":0}`, + width, height, + ) + res, err := c.Post(PathResource, MimeJSON, bytes.NewBufferString(s)) + if err != nil { + return nil, err + } + return io.ReadAll(res.Body) +} + +func (c *Client) LocalIP() string { + if c.Conn == nil { + return "" + } + addr := c.Conn.LocalAddr().(*net.TCPAddr) + return addr.IP.String() +} + +func DecodeKey(s string) []byte { + if s == "" { + return nil + } + data, err := hex.DecodeString(s) + if err != nil { + return nil + } + return data +} diff --git a/installs_on_host/go2rtc/pkg/hap/client_http.go b/installs_on_host/go2rtc/pkg/hap/client_http.go new file mode 100644 index 0000000..7f8314f --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hap/client_http.go @@ -0,0 +1,101 @@ +package hap + +import ( + "bufio" + "errors" + "io" + "net/http" +) + +const ( + MimeTLV8 = "application/pairing+tlv8" + MimeJSON = "application/hap+json" + + PathPairSetup = "/pair-setup" + PathPairVerify = "/pair-verify" + PathPairings = "/pairings" + PathAccessories = "/accessories" + PathCharacteristics = "/characteristics" + PathResource = "/resource" +) + +func (c *Client) Do(req *http.Request) (*http.Response, error) { + if err := req.Write(c.Conn); err != nil { + return nil, err + } + if c.res != nil { + return <-c.res, c.err + } + return http.ReadResponse(c.reader, req) +} + +func (c *Client) Request(method, path, contentType string, body io.Reader) (*http.Response, error) { + req, err := http.NewRequest(method, "http://"+c.DeviceAddress+path, body) + if err != nil { + return nil, err + } + + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + + res, err := c.Do(req) + if err == nil && res.StatusCode >= http.StatusBadRequest { + err = errors.New("hap: wrong http status: " + res.Status) + } + + return res, err +} + +func (c *Client) Get(path string) (*http.Response, error) { + return c.Request("GET", path, "", nil) +} + +func (c *Client) Post(path, contentType string, body io.Reader) (*http.Response, error) { + return c.Request("POST", path, contentType, body) +} + +func (c *Client) Put(path, contentType string, body io.Reader) (*http.Response, error) { + return c.Request("PUT", path, contentType, body) +} + +const ProtoEvent = "EVENT/1.0" + +func ReadResponse(r *bufio.Reader, req *http.Request) (*http.Response, error) { + b, err := r.Peek(9) + if err != nil { + return nil, err + } + + if string(b) != ProtoEvent { + return http.ReadResponse(r, req) + } + + copy(b, "HTTP/1.1 ") + + res, err := http.ReadResponse(r, req) + if err != nil { + return nil, err + } + + res.Proto = ProtoEvent + + return res, nil +} + +func WriteEvent(w io.Writer, res *http.Response) error { + return res.Write(&eventWriter{w: w}) +} + +type eventWriter struct { + w io.Writer + done bool +} + +func (e *eventWriter) Write(p []byte) (n int, err error) { + if !e.done { + p = append([]byte("EVENT/1.0"), p[8:]...) + e.done = true + } + return e.w.Write(p) +} diff --git a/installs_on_host/go2rtc/pkg/hap/client_pairing.go b/installs_on_host/go2rtc/pkg/hap/client_pairing.go new file mode 100644 index 0000000..f253783 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hap/client_pairing.go @@ -0,0 +1,403 @@ +package hap + +import ( + "bufio" + "crypto/sha512" + "errors" + "net" + "net/url" + + "github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305" + "github.com/AlexxIT/go2rtc/pkg/hap/ed25519" + "github.com/AlexxIT/go2rtc/pkg/hap/hkdf" + "github.com/AlexxIT/go2rtc/pkg/hap/tlv8" + "github.com/tadglines/go-pkgs/crypto/srp" +) + +// Pair homekit +func Pair(rawURL string) (*Client, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + query := u.Query() + + c := &Client{ + DeviceAddress: u.Host, + DeviceID: query.Get("device_id"), + ClientID: query.Get("client_id"), + ClientPrivate: DecodeKey(query.Get("client_private")), + } + + if c.ClientID == "" { + c.ClientID = GenerateUUID() + } + if c.ClientPrivate == nil { + c.ClientPrivate = GenerateKey() + } + + if err = c.Pair(query.Get("feature"), query.Get("pin")); err != nil { + return nil, err + } + + return c, nil +} + +func Unpair(rawURL string) error { + u, err := url.Parse(rawURL) + if err != nil { + return err + } + + query := u.Query() + conn := &Client{ + DeviceAddress: u.Host, + DeviceID: query.Get("device_id"), + DevicePublic: DecodeKey(query.Get("device_public")), + ClientID: query.Get("client_id"), + ClientPrivate: DecodeKey(query.Get("client_private")), + } + + if err = conn.Dial(); err != nil { + return err + } + + defer conn.Close() + + if err = conn.ListPairings(); err != nil { + return err + } + + return conn.DeletePairing(conn.ClientID) +} + +func (c *Client) Pair(feature, pin string) (err error) { + if pin, err = SanitizePin(pin); err != nil { + return err + } + + c.Conn, err = net.DialTimeout("tcp", c.DeviceAddress, ConnDialTimeout) + if err != nil { + return + } + + c.reader = bufio.NewReader(c.Conn) + + // STEP M1. Send HELLO + plainM1 := struct { + Method byte `tlv8:"0"` + State byte `tlv8:"6"` + }{ + Method: MethodPair, + State: StateM1, + } + if feature == "1" { + plainM1.Method = MethodPairMFi // ff=1 => method=1, ff=2 => method=0 + } + res, err := c.Post(PathPairSetup, MimeTLV8, tlv8.MarshalReader(plainM1)) + if err != nil { + return + } + + // STEP M2. Read Device Salt and session PublicKey + var plainM2 struct { + Salt string `tlv8:"2"` + SessionKey string `tlv8:"3"` // server public key, aka session.B + State byte `tlv8:"6"` + Error byte `tlv8:"7"` + } + if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &plainM2); err != nil { + return + } + if plainM2.State != StateM2 { + return newResponseError(plainM1, plainM2) + } + if plainM2.Error != 0 { + return newPairingError(plainM2.Error) + } + + // STEP M3. Generate SRP Session using pin + username := []byte("Pair-Setup") + + // Stanford Secure Remote Password (SRP) / Password Authenticated Key Exchange (PAKE) + pake, err := srp.NewSRP("rfc5054.3072", sha512.New, keyDerivativeFuncRFC2945(username)) + if err != nil { + return + } + + pake.SaltLength = 16 + + // username: "Pair-Setup", password: PIN (with dashes) + session := pake.NewClientSession(username, []byte(pin)) + + sessionShared, err := session.ComputeKey([]byte(plainM2.Salt), []byte(plainM2.SessionKey)) + if err != nil { + return + } + + // STEP M3. Send request + plainM3 := struct { + SessionKey string `tlv8:"3"` + Proof string `tlv8:"4"` + State byte `tlv8:"6"` + }{ + SessionKey: string(session.GetA()), // client public key, aka session.A + Proof: string(session.ComputeAuthenticator()), + State: StateM3, + } + if res, err = c.Post(PathPairSetup, MimeTLV8, tlv8.MarshalReader(plainM3)); err != nil { + return + } + + // STEP M4. Read response + var plainM4 struct { + Proof string `tlv8:"4"` // server proof + State byte `tlv8:"6"` + Error byte `tlv8:"7"` + + EncryptedData string `tlv8:"5"` // skip EncryptedData validation (for MFi devices) + } + if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &plainM4); err != nil { + return + } + if plainM4.State != StateM4 { + return newResponseError(plainM3, plainM4) + } + if plainM4.Error != 0 { + return newPairingError(plainM4.Error) + } + + // STEP M4. Verify response + if !session.VerifyServerAuthenticator([]byte(plainM4.Proof)) { + return errors.New("hap: VerifyServerAuthenticator") + } + + // STEP M5. Generate signature + localSign, err := hkdf.Sha512( + sessionShared, "Pair-Setup-Controller-Sign-Salt", "Pair-Setup-Controller-Sign-Info", + ) + if err != nil { + return + } + + b := Append(localSign, c.ClientID, c.ClientPublic()) + signature, err := ed25519.Signature(c.ClientPrivate, b) + if err != nil { + return + } + + // STEP M5. Generate payload + plainM5 := struct { + Identifier string `tlv8:"1"` + PublicKey string `tlv8:"3"` + Signature string `tlv8:"10"` + }{ + Identifier: c.ClientID, + PublicKey: string(c.ClientPublic()), + Signature: string(signature), + } + if b, err = tlv8.Marshal(plainM5); err != nil { + return + } + + // STEP M5. Encrypt payload + encryptKey, err := hkdf.Sha512( + sessionShared, "Pair-Setup-Encrypt-Salt", "Pair-Setup-Encrypt-Info", + ) + if err != nil { + return + } + + if b, err = chacha20poly1305.Encrypt(encryptKey, "PS-Msg05", b); err != nil { + return + } + + // STEP M5. Send request + cipherM5 := struct { + EncryptedData string `tlv8:"5"` + State byte `tlv8:"6"` + }{ + EncryptedData: string(b), + State: StateM5, + } + if res, err = c.Post(PathPairSetup, MimeTLV8, tlv8.MarshalReader(cipherM5)); err != nil { + return + } + + // STEP M6. Read response + cipherM6 := struct { + EncryptedData string `tlv8:"5"` + State byte `tlv8:"6"` + Error byte `tlv8:"7"` + }{} + if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &cipherM6); err != nil { + return + } + if cipherM6.State != StateM6 || cipherM6.Error != 0 { + return newResponseError(plainM5, cipherM6) + } + + // STEP M6. Decrypt payload + b, err = chacha20poly1305.Decrypt(encryptKey, "PS-Msg06", []byte(cipherM6.EncryptedData)) + if err != nil { + return + } + + plainM6 := struct { + Identifier string `tlv8:"1"` + PublicKey string `tlv8:"3"` + Signature string `tlv8:"10"` + }{} + if err = tlv8.Unmarshal(b, &plainM6); err != nil { + return + } + + // STEP M6. Verify payload + remoteSign, err := hkdf.Sha512( + sessionShared, "Pair-Setup-Accessory-Sign-Salt", "Pair-Setup-Accessory-Sign-Info", + ) + if err != nil { + return + } + + b = Append(remoteSign, plainM6.Identifier, plainM6.PublicKey) + if !ed25519.ValidateSignature([]byte(plainM6.PublicKey), b, []byte(plainM6.Signature)) { + return errors.New("hap: ValidateSignature") + } + + if c.DeviceID != plainM6.Identifier { + return errors.New("hap: wrong DeviceID: " + plainM6.Identifier) + } + + c.DevicePublic = []byte(plainM6.PublicKey) + + return nil +} + +func (c *Client) ListPairings() error { + plainM1 := struct { + Method byte `tlv8:"0"` + State byte `tlv8:"6"` + }{ + Method: MethodListPairings, + State: StateM1, + } + res, err := c.Post(PathPairings, MimeTLV8, tlv8.MarshalReader(plainM1)) + if err != nil { + return err + } + + // TODO: don't know how to fix array of items + var plainM2 struct { + Identifier string `tlv8:"1"` + PublicKey string `tlv8:"3"` + State byte `tlv8:"6"` + Permission byte `tlv8:"11"` + } + if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &plainM2); err != nil { + return err + } + + return nil +} + +func (c *Client) PairingsAdd(clientID string, clientPublic []byte, admin bool) error { + plainM1 := struct { + Method byte `tlv8:"0"` + Identifier string `tlv8:"1"` + PublicKey string `tlv8:"3"` + State byte `tlv8:"6"` + Permission byte `tlv8:"11"` + }{ + Method: MethodAddPairing, + Identifier: clientID, + PublicKey: string(clientPublic), + State: StateM1, + Permission: PermissionUser, + } + if admin { + plainM1.Permission = PermissionAdmin + } + res, err := c.Post(PathPairings, MimeTLV8, tlv8.MarshalReader(plainM1)) + if err != nil { + return err + } + + var plainM2 struct { + State byte `tlv8:"6"` + Unknown byte `tlv8:"7"` + } + if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &plainM2); err != nil { + return err + } + + return nil +} + +func (c *Client) DeletePairing(id string) error { + plainM1 := struct { + Method byte `tlv8:"0"` + Identifier string `tlv8:"1"` + State byte `tlv8:"6"` + }{ + Method: MethodDeletePairing, + Identifier: id, + State: StateM1, + } + res, err := c.Post(PathPairings, MimeTLV8, tlv8.MarshalReader(plainM1)) + if err != nil { + return err + } + + var plainM2 struct { + State byte `tlv8:"6"` + } + if err = tlv8.UnmarshalReader(res.Body, res.ContentLength, &plainM2); err != nil { + return err + } + if plainM2.State != StateM2 { + return newResponseError(plainM1, plainM2) + } + + return nil +} + +func newPairingError(code byte) error { + var text string + // https://github.com/apple/HomeKitADK/blob/fb201f98f5fdc7fef6a455054f08b59cca5d1ec8/HAP/HAPPairing.h#L89 + switch code { + case 1: + text = "Generic error to handle unexpected errors" + case 2: + text = "Setup code or signature verification failed" + case 3: + text = "Client must look at the retry delay TLV item and wait that many seconds before retrying" + case 4: + text = "Server cannot accept any more pairings" + case 5: + text = "Server reached its maximum number of authentication attempts" + case 6: + text = "Server pairing method is unavailable" + case 7: + text = "Server is busy and cannot accept a pairing request at this time" + default: + text = "Unknown pairing error" + } + return errors.New("hap: " + text) +} + +func keyDerivativeFuncRFC2945(username []byte) srp.KeyDerivationFunc { + return func(salt, password []byte) []byte { + h1 := sha512.New() + h1.Write(username) + h1.Write([]byte(":")) + h1.Write(password) + + h2 := sha512.New() + h2.Write(salt) + h2.Write(h1.Sum(nil)) + + return h2.Sum(nil) + } +} diff --git a/installs_on_host/go2rtc/pkg/hap/conn.go b/installs_on_host/go2rtc/pkg/hap/conn.go new file mode 100644 index 0000000..2b039dc --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hap/conn.go @@ -0,0 +1,173 @@ +package hap + +import ( + "bufio" + "encoding/binary" + "encoding/json" + "errors" + "io" + "net" + "sync" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305" + "github.com/AlexxIT/go2rtc/pkg/hap/hkdf" +) + +type Conn struct { + conn net.Conn + rw *bufio.ReadWriter + wmu sync.Mutex + + encryptKey []byte + decryptKey []byte + encryptCnt uint64 + decryptCnt uint64 + + //ClientID string + SharedKey []byte + + recv int + send int +} + +func (c *Conn) MarshalJSON() ([]byte, error) { + conn := core.Connection{ + ID: core.ID(c), + FormatName: "homekit", + Protocol: "hap", + RemoteAddr: c.conn.RemoteAddr().String(), + Recv: c.recv, + Send: c.send, + } + return json.Marshal(conn) +} + +func NewConn(conn net.Conn, rw *bufio.ReadWriter, sharedKey []byte, isClient bool) (*Conn, error) { + key1, err := hkdf.Sha512(sharedKey, "Control-Salt", "Control-Read-Encryption-Key") + if err != nil { + return nil, err + } + + key2, err := hkdf.Sha512(sharedKey, "Control-Salt", "Control-Write-Encryption-Key") + if err != nil { + return nil, err + } + + c := &Conn{ + conn: conn, + rw: rw, + + SharedKey: sharedKey, + } + + if isClient { + c.encryptKey, c.decryptKey = key2, key1 + } else { + c.encryptKey, c.decryptKey = key1, key2 + } + + return c, nil +} + +const ( + // packetSizeMax is the max length of encrypted packets + packetSizeMax = 0x400 + + VerifySize = 2 + NonceSize = 8 + Overhead = 16 // chacha20poly1305.Overhead +) + +func (c *Conn) Read(b []byte) (n int, err error) { + if cap(b) < packetSizeMax { + return 0, errors.New("hap: read buffer is too small") + } + + verify := make([]byte, VerifySize) // verify = plain message size + if _, err = io.ReadFull(c.rw, verify); err != nil { + return + } + + n = int(binary.LittleEndian.Uint16(verify)) + + ciphertext := make([]byte, n+Overhead) + if _, err = io.ReadFull(c.rw, ciphertext); err != nil { + return + } + + nonce := make([]byte, NonceSize) + binary.LittleEndian.PutUint64(nonce, c.decryptCnt) + c.decryptCnt++ + + _, err = chacha20poly1305.DecryptAndVerify(c.decryptKey, b[:0], nonce, ciphertext, verify) + + c.recv += n + return +} + +func (c *Conn) Write(b []byte) (n int, err error) { + c.wmu.Lock() + defer c.wmu.Unlock() + + buf := make([]byte, 0, packetSizeMax+Overhead) + nonce := make([]byte, NonceSize) + verify := make([]byte, VerifySize) + + for len(b) > 0 { + size := len(b) + if size > packetSizeMax { + size = packetSizeMax + } + + binary.LittleEndian.PutUint16(verify, uint16(size)) + if _, err = c.rw.Write(verify); err != nil { + return + } + + binary.LittleEndian.PutUint64(nonce, c.encryptCnt) + c.encryptCnt++ + + _, err = chacha20poly1305.EncryptAndSeal(c.encryptKey, buf, nonce, b[:size], verify) + if err != nil { + return + } + + if _, err = c.rw.Write(buf[:size+Overhead]); err != nil { + return + } + + b = b[size:] + n += size + } + + err = c.rw.Flush() + + c.send += n + return +} + +func (c *Conn) Close() error { + return c.conn.Close() +} + +func (c *Conn) LocalAddr() net.Addr { + return c.conn.LocalAddr() +} + +func (c *Conn) RemoteAddr() net.Addr { + return c.conn.RemoteAddr() +} + +func (c *Conn) SetDeadline(t time.Time) error { + return c.conn.SetDeadline(t) +} + +func (c *Conn) SetReadDeadline(t time.Time) error { + return c.conn.SetReadDeadline(t) +} + +func (c *Conn) SetWriteDeadline(t time.Time) error { + return c.conn.SetWriteDeadline(t) +} diff --git a/installs_on_host/go2rtc/pkg/hap/curve25519/curve25519.go b/installs_on_host/go2rtc/pkg/hap/curve25519/curve25519.go new file mode 100644 index 0000000..f73f76d --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hap/curve25519/curve25519.go @@ -0,0 +1,18 @@ +package curve25519 + +import ( + "crypto/rand" + + "golang.org/x/crypto/curve25519" +) + +func GenerateKeyPair() ([]byte, []byte) { + var publicKey, privateKey [32]byte + _, _ = rand.Read(privateKey[:]) + curve25519.ScalarBaseMult(&publicKey, &privateKey) + return publicKey[:], privateKey[:] +} + +func SharedSecret(privateKey, otherPublicKey []byte) ([]byte, error) { + return curve25519.X25519(privateKey, otherPublicKey) +} diff --git a/installs_on_host/go2rtc/pkg/hap/ed25519/ed25519.go b/installs_on_host/go2rtc/pkg/hap/ed25519/ed25519.go new file mode 100644 index 0000000..646ce26 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hap/ed25519/ed25519.go @@ -0,0 +1,24 @@ +package ed25519 + +import ( + "crypto/ed25519" + "errors" +) + +var ErrInvalidParams = errors.New("ed25519: invalid params") + +func ValidateSignature(key, data, signature []byte) bool { + if len(key) != ed25519.PublicKeySize || len(signature) != ed25519.SignatureSize { + return false + } + + return ed25519.Verify(key, data, signature) +} + +func Signature(key, data []byte) ([]byte, error) { + if len(key) != ed25519.PrivateKeySize { + return nil, ErrInvalidParams + } + + return ed25519.Sign(key, data), nil +} diff --git a/installs_on_host/go2rtc/pkg/hap/hds/hds.go b/installs_on_host/go2rtc/pkg/hap/hds/hds.go new file mode 100644 index 0000000..0e29991 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hap/hds/hds.go @@ -0,0 +1,176 @@ +// Package hds - HomeKit Data Stream +package hds + +import ( + "bufio" + "encoding/binary" + "encoding/json" + "errors" + "io" + "net" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/hap" + "github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305" + "github.com/AlexxIT/go2rtc/pkg/hap/hkdf" +) + +func NewConn(conn net.Conn, key []byte, salt string, controller bool) (*Conn, error) { + writeKey, err := hkdf.Sha512(key, salt, "HDS-Write-Encryption-Key") + if err != nil { + return nil, err + } + + readKey, err := hkdf.Sha512(key, salt, "HDS-Read-Encryption-Key") + if err != nil { + return nil, err + } + + c := &Conn{ + conn: conn, + rd: bufio.NewReaderSize(conn, 32*1024), + wr: bufio.NewWriterSize(conn, 32*1024), + } + + if controller { + c.decryptKey, c.encryptKey = readKey, writeKey + } else { + c.decryptKey, c.encryptKey = writeKey, readKey + } + + return c, nil +} + +type Conn struct { + conn net.Conn + + rd *bufio.Reader + wr *bufio.Writer + + decryptKey []byte + encryptKey []byte + decryptCnt uint64 + encryptCnt uint64 + + recv int + send int +} + +func (c *Conn) MarshalJSON() ([]byte, error) { + conn := core.Connection{ + ID: core.ID(c), + FormatName: "homekit", + Protocol: "hds", + RemoteAddr: c.conn.RemoteAddr().String(), + Recv: c.recv, + Send: c.send, + } + return json.Marshal(conn) +} + +func (c *Conn) read() (b []byte, err error) { + verify := make([]byte, 4) + if _, err = io.ReadFull(c.rd, verify); err != nil { + return + } + + n := int(binary.BigEndian.Uint32(verify) & 0xFFFFFF) + + ciphertext := make([]byte, n+hap.Overhead) + if _, err = io.ReadFull(c.rd, ciphertext); err != nil { + return + } + + nonce := make([]byte, hap.NonceSize) + binary.LittleEndian.PutUint64(nonce, c.decryptCnt) + c.decryptCnt++ + + c.recv += n + + return chacha20poly1305.DecryptAndVerify(c.decryptKey, ciphertext[:0], nonce, ciphertext, verify) +} + +func (c *Conn) Read(p []byte) (n int, err error) { + b, err := c.read() + if err != nil { + return 0, err + } + n = copy(p, b) + if len(b) > n { + err = errors.New("hds: read buffer too small") + } + return +} + +func (c *Conn) WriteTo(w io.Writer) (int64, error) { + var total int64 + for { + b, err := c.read() + if err != nil { + return total, err + } + + n, err := w.Write(b) + total += int64(n) + if err != nil { + return total, err + } + } +} + +func (c *Conn) Write(b []byte) (n int, err error) { + n = len(b) + + if n > 0xFFFFFF { + return 0, errors.New("hds: write buffer too big") + } + + verify := make([]byte, 4) + binary.BigEndian.PutUint32(verify, 0x01000000|uint32(n)) + if _, err = c.wr.Write(verify); err != nil { + return + } + + nonce := make([]byte, hap.NonceSize) + binary.LittleEndian.PutUint64(nonce, c.encryptCnt) + c.encryptCnt++ + + buf := make([]byte, n+hap.Overhead) + if _, err = chacha20poly1305.EncryptAndSeal(c.encryptKey, buf[:0], nonce, b, verify); err != nil { + return + } + + if _, err = c.wr.Write(buf); err != nil { + return + } + + err = c.wr.Flush() + + c.send += n + return +} + +func (c *Conn) Close() error { + return c.conn.Close() +} + +func (c *Conn) LocalAddr() net.Addr { + return c.conn.LocalAddr() +} + +func (c *Conn) RemoteAddr() net.Addr { + return c.conn.RemoteAddr() +} + +func (c *Conn) SetDeadline(t time.Time) error { + return c.conn.SetDeadline(t) +} + +func (c *Conn) SetReadDeadline(t time.Time) error { + return c.conn.SetReadDeadline(t) +} + +func (c *Conn) SetWriteDeadline(t time.Time) error { + return c.conn.SetWriteDeadline(t) +} diff --git a/installs_on_host/go2rtc/pkg/hap/hds/hds_test.go b/installs_on_host/go2rtc/pkg/hap/hds/hds_test.go new file mode 100644 index 0000000..f1c8545 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hap/hds/hds_test.go @@ -0,0 +1,35 @@ +package hds + +import ( + "bufio" + "bytes" + "testing" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/stretchr/testify/require" +) + +func TestEncryption(t *testing.T) { + key := []byte(core.RandString(16, 0)) + salt := core.RandString(32, 0) + + c, err := Client(nil, key, salt, true) + require.NoError(t, err) + + buf := bytes.NewBuffer(nil) + c.wr = bufio.NewWriter(buf) + + n, err := c.Write([]byte("test")) + require.NoError(t, err) + require.Equal(t, 4, n) + + c, err = Client(nil, key, salt, false) + c.rd = bufio.NewReader(buf) + require.NoError(t, err) + + b := make([]byte, 32) + n, err = c.Read(b) + require.NoError(t, err) + + require.Equal(t, "test", string(b[:n])) +} diff --git a/installs_on_host/go2rtc/pkg/hap/helpers.go b/installs_on_host/go2rtc/pkg/hap/helpers.go new file mode 100644 index 0000000..3c3b287 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hap/helpers.go @@ -0,0 +1,130 @@ +package hap + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha512" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "strings" +) + +const ( + TXTConfigNumber = "c#" // Current configuration number (ex. 1, 2, 3) + TXTDeviceID = "id" // Device ID of the accessory (ex. 77:75:87:A0:7D:F4) + TXTModel = "md" // Model name of the accessory (ex. MJCTD02YL) + TXTProtoVersion = "pv" // Protocol version string (ex. 1.1) + TXTStateNumber = "s#" // Current state number (ex. 1) + TXTCategory = "ci" // Accessory Category Identifier (ex. 2, 5, 17) + TXTSetupHash = "sh" // Setup hash (ex. Y9w9hQ==) + + // TXTFeatureFlags + // - 0001b - Supports Apple Authentication Coprocessor + // - 0010b - Supports Software Authentication + TXTFeatureFlags = "ff" // Pairing Feature flags (ex. 0, 1, 2) + + // TXTStatusFlags + // - 0001b - Accessory has not been paired with any controllers + // - 0100b - A problem has been detected on the accessory + TXTStatusFlags = "sf" // Status flags (ex. 0, 1) + + StatusPaired = "0" + StatusNotPaired = "1" + + CategoryBridge = "2" + CategoryCamera = "17" + CategoryDoorbell = "18" + + StateM1 = 1 + StateM2 = 2 + StateM3 = 3 + StateM4 = 4 + StateM5 = 5 + StateM6 = 6 + + MethodPair = 0 + MethodPairMFi = 1 // if device has MFI cert + MethodVerifyPair = 2 + MethodAddPairing = 3 + MethodDeletePairing = 4 + MethodListPairings = 5 + + PermissionUser = 0 + PermissionAdmin = 1 +) + +const DeviceAID = 1 // TODO: fix someday + +type JSONAccessories struct { + Value []*Accessory `json:"accessories"` +} + +type JSONCharacters struct { + Value []JSONCharacter `json:"characteristics"` +} + +type JSONCharacter struct { + AID uint8 `json:"aid"` + IID uint64 `json:"iid"` + Status any `json:"status,omitempty"` + Value any `json:"value,omitempty"` + Event any `json:"ev,omitempty"` +} + +// 4.2.1.2 Invalid Setup Codes +const insecurePINs = "00000000 11111111 22222222 33333333 44444444 55555555 66666666 77777777 88888888 99999999 12345678 87654321" + +func SanitizePin(pin string) (string, error) { + s := strings.ReplaceAll(pin, "-", "") + if len(s) != 8 { + return "", errors.New("hap: wrong PIN format: " + pin) + } + if strings.Contains(insecurePINs, s) { + return "", errors.New("hap: insecure PIN: " + pin) + } + // 123-45-678 + return s[:3] + "-" + s[3:5] + "-" + s[5:], nil +} + +func GenerateKey() []byte { + _, key, _ := ed25519.GenerateKey(nil) + return key +} + +func GenerateUUID() string { + //12345678-9012-3456-7890-123456789012 + data := make([]byte, 16) + _, _ = rand.Read(data) + s := hex.EncodeToString(data) + return s[:8] + "-" + s[8:12] + "-" + s[12:16] + "-" + s[16:20] + "-" + s[20:] +} + +func SetupHash(setupID, deviceID string) string { + // should be setup_id (random 4 alphanum) + device_id (mac address) + b := sha512.Sum512([]byte(setupID + deviceID)) + return base64.StdEncoding.EncodeToString(b[:4]) +} + +func Append(items ...any) (b []byte) { + for _, item := range items { + switch v := item.(type) { + case string: + b = append(b, v...) + case []byte: + b = append(b, v[:]...) + default: + panic(v) + } + } + return +} + +func newRequestError(req any) error { + return fmt.Errorf("hap: wrong request: %#v", req) +} + +func newResponseError(req, res any) error { + return fmt.Errorf("hap: wrong response: %#v, on request: %#v", res, req) +} diff --git a/installs_on_host/go2rtc/pkg/hap/hkdf/hkdf.go b/installs_on_host/go2rtc/pkg/hap/hkdf/hkdf.go new file mode 100644 index 0000000..989dcfb --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hap/hkdf/hkdf.go @@ -0,0 +1,17 @@ +package hkdf + +import ( + "crypto/sha512" + "io" + + "golang.org/x/crypto/hkdf" +) + +func Sha512(key []byte, salt, info string) ([]byte, error) { + r := hkdf.New(sha512.New, key, []byte(salt), []byte(info)) + + buf := make([]byte, 32) + _, err := io.ReadFull(r, buf) + + return buf, err +} diff --git a/installs_on_host/go2rtc/pkg/hap/server.go b/installs_on_host/go2rtc/pkg/hap/server.go new file mode 100644 index 0000000..a992528 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hap/server.go @@ -0,0 +1,396 @@ +package hap + +import ( + "bufio" + "crypto/sha512" + "errors" + "fmt" + "net/http" + + "github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305" + "github.com/AlexxIT/go2rtc/pkg/hap/curve25519" + "github.com/AlexxIT/go2rtc/pkg/hap/ed25519" + "github.com/AlexxIT/go2rtc/pkg/hap/hkdf" + "github.com/AlexxIT/go2rtc/pkg/hap/tlv8" + "github.com/tadglines/go-pkgs/crypto/srp" +) + +type Server struct { + Pin string + DeviceID string + DevicePrivate []byte + + // GetClientPublic may be nil, so client validation will be disabled + GetClientPublic func(id string) []byte +} + +func (s *Server) ServerPublic() []byte { + return s.DevicePrivate[32:] +} + +//func (s *Server) Status() string { +// if len(s.Pairings) == 0 { +// return StatusNotPaired +// } +// return StatusPaired +//} + +func (s *Server) PairSetup(req *http.Request, rw *bufio.ReadWriter) (id string, publicKey []byte, err error) { + // STEP 1. Request from iPhone + var plainM1 struct { + State byte `tlv8:"6"` + Method byte `tlv8:"0"` + Flags uint32 `tlv8:"19"` + } + if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &plainM1); err != nil { + return + } + if plainM1.State != StateM1 { + err = newRequestError(plainM1) + return + } + + username := []byte("Pair-Setup") + + // Stanford Secure Remote Password (SRP) / Password Authenticated Key Exchange (PAKE) + pake, err := srp.NewSRP("rfc5054.3072", sha512.New, keyDerivativeFuncRFC2945(username)) + if err != nil { + return + } + + pake.SaltLength = 16 + + salt, verifier, err := pake.ComputeVerifier([]byte(s.Pin)) + if err != nil { + return + } + + session := pake.NewServerSession(username, salt, verifier) + + // STEP 2. Response to iPhone + plainM2 := struct { + State byte `tlv8:"6"` + PublicKey string `tlv8:"3"` + Salt string `tlv8:"2"` + }{ + State: StateM2, + PublicKey: string(session.GetB()), + Salt: string(salt), + } + body, err := tlv8.Marshal(plainM2) + if err != nil { + return + } + if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil { + return + } + + // STEP 3. Request from iPhone + if req, err = http.ReadRequest(rw.Reader); err != nil { + return + } + + var plainM3 struct { + State byte `tlv8:"6"` + PublicKey string `tlv8:"3"` + Proof string `tlv8:"4"` + } + if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &plainM3); err != nil { + return + } + if plainM3.State != StateM3 { + err = newRequestError(plainM3) + return + } + + // important to compute key before verify client + sessionShared, err := session.ComputeKey([]byte(plainM3.PublicKey)) + if err != nil { + return + } + + if !session.VerifyClientAuthenticator([]byte(plainM3.Proof)) { + err = errors.New("hap: VerifyClientAuthenticator") + return + } + + proof := session.ComputeAuthenticator([]byte(plainM3.Proof)) // server proof + + // STEP 4. Response to iPhone + payloadM4 := struct { + State byte `tlv8:"6"` + Proof string `tlv8:"4"` + }{ + State: StateM4, + Proof: string(proof), + } + if body, err = tlv8.Marshal(payloadM4); err != nil { + return + } + if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil { + return + } + + // STEP 5. Request from iPhone + if req, err = http.ReadRequest(rw.Reader); err != nil { + return + } + var cipherM5 struct { + State byte `tlv8:"6"` + EncryptedData string `tlv8:"5"` + } + if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &cipherM5); err != nil { + return + } + if cipherM5.State != StateM5 { + err = newRequestError(cipherM5) + return + } + + // decrypt message using session shared + encryptKey, err := hkdf.Sha512(sessionShared, "Pair-Setup-Encrypt-Salt", "Pair-Setup-Encrypt-Info") + if err != nil { + return + } + + b, err := chacha20poly1305.Decrypt(encryptKey, "PS-Msg05", []byte(cipherM5.EncryptedData)) + if err != nil { + return + } + + // unpack message from TLV8 + var plainM5 struct { + Identifier string `tlv8:"1"` + PublicKey string `tlv8:"3"` + Signature string `tlv8:"10"` + } + if err = tlv8.Unmarshal(b, &plainM5); err != nil { + return + } + + // 3. verify client ID and Public + remoteSign, err := hkdf.Sha512( + sessionShared, "Pair-Setup-Controller-Sign-Salt", "Pair-Setup-Controller-Sign-Info", + ) + if err != nil { + return + } + + b = Append(remoteSign, plainM5.Identifier, plainM5.PublicKey) + if !ed25519.ValidateSignature([]byte(plainM5.PublicKey), b, []byte(plainM5.Signature)) { + err = errors.New("hap: ValidateSignature") + return + } + + // 4. generate signature to our ID and Public + localSign, err := hkdf.Sha512( + sessionShared, "Pair-Setup-Accessory-Sign-Salt", "Pair-Setup-Accessory-Sign-Info", + ) + if err != nil { + return + } + + b = Append(localSign, s.DeviceID, s.ServerPublic()) // ServerPublic + signature, err := ed25519.Signature(s.DevicePrivate, b) + if err != nil { + return + } + + // 5. pack our ID and Public + plainM6 := struct { + Identifier string `tlv8:"1"` + PublicKey string `tlv8:"3"` + Signature string `tlv8:"10"` + }{ + Identifier: s.DeviceID, + PublicKey: string(s.ServerPublic()), + Signature: string(signature), + } + if b, err = tlv8.Marshal(plainM6); err != nil { + return + } + + // 6. encrypt message + b, err = chacha20poly1305.Encrypt(encryptKey, "PS-Msg06", b) + if err != nil { + return + } + + // STEP 6. Response to iPhone + cipherM6 := struct { + State byte `tlv8:"6"` + EncryptedData string `tlv8:"5"` + }{ + State: StateM6, + EncryptedData: string(b), + } + if body, err = tlv8.Marshal(cipherM6); err != nil { + return + } + if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil { + return + } + + id = plainM5.Identifier + publicKey = []byte(plainM5.PublicKey) + + return +} + +func (s *Server) PairVerify(req *http.Request, rw *bufio.ReadWriter) (id string, sessionKey []byte, err error) { + // Request from iPhone + var plainM1 struct { + State byte `tlv8:"6"` + PublicKey string `tlv8:"3"` + } + if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &plainM1); err != nil { + return + } + if plainM1.State != StateM1 { + err = newRequestError(plainM1) + return + } + + // Generate the key pair + sessionPublic, sessionPrivate := curve25519.GenerateKeyPair() + sessionShared, err := curve25519.SharedSecret(sessionPrivate, []byte(plainM1.PublicKey)) + if err != nil { + return + } + + encryptKey, err := hkdf.Sha512( + sessionShared, "Pair-Verify-Encrypt-Salt", "Pair-Verify-Encrypt-Info", + ) + if err != nil { + return + } + + b := Append(sessionPublic, s.DeviceID, plainM1.PublicKey) + signature, err := ed25519.Signature(s.DevicePrivate, b) + if err != nil { + return + } + + // STEP M2. Response to iPhone + plainM2 := struct { + Identifier string `tlv8:"1"` + Signature string `tlv8:"10"` + }{ + Identifier: s.DeviceID, + Signature: string(signature), + } + if b, err = tlv8.Marshal(plainM2); err != nil { + return + } + + b, err = chacha20poly1305.Encrypt(encryptKey, "PV-Msg02", b) + if err != nil { + return + } + + cipherM2 := struct { + State byte `tlv8:"6"` + PublicKey string `tlv8:"3"` + EncryptedData string `tlv8:"5"` + }{ + State: StateM2, + PublicKey: string(sessionPublic), + EncryptedData: string(b), + } + body, err := tlv8.Marshal(cipherM2) + if err != nil { + return + } + if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil { + return + } + + // STEP M3. Request from iPhone + if req, err = http.ReadRequest(rw.Reader); err != nil { + return + } + + var cipherM3 struct { + State byte `tlv8:"6"` + EncryptedData string `tlv8:"5"` + } + if err = tlv8.UnmarshalReader(req.Body, req.ContentLength, &cipherM3); err != nil { + return + } + if cipherM3.State != StateM3 { + err = newRequestError(cipherM3) + return + } + + b, err = chacha20poly1305.Decrypt(encryptKey, "PV-Msg03", []byte(cipherM3.EncryptedData)) + if err != nil { + return + } + + var plainM3 struct { + Identifier string `tlv8:"1"` + Signature string `tlv8:"10"` + } + if err = tlv8.Unmarshal(b, &plainM3); err != nil { + return + } + + if s.GetClientPublic != nil { + clientPublic := s.GetClientPublic(plainM3.Identifier) + if clientPublic == nil { + err = errors.New("hap: PairVerify with unknown client_id: " + plainM3.Identifier) + return + } + + b = Append(plainM1.PublicKey, plainM3.Identifier, sessionPublic) + if !ed25519.ValidateSignature(clientPublic, b, []byte(plainM3.Signature)) { + err = errors.New("hap: ValidateSignature") + return + } + } + + // STEP M4. Response to iPhone + payloadM4 := struct { + State byte `tlv8:"6"` + }{ + State: StateM4, + } + if body, err = tlv8.Marshal(payloadM4); err != nil { + return + } + if err = WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body); err != nil { + return + } + + id = plainM3.Identifier + sessionKey = sessionShared + + return +} + +func WriteResponse(w *bufio.Writer, statusCode int, contentType string, body []byte) error { + header := fmt.Sprintf( + "HTTP/1.1 %d %s\r\nContent-Type: %s\r\nContent-Length: %d\r\n\r\n", + statusCode, http.StatusText(statusCode), contentType, len(body), + ) + body = append([]byte(header), body...) + if _, err := w.Write(body); err != nil { + return err + } + return w.Flush() +} + +//func WriteBackoff(rw *bufio.ReadWriter) error { +// plainM2 := struct { +// State byte `tlv8:"6"` +// Error byte `tlv8:"7"` +// }{ +// State: StateM2, +// Error: 3, // BackoffError +// } +// body, err := tlv8.Marshal(plainM2) +// if err != nil { +// return err +// } +// return WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body) +//} diff --git a/installs_on_host/go2rtc/pkg/hap/setup/setup.go b/installs_on_host/go2rtc/pkg/hap/setup/setup.go new file mode 100644 index 0000000..c5eeb51 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hap/setup/setup.go @@ -0,0 +1,32 @@ +package setup + +import ( + "strconv" + "strings" +) + +const ( + FlagNFC = 1 + FlagIP = 2 + FlagBLE = 4 + FlagWAC = 8 // Wireless Accessory Configuration (WAC)/Apples MFi +) + +func GenerateSetupURI(category, pin, setupID string) string { + c, _ := strconv.Atoi(category) + p, _ := strconv.Atoi(strings.ReplaceAll(pin, "-", "")) + payload := int64(c&0xFF)<<31 | int64(FlagIP&0xF)<<27 | int64(p&0x7FFFFFF) + return "X-HM://" + FormatInt36(payload, 9) + setupID +} + +// FormatInt36 equal to strings.ToUpper(fmt.Sprintf("%0"+strconv.Itoa(n)+"s", strconv.FormatInt(value, 36))) +func FormatInt36(value int64, n int) string { + b := make([]byte, n) + for i := n - 1; 0 <= i; i-- { + b[i] = digits[value%36] + value /= 36 + } + return string(b) +} + +const digits = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" diff --git a/installs_on_host/go2rtc/pkg/hap/setup/setup_test.go b/installs_on_host/go2rtc/pkg/hap/setup/setup_test.go new file mode 100644 index 0000000..0167221 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hap/setup/setup_test.go @@ -0,0 +1,18 @@ +package setup + +import ( + "fmt" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFormatAlphaNum(t *testing.T) { + value := int64(999) + n := 5 + s1 := strings.ToUpper(fmt.Sprintf("%0"+strconv.Itoa(n)+"s", strconv.FormatInt(value, 36))) + s2 := FormatInt36(value, n) + require.Equal(t, s1, s2) +} diff --git a/installs_on_host/go2rtc/pkg/hap/tlv8/tlv8.go b/installs_on_host/go2rtc/pkg/hap/tlv8/tlv8.go new file mode 100644 index 0000000..7b397b9 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hap/tlv8/tlv8.go @@ -0,0 +1,386 @@ +package tlv8 + +import ( + "bytes" + "encoding/base64" + "encoding/binary" + "errors" + "fmt" + "io" + "math" + "reflect" + "strconv" +) + +type errReader struct { + err error +} + +func (e *errReader) Read([]byte) (int, error) { + return 0, e.err +} + +func MarshalBase64(v any) (string, error) { + b, err := Marshal(v) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(b), nil +} + +func MarshalReader(v any) io.Reader { + b, err := Marshal(v) + if err != nil { + return &errReader{err: err} + } + return bytes.NewReader(b) +} + +func Marshal(v any) ([]byte, error) { + value := reflect.ValueOf(v) + kind := value.Type().Kind() + + if kind == reflect.Pointer { + value = value.Elem() + kind = value.Type().Kind() + } + + switch kind { + case reflect.Slice: + return appendSlice(nil, value) + case reflect.Struct: + return appendStruct(nil, value) + } + + return nil, errors.New("tlv8: not implemented: " + kind.String()) +} + +// separator the most confusing meaning in the documentation. +// It can have a value of 0x00 or 0xFF or even 0x05. +const separator = 0xFF + +func appendSlice(b []byte, value reflect.Value) ([]byte, error) { + for i := 0; i < value.Len(); i++ { + if i > 0 { + b = append(b, separator, 0) + } + var err error + if b, err = appendStruct(b, value.Index(i)); err != nil { + return nil, err + } + } + return b, nil +} + +func appendStruct(b []byte, value reflect.Value) ([]byte, error) { + valueType := value.Type() + + for i := 0; i < value.NumField(); i++ { + refField := value.Field(i) + s, ok := valueType.Field(i).Tag.Lookup("tlv8") + if !ok { + continue + } + + tag, err := strconv.Atoi(s) + if err != nil { + return nil, err + } + + b, err = appendValue(b, byte(tag), refField) + if err != nil { + return nil, err + } + } + + return b, nil +} + +func appendValue(b []byte, tag byte, value reflect.Value) ([]byte, error) { + var err error + + switch value.Kind() { + case reflect.Uint8: + v := value.Uint() + return append(b, tag, 1, byte(v)), nil + + case reflect.Uint16: + v := value.Uint() + return append(b, tag, 2, byte(v), byte(v>>8)), nil + + case reflect.Uint32: + v := value.Uint() + return append(b, tag, 4, byte(v), byte(v>>8), byte(v>>16), byte(v>>24)), nil + + case reflect.Uint64: + v := value.Uint() + return binary.LittleEndian.AppendUint64(append(b, tag, 8), v), nil + + case reflect.Float32: + v := math.Float32bits(float32(value.Float())) + return append(b, tag, 4, byte(v), byte(v>>8), byte(v>>16), byte(v>>24)), nil + + case reflect.String: + v := value.String() + l := len(v) // support "big" string + for ; l > 255; l -= 255 { + b = append(b, tag, 255) + b = append(b, v[:255]...) + v = v[255:] + } + b = append(b, tag, byte(l)) + return append(b, v...), nil + + case reflect.Array: + if value.Type().Elem().Kind() == reflect.Uint8 { + n := value.Len() + b = append(b, tag, byte(n)) + for i := 0; i < n; i++ { + b = append(b, byte(value.Index(i).Uint())) + } + return b, nil + } + + case reflect.Slice: + for i := 0; i < value.Len(); i++ { + if i > 0 { + b = append(b, separator, 0) + } + if b, err = appendValue(b, tag, value.Index(i)); err != nil { + return nil, err + } + } + return b, nil + + case reflect.Struct: + b = append(b, tag, 0) + i := len(b) + if b, err = appendStruct(b, value); err != nil { + return nil, err + } + b[i-1] = byte(len(b) - i) // set struct size + return b, nil + } + + return nil, errors.New("tlv8: not implemented: " + value.Kind().String()) +} + +func UnmarshalBase64(in any, out any) error { + s, _ := in.(string) // protect from in == nil + data, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return err + } + return Unmarshal(data, out) +} + +func UnmarshalReader(r io.Reader, n int64, v any) error { + var data []byte + var err error + + if n > 0 { + data = make([]byte, n) + _, err = io.ReadFull(r, data) + } else { + data, err = io.ReadAll(r) + } + if err != nil { + return err + } + + return Unmarshal(data, v) +} + +func Unmarshal(data []byte, v any) error { + if len(data) == 0 { + return errors.New("tlv8: unmarshal zero data") + } + + value := reflect.ValueOf(v) + kind := value.Kind() + + if kind != reflect.Pointer { + return errors.New("tlv8: value should be pointer: " + kind.String()) + } + + value = value.Elem() + kind = value.Kind() + + if kind == reflect.Interface { + value = value.Elem() + kind = value.Kind() + } + + switch kind { + case reflect.Slice: + return unmarshalSlice(data, value) + case reflect.Struct: + return unmarshalStruct(data, value) + } + + return errors.New("tlv8: not implemented: " + kind.String()) +} + +// unmarshalTLV can return two types of errors: +// - critical and then the value of []byte will be nil +// - not critical and then []byte will contain the value +func unmarshalTLV(b []byte, value reflect.Value) ([]byte, error) { + if len(b) < 2 { + return nil, errors.New("tlv8: wrong size: " + value.Type().Name()) + } + + t := b[0] + l := int(b[1]) + + // array item divider (t == 0x00 || t == 0xFF) + if l == 0 { + return b[2:], errors.New("tlv8: zero item") + } + + var v []byte + + for { + if len(b) < 2+l { + return nil, errors.New("tlv8: wrong size: " + value.Type().Name()) + } + + v = append(v, b[2:2+l]...) + b = b[2+l:] + + // if size == 255 and same tag - continue read big payload + if l < 255 || len(b) < 2 || b[0] != t { + break + } + + l = int(b[1]) + } + + tag := strconv.Itoa(int(t)) + + valueField, ok := getStructField(value, tag) + if !ok { + return b, fmt.Errorf("tlv8: can't find T=%d,L=%d,V=%x for: %s", t, l, v, value.Type().Name()) + } + + if err := unmarshalValue(v, valueField); err != nil { + return nil, err + } + + return b, nil +} + +func unmarshalSlice(b []byte, value reflect.Value) error { + valueIndex := value.Index(growSlice(value)) + for len(b) > 0 { + var err error + if b, err = unmarshalTLV(b, valueIndex); err != nil { + if b != nil { + valueIndex = value.Index(growSlice(value)) + continue + } + return err + } + } + return nil +} + +func unmarshalStruct(b []byte, value reflect.Value) error { + for len(b) > 0 { + var err error + if b, err = unmarshalTLV(b, value); b == nil && err != nil { + return err + } + } + return nil +} + +func unmarshalValue(v []byte, value reflect.Value) error { + switch value.Kind() { + case reflect.Uint8: + if len(v) != 1 { + return errors.New("tlv8: wrong size: " + value.Type().Name()) + } + value.SetUint(uint64(v[0])) + + case reflect.Uint16: + if len(v) != 2 { + return errors.New("tlv8: wrong size: " + value.Type().Name()) + } + value.SetUint(uint64(v[0]) | uint64(v[1])<<8) + + case reflect.Uint32: + if len(v) != 4 { + return errors.New("tlv8: wrong size: " + value.Type().Name()) + } + value.SetUint(uint64(v[0]) | uint64(v[1])<<8 | uint64(v[2])<<16 | uint64(v[3])<<24) + + case reflect.Uint64: + if len(v) != 8 { + return errors.New("tlv8: wrong size: " + value.Type().Name()) + } + value.SetUint(binary.LittleEndian.Uint64(v)) + + case reflect.Float32: + f := math.Float32frombits(binary.LittleEndian.Uint32(v)) + value.SetFloat(float64(f)) + + case reflect.String: + value.SetString(string(v)) + + case reflect.Array: + if kind := value.Type().Elem().Kind(); kind != reflect.Uint8 { + return errors.New("tlv8: unsupported array: " + kind.String()) + } + + for i, b := range v { + value.Index(i).SetUint(uint64(b)) + } + return nil + + case reflect.Slice: + i := growSlice(value) + return unmarshalValue(v, value.Index(i)) + + case reflect.Struct: + return unmarshalStruct(v, value) + + default: + return errors.New("tlv8: not implemented: " + value.Kind().String()) + } + + return nil +} + +func getStructField(value reflect.Value, tag string) (reflect.Value, bool) { + valueType := value.Type() + + for i := 0; i < value.NumField(); i++ { + valueField := value.Field(i) + + if s, ok := valueType.Field(i).Tag.Lookup("tlv8"); ok && s == tag { + return valueField, true + } + } + + return reflect.Value{}, false +} + +func growSlice(value reflect.Value) int { + size := value.Len() + + if size >= value.Cap() { + newcap := value.Cap() + value.Cap()/2 + if newcap < 4 { + newcap = 4 + } + newValue := reflect.MakeSlice(value.Type(), value.Len(), newcap) + reflect.Copy(newValue, value) + value.Set(newValue) + } + + if size >= value.Len() { + value.SetLen(size + 1) + } + + return size +} diff --git a/installs_on_host/go2rtc/pkg/hap/tlv8/tlv8_test.go b/installs_on_host/go2rtc/pkg/hap/tlv8/tlv8_test.go new file mode 100644 index 0000000..bb44c98 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hap/tlv8/tlv8_test.go @@ -0,0 +1,156 @@ +package tlv8 + +import ( + "encoding/hex" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMarshal(t *testing.T) { + type Struct struct { + Byte byte `tlv8:"1"` + Uint16 uint16 `tlv8:"2"` + Uint32 uint32 `tlv8:"3"` + Float32 float32 `tlv8:"4"` + String string `tlv8:"5"` + Slice []byte `tlv8:"6"` + Array [4]byte `tlv8:"7"` + } + + src := Struct{ + Byte: 1, + Uint16: 2, + Uint32: 3, + Float32: 1.23, + String: "123", + Slice: []byte{1, 2, 3}, + Array: [4]byte{1, 2, 3, 4}, + } + + b, err := Marshal(src) + require.Nil(t, err) + + var dst Struct + err = Unmarshal(b, &dst) + require.Nil(t, err) + + require.Equal(t, src, dst) +} + +func TestBytes(t *testing.T) { + bytes := make([]byte, 255) + for i := 0; i < len(bytes); i++ { + bytes[i] = byte(i) + } + + type Struct struct { + String string `tlv8:"1"` + } + src := Struct{ + String: string(bytes), + } + + b, err := Marshal(src) + require.Nil(t, err) + + var dst Struct + err = Unmarshal(b, &dst) + require.Nil(t, err) + + require.Equal(t, src, dst) + require.Equal(t, bytes, []byte(dst.String)) +} + +func TestVideoCodecParams(t *testing.T) { + type VideoCodecParams struct { + ProfileID []byte `tlv8:"1"` + Level []byte `tlv8:"2"` + PacketizationMode byte `tlv8:"3"` + CVOEnabled []byte `tlv8:"4"` + CVOID []byte `tlv8:"5"` + } + + src, err := hex.DecodeString("0101010201000000020102030100040100") + require.Nil(t, err) + + var v VideoCodecParams + err = Unmarshal(src, &v) + require.Nil(t, err) + + dst, err := Marshal(v) + require.Nil(t, err) + + require.Equal(t, src, dst) +} + +func TestInterface(t *testing.T) { + type Struct struct { + Byte byte `tlv8:"1"` + } + + src := Struct{ + Byte: 1, + } + var v1 any = &src + + b, err := Marshal(v1) + require.Nil(t, err) + + require.Equal(t, []byte{1, 1, 1}, b) + + var dst Struct + var v2 any = &dst + + err = Unmarshal(b, v2) + require.Nil(t, err) + + require.Equal(t, src, dst) +} + +func TestSlice1(t *testing.T) { + var v struct { + VideoAttrs []struct { + Width uint16 `tlv8:"1"` + Height uint16 `tlv8:"2"` + Framerate uint8 `tlv8:"3"` + } `tlv8:"3"` + } + + s := `030b010280070202380403011e ff00 030b010200050202d00203011e` + b1, err := hex.DecodeString(strings.ReplaceAll(s, " ", "")) + require.NoError(t, err) + + err = Unmarshal(b1, &v) + require.NoError(t, err) + + require.Len(t, v.VideoAttrs, 2) + + b2, err := Marshal(v) + require.NoError(t, err) + + require.Equal(t, b1, b2) +} + +func TestSlice2(t *testing.T) { + var v []struct { + Width uint16 `tlv8:"1"` + Height uint16 `tlv8:"2"` + Framerate uint8 `tlv8:"3"` + } + + s := `010280070202380403011e ff00 010200050202d00203011e` + b1, err := hex.DecodeString(strings.ReplaceAll(s, " ", "")) + require.NoError(t, err) + + err = Unmarshal(b1, &v) + require.NoError(t, err) + + require.Len(t, v, 2) + + b2, err := Marshal(v) + require.NoError(t, err) + + require.Equal(t, b1, b2) +} diff --git a/installs_on_host/go2rtc/pkg/hass/api.go b/installs_on_host/go2rtc/pkg/hass/api.go new file mode 100644 index 0000000..1dcedbb --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hass/api.go @@ -0,0 +1,144 @@ +package hass + +import ( + "errors" + "os" + + "github.com/gorilla/websocket" +) + +type API struct { + ws *websocket.Conn +} + +func NewAPI(url, token string) (*API, error) { + ws, _, err := websocket.DefaultDialer.Dial(url, nil) + if err != nil { + return nil, err + } + + api := &API{ws: ws} + if err = api.Auth(token); err != nil { + _ = ws.Close() + return nil, err + } + + return api, nil +} + +func (a *API) Auth(token string) error { + var res ResponseAuth + + if err := a.ws.ReadJSON(&res); err != nil { + return err + } + if res.Type != "auth_required" { + return errors.New("hass: wrong type: " + res.Type) + } + + s := `{"type":"auth","access_token":"` + token + `"}` + if err := a.ws.WriteMessage(websocket.TextMessage, []byte(s)); err != nil { + return err + } + if err := a.ws.ReadJSON(&res); err != nil { + return err + } + if res.Type != "auth_ok" { + return errors.New("hass: wrong type: " + res.Type) + } + + return nil +} + +func (a *API) Close() error { + return a.ws.Close() +} + +func (a *API) ExchangeSDP(entityID, offer string) (string, error) { + var msg = map[string]any{ + "id": 1, + "type": "camera/web_rtc_offer", + "entity_id": entityID, + "offer": offer, + } + if err := a.ws.WriteJSON(msg); err != nil { + return "", err + } + + var res ResponseOffer + if err := a.ws.ReadJSON(&res); err != nil { + return "", err + } + + if res.Type != "result" || !res.Success { + return "", errors.New("hass: wrong response") + } + + return res.Result.Answer, nil +} + +func (a *API) GetWebRTCEntities() (map[string]string, error) { + s := `{"id":1,"type":"get_states"}` + if err := a.ws.WriteMessage(websocket.TextMessage, []byte(s)); err != nil { + return nil, err + } + + var res ResponseStates + if err := a.ws.ReadJSON(&res); err != nil { + return nil, err + } + if res.Type != "result" || !res.Success { + return nil, errors.New("hass: wrong response") + } + + entities := map[string]string{} + + for _, entity := range res.Result { + if entity.Attributes.FrontendStreamType == "web_rtc" { + entities[entity.Attributes.FriendlyName] = entity.EntityId + } + } + + return entities, nil +} + +type ResponseAuth struct { + Type string `json:"type"` +} + +type ResponseStates struct { + //Id int `json:"id"` + Type string `json:"type"` + Success bool `json:"success"` + Result []struct { + EntityId string `json:"entity_id"` + //State string `json:"state"` + Attributes struct { + //ModelName string `json:"model_name"` + //Brand string `json:"brand"` + FrontendStreamType string `json:"frontend_stream_type"` + FriendlyName string `json:"friendly_name"` + //SupportedFeatures int `json:"supported_features"` + } `json:"attributes"` + //LastChanged time.Time `json:"last_changed"` + //LastUpdated time.Time `json:"last_updated"` + //Context struct { + // Id string `json:"id"` + // ParentId interface{} `json:"parent_id"` + // UserId interface{} `json:"user_id"` + //} `json:"context"` + } `json:"result"` +} + +type ResponseOffer struct { + //Id int `json:"id"` + Type string `json:"type"` + Success bool `json:"success"` + Result struct { + Answer string `json:"answer"` + } `json:"result"` +} + +func SupervisorToken() string { + return os.Getenv("SUPERVISOR_TOKEN") +} diff --git a/installs_on_host/go2rtc/pkg/hass/client.go b/installs_on_host/go2rtc/pkg/hass/client.go new file mode 100644 index 0000000..a9ea026 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hass/client.go @@ -0,0 +1,118 @@ +package hass + +import ( + "errors" + "net/url" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/webrtc" + pion "github.com/pion/webrtc/v4" +) + +type Client struct { + conn *webrtc.Conn +} + +func NewClient(rawURL string) (*Client, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + query := u.Query() + + entityID := query.Get("entity_id") + if entityID == "" { + return nil, errors.New("hass: no entity_id") + } + + var uri, token string + + if u.Host == "supervisor" { + uri = "ws://supervisor/core/websocket" + token = SupervisorToken() + } else { + uri = "ws://" + u.Host + "/api/websocket" + token = query.Get("token") + } + + if token == "" { + return nil, errors.New("hass: no token") + } + + // 1. Check connection to Hass + hassAPI, err := NewAPI(uri, token) + if err != nil { + return nil, err + } + + defer hassAPI.Close() + + // 2. Create WebRTC client + rtcAPI, err := webrtc.NewAPI() + if err != nil { + return nil, err + } + + conf := pion.Configuration{} + pc, err := rtcAPI.NewPeerConnection(conf) + if err != nil { + return nil, err + } + + conn := webrtc.NewConn(pc) + conn.FormatName = "hass/webrtc" + conn.Mode = core.ModeActiveProducer + conn.Protocol = "ws" + conn.URL = rawURL + + // https://developers.google.com/nest/device-access/traits/device/camera-live-stream#generatewebrtcstream-request-fields + medias := []*core.Media{ + {Kind: core.KindAudio, Direction: core.DirectionRecvonly}, + {Kind: core.KindVideo, Direction: core.DirectionRecvonly}, + {Kind: "app"}, // important for Nest + } + + // 3. Create offer with candidates + offer, err := conn.CreateCompleteOffer(medias) + if err != nil { + return nil, err + } + + // 4. Exchange SDP via Hass + answer, err := hassAPI.ExchangeSDP(entityID, offer) + if err != nil { + return nil, err + } + + // 5. Set answer with remote medias + if err = conn.SetAnswer(answer); err != nil { + return nil, err + } + + return &Client{conn: conn}, nil +} + +func (c *Client) GetMedias() []*core.Media { + return c.conn.GetMedias() +} + +func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + return c.conn.GetTrack(media, codec) +} + +func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + return c.conn.AddTrack(media, codec, track) +} + +func (c *Client) Start() error { + return c.conn.Start() +} + +func (c *Client) Stop() error { + return c.conn.Stop() +} + +func (c *Client) MarshalJSON() ([]byte, error) { + return c.conn.MarshalJSON() +} diff --git a/installs_on_host/go2rtc/pkg/hls/producer.go b/installs_on_host/go2rtc/pkg/hls/producer.go new file mode 100644 index 0000000..e1c3ed4 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hls/producer.go @@ -0,0 +1,22 @@ +package hls + +import ( + "io" + "net/url" + + "github.com/AlexxIT/go2rtc/pkg/mpegts" +) + +func OpenURL(u *url.URL, body io.ReadCloser) (*mpegts.Producer, error) { + rd, err := NewReader(u, body) + if err != nil { + return nil, err + } + prod, err := mpegts.Open(rd) + if err != nil { + return nil, err + } + prod.FormatName = "hls/mpegts" + prod.RemoteAddr = u.Host + return prod, nil +} diff --git a/installs_on_host/go2rtc/pkg/hls/reader.go b/installs_on_host/go2rtc/pkg/hls/reader.go new file mode 100644 index 0000000..37554e3 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/hls/reader.go @@ -0,0 +1,165 @@ +package hls + +import ( + "bytes" + "io" + "net/http" + "net/url" + "regexp" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +type reader struct { + client *http.Client + request *http.Request + + playlist []byte + lastSegment []byte + lastTime time.Time + + buf []byte +} + +func NewReader(u *url.URL, body io.ReadCloser) (io.Reader, error) { + b, err := io.ReadAll(body) + if err != nil { + return nil, err + } + + var rawURL string + + re := regexp.MustCompile(`#EXT-X-STREAM-INF.+?\n(\S+)`) + m := re.FindSubmatch(b) + if m != nil { + ref, err := url.Parse(string(m[1])) + if err != nil { + return nil, err + } + + rawURL = u.ResolveReference(ref).String() + } else { + rawURL = u.String() + } + + req, err := http.NewRequest("GET", rawURL, nil) + if err != nil { + return nil, err + } + + rd := &reader{ + client: &http.Client{Timeout: core.ConnDialTimeout}, + request: req, + } + return rd, nil +} + +func (r *reader) Read(dst []byte) (n int, err error) { + // 1. Check temporary tempbuffer + if len(r.buf) == 0 { + src, err2 := r.getSegment() + if err2 != nil { + return 0, err2 + } + + // 2. Check if the message fits in the buffer + if len(src) <= len(dst) { + return copy(dst, src), nil + } + + // 3. Put the message into a temporary buffer + r.buf = src + } + + // 4. Send temporary buffer + n = copy(dst, r.buf) + r.buf = r.buf[n:] + return +} + +func (r *reader) Close() error { + r.client.Transport = r // after close we fail on next request + return nil +} + +func (r *reader) RoundTrip(_ *http.Request) (*http.Response, error) { + return nil, io.EOF +} + +func (r *reader) getSegment() ([]byte, error) { + for i := 0; i < 10; i++ { + if r.playlist == nil { + if wait := time.Second - time.Since(r.lastTime); wait > 0 { + time.Sleep(wait) + } + + // 1. Load playlist + res, err := r.client.Do(r.request) + if err != nil { + return nil, err + } + + r.playlist, err = io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + r.lastTime = time.Now() + + //log.Printf("[hls] load playlist\n%s", r.playlist) + } + + for r.playlist != nil { + // 2. Remove all previous segments from playlist + if i := bytes.Index(r.playlist, r.lastSegment); i > 0 { + r.playlist = r.playlist[i:] + } + + // 3. Get link to new segment + segment := getSegment(r.playlist) + if segment == nil { + r.playlist = nil + break + } + + //log.Printf("[hls] load segment: %s", segment) + + ref, err := url.Parse(string(segment)) + if err != nil { + return nil, err + } + + ref = r.request.URL.ResolveReference(ref) + res, err := r.client.Get(ref.String()) + if err != nil { + return nil, err + } + + r.lastSegment = segment + + return io.ReadAll(res.Body) + } + } + + return nil, io.EOF +} + +func getSegment(src []byte) []byte { + for ok := false; !ok; { + ok = bytes.HasPrefix(src, []byte("#EXTINF")) + + i := bytes.IndexByte(src, '\n') + 1 + if i == 0 { + return nil + } + + src = src[i:] + } + + if i := bytes.IndexByte(src, '\n'); i > 0 { + return src[:i] + } + + return src +} diff --git a/installs_on_host/go2rtc/pkg/homekit/consumer.go b/installs_on_host/go2rtc/pkg/homekit/consumer.go new file mode 100644 index 0000000..c1be744 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/homekit/consumer.go @@ -0,0 +1,204 @@ +package homekit + +import ( + "fmt" + "io" + "math/rand" + "net" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/hap/camera" + "github.com/AlexxIT/go2rtc/pkg/opus" + "github.com/AlexxIT/go2rtc/pkg/srtp" + "github.com/pion/rtp" +) + +type Consumer struct { + core.Connection + conn net.Conn + srtp *srtp.Server + + deadline *time.Timer + + sessionID string + videoSession *srtp.Session + audioSession *srtp.Session + audioRTPTime byte +} + +func NewConsumer(conn net.Conn, server *srtp.Server) *Consumer { + medias := []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecH264}, + }, + }, + { + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecOpus}, + }, + }, + } + return &Consumer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "homekit", + Protocol: "rtp", + RemoteAddr: conn.RemoteAddr().String(), + Medias: medias, + Transport: conn, + }, + conn: conn, + srtp: server, + } +} + +func (c *Consumer) SessionID() string { + return c.sessionID +} + +func (c *Consumer) SetOffer(offer *camera.SetupEndpointsRequest) { + c.sessionID = offer.SessionID + c.videoSession = &srtp.Session{ + Remote: &srtp.Endpoint{ + Addr: offer.Address.IPAddr, + Port: offer.Address.VideoRTPPort, + MasterKey: []byte(offer.VideoCrypto.MasterKey), + MasterSalt: []byte(offer.VideoCrypto.MasterSalt), + }, + } + c.audioSession = &srtp.Session{ + Remote: &srtp.Endpoint{ + Addr: offer.Address.IPAddr, + Port: offer.Address.AudioRTPPort, + MasterKey: []byte(offer.AudioCrypto.MasterKey), + MasterSalt: []byte(offer.AudioCrypto.MasterSalt), + }, + } +} + +func (c *Consumer) GetAnswer() *camera.SetupEndpointsResponse { + c.videoSession.Local = c.srtpEndpoint() + c.audioSession.Local = c.srtpEndpoint() + + return &camera.SetupEndpointsResponse{ + SessionID: c.sessionID, + Status: camera.StreamingStatusAvailable, + Address: camera.Address{ + IPAddr: c.videoSession.Local.Addr, + VideoRTPPort: c.videoSession.Local.Port, + AudioRTPPort: c.audioSession.Local.Port, + }, + VideoCrypto: camera.SRTPCryptoSuite{ + MasterKey: string(c.videoSession.Local.MasterKey), + MasterSalt: string(c.videoSession.Local.MasterSalt), + }, + AudioCrypto: camera.SRTPCryptoSuite{ + MasterKey: string(c.audioSession.Local.MasterKey), + MasterSalt: string(c.audioSession.Local.MasterSalt), + }, + VideoSSRC: c.videoSession.Local.SSRC, + AudioSSRC: c.audioSession.Local.SSRC, + } +} + +func (c *Consumer) SetConfig(conf *camera.SelectedStreamConfiguration) bool { + if c.sessionID != conf.Control.SessionID { + return false + } + + c.SDP = fmt.Sprintf("%+v\n%+v", conf.VideoCodec, conf.AudioCodec) + + c.videoSession.Remote.SSRC = conf.VideoCodec.RTPParams[0].SSRC + c.videoSession.PayloadType = conf.VideoCodec.RTPParams[0].PayloadType + c.videoSession.RTCPInterval = toDuration(conf.VideoCodec.RTPParams[0].RTCPInterval) + + c.audioSession.Remote.SSRC = conf.AudioCodec.RTPParams[0].SSRC + c.audioSession.PayloadType = conf.AudioCodec.RTPParams[0].PayloadType + c.audioSession.RTCPInterval = toDuration(conf.AudioCodec.RTPParams[0].RTCPInterval) + c.audioRTPTime = conf.AudioCodec.CodecParams[0].RTPTime[0] + + c.srtp.AddSession(c.videoSession) + c.srtp.AddSession(c.audioSession) + + return true +} + +func (c *Consumer) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + var session *srtp.Session + if codec.Kind() == core.KindVideo { + session = c.videoSession + } else { + session = c.audioSession + } + + sender := core.NewSender(media, track.Codec) + + if c.deadline == nil { + c.deadline = time.NewTimer(time.Second * 30) + + sender.Handler = func(packet *rtp.Packet) { + c.deadline.Reset(core.ConnDeadline) + if n, err := session.WriteRTP(packet); err == nil { + c.Send += n + } + } + } else { + sender.Handler = func(packet *rtp.Packet) { + if n, err := session.WriteRTP(packet); err == nil { + c.Send += n + } + } + } + + switch codec.Name { + case core.CodecH264: + sender.Handler = h264.RTPPay(1378, sender.Handler) + if track.Codec.IsRTP() { + sender.Handler = h264.RTPDepay(track.Codec, sender.Handler) + } else { + sender.Handler = h264.RepairAVCC(track.Codec, sender.Handler) + } + case core.CodecOpus: + sender.Handler = opus.RepackToHAP(c.audioRTPTime, sender.Handler) + } + + sender.HandleRTP(track) + c.Senders = append(c.Senders, sender) + return nil +} + +func (c *Consumer) WriteTo(io.Writer) (int64, error) { + if c.deadline != nil { + <-c.deadline.C + } + return 0, nil +} + +func (c *Consumer) Stop() error { + if c.deadline != nil { + c.deadline.Reset(0) + } + return c.Connection.Stop() +} + +func (c *Consumer) srtpEndpoint() *srtp.Endpoint { + addr := c.conn.LocalAddr().(*net.TCPAddr) + return &srtp.Endpoint{ + Addr: addr.IP.To4().String(), + Port: uint16(c.srtp.Port()), + MasterKey: []byte(core.RandString(16, 0)), + MasterSalt: []byte(core.RandString(14, 0)), + SSRC: rand.Uint32(), + } +} + +func toDuration(seconds float32) time.Duration { + return time.Duration(seconds * float32(time.Second)) +} diff --git a/installs_on_host/go2rtc/pkg/homekit/helpers.go b/installs_on_host/go2rtc/pkg/homekit/helpers.go new file mode 100644 index 0000000..625e3ab --- /dev/null +++ b/installs_on_host/go2rtc/pkg/homekit/helpers.go @@ -0,0 +1,147 @@ +package homekit + +import ( + "encoding/hex" + + "github.com/AlexxIT/go2rtc/pkg/aac" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/hap/camera" +) + +var videoCodecs = [...]string{core.CodecH264} +var videoProfiles = [...]string{"4200", "4D00", "6400"} +var videoLevels = [...]string{"1F", "20", "28"} + +func videoToMedia(codecs []camera.VideoCodecConfiguration) *core.Media { + media := &core.Media{ + Kind: core.KindVideo, Direction: core.DirectionRecvonly, + } + + for _, codec := range codecs { + for _, param := range codec.CodecParams { + // get best profile and level + profileID := core.Max(param.ProfileID) + level := core.Max(param.Level) + profile := videoProfiles[profileID] + videoLevels[level] + mediaCodec := &core.Codec{ + Name: videoCodecs[codec.CodecType], + ClockRate: 90000, + FmtpLine: "profile-level-id=" + profile, + } + media.Codecs = append(media.Codecs, mediaCodec) + } + } + + return media +} + +var audioCodecs = [...]string{core.CodecPCMU, core.CodecPCMA, core.CodecELD, core.CodecOpus} +var audioSampleRates = [...]uint32{8000, 16000, 24000} + +func audioToMedia(codecs []camera.AudioCodecConfiguration) *core.Media { + media := &core.Media{ + Kind: core.KindAudio, Direction: core.DirectionRecvonly, + } + + for _, codec := range codecs { + for _, param := range codec.CodecParams { + for _, sampleRate := range param.SampleRate { + mediaCodec := &core.Codec{ + Name: audioCodecs[codec.CodecType], + ClockRate: audioSampleRates[sampleRate], + Channels: param.Channels, + } + + if mediaCodec.Name == core.CodecELD { + // only this version works with FFmpeg + conf := aac.EncodeConfig(aac.TypeAACELD, 24000, 1, true) + mediaCodec.FmtpLine = aac.FMTP + hex.EncodeToString(conf) + } + + media.Codecs = append(media.Codecs, mediaCodec) + } + } + } + + return media +} + +func trackToVideo(track *core.Receiver, video0 *camera.VideoCodecConfiguration, maxWidth, maxHeight int) *camera.VideoCodecConfiguration { + profileID := video0.CodecParams[0].ProfileID[0] + level := video0.CodecParams[0].Level[0] + var attrs camera.VideoCodecAttributes + + if track != nil { + profile := h264.GetProfileLevelID(track.Codec.FmtpLine) + + for i, s := range videoProfiles { + if s == profile[:4] { + profileID = byte(i) + break + } + } + + for i, s := range videoLevels { + if s == profile[4:] { + level = byte(i) + break + } + } + + for _, s := range video0.VideoAttrs { + if (maxWidth > 0 && int(s.Width) > maxWidth) || (maxHeight > 0 && int(s.Height) > maxHeight) { + continue + } + if s.Width > attrs.Width || s.Height > attrs.Height { + attrs = s + } + } + } + + return &camera.VideoCodecConfiguration{ + CodecType: video0.CodecType, + CodecParams: []camera.VideoCodecParameters{ + { + ProfileID: []byte{profileID}, + Level: []byte{level}, + }, + }, + VideoAttrs: []camera.VideoCodecAttributes{attrs}, + } +} + +func trackToAudio(track *core.Receiver, audio0 *camera.AudioCodecConfiguration) *camera.AudioCodecConfiguration { + codecType := audio0.CodecType + channels := audio0.CodecParams[0].Channels + sampleRate := audio0.CodecParams[0].SampleRate[0] + + if track != nil { + channels = uint8(track.Codec.Channels) + + for i, s := range audioCodecs { + if s == track.Codec.Name { + codecType = byte(i) + break + } + } + + for i, s := range audioSampleRates { + if s == track.Codec.ClockRate { + sampleRate = byte(i) + break + } + } + } + + return &camera.AudioCodecConfiguration{ + CodecType: codecType, + CodecParams: []camera.AudioCodecParameters{ + { + Channels: channels, + SampleRate: []byte{sampleRate}, + RTPTime: []uint8{20}, + }, + }, + } +} diff --git a/installs_on_host/go2rtc/pkg/homekit/log/debug.go b/installs_on_host/go2rtc/pkg/homekit/log/debug.go new file mode 100644 index 0000000..1fb60be --- /dev/null +++ b/installs_on_host/go2rtc/pkg/homekit/log/debug.go @@ -0,0 +1,45 @@ +package log + +import ( + "bytes" + "io" + "log" + "net/http" +) + +func Debug(v any) { + switch v := v.(type) { + case *http.Request: + if v == nil { + return + } + if v.ContentLength != 0 { + b, err := io.ReadAll(v.Body) + if err != nil { + panic(err) + } + v.Body = io.NopCloser(bytes.NewReader(b)) + log.Printf("[homekit] request: %s %s\n%s", v.Method, v.RequestURI, b) + } else { + log.Printf("[homekit] request: %s %s ", v.Method, v.RequestURI) + } + case *http.Response: + if v == nil { + return + } + if v.Header.Get("Content-Type") == "image/jpeg" { + log.Printf("[homekit] response: %d ", v.StatusCode) + return + } + if v.ContentLength != 0 { + b, err := io.ReadAll(v.Body) + if err != nil { + panic(err) + } + v.Body = io.NopCloser(bytes.NewReader(b)) + log.Printf("[homekit] response: %s %d\n%s", v.Proto, v.StatusCode, b) + } else { + log.Printf("[homekit] response: %s %d ", v.Proto, v.StatusCode) + } + } +} diff --git a/installs_on_host/go2rtc/pkg/homekit/producer.go b/installs_on_host/go2rtc/pkg/homekit/producer.go new file mode 100644 index 0000000..81352a1 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/homekit/producer.go @@ -0,0 +1,243 @@ +package homekit + +import ( + "errors" + "fmt" + "math/rand" + "net" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/hap" + "github.com/AlexxIT/go2rtc/pkg/hap/camera" + "github.com/AlexxIT/go2rtc/pkg/srtp" + "github.com/pion/rtp" +) + +// Deprecated: rename to Producer +type Client struct { + core.Connection + + hap *hap.Client + srtp *srtp.Server + + videoConfig camera.SupportedVideoStreamConfiguration + audioConfig camera.SupportedAudioStreamConfiguration + + videoSession *srtp.Session + audioSession *srtp.Session + + stream *camera.Stream + + MaxWidth int `json:"-"` + MaxHeight int `json:"-"` + Bitrate int `json:"-"` // in bits/s +} + +func Dial(rawURL string, server *srtp.Server) (*Client, error) { + conn, err := hap.Dial(rawURL) + if err != nil { + return nil, err + } + + client := &Client{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "homekit", + Protocol: "udp", + RemoteAddr: conn.Conn.RemoteAddr().String(), + Source: rawURL, + Transport: conn, + }, + hap: conn, + srtp: server, + } + + return client, nil +} + +func (c *Client) Conn() net.Conn { + return c.hap.Conn +} + +func (c *Client) GetMedias() []*core.Media { + if c.Medias != nil { + return c.Medias + } + + acc, err := c.hap.GetFirstAccessory() + if err != nil { + return nil + } + + char := acc.GetCharacter(camera.TypeSupportedVideoStreamConfiguration) + if char == nil { + return nil + } + if err = char.ReadTLV8(&c.videoConfig); err != nil { + return nil + } + + char = acc.GetCharacter(camera.TypeSupportedAudioStreamConfiguration) + if char == nil { + return nil + } + if err = char.ReadTLV8(&c.audioConfig); err != nil { + return nil + } + + c.SDP = fmt.Sprintf("%+v\n%+v", c.videoConfig, c.audioConfig) + + c.Medias = []*core.Media{ + videoToMedia(c.videoConfig.Codecs), + audioToMedia(c.audioConfig.Codecs), + { + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + { + Name: core.CodecJPEG, + ClockRate: 90000, + PayloadType: core.PayloadTypeRAW, + }, + }, + }, + } + + return c.Medias +} + +func (c *Client) Start() error { + if c.Receivers == nil { + return errors.New("producer without tracks") + } + + if c.Receivers[0].Codec.Name == core.CodecJPEG { + return c.startMJPEG() + } + + videoTrack := c.trackByKind(core.KindVideo) + videoCodec := trackToVideo(videoTrack, &c.videoConfig.Codecs[0], c.MaxWidth, c.MaxHeight) + + audioTrack := c.trackByKind(core.KindAudio) + audioCodec := trackToAudio(audioTrack, &c.audioConfig.Codecs[0]) + + c.videoSession = &srtp.Session{Local: c.srtpEndpoint()} + c.audioSession = &srtp.Session{Local: c.srtpEndpoint()} + + var err error + c.stream, err = camera.NewStream(c.hap, videoCodec, audioCodec, c.videoSession, c.audioSession, c.Bitrate) + if err != nil { + return err + } + + c.srtp.AddSession(c.videoSession) + c.srtp.AddSession(c.audioSession) + + deadline := time.NewTimer(core.ConnDeadline) + + if videoTrack != nil { + c.videoSession.OnReadRTP = func(packet *rtp.Packet) { + deadline.Reset(core.ConnDeadline) + videoTrack.WriteRTP(packet) + c.Recv += len(packet.Payload) + } + + if audioTrack != nil { + c.audioSession.OnReadRTP = func(packet *rtp.Packet) { + audioTrack.WriteRTP(packet) + c.Recv += len(packet.Payload) + } + } + } else { + c.audioSession.OnReadRTP = func(packet *rtp.Packet) { + deadline.Reset(core.ConnDeadline) + audioTrack.WriteRTP(packet) + c.Recv += len(packet.Payload) + } + } + + if c.audioSession.OnReadRTP != nil { + c.audioSession.OnReadRTP = timekeeper(c.audioSession.OnReadRTP) + } + + <-deadline.C + + return nil +} + +func (c *Client) Stop() error { + if c.videoSession != nil && c.videoSession.Remote != nil { + c.srtp.DelSession(c.videoSession) + } + if c.audioSession != nil && c.audioSession.Remote != nil { + c.srtp.DelSession(c.audioSession) + } + + return c.Connection.Stop() +} + +func (c *Client) trackByKind(kind string) *core.Receiver { + for _, receiver := range c.Receivers { + if receiver.Codec.Kind() == kind { + return receiver + } + } + return nil +} + +func (c *Client) startMJPEG() error { + receiver := c.Receivers[0] + + for { + b, err := c.hap.GetImage(1920, 1080) + if err != nil { + return err + } + + c.Recv += len(b) + + packet := &rtp.Packet{ + Header: rtp.Header{Timestamp: core.Now90000()}, + Payload: b, + } + receiver.WriteRTP(packet) + } +} + +func (c *Client) srtpEndpoint() *srtp.Endpoint { + return &srtp.Endpoint{ + Addr: c.hap.LocalIP(), + Port: uint16(c.srtp.Port()), + MasterKey: []byte(core.RandString(16, 0)), + MasterSalt: []byte(core.RandString(14, 0)), + SSRC: rand.Uint32(), + } +} + +func timekeeper(handler core.HandlerFunc) core.HandlerFunc { + const sampleRate = 16000 + const sampleSize = 480 + + var send time.Duration + var firstTime time.Time + + return func(packet *rtp.Packet) { + now := time.Now() + + if send != 0 { + elapsed := now.Sub(firstTime) * sampleRate / time.Second + if send+sampleSize > elapsed { + return // drop overflow frame + } + } else { + firstTime = now + } + + send += sampleSize + + packet.Timestamp = uint32(send) + + handler(packet) + } +} diff --git a/installs_on_host/go2rtc/pkg/homekit/proxy.go b/installs_on_host/go2rtc/pkg/homekit/proxy.go new file mode 100644 index 0000000..2132266 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/homekit/proxy.go @@ -0,0 +1,218 @@ +package homekit + +import ( + "bufio" + "bytes" + "encoding/json" + "io" + "net" + "net/http" + "time" + + "github.com/AlexxIT/go2rtc/pkg/hap" + "github.com/AlexxIT/go2rtc/pkg/hap/camera" + "github.com/AlexxIT/go2rtc/pkg/hap/hds" + "github.com/AlexxIT/go2rtc/pkg/hap/tlv8" +) + +type ServerProxy interface { + ServerPair + AddConn(conn any) + DelConn(conn any) +} + +func ProxyHandler(srv ServerProxy, acc net.Conn) HandlerFunc { + return func(con net.Conn) error { + defer con.Close() + + pr := &Proxy{ + con: con.(*hap.Conn), + acc: acc.(*hap.Conn), + res: make(chan *http.Response), + } + + // accessory (ex. Camera) => controller (ex. iPhone) + go pr.handleAcc() + + // controller => accessory + return pr.handleCon(srv) + } +} + +type Proxy struct { + con *hap.Conn + acc *hap.Conn + res chan *http.Response +} + +func (p *Proxy) handleCon(srv ServerProxy) error { + var hdsCharIID uint64 + + rd := bufio.NewReader(p.con) + for { + req, err := http.ReadRequest(rd) + if err != nil { + return err + } + + var hdsConSalt string + + switch { + case req.Method == "POST" && req.URL.Path == hap.PathPairings: + var res *http.Response + if res, err = handlePairings(req, srv); err != nil { + return err + } + if err = res.Write(p.con); err != nil { + return err + } + continue + case req.Method == "PUT" && req.URL.Path == hap.PathCharacteristics && hdsCharIID != 0: + body, _ := io.ReadAll(req.Body) + var v hap.JSONCharacters + _ = json.Unmarshal(body, &v) + for _, char := range v.Value { + if char.IID == hdsCharIID { + var hdsReq camera.SetupDataStreamTransportRequest + _ = tlv8.UnmarshalBase64(char.Value, &hdsReq) + hdsConSalt = hdsReq.ControllerKeySalt + break + } + } + req.Body = io.NopCloser(bytes.NewReader(body)) + } + + if err = req.Write(p.acc); err != nil { + return err + } + + res := <-p.res + + switch { + case req.Method == "GET" && req.URL.Path == hap.PathAccessories: + body, _ := io.ReadAll(res.Body) + var v hap.JSONAccessories + if err = json.Unmarshal(body, &v); err != nil { + return err + } + for _, acc := range v.Value { + if char := acc.GetCharacter(camera.TypeSetupDataStreamTransport); char != nil { + hdsCharIID = char.IID + } + break + } + res.Body = io.NopCloser(bytes.NewReader(body)) + + case hdsConSalt != "": + body, _ := io.ReadAll(res.Body) + var v hap.JSONCharacters + _ = json.Unmarshal(body, &v) + for i, char := range v.Value { + if char.IID == hdsCharIID { + var hdsRes camera.SetupDataStreamTransportResponse + _ = tlv8.UnmarshalBase64(char.Value, &hdsRes) + + hdsAccSalt := hdsRes.AccessoryKeySalt + hdsPort := int(hdsRes.TransportTypeSessionParameters.TCPListeningPort) + + // swtich accPort to conPort + hdsPort, err = p.listenHDS(srv, hdsPort, hdsConSalt+hdsAccSalt) + if err != nil { + return err + } + + hdsRes.TransportTypeSessionParameters.TCPListeningPort = uint16(hdsPort) + if v.Value[i].Value, err = tlv8.MarshalBase64(hdsRes); err != nil { + return err + } + body, _ = json.Marshal(v) + res.ContentLength = int64(len(body)) + break + } + } + res.Body = io.NopCloser(bytes.NewReader(body)) + } + + if err = res.Write(p.con); err != nil { + return err + } + } +} + +func (p *Proxy) handleAcc() error { + rd := bufio.NewReader(p.acc) + for { + res, err := hap.ReadResponse(rd, nil) + if err != nil { + return err + } + + if res.Proto == hap.ProtoEvent { + if err = hap.WriteEvent(p.con, res); err != nil { + return err + } + continue + } + + // important to read body before next read response + body, err := io.ReadAll(res.Body) + if err != nil { + return err + } + res.Body = io.NopCloser(bytes.NewReader(body)) + + p.res <- res + } +} + +func (p *Proxy) listenHDS(srv ServerProxy, accPort int, salt string) (int, error) { + // The TCP port range for HDS must be >= 32768. + ln, err := net.ListenTCP("tcp", nil) + if err != nil { + return 0, err + } + + go func() { + defer ln.Close() + + _ = ln.SetDeadline(time.Now().Add(30 * time.Second)) + + // raw controller conn + conn1, err := ln.Accept() + if err != nil { + return + } + + defer conn1.Close() + + // secured controller conn (controlle=false because we are accessory) + con, err := hds.NewConn(conn1, p.con.SharedKey, salt, false) + if err != nil { + return + } + + srv.AddConn(con) + defer srv.DelConn(con) + + accIP := p.acc.RemoteAddr().(*net.TCPAddr).IP + + // raw accessory conn + conn2, err := net.DialTCP("tcp", nil, &net.TCPAddr{IP: accIP, Port: accPort}) + if err != nil { + return + } + defer conn2.Close() + + // secured accessory conn (controller=true because we are controller) + acc, err := hds.NewConn(conn2, p.acc.SharedKey, salt, true) + if err != nil { + return + } + + go io.Copy(con, acc) + _, _ = io.Copy(acc, con) + }() + + conPort := ln.Addr().(*net.TCPAddr).Port + return conPort, nil +} diff --git a/installs_on_host/go2rtc/pkg/homekit/server.go b/installs_on_host/go2rtc/pkg/homekit/server.go new file mode 100644 index 0000000..75ba2a0 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/homekit/server.go @@ -0,0 +1,194 @@ +package homekit + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "strconv" + "strings" + + "github.com/AlexxIT/go2rtc/pkg/hap" + "github.com/AlexxIT/go2rtc/pkg/hap/tlv8" +) + +type HandlerFunc func(net.Conn) error + +type Server interface { + ServerPair + ServerAccessory +} + +type ServerPair interface { + GetPair(id string) []byte + AddPair(id string, public []byte, permissions byte) + DelPair(id string) +} + +type ServerAccessory interface { + GetAccessories(conn net.Conn) []*hap.Accessory + GetCharacteristic(conn net.Conn, aid uint8, iid uint64) any + SetCharacteristic(conn net.Conn, aid uint8, iid uint64, value any) + GetImage(conn net.Conn, width, height int) []byte +} + +func ServerHandler(server Server) HandlerFunc { + return handleRequest(func(conn net.Conn, req *http.Request) (*http.Response, error) { + switch req.URL.Path { + case hap.PathPairings: + return handlePairings(req, server) + + case hap.PathAccessories: + body := hap.JSONAccessories{Value: server.GetAccessories(conn)} + return makeResponse(hap.MimeJSON, body) + + case hap.PathCharacteristics: + switch req.Method { + case "GET": + var v hap.JSONCharacters + + id := req.URL.Query().Get("id") + for _, id = range strings.Split(id, ",") { + s1, s2, _ := strings.Cut(id, ".") + aid, _ := strconv.Atoi(s1) + iid, _ := strconv.ParseUint(s2, 10, 64) + val := server.GetCharacteristic(conn, uint8(aid), iid) + + v.Value = append(v.Value, hap.JSONCharacter{AID: uint8(aid), IID: iid, Value: val}) + } + + return makeResponse(hap.MimeJSON, v) + + case "PUT": + var v struct { + Value []struct { + AID uint8 `json:"aid"` + IID uint64 `json:"iid"` + Value any `json:"value"` + } `json:"characteristics"` + } + if err := json.NewDecoder(req.Body).Decode(&v); err != nil { + return nil, err + } + + for _, char := range v.Value { + server.SetCharacteristic(conn, char.AID, char.IID, char.Value) + } + + res := &http.Response{ + StatusCode: http.StatusNoContent, + Proto: "HTTP", + ProtoMajor: 1, + ProtoMinor: 1, + } + return res, nil + } + + case hap.PathResource: + var v struct { + Width int `json:"image-width"` + Height int `json:"image-height"` + Type string `json:"resource-type"` + } + if err := json.NewDecoder(req.Body).Decode(&v); err != nil { + return nil, err + } + + body := server.GetImage(conn, v.Width, v.Height) + return makeResponse("image/jpeg", body) + } + + return nil, errors.New("hap: unsupported path: " + req.RequestURI) + }) +} + +func handleRequest(handle func(conn net.Conn, req *http.Request) (*http.Response, error)) HandlerFunc { + return func(conn net.Conn) error { + rw := bufio.NewReaderSize(conn, 16*1024) + wr := bufio.NewWriterSize(conn, 16*1024) + for { + req, err := http.ReadRequest(rw) + //debug(req) + if err != nil { + return err + } + + res, err := handle(conn, req) + //debug(res) + if err != nil { + return err + } + + if err = res.Write(wr); err != nil { + return err + } + if err = wr.Flush(); err != nil { + return err + } + } + } +} + +func handlePairings(req *http.Request, srv ServerPair) (*http.Response, error) { + cmd := struct { + Method byte `tlv8:"0"` + Identifier string `tlv8:"1"` + PublicKey string `tlv8:"3"` + State byte `tlv8:"6"` + Permissions byte `tlv8:"11"` + }{} + + if err := tlv8.UnmarshalReader(req.Body, req.ContentLength, &cmd); err != nil { + return nil, err + } + + switch cmd.Method { + case 3: // add + srv.AddPair(cmd.Identifier, []byte(cmd.PublicKey), cmd.Permissions) + case 4: // delete + srv.DelPair(cmd.Identifier) + } + + body := struct { + State byte `tlv8:"6"` + }{ + State: hap.StateM2, + } + + return makeResponse(hap.MimeTLV8, body) +} + +func makeResponse(mime string, v any) (*http.Response, error) { + var body []byte + var err error + + switch mime { + case hap.MimeJSON: + body, err = json.Marshal(v) + case hap.MimeTLV8: + body, err = tlv8.Marshal(v) + case "image/jpeg": + body = v.([]byte) + } + + if err != nil { + return nil, err + } + + res := &http.Response{ + StatusCode: http.StatusOK, + Proto: "HTTP", + ProtoMajor: 1, + ProtoMinor: 1, + Header: http.Header{ + "Content-Type": []string{mime}, + "Content-Length": []string{strconv.Itoa(len(body))}, + }, + ContentLength: int64(len(body)), + Body: io.NopCloser(bytes.NewReader(body)), + } + return res, nil +} diff --git a/installs_on_host/go2rtc/pkg/image/producer.go b/installs_on_host/go2rtc/pkg/image/producer.go new file mode 100644 index 0000000..2081c04 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/image/producer.go @@ -0,0 +1,92 @@ +package image + +import ( + "errors" + "io" + "net/http" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/tcp" + "github.com/pion/rtp" +) + +type Producer struct { + core.Connection + + closed bool + res *http.Response +} + +func Open(res *http.Response) (*Producer, error) { + return &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "image", + Protocol: "http", + RemoteAddr: res.Request.URL.Host, + Transport: res.Body, + Medias: []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + { + Name: core.CodecJPEG, + ClockRate: 90000, + PayloadType: core.PayloadTypeRAW, + }, + }, + }, + }, + }, + res: res, + }, nil +} + +func (c *Producer) Start() error { + body, err := io.ReadAll(c.res.Body) + if err != nil { + return err + } + + pkt := &rtp.Packet{ + Header: rtp.Header{Timestamp: core.Now90000()}, + Payload: body, + } + c.Receivers[0].WriteRTP(pkt) + + c.Recv += len(body) + + req := c.res.Request + + for !c.closed { + res, err := tcp.Do(req) + if err != nil { + return err + } + + if res.StatusCode != http.StatusOK { + return errors.New("wrong status: " + res.Status) + } + + body, err = io.ReadAll(res.Body) + if err != nil { + return err + } + + c.Recv += len(body) + + pkt = &rtp.Packet{ + Header: rtp.Header{Timestamp: core.Now90000()}, + Payload: body, + } + c.Receivers[0].WriteRTP(pkt) + } + + return nil +} + +func (c *Producer) Stop() error { + c.closed = true + return c.Connection.Stop() +} diff --git a/installs_on_host/go2rtc/pkg/ioctl/README.md b/installs_on_host/go2rtc/pkg/ioctl/README.md new file mode 100644 index 0000000..41f82df --- /dev/null +++ b/installs_on_host/go2rtc/pkg/ioctl/README.md @@ -0,0 +1,3 @@ +# IOCTL + +This is just an example how Linux IOCTL constants works. diff --git a/installs_on_host/go2rtc/pkg/ioctl/ioctl.go b/installs_on_host/go2rtc/pkg/ioctl/ioctl.go new file mode 100644 index 0000000..0f21e17 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/ioctl/ioctl.go @@ -0,0 +1,28 @@ +package ioctl + +import ( + "bytes" +) + +func Str(b []byte) string { + if i := bytes.IndexByte(b, 0); i >= 0 { + return string(b[:i]) + } + return string(b) +} + +func io(mode byte, type_ byte, number byte, size uint16) uintptr { + return uintptr(mode)<<30 | uintptr(size)<<16 | uintptr(type_)<<8 | uintptr(number) +} + +func IOR(type_ byte, number byte, size uint16) uintptr { + return io(read, type_, number, size) +} + +func IOW(type_ byte, number byte, size uint16) uintptr { + return io(write, type_, number, size) +} + +func IORW(type_ byte, number byte, size uint16) uintptr { + return io(read|write, type_, number, size) +} diff --git a/installs_on_host/go2rtc/pkg/ioctl/ioctl_be.go b/installs_on_host/go2rtc/pkg/ioctl/ioctl_be.go new file mode 100644 index 0000000..60de9c4 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/ioctl/ioctl_be.go @@ -0,0 +1,8 @@ +//go:build arm || arm64 || 386 || amd64 + +package ioctl + +const ( + write = 1 + read = 2 +) diff --git a/installs_on_host/go2rtc/pkg/ioctl/ioctl_le.go b/installs_on_host/go2rtc/pkg/ioctl/ioctl_le.go new file mode 100644 index 0000000..3bdb1f6 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/ioctl/ioctl_le.go @@ -0,0 +1,8 @@ +//go:build mipsle + +package ioctl + +const ( + read = 1 + write = 2 +) diff --git a/installs_on_host/go2rtc/pkg/ioctl/ioctl_linux.go b/installs_on_host/go2rtc/pkg/ioctl/ioctl_linux.go new file mode 100644 index 0000000..ed38f6a --- /dev/null +++ b/installs_on_host/go2rtc/pkg/ioctl/ioctl_linux.go @@ -0,0 +1,14 @@ +package ioctl + +import ( + "syscall" + "unsafe" +) + +func Ioctl(fd int, req uint, arg unsafe.Pointer) error { + _, _, err := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), uintptr(req), uintptr(arg)) + if err != 0 { + return err + } + return nil +} diff --git a/installs_on_host/go2rtc/pkg/ioctl/ioctl_test.go b/installs_on_host/go2rtc/pkg/ioctl/ioctl_test.go new file mode 100644 index 0000000..52657e6 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/ioctl/ioctl_test.go @@ -0,0 +1,16 @@ +package ioctl + +import ( + "runtime" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIOR(t *testing.T) { + // #define SNDRV_PCM_IOCTL_INFO _IOR('A', 0x01, struct snd_pcm_info) + if runtime.GOARCH == "arm64" { + c := IOR('A', 0x01, 288) + require.Equal(t, uintptr(0x81204101), c) + } +} diff --git a/installs_on_host/go2rtc/pkg/isapi/backchannel.go b/installs_on_host/go2rtc/pkg/isapi/backchannel.go new file mode 100644 index 0000000..ade1625 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/isapi/backchannel.go @@ -0,0 +1,69 @@ +package isapi + +import ( + "encoding/json" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +func (c *Client) GetMedias() []*core.Media { + return c.medias +} + +func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + return nil, core.ErrCantGetTrack +} + +func (c *Client) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error { + if c.sender == nil { + c.sender = core.NewSender(media, track.Codec) + c.sender.Handler = func(packet *rtp.Packet) { + if c.conn == nil { + return + } + c.send += len(packet.Payload) + _, _ = c.conn.Write(packet.Payload) + } + } + + c.sender.HandleRTP(track) + return nil +} + +func (c *Client) Start() (err error) { + if err = c.Open(); err != nil { + return + } + return +} + +func (c *Client) Stop() (err error) { + if c.sender != nil { + c.sender.Close() + } + + if c.conn != nil { + _ = c.Close() + return c.conn.Close() + } + + return nil +} + +func (c *Client) MarshalJSON() ([]byte, error) { + info := &core.Connection{ + ID: core.ID(c), + FormatName: "isapi", + Protocol: "http", + Medias: c.medias, + Send: c.send, + } + if c.conn != nil { + info.RemoteAddr = c.conn.RemoteAddr().String() + } + if c.sender != nil { + info.Senders = []*core.Sender{c.sender} + } + return json.Marshal(info) +} diff --git a/installs_on_host/go2rtc/pkg/isapi/client.go b/installs_on_host/go2rtc/pkg/isapi/client.go new file mode 100644 index 0000000..ba3e688 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/isapi/client.go @@ -0,0 +1,164 @@ +package isapi + +import ( + "errors" + "io" + "net" + "net/http" + "net/url" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/tcp" +) + +// Deprecated: should be rewritten to core.Connection +type Client struct { + core.Listener + + url string + channel string + conn net.Conn + + medias []*core.Media + sender *core.Sender + send int +} + +func Dial(rawURL string) (*Client, error) { + // check if url is valid url + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + u.Scheme = "http" + u.Path = "" + + client := &Client{url: u.String()} + if err = client.Dial(); err != nil { + return nil, err + } + return client, err +} + +func (c *Client) Dial() (err error) { + link := c.url + "/ISAPI/System/TwoWayAudio/channels" + req, err := http.NewRequest("GET", link, nil) + if err != nil { + return err + } + + res, err := tcp.Do(req) + if err != nil { + return + } + + if res.StatusCode != http.StatusOK { + tcp.Close(res) + return errors.New(res.Status) + } + + b, err := io.ReadAll(res.Body) + if err != nil { + return err + } + + xml := string(b) + + codec := core.Between(xml, ``, `<`) + switch codec { + case "G.711ulaw": + codec = core.CodecPCMU + case "G.711alaw": + codec = core.CodecPCMA + default: + return nil + } + + c.channel = core.Between(xml, ``, `<`) + + media := &core.Media{ + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: codec, ClockRate: 8000}, + }, + } + c.medias = append(c.medias, media) + + return nil +} + +func (c *Client) Open() (err error) { + // Hikvision ISAPI may not accept a new open request if the previous one was not closed (e.g. + // using the test button on-camera or via curl command) but a close request can be sent even if + // the audio is already closed. So, we send a close request first and then open it again. Seems + // janky but it works. + if err = c.Close(); err != nil { + return err + } + + link := c.url + "/ISAPI/System/TwoWayAudio/channels/" + c.channel + req, err := http.NewRequest("PUT", link+"/open", nil) + if err != nil { + return err + } + + res, err := tcp.Do(req) + if err != nil { + return + } + + tcp.Close(res) + + ctx, pconn := tcp.WithConn() + req, err = http.NewRequestWithContext(ctx, "PUT", link+"/audioData", nil) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("Content-Length", "0") + + res, err = tcp.Do(req) + if err != nil { + return err + } + + c.conn = *pconn + + // just block until c.conn closed + b := make([]byte, 1) + _, _ = c.conn.Read(b) + + tcp.Close(res) + + return nil +} + +func (c *Client) Close() (err error) { + link := c.url + "/ISAPI/System/TwoWayAudio/channels/" + c.channel + req, err := http.NewRequest("PUT", link+"/close", nil) + if err != nil { + return err + } + + res, err := tcp.Do(req) + if err != nil { + return err + } + + tcp.Close(res) + + return nil +} + +//type XMLChannels struct { +// Channels []Channel `xml:"TwoWayAudioChannel"` +//} + +//type Channel struct { +// ID string `xml:"id"` +// Enabled string `xml:"enabled"` +// Codec string `xml:"audioCompressionType"` +//} diff --git a/installs_on_host/go2rtc/pkg/iso/atoms.go b/installs_on_host/go2rtc/pkg/iso/atoms.go new file mode 100644 index 0000000..945622e --- /dev/null +++ b/installs_on_host/go2rtc/pkg/iso/atoms.go @@ -0,0 +1,339 @@ +package iso + +const ( + Ftyp = "ftyp" + Moov = "moov" + MoovMvhd = "mvhd" + MoovTrak = "trak" + MoovTrakTkhd = "tkhd" + MoovTrakMdia = "mdia" + MoovTrakMdiaMdhd = "mdhd" + MoovTrakMdiaHdlr = "hdlr" + MoovTrakMdiaMinf = "minf" + MoovTrakMdiaMinfVmhd = "vmhd" + MoovTrakMdiaMinfSmhd = "smhd" + MoovTrakMdiaMinfDinf = "dinf" + MoovTrakMdiaMinfDinfDref = "dref" + MoovTrakMdiaMinfDinfDrefUrl = "url " + MoovTrakMdiaMinfStbl = "stbl" + MoovTrakMdiaMinfStblStsd = "stsd" + MoovTrakMdiaMinfStblStts = "stts" + MoovTrakMdiaMinfStblStsc = "stsc" + MoovTrakMdiaMinfStblStsz = "stsz" + MoovTrakMdiaMinfStblStco = "stco" + MoovMvex = "mvex" + MoovMvexTrex = "trex" + Moof = "moof" + MoofMfhd = "mfhd" + MoofTraf = "traf" + MoofTrafTfhd = "tfhd" + MoofTrafTfdt = "tfdt" + MoofTrafTrun = "trun" + Mdat = "mdat" +) + +const ( + sampleIsNonSync = 0x10000 + sampleDependsOn1 = 0x1000000 + sampleDependsOn2 = 0x2000000 + + SampleVideoIFrame = sampleDependsOn2 + SampleVideoNonIFrame = sampleDependsOn1 | sampleIsNonSync + SampleAudio = sampleDependsOn2 //sampleIsNonSync +) + +func (m *Movie) WriteFileType() { + m.StartAtom(Ftyp) + m.WriteString("iso5") + m.WriteUint32(512) + m.WriteString("iso5") + m.WriteString("iso6") + m.WriteString("mp41") + m.EndAtom() +} + +func (m *Movie) WriteMovieHeader() { + m.StartAtom(MoovMvhd) + m.Skip(1) // version + m.Skip(3) // flags + m.Skip(4) // create time + m.Skip(4) // modify time + m.WriteUint32(1000) // time scale + m.Skip(4) // duration + m.WriteFloat32(1) // preferred rate + m.WriteFloat16(1) // preferred volume + m.Skip(10) // reserved + m.WriteMatrix() + m.Skip(6 * 4) // predefined? + m.WriteUint32(0xFFFFFFFF) // next track ID + m.EndAtom() +} + +func (m *Movie) WriteTrackHeader(id uint32, width, height uint16) { + const ( + TkhdTrackEnabled = 0x0001 + TkhdTrackInMovie = 0x0002 + TkhdTrackInPreview = 0x0004 + TkhdTrackInPoster = 0x0008 + ) + + // https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-32963 + m.StartAtom(MoovTrakTkhd) + m.Skip(1) // version + m.WriteUint24(TkhdTrackEnabled | TkhdTrackInMovie) + m.Skip(4) // create time + m.Skip(4) // modify time + m.WriteUint32(id) // trackID + m.Skip(4) // reserved + m.Skip(4) // duration + m.Skip(8) // reserved + m.Skip(2) // layer + if width > 0 { + m.Skip(2) + m.Skip(2) + } else { + m.WriteUint16(1) // alternate group + m.WriteFloat16(1) // volume + } + m.Skip(2) // reserved + m.WriteMatrix() + if width > 0 { + m.WriteFloat32(float64(width)) + m.WriteFloat32(float64(height)) + } else { + m.Skip(4) + m.Skip(4) + } + m.EndAtom() +} + +func (m *Movie) WriteMediaHeader(timescale uint32) { + // https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-32999 + m.StartAtom(MoovTrakMdiaMdhd) + m.Skip(1) // version + m.Skip(3) // flags + m.Skip(4) // creation time + m.Skip(4) // modification time + m.WriteUint32(timescale) // timescale + m.Skip(4) // duration + m.WriteUint16(0x55C4) // language (Unspecified) + m.Skip(2) // quality + m.EndAtom() +} + +func (m *Movie) WriteMediaHandler(s, name string) { + // https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-33004 + m.StartAtom(MoovTrakMdiaHdlr) + m.Skip(1) // version + m.Skip(3) // flags + m.Skip(4) + m.WriteString(s) // handler type (4 byte!) + m.Skip(3 * 4) // reserved + m.WriteString(name) // handler name (any len) + m.Skip(1) // end string + m.EndAtom() +} + +func (m *Movie) WriteVideoMediaInfo() { + // https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-33012 + m.StartAtom(MoovTrakMdiaMinfVmhd) + m.Skip(1) // version + m.WriteUint24(1) // flags (You should always set this flag to 1) + m.Skip(2) // graphics mode + m.Skip(3 * 2) // op color + m.EndAtom() +} + +func (m *Movie) WriteAudioMediaInfo() { + m.StartAtom(MoovTrakMdiaMinfSmhd) + m.Skip(1) // version + m.Skip(3) // flags + m.Skip(4) // balance + m.EndAtom() +} + +func (m *Movie) WriteDataInfo() { + // https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-25680 + m.StartAtom(MoovTrakMdiaMinfDinf) + m.StartAtom(MoovTrakMdiaMinfDinfDref) + m.Skip(1) // version + m.Skip(3) // flags + m.WriteUint32(1) // childrens + + m.StartAtom(MoovTrakMdiaMinfDinfDrefUrl) + m.Skip(1) // version + m.WriteUint24(1) // flags (self reference) + m.EndAtom() + + m.EndAtom() // DREF + m.EndAtom() // DINF +} + +func (m *Movie) WriteSampleTable(writeSampleDesc func()) { + // https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-33040 + m.StartAtom(MoovTrakMdiaMinfStbl) + + m.StartAtom(MoovTrakMdiaMinfStblStsd) + m.Skip(1) // version + m.Skip(3) // flags + m.WriteUint32(1) // entry count + writeSampleDesc() + m.EndAtom() + + m.StartAtom(MoovTrakMdiaMinfStblStts) + m.Skip(1) // version + m.Skip(3) // flags + m.Skip(4) // entry count + m.EndAtom() + + m.StartAtom(MoovTrakMdiaMinfStblStsc) + m.Skip(1) // version + m.Skip(3) // flags + m.Skip(4) // entry count + m.EndAtom() + + m.StartAtom(MoovTrakMdiaMinfStblStsz) + m.Skip(1) // version + m.Skip(3) // flags + m.Skip(4) // sample size + m.Skip(4) // entry count + m.EndAtom() + + m.StartAtom(MoovTrakMdiaMinfStblStco) + m.Skip(1) // version + m.Skip(3) // flags + m.Skip(4) // entry count + m.EndAtom() + + m.EndAtom() +} + +func (m *Movie) WriteTrackExtend(id uint32) { + m.StartAtom(MoovMvexTrex) + m.Skip(1) // version + m.Skip(3) // flags + m.WriteUint32(id) // trackID + m.WriteUint32(1) // default sample description index + m.Skip(4) // default sample duration + m.Skip(4) // default sample size + m.Skip(4) // default sample flags + m.EndAtom() +} + +func (m *Movie) WriteVideoTrack(id uint32, codec string, timescale uint32, width, height uint16, conf []byte) { + m.StartAtom(MoovTrak) + m.WriteTrackHeader(id, width, height) + + m.StartAtom(MoovTrakMdia) + m.WriteMediaHeader(timescale) + m.WriteMediaHandler("vide", "VideoHandler") + + m.StartAtom(MoovTrakMdiaMinf) + m.WriteVideoMediaInfo() + m.WriteDataInfo() + m.WriteSampleTable(func() { + m.WriteVideo(codec, width, height, conf) + }) + m.EndAtom() // MINF + + m.EndAtom() // MDIA + m.EndAtom() // TRAK +} + +func (m *Movie) WriteAudioTrack(id uint32, codec string, timescale uint32, channels uint16, conf []byte) { + m.StartAtom(MoovTrak) + m.WriteTrackHeader(id, 0, 0) + + m.StartAtom(MoovTrakMdia) + m.WriteMediaHeader(timescale) + m.WriteMediaHandler("soun", "SoundHandler") + + m.StartAtom(MoovTrakMdiaMinf) + m.WriteAudioMediaInfo() + m.WriteDataInfo() + m.WriteSampleTable(func() { + m.WriteAudio(codec, channels, timescale, conf) + }) + m.EndAtom() // MINF + + m.EndAtom() // MDIA + m.EndAtom() // TRAK +} + +const ( + TfhdDefaultSampleDuration = 0x000008 + TfhdDefaultSampleSize = 0x000010 + TfhdDefaultSampleFlags = 0x000020 + TfhdDefaultBaseIsMoof = 0x020000 +) + +const ( + TrunDataOffset = 0x000001 + TrunFirstSampleFlags = 0x000004 + TrunSampleDuration = 0x0000100 + TrunSampleSize = 0x0000200 + TrunSampleFlags = 0x0000400 + TrunSampleCTS = 0x0000800 +) + +func (m *Movie) WriteMovieFragment(seq, tid, duration, size, flags uint32, dts uint64, cts uint32) { + m.StartAtom(Moof) + + m.StartAtom(MoofMfhd) + m.Skip(1) // version + m.Skip(3) // flags + m.WriteUint32(seq) // sequence number + m.EndAtom() + + m.StartAtom(MoofTraf) + + m.StartAtom(MoofTrafTfhd) + m.Skip(1) // version + m.WriteUint24( + TfhdDefaultSampleDuration | + TfhdDefaultSampleSize | + TfhdDefaultSampleFlags | + TfhdDefaultBaseIsMoof, + ) + m.WriteUint32(tid) // track id + m.WriteUint32(duration) // default sample duration + m.WriteUint32(size) // default sample size + m.WriteUint32(flags) // default sample flags + m.EndAtom() + + m.StartAtom(MoofTrafTfdt) + m.WriteBytes(1) // version + m.Skip(3) // flags + m.WriteUint64(dts) // base media decode time + m.EndAtom() + + m.StartAtom(MoofTrafTrun) + m.Skip(1) // version + + if cts == 0 { + m.WriteUint24(TrunDataOffset) // flags + m.WriteUint32(1) // sample count + + // data offset: current pos + uint32 len + MDAT header len + m.WriteUint32(uint32(len(m.b)) + 4 + 8) + } else { + m.WriteUint24(TrunDataOffset | TrunSampleCTS) + m.WriteUint32(1) + + // data offset: current pos + uint32 len + CTS + MDAT header len + m.WriteUint32(uint32(len(m.b)) + 4 + 4 + 8) + m.WriteUint32(cts) + } + + m.EndAtom() // TRUN + + m.EndAtom() // TRAF + + m.EndAtom() // MOOF +} + +func (m *Movie) WriteData(b []byte) { + m.StartAtom(Mdat) + m.Write(b) + m.EndAtom() +} diff --git a/installs_on_host/go2rtc/pkg/iso/codecs.go b/installs_on_host/go2rtc/pkg/iso/codecs.go new file mode 100644 index 0000000..f4a83c7 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/iso/codecs.go @@ -0,0 +1,181 @@ +package iso + +import ( + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/pcm" +) + +func (m *Movie) WriteVideo(codec string, width, height uint16, conf []byte) { + // https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap3/qtff3.html + switch codec { + case core.CodecH264: + m.StartAtom("avc1") + case core.CodecH265: + m.StartAtom("hev1") + default: + panic("unsupported iso video: " + codec) + } + m.Skip(6) + m.WriteUint16(1) // data_reference_index + m.Skip(2) // version + m.Skip(2) // revision + m.Skip(4) // vendor + m.Skip(4) // temporal quality + m.Skip(4) // spatial quality + m.WriteUint16(width) // width + m.WriteUint16(height) // height + m.WriteFloat32(72) // horizontal resolution + m.WriteFloat32(72) // vertical resolution + m.Skip(4) // reserved + m.WriteUint16(1) // frame count + m.Skip(32) // compressor name + m.WriteUint16(24) // depth + m.WriteUint16(0xFFFF) // color table id (-1) + + switch codec { + case core.CodecH264: + m.StartAtom("avcC") + case core.CodecH265: + m.StartAtom("hvcC") + } + m.Write(conf) + m.EndAtom() // AVCC + + m.StartAtom("pasp") // Pixel Aspect Ratio + m.WriteUint32(1) // hSpacing + m.WriteUint32(1) // vSpacing + m.EndAtom() + + m.EndAtom() // AVC1 +} + +func (m *Movie) WriteAudio(codec string, channels uint16, sampleRate uint32, conf []byte) { + switch codec { + case core.CodecAAC, core.CodecMP3: + m.StartAtom("mp4a") // supported in all players and browsers + case core.CodecFLAC: + m.StartAtom("fLaC") // supported in all players and browsers + case core.CodecOpus: + m.StartAtom("Opus") // supported in Chrome and Firefox + case core.CodecPCMU: + m.StartAtom("ulaw") + case core.CodecPCMA: + m.StartAtom("alaw") + default: + panic("unsupported iso audio: " + codec) + } + + if channels == 0 { + channels = 1 + } + + m.Skip(6) + m.WriteUint16(1) // data_reference_index + m.Skip(2) // version + m.Skip(2) // revision + m.Skip(4) // vendor + m.WriteUint16(channels) // channel_count + m.WriteUint16(16) // sample_size + m.Skip(2) // compression id + m.Skip(2) // reserved + m.WriteFloat32(float64(sampleRate)) // sample_rate + + switch codec { + case core.CodecAAC: + m.WriteEsdsAAC(conf) + case core.CodecMP3: + m.WriteEsdsMP3() + case core.CodecFLAC: + m.StartAtom("dfLa") + m.Write(pcm.FLACHeader(false, sampleRate)) + m.EndAtom() + case core.CodecOpus: + m.WriteOpus(channels, sampleRate) + case core.CodecPCMU, core.CodecPCMA: + // don't know what means this magic + m.StartAtom("chan") + m.WriteBytes(0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 4, 0, 0, 0, 0) + m.EndAtom() + } + + m.EndAtom() // MP4A/OPUS +} + +func (m *Movie) WriteEsdsAAC(conf []byte) { + m.StartAtom("esds") + m.Skip(1) // version + m.Skip(3) // flags + + // MP4ESDescrTag[3]: + // - MP4DecConfigDescrTag[4]: + // - MP4DecSpecificDescrTag[5]: conf + // - Other[6] + const header = 5 + const size3 = 3 + const size4 = 13 + size5 := byte(len(conf)) + const size6 = 1 + + m.WriteBytes(3, 0x80, 0x80, 0x80, size3+header+size4+header+size5+header+size6) + m.Skip(2) // es id + m.Skip(1) // es flags + + // https://learn.microsoft.com/en-us/windows/win32/medfound/mpeg-4-file-sink#aac-audio + m.WriteBytes(4, 0x80, 0x80, 0x80, size4+header+size5) + m.WriteBytes(0x40) // object id + m.WriteBytes(0x15) // stream type + m.Skip(3) // buffer size db + m.Skip(4) // max bitraga + m.Skip(4) // avg bitraga + + m.WriteBytes(5, 0x80, 0x80, 0x80, size5) + m.Write(conf) + + m.WriteBytes(6, 0x80, 0x80, 0x80, 1) + m.WriteBytes(2) // ? + + m.EndAtom() // ESDS +} + +func (m *Movie) WriteEsdsMP3() { + m.StartAtom("esds") + m.Skip(1) // version + m.Skip(3) // flags + + // MP4ESDescrTag[3]: + // - MP4DecConfigDescrTag[4]: + // - Other[6] + const header = 5 + const size3 = 3 + const size4 = 13 + const size6 = 1 + + m.WriteBytes(3, 0x80, 0x80, 0x80, size3+header+size4+header+size6) + m.Skip(2) // es id + m.Skip(1) // es flags + + // https://learn.microsoft.com/en-us/windows/win32/medfound/mpeg-4-file-sink#mp3-audio + m.WriteBytes(4, 0x80, 0x80, 0x80, size4) + m.WriteBytes(0x6B) // object id + m.WriteBytes(0x15) // stream type + m.Skip(3) // buffer size db + m.Skip(4) // max bitraga + m.Skip(4) // avg bitraga + + m.WriteBytes(6, 0x80, 0x80, 0x80, 1) + m.WriteBytes(2) // ? + + m.EndAtom() // ESDS +} + +func (m *Movie) WriteOpus(channels uint16, sampleRate uint32) { + // https://www.opus-codec.org/docs/opus_in_isobmff.html + m.StartAtom("dOps") + m.Skip(1) // version + m.WriteBytes(byte(channels)) + m.WriteUint16(0) // PreSkip ??? + m.WriteUint32(sampleRate) + m.Skip(2) // OutputGain + m.Skip(1) // signed int(16) OutputGain; + m.EndAtom() +} diff --git a/installs_on_host/go2rtc/pkg/iso/iso.go b/installs_on_host/go2rtc/pkg/iso/iso.go new file mode 100644 index 0000000..8989072 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/iso/iso.go @@ -0,0 +1,91 @@ +package iso + +import ( + "encoding/binary" + "math" +) + +type Movie struct { + b []byte + start []int +} + +func NewMovie(size int) *Movie { + return &Movie{b: make([]byte, 0, size)} +} + +func (m *Movie) Bytes() []byte { + return m.b +} + +func (m *Movie) StartAtom(name string) { + m.start = append(m.start, len(m.b)) + m.b = append(m.b, 0, 0, 0, 0) + m.b = append(m.b, name...) +} + +func (m *Movie) EndAtom() { + n := len(m.start) - 1 + + i := m.start[n] + size := uint32(len(m.b) - i) + binary.BigEndian.PutUint32(m.b[i:], size) + + m.start = m.start[:n] +} + +func (m *Movie) Write(b []byte) { + m.b = append(m.b, b...) +} + +func (m *Movie) WriteBytes(b ...byte) { + m.b = append(m.b, b...) +} + +func (m *Movie) WriteString(s string) { + m.b = append(m.b, s...) +} + +func (m *Movie) Skip(n int) { + m.b = append(m.b, make([]byte, n)...) +} + +func (m *Movie) WriteUint16(v uint16) { + m.b = append(m.b, byte(v>>8), byte(v)) +} + +func (m *Movie) WriteUint24(v uint32) { + m.b = append(m.b, byte(v>>16), byte(v>>8), byte(v)) +} + +func (m *Movie) WriteUint32(v uint32) { + m.b = append(m.b, byte(v>>24), byte(v>>16), byte(v>>8), byte(v)) +} + +func (m *Movie) WriteUint64(v uint64) { + m.b = append(m.b, byte(v>>56), byte(v>>48), byte(v>>40), byte(v>>32), byte(v>>24), byte(v>>16), byte(v>>8), byte(v)) +} + +func (m *Movie) WriteFloat16(f float64) { + i, f := math.Modf(f) + f *= 256 + m.b = append(m.b, byte(i), byte(f)) +} + +func (m *Movie) WriteFloat32(f float64) { + i, f := math.Modf(f) + f *= 65536 + m.b = append(m.b, byte(uint16(i)>>8), byte(i), byte(uint16(f)>>8), byte(f)) +} + +func (m *Movie) WriteMatrix() { + m.WriteUint32(0x00010000) + m.Skip(4) + m.Skip(4) + m.Skip(4) + m.WriteUint32(0x00010000) + m.Skip(4) + m.Skip(4) + m.Skip(4) + m.WriteUint32(0x40000000) +} diff --git a/installs_on_host/go2rtc/pkg/iso/reader.go b/installs_on_host/go2rtc/pkg/iso/reader.go new file mode 100644 index 0000000..175e256 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/iso/reader.go @@ -0,0 +1,203 @@ +package iso + +import ( + "bytes" + "encoding/binary" + "io" + + "github.com/AlexxIT/go2rtc/pkg/bits" +) + +type Atom struct { + Name string + Data []byte +} + +type AtomTkhd struct { + TrackID uint32 +} + +type AtomMdhd struct { + TimeScale uint32 +} + +type AtomVideo struct { + Name string + Config []byte +} + +type AtomAudio struct { + Name string + Channels uint16 + SampleRate uint32 + Config []byte +} + +type AtomMfhd struct { + Sequence uint32 +} + +type AtomMdat struct { + Data []byte +} + +type AtomTfhd struct { + TrackID uint32 + SampleDuration uint32 + SampleSize uint32 + SampleFlags uint32 +} +type AtomTfdt struct { + DecodeTime uint64 +} + +type AtomTrun struct { + DataOffset uint32 + FirstSampleFlags uint32 + SamplesDuration []uint32 + SamplesSize []uint32 + SamplesFlags []uint32 + SamplesCTS []uint32 +} + +func DecodeAtom(b []byte) (any, error) { + size := binary.BigEndian.Uint32(b) + if len(b) < int(size) { + return nil, io.EOF + } + + name := string(b[4:8]) + data := b[8:size] + + switch name { + // useful containers + case Moov, MoovTrak, MoovTrakMdia, MoovTrakMdiaMinf, MoovTrakMdiaMinfStbl, Moof, MoofTraf: + return DecodeAtoms(data) + + case MoovTrakTkhd: + return &AtomTkhd{TrackID: binary.BigEndian.Uint32(data[1+3+4+4:])}, nil + + case MoovTrakMdiaMdhd: + return &AtomMdhd{TimeScale: binary.BigEndian.Uint32(data[1+3+4+4:])}, nil + + case MoovTrakMdiaMinfStblStsd: + // support only 1 codec entry + if n := binary.BigEndian.Uint32(data[1+3:]); n == 1 { + return DecodeAtom(data[1+3+4:]) + } + + case "avc1", "hev1": + b = data[6+2+2+2+4+4+4+2+2+4+4+4+2+32+2+2:] + atom, err := DecodeAtom(b) + if err != nil { + return nil, err + } + if conf, ok := atom.(*Atom); ok { + return &AtomVideo{Name: name, Config: conf.Data}, nil + } + + case "mp4a": + atom := &AtomAudio{Name: name} + + rd := bits.NewReader(data) + rd.ReadBytes(6 + 2 + 2 + 2 + 4) // skip + atom.Channels = rd.ReadUint16() + rd.ReadBytes(2 + 2 + 2) // skip + atom.SampleRate = uint32(rd.ReadFloat32()) + + atom2, _ := DecodeAtom(rd.Left()) + if conf, ok := atom2.(*Atom); ok { + _, b, _ = bytes.Cut(conf.Data, []byte{5, 0x80, 0x80, 0x80}) + if n := len(b); n > 0 && n > 1+int(b[0]) { + atom.Config = b[1 : 1+b[0]] + } + } + + return atom, nil + + case MoofMfhd: + return &AtomMfhd{Sequence: binary.BigEndian.Uint32(data[4:])}, nil + + case MoofTrafTfhd: + rd := bits.NewReader(data) + _ = rd.ReadByte() // version + flags := rd.ReadUint24() + + atom := &AtomTfhd{ + TrackID: rd.ReadUint32(), + } + + if flags&TfhdDefaultSampleDuration != 0 { + atom.SampleDuration = rd.ReadUint32() + + } + if flags&TfhdDefaultSampleSize != 0 { + atom.SampleSize = rd.ReadUint32() + } + if flags&TfhdDefaultSampleFlags != 0 { + atom.SampleFlags = rd.ReadUint32() // skip + } + + return atom, nil + + case MoofTrafTfdt: + return &AtomTfdt{DecodeTime: binary.BigEndian.Uint64(data[4:])}, nil + + case MoofTrafTrun: + rd := bits.NewReader(data) + _ = rd.ReadByte() // version + flags := rd.ReadUint24() + samples := rd.ReadUint32() + + atom := &AtomTrun{} + + if flags&TrunDataOffset != 0 { + atom.DataOffset = rd.ReadUint32() + } + if flags&TrunFirstSampleFlags != 0 { + atom.FirstSampleFlags = rd.ReadUint32() + } + + for i := uint32(0); i < samples; i++ { + if flags&TrunSampleDuration != 0 { + atom.SamplesDuration = append(atom.SamplesDuration, rd.ReadUint32()) + } + if flags&TrunSampleSize != 0 { + atom.SamplesSize = append(atom.SamplesSize, rd.ReadUint32()) + } + if flags&TrunSampleFlags != 0 { + atom.SamplesFlags = append(atom.SamplesFlags, rd.ReadUint32()) + } + if flags&TrunSampleCTS != 0 { + atom.SamplesCTS = append(atom.SamplesCTS, rd.ReadUint32()) + } + } + + return atom, nil + + case Mdat: + return &AtomMdat{Data: data}, nil + } + + return &Atom{Name: name, Data: data}, nil +} + +func DecodeAtoms(b []byte) (atoms []any, err error) { + for len(b) > 0 { + atom, err := DecodeAtom(b) + if err != nil { + return nil, err + } + + if childs, ok := atom.([]any); ok { + atoms = append(atoms, childs...) + } else { + atoms = append(atoms, atom) + } + + size := binary.BigEndian.Uint32(b) + b = b[size:] + } + + return atoms, nil +} diff --git a/installs_on_host/go2rtc/pkg/ivideon/ivideon.go b/installs_on_host/go2rtc/pkg/ivideon/ivideon.go new file mode 100644 index 0000000..973b9ba --- /dev/null +++ b/installs_on_host/go2rtc/pkg/ivideon/ivideon.go @@ -0,0 +1,187 @@ +package ivideon + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/mp4" + "github.com/gorilla/websocket" +) + +type Producer struct { + core.Connection + conn *websocket.Conn + + buf []byte + + dem *mp4.Demuxer +} + +func Dial(source string) (core.Producer, error) { + id := strings.Replace(source[8:], "/", ":", 1) + + url, err := GetLiveStream(id) + if err != nil { + return nil, err + } + + conn, _, err := websocket.DefaultDialer.Dial(url, nil) + if err != nil { + return nil, err + } + + prod := &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "ivideon", + Protocol: core.Before(url, ":"), // wss + RemoteAddr: conn.RemoteAddr().String(), + Source: source, + URL: url, + Transport: conn, + }, + conn: conn, + } + + if err = prod.probe(); err != nil { + _ = conn.Close() + return nil, err + } + + return prod, nil +} + +func GetLiveStream(id string) (string, error) { + // &video_codecs=h264,h265&audio_codecs=aac,mp3,pcma,pcmu,none + resp, err := http.Get( + "https://openapi-alpha.ivideon.com/cameras/" + id + + "/live_stream?op=GET&access_token=public&q=2&video_codecs=h264&format=ws-fmp4", + ) + if err != nil { + return "", err + } + + var v struct { + Message string `json:"message"` + Result struct { + URL string `json:"url"` + } `json:"result"` + Success bool `json:"success"` + } + if err = json.NewDecoder(resp.Body).Decode(&v); err != nil { + return "", err + } + + if !v.Success { + return "", fmt.Errorf("ivideon: can't get live_stream: " + v.Message) + } + + return v.Result.URL, nil +} + +func (p *Producer) Start() error { + receivers := make(map[uint32]*core.Receiver) + for _, receiver := range p.Receivers { + trackID := p.dem.GetTrackID(receiver.Codec) + receivers[trackID] = receiver + } + + ch := make(chan []byte, 10) + defer close(ch) + + ch <- p.buf + + go func() { + // add delay to the stream for smooth playing (not a best solution) + t0 := time.Now() + + for data := range ch { + trackID, packets := p.dem.Demux(data) + if receiver := receivers[trackID]; receiver != nil { + clockRate := time.Duration(receiver.Codec.ClockRate) + for _, packet := range packets { + // synchronize framerate for WebRTC and MSE + ts := time.Second * time.Duration(packet.Timestamp) / clockRate + d := ts - time.Since(t0) + if d < 0 { + d = 10 * time.Millisecond + } + time.Sleep(d) + + receiver.WriteRTP(packet) + } + } + } + }() + + for { + var msg message + if err := p.conn.ReadJSON(&msg); err != nil { + return err + } + + switch msg.Type { + case "stream-init", "metadata": + continue + + case "fragment": + _, b, err := p.conn.ReadMessage() + if err != nil { + return err + } + + p.Recv += len(b) + ch <- b + + default: + return errors.New("ivideon: wrong message type: " + msg.Type) + } + } +} + +func (p *Producer) probe() (err error) { + p.dem = &mp4.Demuxer{} + + for { + var msg message + if err = p.conn.ReadJSON(&msg); err != nil { + return err + } + + switch msg.Type { + case "metadata": + continue + + case "stream-init": + // it's difficult to maintain audio + if strings.HasPrefix(msg.CodecString, "avc1") { + medias := p.dem.Probe(msg.Data) + p.Medias = append(p.Medias, medias...) + } + + case "fragment": + _, p.buf, err = p.conn.ReadMessage() + return + + default: + return errors.New("ivideon: wrong message type: " + msg.Type) + } + } +} + +type message struct { + Type string `json:"type"` + CodecString string `json:"codec_string"` + Data []byte `json:"data"` + //TrackID byte `json:"track_id"` + //Track byte `json:"track"` + //StartTime float32 `json:"start_time"` + //Duration float32 `json:"duration"` + //IsKey bool `json:"is_key"` + //DataOffset uint32 `json:"data_offset"` +} diff --git a/installs_on_host/go2rtc/pkg/kasa/producer.go b/installs_on_host/go2rtc/pkg/kasa/producer.go new file mode 100644 index 0000000..697c19e --- /dev/null +++ b/installs_on_host/go2rtc/pkg/kasa/producer.go @@ -0,0 +1,215 @@ +package kasa + +import ( + "bufio" + "errors" + "io" + "net/http" + "net/http/httputil" + "strconv" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/h264/annexb" + "github.com/AlexxIT/go2rtc/pkg/mpjpeg" + "github.com/AlexxIT/go2rtc/pkg/tcp" + "github.com/pion/rtp" +) + +type Producer struct { + core.Connection + rd *core.ReadBuffer + + reader *bufio.Reader +} + +func Dial(url string) (*Producer, error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + req.URL.Scheme = "httpx" + + res, err := tcp.Do(req) + if err != nil { + return nil, err + } + + // KC200 + // HTTP/1.0 200 OK + // Content-Type: multipart/x-mixed-replace;boundary=data-boundary-- + // KD110, KC401, KC420WS: + // HTTP/1.0 200 OK + // Content-Type: multipart/x-mixed-replace;boundary=data-boundary-- + // Transfer-Encoding: chunked + // HTTP/1.0 + chunked = out of standard, so golang remove this header + // and we need to check first two bytes + buf := bufio.NewReader(res.Body) + + b, err := buf.Peek(2) + if err != nil { + return nil, err + } + + rd := struct { + io.Reader + io.Closer + }{ + buf, + res.Body, + } + + if string(b) != "--" { + rd.Reader = httputil.NewChunkedReader(buf) + } + + prod := &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "kasa", + Protocol: "http", + Transport: rd, + }, + rd: core.NewReadBuffer(rd), + } + if err = prod.probe(); err != nil { + return nil, err + } + return prod, nil +} + +func (c *Producer) Start() error { + if len(c.Receivers) == 0 { + return errors.New("multipart: no receivers") + } + + var video, audio *core.Receiver + + for _, receiver := range c.Receivers { + switch receiver.Codec.Name { + case core.CodecH264: + video = receiver + case core.CodecPCMU: + audio = receiver + } + } + + for { + header, body, err := mpjpeg.Next(c.reader) + if err != nil { + return err + } + + c.Recv += len(body) + + ct := header.Get("Content-Type") + switch ct { + case MimeVideo: + if video != nil { + ts := GetTimestamp(header) + pkt := &rtp.Packet{ + Header: rtp.Header{ + Timestamp: uint32(ts * 90000), + }, + Payload: annexb.EncodeToAVCC(body), + } + video.WriteRTP(pkt) + } + + case MimeG711U: + if audio != nil { + ts := GetTimestamp(header) + pkt := &rtp.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: true, + Timestamp: uint32(ts * 8000), + }, + Payload: body, + } + audio.WriteRTP(pkt) + } + } + } +} + +const ( + MimeVideo = "video/x-h264" + MimeG711U = "audio/g711u" +) + +func (c *Producer) probe() error { + c.rd.BufferSize = core.ProbeSize + c.reader = bufio.NewReader(c.rd) + + defer func() { + c.rd.Reset() + c.reader = bufio.NewReader(c.rd) + }() + + waitVideo, waitAudio := true, true + timeout := time.Now().Add(core.ProbeTimeout) + + for (waitVideo || waitAudio) && time.Now().Before(timeout) { + header, body, err := mpjpeg.Next(c.reader) + if err != nil { + return err + } + + var media *core.Media + + ct := header.Get("Content-Type") + switch ct { + case MimeVideo: + if !waitVideo { + continue + } + waitVideo = false + + body = annexb.EncodeToAVCC(body) + codec := h264.AVCCToCodec(body) + media = &core.Media{ + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{codec}, + } + + case MimeG711U: + if !waitAudio { + continue + } + waitAudio = false + + media = &core.Media{ + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + { + Name: core.CodecPCMU, + ClockRate: 8000, + }, + }, + } + + default: + return errors.New("kasa: unsupported type: " + ct) + } + + c.Medias = append(c.Medias, media) + } + + return nil +} + +// GetTimestamp - return timestamp in seconds +func GetTimestamp(header http.Header) float64 { + if s := header.Get("X-Timestamp"); s != "" { + if f, _ := strconv.ParseFloat(s, 32); f != 0 { + return f + } + } + + return float64(time.Duration(time.Now().UnixNano()) / time.Second) +} diff --git a/installs_on_host/go2rtc/pkg/magic/bitstream/producer.go b/installs_on_host/go2rtc/pkg/magic/bitstream/producer.go new file mode 100644 index 0000000..5f00f41 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/magic/bitstream/producer.go @@ -0,0 +1,95 @@ +package bitstream + +import ( + "encoding/hex" + "errors" + "io" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/h264/annexb" + "github.com/AlexxIT/go2rtc/pkg/h265" + "github.com/pion/rtp" +) + +type Producer struct { + core.Connection + rd *core.ReadBuffer +} + +func Open(r io.Reader) (*Producer, error) { + rd := core.NewReadBuffer(r) + + buf, err := rd.Peek(256) + if err != nil { + return nil, err + } + + buf = annexb.EncodeToAVCC(buf) // won't break original buffer + + var codec *core.Codec + var format string + + switch { + case h264.NALUType(buf) == h264.NALUTypeSPS: + codec = h264.AVCCToCodec(buf) + format = "h264" + case h265.NALUType(buf) == h265.NALUTypeVPS: + codec = h265.AVCCToCodec(buf) + format = "hevc" + default: + return nil, errors.New("bitstream: unsupported header: " + hex.EncodeToString(buf[:8])) + } + + medias := []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{codec}, + }, + } + return &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: format, + Medias: medias, + Transport: r, + }, + rd: rd, + }, nil +} + +func (c *Producer) Start() error { + var buf []byte + + b := make([]byte, core.BufferSize) + for { + n, err := c.rd.Read(b) + if err != nil { + return err + } + + c.Recv += n + + buf = append(buf, b[:n]...) + + for { + i := annexb.IndexFrame(buf) + if i < 0 { + break + } + + if len(c.Receivers) > 0 { + pkt := &rtp.Packet{ + Header: rtp.Header{Timestamp: core.Now90000()}, + Payload: annexb.EncodeToAVCC(buf[:i]), + } + c.Receivers[0].WriteRTP(pkt) + + //log.Printf("[AVC] %v, len: %d", h264.Types(pkt.Payload), len(pkt.Payload)) + } + + buf = buf[i:] + } + } +} diff --git a/installs_on_host/go2rtc/pkg/magic/keyframe.go b/installs_on_host/go2rtc/pkg/magic/keyframe.go new file mode 100644 index 0000000..9b6ef56 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/magic/keyframe.go @@ -0,0 +1,116 @@ +package magic + +import ( + "io" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/h264/annexb" + "github.com/AlexxIT/go2rtc/pkg/h265" + "github.com/AlexxIT/go2rtc/pkg/mjpeg" + "github.com/pion/rtp" +) + +type Keyframe struct { + core.Connection + wr *core.WriteBuffer +} + +// Deprecated: should be rewritten +func NewKeyframe() *Keyframe { + medias := []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecJPEG}, + {Name: core.CodecRAW}, + {Name: core.CodecH264}, + {Name: core.CodecH265}, + }, + }, + } + wr := core.NewWriteBuffer(nil) + return &Keyframe{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "keyframe", + Medias: medias, + Transport: wr, + }, + wr: wr, + } +} + +func (k *Keyframe) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error { + sender := core.NewSender(media, track.Codec) + + switch track.Codec.Name { + case core.CodecH264: + sender.Handler = func(packet *rtp.Packet) { + if !h264.IsKeyframe(packet.Payload) { + return + } + b := annexb.DecodeAVCC(packet.Payload, true) + if n, err := k.wr.Write(b); err == nil { + k.Send += n + } + } + + if track.Codec.IsRTP() { + sender.Handler = h264.RTPDepay(track.Codec, sender.Handler) + } else { + sender.Handler = h264.RepairAVCC(track.Codec, sender.Handler) + } + + case core.CodecH265: + sender.Handler = func(packet *rtp.Packet) { + if !h265.IsKeyframe(packet.Payload) { + return + } + b := annexb.DecodeAVCC(packet.Payload, true) + if n, err := k.wr.Write(b); err == nil { + k.Send += n + } + } + + if track.Codec.IsRTP() { + sender.Handler = h265.RTPDepay(track.Codec, sender.Handler) + } + + case core.CodecJPEG: + sender.Handler = func(packet *rtp.Packet) { + if n, err := k.wr.Write(packet.Payload); err == nil { + k.Send += n + } + } + + if track.Codec.IsRTP() { + sender.Handler = mjpeg.RTPDepay(sender.Handler) + } + + case core.CodecRAW: + sender.Handler = func(packet *rtp.Packet) { + if n, err := k.wr.Write(packet.Payload); err == nil { + k.Send += n + } + } + + sender.Handler = mjpeg.Encoder(track.Codec, 5, sender.Handler) + } + + sender.HandleRTP(track) + k.Senders = append(k.Senders, sender) + return nil +} + +func (k *Keyframe) CodecName() string { + if len(k.Senders) != 1 { + return "" + } + return k.Senders[0].Codec.Name +} + +func (k *Keyframe) WriteTo(wr io.Writer) (int64, error) { + return k.wr.WriteTo(wr) +} diff --git a/installs_on_host/go2rtc/pkg/magic/mjpeg/producer.go b/installs_on_host/go2rtc/pkg/magic/mjpeg/producer.go new file mode 100644 index 0000000..e47c168 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/magic/mjpeg/producer.go @@ -0,0 +1,78 @@ +package mjpeg + +import ( + "bytes" + "io" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +type Producer struct { + core.Connection + rd *core.ReadBuffer +} + +func Open(rd io.Reader) (*Producer, error) { + medias := []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + { + Name: core.CodecJPEG, + ClockRate: 90000, + PayloadType: core.PayloadTypeRAW, + }, + }, + }, + } + return &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "mjpeg", + Medias: medias, + Transport: rd, + }, + rd: core.NewReadBuffer(rd), + }, nil +} + +func (c *Producer) Start() error { + var buf []byte // total bufer + b := make([]byte, core.BufferSize) // reading buffer + + for { + // one JPEG end and next start + i := bytes.Index(buf, []byte{0xFF, 0xD9, 0xFF, 0xD8}) + if i < 0 { + n, err := c.rd.Read(b) + if err != nil { + return err + } + + c.Recv += n + + buf = append(buf, b[:n]...) + + // if we receive frame + if n >= 2 && b[n-2] == 0xFF && b[n-1] == 0xD9 { + i = len(buf) + } else { + continue + } + } else { + i += 2 + } + + pkt := &rtp.Packet{ + Header: rtp.Header{Timestamp: core.Now90000()}, + Payload: buf[:i], + } + c.Receivers[0].WriteRTP(pkt) + + //log.Printf("[mjpeg] ts=%d size=%d", pkt.Header.Timestamp, len(pkt.Payload)) + + buf = buf[i:] + } +} diff --git a/installs_on_host/go2rtc/pkg/magic/producer.go b/installs_on_host/go2rtc/pkg/magic/producer.go new file mode 100644 index 0000000..3742ccf --- /dev/null +++ b/installs_on_host/go2rtc/pkg/magic/producer.go @@ -0,0 +1,69 @@ +package magic + +import ( + "bytes" + "encoding/hex" + "errors" + "io" + + "github.com/AlexxIT/go2rtc/pkg/aac" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/flv" + "github.com/AlexxIT/go2rtc/pkg/h264/annexb" + "github.com/AlexxIT/go2rtc/pkg/magic/bitstream" + "github.com/AlexxIT/go2rtc/pkg/magic/mjpeg" + "github.com/AlexxIT/go2rtc/pkg/mpegts" + "github.com/AlexxIT/go2rtc/pkg/mpjpeg" + "github.com/AlexxIT/go2rtc/pkg/wav" + "github.com/AlexxIT/go2rtc/pkg/y4m" +) + +func Open(r io.Reader) (core.Producer, error) { + rd := core.NewReadBuffer(r) + + b, err := rd.Peek(4) + if err != nil { + return nil, err + } + + switch string(b) { + case annexb.StartCode: + return bitstream.Open(rd) + case wav.FourCC: + return wav.Open(rd) + case y4m.FourCC: + return y4m.Open(rd) + } + + switch string(b[:3]) { + case flv.Signature: + return flv.Open(rd) + } + + switch string(b[:2]) { + case "\xFF\xD8": + return mjpeg.Open(rd) + case "\xFF\xF1", "\xFF\xF9": + return aac.Open(rd) + case "--": + return mpjpeg.Open(rd) + } + + switch b[0] { + case mpegts.SyncByte: + return mpegts.Open(rd) + } + + // support MJPEG with trash on start + // https://github.com/AlexxIT/go2rtc/issues/747 + if b, err = rd.Peek(4096); err != nil { + return nil, err + } + + if i := bytes.Index(b, []byte{0xFF, 0xD8, 0xFF, 0xDB}); i > 0 { + _, _ = io.ReadFull(rd, make([]byte, i)) + return mjpeg.Open(rd) + } + + return nil, errors.New("magic: unsupported header: " + hex.EncodeToString(b[:4])) +} diff --git a/installs_on_host/go2rtc/pkg/mdns/README.md b/installs_on_host/go2rtc/pkg/mdns/README.md new file mode 100644 index 0000000..e6e21d6 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/mdns/README.md @@ -0,0 +1,3 @@ +# Useful links + +- https://grouper.ieee.org/groups/1722/contributions/2009/Bonjour%20Device%20Discovery.pdf \ No newline at end of file diff --git a/installs_on_host/go2rtc/pkg/mdns/client.go b/installs_on_host/go2rtc/pkg/mdns/client.go new file mode 100644 index 0000000..e7abb50 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/mdns/client.go @@ -0,0 +1,374 @@ +package mdns + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "strings" + "syscall" + "time" + + "github.com/AlexxIT/go2rtc/pkg/xnet" + "github.com/miekg/dns" // awesome library for parsing mDNS records +) + +const ( + ServiceDNSSD = "_services._dns-sd._udp.local." + ServiceHAP = "_hap._tcp.local." // HomeKit Accessory Protocol +) + +type ServiceEntry struct { + Name string `json:"name,omitempty"` + IP net.IP `json:"ip,omitempty"` + Port uint16 `json:"port,omitempty"` + Info map[string]string `json:"info,omitempty"` +} + +func (e *ServiceEntry) String() string { + b, err := json.Marshal(e) + if err != nil { + return err.Error() + } + return string(b) +} + +func (e *ServiceEntry) TXT() []string { + var txt []string + for k, v := range e.Info { + txt = append(txt, k+"="+v) + } + return txt +} + +func (e *ServiceEntry) Complete() bool { + return e.IP != nil && e.Port > 0 && e.Info != nil +} + +func (e *ServiceEntry) Addr() string { + return fmt.Sprintf("%s:%d", e.IP, e.Port) +} + +func (e *ServiceEntry) Host(service string) string { + return e.name() + "." + strings.TrimRight(service, ".") +} + +func (e *ServiceEntry) name() string { + b := []byte(e.Name) + for i, c := range b { + if 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9' { + continue + } + b[i] = '-' + } + return string(b) +} + +var MulticastAddr = &net.UDPAddr{ + IP: net.IP{224, 0, 0, 251}, + Port: 5353, +} + +const sendTimeout = time.Millisecond * 505 +const respTimeout = time.Second * 3 + +// BasicDiscovery - default golang Multicast UDP listener. +// Does not work well with multiple interfaces. +func BasicDiscovery(service string, onentry func(*ServiceEntry) bool) error { + conn, err := net.ListenMulticastUDP("udp4", nil, MulticastAddr) + if err != nil { + return err + } + + b := Browser{ + Service: service, + Addr: MulticastAddr, + Recv: conn, + Sends: []net.PacketConn{conn}, + RecvTimeout: respTimeout, + SendTimeout: sendTimeout, + } + + defer b.Close() + + return b.Browse(onentry) +} + +// Discovery - better discovery version. Works well with multiple interfaces. +func Discovery(service string, onentry func(*ServiceEntry) bool) error { + b := Browser{ + Service: service, + Addr: MulticastAddr, + RecvTimeout: respTimeout, + SendTimeout: sendTimeout, + } + + if err := b.ListenMulticastUDP(); err != nil { + return err + } + + defer b.Close() + + return b.Browse(onentry) +} + +// Query - direct Discovery request on device IP-address. Works even over VPN. +func Query(host, service string) (entry *ServiceEntry, err error) { + conn, err := net.ListenPacket("udp4", ":0") // shouldn't use ":5353" + if err != nil { + return + } + + br := Browser{ + Service: service, + Addr: &net.UDPAddr{ + IP: net.ParseIP(host), + Port: 5353, + }, + Recv: conn, + Sends: []net.PacketConn{conn}, + SendTimeout: time.Millisecond * 255, + RecvTimeout: time.Second, + } + + defer br.Close() + + err = br.Browse(func(en *ServiceEntry) bool { + entry = en + return true + }) + + return +} + +// QueryOrDiscovery - useful if we know previous device host and want +// to update port or any other information. Will work even over VPN. +func QueryOrDiscovery(host, service string, onentry func(*ServiceEntry) bool) error { + entry, _ := Query(host, service) + if entry != nil && onentry(entry) { + return nil + } + + return Discovery(service, onentry) +} + +type Browser struct { + Service string + + Addr net.Addr + Nets []*net.IPNet + Recv net.PacketConn + Sends []net.PacketConn + + RecvTimeout time.Duration + SendTimeout time.Duration +} + +// ListenMulticastUDP - creates multiple senders socket (each for IP4 interface). +// And one receiver with multicast membership for each sender. +// Receiver will get multicast responses on senders requests. +func (b *Browser) ListenMulticastUDP() error { + // 1. Collect IPv4 interfaces + nets, err := xnet.IPNets(func(ip net.IP) bool { + return !xnet.Docker.Contains(ip) + }) + if err != nil { + return err + } + + // 2. Create senders + lc1 := net.ListenConfig{ + Control: func(network, address string, c syscall.RawConn) error { + return c.Control(func(fd uintptr) { + // 1. Allow multicast UDP to listen concurrently across multiple listeners + _ = SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1) + }) + }, + } + + ctx := context.Background() + + for _, ipn := range nets { + conn, err := lc1.ListenPacket(ctx, "udp4", ipn.IP.String()+":5353") // same port important + if err != nil { + continue + } + b.Nets = append(b.Nets, ipn) + b.Sends = append(b.Sends, conn) + } + + if b.Sends == nil { + return errors.New("no interfaces for listen") + } + + // 3. Create receiver + lc2 := net.ListenConfig{ + Control: func(network, address string, c syscall.RawConn) error { + return c.Control(func(fd uintptr) { + // 1. Allow multicast UDP to listen concurrently across multiple listeners + _ = SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1) + + // 2. Disable loop responses + _ = SetsockoptInt(fd, syscall.IPPROTO_IP, syscall.IP_MULTICAST_LOOP, 0) + + // 3. Allow receive multicast responses on all this addresses + mreq := &syscall.IPMreq{ + Multiaddr: [4]byte{224, 0, 0, 251}, + } + _ = SetsockoptIPMreq(fd, syscall.IPPROTO_IP, syscall.IP_ADD_MEMBERSHIP, mreq) + + for _, send := range b.Sends { + addr := send.LocalAddr().(*net.UDPAddr) + mreq.Interface = [4]byte(addr.IP.To4()) + _ = SetsockoptIPMreq(fd, syscall.IPPROTO_IP, syscall.IP_ADD_MEMBERSHIP, mreq) + } + }) + }, + } + + b.Recv, err = lc2.ListenPacket(ctx, "udp4", ":5353") + + return err +} + +func (b *Browser) Browse(onentry func(*ServiceEntry) bool) error { + msg := &dns.Msg{ + Question: []dns.Question{ + {Name: b.Service, Qtype: dns.TypePTR, Qclass: dns.ClassINET}, + }, + } + + query, err := msg.Pack() + if err != nil { + return err + } + + if err = b.Recv.SetDeadline(time.Now().Add(b.RecvTimeout)); err != nil { + return err + } + + go func() { + for { + for _, send := range b.Sends { + if _, err := send.WriteTo(query, b.Addr); err != nil { + return + } + } + time.Sleep(b.SendTimeout) + } + }() + + processed := map[string]struct{}{"": {}} + + b2 := make([]byte, 1500) + for { + // in the Hass docker network can receive same msg from different address + n, addr, err := b.Recv.ReadFrom(b2) + if err != nil { + break + } + + if err = msg.Unpack(b2[:n]); err != nil { + continue + } + + ptr := GetPTR(msg, b.Service) + + if _, ok := processed[ptr]; ok { + continue + } + + ip := addr.(*net.UDPAddr).IP + + for _, entry := range NewServiceEntries(msg, ip) { + if onentry(entry) { + return nil + } + } + + processed[ptr] = struct{}{} + } + + return nil +} + +func (b *Browser) Close() error { + if b.Recv != nil { + _ = b.Recv.Close() + } + for _, send := range b.Sends { + _ = send.Close() + } + return nil +} + +func GetPTR(msg *dns.Msg, service string) string { + for _, record := range msg.Answer { + if ptr, ok := record.(*dns.PTR); ok && ptr.Hdr.Name == service { + return ptr.Ptr + } + } + return "" +} + +func NewServiceEntries(msg *dns.Msg, ip net.IP) (entries []*ServiceEntry) { + records := make([]dns.RR, 0, len(msg.Answer)+len(msg.Ns)+len(msg.Extra)) + records = append(records, msg.Answer...) + records = append(records, msg.Ns...) + records = append(records, msg.Extra...) + + // PTR ptr=SomeName._hap._tcp.local. hdr=_hap._tcp.local. + // TXT txt=... hdr=SomeName._hap._tcp.local. + // SRV target=SomeName.local. hdr=SomeName._hap._tcp.local. + // A a=192.168.1.123 hdr=SomeName.local. + + for _, record := range records { + ptr, ok := record.(*dns.PTR) + if !ok { + continue + } + + entry := &ServiceEntry{} + + if i := strings.IndexByte(ptr.Ptr, '.'); i > 0 { + entry.Name = strings.ReplaceAll(ptr.Ptr[:i], `\ `, " ") + } + + var txt *dns.TXT + var srv *dns.SRV + var a *dns.A + + for _, record = range records { + if txt, ok = record.(*dns.TXT); ok && txt.Hdr.Name == ptr.Ptr { + entry.Info = make(map[string]string, len(txt.Txt)) + for _, s := range txt.Txt { + k, v, _ := strings.Cut(s, "=") + entry.Info[k] = v + } + break + } + } + + for _, record = range records { + if srv, ok = record.(*dns.SRV); ok && srv.Hdr.Name == ptr.Ptr { + entry.Port = srv.Port + + for _, record = range records { + if a, ok = record.(*dns.A); ok && a.Hdr.Name == srv.Target { + // device can send multiple IP addresses (ex. Homebridge) + // use first IP from the list or same IP from sender + if entry.IP == nil || ip.Equal(a.A) { + entry.IP = a.A + } + } + } + break + } + } + + entries = append(entries, entry) + } + + return +} diff --git a/installs_on_host/go2rtc/pkg/mdns/mdns_test.go b/installs_on_host/go2rtc/pkg/mdns/mdns_test.go new file mode 100644 index 0000000..2d74520 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/mdns/mdns_test.go @@ -0,0 +1,17 @@ +package mdns + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDiscovery(t *testing.T) { + onentry := func(entry *ServiceEntry) bool { + return true + } + err := Discovery(ServiceHAP, onentry) + //err := Discovery("_ewelink._tcp.local.", time.Second, onentry) + // err := Discovery("_googlecast._tcp.local.", time.Second, onentry) + require.Nil(t, err) +} diff --git a/installs_on_host/go2rtc/pkg/mdns/server.go b/installs_on_host/go2rtc/pkg/mdns/server.go new file mode 100644 index 0000000..802c07c --- /dev/null +++ b/installs_on_host/go2rtc/pkg/mdns/server.go @@ -0,0 +1,162 @@ +package mdns + +import ( + "net" + + "github.com/miekg/dns" +) + +// ClassCacheFlush https://datatracker.ietf.org/doc/html/rfc6762#section-10.2 +const ClassCacheFlush = 0x8001 + +func Serve(service string, entries []*ServiceEntry) error { + b := Browser{Service: service} + + if err := b.ListenMulticastUDP(); err != nil { + return err + } + + return b.Serve(entries) +} + +func (b *Browser) Serve(entries []*ServiceEntry) error { + names := make(map[string]*ServiceEntry, len(entries)) + for _, entry := range entries { + name := entry.name() + "." + b.Service + names[name] = entry + } + + buf := make([]byte, 1500) + for { + n, addr, err := b.Recv.ReadFrom(buf) + if err != nil { + break + } + + var req dns.Msg // request + if err = req.Unpack(buf[:n]); err != nil { + continue + } + + // skip messages without Questions + if req.Question == nil { + continue + } + + remoteIP := addr.(*net.UDPAddr).IP + localIP := b.MatchLocalIP(remoteIP) + + // skip messages from unknown networks (can be docker network) + if localIP == nil { + continue + } + + var res dns.Msg // response + for _, q := range req.Question { + if q.Qtype != dns.TypePTR || q.Qclass != dns.ClassINET { + continue + } + + if q.Name == ServiceDNSSD { + AppendDNSSD(&res, b.Service) + } else if q.Name == b.Service { + for _, entry := range entries { + AppendEntry(&res, entry, b.Service, localIP) + } + } else if entry, ok := names[q.Name]; ok { + AppendEntry(&res, entry, b.Service, localIP) + } + } + + if res.Answer == nil { + continue + } + + res.MsgHdr.Response = true + res.MsgHdr.Authoritative = true + + data, err := res.Pack() + if err != nil { + continue + } + + for _, send := range b.Sends { + _, _ = send.WriteTo(data, MulticastAddr) + } + } + + return nil +} + +func (b *Browser) MatchLocalIP(remote net.IP) net.IP { + for _, ipn := range b.Nets { + if ipn.Contains(remote) { + return ipn.IP + } + } + return nil +} + +func AppendDNSSD(msg *dns.Msg, service string) { + msg.Answer = append( + msg.Answer, + &dns.PTR{ + Hdr: dns.RR_Header{ + Name: ServiceDNSSD, // _services._dns-sd._udp.local. + Rrtype: dns.TypePTR, // 12 + Class: dns.ClassINET, // 1 + Ttl: 4500, + }, + Ptr: service, // _home-assistant._tcp.local. + }, + ) +} + +func AppendEntry(msg *dns.Msg, entry *ServiceEntry, service string, ip net.IP) { + ptrName := entry.name() + "." + service + srvName := entry.name() + ".local." + + msg.Answer = append( + msg.Answer, + &dns.PTR{ + Hdr: dns.RR_Header{ + Name: service, // _home-assistant._tcp.local. + Rrtype: dns.TypePTR, // 12 + Class: dns.ClassINET, // 1 + Ttl: 4500, + }, + Ptr: ptrName, // Home\ Assistant._home-assistant._tcp.local. + }, + ) + msg.Extra = append( + msg.Extra, + &dns.TXT{ + Hdr: dns.RR_Header{ + Name: ptrName, // Home\ Assistant._home-assistant._tcp.local. + Rrtype: dns.TypeTXT, // 16 + Class: ClassCacheFlush, // 32769 + Ttl: 4500, + }, + Txt: entry.TXT(), + }, + &dns.SRV{ + Hdr: dns.RR_Header{ + Name: ptrName, // Home\ Assistant._home-assistant._tcp.local. + Rrtype: dns.TypeSRV, // 33 + Class: ClassCacheFlush, // 32769 + Ttl: 120, + }, + Port: entry.Port, // 8123 + Target: srvName, // 963f1fa82b7142809711cebe7c826322.local. + }, + &dns.A{ + Hdr: dns.RR_Header{ + Name: srvName, // 963f1fa82b7142809711cebe7c826322.local. + Rrtype: dns.TypeA, // 1 + Class: ClassCacheFlush, // 32769 + Ttl: 120, + }, + A: ip, + }, + ) +} diff --git a/installs_on_host/go2rtc/pkg/mdns/syscall.go b/installs_on_host/go2rtc/pkg/mdns/syscall.go new file mode 100644 index 0000000..0e50535 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/mdns/syscall.go @@ -0,0 +1,15 @@ +//go:build !(darwin || ios || freebsd || openbsd || netbsd || dragonfly || windows) + +package mdns + +import ( + "syscall" +) + +func SetsockoptInt(fd uintptr, level, opt int, value int) (err error) { + return syscall.SetsockoptInt(int(fd), level, opt, value) +} + +func SetsockoptIPMreq(fd uintptr, level, opt int, mreq *syscall.IPMreq) (err error) { + return syscall.SetsockoptIPMreq(int(fd), level, opt, mreq) +} diff --git a/installs_on_host/go2rtc/pkg/mdns/syscall_bsd.go b/installs_on_host/go2rtc/pkg/mdns/syscall_bsd.go new file mode 100644 index 0000000..6ebb0c9 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/mdns/syscall_bsd.go @@ -0,0 +1,26 @@ +//go:build darwin || ios || freebsd || openbsd || netbsd || dragonfly + +package mdns + +import ( + "syscall" +) + +func SetsockoptInt(fd uintptr, level, opt int, value int) (err error) { + // change SO_REUSEADDR and REUSEPORT flags simultaneously for BSD-like OS + // https://github.com/AlexxIT/go2rtc/issues/626 + // https://stackoverflow.com/questions/14388706/how-do-so-reuseaddr-and-so-reuseport-differ/14388707 + if opt == syscall.SO_REUSEADDR { + if err = syscall.SetsockoptInt(int(fd), level, opt, value); err != nil { + return + } + + opt = syscall.SO_REUSEPORT + } + + return syscall.SetsockoptInt(int(fd), level, opt, value) +} + +func SetsockoptIPMreq(fd uintptr, level, opt int, mreq *syscall.IPMreq) (err error) { + return syscall.SetsockoptIPMreq(int(fd), level, opt, mreq) +} diff --git a/installs_on_host/go2rtc/pkg/mdns/syscall_windows.go b/installs_on_host/go2rtc/pkg/mdns/syscall_windows.go new file mode 100644 index 0000000..770510c --- /dev/null +++ b/installs_on_host/go2rtc/pkg/mdns/syscall_windows.go @@ -0,0 +1,13 @@ +//go:build windows + +package mdns + +import "syscall" + +func SetsockoptInt(fd uintptr, level, opt int, value int) (err error) { + return syscall.SetsockoptInt(syscall.Handle(fd), level, opt, value) +} + +func SetsockoptIPMreq(fd uintptr, level, opt int, mreq *syscall.IPMreq) (err error) { + return syscall.SetsockoptIPMreq(syscall.Handle(fd), level, opt, mreq) +} diff --git a/installs_on_host/go2rtc/pkg/mjpeg/README.md b/installs_on_host/go2rtc/pkg/mjpeg/README.md new file mode 100644 index 0000000..fa8c4e1 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/mjpeg/README.md @@ -0,0 +1,5 @@ +## Useful links + +- https://www.rfc-editor.org/rfc/rfc2435 +- https://github.com/GStreamer/gst-plugins-good/blob/master/gst/rtp/gstrtpjpegdepay.c +- https://mjpeg.sanford.io/ diff --git a/installs_on_host/go2rtc/pkg/mjpeg/consumer.go b/installs_on_host/go2rtc/pkg/mjpeg/consumer.go new file mode 100644 index 0000000..819c558 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/mjpeg/consumer.go @@ -0,0 +1,59 @@ +package mjpeg + +import ( + "io" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +type Consumer struct { + core.Connection + wr *core.WriteBuffer +} + +func NewConsumer() *Consumer { + medias := []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecJPEG}, + {Name: core.CodecRAW}, + }, + }, + } + wr := core.NewWriteBuffer(nil) + return &Consumer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "mjpeg", + Medias: medias, + Transport: wr, + }, + wr: wr, + } +} + +func (c *Consumer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error { + sender := core.NewSender(media, track.Codec) + sender.Handler = func(packet *rtp.Packet) { + if n, err := c.wr.Write(packet.Payload); err == nil { + c.Send += n + } + } + + if track.Codec.IsRTP() { + sender.Handler = RTPDepay(sender.Handler) + } else if track.Codec.Name == core.CodecRAW { + sender.Handler = Encoder(track.Codec, 0, sender.Handler) + } + + sender.HandleRTP(track) + c.Senders = append(c.Senders, sender) + return nil +} + +func (c *Consumer) WriteTo(wr io.Writer) (int64, error) { + return c.wr.WriteTo(wr) +} diff --git a/installs_on_host/go2rtc/pkg/mjpeg/helpers.go b/installs_on_host/go2rtc/pkg/mjpeg/helpers.go new file mode 100644 index 0000000..87f59e0 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/mjpeg/helpers.go @@ -0,0 +1,100 @@ +package mjpeg + +import ( + "bytes" + "image/jpeg" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/y4m" + "github.com/pion/rtp" +) + +func FixJPEG(b []byte) []byte { + // skip non-JPEG + if len(b) < 10 || b[0] != 0xFF || b[1] != markerSOI { + return b + } + + // skip JPEG without app marker + if b[2] == 0xFF && b[3] == markerDQT { + return b + } + + switch string(b[6:10]) { + case "JFIF", "Exif": + // skip if header OK for imghdr library + // - https://docs.python.org/3/library/imghdr.html + return b + case "AVI1": + // adds DHT tables to JPEG file before SOS marker + // useful when you want to save a JPEG frame from an MJPEG stream + // - https://github.com/image-rs/jpeg-decoder/issues/76 + // - https://github.com/pion/mediadevices/pull/493 + // - https://bugzilla.mozilla.org/show_bug.cgi?id=963907#c18 + return InjectDHT(b) + } + + // reencode JPEG if it has wrong header + // + // for example, this app produce "bad" images: + // https://github.com/jacksonliam/mjpg-streamer + // + // and they can't be uploaded to the Telegram servers: + // {"ok":false,"error_code":400,"description":"Bad Request: IMAGE_PROCESS_FAILED"} + img, err := jpeg.Decode(bytes.NewReader(b)) + if err != nil { + return b + } + buf := bytes.NewBuffer(nil) + if err = jpeg.Encode(buf, img, nil); err != nil { + return b + } + return buf.Bytes() +} + +// Encoder convert YUV frame to Img. +// Support skipping empty frames, for example if USB cam needs time to start. +func Encoder(codec *core.Codec, skipEmpty int, handler core.HandlerFunc) core.HandlerFunc { + newImage := y4m.NewImage(codec.FmtpLine) + + return func(packet *rtp.Packet) { + img := newImage(packet.Payload) + + if skipEmpty != 0 && y4m.HasSameColor(img) { + skipEmpty-- + return + } + + buf := bytes.NewBuffer(nil) + if err := jpeg.Encode(buf, img, nil); err != nil { + return + } + + clone := *packet + clone.Payload = buf.Bytes() + handler(&clone) + } +} + +const dhtSize = 432 // known size for 4 default tables + +func InjectDHT(b []byte) []byte { + if bytes.Index(b, []byte{0xFF, markerDHT}) > 0 { + return b // already exist + } + + i := bytes.Index(b, []byte{0xFF, markerSOS}) + if i < 0 { + return b + } + + dht := make([]byte, 0, dhtSize) + dht = MakeHuffmanHeaders(dht) + + tmp := make([]byte, len(b)+dhtSize) + copy(tmp, b[:i]) + copy(tmp[i:], dht) + copy(tmp[i+dhtSize:], b[i:]) + + return tmp +} diff --git a/installs_on_host/go2rtc/pkg/mjpeg/jpeg.go b/installs_on_host/go2rtc/pkg/mjpeg/jpeg.go new file mode 100644 index 0000000..8d6d13d --- /dev/null +++ b/installs_on_host/go2rtc/pkg/mjpeg/jpeg.go @@ -0,0 +1,10 @@ +package mjpeg + +const ( + markerSOF = 0xC0 // Start Of Frame (Baseline Sequential) + markerSOI = 0xD8 // Start Of Image + markerEOI = 0xD9 // End Of Image + markerSOS = 0xDA // Start Of Scan + markerDQT = 0xDB // Define Quantization Table + markerDHT = 0xC4 // Define Huffman Table +) diff --git a/installs_on_host/go2rtc/pkg/mjpeg/mjpeg_test.go b/installs_on_host/go2rtc/pkg/mjpeg/mjpeg_test.go new file mode 100644 index 0000000..586f8c8 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/mjpeg/mjpeg_test.go @@ -0,0 +1,13 @@ +package mjpeg + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRFC2435(t *testing.T) { + lqt, cqt := MakeTables(71) + require.Equal(t, byte(9), lqt[0]) + require.Equal(t, byte(10), cqt[0]) +} diff --git a/installs_on_host/go2rtc/pkg/mjpeg/rfc2435.go b/installs_on_host/go2rtc/pkg/mjpeg/rfc2435.go new file mode 100644 index 0000000..aa34c2f --- /dev/null +++ b/installs_on_host/go2rtc/pkg/mjpeg/rfc2435.go @@ -0,0 +1,211 @@ +package mjpeg + +// RFC 2435. Appendix A + +// don't know why two tables are not respect RFC +// https://github.com/FFmpeg/FFmpeg/blob/master/libavformat/rtpdec_jpeg.c + +var jpeg_luma_quantizer = [64]byte{ + 16, 11, 12, 14, 12, 10, 16, 14, + 13, 14, 18, 17, 16, 19, 24, 40, + 26, 24, 22, 22, 24, 49, 35, 37, + 29, 40, 58, 51, 61, 60, 57, 51, + 56, 55, 64, 72, 92, 78, 64, 68, + 87, 69, 55, 56, 80, 109, 81, 87, + 95, 98, 103, 104, 103, 62, 77, 113, + 121, 112, 100, 120, 92, 101, 103, 99, +} +var jpeg_chroma_quantizer = [64]byte{ + 17, 18, 18, 24, 21, 24, 47, 26, + 26, 47, 99, 66, 56, 66, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99, +} + +func MakeTables(q byte) (lqt, cqt []byte) { + var factor int + + switch { + case q < 1: + factor = 1 + case q > 99: + factor = 99 + default: + factor = int(q) + } + + if q < 50 { + factor = 5000 / factor + } else { + factor = 200 - factor*2 + } + + lqt = make([]byte, 64) + cqt = make([]byte, 64) + + for i := 0; i < 64; i++ { + lq := (int(jpeg_luma_quantizer[i])*factor + 50) / 100 + cq := (int(jpeg_chroma_quantizer[i])*factor + 50) / 100 + + /* Limit the quantizers to 1 <= q <= 255 */ + switch { + case lq < 1: + lqt[i] = 1 + case lq > 255: + lqt[i] = 255 + default: + lqt[i] = byte(lq) + } + + switch { + case cq < 1: + cqt[i] = 1 + case cq > 255: + cqt[i] = 255 + default: + cqt[i] = byte(cq) + } + } + + return +} + +// RFC 2435. Appendix B + +var lum_dc_codelens = []byte{ + 0, 1, 5, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, +} +var lum_dc_symbols = []byte{ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, +} +var lum_ac_codelens = []byte{ + 0, 2, 1, 3, 3, 2, 4, 3, 5, 5, 4, 4, 0, 0, 1, 0x7d, +} +var lum_ac_symbols = []byte{ + 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, + 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, + 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xa1, 0x08, + 0x23, 0x42, 0xb1, 0xc1, 0x15, 0x52, 0xd1, 0xf0, + 0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0a, 0x16, + 0x17, 0x18, 0x19, 0x1a, 0x25, 0x26, 0x27, 0x28, + 0x29, 0x2a, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, + 0x3a, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, + 0x4a, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, + 0x5a, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, + 0x6a, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, + 0x7a, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, + 0x8a, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, + 0x99, 0x9a, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, + 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, + 0xb7, 0xb8, 0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5, + 0xc6, 0xc7, 0xc8, 0xc9, 0xca, 0xd2, 0xd3, 0xd4, + 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xe1, 0xe2, + 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, + 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, + 0xf9, 0xfa, +} +var chm_dc_codelens = []byte{ + 0, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, +} +var chm_dc_symbols = []byte{ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, +} +var chm_ac_codelens = []byte{ + 0, 2, 1, 2, 4, 4, 3, 4, 7, 5, 4, 4, 0, 1, 2, 0x77, +} +var chm_ac_symbols = []byte{ + 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, + 0x31, 0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, + 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, + 0xa1, 0xb1, 0xc1, 0x09, 0x23, 0x33, 0x52, 0xf0, + 0x15, 0x62, 0x72, 0xd1, 0x0a, 0x16, 0x24, 0x34, + 0xe1, 0x25, 0xf1, 0x17, 0x18, 0x19, 0x1a, 0x26, + 0x27, 0x28, 0x29, 0x2a, 0x35, 0x36, 0x37, 0x38, + 0x39, 0x3a, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, + 0x49, 0x4a, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, + 0x59, 0x5a, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, + 0x69, 0x6a, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, + 0x79, 0x7a, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, + 0x88, 0x89, 0x8a, 0x92, 0x93, 0x94, 0x95, 0x96, + 0x97, 0x98, 0x99, 0x9a, 0xa2, 0xa3, 0xa4, 0xa5, + 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, + 0xb5, 0xb6, 0xb7, 0xb8, 0xb9, 0xba, 0xc2, 0xc3, + 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xca, 0xd2, + 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, + 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, + 0xea, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, + 0xf9, 0xfa, +} + +func MakeHeaders(p []byte, t byte, w, h uint16, lqt, cqt []byte) []byte { + // Appendix A from https://www.rfc-editor.org/rfc/rfc2435 + p = append(p, 0xFF, markerSOI) + + p = MakeQuantHeader(p, lqt, 0) + p = MakeQuantHeader(p, cqt, 1) + + if t == 0 { + t = 0x21 // hsamp = 2, vsamp = 1 + } else { + t = 0x22 // hsamp = 2, vsamp = 2 + } + + p = append(p, 0xFF, markerSOF, + 0, 17, // size + 8, // bits per component + byte(h>>8), byte(h&0xFF), + byte(w>>8), byte(w&0xFF), + 3, // number of components + 0, // comp 0 + t, + 0, // quant table 0 + 1, // comp 1 + 0x11, // hsamp = 1, vsamp = 1 + 1, // quant table 1 + 2, // comp 2 + 0x11, // hsamp = 1, vsamp = 1 + 1, // quant table 1 + ) + + p = MakeHuffmanHeaders(p) + + return append(p, 0xFF, markerSOS, + 0, 12, // size + 3, // 3 components + 0, // comp 0 + 0, // huffman table 0 + 1, // comp 1 + 0x11, // huffman table 1 + 2, // comp 2 + 0x11, // huffman table 1 + 0, // first DCT coeff + 63, // last DCT coeff + 0, // sucessive approx + ) +} + +func MakeQuantHeader(p []byte, qt []byte, tableNo byte) []byte { + p = append(p, 0xFF, markerDQT, 0, 67, tableNo) + return append(p, qt...) +} + +func MakeHuffmanHeader(p, codelens, symbols []byte, tableNo, tableClass byte) []byte { + p = append(p, 0xFF, markerDHT, + 0, byte(3+len(codelens)+len(symbols)), // size + (tableClass<<4)|tableNo, + ) + p = append(p, codelens...) + return append(p, symbols...) +} + +func MakeHuffmanHeaders(p []byte) []byte { + p = MakeHuffmanHeader(p, lum_dc_codelens, lum_dc_symbols, 0, 0) + p = MakeHuffmanHeader(p, lum_ac_codelens, lum_ac_symbols, 0, 1) + p = MakeHuffmanHeader(p, chm_dc_codelens, chm_dc_symbols, 1, 0) + p = MakeHuffmanHeader(p, chm_ac_codelens, chm_ac_symbols, 1, 1) + return p +} diff --git a/installs_on_host/go2rtc/pkg/mjpeg/rtp.go b/installs_on_host/go2rtc/pkg/mjpeg/rtp.go new file mode 100644 index 0000000..7a9347e --- /dev/null +++ b/installs_on_host/go2rtc/pkg/mjpeg/rtp.go @@ -0,0 +1,220 @@ +package mjpeg + +import ( + "bytes" + "encoding/binary" + "image" + "image/jpeg" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +func RTPDepay(handlerFunc core.HandlerFunc) core.HandlerFunc { + buf := make([]byte, 0, 512*1024) // 512K + + return func(packet *rtp.Packet) { + //log.Printf("[RTP] codec: %s, size: %6d, ts: %10d, pt: %2d, ssrc: %d, seq: %d, mark: %v", track.Codec.Name, len(packet.Payload), packet.Timestamp, packet.PayloadType, packet.SSRC, packet.SequenceNumber, packet.Marker) + + // https://www.rfc-editor.org/rfc/rfc2435#section-3.1 + b := packet.Payload + + // 3.1. JPEG header + t := b[4] + + // 3.1.7. Restart Marker header + if 64 <= t && t <= 127 { + b = b[12:] // skip it + } else { + b = b[8:] + } + + if len(buf) == 0 { + var lqt, cqt []byte + + // 3.1.8. Quantization Table header + q := packet.Payload[5] + if q >= 128 { + lqt = b[4:68] + cqt = b[68:132] + b = b[132:] + } else { + lqt, cqt = MakeTables(q) + } + + // https://www.rfc-editor.org/rfc/rfc2435#section-3.1.5 + // The maximum width is 2040 pixels. + w := uint16(packet.Payload[6]) << 3 + h := uint16(packet.Payload[7]) << 3 + + // fix sizes more than 2040 + switch { + // 512x1920 512x1440 + case w == cutSize(2560) && (h == 1920 || h == 1440): + w = 2560 + // 1792x112 + case w == cutSize(3840) && h == cutSize(2160): + w = 3840 + h = 2160 + // 256x1296 + case w == cutSize(2304) && h == 1296: + w = 2304 + } + + //fmt.Printf("t: %d, q: %d, w: %d, h: %d\n", t, q, w, h) + buf = MakeHeaders(buf, t, w, h, lqt, cqt) + } + + // 3.1.9. JPEG Payload + buf = append(buf, b...) + + if !packet.Marker { + return + } + + if end := buf[len(buf)-2:]; end[0] != 0xFF && end[1] != 0xD9 { + buf = append(buf, 0xFF, 0xD9) + } + + clone := *packet + clone.Payload = buf + + buf = buf[:0] // clear buffer + + handlerFunc(&clone) + } +} + +func cutSize(size uint16) uint16 { + return ((size >> 3) & 0xFF) << 3 +} + +func RTPPay(handlerFunc core.HandlerFunc) core.HandlerFunc { + const packetSize = 1436 + + sequencer := rtp.NewRandomSequencer() + + return func(packet *rtp.Packet) { + // reincode image to more common form + p, err := Transcode(packet.Payload) + if err != nil { + return + } + + h1 := make([]byte, 8) + h1[4] = 1 // Type + h1[5] = 255 // Q + + // MBZ=0, Precision=0, Length=128 + h2 := make([]byte, 4, 132) + h2[3] = 128 + + var jpgData []byte + for jpgData == nil { + // 2 bytes h1 + if p[0] != 0xFF { + return + } + + size := binary.BigEndian.Uint16(p[2:]) + 2 + + // 2 bytes payload size (include 2 bytes) + switch p[1] { + case 0xD8: // 0. Start Of Image (size=0) + p = p[2:] + continue + case 0xDB: // 1. Define Quantization Table (size=130) + for i := uint16(4 + 1); i < size; i += 1 + 64 { + h2 = append(h2, p[i:i+64]...) + } + case 0xC0: // 2. Start Of Frame (size=15) + if p[4] != 8 { + return + } + h := binary.BigEndian.Uint16(p[5:]) + w := binary.BigEndian.Uint16(p[7:]) + h1[6] = uint8(w >> 3) + h1[7] = uint8(h >> 3) + case 0xC4: // 3. Define Huffman Table (size=416) + case 0xDA: // 4. Start Of Scan (size=10) + jpgData = p[size:] + } + + p = p[size:] + } + + offset := 0 + p = make([]byte, 0) + + for jpgData != nil { + p = p[:0] + + if offset > 0 { + h1[1] = byte(offset >> 16) + h1[2] = byte(offset >> 8) + h1[3] = byte(offset) + p = append(p, h1...) + } else { + p = append(p, h1...) + p = append(p, h2...) + } + + dataLen := packetSize - len(p) + if dataLen < len(jpgData) { + p = append(p, jpgData[:dataLen]...) + jpgData = jpgData[dataLen:] + offset += dataLen + } else { + p = append(p, jpgData...) + jpgData = nil + } + + clone := rtp.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: jpgData == nil, + SequenceNumber: sequencer.NextSequenceNumber(), + Timestamp: packet.Timestamp, + }, + Payload: p, + } + handlerFunc(&clone) + } + } +} + +func Transcode(b []byte) ([]byte, error) { + img, err := jpeg.Decode(bytes.NewReader(b)) + if err != nil { + return nil, err + } + + wh := img.Bounds().Size() + w := wh.X + h := wh.Y + + if w > 2040 { + w = 2040 + } else if w&3 > 0 { + w &= 3 + } + if h > 2040 { + h = 2040 + } else if h&3 > 0 { + h &= 3 + } + + if w != wh.X || h != wh.Y { + x0 := (wh.X - w) / 2 + y0 := (wh.Y - h) / 2 + rect := image.Rect(x0, y0, x0+w, y0+h) + img = img.(*image.YCbCr).SubImage(rect) + } + + buf := bytes.NewBuffer(nil) + if err = jpeg.Encode(buf, img, nil); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} diff --git a/installs_on_host/go2rtc/pkg/mjpeg/writer.go b/installs_on_host/go2rtc/pkg/mjpeg/writer.go new file mode 100644 index 0000000..8845bf2 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/mjpeg/writer.go @@ -0,0 +1,38 @@ +package mjpeg + +import ( + "io" + "net/http" + "strconv" +) + +func NewWriter(w io.Writer) io.Writer { + h := w.(http.ResponseWriter).Header() + h.Set("Content-Type", "multipart/x-mixed-replace; boundary=frame") + return &writer{wr: w, buf: []byte(header)} +} + +const header = "--frame\r\nContent-Type: image/jpeg\r\nContent-Length: " + +type writer struct { + wr io.Writer + buf []byte +} + +func (w *writer) Write(p []byte) (n int, err error) { + w.buf = w.buf[:len(header)] + w.buf = append(w.buf, strconv.Itoa(len(p))...) + w.buf = append(w.buf, "\r\n\r\n"...) + w.buf = append(w.buf, p...) + w.buf = append(w.buf, "\r\n"...) + + // Chrome bug: mjpeg image always shows the second to last image + // https://bugs.chromium.org/p/chromium/issues/detail?id=527446 + if _, err = w.wr.Write(w.buf); err != nil { + return 0, err + } + + w.wr.(http.Flusher).Flush() + + return len(p), nil +} diff --git a/installs_on_host/go2rtc/pkg/mp4/README.md b/installs_on_host/go2rtc/pkg/mp4/README.md new file mode 100644 index 0000000..e20e5d7 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/mp4/README.md @@ -0,0 +1,36 @@ +## Fragmented MP4 + +``` +ffmpeg -i "rtsp://..." -movflags +frag_keyframe+separate_moof+default_base_moof+empty_moov -frag_duration 1 -c copy -t 5 sample.mp4 +``` + +- movflags frag_keyframe + Start a new fragment at each video keyframe. +- frag_duration duration + Create fragments that are duration microseconds long. +- movflags separate_moof + Write a separate moof (movie fragment) atom for each track. +- movflags default_base_moof + Similarly to the omit_tfhd_offset, this flag avoids writing the absolute base_data_offset field in tfhd atoms, but does so by using the new default-base-is-moof flag instead. + +https://ffmpeg.org/ffmpeg-formats.html#Options-13 + +## HEVC + +| Browser | avc1 | hvc1 | hev1 | +|-------------|------|------|------| + | Mac Chrome | + | - | + | + | Mac Safari | + | + | - | + | iOS 15? | + | + | - | + | Mac Firefox | + | - | - | + | iOS 12 | + | - | - | + | Android 13 | + | - | - | + +## Useful links + +- https://stackoverflow.com/questions/63468587/what-hevc-codec-tag-to-use-with-fmp4-hvc1-or-hev1 +- https://stackoverflow.com/questions/32152090/encode-h265-to-hvc1-codec +- https://jellyfin.org/docs/general/clients/codec-support.html +- https://github.com/StaZhu/enable-chromium-hevc-hardware-decoding +- https://developer.mozilla.org/ru/docs/Web/Media/Formats/codecs_parameter +- https://gstreamer-devel.narkive.com/rhkUolp2/rtp-dts-pts-result-in-varying-mp4-frame-durations diff --git a/installs_on_host/go2rtc/pkg/mp4/consumer.go b/installs_on_host/go2rtc/pkg/mp4/consumer.go new file mode 100644 index 0000000..3484986 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/mp4/consumer.go @@ -0,0 +1,189 @@ +package mp4 + +import ( + "errors" + "io" + "sync" + + "github.com/AlexxIT/go2rtc/pkg/aac" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/h265" + "github.com/AlexxIT/go2rtc/pkg/pcm" + "github.com/pion/rtp" +) + +type Consumer struct { + core.Connection + wr *core.WriteBuffer + muxer *Muxer + mu sync.Mutex + start bool + + Rotate int `json:"-"` + ScaleX int `json:"-"` + ScaleY int `json:"-"` +} + +func NewConsumer(medias []*core.Media) *Consumer { + if medias == nil { + // default local medias + medias = []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecH264}, + {Name: core.CodecH265}, + }, + }, + { + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecAAC}, + }, + }, + } + } + + wr := core.NewWriteBuffer(nil) + return &Consumer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "mp4", + Medias: medias, + Transport: wr, + }, + muxer: &Muxer{}, + wr: wr, + } +} + +func (c *Consumer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error { + trackID := byte(len(c.Senders)) + + codec := track.Codec.Clone() + handler := core.NewSender(media, codec) + + switch track.Codec.Name { + case core.CodecH264: + handler.Handler = func(packet *rtp.Packet) { + if !c.start { + if !h264.IsKeyframe(packet.Payload) { + return + } + c.start = true + } + + // important to use Mutex because right fragment order + c.mu.Lock() + b := c.muxer.GetPayload(trackID, packet) + if n, err := c.wr.Write(b); err == nil { + c.Send += n + } + c.mu.Unlock() + } + + if track.Codec.IsRTP() { + handler.Handler = h264.RTPDepay(track.Codec, handler.Handler) + } else { + handler.Handler = h264.RepairAVCC(track.Codec, handler.Handler) + } + + case core.CodecH265: + handler.Handler = func(packet *rtp.Packet) { + if !c.start { + if !h265.IsKeyframe(packet.Payload) { + return + } + c.start = true + } + + // important to use Mutex because right fragment order + c.mu.Lock() + b := c.muxer.GetPayload(trackID, packet) + if n, err := c.wr.Write(b); err == nil { + c.Send += n + } + c.mu.Unlock() + } + + if track.Codec.IsRTP() { + handler.Handler = h265.RTPDepay(track.Codec, handler.Handler) + } else { + handler.Handler = h265.RepairAVCC(track.Codec, handler.Handler) + } + + default: + handler.Handler = func(packet *rtp.Packet) { + if !c.start { + return + } + + // important to use Mutex because right fragment order + c.mu.Lock() + b := c.muxer.GetPayload(trackID, packet) + if n, err := c.wr.Write(b); err == nil { + c.Send += n + } + c.mu.Unlock() + } + + switch track.Codec.Name { + case core.CodecAAC: + if track.Codec.IsRTP() { + handler.Handler = aac.RTPDepay(handler.Handler) + } + case core.CodecOpus, core.CodecMP3: // no changes + case core.CodecPCMA, core.CodecPCMU, core.CodecPCM, core.CodecPCML: + codec.Name = core.CodecFLAC + if codec.Channels == 2 { + // hacky way for support two channels audio + codec.Channels = 1 + codec.ClockRate *= 2 + } + handler.Handler = pcm.FLACEncoder(track.Codec.Name, codec.ClockRate, handler.Handler) + + default: + handler.Handler = nil + } + } + + if handler.Handler == nil { + s := "mp4: unsupported codec: " + track.Codec.String() + println(s) + return errors.New(s) + } + + c.muxer.AddTrack(codec) + + handler.HandleRTP(track) + c.Senders = append(c.Senders, handler) + + return nil +} + +func (c *Consumer) WriteTo(wr io.Writer) (int64, error) { + if len(c.Senders) == 1 && c.Senders[0].Codec.IsAudio() { + c.start = true + } + + init, err := c.muxer.GetInit() + if err != nil { + return 0, err + } + + if c.Rotate != 0 { + PatchVideoRotate(init, c.Rotate) + } + if c.ScaleX != 0 && c.ScaleY != 0 { + PatchVideoScale(init, c.ScaleX, c.ScaleY) + } + + if _, err = wr.Write(init); err != nil { + return 0, err + } + + return c.wr.WriteTo(wr) +} diff --git a/installs_on_host/go2rtc/pkg/mp4/demuxer.go b/installs_on_host/go2rtc/pkg/mp4/demuxer.go new file mode 100644 index 0000000..25c8c70 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/mp4/demuxer.go @@ -0,0 +1,116 @@ +package mp4 + +import ( + "github.com/AlexxIT/go2rtc/pkg/aac" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/iso" + "github.com/pion/rtp" +) + +type Demuxer struct { + codecs map[uint32]*core.Codec + timeScales map[uint32]float32 +} + +func (d *Demuxer) Probe(init []byte) (medias []*core.Media) { + var trackID, timeScale uint32 + + if d.codecs == nil { + d.codecs = make(map[uint32]*core.Codec) + d.timeScales = make(map[uint32]float32) + } + + atoms, _ := iso.DecodeAtoms(init) + for _, atom := range atoms { + var codec *core.Codec + + switch atom := atom.(type) { + case *iso.AtomTkhd: + trackID = atom.TrackID + case *iso.AtomMdhd: + timeScale = atom.TimeScale + case *iso.AtomVideo: + switch atom.Name { + case "avc1": + codec = h264.ConfigToCodec(atom.Config) + } + case *iso.AtomAudio: + switch atom.Name { + case "mp4a": + codec = aac.ConfigToCodec(atom.Config) + } + } + + if codec != nil { + d.codecs[trackID] = codec + d.timeScales[trackID] = float32(codec.ClockRate) / float32(timeScale) + + medias = append(medias, &core.Media{ + Kind: codec.Kind(), + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{codec}, + }) + } + } + + return +} + +func (d *Demuxer) GetTrackID(codec *core.Codec) uint32 { + for trackID, c := range d.codecs { + if c == codec { + return trackID + } + } + return 0 +} + +func (d *Demuxer) Demux(data2 []byte) (trackID uint32, packets []*core.Packet) { + atoms, err := iso.DecodeAtoms(data2) + if err != nil { + return 0, nil + } + + var ts uint32 + var trun *iso.AtomTrun + var data []byte + + for _, atom := range atoms { + switch atom := atom.(type) { + case *iso.AtomTfhd: + trackID = atom.TrackID + case *iso.AtomTfdt: + ts = uint32(atom.DecodeTime) + case *iso.AtomTrun: + trun = atom + case *iso.AtomMdat: + data = atom.Data + } + } + + timeScale := d.timeScales[trackID] + if timeScale == 0 { + return 0, nil + } + + n := len(trun.SamplesDuration) + packets = make([]*core.Packet, n) + + for i := 0; i < n; i++ { + duration := trun.SamplesDuration[i] + size := trun.SamplesSize[i] + + // can be SPS, PPS and IFrame in one packet + timestamp := uint32(float32(ts) * timeScale) + packets[i] = &rtp.Packet{ + Header: rtp.Header{Timestamp: timestamp}, + Payload: data[:size], + } + + data = data[size:] + ts += duration + } + + return +} diff --git a/installs_on_host/go2rtc/pkg/mp4/helpers.go b/installs_on_host/go2rtc/pkg/mp4/helpers.go new file mode 100644 index 0000000..ce1825c --- /dev/null +++ b/installs_on_host/go2rtc/pkg/mp4/helpers.go @@ -0,0 +1,166 @@ +package mp4 + +import ( + "bytes" + "encoding/binary" + "strings" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +// ParseQuery - like usual parse, but with mp4 param handler +func ParseQuery(query map[string][]string) []*core.Media { + if v := query["mp4"]; len(v) != 0 { + medias := []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecH264}, + {Name: core.CodecH265}, + }, + }, + { + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecAAC}, + }, + }, + } + + if v[0] == "" { + return medias // legacy + } + + medias[1].Codecs = append(medias[1].Codecs, + &core.Codec{Name: core.CodecPCMA}, + &core.Codec{Name: core.CodecPCMU}, + &core.Codec{Name: core.CodecPCM}, + &core.Codec{Name: core.CodecPCML}, + ) + + if v[0] == "flac" { + return medias // modern browsers + } + + medias[1].Codecs = append(medias[1].Codecs, + &core.Codec{Name: core.CodecOpus}, + &core.Codec{Name: core.CodecMP3}, + ) + + return medias // Chrome, FFmpeg, VLC + } + + return core.ParseQuery(query) +} + +func ParseCodecs(codecs string, parseAudio bool) (medias []*core.Media) { + var videos []*core.Codec + var audios []*core.Codec + + for _, name := range strings.Split(codecs, ",") { + switch name { + case MimeH264: + codec := &core.Codec{Name: core.CodecH264} + videos = append(videos, codec) + case MimeH265: + codec := &core.Codec{Name: core.CodecH265} + videos = append(videos, codec) + case MimeAAC: + codec := &core.Codec{Name: core.CodecAAC} + audios = append(audios, codec) + case MimeFlac: + audios = append(audios, + &core.Codec{Name: core.CodecPCMA}, + &core.Codec{Name: core.CodecPCMU}, + &core.Codec{Name: core.CodecPCM}, + &core.Codec{Name: core.CodecPCML}, + ) + case MimeOpus: + codec := &core.Codec{Name: core.CodecOpus} + audios = append(audios, codec) + } + } + + if videos != nil { + media := &core.Media{ + Kind: core.KindVideo, + Direction: core.DirectionSendonly, + Codecs: videos, + } + medias = append(medias, media) + } + + if audios != nil && parseAudio { + media := &core.Media{ + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: audios, + } + medias = append(medias, media) + } + + return +} + +// PatchVideoRotate - update video track transformation matrix. +// Rotation supported by many players and browsers (except Safari). +// Scale has low support and better not to use it. +// Supported only 0, 90, 180, 270 degrees. +func PatchVideoRotate(init []byte, degrees int) bool { + // search video atom + i := bytes.Index(init, []byte("vide")) + if i < 0 { + return false + } + + // seek to video matrix position + i -= 4 + 3 + 1 + 8 + 32 + 8 + 4 + 4 + 4*9 + + // Rotation matrix: + // [ cos sin 0] + // [ -sin cos 0] + // [ 0 0 16384] + var cos, sin uint16 + + switch degrees { + case 0: + cos = 1 + sin = 0 + case 90: + cos = 0 + sin = 1 + case 180: + cos = 0xFFFF // -1 + sin = 0 + case 270: + cos = 0 + sin = 0xFFFF // -1 + default: + return false + } + + binary.BigEndian.PutUint16(init[i:], cos) + binary.BigEndian.PutUint16(init[i+4:], sin) + binary.BigEndian.PutUint16(init[i+12:], -sin) + binary.BigEndian.PutUint16(init[i+16:], cos) + + return true +} + +// PatchVideoScale - update "Pixel Aspect Ratio" atom. +// Supported by many players and browsers (except Firefox). +// Supported only positive integers. +func PatchVideoScale(init []byte, scaleX, scaleY int) bool { + // search video atom + i := bytes.Index(init, []byte("pasp")) + if i < 0 { + return false + } + + binary.BigEndian.PutUint32(init[i+4:], uint32(scaleX)) + binary.BigEndian.PutUint32(init[i+8:], uint32(scaleY)) + + return true +} diff --git a/installs_on_host/go2rtc/pkg/mp4/keyframe.go b/installs_on_host/go2rtc/pkg/mp4/keyframe.go new file mode 100644 index 0000000..399f95e --- /dev/null +++ b/installs_on_host/go2rtc/pkg/mp4/keyframe.go @@ -0,0 +1,104 @@ +package mp4 + +import ( + "io" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/h265" + "github.com/pion/rtp" +) + +type Keyframe struct { + core.Connection + wr *core.WriteBuffer + muxer *Muxer +} + +// Deprecated: should be rewritten +func NewKeyframe(medias []*core.Media) *Keyframe { + if medias == nil { + medias = []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecH264}, + {Name: core.CodecH265}, + }, + }, + } + } + + wr := core.NewWriteBuffer(nil) + cons := &Keyframe{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "mp4", + Transport: wr, + }, + muxer: &Muxer{}, + wr: wr, + } + cons.Medias = medias + return cons +} + +func (c *Keyframe) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error { + c.muxer.AddTrack(track.Codec) + init, err := c.muxer.GetInit() + if err != nil { + return err + } + + handler := core.NewSender(media, track.Codec) + + switch track.Codec.Name { + case core.CodecH264: + handler.Handler = func(packet *rtp.Packet) { + if !h264.IsKeyframe(packet.Payload) { + return + } + + // important to use Mutex because right fragment order + b := c.muxer.GetPayload(0, packet) + b = append(init, b...) + if n, err := c.wr.Write(b); err == nil { + c.Send += n + } + } + + if track.Codec.IsRTP() { + handler.Handler = h264.RTPDepay(track.Codec, handler.Handler) + } else { + handler.Handler = h264.RepairAVCC(track.Codec, handler.Handler) + } + + case core.CodecH265: + handler.Handler = func(packet *rtp.Packet) { + if !h265.IsKeyframe(packet.Payload) { + return + } + + // important to use Mutex because right fragment order + b := c.muxer.GetPayload(0, packet) + b = append(init, b...) + if n, err := c.wr.Write(b); err == nil { + c.Send += n + } + } + + if track.Codec.IsRTP() { + handler.Handler = h265.RTPDepay(track.Codec, handler.Handler) + } + } + + handler.HandleRTP(track) + c.Senders = append(c.Senders, handler) + + return nil +} + +func (c *Keyframe) WriteTo(wr io.Writer) (int64, error) { + return c.wr.WriteTo(wr) +} diff --git a/installs_on_host/go2rtc/pkg/mp4/mime.go b/installs_on_host/go2rtc/pkg/mp4/mime.go new file mode 100644 index 0000000..f1a74e4 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/mp4/mime.go @@ -0,0 +1,45 @@ +package mp4 + +import ( + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" +) + +const ( + MimeH264 = "avc1.640029" + MimeH265 = "hvc1.1.6.L153.B0" + MimeAAC = "mp4a.40.2" + MimeFlac = "flac" + MimeOpus = "opus" +) + +func MimeCodecs(codecs []*core.Codec) string { + var s string + + for i, codec := range codecs { + if i > 0 { + s += "," + } + + switch codec.Name { + case core.CodecH264: + s += "avc1." + h264.GetProfileLevelID(codec.FmtpLine) + case core.CodecH265: + // H.265 profile=main level=5.1 + // hvc1 - supported in Safari, hev1 - doesn't, both supported in Chrome + s += MimeH265 + case core.CodecAAC: + s += MimeAAC + case core.CodecOpus: + s += MimeOpus + case core.CodecFLAC: + s += MimeFlac + } + } + + return s +} + +func ContentType(codecs []*core.Codec) string { + return `video/mp4; codecs="` + MimeCodecs(codecs) + `"` +} diff --git a/installs_on_host/go2rtc/pkg/mp4/muxer.go b/installs_on_host/go2rtc/pkg/mp4/muxer.go new file mode 100644 index 0000000..b371f68 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/mp4/muxer.go @@ -0,0 +1,172 @@ +package mp4 + +import ( + "encoding/hex" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/h265" + "github.com/AlexxIT/go2rtc/pkg/iso" + "github.com/pion/rtp" +) + +type Muxer struct { + index uint32 + dts []uint64 + pts []uint32 + codecs []*core.Codec +} + +func (m *Muxer) AddTrack(codec *core.Codec) { + m.dts = append(m.dts, 0) + m.pts = append(m.pts, 0) + m.codecs = append(m.codecs, codec) +} + +func (m *Muxer) GetInit() ([]byte, error) { + mv := iso.NewMovie(1024) + mv.WriteFileType() + + mv.StartAtom(iso.Moov) + mv.WriteMovieHeader() + + for i, codec := range m.codecs { + switch codec.Name { + case core.CodecH264: + sps, pps := h264.GetParameterSet(codec.FmtpLine) + // some dummy SPS and PPS not a problem for MP4, but problem for HLS :( + if len(sps) == 0 { + sps = []byte{0x67, 0x42, 0x00, 0x0a, 0xf8, 0x41, 0xa2} + } + if len(pps) == 0 { + pps = []byte{0x68, 0xce, 0x38, 0x80} + } + + var width, height uint16 + if s := h264.DecodeSPS(sps); s != nil { + width = s.Width() + height = s.Height() + } else { + width = 1920 + height = 1080 + } + + mv.WriteVideoTrack( + uint32(i+1), codec.Name, codec.ClockRate, width, height, h264.EncodeConfig(sps, pps), + ) + + case core.CodecH265: + vps, sps, pps := h265.GetParameterSet(codec.FmtpLine) + // some dummy SPS and PPS not a problem + if len(vps) == 0 { + vps = []byte{0x40, 0x01, 0x0c, 0x01, 0xff, 0xff, 0x01, 0x40, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x99, 0xac, 0x09} + } + if len(sps) == 0 { + sps = []byte{0x42, 0x01, 0x01, 0x01, 0x40, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x99, 0xa0, 0x01, 0x40, 0x20, 0x05, 0xa1, 0xfe, 0x5a, 0xee, 0x46, 0xc1, 0xae, 0x55, 0x04} + } + if len(pps) == 0 { + pps = []byte{0x44, 0x01, 0xc0, 0x73, 0xc0, 0x4c, 0x90} + } + + var width, height uint16 + if s := h265.DecodeSPS(sps); s != nil { + width = s.Width() + height = s.Height() + } else { + width = 1920 + height = 1080 + } + + mv.WriteVideoTrack( + uint32(i+1), codec.Name, codec.ClockRate, width, height, h265.EncodeConfig(vps, sps, pps), + ) + + case core.CodecAAC: + s := core.Between(codec.FmtpLine, "config=", ";") + b, err := hex.DecodeString(s) + if err != nil { + return nil, err + } + + mv.WriteAudioTrack( + uint32(i+1), codec.Name, codec.ClockRate, uint16(codec.Channels), b, + ) + + case core.CodecOpus, core.CodecMP3, core.CodecPCMA, core.CodecPCMU, core.CodecFLAC: + mv.WriteAudioTrack( + uint32(i+1), codec.Name, codec.ClockRate, uint16(codec.Channels), nil, + ) + } + } + + mv.StartAtom(iso.MoovMvex) + for i := range m.codecs { + mv.WriteTrackExtend(uint32(i + 1)) + } + mv.EndAtom() // MVEX + + mv.EndAtom() // MOOV + + return mv.Bytes(), nil +} + +func (m *Muxer) Reset() { + m.index = 0 + for i := range m.dts { + m.dts[i] = 0 + m.pts[i] = 0 + } +} + +func (m *Muxer) GetPayload(trackID byte, packet *rtp.Packet) []byte { + codec := m.codecs[trackID] + + m.index++ + + duration := packet.Timestamp - m.pts[trackID] + m.pts[trackID] = packet.Timestamp + + // flags important for Apple Finder video preview + var flags uint32 + + switch codec.Name { + case core.CodecH264: + if h264.IsKeyframe(packet.Payload) { + flags = iso.SampleVideoIFrame + } else { + flags = iso.SampleVideoNonIFrame + } + case core.CodecH265: + if h265.IsKeyframe(packet.Payload) { + flags = iso.SampleVideoIFrame + } else { + flags = iso.SampleVideoNonIFrame + } + case core.CodecAAC: + duration = 1024 // important for Apple Finder and QuickTime + flags = iso.SampleAudio // not important? + default: + flags = iso.SampleAudio // important for FLAC on Android Telegram + } + + // minumum duration important for MSE in Apple Safari + if duration == 0 || duration > codec.ClockRate { + duration = codec.ClockRate/1000 + 1 + m.pts[trackID] += duration + } + + size := len(packet.Payload) + + mv := iso.NewMovie(1024 + size) + mv.WriteMovieFragment( + // ExtensionProfile - wrong place for CTS (supported by mpegts.Demuxer) + m.index, uint32(trackID+1), duration, uint32(size), flags, m.dts[trackID], uint32(packet.ExtensionProfile), + ) + mv.WriteData(packet.Payload) + + //log.Printf("[MP4] idx:%3d trk:%d dts:%6d cts:%4d dur:%5d time:%10d len:%5d", m.index, trackID+1, m.dts[trackID], packet.SSRC, duration, packet.Timestamp, len(packet.Payload)) + + m.dts[trackID] += uint64(duration) + + return mv.Bytes() +} diff --git a/installs_on_host/go2rtc/pkg/mpegts/README.md b/installs_on_host/go2rtc/pkg/mpegts/README.md new file mode 100644 index 0000000..5f19fc7 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/mpegts/README.md @@ -0,0 +1,35 @@ +## PTS/DTS/CTS + +``` +if DTS == 0 { + // for I and P frames + packet.Timestamp = PTS (presentation time) +} else { + // for B frames + packet.Timestamp = DTS (decode time) + CTS = PTS-DTS (composition time) +} +``` + +- MPEG-TS container uses PTS and optional DTS. +- MP4 container uses DTS and CTS +- RTP container uses PTS + +## MPEG-TS + +FFmpeg: +- PMTID=4096 +- H264: PESID=256, StreamType=27, StreamID=224 +- H265: PESID=256, StreamType=36, StreamID=224 +- AAC: PESID=257, StreamType=15, StreamID=192 + +Tapo: +- PMTID=18 +- H264: PESID=68, StreamType=27, StreamID=224 +- AAC: PESID=69, StreamType=144, StreamID=192 + +## Useful links + +- https://github.com/theREDspace/video-onboarding/blob/main/MPEGTS%20Knowledge.md +- https://en.wikipedia.org/wiki/MPEG_transport_stream +- https://en.wikipedia.org/wiki/Program-specific_information diff --git a/installs_on_host/go2rtc/pkg/mpegts/checksum.go b/installs_on_host/go2rtc/pkg/mpegts/checksum.go new file mode 100644 index 0000000..e059d06 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/mpegts/checksum.go @@ -0,0 +1,57 @@ +package mpegts + +// have to create this table manually because it is in another endian +// https://github.com/arturvt/TSreader/blob/master/src/br/ufpe/cin/tool/mpegts/CRC32.java +var table = [256]uint32{ + 0x00000000, 0xB71DC104, 0x6E3B8209, 0xD926430D, 0xDC760413, 0x6B6BC517, + 0xB24D861A, 0x0550471E, 0xB8ED0826, 0x0FF0C922, 0xD6D68A2F, 0x61CB4B2B, + 0x649B0C35, 0xD386CD31, 0x0AA08E3C, 0xBDBD4F38, 0x70DB114C, 0xC7C6D048, + 0x1EE09345, 0xA9FD5241, 0xACAD155F, 0x1BB0D45B, 0xC2969756, 0x758B5652, + 0xC836196A, 0x7F2BD86E, 0xA60D9B63, 0x11105A67, 0x14401D79, 0xA35DDC7D, + 0x7A7B9F70, 0xCD665E74, 0xE0B62398, 0x57ABE29C, 0x8E8DA191, 0x39906095, + 0x3CC0278B, 0x8BDDE68F, 0x52FBA582, 0xE5E66486, 0x585B2BBE, 0xEF46EABA, + 0x3660A9B7, 0x817D68B3, 0x842D2FAD, 0x3330EEA9, 0xEA16ADA4, 0x5D0B6CA0, + 0x906D32D4, 0x2770F3D0, 0xFE56B0DD, 0x494B71D9, 0x4C1B36C7, 0xFB06F7C3, + 0x2220B4CE, 0x953D75CA, 0x28803AF2, 0x9F9DFBF6, 0x46BBB8FB, 0xF1A679FF, + 0xF4F63EE1, 0x43EBFFE5, 0x9ACDBCE8, 0x2DD07DEC, 0x77708634, 0xC06D4730, + 0x194B043D, 0xAE56C539, 0xAB068227, 0x1C1B4323, 0xC53D002E, 0x7220C12A, + 0xCF9D8E12, 0x78804F16, 0xA1A60C1B, 0x16BBCD1F, 0x13EB8A01, 0xA4F64B05, + 0x7DD00808, 0xCACDC90C, 0x07AB9778, 0xB0B6567C, 0x69901571, 0xDE8DD475, + 0xDBDD936B, 0x6CC0526F, 0xB5E61162, 0x02FBD066, 0xBF469F5E, 0x085B5E5A, + 0xD17D1D57, 0x6660DC53, 0x63309B4D, 0xD42D5A49, 0x0D0B1944, 0xBA16D840, + 0x97C6A5AC, 0x20DB64A8, 0xF9FD27A5, 0x4EE0E6A1, 0x4BB0A1BF, 0xFCAD60BB, + 0x258B23B6, 0x9296E2B2, 0x2F2BAD8A, 0x98366C8E, 0x41102F83, 0xF60DEE87, + 0xF35DA999, 0x4440689D, 0x9D662B90, 0x2A7BEA94, 0xE71DB4E0, 0x500075E4, + 0x892636E9, 0x3E3BF7ED, 0x3B6BB0F3, 0x8C7671F7, 0x555032FA, 0xE24DF3FE, + 0x5FF0BCC6, 0xE8ED7DC2, 0x31CB3ECF, 0x86D6FFCB, 0x8386B8D5, 0x349B79D1, + 0xEDBD3ADC, 0x5AA0FBD8, 0xEEE00C69, 0x59FDCD6D, 0x80DB8E60, 0x37C64F64, + 0x3296087A, 0x858BC97E, 0x5CAD8A73, 0xEBB04B77, 0x560D044F, 0xE110C54B, + 0x38368646, 0x8F2B4742, 0x8A7B005C, 0x3D66C158, 0xE4408255, 0x535D4351, + 0x9E3B1D25, 0x2926DC21, 0xF0009F2C, 0x471D5E28, 0x424D1936, 0xF550D832, + 0x2C769B3F, 0x9B6B5A3B, 0x26D61503, 0x91CBD407, 0x48ED970A, 0xFFF0560E, + 0xFAA01110, 0x4DBDD014, 0x949B9319, 0x2386521D, 0x0E562FF1, 0xB94BEEF5, + 0x606DADF8, 0xD7706CFC, 0xD2202BE2, 0x653DEAE6, 0xBC1BA9EB, 0x0B0668EF, + 0xB6BB27D7, 0x01A6E6D3, 0xD880A5DE, 0x6F9D64DA, 0x6ACD23C4, 0xDDD0E2C0, + 0x04F6A1CD, 0xB3EB60C9, 0x7E8D3EBD, 0xC990FFB9, 0x10B6BCB4, 0xA7AB7DB0, + 0xA2FB3AAE, 0x15E6FBAA, 0xCCC0B8A7, 0x7BDD79A3, 0xC660369B, 0x717DF79F, + 0xA85BB492, 0x1F467596, 0x1A163288, 0xAD0BF38C, 0x742DB081, 0xC3307185, + 0x99908A5D, 0x2E8D4B59, 0xF7AB0854, 0x40B6C950, 0x45E68E4E, 0xF2FB4F4A, + 0x2BDD0C47, 0x9CC0CD43, 0x217D827B, 0x9660437F, 0x4F460072, 0xF85BC176, + 0xFD0B8668, 0x4A16476C, 0x93300461, 0x242DC565, 0xE94B9B11, 0x5E565A15, + 0x87701918, 0x306DD81C, 0x353D9F02, 0x82205E06, 0x5B061D0B, 0xEC1BDC0F, + 0x51A69337, 0xE6BB5233, 0x3F9D113E, 0x8880D03A, 0x8DD09724, 0x3ACD5620, + 0xE3EB152D, 0x54F6D429, 0x7926A9C5, 0xCE3B68C1, 0x171D2BCC, 0xA000EAC8, + 0xA550ADD6, 0x124D6CD2, 0xCB6B2FDF, 0x7C76EEDB, 0xC1CBA1E3, 0x76D660E7, + 0xAFF023EA, 0x18EDE2EE, 0x1DBDA5F0, 0xAAA064F4, 0x738627F9, 0xC49BE6FD, + 0x09FDB889, 0xBEE0798D, 0x67C63A80, 0xD0DBFB84, 0xD58BBC9A, 0x62967D9E, + 0xBBB03E93, 0x0CADFF97, 0xB110B0AF, 0x060D71AB, 0xDF2B32A6, 0x6836F3A2, + 0x6D66B4BC, 0xDA7B75B8, 0x035D36B5, 0xB440F7B1, +} + +func checksum(data []byte) uint32 { + crc := uint32(0xFFFFFFFF) + for _, b := range data { + crc = table[b^byte(crc)] ^ (crc >> 8) + } + return crc +} diff --git a/installs_on_host/go2rtc/pkg/mpegts/consumer.go b/installs_on_host/go2rtc/pkg/mpegts/consumer.go new file mode 100644 index 0000000..fcb57c7 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/mpegts/consumer.go @@ -0,0 +1,124 @@ +package mpegts + +import ( + "io" + + "github.com/AlexxIT/go2rtc/pkg/aac" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/h265" + "github.com/pion/rtp" +) + +type Consumer struct { + core.Connection + muxer *Muxer + wr *core.WriteBuffer +} + +func NewConsumer() *Consumer { + medias := []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecH264}, + {Name: core.CodecH265}, + }, + }, + { + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecAAC}, + }, + }, + } + wr := core.NewWriteBuffer(nil) + return &Consumer{ + core.Connection{ + ID: core.NewID(), + FormatName: "mpegts", + Medias: medias, + Transport: wr, + }, + NewMuxer(), + wr, + } +} + +func (c *Consumer) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + sender := core.NewSender(media, track.Codec) + + switch track.Codec.Name { + case core.CodecH264: + pid := c.muxer.AddTrack(StreamTypeH264) + + sender.Handler = func(pkt *rtp.Packet) { + b := c.muxer.GetPayload(pid, pkt.Timestamp, pkt.Payload) + if n, err := c.wr.Write(b); err == nil { + c.Send += n + } + } + + if track.Codec.IsRTP() { + sender.Handler = h264.RTPDepay(track.Codec, sender.Handler) + } else { + sender.Handler = h264.RepairAVCC(track.Codec, sender.Handler) + } + + case core.CodecH265: + pid := c.muxer.AddTrack(StreamTypeH265) + + sender.Handler = func(pkt *rtp.Packet) { + b := c.muxer.GetPayload(pid, pkt.Timestamp, pkt.Payload) + if n, err := c.wr.Write(b); err == nil { + c.Send += n + } + } + + if track.Codec.IsRTP() { + sender.Handler = h265.RTPDepay(track.Codec, sender.Handler) + } + + case core.CodecAAC: + pid := c.muxer.AddTrack(StreamTypeAAC) + + // convert timestamp to 90000Hz clock + dt := 90000 / float64(track.Codec.ClockRate) + + sender.Handler = func(pkt *rtp.Packet) { + pts := uint32(float64(pkt.Timestamp) * dt) + b := c.muxer.GetPayload(pid, pts, pkt.Payload) + if n, err := c.wr.Write(b); err == nil { + c.Send += n + } + } + + if track.Codec.IsRTP() { + sender.Handler = aac.RTPToADTS(track.Codec, sender.Handler) + } else { + sender.Handler = aac.EncodeToADTS(track.Codec, sender.Handler) + } + } + + sender.HandleRTP(track) + c.Senders = append(c.Senders, sender) + return nil +} + +func (c *Consumer) WriteTo(wr io.Writer) (int64, error) { + b := c.muxer.GetHeader() + if _, err := wr.Write(b); err != nil { + return 0, err + } + + return c.wr.WriteTo(wr) +} + +//func TimestampFromRTP(rtp *rtp.Packet, codec *core.Codec) { +// if codec.ClockRate == ClockRate { +// return +// } +// rtp.Timestamp = uint32(float64(rtp.Timestamp) / float64(codec.ClockRate) * ClockRate) +//} diff --git a/installs_on_host/go2rtc/pkg/mpegts/demuxer.go b/installs_on_host/go2rtc/pkg/mpegts/demuxer.go new file mode 100644 index 0000000..3e78fce --- /dev/null +++ b/installs_on_host/go2rtc/pkg/mpegts/demuxer.go @@ -0,0 +1,434 @@ +package mpegts + +import ( + "bytes" + "errors" + "io" + + "github.com/AlexxIT/go2rtc/pkg/aac" + "github.com/AlexxIT/go2rtc/pkg/bits" + "github.com/AlexxIT/go2rtc/pkg/h264/annexb" + "github.com/pion/rtp" +) + +type Demuxer struct { + buf [PacketSize]byte // total buf + + byte byte // current byte + bits byte // bits left in byte + pos byte // current pos in buf + end byte // end position + + pmtID uint16 // Program Map Table (PMT) PID + pes map[uint16]*PES +} + +func NewDemuxer() *Demuxer { + return &Demuxer{} +} + +const skipRead = 0xFF + +func (d *Demuxer) ReadPacket(rd io.Reader) (*rtp.Packet, error) { + for { + if d.pos != skipRead { + if _, err := io.ReadFull(rd, d.buf[:]); err != nil { + return nil, err + } + } + + pid, start, err := d.readPacketHeader() + if err != nil { + return nil, err + } + + if d.pes == nil { + switch pid { + case 0: // PAT ID + d.readPAT() // PAT: Program Association Table + case d.pmtID: + d.readPMT() // PMT : Program Map Table + + pkt := &rtp.Packet{ + Payload: make([]byte, 0, len(d.pes)), + } + for _, pes := range d.pes { + pkt.Payload = append(pkt.Payload, pes.StreamType) + } + return pkt, nil + } + continue + } + + if pkt := d.readPES(pid, start); pkt != nil { + return pkt, nil + } + } +} + +func (d *Demuxer) readPacketHeader() (pid uint16, start bool, err error) { + d.reset() + + sb := d.readByte() // Sync byte + if sb != SyncByte { + return 0, false, errors.New("mpegts: wrong sync byte") + } + + _ = d.readBit() // Transport error indicator (TEI) + pusi := d.readBit() // Payload unit start indicator (PUSI) + _ = d.readBit() // Transport priority + pid = d.readBits16(13) // PID + + _ = d.readBits(2) // Transport scrambling control (TSC) + af := d.readBit() // Adaptation field + _ = d.readBit() // Payload + _ = d.readBits(4) // Continuity counter + + if af != 0 { + adSize := d.readByte() // Adaptation field length + if adSize > PacketSize-6 { + return 0, false, errors.New("mpegts: wrong adaptation size") + } + d.skip(adSize) + } + + return pid, pusi != 0, nil +} + +func (d *Demuxer) skip(i byte) { + d.pos += i +} + +func (d *Demuxer) readBytes(i byte) []byte { + d.pos += i + return d.buf[d.pos-i : d.pos] +} + +func (d *Demuxer) readPSIHeader() { + // https://en.wikipedia.org/wiki/Program-specific_information#Table_Sections + pointer := d.readByte() // Pointer field + d.skip(pointer) // Pointer filler bytes + + _ = d.readByte() // Table ID + _ = d.readBit() // Section syntax indicator + _ = d.readBit() // Private bit + _ = d.readBits(2) // Reserved bits + _ = d.readBits(2) // Section length unused bits + size := d.readBits(10) // Section length + d.setSize(byte(size)) + + _ = d.readBits(16) // Table ID extension + _ = d.readBits(2) // Reserved bits + _ = d.readBits(5) // Version number + _ = d.readBit() // Current/next indicator + _ = d.readByte() // Section number + _ = d.readByte() // Last section number +} + +// ReadPAT (Program Association Table) +func (d *Demuxer) readPAT() { + // https://en.wikipedia.org/wiki/Program-specific_information#PAT_(Program_Association_Table) + d.readPSIHeader() + + const CRCSize = 4 + for d.left() > CRCSize { + num := d.readBits(16) // Program num + _ = d.readBits(3) // Reserved bits + pid := d.readBits16(13) // Program map PID + if num != 0 { + d.pmtID = pid + } + } + + d.skip(4) // CRC32 +} + +// ReadPMT (Program map specific data) +func (d *Demuxer) readPMT() { + // https://en.wikipedia.org/wiki/Program-specific_information#PMT_(Program_map_specific_data) + d.readPSIHeader() + + _ = d.readBits(3) // Reserved bits + _ = d.readBits(13) // PCR PID + _ = d.readBits(4) // Reserved bits + _ = d.readBits(2) // Program info length unused bits + size := d.readBits(10) // Program info length + d.skip(byte(size)) + + d.pes = map[uint16]*PES{} + + const CRCSize = 4 + for d.left() > CRCSize { + streamType := d.readByte() // Stream type + _ = d.readBits(3) // Reserved bits + pid := d.readBits16(13) // Elementary PID + _ = d.readBits(4) // Reserved bits + _ = d.readBits(2) // ES Info length unused bits + size = d.readBits(10) // ES Info length + info := d.readBytes(byte(size)) + + if streamType == StreamTypePrivate && bytes.HasPrefix(info, opusInfo) { + streamType = StreamTypePrivateOPUS + } + + d.pes[pid] = &PES{StreamType: streamType} + } + + d.skip(4) // CRC32 +} + +func (d *Demuxer) readPES(pid uint16, start bool) *rtp.Packet { + pes := d.pes[pid] + if pes == nil { + return nil + } + + // if new payload beging + if start { + if len(pes.Payload) != 0 { + d.pos = skipRead + return pes.GetPacket() // finish previous packet + } + + // https://en.wikipedia.org/wiki/Packetized_elementary_stream + // Packet start code prefix + if d.readByte() != 0 || d.readByte() != 0 || d.readByte() != 1 { + return nil + } + + pes.StreamID = d.readByte() // Stream id + packetSize := d.readBits16(16) // PES Packet length + + _ = d.readBits(2) // Marker bits + _ = d.readBits(2) // Scrambling control + _ = d.readBit() // Priority + _ = d.readBit() // Data alignment indicator + _ = d.readBit() // Copyright + _ = d.readBit() // Original or Copy + + ptsi := d.readBit() // PTS indicator + dtsi := d.readBit() // DTS indicator + _ = d.readBit() // ESCR flag + _ = d.readBit() // ES rate flag + _ = d.readBit() // DSM trick mode flag + _ = d.readBit() // Additional copy info flag + _ = d.readBit() // CRC flag + _ = d.readBit() // extension flag + + headerSize := d.readByte() // PES header length + + if packetSize != 0 { + packetSize -= uint16(3 + headerSize) + } + + if ptsi != 0 { + pes.PTS = d.readTime() + headerSize -= 5 + } else { + pes.PTS = 0 + } + + if dtsi != 0 { + pes.DTS = d.readTime() + headerSize -= 5 + } else { + pes.DTS = 0 + } + + d.skip(headerSize) + + pes.SetBuffer(packetSize, d.bytes()) + } else { + pes.AppendBuffer(d.bytes()) + } + + if pes.Size != 0 && len(pes.Payload) >= pes.Size { + return pes.GetPacket() // finish current packet + } + + return nil +} + +func (d *Demuxer) reset() { + d.pos = 0 + d.end = PacketSize + d.bits = 0 +} + +//goland:noinspection GoStandardMethods +func (d *Demuxer) readByte() byte { + if d.bits != 0 { + return byte(d.readBits(8)) + } + + b := d.buf[d.pos] + d.pos++ + return b +} + +func (d *Demuxer) readBit() byte { + if d.bits == 0 { + d.byte = d.readByte() + d.bits = 7 + } else { + d.bits-- + } + + return (d.byte >> d.bits) & 0b1 +} + +func (d *Demuxer) readBits(n byte) (res uint32) { + for i := n - 1; i != 255; i-- { + res |= uint32(d.readBit()) << i + } + return +} + +func (d *Demuxer) readBits16(n byte) (res uint16) { + for i := n - 1; i != 255; i-- { + res |= uint16(d.readBit()) << i + } + return +} + +func (d *Demuxer) readTime() uint32 { + // https://en.wikipedia.org/wiki/Packetized_elementary_stream + // xxxxAAAx BBBBBBBB BBBBBBBx CCCCCCCC CCCCCCCx + _ = d.readBits(4) // 0010b or 0011b or 0001b + ts := d.readBits(3) << 30 + _ = d.readBits(1) // 1b + ts |= d.readBits(15) << 15 + _ = d.readBits(1) // 1b + ts |= d.readBits(15) + _ = d.readBits(1) // 1b + return ts +} + +func (d *Demuxer) bytes() []byte { + return d.buf[d.pos:PacketSize] +} + +func (d *Demuxer) left() byte { + return d.end - d.pos +} + +func (d *Demuxer) setSize(size byte) { + d.end = d.pos + size +} + +const ( + PacketSize = 188 + SyncByte = 0x47 // Uppercase G + ClockRate = 90000 // fixed clock rate for PTS/DTS of any type +) + +// https://en.wikipedia.org/wiki/Program-specific_information#Elementary_stream_types +const ( + StreamTypeMetadata = 0 // Reserved + StreamTypePrivate = 0x06 // PCMU or PCMA or FLAC from FFmpeg + StreamTypeAAC = 0x0F + StreamTypeH264 = 0x1B + StreamTypeH265 = 0x24 + StreamTypePCMATapo = 0x90 + StreamTypePCMUTapo = 0x91 + StreamTypePrivateOPUS = 0xEB +) + +// PES - Packetized Elementary Stream +type PES struct { + StreamID byte // from each PES header + StreamType byte // from PMT table + Sequence uint16 // manual + Timestamp uint32 // manual + PTS uint32 // from extra header, always 90000Hz + DTS uint32 + Payload []byte // from PES body + Size int // from PES header, can be 0 + + wr *bits.Writer +} + +func (p *PES) SetBuffer(size uint16, b []byte) { + p.Payload = make([]byte, 0, size) + p.Payload = append(p.Payload, b...) + p.Size = int(size) +} + +func (p *PES) AppendBuffer(b []byte) { + p.Payload = append(p.Payload, b...) +} + +func (p *PES) GetPacket() (pkt *rtp.Packet) { + switch p.StreamType { + case StreamTypeH264, StreamTypeH265: + pkt = &rtp.Packet{ + Header: rtp.Header{ + PayloadType: p.StreamType, + }, + Payload: annexb.EncodeToAVCC(p.Payload), + } + + if p.DTS != 0 { + pkt.Timestamp = p.DTS + // wrong place for CTS, but we don't have another one + pkt.ExtensionProfile = uint16(p.PTS - p.DTS) + } else { + pkt.Timestamp = p.PTS + } + + case StreamTypeAAC: + p.Sequence++ + + pkt = &rtp.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: true, + PayloadType: p.StreamType, + SequenceNumber: p.Sequence, + Timestamp: p.PTS, + //Timestamp: p.Timestamp, + }, + Payload: aac.ADTStoRTP(p.Payload), + } + + //p.Timestamp += aac.RTPTimeSize(pkt.Payload) // update next timestamp! + + case StreamTypePCMATapo, StreamTypePCMUTapo: + p.Sequence++ + + pkt = &rtp.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: true, + PayloadType: p.StreamType, + SequenceNumber: p.Sequence, + Timestamp: p.PTS, + //Timestamp: p.Timestamp, + }, + Payload: p.Payload, + } + + //p.Timestamp += uint32(len(p.Payload)) // update next timestamp! + + case StreamTypePrivateOPUS: + p.Sequence++ + + pkt = &rtp.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: true, + PayloadType: p.StreamType, + SequenceNumber: p.Sequence, + Timestamp: p.PTS, + }, + } + + pkt.Payload, p.Payload = CutOPUSPacket(p.Payload) + p.PTS += opusDT + return + } + + p.Payload = nil + + return +} diff --git a/installs_on_host/go2rtc/pkg/mpegts/muxer.go b/installs_on_host/go2rtc/pkg/mpegts/muxer.go new file mode 100644 index 0000000..5d4129d --- /dev/null +++ b/installs_on_host/go2rtc/pkg/mpegts/muxer.go @@ -0,0 +1,226 @@ +package mpegts + +import ( + "encoding/binary" + + "github.com/AlexxIT/go2rtc/pkg/bits" + "github.com/AlexxIT/go2rtc/pkg/h264/annexb" +) + +type Muxer struct { + pes map[uint16]*PES +} + +func NewMuxer() *Muxer { + return &Muxer{ + pes: map[uint16]*PES{}, + } +} + +func (m *Muxer) AddTrack(streamType byte) (pid uint16) { + pes := &PES{StreamType: streamType} + + // Audio streams (0xC0-0xDF), Video streams (0xE0-0xEF) + switch streamType { + case StreamTypeH264, StreamTypeH265: + pes.StreamID = 0xE0 + case StreamTypeAAC, StreamTypePCMATapo: + pes.StreamID = 0xC0 + } + + pid = pes0PID + uint16(len(m.pes)) + m.pes[pid] = pes + + return +} + +func (m *Muxer) GetHeader() []byte { + bw := bits.NewWriter(nil) + m.writePAT(bw) + m.writePMT(bw) + return bw.Bytes() +} + +// GetPayload - safe to run concurently with different pid +func (m *Muxer) GetPayload(pid uint16, timestamp uint32, payload []byte) []byte { + pes := m.pes[pid] + + switch pes.StreamType { + case StreamTypeH264, StreamTypeH265: + payload = annexb.DecodeAVCCWithAUD(payload) + } + + if pes.Timestamp != 0 { + pes.PTS += timestamp - pes.Timestamp + } + pes.Timestamp = timestamp + + // min header size (3 byte) + adv header size (PES) + size := 3 + 5 + len(payload) + + b := make([]byte, 6+3+5) + + b[0], b[1], b[2] = 0, 0, 1 // Packet start code prefix + b[3] = pes.StreamID // Stream ID + + // PES Packet length (zero value OK for video) + if size <= 0xFFFF { + binary.BigEndian.PutUint16(b[4:], uint16(size)) + } + + // Optional PES header: + b[6] = 0x80 // Marker bits (binary) + b[7] = 0x80 // PTS indicator + b[8] = 5 // PES header length + + WriteTime(b[9:], pes.PTS) + + pes.Payload = append(b, payload...) + pes.Size = 1 // set PUSI in first PES + + if pes.wr == nil { + pes.wr = bits.NewWriter(nil) + } else { + pes.wr.Reset() + } + + for len(pes.Payload) > 0 { + m.writePES(pes.wr, pid, pes) + pes.Sequence++ + pes.Size = 0 + } + + return pes.wr.Bytes() +} + +const patPID = 0 +const pmtPID = 0x1000 +const pes0PID = 0x100 + +func (m *Muxer) writePAT(wr *bits.Writer) { + m.writeHeader(wr, patPID) + i := wr.Len() + 1 // start for CRC32 + m.writePSIHeader(wr, 0, 4) + + wr.WriteUint16(1) // Program num + wr.WriteBits8(0b111, 3) // Reserved bits (all to 1) + wr.WriteBits16(pmtPID, 13) // Program map PID + + crc := checksum(wr.Bytes()[i:]) + wr.WriteBytes(byte(crc), byte(crc>>8), byte(crc>>16), byte(crc>>24)) // CRC32 (little endian) + + m.WriteTail(wr) +} + +func (m *Muxer) writePMT(wr *bits.Writer) { + m.writeHeader(wr, pmtPID) + i := wr.Len() + 1 // start for CRC32 + m.writePSIHeader(wr, 2, 4+uint16(len(m.pes))*5) // 4 bytes below + 5 bytes each PES + + wr.WriteBits8(0b111, 3) // Reserved bits (all to 1) + wr.WriteBits16(0x1FFF, 13) // Program map PID (not used) + + wr.WriteBits8(0b1111, 4) // Reserved bits (all to 1) + wr.WriteBits8(0, 2) // Program info length unused bits (all to 0) + wr.WriteBits16(0, 10) // Program info length + + for pid := uint16(pes0PID); ; pid++ { + pes, ok := m.pes[pid] + if !ok { + break + } + wr.WriteByte(pes.StreamType) // Stream type + wr.WriteBits8(0b111, 3) // Reserved bits (all to 1) + wr.WriteBits16(pid, 13) // Elementary PID + wr.WriteBits8(0b1111, 4) // Reserved bits (all to 1) + wr.WriteBits(0, 2) // ES Info length unused bits + wr.WriteBits16(0, 10) // ES Info length + } + + crc := checksum(wr.Bytes()[i:]) + wr.WriteBytes(byte(crc), byte(crc>>8), byte(crc>>16), byte(crc>>24)) // CRC32 (little endian) + + m.WriteTail(wr) +} + +func (m *Muxer) writePES(wr *bits.Writer, pid uint16, pes *PES) { + const flagPUSI = 0b01000000_00000000 + const flagAdaptation = 0b00100000 + const flagPayload = 0b00010000 + + wr.WriteByte(SyncByte) + + if pes.Size != 0 { + pid |= flagPUSI // Payload unit start indicator (PUSI) + } + + wr.WriteUint16(pid) + + counter := byte(pes.Sequence) & 0xF + + if size := len(pes.Payload); size < PacketSize-4 { + wr.WriteByte(flagAdaptation | flagPayload | counter) // adaptation + payload + + // for 183 payload will be zero + adSize := PacketSize - 4 - 1 - byte(size) + wr.WriteByte(adSize) + wr.WriteBytes(make([]byte, adSize)...) + + wr.WriteBytes(pes.Payload...) + pes.Payload = nil + } else { + wr.WriteByte(flagPayload | counter) // only payload + + wr.WriteBytes(pes.Payload[:PacketSize-4]...) + pes.Payload = pes.Payload[PacketSize-4:] + } +} + +func (m *Muxer) writeHeader(wr *bits.Writer, pid uint16) { + wr.WriteByte(SyncByte) + + wr.WriteBit(0) // Transport error indicator (TEI) + wr.WriteBit(1) // Payload unit start indicator (PUSI) + wr.WriteBit(0) // Transport priority + wr.WriteBits16(pid, 13) // PID + + wr.WriteBits8(0, 2) // Transport scrambling control (TSC) + wr.WriteBit(0) // Adaptation field + wr.WriteBit(1) // Payload + wr.WriteBits8(0, 4) // Continuity counter +} + +func (m *Muxer) writePSIHeader(wr *bits.Writer, tableID byte, size uint16) { + wr.WriteByte(0) // Pointer field + + wr.WriteByte(tableID) // Table ID + + wr.WriteBit(1) // Section syntax indicator + wr.WriteBit(0) // Private bit + wr.WriteBits8(0b11, 2) // Reserved bits (all to 1) + wr.WriteBits8(0, 2) // Section length unused bits (all to 0) + wr.WriteBits16(5+size+4, 10) // Section length (5 bytes below + content + 4 bytes CRC32) + + wr.WriteUint16(1) // Table ID extension + wr.WriteBits8(0b11, 2) // Reserved bits (all to 1) + wr.WriteBits8(0, 5) // Version number + wr.WriteBit(1) // Current/next indicator + + wr.WriteByte(0) // Section number + wr.WriteByte(0) // Last section number +} + +func (m *Muxer) WriteTail(wr *bits.Writer) { + size := PacketSize - wr.Len()%PacketSize + wr.WriteBytes(make([]byte, size)...) +} + +func WriteTime(b []byte, t uint32) { + _ = b[4] // bounds + const onlyPTS = 0x20 + b[0] = onlyPTS | byte(t>>(32-3)) | 1 + b[1] = byte(t >> (24 - 2)) + b[2] = byte(t>>(16-2)) | 1 + b[3] = byte(t >> (8 - 1)) + b[4] = byte(t<<1) | 1 // t>>(0-1) +} diff --git a/installs_on_host/go2rtc/pkg/mpegts/opus.go b/installs_on_host/go2rtc/pkg/mpegts/opus.go new file mode 100644 index 0000000..d6077ea --- /dev/null +++ b/installs_on_host/go2rtc/pkg/mpegts/opus.go @@ -0,0 +1,66 @@ +package mpegts + +import ( + "github.com/AlexxIT/go2rtc/pkg/bits" +) + +// opusDT - each AU from FFmpeg has 5 OPUS packets. Each packet len = 960 in the 48000 clock. +const opusDT = 960 * ClockRate / 48000 + +// https://opus-codec.org/docs/ +var opusInfo = []byte{ // registration_descriptor + 0x05, // descriptor_tag + 0x04, // descriptor_length + 'O', 'p', 'u', 's', // format_identifier +} + +//goland:noinspection GoSnakeCaseUsage +func CutOPUSPacket(b []byte) (packet []byte, left []byte) { + r := bits.NewReader(b) + + size := opus_control_header(r) + if size == 0 { + return nil, nil + } + + packet = r.ReadBytes(size) + left = r.Left() + return +} + +//goland:noinspection GoSnakeCaseUsage +func opus_control_header(r *bits.Reader) int { + control_header_prefix := r.ReadBits(11) + if control_header_prefix != 0x3FF { + return 0 + } + + start_trim_flag := r.ReadBit() + end_trim_flag := r.ReadBit() + control_extension_flag := r.ReadBit() + _ = r.ReadBits(2) // reserved + + var payload_size int + for { + i := r.ReadByte() + payload_size += int(i) + if i < 255 { + break + } + } + + if start_trim_flag != 0 { + _ = r.ReadBits(3) + _ = r.ReadBits(13) + } + if end_trim_flag != 0 { + _ = r.ReadBits(3) + _ = r.ReadBits(13) + } + if control_extension_flag != 0 { + control_extension_length := r.ReadByte() + _ = r.ReadBytes(int(control_extension_length)) // reserved + } + + return payload_size +} diff --git a/installs_on_host/go2rtc/pkg/mpegts/producer.go b/installs_on_host/go2rtc/pkg/mpegts/producer.go new file mode 100644 index 0000000..14417bf --- /dev/null +++ b/installs_on_host/go2rtc/pkg/mpegts/producer.go @@ -0,0 +1,177 @@ +package mpegts + +import ( + "bytes" + "io" + "time" + + "github.com/AlexxIT/go2rtc/pkg/aac" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/h265" + "github.com/pion/rtp" +) + +type Producer struct { + core.Connection + rd *core.ReadBuffer +} + +func Open(rd io.Reader) (*Producer, error) { + prod := &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "mpegts", + Transport: rd, + }, + rd: core.NewReadBuffer(rd), + } + if err := prod.probe(); err != nil { + return nil, err + } + return prod, nil +} + +func (c *Producer) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + receiver, _ := c.Connection.GetTrack(media, codec) + receiver.ID = StreamType(codec) + return receiver, nil +} + +func (c *Producer) Start() error { + rd := NewDemuxer() + + for { + pkt, err := rd.ReadPacket(c.rd) + if err != nil { + return err + } + + c.Recv += len(pkt.Payload) + + //log.Printf("[mpegts] size: %6d, muxer: %10d, pt: %2d", len(pkt.Payload), pkt.Timestamp, pkt.PayloadType) + + for _, receiver := range c.Receivers { + if receiver.ID == pkt.PayloadType { + TimestampToRTP(pkt, receiver.Codec) + receiver.WriteRTP(pkt) + break + } + } + } +} + +func (c *Producer) probe() error { + c.rd.BufferSize = core.ProbeSize + defer c.rd.Reset() + + rd := NewDemuxer() + + // Strategy: + // 1. Wait packet with metadata, init other packets for wait + // 2. Wait other packets + // 3. Stop after timeout + waitType := []byte{StreamTypeMetadata} + timeout := time.Now().Add(core.ProbeTimeout) + + for len(waitType) != 0 && time.Now().Before(timeout) { + pkt, err := rd.ReadPacket(c.rd) + if err != nil { + return err + } + + // check if we wait this type + if i := bytes.IndexByte(waitType, pkt.PayloadType); i < 0 { + continue + } else { + waitType = append(waitType[:i], waitType[i+1:]...) + } + + switch pkt.PayloadType { + case StreamTypeMetadata: + for _, streamType := range pkt.Payload { + switch streamType { + case StreamTypeH264, StreamTypeH265, StreamTypeAAC, StreamTypePrivateOPUS, StreamTypePCMATapo: + waitType = append(waitType, streamType) + } + } + + case StreamTypeH264: + codec := h264.AVCCToCodec(pkt.Payload) + media := &core.Media{ + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{codec}, + } + c.Medias = append(c.Medias, media) + + case StreamTypeH265: + codec := h265.AVCCToCodec(pkt.Payload) + media := &core.Media{ + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{codec}, + } + c.Medias = append(c.Medias, media) + + case StreamTypeAAC: + codec := aac.RTPToCodec(pkt.Payload) + media := &core.Media{ + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{codec}, + } + c.Medias = append(c.Medias, media) + + case StreamTypePrivateOPUS: + codec := &core.Codec{ + Name: core.CodecOpus, + ClockRate: 48000, + Channels: 2, + } + media := &core.Media{ + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{codec}, + } + c.Medias = append(c.Medias, media) + + case StreamTypePCMATapo: + codec := &core.Codec{ + Name: core.CodecPCMA, + ClockRate: 8000, + } + media := &core.Media{ + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{codec}, + } + c.Medias = append(c.Medias, media) + } + } + + return nil +} + +func StreamType(codec *core.Codec) uint8 { + switch codec.Name { + case core.CodecH264: + return StreamTypeH264 + case core.CodecH265: + return StreamTypeH265 + case core.CodecAAC: + return StreamTypeAAC + case core.CodecPCMA: + return StreamTypePCMATapo + case core.CodecOpus: + return StreamTypePrivateOPUS + } + return 0 +} + +func TimestampToRTP(rtp *rtp.Packet, codec *core.Codec) { + if codec.ClockRate == ClockRate { + return + } + rtp.Timestamp = uint32(float64(rtp.Timestamp) * float64(codec.ClockRate) / ClockRate) +} diff --git a/installs_on_host/go2rtc/pkg/mpjpeg/multipart.go b/installs_on_host/go2rtc/pkg/mpjpeg/multipart.go new file mode 100644 index 0000000..ca8924e --- /dev/null +++ b/installs_on_host/go2rtc/pkg/mpjpeg/multipart.go @@ -0,0 +1,60 @@ +package mpjpeg + +import ( + "bufio" + "errors" + "io" + "net/http" + "net/textproto" + "strconv" + "strings" +) + +func Next(rd *bufio.Reader) (http.Header, []byte, error) { + for { + // search next boundary and skip empty lines + s, err := rd.ReadString('\n') + if err != nil { + return nil, nil, err + } + + if s == "\r\n" { + continue + } + + if !strings.HasPrefix(s, "--") { + return nil, nil, errors.New("multipart: wrong boundary: " + s) + } + + // Foscam G2 has a awful implementation of MJPEG + // https://github.com/AlexxIT/go2rtc/issues/1258 + if b, _ := rd.Peek(2); string(b) == "--" { + continue + } + + break + } + + tp := textproto.NewReader(rd) + header, err := tp.ReadMIMEHeader() + if err != nil { + return nil, nil, err + } + + s := header.Get("Content-Length") + if s == "" { + return nil, nil, errors.New("multipart: no content length") + } + + size, err := strconv.Atoi(s) + if err != nil { + return nil, nil, err + } + + buf := make([]byte, size) + if _, err = io.ReadFull(rd, buf); err != nil { + return nil, nil, err + } + + return http.Header(header), buf, nil +} diff --git a/installs_on_host/go2rtc/pkg/mpjpeg/producer.go b/installs_on_host/go2rtc/pkg/mpjpeg/producer.go new file mode 100644 index 0000000..a8d5e16 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/mpjpeg/producer.go @@ -0,0 +1,65 @@ +package mpjpeg + +import ( + "bufio" + "errors" + "io" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +type Producer struct { + core.Connection + rd *bufio.Reader +} + +func Open(rd io.Reader) (*Producer, error) { + return &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "mpjpeg", // Multipart JPEG + Transport: rd, + Medias: []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + { + Name: core.CodecJPEG, + ClockRate: 90000, + PayloadType: core.PayloadTypeRAW, + }, + }, + }, + }, + }, + }, nil +} + +func (c *Producer) Start() error { + if len(c.Receivers) != 1 { + return errors.New("mjpeg: no receivers") + } + + rd := bufio.NewReader(c.Transport.(io.Reader)) + + mjpeg := c.Receivers[0] + + for { + _, body, err := Next(rd) + if err != nil { + return err + } + + c.Recv += len(body) + + if mjpeg != nil { + packet := &rtp.Packet{ + Header: rtp.Header{Timestamp: core.Now90000()}, + Payload: body, + } + mjpeg.WriteRTP(packet) + } + } +} diff --git a/installs_on_host/go2rtc/pkg/mqtt/client.go b/installs_on_host/go2rtc/pkg/mqtt/client.go new file mode 100644 index 0000000..8874d08 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/mqtt/client.go @@ -0,0 +1,112 @@ +package mqtt + +import ( + "bytes" + "encoding/binary" + "errors" + "io" + "net" + "time" +) + +const Timeout = time.Second * 5 + +type Client struct { + conn net.Conn + mid uint16 +} + +func NewClient(conn net.Conn) *Client { + return &Client{conn: conn, mid: 2} +} + +func (c *Client) Connect(clientID, username, password string) (err error) { + if err = c.conn.SetDeadline(time.Now().Add(Timeout)); err != nil { + return + } + + msg := NewConnect(clientID, username, password) + if _, err = c.conn.Write(msg.b); err != nil { + return + } + + b := make([]byte, 4) + if _, err = io.ReadFull(c.conn, b); err != nil { + return + } + + if !bytes.Equal(b, []byte{CONNACK, 2, 0, 0}) { + return errors.New("wrong login") + } + + return +} + +func (c *Client) Subscribe(topic string) (err error) { + if err = c.conn.SetDeadline(time.Now().Add(Timeout)); err != nil { + return + } + + c.mid++ + msg := NewSubscribe(c.mid, topic, 1) + _, err = c.conn.Write(msg.b) + return +} + +func (c *Client) Publish(topic string, payload []byte) (err error) { + if err = c.conn.SetDeadline(time.Now().Add(Timeout)); err != nil { + return + } + + c.mid++ + msg := NewPublishQOS1(c.mid, topic, payload) + _, err = c.conn.Write(msg.b) + return +} + +func (c *Client) Read() (string, []byte, error) { + if err := c.conn.SetDeadline(time.Now().Add(Timeout)); err != nil { + return "", nil, err + } + + b := make([]byte, 1) + if _, err := io.ReadFull(c.conn, b); err != nil { + return "", nil, err + } + + size, err := ReadLen(c.conn) + if err != nil { + return "", nil, err + } + + b0 := b[0] + b = make([]byte, size) + if _, err = io.ReadFull(c.conn, b); err != nil { + return "", nil, err + } + + if b0&0xF0 != PUBLISH { + return "", nil, nil + } + + i := binary.BigEndian.Uint16(b) + if uint32(i) > size { + return "", nil, errors.New("wrong topic size") + } + + b = b[2:] + + if qos := (b0 >> 1) & 0b11; qos == 0 { + return string(b[:i]), b[i:], nil + } + + // response with packet ID + _, _ = c.conn.Write([]byte{PUBACK, 2, b[i], b[i+1]}) + + return string(b[2:i]), b[i+2:], nil +} + +func (c *Client) Close() error { + // TODO: Teardown + return c.conn.Close() +} diff --git a/installs_on_host/go2rtc/pkg/mqtt/message.go b/installs_on_host/go2rtc/pkg/mqtt/message.go new file mode 100644 index 0000000..e3d3421 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/mqtt/message.go @@ -0,0 +1,122 @@ +package mqtt + +import ( + "io" +) + +type Message struct { + b []byte +} + +// https://docs.oasis-open.org/mqtt/mqtt/v5.0/mqtt-v5.0.html +const ( + CONNECT = 0x10 + CONNACK = 0x20 + PUBLISH = 0x30 + PUBACK = 0x40 + SUBSCRIBE = 0x82 + SUBACK = 0x90 + QOS1 = 0x02 +) + +func (m *Message) WriteByte(b byte) { + m.b = append(m.b, b) +} + +func (m *Message) WriteBytes(b []byte) { + m.b = append(m.b, b...) +} + +func (m *Message) WriteUint16(i uint16) { + m.b = append(m.b, byte(i>>8), byte(i)) +} + +func (m *Message) WriteLen(i int) { + for i > 0 { + b := byte(i % 128) + if i /= 128; i > 0 { + b |= 0x80 + } + m.WriteByte(b) + } +} + +func (m *Message) WriteString(s string) { + m.WriteUint16(uint16(len(s))) + m.b = append(m.b, s...) +} + +func (m *Message) Bytes() []byte { + return m.b +} + +const ( + flagCleanStart = 0x02 + flagUsername = 0x80 + flagPassword = 0x40 +) + +func NewConnect(clientID, username, password string) *Message { + m := &Message{} + m.WriteByte(CONNECT) + m.WriteLen(16 + len(clientID) + len(username) + len(password)) + + m.WriteString("MQTT") + m.WriteByte(4) // MQTT version + m.WriteByte(flagCleanStart | flagUsername | flagPassword) + m.WriteUint16(30) // keepalive + + m.WriteString(clientID) + m.WriteString(username) + m.WriteString(password) + return m +} + +func NewSubscribe(mid uint16, topic string, qos byte) *Message { + m := &Message{} + m.WriteByte(SUBSCRIBE) + m.WriteLen(5 + len(topic)) + + m.WriteUint16(mid) + m.WriteString(topic) + m.WriteByte(qos) + return m +} + +func NewPublish(topic string, payload []byte) *Message { + m := &Message{} + m.WriteByte(PUBLISH) + m.WriteLen(2 + len(topic) + len(payload)) + + m.WriteString(topic) + m.WriteBytes(payload) + return m +} + +func NewPublishQOS1(mid uint16, topic string, payload []byte) *Message { + m := &Message{} + m.WriteByte(PUBLISH | QOS1) + m.WriteLen(4 + len(topic) + len(payload)) + + m.WriteString(topic) + m.WriteUint16(mid) + m.WriteBytes(payload) + return m +} + +func ReadLen(r io.Reader) (uint32, error) { + var i uint32 + var shift byte + + b := []byte{0x80} + for b[0]&0x80 != 0 { + if _, err := r.Read(b); err != nil { + return 0, err + } + + i += uint32(b[0]&0x7F) << shift + shift += 7 + } + + return i, nil +} diff --git a/installs_on_host/go2rtc/pkg/multitrans/client.go b/installs_on_host/go2rtc/pkg/multitrans/client.go new file mode 100644 index 0000000..d71269c --- /dev/null +++ b/installs_on_host/go2rtc/pkg/multitrans/client.go @@ -0,0 +1,203 @@ +package multitrans + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "net" + "net/http" + "net/url" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/tcp" + "github.com/google/uuid" + "github.com/pion/rtp" +) + +type Client struct { + core.Connection + conn net.Conn + rd *bufio.Reader + closed core.Waiter +} + +func Dial(rawURL string) (core.Producer, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + if u.Port() == "" { + u.Host += ":554" + } + + conn, err := net.DialTimeout("tcp", u.Host, core.ConnDialTimeout) + if err != nil { + return nil, err + } + + c := &Client{ + conn: conn, + rd: bufio.NewReader(conn), + } + + if err = c.handshake(u); err != nil { + _ = conn.Close() + return nil, err + } + + c.Connection = core.Connection{ + ID: core.NewID(), + FormatName: "multitrans", + Protocol: "rtsp", + RemoteAddr: conn.RemoteAddr().String(), + Source: rawURL, + Medias: []*core.Media{ + { + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{{Name: core.CodecPCMA, ClockRate: 8000, PayloadType: 8}}, + }, + }, + Transport: conn, + } + + return c, nil +} + +func (c *Client) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error { + sender := core.NewSender(media, track.Codec) + sender.Handler = func(packet *rtp.Packet) { + clone := rtp.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: packet.Marker, + PayloadType: 8, + SequenceNumber: packet.SequenceNumber, + Timestamp: packet.Timestamp, + SSRC: packet.SSRC, + }, + Payload: packet.Payload, + } + + // Encapsulate in RTSP Interleaved Frame (Channel 1) + // $ + Channel(1 byte) + Length(2 bytes) + Packet + size := 12 + len(clone.Payload) + b := make([]byte, 4+size) + b[0] = '$' + b[1] = 1 // Channel 1 for audio + b[2] = byte(size >> 8) + b[3] = byte(size) + if _, err := clone.MarshalTo(b[4:]); err != nil { + return + } + if _, err := c.conn.Write(b); err != nil { + return + } + } + sender.HandleRTP(track) + c.Senders = append(c.Senders, sender) + return nil +} + +func (c *Client) handshake(u *url.URL) error { + // Step 1: Get Challenge + uid := uuid.New().String() + + uri := fmt.Sprintf("rtsp://%s/multitrans", u.Host) + data := fmt.Sprintf("MULTITRANS %s RTSP/1.0\r\nCSeq: 0\r\nX-Client-UUID: %s\r\n\r\n", uri, uid) + + if _, err := c.conn.Write([]byte(data)); err != nil { + return err + } + + res, err := tcp.ReadResponse(c.rd) + if err != nil { + return err + } + + if res.StatusCode != http.StatusUnauthorized { + return errors.New("multitrans: expected 401, got " + res.Status) + } + + auth := res.Header.Get("WWW-Authenticate") + realm := tcp.Between(auth, `realm="`, `"`) + nonce := tcp.Between(auth, `nonce="`, `"`) + + // Step 2: Send Auth + user := u.User.Username() + pass, _ := u.User.Password() + + ha1 := tcp.HexMD5(user, realm, pass) + ha2 := tcp.HexMD5("MULTITRANS", uri) + response := tcp.HexMD5(ha1, nonce, ha2) + + authHeader := fmt.Sprintf(`Digest username="%s", realm="%s", nonce="%s", uri="%s", response="%s"`, + user, realm, nonce, uri, response) + + data = fmt.Sprintf("MULTITRANS %s RTSP/1.0\r\nCSeq: 1\r\nAuthorization: %s\r\nX-Client-UUID: %s\r\n\r\n", + uri, authHeader, uid) + + if _, err = c.conn.Write([]byte(data)); err != nil { + return err + } + + res, err = tcp.ReadResponse(c.rd) + if err != nil { + return err + } + + if res.StatusCode != http.StatusOK { + return errors.New("multitrans: auth failed: " + res.Status) + } + + // Session: 7116520596809429228 + session := res.Header.Get("Session") + if session == "" { + return errors.New("multitrans: no session") + } + + return c.openTalkChannel(uri, session) +} + +func (c *Client) openTalkChannel(uri, session string) error { + payload := `{"type":"request","seq":0,"params":{"method":"get","talk":{"mode":"full_duplex"}}}` + + data := fmt.Sprintf("MULTITRANS %s RTSP/1.0\r\nCSeq: 2\r\nSession: %s\r\nContent-Type: application/json\r\nContent-Length: %d\r\n\r\n%s", + uri, session, len(payload), payload) + + if _, err := c.conn.Write([]byte(data)); err != nil { + return err + } + + res, err := tcp.ReadResponse(c.rd) + if err != nil { + return err + } + + if res.StatusCode != http.StatusOK { + return errors.New("multitrans: talkback failed: " + res.Status) + } + + // Python checks for "error_code":0 in body. + if !bytes.Contains(res.Body, []byte(`"error_code":0`)) { + return fmt.Errorf("multitrans: talkback error: %s", string(res.Body)) + } + + return nil +} + +func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + return nil, core.ErrCantGetTrack +} + +func (c *Client) Start() error { + _ = c.closed.Wait() + return nil +} + +func (c *Client) Stop() error { + c.closed.Done(nil) + return c.Connection.Stop() +} diff --git a/installs_on_host/go2rtc/pkg/nest/api.go b/installs_on_host/go2rtc/pkg/nest/api.go new file mode 100644 index 0000000..4e9e4db --- /dev/null +++ b/installs_on_host/go2rtc/pkg/nest/api.go @@ -0,0 +1,486 @@ +package nest + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +type API struct { + Token string + ExpiresAt time.Time + + StreamProjectID string + StreamDeviceID string + StreamExpiresAt time.Time + + // WebRTC + StreamSessionID string + + // RTSP + StreamToken string + StreamExtensionToken string + + extendTimer *time.Timer +} + +type Auth struct { + AccessToken string +} + +type DeviceInfo struct { + Name string + DeviceID string + Protocols []string +} + +var cache = map[string]*API{} +var cacheMu sync.Mutex + +func NewAPI(clientID, clientSecret, refreshToken string) (*API, error) { + cacheMu.Lock() + defer cacheMu.Unlock() + + key := clientID + ":" + clientSecret + ":" + refreshToken + now := time.Now() + + if api := cache[key]; api != nil && now.Before(api.ExpiresAt) { + return api, nil + } + + data := url.Values{ + "grant_type": []string{"refresh_token"}, + "client_id": []string{clientID}, + "client_secret": []string{clientSecret}, + "refresh_token": []string{refreshToken}, + } + + client := &http.Client{Timeout: time.Second * 5000} + res, err := client.PostForm("https://www.googleapis.com/oauth2/v4/token", data) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != 200 { + return nil, errors.New("nest: wrong status: " + res.Status) + } + + var resv struct { + AccessToken string `json:"access_token"` + ExpiresIn time.Duration `json:"expires_in"` + Scope string `json:"scope"` + TokenType string `json:"token_type"` + } + + if err = json.NewDecoder(res.Body).Decode(&resv); err != nil { + return nil, err + } + + api := &API{ + Token: resv.AccessToken, + ExpiresAt: now.Add(resv.ExpiresIn * time.Second), + } + + cache[key] = api + + return api, nil +} + +func (a *API) GetDevices(projectID string) ([]DeviceInfo, error) { + uri := "https://smartdevicemanagement.googleapis.com/v1/enterprises/" + projectID + "/devices" + req, err := http.NewRequest("GET", uri, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "Bearer "+a.Token) + + client := &http.Client{Timeout: time.Second * 5000} + res, err := client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != 200 { + return nil, errors.New("nest: wrong status: " + res.Status) + } + + var resv struct { + Devices []Device + } + + if err = json.NewDecoder(res.Body).Decode(&resv); err != nil { + return nil, err + } + + devices := make([]DeviceInfo, 0, len(resv.Devices)) + + for _, device := range resv.Devices { + // only RTSP and WEB_RTC available (both supported) + if len(device.Traits.SdmDevicesTraitsCameraLiveStream.SupportedProtocols) == 0 { + continue + } + + i := strings.LastIndexByte(device.Name, '/') + if i <= 0 { + continue + } + + name := device.Traits.SdmDevicesTraitsInfo.CustomName + // Devices configured through the Nest app use the container/room name as opposed to the customName trait + if name == "" && len(device.ParentRelations) > 0 { + name = device.ParentRelations[0].DisplayName + } + + devices = append(devices, DeviceInfo{ + Name: name, + DeviceID: device.Name[i+1:], + Protocols: device.Traits.SdmDevicesTraitsCameraLiveStream.SupportedProtocols, + }) + } + + return devices, nil +} + +func (a *API) ExchangeSDP(projectID, deviceID, offer string) (string, error) { + var reqv struct { + Command string `json:"command"` + Params struct { + Offer string `json:"offerSdp"` + } `json:"params"` + } + reqv.Command = "sdm.devices.commands.CameraLiveStream.GenerateWebRtcStream" + reqv.Params.Offer = offer + + b, err := json.Marshal(reqv) + if err != nil { + return "", err + } + + uri := "https://smartdevicemanagement.googleapis.com/v1/enterprises/" + + projectID + "/devices/" + deviceID + ":executeCommand" + + maxRetries := 3 + retryDelay := time.Second * 30 + + for attempt := 0; attempt < maxRetries; attempt++ { + req, err := http.NewRequest("POST", uri, bytes.NewReader(b)) + if err != nil { + return "", err + } + + req.Header.Set("Authorization", "Bearer "+a.Token) + + client := &http.Client{Timeout: time.Second * 5000} + res, err := client.Do(req) + if err != nil { + return "", err + } + + // Handle 409 (Conflict), 429 (Too Many Requests), and 401 (Unauthorized) + if res.StatusCode == 409 || res.StatusCode == 429 || res.StatusCode == 401 { + res.Body.Close() + if attempt < maxRetries-1 { + // Get new token from Google + if err := a.refreshToken(); err != nil { + return "", err + } + time.Sleep(retryDelay) + retryDelay *= 2 // exponential backoff + continue + } + } + + defer res.Body.Close() + + if res.StatusCode != 200 { + return "", errors.New("nest: wrong status: " + res.Status) + } + + var resv struct { + Results struct { + Answer string `json:"answerSdp"` + ExpiresAt time.Time `json:"expiresAt"` + MediaSessionID string `json:"mediaSessionId"` + } `json:"results"` + } + + if err = json.NewDecoder(res.Body).Decode(&resv); err != nil { + return "", err + } + + a.StreamProjectID = projectID + a.StreamDeviceID = deviceID + a.StreamSessionID = resv.Results.MediaSessionID + a.StreamExpiresAt = resv.Results.ExpiresAt + + return resv.Results.Answer, nil + } + + return "", errors.New("nest: max retries exceeded") +} + +func (a *API) refreshToken() error { + // Get the cached API with matching token to get credentials + var refreshKey string + cacheMu.Lock() + for key, api := range cache { + if api.Token == a.Token { + refreshKey = key + break + } + } + cacheMu.Unlock() + + if refreshKey == "" { + return errors.New("nest: unable to find cached credentials") + } + + // Parse credentials from cache key + parts := strings.Split(refreshKey, ":") + if len(parts) != 3 { + return errors.New("nest: invalid cache key format") + } + clientID, clientSecret, refreshToken := parts[0], parts[1], parts[2] + + // Get new API instance which will refresh the token + newAPI, err := NewAPI(clientID, clientSecret, refreshToken) + if err != nil { + return err + } + + // Update current API with new token + a.Token = newAPI.Token + a.ExpiresAt = newAPI.ExpiresAt + return nil +} + +func (a *API) ExtendStream() error { + var reqv struct { + Command string `json:"command"` + Params struct { + MediaSessionID string `json:"mediaSessionId,omitempty"` + StreamExtensionToken string `json:"streamExtensionToken,omitempty"` + } `json:"params"` + } + + if a.StreamToken != "" { + // RTSP + reqv.Command = "sdm.devices.commands.CameraLiveStream.ExtendRtspStream" + reqv.Params.StreamExtensionToken = a.StreamExtensionToken + } else { + // WebRTC + reqv.Command = "sdm.devices.commands.CameraLiveStream.ExtendWebRtcStream" + reqv.Params.MediaSessionID = a.StreamSessionID + } + + b, err := json.Marshal(reqv) + if err != nil { + return err + } + + uri := "https://smartdevicemanagement.googleapis.com/v1/enterprises/" + + a.StreamProjectID + "/devices/" + a.StreamDeviceID + ":executeCommand" + req, err := http.NewRequest("POST", uri, bytes.NewReader(b)) + if err != nil { + return err + } + + req.Header.Set("Authorization", "Bearer "+a.Token) + + client := &http.Client{Timeout: time.Second * 5000} + res, err := client.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode != 200 { + return errors.New("nest: wrong status: " + res.Status) + } + + var resv struct { + Results struct { + ExpiresAt time.Time `json:"expiresAt"` + MediaSessionID string `json:"mediaSessionId"` + StreamExtensionToken string `json:"streamExtensionToken"` + StreamToken string `json:"streamToken"` + } `json:"results"` + } + + if err = json.NewDecoder(res.Body).Decode(&resv); err != nil { + return err + } + + a.StreamSessionID = resv.Results.MediaSessionID + a.StreamExpiresAt = resv.Results.ExpiresAt + a.StreamExtensionToken = resv.Results.StreamExtensionToken + a.StreamToken = resv.Results.StreamToken + + return nil +} + +func (a *API) GenerateRtspStream(projectID, deviceID string) (string, error) { + var reqv struct { + Command string `json:"command"` + Params struct{} `json:"params"` + } + reqv.Command = "sdm.devices.commands.CameraLiveStream.GenerateRtspStream" + + b, err := json.Marshal(reqv) + if err != nil { + return "", err + } + + uri := "https://smartdevicemanagement.googleapis.com/v1/enterprises/" + + projectID + "/devices/" + deviceID + ":executeCommand" + req, err := http.NewRequest("POST", uri, bytes.NewReader(b)) + if err != nil { + return "", err + } + + req.Header.Set("Authorization", "Bearer "+a.Token) + + client := &http.Client{Timeout: time.Second * 5000} + res, err := client.Do(req) + if err != nil { + return "", err + } + + if res.StatusCode != 200 { + return "", errors.New("nest: wrong status: " + res.Status) + } + + var resv struct { + Results struct { + StreamURLs map[string]string `json:"streamUrls"` + StreamExtensionToken string `json:"streamExtensionToken"` + StreamToken string `json:"streamToken"` + ExpiresAt time.Time `json:"expiresAt"` + } `json:"results"` + } + + if err = json.NewDecoder(res.Body).Decode(&resv); err != nil { + return "", err + } + + if _, ok := resv.Results.StreamURLs["rtspUrl"]; !ok { + return "", errors.New("nest: failed to generate rtsp url") + } + + a.StreamProjectID = projectID + a.StreamDeviceID = deviceID + a.StreamToken = resv.Results.StreamToken + a.StreamExtensionToken = resv.Results.StreamExtensionToken + a.StreamExpiresAt = resv.Results.ExpiresAt + + return resv.Results.StreamURLs["rtspUrl"], nil +} + +func (a *API) StopRTSPStream() error { + if a.StreamProjectID == "" || a.StreamDeviceID == "" { + return errors.New("nest: tried to stop rtsp stream without a project or device ID") + } + + var reqv struct { + Command string `json:"command"` + Params struct { + StreamExtensionToken string `json:"streamExtensionToken"` + } `json:"params"` + } + reqv.Command = "sdm.devices.commands.CameraLiveStream.StopRtspStream" + reqv.Params.StreamExtensionToken = a.StreamExtensionToken + + b, err := json.Marshal(reqv) + if err != nil { + return err + } + + uri := "https://smartdevicemanagement.googleapis.com/v1/enterprises/" + + a.StreamProjectID + "/devices/" + a.StreamDeviceID + ":executeCommand" + req, err := http.NewRequest("POST", uri, bytes.NewReader(b)) + if err != nil { + return err + } + + req.Header.Set("Authorization", "Bearer "+a.Token) + + client := &http.Client{Timeout: time.Second * 5000} + res, err := client.Do(req) + if err != nil { + return err + } + + if res.StatusCode != 200 { + return errors.New("nest: wrong status: " + res.Status) + } + + a.StreamProjectID = "" + a.StreamDeviceID = "" + a.StreamExtensionToken = "" + a.StreamToken = "" + + return nil +} + +type Device struct { + Name string `json:"name"` + Type string `json:"type"` + //Assignee string `json:"assignee"` + Traits struct { + SdmDevicesTraitsInfo struct { + CustomName string `json:"customName"` + } `json:"sdm.devices.traits.Info"` + SdmDevicesTraitsCameraLiveStream struct { + VideoCodecs []string `json:"videoCodecs"` + AudioCodecs []string `json:"audioCodecs"` + SupportedProtocols []string `json:"supportedProtocols"` + } `json:"sdm.devices.traits.CameraLiveStream"` + //SdmDevicesTraitsCameraImage struct { + // MaxImageResolution struct { + // Width int `json:"width"` + // Height int `json:"height"` + // } `json:"maxImageResolution"` + //} `json:"sdm.devices.traits.CameraImage"` + //SdmDevicesTraitsCameraPerson struct { + //} `json:"sdm.devices.traits.CameraPerson"` + //SdmDevicesTraitsCameraMotion struct { + //} `json:"sdm.devices.traits.CameraMotion"` + //SdmDevicesTraitsDoorbellChime struct { + //} `json:"sdm.devices.traits.DoorbellChime"` + //SdmDevicesTraitsCameraClipPreview struct { + //} `json:"sdm.devices.traits.CameraClipPreview"` + } `json:"traits"` + ParentRelations []struct { + Parent string `json:"parent"` + DisplayName string `json:"displayName"` + } `json:"parentRelations"` +} + +func (a *API) StartExtendStreamTimer() { + if a.extendTimer != nil { + return + } + + a.extendTimer = time.NewTimer(time.Until(a.StreamExpiresAt) - time.Minute) + go func() { + <-a.extendTimer.C + if err := a.ExtendStream(); err != nil { + return + } + }() +} + +func (a *API) StopExtendStreamTimer() { + if a.extendTimer != nil { + a.extendTimer.Stop() + a.extendTimer = nil + } +} diff --git a/installs_on_host/go2rtc/pkg/nest/client.go b/installs_on_host/go2rtc/pkg/nest/client.go new file mode 100644 index 0000000..6a57091 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/nest/client.go @@ -0,0 +1,197 @@ +package nest + +import ( + "errors" + "net/url" + "strings" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/rtsp" + "github.com/AlexxIT/go2rtc/pkg/webrtc" + pion "github.com/pion/webrtc/v4" +) + +type WebRTCClient struct { + conn *webrtc.Conn + api *API +} + +type RTSPClient struct { + conn *rtsp.Conn + api *API +} + +func Dial(rawURL string) (core.Producer, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + query := u.Query() + cliendID := query.Get("client_id") + cliendSecret := query.Get("client_secret") + refreshToken := query.Get("refresh_token") + projectID := query.Get("project_id") + deviceID := query.Get("device_id") + + if cliendID == "" || cliendSecret == "" || refreshToken == "" || projectID == "" || deviceID == "" { + return nil, errors.New("nest: wrong query") + } + + maxRetries := 3 + retryDelay := time.Second * 30 + + var nestAPI *API + var lastErr error + + for attempt := 0; attempt < maxRetries; attempt++ { + nestAPI, err = NewAPI(cliendID, cliendSecret, refreshToken) + if err == nil { + break + } + lastErr = err + if attempt < maxRetries-1 { + time.Sleep(retryDelay) + retryDelay *= 2 // exponential backoff + } + } + + if nestAPI == nil { + return nil, lastErr + } + + protocols := strings.Split(query.Get("protocols"), ",") + if len(protocols) > 0 && protocols[0] == "RTSP" { + return rtspConn(nestAPI, rawURL, projectID, deviceID) + } + + // Default to WEB_RTC for backwards compataiility + return rtcConn(nestAPI, rawURL, projectID, deviceID) +} + +func (c *WebRTCClient) GetMedias() []*core.Media { + return c.conn.GetMedias() +} + +func (c *WebRTCClient) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + return c.conn.GetTrack(media, codec) +} + +func (c *WebRTCClient) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + return c.conn.AddTrack(media, codec, track) +} + +func (c *WebRTCClient) Start() error { + c.api.StartExtendStreamTimer() + return c.conn.Start() +} + +func (c *WebRTCClient) Stop() error { + c.api.StopExtendStreamTimer() + return c.conn.Stop() +} + +func (c *WebRTCClient) MarshalJSON() ([]byte, error) { + return c.conn.MarshalJSON() +} + +func rtcConn(nestAPI *API, rawURL, projectID, deviceID string) (*WebRTCClient, error) { + maxRetries := 3 + retryDelay := time.Second * 30 + var lastErr error + + for attempt := 0; attempt < maxRetries; attempt++ { + rtcAPI, err := webrtc.NewAPI() + if err != nil { + return nil, err + } + + conf := pion.Configuration{} + pc, err := rtcAPI.NewPeerConnection(conf) + if err != nil { + return nil, err + } + + conn := webrtc.NewConn(pc) + conn.FormatName = "nest/webrtc" + conn.Mode = core.ModeActiveProducer + conn.Protocol = "http" + conn.URL = rawURL + + // https://developers.google.com/nest/device-access/traits/device/camera-live-stream#generatewebrtcstream-request-fields + medias := []*core.Media{ + {Kind: core.KindAudio, Direction: core.DirectionRecvonly}, + {Kind: core.KindVideo, Direction: core.DirectionRecvonly}, + {Kind: "app"}, // important for Nest + } + + // 3. Create offer with candidates + offer, err := conn.CreateCompleteOffer(medias) + if err != nil { + return nil, err + } + + // 4. Exchange SDP via Hass + answer, err := nestAPI.ExchangeSDP(projectID, deviceID, offer) + if err != nil { + lastErr = err + if attempt < maxRetries-1 { + time.Sleep(retryDelay) + retryDelay *= 2 + continue + } + return nil, err + } + + // 5. Set answer with remote medias + if err = conn.SetAnswer(answer); err != nil { + return nil, err + } + + return &WebRTCClient{conn: conn, api: nestAPI}, nil + } + + return nil, lastErr +} + +func rtspConn(nestAPI *API, rawURL, projectID, deviceID string) (*RTSPClient, error) { + rtspURL, err := nestAPI.GenerateRtspStream(projectID, deviceID) + if err != nil { + return nil, err + } + + rtspClient := rtsp.NewClient(rtspURL) + if err := rtspClient.Dial(); err != nil { + return nil, err + } + if err := rtspClient.Describe(); err != nil { + return nil, err + } + + return &RTSPClient{conn: rtspClient, api: nestAPI}, nil +} + +func (c *RTSPClient) GetMedias() []*core.Media { + result := c.conn.GetMedias() + return result +} + +func (c *RTSPClient) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + return c.conn.GetTrack(media, codec) +} + +func (c *RTSPClient) Start() error { + c.api.StartExtendStreamTimer() + return c.conn.Start() +} + +func (c *RTSPClient) Stop() error { + c.api.StopRTSPStream() + c.api.StopExtendStreamTimer() + return c.conn.Stop() +} + +func (c *RTSPClient) MarshalJSON() ([]byte, error) { + return c.conn.MarshalJSON() +} diff --git a/installs_on_host/go2rtc/pkg/ngrok/ngrok.go b/installs_on_host/go2rtc/pkg/ngrok/ngrok.go new file mode 100644 index 0000000..33fa0f8 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/ngrok/ngrok.go @@ -0,0 +1,80 @@ +package ngrok + +import ( + "bufio" + "encoding/json" + "io" + "os/exec" + "strings" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +type Ngrok struct { + core.Listener + + Tunnels map[string]string + + reader *bufio.Reader +} + +type Message struct { + Msg string `json:"msg"` + Addr string `json:"addr"` + URL string `json:"url"` + Line string +} + +func NewNgrok(command any) (*Ngrok, error) { + var arg []string + switch command.(type) { + case string: + arg = strings.Split(command.(string), " ") + case []string: + arg = command.([]string) + } + + arg = append(arg, "--log", "stdout", "--log-format", "json") + + cmd := exec.Command(arg[0], arg[1:]...) + + r, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + cmd.Stderr = cmd.Stdout + + n := &Ngrok{ + Tunnels: map[string]string{}, + reader: bufio.NewReader(r), + } + + if err = cmd.Start(); err != nil { + return nil, err + } + + return n, nil +} + +func (n *Ngrok) Serve() error { + for { + line, _, err := n.reader.ReadLine() + if err != nil { + if err != io.EOF { + return err + } + return nil + } + + msg := new(Message) + _ = json.Unmarshal(line, msg) + + if msg.Msg == "started tunnel" { + n.Tunnels[msg.Addr] = msg.URL + } + + msg.Line = string(line) + + n.Fire(msg) + } +} diff --git a/installs_on_host/go2rtc/pkg/onvif/README.md b/installs_on_host/go2rtc/pkg/onvif/README.md new file mode 100644 index 0000000..7326737 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/onvif/README.md @@ -0,0 +1,38 @@ +## Profiles + +- Profile A - For access control configuration +- Profile C - For door control and event management +- Profile S - For basic video streaming + - Video streaming and configuration +- Profile T - For advanced video streaming + - H.264 / H.265 video compression + - Imaging settings + - Motion alarm and tampering events + - Metadata streaming + - Bi-directional audio + +## Services + +https://www.onvif.org/profiles/specifications/ + +- https://www.onvif.org/ver10/device/wsdl/devicemgmt.wsdl +- https://www.onvif.org/ver20/imaging/wsdl/imaging.wsdl +- https://www.onvif.org/ver10/media/wsdl/media.wsdl + +## TMP + +| | Dahua | Reolink | TP-Link | +|------------------------|---------|---------|---------| +| GetCapabilities | no auth | no auth | no auth | +| GetServices | no auth | no auth | no auth | +| GetServiceCapabilities | no auth | no auth | auth | +| GetSystemDateAndTime | no auth | no auth | no auth | +| GetNetworkInterfaces | auth | auth | auth | +| GetDeviceInformation | auth | auth | auth | +| GetProfiles | auth | auth | auth | +| GetScopes | auth | auth | auth | + +- Dahua - onvif://192.168.10.90:80 +- Reolink - onvif://192.168.10.92:8000 +- TP-Link - onvif://192.168.10.91:2020/onvif/device_service +- \ No newline at end of file diff --git a/installs_on_host/go2rtc/pkg/onvif/client.go b/installs_on_host/go2rtc/pkg/onvif/client.go new file mode 100644 index 0000000..bad103c --- /dev/null +++ b/installs_on_host/go2rtc/pkg/onvif/client.go @@ -0,0 +1,197 @@ +package onvif + +import ( + "bytes" + "errors" + "html" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "time" +) + +const PathDevice = "/onvif/device_service" + +type Client struct { + url *url.URL + + deviceURL string + mediaURL string + imaginURL string +} + +func NewClient(rawURL string) (*Client, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + baseURL := "http://" + u.Host + + client := &Client{url: u} + client.deviceURL = baseURL + GetPath(u.Path, PathDevice) + + b, err := client.DeviceRequest(DeviceGetCapabilities) + if err != nil { + return nil, err + } + + s := FindTagValue(b, "Media.+?XAddr") + client.mediaURL = baseURL + GetPath(s, "/onvif/media_service") + + s = FindTagValue(b, "Imaging.+?XAddr") + client.imaginURL = baseURL + GetPath(s, "/onvif/imaging_service") + + return client, nil +} + +func (c *Client) GetURI() (string, error) { + query := c.url.Query() + + token := query.Get("subtype") + + // support empty + if i := atoi(token); i >= 0 { + tokens, err := c.GetProfilesTokens() + if err != nil { + return "", err + } + if i >= len(tokens) { + return "", errors.New("onvif: wrong subtype") + } + token = tokens[i] + } + + getUri := c.GetStreamUri + if query.Has("snapshot") { + getUri = c.GetSnapshotUri + } + + b, err := getUri(token) + if err != nil { + return "", err + } + + rawURL := FindTagValue(b, "Uri") + rawURL = strings.TrimSpace(html.UnescapeString(rawURL)) + + u, err := url.Parse(rawURL) + if err != nil { + return "", err + } + + if u.User == nil && c.url.User != nil { + u.User = c.url.User + } + + return u.String(), nil +} + +func (c *Client) GetName() (string, error) { + b, err := c.DeviceRequest(DeviceGetDeviceInformation) + if err != nil { + return "", err + } + + return FindTagValue(b, "Manufacturer") + " " + FindTagValue(b, "Model"), nil +} + +func (c *Client) GetProfilesTokens() ([]string, error) { + b, err := c.MediaRequest(MediaGetProfiles) + if err != nil { + return nil, err + } + + var tokens []string + + re := regexp.MustCompile(`Profiles.+?token="([^"]+)`) + for _, s := range re.FindAllStringSubmatch(string(b), 10) { + tokens = append(tokens, s[1]) + } + + return tokens, nil +} + +func (c *Client) HasSnapshots() bool { + b, err := c.GetServiceCapabilities() + if err != nil { + return false + } + return strings.Contains(string(b), `SnapshotUri="true"`) +} + +func (c *Client) GetProfile(token string) ([]byte, error) { + return c.Request( + c.mediaURL, ``+token+``, + ) +} + +func (c *Client) GetVideoSourceConfiguration(token string) ([]byte, error) { + return c.Request(c.mediaURL, ` + `+token+` +`) +} + +func (c *Client) GetStreamUri(token string) ([]byte, error) { + return c.Request(c.mediaURL, ` + + RTP-Unicast + RTSP + + `+token+` +`) +} + +func (c *Client) GetSnapshotUri(token string) ([]byte, error) { + return c.Request( + c.imaginURL, ``+token+``, + ) +} + +func (c *Client) GetServiceCapabilities() ([]byte, error) { + // some cameras answer GetServiceCapabilities for media only for path = "/onvif/media" + return c.Request( + c.mediaURL, ``, + ) +} + +func (c *Client) DeviceRequest(operation string) ([]byte, error) { + switch operation { + case DeviceGetServices: + operation = `true` + case DeviceGetCapabilities: + operation = `All` + default: + operation = `` + } + return c.Request(c.deviceURL, operation) +} + +func (c *Client) MediaRequest(operation string) ([]byte, error) { + operation = `` + return c.Request(c.mediaURL, operation) +} + +func (c *Client) Request(url, body string) ([]byte, error) { + if url == "" { + return nil, errors.New("onvif: unsupported service") + } + + e := NewEnvelopeWithUser(c.url.User) + e.Append(body) + + client := &http.Client{Timeout: time.Second * 5000} + res, err := client.Post(url, `application/soap+xml;charset=utf-8`, bytes.NewReader(e.Bytes())) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, errors.New("onvif: wrong response " + res.Status) + } + + return io.ReadAll(res.Body) +} diff --git a/installs_on_host/go2rtc/pkg/onvif/envelope.go b/installs_on_host/go2rtc/pkg/onvif/envelope.go new file mode 100644 index 0000000..76a4126 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/onvif/envelope.go @@ -0,0 +1,73 @@ +package onvif + +import ( + "crypto/sha1" + "encoding/base64" + "fmt" + "net/url" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +type Envelope struct { + buf []byte +} + +const ( + prefix1 = `` + prefix2 = `` + suffix = `` +) + +func NewEnvelope() *Envelope { + e := &Envelope{buf: make([]byte, 0, 1024)} + e.Append(prefix1, prefix2) + return e +} + +func NewEnvelopeWithUser(user *url.Userinfo) *Envelope { + if user == nil { + return NewEnvelope() + } + + nonce := core.RandString(16, 36) + created := time.Now().UTC().Format(time.RFC3339Nano) + pass, _ := user.Password() + + h := sha1.New() + h.Write([]byte(nonce + created + pass)) + + e := &Envelope{buf: make([]byte, 0, 1024)} + e.Append(prefix1) + e.Appendf(` + + + %s + %s + %s + %s + + +`, + user.Username(), + base64.StdEncoding.EncodeToString(h.Sum(nil)), + base64.StdEncoding.EncodeToString([]byte(nonce)), + created) + e.Append(prefix2) + return e +} + +func (e *Envelope) Append(args ...string) { + for _, s := range args { + e.buf = append(e.buf, s...) + } +} + +func (e *Envelope) Appendf(format string, args ...any) { + e.buf = fmt.Appendf(e.buf, format, args...) +} + +func (e *Envelope) Bytes() []byte { + return append(e.buf, suffix...) +} diff --git a/installs_on_host/go2rtc/pkg/onvif/helpers.go b/installs_on_host/go2rtc/pkg/onvif/helpers.go new file mode 100644 index 0000000..8fac9ac --- /dev/null +++ b/installs_on_host/go2rtc/pkg/onvif/helpers.go @@ -0,0 +1,162 @@ +package onvif + +import ( + "fmt" + "net" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +type DiscoveryDevice struct { + URL string + Name string + Hardware string +} + +func FindTagValue(b []byte, tag string) string { + re := regexp.MustCompile(`(?s)<(?:\w+:)?` + tag + `\b[^>]*>([^<]+)`) + m := re.FindSubmatch(b) + if len(m) != 2 { + return "" + } + return string(m[1]) +} + +// UUID - generate something like 44302cbf-0d18-4feb-79b3-33b575263da3 +func UUID() string { + s := core.RandString(32, 16) + return s[:8] + "-" + s[8:12] + "-" + s[12:16] + "-" + s[16:20] + "-" + s[20:] +} + +// DiscoveryStreamingDevices return list of tuple (onvif_url, name, hardware) +func DiscoveryStreamingDevices() ([]DiscoveryDevice, error) { + conn, err := net.ListenUDP("udp4", nil) + if err != nil { + return nil, err + } + + defer conn.Close() + + // https://www.onvif.org/wp-content/uploads/2016/12/ONVIF_Feature_Discovery_Specification_16.07.pdf + // 5.3 Discovery Procedure: + msg := ` + + + http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe + urn:uuid:` + UUID() + ` + urn:schemas-xmlsoap-org:ws:2005:04:discovery + + + + + + + +` + + addr := &net.UDPAddr{ + IP: net.IP{239, 255, 255, 250}, + Port: 3702, + } + + if _, err = conn.WriteTo([]byte(msg), addr); err != nil { + return nil, err + } + + _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + + var devices []DiscoveryDevice + + b := make([]byte, 8192) + for { + n, addr, err := conn.ReadFromUDP(b) + if err != nil { + break + } + + //log.Printf("[onvif] discovery response addr=%s:\n%s", addr, b[:n]) + + // ignore printers, etc + if !strings.Contains(string(b[:n]), "onvif") { + continue + } + + device := DiscoveryDevice{ + URL: FindTagValue(b[:n], "XAddrs"), + } + + if device.URL == "" { + continue + } + + // fix some buggy cameras + // http://0.0.0.0:8080/onvif/device_service + if s, ok := strings.CutPrefix(device.URL, "http://0.0.0.0"); ok { + device.URL = "http://" + addr.IP.String() + s + } + + // try to find the camera name and model (hardware) + scopes := FindTagValue(b[:n], "Scopes") + device.Name = findScope(scopes, "onvif://www.onvif.org/name/") + device.Hardware = findScope(scopes, "onvif://www.onvif.org/hardware/") + + devices = append(devices, device) + } + + return devices, nil +} + +func findScope(s, prefix string) string { + s = core.Between(s, prefix, " ") + s, _ = url.QueryUnescape(s) + return s +} + +func atoi(s string) int { + if s == "" { + return 0 + } + i, err := strconv.Atoi(s) + if err != nil { + return -1 + } + return i +} + +func GetPosixTZ(current time.Time) string { + // Thanks to https://github.com/Path-Variable/go-posix-time + _, offset := current.Zone() + + if current.IsDST() { + _, end := current.ZoneBounds() + endPlus1 := end.Add(time.Hour * 25) + _, offset = endPlus1.Zone() + } + + var prefix string + if offset < 0 { + prefix = "GMT+" + offset = -offset / 60 + } else { + prefix = "GMT-" + offset = offset / 60 + } + + return prefix + fmt.Sprintf("%02d:%02d", offset/60, offset%60) +} + +func GetPath(urlOrPath, defPath string) string { + if urlOrPath == "" || urlOrPath[0] == '/' { + return defPath + } + u, err := url.Parse(urlOrPath) + if err != nil { + return defPath + } + return GetPath(u.Path, defPath) +} diff --git a/installs_on_host/go2rtc/pkg/onvif/onvif_test.go b/installs_on_host/go2rtc/pkg/onvif/onvif_test.go new file mode 100644 index 0000000..e9ffab0 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/onvif/onvif_test.go @@ -0,0 +1,227 @@ +package onvif + +import ( + "html" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGetStreamUri(t *testing.T) { + tests := []struct { + name string + xml string + url string + }{ + { + name: "Dahua stream default", + xml: `rtsp://192.168.1.123:554/cam/realmonitor?channel=1&subtype=1&unicast=true&proto=OnviftruetruePT0S`, + url: "rtsp://192.168.1.123:554/cam/realmonitor?channel=1&subtype=1&unicast=true&proto=Onvif", + }, + { + name: "Dahua snapshot default", + xml: `http://192.168.1.123/onvifsnapshot/media_service/snapshot?channel=1&subtype=1falsefalsePT0S`, + url: "http://192.168.1.123/onvifsnapshot/media_service/snapshot?channel=1&subtype=1", + }, + { + name: "Dahua stream formatted", + xml: ` + + + + + + + rtsp://192.168.1.123:554/cam/realmonitor?channel=1&subtype=1&unicast=true&proto=Onvif + true + true + PT0S + + + +`, + url: "rtsp://192.168.1.123:554/cam/realmonitor?channel=1&subtype=1&unicast=true&proto=Onvif", + }, + { + name: "Dahua snapshot formatted", + xml: ` + + + + + + + http://192.168.1.123/onvifsnapshot/media_service/snapshot?channel=1&subtype=1 + false + false + PT0S + + + +`, + url: "http://192.168.1.123/onvifsnapshot/media_service/snapshot?channel=1&subtype=1", + }, + { + name: "Unknown", + xml: ` + + + + + + + rtsp://192.168.5.53:8090/profile1=r + + + + +`, + url: "rtsp://192.168.5.53:8090/profile1=r", + }, + { + name: "go2rtc 1.9.4", + xml: ` + + + + rtsp://192.168.1.123:8554/rtsp-dahua1 + + + +`, + url: "rtsp://192.168.1.123:8554/rtsp-dahua1", + }, + { + name: "go2rtc 1.9.8", + xml: ` + + + + + rtsp://192.168.1.123:8554/rtsp-dahua2 + + + + +`, + url: "rtsp://192.168.1.123:8554/rtsp-dahua2", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + uri := FindTagValue([]byte(test.xml), "Uri") + uri = strings.TrimSpace(html.UnescapeString(uri)) + u, err := url.Parse(uri) + require.Nil(t, err) + require.Equal(t, test.url, u.String()) + }) + } +} + +func TestGetCapabilities(t *testing.T) { + tests := []struct { + name string + xml string + }{ + { + name: "Dahua default", + xml: `http://192.168.1.123/onvif/analytics_servicetruetruehttp://192.168.1.123/onvif/device_servicefalsefalsefalsefalsefalsefalsetruefalsefalsetruetrue200210220230240242161218061812190619122006truefalsefalsefalse21falsefalsefalsefalsefalsefalsefalsefalsefalsefalsefalse0falsehttp://192.168.1.123/onvif/event_servicetruetruefalsehttp://192.168.1.123/onvif/imaging_servicehttp://192.168.1.123/onvif/media_servicetruetruetrue6http://192.168.1.123/onvif/deviceIO_service10111`, + }, + { + name: "Dahua formatted", + xml: ` + + + + + + + http://192.168.1.123/onvif/analytics_service + true + true + + + http://192.168.1.123/onvif/device_service + + false + false + false + false + + false + + + + ... + + + 2 + 1 + + false + + + + + + ... + + + + http://192.168.1.123/onvif/event_service + true + true + false + + + http://192.168.1.123/onvif/imaging_service + + + http://192.168.1.123/onvif/media_service + + true + true + true + + + + 6 + + + + + + http://192.168.1.123/onvif/deviceIO_service + 1 + 0 + 1 + 1 + 1 + + + + + +`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + rawURL := FindTagValue([]byte(test.xml), "Media.+?XAddr") + require.Equal(t, "http://192.168.1.123/onvif/media_service", rawURL) + + rawURL = FindTagValue([]byte(test.xml), "Imaging.+?XAddr") + require.Equal(t, "http://192.168.1.123/onvif/imaging_service", rawURL) + }) + } +} diff --git a/installs_on_host/go2rtc/pkg/onvif/server.go b/installs_on_host/go2rtc/pkg/onvif/server.go new file mode 100644 index 0000000..fe3ba8b --- /dev/null +++ b/installs_on_host/go2rtc/pkg/onvif/server.go @@ -0,0 +1,301 @@ +package onvif + +import ( + "bytes" + "regexp" + "time" +) + +const ServiceGetServiceCapabilities = "GetServiceCapabilities" + +const ( + DeviceGetCapabilities = "GetCapabilities" + DeviceGetDeviceInformation = "GetDeviceInformation" + DeviceGetDiscoveryMode = "GetDiscoveryMode" + DeviceGetDNS = "GetDNS" + DeviceGetHostname = "GetHostname" + DeviceGetNetworkDefaultGateway = "GetNetworkDefaultGateway" + DeviceGetNetworkInterfaces = "GetNetworkInterfaces" + DeviceGetNetworkProtocols = "GetNetworkProtocols" + DeviceGetNTP = "GetNTP" + DeviceGetScopes = "GetScopes" + DeviceGetServices = "GetServices" + DeviceGetSystemDateAndTime = "GetSystemDateAndTime" + DeviceSetSystemDateAndTime = "SetSystemDateAndTime" + DeviceSystemReboot = "SystemReboot" +) + +const ( + MediaGetAudioEncoderConfigurations = "GetAudioEncoderConfigurations" + MediaGetAudioSources = "GetAudioSources" + MediaGetAudioSourceConfigurations = "GetAudioSourceConfigurations" + MediaGetProfile = "GetProfile" + MediaGetProfiles = "GetProfiles" + MediaGetSnapshotUri = "GetSnapshotUri" + MediaGetStreamUri = "GetStreamUri" + MediaGetVideoEncoderConfiguration = "GetVideoEncoderConfiguration" + MediaGetVideoEncoderConfigurations = "GetVideoEncoderConfigurations" + MediaGetVideoEncoderConfigurationOptions = "GetVideoEncoderConfigurationOptions" + MediaGetVideoSources = "GetVideoSources" + MediaGetVideoSourceConfiguration = "GetVideoSourceConfiguration" + MediaGetVideoSourceConfigurations = "GetVideoSourceConfigurations" +) + +func GetRequestAction(b []byte) string { + // + // + re := regexp.MustCompile(`Body[^<]+<([^ />]+)`) + m := re.FindSubmatch(b) + if len(m) != 2 { + return "" + } + if i := bytes.IndexByte(m[1], ':'); i > 0 { + return string(m[1][i+1:]) + } + return string(m[1]) +} + +func GetCapabilitiesResponse(host string) []byte { + e := NewEnvelope() + e.Appendf(` + + + http://%s/onvif/device_service + + + http://%s/onvif/media_service + + false + false + true + + + +`, host, host) + return e.Bytes() +} + +func GetServicesResponse(host string) []byte { + e := NewEnvelope() + e.Appendf(` + + http://www.onvif.org/ver10/device/wsdl + http://%s/onvif/device_service + 25 + + + http://www.onvif.org/ver10/media/wsdl + http://%s/onvif/media_service + 25 + +`, host, host) + return e.Bytes() +} + +func GetSystemDateAndTimeResponse() []byte { + loc := time.Now() + utc := loc.UTC() + + e := NewEnvelope() + e.Appendf(` + + NTP + true + + %s + + + %d%d%d + %d%d%d + + + %d%d%d + %d%d%d + + +`, + GetPosixTZ(loc), + utc.Hour(), utc.Minute(), utc.Second(), utc.Year(), utc.Month(), utc.Day(), + loc.Hour(), loc.Minute(), loc.Second(), loc.Year(), loc.Month(), loc.Day(), + ) + return e.Bytes() +} + +func GetDeviceInformationResponse(manuf, model, firmware, serial string) []byte { + e := NewEnvelope() + e.Appendf(` + %s + %s + %s + %s + 1.00 +`, manuf, model, firmware, serial) + return e.Bytes() +} + +func GetProfilesResponse(names []string) []byte { + e := NewEnvelope() + e.Append(``) + for _, name := range names { + appendProfile(e, "Profiles", name) + } + e.Append(``) + return e.Bytes() +} + +func GetProfileResponse(name string) []byte { + e := NewEnvelope() + e.Append(``) + appendProfile(e, "Profile", name) + e.Append(``) + return e.Bytes() +} + +func appendProfile(e *Envelope, tag, name string) { + // go2rtc name = ONVIF Profile Name = ONVIF Profile token + e.Appendf(``, tag, name) + e.Appendf(`%s`, name) + appendVideoSourceConfiguration(e, "VideoSourceConfiguration", name) + appendVideoEncoderConfiguration(e, "VideoEncoderConfiguration") + e.Appendf(``, tag) +} + +func GetVideoSourcesResponse(names []string) []byte { + // go2rtc name = ONVIF VideoSource token + e := NewEnvelope() + e.Append(``) + for _, name := range names { + e.Appendf(` + 30.000000 + 19201080 +`, name) + } + e.Append(``) + return e.Bytes() +} + +func GetVideoSourceConfigurationsResponse(names []string) []byte { + e := NewEnvelope() + e.Append(``) + for _, name := range names { + appendVideoSourceConfiguration(e, "Configurations", name) + } + e.Append(``) + return e.Bytes() +} + +func GetVideoSourceConfigurationResponse(name string) []byte { + e := NewEnvelope() + e.Append(``) + appendVideoSourceConfiguration(e, "Configuration", name) + e.Append(``) + return e.Bytes() +} + +func appendVideoSourceConfiguration(e *Envelope, tag, name string) { + // go2rtc name = ONVIF VideoSourceConfiguration token + e.Appendf(` + VSC + %s + +`, tag, name, name, tag) +} + +func GetVideoEncoderConfigurationsResponse() []byte { + e := NewEnvelope() + e.Append(``) + appendVideoEncoderConfiguration(e, "VideoEncoderConfigurations") + e.Append(``) + return e.Bytes() +} + +func GetVideoEncoderConfigurationResponse() []byte { + e := NewEnvelope() + e.Append(``) + appendVideoEncoderConfiguration(e, "VideoEncoderConfiguration") + e.Append(``) + return e.Bytes() +} + +func appendVideoEncoderConfiguration(e *Envelope, tag string) { + // empty `RateControl` important for UniFi Protect + e.Appendf(` + VEC + 1 + H264 + 19201080 + 0 + 3018192 + 10Main + PT10S + `, tag, tag) +} + +func GetStreamUriResponse(uri string) []byte { + e := NewEnvelope() + e.Appendf(`%s`, uri) + return e.Bytes() +} + +func GetSnapshotUriResponse(uri string) []byte { + e := NewEnvelope() + e.Appendf(`%s`, uri) + return e.Bytes() +} + +func StaticResponse(operation string) []byte { + switch operation { + case DeviceGetSystemDateAndTime: + return GetSystemDateAndTimeResponse() + case MediaGetVideoEncoderConfiguration: + return GetVideoEncoderConfigurationResponse() + case MediaGetVideoEncoderConfigurations: + return GetVideoEncoderConfigurationsResponse() + } + + e := NewEnvelope() + e.Append(responses[operation]) + return e.Bytes() +} + +var responses = map[string]string{ + ServiceGetServiceCapabilities: ` + + + +`, + + DeviceGetDiscoveryMode: `Discoverable`, + DeviceGetDNS: ``, + DeviceGetHostname: ``, + DeviceGetNetworkDefaultGateway: ``, + DeviceGetNTP: ``, + DeviceSetSystemDateAndTime: ``, + DeviceSystemReboot: `OK`, + + DeviceGetNetworkInterfaces: ``, + DeviceGetNetworkProtocols: ``, + DeviceGetScopes: ` + Fixedonvif://www.onvif.org/name/go2rtc + Fixedonvif://www.onvif.org/location/github + Fixedonvif://www.onvif.org/Profile/Streaming + Fixedonvif://www.onvif.org/type/Network_Video_Transmitter +`, + + MediaGetAudioEncoderConfigurations: ``, + MediaGetAudioSources: ``, + MediaGetAudioSourceConfigurations: ``, + + MediaGetVideoEncoderConfigurationOptions: ` + + 16 + + 19201080 + 0100 + 130 + 1100 + Main + + +`, +} diff --git a/installs_on_host/go2rtc/pkg/opus/README.md b/installs_on_host/go2rtc/pkg/opus/README.md new file mode 100644 index 0000000..15e3cc8 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/opus/README.md @@ -0,0 +1,5 @@ +## Useful links + +- [RFC 3550: RTP: A Transport Protocol for Real-Time Applications](https://datatracker.ietf.org/doc/html/rfc3550) +- [RFC 6716: Definition of the Opus Audio Codec](https://datatracker.ietf.org/doc/html/rfc6716) +- [RFC 7587: RTP Payload Format for the Opus Speech and Audio Codec](https://datatracker.ietf.org/doc/html/rfc7587) diff --git a/installs_on_host/go2rtc/pkg/opus/homekit.go b/installs_on_host/go2rtc/pkg/opus/homekit.go new file mode 100644 index 0000000..e0eaee9 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/opus/homekit.go @@ -0,0 +1,96 @@ +package opus + +import ( + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +// Some info about this magic: +// - Apple has no respect for RFC 7587 standard and using RFC 3550 for RTP timestamps +// - Apple can request packets with 20ms duration over LAN connection and 60ms over LTE +// - FFmpeg produce packets with 20ms duration by default and only one frame per packet +// - FFmpeg should use "-min_comp 0" option, so every packet will be same duration +// - Apple doesn't care about real sample rate of track +// - Apple only cares about proper timestamp based on REQUESTED sample rate + +// RepackToHAP - convert standart RTP packet with OPUS to HAP packet +// We expect that: +// - incoming packet will be 20ms duration and only one frame per packet +// - outgouing packet will be 20ms or 60ms duration +// - incoming sample rate will be any (but not very big if we needs 60ms packets for output) +// - outgouing sample rate will be 16000 +// https://github.com/AlexxIT/go2rtc/issues/667 +func RepackToHAP(rtpTime byte, handler core.HandlerFunc) core.HandlerFunc { + switch rtpTime { + case 20: + return repackToHAP20(handler) + case 60: + return repackToHAP60(handler) + } + return handler +} + +// we using only one sample rate in the pkg/hap/camera/accessory.go +const ( + timestamp20 = 16000 * 0.020 + timestamp60 = 16000 * 0.060 +) + +// repackToHAP20 - just fix RTP timestamp from RFC 7587 to RFC 3550 +func repackToHAP20(handler core.HandlerFunc) core.HandlerFunc { + var timestamp uint32 + + return func(pkt *rtp.Packet) { + timestamp += timestamp20 + + clone := *pkt + clone.Timestamp = timestamp + handler(&clone) + } +} + +// repackToHAP60 - collect 20ms frames to single 60ms packet +// thanks to @civita idea https://github.com/AlexxIT/go2rtc/pull/843 +func repackToHAP60(handler core.HandlerFunc) core.HandlerFunc { + var sequence uint16 + var timestamp uint32 + + var framesCount byte + var framesSize []byte + var framesData []byte + + return func(pkt *rtp.Packet) { + framesData = append(framesData, pkt.Payload[1:]...) + + if framesCount++; framesCount < 3 { + if frameSize := len(pkt.Payload) - 1; frameSize >= 252 { + b0 := 252 + byte(frameSize)&0b11 + framesSize = append(framesSize, b0, byte(frameSize/4)-b0) + } else { + framesSize = append(framesSize, byte(frameSize)) + } + return + } + + toc := pkt.Payload[0] + + payload := make([]byte, 2, 2+len(framesSize)+len(framesData)) + payload[0] = toc | 0b11 // code 3 (multiple frames per packet) + payload[1] = 0b1000_0011 // VBR, no padding, 3 frames + payload = append(payload, framesSize...) + payload = append(payload, framesData...) + + sequence++ + timestamp += timestamp60 + + clone := *pkt + clone.Payload = payload + clone.SequenceNumber = sequence + clone.Timestamp = timestamp + handler(&clone) + + framesCount = 0 + framesSize = framesSize[:0] + framesData = framesData[:0] + } +} diff --git a/installs_on_host/go2rtc/pkg/opus/opus.go b/installs_on_host/go2rtc/pkg/opus/opus.go new file mode 100644 index 0000000..fb67c66 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/opus/opus.go @@ -0,0 +1,118 @@ +package opus + +import ( + "time" +) + +type Header struct { + Mode string + SampleRate uint16 + FrameSize time.Duration + Channels byte + Frames byte +} + +func UnmarshalHeader(b []byte) *Header { + // https://datatracker.ietf.org/doc/html/rfc6716#section-3.1 + b0 := b[0] + config := b0 >> 3 + return &Header{ + Mode: parseMode(config), + SampleRate: parseSampleRate(config), + FrameSize: parseFrameSize(config), + Channels: parseChannels(b0 >> 2 & 0b1), + Frames: parseFrames(b0 & 0b11), + } +} + +func parseMode(config byte) string { + if config <= 11 { + return "silk" + } + if config <= 15 { + return "hybrid" + } + return "celt" +} + +func parseSampleRate(config byte) uint16 { + switch config { + case 0, 1, 2, 3, 16, 17, 18, 19: + return 8000 // NB (narrowband) + case 4, 5, 6, 7: + return 12000 // MB (medium-band) + case 8, 9, 10, 11, 20, 21, 22, 23: + return 16000 // WB (wideband) + case 12, 13, 24, 25, 26, 27: + return 24000 // SWB (super-wideband) + case 14, 15, 28, 29, 30, 31: + return 48000 // FB (fullband) + } + return 0 +} + +func parseFrameSize(config byte) time.Duration { + switch config { + case 0, 4, 8, 12, 14, 18, 22, 26, 30: + return 10_000_000 + case 1, 5, 9, 13, 15, 19, 23, 27, 31: + return 20_000_000 + case 2, 6, 10: + return 40_000_000 + case 3, 7, 11: + return 60_000_000 + case 16, 20, 24, 28: + return 2_500_000 + case 17, 21, 25, 29: + return 5_000_000 + } + return 0 +} + +func parseChannels(s byte) byte { + if s == 1 { + return 2 + } + return 1 +} + +func parseFrames(c byte) byte { + switch c { + case 0: + return 1 + case 1, 2: + return 2 + } + return 0xFF +} + +func JoinFrames(b1, b2 []byte) []byte { + // can't join + if b1[0]&0b11 != 0 || b2[0]&0b11 != 0 { + return append(b1, b2...) + } + + size1, size2 := len(b1)-1, len(b2)-1 + + // join same sizes + if size1 == size2 { + b := make([]byte, 1+size1+size2) + copy(b, b1) + copy(b[1+size1:], b2[1:]) + b[0] |= 0b01 + return b + } + + b := make([]byte, 1, 3+size1+size2) + b[0] = b1[0] | 0b10 + if size1 >= 252 { + b0 := 252 + byte(size1)&0b11 + b = append(b, b0, byte(size1/4)-b0) + } else { + b = append(b, byte(size1)) + } + + b = append(b, b1[1:]...) + b = append(b, b2[1:]...) + return b +} diff --git a/installs_on_host/go2rtc/pkg/pcm/backchannel.go b/installs_on_host/go2rtc/pkg/pcm/backchannel.go new file mode 100644 index 0000000..99b6e3a --- /dev/null +++ b/installs_on_host/go2rtc/pkg/pcm/backchannel.go @@ -0,0 +1,69 @@ +package pcm + +import ( + "errors" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/shell" + "github.com/pion/rtp" +) + +type Backchannel struct { + core.Connection + cmd *shell.Command +} + +func NewBackchannel(cmd *shell.Command, audio string) (core.Producer, error) { + var codec *core.Codec + + if audio == "" { + // default codec + codec = &core.Codec{Name: core.CodecPCML, ClockRate: 16000} + } else if codec = core.ParseCodecString(audio); codec == nil { + return nil, errors.New("pcm: unsupported audio format: " + audio) + } + + medias := []*core.Media{ + { + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{codec}, + }, + } + + return &Backchannel{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "pcm", + Protocol: "pipe", + Medias: medias, + Transport: cmd, + }, + cmd: cmd, + }, nil +} + +func (c *Backchannel) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + return nil, core.ErrCantGetTrack +} + +func (c *Backchannel) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + wr, err := c.cmd.StdinPipe() + if err != nil { + return err + } + + sender := core.NewSender(media, track.Codec) + sender.Handler = func(packet *rtp.Packet) { + if n, err := wr.Write(packet.Payload); err != nil { + c.Send += n + } + } + sender.HandleRTP(track) + c.Senders = append(c.Senders, sender) + return nil +} + +func (c *Backchannel) Start() error { + return c.cmd.Run() +} diff --git a/installs_on_host/go2rtc/pkg/pcm/flac.go b/installs_on_host/go2rtc/pkg/pcm/flac.go new file mode 100644 index 0000000..808d4a7 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/pcm/flac.go @@ -0,0 +1,151 @@ +// Package pcm - support raw (verbatim) PCM 16 bit in the FLAC container: +// - only 1 channel +// - only 16 bit per sample +// - only 8000, 16000, 24000, 48000 sample rate +package pcm + +import ( + "encoding/binary" + "unicode/utf8" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" + "github.com/sigurn/crc16" + "github.com/sigurn/crc8" +) + +func FLACHeader(magic bool, sampleRate uint32) []byte { + b := make([]byte, 42) + + if magic { + copy(b, "fLaC") // [0..3] + } + + // https://xiph.org/flac/format.html#metadata_block_header + b[4] = 0x80 // [4] lastMetadata=1 (1 bit), blockType=0 - STREAMINFO (7 bit) + b[7] = 0x22 // [5..7] blockLength=34 (24 bit) + + // Important for Apple QuickTime player: + // 1. Both values should be same + // 2. Maximum value = 32768 + binary.BigEndian.PutUint16(b[8:], 32768) // [8..9] info.BlockSizeMin=16 (16 bit) + binary.BigEndian.PutUint16(b[10:], 32768) // [10..11] info.BlockSizeMin=65535 (16 bit) + + // [12..14] info.FrameSizeMin=0 (24 bit) + // [15..17] info.FrameSizeMax=0 (24 bit) + + b[18] = byte(sampleRate >> 12) + b[19] = byte(sampleRate >> 4) + b[20] = byte(sampleRate << 4) // [18..20] info.SampleRate=8000 (20 bit), info.NChannels=1-1 (3 bit) + + b[21] = 0xF0 // [21..25] info.BitsPerSample=16-1 (5 bit), info.NSamples (36 bit) + + // [26..41] MD5sum (16 bytes) + + return b +} + +var table8 *crc8.Table +var table16 *crc16.Table + +func FLACEncoder(codecName string, clockRate uint32, handler core.HandlerFunc) core.HandlerFunc { + var sr byte + switch clockRate { + case 8000: + sr = 0b0100 + case 16000: + sr = 0b0101 + case 22050: + sr = 0b0110 + case 24000: + sr = 0b0111 + case 32000: + sr = 0b1000 + case 44100: + sr = 0b1001 + case 48000: + sr = 0b1010 + case 96000: + sr = 0b1011 + default: + return nil + } + + if table8 == nil { + table8 = crc8.MakeTable(crc8.CRC8) + } + if table16 == nil { + table16 = crc16.MakeTable(crc16.CRC16_BUYPASS) + } + + var sampleNumber int32 + + return func(packet *rtp.Packet) { + samples := uint16(len(packet.Payload)) + + if codecName == core.CodecPCM || codecName == core.CodecPCML { + samples /= 2 + } + + // https://xiph.org/flac/format.html#frame_header + buf := make([]byte, samples*2+30) + + // 1. Frame header + buf[0] = 0xFF + buf[1] = 0xF9 // [0..1] syncCode=0xFFF8 - reserved (15 bit), blockStrategy=1 - variable-blocksize (1 bit) + buf[2] = 0x70 | sr // blockSizeType=7 (4 bit), sampleRate=4 - 8000 (4 bit) + buf[3] = 0x08 // channels=1-1 (4 bit), sampleSize=4 - 16 (3 bit), reserved=0 (1 bit) + + n := 4 + utf8.EncodeRune(buf[4:], sampleNumber) // 4 bytes max + sampleNumber += int32(samples) + + // this is wrong but very simple frame block size value + binary.BigEndian.PutUint16(buf[n:], samples-1) + n += 2 + + buf[n] = crc8.Checksum(buf[:n], table8) + n += 1 + + // 2. Subframe header + buf[n] = 0x02 // padding=0 (1 bit), subframeType=1 - verbatim (6 bit), wastedFlag=0 (1 bit) + n += 1 + + // 3. Subframe + switch codecName { + case core.CodecPCMA: + for _, b := range packet.Payload { + s16 := PCMAtoPCM(b) + buf[n] = byte(s16 >> 8) + buf[n+1] = byte(s16) + n += 2 + } + case core.CodecPCMU: + for _, b := range packet.Payload { + s16 := PCMUtoPCM(b) + buf[n] = byte(s16 >> 8) + buf[n+1] = byte(s16) + n += 2 + } + case core.CodecPCM: + n += copy(buf[n:], packet.Payload) + case core.CodecPCML: + // reverse endian from little to big + size := len(packet.Payload) + for i := 0; i < size; i += 2 { + buf[n] = packet.Payload[i+1] + buf[n+1] = packet.Payload[i] + n += 2 + } + } + + // 4. Frame footer + crc := crc16.Checksum(buf[:n], table16) + binary.BigEndian.PutUint16(buf[n:], crc) + n += 2 + + clone := *packet + clone.Payload = buf[:n] + + handler(&clone) + } +} diff --git a/installs_on_host/go2rtc/pkg/pcm/handlers.go b/installs_on_host/go2rtc/pkg/pcm/handlers.go new file mode 100644 index 0000000..18a9646 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/pcm/handlers.go @@ -0,0 +1,109 @@ +package pcm + +import ( + "sync" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +// RepackG711 - Repack G.711 PCMA/PCMU into frames of size 1024 +// 1. Fixes WebRTC audio quality issue (monotonic timestamp) +// 2. Fixes Reolink Doorbell backchannel issue (zero timestamp) +// https://github.com/AlexxIT/go2rtc/issues/331 +func RepackG711(zeroTS bool, handler core.HandlerFunc) core.HandlerFunc { + const PacketSize = 1024 + + var buf []byte + var seq uint16 + var ts uint32 + + // fix https://github.com/AlexxIT/go2rtc/issues/432 + var mu sync.Mutex + + return func(packet *rtp.Packet) { + mu.Lock() + + buf = append(buf, packet.Payload...) + if len(buf) < PacketSize { + mu.Unlock() + return + } + + pkt := &rtp.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: true, // should be true + PayloadType: packet.PayloadType, // will be owerwriten + SequenceNumber: seq, + SSRC: packet.SSRC, + }, + Payload: buf[:PacketSize], + } + + seq++ + + // don't know if zero TS important for Reolink Doorbell + // don't have this strange devices for tests + if !zeroTS { + pkt.Timestamp = ts + ts += PacketSize + } + + buf = buf[PacketSize:] + + mu.Unlock() + + handler(pkt) + } +} + +// LittleToBig - convert PCM little endian to PCM big endian +func LittleToBig(handler core.HandlerFunc) core.HandlerFunc { + return func(packet *rtp.Packet) { + clone := *packet + clone.Payload = FlipEndian(packet.Payload) + handler(&clone) + } +} + +func TranscodeHandler(dst, src *core.Codec, handler core.HandlerFunc) core.HandlerFunc { + var ts uint32 + k := float32(BytesPerFrame(dst)) / float32(BytesPerFrame(src)) + f := Transcode(dst, src) + + return func(packet *rtp.Packet) { + ts += uint32(k * float32(len(packet.Payload))) + + clone := *packet + clone.Payload = f(packet.Payload) + clone.Timestamp = ts + handler(&clone) + } +} + +func BytesPerSample(codec *core.Codec) int { + switch codec.Name { + case core.CodecPCML, core.CodecPCM: + return 2 + case core.CodecPCMU, core.CodecPCMA: + return 1 + } + return 0 +} + +func BytesPerFrame(codec *core.Codec) int { + if codec.Channels <= 1 { + return BytesPerSample(codec) + } + return int(codec.Channels) * BytesPerSample(codec) +} + +func FramesPerDuration(codec *core.Codec, duration time.Duration) int { + return int(time.Duration(codec.ClockRate) * duration / time.Second) +} + +func BytesPerDuration(codec *core.Codec, duration time.Duration) int { + return BytesPerFrame(codec) * FramesPerDuration(codec, duration) +} diff --git a/installs_on_host/go2rtc/pkg/pcm/pcm.go b/installs_on_host/go2rtc/pkg/pcm/pcm.go new file mode 100644 index 0000000..5395621 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/pcm/pcm.go @@ -0,0 +1,220 @@ +package pcm + +import ( + "math" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +func ceil(x float32) int { + d, fract := math.Modf(float64(x)) + if fract == 0.0 { + return int(d) + } + return int(d) + 1 +} + +func Downsample(k float32) func([]int16) []int16 { + var sampleN, sampleSum float32 + + return func(src []int16) (dst []int16) { + var i int + dst = make([]int16, ceil((float32(len(src))+sampleN)/k)) + for _, sample := range src { + sampleSum += float32(sample) + sampleN++ + if sampleN >= k { + dst[i] = int16(sampleSum / k) + i++ + + sampleSum = 0 + sampleN -= k + } + } + return + } +} + +func Upsample(k float32) func([]int16) []int16 { + var sampleN float32 + + return func(src []int16) (dst []int16) { + var i int + dst = make([]int16, ceil(k*float32(len(src)))) + for _, sample := range src { + sampleN += k + for sampleN > 0 { + dst[i] = sample + i++ + + sampleN -= 1 + } + } + return + } +} + +func FlipEndian(src []byte) (dst []byte) { + var i, j int + n := len(src) + dst = make([]byte, n) + for i < n { + x := src[i] + i++ + dst[j] = src[i] + j++ + i++ + dst[j] = x + j++ + } + return +} + +func Transcode(dst, src *core.Codec) func([]byte) []byte { + var reader func([]byte) []int16 + var writer func([]int16) []byte + var filters []func([]int16) []int16 + + switch src.Name { + case core.CodecPCML: + reader = func(src []byte) (dst []int16) { + var i, j int + n := len(src) + dst = make([]int16, n/2) + for i < n { + lo := src[i] + i++ + hi := src[i] + i++ + dst[j] = int16(hi)<<8 | int16(lo) + j++ + } + return + } + case core.CodecPCM: + reader = func(src []byte) (dst []int16) { + var i, j int + n := len(src) + dst = make([]int16, n/2) + for i < n { + hi := src[i] + i++ + lo := src[i] + i++ + dst[j] = int16(hi)<<8 | int16(lo) + j++ + } + return + } + case core.CodecPCMU: + reader = func(src []byte) (dst []int16) { + var i int + dst = make([]int16, len(src)) + for _, sample := range src { + dst[i] = PCMUtoPCM(sample) + i++ + } + return + } + case core.CodecPCMA: + reader = func(src []byte) (dst []int16) { + var i int + dst = make([]int16, len(src)) + for _, sample := range src { + dst[i] = PCMAtoPCM(sample) + i++ + } + return + } + } + + if src.Channels > 1 { + filters = append(filters, Downsample(float32(src.Channels))) + } + + if src.ClockRate > dst.ClockRate { + filters = append(filters, Downsample(float32(src.ClockRate)/float32(dst.ClockRate))) + } else if src.ClockRate < dst.ClockRate { + filters = append(filters, Upsample(float32(dst.ClockRate)/float32(src.ClockRate))) + } + + if dst.Channels > 1 { + filters = append(filters, Upsample(float32(dst.Channels))) + } + + switch dst.Name { + case core.CodecPCML: + writer = func(src []int16) (dst []byte) { + var i int + dst = make([]byte, len(src)*2) + for _, sample := range src { + dst[i] = byte(sample) + i++ + dst[i] = byte(sample >> 8) + i++ + } + return + } + case core.CodecPCM: + writer = func(src []int16) (dst []byte) { + var i int + dst = make([]byte, len(src)*2) + for _, sample := range src { + dst[i] = byte(sample >> 8) + i++ + dst[i] = byte(sample) + i++ + } + return + } + case core.CodecPCMU: + writer = func(src []int16) (dst []byte) { + var i int + dst = make([]byte, len(src)) + for _, sample := range src { + dst[i] = PCMtoPCMU(sample) + i++ + } + return + } + case core.CodecPCMA: + writer = func(src []int16) (dst []byte) { + var i int + dst = make([]byte, len(src)) + for _, sample := range src { + dst[i] = PCMtoPCMA(sample) + i++ + } + return + } + } + + return func(b []byte) []byte { + samples := reader(b) + for _, filter := range filters { + samples = filter(samples) + } + return writer(samples) + } +} + +func ConsumerCodecs() []*core.Codec { + return []*core.Codec{ + {Name: core.CodecPCML}, + {Name: core.CodecPCM}, + {Name: core.CodecPCMA}, + {Name: core.CodecPCMU}, + } +} + +func ProducerCodecs() []*core.Codec { + return []*core.Codec{ + {Name: core.CodecPCML, ClockRate: 16000}, + {Name: core.CodecPCM, ClockRate: 16000}, + {Name: core.CodecPCML, ClockRate: 8000}, + {Name: core.CodecPCM, ClockRate: 8000}, + {Name: core.CodecPCMA, ClockRate: 8000}, + {Name: core.CodecPCMU, ClockRate: 8000}, + {Name: core.CodecPCML, ClockRate: 22050}, // wyoming-snd-external + } +} diff --git a/installs_on_host/go2rtc/pkg/pcm/pcm_test.go b/installs_on_host/go2rtc/pkg/pcm/pcm_test.go new file mode 100644 index 0000000..2832be6 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/pcm/pcm_test.go @@ -0,0 +1,79 @@ +package pcm + +import ( + "encoding/hex" + "fmt" + "testing" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/stretchr/testify/require" +) + +func TestTranscode(t *testing.T) { + tests := []struct { + name string + src core.Codec + dst core.Codec + source string + expect string + }{ + { + name: "s16be->s16be", + src: core.Codec{Name: core.CodecPCM, ClockRate: 8000, Channels: 1}, + dst: core.Codec{Name: core.CodecPCM, ClockRate: 8000, Channels: 1}, + source: "FCCA00130343062808130B510D9E0F7610DA111113EA15BD16F2168215D41561", + expect: "FCCA00130343062808130B510D9E0F7610DA111113EA15BD16F2168215D41561", + }, + { + name: "s16be->s16le", + src: core.Codec{Name: core.CodecPCM, ClockRate: 8000, Channels: 1}, + dst: core.Codec{Name: core.CodecPCML, ClockRate: 8000, Channels: 1}, + source: "FCCA00130343062808130B510D9E0F7610DA111113EA15BD16F2168215D41561", + expect: "CAFC1300430328061308510B9E0D760FDA101111EA13BD15F2168216D4156115", + }, + { + name: "s16be->mulaw", + src: core.Codec{Name: core.CodecPCM, ClockRate: 8000, Channels: 1}, + dst: core.Codec{Name: core.CodecPCMU, ClockRate: 8000, Channels: 1}, + source: "FCCA00130343062808130B510D9E0F7610DA111113EA15BD16F2168215D41561", + expect: "52FDD1C5BEB8B3B0AEAEABA9A8A8A9AA", + }, + { + name: "s16be->alaw", + src: core.Codec{Name: core.CodecPCM, ClockRate: 8000, Channels: 1}, + dst: core.Codec{Name: core.CodecPCMA, ClockRate: 8000, Channels: 1}, + source: "FCCA00130343062808130B510D9E0F7610DA111113EA15BD16F2168215D41561", + expect: "7CD4FFED95939E9B8584868083838080", + }, + { + name: "2ch->1ch", + src: core.Codec{Name: core.CodecPCM, ClockRate: 8000, Channels: 2}, + dst: core.Codec{Name: core.CodecPCM, ClockRate: 8000, Channels: 1}, + source: "FCCAFCCA001300130343034306280628081308130B510B510D9E0D9E0F760F76", + expect: "FCCA00130343062808130B510D9E0F76", + }, + { + name: "1ch->2ch", + src: core.Codec{Name: core.CodecPCM, ClockRate: 8000, Channels: 1}, + dst: core.Codec{Name: core.CodecPCM, ClockRate: 8000, Channels: 2}, + source: "FCCA00130343062808130B510D9E0F76", + expect: "FCCAFCCA001300130343034306280628081308130B510B510D9E0D9E0F760F76", + }, + { + name: "16khz->8khz", + src: core.Codec{Name: core.CodecPCM, ClockRate: 16000, Channels: 1}, + dst: core.Codec{Name: core.CodecPCM, ClockRate: 8000, Channels: 1}, + source: "FCCAFCCA001300130343034306280628081308130B510B510D9E0D9E0F760F76", + expect: "FCCA00130343062808130B510D9E0F76", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + f := Transcode(&test.dst, &test.src) + b, _ := hex.DecodeString(test.source) + b = f(b) + s := fmt.Sprintf("%X", b) + require.Equal(t, test.expect, s) + }) + } +} diff --git a/installs_on_host/go2rtc/pkg/pcm/pcma.go b/installs_on_host/go2rtc/pkg/pcm/pcma.go new file mode 100644 index 0000000..3e1ef11 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/pcm/pcma.go @@ -0,0 +1,53 @@ +// Package pcm +// https://www.codeproject.com/Articles/14237/Using-the-G711-standard +package pcm + +const alawMax = 0x7FFF + +func PCMAtoPCM(alaw byte) int16 { + alaw ^= 0xD5 + + data := int16(((alaw & 0x0F) << 4) + 8) + exponent := (alaw & 0x70) >> 4 + + if exponent != 0 { + data |= 0x100 + } + + if exponent > 1 { + data <<= exponent - 1 + } + + // sign + if alaw&0x80 == 0 { + return data + } else { + return -data + } +} + +func PCMtoPCMA(pcm int16) byte { + var alaw byte + + if pcm < 0 { + pcm = -pcm + alaw = 0x80 + } + + if pcm > alawMax { + pcm = alawMax + } + + exponent := byte(7) + for expMask := int16(0x4000); (pcm&expMask) == 0 && exponent > 0; expMask >>= 1 { + exponent-- + } + + if exponent == 0 { + alaw |= byte(pcm>>4) & 0x0F + } else { + alaw |= (exponent << 4) | (byte(pcm>>(exponent+3)) & 0x0F) + } + + return alaw ^ 0xD5 +} diff --git a/installs_on_host/go2rtc/pkg/pcm/pcmu.go b/installs_on_host/go2rtc/pkg/pcm/pcmu.go new file mode 100644 index 0000000..954d8a9 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/pcm/pcmu.go @@ -0,0 +1,51 @@ +// Package pcm +// https://www.codeproject.com/Articles/14237/Using-the-G711-standard +package pcm + +const bias = 0x84 // 132 or 1000 0100 +const ulawMax = alawMax - bias + +func PCMUtoPCM(ulaw byte) int16 { + ulaw = ^ulaw + + exponent := (ulaw & 0x70) >> 4 + data := (int16((((ulaw&0x0F)|0x10)<<1)+1) << (exponent + 2)) - bias + + // sign + if ulaw&0x80 == 0 { + return data + } else if data == 0 { + return -1 + } else { + return -data + } +} + +func PCMtoPCMU(pcm int16) byte { + var ulaw byte + + if pcm < 0 { + pcm = -pcm + ulaw = 0x80 + } + + if pcm > ulawMax { + pcm = ulawMax + } + + pcm += bias + + exponent := byte(7) + for expMask := int16(0x4000); (pcm & expMask) == 0; expMask >>= 1 { + exponent-- + } + + // mantisa + ulaw |= byte(pcm>>(exponent+3)) & 0x0F + + if exponent > 0 { + ulaw |= exponent << 4 + } + + return ^ulaw +} diff --git a/installs_on_host/go2rtc/pkg/pcm/producer.go b/installs_on_host/go2rtc/pkg/pcm/producer.go new file mode 100644 index 0000000..8a957f6 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/pcm/producer.go @@ -0,0 +1,55 @@ +package pcm + +import ( + "io" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +type Producer struct { + core.Connection + rd io.Reader +} + +func Open(rd io.Reader) (*Producer, error) { + medias := []*core.Media{ + { + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + {Name: core.CodecPCMU, ClockRate: 8000}, + }, + }, + } + return &Producer{ + core.Connection{ + ID: core.NewID(), + FormatName: "pcm", + Medias: medias, + Transport: rd, + }, + rd, + }, nil +} + +func (c *Producer) Start() error { + for { + payload := make([]byte, 1024) + if _, err := io.ReadFull(c.rd, payload); err != nil { + return err + } + + c.Recv += 1024 + + if len(c.Receivers) == 0 { + continue + } + + pkt := &rtp.Packet{ + Header: rtp.Header{Timestamp: core.Now90000()}, + Payload: payload, + } + c.Receivers[0].WriteRTP(pkt) + } +} diff --git a/installs_on_host/go2rtc/pkg/pcm/producer_sync.go b/installs_on_host/go2rtc/pkg/pcm/producer_sync.go new file mode 100644 index 0000000..fedef26 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/pcm/producer_sync.go @@ -0,0 +1,96 @@ +package pcm + +import ( + "io" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +type ProducerSync struct { + core.Connection + src *core.Codec + rd io.Reader + onClose func() +} + +func OpenSync(codec *core.Codec, rd io.Reader) *ProducerSync { + medias := []*core.Media{ + { + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: ProducerCodecs(), + }, + } + + return &ProducerSync{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "pcm", + Medias: medias, + Transport: rd, + }, + src: codec, + rd: rd, + } +} + +func (p *ProducerSync) OnClose(f func()) { + p.onClose = f +} + +func (p *ProducerSync) Start() error { + if len(p.Receivers) == 0 { + return nil + } + + var pktSeq uint16 + var pktTS uint32 // time in frames + var pktTime time.Duration // time in seconds + + t0 := time.Now() + + dst := p.Receivers[0].Codec + transcode := Transcode(dst, p.src) + + const chunkDuration = 20 * time.Millisecond + chunkBytes := BytesPerDuration(p.src, chunkDuration) + chunkFrames := uint32(FramesPerDuration(dst, chunkDuration)) + + for { + buf := make([]byte, chunkBytes) + n, _ := io.ReadFull(p.rd, buf) + + if n == 0 { + break + } + + pkt := &core.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: true, + SequenceNumber: pktSeq, + Timestamp: pktTS, + }, + Payload: transcode(buf[:n]), + } + + if d := pktTime - time.Since(t0); d > 0 { + time.Sleep(d) + } + + p.Receivers[0].WriteRTP(pkt) + p.Recv += n + + pktSeq++ + pktTS += chunkFrames + pktTime += chunkDuration + } + + if p.onClose != nil { + p.onClose() + } + + return nil +} diff --git a/installs_on_host/go2rtc/pkg/pcm/s16le/s16le.go b/installs_on_host/go2rtc/pkg/pcm/s16le/s16le.go new file mode 100644 index 0000000..acd2d4f --- /dev/null +++ b/installs_on_host/go2rtc/pkg/pcm/s16le/s16le.go @@ -0,0 +1,42 @@ +package s16le + +func PeaksRMS(b []byte) int16 { + // RMS of sine wave = peak / sqrt2 + // https://en.wikipedia.org/wiki/Root_mean_square + // https://www.youtube.com/watch?v=MUDkL4KZi0I + var peaks int32 + var peaksSum int32 + var prevSample int16 + var prevUp bool + + var i int + for n := len(b); i < n; { + lo := b[i] + i++ + hi := b[i] + i++ + + sample := int16(hi)<<8 | int16(lo) + up := sample >= prevSample + + if i >= 4 { + if up != prevUp { + if prevSample >= 0 { + peaksSum += int32(prevSample) + } else { + peaksSum -= int32(prevSample) + } + peaks++ + } + } + + prevSample = sample + prevUp = up + } + + if peaks == 0 { + return 0 + } + + return int16(peaksSum / peaks) +} diff --git a/installs_on_host/go2rtc/pkg/pcm/v1/pcm.go b/installs_on_host/go2rtc/pkg/pcm/v1/pcm.go new file mode 100644 index 0000000..e165235 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/pcm/v1/pcm.go @@ -0,0 +1,155 @@ +// Package v1 +// http://web.archive.org/web/20110719132013/http://hazelware.luggle.com/tutorials/mulawcompression.html +package v1 + +const cBias = 0x84 +const cClip = 32635 + +var MuLawCompressTable = [256]byte{ + 0, 0, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, + 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, + 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, + 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, + 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, + 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, +} + +func LinearToMuLawSample(sample int16) byte { + sign := byte(sample>>8) & 0x80 + if sign != 0 { + sample = -sample + } + + if sample > cClip { + sample = cClip + } + sample = sample + cBias + + exponent := MuLawCompressTable[(sample>>7)&0xFF] + mantissa := byte(sample>>(exponent+3)) & 0x0F + + compressedByte := ^(sign | (exponent << 4) | mantissa) + + return compressedByte +} + +var ALawCompressTable = [128]byte{ + 1, 1, 2, 2, 3, 3, 3, 3, + 4, 4, 4, 4, 4, 4, 4, 4, + 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, + 6, 6, 6, 6, 6, 6, 6, 6, + 6, 6, 6, 6, 6, 6, 6, 6, + 6, 6, 6, 6, 6, 6, 6, 6, + 6, 6, 6, 6, 6, 6, 6, 6, + 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, +} + +func LinearToALawSample(sample int16) byte { + sign := byte((^sample)>>8) & 0x80 + if sign == 0 { + sample = -sample + } + + if sample > cClip { + sample = cClip + } + + var compressedByte byte + if sample >= 256 { + exponent := ALawCompressTable[(sample>>8)&0x7F] + mantissa := byte(sample>>(exponent+3)) & 0x0F + compressedByte = (exponent << 4) | mantissa + } else { + compressedByte = byte(sample >> 4) + } + compressedByte ^= sign ^ 0x55 + return compressedByte +} + +var MuLawDecompressTable = [256]int16{ + -32124, -31100, -30076, -29052, -28028, -27004, -25980, -24956, + -23932, -22908, -21884, -20860, -19836, -18812, -17788, -16764, + -15996, -15484, -14972, -14460, -13948, -13436, -12924, -12412, + -11900, -11388, -10876, -10364, -9852, -9340, -8828, -8316, + -7932, -7676, -7420, -7164, -6908, -6652, -6396, -6140, + -5884, -5628, -5372, -5116, -4860, -4604, -4348, -4092, + -3900, -3772, -3644, -3516, -3388, -3260, -3132, -3004, + -2876, -2748, -2620, -2492, -2364, -2236, -2108, -1980, + -1884, -1820, -1756, -1692, -1628, -1564, -1500, -1436, + -1372, -1308, -1244, -1180, -1116, -1052, -988, -924, + -876, -844, -812, -780, -748, -716, -684, -652, + -620, -588, -556, -524, -492, -460, -428, -396, + -372, -356, -340, -324, -308, -292, -276, -260, + -244, -228, -212, -196, -180, -164, -148, -132, + -120, -112, -104, -96, -88, -80, -72, -64, + -56, -48, -40, -32, -24, -16, -8, -1, + 32124, 31100, 30076, 29052, 28028, 27004, 25980, 24956, + 23932, 22908, 21884, 20860, 19836, 18812, 17788, 16764, + 15996, 15484, 14972, 14460, 13948, 13436, 12924, 12412, + 11900, 11388, 10876, 10364, 9852, 9340, 8828, 8316, + 7932, 7676, 7420, 7164, 6908, 6652, 6396, 6140, + 5884, 5628, 5372, 5116, 4860, 4604, 4348, 4092, + 3900, 3772, 3644, 3516, 3388, 3260, 3132, 3004, + 2876, 2748, 2620, 2492, 2364, 2236, 2108, 1980, + 1884, 1820, 1756, 1692, 1628, 1564, 1500, 1436, + 1372, 1308, 1244, 1180, 1116, 1052, 988, 924, + 876, 844, 812, 780, 748, 716, 684, 652, + 620, 588, 556, 524, 492, 460, 428, 396, + 372, 356, 340, 324, 308, 292, 276, 260, + 244, 228, 212, 196, 180, 164, 148, 132, + 120, 112, 104, 96, 88, 80, 72, 64, + 56, 48, 40, 32, 24, 16, 8, 0, +} + +var ALawDecompressTable = [256]int16{ + -5504, -5248, -6016, -5760, -4480, -4224, -4992, -4736, + -7552, -7296, -8064, -7808, -6528, -6272, -7040, -6784, + -2752, -2624, -3008, -2880, -2240, -2112, -2496, -2368, + -3776, -3648, -4032, -3904, -3264, -3136, -3520, -3392, + -22016, -20992, -24064, -23040, -17920, -16896, -19968, -18944, + -30208, -29184, -32256, -31232, -26112, -25088, -28160, -27136, + -11008, -10496, -12032, -11520, -8960, -8448, -9984, -9472, + -15104, -14592, -16128, -15616, -13056, -12544, -14080, -13568, + -344, -328, -376, -360, -280, -264, -312, -296, + -472, -456, -504, -488, -408, -392, -440, -424, + -88, -72, -120, -104, -24, -8, -56, -40, + -216, -200, -248, -232, -152, -136, -184, -168, + -1376, -1312, -1504, -1440, -1120, -1056, -1248, -1184, + -1888, -1824, -2016, -1952, -1632, -1568, -1760, -1696, + -688, -656, -752, -720, -560, -528, -624, -592, + -944, -912, -1008, -976, -816, -784, -880, -848, + 5504, 5248, 6016, 5760, 4480, 4224, 4992, 4736, + 7552, 7296, 8064, 7808, 6528, 6272, 7040, 6784, + 2752, 2624, 3008, 2880, 2240, 2112, 2496, 2368, + 3776, 3648, 4032, 3904, 3264, 3136, 3520, 3392, + 22016, 20992, 24064, 23040, 17920, 16896, 19968, 18944, + 30208, 29184, 32256, 31232, 26112, 25088, 28160, 27136, + 11008, 10496, 12032, 11520, 8960, 8448, 9984, 9472, + 15104, 14592, 16128, 15616, 13056, 12544, 14080, 13568, + 344, 328, 376, 360, 280, 264, 312, 296, + 472, 456, 504, 488, 408, 392, 440, 424, + 88, 72, 120, 104, 24, 8, 56, 40, + 216, 200, 248, 232, 152, 136, 184, 168, + 1376, 1312, 1504, 1440, 1120, 1056, 1248, 1184, + 1888, 1824, 2016, 1952, 1632, 1568, 1760, 1696, + 688, 656, 752, 720, 560, 528, 624, 592, + 944, 912, 1008, 976, 816, 784, 880, 848, +} diff --git a/installs_on_host/go2rtc/pkg/pcm/v1/pcm_test.go b/installs_on_host/go2rtc/pkg/pcm/v1/pcm_test.go new file mode 100644 index 0000000..07b70b9 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/pcm/v1/pcm_test.go @@ -0,0 +1,40 @@ +package v1 + +import ( + "testing" + + v2 "github.com/AlexxIT/go2rtc/pkg/pcm" + "github.com/stretchr/testify/require" +) + +func TestPCMUtoPCM(t *testing.T) { + for pcmu := byte(0); pcmu < 255; pcmu++ { + pcm1 := MuLawDecompressTable[pcmu] + pcm2 := v2.PCMUtoPCM(pcmu) + require.Equal(t, pcm1, pcm2) + } +} + +func TestPCMAtoPCM(t *testing.T) { + for pcma := byte(0); pcma < 255; pcma++ { + pcm1 := ALawDecompressTable[pcma] + pcm2 := v2.PCMAtoPCM(pcma) + require.Equal(t, pcm1, pcm2) + } +} + +func TestPCMtoPCMU(t *testing.T) { + for pcm := int16(-32768); pcm < 32767; pcm++ { + pcmu1 := LinearToMuLawSample(pcm) + pcmu2 := v2.PCMtoPCMU(pcm) + require.Equal(t, pcmu1, pcmu2) + } +} + +func TestPCMtoPCMA(t *testing.T) { + for pcm := int16(-32768); pcm < 32767; pcm++ { + pcma1 := LinearToALawSample(pcm) + pcma2 := v2.PCMtoPCMA(pcm) + require.Equal(t, pcma1, pcma2) + } +} diff --git a/installs_on_host/go2rtc/pkg/pinggy/pinggy.go b/installs_on_host/go2rtc/pkg/pinggy/pinggy.go new file mode 100644 index 0000000..22ebfc9 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/pinggy/pinggy.go @@ -0,0 +1,137 @@ +package pinggy + +import ( + "context" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "time" + + "golang.org/x/crypto/ssh" +) + +type Client struct { + SSH *ssh.Client + TCP net.Listener + API *http.Client +} + +func NewClient(proto string) (*Client, error) { + switch proto { + case "http", "tcp", "tls", "tlstcp": + case "": + proto = "http" + default: + return nil, errors.New("pinggy: unsupported proto: " + proto) + } + + config := &ssh.ClientConfig{ + User: "auth+" + proto, + Auth: []ssh.AuthMethod{ssh.Password("nopass")}, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 5 * time.Second, + } + + client, err := ssh.Dial("tcp", "a.pinggy.io:443", config) + if err != nil { + return nil, err + } + + ln, err := client.Listen("tcp", "0.0.0.0:0") + if err != nil { + _ = client.Close() + return nil, err + } + + c := &Client{ + SSH: client, + TCP: ln, + API: &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return client.Dial(network, addr) + }, + }, + }, + } + + if proto == "http" { + if err = c.NewSession(); err != nil { + _ = client.Close() + return nil, err + } + } + + return c, nil +} + +func (c *Client) Close() error { + return errors.Join(c.SSH.Close(), c.TCP.Close()) +} + +func (c *Client) NewSession() error { + session, err := c.SSH.NewSession() + if err != nil { + return err + } + return session.Shell() +} + +func (c *Client) GetURLs() ([]string, error) { + res, err := c.API.Get("http://localhost:4300/urls") + if err != nil { + return nil, err + } + defer res.Body.Close() + + var v struct { + URLs []string `json:"urls"` + } + + if err = json.NewDecoder(res.Body).Decode(&v); err != nil { + return nil, err + } + + return v.URLs, nil +} + +func (c *Client) Proxy(address string) error { + defer c.TCP.Close() + + for { + conn, err := c.TCP.Accept() + if err != nil { + return err + } + go proxy(conn, address) + } +} + +func proxy(conn1 net.Conn, address string) { + defer conn1.Close() + + conn2, err := net.Dial("tcp", address) + if err != nil { + return + } + defer conn2.Close() + + go io.Copy(conn2, conn1) + io.Copy(conn1, conn2) +} + +// DialTLS like ssh.Dial but with TLS +//func DialTLS(network, addr, sni string, config *ssh.ClientConfig) (*ssh.Client, error) { +// conn, err := net.DialTimeout(network, addr, config.Timeout) +// if err != nil { +// return nil, err +// } +// conn = tls.Client(conn, &tls.Config{ServerName: sni, InsecureSkipVerify: sni == ""}) +// c, chans, reqs, err := ssh.NewClientConn(conn, addr, config) +// if err != nil { +// return nil, err +// } +// return ssh.NewClient(c, chans, reqs), nil +//} diff --git a/installs_on_host/go2rtc/pkg/probe/consumer.go b/installs_on_host/go2rtc/pkg/probe/consumer.go new file mode 100644 index 0000000..a1ca7ca --- /dev/null +++ b/installs_on_host/go2rtc/pkg/probe/consumer.go @@ -0,0 +1,53 @@ +package probe + +import ( + "net/url" + "strings" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +type Probe struct { + core.Connection +} + +func Create(name string, query url.Values) *Probe { + medias := core.ParseQuery(query) + + for _, value := range query["microphone"] { + media := &core.Media{Kind: core.KindAudio, Direction: core.DirectionRecvonly} + + for _, name := range strings.Split(value, ",") { + name = strings.ToUpper(name) + switch name { + case "", "COPY": + name = core.CodecAny + } + media.Codecs = append(media.Codecs, &core.Codec{Name: name}) + } + + medias = append(medias, media) + } + + return &Probe{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: name, + Medias: medias, + }, + } +} + +func (p *Probe) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + sender := core.NewSender(media, track.Codec) + sender.Handler = func(pkt *core.Packet) { + p.Send += len(pkt.Payload) + } + sender.HandleRTP(track) + p.Senders = append(p.Senders, sender) + return nil +} + +func (p *Probe) Start() error { + return nil +} diff --git a/installs_on_host/go2rtc/pkg/ring/api.go b/installs_on_host/go2rtc/pkg/ring/api.go new file mode 100644 index 0000000..ea7c95a --- /dev/null +++ b/installs_on_host/go2rtc/pkg/ring/api.go @@ -0,0 +1,702 @@ +package ring + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "reflect" + "strings" + "sync" + "time" +) + +var clientCache = map[string]*RingApi{} +var cacheMutex sync.Mutex + +type RefreshTokenAuth struct { + RefreshToken string +} + +type EmailAuth struct { + Email string + Password string +} + +type AuthConfig struct { + RT string `json:"rt"` // Refresh Token + HID string `json:"hid"` // Hardware ID +} + +type AuthTokenResponse struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + Scope string `json:"scope"` // Always "client" + TokenType string `json:"token_type"` // Always "Bearer" +} + +type Auth2faResponse struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + TSVState string `json:"tsv_state"` + Phone string `json:"phone"` + NextTimeInSecs int `json:"next_time_in_secs"` +} + +type SocketTicketResponse struct { + Ticket string `json:"ticket"` + ResponseTimestamp int64 `json:"response_timestamp"` +} + +type SessionResponse struct { + Profile struct { + ID int64 `json:"id"` + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + } `json:"profile"` +} + +type RingApi struct { + httpClient *http.Client + authConfig *AuthConfig + hardwareID string + authToken *AuthTokenResponse + tokenExpiry time.Time + Using2FA bool + PromptFor2FA string + RefreshToken string + auth interface{} // EmailAuth or RefreshTokenAuth + onTokenRefresh func(string) + authMutex sync.Mutex + session *SessionResponse + sessionExpiry time.Time + sessionMutex sync.Mutex + cacheKey string +} + +type CameraKind string + +type CameraData struct { + ID int `json:"id"` + Description string `json:"description"` + DeviceID string `json:"device_id"` + Kind string `json:"kind"` + LocationID string `json:"location_id"` +} + +type RingDeviceType string + +type RingDevicesResponse struct { + Doorbots []CameraData `json:"doorbots"` + AuthorizedDoorbots []CameraData `json:"authorized_doorbots"` + StickupCams []CameraData `json:"stickup_cams"` + AllCameras []CameraData `json:"all_cameras"` + Chimes []CameraData `json:"chimes"` + Other []map[string]interface{} `json:"other"` +} + +const ( + Doorbot CameraKind = "doorbot" + Doorbell CameraKind = "doorbell" + DoorbellV3 CameraKind = "doorbell_v3" + DoorbellV4 CameraKind = "doorbell_v4" + DoorbellV5 CameraKind = "doorbell_v5" + DoorbellOyster CameraKind = "doorbell_oyster" + DoorbellPortal CameraKind = "doorbell_portal" + DoorbellScallop CameraKind = "doorbell_scallop" + DoorbellScallopLite CameraKind = "doorbell_scallop_lite" + DoorbellGraham CameraKind = "doorbell_graham_cracker" + LpdV1 CameraKind = "lpd_v1" + LpdV2 CameraKind = "lpd_v2" + LpdV4 CameraKind = "lpd_v4" + JboxV1 CameraKind = "jbox_v1" + StickupCam CameraKind = "stickup_cam" + StickupCamV3 CameraKind = "stickup_cam_v3" + StickupCamElite CameraKind = "stickup_cam_elite" + StickupCamLongfin CameraKind = "stickup_cam_longfin" + StickupCamLunar CameraKind = "stickup_cam_lunar" + SpotlightV2 CameraKind = "spotlightw_v2" + HpCamV1 CameraKind = "hp_cam_v1" + HpCamV2 CameraKind = "hp_cam_v2" + StickupCamV4 CameraKind = "stickup_cam_v4" + FloodlightV1 CameraKind = "floodlight_v1" + FloodlightV2 CameraKind = "floodlight_v2" + FloodlightPro CameraKind = "floodlight_pro" + CocoaCamera CameraKind = "cocoa_camera" + CocoaDoorbell CameraKind = "cocoa_doorbell" + CocoaFloodlight CameraKind = "cocoa_floodlight" + CocoaSpotlight CameraKind = "cocoa_spotlight" + StickupCamMini CameraKind = "stickup_cam_mini" + OnvifCamera CameraKind = "onvif_camera" +) + +const ( + IntercomHandsetAudio RingDeviceType = "intercom_handset_audio" + OnvifCameraType RingDeviceType = "onvif_camera" +) + +const ( + clientAPIBaseURL = "https://api.ring.com/clients_api/" + deviceAPIBaseURL = "https://api.ring.com/devices/v1/" + commandsAPIBaseURL = "https://api.ring.com/commands/v1/" + appAPIBaseURL = "https://prd-api-us.prd.rings.solutions/api/v1/" + oauthURL = "https://oauth.ring.com/oauth/token" + apiVersion = 11 + defaultTimeout = 20 * time.Second + maxRetries = 3 + sessionValidTime = 12 * time.Hour +) + +func NewRestClient(auth interface{}, onTokenRefresh func(string)) (*RingApi, error) { + var cacheKey string + + // Create cache key based on auth data + switch a := auth.(type) { + case RefreshTokenAuth: + if a.RefreshToken == "" { + return nil, fmt.Errorf("refresh token is required") + } + cacheKey = "refresh:" + a.RefreshToken + case EmailAuth: + if a.Email == "" || a.Password == "" { + return nil, fmt.Errorf("email and password are required") + } + cacheKey = "email:" + a.Email + ":" + a.Password + default: + return nil, fmt.Errorf("invalid auth type") + } + + cacheMutex.Lock() + defer cacheMutex.Unlock() + + if cachedClient, ok := clientCache[cacheKey]; ok { + // Check if token is not nil and not expired + if cachedClient.authToken != nil && time.Now().Before(cachedClient.tokenExpiry) { + cachedClient.onTokenRefresh = onTokenRefresh + return cachedClient, nil + } + } + + client := &RingApi{ + httpClient: &http.Client{Timeout: defaultTimeout}, + onTokenRefresh: onTokenRefresh, + hardwareID: generateHardwareID(), + auth: auth, + cacheKey: cacheKey, + } + + switch a := auth.(type) { + case RefreshTokenAuth: + config, err := parseAuthConfig(a.RefreshToken) + if err != nil { + return nil, fmt.Errorf("failed to parse refresh token: %w", err) + } + + client.authConfig = config + client.hardwareID = config.HID + client.RefreshToken = a.RefreshToken + } + + clientCache[cacheKey] = client + + return client, nil +} + +func ClientAPI(path string) string { + return clientAPIBaseURL + path +} + +func DeviceAPI(path string) string { + return deviceAPIBaseURL + path +} + +func CommandsAPI(path string) string { + return commandsAPIBaseURL + path +} + +func AppAPI(path string) string { + return appAPIBaseURL + path +} + +func (c *RingApi) GetAuth(twoFactorAuthCode string) (*AuthTokenResponse, error) { + var grantData map[string]string + + if c.authConfig != nil && twoFactorAuthCode == "" { + grantData = map[string]string{ + "grant_type": "refresh_token", + "refresh_token": c.authConfig.RT, + } + } else { + authEmail, ok := c.auth.(EmailAuth) + if !ok { + return nil, fmt.Errorf("invalid auth type for email authentication") + } + grantData = map[string]string{ + "grant_type": "password", + "username": authEmail.Email, + "password": authEmail.Password, + } + } + + grantData["client_id"] = "ring_official_android" + grantData["scope"] = "client" + + body, err := json.Marshal(grantData) + if err != nil { + return nil, fmt.Errorf("failed to marshal auth request: %w", err) + } + + req, err := http.NewRequest("POST", oauthURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("hardware_id", c.hardwareID) + req.Header.Set("User-Agent", "android:com.ringapp") + req.Header.Set("2fa-support", "true") + if twoFactorAuthCode != "" { + req.Header.Set("2fa-code", twoFactorAuthCode) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + // Handle 2FA Responses + if resp.StatusCode == http.StatusPreconditionFailed || + (resp.StatusCode == http.StatusBadRequest && strings.Contains(resp.Header.Get("WWW-Authenticate"), "Verification Code")) { + + var tfaResp Auth2faResponse + if err := json.NewDecoder(resp.Body).Decode(&tfaResp); err != nil { + return nil, err + } + + c.Using2FA = true + if resp.StatusCode == http.StatusBadRequest { + c.PromptFor2FA = "Invalid 2fa code entered. Please try again." + return nil, fmt.Errorf("invalid 2FA code") + } + + if tfaResp.TSVState != "" { + prompt := "from your authenticator app" + if tfaResp.TSVState != "totp" { + prompt = fmt.Sprintf("sent to %s via %s", tfaResp.Phone, tfaResp.TSVState) + } + c.PromptFor2FA = fmt.Sprintf("Please enter the code %s", prompt) + } else { + c.PromptFor2FA = "Please enter the code sent to your text/email" + } + + return nil, fmt.Errorf("2FA required") + } + + // Handle errors + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("auth request failed with status %d: %s", resp.StatusCode, string(body)) + } + + var authResp AuthTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil { + return nil, fmt.Errorf("failed to decode auth response: %w", err) + } + + // Refresh token and expiry + c.authToken = &authResp + c.authConfig = &AuthConfig{ + RT: authResp.RefreshToken, + HID: c.hardwareID, + } + // Set token expiry (1 minute before actual expiry) + expiresIn := time.Duration(authResp.ExpiresIn-60) * time.Second + c.tokenExpiry = time.Now().Add(expiresIn) + + c.RefreshToken = encodeAuthConfig(c.authConfig) + if c.onTokenRefresh != nil { + c.onTokenRefresh(c.RefreshToken) + } + + // Refresh the cached client + cacheMutex.Lock() + clientCache[c.cacheKey] = c + cacheMutex.Unlock() + + return c.authToken, nil +} + +func (c *RingApi) FetchRingDevices() (*RingDevicesResponse, error) { + response, err := c.Request("GET", ClientAPI("ring_devices"), nil) + if err != nil { + return nil, fmt.Errorf("failed to fetch ring devices: %w", err) + } + + var devices RingDevicesResponse + if err := json.Unmarshal(response, &devices); err != nil { + return nil, fmt.Errorf("failed to unmarshal devices response: %w", err) + } + + // Process "other" devices + var onvifCameras []CameraData + var intercoms []CameraData + + for _, device := range devices.Other { + kind, ok := device["kind"].(string) + if !ok { + continue + } + + switch RingDeviceType(kind) { + case OnvifCameraType: + var camera CameraData + if deviceJson, err := json.Marshal(device); err == nil { + if err := json.Unmarshal(deviceJson, &camera); err == nil { + onvifCameras = append(onvifCameras, camera) + } + } + case IntercomHandsetAudio: + var intercom CameraData + if deviceJson, err := json.Marshal(device); err == nil { + if err := json.Unmarshal(deviceJson, &intercom); err == nil { + intercoms = append(intercoms, intercom) + } + } + } + } + + // Combine all cameras into AllCameras slice + allCameras := make([]CameraData, 0) + allCameras = append(allCameras, interfaceSlice(devices.Doorbots)...) + allCameras = append(allCameras, interfaceSlice(devices.StickupCams)...) + allCameras = append(allCameras, interfaceSlice(devices.AuthorizedDoorbots)...) + allCameras = append(allCameras, interfaceSlice(onvifCameras)...) + allCameras = append(allCameras, interfaceSlice(intercoms)...) + + devices.AllCameras = allCameras + + return &devices, nil +} + +func (c *RingApi) GetSocketTicket() (*SocketTicketResponse, error) { + response, err := c.Request("POST", AppAPI("clap/ticket/request/signalsocket"), nil) + if err != nil { + return nil, fmt.Errorf("failed to fetch socket ticket: %w", err) + } + + var ticket SocketTicketResponse + if err := json.Unmarshal(response, &ticket); err != nil { + return nil, fmt.Errorf("failed to unmarshal socket ticket response: %w", err) + } + + return &ticket, nil +} + +func (c *RingApi) Request(method, url string, body interface{}) ([]byte, error) { + // Ensure we have a valid session + if err := c.ensureSession(); err != nil { + return nil, fmt.Errorf("session validation failed: %w", err) + } + + var bodyReader io.Reader + if body != nil { + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + bodyReader = bytes.NewReader(jsonBody) + } + + // Create request + req, err := http.NewRequest(method, url, bodyReader) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Set headers + req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("hardware_id", c.hardwareID) + req.Header.Set("User-Agent", "android:com.ringapp") + + // Make request with retries + var resp *http.Response + var responseBody []byte + + for attempt := 0; attempt <= maxRetries; attempt++ { + resp, err = c.httpClient.Do(req) + if err != nil { + if attempt == maxRetries { + return nil, fmt.Errorf("request failed after %d retries: %w", maxRetries, err) + } + time.Sleep(5 * time.Second) + continue + } + defer resp.Body.Close() + + responseBody, err = io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + // Handle 401 by refreshing auth and retrying + if resp.StatusCode == http.StatusUnauthorized { + // Reset token to force refresh + c.authMutex.Lock() + c.authToken = nil + c.tokenExpiry = time.Time{} // Reset token expiry + c.authMutex.Unlock() + + if attempt == maxRetries { + return nil, fmt.Errorf("authentication failed after %d retries", maxRetries) + } + + // By 401 with Auth AND Session start over + c.sessionMutex.Lock() + c.session = nil + c.sessionExpiry = time.Time{} // Reset session expiry + c.sessionMutex.Unlock() + + if err := c.ensureSession(); err != nil { + return nil, fmt.Errorf("failed to refresh session: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken) + continue + } + + // Handle 404 error with hardware_id reference - session issue + if resp.StatusCode == 404 && strings.Contains(url, clientAPIBaseURL) { + var errorBody map[string]interface{} + if err := json.Unmarshal(responseBody, &errorBody); err == nil { + if errorStr, ok := errorBody["error"].(string); ok && strings.Contains(errorStr, c.hardwareID) { + // Session with hardware_id not found, refresh session + c.sessionMutex.Lock() + c.session = nil + c.sessionExpiry = time.Time{} // Reset session expiry + c.sessionMutex.Unlock() + + if attempt == maxRetries { + return nil, fmt.Errorf("session refresh failed after %d retries", maxRetries) + } + + if err := c.ensureSession(); err != nil { + return nil, fmt.Errorf("failed to refresh session: %w", err) + } + + continue + } + } + } + + // Handle other error status codes + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(responseBody)) + } + + break + } + + return responseBody, nil +} + +func (c *RingApi) ensureSession() error { + c.sessionMutex.Lock() + defer c.sessionMutex.Unlock() + + // If session is still valid, use it + if c.session != nil && time.Now().Before(c.sessionExpiry) { + return nil + } + + // Make sure we have a valid auth token + if err := c.ensureAuth(); err != nil { + return fmt.Errorf("authentication failed while creating session: %w", err) + } + + sessionPayload := map[string]interface{}{ + "device": map[string]interface{}{ + "hardware_id": c.hardwareID, + "metadata": map[string]interface{}{ + "api_version": apiVersion, + "device_model": "ring-client-go", + }, + "os": "android", + }, + } + + body, err := json.Marshal(sessionPayload) + if err != nil { + return fmt.Errorf("failed to marshal session request: %w", err) + } + + req, err := http.NewRequest("POST", ClientAPI("session"), bytes.NewReader(body)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken) + req.Header.Set("hardware_id", c.hardwareID) + req.Header.Set("User-Agent", "android:com.ringapp") + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("session request failed with status %d: %s", resp.StatusCode, string(respBody)) + } + + var sessionResp SessionResponse + if err := json.NewDecoder(resp.Body).Decode(&sessionResp); err != nil { + return fmt.Errorf("failed to decode session response: %w", err) + } + + c.session = &sessionResp + c.sessionExpiry = time.Now().Add(sessionValidTime) + + // Aktualisiere den gecachten Client + cacheMutex.Lock() + clientCache[c.cacheKey] = c + cacheMutex.Unlock() + + return nil +} + +func (c *RingApi) ensureAuth() error { + c.authMutex.Lock() + defer c.authMutex.Unlock() + + // If token exists and is not expired, use it + if c.authToken != nil && time.Now().Before(c.tokenExpiry) { + return nil + } + + var grantData = map[string]string{ + "grant_type": "refresh_token", + "refresh_token": c.authConfig.RT, + } + + // Add common fields + grantData["client_id"] = "ring_official_android" + grantData["scope"] = "client" + + // Make auth request + body, err := json.Marshal(grantData) + if err != nil { + return fmt.Errorf("failed to marshal auth request: %w", err) + } + + req, err := http.NewRequest("POST", oauthURL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create auth request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("hardware_id", c.hardwareID) + req.Header.Set("User-Agent", "android:com.ringapp") + req.Header.Set("2fa-support", "true") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("auth request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusPreconditionFailed { + return fmt.Errorf("2FA required. Please see documentation for handling 2FA") + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("auth request failed with status %d: %s", resp.StatusCode, string(body)) + } + + var authResp AuthTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil { + return fmt.Errorf("failed to decode auth response: %w", err) + } + + // Update auth config and refresh token + c.authToken = &authResp + c.authConfig = &AuthConfig{ + RT: authResp.RefreshToken, + HID: c.hardwareID, + } + + // Set token expiry (1 minute before actual expiry) + expiresIn := time.Duration(authResp.ExpiresIn-60) * time.Second + c.tokenExpiry = time.Now().Add(expiresIn) + + // Encode and notify about new refresh token + if c.onTokenRefresh != nil { + newRefreshToken := encodeAuthConfig(c.authConfig) + c.onTokenRefresh(newRefreshToken) + } + + // Refreshn the token in the client + c.RefreshToken = encodeAuthConfig(c.authConfig) + + // Refresh the cached client + cacheMutex.Lock() + clientCache[c.cacheKey] = c + cacheMutex.Unlock() + + return nil +} + +func parseAuthConfig(refreshToken string) (*AuthConfig, error) { + decoded, err := base64.StdEncoding.DecodeString(refreshToken) + if err != nil { + return nil, err + } + + var config AuthConfig + if err := json.Unmarshal(decoded, &config); err != nil { + // Handle legacy format where refresh token is the raw token + return &AuthConfig{RT: refreshToken}, nil + } + + return &config, nil +} + +func encodeAuthConfig(config *AuthConfig) string { + jsonBytes, _ := json.Marshal(config) + return base64.StdEncoding.EncodeToString(jsonBytes) +} + +func generateHardwareID() string { + h := sha256.New() + h.Write([]byte("ring-client-go2rtc")) + return hex.EncodeToString(h.Sum(nil)[:16]) +} + +func interfaceSlice(slice interface{}) []CameraData { + s := reflect.ValueOf(slice) + if s.Kind() != reflect.Slice { + return nil + } + + ret := make([]CameraData, s.Len()) + for i := 0; i < s.Len(); i++ { + if camera, ok := s.Index(i).Interface().(CameraData); ok { + ret[i] = camera + } + } + return ret +} diff --git a/installs_on_host/go2rtc/pkg/ring/client.go b/installs_on_host/go2rtc/pkg/ring/client.go new file mode 100644 index 0000000..fb77e19 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/ring/client.go @@ -0,0 +1,355 @@ +package ring + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "strconv" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/webrtc" + "github.com/google/uuid" + pion "github.com/pion/webrtc/v4" +) + +type Client struct { + api *RingApi + wsClient *WSClient + prod core.Producer + cameraID int + dialogID string + connected core.Waiter + closed bool +} + +func Dial(rawURL string) (*Client, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + query := u.Query() + encodedToken := query.Get("refresh_token") + cameraID := query.Get("camera_id") + deviceID := query.Get("device_id") + _, isSnapshot := query["snapshot"] + + if encodedToken == "" || deviceID == "" || cameraID == "" { + return nil, errors.New("ring: wrong query") + } + + client := &Client{ + dialogID: uuid.NewString(), + } + + client.cameraID, err = strconv.Atoi(cameraID) + if err != nil { + return nil, fmt.Errorf("ring: invalid camera_id: %w", err) + } + + refreshToken, err := url.QueryUnescape(encodedToken) + if err != nil { + return nil, fmt.Errorf("ring: invalid refresh token encoding: %w", err) + } + + client.api, err = NewRestClient(RefreshTokenAuth{RefreshToken: refreshToken}, nil) + if err != nil { + return nil, err + } + + // Snapshot Flow + if isSnapshot { + client.prod = NewSnapshotProducer(client.api, client.cameraID) + return client, nil + } + + client.wsClient, err = StartWebsocket(client.cameraID, client.api) + if err != nil { + client.Stop() + return nil, err + } + + // Create Peer Connection + conf := pion.Configuration{ + ICEServers: []pion.ICEServer{ + {URLs: []string{ + "stun:stun.kinesisvideo.us-east-1.amazonaws.com:443", + "stun:stun.kinesisvideo.us-east-2.amazonaws.com:443", + "stun:stun.kinesisvideo.us-west-2.amazonaws.com:443", + "stun:stun.l.google.com:19302", + "stun:stun1.l.google.com:19302", + "stun:stun2.l.google.com:19302", + "stun:stun3.l.google.com:19302", + "stun:stun4.l.google.com:19302", + }}, + }, + ICETransportPolicy: pion.ICETransportPolicyAll, + BundlePolicy: pion.BundlePolicyBalanced, + } + + api, err := webrtc.NewAPI() + if err != nil { + client.Stop() + return nil, err + } + + pc, err := api.NewPeerConnection(conf) + if err != nil { + client.Stop() + return nil, err + } + + // protect from sending ICE candidate before Offer + var sendOffer core.Waiter + + // protect from blocking on errors + defer sendOffer.Done(nil) + + prod := webrtc.NewConn(pc) + prod.FormatName = "ring/webrtc" + prod.Mode = core.ModeActiveProducer + prod.Protocol = "ws" + prod.URL = rawURL + + client.wsClient.onMessage = func(msg WSMessage) { + client.onWSMessage(msg) + } + + client.wsClient.onError = func(err error) { + // fmt.Printf("ring: error: %s\n", err.Error()) + client.Stop() + client.connected.Done(err) + } + + client.wsClient.onClose = func() { + // fmt.Println("ring: disconnect") + client.Stop() + client.connected.Done(errors.New("ring: disconnect")) + } + + prod.Listen(func(msg any) { + switch msg := msg.(type) { + case *pion.ICECandidate: + _ = sendOffer.Wait() + + iceCandidate := msg.ToJSON() + + // skip empty ICE candidates + if iceCandidate.Candidate == "" { + return + } + + icePayload := map[string]interface{}{ + "ice": iceCandidate.Candidate, + "mlineindex": iceCandidate.SDPMLineIndex, + } + + if err = client.wsClient.sendSessionMessage("ice", icePayload); err != nil { + client.connected.Done(err) + return + } + + case pion.PeerConnectionState: + switch msg { + case pion.PeerConnectionStateNew: + break + case pion.PeerConnectionStateConnecting: + break + case pion.PeerConnectionStateConnected: + client.connected.Done(nil) + default: + client.Stop() + client.connected.Done(errors.New("ring: " + msg.String())) + } + } + }) + + client.prod = prod + + // Setup media configuration + medias := []*core.Media{ + { + Kind: core.KindAudio, + Direction: core.DirectionSendRecv, + Codecs: []*core.Codec{ + { + Name: "opus", + ClockRate: 48000, + Channels: 2, + }, + }, + }, + { + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + { + Name: "H264", + ClockRate: 90000, + }, + }, + }, + } + + // Create offer + offer, err := prod.CreateOffer(medias) + if err != nil { + client.Stop() + return nil, err + } + + // Send offer + offerPayload := map[string]interface{}{ + "stream_options": map[string]bool{ + "audio_enabled": true, + "video_enabled": true, + }, + "sdp": offer, + } + + if err = client.wsClient.sendSessionMessage("live_view", offerPayload); err != nil { + client.Stop() + return nil, err + } + + sendOffer.Done(nil) + + if err = client.connected.Wait(); err != nil { + return nil, err + } + + return client, nil +} + +func (c *Client) onWSMessage(msg WSMessage) { + rawMsg, _ := json.Marshal(msg) + + // fmt.Printf("ring: onWSMessage: %s\n", string(rawMsg)) + + // check if "doorbot_id" is present + if _, ok := msg.Body["doorbot_id"]; !ok { + return + } + + // check if the message is from the correct doorbot + doorbotID := msg.Body["doorbot_id"].(float64) + if int(doorbotID) != c.cameraID { + return + } + + if msg.Method == "session_created" || msg.Method == "session_started" { + if _, ok := msg.Body["session_id"]; ok && c.wsClient.sessionID == "" { + c.wsClient.sessionID = msg.Body["session_id"].(string) + } + } + + // check if the message is from the correct session + if _, ok := msg.Body["session_id"]; ok { + if msg.Body["session_id"].(string) != c.wsClient.sessionID { + return + } + } + + switch msg.Method { + case "sdp": + if prod, ok := c.prod.(*webrtc.Conn); ok { + // Get answer + var msg AnswerMessage + if err := json.Unmarshal(rawMsg, &msg); err != nil { + c.Stop() + c.connected.Done(err) + return + } + + if err := prod.SetAnswer(msg.Body.SDP); err != nil { + c.Stop() + c.connected.Done(err) + return + } + + if err := c.wsClient.activateSession(); err != nil { + c.Stop() + c.connected.Done(err) + return + } + + prod.SDP = msg.Body.SDP + } + + case "ice": + if prod, ok := c.prod.(*webrtc.Conn); ok { + var msg IceCandidateMessage + if err := json.Unmarshal(rawMsg, &msg); err != nil { + break + } + + // Skip empty candidates + if msg.Body.Ice == "" { + break + } + + if err := prod.AddCandidate(msg.Body.Ice); err != nil { + c.Stop() + c.connected.Done(err) + return + } + } + + case "close": + c.Stop() + c.connected.Done(errors.New("ring: close")) + + case "pong": + // Ignore + } +} + +func (c *Client) GetMedias() []*core.Media { + return c.prod.GetMedias() +} + +func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + return c.prod.GetTrack(media, codec) +} + +func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + if webrtcProd, ok := c.prod.(*webrtc.Conn); ok { + if media.Kind == core.KindAudio { + // Enable speaker + speakerPayload := map[string]interface{}{ + "stealth_mode": false, + } + _ = c.wsClient.sendSessionMessage("camera_options", speakerPayload) + } + return webrtcProd.AddTrack(media, codec, track) + } + + return fmt.Errorf("add track not supported for snapshot") +} + +func (c *Client) Start() error { + return c.prod.Start() +} + +func (c *Client) Stop() error { + if c.closed { + return nil + } + + c.closed = true + + if c.prod != nil { + _ = c.prod.Stop() + } + + if c.wsClient != nil { + _ = c.wsClient.Close() + } + + return nil +} + +func (c *Client) MarshalJSON() ([]byte, error) { + return json.Marshal(c.prod) +} diff --git a/installs_on_host/go2rtc/pkg/ring/snapshot.go b/installs_on_host/go2rtc/pkg/ring/snapshot.go new file mode 100644 index 0000000..b52eada --- /dev/null +++ b/installs_on_host/go2rtc/pkg/ring/snapshot.go @@ -0,0 +1,61 @@ +package ring + +import ( + "fmt" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +type SnapshotProducer struct { + core.Connection + + client *RingApi + cameraID int +} + +func NewSnapshotProducer(client *RingApi, cameraID int) *SnapshotProducer { + return &SnapshotProducer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "ring/snapshot", + Protocol: "https", + RemoteAddr: "app-snaps.ring.com", + Medias: []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + { + Name: core.CodecJPEG, + ClockRate: 90000, + PayloadType: core.PayloadTypeRAW, + }, + }, + }, + }, + }, + client: client, + cameraID: cameraID, + } +} + +func (p *SnapshotProducer) Start() error { + response, err := p.client.Request("GET", fmt.Sprintf("https://app-snaps.ring.com/snapshots/next/%d", p.cameraID), nil) + if err != nil { + return err + } + + pkt := &rtp.Packet{ + Header: rtp.Header{Timestamp: core.Now90000()}, + Payload: response, + } + + p.Receivers[0].WriteRTP(pkt) + + return nil +} + +func (p *SnapshotProducer) Stop() error { + return p.Connection.Stop() +} diff --git a/installs_on_host/go2rtc/pkg/ring/ws.go b/installs_on_host/go2rtc/pkg/ring/ws.go new file mode 100644 index 0000000..51e72fe --- /dev/null +++ b/installs_on_host/go2rtc/pkg/ring/ws.go @@ -0,0 +1,265 @@ +package ring + +import ( + "fmt" + "net/http" + "net/url" + "sync" + "time" + + "github.com/google/uuid" + "github.com/gorilla/websocket" +) + +type SessionBody struct { + DoorbotID int `json:"doorbot_id"` + SessionID string `json:"session_id"` +} + +type AnswerMessage struct { + Method string `json:"method"` // "sdp" + Body struct { + SessionBody + SDP string `json:"sdp"` + Type string `json:"type"` // "answer" + } `json:"body"` +} + +type IceCandidateMessage struct { + Method string `json:"method"` // "ice" + Body struct { + SessionBody + Ice string `json:"ice"` + MLineIndex int `json:"mlineindex"` + } `json:"body"` +} + +type SessionMessage struct { + Method string `json:"method"` // "session_created" or "session_started" + Body SessionBody `json:"body"` +} + +type PongMessage struct { + Method string `json:"method"` // "pong" + Body SessionBody `json:"body"` +} + +type NotificationMessage struct { + Method string `json:"method"` // "notification" + Body struct { + SessionBody + IsOK bool `json:"is_ok"` + Text string `json:"text"` + } `json:"body"` +} + +type StreamInfoMessage struct { + Method string `json:"method"` // "stream_info" + Body struct { + SessionBody + Transcoding bool `json:"transcoding"` + TranscodingReason string `json:"transcoding_reason"` + } `json:"body"` +} + +type CloseRequest struct { + Method string `json:"method"` // "close" + Body struct { + SessionBody + Reason struct { + Code int `json:"code"` + Text string `json:"text"` + } `json:"reason"` + } `json:"body"` +} + +type WSMessage struct { + Method string `json:"method"` + Body map[string]any `json:"body"` +} + +type WSClient struct { + ws *websocket.Conn + api *RingApi + wsMutex sync.Mutex + cameraID int + dialogID string + sessionID string + + onMessage func(msg WSMessage) + onError func(err error) + onClose func() + + closed chan struct{} +} + +const ( + CloseReasonNormalClose = 0 + CloseReasonAuthenticationFailed = 5 + CloseReasonTimeout = 6 +) + +func StartWebsocket(cameraID int, api *RingApi) (*WSClient, error) { + client := &WSClient{ + api: api, + cameraID: cameraID, + dialogID: uuid.NewString(), + closed: make(chan struct{}), + } + + ticket, err := client.api.GetSocketTicket() + if err != nil { + return nil, err + } + + url := fmt.Sprintf("wss://api.prod.signalling.ring.devices.a2z.com/ws?api_version=4.0&auth_type=ring_solutions&client_id=ring_site-%s&token=%s", + uuid.NewString(), url.QueryEscape(ticket.Ticket)) + + httpHeader := http.Header{} + httpHeader.Set("User-Agent", "android:com.ringapp") + + client.ws, _, err = websocket.DefaultDialer.Dial(url, httpHeader) + if err != nil { + return nil, err + } + + client.ws.SetCloseHandler(func(code int, text string) error { + client.onWsClose() + return nil + }) + + go client.startPingLoop() + go client.startMessageLoop() + + return client, nil +} + +func (c *WSClient) Close() error { + select { + case <-c.closed: + return nil + default: + close(c.closed) + } + + closePayload := map[string]interface{}{ + "reason": map[string]interface{}{ + "code": CloseReasonNormalClose, + "text": "", + }, + } + + _ = c.sendSessionMessage("close", closePayload) + + return c.ws.Close() +} + +func (c *WSClient) startPingLoop() { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + select { + case <-c.closed: + return + case <-ticker.C: + if err := c.sendSessionMessage("ping", nil); err != nil { + return + } + } + } +} + +func (c *WSClient) startMessageLoop() { + for { + select { + case <-c.closed: + return + default: + var res WSMessage + if err := c.ws.ReadJSON(&res); err != nil { + select { + case <-c.closed: + // Ignore error if closed + default: + c.onWsError(err) + } + + return + } + + c.onWsMessage(res) + } + } +} + +func (c *WSClient) activateSession() error { + if err := c.sendSessionMessage("activate_session", nil); err != nil { + return err + } + + streamPayload := map[string]interface{}{ + "audio_enabled": true, + "video_enabled": true, + } + + if err := c.sendSessionMessage("stream_options", streamPayload); err != nil { + return err + } + + return nil +} + +func (c *WSClient) sendSessionMessage(method string, payload map[string]interface{}) error { + select { + case <-c.closed: + return nil + default: + // continue + } + + c.wsMutex.Lock() + defer c.wsMutex.Unlock() + + if payload == nil { + payload = make(map[string]interface{}) + } + + payload["doorbot_id"] = c.cameraID + if c.sessionID != "" { + payload["session_id"] = c.sessionID + } + + msg := map[string]interface{}{ + "method": method, + "dialog_id": c.dialogID, + "body": payload, + } + + // rawMsg, _ := json.Marshal(msg) + // fmt.Printf("ring: sendSessionMessage: %s: %s\n", method, string(rawMsg)) + + if err := c.ws.WriteJSON(msg); err != nil { + return err + } + + return nil +} + +func (c *WSClient) onWsMessage(msg WSMessage) { + if c.onMessage != nil { + c.onMessage(msg) + } +} + +func (c *WSClient) onWsError(err error) { + if c.onError != nil { + c.onError(err) + } +} + +func (c *WSClient) onWsClose() { + if c.onClose != nil { + c.onClose() + } +} diff --git a/installs_on_host/go2rtc/pkg/roborock/api.go b/installs_on_host/go2rtc/pkg/roborock/api.go new file mode 100644 index 0000000..8876a85 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/roborock/api.go @@ -0,0 +1,167 @@ +package roborock + +import ( + "crypto/hmac" + "crypto/md5" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +type UserInfo struct { + Token string `json:"token"` + IoT struct { + User string `json:"u"` + Pass string `json:"s"` + Hash string `json:"h"` + Domain string `json:"k"` + URL struct { + API string `json:"a"` + MQTT string `json:"m"` + } `json:"r"` + } `json:"rriot"` +} + +func GetBaseURL(username string) (string, error) { + u := "https://euiot.roborock.com/api/v1/getUrlByEmail?email=" + url.QueryEscape(username) + req, err := http.NewRequest("POST", u, nil) + if err != nil { + return "", err + } + + client := http.Client{Timeout: time.Second * 5000} + res, err := client.Do(req) + + var v struct { + Msg string `json:"msg"` + Code int `json:"code"` + Data struct { + URL string `json:"url"` + } `json:"data"` + } + if err = json.NewDecoder(res.Body).Decode(&v); err != nil { + return "", err + } + + if v.Code != 200 { + return "", fmt.Errorf("%d: %s", v.Code, v.Msg) + } + + return v.Data.URL, nil +} + +func Login(baseURL, username, password string) (*UserInfo, error) { + u := baseURL + "/api/v1/login?username=" + url.QueryEscape(username) + + "&password=" + url.QueryEscape(password) + "&needtwostepauth=false" + req, err := http.NewRequest("POST", u, nil) + if err != nil { + return nil, err + } + + clientID := core.RandString(16, 64) + clientID = base64.StdEncoding.EncodeToString([]byte(clientID)) + req.Header.Set("header_clientid", clientID) + + client := http.Client{Timeout: time.Second * 5000} + res, err := client.Do(req) + + var v struct { + Msg string `json:"msg"` + Code int `json:"code"` + Data UserInfo `json:"data"` + } + if err = json.NewDecoder(res.Body).Decode(&v); err != nil { + return nil, err + } + + if v.Code != 200 { + return nil, fmt.Errorf("%d: %s", v.Code, v.Msg) + } + + return &v.Data, nil +} + +func GetHomeID(baseURL, token string) (int, error) { + req, err := http.NewRequest("GET", baseURL+"/api/v1/getHomeDetail", nil) + if err != nil { + return 0, err + } + req.Header.Set("Authorization", token) + + client := http.Client{Timeout: time.Second * 5000} + res, err := client.Do(req) + if err != nil { + return 0, err + } + + var v struct { + Msg string `json:"msg"` + Code int `json:"code"` + Data struct { + HomeID int `json:"rrHomeId"` + } `json:"data"` + } + if err = json.NewDecoder(res.Body).Decode(&v); err != nil { + return 0, err + } + + if v.Code != 200 { + return 0, fmt.Errorf("%d: %s", v.Code, v.Msg) + } + + return v.Data.HomeID, nil +} + +type DeviceInfo struct { + DID string `json:"duid"` + Name string `json:"name"` + Key string `json:"localKey"` +} + +func GetDevices(ui *UserInfo, homeID int) ([]DeviceInfo, error) { + nonce := core.RandString(6, 64) + ts := time.Now().Unix() + path := "/user/homes/" + strconv.Itoa(homeID) + + mac := fmt.Sprintf( + "%s:%s:%s:%d:%x::", ui.IoT.User, ui.IoT.Pass, nonce, ts, md5.Sum([]byte(path)), + ) + hash := hmac.New(sha256.New, []byte(ui.IoT.Hash)) + hash.Write([]byte(mac)) + mac = base64.StdEncoding.EncodeToString(hash.Sum(nil)) + + auth := fmt.Sprintf( + `Hawk id="%s", s="%s", ts="%d", nonce="%s", mac="%s"`, + ui.IoT.User, ui.IoT.Pass, ts, nonce, mac, + ) + + req, err := http.NewRequest("GET", ui.IoT.URL.API+path, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", auth) + + client := http.Client{Timeout: time.Second * 5000} + res, err := client.Do(req) + if err != nil { + return nil, err + } + + var v struct { + Result struct { + Devices []DeviceInfo `json:"devices"` + } `json:"result"` + } + if err = json.NewDecoder(res.Body).Decode(&v); err != nil { + return nil, err + } + + return v.Result.Devices, nil +} diff --git a/installs_on_host/go2rtc/pkg/roborock/client.go b/installs_on_host/go2rtc/pkg/roborock/client.go new file mode 100644 index 0000000..4940b74 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/roborock/client.go @@ -0,0 +1,381 @@ +package roborock + +import ( + "crypto/md5" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/rpc" + "net/url" + "strconv" + "sync" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/roborock/iot" + "github.com/AlexxIT/go2rtc/pkg/webrtc" + pion "github.com/pion/webrtc/v4" +) + +// Deprecated: should be rewritten to core.Connection +type Client struct { + core.Listener + + url string + + conn *webrtc.Conn + iot *rpc.Client + + devKey string + pin string + devTopic string + + audio bool + backchannel bool +} + +func Dial(rawURL string) (*Client, error) { + client := &Client{url: rawURL} + if err := client.Dial(); err != nil { + return nil, err + } + if err := client.Connect(); err != nil { + return nil, err + } + return client, nil +} + +func (c *Client) Dial() error { + u, err := url.Parse(c.url) + if err != nil { + return err + } + + if c.iot, err = iot.Dial(c.url); err != nil { + return err + } + + c.pin = u.Query().Get("pin") + if c.pin != "" { + c.pin = fmt.Sprintf("%x", md5.Sum([]byte(c.pin))) + return c.CheckHomesecPassword() + } + + return nil +} + +func (c *Client) Connect() error { + // 1. Check if camera ready for connection + for i := 0; ; i++ { + clientID, err := c.GetHomesecConnectStatus() + if err != nil { + return err + } + if clientID == "none" { + break + } + if err = c.StopCameraPreview(clientID); err != nil { + return err + } + if i == 5 { + return errors.New("camera not ready") + } + time.Sleep(time.Second) + } + + // 2. Start camera + if err := c.StartCameraPreview(); err != nil { + return err + } + + // 3. Get TURN config + conf := pion.Configuration{} + + if turn, _ := c.GetTurnServer(); turn != nil { + conf.ICEServers = append(conf.ICEServers, *turn) + } + + // 4. Create Peer Connection + api, err := webrtc.NewAPI() + if err != nil { + return err + } + + pc, err := api.NewPeerConnection(conf) + if err != nil { + return err + } + + var connected = make(chan bool) + var sendOffer sync.WaitGroup + + c.conn = webrtc.NewConn(pc) + c.conn.FormatName = "roborock" + c.conn.Mode = core.ModeActiveProducer + c.conn.Protocol = "mqtt" + c.conn.URL = c.url + c.conn.Listen(func(msg any) { + switch msg := msg.(type) { + case *pion.ICECandidate: + if msg != nil && msg.Component == 1 { + sendOffer.Wait() + _ = c.SendICEtoRobot(msg.ToJSON().Candidate, "0") + } + case pion.PeerConnectionState: + if msg == pion.PeerConnectionStateConnecting { + return + } + // unblocking write to channel + select { + case connected <- msg == pion.PeerConnectionStateConnected: + default: + } + } + }) + + // 5. Send Offer + sendOffer.Add(1) + + medias := []*core.Media{ + {Kind: core.KindVideo, Direction: core.DirectionRecvonly}, + {Kind: core.KindAudio, Direction: core.DirectionSendRecv}, + } + + if _, err = c.conn.CreateOffer(medias); err != nil { + return err + } + + offer := pc.LocalDescription() + //log.Printf("[roborock] offer\n%s", offer.SDP) + if err = c.SendSDPtoRobot(offer); err != nil { + return err + } + + sendOffer.Done() + + // 6. Receive answer + ts := time.Now().Add(time.Second * 5) + for { + time.Sleep(time.Second) + + if desc, _ := c.GetDeviceSDP(); desc != nil { + //log.Printf("[roborock] answer\n%s", desc.SDP) + if err = c.conn.SetAnswer(desc.SDP); err != nil { + return err + } + break + } + + if time.Now().After(ts) { + return errors.New("can't get device SDP") + } + } + + ticker := time.NewTicker(time.Second * 2) + for { + select { + case <-ticker.C: + // 7. Receive remote candidates + if pc.ICEConnectionState() == pion.ICEConnectionStateCompleted { + ticker.Stop() + continue + } + + if ice, _ := c.GetDeviceICE(); ice != nil { + for _, candidate := range ice { + _ = c.conn.AddCandidate(candidate) + } + } + + case ok := <-connected: + // 8. Wait connected result (true or false) + if !ok { + return errors.New("can't connect") + } + + return nil + } + } +} + +func (c *Client) CheckHomesecPassword() (err error) { + var ok bool + + params := `{"password":"` + c.pin + `"}` + if err = c.iot.Call("check_homesec_password", params, &ok); err != nil { + return + } + + if !ok { + return errors.New("wrong pin code") + } + + return nil +} + +func (c *Client) GetHomesecConnectStatus() (clientID string, err error) { + var res []byte + + if err = c.iot.Call("get_homesec_connect_status", nil, &res); err != nil { + return + } + + var v struct { + Status int `json:"status"` + ClientID string `json:"client_id"` + } + if err = json.Unmarshal(res, &v); err != nil { + return + } + + return v.ClientID, nil +} + +func (c *Client) StartCameraPreview() error { + params := `{"client_id":"676f32727463","quality":"HD","password":"` + c.pin + `"}` + return c.Request("start_camera_preview", params) +} + +func (c *Client) StopCameraPreview(clientID string) error { + params := `{"client_id":"` + clientID + `"}` + return c.Request("stop_camera_preview", params) +} + +func (c *Client) GetTurnServer() (turn *pion.ICEServer, err error) { + var res []byte + + if err = c.iot.Call("get_turn_server", nil, &res); err != nil { + return + } + + var v struct { + URL string `json:"url"` + User string `json:"user"` + Pwd string `json:"pwd"` + } + if err = json.Unmarshal(res, &v); err != nil { + return nil, err + } + + turn = &pion.ICEServer{ + URLs: []string{v.URL}, + Username: v.User, + Credential: v.Pwd, + } + + return +} + +func (c *Client) SendSDPtoRobot(offer *pion.SessionDescription) (err error) { + b, err := json.Marshal(offer) + if err != nil { + return + } + + params := `{"app_sdp":"` + base64.StdEncoding.EncodeToString(b) + `"}` + return c.iot.Call("send_sdp_to_robot", params, nil) +} + +func (c *Client) SendICEtoRobot(candidate string, mid string) (err error) { + b := []byte(`{"candidate":"` + candidate + `","sdpMLineIndex":` + mid + `,"sdpMid":"` + mid + `"}`) + + params := `{"app_ice":"` + base64.StdEncoding.EncodeToString(b) + `"}` + return c.iot.Call("send_ice_to_robot", params, nil) +} + +func (c *Client) GetDeviceSDP() (sd *pion.SessionDescription, err error) { + var res []byte + + if err = c.iot.Call("get_device_sdp", nil, &res); err != nil { + return + } + + if string(res) == `{"dev_sdp":"retry"}` { + return nil, nil + } + + var v struct { + SDP []byte `json:"dev_sdp"` + } + if err = json.Unmarshal(res, &v); err != nil { + return nil, err + } + + sd = &pion.SessionDescription{} + if err = json.Unmarshal(v.SDP, sd); err != nil { + return nil, err + } + + return +} + +func (c *Client) GetDeviceICE() (ice []string, err error) { + var res []byte + + if err = c.iot.Call("get_device_ice", nil, &res); err != nil { + return + } + + if string(res) == `{"dev_ice":"retry"}` { + return nil, nil + } + + var v struct { + ICE [][]byte `json:"dev_ice"` + } + if err = json.Unmarshal(res, &v); err != nil { + return + } + + for _, b := range v.ICE { + init := pion.ICECandidateInit{} + if err = json.Unmarshal(b, &init); err != nil { + return + } + ice = append(ice, init.Candidate) + } + + return +} + +func (c *Client) StartVoiceChat() error { + // record - audio from robot, play - audio to robot? + params := fmt.Sprintf(`{"record":%t,"play":%t}`, c.audio, c.backchannel) + return c.Request("start_voice_chat", params) +} + +func (c *Client) SwitchVideoQuality(hd bool) error { + if hd { + return c.Request("switch_video_quality", `{"quality":"HD"}`) + } else { + return c.Request("switch_video_quality", `{"quality":"SD"}`) + } +} + +func (c *Client) SetVoiceChatVolume(volume int) error { + params := `{"volume":` + strconv.Itoa(volume) + `}` + return c.Request("set_voice_chat_volume", params) +} + +func (c *Client) EnableHomesecVoice(enable bool) error { + if enable { + return c.Request("enable_homesec_voice", `{"enable":true}`) + } else { + return c.Request("enable_homesec_voice", `{"enable":false}`) + } +} + +func (c *Client) Request(method string, args any) (err error) { + var reply string + + if err = c.iot.Call(method, args, &reply); err != nil { + return + } + + if reply != `["ok"]` { + return errors.New(reply) + } + + return +} diff --git a/installs_on_host/go2rtc/pkg/roborock/iot/client.go b/installs_on_host/go2rtc/pkg/roborock/iot/client.go new file mode 100644 index 0000000..c3b2d97 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/roborock/iot/client.go @@ -0,0 +1,173 @@ +package iot + +import ( + "crypto/md5" + "crypto/tls" + "encoding/hex" + "encoding/json" + "fmt" + "net" + "net/rpc" + "net/url" + "time" + + "github.com/AlexxIT/go2rtc/pkg/mqtt" +) + +type Codec struct { + mqtt *mqtt.Client + + devTopic string + devKey string + + body json.RawMessage +} + +type dps struct { + Dps struct { + Req string `json:"101,omitempty"` + Res string `json:"102,omitempty"` + } `json:"dps"` + T uint32 `json:"t"` +} + +type response struct { + ID uint64 `json:"id"` + Result json.RawMessage `json:"result"` + Error struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` +} + +func (c *Codec) WriteRequest(r *rpc.Request, v any) error { + if v == nil { + v = "[]" + } + + ts := uint32(time.Now().Unix()) + msg := dps{T: ts} + msg.Dps.Req = fmt.Sprintf( + `{"id":%d,"method":"%s","params":%s}`, r.Seq, r.ServiceMethod, v, + ) + + payload, err := json.Marshal(msg) + if err != nil { + return err + } + + //log.Printf("[roborock] send: %s", payload) + + payload = c.Encrypt(payload, ts, ts, ts) + + return c.mqtt.Publish("rr/m/i/"+c.devTopic, payload) +} + +func (c *Codec) ReadResponseHeader(r *rpc.Response) error { + for { + // receive any message from MQTT + _, payload, err := c.mqtt.Read() + if err != nil { + return err + } + + // skip if it is not PUBLISH message + if payload == nil { + continue + } + + // decrypt MQTT PUBLISH payload + if payload, err = c.Decrypt(payload); err != nil { + continue + } + + // skip if we can't decrypt this payload (ex. binary payload) + if payload == nil { + continue + } + + //log.Printf("[roborock] recv %s", payload) + + // get content from response payload: + // {"t":1676871268,"dps":{"102":"{\"id\":315003,\"result\":[\"ok\"]}"}} + var msg dps + if err = json.Unmarshal(payload, &msg); err != nil { + continue + } + + var res response + if err = json.Unmarshal([]byte(msg.Dps.Res), &res); err != nil { + continue + } + + r.Seq = res.ID + if res.Error.Code != 0 { + r.Error = res.Error.Message + } else { + c.body = res.Result + } + + return nil + } +} + +func (c *Codec) ReadResponseBody(v any) error { + switch vv := v.(type) { + case *[]byte: + *vv = c.body + case *string: + *vv = string(c.body) + case *bool: + *vv = string(c.body) == `["ok"]` + } + return nil +} + +func (c *Codec) Close() error { + return c.mqtt.Close() +} + +func Dial(rawURL string) (*rpc.Client, error) { + link, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + // dial to MQTT + conn, err := net.DialTimeout("tcp", link.Host, time.Second*5) + if err != nil { + return nil, err + } + + // process MQTT SSL + conf := &tls.Config{ServerName: link.Hostname()} + sconn := tls.Client(conn, conf) + if err = sconn.Handshake(); err != nil { + return nil, err + } + + query := link.Query() + + // send MQTT login + uk := md5.Sum([]byte(query.Get("u") + ":" + query.Get("k"))) + sk := md5.Sum([]byte(query.Get("s") + ":" + query.Get("k"))) + user := hex.EncodeToString(uk[1:5]) + pass := hex.EncodeToString(sk[8:]) + + c := &Codec{ + mqtt: mqtt.NewClient(sconn), + devKey: query.Get("key"), + devTopic: query.Get("u") + "/" + user + "/" + query.Get("did"), + } + + if err = c.mqtt.Connect("com.roborock.smart:mbrriot", user, pass); err != nil { + return nil, err + } + + // subscribe on device topic + if err = c.mqtt.Subscribe("rr/m/o/" + c.devTopic); err != nil { + return nil, err + } + + return rpc.NewClientWithCodec(c), nil +} diff --git a/installs_on_host/go2rtc/pkg/roborock/iot/crypto.go b/installs_on_host/go2rtc/pkg/roborock/iot/crypto.go new file mode 100644 index 0000000..7a4c641 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/roborock/iot/crypto.go @@ -0,0 +1,115 @@ +package iot + +import ( + "crypto/aes" + "crypto/md5" + "encoding/binary" + "errors" + "hash/crc32" +) + +// key - convert timestamp to key +func (c *Codec) key(timestamp uint32) []byte { + const salt = "TXdfu$jyZ#TZHsg4" + key := md5.Sum([]byte(encodeTimestamp(timestamp) + c.devKey + salt)) + return key[:] +} + +func (c *Codec) Decrypt(cipherText []byte) ([]byte, error) { + if len(cipherText) < 32 || string(cipherText[:3]) != "1.0" { + return nil, errors.New("wrong message prefix") + } + + i := len(cipherText) - 4 + if binary.BigEndian.Uint32(cipherText[i:]) != crc32.ChecksumIEEE(cipherText[:i]) { + return nil, errors.New("wrong message checksum") + } + + if proto := binary.BigEndian.Uint16(cipherText[15:]); proto != 102 { + return nil, nil + } + + timestamp := binary.BigEndian.Uint32(cipherText[11:]) + return decryptECB(cipherText[19:i], c.key(timestamp)), nil +} + +func (c *Codec) Encrypt(plainText []byte, seq, random, timestamp uint32) []byte { + const proto = 101 + + cipherText := encryptECB(plainText, c.key(timestamp)) + + size := uint16(len(cipherText)) + + msg := make([]byte, 23+size) + copy(msg, "1.0") + binary.BigEndian.PutUint32(msg[3:], seq) + binary.BigEndian.PutUint32(msg[7:], random) + binary.BigEndian.PutUint32(msg[11:], timestamp) + binary.BigEndian.PutUint16(msg[15:], proto) + binary.BigEndian.PutUint16(msg[17:], size) + copy(msg[19:], cipherText) + + crc := crc32.ChecksumIEEE(msg[:19+size]) + + binary.BigEndian.PutUint32(msg[19+size:], crc) + return msg +} + +func encodeTimestamp(i uint32) string { + const hextable = "0123456789abcdef" + b := []byte{ + hextable[i>>8&0xF], hextable[i>>4&0xF], + hextable[i>>16&0xF], hextable[i&0xF], + hextable[i>>24&0xF], hextable[i>>20&0xF], + hextable[i>>28&0xF], hextable[i>>12&0xF], + } + return string(b) +} + +func pad(plainText []byte, blockSize int) []byte { + b0 := byte(blockSize - len(plainText)%blockSize) + for i := byte(0); i < b0; i++ { + plainText = append(plainText, b0) + } + return plainText +} + +func unpad(paddedText []byte) []byte { + padSize := int(paddedText[len(paddedText)-1]) + return paddedText[:len(paddedText)-padSize] +} + +func encryptECB(plainText, key []byte) []byte { + block, err := aes.NewCipher(key) + if err != nil { + panic(err) + } + + blockSize := block.BlockSize() + plainText = pad(plainText, blockSize) + cipherText := plainText + + for len(plainText) > 0 { + block.Encrypt(plainText, plainText) + plainText = plainText[blockSize:] + } + + return cipherText +} + +func decryptECB(cipherText, key []byte) []byte { + block, err := aes.NewCipher(key) + if err != nil { + panic(err) + } + + blockSize := block.BlockSize() + paddedText := cipherText + + for len(cipherText) > 0 { + block.Decrypt(cipherText, cipherText) + cipherText = cipherText[blockSize:] + } + + return unpad(paddedText) +} diff --git a/installs_on_host/go2rtc/pkg/roborock/producer.go b/installs_on_host/go2rtc/pkg/roborock/producer.go new file mode 100644 index 0000000..c0dbe87 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/roborock/producer.go @@ -0,0 +1,46 @@ +package roborock + +import ( + "github.com/AlexxIT/go2rtc/pkg/core" +) + +func (c *Client) GetMedias() []*core.Media { + return c.conn.GetMedias() +} + +func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + if media.Kind == core.KindAudio { + c.audio = true + } + + return c.conn.GetTrack(media, codec) +} + +func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + c.backchannel = true + return c.conn.AddTrack(media, codec, track) +} + +func (c *Client) Start() error { + if c.audio || c.backchannel { + if err := c.StartVoiceChat(); err != nil { + return err + } + + if c.backchannel { + if err := c.SetVoiceChatVolume(80); err != nil { + return err + } + } + } + return c.conn.Start() +} + +func (c *Client) Stop() error { + _ = c.iot.Close() + return c.conn.Stop() +} + +func (c *Client) MarshalJSON() ([]byte, error) { + return c.conn.MarshalJSON() +} diff --git a/installs_on_host/go2rtc/pkg/rtmp/README.md b/installs_on_host/go2rtc/pkg/rtmp/README.md new file mode 100644 index 0000000..f6e6632 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/rtmp/README.md @@ -0,0 +1,27 @@ +## Tests + +- go2rtc rtmp client => Reolink +- go2rtc rtmp server <= Dahua +- go2rtc rtmp publish => YouTube +- go2rtc rtmp publish => Telegram + +## Logs + +``` +request []interface {}{"connect", 1, map[string]interface {}{"app":"s", "flashVer":"FMLE/3.0 (compatible; FMSc/1.0)", "tcUrl":"rtmps://xxx.rtmp.t.me/s/xxxxx"}} +response []interface {}{"_result", 1, map[string]interface {}{"capabilities":31, "fmsVer":"FMS/3,0,1,123"}, map[string]interface {}{"code":"NetConnection.Connect.Success", "description":"Connection succeeded.", "level":"status", "objectEncoding":0}} +request []interface {}{"releaseStream", 2, interface {}(nil), "xxxxx"} +request []interface {}{"FCPublish", 3, interface {}(nil), "xxxxx"} +request []interface {}{"createStream", 4, interface {}(nil)} +response []interface {}{"_result", 2, interface {}(nil)} +response []interface {}{"_result", 4, interface {}(nil), 1} +request []interface {}{"publish", 5, interface {}(nil), "xxxxx", "live"} +response []interface {}{"onStatus", 0, interface {}(nil), map[string]interface {}{"code":"NetStream.Publish.Start", "description":"xxxxx is now published", "detail":"xxxxx", "level":"status"}} +``` + +## Useful links + +- https://en.wikipedia.org/wiki/Flash_Video +- https://en.wikipedia.org/wiki/Real-Time_Messaging_Protocol +- https://rtmp.veriskope.com/pdf/rtmp_specification_1.0.pdf +- https://rtmp.veriskope.com/docs/spec/ diff --git a/installs_on_host/go2rtc/pkg/rtmp/client.go b/installs_on_host/go2rtc/pkg/rtmp/client.go new file mode 100644 index 0000000..c9e9ad1 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/rtmp/client.go @@ -0,0 +1,161 @@ +package rtmp + +import ( + "bufio" + "io" + "net" + "net/url" + "strings" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/flv" + "github.com/AlexxIT/go2rtc/pkg/tcp" +) + +func DialPlay(rawURL string) (*flv.Producer, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + conn, err := tcp.Dial(u, core.ConnDialTimeout) + if err != nil { + return nil, err + } + + client, err := NewClient(conn, u) + if err != nil { + return nil, err + } + + if err = client.play(); err != nil { + return nil, err + } + + return client.Producer() +} + +func DialPublish(rawURL string, cons *flv.Consumer) (io.Writer, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + conn, err := tcp.Dial(u, core.ConnDialTimeout) + if err != nil { + return nil, err + } + + client, err := NewClient(conn, u) + if err != nil { + return nil, err + } + + if err = client.publish(); err != nil { + return nil, err + } + + cons.FormatName = "rtmp" + cons.Protocol = "rtmp" + cons.RemoteAddr = conn.RemoteAddr().String() + cons.URL = rawURL + + return client, nil +} + +func NewClient(conn net.Conn, u *url.URL) (*Conn, error) { + c := &Conn{ + url: u.String(), + + conn: conn, + rd: bufio.NewReaderSize(conn, core.BufferSize), + wr: conn, + + chunks: map[uint8]*chunk{}, + + rdPacketSize: 128, + wrPacketSize: 4096, // OBS - 4096, Reolink - 4096 + } + + if args := strings.Split(u.Path, "/"); len(args) >= 2 { + c.App = args[1] + if len(args) >= 3 { + c.Stream = args[2] + if u.RawQuery != "" { + c.Stream += "?" + u.RawQuery + } + } + } + + if err := c.clienHandshake(); err != nil { + return nil, err + } + if err := c.writePacketSize(); err != nil { + return nil, err + } + + return c, nil +} + +func (c *Conn) clienHandshake() error { + // simple handshake without real random and check response + b := make([]byte, 1+1536) + b[0] = 0x03 + // write C0+C1 + if _, err := c.conn.Write(b); err != nil { + return err + } + // read S0+S1 + if _, err := io.ReadFull(c.rd, b); err != nil { + return err + } + // write S1 + if _, err := c.conn.Write(b[1:]); err != nil { + return err + } + // read C1, skip check + if _, err := io.ReadFull(c.rd, b[1:]); err != nil { + return err + } + return nil +} + +func (c *Conn) play() error { + if err := c.writeConnect(); err != nil { + return err + } + if err := c.writeCreateStream(); err != nil { + return err + } + if err := c.writePlay(); err != nil { + return err + } + return nil +} + +func (c *Conn) publish() error { + if err := c.writeConnect(); err != nil { + return err + } + if err := c.writeReleaseStream(); err != nil { + return err + } + if err := c.writeCreateStream(); err != nil { + return err + } + if err := c.writePublish(); err != nil { + return err + } + + go func() { + for { + _, _, _, err := c.readMessage() + //log.Printf("!!! %d %d %.30x", msgType, timeMS, b) + if err != nil { + return + } + } + }() + + return nil +} diff --git a/installs_on_host/go2rtc/pkg/rtmp/conn.go b/installs_on_host/go2rtc/pkg/rtmp/conn.go new file mode 100644 index 0000000..70e2aec --- /dev/null +++ b/installs_on_host/go2rtc/pkg/rtmp/conn.go @@ -0,0 +1,376 @@ +package rtmp + +import ( + "encoding/binary" + "fmt" + "io" + "net" + "strings" + "sync" + + "github.com/AlexxIT/go2rtc/pkg/flv/amf" +) + +const ( + TypeSetPacketSize = 1 + TypeServerBandwidth = 5 + TypeClientBandwidth = 6 + TypeAudio = 8 + TypeVideo = 9 + TypeData = 18 + TypeCommand = 20 +) + +type Conn struct { + App string + Stream string + Intent string + + rdPacketSize uint32 + wrPacketSize uint32 + + chunks map[byte]*chunk + streamID byte + url string + + conn net.Conn + rd io.Reader + wr io.Writer + + rdBuf []byte + wrBuf []byte + mu sync.Mutex +} + +func (c *Conn) Close() error { + return c.conn.Close() +} + +func (c *Conn) readResponse(wait func(items []any) bool) ([]any, error) { + for { + msgType, _, b, err := c.readMessage() + if err != nil { + return nil, err + } + //log.Printf("[rtmp] type=%d data=%s", msgType, b) + + switch msgType { + case TypeSetPacketSize: + c.rdPacketSize = binary.BigEndian.Uint32(b) + case TypeCommand: + items, _ := amf.NewReader(b).ReadItems() + if wait(items) { + return items, nil + } + } + } +} + +type chunk struct { + conn *Conn + rawTime uint32 + dataSize uint32 + tagType byte + streamID uint32 + timeMS uint32 +} + +func (c *chunk) readHeader(typ byte) error { + switch typ { + case 0: // 12 byte header (full header) + b, err := c.conn.readSize(11) + if err != nil { + return err + } + c.rawTime = Uint24(b) + c.dataSize = Uint24(b[3:]) + c.tagType = b[6] + c.streamID = binary.LittleEndian.Uint32(b[7:]) + c.timeMS = c.readExtendedTime() + + case 1: // 8 bytes - like type b00, not including message ID (4 last bytes) + b, err := c.conn.readSize(7) + if err != nil { + return err + } + c.rawTime = Uint24(b) + c.dataSize = Uint24(b[3:]) // msgdatalen + c.tagType = b[6] // msgtypeid + c.timeMS += c.readExtendedTime() + + case 2: // 4 bytes - Basic Header and timestamp (3 bytes) are included + b, err := c.conn.readSize(3) + if err != nil { + return err + } + c.rawTime = Uint24(b) // timestamp + c.timeMS += c.readExtendedTime() + + case 3: // 1 byte - only the Basic Header is included + // use here hdr from previous msg with same session ID (sid) + } + return nil +} + +func (c *chunk) readExtendedTime() uint32 { + if c.rawTime == 0xFFFFFF { + if b, err := c.conn.readSize(4); err == nil { + return binary.BigEndian.Uint32(b) + } + } + return c.rawTime +} + +//var ErrNotImplemented = errors.New("rtmp: not implemented") + +func (c *Conn) readMessage() (byte, uint32, []byte, error) { + b, err := c.readSize(1) // doesn't support big chunkID!!! + if err != nil { + return 0, 0, nil, err + } + + hdrType := b[0] >> 6 + chunkID := b[0] & 0b111111 + + // storing header information for support header type 3 + ch, ok := c.chunks[chunkID] + if !ok { + ch = &chunk{conn: c} + c.chunks[chunkID] = ch + } + + if err = ch.readHeader(hdrType); err != nil { + return 0, 0, nil, err + } + + //log.Printf("[rtmp] hdr=%d chunkID=%d timeMS=%d size=%d tagType=%d streamID=%d", hdrType, chunkID, ch.timeMS, ch.dataSize, ch.tagType, ch.streamID) + + // 1. Response zero size + if ch.dataSize == 0 { + return ch.tagType, ch.timeMS, nil, nil + } + + data := make([]byte, ch.dataSize) + + // 2. Response small packet + if ch.dataSize <= c.rdPacketSize { + if _, err = io.ReadFull(c.rd, data); err != nil { + return 0, 0, nil, err + } + return ch.tagType, ch.timeMS, data, nil + } + + // 3. Response big packet + var i0 uint32 + for i1 := c.rdPacketSize; i1 < ch.dataSize; i1 += c.rdPacketSize { + if _, err = io.ReadFull(c.rd, data[i0:i1]); err != nil { + return 0, 0, nil, err + } + + // hopefully this will be hdrType=3 with same chunkID + if _, err = c.readSize(1); err != nil { + return 0, 0, nil, err + } + + _ = ch.readExtendedTime() + + i0 = i1 + } + + if _, err = io.ReadFull(c.rd, data[i0:]); err != nil { + return 0, 0, nil, err + } + + return ch.tagType, ch.timeMS, data, nil +} + +func (c *Conn) writeMessage(chunkID, tagType byte, timeMS uint32, payload []byte) error { + c.mu.Lock() + c.resetBuffer() + + b := payload + size := uint32(len(b)) + + if size > c.wrPacketSize { + c.appendType0(chunkID, tagType, timeMS, size, b[:c.wrPacketSize]) + + for { + b = b[c.wrPacketSize:] + if uint32(len(b)) > c.wrPacketSize { + c.appendType3(chunkID, b[:c.wrPacketSize]) + } else { + c.appendType3(chunkID, b) + break + } + } + } else { + c.appendType0(chunkID, tagType, timeMS, size, b) + } + + //log.Printf("%d %2d %5d %6d %.32x", chunkID, tagType, timeMS, size, payload) + + _, err := c.wr.Write(c.wrBuf) + c.mu.Unlock() + return err +} + +func (c *Conn) resetBuffer() { + c.wrBuf = c.wrBuf[:0] +} + +func (c *Conn) appendType0(chunkID, tagType byte, timeMS, size uint32, payload []byte) { + // TODO: timeMS more than 24 bit + c.wrBuf = append(c.wrBuf, + chunkID, + byte(timeMS>>16), byte(timeMS>>8), byte(timeMS), + byte(size>>16), byte(size>>8), byte(size), + tagType, + c.streamID, 0, 0, 0, // little endian streamID + ) + c.wrBuf = append(c.wrBuf, payload...) +} + +func (c *Conn) appendType3(chunkID byte, payload []byte) { + c.wrBuf = append(c.wrBuf, 3<<6|chunkID) + c.wrBuf = append(c.wrBuf, payload...) +} + +func (c *Conn) writePacketSize() error { + b := binary.BigEndian.AppendUint32(nil, c.wrPacketSize) + return c.writeMessage(2, TypeSetPacketSize, 0, b) +} + +func (c *Conn) writeConnect() error { + b := amf.EncodeItems("connect", 1, map[string]any{ + "app": c.App, + "flashVer": "FMLE/3.0 (compatible; FMSc/1.0)", + "tcUrl": c.url, + }) + if err := c.writeMessage(3, TypeCommand, 0, b); err != nil { + return err + } + + v, err := c.readResponse(func(items []any) bool { + return len(items) >= 3 && items[0] == "_result" && items[1] == float64(1) + }) + if err != nil { + return err + } + + code := getString(v, 3, "code") + if code != "NetConnection.Connect.Success" { + return fmt.Errorf("rtmp: wrong response %#v", v) + } + + return nil +} + +func (c *Conn) writeReleaseStream() error { + b := amf.EncodeItems("releaseStream", 2, nil, c.Stream) + if err := c.writeMessage(3, TypeCommand, 0, b); err != nil { + return err + } + b = amf.EncodeItems("FCPublish", 3, nil, c.Stream) + if err := c.writeMessage(3, TypeCommand, 0, b); err != nil { + return err + } + return nil +} +func (c *Conn) writeCreateStream() error { + b := amf.EncodeItems("createStream", 4, nil) + if err := c.writeMessage(3, TypeCommand, 0, b); err != nil { + return err + } + + v, err := c.readResponse(func(items []any) bool { + return len(items) >= 3 && items[0] == "_result" && items[1] == float64(4) + }) + if err != nil { + return err + } + + if len(v) == 4 { + if f, ok := v[3].(float64); ok { + c.streamID = byte(f) + return nil + } + } + + return fmt.Errorf("rtmp: wrong response %#v", v) +} + +func (c *Conn) writePublish() error { + b := amf.EncodeItems("publish", 5, nil, c.Stream, "live") + if err := c.writeMessage(3, TypeCommand, 0, b); err != nil { + return err + } + + // YouTube can response with "onBWDone 0" + v, err := c.readResponse(func(items []any) bool { + return len(items) >= 3 && items[0] == "onStatus" + }) + if err != nil { + return nil + } + + code := getString(v, 3, "code") + if code != "NetStream.Publish.Start" { + return fmt.Errorf("rtmp: wrong response %#v", v) + } + + return nil +} + +func (c *Conn) writePlay() error { + b := amf.EncodeItems("play", 5, nil, c.Stream) + if err := c.writeMessage(3, TypeCommand, 0, b); err != nil { + return err + } + + // Reolink response with ID=0, other software respose with ID=5 + v, err := c.readResponse(func(items []any) bool { + return len(items) >= 3 && items[0] == "onStatus" + }) + if err != nil { + return nil + } + + code := getString(v, 3, "code") + if !strings.HasPrefix(code, "NetStream.Play.") { + return fmt.Errorf("rtmp: wrong response %#v", v) + } + + return nil +} + +func (c *Conn) readSize(n uint32) ([]byte, error) { + b := make([]byte, n) + if _, err := io.ReadFull(c.rd, b); err != nil { + return nil, err + } + return b, nil +} + +func PutUint24(b []byte, v uint32) { + _ = b[2] + b[0] = byte(v >> 16) + b[1] = byte(v >> 8) + b[2] = byte(v) +} + +func Uint24(b []byte) uint32 { + _ = b[2] + return uint32(b[0])<<16 | uint32(b[1])<<8 | uint32(b[2]) +} + +func getString(v []any, i int, key string) string { + if len(v) <= i { + return "" + } + if v, ok := v[i].(map[string]any); ok { + if s, ok := v[key].(string); ok { + return s + } + } + return "" +} diff --git a/installs_on_host/go2rtc/pkg/rtmp/flv.go b/installs_on_host/go2rtc/pkg/rtmp/flv.go new file mode 100644 index 0000000..350f4c3 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/rtmp/flv.go @@ -0,0 +1,96 @@ +package rtmp + +import ( + "github.com/AlexxIT/go2rtc/pkg/flv" +) + +func (c *Conn) Producer() (*flv.Producer, error) { + c.rdBuf = []byte{ + 'F', 'L', 'V', // signature + 1, // version + 0, // flags (has video/audio) + 0, 0, 0, 9, // header size + } + + prod, err := flv.Open(c) + if err != nil { + return nil, err + } + + prod.FormatName = "rtmp" + prod.Protocol = "rtmp" + prod.RemoteAddr = c.conn.RemoteAddr().String() + prod.URL = c.url + + return prod, nil +} + +// Read - convert RTMP to FLV format +func (c *Conn) Read(p []byte) (n int, err error) { + // 1. Check temporary tempbuffer + if len(c.rdBuf) == 0 { + msgType, timeMS, payload, err2 := c.readMessage() + if err2 != nil { + return 0, err2 + } + + // previous tag size (4 byte) + header (11 byte) + payload + n = 4 + 11 + len(payload) + + // 2. Check if the message fits in the buffer + if n <= len(p) { + encodeFLV(p, msgType, timeMS, payload) + return + } + + // 3. Put the message into a temporary buffer + c.rdBuf = make([]byte, n) + encodeFLV(c.rdBuf, msgType, timeMS, payload) + } + + // 4. Send temporary buffer + n = copy(p, c.rdBuf) + c.rdBuf = c.rdBuf[n:] + return +} + +func encodeFLV(b []byte, msgType byte, time uint32, payload []byte) { + _ = b[4+11] + + b[0] = 0 + b[1] = 0 + b[2] = 0 + b[3] = 0 + b[4+0] = msgType + PutUint24(b[4+1:], uint32(len(payload))) + PutUint24(b[4+4:], time) + b[4+7] = byte(time >> 24) + + copy(b[4+11:], payload) +} + +// Write - convert FLV format to RTMP format +func (c *Conn) Write(p []byte) (n int, err error) { + n = len(p) + + if p[0] == 'F' { + p = p[9+4:] // skip first msg with FLV header + + for len(p) > 0 { + size := 11 + uint16(p[2])<<8 + uint16(p[3]) + 4 + if _, err = c.Write(p[:size]); err != nil { + return 0, err + } + p = p[size:] + } + return + } + + // decode FLV: 11 bytes header + payload + 4 byte size + tagType := p[0] + timeMS := uint32(p[4])<<16 | uint32(p[5])<<8 | uint32(p[6]) | uint32(p[7])<<24 + payload := p[11 : len(p)-4] + + err = c.writeMessage(4, tagType, timeMS, payload) + return +} diff --git a/installs_on_host/go2rtc/pkg/rtmp/server.go b/installs_on_host/go2rtc/pkg/rtmp/server.go new file mode 100644 index 0000000..014b285 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/rtmp/server.go @@ -0,0 +1,201 @@ +package rtmp + +import ( + "bufio" + "crypto/rand" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/flv/amf" +) + +func NewServer(conn net.Conn) (*Conn, error) { + c := &Conn{ + conn: conn, + rd: bufio.NewReaderSize(conn, core.BufferSize), + wr: conn, + + chunks: map[uint8]*chunk{}, + + rdPacketSize: 128, + wrPacketSize: 4096, + } + + if err := c.serverHandshake(); err != nil { + return nil, err + } + if err := c.writePacketSize(); err != nil { + return nil, err + } + + return c, nil +} + +func (c *Conn) serverHandshake() error { + // based on https://rtmp.veriskope.com/docs/spec/ + _ = c.conn.SetDeadline(time.Now().Add(core.ConnDeadline)) + + // read C0 + b := make([]byte, 1) + if _, err := io.ReadFull(c.rd, b); err != nil { + return err + } + + if b[0] != 3 { + return errors.New("rtmp: wrong handshake") + } + + // write S0 + if _, err := c.conn.Write([]byte{3}); err != nil { + return err + } + + b = make([]byte, 1536) + + // write S1 + tsS1 := nowMS() + binary.BigEndian.PutUint32(b, tsS1) + binary.BigEndian.PutUint32(b[4:], 0) + _, _ = rand.Read(b[8:]) + if _, err := c.conn.Write(b); err != nil { + return err + } + + // read C1 + if _, err := io.ReadFull(c.rd, b); err != nil { + return err + } + + // write S2 + tsS2 := nowMS() + binary.BigEndian.PutUint32(b, tsS1) + binary.BigEndian.PutUint32(b[4:], tsS2) + if _, err := c.conn.Write(b); err != nil { + return err + } + + // read C2 + if _, err := io.ReadFull(c.rd, b); err != nil { + return err + } + + _ = c.conn.SetDeadline(time.Time{}) + return nil +} + +func (c *Conn) ReadCommands() error { + for { + msgType, _, b, err := c.readMessage() + if err != nil { + return err + } + + //log.Printf("%d %.256x", msgType, b) + + switch msgType { + case TypeSetPacketSize: + c.rdPacketSize = binary.BigEndian.Uint32(b) + case TypeCommand: + if err = c.acceptCommand(b); err != nil { + return err + } + + if c.Intent != "" { + return nil + } + } + } +} + +const ( + CommandConnect = "connect" + CommandReleaseStream = "releaseStream" + CommandFCPublish = "FCPublish" + CommandCreateStream = "createStream" + CommandPublish = "publish" + CommandPlay = "play" +) + +func (c *Conn) acceptCommand(b []byte) error { + items, err := amf.NewReader(b).ReadItems() + if err != nil { + return nil + } + + //log.Printf("%#v", items) + + if len(items) < 2 { + return fmt.Errorf("rtmp: read command %x", b) + } + + cmd, ok := items[0].(string) + if !ok { + return fmt.Errorf("rtmp: read command %x", b) + } + + tID, ok := items[1].(float64) // transaction ID + if !ok { + return fmt.Errorf("rtmp: read command %x", b) + } + + switch cmd { + case CommandConnect: + if len(items) == 3 { + if v, ok := items[2].(map[string]any); ok { + c.App, _ = v["app"].(string) + } + } + + payload := amf.EncodeItems( + "_result", tID, + map[string]any{"fmsVer": "FMS/3,0,1,123"}, + map[string]any{"code": "NetConnection.Connect.Success"}, + ) + return c.writeMessage(3, TypeCommand, 0, payload) + + case CommandReleaseStream: + // if app is empty - will use key as app + if c.App == "" && len(items) == 4 { + c.App, _ = items[3].(string) + } + + payload := amf.EncodeItems("_result", tID, nil) + return c.writeMessage(3, TypeCommand, 0, payload) + + case CommandFCPublish: // no response + + case CommandCreateStream: + payload := amf.EncodeItems("_result", tID, nil, 1) + return c.writeMessage(3, TypeCommand, 0, payload) + + case CommandPublish, CommandPlay: // response later + c.Intent = cmd + c.streamID = 1 + + default: + println("rtmp: unknown command: " + cmd) + } + + return nil +} + +func (c *Conn) WriteStart() error { + var code string + if c.Intent == CommandPublish { + code = "NetStream.Publish.Start" + } else { + code = "NetStream.Play.Start" + } + + payload := amf.EncodeItems("onStatus", 0, nil, map[string]any{"code": code}) + return c.writeMessage(3, TypeCommand, 0, payload) +} + +func nowMS() uint32 { + return uint32(time.Now().UnixNano() / int64(time.Millisecond)) +} diff --git a/installs_on_host/go2rtc/pkg/rtsp/README.md b/installs_on_host/go2rtc/pkg/rtsp/README.md new file mode 100644 index 0000000..ddaafdb --- /dev/null +++ b/installs_on_host/go2rtc/pkg/rtsp/README.md @@ -0,0 +1,3 @@ +## Useful links + +- https://www.kurento.org/blog/rtp-i-intro-rtp-and-sdp \ No newline at end of file diff --git a/installs_on_host/go2rtc/pkg/rtsp/client.go b/installs_on_host/go2rtc/pkg/rtsp/client.go new file mode 100644 index 0000000..c960732 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/rtsp/client.go @@ -0,0 +1,456 @@ +package rtsp + +import ( + "bufio" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "github.com/AlexxIT/go2rtc/pkg/tcp/websocket" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/tcp" +) + +var Timeout = time.Second * 5 + +func NewClient(uri string) *Conn { + return &Conn{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "rtsp", + }, + uri: uri, + } +} + +func (c *Conn) Dial() (err error) { + if c.URL, err = url.Parse(c.uri); err != nil { + return + } + + var conn net.Conn + + switch c.Transport { + case "", "tcp", "udp": + var timeout time.Duration + if c.Timeout != 0 { + timeout = time.Second * time.Duration(c.Timeout) + } else { + timeout = core.ConnDialTimeout + } + conn, err = tcp.Dial(c.URL, timeout) + + if c.Transport != "udp" { + c.Protocol = "rtsp+tcp" + } else { + c.Protocol = "rtsp+udp" + } + default: + conn, err = websocket.Dial(c.Transport) + c.Protocol = "ws" + } + if err != nil { + return + } + + // remove UserInfo from URL + c.auth = tcp.NewAuth(c.URL.User) + c.URL.User = nil + + c.conn = conn + c.reader = bufio.NewReaderSize(conn, core.BufferSize) + c.session = "" + c.sequence = 0 + c.state = StateConn + + c.udpConn = nil + c.udpAddr = nil + + c.Connection.RemoteAddr = conn.RemoteAddr().String() + c.Connection.Transport = conn + c.Connection.URL = c.uri + + return nil +} + +// Do send WriteRequest and receive and process WriteResponse +func (c *Conn) Do(req *tcp.Request) (*tcp.Response, error) { + if err := c.WriteRequest(req); err != nil { + return nil, err + } + + res, err := c.ReadResponse() + if err != nil { + return nil, err + } + + c.Fire(res) + + switch res.StatusCode { + case http.StatusOK: + return res, nil + + case http.StatusMovedPermanently, http.StatusFound: + rawURL := res.Header.Get("Location") + + var u *url.URL + if u, err = url.Parse(rawURL); err != nil { + return nil, err + } + + if u.User == nil { + u.User = c.auth.UserInfo() // restore auth if we don't have it in the new URL + } + + c.uri = u.String() // so auth will be saved on reconnect + + _ = c.conn.Close() + + if err = c.Dial(); err != nil { + return nil, err + } + + req.URL = c.URL // because path was changed + + return c.Do(req) + + case http.StatusUnauthorized: + switch c.auth.Method { + case tcp.AuthNone: + if c.auth.ReadNone(res) { + return c.Do(req) + } + return nil, errors.New("user/pass not provided") + case tcp.AuthUnknown: + if c.auth.Read(res) { + return c.Do(req) + } + default: + return nil, errors.New("wrong user/pass") + } + } + + return res, fmt.Errorf("wrong response on %s", req.Method) +} + +func (c *Conn) Options() error { + req := &tcp.Request{Method: MethodOptions, URL: c.URL} + + res, err := c.Do(req) + if err != nil { + return err + } + + if val := res.Header.Get("Content-Base"); val != "" { + c.URL, err = urlParse(val) + if err != nil { + return err + } + } + + return nil +} + +func (c *Conn) Describe() error { + // 5.3 Back channel connection + // https://www.onvif.org/specs/stream/ONVIF-Streaming-Spec.pdf + req := &tcp.Request{ + Method: MethodDescribe, + URL: c.URL, + Header: map[string][]string{ + "Accept": {"application/sdp"}, + }, + } + + if c.Backchannel { + req.Header.Set("Require", "www.onvif.org/ver20/backchannel") + } + + if c.UserAgent != "" { + // this camera will answer with 401 on DESCRIBE without User-Agent + // https://github.com/AlexxIT/go2rtc/issues/235 + req.Header.Set("User-Agent", c.UserAgent) + } + + res, err := c.Do(req) + if err != nil { + return err + } + + if val := res.Header.Get("Content-Base"); val != "" { + c.URL, err = urlParse(val) + if err != nil { + return err + } + } + + c.SDP = string(res.Body) // for info + + medias, err := UnmarshalSDP(res.Body) + if err != nil { + return err + } + + if c.Media != "" { + clone := make([]*core.Media, 0, len(medias)) + for _, media := range medias { + if strings.Contains(c.Media, media.Kind) { + clone = append(clone, media) + } + } + medias = clone + } + + // TODO: rewrite more smart + if c.Medias == nil { + c.Medias = medias + } else if len(c.Medias) > len(medias) { + c.Medias = c.Medias[:len(medias)] + } + + c.mode = core.ModeActiveProducer + + return nil +} + +func (c *Conn) Announce() (err error) { + req := &tcp.Request{ + Method: MethodAnnounce, + URL: c.URL, + Header: map[string][]string{ + "Content-Type": {"application/sdp"}, + }, + } + + req.Body, err = core.MarshalSDP(c.SessionName, c.Medias) + if err != nil { + return err + } + + _, err = c.Do(req) + return +} + +func (c *Conn) Record() (err error) { + req := &tcp.Request{ + Method: MethodRecord, + URL: c.URL, + Header: map[string][]string{ + "Range": {"npt=0.000-"}, + }, + } + + _, err = c.Do(req) + return +} + +func (c *Conn) SetupMedia(media *core.Media) (byte, error) { + var transport string + + if c.Transport == "udp" { + conn1, conn2, err := ListenUDPPair() + if err != nil { + return 0, err + } + + c.udpConn = append(c.udpConn, conn1, conn2) + + port := conn1.LocalAddr().(*net.UDPAddr).Port + transport = fmt.Sprintf("RTP/AVP;unicast;client_port=%d-%d", port, port+1) + } else { + // try to use media position as channel number + for i, m := range c.Medias { + if m.Equal(media) { + transport = fmt.Sprintf( + // i - RTP (data channel) + // i+1 - RTCP (control channel) + "RTP/AVP/TCP;unicast;interleaved=%d-%d", i*2, i*2+1, + ) + break + } + } + } + + if transport == "" { + return 0, fmt.Errorf("wrong media: %v", media) + } + + rawURL := media.ID // control + if !strings.Contains(rawURL, "://") { + rawURL = c.URL.String() + // prefix check for https://github.com/AlexxIT/go2rtc/issues/1236 + if !strings.HasSuffix(rawURL, "/") && !strings.HasPrefix(media.ID, "/") { + rawURL += "/" + } + rawURL += media.ID + } + trackURL, err := urlParse(rawURL) + if err != nil { + return 0, err + } + + req := &tcp.Request{ + Method: MethodSetup, + URL: trackURL, + Header: map[string][]string{ + "Transport": {transport}, + }, + } + + res, err := c.Do(req) + if err != nil { + // some Dahua/Amcrest cameras fail here because two simultaneous + // backchannel connections + if c.Backchannel { + c.Backchannel = false + if err = c.Reconnect(); err != nil { + return 0, err + } + return c.SetupMedia(media) + } + + return 0, err + } + + if c.session == "" { + // Session: 7116520596809429228 + // Session: 216525287999;timeout=60 + if s := res.Header.Get("Session"); s != "" { + if i := strings.IndexByte(s, ';'); i > 0 { + c.session = s[:i] + if i = strings.Index(s, "timeout="); i > 0 { + c.keepalive, _ = strconv.Atoi(s[i+8:]) + } + } else { + c.session = s + } + } + } + + // Parse server response + transport = res.Header.Get("Transport") + + if c.Transport == "udp" { + channel := byte(len(c.udpConn) - 2) + + // Dahua: RTP/AVP/UDP;unicast;client_port=49292-49293;server_port=43670-43671;ssrc=7CB694B4 + // OpenIPC: RTP/AVP/UDP;unicast;client_port=59612-59613 + if s := core.Between(transport, "server_port=", ";"); s != "" { + s1, s2, _ := strings.Cut(s, "-") + port1 := core.Atoi(s1) + port2 := core.Atoi(s2) + // TODO: more smart handling empty server ports + if port1 > 0 && port2 > 0 { + remoteIP := c.conn.RemoteAddr().(*net.TCPAddr).IP + c.udpAddr = append(c.udpAddr, + &net.UDPAddr{IP: remoteIP, Port: port1}, + &net.UDPAddr{IP: remoteIP, Port: port2}, + ) + + go func() { + // Try to open a hole in the NAT router (to allow incoming UDP packets) + // by send a UDP packet for RTP and RTCP to the remote RTSP server. + // https://github.com/FFmpeg/FFmpeg/blob/aa91ae25b88e195e6af4248e0ab30605735ca1cd/libavformat/rtpdec.c#L416-L438 + _, _ = c.WriteToUDP([]byte{0x80, 0x00, 0x00, 0x00}, channel) + _, _ = c.WriteToUDP([]byte{0x80, 0xC8, 0x00, 0x01}, channel+1) + }() + } + } + + return channel, nil + } else { + // we send our `interleaved`, but camera can answer with another + + // Transport: RTP/AVP/TCP;unicast;interleaved=10-11;ssrc=10117CB7 + // Transport: RTP/AVP/TCP;unicast;destination=192.168.1.111;source=192.168.1.222;interleaved=0 + // Transport: RTP/AVP/TCP;ssrc=22345682;interleaved=0-1 + // Escam Q6 has a bug: + // Transport: RTP/AVP;unicast;destination=192.168.1.111;source=192.168.1.222;interleaved=0-1 + s := core.Between(transport, "interleaved=", "-") + i, err := strconv.Atoi(s) + if err != nil { + return 0, fmt.Errorf("wrong transport: %s", transport) + } + + return byte(i), nil + } +} + +func (c *Conn) Play() (err error) { + req := &tcp.Request{Method: MethodPlay, URL: c.URL} + return c.WriteRequest(req) +} + +func (c *Conn) Teardown() (err error) { + // allow TEARDOWN from any state (ex. ANNOUNCE > SETUP) + req := &tcp.Request{Method: MethodTeardown, URL: c.URL} + return c.WriteRequest(req) +} + +func (c *Conn) Close() error { + if c.mode == core.ModeActiveProducer { + _ = c.Teardown() + } + if c.OnClose != nil { + _ = c.OnClose() + } + for _, conn := range c.udpConn { + _ = conn.Close() + } + return c.conn.Close() +} + +func (c *Conn) WriteToUDP(b []byte, channel byte) (int, error) { + return c.udpConn[channel].WriteToUDP(b, c.udpAddr[channel]) +} + +const listenUDPAttemps = 10 + +var listenUDPMu sync.Mutex + +func ListenUDPPair() (*net.UDPConn, *net.UDPConn, error) { + listenUDPMu.Lock() + defer listenUDPMu.Unlock() + + for i := 0; i < listenUDPAttemps; i++ { + // Get a random even port from the OS + ln1, err := net.ListenUDP("udp", &net.UDPAddr{IP: nil, Port: 0}) + if err != nil { + continue + } + + var port1 = ln1.LocalAddr().(*net.UDPAddr).Port + var port2 int + + // 11. RTP over Network and Transport Protocols (https://www.ietf.org/rfc/rfc3550.txt) + // For UDP and similar protocols, + // RTP SHOULD use an even destination port number and the corresponding + // RTCP stream SHOULD use the next higher (odd) destination port number + if port1&1 > 0 { + port2 = port1 - 1 + } else { + port2 = port1 + 1 + } + + ln2, err := net.ListenUDP("udp", &net.UDPAddr{IP: nil, Port: port2}) + if err != nil { + _ = ln1.Close() + continue + } + + if port1 < port2 { + return ln1, ln2, nil + } else { + return ln2, ln1, nil + } + } + + return nil, nil, fmt.Errorf("can't open two UDP ports") +} diff --git a/installs_on_host/go2rtc/pkg/rtsp/client_test.go b/installs_on_host/go2rtc/pkg/rtsp/client_test.go new file mode 100644 index 0000000..5d714a5 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/rtsp/client_test.go @@ -0,0 +1,95 @@ +package rtsp + +import ( + "net" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestTimeout(t *testing.T) { + Timeout = time.Millisecond + + ln, err := net.Listen("tcp", "localhost:0") + require.Nil(t, err) + + client := NewClient("rtsp://" + ln.Addr().String() + "/stream") + client.Backchannel = true + + err = client.Dial() + require.Nil(t, err) + + err = client.Describe() + require.ErrorIs(t, err, os.ErrDeadlineExceeded) +} + +func TestMissedControl(t *testing.T) { + Timeout = time.Millisecond + + ln, err := net.Listen("tcp", "localhost:0") + require.Nil(t, err) + + go func() { + conn, err := ln.Accept() + require.Nil(t, err) + + b := make([]byte, 8192) + for { + n, err := conn.Read(b) + require.Nil(t, err) + + req := string(b[:n]) + + switch req[:4] { + case "DESC": + _, _ = conn.Write([]byte(`RTSP/1.0 200 OK +Cseq: 1 +Content-Length: 495 +Content-Type: application/sdp + +v=0 +o=- 1 1 IN IP4 0.0.0.0 +s=go2rtc/1.2.0 +c=IN IP4 0.0.0.0 +t=0 0 +m=audio 0 RTP/AVP 96 +a=rtpmap:96 MPEG4-GENERIC/48000/2 +a=fmtp:96 profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3; config=119056E500 +m=audio 0 RTP/AVP 97 +a=rtpmap:97 OPUS/48000/2 +a=fmtp:97 sprop-stereo=1 +m=video 0 RTP/AVP 98 +a=rtpmap:98 H264/90000 +a=fmtp:98 packetization-mode=1; sprop-parameter-sets=Z2QAKaw0yAeAIn5cBagICAoAAAfQAAE4gdDAAjhAACOEF3lxoYAEcIAARwgu8uFA,aO48MAA=; profile-level-id=640029 +`)) + + case "SETU": + _, _ = conn.Write([]byte(`RTSP/1.0 200 OK +Transport: RTP/AVP/TCP;unicast;interleaved=4-5 +Cseq: 3 +Session: 1 + +`)) + + default: + t.Fail() + } + } + }() + + client := NewClient("rtsp://" + ln.Addr().String() + "/stream") + client.Backchannel = true + + err = client.Dial() + require.Nil(t, err) + + err = client.Describe() + require.Nil(t, err) + require.Len(t, client.Medias, 3) + + ch, err := client.SetupMedia(client.Medias[2]) + require.Nil(t, err) + require.Equal(t, ch, byte(4)) +} diff --git a/installs_on_host/go2rtc/pkg/rtsp/conn.go b/installs_on_host/go2rtc/pkg/rtsp/conn.go new file mode 100644 index 0000000..2984c78 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/rtsp/conn.go @@ -0,0 +1,409 @@ +package rtsp + +import ( + "bufio" + "context" + "encoding/binary" + "fmt" + "io" + "net" + "net/url" + "strconv" + "sync" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/tcp" + "github.com/pion/rtp" +) + +type Conn struct { + core.Connection + core.Listener + + // public + + Backchannel bool + Media string + OnClose func() error + PacketSize uint16 + SessionName string + Timeout int + Transport string // custom transport support, ex. RTSP over WebSocket + + URL *url.URL + + // internal + + auth *tcp.Auth + conn net.Conn + keepalive int + mode core.Mode + playOK bool + playErr error + reader *bufio.Reader + sequence int + session string + uri string + + state State + stateMu sync.Mutex + + udpConn []*net.UDPConn + udpAddr []*net.UDPAddr +} + +const ( + ProtoRTSP = "RTSP/1.0" + MethodOptions = "OPTIONS" + MethodSetup = "SETUP" + MethodTeardown = "TEARDOWN" + MethodDescribe = "DESCRIBE" + MethodPlay = "PLAY" + MethodPause = "PAUSE" + MethodAnnounce = "ANNOUNCE" + MethodRecord = "RECORD" +) + +type State byte + +func (s State) String() string { + switch s { + case StateNone: + return "NONE" + case StateConn: + return "CONN" + case StateSetup: + return MethodSetup + case StatePlay: + return MethodPlay + } + return strconv.Itoa(int(s)) +} + +const ( + StateNone State = iota + StateConn + StateSetup + StatePlay +) + +func (c *Conn) Handle() (err error) { + var timeout time.Duration + + switch c.mode { + case core.ModeActiveProducer: + var keepaliveDT time.Duration + + if c.keepalive > 5 { + keepaliveDT = time.Duration(c.keepalive-5) * time.Second + } else { + keepaliveDT = 25 * time.Second + } + + ctx, cancel := context.WithCancel(context.Background()) + go c.handleKeepalive(ctx, keepaliveDT) + defer cancel() + + if c.Timeout == 0 { + // polling frames from remote RTSP Server (ex Camera) + timeout = time.Second * 5 + + if len(c.Receivers) == 0 || c.Transport == "udp" { + // if we only send audio to camera + // https://github.com/AlexxIT/go2rtc/issues/659 + timeout += keepaliveDT + } + } else { + timeout = time.Second * time.Duration(c.Timeout) + } + + case core.ModePassiveProducer: + // polling frames from remote RTSP Client (ex FFmpeg) + if c.Timeout == 0 { + timeout = time.Second * 15 + } else { + timeout = time.Second * time.Duration(c.Timeout) + } + + case core.ModePassiveConsumer: + // pushing frames to remote RTSP Client (ex VLC) + timeout = time.Second * 60 + + default: + return fmt.Errorf("wrong RTSP conn mode: %d", c.mode) + } + + for i := 0; i < len(c.udpConn); i++ { + go c.handleUDPData(byte(i)) + } + + for c.state != StateNone { + ts := time.Now() + + _ = c.conn.SetReadDeadline(ts.Add(timeout)) + + if err = c.handleTCPData(); err != nil { + return + } + } + + return +} + +func (c *Conn) handleKeepalive(ctx context.Context, d time.Duration) { + ticker := time.NewTicker(d) + for { + select { + case <-ticker.C: + req := &tcp.Request{Method: MethodOptions, URL: c.URL} + if err := c.WriteRequest(req); err != nil { + return + } + case <-ctx.Done(): + return + } + } +} + +func (c *Conn) handleUDPData(channel byte) { + // TODO: handle timeouts and drop TCP connection after any error + conn := c.udpConn[channel] + + for { + // TP-Link Tapo camera has crazy 10000 bytes packet size + buf := make([]byte, 10240) + + n, _, err := conn.ReadFromUDP(buf) + if err != nil { + return + } + + if err = c.handleRawPacket(channel, buf[:n]); err != nil { + return + } + } +} + +func (c *Conn) handleTCPData() error { + // we can read: + // 1. RTP interleaved: `$` + 1B channel number + 2B size + // 2. RTSP response: RTSP/1.0 200 OK + // 3. RTSP request: OPTIONS ... + var buf4 []byte // `$` + 1B channel number + 2B size + var err error + + buf4, err = c.reader.Peek(4) + if err != nil { + return err + } + + var channel byte + var size uint16 + + if buf4[0] != '$' { + switch string(buf4) { + case "RTSP": + var res *tcp.Response + if res, err = c.ReadResponse(); err != nil { + return err + } + c.Fire(res) + // for playing backchannel only after OK response on play + c.playOK = true + return nil + + case "OPTI", "TEAR", "DESC", "SETU", "PLAY", "PAUS", "RECO", "ANNO", "GET_", "SET_": + var req *tcp.Request + if req, err = c.ReadRequest(); err != nil { + return err + } + c.Fire(req) + if req.Method == MethodOptions { + res := &tcp.Response{Request: req} + if err = c.WriteResponse(res); err != nil { + return err + } + } + return nil + + default: + c.Fire("RTSP wrong input") + + for i := 0; ; i++ { + // search next start symbol + if _, err = c.reader.ReadBytes('$'); err != nil { + return err + } + + if channel, err = c.reader.ReadByte(); err != nil { + return err + } + + // TODO: better check maximum good channel ID + if channel >= 20 { + continue + } + + buf4 = make([]byte, 2) + if _, err = io.ReadFull(c.reader, buf4); err != nil { + return err + } + + // check if size good for RTP + size = binary.BigEndian.Uint16(buf4) + if size <= 1500 { + break + } + + // 10 tries to find good packet + if i >= 10 { + return fmt.Errorf("RTSP wrong input") + } + } + } + } else { + // hope that the odd channels are always RTCP + channel = buf4[1] + + // get data size + size = binary.BigEndian.Uint16(buf4[2:]) + + // skip 4 bytes from c.reader.Peek + if _, err = c.reader.Discard(4); err != nil { + return err + } + } + + // init memory for data + buf := make([]byte, size) + if _, err = io.ReadFull(c.reader, buf); err != nil { + return err + } + + c.Recv += int(size) + + return c.handleRawPacket(channel, buf) +} + +func (c *Conn) handleRawPacket(channel byte, buf []byte) error { + if channel&1 == 0 { + packet := &rtp.Packet{} + if err := packet.Unmarshal(buf); err != nil { + return err + } + + for _, receiver := range c.Receivers { + if receiver.ID == channel { + receiver.WriteRTP(packet) + break + } + } + } else { + msg := &RTCP{Channel: channel} + + if err := msg.Header.Unmarshal(buf); err != nil { + return nil + } + + //var err error + //msg.Packets, err = rtcp.Unmarshal(buf) + //if err != nil { + // return nil + //} + + c.Fire(msg) + } + + return nil +} + +func (c *Conn) WriteRequest(req *tcp.Request) error { + if req.Proto == "" { + req.Proto = ProtoRTSP + } + + if req.Header == nil { + req.Header = make(map[string][]string) + } + + c.sequence++ + // important to send case sensitive CSeq + // https://github.com/AlexxIT/go2rtc/issues/7 + req.Header["CSeq"] = []string{strconv.Itoa(c.sequence)} + + c.auth.Write(req) + + if c.session != "" { + req.Header.Set("Session", c.session) + } + + if req.Body != nil { + val := strconv.Itoa(len(req.Body)) + req.Header.Set("Content-Length", val) + } + + c.Fire(req) + + if err := c.conn.SetWriteDeadline(time.Now().Add(Timeout)); err != nil { + return err + } + + return req.Write(c.conn) +} + +func (c *Conn) ReadRequest() (*tcp.Request, error) { + if err := c.conn.SetReadDeadline(time.Now().Add(Timeout)); err != nil { + return nil, err + } + return tcp.ReadRequest(c.reader) +} + +func (c *Conn) WriteResponse(res *tcp.Response) error { + if res.Proto == "" { + res.Proto = ProtoRTSP + } + + if res.Status == "" { + res.Status = "200 OK" + } + + if res.Header == nil { + res.Header = make(map[string][]string) + } + + if res.Request != nil && res.Request.Header != nil { + seq := res.Request.Header.Get("CSeq") + if seq != "" { + res.Header.Set("CSeq", seq) + } + } + + if c.session != "" { + if res.Request != nil && res.Request.Method == MethodSetup { + res.Header.Set("Session", c.session+";timeout=60") + } else { + res.Header.Set("Session", c.session) + } + } + + if res.Body != nil { + val := strconv.Itoa(len(res.Body)) + res.Header.Set("Content-Length", val) + } + + c.Fire(res) + + if err := c.conn.SetWriteDeadline(time.Now().Add(Timeout)); err != nil { + return err + } + + return res.Write(c.conn) +} + +func (c *Conn) ReadResponse() (*tcp.Response, error) { + if err := c.conn.SetReadDeadline(time.Now().Add(Timeout)); err != nil { + return nil, err + } + return tcp.ReadResponse(c.reader) +} diff --git a/installs_on_host/go2rtc/pkg/rtsp/consumer.go b/installs_on_host/go2rtc/pkg/rtsp/consumer.go new file mode 100644 index 0000000..e6525d9 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/rtsp/consumer.go @@ -0,0 +1,198 @@ +package rtsp + +import ( + "time" + + "github.com/AlexxIT/go2rtc/pkg/aac" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/h265" + "github.com/AlexxIT/go2rtc/pkg/mjpeg" + "github.com/AlexxIT/go2rtc/pkg/pcm" + "github.com/pion/rtp" +) + +func (c *Conn) GetMedias() []*core.Media { + //core.Assert(c.Medias != nil) + return c.Medias +} + +func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) (err error) { + var channel byte + + switch c.mode { + case core.ModeActiveProducer: // backchannel + c.stateMu.Lock() + defer c.stateMu.Unlock() + + if c.state == StatePlay { + if err = c.Reconnect(); err != nil { + return + } + } + + if channel, err = c.SetupMedia(media); err != nil { + return + } + + c.state = StateSetup + + case core.ModePassiveConsumer: + channel = byte(len(c.Senders)) * 2 + + // for consumer is better to use original track codec + codec = track.Codec.Clone() + // generate new payload type, starting from 96 + codec.PayloadType = byte(96 + len(c.Senders)) + + default: + panic(core.Caller()) + } + + // save original codec to sender (can have Codec.Name = ANY) + sender := core.NewSender(media, codec) + // important to send original codec for valid IsRTP check + sender.Handler = c.packetWriter(track.Codec, channel, codec.PayloadType) + + if c.mode == core.ModeActiveProducer && track.Codec.Name == core.CodecPCMA { + // Fix Reolink Doorbell https://github.com/AlexxIT/go2rtc/issues/331 + sender.Handler = pcm.RepackG711(true, sender.Handler) + } + + sender.HandleRTP(track) + + c.Senders = append(c.Senders, sender) + return nil +} + +const ( + startVideoBuf = 32 * 1024 // 32KB + startAudioBuf = 2 * 1024 // 2KB + maxBuf = 1024 * 1024 // 1MB + rtpHdr = 12 // basic RTP header size + intHdr = 4 // interleaved header size +) + +func (c *Conn) packetWriter(codec *core.Codec, channel, payloadType uint8) core.HandlerFunc { + var buf []byte + var n int + + video := codec.IsVideo() + if video { + buf = make([]byte, startVideoBuf) + } else { + buf = make([]byte, startAudioBuf) + } + + flushBuf := func() { + //log.Printf("[rtsp] channel:%2d write_size:%6d buffer_size:%6d", channel, n, len(buf)) + if err := c.writeInterleavedData(buf[:n]); err != nil { + c.Send += n + } + n = 0 + } + + handlerFunc := func(packet *rtp.Packet) { + if c.state == StateNone { + return + } + + clone := rtp.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: packet.Marker, + PayloadType: payloadType, + SequenceNumber: packet.SequenceNumber, + Timestamp: packet.Timestamp, + SSRC: packet.SSRC, + }, + Payload: packet.Payload, + } + + if !video { + packet.Marker = true // better to have marker on all audio packets + } + + size := rtpHdr + len(packet.Payload) + + if l := len(buf); n+intHdr+size > l { + if l < maxBuf { + buf = append(buf, make([]byte, l)...) // double buffer size + } else { + flushBuf() + } + } + + //log.Printf("[RTP] codec: %s, size: %6d, ts: %10d, pt: %2d, ssrc: %d, seq: %d, mark: %v", codec.Name, len(packet.Payload), packet.Timestamp, packet.PayloadType, packet.SSRC, packet.SequenceNumber, packet.Marker) + + chunk := buf[n:] + _ = chunk[4] // bounds + chunk[0] = '$' + chunk[1] = channel + chunk[2] = byte(size >> 8) + chunk[3] = byte(size) + + if _, err := clone.MarshalTo(chunk[4:]); err != nil { + return + } + + n += 4 + size + + if !packet.Marker || !c.playOK { + // collect continious video packets to buffer + // or wait OK for PLAY command for backchannel + //log.Printf("[rtsp] collecting buffer ok=%t", c.playOK) + return + } + + flushBuf() + } + + if !codec.IsRTP() { + switch codec.Name { + case core.CodecH264: + handlerFunc = h264.RTPPay(c.PacketSize, handlerFunc) + case core.CodecH265: + handlerFunc = h265.RTPPay(c.PacketSize, handlerFunc) + case core.CodecAAC: + handlerFunc = aac.RTPPay(handlerFunc) + case core.CodecJPEG: + handlerFunc = mjpeg.RTPPay(handlerFunc) + } + } else if codec.Name == core.CodecPCML { + handlerFunc = pcm.LittleToBig(handlerFunc) + } else if c.PacketSize != 0 { + switch codec.Name { + case core.CodecH264: + handlerFunc = h264.RTPPay(c.PacketSize, handlerFunc) + handlerFunc = h264.RTPDepay(codec, handlerFunc) + case core.CodecH265: + handlerFunc = h265.RTPPay(c.PacketSize, handlerFunc) + handlerFunc = h265.RTPDepay(codec, handlerFunc) + } + } + + return handlerFunc +} + +func (c *Conn) writeInterleavedData(data []byte) error { + if c.Transport != "udp" { + _ = c.conn.SetWriteDeadline(time.Now().Add(Timeout)) + _, err := c.conn.Write(data) + return err + } + + for len(data) >= 4 && data[0] == '$' { + channel := data[1] + size := uint16(data[2])<<8 | uint16(data[3]) + rtpData := data[4 : 4+size] + + if _, err := c.WriteToUDP(rtpData, channel); err != nil { + return err + } + + data = data[4+size:] + } + + return nil +} diff --git a/installs_on_host/go2rtc/pkg/rtsp/helpers.go b/installs_on_host/go2rtc/pkg/rtsp/helpers.go new file mode 100644 index 0000000..c73bd0a --- /dev/null +++ b/installs_on_host/go2rtc/pkg/rtsp/helpers.go @@ -0,0 +1,154 @@ +package rtsp + +import ( + "bytes" + "io" + "net/url" + "regexp" + "strconv" + "strings" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtcp" + "github.com/pion/sdp/v3" +) + +type RTCP struct { + Channel byte + Header rtcp.Header + Packets []rtcp.Packet +} + +const sdpHeader = `v=0 +o=- 0 0 IN IP4 0.0.0.0 +s=- +t=0 0` + +func UnmarshalSDP(rawSDP []byte) ([]*core.Media, error) { + sd := &sdp.SessionDescription{} + if err := sd.Unmarshal(rawSDP); err != nil { + // fix multiple `s=` https://github.com/AlexxIT/WebRTC/issues/417 + rawSDP = regexp.MustCompile("\ns=[^\n]+").ReplaceAll(rawSDP, nil) + + // fix broken `c=` https://github.com/AlexxIT/go2rtc/issues/1426 + rawSDP = regexp.MustCompile("\nc=[^\n]+").ReplaceAll(rawSDP, nil) + + // fix SDP header for some cameras + if i := bytes.Index(rawSDP, []byte("\nm=")); i > 0 { + rawSDP = append([]byte(sdpHeader), rawSDP[i:]...) + } + + // Fix invalid media type (errSDPInvalidValue) caused by + // some TP-LINK IP camera, e.g. TL-IPC44GW + for _, b := range regexp.MustCompile("m=[^ ]+ ").FindAll(rawSDP, -1) { + switch string(b[2 : len(b)-1]) { + case "audio", "video", "application": + default: + rawSDP = bytes.Replace(rawSDP, b, []byte("m=application "), 1) + } + } + + if err == io.EOF { + rawSDP = append(rawSDP, '\n') + } + + sd = &sdp.SessionDescription{} + err = sd.Unmarshal(rawSDP) + if err != nil { + return nil, err + } + } + + // fix buggy camera https://github.com/AlexxIT/go2rtc/issues/771 + forceDirection := sd.Origin.Username == "CV-RTSPHandler" + + var medias []*core.Media + + for _, md := range sd.MediaDescriptions { + media := core.UnmarshalMedia(md) + + // Check buggy SDP with fmtp for H264 on another track + // https://github.com/AlexxIT/WebRTC/issues/419 + for _, codec := range media.Codecs { + switch codec.Name { + case core.CodecH264: + if codec.FmtpLine == "" { + codec.FmtpLine = findFmtpLine(codec.PayloadType, sd.MediaDescriptions) + } + case core.CodecH265: + if codec.FmtpLine != "" { + // all three parameters are needed for a valid fmtp line + // https://github.com/AlexxIT/go2rtc/pull/1588 + if !strings.Contains(codec.FmtpLine, "sprop-vps=") || + !strings.Contains(codec.FmtpLine, "sprop-sps=") || + !strings.Contains(codec.FmtpLine, "sprop-pps=") { + codec.FmtpLine = "" + } + } + case core.CodecOpus: + // fix OPUS for some cameras https://datatracker.ietf.org/doc/html/rfc7587 + codec.ClockRate = 48000 + codec.Channels = 2 + } + } + + if media.Direction == "" || forceDirection { + media.Direction = core.DirectionRecvonly + } + + medias = append(medias, media) + } + + return medias, nil +} + +func findFmtpLine(payloadType uint8, descriptions []*sdp.MediaDescription) string { + s := strconv.Itoa(int(payloadType)) + for _, md := range descriptions { + codec := core.UnmarshalCodec(md, s) + if codec.FmtpLine != "" { + return codec.FmtpLine + } + } + return "" +} + +// urlParse fix bugs: +// 1. Content-Base: rtsp://::ffff:192.168.1.123/onvif/profile.1/ +// 2. Content-Base: rtsp://rtsp://turret2-cam.lan:554/stream1/ +// 3. Content-Base: 192.168.253.220:1935/ +func urlParse(rawURL string) (*url.URL, error) { + // fix https://github.com/AlexxIT/go2rtc/issues/830 + if strings.HasPrefix(rawURL, "rtsp://rtsp://") { + rawURL = rawURL[7:] + } + + // fix https://github.com/AlexxIT/go2rtc/issues/1852 + if !strings.Contains(rawURL, "://") { + rawURL = "rtsp://" + rawURL + } + + u, err := url.Parse(rawURL) + if err != nil && strings.HasSuffix(err.Error(), "after host") { + if i := indexN(rawURL, '/', 3); i > 0 { + return urlParse(rawURL[:i] + ":" + rawURL[i:]) + } + } + + return u, err +} + +func indexN(s string, c byte, n int) int { + var offset int + for { + i := strings.IndexByte(s[offset:], c) + if i < 0 { + break + } + if n--; n == 0 { + return offset + i + } + offset += i + 1 + } + return -1 +} diff --git a/installs_on_host/go2rtc/pkg/rtsp/producer.go b/installs_on_host/go2rtc/pkg/rtsp/producer.go new file mode 100644 index 0000000..3d818b6 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/rtsp/producer.go @@ -0,0 +1,143 @@ +package rtsp + +import ( + "encoding/json" + "errors" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +func (c *Conn) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + core.Assert(media.Direction == core.DirectionRecvonly) + + for _, track := range c.Receivers { + if track.Codec == codec { + return track, nil + } + } + + c.stateMu.Lock() + defer c.stateMu.Unlock() + + var channel byte + + switch c.mode { + case core.ModeActiveProducer: + if c.state == StatePlay { + if err := c.Reconnect(); err != nil { + return nil, err + } + } + + var err error + channel, err = c.SetupMedia(media) + if err != nil { + return nil, err + } + + c.state = StateSetup + case core.ModePassiveConsumer: + // Backchannel + channel = byte(len(c.Senders)) * 2 + default: + return nil, errors.New("rtsp: wrong mode for GetTrack") + } + + track := core.NewReceiver(media, codec) + track.ID = channel + c.Receivers = append(c.Receivers, track) + + return track, nil +} + +func (c *Conn) Start() (err error) { + core.Assert(c.mode == core.ModeActiveProducer || c.mode == core.ModePassiveProducer) + + for { + ok := false + + c.stateMu.Lock() + switch c.state { + case StateNone: + err = nil + case StateConn: + err = errors.New("start from CONN state") + case StateSetup: + switch c.mode { + case core.ModeActiveProducer: + err = c.Play() + case core.ModePassiveProducer: + err = nil + default: + err = errors.New("start from wrong mode: " + c.mode.String()) + } + + if err == nil { + c.state = StatePlay + ok = true + } + } + c.stateMu.Unlock() + + if !ok { + return + } + + // Handler can return different states: + // 1. None after PLAY should exit without error + // 2. Play after PLAY should exit from Start with error + // 3. Setup after PLAY should Play once again + err = c.Handle() + } +} + +func (c *Conn) Stop() (err error) { + for _, receiver := range c.Receivers { + receiver.Close() + } + for _, sender := range c.Senders { + sender.Close() + } + + c.stateMu.Lock() + if c.state != StateNone { + c.state = StateNone + err = c.Close() + } + c.stateMu.Unlock() + + return +} + +func (c *Conn) MarshalJSON() ([]byte, error) { + return json.Marshal(c.Connection) +} + +func (c *Conn) Reconnect() error { + c.Fire("RTSP reconnect") + + // close current session + _ = c.Close() + + // start new session + if err := c.Dial(); err != nil { + return err + } + if err := c.Describe(); err != nil { + return err + } + + // restore previous medias + for _, receiver := range c.Receivers { + if _, err := c.SetupMedia(receiver.Media); err != nil { + return err + } + } + for _, sender := range c.Senders { + if _, err := c.SetupMedia(sender.Media); err != nil { + return err + } + } + + return nil +} diff --git a/installs_on_host/go2rtc/pkg/rtsp/rtsp_test.go b/installs_on_host/go2rtc/pkg/rtsp/rtsp_test.go new file mode 100644 index 0000000..282c04f --- /dev/null +++ b/installs_on_host/go2rtc/pkg/rtsp/rtsp_test.go @@ -0,0 +1,275 @@ +package rtsp + +import ( + "testing" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/stretchr/testify/assert" +) + +func TestURLParse(t *testing.T) { + // https://github.com/AlexxIT/WebRTC/issues/395 + base := "rtsp://::ffff:192.168.1.123/onvif/profile.1/" + u, err := urlParse(base) + assert.NoError(t, err) + assert.Equal(t, "::ffff:192.168.1.123:", u.Host) + + // https://github.com/AlexxIT/go2rtc/issues/208 + base = "rtsp://rtsp://turret2-cam.lan:554/stream1/" + u, err = urlParse(base) + assert.NoError(t, err) + assert.Equal(t, "turret2-cam.lan:554", u.Host) + + // https://github.com/AlexxIT/go2rtc/issues/1852 + base = "192.168.253.220:1935/" + u, err = urlParse(base) + assert.NoError(t, err) + assert.Equal(t, "192.168.253.220:1935", u.Host) +} + +func TestBugSDP1(t *testing.T) { + // https://github.com/AlexxIT/WebRTC/issues/417 + s := `v=0 +o=- 91674849066 1 IN IP4 192.168.1.123 +s=RtspServer +i=live +t=0 0 +a=control:* +a=range:npt=0- +m=video 0 RTP/AVP 96 +c=IN IP4 0.0.0.0 +s=RtspServer +i=live +a=control:track0 +a=range:npt=0- +a=rtpmap:96 H264/90000 +a=fmtp:96 packetization-mode=1;profile-level-id=42001E;sprop-parameter-sets=Z0IAHvQCgC3I,aM48gA== +a=control:track0 +m=audio 0 RTP/AVP 97 +c=IN IP4 0.0.0.0 +s=RtspServer +i=live +a=control:track1 +a=range:npt=0- +a=rtpmap:97 MPEG4-GENERIC/8000/1 +a=fmtp:97 profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3;config=1588 +a=control:track1 +` + medias, err := UnmarshalSDP([]byte(s)) + assert.Nil(t, err) + assert.NotNil(t, medias) +} + +func TestBugSDP2(t *testing.T) { + // https://github.com/AlexxIT/WebRTC/issues/419 + s := `v=0 +o=- 1675628282 1675628283 IN IP4 192.168.1.123 +s=streamed by the RTSP server +t=0 0 +m=video 0 RTP/AVP 96 +a=rtpmap:96 H264/90000 +a=control:track0 +m=audio 0 RTP/AVP 8 +a=rtpmap:0 pcma/8000/1 +a=control:track1 +a=framerate:25 +a=range:npt=now- +a=fmtp:96 packetization-mode=1;profile-level-id=64001F;sprop-parameter-sets=Z0IAH5WoFAFuQA==,aM48gA== +` + medias, err := UnmarshalSDP([]byte(s)) + assert.Nil(t, err) + assert.NotNil(t, medias) + assert.NotEqual(t, "", medias[0].Codecs[0].FmtpLine) +} + +func TestBugSDP3(t *testing.T) { + s := `v=0 +o=- 1680614126554766 1 IN IP4 192.168.0.3 +s=Session streamed by "preview" +t=0 0 +a=tool:BC Streaming Media v202210012022.10.01 +a=type:broadcast +a=control:* +a=range:npt=now- +a=x-qt-text-nam:Session streamed by "preview" +m=video 0 RTP/AVP 96 +c=IN IP4 0.0.0.0 +b=AS:8192 +a=rtpmap:96 H264/90000 +a=range:npt=now- +a=fmtp:96 packetization-mode=1;profile-level-id=640033;sprop-parameter-sets=Z2QAM6wVFKAoAPGQ,aO48sA== +a=recvonly +a=control:track1 +m=audio 0 RTP/AVP 97 +c=IN IP4 0.0.0.0 +b=AS:8192 +a=rtpmap:97 MPEG4-GENERIC/16000 +a=fmtp:97 streamtype=5;profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3;config=1408; +a=recvonly +a=control:track2 +m=audio 0 RTP/AVP 8 +a=control:track3 +a=rtpmap:8 PCMA/8000 +a=sendonly` + medias, err := UnmarshalSDP([]byte(s)) + assert.Nil(t, err) + assert.Len(t, medias, 3) +} + +func TestBugSDP4(t *testing.T) { + s := `v=0 +o=- 14665860 31787219 1 IN IP4 10.0.0.94 +s=Session streamed by "MERCURY RTSP Server" +t=0 0 +m=video 0 RTP/AVP 96 +c=IN IP4 0.0.0.0 +b=AS:4096 +a=range:npt=0- +a=control:track1 +a=rtpmap:96 H264/90000 +a=fmtp:96 packetization-mode=1; profile-level-id=640016; sprop-parameter-sets=Z2QAFqzGoCgPaEAAAAMAQAAAB6E=,aOqPLA== +m=audio 0 RTP/AVP 8 +a=rtpmap:8 PCMA/8000 +a=control:track2 +m=application/MERCURY 0 RTP/AVP smart/1/90000 +a=rtpmap:95 MERCURY/90000 +a=control:track3 +` + medias, err := UnmarshalSDP([]byte(s)) + assert.Nil(t, err) + assert.Len(t, medias, 3) +} + +func TestBugSDP5(t *testing.T) { + s := `v=0 +o=CV-RTSPHandler 1123412 0 IN IP4 192.168.1.22 +s=Camera +c=IN IP4 192.168.1.22 +t=0 0 +a=charset:Shift_JIS +a=range:npt=0- +a=control:* +a=etag:1234567890 +m=video 0 RTP/AVP 99 +a=rtpmap:99 H264/90000 +a=fmtp:99 profile-level-id=42A01E;packetization-mode=1;sprop-parameter-sets=Z0KgKedAPAET8uAIEAABd2AAK/IGAAADAC+vCAAAHc1lP//jAAADABfXhAAADuayn//wIA==,aN48gA== +a=control:trackID=1 +a=sendonly +m=audio 0 RTP/AVP 127 +a=rtpmap:127 mpeg4-generic/8000/1 +a=fmtp:127 streamtype=5; profile-level-id=15; mode=AAC-hbr; sizeLength=13; indexLength=3; indexDeltalength=3; config=1588; CTSDeltaLength=0; DTSDeltaLength=0; +a=control:trackID=2 +` + medias, err := UnmarshalSDP([]byte(s)) + assert.Nil(t, err) + assert.Len(t, medias, 2) + assert.Equal(t, "recvonly", medias[0].Direction) + assert.Equal(t, "recvonly", medias[1].Direction) +} + +func TestBugSDP6(t *testing.T) { + // https://github.com/AlexxIT/go2rtc/issues/1278 + s := `v=0 +o=- 3730506281693 1 IN IP4 172.20.0.215 +s=IP camera Live streaming +i=stream1 +t=0 0 +a=tool:LIVE555 Streaming Media v2014.02.04 +a=type:broadcast +a=control:* +a=range:npt=0- +a=x-qt-text-nam:IP camera Live streaming +a=x-qt-text-inf:stream1 +m=video 0 RTP/AVP 26 +c=IN IP4 172.20.0.215 +b=AS:1500 +a=x-bufferdelay:0.55000 +a=x-dimensions:1280,960 +a=control:track1 +m=audio 0 RTP/AVP 0 +c=IN IP4 172.20.0.215 +b=AS:64 +a=x-bufferdelay:0.55000 +a=control:track2 +m=application 0 RTP/AVP 107 +c=IN IP4 172.20.0.215 +b=AS:1 +a=x-bufferdelay:0.55000 +a=rtpmap:107 vnd.onvif.metadata/90000/500 +a=control:track4 +m=vana 0 RTP/AVP 108 +c=IN IP4 172.20.0.215 +b=AS:1 +a=x-bufferdelay:0.55000 +a=rtpmap:108 video.analysis/90000/500 +a=control:track5 +` + medias, err := UnmarshalSDP([]byte(s)) + assert.Nil(t, err) + assert.Len(t, medias, 4) +} + +func TestBugSDP7(t *testing.T) { + // https://github.com/AlexxIT/go2rtc/issues/1426 + s := `v=0 +o=- 1001 1 IN +s=VCP IPC Realtime stream +m=video 0 RTP/AVP 105 +c=IN +a=control:rtsp://1.0.1.113/media/video2/video +a=rtpmap:105 H264/90000 +a=fmtp:105 profile-level-id=640016; packetization-mode=1; sprop-parameter-sets=Z2QAFqw7UFAX/LCAAAH0AABOIEI=,aOqPLA== +a=recvonly +m=audio 0 RTP/AVP 0 +c=IN +a=fmtp:0 RTCP=0 +a=control:rtsp://1.0.1.113/media/video2/audio1 +a=recvonly +m=audio 0 RTP/AVP 0 +c=IN +a=control:rtsp://1.0.1.113/media/video2/backchannel +a=rtpmap:0 PCMA/8000 +a=rtpmap:0 PCMU/8000 +a=sendonly +m=application 0 RTP/AVP 107 +c=IN +a=control:rtsp://1.0.1.113/media/video2/metadata +a=rtpmap:107 vnd.onvif.metadata/90000 +a=fmtp:107 DecoderTag=h3c-v3 RTCP=0 +a=recvonly +` + medias, err := UnmarshalSDP([]byte(s)) + assert.Nil(t, err) + assert.Len(t, medias, 4) +} + +func TestHikvisionPCM(t *testing.T) { + s := `v=0 +o=- 1721969533379665 1721969533379665 IN IP4 192.168.1.12 +s=Media Presentation +e=NONE +b=AS:5100 +t=0 0 +a=control:rtsp://192.168.1.12:554/Streaming/channels/101/ +m=video 0 RTP/AVP 96 +c=IN IP4 0.0.0.0 +b=AS:5000 +a=recvonly +a=x-dimensions:3200,1800 +a=control:rtsp://192.168.1.12:554/Streaming/channels/101/trackID=1 +a=rtpmap:96 H264/90000 +a=fmtp:96 profile-level-id=420029; packetization-mode=1; sprop-parameter-sets=Z2QAM6wVFKAyAOP5f/AAEAAWyAAAH0AAB1MAIA==,aO48sA== +m=audio 0 RTP/AVP 11 +c=IN IP4 0.0.0.0 +b=AS:50 +a=recvonly +a=control:rtsp://192.168.1.12:554/Streaming/channels/101/trackID=2 +a=rtpmap:11 PCM/48000 +a=Media_header:MEDIAINFO=494D4B4801030000040000010170011080BB0000007D000000000000000000000000000000000000; +a=appversion:1.0 +` + medias, err := UnmarshalSDP([]byte(s)) + assert.Nil(t, err) + assert.Len(t, medias, 2) + assert.Equal(t, core.CodecPCML, medias[1].Codecs[0].Name) +} diff --git a/installs_on_host/go2rtc/pkg/rtsp/server.go b/installs_on_host/go2rtc/pkg/rtsp/server.go new file mode 100644 index 0000000..343bdc6 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/rtsp/server.go @@ -0,0 +1,235 @@ +package rtsp + +import ( + "bufio" + "errors" + "fmt" + "net" + "net/url" + "strconv" + "strings" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/tcp" +) + +var FailedAuth = errors.New("failed authentication") + +func NewServer(conn net.Conn) *Conn { + return &Conn{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "rtsp", + Protocol: "rtsp+tcp", + RemoteAddr: conn.RemoteAddr().String(), + }, + conn: conn, + reader: bufio.NewReader(conn), + } +} + +func (c *Conn) Auth(username, password string) { + info := url.UserPassword(username, password) + c.auth = tcp.NewAuth(info) +} + +func (c *Conn) Accept() error { + for { + req, err := c.ReadRequest() + if err != nil { + return err + } + + if c.URL == nil { + c.URL = req.URL + c.UserAgent = req.Header.Get("User-Agent") + } + + c.Fire(req) + + if valid, empty := c.auth.Validate(req); !valid { + res := &tcp.Response{ + Status: "401 Unauthorized", + Header: map[string][]string{"Www-Authenticate": {`Basic realm="go2rtc"`}}, + Request: req, + } + if err = c.WriteResponse(res); err != nil { + return err + } + if empty { + // eliminate false positive: ffmpeg sends first request without + // authorization header even if the user provides credentials + continue + } + return FailedAuth + } + + // Receiver: OPTIONS > DESCRIBE > SETUP... > PLAY > TEARDOWN + // Sender: OPTIONS > ANNOUNCE > SETUP... > RECORD > TEARDOWN + switch req.Method { + case MethodOptions: + res := &tcp.Response{ + Header: map[string][]string{ + "Public": {"OPTIONS, SETUP, TEARDOWN, DESCRIBE, PLAY, PAUSE, ANNOUNCE, RECORD"}, + }, + Request: req, + } + if err = c.WriteResponse(res); err != nil { + return err + } + + case MethodAnnounce: + if req.Header.Get("Content-Type") != "application/sdp" { + return errors.New("wrong content type") + } + + c.SDP = string(req.Body) // for info + + c.Medias, err = UnmarshalSDP(req.Body) + if err != nil { + return err + } + + // TODO: fix someday... + for i, media := range c.Medias { + track := core.NewReceiver(media, media.Codecs[0]) + track.ID = byte(i * 2) + c.Receivers = append(c.Receivers, track) + } + + c.mode = core.ModePassiveProducer + c.Fire(MethodAnnounce) + + res := &tcp.Response{Request: req} + if err = c.WriteResponse(res); err != nil { + return err + } + + case MethodDescribe: + c.mode = core.ModePassiveConsumer + c.Fire(MethodDescribe) + + if c.Senders == nil { + res := &tcp.Response{ + Status: "404 Not Found", + Request: req, + } + return c.WriteResponse(res) + } + + res := &tcp.Response{ + Header: map[string][]string{ + "Content-Type": {"application/sdp"}, + }, + Request: req, + } + + // convert tracks to real output medias medias + var medias []*core.Media + for i, track := range c.Senders { + media := &core.Media{ + Kind: core.GetKind(track.Codec.Name), + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{track.Codec}, + ID: "trackID=" + strconv.Itoa(i), + } + medias = append(medias, media) + } + + for i, track := range c.Receivers { + media := &core.Media{ + Kind: core.GetKind(track.Codec.Name), + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{track.Codec}, + ID: "trackID=" + strconv.Itoa(i+len(c.Senders)), + } + medias = append(medias, media) + } + + res.Body, err = core.MarshalSDP(c.SessionName, medias) + if err != nil { + return err + } + + c.SDP = string(res.Body) // for info + + if err = c.WriteResponse(res); err != nil { + return err + } + + case MethodSetup: + res := &tcp.Response{ + Header: map[string][]string{}, + Request: req, + } + + // Test if client requests TCP transport, otherwise return 461 Transport not supported + // This allows smart clients who initially requested UDP to fall back on TCP transport + if tr := req.Header.Get("Transport"); strings.HasPrefix(tr, "RTP/AVP/TCP") { + c.session = core.RandString(8, 10) + c.state = StateSetup + + if c.mode == core.ModePassiveConsumer { + if i := reqTrackID(req); i >= 0 && i < len(c.Senders)+len(c.Receivers) { + if i < len(c.Senders) { + c.Senders[i].Media.ID = MethodSetup + } else { + c.Receivers[i-len(c.Senders)].Media.ID = MethodSetup + } + tr = fmt.Sprintf("RTP/AVP/TCP;unicast;interleaved=%d-%d", i*2, i*2+1) + res.Header.Set("Transport", tr) + } else { + res.Status = "400 Bad Request" + } + } else { + res.Header.Set("Transport", tr) + } + } else { + res.Status = "461 Unsupported transport" + } + + if err = c.WriteResponse(res); err != nil { + return err + } + + case MethodRecord, MethodPlay: + if c.mode == core.ModePassiveConsumer { + // stop unconfigured senders + for _, track := range c.Senders { + if track.Media.ID != MethodSetup { + track.Close() + } + } + } + + res := &tcp.Response{Request: req} + err = c.WriteResponse(res) + c.playOK = true + return err + + case MethodTeardown: + res := &tcp.Response{Request: req} + _ = c.WriteResponse(res) + c.state = StateNone + return c.conn.Close() + + default: + return fmt.Errorf("unsupported method: %s", req.Method) + } + } +} + +func reqTrackID(req *tcp.Request) int { + var s string + if req.URL.RawQuery != "" { + s = req.URL.RawQuery + } else { + s = req.URL.Path + } + if i := strings.LastIndexByte(s, '='); i > 0 { + if i, err := strconv.Atoi(s[i+1:]); err == nil { + return i + } + } + return -1 +} diff --git a/installs_on_host/go2rtc/pkg/shell/command.go b/installs_on_host/go2rtc/pkg/shell/command.go new file mode 100644 index 0000000..b7c8189 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/shell/command.go @@ -0,0 +1,59 @@ +package shell + +import ( + "context" + "os/exec" +) + +// Command like exec.Cmd, but with support: +// - io.Closer interface +// - Wait from multiple places +// - Done channel +type Command struct { + *exec.Cmd + ctx context.Context + cancel context.CancelFunc + err error +} + +func NewCommand(s string) *Command { + ctx, cancel := context.WithCancel(context.Background()) + args := QuoteSplit(s) + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + cmd.SysProcAttr = procAttr + return &Command{cmd, ctx, cancel, nil} +} + +func (c *Command) Start() error { + if err := c.Cmd.Start(); err != nil { + return err + } + + go func() { + c.err = c.Cmd.Wait() + c.cancel() // release context resources + }() + + return nil +} + +func (c *Command) Wait() error { + <-c.ctx.Done() + return c.err +} + +func (c *Command) Run() error { + if err := c.Start(); err != nil { + return err + } + return c.Wait() +} + +func (c *Command) Done() <-chan struct{} { + return c.ctx.Done() +} + +func (c *Command) Close() error { + c.cancel() + return nil +} diff --git a/installs_on_host/go2rtc/pkg/shell/procattr.go b/installs_on_host/go2rtc/pkg/shell/procattr.go new file mode 100644 index 0000000..fffdc2a --- /dev/null +++ b/installs_on_host/go2rtc/pkg/shell/procattr.go @@ -0,0 +1,7 @@ +//go:build !linux + +package shell + +import "syscall" + +var procAttr *syscall.SysProcAttr diff --git a/installs_on_host/go2rtc/pkg/shell/procattr_linux.go b/installs_on_host/go2rtc/pkg/shell/procattr_linux.go new file mode 100644 index 0000000..cef1d15 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/shell/procattr_linux.go @@ -0,0 +1,6 @@ +package shell + +import "syscall" + +// will stop child if parent died (even with SIGKILL) +var procAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGTERM} diff --git a/installs_on_host/go2rtc/pkg/shell/shell.go b/installs_on_host/go2rtc/pkg/shell/shell.go new file mode 100644 index 0000000..e04a58c --- /dev/null +++ b/installs_on_host/go2rtc/pkg/shell/shell.go @@ -0,0 +1,43 @@ +package shell + +import ( + "os" + "os/signal" + "strings" + "syscall" +) + +func QuoteSplit(s string) []string { + var a []string + + for len(s) > 0 { + switch c := s[0]; c { + case '\t', '\n', '\r', ' ': // unicode.IsSpace + s = s[1:] + case '"', '\'': // quote chars + if i := strings.IndexByte(s[1:], c); i > 0 { + a = append(a, s[1:i+1]) + s = s[i+2:] + } else { + return nil // error + } + default: + i := strings.IndexAny(s, "\t\n\r ") + if i > 0 { + a = append(a, s[:i]) + s = s[i:] + } else { + a = append(a, s) + s = "" + } + } + } + + return a +} + +func RunUntilSignal() { + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + println("exit with signal:", (<-sigs).String()) +} diff --git a/installs_on_host/go2rtc/pkg/shell/shell_test.go b/installs_on_host/go2rtc/pkg/shell/shell_test.go new file mode 100644 index 0000000..0910270 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/shell/shell_test.go @@ -0,0 +1,18 @@ +package shell + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestQuoteSplit(t *testing.T) { + s := ` +python "-c" 'import time +print("time", time.time())' +` + require.Equal(t, []string{"python", "-c", "import time\nprint(\"time\", time.time())"}, QuoteSplit(s)) + + s = `ffmpeg -i "video=FaceTime HD Camera" -i "DeckLink SDI (2)"` + require.Equal(t, []string{"ffmpeg", "-i", `video=FaceTime HD Camera`, "-i", "DeckLink SDI (2)"}, QuoteSplit(s)) +} diff --git a/installs_on_host/go2rtc/pkg/srtp/server.go b/installs_on_host/go2rtc/pkg/srtp/server.go new file mode 100644 index 0000000..1e396f2 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/srtp/server.go @@ -0,0 +1,102 @@ +package srtp + +import ( + "encoding/binary" + "net" + "strconv" + "sync" +) + +type Server struct { + address string + conn net.PacketConn + sessions map[uint32]*Session + mu sync.Mutex +} + +func NewServer(address string) *Server { + return &Server{ + address: address, + sessions: map[uint32]*Session{}, + } +} + +func (s *Server) Port() int { + if s.conn != nil { + return s.conn.LocalAddr().(*net.UDPAddr).Port + } + + _, a, _ := net.SplitHostPort(s.address) + i, _ := strconv.Atoi(a) + return i +} + +func (s *Server) AddSession(session *Session) { + s.mu.Lock() + defer s.mu.Unlock() + + if err := session.init(); err != nil { + return + } + + if len(s.sessions) == 0 { + var err error + if s.conn, err = net.ListenPacket("udp", s.address); err != nil { + return + } + go s.handle() + } + + session.conn = s.conn + + s.sessions[session.Remote.SSRC] = session +} + +func (s *Server) DelSession(session *Session) { + s.mu.Lock() + + delete(s.sessions, session.Remote.SSRC) + + // check s.conn for https://github.com/AlexxIT/go2rtc/issues/734 + if len(s.sessions) == 0 && s.conn != nil { + _ = s.conn.Close() + } + + s.mu.Unlock() +} + +func (s *Server) GetSession(ssrc uint32) (session *Session) { + s.mu.Lock() + session = s.sessions[ssrc] + s.mu.Unlock() + return +} + +func (s *Server) handle() error { + b := make([]byte, 2048) + for { + n, _, err := s.conn.ReadFrom(b) + if err != nil { + return err + } + + // Multiplexing RTP Data and Control Packets on a Single Port + // https://datatracker.ietf.org/doc/html/rfc5761 + + switch packetType := b[1]; packetType { + case 99, 110, 0x80 | 99, 0x80 | 110: + // this is default position for SSRC in RTP packet + ssrc := binary.BigEndian.Uint32(b[8:]) + if session := s.GetSession(ssrc); session != nil { + session.ReadRTP(b[:n]) + } + + case 200, 201, 202, 203, 204, 205, 206, 207: + // this is default position for SSRC in RTCP packet + ssrc := binary.BigEndian.Uint32(b[4:]) + if session := s.GetSession(ssrc); session != nil { + session.ReadRTCP(b[:n]) + } + } + } +} diff --git a/installs_on_host/go2rtc/pkg/srtp/session.go b/installs_on_host/go2rtc/pkg/srtp/session.go new file mode 100644 index 0000000..0ab8164 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/srtp/session.go @@ -0,0 +1,160 @@ +package srtp + +import ( + "net" + "time" + + "github.com/pion/rtcp" + "github.com/pion/rtp" + "github.com/pion/srtp/v3" +) + +type Session struct { + Local *Endpoint + Remote *Endpoint + + OnReadRTP func(packet *rtp.Packet) + + Recv int // bytes recv + Send int // bytes send + + conn net.PacketConn // local conn endpoint + + PayloadType uint8 + RTCPInterval time.Duration + + senderRTCP rtcp.SenderReport + senderTime time.Time +} + +type Endpoint struct { + Addr string + Port uint16 + MasterKey []byte + MasterSalt []byte + SSRC uint32 + + addr net.Addr + srtp *srtp.Context +} + +func (e *Endpoint) init() (err error) { + e.addr = &net.UDPAddr{IP: net.ParseIP(e.Addr), Port: int(e.Port)} + e.srtp, err = srtp.CreateContext(e.MasterKey, e.MasterSalt, profile(e.MasterKey)) + return +} + +func profile(key []byte) srtp.ProtectionProfile { + switch len(key) { + case 16: + return srtp.ProtectionProfileAes128CmHmacSha1_80 + //case 32: + // return srtp.ProtectionProfileAes256CmHmacSha1_80 + } + return 0 +} + +func (s *Session) init() error { + if err := s.Local.init(); err != nil { + return err + } + if err := s.Remote.init(); err != nil { + return err + } + + s.senderRTCP.SSRC = s.Local.SSRC + s.senderTime = time.Now().Add(s.RTCPInterval) + + return nil +} + +func (s *Session) WriteRTP(packet *rtp.Packet) (int, error) { + if s.Local.srtp == nil { + return 0, nil // before init call + } + + if now := time.Now(); now.After(s.senderTime) { + s.senderRTCP.NTPTime = uint64(now.UnixNano()) + s.senderTime = now.Add(s.RTCPInterval) + _, _ = s.WriteRTCP(&s.senderRTCP) + } + + clone := rtp.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: packet.Marker, + PayloadType: s.PayloadType, + SequenceNumber: packet.SequenceNumber, + Timestamp: packet.Timestamp, + SSRC: s.Local.SSRC, + }, + Payload: packet.Payload, + } + + b, err := clone.Marshal() + if err != nil { + return 0, err + } + + s.senderRTCP.PacketCount++ + s.senderRTCP.RTPTime = clone.Timestamp + s.senderRTCP.OctetCount += uint32(len(clone.Payload)) + + if b, err = s.Local.srtp.EncryptRTP(nil, b, nil); err != nil { + return 0, err + } + + return s.conn.WriteTo(b, s.Remote.addr) +} + +func (s *Session) WriteRTCP(packet rtcp.Packet) (int, error) { + b, err := packet.Marshal() + if err != nil { + return 0, err + } + b, err = s.Local.srtp.EncryptRTCP(nil, b, nil) + if err != nil { + return 0, err + } + return s.conn.WriteTo(b, s.Remote.addr) +} + +func (s *Session) ReadRTP(b []byte) { + packet := &rtp.Packet{} + + b, err := s.Remote.srtp.DecryptRTP(nil, b, &packet.Header) + if err != nil { + return + } + + if err = packet.Unmarshal(b); err != nil { + return + } + + if s.OnReadRTP != nil { + s.OnReadRTP(packet) + } +} + +func (s *Session) ReadRTCP(b []byte) { + header := rtcp.Header{} + b, err := s.Remote.srtp.DecryptRTCP(nil, b, &header) + if err != nil { + return + } + + //packets, err := rtcp.Unmarshal(b) + //if err != nil { + // return + //} + //if report, ok := packets[0].(*rtcp.SenderReport); ok { + // log.Printf("[srtp] rtcp type=%d report=%v", header.Type, report) + //} + + if header.Type != rtcp.TypeSenderReport { + return + } + + receiverRTCP := rtcp.ReceiverReport{SSRC: s.Local.SSRC} + _, _ = s.WriteRTCP(&receiverRTCP) +} diff --git a/installs_on_host/go2rtc/pkg/tapo/backchannel.go b/installs_on_host/go2rtc/pkg/tapo/backchannel.go new file mode 100644 index 0000000..b494126 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/tapo/backchannel.go @@ -0,0 +1,62 @@ +package tapo + +import ( + "bytes" + "strconv" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/mpegts" + "github.com/pion/rtp" +) + +func (c *Client) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error { + if c.sender == nil { + if err := c.SetupBackchannel(); err != nil { + return err + } + + muxer := mpegts.NewMuxer() + pid := muxer.AddTrack(mpegts.StreamTypePCMATapo) + if err := c.WriteBackchannel(muxer.GetHeader()); err != nil { + return err + } + + c.sender = core.NewSender(media, track.Codec) + c.sender.Handler = func(packet *rtp.Packet) { + b := muxer.GetPayload(pid, packet.Timestamp, packet.Payload) + _ = c.WriteBackchannel(b) + } + } + + c.sender.HandleRTP(track) + return nil +} + +func (c *Client) SetupBackchannel() (err error) { + // if conn1 is not used - we will use it for backchannel + // or we need to start another conn for session2 + if c.session1 != "" { + if c.conn2, err = c.newConn(); err != nil { + return + } + } else { + c.conn2 = c.conn1 + } + + c.session2, err = c.Request(c.conn2, []byte(`{"params":{"talk":{"mode":"aec"},"method":"get"},"seq":3,"type":"request"}`)) + return +} + +func (c *Client) WriteBackchannel(body []byte) (err error) { + // TODO: fixme (size) + buf := bytes.NewBuffer(nil) + buf.WriteString("----client-stream-boundary--\r\n") + buf.WriteString("Content-Type: audio/mp2t\r\n") + buf.WriteString("X-If-Encrypt: 0\r\n") + buf.WriteString("X-Session-Id: " + c.session2 + "\r\n") + buf.WriteString("Content-Length: " + strconv.Itoa(len(body)) + "\r\n\r\n") + buf.Write(body) + + _, err = buf.WriteTo(c.conn2) + return +} diff --git a/installs_on_host/go2rtc/pkg/tapo/client.go b/installs_on_host/go2rtc/pkg/tapo/client.go new file mode 100644 index 0000000..e52250c --- /dev/null +++ b/installs_on_host/go2rtc/pkg/tapo/client.go @@ -0,0 +1,407 @@ +package tapo + +import ( + "bufio" + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/md5" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/mpegts" + "github.com/AlexxIT/go2rtc/pkg/pcm" + "github.com/AlexxIT/go2rtc/pkg/tcp" +) + +// Deprecated: should be rewritten to core.Connection +type Client struct { + core.Listener + + url *url.URL + + medias []*core.Media + receivers []*core.Receiver + sender *core.Sender + + conn1 net.Conn + conn2 net.Conn + + decrypt func(b []byte) []byte + + session1 string + session2 string + request string + + recv int + send int +} + +// block ciphers using cipher block chaining. +type cbcMode interface { + cipher.BlockMode + SetIV([]byte) +} + +// Dial support different urls: +// - tapo://{cloud-password}@192.168.1.123 - auth to Tapo cameras +// with cloud password (autodetect hash method) +// - tapo://admin:{hashed-cloud-password}@192.168.1.123 - auth to Tapo cameras +// with pre-hashed cloud password +// - vigi://admin:{password}@192.168.1.123 - auth to Vigi cameras with password +// for admin account (other not supported) +func Dial(rawURL string) (*Client, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + if u.Port() == "" { + u.Host += ":8800" + } + + c := &Client{url: u} + if c.conn1, err = c.newConn(); err != nil { + return nil, err + } + return c, nil +} + +func (c *Client) newConn() (net.Conn, error) { + req, err := http.NewRequest("POST", "http://"+c.url.Host+"/stream", nil) + if err != nil { + return nil, err + } + + query := c.url.Query() + + if deviceId := query.Get("deviceId"); deviceId != "" { + req.URL.RawQuery = "deviceId=" + deviceId + } + + req.Header.Set("Content-Type", "multipart/mixed; boundary=--client-stream-boundary--") + + username := c.url.User.Username() + password, _ := c.url.User.Password() + + conn, res, err := dial(req, c.url.Scheme, username, password) + if err != nil { + return nil, err + } + + if res.StatusCode != http.StatusOK { + return nil, errors.New(res.Status) + } + + if c.decrypt == nil { + c.newDectypter(res, c.url.Scheme, username, password) + } + + channel := query.Get("channel") + if channel == "" { + channel = "0" + } + + subtype := query.Get("subtype") + switch subtype { + case "", "0": + subtype = "HD" + case "1": + subtype = "VGA" + } + + c.request = fmt.Sprintf( + `{"params":{"preview":{"audio":["default"],"channels":[%s],"resolutions":["%s"]},"method":"get"},"seq":1,"type":"request"}`, + channel, subtype, + ) + + return conn, nil +} + +func (c *Client) newDectypter(res *http.Response, brand, username, password string) { + exchange := res.Header.Get("Key-Exchange") + nonce := core.Between(exchange, `nonce="`, `"`) + + if brand == "tapo" && password == "" { + if strings.Contains(exchange, `encrypt_type="3"`) { + password = fmt.Sprintf("%32X", sha256.Sum256([]byte(username))) + } else { + password = fmt.Sprintf("%16X", md5.Sum([]byte(username))) + } + username = "admin" + } + + if strings.Contains(exchange, `username="none"`) { + // https://nvd.nist.gov/vuln/detail/CVE-2022-37255 + username = "none" + password = "TPL075526460603" + } + + key := md5.Sum([]byte(nonce + ":" + password)) + iv := md5.Sum([]byte(username + ":" + nonce)) + + block, err := aes.NewCipher(key[:]) + if err != nil { + return + } + + cbc := cipher.NewCBCDecrypter(block, iv[:]).(cbcMode) + + c.decrypt = func(b []byte) []byte { + // restore IV + cbc.SetIV(iv[:]) + + // decrypt + cbc.CryptBlocks(b, b) + + // unpad + n := len(b) + padSize := int(b[n-1]) + return b[:n-padSize] + } +} + +func (c *Client) SetupStream() (err error) { + if c.session1 != "" { + return + } + + // audio: default, disable, enable + c.session1, err = c.Request(c.conn1, []byte(c.request)) + return +} + +// Handle - first run will be in probe state +func (c *Client) Handle() error { + rd := multipart.NewReader(c.conn1, "--device-stream-boundary--") + demux := mpegts.NewDemuxer() + + var transcode func([]byte) []byte + + for { + p, err := rd.NextRawPart() + if err != nil { + return err + } + + if ct := p.Header.Get("Content-Type"); ct != "video/mp2t" { + continue + } + + cl := p.Header.Get("Content-Length") + size, err := strconv.Atoi(cl) + if err != nil { + return err + } + + c.recv += size + + body := make([]byte, size) + + b := body + for { + if n, err2 := p.Read(b); err2 == nil { + b = b[n:] + } else { + break + } + } + + body = c.decrypt(body) + bytesRd := bytes.NewReader(body) + + for { + pkt, err2 := demux.ReadPacket(bytesRd) + if pkt == nil || err2 == io.EOF { + break + } + if err2 != nil { + return err2 + } + + if pkt.PayloadType == mpegts.StreamTypePCMUTapo { + // TODO: rewrite this part in the future + // Some cameras in the new firmware began to use PCMU/16000. + // https://github.com/AlexxIT/go2rtc/issues/1954 + // I don't know why Tapo considers this an improvement. The codec is no better than the previous one. + // Unfortunately, we don't know in advance what codec the camera will use. + // Therefore, it's easier to transcode to a standard codec that all Tapo cameras have. + if transcode == nil { + transcode = pcm.Transcode( + &core.Codec{Name: core.CodecPCMA, ClockRate: 8000}, + &core.Codec{Name: core.CodecPCMU, ClockRate: 16000}, + ) + } + pkt.PayloadType = mpegts.StreamTypePCMATapo + pkt.Payload = transcode(pkt.Payload) + } + + for _, receiver := range c.receivers { + if receiver.ID == pkt.PayloadType { + mpegts.TimestampToRTP(pkt, receiver.Codec) + receiver.WriteRTP(pkt) + break + } + } + } + } +} + +func (c *Client) Close() (err error) { + if c.conn1 != nil { + err = c.conn1.Close() + } + if c.conn2 != nil { + _ = c.conn2.Close() + } + return +} + +func (c *Client) Request(conn net.Conn, body []byte) (string, error) { + // TODO: fixme (size) + buf := bytes.NewBuffer(nil) + buf.WriteString("----client-stream-boundary--\r\n") + buf.WriteString("Content-Type: application/json\r\n") + buf.WriteString("Content-Length: " + strconv.Itoa(len(body)) + "\r\n\r\n") + buf.Write(body) + buf.WriteString("\r\n") + + if _, err := buf.WriteTo(conn); err != nil { + return "", err + } + + mpReader := multipart.NewReader(conn, "--device-stream-boundary--") + + for { + p, err := mpReader.NextRawPart() + if err != nil { + return "", err + } + + var v struct { + Params struct { + SessionID string `json:"session_id"` + } `json:"params"` + } + + if err = json.NewDecoder(p).Decode(&v); err != nil { + return "", err + } + + return v.Params.SessionID, nil + } +} + +func dial(req *http.Request, brand, username, password string) (net.Conn, *http.Response, error) { + conn, err := net.DialTimeout("tcp", req.URL.Host, core.ConnDialTimeout) + if err != nil { + return nil, nil, err + } + + if err = req.Write(conn); err != nil { + return nil, nil, err + } + + r := bufio.NewReader(conn) + + res, err := http.ReadResponse(r, req) + if err != nil { + return nil, nil, err + } + _, _ = io.Copy(io.Discard, res.Body) // discard leftovers + _ = res.Body.Close() // ignore response body + + auth := res.Header.Get("WWW-Authenticate") + + if res.StatusCode != http.StatusUnauthorized || !strings.HasPrefix(auth, "Digest") { + return nil, nil, errors.New("tapo: wrond status: " + res.Status) + } + + if brand == "tapo" && password == "" { + // support cloud password in place of username + if strings.Contains(auth, `encrypt_type="3"`) { + password = fmt.Sprintf("%32X", sha256.Sum256([]byte(username))) + } else { + password = fmt.Sprintf("%16X", md5.Sum([]byte(username))) + } + username = "admin" + } else if brand == "vigi" && username == "admin" { + password = securityEncode(password) + } + + realm := tcp.Between(auth, `realm="`, `"`) + nonce := tcp.Between(auth, `nonce="`, `"`) + qop := tcp.Between(auth, `qop="`, `"`) + uri := req.URL.RequestURI() + ha1 := tcp.HexMD5(username, realm, password) + ha2 := tcp.HexMD5(req.Method, uri) + nc := "00000001" + cnonce := core.RandString(32, 64) + response := tcp.HexMD5(ha1, nonce, nc, cnonce, qop, ha2) + + // https://datatracker.ietf.org/doc/html/rfc7616 + header := fmt.Sprintf( + `Digest username="%s", realm="%s", nonce="%s", uri="%s", qop=%s, nc=%s, cnonce="%s", response="%s"`, + username, realm, nonce, uri, qop, nc, cnonce, response, + ) + + if opaque := tcp.Between(auth, `opaque="`, `"`); opaque != "" { + header += fmt.Sprintf(`, opaque="%s", algorithm=MD5`, opaque) + } + + req.Header.Set("Authorization", header) + + if err = req.Write(conn); err != nil { + return nil, nil, err + } + + if res, err = http.ReadResponse(r, req); err != nil { + return nil, nil, err + } + + return conn, res, nil +} + +const ( + keyShort = "RDpbLfCPsJZ7fiv" + keyLong = "yLwVl0zKqws7LgKPRQ84Mdt708T1qQ3Ha7xv3H7NyU84p21BriUWBU43odz3iP4rBL3cD02KZciXTysVXiV8ngg6vL48rPJyAUw0HurW20xqxv9aYb4M9wK1Ae0wlro510qXeU07kV57fQMc8L6aLgMLwygtc0F10a0Dg70TOoouyFhdysuRMO51yY5ZlOZZLEal1h0t9YQW0Ko7oBwmCAHoic4HYbUyVeU3sfQ1xtXcPcf1aT303wAQhv66qzW" +) + +func securityEncode(s string) string { + size := len(s) + + var n int // max + if size > len(keyShort) { + n = size + } else { + n = len(keyShort) + } + + b := make([]byte, n) + + for i := 0; i < n; i++ { + c1 := 187 + c2 := 187 + if i >= size { + c1 = int(keyShort[i]) + } else if i >= len(keyShort) { + c2 = int(s[i]) + } else { + c1 = int(keyShort[i]) + c2 = int(s[i]) + } + b[i] = keyLong[(c1^c2)%len(keyLong)] + } + + return string(b) +} diff --git a/installs_on_host/go2rtc/pkg/tapo/producer.go b/installs_on_host/go2rtc/pkg/tapo/producer.go new file mode 100644 index 0000000..87a91ff --- /dev/null +++ b/installs_on_host/go2rtc/pkg/tapo/producer.go @@ -0,0 +1,94 @@ +package tapo + +import ( + "encoding/json" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/mpegts" +) + +func (c *Client) GetMedias() []*core.Media { + if c.medias == nil { + // don't know if all Tapo has this capabilities... + c.medias = []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + {Name: core.CodecH264, ClockRate: 90000, PayloadType: core.PayloadTypeRAW}, + }, + }, + { + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + {Name: core.CodecPCMA, ClockRate: 8000, PayloadType: 8}, + }, + }, + { + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecPCMA, ClockRate: 8000, PayloadType: 8}, + }, + }, + } + } + + return c.medias +} + +func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + for _, track := range c.receivers { + if track.Codec == codec { + return track, nil + } + } + + if err := c.SetupStream(); err != nil { + return nil, err + } + + track := core.NewReceiver(media, codec) + switch media.Kind { + case core.KindVideo: + track.ID = mpegts.StreamTypeH264 + case core.KindAudio: + track.ID = mpegts.StreamTypePCMATapo + } + c.receivers = append(c.receivers, track) + return track, nil +} + +func (c *Client) Start() error { + return c.Handle() +} + +func (c *Client) Stop() error { + for _, receiver := range c.receivers { + receiver.Close() + } + if c.sender != nil { + c.sender.Close() + } + return c.Close() +} + +func (c *Client) MarshalJSON() ([]byte, error) { + info := &core.Connection{ + ID: core.ID(c), + FormatName: c.url.Scheme, + Protocol: "http", + Medias: c.medias, + Recv: c.recv, + Receivers: c.receivers, + Send: c.send, + } + if c.sender != nil { + info.Senders = []*core.Sender{c.sender} + } + if c.conn1 != nil { + info.RemoteAddr = c.conn1.RemoteAddr().String() + } + return json.Marshal(info) +} diff --git a/installs_on_host/go2rtc/pkg/tcp/auth.go b/installs_on_host/go2rtc/pkg/tcp/auth.go new file mode 100644 index 0000000..9cc56ba --- /dev/null +++ b/installs_on_host/go2rtc/pkg/tcp/auth.go @@ -0,0 +1,140 @@ +package tcp + +import ( + "crypto/md5" + "encoding/base64" + "encoding/hex" + "fmt" + "net/url" + "strings" +) + +type Auth struct { + Method byte + user string + pass string + header string + h1nonce string +} + +const ( + AuthNone byte = iota + AuthUnknown + AuthBasic + AuthDigest + AuthTPLink // https://drmnsamoliu.github.io/video.html +) + +func NewAuth(user *url.Userinfo) *Auth { + a := new(Auth) + a.user = user.Username() + a.pass, _ = user.Password() + if a.user != "" { + a.Method = AuthUnknown + } + return a +} + +func (a *Auth) Read(res *Response) bool { + auth := res.Header.Get("WWW-Authenticate") + if len(auth) < 6 { + return false + } + + switch auth[:6] { + case "Basic ": + a.header = "Basic " + B64(a.user, a.pass) + a.Method = AuthBasic + return true + case "Digest": + realm := Between(auth, `realm="`, `"`) + nonce := Between(auth, `nonce="`, `"`) + + a.h1nonce = HexMD5(a.user, realm, a.pass) + ":" + nonce + a.header = fmt.Sprintf( + `Digest username="%s", realm="%s", nonce="%s"`, + a.user, realm, nonce, + ) + a.Method = AuthDigest + return true + default: + return false + } +} + +func (a *Auth) Write(req *Request) { + if a == nil { + return + } + + switch a.Method { + case AuthBasic: + req.Header.Set("Authorization", a.header) + case AuthDigest: + // important to use String except RequestURL for RtspServer: + // https://github.com/AlexxIT/go2rtc/issues/244 + uri := req.URL.String() + h2 := HexMD5(req.Method, uri) + response := HexMD5(a.h1nonce, h2) + header := a.header + fmt.Sprintf( + `, uri="%s", response="%s"`, uri, response, + ) + req.Header.Set("Authorization", header) + case AuthTPLink: + req.URL.Host = "127.0.0.1" + } +} + +func (a *Auth) Validate(req *Request) (valid, empty bool) { + if a == nil { + return true, true + } + + header := req.Header.Get("Authorization") + if header == "" { + return false, true + } + + if a.Method == AuthUnknown { + a.Method = AuthBasic + a.header = "Basic " + B64(a.user, a.pass) + } + + return header == a.header, false +} + +func (a *Auth) ReadNone(res *Response) bool { + auth := res.Header.Get("WWW-Authenticate") + if strings.Contains(auth, "TP-LINK Streaming Media") { + a.Method = AuthTPLink + return true + } + return false +} + +func (a *Auth) UserInfo() *url.Userinfo { + return url.UserPassword(a.user, a.pass) +} + +func Between(s, sub1, sub2 string) string { + i := strings.Index(s, sub1) + if i < 0 { + return "" + } + s = s[i+len(sub1):] + i = strings.Index(s, sub2) + if i < 0 { + return "" + } + return s[:i] +} + +func HexMD5(s ...string) string { + b := md5.Sum([]byte(strings.Join(s, ":"))) + return hex.EncodeToString(b[:]) +} + +func B64(s ...string) string { + b := []byte(strings.Join(s, ":")) + return base64.StdEncoding.EncodeToString(b) +} diff --git a/installs_on_host/go2rtc/pkg/tcp/dial.go b/installs_on_host/go2rtc/pkg/tcp/dial.go new file mode 100644 index 0000000..447d4d3 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/tcp/dial.go @@ -0,0 +1,64 @@ +package tcp + +import ( + "crypto/tls" + "errors" + "net" + "net/url" + "strings" + "time" +) + +// Dial - for RTSP(S|X) and RTMP(S|X) +func Dial(u *url.URL, timeout time.Duration) (net.Conn, error) { + var address string + var hostname string // without port + if i := strings.IndexByte(u.Host, ':'); i > 0 { + address = u.Host + hostname = u.Host[:i] + } else { + switch u.Scheme { + case "rtsp", "rtsps", "rtspx": + address = u.Host + ":554" + case "rtmp": + address = u.Host + ":1935" + case "rtmps", "rtmpx": + address = u.Host + ":443" + } + hostname = u.Host + } + + var secure *tls.Config + + switch u.Scheme { + case "rtsp", "rtmp": + case "rtsps", "rtspx", "rtmps", "rtmpx": + if u.Scheme[4] == 'x' || IsIP(hostname) { + secure = &tls.Config{InsecureSkipVerify: true} + } else { + secure = &tls.Config{ServerName: hostname} + } + default: + return nil, errors.New("unsupported scheme: " + u.Scheme) + } + + conn, err := net.DialTimeout("tcp", address, timeout) + if err != nil { + return nil, err + } + + if secure == nil { + return conn, nil + } + + tlsConn := tls.Client(conn, secure) + if err = tlsConn.Handshake(); err != nil { + return nil, err + } + + if u.Scheme[4] == 'x' { + u.Scheme = u.Scheme[:4] + "s" + } + + return tlsConn, nil +} diff --git a/installs_on_host/go2rtc/pkg/tcp/request.go b/installs_on_host/go2rtc/pkg/tcp/request.go new file mode 100644 index 0000000..74fd413 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/tcp/request.go @@ -0,0 +1,172 @@ +package tcp + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net" + "net/http" + "strings" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +// Do - http.Client with support Digest Authorization +func Do(req *http.Request) (*http.Response, error) { + var secure *tls.Config + + switch req.URL.Scheme { + case "httpx": + secure = insecureConfig + req.URL.Scheme = "https" + case "https": + if hostname := req.URL.Hostname(); IsIP(hostname) { + secure = insecureConfig + } + } + + if secure != nil { + ctx := context.WithValue(req.Context(), secureKey, secure) + req = req.WithContext(ctx) + } + + if client == nil { + transport := http.DefaultTransport.(*http.Transport).Clone() + + dial := transport.DialContext + transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + conn, err := dial(ctx, network, addr) + if pconn, ok := ctx.Value(connKey).(*net.Conn); ok { + *pconn = conn + } + return conn, err + } + transport.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + conn, err := dial(ctx, network, addr) + if err != nil { + return nil, err + } + + var conf *tls.Config + if v, ok := ctx.Value(secureKey).(*tls.Config); ok { + conf = v + } else if host, _, err := net.SplitHostPort(addr); err != nil { + conf = &tls.Config{ServerName: addr} + } else { + conf = &tls.Config{ServerName: host} + } + + tlsConn := tls.Client(conn, conf) + if err = tlsConn.Handshake(); err != nil { + return nil, err + } + + if pconn, ok := ctx.Value(connKey).(*net.Conn); ok { + *pconn = tlsConn + } + return tlsConn, err + } + + client = &http.Client{Transport: transport} + } + + user := req.URL.User + + // Hikvision won't answer on Basic auth with any headers + if strings.HasPrefix(req.URL.Path, "/ISAPI/") { + req.URL.User = nil + } + + res, err := client.Do(req) + if err != nil { + return nil, err + } + + if res.StatusCode == http.StatusUnauthorized && user != nil { + Close(res) + + auth := res.Header.Get("WWW-Authenticate") + if !strings.HasPrefix(auth, "Digest") { + return nil, errors.New("unsupported auth: " + auth) + } + + realm := Between(auth, `realm="`, `"`) + nonce := Between(auth, `nonce="`, `"`) + qop := Between(auth, `qop="`, `"`) + + username := user.Username() + password, _ := user.Password() + ha1 := HexMD5(username, realm, password) + + uri := req.URL.RequestURI() + ha2 := HexMD5(req.Method, uri) + + var header string + + switch qop { + case "": + response := HexMD5(ha1, nonce, ha2) + header = fmt.Sprintf( + `Digest username="%s", realm="%s", nonce="%s", uri="%s", response="%s"`, + username, realm, nonce, uri, response, + ) + case "auth": + nc := "00000001" + cnonce := core.RandString(32, 64) + response := HexMD5(ha1, nonce, nc, cnonce, qop, ha2) + header = fmt.Sprintf( + `Digest username="%s", realm="%s", nonce="%s", uri="%s", qop=%s, nc=%s, cnonce="%s", response="%s"`, + username, realm, nonce, uri, qop, nc, cnonce, response, + ) + default: + return nil, errors.New("unsupported qop: " + auth) + } + + req.Header.Set("Authorization", header) + + if res, err = client.Do(req); err != nil { + return nil, err + } + } + + return res, nil +} + +var client *http.Client + +type key string + +var connKey = key("conn") +var secureKey = key("secure") + +var insecureConfig = &tls.Config{ + InsecureSkipVerify: true, + CipherSuites: []uint16{ + tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, + tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, + tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, + + // this cipher suites disabled starting from https://tip.golang.org/doc/go1.22 + // but cameras can't work without them https://github.com/AlexxIT/go2rtc/issues/1172 + tls.TLS_RSA_WITH_AES_128_GCM_SHA256, // insecure + tls.TLS_RSA_WITH_AES_256_GCM_SHA384, // insecure + }, +} + +func WithConn() (context.Context, *net.Conn) { + pconn := new(net.Conn) + return context.WithValue(context.Background(), connKey, pconn), pconn +} + +func Close(res *http.Response) { + if res.Body != nil { + _ = res.Body.Close() + } +} + +func IsIP(hostname string) bool { + return net.ParseIP(hostname) != nil +} diff --git a/installs_on_host/go2rtc/pkg/tcp/textproto.go b/installs_on_host/go2rtc/pkg/tcp/textproto.go new file mode 100644 index 0000000..c629015 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/tcp/textproto.go @@ -0,0 +1,150 @@ +package tcp + +import ( + "bufio" + "errors" + "fmt" + "io" + "net/textproto" + "net/url" + "strconv" + "strings" +) + +const EndLine = "\r\n" + +// Response like http.Response, but with any proto +type Response struct { + Status string + StatusCode int + Proto string + Header textproto.MIMEHeader + Body []byte + Request *Request +} + +func (r Response) String() string { + s := r.Proto + " " + r.Status + EndLine + for k, v := range r.Header { + s += k + ": " + v[0] + EndLine + } + s += EndLine + if r.Body != nil { + s += string(r.Body) + } + return s +} + +func (r *Response) Write(w io.Writer) (err error) { + _, err = w.Write([]byte(r.String())) + return +} + +func ReadResponse(r *bufio.Reader) (*Response, error) { + tp := textproto.NewReader(r) + + line, err := tp.ReadLine() + if err != nil { + return nil, err + } + if line == "" { + return nil, errors.New("empty response on RTSP request") + } + + ss := strings.SplitN(line, " ", 3) + if len(ss) != 3 { + return nil, fmt.Errorf("malformed response: %s", line) + } + + res := &Response{ + Status: ss[1] + " " + ss[2], + Proto: ss[0], + } + + res.StatusCode, err = strconv.Atoi(ss[1]) + if err != nil { + return nil, err + } + + res.Header, err = tp.ReadMIMEHeader() + if err != nil { + return nil, err + } + + if val := res.Header.Get("Content-Length"); val != "" { + var i int + i, err = strconv.Atoi(val) + res.Body = make([]byte, i) + if _, err = io.ReadAtLeast(r, res.Body, i); err != nil { + return nil, err + } + } + + return res, nil +} + +// Request like http.Request, but with any proto +type Request struct { + Method string + URL *url.URL + Proto string + Header textproto.MIMEHeader + Body []byte +} + +func (r *Request) String() string { + s := r.Method + " " + r.URL.String() + " " + r.Proto + EndLine + for k, v := range r.Header { + s += k + ": " + v[0] + EndLine + } + s += EndLine + if r.Body != nil { + s += string(r.Body) + } + return s +} + +func (r *Request) Write(w io.Writer) (err error) { + _, err = w.Write([]byte(r.String())) + return +} + +func ReadRequest(r *bufio.Reader) (*Request, error) { + tp := textproto.NewReader(r) + + line, err := tp.ReadLine() + if err != nil { + return nil, err + } + + ss := strings.SplitN(line, " ", 3) + if len(ss) != 3 { + return nil, fmt.Errorf("wrong request: %s", line) + } + + req := &Request{ + Method: ss[0], + Proto: ss[2], + } + + req.URL, err = url.Parse(ss[1]) + if err != nil { + return nil, err + } + + req.Header, err = tp.ReadMIMEHeader() + if err != nil { + return nil, err + } + + if val := req.Header.Get("Content-Length"); val != "" { + var i int + i, err = strconv.Atoi(val) + req.Body = make([]byte, i) + if _, err = io.ReadAtLeast(r, req.Body, i); err != nil { + return nil, err + } + } + + return req, nil +} diff --git a/installs_on_host/go2rtc/pkg/tcp/textproto_test.go b/installs_on_host/go2rtc/pkg/tcp/textproto_test.go new file mode 100644 index 0000000..08bc5a1 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/tcp/textproto_test.go @@ -0,0 +1,30 @@ +package tcp + +import ( + "bufio" + "bytes" + "net/http" + "testing" +) + +func assert(t *testing.T, one, two any) { + if one != two { + t.FailNow() + } +} + +func TestName(t *testing.T) { + data := []byte(`RTSP/1.0 401 Unauthorized +WWW-Authenticate: Digest realm="testrealm@host.com", + nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", + +`) + + buf := bytes.NewBuffer(data) + r := bufio.NewReader(buf) + + res, err := ReadResponse(r) + assert(t, err, nil) + + assert(t, res.StatusCode, http.StatusUnauthorized) +} diff --git a/installs_on_host/go2rtc/pkg/tcp/websocket/client.go b/installs_on_host/go2rtc/pkg/tcp/websocket/client.go new file mode 100644 index 0000000..e95ce1e --- /dev/null +++ b/installs_on_host/go2rtc/pkg/tcp/websocket/client.go @@ -0,0 +1,130 @@ +package websocket + +import ( + cryptorand "crypto/rand" + "encoding/binary" + "fmt" + "io" + "net" + "time" +) + +const BinaryMessage = 2 + +type Client struct { + conn net.Conn + remain int +} + +func NewClient(conn net.Conn) *Client { + return &Client{conn: conn} +} + +const finalBit = 0x80 +const maskBit = 0x80 + +func (w *Client) Read(b []byte) (n int, err error) { + if w.remain == 0 { + b2 := make([]byte, 2) + if _, err = io.ReadFull(w.conn, b2); err != nil { + return 0, err + } + + frameType := b2[0] & 0xF + w.remain = int(b2[1] & 0x7F) + + switch frameType { + case BinaryMessage: + default: + return 0, fmt.Errorf("unsupported frame type: %d", frameType) + } + + switch w.remain { + case 126: + if _, err = io.ReadFull(w.conn, b2); err != nil { + return 0, err + } + w.remain = int(binary.BigEndian.Uint16(b2)) + case 127: + b8 := make([]byte, 8) + if _, err = io.ReadFull(w.conn, b8); err != nil { + return 0, err + } + w.remain = int(binary.BigEndian.Uint64(b8)) + } + } + + if w.remain > len(b) { + n, err = io.ReadFull(w.conn, b) + w.remain -= n + return + } + + n, err = io.ReadFull(w.conn, b[:w.remain]) + w.remain = 0 + + return +} + +func (w *Client) Write(b []byte) (n int, err error) { + var data []byte + var start byte + + size := len(b) + + switch { + case size > 65535: + start = 10 + data = make([]byte, size+14) + data[1] = maskBit | 127 + binary.BigEndian.PutUint64(data[2:], uint64(size)) + case size > 125: + start = 4 + data = make([]byte, size+8) + data[1] = maskBit | 126 + binary.BigEndian.PutUint16(data[2:], uint16(size)) + default: + start = 2 + data = make([]byte, size+6) + data[1] = maskBit | byte(size) + } + + data[0] = BinaryMessage | finalBit + + mask := data[start : start+4] + msg := data[start+4:] + + if _, err = cryptorand.Read(mask); err != nil { + return 0, err + } + + for i := 0; i < len(b); i++ { + msg[i] = b[i] ^ mask[i%4] + } + + return w.conn.Write(data) +} + +func (w *Client) Close() error { + return w.conn.Close() +} + +func (w *Client) LocalAddr() net.Addr { + return w.conn.LocalAddr() +} + +func (w *Client) RemoteAddr() net.Addr { + return w.conn.RemoteAddr() +} + +func (w *Client) SetDeadline(t time.Time) error { + return w.conn.SetDeadline(t) +} + +func (w *Client) SetReadDeadline(t time.Time) error { + return w.conn.SetReadDeadline(t) +} + +func (w *Client) SetWriteDeadline(t time.Time) error { + return w.conn.SetWriteDeadline(t) +} diff --git a/installs_on_host/go2rtc/pkg/tcp/websocket/dial.go b/installs_on_host/go2rtc/pkg/tcp/websocket/dial.go new file mode 100644 index 0000000..3e1fd48 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/tcp/websocket/dial.go @@ -0,0 +1,65 @@ +package websocket + +import ( + cryptorand "crypto/rand" + "crypto/sha1" + "encoding/base64" + "errors" + "net" + "net/http" + "strings" + + "github.com/AlexxIT/go2rtc/pkg/tcp" +) + +func Dial(address string) (net.Conn, error) { + if strings.HasPrefix(address, "ws") { + address = "http" + address[2:] // support http and https + } + + // using custom client for support Digest Auth + // https://github.com/AlexxIT/go2rtc/issues/415 + ctx, pconn := tcp.WithConn() + + req, err := http.NewRequestWithContext(ctx, "GET", address, nil) + if err != nil { + return nil, err + } + + key, accept := GetKeyAccept() + + // Version, Key, Protocol important for Axis cameras + req.Header.Set("Connection", "Upgrade") + req.Header.Set("Upgrade", "websocket") + req.Header.Set("Sec-WebSocket-Version", "13") + req.Header.Set("Sec-WebSocket-Key", key) + req.Header.Set("Sec-WebSocket-Protocol", "binary") + + res, err := tcp.Do(req) + if err != nil { + return nil, err + } + + if res.StatusCode != http.StatusSwitchingProtocols { + return nil, errors.New("wrong status: " + res.Status) + } + + if res.Header.Get("Sec-Websocket-Accept") != accept { + return nil, errors.New("wrong websocket accept") + } + + return NewClient(*pconn), nil +} + +func GetKeyAccept() (key, accept string) { + b := make([]byte, 16) + _, _ = cryptorand.Read(b) + key = base64.StdEncoding.EncodeToString(b) + + h := sha1.New() + h.Write([]byte(key)) + h.Write([]byte("258EAFA5-E914-47DA-95CA-C5AB0DC85B11")) + accept = base64.StdEncoding.EncodeToString(h.Sum(nil)) + + return +} diff --git a/installs_on_host/go2rtc/pkg/tutk/codec.go b/installs_on_host/go2rtc/pkg/tutk/codec.go new file mode 100644 index 0000000..9ec7d8c --- /dev/null +++ b/installs_on_host/go2rtc/pkg/tutk/codec.go @@ -0,0 +1,59 @@ +package tutk + +// https://github.com/seydx/tutk_wyze#11-codec-reference +const ( + CodecMPEG4 byte = 0x4C + CodecH263 byte = 0x4D + CodecH264 byte = 0x4E + CodecMJPEG byte = 0x4F + CodecH265 byte = 0x50 +) + +const ( + CodecAACRaw byte = 0x86 + CodecAACADTS byte = 0x87 + CodecAACLATM byte = 0x88 + CodecPCMU byte = 0x89 + CodecPCMA byte = 0x8A + CodecADPCM byte = 0x8B + CodecPCML byte = 0x8C + CodecSPEEX byte = 0x8D + CodecMP3 byte = 0x8E + CodecG726 byte = 0x8F + CodecAACAlt byte = 0x90 + CodecOpus byte = 0x92 +) + +var sampleRates = [9]uint32{8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000} + +func GetSampleRateIndex(sampleRate uint32) uint8 { + for i, rate := range sampleRates { + if rate == sampleRate { + return uint8(i) + } + } + return 3 // default 16kHz +} + +func GetSamplesPerFrame(codecID byte) uint32 { + switch codecID { + case CodecAACRaw, CodecAACADTS, CodecAACLATM, CodecAACAlt: + return 1024 + case CodecPCMU, CodecPCMA, CodecPCML, CodecADPCM, CodecSPEEX, CodecG726: + return 160 + case CodecMP3: + return 1152 + case CodecOpus: + return 960 + default: + return 1024 + } +} + +func IsVideoCodec(id byte) bool { + return id >= CodecMPEG4 && id <= CodecH265 +} + +func IsAudioCodec(id byte) bool { + return id >= CodecAACRaw && id <= CodecOpus +} diff --git a/installs_on_host/go2rtc/pkg/tutk/conn.go b/installs_on_host/go2rtc/pkg/tutk/conn.go new file mode 100644 index 0000000..e061069 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/tutk/conn.go @@ -0,0 +1,264 @@ +package tutk + +import ( + "fmt" + "io" + "net" + "sync" + "sync/atomic" + "time" +) + +func Dial(host, uid, username, password string) (*Conn, error) { + addr, err := net.ResolveUDPAddr("udp", host) + if err != nil { + // Default port for listening incoming LAN connections. + // Important. It's not using for real connection. + addr = &net.UDPAddr{IP: net.ParseIP(host), Port: 32761} + } + + udpConn, err := net.ListenUDP("udp", nil) + if err != nil { + return nil, err + } + + c := &Conn{UDPConn: udpConn, addr: addr} + + sid := GenSessionID() + + _ = c.SetDeadline(time.Now().Add(5 * time.Second)) + + if addr.Port != 10001 { + err = c.connectDirect(uid, sid) + } else { + err = c.connectRemote(uid, sid) + } + if err != nil { + _ = c.Close() + return nil, err + } + + if c.ver[0] >= 25 { + c.session = NewSession25(c, sid) + } else { + c.session = NewSession16(c, sid) + } + + if err = c.clientStart(username, password); err != nil { + _ = c.Close() + return nil, err + } + + go c.worker() + + return c, nil +} + +type Conn struct { + *net.UDPConn + addr *net.UDPAddr + session Session + + ver []byte + err error + cmdMu sync.Mutex + cmdAck func() +} + +// Read overwrite net.Conn +func (c *Conn) Read(buf []byte) (n int, err error) { + for { + var addr *net.UDPAddr + if n, addr, err = c.UDPConn.ReadFromUDP(buf); err != nil { + return 0, err + } + + if string(c.addr.IP) != string(addr.IP) || n < 16 { + continue // skip messages from another IP + } + + if c.addr.Port != addr.Port { + c.addr.Port = addr.Port + } + + ReverseTransCodePartial(buf, buf[:n]) + //log.Printf("<- %x", buf[:n]) + return n, nil + } +} + +// Write overwrite net.Conn +func (c *Conn) Write(b []byte) (n int, err error) { + //log.Printf("-> %x", b) + return c.UDPConn.WriteToUDP(TransCodePartial(nil, b), c.addr) +} + +// RemoteAddr overwrite net.Conn +func (c *Conn) RemoteAddr() net.Addr { + return c.addr +} + +func (c *Conn) Protocol() string { + return "tutk+udp" +} + +func (c *Conn) Version() string { + if len(c.ver) == 1 { + return fmt.Sprintf("TUTK/%d", c.ver[0]) + } + return fmt.Sprintf("TUTK/%d SDK %d.%d.%d.%d", c.ver[0], c.ver[1], c.ver[2], c.ver[3], c.ver[4]) +} + +func (c *Conn) ReadCommand() (ctrlType uint32, ctrlData []byte, err error) { + return c.session.RecvIOCtrl() +} + +func (c *Conn) WriteCommand(ctrlType uint32, ctrlData []byte) error { + c.cmdMu.Lock() + defer c.cmdMu.Unlock() + + var repeat atomic.Int32 + repeat.Store(5) + + timeout := time.NewTicker(time.Second) + defer timeout.Stop() + + c.cmdAck = func() { + repeat.Store(0) + timeout.Reset(1) + } + + buf := c.session.SendIOCtrl(ctrlType, ctrlData) + + for { + if err := c.session.SessionWrite(0, buf); err != nil { + return err + } + <-timeout.C + r := repeat.Add(-1) + if r < 0 { + return nil + } + if r == 0 { + return fmt.Errorf("%s: can't send command %d", "tutk", ctrlType) + } + } +} + +func (c *Conn) ReadPacket() (hdr, payload []byte, err error) { + return c.session.RecvFrameData() +} + +func (c *Conn) WritePacket(hdr, payload []byte) error { + buf := c.session.SendFrameData(hdr, payload) + return c.session.SessionWrite(1, buf) +} + +func (c *Conn) Error() error { + if c.err != nil { + return c.err + } + return io.EOF +} + +func (c *Conn) worker() { + defer c.session.Close() + + buf := make([]byte, 1200) + + for { + n, err := c.Read(buf) + if err != nil { + c.err = fmt.Errorf("%s: %w", "tutk", err) + return + } + + switch c.handleMsg(buf[:n]) { + case msgUnknown: + fmt.Printf("tutk: unknown msg: %x\n", buf[:n]) + case msgError: + return + case msgCommandAck: + if c.cmdAck != nil { + c.cmdAck() + } + } + } +} + +const ( + msgUnknown = iota + msgError + msgPing + msgUnknownPing + msgClientStart + msgClientStart2 + msgClientStartAck2 + msgCommand + msgCommandAck + msgCounters + msgMediaChunk + msgMediaFrame + msgMediaReorder + msgMediaLost + msgCh5 + + msgUnknown0007 // time sync without data? + msgUnknown0008 // time sync with data? + msgUnknown0010 + msgUnknown0013 + msgUnknown0900 + msgUnknown0a08 + msgUnknownCh1c + msgDafang0012 +) + +func (c *Conn) handleMsg(msg []byte) int { + // off sample + // 0 0402 tutk magic + // 2 120a tutk version (120a, 190a...) + // 4 0800 msg size = len(b)-16 + // 6 0000 channel seq + // 8 28041200 msg type + // 14 0100 channel (not all msg) + // 28 0700 msg data (not all msg) + switch msg[8] { + case 0x08: + switch ch := msg[14]; ch { + case 0, 1: + return c.session.SessionRead(ch, msg[28:]) + case 5: + if len(msg) == 48 { + _, _ = c.Write(msgAckCh5(msg)) + return msgCh5 + } + case 0x1c: + return msgUnknownCh1c + } + case 0x18: + return msgUnknownPing + case 0x28: + if len(msg) == 24 { + _, _ = c.Write(msgAckPing(msg)) + return msgPing + } + } + return msgUnknown +} + +func msgAckPing(msg []byte) []byte { + // <- [24] 0402120a 08000000 28041200 000000005b0d4202070aa8c0 + // -> [24] 04021a0a 08000000 27042100 000000005b0d4202070aa8c0 + msg[8] = 0x27 + msg[10] = 0x21 + return msg +} + +func msgAckCh5(msg []byte) []byte { + // <- [48] 0402190a 20000400 07042100 7ecc05000c0000007ecc93c456c2561f 5a97c2f101050000000000000000000000010000 + // -> [48] 0402190a 20000400 08041200 7ecc05000c0000007ecc93c456c2561f 5a97c2f141050000000000000000000000010000 + msg[8] = 0x07 + msg[10] = 0x21 + msg[32] = 0x41 + return msg +} diff --git a/installs_on_host/go2rtc/pkg/tutk/crypto.go b/installs_on_host/go2rtc/pkg/tutk/crypto.go new file mode 100644 index 0000000..469bd2b --- /dev/null +++ b/installs_on_host/go2rtc/pkg/tutk/crypto.go @@ -0,0 +1,279 @@ +package tutk + +import ( + "encoding/binary" + "math/bits" +) + +// I'd like to say hello to Charlie. Your name is forever etched into the history of streaming software. +const charlie = "Charlie is the designer of P2P!!" + +func ReverseTransCodePartial(dst, src []byte) []byte { + n := len(src) + tmp := make([]byte, n) + if len(dst) < n { + dst = make([]byte, n) + } + + src16 := src + tmp16 := tmp + dst16 := dst + + for ; n >= 16; n -= 16 { + for i := 0; i != 16; i += 4 { + x := binary.LittleEndian.Uint32(src16[i:]) + binary.LittleEndian.PutUint32(tmp16[i:], bits.RotateLeft32(x, i+3)) + } + + swap(dst16, tmp16, 16) + + for i := 0; i != 16; i++ { + tmp16[i] = dst16[i] ^ charlie[i] + } + + for i := 0; i != 16; i += 4 { + x := binary.LittleEndian.Uint32(tmp16[i:]) + binary.LittleEndian.PutUint32(dst16[i:], bits.RotateLeft32(x, i+1)) + } + + tmp16 = tmp16[16:] + dst16 = dst16[16:] + src16 = src16[16:] + } + + swap(tmp16, src16, n) + + for i := 0; i < n; i++ { + dst16[i] = tmp16[i] ^ charlie[i] + } + + return dst +} + +func ReverseTransCodeBlob(src []byte) []byte { + if len(src) < 16 { + return ReverseTransCodePartial(nil, src) + } + + dst := make([]byte, len(src)) + header := ReverseTransCodePartial(nil, src[:16]) + copy(dst, header) + + if len(src) > 16 { + if dst[3]&1 != 0 { // Partial encryption (check decrypted header) + remaining := len(src) - 16 + decryptLen := min(remaining, 48) + if decryptLen > 0 { + decrypted := ReverseTransCodePartial(nil, src[16:16+decryptLen]) + copy(dst[16:], decrypted) + } + if remaining > 48 { + copy(dst[64:], src[64:]) + } + } else { // Full decryption + decrypted := ReverseTransCodePartial(nil, src[16:]) + copy(dst[16:], decrypted) + } + } + return dst +} + +func TransCodePartial(dst, src []byte) []byte { + n := len(src) + tmp := make([]byte, n) + if len(dst) < n { + dst = make([]byte, n) + } + + src16 := src + tmp16 := tmp + dst16 := dst + + for ; n >= 16; n -= 16 { + for i := 0; i != 16; i += 4 { + x := binary.LittleEndian.Uint32(src16[i:]) + binary.LittleEndian.PutUint32(tmp16[i:], bits.RotateLeft32(x, -i-1)) + } + + for i := 0; i != 16; i++ { + dst16[i] = tmp16[i] ^ charlie[i] + } + + swap(tmp16, dst16, 16) + + for i := 0; i != 16; i += 4 { + x := binary.LittleEndian.Uint32(tmp16[i:]) + binary.LittleEndian.PutUint32(dst16[i:], bits.RotateLeft32(x, -i-3)) + } + + tmp16 = tmp16[16:] + dst16 = dst16[16:] + src16 = src16[16:] + } + + for i := 0; i < n; i++ { + tmp16[i] = src16[i] ^ charlie[i] + } + + swap(dst16, tmp16, n) + + return dst +} + +func TransCodeBlob(src []byte) []byte { + if len(src) < 16 { + return TransCodePartial(nil, src) + } + + dst := make([]byte, len(src)) + header := TransCodePartial(nil, src[:16]) + copy(dst, header) + + if len(src) > 16 { + if src[3]&1 != 0 { // Partial encryption + remaining := len(src) - 16 + encryptLen := min(remaining, 48) + if encryptLen > 0 { + encrypted := TransCodePartial(nil, src[16:16+encryptLen]) + copy(dst[16:], encrypted) + } + if remaining > 48 { + copy(dst[64:], src[64:]) + } + } else { // Full encryption + encrypted := TransCodePartial(nil, src[16:]) + copy(dst[16:], encrypted) + } + } + return dst +} + +func swap(dst, src []byte, n int) { + switch n { + case 2: + _, _ = src[1], dst[1] + dst[0] = src[1] + dst[1] = src[0] + return + case 4: + _, _ = src[3], dst[3] + dst[0] = src[2] + dst[1] = src[3] + dst[2] = src[0] + dst[3] = src[1] + return + case 8: + _, _ = src[7], dst[7] + dst[0] = src[7] + dst[1] = src[4] + dst[2] = src[3] + dst[3] = src[2] + dst[4] = src[1] + dst[5] = src[6] + dst[6] = src[5] + dst[7] = src[0] + return + case 16: + _, _ = src[15], dst[15] + dst[0] = src[11] + dst[1] = src[9] + dst[2] = src[8] + dst[3] = src[15] + dst[4] = src[13] + dst[5] = src[10] + dst[6] = src[12] + dst[7] = src[14] + dst[8] = src[2] + dst[9] = src[1] + dst[10] = src[5] + dst[11] = src[0] + dst[12] = src[6] + dst[13] = src[4] + dst[14] = src[7] + dst[15] = src[3] + return + } + copy(dst, src[:n]) +} + +const delta = 0x9e3779b9 + +func XXTEADecrypt(dst, src, key []byte) { + const n = int8(4) // support only 16 bytes src + + var w, k [n]uint32 + for i := int8(0); i < n; i++ { + w[i] = binary.LittleEndian.Uint32(src) + k[i] = binary.LittleEndian.Uint32(key) + src = src[4:] + key = key[4:] + } + + rounds := 52/n + 6 + sum := uint32(rounds) * delta + for ; rounds > 0; rounds-- { + w0 := w[0] + i2 := int8((sum >> 2) & 3) + for i := n - 1; i >= 0; i-- { + wi := w[(i-1)&3] + ki := k[i^i2] + t1 := (w0 ^ sum) + (wi ^ ki) + t2 := (wi >> 5) ^ (w0 << 2) + t3 := (w0 >> 3) ^ (wi << 4) + w[i] -= t1 ^ (t2 + t3) + w0 = w[i] + } + sum -= delta + } + + for _, i := range w { + binary.LittleEndian.PutUint32(dst, i) + dst = dst[4:] + } +} + +func XXTEADecryptVar(data, key []byte) []byte { + if len(data) < 8 || len(key) < 16 { + return nil + } + + k := make([]uint32, 4) + for i := range 4 { + k[i] = binary.LittleEndian.Uint32(key[i*4:]) + } + + n := max(len(data)/4, 2) + v := make([]uint32, n) + for i := 0; i < len(data)/4; i++ { + v[i] = binary.LittleEndian.Uint32(data[i*4:]) + } + + rounds := 6 + 52/n + sum := uint32(rounds) * delta + y := v[0] + + for rounds > 0 { + e := (sum >> 2) & 3 + for p := n - 1; p > 0; p-- { + z := v[p-1] + v[p] -= xxteaMX(sum, y, z, p, e, k) + y = v[p] + } + z := v[n-1] + v[0] -= xxteaMX(sum, y, z, 0, e, k) + y = v[0] + sum -= delta + rounds-- + } + + result := make([]byte, n*4) + for i := range n { + binary.LittleEndian.PutUint32(result[i*4:], v[i]) + } + + return result[:len(data)] +} + +func xxteaMX(sum, y, z uint32, p int, e uint32, k []uint32) uint32 { + return ((z>>5 ^ y<<2) + (y>>3 ^ z<<4)) ^ ((sum ^ y) + (k[(p&3)^int(e)] ^ z)) +} diff --git a/installs_on_host/go2rtc/pkg/tutk/crypto_test.go b/installs_on_host/go2rtc/pkg/tutk/crypto_test.go new file mode 100644 index 0000000..1f1be3f --- /dev/null +++ b/installs_on_host/go2rtc/pkg/tutk/crypto_test.go @@ -0,0 +1,14 @@ +package tutk + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestXXTEADecrypt(t *testing.T) { + buf := []byte("WERhJxb87WF3zgPa") + key := []byte("GAgDiwVPg2E4GMke") + XXTEADecrypt(buf, buf, key) + require.Equal(t, "\xc4\xa6\x2c\xa1\x10\x64\x17\xa5\xda\x02\xe1\x62\xa5\xf0\x62\x71", string(buf)) +} diff --git a/installs_on_host/go2rtc/pkg/tutk/dtls/auth.go b/installs_on_host/go2rtc/pkg/tutk/dtls/auth.go new file mode 100644 index 0000000..7354428 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/tutk/dtls/auth.go @@ -0,0 +1,35 @@ +package dtls + +import ( + "crypto/sha256" + "encoding/base64" + "strings" +) + +func CalculateAuthKey(enr, mac string) []byte { + data := enr + strings.ToUpper(mac) + hash := sha256.Sum256([]byte(data)) + b64 := base64.StdEncoding.EncodeToString(hash[:6]) + b64 = strings.ReplaceAll(b64, "+", "Z") + b64 = strings.ReplaceAll(b64, "/", "9") + b64 = strings.ReplaceAll(b64, "=", "A") + return []byte(b64) +} + +func DerivePSK(enr string) []byte { + // DerivePSK derives the DTLS PSK from ENR + // TUTK SDK treats the PSK as a NULL-terminated C string, so if SHA256(ENR) + // contains a 0x00 byte, the PSK is truncated at that position. + hash := sha256.Sum256([]byte(enr)) + pskLen := 32 + for i := range 32 { + if hash[i] == 0x00 { + pskLen = i + break + } + } + + psk := make([]byte, 32) + copy(psk[:pskLen], hash[:pskLen]) + return psk +} diff --git a/installs_on_host/go2rtc/pkg/tutk/dtls/cipher.go b/installs_on_host/go2rtc/pkg/tutk/dtls/cipher.go new file mode 100644 index 0000000..e987ff8 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/tutk/dtls/cipher.go @@ -0,0 +1,218 @@ +package dtls + +import ( + "crypto/cipher" + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" + "hash" + "sync/atomic" + + "github.com/pion/dtls/v3" + "github.com/pion/dtls/v3/pkg/crypto/clientcertificate" + "github.com/pion/dtls/v3/pkg/crypto/prf" + "github.com/pion/dtls/v3/pkg/protocol" + "github.com/pion/dtls/v3/pkg/protocol/recordlayer" + "golang.org/x/crypto/chacha20poly1305" +) + +const CipherSuiteID_CCAC dtls.CipherSuiteID = 0xCCAC + +const ( + chachaTagLength = 16 + chachaNonceLength = 12 +) + +var ( + errDecryptPacket = &protocol.TemporaryError{Err: errors.New("failed to decrypt packet")} + errCipherSuiteNotInit = &protocol.TemporaryError{Err: errors.New("CipherSuite not initialized")} +) + +type ChaCha20Poly1305Cipher struct { + localCipher, remoteCipher cipher.AEAD + localWriteIV, remoteWriteIV []byte +} + +func NewChaCha20Poly1305Cipher(localKey, localWriteIV, remoteKey, remoteWriteIV []byte) (*ChaCha20Poly1305Cipher, error) { + localCipher, err := chacha20poly1305.New(localKey) + if err != nil { + return nil, err + } + + remoteCipher, err := chacha20poly1305.New(remoteKey) + if err != nil { + return nil, err + } + + return &ChaCha20Poly1305Cipher{ + localCipher: localCipher, + localWriteIV: localWriteIV, + remoteCipher: remoteCipher, + remoteWriteIV: remoteWriteIV, + }, nil +} + +func generateAEADAdditionalData(h *recordlayer.Header, payloadLen int) []byte { + var additionalData [13]byte + + binary.BigEndian.PutUint64(additionalData[:], h.SequenceNumber) + binary.BigEndian.PutUint16(additionalData[:], h.Epoch) + additionalData[8] = byte(h.ContentType) + additionalData[9] = h.Version.Major + additionalData[10] = h.Version.Minor + binary.BigEndian.PutUint16(additionalData[11:], uint16(payloadLen)) + + return additionalData[:] +} + +func computeNonce(iv []byte, epoch uint16, sequenceNumber uint64) []byte { + nonce := make([]byte, chachaNonceLength) + + binary.BigEndian.PutUint64(nonce[4:], sequenceNumber) + binary.BigEndian.PutUint16(nonce[4:], epoch) + + for i := range chachaNonceLength { + nonce[i] ^= iv[i] + } + + return nonce +} + +func (c *ChaCha20Poly1305Cipher) Encrypt(pkt *recordlayer.RecordLayer, raw []byte) ([]byte, error) { + payload := raw[pkt.Header.Size():] + raw = raw[:pkt.Header.Size()] + + nonce := computeNonce(c.localWriteIV, pkt.Header.Epoch, pkt.Header.SequenceNumber) + additionalData := generateAEADAdditionalData(&pkt.Header, len(payload)) + encryptedPayload := c.localCipher.Seal(nil, nonce, payload, additionalData) + + r := make([]byte, len(raw)+len(encryptedPayload)) + copy(r, raw) + copy(r[len(raw):], encryptedPayload) + + binary.BigEndian.PutUint16(r[pkt.Header.Size()-2:], uint16(len(r)-pkt.Header.Size())) + + return r, nil +} + +func (c *ChaCha20Poly1305Cipher) Decrypt(header recordlayer.Header, in []byte) ([]byte, error) { + err := header.Unmarshal(in) + switch { + case err != nil: + return nil, err + case header.ContentType == protocol.ContentTypeChangeCipherSpec: + return in, nil + case len(in) <= header.Size()+chachaTagLength: + return nil, fmt.Errorf("ciphertext too short: %d <= %d", len(in), header.Size()+chachaTagLength) + } + + nonce := computeNonce(c.remoteWriteIV, header.Epoch, header.SequenceNumber) + out := in[header.Size():] + additionalData := generateAEADAdditionalData(&header, len(out)-chachaTagLength) + + out, err = c.remoteCipher.Open(out[:0], nonce, out, additionalData) + if err != nil { + return nil, fmt.Errorf("%w: %v", errDecryptPacket, err) + } + + return append(in[:header.Size()], out...), nil +} + +type TLSEcdhePskWithChacha20Poly1305Sha256 struct { + aead atomic.Value +} + +func NewTLSEcdhePskWithChacha20Poly1305Sha256() *TLSEcdhePskWithChacha20Poly1305Sha256 { + return &TLSEcdhePskWithChacha20Poly1305Sha256{} +} + +func (c *TLSEcdhePskWithChacha20Poly1305Sha256) CertificateType() clientcertificate.Type { + return clientcertificate.Type(0) +} + +func (c *TLSEcdhePskWithChacha20Poly1305Sha256) KeyExchangeAlgorithm() dtls.CipherSuiteKeyExchangeAlgorithm { + return dtls.CipherSuiteKeyExchangeAlgorithmPsk | dtls.CipherSuiteKeyExchangeAlgorithmEcdhe +} + +func (c *TLSEcdhePskWithChacha20Poly1305Sha256) ECC() bool { + return true +} + +func (c *TLSEcdhePskWithChacha20Poly1305Sha256) ID() dtls.CipherSuiteID { + return CipherSuiteID_CCAC +} + +func (c *TLSEcdhePskWithChacha20Poly1305Sha256) String() string { + return "TLS_ECDHE_PSK_WITH_CHACHA20_POLY1305_SHA256" +} + +func (c *TLSEcdhePskWithChacha20Poly1305Sha256) HashFunc() func() hash.Hash { + return sha256.New +} + +func (c *TLSEcdhePskWithChacha20Poly1305Sha256) AuthenticationType() dtls.CipherSuiteAuthenticationType { + return dtls.CipherSuiteAuthenticationTypePreSharedKey +} + +func (c *TLSEcdhePskWithChacha20Poly1305Sha256) IsInitialized() bool { + return c.aead.Load() != nil +} + +func (c *TLSEcdhePskWithChacha20Poly1305Sha256) Init(masterSecret, clientRandom, serverRandom []byte, isClient bool) error { + const ( + prfMacLen = 0 + prfKeyLen = 32 + prfIvLen = 12 + ) + + keys, err := prf.GenerateEncryptionKeys( + masterSecret, clientRandom, serverRandom, + prfMacLen, prfKeyLen, prfIvLen, + c.HashFunc(), + ) + if err != nil { + return err + } + + var aead *ChaCha20Poly1305Cipher + if isClient { + aead, err = NewChaCha20Poly1305Cipher( + keys.ClientWriteKey, keys.ClientWriteIV, + keys.ServerWriteKey, keys.ServerWriteIV, + ) + } else { + aead, err = NewChaCha20Poly1305Cipher( + keys.ServerWriteKey, keys.ServerWriteIV, + keys.ClientWriteKey, keys.ClientWriteIV, + ) + } + if err != nil { + return err + } + + c.aead.Store(aead) + return nil +} + +func (c *TLSEcdhePskWithChacha20Poly1305Sha256) Encrypt(pkt *recordlayer.RecordLayer, raw []byte) ([]byte, error) { + aead, ok := c.aead.Load().(*ChaCha20Poly1305Cipher) + if !ok { + return nil, fmt.Errorf("%w: unable to encrypt", errCipherSuiteNotInit) + } + return aead.Encrypt(pkt, raw) +} + +func (c *TLSEcdhePskWithChacha20Poly1305Sha256) Decrypt(h recordlayer.Header, raw []byte) ([]byte, error) { + aead, ok := c.aead.Load().(*ChaCha20Poly1305Cipher) + if !ok { + return nil, fmt.Errorf("%w: unable to decrypt", errCipherSuiteNotInit) + } + return aead.Decrypt(h, raw) +} + +func CustomCipherSuites() []dtls.CipherSuite { + return []dtls.CipherSuite{ + NewTLSEcdhePskWithChacha20Poly1305Sha256(), + } +} diff --git a/installs_on_host/go2rtc/pkg/tutk/dtls/conn_dtls.go b/installs_on_host/go2rtc/pkg/tutk/dtls/conn_dtls.go new file mode 100644 index 0000000..c1d5f6c --- /dev/null +++ b/installs_on_host/go2rtc/pkg/tutk/dtls/conn_dtls.go @@ -0,0 +1,987 @@ +package dtls + +import ( + "context" + "crypto/hmac" + "crypto/sha1" + "encoding/binary" + "fmt" + "io" + "net" + "sync" + "time" + + "github.com/AlexxIT/go2rtc/pkg/tutk" + "github.com/pion/dtls/v3" +) + +const ( + magicCC51 = "\x51\xcc" // (wyze specific?) + sdkVersion42 = "\x01\x01\x02\x04" // 4.2.1.1 + sdkVersion43 = "\x00\x08\x03\x04" // 4.3.8.0 +) + +const ( + cmdDiscoReq uint16 = 0x0601 + cmdDiscoRes uint16 = 0x0602 + cmdSessionReq uint16 = 0x0402 + cmdSessionRes uint16 = 0x0404 + cmdDataTX uint16 = 0x0407 + cmdDataRX uint16 = 0x0408 + cmdKeepaliveReq uint16 = 0x0427 + cmdKeepaliveRes uint16 = 0x0428 + + headerSize = 16 + discoBodySize = 72 + discoSize = headerSize + discoBodySize + sessionBody = 36 + sessionSize = headerSize + sessionBody +) + +const ( + cmdDiscoCC51 uint16 = 0x1002 + cmdKeepaliveCC51 uint16 = 0x1202 + cmdDTLSCC51 uint16 = 0x1502 + payloadSizeCC51 uint16 = 0x0028 + packetSizeCC51 = 52 + headerSizeCC51 = 28 + authSizeCC51 = 20 + keepaliveSizeCC51 = 48 +) + +const ( + magicAVLoginResp uint16 = 0x2100 + magicIOCtrl uint16 = 0x7000 + magicChannelMsg uint16 = 0x1000 + magicACK uint16 = 0x0009 + magicAVLogin1 uint16 = 0x0000 + magicAVLogin2 uint16 = 0x2000 +) + +const ( + protoVersion uint16 = 0x000c + defaultCaps uint32 = 0x001f07fb +) + +const ( + iotcChannelMain = 0 // Main AV (we = DTLS Client) + iotcChannelBack = 1 // Backchannel (we = DTLS Server) +) + +type DTLSConn struct { + conn *net.UDPConn + addr *net.UDPAddr + frames *tutk.FrameHandler + err error + verbose bool + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + mu sync.RWMutex + + // DTLS + clientConn *dtls.Conn + serverConn *dtls.Conn + clientBuf chan []byte + serverBuf chan []byte + rawCmd chan []byte + + // Identity + uid string + authKey string + enr string + psk []byte + + // Session + sid []byte + ticket uint16 + hasTwoWayStreaming bool + + // Protocol + isCC51 bool + seq uint16 + seqCmd uint16 + avSeq uint32 + kaSeq uint32 + audioSeq uint32 + audioFrameNo uint32 + + // Ack + ackFlags uint16 + rxSeqStart uint16 + rxSeqEnd uint16 + rxSeqInit bool + cmdAck func() +} + +func DialDTLS(host string, port int, uid, authKey, enr string, verbose bool) (*DTLSConn, error) { + udp, err := net.ListenUDP("udp", nil) + if err != nil { + return nil, err + } + + _ = udp.SetReadBuffer(2 * 1024 * 1024) + + ctx, cancel := context.WithCancel(context.Background()) + psk := DerivePSK(enr) + + if port == 0 { + port = 32761 + } + + c := &DTLSConn{ + conn: udp, + addr: &net.UDPAddr{IP: net.ParseIP(host), Port: port}, + uid: uid, + authKey: authKey, + enr: enr, + psk: psk, + verbose: verbose, + ctx: ctx, + cancel: cancel, + rxSeqStart: 0xffff, + rxSeqEnd: 0xffff, + } + + if err = c.discovery(); err != nil { + _ = c.Close() + return nil, err + } + + c.clientBuf = make(chan []byte, 64) + c.serverBuf = make(chan []byte, 64) + c.rawCmd = make(chan []byte, 16) + c.frames = tutk.NewFrameHandler(c.verbose) + + c.wg.Add(1) + go c.reader() + + if err = c.connect(); err != nil { + _ = c.Close() + return nil, err + } + + c.wg.Add(1) + go c.worker() + + return c, nil +} + +func (c *DTLSConn) AVClientStart(timeout time.Duration) error { + randomID := tutk.GenSessionID() + pkt1 := c.msgAVLogin(magicAVLogin1, 570, 0x0001, randomID) + pkt2 := c.msgAVLogin(magicAVLogin2, 572, 0x0000, randomID) + pkt2[20]++ // pkt2 has randomID incremented by 1 + + if _, err := c.clientConn.Write(pkt1); err != nil { + return fmt.Errorf("av login 1 failed: %w", err) + } + + time.Sleep(10 * time.Millisecond) + + if _, err := c.clientConn.Write(pkt2); err != nil { + return fmt.Errorf("av login 2 failed: %w", err) + } + + // Wait for response + timer := time.NewTimer(timeout) + defer timer.Stop() + for { + select { + case data, ok := <-c.rawCmd: + if !ok { + return io.EOF + } + if len(data) >= 32 && binary.LittleEndian.Uint16(data) == magicAVLoginResp { + c.hasTwoWayStreaming = data[31] == 1 + + ack := c.msgACK() + c.clientConn.Write(ack) + + // Start ACK sender for continuous streaming + c.wg.Add(1) + go func() { + defer c.wg.Done() + ackTicker := time.NewTicker(100 * time.Millisecond) + defer ackTicker.Stop() + + for { + select { + case <-c.ctx.Done(): + return + case <-ackTicker.C: + if c.clientConn != nil { + ack := c.msgACK() + c.clientConn.Write(ack) + } + } + } + }() + + return nil + } + case <-timer.C: + return context.DeadlineExceeded + } + } +} + +func (c *DTLSConn) AVServStart() error { + conn, err := NewDTLSServer(c.ctx, iotcChannelBack, c.addr, c.WriteDTLS, c.serverBuf, c.psk) + if err != nil { + return fmt.Errorf("dtls: server handshake failed: %w", err) + } + + if c.verbose { + fmt.Printf("[DTLS] Server handshake complete on channel %d\n", iotcChannelBack) + fmt.Printf("[SERVER] Waiting for AV Login request from camera...\n") + } + + // Wait for AV Login request from camera + buf := make([]byte, 1024) + conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + n, err := conn.Read(buf) + if err != nil { + go conn.Close() + return fmt.Errorf("read av login: %w", err) + } + + if c.verbose { + fmt.Printf("[SERVER] AV Login request len=%d data:\n%s", n, hexDump(buf[:n])) + } + + if n < 24 { + go conn.Close() + return fmt.Errorf("av login too short: %d bytes", n) + } + + checksum := binary.LittleEndian.Uint32(buf[20:]) + resp := c.msgAVLoginResponse(checksum) + + if c.verbose { + fmt.Printf("[SERVER] Sending AV Login response: %d bytes\n", len(resp)) + } + + if _, err = conn.Write(resp); err != nil { + go conn.Close() + return fmt.Errorf("write av login response: %w", err) + } + + if c.verbose { + fmt.Printf("[SERVER] AV Login response sent, waiting for possible resend...\n") + } + + // Camera may resend, respond again + conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + if n, _ = conn.Read(buf); n > 0 { + if c.verbose { + fmt.Printf("[SERVER] Received AV Login resend: %d bytes\n", n) + } + conn.Write(resp) + } + + conn.SetReadDeadline(time.Time{}) + + if c.verbose { + fmt.Printf("[SERVER] AV Login complete, ready for two way streaming\n") + } + + c.mu.Lock() + c.serverConn = conn + c.mu.Unlock() + + return nil +} + +func (c *DTLSConn) AVServStop() error { + c.mu.Lock() + serverConn := c.serverConn + c.serverConn = nil + + // Reset audio TX state + c.audioSeq = 0 + c.audioFrameNo = 0 + c.mu.Unlock() + + if serverConn == nil { + return nil + } + + go serverConn.Close() + + return nil +} + +func (c *DTLSConn) AVRecvFrameData() (*tutk.Packet, error) { + select { + case pkt, ok := <-c.frames.Recv(): + if !ok { + return nil, c.Error() + } + return pkt, nil + case <-c.ctx.Done(): + return nil, c.Error() + } +} + +func (c *DTLSConn) AVSendAudioData(codec byte, payload []byte, timestampUS uint32, sampleRate uint32, channels uint8) error { + c.mu.Lock() + conn := c.serverConn + if conn == nil { + c.mu.Unlock() + return fmt.Errorf("av server not ready") + } + + frame := c.msgAudioFrame(payload, timestampUS, codec, sampleRate, channels) + + c.mu.Unlock() + + n, err := conn.Write(frame) + if c.verbose { + if err != nil { + fmt.Printf("[SERVER TX] DTLS Write ERROR: %v\n", err) + } else { + fmt.Printf("[SERVER TX] len=%d, data:\n%s", n, hexDump(frame)) + } + } + return err +} + +func (c *DTLSConn) Write(data []byte) error { + if c.isCC51 { + _, err := c.conn.WriteToUDP(data, c.addr) + return err + } + _, err := c.conn.WriteToUDP(tutk.TransCodeBlob(data), c.addr) + return err +} + +func (c *DTLSConn) WriteDTLS(payload []byte, channel byte) error { + var frame []byte + if c.isCC51 { + frame = c.msgTxDataCC51(payload, channel) + } else { + frame = c.msgTxData(payload, channel) + } + + return c.Write(frame) +} + +func (c *DTLSConn) WriteIOCtrl(payload []byte) error { + _, err := c.conn.Write(c.msgIOCtrl(payload)) + return err +} + +func (c *DTLSConn) WriteAndWait(req []byte, ok func(res []byte) bool) ([]byte, error) { + var t *time.Timer + t = time.AfterFunc(1, func() { + if err := c.Write(req); err == nil && t != nil { + t.Reset(time.Second) + } + }) + defer t.Stop() + + _ = c.conn.SetDeadline(time.Now().Add(5 * time.Second)) + defer c.conn.SetDeadline(time.Time{}) + + buf := make([]byte, 2048) + for { + n, addr, err := c.conn.ReadFromUDP(buf) + if err != nil { + return nil, err + } + if string(addr.IP) != string(c.addr.IP) || n < 16 { + continue + } + + var res []byte + if c.isCC51 { + res = buf[:n] + } else { + res = tutk.ReverseTransCodeBlob(buf[:n]) + } + + if ok(res) { + c.addr.Port = addr.Port + return res, nil + } + } +} + +func (c *DTLSConn) WriteAndWaitIOCtrl(payload []byte, match func([]byte) bool, timeout time.Duration) ([]byte, error) { + frame := c.msgIOCtrl(payload) + var t *time.Timer + t = time.AfterFunc(1, func() { + c.mu.RLock() + conn := c.clientConn + c.mu.RUnlock() + if conn != nil { + if _, err := conn.Write(frame); err == nil && t != nil { + t.Reset(time.Second) + } + } + }) + defer t.Stop() + + timer := time.NewTimer(timeout) + defer timer.Stop() + + for { + select { + case data, ok := <-c.rawCmd: + if !ok { + return nil, io.EOF + } + + ack := c.msgACK() + c.clientConn.Write(ack) + + if match(data) { + return data, nil + } + case <-timer.C: + return nil, fmt.Errorf("timeout waiting for response") + } + } +} + +func (c *DTLSConn) HasTwoWayStreaming() bool { + return c.hasTwoWayStreaming +} + +func (c *DTLSConn) IsBackchannelReady() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.serverConn != nil +} + +func (c *DTLSConn) RemoteAddr() *net.UDPAddr { + return c.addr +} + +func (c *DTLSConn) LocalAddr() *net.UDPAddr { + return c.conn.LocalAddr().(*net.UDPAddr) +} + +func (c *DTLSConn) SetDeadline(t time.Time) error { + return c.conn.SetDeadline(t) +} + +func (c *DTLSConn) Close() error { + c.cancel() + + c.mu.Lock() + if conn := c.serverConn; conn != nil { + c.serverConn = nil + go conn.Close() + } + if conn := c.clientConn; conn != nil { + c.clientConn = nil + go conn.Close() + } + if c.frames != nil { + c.frames.Close() + } + c.mu.Unlock() + + c.wg.Wait() + + return c.conn.Close() +} + +func (c *DTLSConn) Error() error { + if c.err != nil { + return c.err + } + return io.EOF +} + +func (c *DTLSConn) discovery() error { + c.sid = tutk.GenSessionID() + + pktIOTC := tutk.TransCodeBlob(c.msgDisco(1)) + pktCC51 := c.msgDiscoCC51(0, 0, false) + + buf := make([]byte, 2048) + deadline := time.Now().Add(5 * time.Second) + + for time.Now().Before(deadline) { + c.conn.WriteToUDP(pktIOTC, c.addr) + c.conn.WriteToUDP(pktCC51, c.addr) + + c.conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) + n, addr, err := c.conn.ReadFromUDP(buf) + if err != nil { + continue + } + if !addr.IP.Equal(c.addr.IP) { + continue + } + + // CC51 protocol + if n >= packetSizeCC51 && string(buf[:2]) == magicCC51 { + if binary.LittleEndian.Uint16(buf[4:]) == cmdDiscoCC51 { + c.addr, c.isCC51, c.ticket = addr, true, binary.LittleEndian.Uint16(buf[14:]) + if n >= 24 { + copy(c.sid, buf[16:24]) + } + return c.discoDoneCC51() + } + continue + } + + // IOTC Protocol (Basis) + data := tutk.ReverseTransCodeBlob(buf[:n]) + if len(data) >= 16 && binary.LittleEndian.Uint16(data[8:]) == cmdDiscoRes { + c.addr, c.isCC51 = addr, false + return c.discoDone() + } + } + + return fmt.Errorf("discovery timeout") +} + +func (c *DTLSConn) discoDone() error { + c.Write(c.msgDisco(2)) + time.Sleep(100 * time.Millisecond) + _, err := c.WriteAndWait(c.msgSession(), func(res []byte) bool { + return len(res) >= 16 && binary.LittleEndian.Uint16(res[8:]) == cmdSessionRes + }) + return err +} + +func (c *DTLSConn) discoDoneCC51() error { + _, err := c.WriteAndWait(c.msgDiscoCC51(2, c.ticket, false), func(res []byte) bool { + if len(res) < packetSizeCC51 || string(res[:2]) != magicCC51 { + return false + } + cmd := binary.LittleEndian.Uint16(res[4:]) + dir := binary.LittleEndian.Uint16(res[8:]) + seq := binary.LittleEndian.Uint16(res[12:]) + return cmd == cmdDiscoCC51 && dir == 0xFFFF && seq == 3 + }) + return err +} + +func (c *DTLSConn) connect() error { + conn, err := NewDTLSClient(c.ctx, iotcChannelMain, c.addr, c.WriteDTLS, c.clientBuf, c.psk) + if err != nil { + return fmt.Errorf("dtls: client handshake failed: %w", err) + } + + c.mu.Lock() + c.clientConn = conn + c.mu.Unlock() + + if c.verbose { + fmt.Printf("[DTLS] Client handshake complete on channel %d\n", iotcChannelMain) + } + + return nil +} + +func (c *DTLSConn) worker() { + defer c.wg.Done() + + buf := make([]byte, 2048) + + for { + select { + case <-c.ctx.Done(): + return + default: + } + + n, err := c.clientConn.Read(buf) + if err != nil { + c.err = err + return + } + + if n < 2 { + continue + } + + data := buf[:n] + magic := binary.LittleEndian.Uint16(data) + + if c.verbose { + fmt.Printf("[DTLS RX] magic=0x%04x len=%d\n", magic, n) + } + + switch magic { + case magicAVLoginResp: + c.queue(c.rawCmd, data) + + case magicIOCtrl, magicChannelMsg: + c.queue(c.rawCmd, data) + + case protoVersion: + // Seq-Tracking + if len(data) >= 8 { + seq := binary.LittleEndian.Uint16(data[4:]) + if !c.rxSeqInit { + c.rxSeqInit = true + } + if seq > c.rxSeqEnd || c.rxSeqEnd == 0xffff { + c.rxSeqEnd = seq + } + } + c.queue(c.rawCmd, data) + + case magicACK: + c.mu.RLock() + ack := c.cmdAck + c.mu.RUnlock() + if ack != nil { + ack() + } + + default: + channel := data[0] + if channel == tutk.ChannelAudio || channel == tutk.ChannelIVideo || channel == tutk.ChannelPVideo { + c.frames.Handle(data) + } + } + } +} + +func (c *DTLSConn) reader() { + defer c.wg.Done() + + buf := make([]byte, 2048) + + for { + select { + case <-c.ctx.Done(): + return + default: + } + + c.conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) + n, addr, err := c.conn.ReadFromUDP(buf) + if err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + continue + } + return + } + + if !addr.IP.Equal(c.addr.IP) { + if c.verbose { + fmt.Printf("Ignored packet from unknown IP: %s\n", addr.IP.String()) + } + continue + } + if addr.Port != c.addr.Port { + c.addr.Port = addr.Port + } + + // CC51 Protocol + if c.isCC51 && n >= 12 && string(buf[:2]) == magicCC51 { + cmd := binary.LittleEndian.Uint16(buf[4:]) + switch cmd { + case cmdKeepaliveCC51: + if n >= keepaliveSizeCC51 { + _ = c.Write(c.msgKeepaliveCC51()) + } + case cmdDTLSCC51: + if n >= headerSizeCC51+authSizeCC51 { + ch := byte(binary.LittleEndian.Uint16(buf[12:]) >> 8) + dtlsData := buf[headerSizeCC51 : n-authSizeCC51] + switch ch { + case iotcChannelMain: + c.queue(c.clientBuf, dtlsData) + case iotcChannelBack: + c.queue(c.serverBuf, dtlsData) + } + } + } + continue + } + + // IOTC Protocol (Basis) + data := tutk.ReverseTransCodeBlob(buf[:n]) + if len(data) < 16 { + continue + } + + switch binary.LittleEndian.Uint16(data[8:]) { + case cmdKeepaliveRes: + if len(data) > 24 { + _ = c.Write(c.msgKeepalive(data[16:])) + } + case cmdDataRX: + if len(data) > 28 { + ch := data[14] + switch ch { + case iotcChannelMain: + c.queue(c.clientBuf, data[28:]) + case iotcChannelBack: + c.queue(c.serverBuf, data[28:]) + } + } + } + } +} + +func (c *DTLSConn) queue(ch chan []byte, data []byte) { + b := make([]byte, len(data)) + copy(b, data) + select { + case ch <- b: + default: + select { + case <-ch: + default: + } + ch <- b + } +} + +func (c *DTLSConn) msgDisco(stage byte) []byte { + b := make([]byte, discoSize) + copy(b, "\x04\x02\x1a\x02") // marker + mode + binary.LittleEndian.PutUint16(b[4:], discoBodySize) // body size + binary.LittleEndian.PutUint16(b[8:], cmdDiscoReq) // 0x0601 + binary.LittleEndian.PutUint16(b[10:], 0x0021) // flags + body := b[headerSize:] + copy(body[:20], c.uid) + copy(body[36:], sdkVersion42) // SDK 4.2.1.1 + copy(body[40:], c.sid) + body[48] = stage + if stage == 1 && len(c.authKey) > 0 { + copy(body[58:], c.authKey) + } + return b +} + +func (c *DTLSConn) msgDiscoCC51(seq, ticket uint16, isResponse bool) []byte { + b := make([]byte, packetSizeCC51) + copy(b[:2], magicCC51) + binary.LittleEndian.PutUint16(b[4:], cmdDiscoCC51) // 0x1002 + binary.LittleEndian.PutUint16(b[6:], payloadSizeCC51) // 40 bytes + if isResponse { + binary.LittleEndian.PutUint16(b[8:], 0xFFFF) // response + } + binary.LittleEndian.PutUint16(b[12:], seq) + binary.LittleEndian.PutUint16(b[14:], ticket) + copy(b[16:24], c.sid) + copy(b[24:28], sdkVersion43) // SDK 4.3.8.0 + b[28] = 0x1d // unknown field (capability/build flag?) + h := hmac.New(sha1.New, append([]byte(c.uid), c.authKey...)) + h.Write(b[:32]) + copy(b[32:52], h.Sum(nil)) + return b +} + +func (c *DTLSConn) msgKeepaliveCC51() []byte { + c.kaSeq += 2 + b := make([]byte, keepaliveSizeCC51) + copy(b[:2], magicCC51) + binary.LittleEndian.PutUint16(b[4:], cmdKeepaliveCC51) // 0x1202 + binary.LittleEndian.PutUint16(b[6:], 0x0024) // 36 bytes payload + binary.LittleEndian.PutUint32(b[16:], c.kaSeq) // counter + copy(b[20:28], c.sid) // session ID + h := hmac.New(sha1.New, append([]byte(c.uid), c.authKey...)) + h.Write(b[:28]) + copy(b[28:48], h.Sum(nil)) + return b +} + +func (c *DTLSConn) msgSession() []byte { + b := make([]byte, sessionSize) + copy(b, "\x04\x02\x1a\x02") // marker + mode + binary.LittleEndian.PutUint16(b[4:], sessionBody) // body size + binary.LittleEndian.PutUint16(b[8:], cmdSessionReq) // 0x0402 + binary.LittleEndian.PutUint16(b[10:], 0x0033) // flags + body := b[headerSize:] + copy(body[:20], c.uid) + copy(body[20:], c.sid) + binary.LittleEndian.PutUint32(body[32:], uint32(time.Now().Unix())) + return b +} + +func (c *DTLSConn) msgAVLogin(magic uint16, size int, flags uint16, randomID []byte) []byte { + b := make([]byte, size) + binary.LittleEndian.PutUint16(b, magic) + binary.LittleEndian.PutUint16(b[2:], protoVersion) + binary.LittleEndian.PutUint16(b[16:], uint16(size-24)) // payload size + binary.LittleEndian.PutUint16(b[18:], flags) + copy(b[20:], randomID[:4]) + copy(b[24:], "admin") // username + copy(b[280:], c.enr) // password/ENR + binary.LittleEndian.PutUint32(b[540:], 4) // security_mode ? + binary.LittleEndian.PutUint32(b[552:], defaultCaps) // capabilities + return b +} + +func (c *DTLSConn) msgAVLoginResponse(checksum uint32) []byte { + b := make([]byte, 60) + binary.LittleEndian.PutUint16(b, 0x2100) // magic + binary.LittleEndian.PutUint16(b[2:], 0x000c) // version + b[4] = 0x10 // success + binary.LittleEndian.PutUint32(b[16:], 0x24) // payload size + binary.LittleEndian.PutUint32(b[20:], checksum) // echo checksum + b[29] = 0x01 // enable flag + b[31] = 0x01 // two-way streaming + binary.LittleEndian.PutUint32(b[36:], 0x04) // buffer config + binary.LittleEndian.PutUint32(b[40:], defaultCaps) + binary.LittleEndian.PutUint16(b[54:], 0x0003) // channel info + binary.LittleEndian.PutUint16(b[56:], 0x0002) + return b +} + +func (c *DTLSConn) msgAudioFrame(payload []byte, timestampUS uint32, codec byte, sampleRate uint32, channels uint8) []byte { + c.audioSeq++ + c.audioFrameNo++ + prevFrame := uint32(0) + if c.audioFrameNo > 1 { + prevFrame = c.audioFrameNo - 1 + } + + totalPayload := len(payload) + 16 // payload + frameinfo + b := make([]byte, 36+totalPayload) + + // Outer header (36 bytes) + b[0] = tutk.ChannelAudio // 0x03 + b[1] = tutk.FrameTypeStartAlt // 0x09 + binary.LittleEndian.PutUint16(b[2:], protoVersion) + binary.LittleEndian.PutUint32(b[4:], c.audioSeq) + binary.LittleEndian.PutUint32(b[8:], timestampUS) + if c.audioFrameNo == 1 { + binary.LittleEndian.PutUint32(b[12:], 0x00000001) + } else { + binary.LittleEndian.PutUint32(b[12:], 0x00100001) + } + + // Inner header + b[16] = tutk.ChannelAudio + b[17] = tutk.FrameTypeEndSingle + binary.LittleEndian.PutUint16(b[18:], uint16(prevFrame)) + binary.LittleEndian.PutUint16(b[20:], 0x0001) // pkt_total + binary.LittleEndian.PutUint16(b[22:], 0x0010) // flags + binary.LittleEndian.PutUint32(b[24:], uint32(totalPayload)) + binary.LittleEndian.PutUint32(b[28:], prevFrame) + binary.LittleEndian.PutUint32(b[32:], c.audioFrameNo) + copy(b[36:], payload) // Payload + FrameInfo + fi := b[36+len(payload):] + fi[0] = codec // Codec ID (low byte) + fi[1] = 0 // Codec ID (high byte, unused) + // Audio flags: [3:2]=sampleRateIdx [1]=16bit [0]=stereo + srIdx := tutk.GetSampleRateIndex(sampleRate) + fi[2] = (srIdx << 2) | 0x02 // 16-bit always set + if channels == 2 { + fi[2] |= 0x01 + } + fi[4] = 1 // online + binary.LittleEndian.PutUint32(fi[12:], (c.audioFrameNo-1)*tutk.GetSamplesPerFrame(codec)*1000/sampleRate) + return b +} + +func (c *DTLSConn) msgTxData(payload []byte, channel byte) []byte { + bodySize := 12 + len(payload) + b := make([]byte, 16+bodySize) + copy(b, "\x04\x02\x1a\x0b") // marker + mode=data + binary.LittleEndian.PutUint16(b[4:], uint16(bodySize)) // body size + binary.LittleEndian.PutUint16(b[6:], c.seq) // sequence + c.seq++ + binary.LittleEndian.PutUint16(b[8:], cmdDataTX) // 0x0407 + binary.LittleEndian.PutUint16(b[10:], 0x0021) // flags + copy(b[12:], c.sid[:2]) // rid[0:2] + b[14] = channel // channel + b[15] = 0x01 // marker + binary.LittleEndian.PutUint32(b[16:], 0x0000000c) // const + copy(b[20:], c.sid[:8]) // rid + copy(b[28:], payload) + return b +} + +func (c *DTLSConn) msgTxDataCC51(payload []byte, channel byte) []byte { + payloadSize := uint16(16 + len(payload) + authSizeCC51) + b := make([]byte, headerSizeCC51+len(payload)+authSizeCC51) + copy(b[:2], magicCC51) + binary.LittleEndian.PutUint16(b[4:], cmdDTLSCC51) // 0x1502 + binary.LittleEndian.PutUint16(b[6:], payloadSize) + binary.LittleEndian.PutUint16(b[12:], uint16(0x0010)|(uint16(channel)<<8)) // channel in high byte + binary.LittleEndian.PutUint16(b[14:], c.ticket) + copy(b[16:24], c.sid) + binary.LittleEndian.PutUint32(b[24:], 1) // const + copy(b[headerSizeCC51:], payload) + h := hmac.New(sha1.New, append([]byte(c.uid), c.authKey...)) + h.Write(b[:headerSizeCC51]) + copy(b[headerSizeCC51+len(payload):], h.Sum(nil)) + return b +} + +func (c *DTLSConn) msgACK() []byte { + c.ackFlags++ + b := make([]byte, 24) + binary.LittleEndian.PutUint16(b[0:], magicACK) // 0x0009 + binary.LittleEndian.PutUint16(b[2:], protoVersion) // 0x000c + binary.LittleEndian.PutUint32(b[4:], c.avSeq) // TX seq + c.avSeq++ + binary.LittleEndian.PutUint16(b[8:], c.rxSeqStart) // RX start (last acked) + binary.LittleEndian.PutUint16(b[10:], c.rxSeqEnd) // RX end (highest received) + if c.rxSeqInit { + c.rxSeqStart = c.rxSeqEnd + } + binary.LittleEndian.PutUint16(b[12:], c.ackFlags) // AckFlags + binary.LittleEndian.PutUint32(b[16:], uint32(c.ackFlags)<<16) // AckCounter + ts := uint32(time.Now().UnixMilli() & 0xFFFF) + binary.LittleEndian.PutUint16(b[20:], uint16(ts)) // Timestamp + return b +} + +func (c *DTLSConn) msgKeepalive(incoming []byte) []byte { + b := make([]byte, 24) + copy(b, "\x04\x02\x1a\x0a") // marker + mode + binary.LittleEndian.PutUint16(b[4:], 8) // body size + binary.LittleEndian.PutUint16(b[8:], cmdKeepaliveReq) // 0x0427 + binary.LittleEndian.PutUint16(b[10:], 0x0021) // flags + if len(incoming) >= 8 { + copy(b[16:], incoming[:8]) // echo payload + } + return b +} + +func (c *DTLSConn) msgIOCtrl(payload []byte) []byte { + b := make([]byte, 40+len(payload)) + binary.LittleEndian.PutUint16(b, protoVersion) // magic + binary.LittleEndian.PutUint16(b[2:], protoVersion) // version + binary.LittleEndian.PutUint32(b[4:], c.avSeq) // av seq + c.avSeq++ + binary.LittleEndian.PutUint16(b[16:], magicIOCtrl) // 0x7000 + binary.LittleEndian.PutUint16(b[18:], c.seqCmd) // sub channel + binary.LittleEndian.PutUint32(b[20:], 1) // ioctl seq + binary.LittleEndian.PutUint32(b[24:], uint32(len(payload)+4)) // payload size + binary.LittleEndian.PutUint32(b[28:], uint32(c.seqCmd)) // flag + b[37] = 0x01 + copy(b[40:], payload) + c.seqCmd++ + return b +} + +func hexDump(data []byte) string { + const maxBytes = 650 + totalLen := len(data) + truncated := totalLen > maxBytes + if truncated { + data = data[:maxBytes] + } + + var result string + for i := 0; i < len(data); i += 16 { + end := min(i+16, len(data)) + line := fmt.Sprintf(" %04x:", i) + for j := i; j < end; j++ { + line += fmt.Sprintf(" %02x", data[j]) + } + result += line + "\n" + } + + if truncated { + result += fmt.Sprintf(" ... (truncated, showing %d of %d bytes)\n", maxBytes, totalLen) + } + return result +} diff --git a/installs_on_host/go2rtc/pkg/tutk/dtls/dtls.go b/installs_on_host/go2rtc/pkg/tutk/dtls/dtls.go new file mode 100644 index 0000000..3b0573a --- /dev/null +++ b/installs_on_host/go2rtc/pkg/tutk/dtls/dtls.go @@ -0,0 +1,146 @@ +package dtls + +import ( + "context" + "net" + "sync" + "time" + + "github.com/pion/dtls/v3" +) + +func NewDTLSClient(ctx context.Context, channel uint8, addr net.Addr, writeFn func([]byte, uint8) error, readChan chan []byte, psk []byte) (*dtls.Conn, error) { + return dialDTLS(ctx, channel, addr, writeFn, readChan, psk, false) +} + +func NewDTLSServer(ctx context.Context, channel uint8, addr net.Addr, writeFn func([]byte, uint8) error, readChan chan []byte, psk []byte) (*dtls.Conn, error) { + return dialDTLS(ctx, channel, addr, writeFn, readChan, psk, true) +} + +func dialDTLS(ctx context.Context, channel uint8, addr net.Addr, writeFn func([]byte, uint8) error, readChan chan []byte, psk []byte, isServer bool) (*dtls.Conn, error) { + adapter := &channelAdapter{ + ctx: ctx, + channel: channel, + addr: addr, + writeFn: writeFn, + readChan: readChan, + } + + var conn *dtls.Conn + var err error + + if isServer { + conn, err = dtls.Server(adapter, addr, buildDTLSConfig(psk, true)) + } else { + conn, err = dtls.Client(adapter, addr, buildDTLSConfig(psk, false)) + } + if err != nil { + return nil, err + } + + timeout := 5 * time.Second + adapter.SetReadDeadline(time.Now().Add(timeout)) + hsCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + if err := conn.HandshakeContext(hsCtx); err != nil { + go conn.Close() + return nil, err + } + + adapter.SetReadDeadline(time.Time{}) + return conn, nil +} + +func buildDTLSConfig(psk []byte, isServer bool) *dtls.Config { + config := &dtls.Config{ + PSK: func(hint []byte) ([]byte, error) { + return psk, nil + }, + PSKIdentityHint: []byte("AUTHPWD_admin"), + InsecureSkipVerify: true, + InsecureSkipVerifyHello: true, + MTU: 1200, + FlightInterval: 300 * time.Millisecond, + ExtendedMasterSecret: dtls.DisableExtendedMasterSecret, + } + + if isServer { + config.CipherSuites = []dtls.CipherSuiteID{dtls.TLS_PSK_WITH_AES_128_CBC_SHA256} + } else { + config.CustomCipherSuites = CustomCipherSuites + } + + return config +} + +type channelAdapter struct { + ctx context.Context + channel uint8 + writeFn func([]byte, uint8) error + readChan chan []byte + addr net.Addr + mu sync.Mutex + readDeadline time.Time +} + +func (a *channelAdapter) ReadFrom(p []byte) (n int, addr net.Addr, err error) { + a.mu.Lock() + deadline := a.readDeadline + a.mu.Unlock() + + if !deadline.IsZero() { + timeout := time.Until(deadline) + if timeout <= 0 { + return 0, nil, &timeoutError{} + } + + timer := time.NewTimer(timeout) + defer timer.Stop() + + select { + case data := <-a.readChan: + return copy(p, data), a.addr, nil + case <-timer.C: + return 0, nil, &timeoutError{} + case <-a.ctx.Done(): + return 0, nil, net.ErrClosed + } + } + + select { + case data := <-a.readChan: + return copy(p, data), a.addr, nil + case <-a.ctx.Done(): + return 0, nil, net.ErrClosed + } +} + +func (a *channelAdapter) WriteTo(p []byte, _ net.Addr) (int, error) { + if err := a.writeFn(p, a.channel); err != nil { + return 0, err + } + return len(p), nil +} + +func (a *channelAdapter) Close() error { return nil } +func (a *channelAdapter) LocalAddr() net.Addr { return &net.UDPAddr{} } +func (a *channelAdapter) SetDeadline(t time.Time) error { + a.mu.Lock() + a.readDeadline = t + a.mu.Unlock() + return nil +} +func (a *channelAdapter) SetReadDeadline(t time.Time) error { + a.mu.Lock() + a.readDeadline = t + a.mu.Unlock() + return nil +} +func (a *channelAdapter) SetWriteDeadline(time.Time) error { return nil } + +type timeoutError struct{} + +func (e *timeoutError) Error() string { return "i/o timeout" } +func (e *timeoutError) Timeout() bool { return true } +func (e *timeoutError) Temporary() bool { return true } diff --git a/installs_on_host/go2rtc/pkg/tutk/frame.go b/installs_on_host/go2rtc/pkg/tutk/frame.go new file mode 100644 index 0000000..db5bf07 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/tutk/frame.go @@ -0,0 +1,571 @@ +package tutk + +import ( + "encoding/binary" + "encoding/hex" + "fmt" + "sync" + + "github.com/AlexxIT/go2rtc/pkg/aac" +) + +const ( + FrameTypeStart uint8 = 0x08 // Extended start (36-byte header) + FrameTypeStartAlt uint8 = 0x09 // StartAlt (36-byte header) + FrameTypeCont uint8 = 0x00 // Continuation (28-byte header) + FrameTypeContAlt uint8 = 0x04 // Continuation alt + FrameTypeEndSingle uint8 = 0x01 // Single-packet frame (28-byte) + FrameTypeEndMulti uint8 = 0x05 // Multi-packet end (28-byte) + FrameTypeEndExt uint8 = 0x0d // Extended end (36-byte) +) + +const ( + ChannelIVideo uint8 = 0x05 + ChannelAudio uint8 = 0x03 + ChannelPVideo uint8 = 0x07 +) + +const frameInfoSize = 40 + +// FrameInfo - Wyze extended FRAMEINFO (40 bytes at end of packet) +// Video: 40 bytes, Audio: 16 bytes (uses same struct, fields 16+ are zero) +// +// Offset Size Field +// 0-1 2 CodecID - 0x4E=H264, 0x7B=H265, 0x90=AAC_WYZE +// 2 1 Flags - Video: 1=Keyframe, 0=P-frame | Audio: sample rate/bits/channels +// 3 1 CamIndex - Camera index +// 4 1 OnlineNum - Online number +// 5 1 FPS - Framerate (e.g. 20) +// 6 1 ResTier - Video: 1=Low(360P), 4=High(HD/2K) | Audio: 0 +// 7 1 Bitrate - Video: 30=360P, 100=HD, 200=2K | Audio: 1 +// 8-11 4 Timestamp - Timestamp (increases ~50000/frame for 20fps video) +// 12-15 4 SessionID - Session marker (constant per stream) +// 16-19 4 PayloadSize - Frame payload size in bytes +// 20-23 4 FrameNo - Global frame number +// 24-35 12 DeviceID - MAC address (ASCII) - video only +// 36-39 4 Padding - Always 0 - video only +type FrameInfo struct { + CodecID byte // 0 (only low byte used) + Flags uint8 // 2 + CamIndex uint8 // 3 + OnlineNum uint8 // 4 + FPS uint8 // 5: Framerate + ResTier uint8 // 6: Resolution tier (1=Low, 4=High) + Bitrate uint8 // 7: Bitrate index (30=360P, 100=HD, 200=2K) + Timestamp uint32 // 8-11: Timestamp + SessionID uint32 // 12-15: Session marker (constant) + PayloadSize uint32 // 16-19: Payload size + FrameNo uint32 // 20-23: Frame number +} + +func (fi *FrameInfo) IsKeyframe() bool { + return fi.Flags == 0x01 +} + +func (fi *FrameInfo) SampleRate() uint32 { + idx := (fi.Flags >> 2) & 0x0F + if idx < uint8(len(sampleRates)) { + return sampleRates[idx] + } + return 16000 +} + +func (fi *FrameInfo) Channels() uint8 { + if fi.Flags&0x01 == 1 { + return 2 + } + return 1 +} + +func ParseFrameInfo(data []byte) *FrameInfo { + if len(data) < frameInfoSize { + return nil + } + + offset := len(data) - frameInfoSize + fi := data[offset:] + + return &FrameInfo{ + CodecID: fi[0], + Flags: fi[2], + CamIndex: fi[3], + OnlineNum: fi[4], + FPS: fi[5], + ResTier: fi[6], + Bitrate: fi[7], + Timestamp: binary.LittleEndian.Uint32(fi[8:]), + SessionID: binary.LittleEndian.Uint32(fi[12:]), + PayloadSize: binary.LittleEndian.Uint32(fi[16:]), + FrameNo: binary.LittleEndian.Uint32(fi[20:]), + } +} + +type Packet struct { + Channel uint8 + Codec byte + Timestamp uint32 + Payload []byte + IsKeyframe bool + FrameNo uint32 + SampleRate uint32 + Channels uint8 +} + +type PacketHeader struct { + Channel byte + FrameType byte + HeaderSize int + FrameNo uint32 + PktIdx uint16 + PktTotal uint16 + PayloadSize uint16 + HasFrameInfo bool +} + +func ParsePacketHeader(data []byte) *PacketHeader { + if len(data) < 28 { + return nil + } + + frameType := data[1] + hdr := &PacketHeader{ + Channel: data[0], + FrameType: frameType, + } + + switch frameType { + case FrameTypeStart, FrameTypeStartAlt, FrameTypeEndExt: + hdr.HeaderSize = 36 + default: + hdr.HeaderSize = 28 + } + + if len(data) < hdr.HeaderSize { + return nil + } + + if hdr.HeaderSize == 28 { + hdr.PktTotal = binary.LittleEndian.Uint16(data[12:]) + pktIdxOrMarker := binary.LittleEndian.Uint16(data[14:]) + hdr.PayloadSize = binary.LittleEndian.Uint16(data[16:]) + hdr.FrameNo = binary.LittleEndian.Uint32(data[24:]) + + if pktIdxOrMarker == 0x0028 && (IsEndFrame(frameType) || hdr.PktTotal == 1) { + hdr.HasFrameInfo = true + if hdr.PktTotal > 0 { + hdr.PktIdx = hdr.PktTotal - 1 + } + } else { + hdr.PktIdx = pktIdxOrMarker + } + } else { + hdr.PktTotal = binary.LittleEndian.Uint16(data[20:]) + pktIdxOrMarker := binary.LittleEndian.Uint16(data[22:]) + hdr.PayloadSize = binary.LittleEndian.Uint16(data[24:]) + hdr.FrameNo = binary.LittleEndian.Uint32(data[32:]) + + if pktIdxOrMarker == 0x0028 && (IsEndFrame(frameType) || hdr.PktTotal == 1) { + hdr.HasFrameInfo = true + if hdr.PktTotal > 0 { + hdr.PktIdx = hdr.PktTotal - 1 + } + } else { + hdr.PktIdx = pktIdxOrMarker + } + } + + return hdr +} + +func IsStartFrame(frameType uint8) bool { + return frameType == FrameTypeStart || frameType == FrameTypeStartAlt +} + +func IsEndFrame(frameType uint8) bool { + return frameType == FrameTypeEndSingle || + frameType == FrameTypeEndMulti || + frameType == FrameTypeEndExt +} + +func IsContinuationFrame(frameType uint8) bool { + return frameType == FrameTypeCont || frameType == FrameTypeContAlt +} + +type channelState struct { + frameNo uint32 // current frame being assembled + pktTotal uint16 // expected total packets + waitSeq uint16 // next expected packet index (0, 1, 2, ...) + waitData []byte // accumulated payload data + frameInfo *FrameInfo // frame info (from end packet) + hasStarted bool // received first packet of frame + lastPktIdx uint16 // last received packet index (for OOO detection) +} + +func (cs *channelState) reset() { + cs.frameNo = 0 + cs.pktTotal = 0 + cs.waitSeq = 0 + cs.waitData = cs.waitData[:0] + cs.frameInfo = nil + cs.hasStarted = false + cs.lastPktIdx = 0 +} + +const tsWrapPeriod uint32 = 1000000 + +type tsTracker struct { + lastRawTS uint32 + accumUS uint64 + firstTS bool +} + +func (t *tsTracker) update(rawTS uint32) uint64 { + if !t.firstTS { + t.firstTS = true + t.lastRawTS = rawTS + return 0 + } + + var delta uint32 + if rawTS >= t.lastRawTS { + delta = rawTS - t.lastRawTS + } else { + // Wrapped: delta = (wrap - last) + new + delta = (tsWrapPeriod - t.lastRawTS) + rawTS + } + + t.accumUS += uint64(delta) + t.lastRawTS = rawTS + + return t.accumUS +} + +type FrameHandler struct { + channels map[byte]*channelState + videoTS tsTracker + audioTS tsTracker + output chan *Packet + verbose bool + closed bool + closeMu sync.Mutex +} + +func NewFrameHandler(verbose bool) *FrameHandler { + return &FrameHandler{ + channels: make(map[byte]*channelState), + output: make(chan *Packet, 128), + verbose: verbose, + } +} + +func (h *FrameHandler) Recv() <-chan *Packet { + return h.output +} + +func (h *FrameHandler) Close() { + h.closeMu.Lock() + defer h.closeMu.Unlock() + + if h.closed { + return + } + h.closed = true + close(h.output) +} + +func (h *FrameHandler) Handle(data []byte) { + hdr := ParsePacketHeader(data) + if hdr == nil { + return + } + + payload, fi := h.extractPayload(data, hdr.Channel) + if payload == nil { + return + } + + if h.verbose { + fiStr := "" + if hdr.HasFrameInfo { + fiStr = " +FI" + } + fmt.Printf("[RX] ch=0x%02x type=0x%02x #%d pkt=%d/%d data=%dB%s\n", + hdr.Channel, hdr.FrameType, + hdr.FrameNo, hdr.PktIdx, hdr.PktTotal, len(payload), fiStr) + } + + switch hdr.Channel { + case ChannelAudio: + h.handleAudio(payload, fi) + case ChannelIVideo, ChannelPVideo: + h.handleVideo(hdr.Channel, hdr, payload, fi) + } +} + +func (h *FrameHandler) extractPayload(data []byte, channel byte) ([]byte, *FrameInfo) { + if len(data) < 2 { + return nil, nil + } + + frameType := data[1] + + headerSize := 28 + fiSize := 0 + + switch frameType { + case FrameTypeStart: + headerSize = 36 + case FrameTypeStartAlt: + headerSize = 36 + if len(data) >= 22 { + pktTotal := binary.LittleEndian.Uint16(data[20:]) + if pktTotal == 1 { + fiSize = frameInfoSize + } + } + case FrameTypeCont, FrameTypeContAlt: + headerSize = 28 + case FrameTypeEndSingle, FrameTypeEndMulti: + headerSize = 28 + fiSize = frameInfoSize + case FrameTypeEndExt: + headerSize = 36 + fiSize = frameInfoSize + default: + headerSize = 28 + } + + if len(data) < headerSize { + return nil, nil + } + + if fiSize == 0 { + return data[headerSize:], nil + } + + if len(data) < headerSize+fiSize { + return data[headerSize:], nil + } + + fi := ParseFrameInfo(data) + + validCodec := false + switch channel { + case ChannelIVideo, ChannelPVideo: + validCodec = IsVideoCodec(fi.CodecID) + case ChannelAudio: + validCodec = IsAudioCodec(fi.CodecID) + } + + if validCodec { + payload := data[headerSize : len(data)-fiSize] + return payload, fi + } + + return data[headerSize:], nil +} + +func (h *FrameHandler) handleVideo(channel byte, hdr *PacketHeader, payload []byte, fi *FrameInfo) { + cs := h.channels[channel] + if cs == nil { + cs = &channelState{} + h.channels[channel] = cs + } + + // New frame number - reset and start fresh + if hdr.FrameNo != cs.frameNo { + // Check if previous frame was incomplete + if cs.hasStarted && cs.waitSeq < cs.pktTotal { + fmt.Printf("[DROP] ch=0x%02x #%d INCOMPLETE: got %d/%d pkts\n", + channel, cs.frameNo, cs.waitSeq, cs.pktTotal) + } + cs.reset() + cs.frameNo = hdr.FrameNo + cs.pktTotal = hdr.PktTotal + } + + // If packet index doesn't match expected, reset (data loss) + if hdr.PktIdx != cs.waitSeq { + fmt.Printf("[OOO] ch=0x%02x #%d frameType=0x%02x pktTotal=%d expected pkt %d, got %d - reset\n", + channel, hdr.FrameNo, hdr.FrameType, hdr.PktTotal, cs.waitSeq, hdr.PktIdx) + cs.reset() + return + } + + // First packet - mark as started + if cs.waitSeq == 0 { + cs.hasStarted = true + } + + cs.waitData = append(cs.waitData, payload...) + cs.waitSeq++ + + // Store frame info if present + if fi != nil { + cs.frameInfo = fi + } + + // Check if frame is complete + if cs.waitSeq != cs.pktTotal || cs.frameInfo == nil { + return + } + + fi = cs.frameInfo + defer cs.reset() + + if fi.PayloadSize > 0 && uint32(len(cs.waitData)) != fi.PayloadSize { + fmt.Printf("[SIZE] ch=0x%02x #%d mismatch: expected %d, got %d\n", + channel, cs.frameNo, fi.PayloadSize, len(cs.waitData)) + return + } + + if len(cs.waitData) == 0 { + return + } + + accumUS := h.videoTS.update(fi.Timestamp) + rtpTS := uint32(accumUS * 90000 / 1000000) + + pkt := &Packet{ + Channel: channel, + Payload: append([]byte{}, cs.waitData...), + Codec: fi.CodecID, + Timestamp: rtpTS, + IsKeyframe: fi.IsKeyframe(), + FrameNo: fi.FrameNo, + } + + if h.verbose { + frameType := "P" + if fi.IsKeyframe() { + frameType = "KEY" + } + fmt.Printf("[OK] ch=0x%02x #%d codec=0x%02x %s size=%d\n", + channel, fi.FrameNo, fi.CodecID, frameType, len(pkt.Payload)) + fmt.Printf(" [0-1]codec=0x%02x [2]flags=0x%x [3]=%d [4]=%d\n", + fi.CodecID, fi.Flags, fi.CamIndex, fi.OnlineNum) + fmt.Printf(" [5]=%d [6]=%d [7]=%d [8-11]ts=%d\n", + fi.FPS, fi.ResTier, fi.Bitrate, fi.Timestamp) + fmt.Printf(" [12-15]=0x%x [16-19]payload=%d [20-23]frameNo=%d\n", + fi.SessionID, fi.PayloadSize, fi.FrameNo) + fmt.Printf(" rtp_ts=%d accum_us=%d\n", rtpTS, accumUS) + fmt.Printf(" hex: %s\n", dumpHex(fi)) + } + + h.queue(pkt) +} + +func (h *FrameHandler) handleAudio(payload []byte, fi *FrameInfo) { + if len(payload) == 0 || fi == nil { + return + } + + var sampleRate uint32 + var channels uint8 + + switch fi.CodecID { + case CodecAACRaw, CodecAACADTS, CodecAACLATM, CodecAACAlt: + sampleRate, channels = parseAudioParams(payload, fi) + default: + sampleRate = fi.SampleRate() + channels = fi.Channels() + } + + accumUS := h.audioTS.update(fi.Timestamp) + rtpTS := uint32(accumUS * uint64(sampleRate) / 1000000) + + payloadCopy := make([]byte, len(payload)) + copy(payloadCopy, payload) + + pkt := &Packet{ + Channel: ChannelAudio, + Payload: payloadCopy, + Codec: fi.CodecID, + Timestamp: rtpTS, + SampleRate: sampleRate, + Channels: channels, + FrameNo: fi.FrameNo, + } + + if h.verbose { + bits := 8 + if fi.Flags&0x02 != 0 { + bits = 16 + } + fmt.Printf("[OK] Audio #%d codec=0x%02x size=%d\n", + fi.FrameNo, fi.CodecID, len(payload)) + fmt.Printf(" [0-1]codec=0x%02x [2]flags=0x%x(%dHz/%dbit/%dch)\n", + fi.CodecID, fi.Flags, sampleRate, bits, channels) + fmt.Printf(" [8-11]ts=%d [12-15]=0x%x rtp_ts=%d\n", + fi.Timestamp, fi.SessionID, rtpTS) + fmt.Printf(" hex: %s\n", dumpHex(fi)) + } + + h.queue(pkt) +} + +func (h *FrameHandler) queue(pkt *Packet) { + h.closeMu.Lock() + defer h.closeMu.Unlock() + + if h.closed { + return + } + + select { + case h.output <- pkt: + default: + // Queue full - drop oldest + select { + case <-h.output: + default: + } + select { + case h.output <- pkt: + default: + // Queue still full, drop this packet + } + } +} + +func parseAudioParams(payload []byte, fi *FrameInfo) (sampleRate uint32, channels uint8) { + if aac.IsADTS(payload) { + codec := aac.ADTSToCodec(payload) + if codec != nil { + return codec.ClockRate, codec.Channels + } + } + + if fi != nil { + return fi.SampleRate(), fi.Channels() + } + + return 16000, 1 +} + +func dumpHex(fi *FrameInfo) string { + b := make([]byte, frameInfoSize) + b[0] = fi.CodecID + b[1] = 0 // High byte (unused) + b[2] = fi.Flags + b[3] = fi.CamIndex + b[4] = fi.OnlineNum + b[5] = fi.FPS + b[6] = fi.ResTier + b[7] = fi.Bitrate + binary.LittleEndian.PutUint32(b[8:], fi.Timestamp) + binary.LittleEndian.PutUint32(b[12:], fi.SessionID) + binary.LittleEndian.PutUint32(b[16:], fi.PayloadSize) + binary.LittleEndian.PutUint32(b[20:], fi.FrameNo) + // Bytes 24-39 are DeviceID and Padding (not stored in struct) + + hexStr := hex.EncodeToString(b) + formatted := "" + for i := 0; i < len(hexStr); i += 2 { + if i > 0 { + formatted += " " + } + formatted += hexStr[i : i+2] + } + return formatted +} diff --git a/installs_on_host/go2rtc/pkg/tutk/helpers.go b/installs_on_host/go2rtc/pkg/tutk/helpers.go new file mode 100644 index 0000000..93bf4b5 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/tutk/helpers.go @@ -0,0 +1,71 @@ +package tutk + +import ( + "encoding/binary" + "time" +) + +func GenSessionID() []byte { + b := make([]byte, 8) + binary.LittleEndian.PutUint64(b, uint64(time.Now().UnixNano())) + return b +} + +func ICAM(cmd uint32, args ...byte) []byte { + // 0 4943414d ICAM + // 4 d807ff00 command + // 8 00000000000000 + // 15 02 args count + // 16 00000000000000 + // 23 0101 args + n := byte(len(args)) + b := make([]byte, 23+n) + copy(b, "ICAM") + binary.LittleEndian.PutUint32(b[4:], cmd) + b[15] = n + copy(b[23:], args) + return b +} + +func HL(cmdID uint16, payload []byte) []byte { + // 0-1 "HL" magic + // 2 version (typically 5) + // 3 reserved + // 4-5 cmdID command ID (uint16 LE) + // 6-7 payloadLen payload length (uint16 LE) + // 8-15 reserved + // 16+ payload + const headerSize = 16 + const version = 5 + + b := make([]byte, headerSize+len(payload)) + copy(b, "HL") + b[2] = version + binary.LittleEndian.PutUint16(b[4:], cmdID) + binary.LittleEndian.PutUint16(b[6:], uint16(len(payload))) + copy(b[headerSize:], payload) + return b +} + +func ParseHL(data []byte) (cmdID uint16, payload []byte, ok bool) { + if len(data) < 16 || data[0] != 'H' || data[1] != 'L' { + return 0, nil, false + } + cmdID = binary.LittleEndian.Uint16(data[4:]) + payloadLen := binary.LittleEndian.Uint16(data[6:]) + if len(data) >= 16+int(payloadLen) { + payload = data[16 : 16+payloadLen] + } else if len(data) > 16 { + payload = data[16:] + } + return cmdID, payload, true +} + +func FindHL(data []byte, offset int) []byte { + for i := offset; i+16 <= len(data); i++ { + if data[i] == 'H' && data[i+1] == 'L' { + return data[i:] + } + } + return nil +} diff --git a/installs_on_host/go2rtc/pkg/tutk/session0.go b/installs_on_host/go2rtc/pkg/tutk/session0.go new file mode 100644 index 0000000..6a1b225 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/tutk/session0.go @@ -0,0 +1,157 @@ +package tutk + +import ( + "bytes" + "encoding/binary" + "net" + "time" +) + +func (c *Conn) connectDirect(uid string, sid []byte) error { + res, err := writeAndWait( + c, func(res []byte) bool { return bytes.Index(res, []byte("\x02\x06\x12\x00")) == 8 }, + ConnectByUID(stageBroadcast, uid, sid), + ) + if err != nil { + return err + } + + n := len(res) // should be 200 + c.ver = []byte{res[2], res[n-13], res[n-14], res[n-15], res[n-16]} + + _, err = c.Write(ConnectByUID(stageDirect, uid, sid)) + return err +} + +func (c *Conn) connectRemote(uid string, sid []byte) error { + res, err := writeAndWait( + c, func(res []byte) bool { return bytes.Index(res, []byte("\x01\x03\x43")) == 8 }, + ConnectByUID(stageGetRemoteIP, uid, sid), + ) + if err != nil { + return err + } + + // Read real IP from cloud server response. + // Important ot use net.IPv4 because slice will be 16 bytes. + c.addr.IP = net.IPv4(res[40], res[41], res[42], res[43]) + c.addr.Port = int(binary.BigEndian.Uint16(res[38:])) + + res, err = writeAndWait( + c, func(res []byte) bool { return bytes.Index(res, []byte("\x04\x04\x33")) == 8 }, + ConnectByUID(stageRemoteAck, uid, sid), + ) + if err != nil { + return err + } + + if len(res) == 52 { + c.ver = []byte{res[2], res[51], res[50], res[49], res[48]} + } else { + c.ver = []byte{res[2]} + } + + _, err = c.Write(ConnectByUID(stageRemoteOK, uid, sid)) + return err +} + +func (c *Conn) clientStart(username, password string) error { + _, err := writeAndWait( + c, func(res []byte) bool { + return len(res) >= 84 && res[28] == 0 && (res[29] == 0x14 || res[29] == 0x21) + }, + c.session.ClientStart(0, username, password), + c.session.ClientStart(1, username, password), + ) + return err +} + +func writeAndWait(conn net.Conn, ok func(res []byte) bool, req ...[]byte) ([]byte, error) { + var t *time.Timer + t = time.AfterFunc(1, func() { + for _, b := range req { + if _, err := conn.Write(b); err != nil { + return + } + } + if t != nil { + t.Reset(time.Second) + } + }) + defer t.Stop() + + buf := make([]byte, 1200) + + for { + n, err := conn.Read(buf) + if err != nil { + return nil, err + } + + if ok(buf[:n]) { + return buf[:n], nil + } + } +} + +const ( + magic = "\x04\x02\x19" // include version 0x19 + sdkVersion = "\x06\x00\x03\x03" // 3.3.0.6 +) + +const ( + stageBroadcast = iota + 1 + stageDirect + stageGetPublicIP + stageGetRemoteIP + stageRemoteReq + stageRemoteAck + stageRemoteOK +) + +func ConnectByUID(stage byte, uid string, sid8 []byte) []byte { + var b []byte + + switch stage { + case stageBroadcast, stageDirect: + b = make([]byte, 68) + copy(b[8:], "\x01\x06\x21") + copy(b[52:], sdkVersion) + copy(b[56:], sid8) + b[64] = stage // 1 or 2 + + case stageGetPublicIP: + b = make([]byte, 54) + copy(b[8:], "\x07\x10\x18") + + case stageGetRemoteIP: + b = make([]byte, 112) + copy(b[8:], "\x03\x02\x34") + copy(b[100:], sid8) + b[108] = stageDirect + + case stageRemoteReq: + b = make([]byte, 52) + copy(b[8:], "\x01\x04\x33") + copy(b[36:], sid8) + copy(b[48:], sdkVersion) + + case stageRemoteAck: + b = make([]byte, 44) + copy(b[8:], "\x02\x04\x33") + copy(b[36:], sid8) + + case stageRemoteOK: + b = make([]byte, 52) + copy(b[8:], "\x04\x04\x33") + copy(b[36:], sid8) + copy(b[48:], sdkVersion) + } + + copy(b, magic) + b[3] = 0x02 // connection stage + binary.LittleEndian.PutUint16(b[4:], uint16(len(b))-16) + copy(b[16:], uid) + + return b +} diff --git a/installs_on_host/go2rtc/pkg/tutk/session16.go b/installs_on_host/go2rtc/pkg/tutk/session16.go new file mode 100644 index 0000000..5344bdb --- /dev/null +++ b/installs_on_host/go2rtc/pkg/tutk/session16.go @@ -0,0 +1,381 @@ +package tutk + +import ( + "bytes" + "encoding/binary" + "io" + "net" + "time" +) + +type Session interface { + Close() error + + ClientStart(i byte, username, password string) []byte + + SendIOCtrl(ctrlType uint32, ctrlData []byte) []byte + SendFrameData(frameInfo, frameData []byte) []byte + + RecvIOCtrl() (ctrlType uint32, ctrlData []byte, err error) + RecvFrameData() (frameInfo, frameData []byte, err error) + + SessionRead(chID byte, buf []byte) int + SessionWrite(chID byte, buf []byte) error +} + +func NewSession16(conn net.Conn, sid8 []byte) *Session16 { + sid16 := make([]byte, 16) + copy(sid16[8:], sid8) + copy(sid16, sid8[:2]) + sid16[4] = 0x0c + + return &Session16{ + conn: conn, + sid16: sid16, + rawCmd: make(chan []byte, 10), + rawPkt: make(chan [2][]byte, 100), + } +} + +type Session16 struct { + conn net.Conn + sid16 []byte + + rawCmd chan []byte + rawPkt chan [2][]byte + + seqSendCh0 uint16 + seqSendCh1 uint16 + + seqSendCmd1 uint16 + seqSendAud uint16 + + waitFSeq uint16 + waitCSeq uint16 + waitSize int + waitData []byte +} + +func (s *Session16) Close() error { + close(s.rawCmd) + close(s.rawPkt) + return nil +} + +func (s *Session16) Msg(size uint16) []byte { + b := make([]byte, size) + copy(b, magic) + b[3] = 0x0a // connected stage + binary.LittleEndian.PutUint16(b[4:], size-16) + copy(b[8:], "\x07\x04\x21") // client request + copy(b[12:], s.sid16) + return b +} + +const ( + msgHhrSize = 28 + cmdHdrSize = 24 +) + +func (s *Session16) ClientStart(i byte, username, password string) []byte { + const size = 566 + 32 + msg := s.Msg(size) + + // 0 00000b0000000000000000000000000022020000fcfc7284 + // 24 4d69737300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 + // 281 636c69656e740000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 + // 538 0100000004000000fb071f000000000000000000000003000000000001000000 + cmd := msg[msgHhrSize:] + copy(cmd, "\x00\x00\x0b\x00") + binary.LittleEndian.PutUint16(cmd[16:], size-52) + if i == 0 { + cmd[18] = 1 + } else { + cmd[1] = 0x20 + } + binary.LittleEndian.PutUint32(cmd[20:], uint32(time.Now().UnixMilli())) + + // important values for some cameras (not for df3) + data := cmd[cmdHdrSize:] + copy(data, username) + copy(data[257:], password) + + // 0100000004000000fb071f000000000000000000000003000000000001000000 + cfg := data[257+257:] + //cfg[0] = 1 // 0 - simple proto, 1 - complex proto with "0Cxx" commands + cfg[4] = 4 + copy(cfg[8:], "\xfb\x07\x1f\x00") + cfg[22] = 3 + //cfg[28] = 1 // unknown + return msg +} + +func (s *Session16) SendIOCtrl(ctrlType uint32, ctrlData []byte) []byte { + dataSize := 4 + uint16(len(ctrlData)) + msg := s.Msg(msgHhrSize + cmdHdrSize + dataSize) + + cmd := msg[msgHhrSize:] + copy(cmd, "\x00\x70\x0b\x00") + + s.seqSendCmd1++ // start from 1, important! + binary.LittleEndian.PutUint16(cmd[4:], s.seqSendCmd1) + + binary.LittleEndian.PutUint16(cmd[16:], dataSize) + binary.LittleEndian.PutUint32(cmd[20:], uint32(time.Now().UnixMilli())) + + data := cmd[cmdHdrSize:] + binary.LittleEndian.PutUint32(data, ctrlType) + copy(data[4:], ctrlData) + return msg +} + +func (s *Session16) SendFrameData(frameInfo, frameData []byte) []byte { + // -> 01030b001d0000008802000000002800b0020bf501000000 ... 4f4455412000000088020000030400001d000000000000000bf51f7a9b0100000000000000000000 + + n := uint16(len(frameData)) + dataSize := n + 8 + 32 + msg := s.Msg(msgHhrSize + cmdHdrSize + dataSize) + + // 0 01030b00 command + version + // 4 1d000000 seq + // 8 8802 media size (648) + // 10 00000000 + // 14 2800 tail (pkt header) size? + // 16 b002 size (648 + 8 + 32) + // 18 0bf5 random msg id (unixms) + // 20 01000000 fixed + cmd := msg[msgHhrSize:] + copy(cmd, "\x01\x03\x0b\x00") + binary.LittleEndian.PutUint16(cmd[4:], s.seqSendAud) + s.seqSendAud++ + binary.LittleEndian.PutUint16(cmd[8:], n) + cmd[14] = 0x28 // important! + binary.LittleEndian.PutUint16(cmd[16:], dataSize) + binary.LittleEndian.PutUint16(cmd[18:], uint16(time.Now().UnixMilli())) + cmd[20] = 1 + + data := cmd[cmdHdrSize:] + copy(data, frameData) + copy(data[n:], "ODUA\x20\x00\x00\x00") + copy(data[n+8:], frameInfo) + + return msg +} + +func (s *Session16) RecvIOCtrl() (ctrlType uint32, ctrlData []byte, err error) { + buf, ok := <-s.rawCmd + if !ok { + return 0, nil, io.EOF + } + return binary.LittleEndian.Uint32(buf), buf[4:], nil +} + +func (s *Session16) RecvFrameData() (frameInfo, frameData []byte, err error) { + buf, ok := <-s.rawPkt + if !ok { + return nil, nil, io.EOF + } + return buf[0], buf[1], nil +} + +func (s *Session16) SessionRead(chID byte, cmd []byte) int { + if chID != 0 { + return s.handleCh1(cmd) + } + + // 0 01030800 command + version + // 4 00000000 frame seq + // 8 ac880100 total size + // 12 6200 chunk seq + // 14 2000 tail (pkt header) size + // 16 cc00 size + // 18 0000 + // 20 01000000 fixed + + switch cmd[0] { + case 0x01: + var packetData [2][]byte + + switch cmd[1] { + case 0x03: + frameSeq := binary.LittleEndian.Uint16(cmd[4:]) + chunkSeq := binary.LittleEndian.Uint16(cmd[12:]) + if chunkSeq == 0 { + s.waitFSeq = frameSeq + s.waitCSeq = 0 + s.waitData = s.waitData[:0] + payloadSize := binary.LittleEndian.Uint32(cmd[8:]) + hdrSize := binary.LittleEndian.Uint16(cmd[14:]) + s.waitSize = int(hdrSize) + int(payloadSize) + } else if frameSeq != s.waitFSeq || chunkSeq != s.waitCSeq { + s.waitCSeq = 0 + return msgMediaLost + } + + s.waitData = append(s.waitData, cmd[24:]...) + if n := len(s.waitData); n < s.waitSize { + s.waitCSeq++ + return msgMediaChunk + } + + s.waitCSeq = 0 + + payloadSize := binary.LittleEndian.Uint32(cmd[8:]) + packetData[0] = bytes.Clone(s.waitData[payloadSize:]) + packetData[1] = bytes.Clone(s.waitData[:payloadSize]) + + case 0x04: + data := cmd[24:] + hdrSize := binary.LittleEndian.Uint16(cmd[14:]) + packetData[0] = bytes.Clone(data[:hdrSize]) + packetData[1] = bytes.Clone(data[hdrSize:]) + + default: + return msgUnknown + } + + select { + case s.rawPkt <- packetData: + default: + return msgError + } + return msgMediaFrame + + case 0x00: + switch cmd[1] { + case 0x70: + _ = s.SessionWrite(0, s.msgAck0070(cmd)) + select { + case s.rawCmd <- append([]byte{}, cmd[24:]...): + default: + } + + return msgCommand + case 0x12: + _ = s.SessionWrite(0, s.msgAck0012(cmd)) + return msgDafang0012 + case 0x71: + return msgCommandAck + } + } + + return msgUnknown +} + +func (s *Session16) msgAck0070(msg28 []byte) []byte { + // <- 00700800010000000000000000000000340000007625a02f ... + // -> 00710800010000000000000000000000000000007625a02f + msg := s.Msg(msgHhrSize + cmdHdrSize) + + cmd := msg[msgHhrSize:] + copy(cmd, "\x00\x71") + copy(cmd[2:], msg28[2:6]) // same version and seq + copy(cmd[20:], msg28[20:24]) // same msg random + + return msg +} + +func (s *Session16) msgAck0012(msg28 []byte) []byte { + // <- 001208000000000000000000000000000c00000000000000 020000000100000001000000 + // -> 00130b000000000000000000000000001400000000000000 0200000001000000010000000000000000000000 + const dataSize = 20 + msg := s.Msg(msgHhrSize + cmdHdrSize + dataSize) + + cmd := msg[msgHhrSize:] + copy(cmd, "\x00\x13\x0b\x00") + cmd[16] = dataSize + + data := cmd[cmdHdrSize:] + copy(data, msg28[cmdHdrSize:]) + + return msg +} + +func (s *Session16) handleCh1(cmd []byte) int { + // Channel 1 used for two-way audio. It's important: + // - answer on 0000 command with exact config response (can't set simple proto) + // - send 0012 command at start + // - respond on every 0008 command for smooth playback + switch cid := string(cmd[:2]); cid { + case "\x00\x00": // client start + _ = s.SessionWrite(1, s.msgAck0000(cmd)) + _ = s.SessionWrite(1, s.msg0012()) + return msgClientStart + case "\x00\x07": // time sync without data + _ = s.SessionWrite(1, s.msgAck0007(cmd)) + return msgUnknown0007 + case "\x00\x08": // time sync with data + _ = s.SessionWrite(1, s.msgAck0008(cmd)) + return msgUnknown0008 + case "\x00\x13": // ack for 0012 + return msgUnknown0013 + } + return msgUnknown +} + +func (s *Session16) msgAck0000(msg28 []byte) []byte { + // <- 000008000000000000000000000000001a0200004f47c714 ... 00000000000000000100000004000000fb071f00000000000000000000000300 + // -> 00140b00000000000000000000000000200000004f47c714 00000000000000000100000004000000fb071f00000000000000000000000300 + const cmdDataSize = 32 + msg := s.Msg(msgHhrSize + cmdHdrSize + cmdDataSize) + + cmd := msg[msgHhrSize:] + copy(cmd, "\x00\x14\x0b\x00") + cmd[16] = cmdDataSize + copy(cmd[20:], msg28[20:24]) // request id (random) + + // Important to answer with same data. + data := cmd[cmdHdrSize:] + copy(data, msg28[len(msg28)-32:]) + return msg +} + +func (s *Session16) msg0012() []byte { + // -> 00120b000000000000000000000000000c00000000000000020000000100000001000000 + const dataSize = 12 + msg := s.Msg(msgHhrSize + cmdHdrSize + dataSize) + cmd := msg[msgHhrSize:] + + copy(cmd, "\x00\x12\x0b\x00") + cmd[16] = dataSize + data := cmd[cmdHdrSize:] + + data[0] = 2 + data[4] = 1 + data[9] = 1 + return msg +} + +func (s *Session16) msgAck0007(msg28 []byte) []byte { + // <- 000708000000000000000000000000000c00000001000000000000001c551f7a00000000 + // -> 010a0b00000000000000000000000000000000000100000000000000 + msg := s.Msg(msgHhrSize + 28) + cmd := msg[msgHhrSize:] + copy(cmd, "\x01\x0a\x0b\x00") + cmd[20] = 1 + return msg +} + +func (s *Session16) msgAck0008(msg28 []byte) []byte { + // <- 000808000000000000000000000000000000f9f0010000000200000050f31f7a + // -> 01090b0000000000000000000000000000000000010000000200000050f31f7a + msg := s.Msg(msgHhrSize + 28) + cmd := msg[msgHhrSize:] + copy(cmd, "\x01\x09\x0b\x00") + copy(cmd[20:], msg28[20:]) + return msg +} + +func (s *Session16) SessionWrite(chID byte, buf []byte) error { + switch chID { + case 0: + binary.LittleEndian.PutUint16(buf[6:], s.seqSendCh0) + s.seqSendCh0++ + case 1: + binary.LittleEndian.PutUint16(buf[6:], s.seqSendCh1) + s.seqSendCh1++ + buf[14] = 1 // channel + } + _, err := s.conn.Write(buf) + return err +} diff --git a/installs_on_host/go2rtc/pkg/tutk/session25.go b/installs_on_host/go2rtc/pkg/tutk/session25.go new file mode 100644 index 0000000..dc79d3a --- /dev/null +++ b/installs_on_host/go2rtc/pkg/tutk/session25.go @@ -0,0 +1,337 @@ +package tutk + +import ( + "bytes" + "encoding/binary" + "net" + "time" +) + +func NewSession25(conn net.Conn, sid []byte) *Session25 { + return &Session25{ + Session16: NewSession16(conn, sid), + rb: NewReorderBuffer(5), + } +} + +type Session25 struct { + *Session16 + + rb *ReorderBuffer + + seqSendCmd2 uint16 + seqSendCnt uint16 + + seqRecvPkt0 uint16 + seqRecvPkt1 uint16 + seqRecvCmd2 uint16 +} + +const cmdHdrSize25 = 28 + +func (s *Session25) SendIOCtrl(ctrlType uint32, ctrlData []byte) []byte { + size := msgHhrSize + cmdHdrSize25 + 4 + uint16(len(ctrlData)) + msg := s.Msg(size) + + // 0 0070 command + // 2 0b00 version + // 4 1000 seq + // 6 0076 ??? + cmd := msg[msgHhrSize:] + copy(cmd, "\x00\x70\x0b\x00") + binary.LittleEndian.PutUint16(cmd[4:], s.seqSendCmd1) + s.seqSendCmd1++ + + // 8 0070 command (second time) + // 10 0300 seq + // 12 0100 chunks count + // 14 0000 chunk seq (starts from 0) + // 16 5500 size + // 18 0000 random msg id (always 0) + // 20 03000000 seq (second time) + // 24 00000000 + // 28 01010000 ctrlType + cmd[9] = 0x70 + cmd[12] = 1 + binary.LittleEndian.PutUint16(cmd[16:], size-52) + + binary.LittleEndian.PutUint16(cmd[10:], s.seqSendCmd2) + binary.LittleEndian.PutUint16(cmd[20:], s.seqSendCmd2) + s.seqSendCmd2++ + + data := cmd[28:] + binary.LittleEndian.PutUint32(data, ctrlType) + copy(data[4:], ctrlData) + return msg +} + +func (s *Session25) SendFrameData(frameInfo, frameData []byte) []byte { + return nil +} + +func (s *Session25) SessionRead(chID byte, cmd []byte) (res int) { + if chID != 0 { + return s.handleCh1(cmd) + } + + switch cmd[0] { + case 0x03, 0x05, 0x07: + for i := 0; cmd != nil; i++ { + res = s.handleChunk(cmd, i == 0) + cmd = s.rb.Pop() + } + return + + case 0x00: + _ = s.SessionWrite(0, s.msgAckCounters()) + s.seqRecvCmd2 = binary.LittleEndian.Uint16(cmd[2:]) + + switch cmd[1] { + case 0x10: + return msgUnknown0010 // unknown + case 0x21: + return msgClientStartAck2 + case 0x70: + select { + case s.rawCmd <- cmd[28:]: + default: + } + return msgCommand // cmd from camera + case 0x71: + return msgCommandAck + } + + case 0x09: + // off sample + // 0 09000b00 cmd1 + // 4 0d000000 seqCmd1 + // 12 0000 seqRecvCmd2 + seq := binary.LittleEndian.Uint16(cmd[12:]) + if s.seqSendCmd1 > seq { + return msgCommandAck + } + return msgCounters + + case 0x0a: + // seq sample + // 0 0a080b00 + // 4 03000000 + // 8 e2043200 + // 12 01000000 + _ = s.SessionWrite(0, s.msgAck0A08(cmd)) + return msgUnknown0a08 + } + + return msgUnknown +} + +func (s *Session25) handleChunk(cmd []byte, checkSeq bool) int { + var cmd2 []byte + + flags := cmd[1] + if flags&0b1000 == 0 { + // off sample + // 0 0700 command + // 2 0b00 version + // 4 2700 seq + // 6 0000 ??? + // 8 0700 command (second time) + // 10 1400 seq + // 12 1300 chunks count per this frame + // 14 1100 chunk seq, starts from 0 (0x20 for last chunk) + // 16 0004 frame data size + // 18 0000 random msg id (always 0) + // 20 02000000 previous frame seq, starts from 0 + // 24 03000000 current frame seq, starts from 1 + cmd2 = cmd[8:] + } else { + // off sample + // 0 070d0b00 + // 4 30000000 + // 8 5c965500 ??? + // 12 ffff0000 ??? + // 16 0701 fixed command + // 18 190001002000a802000006000000070000000 + cmd2 = cmd[16:] + } + + seq := binary.LittleEndian.Uint16(cmd2[2:]) + + if checkSeq { + if s.rb.Check(seq) { + s.rb.Next() + } else { + s.rb.Push(seq, cmd) + return msgMediaReorder + } + } + + // Check if this is first chunk for frame. + // Handle protocol bug "0x20 chunk seq for last chunk" and sometimes + // "0x20 chunk seq for first chunk if only one chunk". + if binary.LittleEndian.Uint16(cmd2[6:]) == 0 || binary.LittleEndian.Uint16(cmd2[4:]) == 1 { + s.waitData = s.waitData[:0] + s.waitCSeq = seq + } else if seq != s.waitCSeq { + return msgMediaLost + } + + s.waitData = append(s.waitData, cmd2[20:]...) + + if flags&0b0001 == 0 { + s.waitCSeq++ + return msgMediaChunk + } + + s.seqRecvPkt1 = seq + _ = s.SessionWrite(0, s.msgAckCounters()) + + n := len(s.waitData) - 32 + packetData := [2][]byte{bytes.Clone(s.waitData[n:]), bytes.Clone(s.waitData[:n])} + + select { + case s.rawPkt <- packetData: + default: + return msgError + } + return msgMediaFrame +} + +func (s *Session25) msgAckCounters() []byte { + msg := s.Msg(msgHhrSize + cmdHdrSize) + + // off sample + // 0 09000b00 cmd1 + // 4 2700 seqCmd1 + // 6 0000 + // 8 1300 seqRecvPkt0 + // 10 2600 seqRecvPkt1 + // 12 0400 seqRecvCmd2 + // 14 00000000 + // 18 1400 seqSendCnt + // 20 d91a random + // 22 0000 + cmd := msg[msgHhrSize:] + copy(cmd, "\x09\x00\x0b\x00") + + binary.LittleEndian.PutUint16(cmd[4:], s.seqSendCmd1) + s.seqSendCmd1++ + + // seqRecvPkt0 stores previous value of seqRecvPkt1 + // don't understand why this needs + binary.LittleEndian.PutUint16(cmd[8:], s.seqRecvPkt0) + s.seqRecvPkt0 = s.seqRecvPkt1 + binary.LittleEndian.PutUint16(cmd[10:], s.seqRecvPkt1) + binary.LittleEndian.PutUint16(cmd[12:], s.seqRecvCmd2) + + binary.LittleEndian.PutUint16(cmd[18:], s.seqSendCnt) + s.seqSendCnt++ + binary.LittleEndian.PutUint16(cmd[20:], uint16(time.Now().UnixMilli())) + return msg +} + +func (s *Session25) handleCh1(cmd []byte) int { + switch cid := string(cmd[:2]); cid { + case "\x00\x00": // client start + return msgClientStart + case "\x00\x07": // time sync without data + _ = s.SessionWrite(1, s.msgAck0007(cmd)) + return msgUnknown0007 + case "\x00\x20": // client start2 + _ = s.SessionWrite(1, s.msgAck0020(cmd)) + return msgClientStart2 + case "\x09\x00": + return msgUnknown0900 + case "\x0a\x08": + return msgUnknown0a08 + } + return msgUnknown +} + +func (s *Session25) msgAck0020(msg28 []byte) []byte { + const cmdDataSize = 36 + + msg := s.Msg(msgHhrSize + cmdHdrSize25 + cmdDataSize) + + cmd := msg[msgHhrSize:] + copy(cmd, "\x00\x21\x0b\x00") + cmd[16] = cmdDataSize + copy(cmd[20:], msg28[20:24]) // request id (random) + + // 0 00000000 + // 4 00010001 + // 8 01000000 + // 12 04000000 + // 16 fb071f00 + // 20 00000000 + // 24 00000000 + // 28 00000300 + // 32 01000000 + data := cmd[cmdHdrSize25:] + data[5] = 1 + data[7] = 1 + data[8] = 1 + data[12] = 4 + copy(data[16:], "\xfb\x07\x1f\x00") + data[30] = 3 + data[32] = 1 + return msg +} + +func (s *Session25) msgAck0A08(msg28 []byte) []byte { + // <- 0a080b005b0000000b51590002000000 + // -> 0b000b00000001000b5103000300000000000000 + msg := s.Msg(msgHhrSize + 20) + cmd := msg[msgHhrSize:] + copy(cmd, "\x0b\x00\x0b\x00") + copy(cmd[8:], msg28[8:10]) + return msg +} + +// ReorderBuffer used for UDP incoming data. Because the order of the packets may be mixed up. +type ReorderBuffer struct { + buf map[uint16][]byte + seq uint16 + size int +} + +func NewReorderBuffer(size int) *ReorderBuffer { + return &ReorderBuffer{buf: make(map[uint16][]byte), size: size} +} + +// Check return OK if this is the seq we are waiting for. +func (r *ReorderBuffer) Check(seq uint16) (ok bool) { + return seq == r.seq +} + +func (r *ReorderBuffer) Next() { + r.seq++ +} + +// Available return how much free slots is in the buffer. +func (r *ReorderBuffer) Available() int { + return r.size - len(r.buf) +} + +// Push new item to buffer. Important! There is no buffer full check here. +func (r *ReorderBuffer) Push(seq uint16, data []byte) { + //log.Printf("push seq=%d wait=%d", seq, r.seq) + r.buf[seq] = bytes.Clone(data) +} + +// Pop latest item from buffer. OK - if items wasn't dropped. +func (r *ReorderBuffer) Pop() []byte { + for { + if data := r.buf[r.seq]; data != nil { + delete(r.buf, r.seq) + r.Next() + //log.Printf("pop seq=%d", r.seq) + return data + } + if r.Available() > 0 { + return nil + } + //log.Printf("drop seq=%d", r.seq) + r.Next() // drop item + } +} diff --git a/installs_on_host/go2rtc/pkg/tuya/README.md b/installs_on_host/go2rtc/pkg/tuya/README.md new file mode 100644 index 0000000..f193640 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/tuya/README.md @@ -0,0 +1,9 @@ +## Useful links + +- https://developer.tuya.com/en/docs/iot/webrtc?id=Kacsd4x2hl0se +- https://github.com/tuya/webrtc-demo-go +- https://github.com/bacco007/HomeAssistantConfig/blob/master/custom_components/xtend_tuya/multi_manager/tuya_iot/ipc/webrtc/xt_tuya_iot_webrtc_manager.py +- https://github.com/tuya/tuya-device-sharing-sdk +- https://github.com/make-all/tuya-local/blob/main/custom_components/tuya_local/cloud.py +- https://ipc-us.ismartlife.me/ +- https://protect-us.ismartlife.me/ \ No newline at end of file diff --git a/installs_on_host/go2rtc/pkg/tuya/client.go b/installs_on_host/go2rtc/pkg/tuya/client.go new file mode 100644 index 0000000..3043a8d --- /dev/null +++ b/installs_on_host/go2rtc/pkg/tuya/client.go @@ -0,0 +1,555 @@ +package tuya + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "regexp" + "strings" + "sync" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/webrtc" + "github.com/pion/rtp" + pion "github.com/pion/webrtc/v4" +) + +type Client struct { + api TuyaAPI + conn *webrtc.Conn + pc *pion.PeerConnection + connected core.Waiter + closed bool + + // HEVC only: + dc *pion.DataChannel + videoSSRC *uint32 + audioSSRC *uint32 + streamType int + isHEVC bool + handlersMu sync.RWMutex + handlers map[uint32]func(*rtp.Packet) +} + +type DataChannelMessage struct { + Type string `json:"type"` // "codec", "start", "recv", "complete" + Msg string `json:"msg"` +} + +// RecvMessage contains SSRC values for video/audio streams +type RecvMessage struct { + Video struct { + SSRC uint32 `json:"ssrc"` + } `json:"video"` + Audio struct { + SSRC uint32 `json:"ssrc"` + } `json:"audio"` +} + +func Dial(rawURL string) (core.Producer, error) { + escapedURL := strings.ReplaceAll(rawURL, "#", "%23") + u, err := url.Parse(escapedURL) + if err != nil { + return nil, err + } + + query := u.Query() + + // Tuya Smart API + email := query.Get("email") + password := query.Get("password") + + // Tuya Cloud API + uid := query.Get("uid") + clientId := query.Get("client_id") + clientSecret := query.Get("client_secret") + + // Shared params + deviceId := query.Get("device_id") + + // Stream params + streamResolution := query.Get("resolution") + + useSmartApi := deviceId != "" && email != "" && password != "" + useCloudApi := deviceId != "" && uid != "" && clientId != "" && clientSecret != "" + + if streamResolution == "" || (streamResolution != "hd" && streamResolution != "sd") { + streamResolution = "hd" + } + + if !useSmartApi && !useCloudApi { + return nil, errors.New("tuya: wrong query params") + } + + client := &Client{ + handlers: make(map[uint32]func(*rtp.Packet)), + } + + if useSmartApi { + if client.api, err = NewTuyaSmartApiClient(nil, u.Hostname(), email, password, deviceId); err != nil { + return nil, fmt.Errorf("tuya: %w", err) + } + } else { + if client.api, err = NewTuyaCloudApiClient(u.Hostname(), uid, deviceId, clientId, clientSecret); err != nil { + return nil, fmt.Errorf("tuya: %w", err) + } + } + + if err := client.api.Init(); err != nil { + return nil, fmt.Errorf("tuya: %w", err) + } + + client.streamType = client.api.GetStreamType(streamResolution) + client.isHEVC = client.api.IsHEVC(client.streamType) + + // Create a new PeerConnection + conf := pion.Configuration{ + ICEServers: client.api.GetICEServers(), + ICETransportPolicy: pion.ICETransportPolicyAll, + BundlePolicy: pion.BundlePolicyMaxBundle, + } + + api, err := webrtc.NewAPI() + if err != nil { + client.Close(err) + return nil, err + } + + client.pc, err = api.NewPeerConnection(conf) + if err != nil { + client.Close(err) + return nil, err + } + + // protect from sending ICE candidate before Offer + var sendOffer core.Waiter + + // protect from blocking on errors + defer sendOffer.Done(nil) + + // Create new WebRTC connection + client.conn = webrtc.NewConn(client.pc) + client.conn.FormatName = "tuya/webrtc" + client.conn.Mode = core.ModeActiveProducer + client.conn.Protocol = "mqtt" + + mqttClient := client.api.GetMqtt() + if mqttClient == nil { + err = errors.New("tuya: no mqtt client") + client.Close(err) + return nil, err + } + + // Set up MQTT handlers + mqttClient.handleAnswer = func(answer AnswerFrame) { + // fmt.Printf("tuya: answer: %s\n", answer.Sdp) + + desc := pion.SessionDescription{ + Type: pion.SDPTypePranswer, + SDP: answer.Sdp, + } + + if err = client.pc.SetRemoteDescription(desc); err != nil { + client.Close(err) + return + } + + if err = client.conn.SetAnswer(answer.Sdp); err != nil { + client.Close(err) + return + } + + if client.isHEVC { + // Tuya responds with H264/90000 even for HEVC streams + // So we need to replace video codecs with HEVC ones from API + for _, media := range client.conn.Medias { + if media.Kind == core.KindVideo { + codecs := client.api.GetVideoCodecs() + if codecs != nil { + media.Codecs = codecs + } + } + } + + // Audio codecs from API as well + // Tuya responds with multiple audio codecs (PCMU, PCMA) + // But the quality is bad if we use PCMU and skill only has PCMA + for _, media := range client.conn.Medias { + if media.Kind == core.KindAudio { + codecs := client.api.GetAudioCodecs() + if codecs != nil { + media.Codecs = codecs + } + } + } + } + } + + mqttClient.handleCandidate = func(candidate CandidateFrame) { + // fmt.Printf("tuya: candidate: %s\n", candidate.Candidate) + + if candidate.Candidate != "" { + client.conn.AddCandidate(candidate.Candidate) + if err != nil { + client.Close(err) + } + } + } + + mqttClient.handleDisconnect = func() { + // fmt.Println("tuya: disconnect") + client.Close(errors.New("mqtt: disconnect")) + } + + mqttClient.handleError = func(err error) { + // fmt.Printf("tuya: error: %s\n", err.Error()) + client.Close(err) + } + + if client.isHEVC { + maxRetransmits := uint16(5) + ordered := true + client.dc, err = client.pc.CreateDataChannel("fmp4Stream", &pion.DataChannelInit{ + MaxRetransmits: &maxRetransmits, + Ordered: &ordered, + }) + + // DataChannel receives two types of messages: + // 1. String messages: Control messages (codec, recv) + // 2. Binary messages: RTP packets with video/audio + client.dc.OnMessage(func(msg pion.DataChannelMessage) { + if msg.IsString { + // Handle control messages (codec, recv, etc.) + if connected, err := client.probe(msg); err != nil { + client.Close(err) + } else if connected { + client.connected.Done(nil) + } + } else { + // Handle RTP packets - Route by SSRC retrieved from "recv" message + packet := &rtp.Packet{} + if err := packet.Unmarshal(msg.Data); err != nil { + // Skip invalid packets + return + } + + if handler, ok := client.getHandler(packet.SSRC); ok { + handler(packet) + } + } + }) + + client.dc.OnError(func(err error) { + // fmt.Printf("tuya: datachannel error: %s\n", err.Error()) + client.Close(err) + }) + + client.dc.OnClose(func() { + // fmt.Println("tuya: datachannel closed") + client.Close(errors.New("datachannel: closed")) + }) + + client.dc.OnOpen(func() { + // fmt.Println("tuya: datachannel opened") + + codecRequest, _ := json.Marshal(DataChannelMessage{ + Type: "codec", + Msg: "", + }) + + if err := client.sendMessageToDataChannel(codecRequest); err != nil { + client.Close(fmt.Errorf("failed to send codec request: %w", err)) + } + }) + } + + // Set up pc handler + client.conn.Listen(func(msg any) { + switch msg := msg.(type) { + case *pion.ICECandidate: + _ = sendOffer.Wait() + if err := mqttClient.SendCandidate("a=" + msg.ToJSON().Candidate); err != nil { + client.Close(err) + } + + case pion.PeerConnectionState: + switch msg { + case pion.PeerConnectionStateNew: + break + case pion.PeerConnectionStateConnecting: + break + case pion.PeerConnectionStateConnected: + // On HEVC, wait for DataChannel to be opened and camera to send codec info + if !client.isHEVC { + if streamResolution == "hd" { + _ = mqttClient.SendResolution(0) + } + client.connected.Done(nil) + } + case pion.PeerConnectionStateClosed: + client.Close(errors.New("webrtc: " + msg.String())) + default: + // client.Close(errors.New("webrtc: " + msg.String())) + } + } + }) + + // Audio first, otherwise tuya will send corrupt sdp + medias := []*core.Media{ + {Kind: core.KindAudio, Direction: core.DirectionSendRecv}, + {Kind: core.KindVideo, Direction: core.DirectionRecvonly}, + } + + // Create offer + offer, err := client.conn.CreateOffer(medias) + if err != nil { + client.Close(err) + return nil, err + } + + // horter sdp, remove a=extmap... line, device ONLY allow 8KB json payload + // https://github.com/tuya/webrtc-demo-go/blob/04575054f18ccccb6bc9d82939dd46d449544e20/static/js/main.js#L224 + re := regexp.MustCompile(`\r\na=extmap[^\r\n]*`) + offer = re.ReplaceAllString(offer, "") + + // Send offer + if err := mqttClient.SendOffer(offer, streamResolution, client.streamType, client.isHEVC); err != nil { + err = fmt.Errorf("tuya: %w", err) + client.Close(err) + return nil, err + } + + sendOffer.Done(nil) + + // Wait for connection + if err = client.connected.Wait(); err != nil { + err = fmt.Errorf("tuya: %w", err) + client.Close(err) + return nil, err + } + + return client, nil +} + +func (c *Client) GetMedias() []*core.Media { + return c.conn.GetMedias() +} + +func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + return c.conn.GetTrack(media, codec) +} + +func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + localTrack := c.conn.GetSenderTrack(media.ID) + if localTrack == nil { + return errors.New("webrtc: can't get track") + } + + // DISABLED: Speaker Protocol 312 command + // JavaScript client doesn't send this on first call either + // Only subsequent calls (when speakerChloron is set) send Protocol 312 + // mqttClient := c.api.GetMqtt() + // if mqttClient != nil { + // _ = mqttClient.SendSpeaker(1) + // } + + payloadType := codec.PayloadType + + sender := core.NewSender(media, codec) + + switch track.Codec.Name { + case core.CodecPCMA, core.CodecPCMU, core.CodecPCM, core.CodecPCML: + // Frame size affects audio delay with Tuya cameras: + // Browser sends standard 20ms frames (160 bytes for G.711), but this causes + // up to 4s delay on some Tuya cameras. Increasing to 240 bytes (30ms) reduces + // delay to ~2s. Higher values (320+ bytes) don't work and cause issues. + // Using 240 bytes (30ms) as optimal balance between latency and stability. + frameSize := 240 + + var buf []byte + var seq uint16 + var ts uint32 + + sender.Handler = func(packet *rtp.Packet) { + buf = append(buf, packet.Payload...) + + for len(buf) >= frameSize { + payload := buf[:frameSize] + + pkt := &rtp.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: true, + PayloadType: payloadType, + SequenceNumber: seq, + Timestamp: ts, + SSRC: packet.SSRC, + }, + Payload: payload, + } + + seq++ + ts += uint32(frameSize) + buf = buf[frameSize:] + + c.conn.Send += pkt.MarshalSize() + _ = localTrack.WriteRTP(payloadType, pkt) + } + } + + default: + sender.Handler = func(packet *rtp.Packet) { + c.conn.Send += packet.MarshalSize() + _ = localTrack.WriteRTP(payloadType, packet) + } + } + + sender.HandleRTP(track) + c.conn.Senders = append(c.conn.Senders, sender) + + return nil +} + +func (c *Client) Start() error { + if len(c.conn.Receivers) == 0 { + return errors.New("tuya: no receivers") + } + + var video, audio *core.Receiver + for _, receiver := range c.conn.Receivers { + if receiver.Codec.IsVideo() { + video = receiver + } else if receiver.Codec.IsAudio() { + audio = receiver + } + } + + if c.videoSSRC != nil { + c.setHandler(*c.videoSSRC, func(packet *rtp.Packet) { + if video != nil { + video.WriteRTP(packet) + } + }) + } + + if c.audioSSRC != nil { + c.setHandler(*c.audioSSRC, func(packet *rtp.Packet) { + if audio != nil { + audio.WriteRTP(packet) + } + }) + } + + return c.conn.Start() +} + +func (c *Client) Stop() error { + if c.closed { + return nil + } + + c.closed = true + + c.clearHandlers() + + if c.conn != nil { + _ = c.conn.Stop() + } + + if c.api != nil { + c.api.Close() + } + + return nil +} + +func (c *Client) Close(err error) error { + c.connected.Done(err) + return c.Stop() +} + +func (c *Client) MarshalJSON() ([]byte, error) { + return c.conn.MarshalJSON() +} + +func (c *Client) setHandler(ssrc uint32, handler func(*rtp.Packet)) { + c.handlersMu.Lock() + defer c.handlersMu.Unlock() + c.handlers[ssrc] = handler +} + +func (c *Client) getHandler(ssrc uint32) (func(*rtp.Packet), bool) { + c.handlersMu.RLock() + defer c.handlersMu.RUnlock() + handler, ok := c.handlers[ssrc] + return handler, ok +} + +func (c *Client) clearHandlers() { + c.handlersMu.Lock() + defer c.handlersMu.Unlock() + for ssrc := range c.handlers { + delete(c.handlers, ssrc) + } +} + +func (c *Client) probe(msg pion.DataChannelMessage) (bool, error) { + // fmt.Printf("[tuya] Received string message: %s\n", string(msg.Data)) + + var message DataChannelMessage + if err := json.Unmarshal([]byte(msg.Data), &message); err != nil { + return false, err + } + + switch message.Type { + case "codec": + // Camera responded to our codec request - now request frame start + frameRequest, _ := json.Marshal(DataChannelMessage{ + Type: "start", + Msg: "frame", + }) + + err := c.sendMessageToDataChannel(frameRequest) + if err != nil { + return false, err + } + + case "recv": + // Camera sends SSRC values for video/audio streams + // We need these to route incoming RTP packets correctly + var recvMessage RecvMessage + if err := json.Unmarshal([]byte(message.Msg), &recvMessage); err != nil { + return false, err + } + + videoSSRC := recvMessage.Video.SSRC + audioSSRC := recvMessage.Audio.SSRC + c.videoSSRC = &videoSSRC + c.audioSSRC = &audioSSRC + + // Send "complete" to tell camera we're ready to receive RTP packets + completeMsg, _ := json.Marshal(DataChannelMessage{ + Type: "complete", + Msg: "", + }) + + err := c.sendMessageToDataChannel(completeMsg) + if err != nil { + return false, err + } + + return true, nil + } + + return false, nil +} + +func (c *Client) sendMessageToDataChannel(message []byte) error { + if c.dc != nil { + // fmt.Printf("[tuya] sending message to data channel: %s\n", message) + return c.dc.Send(message) + } + + return nil +} diff --git a/installs_on_host/go2rtc/pkg/tuya/cloud_api.go b/installs_on_host/go2rtc/pkg/tuya/cloud_api.go new file mode 100644 index 0000000..c34d0fe --- /dev/null +++ b/installs_on_host/go2rtc/pkg/tuya/cloud_api.go @@ -0,0 +1,322 @@ +package tuya + +import ( + "bytes" + "crypto/md5" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "time" + + "github.com/AlexxIT/go2rtc/pkg/webrtc" + "github.com/google/uuid" +) + +type Token struct { + UID string `json:"uid"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpireTime int64 `json:"expire_time"` +} + +type WebRTCConfigResponse struct { + Timestamp int64 `json:"t"` + Success bool `json:"success"` + Result WebRTCConfig `json:"result"` + Msg string `json:"msg,omitempty"` + Code int `json:"code,omitempty"` +} + +type TokenResponse struct { + Timestamp int64 `json:"t"` + Success bool `json:"success"` + Result Token `json:"result"` + Msg string `json:"msg,omitempty"` + Code int `json:"code,omitempty"` +} + +type OpenIoTHubConfigRequest struct { + UID string `json:"uid"` + UniqueID string `json:"unique_id"` + LinkType string `json:"link_type"` + Topics string `json:"topics"` +} + +type OpenIoTHubConfig struct { + Url string `json:"url"` + ClientID string `json:"client_id"` + Username string `json:"username"` + Password string `json:"password"` + SinkTopic struct { + IPC string `json:"ipc"` + } `json:"sink_topic"` + SourceSink struct { + IPC string `json:"ipc"` + } `json:"source_topic"` + ExpireTime int `json:"expire_time"` +} + +type OpenIoTHubConfigResponse struct { + Timestamp int `json:"t"` + Success bool `json:"success"` + Result OpenIoTHubConfig `json:"result"` + Msg string `json:"msg,omitempty"` + Code int `json:"code,omitempty"` +} + +type TuyaCloudApiClient struct { + TuyaClient + uid string + clientId string + clientSecret string + accessToken string + refreshToken string + refreshingToken bool +} + +func NewTuyaCloudApiClient(baseUrl, uid, deviceId, clientId, clientSecret string) (*TuyaCloudApiClient, error) { + mqttClient := NewTuyaMqttClient(deviceId) + + client := &TuyaCloudApiClient{ + TuyaClient: TuyaClient{ + httpClient: &http.Client{Timeout: 15 * time.Second}, + mqtt: mqttClient, + deviceId: deviceId, + expireTime: 0, + baseUrl: baseUrl, + }, + uid: uid, + clientId: clientId, + clientSecret: clientSecret, + refreshingToken: false, + } + + return client, nil +} + +// WebRTC Flow +func (c *TuyaCloudApiClient) Init() error { + if err := c.initToken(); err != nil { + return fmt.Errorf("failed to initialize token: %w", err) + } + + webrtcConfig, err := c.loadWebrtcConfig() + if err != nil { + return fmt.Errorf("failed to load webrtc config: %w", err) + } + + hubConfig, err := c.loadHubConfig() + if err != nil { + return fmt.Errorf("failed to load hub config: %w", err) + } + + if err := c.mqtt.Start(hubConfig, webrtcConfig, c.skill.WebRTC); err != nil { + return fmt.Errorf("failed to start MQTT: %w", err) + } + + if c.skill.LowPower > 0 { + _ = c.mqtt.WakeUp(c.localKey) + } + + return nil +} + +func (c *TuyaCloudApiClient) GetStreamUrl(streamType string) (streamUrl string, err error) { + if err := c.initToken(); err != nil { + return "", fmt.Errorf("failed to initialize token: %w", err) + } + + url := fmt.Sprintf("https://%s/v1.0/devices/%s/stream/actions/allocate", c.baseUrl, c.deviceId) + + request := &AllocateRequest{ + Type: streamType, + } + + body, err := c.request("POST", url, request) + if err != nil { + return "", err + } + + var allocResponse AllocateResponse + err = json.Unmarshal(body, &allocResponse) + if err != nil { + return "", err + } + + if !allocResponse.Success { + return "", errors.New(allocResponse.Msg) + } + + return allocResponse.Result.URL, nil +} + +func (c *TuyaCloudApiClient) initToken() (err error) { + if c.refreshingToken { + return nil + } + + now := time.Now().Unix() + if (c.expireTime - 60) > now { + return nil + } + + c.refreshingToken = true + + url := fmt.Sprintf("https://%s/v1.0/token?grant_type=1", c.baseUrl) + + c.accessToken = "" + c.refreshToken = "" + + body, err := c.request("GET", url, nil) + if err != nil { + return err + } + + var tokenResponse TokenResponse + err = json.Unmarshal(body, &tokenResponse) + if err != nil { + return err + } + + if !tokenResponse.Success { + return errors.New(tokenResponse.Msg) + } + + c.accessToken = tokenResponse.Result.AccessToken + c.refreshToken = tokenResponse.Result.RefreshToken + c.expireTime = tokenResponse.Timestamp + tokenResponse.Result.ExpireTime + c.refreshingToken = false + + return nil +} + +func (c *TuyaCloudApiClient) loadWebrtcConfig() (*WebRTCConfig, error) { + url := fmt.Sprintf("https://%s/v1.0/users/%s/devices/%s/webrtc-configs", c.baseUrl, c.uid, c.deviceId) + + body, err := c.request("GET", url, nil) + if err != nil { + return nil, err + } + + var webRTCConfigResponse WebRTCConfigResponse + err = json.Unmarshal(body, &webRTCConfigResponse) + if err != nil { + return nil, err + } + + if !webRTCConfigResponse.Success { + return nil, fmt.Errorf(webRTCConfigResponse.Msg) + } + + err = json.Unmarshal([]byte(webRTCConfigResponse.Result.Skill), &c.skill) + if err != nil { + return nil, err + } + + // Store LocalKey (not sure if cloud api provides this, but we need it for low power cameras) + c.localKey = webRTCConfigResponse.Result.LocalKey + + iceServers, err := json.Marshal(&webRTCConfigResponse.Result.P2PConfig.Ices) + if err != nil { + return nil, err + } + + c.iceServers, err = webrtc.UnmarshalICEServers(iceServers) + if err != nil { + return nil, err + } + + return &webRTCConfigResponse.Result, nil +} + +func (c *TuyaCloudApiClient) loadHubConfig() (config *MQTTConfig, err error) { + url := fmt.Sprintf("https://%s/v2.0/open-iot-hub/access/config", c.baseUrl) + + request := &OpenIoTHubConfigRequest{ + UID: c.uid, + UniqueID: uuid.New().String(), + LinkType: "mqtt", + Topics: "ipc", + } + + body, err := c.request("POST", url, request) + if err != nil { + return nil, err + } + + var openIoTHubConfigResponse OpenIoTHubConfigResponse + err = json.Unmarshal(body, &openIoTHubConfigResponse) + if err != nil { + return nil, err + } + + if !openIoTHubConfigResponse.Success { + return nil, fmt.Errorf(openIoTHubConfigResponse.Msg) + } + + return &MQTTConfig{ + Url: openIoTHubConfigResponse.Result.Url, + Username: openIoTHubConfigResponse.Result.Username, + Password: openIoTHubConfigResponse.Result.Password, + ClientID: openIoTHubConfigResponse.Result.ClientID, + PublishTopic: openIoTHubConfigResponse.Result.SinkTopic.IPC, + SubscribeTopic: openIoTHubConfigResponse.Result.SourceSink.IPC, + }, nil +} + +func (c *TuyaCloudApiClient) request(method string, url string, body any) ([]byte, error) { + var bodyReader io.Reader + if body != nil { + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(jsonBody) + } + + req, err := http.NewRequest(method, url, bodyReader) + if err != nil { + return nil, err + } + + ts := time.Now().UnixNano() / 1000000 + sign := c.calBusinessSign(ts) + + req.Header.Set("Accept", "*") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Access-Control-Allow-Origin", "*") + req.Header.Set("Access-Control-Allow-Methods", "*") + req.Header.Set("Access-Control-Allow-Headers", "*") + req.Header.Set("mode", "no-cors") + req.Header.Set("client_id", c.clientId) + req.Header.Set("access_token", c.accessToken) + req.Header.Set("sign", sign) + req.Header.Set("t", strconv.FormatInt(ts, 10)) + + response, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer response.Body.Close() + + res, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + + if response.StatusCode != http.StatusOK { + return nil, err + } + + return res, nil +} + +func (c *TuyaCloudApiClient) calBusinessSign(ts int64) string { + data := fmt.Sprintf("%s%s%s%d", c.clientId, c.accessToken, c.clientSecret, ts) + val := md5.Sum([]byte(data)) + res := fmt.Sprintf("%X", val) + return res +} diff --git a/installs_on_host/go2rtc/pkg/tuya/helper.go b/installs_on_host/go2rtc/pkg/tuya/helper.go new file mode 100644 index 0000000..7c9eb41 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/tuya/helper.go @@ -0,0 +1,69 @@ +package tuya + +import ( + "crypto/md5" + cryptoRand "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "errors" + "net/http" + "net/http/cookiejar" + "regexp" + "time" + + "golang.org/x/net/publicsuffix" +) + +func EncryptPassword(password, pbKey string) (string, error) { + // Hash password with MD5 + hasher := md5.New() + hasher.Write([]byte(password)) + hashedPassword := hex.EncodeToString(hasher.Sum(nil)) + + // Decode PEM public key + block, _ := pem.Decode([]byte("-----BEGIN PUBLIC KEY-----\n" + pbKey + "\n-----END PUBLIC KEY-----")) + if block == nil { + return "", errors.New("failed to decode PEM block") + } + + pubKey, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return "", err + } + + rsaPubKey, ok := pubKey.(*rsa.PublicKey) + if !ok { + return "", errors.New("not an RSA public key") + } + + // Encrypt with RSA + encrypted, err := rsa.EncryptPKCS1v15(cryptoRand.Reader, rsaPubKey, []byte(hashedPassword)) + if err != nil { + return "", err + } + + // Convert to hex string + return hex.EncodeToString(encrypted), nil +} + +func IsEmailAddress(input string) bool { + emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) + return emailRegex.MatchString(input) +} + +func CreateHTTPClientWithSession() *http.Client { + jar, err := cookiejar.New(&cookiejar.Options{ + PublicSuffixList: publicsuffix.List, + }) + + if err != nil { + return nil + } + + return &http.Client{ + Timeout: 30 * time.Second, + Jar: jar, + } +} diff --git a/installs_on_host/go2rtc/pkg/tuya/interface.go b/installs_on_host/go2rtc/pkg/tuya/interface.go new file mode 100644 index 0000000..25ba0dd --- /dev/null +++ b/installs_on_host/go2rtc/pkg/tuya/interface.go @@ -0,0 +1,270 @@ +package tuya + +import ( + "net/http" + + "github.com/AlexxIT/go2rtc/pkg/core" + pionWebrtc "github.com/pion/webrtc/v4" +) + +type TuyaAPI interface { + GetMqtt() *TuyaMqttClient + + GetStreamType(streamResolution string) int + IsHEVC(streamType int) bool + + GetVideoCodecs() []*core.Codec + GetAudioCodecs() []*core.Codec + + GetStreamUrl(streamUrl string) (string, error) + GetICEServers() []pionWebrtc.ICEServer + + Init() error + Close() +} + +type TuyaClient struct { + TuyaAPI + + httpClient *http.Client + mqtt *TuyaMqttClient + baseUrl string + expireTime int64 + deviceId string + localKey string + skill *Skill + iceServers []pionWebrtc.ICEServer +} + +type AudioAttributes struct { + CallMode []int `json:"call_mode"` // 1 = one way, 2 = two way + HardwareCapability []int `json:"hardware_capability"` // 1 = mic, 2 = speaker +} + +type ICEServer struct { + Urls string `json:"urls"` + Username string `json:"username,omitempty"` + Credential string `json:"credential,omitempty"` + TTL int `json:"ttl,omitempty"` +} + +type WebICE struct { + Urls string `json:"urls"` + Username string `json:"username,omitempty"` + Credential string `json:"credential,omitempty"` +} + +type P2PConfig struct { + Ices []ICEServer `json:"ices"` +} + +type AudioSkill struct { + Channels int `json:"channels"` + DataBit int `json:"dataBit"` + CodecType int `json:"codecType"` + SampleRate int `json:"sampleRate"` +} + +type VideoSkill struct { + StreamType int `json:"streamType"` // 2 = main stream (HD), 4 = sub stream (SD) + CodecType int `json:"codecType"` // 2 = H264, 4 = H265 (HEVC) + Width int `json:"width"` + Height int `json:"height"` + SampleRate int `json:"sampleRate"` + ProfileId string `json:"profileId,omitempty"` +} + +type Skill struct { + WebRTC int `json:"webrtc"` // Bit flags: bit 4=speaker, bit 5=clarity, bit 6=record + LowPower int `json:"lowPower,omitempty"` // 1 = battery-powered camera + Audios []AudioSkill `json:"audios"` + Videos []VideoSkill `json:"videos"` +} + +type WebRTCConfig struct { + AudioAttributes AudioAttributes `json:"audio_attributes"` + Auth string `json:"auth"` + ID string `json:"id"` + LocalKey string `json:"local_key,omitempty"` + MotoID string `json:"moto_id"` + P2PConfig P2PConfig `json:"p2p_config"` + ProtocolVersion string `json:"protocol_version"` + Skill string `json:"skill"` + SupportsWebRTCRecord bool `json:"supports_webrtc_record"` + SupportsWebRTC bool `json:"supports_webrtc"` + VedioClaritiy int `json:"vedio_clarity"` + VideoClaritiy int `json:"video_clarity"` + VideoClarities []int `json:"video_clarities"` +} + +type MQTTConfig struct { + Url string `json:"url"` + PublishTopic string `json:"publish_topic"` + SubscribeTopic string `json:"subscribe_topic"` + ClientID string `json:"client_id"` + Username string `json:"username"` + Password string `json:"password"` +} + +type Allocate struct { + URL string `json:"url"` +} + +type AllocateRequest struct { + Type string `json:"type"` +} + +type AllocateResponse struct { + Success bool `json:"success"` + Result Allocate `json:"result"` + Msg string `json:"msg,omitempty"` +} + +func (c *TuyaClient) GetICEServers() []pionWebrtc.ICEServer { + return c.iceServers +} + +func (c *TuyaClient) GetMqtt() *TuyaMqttClient { + return c.mqtt +} + +// GetStreamType returns the Skill StreamType for the requested resolution +// Returns Skill values (2 or 4), not MQTT values (0 or 1) +// - "hd" → highest resolution streamType (usually 2 = mainStream) +// - "sd" → lowest resolution streamType (usually 4 = substream) +// +// These values must be mapped before sending to MQTT: +// - streamType 2 → MQTT stream_type 0 +// - streamType 4 → MQTT stream_type 1 +func (c *TuyaClient) GetStreamType(streamResolution string) int { + // Default streamType if nothing is found + defaultStreamType := 1 + + if c.skill == nil || len(c.skill.Videos) == 0 { + return defaultStreamType + } + + // Find the highest and lowest resolution based on pixel count + var highestResType = defaultStreamType + var highestRes = 0 + var lowestResType = defaultStreamType + var lowestRes = 0 + + for _, video := range c.skill.Videos { + res := video.Width * video.Height + + // Highest Resolution + if res > highestRes { + highestRes = res + highestResType = video.StreamType + } + + // Lower Resolution (or first if not set yet) + if lowestRes == 0 || res < lowestRes { + lowestRes = res + lowestResType = video.StreamType + } + } + + // Return the streamType based on the selection + switch streamResolution { + case "hd": + return highestResType + case "sd": + return lowestResType + default: + return defaultStreamType + } +} + +// IsHEVC checks if the given streamType uses H265 (HEVC) codec +// HEVC cameras use DataChannel, H264 cameras use RTP tracks +// - codecType 4 = H265 (HEVC) → DataChannel mode +// - codecType 2 = H264 → Normal RTP mode +func (c *TuyaClient) IsHEVC(streamType int) bool { + for _, video := range c.skill.Videos { + if video.StreamType == streamType { + return video.CodecType == 4 // 4 = H265/HEVC + } + } + + return false +} + +func (c *TuyaClient) GetVideoCodecs() []*core.Codec { + if len(c.skill.Videos) > 0 { + codecs := make([]*core.Codec, 0) + + for _, video := range c.skill.Videos { + name := core.CodecH264 + if c.IsHEVC(video.StreamType) { + name = core.CodecH265 + } + + codec := &core.Codec{ + Name: name, + ClockRate: uint32(video.SampleRate), + } + + codecs = append(codecs, codec) + } + + if len(codecs) > 0 { + return codecs + } + } + + return nil +} + +func (c *TuyaClient) GetAudioCodecs() []*core.Codec { + if len(c.skill.Audios) > 0 { + codecs := make([]*core.Codec, 0) + + for _, audio := range c.skill.Audios { + name := getAudioCodecName(&audio) + + codec := &core.Codec{ + Name: name, + ClockRate: uint32(audio.SampleRate), + Channels: uint8(audio.Channels), + } + codecs = append(codecs, codec) + } + + if len(codecs) > 0 { + return codecs + } + } + + return nil +} + +func (c *TuyaClient) Close() { + c.mqtt.Stop() + c.httpClient.CloseIdleConnections() +} + +// https://protect-us.ismartlife.me/ +func getAudioCodecName(audioSkill *AudioSkill) string { + switch audioSkill.CodecType { + // case 100: + // return "ADPCM" + case 101: + return core.CodecPCML + case 102, 103, 104: + return core.CodecAAC + case 105: + return core.CodecPCMU + case 106: + return core.CodecPCMA + // case 107: + // return "G726-32" + // case 108: + // return "SPEEX" + case 109: + return core.CodecMP3 + default: + return core.CodecPCML + } +} diff --git a/installs_on_host/go2rtc/pkg/tuya/mqtt.go b/installs_on_host/go2rtc/pkg/tuya/mqtt.go new file mode 100644 index 0000000..5f64ef4 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/tuya/mqtt.go @@ -0,0 +1,436 @@ +package tuya + +import ( + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "hash/crc32" + "strings" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + mqtt "github.com/eclipse/paho.mqtt.golang" +) + +type TuyaMqttClient struct { + client mqtt.Client + waiter core.Waiter + wakeupWaiter core.Waiter + speakerWaiter core.Waiter + publishTopic string + subscribeTopic string + auth string + iceServers []ICEServer + uid string + motoId string + deviceId string + sessionId string + closed bool + webrtcVersion int + handleAnswer func(answer AnswerFrame) + handleCandidate func(candidate CandidateFrame) + handleDisconnect func() + handleError func(err error) +} + +type MqttFrameHeader struct { + Type string `json:"type"` + From string `json:"from"` + To string `json:"to"` + SubDevID string `json:"sub_dev_id"` + SessionID string `json:"sessionid"` + MotoID string `json:"moto_id"` + TransactionID string `json:"tid"` +} + +type MqttFrame struct { + Header MqttFrameHeader `json:"header"` + Message json.RawMessage `json:"msg"` +} + +type OfferFrame struct { + Mode string `json:"mode"` + Sdp string `json:"sdp"` + StreamType int `json:"stream_type"` // 0: mainStream(HD), 1: substream(SD) + Auth string `json:"auth"` + DatachannelEnable bool `json:"datachannel_enable"` // true for HEVC, false for H264 + Token []ICEServer `json:"token"` +} + +type AnswerFrame struct { + Mode string `json:"mode"` + Sdp string `json:"sdp"` +} + +type CandidateFrame struct { + Mode string `json:"mode"` + Candidate string `json:"candidate"` +} + +type ResolutionFrame struct { + Mode string `json:"mode"` + Value int `json:"cmdValue"` // 0: HD, 1: SD +} + +type SpeakerFrame struct { + Mode string `json:"mode"` + Value int `json:"cmdValue"` // 0: off, 1: on +} + +type DisconnectFrame struct { + Mode string `json:"mode"` +} + +type MqttLowPowerMessage struct { + Protocol int `json:"protocol"` + T int `json:"t"` + S int `json:"s,omitempty"` + Type string `json:"type,omitempty"` + Data struct { + DevID string `json:"devId,omitempty"` + Online bool `json:"online,omitempty"` + LastOnlineChangeTime int64 `json:"lastOnlineChangeTime,omitempty"` + GwID string `json:"gwId,omitempty"` + Cmd string `json:"cmd,omitempty"` + Dps map[string]interface{} `json:"dps,omitempty"` + } `json:"data"` +} + +type MqttMessage struct { + Protocol int `json:"protocol"` + Pv string `json:"pv"` + T int64 `json:"t"` + Data MqttFrame `json:"data"` +} + +func NewTuyaMqttClient(deviceId string) *TuyaMqttClient { + return &TuyaMqttClient{ + deviceId: deviceId, + sessionId: core.RandString(6, 62), + waiter: core.Waiter{}, + wakeupWaiter: core.Waiter{}, + } +} + +func (c *TuyaMqttClient) Start(hubConfig *MQTTConfig, webrtcConfig *WebRTCConfig, webrtcVersion int) error { + c.webrtcVersion = webrtcVersion + c.motoId = webrtcConfig.MotoID + c.auth = webrtcConfig.Auth + c.iceServers = webrtcConfig.P2PConfig.Ices + + c.publishTopic = hubConfig.PublishTopic + c.subscribeTopic = hubConfig.SubscribeTopic + + c.publishTopic = strings.Replace(c.publishTopic, "moto_id", c.motoId, 1) + c.publishTopic = strings.Replace(c.publishTopic, "{device_id}", c.deviceId, 1) + + parts := strings.Split(c.subscribeTopic, "/") + c.uid = parts[3] + + opts := mqtt.NewClientOptions().AddBroker(hubConfig.Url). + SetClientID(hubConfig.ClientID). + SetUsername(hubConfig.Username). + SetPassword(hubConfig.Password). + SetOnConnectHandler(c.onConnect). + SetAutoReconnect(true). + SetMaxReconnectInterval(30 * time.Second). + SetConnectTimeout(30 * time.Second). + SetKeepAlive(60 * time.Second). + SetPingTimeout(20 * time.Second) + + c.client = mqtt.NewClient(opts) + + if token := c.client.Connect(); token.Wait() && token.Error() != nil { + return token.Error() + } + + if err := c.waiter.Wait(); err != nil { + return err + } + + return nil +} + +func (c *TuyaMqttClient) Stop() { + c.waiter.Done(errors.New("mqtt: stopped")) + c.wakeupWaiter.Done(errors.New("mqtt: stopped")) + c.speakerWaiter.Done(errors.New("mqtt: stopped")) + + if c.client != nil { + _ = c.SendDisconnect() + c.client.Disconnect(100) + } + + c.closed = true +} + +// WakeUp sends a wake-up signal to battery-powered cameras (LowPower mode). +// The camera wakes up and starts responding immediately - we don't wait for dps[149]. +// Note: LowPower cameras sleep after ~3 minutes of inactivity. +func (c *TuyaMqttClient) WakeUp(localKey string) error { + // Calculate CRC32 of localKey as wake-up payload + crc := crc32.ChecksumIEEE([]byte(localKey)) + + // Convert to hex string + hexStr := fmt.Sprintf("%08x", crc) + + // Convert hex string to byte array (2 chars at a time) + payload := make([]byte, len(hexStr)/2) + for i := 0; i < len(hexStr); i += 2 { + b, err := hex.DecodeString(hexStr[i : i+2]) + if err != nil { + return fmt.Errorf("failed to decode hex: %w", err) + } + payload[i/2] = b[0] + } + + // Publish to wake-up topic: m/w/{deviceId} + wakeUpTopic := fmt.Sprintf("m/w/%s", c.deviceId) + token := c.client.Publish(wakeUpTopic, 1, false, payload) + if token.Wait() && token.Error() != nil { + return fmt.Errorf("failed to publish wake-up message: %w", token.Error()) + } + + // Subscribe to lowPower topic to receive dps[149] status updates + // (we don't wait for this signal - camera responds immediately) + lowPowerTopic := fmt.Sprintf("smart/decrypt/in/%s", c.deviceId) + if token := c.client.Subscribe(lowPowerTopic, 1, c.onLowPowerMessage); token.Wait() && token.Error() != nil { + return fmt.Errorf("failed to subscribe to lowPower topic: %w", token.Error()) + } + + return nil +} + +func (c *TuyaMqttClient) SendOffer(sdp string, streamResolution string, streamType int, isHEVC bool) error { + // Map Skill StreamType to MQTT stream_type values + // streamType comes from GetStreamType() and uses Skill StreamType values: + // - mainStream = 2 (HD) + // - substream = 4 (SD) + // + // But MQTT expects mapped stream_type values: + // - mainStream (2) → stream_type: 0 + // - substream (4) → stream_type: 1 + + mqttStreamType := streamType + switch streamType { + case 2: + mqttStreamType = 0 // mainStream (HD) + case 4: + mqttStreamType = 1 // substream (SD) + } + + return c.sendMqttMessage("offer", 302, "", OfferFrame{ + Mode: "webrtc", + Sdp: sdp, + StreamType: mqttStreamType, + Auth: c.auth, + DatachannelEnable: isHEVC, // must be true for HEVC + Token: c.iceServers, + }) +} + +func (c *TuyaMqttClient) SendCandidate(candidate string) error { + return c.sendMqttMessage("candidate", 302, "", CandidateFrame{ + Mode: "webrtc", + Candidate: candidate, + }) +} + +func (c *TuyaMqttClient) SendResolution(resolution int) error { + // Check if camera supports clarity switching + isClaritySupported := (c.webrtcVersion & (1 << 5)) != 0 + if !isClaritySupported { + return nil + } + + return c.sendMqttMessage("resolution", 312, "", ResolutionFrame{ + Mode: "webrtc", + Value: resolution, // 0: HD, 1: SD + }) +} + +func (c *TuyaMqttClient) SendSpeaker(speaker int) error { + if err := c.sendMqttMessage("speaker", 312, "", SpeakerFrame{ + Mode: "webrtc", + Value: speaker, // 0: off, 1: on + }); err != nil { + return err + } + + // Wait for camera response + if err := c.speakerWaiter.Wait(); err != nil { + return fmt.Errorf("speaker wait failed: %w", err) + } + + return nil +} + +func (c *TuyaMqttClient) SendDisconnect() error { + return c.sendMqttMessage("disconnect", 302, "", DisconnectFrame{ + Mode: "webrtc", + }) +} + +func (c *TuyaMqttClient) onConnect(client mqtt.Client) { + if token := client.Subscribe(c.subscribeTopic, 1, c.onMessage); token.Wait() && token.Error() != nil { + c.waiter.Done(token.Error()) + return + } + + c.waiter.Done(nil) +} + +func (c *TuyaMqttClient) onMessage(client mqtt.Client, msg mqtt.Message) { + var rmqtt MqttMessage + if err := json.Unmarshal(msg.Payload(), &rmqtt); err != nil { + c.onError(err) + return + } + + // Filter by session ID to prevent processing messages from other sessions + if rmqtt.Data.Header.SessionID != c.sessionId { + return + } + + switch rmqtt.Data.Header.Type { + case "answer": + c.onMqttAnswer(&rmqtt) + case "candidate": + c.onMqttCandidate(&rmqtt) + case "disconnect": + c.onMqttDisconnect() + case "speaker": + c.onMqttSpeaker(&rmqtt) + } +} + +func (c *TuyaMqttClient) onLowPowerMessage(client mqtt.Client, msg mqtt.Message) { + var message MqttLowPowerMessage + if err := json.Unmarshal(msg.Payload(), &message); err != nil { + return + } + + // Check if protocol is 4 and dps[149] is true + // https://developer.tuya.com/en/docs/iot-device-dev/doorbell_solution?id=Kayamyivh15ox#title-2-Battery + if message.Protocol == 4 { + if val, ok := message.Data.Dps["149"]; ok { + if ready, ok := val.(bool); ok && ready { + // Camera is now ready after wake-up (dps[149]:true received). + // However, we don't wait for this signal (like ismartlife.me doesn't either). + // The camera starts responding immediately after WakeUp() is called, + // so we proceed with the connection without blocking. + // This waiter is kept for potential future use. + c.wakeupWaiter.Done(nil) + } + } + } +} + +func (c *TuyaMqttClient) onMqttAnswer(msg *MqttMessage) { + var answerFrame AnswerFrame + if err := json.Unmarshal(msg.Data.Message, &answerFrame); err != nil { + c.onError(err) + return + } + + c.onAnswer(answerFrame) +} + +func (c *TuyaMqttClient) onMqttCandidate(msg *MqttMessage) { + var candidateFrame CandidateFrame + if err := json.Unmarshal(msg.Data.Message, &candidateFrame); err != nil { + c.onError(err) + return + } + + // fix candidates + candidateFrame.Candidate = strings.TrimPrefix(candidateFrame.Candidate, "a=") + candidateFrame.Candidate = strings.TrimSuffix(candidateFrame.Candidate, "\r\n") + + c.onCandidate(candidateFrame) +} + +func (c *TuyaMqttClient) onMqttDisconnect() { + c.closed = true + c.onDisconnect() +} + +func (c *TuyaMqttClient) onMqttSpeaker(msg *MqttMessage) { + var speakerResponse struct { + ResCode int `json:"resCode"` + } + + if err := json.Unmarshal(msg.Data.Message, &speakerResponse); err == nil { + if speakerResponse.ResCode != 0 { + c.speakerWaiter.Done(fmt.Errorf("speaker failed with resCode: %d", speakerResponse.ResCode)) + return + } + } + + c.speakerWaiter.Done(nil) +} + +func (c *TuyaMqttClient) onAnswer(answer AnswerFrame) { + if c.handleAnswer != nil { + c.handleAnswer(answer) + } +} + +func (c *TuyaMqttClient) onCandidate(candidate CandidateFrame) { + if c.handleCandidate != nil { + c.handleCandidate(candidate) + } +} + +func (c *TuyaMqttClient) onDisconnect() { + if c.handleDisconnect != nil { + c.handleDisconnect() + } +} + +func (c *TuyaMqttClient) onError(err error) { + if c.handleError != nil { + c.handleError(err) + } +} + +func (c *TuyaMqttClient) sendMqttMessage(messageType string, protocol int, transactionID string, data interface{}) error { + if c.closed { + return fmt.Errorf("mqtt client is closed, send mqtt message fail") + } + + jsonMessage, err := json.Marshal(data) + if err != nil { + return err + } + + msg := &MqttMessage{ + Protocol: protocol, + Pv: "2.2", + T: time.Now().Unix(), + Data: MqttFrame{ + Header: MqttFrameHeader{ + Type: messageType, + From: c.uid, + To: c.deviceId, + SessionID: c.sessionId, + MotoID: c.motoId, + TransactionID: transactionID, + }, + Message: jsonMessage, + }, + } + + payload, err := json.Marshal(msg) + if err != nil { + return err + } + + token := c.client.Publish(c.publishTopic, 1, false, payload) + if token.Wait() && token.Error() != nil { + return token.Error() + } + + return nil +} diff --git a/installs_on_host/go2rtc/pkg/tuya/smart_api.go b/installs_on_host/go2rtc/pkg/tuya/smart_api.go new file mode 100644 index 0000000..09615db --- /dev/null +++ b/installs_on_host/go2rtc/pkg/tuya/smart_api.go @@ -0,0 +1,597 @@ +package tuya + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "math/rand" + "net/http" + "time" + + "github.com/AlexxIT/go2rtc/pkg/webrtc" +) + +type LoginTokenRequest struct { + CountryCode string `json:"countryCode"` + Username string `json:"username"` + IsUid bool `json:"isUid"` +} + +type LoginTokenResponse struct { + Result LoginToken `json:"result"` + Success bool `json:"success"` + Msg string `json:"errorMsg,omitempty"` +} + +type LoginToken struct { + Token string `json:"token"` + Exponent string `json:"exponent"` + PublicKey string `json:"publicKey"` + PbKey string `json:"pbKey"` +} + +type PasswordLoginRequest struct { + CountryCode string `json:"countryCode"` + Email string `json:"email,omitempty"` + Mobile string `json:"mobile,omitempty"` + Passwd string `json:"passwd"` + Token string `json:"token"` + IfEncrypt int `json:"ifencrypt"` + Options string `json:"options"` +} + +type PasswordLoginResponse struct { + Result LoginResult `json:"result"` + Success bool `json:"success"` + Status string `json:"status"` + ErrorMsg string `json:"errorMsg,omitempty"` +} + +type LoginResult struct { + Attribute int `json:"attribute"` + ClientId string `json:"clientId"` + DataVersion int `json:"dataVersion"` + Domain Domain `json:"domain"` + Ecode string `json:"ecode"` + Email string `json:"email"` + Extras Extras `json:"extras"` + HeadPic string `json:"headPic"` + ImproveCompanyInfo bool `json:"improveCompanyInfo"` + Nickname string `json:"nickname"` + PartnerIdentity string `json:"partnerIdentity"` + PhoneCode string `json:"phoneCode"` + Receiver string `json:"receiver"` + RegFrom int `json:"regFrom"` + Sid string `json:"sid"` + SnsNickname string `json:"snsNickname"` + TempUnit int `json:"tempUnit"` + Timezone string `json:"timezone"` + TimezoneId string `json:"timezoneId"` + Uid string `json:"uid"` + UserType int `json:"userType"` + Username string `json:"username"` +} + +type Domain struct { + AispeechHttpsUrl string `json:"aispeechHttpsUrl"` + AispeechQuicUrl string `json:"aispeechQuicUrl"` + DeviceHttpUrl string `json:"deviceHttpUrl"` + DeviceHttpsPskUrl string `json:"deviceHttpsPskUrl"` + DeviceHttpsUrl string `json:"deviceHttpsUrl"` + DeviceMediaMqttUrl string `json:"deviceMediaMqttUrl"` + DeviceMediaMqttsUrl string `json:"deviceMediaMqttsUrl"` + DeviceMqttsPskUrl string `json:"deviceMqttsPskUrl"` + DeviceMqttsUrl string `json:"deviceMqttsUrl"` + GwApiUrl string `json:"gwApiUrl"` + GwMqttUrl string `json:"gwMqttUrl"` + HttpPort int `json:"httpPort"` + HttpsPort int `json:"httpsPort"` + HttpsPskPort int `json:"httpsPskPort"` + MobileApiUrl string `json:"mobileApiUrl"` + MobileMediaMqttUrl string `json:"mobileMediaMqttUrl"` + MobileMqttUrl string `json:"mobileMqttUrl"` + MobileMqttsUrl string `json:"mobileMqttsUrl"` + MobileQuicUrl string `json:"mobileQuicUrl"` + MqttPort int `json:"mqttPort"` + MqttQuicUrl string `json:"mqttQuicUrl"` + MqttsPort int `json:"mqttsPort"` + MqttsPskPort int `json:"mqttsPskPort"` + RegionCode string `json:"regionCode"` +} + +type Extras struct { + HomeId string `json:"homeId"` + SceneType string `json:"sceneType"` +} + +type AppInfoResponse struct { + Result AppInfo `json:"result"` + T int64 `json:"t"` + Success bool `json:"success"` + Msg string `json:"errorMsg,omitempty"` +} + +type AppInfo struct { + AppId int `json:"appId"` + AppName string `json:"appName"` + ClientId string `json:"clientId"` + Icon string `json:"icon"` +} + +type MQTTConfigResponse struct { + Result SmartApiMQTTConfig `json:"result"` + Success bool `json:"success"` + Msg string `json:"errorMsg,omitempty"` +} + +type SmartApiMQTTConfig struct { + Msid string `json:"msid"` + Password string `json:"password"` +} + +type HomeListResponse struct { + Result []Home `json:"result"` + T int64 `json:"t"` + Success bool `json:"success"` + Msg string `json:"errorMsg,omitempty"` +} + +type SharedHomeListResponse struct { + Result SharedHome `json:"result"` + T int64 `json:"t"` + Success bool `json:"success"` + Msg string `json:"errorMsg,omitempty"` +} + +type SharedHome struct { + SecurityWebCShareInfoList []struct { + DeviceInfoList []Device `json:"deviceInfoList"` + Nickname string `json:"nickname"` + Username string `json:"username"` + } `json:"securityWebCShareInfoList"` +} + +type Home struct { + Admin bool `json:"admin"` + Background string `json:"background"` + DealStatus int `json:"dealStatus"` + DisplayOrder int `json:"displayOrder"` + GeoName string `json:"geoName"` + Gid int `json:"gid"` + GmtCreate int64 `json:"gmtCreate"` + GmtModified int64 `json:"gmtModified"` + GroupId int `json:"groupId"` + GroupUserId int `json:"groupUserId"` + Id int `json:"id"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + ManagementStatus bool `json:"managementStatus"` + Name string `json:"name"` + OwnerId string `json:"ownerId"` + Role int `json:"role"` + Status bool `json:"status"` + Uid string `json:"uid"` +} + +type RoomListRequest struct { + HomeId string `json:"homeId"` +} + +type RoomListResponse struct { + Result []Room `json:"result"` + T int64 `json:"t"` + Success bool `json:"success"` + Msg string `json:"errorMsg,omitempty"` +} + +type Room struct { + DeviceCount int `json:"deviceCount"` + DeviceList []Device `json:"deviceList"` + RoomId string `json:"roomId"` + RoomName string `json:"roomName"` +} + +type Device struct { + Category string `json:"category"` + DeviceId string `json:"deviceId"` + DeviceName string `json:"deviceName"` + P2pType int `json:"p2pType"` + ProductId string `json:"productId"` + SupportCloudStorage bool `json:"supportCloudStorage"` + Uuid string `json:"uuid"` +} + +type SmartApiWebRTCConfigRequest struct { + DevId string `json:"devId"` + ClientTraceId string `json:"clientTraceId"` +} + +type SmartApiWebRTCConfigResponse struct { + Result SmartApiWebRTCConfig `json:"result"` + Success bool `json:"success"` + Msg string `json:"errorMsg,omitempty"` +} + +type SmartApiWebRTCConfig struct { + AudioAttributes AudioAttributes `json:"audioAttributes"` + Auth string `json:"auth"` + GatewayId string `json:"gatewayId"` + Id string `json:"id"` + LocalKey string `json:"localKey"` + MotoId string `json:"motoId"` + NodeId string `json:"nodeId"` + P2PConfig P2PConfig `json:"p2pConfig"` + ProtocolVersion string `json:"protocolVersion"` + Skill string `json:"skill"` + Sub bool `json:"sub"` + SupportWebrtcRecord bool `json:"supportWebrtcRecord"` + SupportsPtz bool `json:"supportsPtz"` + SupportsWebrtc bool `json:"supportsWebrtc"` + VedioClarity int `json:"vedioClarity"` + VedioClaritys []int `json:"vedioClaritys"` + VideoClarity int `json:"videoClarity"` +} + +type TuyaSmartApiClient struct { + TuyaClient + + email string + password string + countryCode string + mqttsUrl string +} + +type Region struct { + Name string `json:"name"` + Host string `json:"host"` + Description string `json:"description"` + Continent string `json:"continent"` +} + +var AvailableRegions = []Region{ + {"eu-central", "protect-eu.ismartlife.me", "Central Europe", "EU"}, + {"eu-east", "protect-we.ismartlife.me", "East Europe", "EU"}, + {"us-west", "protect-us.ismartlife.me", "West America", "AZ"}, + {"us-east", "protect-ue.ismartlife.me", "East America", "AZ"}, + {"china", "protect.ismartlife.me", "China", "AY"}, + {"india", "protect-in.ismartlife.me", "India", "IN"}, +} + +func NewTuyaSmartApiClient(httpClient *http.Client, baseUrl, email, password, deviceId string) (*TuyaSmartApiClient, error) { + var region *Region + for _, r := range AvailableRegions { + if r.Host == baseUrl { + region = &r + break + } + } + + if region == nil { + return nil, fmt.Errorf("invalid region: %s", baseUrl) + } + + if httpClient == nil { + httpClient = CreateHTTPClientWithSession() + } + + mqttClient := NewTuyaMqttClient(deviceId) + + client := &TuyaSmartApiClient{ + TuyaClient: TuyaClient{ + httpClient: httpClient, + mqtt: mqttClient, + deviceId: deviceId, + expireTime: 0, + baseUrl: baseUrl, + }, + email: email, + password: password, + countryCode: region.Continent, + } + + return client, nil +} + +// WebRTC Flow +func (c *TuyaSmartApiClient) Init() error { + if err := c.initToken(); err != nil { + return fmt.Errorf("failed to initialize token: %w", err) + } + + webrtcConfig, err := c.loadWebrtcConfig() + if err != nil { + return fmt.Errorf("failed to load webrtc config: %w", err) + } + + hubConfig, err := c.loadHubConfig() + if err != nil { + return fmt.Errorf("failed to load hub config: %w", err) + } + + if err := c.mqtt.Start(hubConfig, webrtcConfig, c.skill.WebRTC); err != nil { + return fmt.Errorf("failed to start MQTT: %w", err) + } + + if c.skill.LowPower > 0 { + _ = c.mqtt.WakeUp(c.localKey) + } + + return nil +} + +func (c *TuyaSmartApiClient) GetStreamUrl(streamType string) (streamUrl string, err error) { + return "", errors.New("not supported") +} + +func (c *TuyaSmartApiClient) GetAppInfo() (*AppInfoResponse, error) { + url := fmt.Sprintf("https://%s/api/customized/web/app/info", c.baseUrl) + + body, err := c.request("POST", url, nil) + if err != nil { + return nil, err + } + + var appInfoResponse AppInfoResponse + if err := json.Unmarshal(body, &appInfoResponse); err != nil { + return nil, err + } + + if !appInfoResponse.Success { + return nil, errors.New(appInfoResponse.Msg) + } + + return &appInfoResponse, nil +} + +func (c *TuyaSmartApiClient) GetHomeList() (*HomeListResponse, error) { + url := fmt.Sprintf("https://%s/api/new/common/homeList", c.baseUrl) + + body, err := c.request("POST", url, nil) + if err != nil { + return nil, err + } + + var homeListResponse HomeListResponse + if err := json.Unmarshal(body, &homeListResponse); err != nil { + return nil, err + } + + if !homeListResponse.Success { + return nil, errors.New(homeListResponse.Msg) + } + + return &homeListResponse, nil +} + +func (c *TuyaSmartApiClient) GetSharedHomeList() (*SharedHomeListResponse, error) { + url := fmt.Sprintf("https://%s/api/new/playback/shareList", c.baseUrl) + + body, err := c.request("POST", url, nil) + if err != nil { + return nil, err + } + + var sharedHomeListResponse SharedHomeListResponse + if err := json.Unmarshal(body, &sharedHomeListResponse); err != nil { + return nil, err + } + + if !sharedHomeListResponse.Success { + return nil, errors.New(sharedHomeListResponse.Msg) + } + + return &sharedHomeListResponse, nil +} + +func (c *TuyaSmartApiClient) GetRoomList(homeId string) (*RoomListResponse, error) { + url := fmt.Sprintf("https://%s/api/new/common/roomList", c.baseUrl) + + data := RoomListRequest{ + HomeId: homeId, + } + + body, err := c.request("POST", url, data) + if err != nil { + return nil, err + } + + var roomListResponse RoomListResponse + if err := json.Unmarshal(body, &roomListResponse); err != nil { + return nil, err + } + + if !roomListResponse.Success { + return nil, errors.New(roomListResponse.Msg) + } + + return &roomListResponse, nil +} + +func (c *TuyaSmartApiClient) initToken() error { + tokenUrl := fmt.Sprintf("https://%s/api/login/token", c.baseUrl) + + tokenReq := LoginTokenRequest{ + CountryCode: c.countryCode, + Username: c.email, + IsUid: false, + } + + body, err := c.request("POST", tokenUrl, tokenReq) + if err != nil { + return err + } + + var tokenResp LoginTokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return err + } + + if !tokenResp.Success { + return errors.New(tokenResp.Msg) + } + + encryptedPassword, err := EncryptPassword(c.password, tokenResp.Result.PbKey) + if err != nil { + return fmt.Errorf("failed to encrypt password: %v", err) + } + var loginUrl string + + loginReq := PasswordLoginRequest{ + CountryCode: c.countryCode, + Passwd: encryptedPassword, + Token: tokenResp.Result.Token, + IfEncrypt: 1, + Options: `{"group":1}`, + } + + if IsEmailAddress(c.email) { + loginUrl = fmt.Sprintf("https://%s/api/private/email/login", c.baseUrl) + loginReq.Email = c.email + } else { + loginUrl = fmt.Sprintf("https://%s/api/private/phone/login", c.baseUrl) + loginReq.Mobile = c.email + } + + body, err = c.request("POST", loginUrl, loginReq) + if err != nil { + return err + } + + var loginResp *PasswordLoginResponse + if err := json.Unmarshal(body, &loginResp); err != nil { + return err + } + + if !loginResp.Success { + return errors.New(loginResp.ErrorMsg) + } + + c.mqttsUrl = fmt.Sprintf("ssl://%s:%d", loginResp.Result.Domain.MobileMqttsUrl, loginResp.Result.Domain.MqttsPort) + c.expireTime = time.Now().Unix() + 2*24*60*60 // 2 days in seconds + + return nil +} + +func (c *TuyaSmartApiClient) loadWebrtcConfig() (*WebRTCConfig, error) { + url := fmt.Sprintf("https://%s/api/jarvis/config", c.baseUrl) + + data := SmartApiWebRTCConfigRequest{ + DevId: c.deviceId, + ClientTraceId: fmt.Sprintf("%x", rand.Int63()), + } + + body, err := c.request("POST", url, data) + if err != nil { + return nil, err + } + + var webRTCConfigResponse SmartApiWebRTCConfigResponse + err = json.Unmarshal(body, &webRTCConfigResponse) + if err != nil { + return nil, err + } + + if !webRTCConfigResponse.Success { + return nil, errors.New(webRTCConfigResponse.Msg) + } + + err = json.Unmarshal([]byte(webRTCConfigResponse.Result.Skill), &c.skill) + if err != nil { + return nil, err + } + + // Store LocalKey + c.localKey = webRTCConfigResponse.Result.LocalKey + + iceServers, err := json.Marshal(&webRTCConfigResponse.Result.P2PConfig.Ices) + if err != nil { + return nil, err + } + + c.iceServers, err = webrtc.UnmarshalICEServers(iceServers) + if err != nil { + return nil, err + } + + return &WebRTCConfig{ + AudioAttributes: webRTCConfigResponse.Result.AudioAttributes, + Auth: webRTCConfigResponse.Result.Auth, + ID: webRTCConfigResponse.Result.Id, + MotoID: webRTCConfigResponse.Result.MotoId, + P2PConfig: webRTCConfigResponse.Result.P2PConfig, + ProtocolVersion: webRTCConfigResponse.Result.ProtocolVersion, + Skill: webRTCConfigResponse.Result.Skill, + SupportsWebRTCRecord: webRTCConfigResponse.Result.SupportWebrtcRecord, + SupportsWebRTC: webRTCConfigResponse.Result.SupportsWebrtc, + VedioClaritiy: webRTCConfigResponse.Result.VedioClarity, + VideoClaritiy: webRTCConfigResponse.Result.VideoClarity, + VideoClarities: webRTCConfigResponse.Result.VedioClaritys, + }, nil +} + +func (c *TuyaSmartApiClient) loadHubConfig() (config *MQTTConfig, err error) { + mqttUrl := fmt.Sprintf("https://%s/api/jarvis/mqtt", c.baseUrl) + + mqttBody, err := c.request("POST", mqttUrl, nil) + if err != nil { + return nil, err + } + + var mqttConfigResponse MQTTConfigResponse + err = json.Unmarshal(mqttBody, &mqttConfigResponse) + if err != nil { + return nil, err + } + + if !mqttConfigResponse.Success { + return nil, errors.New(mqttConfigResponse.Msg) + } + + return &MQTTConfig{ + Url: c.mqttsUrl, + ClientID: fmt.Sprintf("web_%s", mqttConfigResponse.Result.Msid), + Username: fmt.Sprintf("web_%s", mqttConfigResponse.Result.Msid), + Password: mqttConfigResponse.Result.Password, + PublishTopic: "/av/moto/moto_id/u/{device_id}", + SubscribeTopic: fmt.Sprintf("/av/u/%s", mqttConfigResponse.Result.Msid), + }, nil +} + +func (c *TuyaSmartApiClient) request(method string, url string, body any) ([]byte, error) { + var bodyReader io.Reader + if body != nil { + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(jsonBody) + } + + req, err := http.NewRequest(method, url, bodyReader) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("Accept", "*/*") + req.Header.Set("Origin", fmt.Sprintf("https://%s", c.baseUrl)) + + response, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer response.Body.Close() + + res, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + + if response.StatusCode != http.StatusOK { + return nil, err + } + + return res, nil +} diff --git a/installs_on_host/go2rtc/pkg/v4l2/device/README.md b/installs_on_host/go2rtc/pkg/v4l2/device/README.md new file mode 100644 index 0000000..de686ea --- /dev/null +++ b/installs_on_host/go2rtc/pkg/v4l2/device/README.md @@ -0,0 +1,21 @@ +# Video For Linux Two + +Build on Ubuntu + +```bash +sudo apt install gcc-x86-64-linux-gnu +sudo apt install gcc-i686-linux-gnu +sudo apt install gcc-aarch64-linux-gnu binutils +sudo apt install gcc-arm-linux-gnueabihf +sudo apt install gcc-mipsel-linux-gnu + +x86_64-linux-gnu-gcc -w -static videodev2_arch.c -o videodev2_x86_64 +i686-linux-gnu-gcc -w -static videodev2_arch.c -o videodev2_i686 +aarch64-linux-gnu-gcc -w -static videodev2_arch.c -o videodev2_aarch64 +arm-linux-gnueabihf-gcc -w -static videodev2_arch.c -o videodev2_armhf +mipsel-linux-gnu-gcc -w -static videodev2_arch.c -o videodev2_mipsel -D_TIME_BITS=32 +``` + +## Useful links + +- https://github.com/torvalds/linux/blob/master/include/uapi/linux/videodev2.h diff --git a/installs_on_host/go2rtc/pkg/v4l2/device/device.go b/installs_on_host/go2rtc/pkg/v4l2/device/device.go new file mode 100644 index 0000000..c77d60f --- /dev/null +++ b/installs_on_host/go2rtc/pkg/v4l2/device/device.go @@ -0,0 +1,252 @@ +//go:build linux + +package device + +import ( + "bytes" + "errors" + "fmt" + "syscall" + "unsafe" +) + +type Device struct { + fd int + bufs [][]byte + pixFmt uint32 +} + +func Open(path string) (*Device, error) { + fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CLOEXEC, 0) + if err != nil { + return nil, err + } + return &Device{fd: fd}, nil +} + +const buffersCount = 2 + +type Capability struct { + Driver string + Card string + BusInfo string + Version string +} + +func (d *Device) Capability() (*Capability, error) { + c := v4l2_capability{} + if err := ioctl(d.fd, VIDIOC_QUERYCAP, unsafe.Pointer(&c)); err != nil { + return nil, err + } + return &Capability{ + Driver: str(c.driver[:]), + Card: str(c.card[:]), + BusInfo: str(c.bus_info[:]), + Version: fmt.Sprintf("%d.%d.%d", byte(c.version>>16), byte(c.version>>8), byte(c.version)), + }, nil +} + +func (d *Device) ListFormats() ([]uint32, error) { + var items []uint32 + + for i := uint32(0); ; i++ { + fd := v4l2_fmtdesc{ + index: i, + typ: V4L2_BUF_TYPE_VIDEO_CAPTURE, + } + if err := ioctl(d.fd, VIDIOC_ENUM_FMT, unsafe.Pointer(&fd)); err != nil { + if !errors.Is(err, syscall.EINVAL) { + return nil, err + } + break + } + + items = append(items, fd.pixelformat) + } + + return items, nil +} + +func (d *Device) ListSizes(pixFmt uint32) ([][2]uint32, error) { + var items [][2]uint32 + + for i := uint32(0); ; i++ { + fs := v4l2_frmsizeenum{ + index: i, + pixel_format: pixFmt, + } + if err := ioctl(d.fd, VIDIOC_ENUM_FRAMESIZES, unsafe.Pointer(&fs)); err != nil { + if !errors.Is(err, syscall.EINVAL) { + return nil, err + } + break + } + + if fs.typ != V4L2_FRMSIZE_TYPE_DISCRETE { + continue + } + + items = append(items, [2]uint32{fs.discrete.width, fs.discrete.height}) + } + + return items, nil +} + +func (d *Device) ListFrameRates(pixFmt, width, height uint32) ([]uint32, error) { + var items []uint32 + + for i := uint32(0); ; i++ { + fi := v4l2_frmivalenum{ + index: i, + pixel_format: pixFmt, + width: width, + height: height, + } + if err := ioctl(d.fd, VIDIOC_ENUM_FRAMEINTERVALS, unsafe.Pointer(&fi)); err != nil { + if !errors.Is(err, syscall.EINVAL) { + return nil, err + } + break + } + + if fi.typ != V4L2_FRMIVAL_TYPE_DISCRETE || fi.discrete.numerator != 1 { + continue + } + + items = append(items, fi.discrete.denominator) + } + + return items, nil +} + +func (d *Device) SetFormat(width, height, pixFmt uint32) error { + d.pixFmt = pixFmt + + f := v4l2_format{ + typ: V4L2_BUF_TYPE_VIDEO_CAPTURE, + pix: v4l2_pix_format{ + width: width, + height: height, + pixelformat: pixFmt, + field: V4L2_FIELD_NONE, + colorspace: V4L2_COLORSPACE_DEFAULT, + }, + } + return ioctl(d.fd, VIDIOC_S_FMT, unsafe.Pointer(&f)) +} + +func (d *Device) SetParam(fps uint32) error { + p := v4l2_streamparm{ + typ: V4L2_BUF_TYPE_VIDEO_CAPTURE, + capture: v4l2_captureparm{ + timeperframe: v4l2_fract{numerator: 1, denominator: fps}, + }, + } + return ioctl(d.fd, VIDIOC_S_PARM, unsafe.Pointer(&p)) +} + +func (d *Device) StreamOn() (err error) { + rb := v4l2_requestbuffers{ + count: buffersCount, + typ: V4L2_BUF_TYPE_VIDEO_CAPTURE, + memory: V4L2_MEMORY_MMAP, + } + if err = ioctl(d.fd, VIDIOC_REQBUFS, unsafe.Pointer(&rb)); err != nil { + return err + } + + d.bufs = make([][]byte, buffersCount) + for i := uint32(0); i < buffersCount; i++ { + qb := v4l2_buffer{ + index: i, + typ: V4L2_BUF_TYPE_VIDEO_CAPTURE, + memory: V4L2_MEMORY_MMAP, + } + if err = ioctl(d.fd, VIDIOC_QUERYBUF, unsafe.Pointer(&qb)); err != nil { + return err + } + + if d.bufs[i], err = syscall.Mmap( + d.fd, int64(qb.offset), int(qb.length), syscall.PROT_READ, syscall.MAP_SHARED, + ); nil != err { + return err + } + + if err = ioctl(d.fd, VIDIOC_QBUF, unsafe.Pointer(&qb)); err != nil { + return err + } + } + + typ := uint32(V4L2_BUF_TYPE_VIDEO_CAPTURE) + return ioctl(d.fd, VIDIOC_STREAMON, unsafe.Pointer(&typ)) +} + +func (d *Device) StreamOff() (err error) { + typ := uint32(V4L2_BUF_TYPE_VIDEO_CAPTURE) + if err = ioctl(d.fd, VIDIOC_STREAMOFF, unsafe.Pointer(&typ)); err != nil { + return err + } + + for i := range d.bufs { + _ = syscall.Munmap(d.bufs[i]) + } + + rb := v4l2_requestbuffers{ + count: 0, + typ: V4L2_BUF_TYPE_VIDEO_CAPTURE, + memory: V4L2_MEMORY_MMAP, + } + return ioctl(d.fd, VIDIOC_REQBUFS, unsafe.Pointer(&rb)) +} + +func (d *Device) Capture() ([]byte, error) { + dec := v4l2_buffer{ + typ: V4L2_BUF_TYPE_VIDEO_CAPTURE, + memory: V4L2_MEMORY_MMAP, + } + if err := ioctl(d.fd, VIDIOC_DQBUF, unsafe.Pointer(&dec)); err != nil { + return nil, err + } + + src := d.bufs[dec.index][:dec.bytesused] + dst := make([]byte, dec.bytesused) + + switch d.pixFmt { + case V4L2_PIX_FMT_YUYV: + YUYVtoYUV(dst, src) + case V4L2_PIX_FMT_NV12: + NV12toYUV(dst, src) + default: + copy(dst, d.bufs[dec.index][:dec.bytesused]) + } + + enc := v4l2_buffer{ + typ: V4L2_BUF_TYPE_VIDEO_CAPTURE, + memory: V4L2_MEMORY_MMAP, + index: dec.index, + } + if err := ioctl(d.fd, VIDIOC_QBUF, unsafe.Pointer(&enc)); err != nil { + return nil, err + } + + return dst, nil +} + +func (d *Device) Close() error { + return syscall.Close(d.fd) +} + +func ioctl(fd int, req uint, arg unsafe.Pointer) error { + _, _, err := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), uintptr(req), uintptr(arg)) + if err != 0 { + return err + } + return nil +} + +func str(b []byte) string { + if i := bytes.IndexByte(b, 0); i >= 0 { + return string(b[:i]) + } + return string(b) +} diff --git a/installs_on_host/go2rtc/pkg/v4l2/device/formats.go b/installs_on_host/go2rtc/pkg/v4l2/device/formats.go new file mode 100644 index 0000000..a0b4108 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/v4l2/device/formats.go @@ -0,0 +1,62 @@ +package device + +const ( + V4L2_PIX_FMT_YUYV = 'Y' | 'U'<<8 | 'Y'<<16 | 'V'<<24 + V4L2_PIX_FMT_NV12 = 'N' | 'V'<<8 | '1'<<16 | '2'<<24 + V4L2_PIX_FMT_MJPEG = 'M' | 'J'<<8 | 'P'<<16 | 'G'<<24 + V4L2_PIX_FMT_H264 = 'H' | '2'<<8 | '6'<<16 | '4'<<24 + V4L2_PIX_FMT_HEVC = 'H' | 'E'<<8 | 'V'<<16 | 'C'<<24 +) + +type Format struct { + FourCC uint32 + Name string + FFmpeg string +} + +var Formats = []Format{ + {V4L2_PIX_FMT_YUYV, "YUV 4:2:2", "yuyv422"}, + {V4L2_PIX_FMT_NV12, "Y/UV 4:2:0", "nv12"}, + {V4L2_PIX_FMT_MJPEG, "Motion-JPEG", "mjpeg"}, + {V4L2_PIX_FMT_H264, "H.264", "h264"}, + {V4L2_PIX_FMT_HEVC, "HEVC", "hevc"}, +} + +func YUYVtoYUV(dst, src []byte) { + n := len(src) + i0 := 0 + iy := 0 + iu := n / 2 + iv := n / 4 * 3 + for i0 < n { + dst[iy] = src[i0] + i0++ + iy++ + dst[iu] = src[i0] + i0++ + iu++ + dst[iy] = src[i0] + i0++ + iy++ + dst[iv] = src[i0] + i0++ + iv++ + } +} + +func NV12toYUV(dst, src []byte) { + n := len(src) + k := n / 6 + i0 := k * 4 + iu := i0 + iv := i0 + k + copy(dst, src[:i0]) // copy Y + for i0 < n { + dst[iu] = src[i0] + i0++ + iu++ + dst[iv] = src[i0] + i0++ + iv++ + } +} diff --git a/installs_on_host/go2rtc/pkg/v4l2/device/videodev2_386.go b/installs_on_host/go2rtc/pkg/v4l2/device/videodev2_386.go new file mode 100644 index 0000000..8737ca9 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/v4l2/device/videodev2_386.go @@ -0,0 +1,149 @@ +package device + +const ( + VIDIOC_QUERYCAP = 0x80685600 + VIDIOC_ENUM_FMT = 0xc0405602 + VIDIOC_G_FMT = 0xc0cc5604 + VIDIOC_S_FMT = 0xc0cc5605 + VIDIOC_REQBUFS = 0xc0145608 + VIDIOC_QUERYBUF = 0xc0445609 + + VIDIOC_QBUF = 0xc044560f + VIDIOC_DQBUF = 0xc0445611 + VIDIOC_STREAMON = 0x40045612 + VIDIOC_STREAMOFF = 0x40045613 + VIDIOC_G_PARM = 0xc0cc5615 + VIDIOC_S_PARM = 0xc0cc5616 + + VIDIOC_ENUM_FRAMESIZES = 0xc02c564a + VIDIOC_ENUM_FRAMEINTERVALS = 0xc034564b +) + +const ( + V4L2_BUF_TYPE_VIDEO_CAPTURE = 1 + V4L2_COLORSPACE_DEFAULT = 0 + V4L2_FIELD_NONE = 1 + V4L2_FRMIVAL_TYPE_DISCRETE = 1 + V4L2_FRMSIZE_TYPE_DISCRETE = 1 + V4L2_MEMORY_MMAP = 1 +) + +type v4l2_capability struct { // size 104 + driver [16]byte // offset 0, size 16 + card [32]byte // offset 16, size 32 + bus_info [32]byte // offset 48, size 32 + version uint32 // offset 80, size 4 + capabilities uint32 // offset 84, size 4 + device_caps uint32 // offset 88, size 4 + reserved [3]uint32 // offset 92, size 12 +} + +type v4l2_format struct { // size 204 + typ uint32 // offset 0, size 4 + _ [0]byte // align + pix v4l2_pix_format // offset 4, size 48 + _ [152]byte // filler +} + +type v4l2_pix_format struct { // size 48 + width uint32 // offset 0, size 4 + height uint32 // offset 4, size 4 + pixelformat uint32 // offset 8, size 4 + field uint32 // offset 12, size 4 + bytesperline uint32 // offset 16, size 4 + sizeimage uint32 // offset 20, size 4 + colorspace uint32 // offset 24, size 4 + priv uint32 // offset 28, size 4 + flags uint32 // offset 32, size 4 + ycbcr_enc uint32 // offset 36, size 4 + quantization uint32 // offset 40, size 4 + xfer_func uint32 // offset 44, size 4 +} + +type v4l2_streamparm struct { // size 204 + typ uint32 // offset 0, size 4 + capture v4l2_captureparm // offset 4, size 40 + _ [160]byte // filler +} + +type v4l2_captureparm struct { // size 40 + capability uint32 // offset 0, size 4 + capturemode uint32 // offset 4, size 4 + timeperframe v4l2_fract // offset 8, size 8 + extendedmode uint32 // offset 16, size 4 + readbuffers uint32 // offset 20, size 4 + reserved [4]uint32 // offset 24, size 16 +} + +type v4l2_fract struct { // size 8 + numerator uint32 // offset 0, size 4 + denominator uint32 // offset 4, size 4 +} + +type v4l2_requestbuffers struct { // size 20 + count uint32 // offset 0, size 4 + typ uint32 // offset 4, size 4 + memory uint32 // offset 8, size 4 + capabilities uint32 // offset 12, size 4 + flags uint8 // offset 16, size 1 + reserved [3]uint8 // offset 17, size 3 +} + +type v4l2_buffer struct { // size 68 + index uint32 // offset 0, size 4 + typ uint32 // offset 4, size 4 + bytesused uint32 // offset 8, size 4 + flags uint32 // offset 12, size 4 + field uint32 // offset 16, size 4 + _ [8]byte // align + timecode v4l2_timecode // offset 28, size 16 + sequence uint32 // offset 44, size 4 + memory uint32 // offset 48, size 4 + offset uint32 // offset 52, size 4 + _ [0]byte // align + length uint32 // offset 56, size 4 + _ [8]byte // filler +} + +type v4l2_timecode struct { // size 16 + typ uint32 // offset 0, size 4 + flags uint32 // offset 4, size 4 + frames uint8 // offset 8, size 1 + seconds uint8 // offset 9, size 1 + minutes uint8 // offset 10, size 1 + hours uint8 // offset 11, size 1 + userbits [4]uint8 // offset 12, size 4 +} + +type v4l2_fmtdesc struct { // size 64 + index uint32 // offset 0, size 4 + typ uint32 // offset 4, size 4 + flags uint32 // offset 8, size 4 + description [32]byte // offset 12, size 32 + pixelformat uint32 // offset 44, size 4 + mbus_code uint32 // offset 48, size 4 + reserved [3]uint32 // offset 52, size 12 +} + +type v4l2_frmsizeenum struct { // size 44 + index uint32 // offset 0, size 4 + pixel_format uint32 // offset 4, size 4 + typ uint32 // offset 8, size 4 + discrete v4l2_frmsize_discrete // offset 12, size 8 + _ [24]byte // filler +} + +type v4l2_frmsize_discrete struct { // size 8 + width uint32 // offset 0, size 4 + height uint32 // offset 4, size 4 +} + +type v4l2_frmivalenum struct { // size 52 + index uint32 // offset 0, size 4 + pixel_format uint32 // offset 4, size 4 + width uint32 // offset 8, size 4 + height uint32 // offset 12, size 4 + typ uint32 // offset 16, size 4 + discrete v4l2_fract // offset 20, size 8 + _ [24]byte // filler +} diff --git a/installs_on_host/go2rtc/pkg/v4l2/device/videodev2_arch.c b/installs_on_host/go2rtc/pkg/v4l2/device/videodev2_arch.c new file mode 100644 index 0000000..19ac6a6 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/v4l2/device/videodev2_arch.c @@ -0,0 +1,164 @@ +//go:build ignore +#include +#include +#include + +#define printconst1(con) printf("\t%s = 0x%08lx\n", #con, con) +#define printconst2(con) printf("\t%s = %d\n", #con, con) +#define printstruct(str) printf("type %s struct { // size %lu\n", #str, sizeof(struct str)) +#define printmember(str, mem, typ) printf("\t%s %s // offset %lu, size %lu\n", #mem == "type" ? "typ" : #mem, typ, offsetof(struct str, mem), sizeof((struct str){0}.mem)) +#define printunimem(str, uni, mem, typ) printf("\t%s %s // offset %lu, size %lu\n", #mem, typ, offsetof(struct str, uni.mem), sizeof((struct str){0}.uni.mem)) +#define printalign1(str, mem2, mem1) printf("\t_ [%lu]byte // align\n", offsetof(struct str, mem2) - offsetof(struct str, mem1) - sizeof((struct str){0}.mem1)) +#define printfiller(str, mem) printf("\t_ [%lu]byte // filler\n", sizeof(struct str) - offsetof(struct str, mem) - sizeof((struct str){0}.mem)) + +int main() { + printf("const (\n"); + printconst1(VIDIOC_QUERYCAP); + printconst1(VIDIOC_ENUM_FMT); + printconst1(VIDIOC_G_FMT); + printconst1(VIDIOC_S_FMT); + printconst1(VIDIOC_REQBUFS); + printconst1(VIDIOC_QUERYBUF); + printf("\n"); + printconst1(VIDIOC_QBUF); + printconst1(VIDIOC_DQBUF); + printconst1(VIDIOC_STREAMON); + printconst1(VIDIOC_STREAMOFF); + printconst1(VIDIOC_G_PARM); + printconst1(VIDIOC_S_PARM); + printf("\n"); + printconst1(VIDIOC_ENUM_FRAMESIZES); + printconst1(VIDIOC_ENUM_FRAMEINTERVALS); + printf(")\n\n"); + + printf("const (\n"); + printconst2(V4L2_BUF_TYPE_VIDEO_CAPTURE); + printconst2(V4L2_COLORSPACE_DEFAULT); + printconst2(V4L2_FIELD_NONE); + printconst2(V4L2_FRMIVAL_TYPE_DISCRETE); + printconst2(V4L2_FRMSIZE_TYPE_DISCRETE); + printconst2(V4L2_MEMORY_MMAP); + printf(")\n\n"); + + printstruct(v4l2_capability); + printmember(v4l2_capability, driver, "[16]byte"); + printmember(v4l2_capability, card, "[32]byte"); + printmember(v4l2_capability, bus_info, "[32]byte"); + printmember(v4l2_capability, version, "uint32"); + printmember(v4l2_capability, capabilities, "uint32"); + printmember(v4l2_capability, device_caps, "uint32"); + printmember(v4l2_capability, reserved, "[3]uint32"); + printf("}\n\n"); + + printstruct(v4l2_format); + printmember(v4l2_format, type, "uint32"); + printalign1(v4l2_format, fmt, type); + printunimem(v4l2_format, fmt, pix, "v4l2_pix_format"); + printfiller(v4l2_format, fmt.pix); + printf("}\n\n"); + + printstruct(v4l2_pix_format); + printmember(v4l2_pix_format, width, "uint32"); + printmember(v4l2_pix_format, height, "uint32"); + printmember(v4l2_pix_format, pixelformat, "uint32"); + printmember(v4l2_pix_format, field, "uint32"); + printmember(v4l2_pix_format, bytesperline, "uint32"); + printmember(v4l2_pix_format, sizeimage, "uint32"); + printmember(v4l2_pix_format, colorspace, "uint32"); + printmember(v4l2_pix_format, priv, "uint32"); + printmember(v4l2_pix_format, flags, "uint32"); + printmember(v4l2_pix_format, ycbcr_enc, "uint32"); + printmember(v4l2_pix_format, quantization, "uint32"); + printmember(v4l2_pix_format, xfer_func, "uint32"); + printf("}\n\n"); + + printstruct(v4l2_streamparm); + printmember(v4l2_streamparm, type, "uint32"); + printunimem(v4l2_streamparm, parm, capture, "v4l2_captureparm"); + printfiller(v4l2_streamparm, parm.capture); + printf("}\n\n"); + + printstruct(v4l2_captureparm); + printmember(v4l2_captureparm, capability, "uint32"); + printmember(v4l2_captureparm, capturemode, "uint32"); + printmember(v4l2_captureparm, timeperframe, "v4l2_fract"); + printmember(v4l2_captureparm, extendedmode, "uint32"); + printmember(v4l2_captureparm, readbuffers, "uint32"); + printmember(v4l2_captureparm, reserved, "[4]uint32"); + printf("}\n\n"); + + printstruct(v4l2_fract); + printmember(v4l2_fract, numerator, "uint32"); + printmember(v4l2_fract, denominator, "uint32"); + printf("}\n\n"); + + printstruct(v4l2_requestbuffers); + printmember(v4l2_requestbuffers, count, "uint32"); + printmember(v4l2_requestbuffers, type, "uint32"); + printmember(v4l2_requestbuffers, memory, "uint32"); + printmember(v4l2_requestbuffers, capabilities, "uint32"); + printmember(v4l2_requestbuffers, flags, "uint8"); + printmember(v4l2_requestbuffers, reserved, "[3]uint8"); + printf("}\n\n"); + + printstruct(v4l2_buffer); + printmember(v4l2_buffer, index, "uint32"); + printmember(v4l2_buffer, type, "uint32"); + printmember(v4l2_buffer, bytesused, "uint32"); + printmember(v4l2_buffer, flags, "uint32"); + printmember(v4l2_buffer, field, "uint32"); + printalign1(v4l2_buffer, timecode, field); + printmember(v4l2_buffer, timecode, "v4l2_timecode"); + printmember(v4l2_buffer, sequence, "uint32"); + printmember(v4l2_buffer, memory, "uint32"); + printunimem(v4l2_buffer, m, offset, "uint32"); + printalign1(v4l2_buffer, length, m.offset); + printmember(v4l2_buffer, length, "uint32"); + printfiller(v4l2_buffer, length); + printf("}\n\n"); + + printstruct(v4l2_timecode); + printmember(v4l2_timecode, type, "uint32"); + printmember(v4l2_timecode, flags, "uint32"); + printmember(v4l2_timecode, frames, "uint8"); + printmember(v4l2_timecode, seconds, "uint8"); + printmember(v4l2_timecode, minutes, "uint8"); + printmember(v4l2_timecode, hours, "uint8"); + printmember(v4l2_timecode, userbits, "[4]uint8"); + printf("}\n\n"); + + printstruct(v4l2_fmtdesc); + printmember(v4l2_fmtdesc, index, "uint32"); + printmember(v4l2_fmtdesc, type, "uint32"); + printmember(v4l2_fmtdesc, flags, "uint32"); + printmember(v4l2_fmtdesc, description, "[32]byte"); + printmember(v4l2_fmtdesc, pixelformat, "uint32"); + printmember(v4l2_fmtdesc, mbus_code, "uint32"); + printmember(v4l2_fmtdesc, reserved, "[3]uint32"); + printf("}\n\n"); + + printstruct(v4l2_frmsizeenum); + printmember(v4l2_frmsizeenum, index, "uint32"); + printmember(v4l2_frmsizeenum, pixel_format, "uint32"); + printmember(v4l2_frmsizeenum, type, "uint32"); + printmember(v4l2_frmsizeenum, discrete, "v4l2_frmsize_discrete"); + printfiller(v4l2_frmsizeenum, discrete); + printf("}\n\n"); + + printstruct(v4l2_frmsize_discrete); + printmember(v4l2_frmsize_discrete, width, "uint32"); + printmember(v4l2_frmsize_discrete, height, "uint32"); + printf("}\n\n"); + + printstruct(v4l2_frmivalenum); + printmember(v4l2_frmivalenum, index, "uint32"); + printmember(v4l2_frmivalenum, pixel_format, "uint32"); + printmember(v4l2_frmivalenum, width, "uint32"); + printmember(v4l2_frmivalenum, height, "uint32"); + printmember(v4l2_frmivalenum, type, "uint32"); + printmember(v4l2_frmivalenum, discrete, "v4l2_fract"); + printfiller(v4l2_frmivalenum, discrete); + printf("}\n\n"); + + return 0; +} \ No newline at end of file diff --git a/installs_on_host/go2rtc/pkg/v4l2/device/videodev2_arm.go b/installs_on_host/go2rtc/pkg/v4l2/device/videodev2_arm.go new file mode 100644 index 0000000..098ca5a --- /dev/null +++ b/installs_on_host/go2rtc/pkg/v4l2/device/videodev2_arm.go @@ -0,0 +1,149 @@ +package device + +const ( + VIDIOC_QUERYCAP = 0x80685600 + VIDIOC_ENUM_FMT = 0xc0405602 + VIDIOC_G_FMT = 0xc0cc5604 + VIDIOC_S_FMT = 0xc0cc5605 + VIDIOC_REQBUFS = 0xc0145608 + VIDIOC_QUERYBUF = 0xc0505609 + + VIDIOC_QBUF = 0xc050560f + VIDIOC_DQBUF = 0xc0505611 + VIDIOC_STREAMON = 0x40045612 + VIDIOC_STREAMOFF = 0x40045613 + VIDIOC_G_PARM = 0xc0cc5615 + VIDIOC_S_PARM = 0xc0cc5616 + + VIDIOC_ENUM_FRAMESIZES = 0xc02c564a + VIDIOC_ENUM_FRAMEINTERVALS = 0xc034564b +) + +const ( + V4L2_BUF_TYPE_VIDEO_CAPTURE = 1 + V4L2_COLORSPACE_DEFAULT = 0 + V4L2_FIELD_NONE = 1 + V4L2_FRMIVAL_TYPE_DISCRETE = 1 + V4L2_FRMSIZE_TYPE_DISCRETE = 1 + V4L2_MEMORY_MMAP = 1 +) + +type v4l2_capability struct { // size 104 + driver [16]byte // offset 0, size 16 + card [32]byte // offset 16, size 32 + bus_info [32]byte // offset 48, size 32 + version uint32 // offset 80, size 4 + capabilities uint32 // offset 84, size 4 + device_caps uint32 // offset 88, size 4 + reserved [3]uint32 // offset 92, size 12 +} + +type v4l2_format struct { // size 204 + typ uint32 // offset 0, size 4 + _ [0]byte // align + pix v4l2_pix_format // offset 4, size 48 + _ [152]byte // filler +} + +type v4l2_pix_format struct { // size 48 + width uint32 // offset 0, size 4 + height uint32 // offset 4, size 4 + pixelformat uint32 // offset 8, size 4 + field uint32 // offset 12, size 4 + bytesperline uint32 // offset 16, size 4 + sizeimage uint32 // offset 20, size 4 + colorspace uint32 // offset 24, size 4 + priv uint32 // offset 28, size 4 + flags uint32 // offset 32, size 4 + ycbcr_enc uint32 // offset 36, size 4 + quantization uint32 // offset 40, size 4 + xfer_func uint32 // offset 44, size 4 +} + +type v4l2_streamparm struct { // size 204 + typ uint32 // offset 0, size 4 + capture v4l2_captureparm // offset 4, size 40 + _ [160]byte // filler +} + +type v4l2_captureparm struct { // size 40 + capability uint32 // offset 0, size 4 + capturemode uint32 // offset 4, size 4 + timeperframe v4l2_fract // offset 8, size 8 + extendedmode uint32 // offset 16, size 4 + readbuffers uint32 // offset 20, size 4 + reserved [4]uint32 // offset 24, size 16 +} + +type v4l2_fract struct { // size 8 + numerator uint32 // offset 0, size 4 + denominator uint32 // offset 4, size 4 +} + +type v4l2_requestbuffers struct { // size 20 + count uint32 // offset 0, size 4 + typ uint32 // offset 4, size 4 + memory uint32 // offset 8, size 4 + capabilities uint32 // offset 12, size 4 + flags uint8 // offset 16, size 1 + reserved [3]uint8 // offset 17, size 3 +} + +type v4l2_buffer struct { // size 80 + index uint32 // offset 0, size 4 + typ uint32 // offset 4, size 4 + bytesused uint32 // offset 8, size 4 + flags uint32 // offset 12, size 4 + field uint32 // offset 16, size 4 + _ [20]byte // align + timecode v4l2_timecode // offset 40, size 16 + sequence uint32 // offset 56, size 4 + memory uint32 // offset 60, size 4 + offset uint32 // offset 64, size 4 + _ [0]byte // align + length uint32 // offset 68, size 4 + _ [8]byte // filler +} + +type v4l2_timecode struct { // size 16 + typ uint32 // offset 0, size 4 + flags uint32 // offset 4, size 4 + frames uint8 // offset 8, size 1 + seconds uint8 // offset 9, size 1 + minutes uint8 // offset 10, size 1 + hours uint8 // offset 11, size 1 + userbits [4]uint8 // offset 12, size 4 +} + +type v4l2_fmtdesc struct { // size 64 + index uint32 // offset 0, size 4 + typ uint32 // offset 4, size 4 + flags uint32 // offset 8, size 4 + description [32]byte // offset 12, size 32 + pixelformat uint32 // offset 44, size 4 + mbus_code uint32 // offset 48, size 4 + reserved [3]uint32 // offset 52, size 12 +} + +type v4l2_frmsizeenum struct { // size 44 + index uint32 // offset 0, size 4 + pixel_format uint32 // offset 4, size 4 + typ uint32 // offset 8, size 4 + discrete v4l2_frmsize_discrete // offset 12, size 8 + _ [24]byte // filler +} + +type v4l2_frmsize_discrete struct { // size 8 + width uint32 // offset 0, size 4 + height uint32 // offset 4, size 4 +} + +type v4l2_frmivalenum struct { // size 52 + index uint32 // offset 0, size 4 + pixel_format uint32 // offset 4, size 4 + width uint32 // offset 8, size 4 + height uint32 // offset 12, size 4 + typ uint32 // offset 16, size 4 + discrete v4l2_fract // offset 20, size 8 + _ [24]byte // filler +} diff --git a/installs_on_host/go2rtc/pkg/v4l2/device/videodev2_mipsle.go b/installs_on_host/go2rtc/pkg/v4l2/device/videodev2_mipsle.go new file mode 100644 index 0000000..cecc54c --- /dev/null +++ b/installs_on_host/go2rtc/pkg/v4l2/device/videodev2_mipsle.go @@ -0,0 +1,149 @@ +package device + +const ( + VIDIOC_QUERYCAP = 0x40685600 + VIDIOC_ENUM_FMT = 0xc0405602 + VIDIOC_G_FMT = 0xc0cc5604 + VIDIOC_S_FMT = 0xc0cc5605 + VIDIOC_REQBUFS = 0xc0145608 + VIDIOC_QUERYBUF = 0xc0445609 + + VIDIOC_QBUF = 0xc044560f + VIDIOC_DQBUF = 0xc0445611 + VIDIOC_STREAMON = 0x80045612 + VIDIOC_STREAMOFF = 0x80045613 + VIDIOC_G_PARM = 0xc0cc5615 + VIDIOC_S_PARM = 0xc0cc5616 + + VIDIOC_ENUM_FRAMESIZES = 0xc02c564a + VIDIOC_ENUM_FRAMEINTERVALS = 0xc034564b +) + +const ( + V4L2_BUF_TYPE_VIDEO_CAPTURE = 1 + V4L2_COLORSPACE_DEFAULT = 0 + V4L2_FIELD_NONE = 1 + V4L2_FRMIVAL_TYPE_DISCRETE = 1 + V4L2_FRMSIZE_TYPE_DISCRETE = 1 + V4L2_MEMORY_MMAP = 1 +) + +type v4l2_capability struct { // size 104 + driver [16]byte // offset 0, size 16 + card [32]byte // offset 16, size 32 + bus_info [32]byte // offset 48, size 32 + version uint32 // offset 80, size 4 + capabilities uint32 // offset 84, size 4 + device_caps uint32 // offset 88, size 4 + reserved [3]uint32 // offset 92, size 12 +} + +type v4l2_format struct { // size 204 + typ uint32 // offset 0, size 4 + _ [0]byte // align + pix v4l2_pix_format // offset 4, size 48 + _ [152]byte // filler +} + +type v4l2_pix_format struct { // size 48 + width uint32 // offset 0, size 4 + height uint32 // offset 4, size 4 + pixelformat uint32 // offset 8, size 4 + field uint32 // offset 12, size 4 + bytesperline uint32 // offset 16, size 4 + sizeimage uint32 // offset 20, size 4 + colorspace uint32 // offset 24, size 4 + priv uint32 // offset 28, size 4 + flags uint32 // offset 32, size 4 + ycbcr_enc uint32 // offset 36, size 4 + quantization uint32 // offset 40, size 4 + xfer_func uint32 // offset 44, size 4 +} + +type v4l2_streamparm struct { // size 204 + typ uint32 // offset 0, size 4 + capture v4l2_captureparm // offset 4, size 40 + _ [160]byte // filler +} + +type v4l2_captureparm struct { // size 40 + capability uint32 // offset 0, size 4 + capturemode uint32 // offset 4, size 4 + timeperframe v4l2_fract // offset 8, size 8 + extendedmode uint32 // offset 16, size 4 + readbuffers uint32 // offset 20, size 4 + reserved [4]uint32 // offset 24, size 16 +} + +type v4l2_fract struct { // size 8 + numerator uint32 // offset 0, size 4 + denominator uint32 // offset 4, size 4 +} + +type v4l2_requestbuffers struct { // size 20 + count uint32 // offset 0, size 4 + typ uint32 // offset 4, size 4 + memory uint32 // offset 8, size 4 + capabilities uint32 // offset 12, size 4 + flags uint8 // offset 16, size 1 + reserved [3]uint8 // offset 17, size 3 +} + +type v4l2_buffer struct { // size 68 + index uint32 // offset 0, size 4 + typ uint32 // offset 4, size 4 + bytesused uint32 // offset 8, size 4 + flags uint32 // offset 12, size 4 + field uint32 // offset 16, size 4 + _ [8]byte // align + timecode v4l2_timecode // offset 28, size 16 + sequence uint32 // offset 44, size 4 + memory uint32 // offset 48, size 4 + offset uint32 // offset 52, size 4 + _ [0]byte // align + length uint32 // offset 56, size 4 + _ [8]byte // filler +} + +type v4l2_timecode struct { // size 16 + typ uint32 // offset 0, size 4 + flags uint32 // offset 4, size 4 + frames uint8 // offset 8, size 1 + seconds uint8 // offset 9, size 1 + minutes uint8 // offset 10, size 1 + hours uint8 // offset 11, size 1 + userbits [4]uint8 // offset 12, size 4 +} + +type v4l2_fmtdesc struct { // size 64 + index uint32 // offset 0, size 4 + typ uint32 // offset 4, size 4 + flags uint32 // offset 8, size 4 + description [32]byte // offset 12, size 32 + pixelformat uint32 // offset 44, size 4 + mbus_code uint32 // offset 48, size 4 + reserved [3]uint32 // offset 52, size 12 +} + +type v4l2_frmsizeenum struct { // size 44 + index uint32 // offset 0, size 4 + pixel_format uint32 // offset 4, size 4 + typ uint32 // offset 8, size 4 + discrete v4l2_frmsize_discrete // offset 12, size 8 + _ [24]byte // filler +} + +type v4l2_frmsize_discrete struct { // size 8 + width uint32 // offset 0, size 4 + height uint32 // offset 4, size 4 +} + +type v4l2_frmivalenum struct { // size 52 + index uint32 // offset 0, size 4 + pixel_format uint32 // offset 4, size 4 + width uint32 // offset 8, size 4 + height uint32 // offset 12, size 4 + typ uint32 // offset 16, size 4 + discrete v4l2_fract // offset 20, size 8 + _ [24]byte // filler +} diff --git a/installs_on_host/go2rtc/pkg/v4l2/device/videodev2_x64.go b/installs_on_host/go2rtc/pkg/v4l2/device/videodev2_x64.go new file mode 100644 index 0000000..6e1018e --- /dev/null +++ b/installs_on_host/go2rtc/pkg/v4l2/device/videodev2_x64.go @@ -0,0 +1,151 @@ +//go:build amd64 || arm64 + +package device + +const ( + VIDIOC_QUERYCAP = 0x80685600 + VIDIOC_ENUM_FMT = 0xc0405602 + VIDIOC_G_FMT = 0xc0d05604 + VIDIOC_S_FMT = 0xc0d05605 + VIDIOC_REQBUFS = 0xc0145608 + VIDIOC_QUERYBUF = 0xc0585609 + + VIDIOC_QBUF = 0xc058560f + VIDIOC_DQBUF = 0xc0585611 + VIDIOC_STREAMON = 0x40045612 + VIDIOC_STREAMOFF = 0x40045613 + VIDIOC_G_PARM = 0xc0cc5615 + VIDIOC_S_PARM = 0xc0cc5616 + + VIDIOC_ENUM_FRAMESIZES = 0xc02c564a + VIDIOC_ENUM_FRAMEINTERVALS = 0xc034564b +) + +const ( + V4L2_BUF_TYPE_VIDEO_CAPTURE = 1 + V4L2_COLORSPACE_DEFAULT = 0 + V4L2_FIELD_NONE = 1 + V4L2_FRMIVAL_TYPE_DISCRETE = 1 + V4L2_FRMSIZE_TYPE_DISCRETE = 1 + V4L2_MEMORY_MMAP = 1 +) + +type v4l2_capability struct { // size 104 + driver [16]byte // offset 0, size 16 + card [32]byte // offset 16, size 32 + bus_info [32]byte // offset 48, size 32 + version uint32 // offset 80, size 4 + capabilities uint32 // offset 84, size 4 + device_caps uint32 // offset 88, size 4 + reserved [3]uint32 // offset 92, size 12 +} + +type v4l2_format struct { // size 208 + typ uint32 // offset 0, size 4 + _ [4]byte // align + pix v4l2_pix_format // offset 8, size 48 + _ [152]byte // filler +} + +type v4l2_pix_format struct { // size 48 + width uint32 // offset 0, size 4 + height uint32 // offset 4, size 4 + pixelformat uint32 // offset 8, size 4 + field uint32 // offset 12, size 4 + bytesperline uint32 // offset 16, size 4 + sizeimage uint32 // offset 20, size 4 + colorspace uint32 // offset 24, size 4 + priv uint32 // offset 28, size 4 + flags uint32 // offset 32, size 4 + ycbcr_enc uint32 // offset 36, size 4 + quantization uint32 // offset 40, size 4 + xfer_func uint32 // offset 44, size 4 +} + +type v4l2_streamparm struct { // size 204 + typ uint32 // offset 0, size 4 + capture v4l2_captureparm // offset 4, size 40 + _ [160]byte // filler +} + +type v4l2_captureparm struct { // size 40 + capability uint32 // offset 0, size 4 + capturemode uint32 // offset 4, size 4 + timeperframe v4l2_fract // offset 8, size 8 + extendedmode uint32 // offset 16, size 4 + readbuffers uint32 // offset 20, size 4 + reserved [4]uint32 // offset 24, size 16 +} + +type v4l2_fract struct { // size 8 + numerator uint32 // offset 0, size 4 + denominator uint32 // offset 4, size 4 +} + +type v4l2_requestbuffers struct { // size 20 + count uint32 // offset 0, size 4 + typ uint32 // offset 4, size 4 + memory uint32 // offset 8, size 4 + capabilities uint32 // offset 12, size 4 + flags uint8 // offset 16, size 1 + reserved [3]uint8 // offset 17, size 3 +} + +type v4l2_buffer struct { // size 88 + index uint32 // offset 0, size 4 + typ uint32 // offset 4, size 4 + bytesused uint32 // offset 8, size 4 + flags uint32 // offset 12, size 4 + field uint32 // offset 16, size 4 + _ [20]byte // align + timecode v4l2_timecode // offset 40, size 16 + sequence uint32 // offset 56, size 4 + memory uint32 // offset 60, size 4 + offset uint32 // offset 64, size 4 + _ [4]byte // align + length uint32 // offset 72, size 4 + _ [12]byte // filler +} + +type v4l2_timecode struct { // size 16 + typ uint32 // offset 0, size 4 + flags uint32 // offset 4, size 4 + frames uint8 // offset 8, size 1 + seconds uint8 // offset 9, size 1 + minutes uint8 // offset 10, size 1 + hours uint8 // offset 11, size 1 + userbits [4]uint8 // offset 12, size 4 +} + +type v4l2_fmtdesc struct { // size 64 + index uint32 // offset 0, size 4 + typ uint32 // offset 4, size 4 + flags uint32 // offset 8, size 4 + description [32]byte // offset 12, size 32 + pixelformat uint32 // offset 44, size 4 + mbus_code uint32 // offset 48, size 4 + reserved [3]uint32 // offset 52, size 12 +} + +type v4l2_frmsizeenum struct { // size 44 + index uint32 // offset 0, size 4 + pixel_format uint32 // offset 4, size 4 + typ uint32 // offset 8, size 4 + discrete v4l2_frmsize_discrete // offset 12, size 8 + _ [24]byte // filler +} + +type v4l2_frmsize_discrete struct { // size 8 + width uint32 // offset 0, size 4 + height uint32 // offset 4, size 4 +} + +type v4l2_frmivalenum struct { // size 52 + index uint32 // offset 0, size 4 + pixel_format uint32 // offset 4, size 4 + width uint32 // offset 8, size 4 + height uint32 // offset 12, size 4 + typ uint32 // offset 16, size 4 + discrete v4l2_fract // offset 20, size 8 + _ [24]byte // filler +} diff --git a/installs_on_host/go2rtc/pkg/v4l2/producer.go b/installs_on_host/go2rtc/pkg/v4l2/producer.go new file mode 100644 index 0000000..663d0a9 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/v4l2/producer.go @@ -0,0 +1,142 @@ +//go:build linux + +package v4l2 + +import ( + "errors" + "net/url" + "strings" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264/annexb" + "github.com/AlexxIT/go2rtc/pkg/v4l2/device" + "github.com/pion/rtp" +) + +type Producer struct { + core.Connection + dev *device.Device +} + +func Open(rawURL string) (*Producer, error) { + // Example (ffmpeg source compatible): + // v4l2:device?video=/dev/video0&input_format=mjpeg&video_size=1280x720 + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + query := u.Query() + + dev, err := device.Open(query.Get("video")) + if err != nil { + return nil, err + } + + codec := &core.Codec{ + ClockRate: 90000, + PayloadType: core.PayloadTypeRAW, + } + + var width, height, pixFmt uint32 + + if wh := strings.Split(query.Get("video_size"), "x"); len(wh) == 2 { + codec.FmtpLine = "width=" + wh[0] + ";height=" + wh[1] + width = uint32(core.Atoi(wh[0])) + height = uint32(core.Atoi(wh[1])) + } + + switch query.Get("input_format") { + case "yuyv422": + if codec.FmtpLine == "" { + return nil, errors.New("v4l2: invalid video_size") + } + codec.Name = core.CodecRAW + codec.FmtpLine += ";colorspace=422" + pixFmt = device.V4L2_PIX_FMT_YUYV + case "nv12": + if codec.FmtpLine == "" { + return nil, errors.New("v4l2: invalid video_size") + } + codec.Name = core.CodecRAW + codec.FmtpLine += ";colorspace=420mpeg2" // maybe 420jpeg + pixFmt = device.V4L2_PIX_FMT_NV12 + case "mjpeg": + codec.Name = core.CodecJPEG + pixFmt = device.V4L2_PIX_FMT_MJPEG + case "h264": + codec.Name = core.CodecH264 + pixFmt = device.V4L2_PIX_FMT_H264 + case "hevc": + codec.Name = core.CodecH265 + pixFmt = device.V4L2_PIX_FMT_HEVC + default: + return nil, errors.New("v4l2: invalid input_format") + } + + if err = dev.SetFormat(width, height, pixFmt); err != nil { + return nil, err + } + + if fps := core.Atoi(query.Get("framerate")); fps > 0 { + if err = dev.SetParam(uint32(fps)); err != nil { + return nil, err + } + } + + medias := []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{codec}, + }, + } + return &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "v4l2", + Medias: medias, + }, + dev: dev, + }, nil +} + +func (c *Producer) Start() error { + if err := c.dev.StreamOn(); err != nil { + return err + } + + var bitstream bool + switch c.Medias[0].Codecs[0].Name { + case core.CodecH264, core.CodecH265: + bitstream = true + } + + for { + buf, err := c.dev.Capture() + if err != nil { + return err + } + + c.Recv += len(buf) + + if len(c.Receivers) == 0 { + continue + } + + if bitstream { + buf = annexb.EncodeToAVCC(buf) + } + + pkt := &rtp.Packet{ + Header: rtp.Header{Timestamp: core.Now90000()}, + Payload: buf, + } + c.Receivers[0].WriteRTP(pkt) + } +} + +func (c *Producer) Stop() error { + _ = c.Connection.Stop() + return errors.Join(c.dev.StreamOff(), c.dev.Close()) +} diff --git a/installs_on_host/go2rtc/pkg/wav/backchannel.go b/installs_on_host/go2rtc/pkg/wav/backchannel.go new file mode 100644 index 0000000..f9697ee --- /dev/null +++ b/installs_on_host/go2rtc/pkg/wav/backchannel.go @@ -0,0 +1,67 @@ +package wav + +import ( + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/shell" + "github.com/pion/rtp" +) + +type Backchannel struct { + core.Connection + cmd *shell.Command +} + +func NewBackchannel(cmd *shell.Command) (core.Producer, error) { + medias := []*core.Media{ + { + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + //{Name: core.CodecPCML}, + {Name: core.CodecPCMA}, + {Name: core.CodecPCMU}, + }, + }, + } + + return &Backchannel{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "wav", + Protocol: "pipe", + Medias: medias, + Transport: cmd, + }, + cmd: cmd, + }, nil +} + +func (c *Backchannel) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + return nil, core.ErrCantGetTrack +} + +func (c *Backchannel) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + wr, err := c.cmd.StdinPipe() + if err != nil { + return err + } + + b := Header(track.Codec) + if _, err = wr.Write(b); err != nil { + return err + } + + sender := core.NewSender(media, track.Codec) + sender.Handler = func(packet *rtp.Packet) { + if n, err := wr.Write(packet.Payload); err != nil { + c.Send += n + } + } + sender.HandleRTP(track) + c.Senders = append(c.Senders, sender) + return nil +} + +func (c *Backchannel) Start() error { + return c.cmd.Run() +} diff --git a/installs_on_host/go2rtc/pkg/wav/producer.go b/installs_on_host/go2rtc/pkg/wav/producer.go new file mode 100644 index 0000000..60bdeaa --- /dev/null +++ b/installs_on_host/go2rtc/pkg/wav/producer.go @@ -0,0 +1,83 @@ +package wav + +import ( + "bufio" + "errors" + "io" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +const FourCC = "RIFF" + +func Open(r io.Reader) (*Producer, error) { + // https://en.wikipedia.org/wiki/WAV + // https://www.mmsp.ece.mcgill.ca/Documents/AudioFormats/WAVE/WAVE.html + rd := bufio.NewReaderSize(r, core.BufferSize) + + codec, err := ReadHeader(r) + if err != nil { + return nil, err + } + + if codec.Name == "" { + return nil, errors.New("waw: unsupported codec") + } + + medias := []*core.Media{ + { + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{codec}, + }, + } + return &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "wav", + Medias: medias, + Transport: r, + }, + rd: rd, + }, nil +} + +type Producer struct { + core.Connection + rd *bufio.Reader +} + +func (c *Producer) Start() error { + var seq uint16 + var ts uint32 + + const PacketSize = 0.040 * 8000 // 40ms + + for { + payload := make([]byte, PacketSize) + if _, err := io.ReadFull(c.rd, payload); err != nil { + return err + } + + c.Recv += PacketSize + + if len(c.Receivers) == 0 { + continue + } + + pkt := &rtp.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: true, + SequenceNumber: seq, + Timestamp: ts, + }, + Payload: payload, + } + c.Receivers[0].WriteRTP(pkt) + + seq++ + ts += PacketSize + } +} diff --git a/installs_on_host/go2rtc/pkg/wav/wav.go b/installs_on_host/go2rtc/pkg/wav/wav.go new file mode 100644 index 0000000..9fe857d --- /dev/null +++ b/installs_on_host/go2rtc/pkg/wav/wav.go @@ -0,0 +1,103 @@ +package wav + +import ( + "encoding/binary" + "io" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +func Header(codec *core.Codec) []byte { + var fmt, size, extra byte + + switch codec.Name { + case core.CodecPCML: + fmt = 1 + size = 2 + case core.CodecPCMA: + fmt = 6 + size = 1 + extra = 2 + case core.CodecPCMU: + fmt = 7 + size = 1 + extra = 2 + default: + return nil + } + + channels := byte(codec.Channels) + if channels == 0 { + channels = 1 + } + + b := make([]byte, 0, 46) // cap with extra + b = append(b, "RIFF\xFF\xFF\xFF\xFFWAVEfmt "...) + + b = append(b, 0x10+extra, 0, 0, 0) + b = append(b, fmt, 0) + b = append(b, channels, 0) + b = binary.LittleEndian.AppendUint32(b, codec.ClockRate) + b = binary.LittleEndian.AppendUint32(b, uint32(size*channels)*codec.ClockRate) + b = append(b, size*channels, 0) + b = append(b, size*8, 0) + if extra > 0 { + b = append(b, 0, 0) // ExtraParamSize (if PCM, then doesn't exist) + } + + b = append(b, "data\xFF\xFF\xFF\xFF"...) + + return b +} + +func ReadHeader(r io.Reader) (*core.Codec, error) { + // skip Master RIFF chunk + if _, err := io.ReadFull(r, make([]byte, 12)); err != nil { + return nil, err + } + + var codec core.Codec + + for { + chunkID, data, err := readChunk(r) + if err != nil { + return nil, err + } + + if chunkID == "data" { + break + } + + if chunkID == "fmt " { + // https://audiocoding.cc/articles/2008-05-22-wav-file-structure/wav_formats.txt + switch data[0] { + case 1: + codec.Name = core.CodecPCML + case 6: + codec.Name = core.CodecPCMA + case 7: + codec.Name = core.CodecPCMU + } + + codec.Channels = data[2] + codec.ClockRate = binary.LittleEndian.Uint32(data[4:]) + } + } + + return &codec, nil +} + +func readChunk(r io.Reader) (chunkID string, data []byte, err error) { + b := make([]byte, 8) + if _, err = io.ReadFull(r, b); err != nil { + return + } + + if chunkID = string(b[:4]); chunkID != "data" { + size := binary.LittleEndian.Uint32(b[4:]) + data = make([]byte, size) + _, err = io.ReadFull(r, data) + } + + return +} diff --git a/installs_on_host/go2rtc/pkg/webrtc/README.md b/installs_on_host/go2rtc/pkg/webrtc/README.md new file mode 100644 index 0000000..24282e0 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/webrtc/README.md @@ -0,0 +1,11 @@ +## StateChange + +1. offer = pc.CreateOffer() +2. pc.SetLocalDescription(offer) +3. OnICEGatheringStateChange: gathering +4. OnSignalingStateChange: have-local-offer +*. OnICEGatheringStateChange: complete +5. pc.SetRemoteDescription(answer) +6. OnSignalingStateChange: stable +7. OnICEConnectionStateChange: checking +8. OnICEConnectionStateChange: connected diff --git a/installs_on_host/go2rtc/pkg/webrtc/api.go b/installs_on_host/go2rtc/pkg/webrtc/api.go new file mode 100644 index 0000000..5551d65 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/webrtc/api.go @@ -0,0 +1,316 @@ +package webrtc + +import ( + "fmt" + "net" + "slices" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/xnet" + "github.com/pion/ice/v4" + "github.com/pion/interceptor" + "github.com/pion/webrtc/v4" +) + +// ReceiveMTU = Ethernet MTU (1500) - IP Header (20) - UDP Header (8) +// https://ffmpeg.org/ffmpeg-all.html#Muxer +const ReceiveMTU = 1472 + +func NewAPI() (*webrtc.API, error) { + return NewServerAPI("", "", nil) +} + +type Filters struct { + Candidates []string `yaml:"candidates"` + Loopback bool `yaml:"loopback"` + Interfaces []string `yaml:"interfaces"` + IPs []string `yaml:"ips"` + Networks []string `yaml:"networks"` + UDPPorts []uint16 `yaml:"udp_ports"` +} + +func (f *Filters) Network(protocol string) string { + if f == nil || f.Networks == nil { + return protocol + } + v4 := slices.Contains(f.Networks, protocol+"4") + v6 := slices.Contains(f.Networks, protocol+"6") + if v4 && v6 { + return protocol + } else if v4 { + return protocol + "4" + } else if v6 { + return protocol + "6" + } + return "" +} + +func (f *Filters) NetIPs() (ips []net.IP) { + itfs, _ := net.Interfaces() + for _, itf := range itfs { + if itf.Flags&net.FlagUp == 0 { + continue + } + if !f.IncludeLoopback() && itf.Flags&net.FlagLoopback != 0 { + continue + } + if !f.InterfaceFilter(itf.Name) { + continue + } + + addrs, _ := itf.Addrs() + for _, addr := range addrs { + ip := parseNetAddr(addr) + if ip == nil || !f.IPFilter(ip) { + continue + } + ips = append(ips, ip) + } + } + return +} + +func parseNetAddr(addr net.Addr) net.IP { + switch addr := addr.(type) { + case *net.IPNet: + return addr.IP + case *net.IPAddr: + return addr.IP + } + return nil +} + +func (f *Filters) IncludeLoopback() bool { + return f != nil && f.Loopback +} + +func (f *Filters) InterfaceFilter(name string) bool { + return f == nil || f.Interfaces == nil || slices.Contains(f.Interfaces, name) +} + +func (f *Filters) IPFilter(ip net.IP) bool { + return f == nil || f.IPs == nil || core.Contains(f.IPs, ip.String()) +} + +func NewServerAPI(network, address string, filters *Filters) (*webrtc.API, error) { + // for debug logs add to env: `PION_LOG_DEBUG=all` + m := &webrtc.MediaEngine{} + //if err := m.RegisterDefaultCodecs(); err != nil { + // return nil, err + //} + if err := RegisterDefaultCodecs(m); err != nil { + return nil, err + } + + i := &interceptor.Registry{} + if err := webrtc.RegisterDefaultInterceptors(m, i); err != nil { + return nil, err + } + + s := webrtc.SettingEngine{} + + // fix https://github.com/pion/webrtc/pull/2407 + s.SetDTLSInsecureSkipHelloVerify(true) + + if filters != nil && filters.Loopback { + s.SetIncludeLoopbackCandidate(true) + } + + var interfaceFilter func(name string) bool + if filters != nil && filters.Interfaces != nil { + interfaceFilter = func(name string) bool { + return core.Contains(filters.Interfaces, name) + } + } else { + // default interfaces - all, except loopback + } + s.SetInterfaceFilter(interfaceFilter) + + var ipFilter func(ip net.IP) bool + if filters != nil && filters.IPs != nil { + ipFilter = func(ip net.IP) bool { + return core.Contains(filters.IPs, ip.String()) + } + } else { + // try filter all Docker-like interfaces + ipFilter = func(ip net.IP) bool { + return !xnet.Docker.Contains(ip) + } + // if there are no such interfaces - disable the filter + // the user will need to enable port forwarding + if nets, _ := xnet.IPNets(ipFilter); len(nets) == 0 { + ipFilter = nil + } + } + s.SetIPFilter(ipFilter) + + var networkTypes []webrtc.NetworkType + if filters != nil && filters.Networks != nil { + for _, s := range filters.Networks { + if networkType, err := webrtc.NewNetworkType(s); err == nil { + networkTypes = append(networkTypes, networkType) + } + } + } else { + // default network types - all + networkTypes = []webrtc.NetworkType{ + webrtc.NetworkTypeUDP4, webrtc.NetworkTypeUDP6, + webrtc.NetworkTypeTCP4, webrtc.NetworkTypeTCP6, + } + } + s.SetNetworkTypes(networkTypes) + + if filters != nil && len(filters.UDPPorts) == 2 { + _ = s.SetEphemeralUDPPortRange(filters.UDPPorts[0], filters.UDPPorts[1]) + } + + // If you don't specify an address, this won't cause an error. + // Connections can still be established using random UDP addresses. + if address != "" { + // Both newMux functions respect filters and do not raise an error + // if the port cannot be listened on. + if network == "" || network == "tcp" { + tcpMux := newTCPMux(address, filters) + s.SetICETCPMux(tcpMux) + } + if network == "" || network == "udp" { + udpMux := newUDPMux(address, filters) + s.SetICEUDPMux(udpMux) + } + } + + return webrtc.NewAPI( + webrtc.WithMediaEngine(m), + webrtc.WithInterceptorRegistry(i), + webrtc.WithSettingEngine(s), + ), nil +} + +// OnNewListener temporary ugly solution for log +var OnNewListener = func(ln any) {} + +func newTCPMux(address string, filters *Filters) ice.TCPMux { + networkTCP := filters.Network("tcp") // tcp or tcp4 or tcp6 + if ln, _ := net.Listen(networkTCP, address); ln != nil { + OnNewListener(ln) + return webrtc.NewICETCPMux(nil, ln, 8) + } + return nil +} + +func newUDPMux(address string, filters *Filters) ice.UDPMux { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil + } + + // UDPMux should not listening on unspecified address. + // So we will create a listener on all available interfaces. + // We can't use ice.NewMultiUDPMuxFromPort, because it sometimes crashes with an error: + // listen udp [***]:8555: bind: cannot assign requested address + var addrs []string + if host == "" { + for _, ip := range filters.NetIPs() { + addrs = append(addrs, fmt.Sprintf("%s:%s", ip, port)) + } + } else { + addrs = []string{address} + } + + networkUDP := filters.Network("udp") // udp or udp4 or udp6 + + var muxes []ice.UDPMux + for _, addr := range addrs { + if ln, _ := net.ListenPacket(networkUDP, addr); ln != nil { + OnNewListener(ln) + mux := ice.NewUDPMuxDefault(ice.UDPMuxParams{UDPConn: ln}) + muxes = append(muxes, mux) + } + } + + switch len(muxes) { + case 0: + return nil + case 1: + return muxes[0] + } + return ice.NewMultiUDPMuxDefault(muxes...) +} + +func RegisterDefaultCodecs(m *webrtc.MediaEngine) error { + for _, codec := range []webrtc.RTPCodecParameters{ + { + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeOpus, ClockRate: 48000, Channels: 2, SDPFmtpLine: "minptime=10;useinbandfec=1", + }, + PayloadType: 101, //111, + }, + { + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypePCMU, ClockRate: 8000, + }, + PayloadType: 0, + }, + { + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypePCMA, ClockRate: 8000, + }, + PayloadType: 8, + }, + } { + if err := m.RegisterCodec(codec, webrtc.RTPCodecTypeAudio); err != nil { + return err + } + } + + videoRTCPFeedback := []webrtc.RTCPFeedback{ + {"goog-remb", ""}, + {"ccm", "fir"}, + {"nack", ""}, + {"nack", "pli"}, + } + for _, codec := range []webrtc.RTPCodecParameters{ + { + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeH264, + ClockRate: 90000, + SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f", + RTCPFeedback: videoRTCPFeedback, + }, + PayloadType: 96, // Chrome v110 - PayloadType: 102 + }, + { + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeH264, + ClockRate: 90000, + SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f", + RTCPFeedback: videoRTCPFeedback, + }, + PayloadType: 97, // Chrome v110 - PayloadType: 106 + }, + { + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeH264, + ClockRate: 90000, + SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640032", + RTCPFeedback: videoRTCPFeedback, + }, + PayloadType: 98, // Chrome v110 - PayloadType: 112 + }, + // macOS Safari 15.1 + { + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeH265, + ClockRate: 90000, + RTCPFeedback: videoRTCPFeedback, + }, + PayloadType: 100, + }, + } { + if err := m.RegisterCodec(codec, webrtc.RTPCodecTypeVideo); err != nil { + return err + } + } + + return nil +} diff --git a/installs_on_host/go2rtc/pkg/webrtc/client.go b/installs_on_host/go2rtc/pkg/webrtc/client.go new file mode 100644 index 0000000..bc2c4f8 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/webrtc/client.go @@ -0,0 +1,145 @@ +package webrtc + +import ( + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/sdp/v3" + "github.com/pion/webrtc/v4" +) + +func (c *Conn) CreateOffer(medias []*core.Media) (string, error) { + // 1. Create transeivers with proper kind and direction + for _, media := range medias { + var err error + switch media.Direction { + case core.DirectionRecvonly: + _, err = c.pc.AddTransceiverFromKind( + webrtc.NewRTPCodecType(media.Kind), + webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly}, + ) + case core.DirectionSendonly: + _, err = c.pc.AddTransceiverFromTrack( + NewTrack(media.Kind), + webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionSendonly}, + ) + case core.DirectionSendRecv: + // default transceiver is sendrecv + _, err = c.pc.AddTransceiverFromTrack(NewTrack(media.Kind)) + default: + // Nest cameras require data channel + _, err = c.pc.CreateDataChannel(media.Kind, nil) + } + + if err != nil { + return "", err + } + } + + // 2. Create local offer + desc, err := c.pc.CreateOffer(nil) + if err != nil { + return "", err + } + + // 3. Start gathering phase + if err = c.pc.SetLocalDescription(desc); err != nil { + return "", err + } + + return c.pc.LocalDescription().SDP, nil +} + +func (c *Conn) CreateCompleteOffer(medias []*core.Media) (string, error) { + if _, err := c.CreateOffer(medias); err != nil { + return "", err + } + + <-webrtc.GatheringCompletePromise(c.pc) + return c.pc.LocalDescription().SDP, nil +} + +func (c *Conn) SetAnswer(answer string) (err error) { + desc := webrtc.SessionDescription{ + Type: webrtc.SDPTypeAnswer, + SDP: fakeFormatsInAnswer(c.pc.LocalDescription().SDP, answer), + } + if err = c.pc.SetRemoteDescription(desc); err != nil { + return err + } + + sd := &sdp.SessionDescription{} + if err = sd.Unmarshal([]byte(answer)); err != nil { + return err + } + + c.Medias = UnmarshalMedias(sd.MediaDescriptions) + + return nil +} + +// fakeFormatsInAnswer - fix pion bug with remote SDP parsing: +// pion will process formats only from first media of each kind +// so we add all formats from first offer media to the first answer media +func fakeFormatsInAnswer(offer, answer string) string { + sd2 := &sdp.SessionDescription{} + if err := sd2.Unmarshal([]byte(answer)); err != nil { + return answer + } + + // check if answer has recvonly audio + var ok bool + for _, md2 := range sd2.MediaDescriptions { + if md2.MediaName.Media == "audio" { + if _, ok = md2.Attribute("recvonly"); ok { + break + } + } + } + if !ok { + return answer + } + + sd1 := &sdp.SessionDescription{} + if err := sd1.Unmarshal([]byte(offer)); err != nil { + return answer + } + + var formats []string + var attrs []sdp.Attribute + + for _, md1 := range sd1.MediaDescriptions { + if md1.MediaName.Media == "audio" { + for _, attr := range md1.Attributes { + switch attr.Key { + case "rtpmap", "fmtp", "rtcp-fb", "extmap": + attrs = append(attrs, attr) + } + } + + formats = md1.MediaName.Formats + break + } + } + + for _, md2 := range sd2.MediaDescriptions { + if md2.MediaName.Media == "audio" { + for _, attr := range md2.Attributes { + switch attr.Key { + case "rtpmap", "fmtp", "rtcp-fb", "extmap": + default: + attrs = append(attrs, attr) + } + } + + md2.MediaName.Formats = formats + md2.Attributes = attrs + break + } + } + + b, err := sd2.Marshal() + if err != nil { + return answer + } + + return string(b) +} diff --git a/installs_on_host/go2rtc/pkg/webrtc/client_test.go b/installs_on_host/go2rtc/pkg/webrtc/client_test.go new file mode 100644 index 0000000..5d195a2 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/webrtc/client_test.go @@ -0,0 +1,118 @@ +package webrtc + +import ( + "testing" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/webrtc/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClient(t *testing.T) { + api, err := NewAPI() + require.Nil(t, err) + + pc, err := api.NewPeerConnection(webrtc.Configuration{}) + require.Nil(t, err) + + prod := NewConn(pc) + + medias := []*core.Media{ + {Kind: core.KindVideo, Direction: core.DirectionRecvonly}, + {Kind: core.KindAudio, Direction: core.DirectionRecvonly}, + {Kind: core.KindAudio, Direction: core.DirectionSendonly}, + } + + offer, err := prod.CreateOffer(medias) + require.Nil(t, err) + assert.NotEmpty(t, offer) + + require.Len(t, prod.pc.GetReceivers(), 2) + require.Len(t, prod.pc.GetSenders(), 1) + + answer := `v=0 +o=- 1934370540648269799 1678277622 IN IP4 0.0.0.0 +s=- +t=0 0 +a=fingerprint:sha-256 77:8C:9A:62:51:81:69:EA:4E:BE:93:6B:4E:DF:51:D2:2F:E3:DF:E7:F4:8A:18:1A:C0:74:FA:AE:B8:98:29:9B +a=extmap-allow-mixed +a=group:BUNDLE 0 1 2 +m=video 9 UDP/TLS/RTP/SAVPF 97 +c=IN IP4 0.0.0.0 +a=setup:active +a=mid:0 +a=ice-ufrag:xxx +a=ice-pwd:xxx +a=rtcp-mux +a=rtcp-rsize +a=rtpmap:97 H264/90000 +a=fmtp:97 packetization-mode=1;profile-level-id=42e01f +a=extmap:1 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01 +a=ssrc:2815449682 cname:go2rtc +a=ssrc:2815449682 msid:go2rtc video +a=ssrc:2815449682 mslabel:go2rtc +a=ssrc:2815449682 label:video +a=msid:go2rtc video +a=sendonly +m=audio 9 UDP/TLS/RTP/SAVPF 8 +c=IN IP4 0.0.0.0 +a=setup:active +a=mid:1 +a=ice-ufrag:xxx +a=ice-pwd:xxx +a=rtcp-mux +a=rtcp-rsize +a=rtpmap:8 PCMA/8000 +a=extmap:1 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01 +a=ssrc:1392166302 cname:go2rtc +a=ssrc:1392166302 msid:go2rtc audio +a=ssrc:1392166302 mslabel:go2rtc +a=ssrc:1392166302 label:audio +a=msid:go2rtc audio +a=sendonly +m=audio 9 UDP/TLS/RTP/SAVPF 0 +c=IN IP4 0.0.0.0 +a=setup:active +a=mid:2 +a=ice-ufrag:xxx +a=ice-pwd:xxx +a=rtcp-mux +a=rtcp-rsize +a=rtpmap:0 PCMU/8000 +a=extmap:1 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01 +a=recvonly +` + + err = prod.SetAnswer(answer) + require.Nil(t, err) + + sender := prod.pc.GetSenders()[0] + + caps := webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypePCMU, + ClockRate: 8000, + Channels: 0, + } + track := sender.Track() + track, err = webrtc.NewTrackLocalStaticRTP(caps, track.ID(), track.StreamID()) + require.Nil(t, err) + + err = sender.ReplaceTrack(track) + require.Nil(t, err) +} + +func TestUnmarshalICEServers(t *testing.T) { + s := `[{"credential":"xxx","urls":"xxx","username":"xxx"},{"credential":null,"urls":"xxx","username":null}]` + servers, err := UnmarshalICEServers([]byte(s)) + require.Nil(t, err) + require.Len(t, servers, 2) + require.Equal(t, []string{"xxx"}, servers[0].URLs) + + s = `[{"urls":"xxx"},{"urls":["yyy","zzz"]}]` + servers, err = UnmarshalICEServers([]byte(s)) + require.Nil(t, err) + require.Len(t, servers, 2) + require.Equal(t, []string{"xxx"}, servers[0].URLs) + require.Equal(t, []string{"yyy", "zzz"}, servers[1].URLs) +} diff --git a/installs_on_host/go2rtc/pkg/webrtc/conn.go b/installs_on_host/go2rtc/pkg/webrtc/conn.go new file mode 100644 index 0000000..924fd55 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/webrtc/conn.go @@ -0,0 +1,220 @@ +package webrtc + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtcp" + "github.com/pion/rtp" + "github.com/pion/webrtc/v4" +) + +type Conn struct { + core.Connection + core.Listener + + Mode core.Mode `json:"mode"` + + pc *webrtc.PeerConnection + + offer string + closed core.Waiter +} + +func NewConn(pc *webrtc.PeerConnection) *Conn { + c := &Conn{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "webrtc", + Transport: pc, + }, + pc: pc, + } + + pc.OnICECandidate(func(candidate *webrtc.ICECandidate) { + // last candidate will be empty + if candidate != nil { + c.Fire(candidate) + } + }) + + pc.OnDataChannel(func(channel *webrtc.DataChannel) { + c.Fire(channel) + }) + + pc.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) { + if state != webrtc.ICEConnectionStateChecking { + return + } + pc.SCTP().Transport().ICETransport().OnSelectedCandidatePairChange( + func(pair *webrtc.ICECandidatePair) { + // fix situation when candidate pair changes multiple times + if i := strings.IndexByte(c.Protocol, '+'); i > 0 { + c.Protocol = c.Protocol[:i] + } + c.Protocol += "+" + pair.Remote.Protocol.String() + c.RemoteAddr = fmt.Sprintf( + "%s:%d %s", sanitizeIP6(pair.Remote.Address), pair.Remote.Port, pair.Remote.Typ, + ) + if pair.Remote.RelatedAddress != "" { + c.RemoteAddr += fmt.Sprintf( + " %s:%d", sanitizeIP6(pair.Remote.RelatedAddress), pair.Remote.RelatedPort, + ) + } + }, + ) + }) + + pc.OnTrack(func(remote *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) { + media, codec := c.getMediaCodec(remote) + if media == nil { + return + } + + track, err := c.GetTrack(media, codec) + if err != nil { + return + } + + switch c.Mode { + case core.ModePassiveProducer, core.ModeActiveProducer: + // replace the theoretical list of codecs with the actual list of codecs + if len(media.Codecs) > 1 { + media.Codecs = []*core.Codec{codec} + } + } + + if c.Mode == core.ModePassiveProducer && remote.Kind() == webrtc.RTPCodecTypeVideo { + go func() { + pkts := []rtcp.Packet{&rtcp.PictureLossIndication{MediaSSRC: uint32(remote.SSRC())}} + for range time.NewTicker(time.Second * 2).C { + if err := pc.WriteRTCP(pkts); err != nil { + return + } + } + }() + } + + for { + b := make([]byte, ReceiveMTU) + n, _, err := remote.Read(b) + if err != nil { + return + } + + c.Recv += n + + packet := &rtp.Packet{} + if err := packet.Unmarshal(b[:n]); err != nil { + return + } + + if len(packet.Payload) == 0 { + continue + } + + track.WriteRTP(packet) + } + }) + + // OK connection: + // 15:01:46 ICE connection state changed: checking + // 15:01:46 peer connection state changed: connected + // 15:01:54 peer connection state changed: disconnected + // 15:02:20 peer connection state changed: failed + // + // Fail connection: + // 14:53:08 ICE connection state changed: checking + // 14:53:39 peer connection state changed: failed + pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) { + c.Fire(state) + + switch state { + case webrtc.PeerConnectionStateConnected: + for _, sender := range c.Senders { + sender.Start() + } + case webrtc.PeerConnectionStateDisconnected, webrtc.PeerConnectionStateFailed, webrtc.PeerConnectionStateClosed: + // disconnect event comes earlier, than failed + // but it comes only for success connections + _ = c.Close() + } + }) + + return c +} + +func (c *Conn) MarshalJSON() ([]byte, error) { + return json.Marshal(c.Connection) +} + +func (c *Conn) Close() error { + c.closed.Done(nil) + return c.pc.Close() +} + +func (c *Conn) AddCandidate(candidate string) error { + // pion uses only candidate value from json/object candidate struct + return c.pc.AddICECandidate(webrtc.ICECandidateInit{Candidate: candidate}) +} + +func (c *Conn) GetSenderTrack(mid string) *Track { + if tr := c.getTranseiver(mid); tr != nil { + if s := tr.Sender(); s != nil { + if t := s.Track().(*Track); t != nil { + return t + } + } + } + return nil +} + +func (c *Conn) getTranseiver(mid string) *webrtc.RTPTransceiver { + for _, tr := range c.pc.GetTransceivers() { + if tr.Mid() == mid { + return tr + } + } + return nil +} + +func (c *Conn) getMediaCodec(remote *webrtc.TrackRemote) (*core.Media, *core.Codec) { + for _, tr := range c.pc.GetTransceivers() { + // search Transeiver for this TrackRemote + if tr.Receiver() == nil || tr.Receiver().Track() != remote { + continue + } + + // search Media for this MID + for _, media := range c.Medias { + if media.ID != tr.Mid() || media.Direction != core.DirectionRecvonly { + continue + } + + // search codec for this PayloadType + for _, codec := range media.Codecs { + if codec.PayloadType != uint8(remote.PayloadType()) { + continue + } + return media, codec + } + } + } + + // fix moment when core.ModePassiveProducer or core.ModeActiveProducer + // sends new codec with new payload type to same media + // check GetTrack + panic(core.Caller()) + + return nil, nil +} + +func sanitizeIP6(host string) string { + if strings.IndexByte(host, ':') > 0 { + return "[" + host + "]" + } + return host +} diff --git a/installs_on_host/go2rtc/pkg/webrtc/consumer.go b/installs_on_host/go2rtc/pkg/webrtc/consumer.go new file mode 100644 index 0000000..767394d --- /dev/null +++ b/installs_on_host/go2rtc/pkg/webrtc/consumer.go @@ -0,0 +1,90 @@ +package webrtc + +import ( + "errors" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/h265" + "github.com/AlexxIT/go2rtc/pkg/pcm" + "github.com/pion/rtp" +) + +func (c *Conn) GetMedias() []*core.Media { + return WithResampling(c.Medias) +} + +func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + core.Assert(media.Direction == core.DirectionSendonly) + + for _, sender := range c.Senders { + if sender.Codec == codec { + sender.Bind(track) + return nil + } + } + + switch c.Mode { + case core.ModePassiveConsumer: // video/audio for browser + case core.ModeActiveProducer: // go2rtc as WebRTC client (backchannel) + case core.ModePassiveProducer: // WebRTC/WHIP + default: + panic(core.Caller()) + } + + localTrack := c.GetSenderTrack(media.ID) + if localTrack == nil { + return errors.New("webrtc: can't get track") + } + + payloadType := codec.PayloadType + + sender := core.NewSender(media, codec) + sender.Handler = func(packet *rtp.Packet) { + c.Send += packet.MarshalSize() + //important to send with remote PayloadType + _ = localTrack.WriteRTP(payloadType, packet) + } + + switch track.Codec.Name { + case core.CodecH264: + sender.Handler = h264.RTPPay(1200, sender.Handler) + if track.Codec.IsRTP() { + sender.Handler = h264.RTPDepay(track.Codec, sender.Handler) + } else { + sender.Handler = h264.RepairAVCC(track.Codec, sender.Handler) + } + + case core.CodecH265: + sender.Handler = h265.RTPPay(1200, sender.Handler) + if track.Codec.IsRTP() { + sender.Handler = h265.RTPDepay(track.Codec, sender.Handler) + } else { + sender.Handler = h265.RepairAVCC(track.Codec, sender.Handler) + } + + case core.CodecPCMA, core.CodecPCMU, core.CodecPCM, core.CodecPCML: + // Fix audio quality https://github.com/AlexxIT/WebRTC/issues/500 + // should be before ResampleToG711, because it will be called last + sender.Handler = pcm.RepackG711(false, sender.Handler) + + if codec.ClockRate == 0 { + if codec.Name == core.CodecPCM || codec.Name == core.CodecPCML { + codec.Name = core.CodecPCMA + } + codec.ClockRate = 8000 + sender.Handler = pcm.TranscodeHandler(codec, track.Codec, sender.Handler) + } + } + + // TODO: rewrite this dirty logic + // maybe not best solution, but ActiveProducer connected before AddTrack + if c.Mode != core.ModeActiveProducer { + sender.Bind(track) + } else { + sender.HandleRTP(track) + } + + c.Senders = append(c.Senders, sender) + return nil +} diff --git a/installs_on_host/go2rtc/pkg/webrtc/helpers.go b/installs_on_host/go2rtc/pkg/webrtc/helpers.go new file mode 100644 index 0000000..167697f --- /dev/null +++ b/installs_on_host/go2rtc/pkg/webrtc/helpers.go @@ -0,0 +1,348 @@ +package webrtc + +import ( + "encoding/json" + "errors" + "fmt" + "hash/crc32" + "net" + "strconv" + "strings" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/ice/v4" + "github.com/pion/sdp/v3" + "github.com/pion/stun/v3" + "github.com/pion/webrtc/v4" +) + +func UnmarshalMedias(descriptions []*sdp.MediaDescription) (medias []*core.Media) { + // 1. Sort medias, so video will always be before audio + // 2. Ignore application media from Hass default lovelace card + // 3. Ignore media without direction (inactive media) + // 4. Inverse media direction (because it is remote peer medias list) + for _, kind := range []string{core.KindVideo, core.KindAudio} { + for _, md := range descriptions { + if md.MediaName.Media != kind { + continue + } + + media := core.UnmarshalMedia(md) + switch media.Direction { + case core.DirectionSendRecv: + media.Direction = core.DirectionRecvonly + medias = append(medias, media) + + media = media.Clone() + media.Direction = core.DirectionSendonly + + case core.DirectionRecvonly: + media.Direction = core.DirectionSendonly + + case core.DirectionSendonly: + media.Direction = core.DirectionRecvonly + + case "": + continue + } + + // skip non-media codecs to avoid confusing users in info and logs + media.Codecs = SkipNonMediaCodecs(media.Codecs) + + medias = append(medias, media) + } + } + + return +} + +func SkipNonMediaCodecs(input []*core.Codec) (output []*core.Codec) { + for _, codec := range input { + switch codec.Name { + case "RTX", "RED", "ULPFEC", "FLEXFEC-03": + continue + case "CN", "TELEPHONE-EVENT": + continue // https://datatracker.ietf.org/doc/html/rfc7874 + } + // VP8, VP9, H264, H265, AV1 + // OPUS, G722, PCMU, PCMA + output = append(output, codec) + } + return +} + +// WithResampling - will add for consumer: PCMA/0, PCMU/0, PCM/0, PCML/0 +// so it can add resampling for PCMA/PCMU and repack for PCM/PCML +func WithResampling(medias []*core.Media) []*core.Media { + for _, media := range medias { + if media.Kind != core.KindAudio || media.Direction != core.DirectionSendonly { + continue + } + + var pcma, pcmu, pcm, pcml *core.Codec + + for _, codec := range media.Codecs { + switch codec.Name { + case core.CodecPCMA: + if codec.ClockRate != 0 { + pcma = codec + } else { + pcma = nil + } + case core.CodecPCMU: + if codec.ClockRate != 0 { + pcmu = codec + } else { + pcmu = nil + } + case core.CodecPCM: + pcm = codec + case core.CodecPCML: + pcml = codec + } + } + + if pcma != nil { + pcma = pcma.Clone() + pcma.ClockRate = 0 // reset clock rate so will match any + media.Codecs = append(media.Codecs, pcma) + } + if pcmu != nil { + pcmu = pcmu.Clone() + pcmu.ClockRate = 0 + media.Codecs = append(media.Codecs, pcmu) + } + if pcma != nil && pcm == nil { + pcm = pcma.Clone() + pcm.Name = core.CodecPCM + media.Codecs = append(media.Codecs, pcm) + } + if pcma != nil && pcml == nil { + pcml = pcma.Clone() + pcml.Name = core.CodecPCML + media.Codecs = append(media.Codecs, pcml) + } + } + + return medias +} + +func NewCandidate(network, address string) (string, error) { + i := strings.LastIndexByte(address, ':') + if i < 0 { + return "", errors.New("wrong candidate: " + address) + } + host, port := address[:i], address[i+1:] + + i, err := strconv.Atoi(port) + if err != nil { + return "", err + } + + config := &ice.CandidateHostConfig{ + Network: network, + Address: host, + Port: i, + Component: ice.ComponentRTP, + } + + if network == "tcp" { + config.TCPType = ice.TCPTypePassive + } + + cand, err := ice.NewCandidateHost(config) + if err != nil { + return "", err + } + + return "candidate:" + cand.Marshal(), nil +} + +func LookupIP(address string) (string, error) { + if strings.HasPrefix(address, "stun:") { + ip, err := GetCachedPublicIP() + if err != nil { + return "", err + } + return ip.String() + address[4:], nil + } + + if IsIP(address) { + return address, nil + } + + i := strings.IndexByte(address, ':') + ips, err := net.LookupIP(address[:i]) + if err != nil { + return "", err + } + if len(ips) == 0 { + return "", fmt.Errorf("can't resolve: %s", address) + } + + return ips[0].String() + address[i:], nil +} + +// GetPublicIP example from https://github.com/pion/stun +func GetPublicIP(address string) (net.IP, error) { + conn, err := net.Dial("udp", address) + if err != nil { + return nil, err + } + + c, err := stun.NewClient(conn) + if err != nil { + return nil, err + } + + if err = conn.SetDeadline(time.Now().Add(time.Second * 3)); err != nil { + return nil, err + } + + var res stun.Event + + message := stun.MustBuild(stun.TransactionID, stun.BindingRequest) + if err = c.Do(message, func(e stun.Event) { res = e }); err != nil { + return nil, err + } + if err = c.Close(); err != nil { + return nil, err + } + + if res.Error != nil { + return nil, res.Error + } + + var xorAddr stun.XORMappedAddress + if err = xorAddr.GetFrom(res.Message); err != nil { + return nil, err + } + + return xorAddr.IP, nil +} + +var cachedIP net.IP +var cachedTS time.Time + +func GetCachedPublicIP(stuns ...string) (net.IP, error) { + if now := time.Now(); now.After(cachedTS) { + for _, addr := range stuns { + if ip, _ := GetPublicIP(addr); ip != nil { + cachedIP = ip + cachedTS = now.Add(time.Minute * 5) + return ip, nil + } + } + } + if cachedIP == nil { + return nil, errors.New("webrtc: can't get public IP") + } + return cachedIP, nil +} + +func IsIP(host string) bool { + for _, i := range host { + if i >= 'A' { + return false + } + } + return true +} + +func MimeType(codec *core.Codec) string { + switch codec.Name { + case core.CodecH264: + return webrtc.MimeTypeH264 + case core.CodecH265: + return webrtc.MimeTypeH265 + case core.CodecVP8: + return webrtc.MimeTypeVP8 + case core.CodecVP9: + return webrtc.MimeTypeVP9 + case core.CodecAV1: + return webrtc.MimeTypeAV1 + case core.CodecPCMU: + return webrtc.MimeTypePCMU + case core.CodecPCMA: + return webrtc.MimeTypePCMA + case core.CodecOpus: + return webrtc.MimeTypeOpus + case core.CodecG722: + return webrtc.MimeTypeG722 + } + panic("not implemented") +} + +func CandidateICE(network, host, port string, priority uint32) string { + // 1. Foundation + // 2. Component, always 1 because RTP + // 3. "udp" or "tcp" + // 4. Priority + // 5. Host - IP4 or IP6 or domain name + // 6. Port + // 7. "typ host" + foundation := crc32.ChecksumIEEE([]byte("host" + host + network + "4")) + s := fmt.Sprintf("candidate:%d 1 %s %d %s %s typ host", foundation, network, priority, host, port) + if network == "tcp" { + return s + " tcptype passive" + } + return s +} + +// Priority = type << 24 + local << 8 + component +// https://www.rfc-editor.org/rfc/rfc8445#section-5.1.2.1 + +const PriorityHostUDP uint32 = 0x001F_FFFF | + 126<<24 | // udp host + 7<<21 // udp +const PriorityHostTCPPassive uint32 = 0x001F_FFFF | + 99<<24 | // tcp host + 4<<21 // tcp passive + +// CandidateHostPriority (lower indexes has a higher priority) +func CandidateHostPriority(network string, index int) uint32 { + switch network { + case "udp": + return PriorityHostUDP - uint32(index) + case "tcp": + return PriorityHostTCPPassive - uint32(index) + } + return 0 +} + +func UnmarshalICEServers(b []byte) ([]webrtc.ICEServer, error) { + type ICEServer struct { + URLs any `json:"urls"` + Username string `json:"username,omitempty"` + Credential string `json:"credential,omitempty"` + } + + var src []ICEServer + if err := json.Unmarshal(b, &src); err != nil { + return nil, err + } + + var dst []webrtc.ICEServer + for i := range src { + srv := webrtc.ICEServer{ + Username: src[i].Username, + Credential: src[i].Credential, + } + + switch v := src[i].URLs.(type) { + case []any: + for _, u := range v { + if s, ok := u.(string); ok { + srv.URLs = append(srv.URLs, s) + } + } + case string: + srv.URLs = []string{v} + } + + dst = append(dst, srv) + } + + return dst, nil +} diff --git a/installs_on_host/go2rtc/pkg/webrtc/producer.go b/installs_on_host/go2rtc/pkg/webrtc/producer.go new file mode 100644 index 0000000..32e958e --- /dev/null +++ b/installs_on_host/go2rtc/pkg/webrtc/producer.go @@ -0,0 +1,49 @@ +package webrtc + +import ( + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/webrtc/v4" +) + +func (c *Conn) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + core.Assert(media.Direction == core.DirectionRecvonly) + + for _, track := range c.Receivers { + if track.Codec == codec { + return track, nil + } + } + + switch c.Mode { + case core.ModePassiveConsumer: // backchannel from browser + // set codec for consumer recv track so remote peer should send media with this codec + params := webrtc.RTPCodecParameters{ + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: MimeType(codec), + ClockRate: codec.ClockRate, + Channels: uint16(codec.Channels), + }, + PayloadType: 0, // don't know if this necessary + } + + tr := c.getTranseiver(media.ID) + + _ = tr.SetCodecPreferences([]webrtc.RTPCodecParameters{params}) + + case core.ModePassiveProducer, core.ModeActiveProducer: + // Passive producers: OBS Studio via WHIP or Browser + // Active producers: go2rtc as WebRTC client or WebTorrent + + default: + panic(core.Caller()) + } + + track := core.NewReceiver(media, codec) + c.Receivers = append(c.Receivers, track) + return track, nil +} + +func (c *Conn) Start() error { + c.closed.Wait() + return nil +} diff --git a/installs_on_host/go2rtc/pkg/webrtc/server.go b/installs_on_host/go2rtc/pkg/webrtc/server.go new file mode 100644 index 0000000..4714a6a --- /dev/null +++ b/installs_on_host/go2rtc/pkg/webrtc/server.go @@ -0,0 +1,123 @@ +package webrtc + +import ( + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/sdp/v3" + "github.com/pion/webrtc/v4" +) + +func (c *Conn) SetOffer(offer string) (err error) { + c.offer = offer + + sd := &sdp.SessionDescription{} + if err = sd.Unmarshal([]byte(offer)); err != nil { + return + } + + // create transceivers with opposite direction + for _, md := range sd.MediaDescriptions { + var mid string + var tr *webrtc.RTPTransceiver + for _, attr := range md.Attributes { + switch attr.Key { + case core.DirectionSendRecv: + tr, _ = c.pc.AddTransceiverFromTrack(NewTrack(md.MediaName.Media)) + case core.DirectionSendonly: + tr, _ = c.pc.AddTransceiverFromKind( + webrtc.NewRTPCodecType(md.MediaName.Media), + webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly}, + ) + case core.DirectionRecvonly: + tr, _ = c.pc.AddTransceiverFromTrack( + NewTrack(md.MediaName.Media), + webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionSendonly}, + ) + case "mid": + mid = attr.Value + } + } + + if mid != "" && tr != nil { + _ = tr.SetMid(mid) + } + } + + c.Medias = UnmarshalMedias(sd.MediaDescriptions) + + return +} + +func (c *Conn) GetAnswer() (answer string, err error) { + // we need to process remote offer after we create transeivers + desc := webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: c.offer} + if err = c.pc.SetRemoteDescription(desc); err != nil { + return "", err + } + + // disable transceivers if we don't have track, make direction=inactive +transeivers: + for _, tr := range c.pc.GetTransceivers() { + for _, sender := range c.Senders { + if sender.Media.ID == tr.Mid() { + continue transeivers + } + } + + switch tr.Direction() { + case webrtc.RTPTransceiverDirectionSendrecv: + _ = tr.Sender().Stop() // don't know if necessary + _ = tr.SetSender(tr.Sender(), nil) // set direction to recvonly + case webrtc.RTPTransceiverDirectionSendonly: + _ = tr.Stop() + } + } + + if desc, err = c.pc.CreateAnswer(nil); err != nil { + return + } + if err = c.pc.SetLocalDescription(desc); err != nil { + return + } + + return c.pc.LocalDescription().SDP, nil +} + +// GetCompleteAnswer - get SDP answer with candidates inside +func (c *Conn) GetCompleteAnswer(candidates []string, filter func(*webrtc.ICECandidate) bool) (string, error) { + var done = make(chan struct{}) + + c.pc.OnICECandidate(func(candidate *webrtc.ICECandidate) { + if candidate != nil { + if filter == nil || filter(candidate) { + candidates = append(candidates, candidate.ToJSON().Candidate) + } + } else { + done <- struct{}{} + } + }) + + answer, err := c.GetAnswer() + if err != nil { + return "", err + } + + <-done + + sd := &sdp.SessionDescription{} + if err = sd.Unmarshal([]byte(answer)); err != nil { + return "", err + } + + md := sd.MediaDescriptions[0] + + for _, candidate := range candidates { + md.WithPropertyAttribute(candidate) + } + + b, err := sd.Marshal() + if err != nil { + return "", err + } + + return string(b), nil +} diff --git a/installs_on_host/go2rtc/pkg/webrtc/track.go b/installs_on_host/go2rtc/pkg/webrtc/track.go new file mode 100644 index 0000000..657eee1 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/webrtc/track.go @@ -0,0 +1,83 @@ +package webrtc + +import ( + "sync" + + "github.com/pion/rtp" + "github.com/pion/webrtc/v4" +) + +type Track struct { + kind string + id string + streamID string + sequence uint16 + ssrc uint32 + writer webrtc.TrackLocalWriter + mu sync.Mutex +} + +func NewTrack(kind string) *Track { + return &Track{ + kind: kind, + id: "go2rtc-" + kind, + streamID: "go2rtc", + } +} + +func (t *Track) Bind(context webrtc.TrackLocalContext) (webrtc.RTPCodecParameters, error) { + t.mu.Lock() + t.ssrc = uint32(context.SSRC()) + t.writer = context.WriteStream() + t.mu.Unlock() + + for _, parameters := range context.CodecParameters() { + // return first parameters + return parameters, nil + } + + return webrtc.RTPCodecParameters{}, nil +} + +func (t *Track) Unbind(context webrtc.TrackLocalContext) error { + t.mu.Lock() + t.writer = nil + t.mu.Unlock() + return nil +} + +func (t *Track) ID() string { + return t.id +} + +func (t *Track) RID() string { + return "" // don't know what it is +} + +func (t *Track) StreamID() string { + return t.streamID +} + +func (t *Track) Kind() webrtc.RTPCodecType { + return webrtc.NewRTPCodecType(t.kind) +} + +func (t *Track) WriteRTP(payloadType uint8, packet *rtp.Packet) (err error) { + // using mutex because Unbind https://github.com/AlexxIT/go2rtc/issues/994 + t.mu.Lock() + + // in case when we start WriteRTP before Track.Bind + if t.writer != nil { + // important to have internal counter if input packets from different sources + t.sequence++ + + header := packet.Header + header.SSRC = t.ssrc + header.PayloadType = payloadType + header.SequenceNumber = t.sequence + _, err = t.writer.WriteRTP(&header, packet.Payload) + } + + t.mu.Unlock() + return +} diff --git a/installs_on_host/go2rtc/pkg/webrtc/webrtc_test.go b/installs_on_host/go2rtc/pkg/webrtc/webrtc_test.go new file mode 100644 index 0000000..6b20b08 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/webrtc/webrtc_test.go @@ -0,0 +1,71 @@ +package webrtc + +import ( + "testing" + + "github.com/pion/webrtc/v4" + "github.com/stretchr/testify/require" +) + +func TestAlexa(t *testing.T) { + // from https://github.com/AlexxIT/go2rtc/issues/825 + offer := `v=0 +o=- 3911343731 3911343731 IN IP4 0.0.0.0 +s=a 2 z +c=IN IP4 0.0.0.0 +t=0 0 +a=group:BUNDLE audio0 video0 +m=audio 1 UDP/TLS/RTP/SAVPF 96 0 8 +a=candidate:1 1 UDP 2013266431 52.90.193.210 60128 typ host +a=candidate:2 1 TCP 1015021823 52.90.193.210 9 typ host tcptype active +a=candidate:3 1 TCP 1010827519 52.90.193.210 45962 typ host tcptype passive +a=candidate:1 2 UDP 2013266430 52.90.193.210 46109 typ host +a=candidate:2 2 TCP 1015021822 52.90.193.210 9 typ host tcptype active +a=candidate:3 2 TCP 1010827518 52.90.193.210 53795 typ host tcptype passive +a=setup:actpass +a=extmap:3 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time +a=rtpmap:96 opus/48000/2 +a=rtpmap:0 PCMU/8000 +a=rtpmap:8 PCMA/8000 +a=rtcp:9 IN IP4 0.0.0.0 +a=rtcp-mux +a=sendrecv +a=mid:audio0 +a=ssrc:3573704076 cname:user3856789923@host-9dd1dd33 +a=ice-ufrag:gxfV +a=ice-pwd:KepKrlQ1+LD+RGTAFaqVck +a=fingerprint:sha-256 A2:93:53:50:E4:2F:C5:4E:DF:7C:70:99:5A:A7:39:50:1A:63:E5:B2:CA:73:70:7A:C5:F4:01:BF:BD:99:57:FC +m=video 1 UDP/TLS/RTP/SAVPF 99 +a=candidate:1 1 UDP 2013266431 52.90.193.210 60128 typ host +a=candidate:1 2 UDP 2013266430 52.90.193.210 46109 typ host +a=candidate:2 1 TCP 1015021823 52.90.193.210 9 typ host tcptype active +a=candidate:3 1 TCP 1010827519 52.90.193.210 45962 typ host tcptype passive +a=candidate:3 2 TCP 1010827518 52.90.193.210 53795 typ host tcptype passive +a=candidate:2 2 TCP 1015021822 52.90.193.210 9 typ host tcptype active +b=AS:2500 +a=setup:actpass +a=extmap:3 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time +a=rtpmap:99 H264/90000 +a=rtcp:9 IN IP4 0.0.0.0 +a=rtcp-mux +a=sendrecv +a=mid:video0 +a=rtcp-fb:99 nack +a=rtcp-fb:99 nack pli +a=rtcp-fb:99 ccm fir +a=ssrc:3778078295 cname:user3856789923@host-9dd1dd33 +a=ice-ufrag:gxfV +a=ice-pwd:KepKrlQ1+LD+RGTAFaqVck +a=fingerprint:sha-256 A2:93:53:50:E4:2F:C5:4E:DF:7C:70:99:5A:A7:39:50:1A:63:E5:B2:CA:73:70:7A:C5:F4:01:BF:BD:99:57:FC +` + + pc, err := webrtc.NewPeerConnection(webrtc.Configuration{}) + require.Nil(t, err) + + conn := NewConn(pc) + err = conn.SetOffer(offer) + require.Nil(t, err) + + _, err = conn.GetAnswer() + require.Nil(t, err) +} diff --git a/installs_on_host/go2rtc/pkg/webtorrent/client.go b/installs_on_host/go2rtc/pkg/webtorrent/client.go new file mode 100644 index 0000000..04eeccd --- /dev/null +++ b/installs_on_host/go2rtc/pkg/webtorrent/client.go @@ -0,0 +1,94 @@ +package webtorrent + +import ( + "encoding/base64" + "fmt" + "strconv" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/webrtc" + "github.com/gorilla/websocket" + pion "github.com/pion/webrtc/v4" +) + +func NewClient(tracker, share, pwd string, pc *pion.PeerConnection) (*webrtc.Conn, error) { + // 1. Create WebRTC producer + prod := webrtc.NewConn(pc) + prod.FormatName = "webtorrent" + prod.Mode = core.ModeActiveProducer + prod.Protocol = "ws" + + medias := []*core.Media{ + {Kind: core.KindVideo, Direction: core.DirectionRecvonly}, + {Kind: core.KindAudio, Direction: core.DirectionRecvonly}, + } + + // 2. Create offer + offer, err := prod.CreateCompleteOffer(medias) + if err != nil { + return nil, err + } + + // 3. Encrypt offer + nonce := strconv.FormatInt(time.Now().UnixNano(), 36) + + cipher, err := NewCipher(share, pwd, nonce) + if err != nil { + return nil, err + } + + enc := cipher.Encrypt([]byte(offer)) + + // 4. Connect to Tracker + ws, _, err := websocket.DefaultDialer.Dial(tracker, nil) + if err != nil { + return nil, err + } + + defer ws.Close() + + // 5. Send offer + msg := fmt.Sprintf( + `{"action":"announce","info_hash":"%s","peer_id":"%s","offers":[{"offer_id":"%s","offer":{"type":"offer","sdp":"%s"}}],"numwant":1}`, + InfoHash(share), core.RandString(16, 36), nonce, base64.StdEncoding.EncodeToString(enc), + ) + if err = ws.WriteMessage(websocket.TextMessage, []byte(msg)); err != nil { + return nil, err + } + + // wait 30 seconds until full answer + if err = ws.SetReadDeadline(time.Now().Add(time.Second * 30)); err != nil { + return nil, err + } + + for { + // 6. Read answer + var v Message + if err = ws.ReadJSON(&v); err != nil { + return nil, err + } + + if v.Answer == nil { + continue + } + + // 7. Decrypt answer + enc, err = base64.StdEncoding.DecodeString(v.Answer.SDP) + if err != nil { + return nil, err + } + + answer, err := cipher.Decrypt(enc) + if err != nil { + return nil, err + } + + // 8. Set answer + if err = prod.SetAnswer(string(answer)); err != nil { + return nil, err + } + + return prod, nil + } +} diff --git a/installs_on_host/go2rtc/pkg/webtorrent/crypto.go b/installs_on_host/go2rtc/pkg/webtorrent/crypto.go new file mode 100644 index 0000000..3e90fd6 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/webtorrent/crypto.go @@ -0,0 +1,72 @@ +package webtorrent + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/sha256" + "encoding/base64" + "fmt" + "strconv" + "time" +) + +type Cipher struct { + gcm cipher.AEAD + iv []byte + nonce []byte +} + +func NewCipher(share, pwd, nonce string) (*Cipher, error) { + timestamp, err := strconv.ParseInt(nonce, 36, 64) + if err != nil { + return nil, err + } + + delta := time.Duration(time.Now().UnixNano() - timestamp) + if delta < 0 { + delta = -delta + } + + // protect from replay attack, but respect wrong timezone on server + if delta > 12*time.Hour { + return nil, fmt.Errorf("wrong timedelta %s", delta) + } + + c := &Cipher{} + + hash := sha256.New() + hash.Write([]byte(nonce + ":" + pwd)) + key := hash.Sum(nil) + + hash.Reset() + hash.Write([]byte(share + ":" + nonce)) + c.iv = hash.Sum(nil)[:12] + + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + c.gcm, err = cipher.NewGCM(block) + if err != nil { + return nil, err + } + + c.nonce = []byte(nonce) + + return c, nil +} + +func (c *Cipher) Decrypt(ciphertext []byte) ([]byte, error) { + return c.gcm.Open(nil, c.iv, ciphertext, c.nonce) +} + +func (c *Cipher) Encrypt(plaintext []byte) []byte { + return c.gcm.Seal(nil, c.iv, plaintext, c.nonce) +} + +func InfoHash(share string) string { + hash := sha256.New() + hash.Write([]byte(share)) + sum := hash.Sum(nil) + return base64.StdEncoding.EncodeToString(sum) +} diff --git a/installs_on_host/go2rtc/pkg/webtorrent/server.go b/installs_on_host/go2rtc/pkg/webtorrent/server.go new file mode 100644 index 0000000..86b2be5 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/webtorrent/server.go @@ -0,0 +1,228 @@ +package webtorrent + +import ( + "encoding/base64" + "fmt" + "sync" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/gorilla/websocket" +) + +type Server struct { + core.Listener + + URL string + Exchange func(src, offer string) (answer string, err error) + + shares map[string]*Share + mu sync.Mutex + announce *core.Worker +} + +type Share struct { + name string + pwd string + src string +} + +func (s *Server) AddShare(name, pwd, src string) { + s.mu.Lock() + + if s.shares == nil { + s.shares = map[string]*Share{} + } + + if len(s.shares) == 0 { + go s.Serve() + } + + hash := InfoHash(name) + s.shares[hash] = &Share{ + name: name, + pwd: pwd, + src: src, + } + + s.announce.Do() + + s.mu.Unlock() +} + +func (s *Server) GetSharePwd(name string) (pwd string) { + hash := InfoHash(name) + s.mu.Lock() + if share, ok := s.shares[hash]; ok { + pwd = share.pwd + } + s.mu.Unlock() + return +} + +func (s *Server) RemoveShare(name string) { + hash := InfoHash(name) + s.mu.Lock() + if _, ok := s.shares[hash]; ok { + delete(s.shares, hash) + } + s.mu.Unlock() +} + +// Serve - run reconnection loop, will exit on?? +func (s *Server) Serve() error { + for s.HasShares() { + s.Fire("connect to tracker: " + s.URL) + + ws, _, err := websocket.DefaultDialer.Dial(s.URL, nil) + if err != nil { + s.Fire(err) + time.Sleep(time.Minute) + continue + } + + peerID := core.RandString(16, 36) + + // instant run announce worker + s.announce = core.NewWorker(0, func() time.Duration { + if err = s.writer(ws, peerID); err != nil { + return 0 + } + return time.Minute + }) + + // run reader forewer + for { + if err = s.reader(ws, peerID); err != nil { + break + } + } + + // stop announcing worker + s.announce.Stop() + + // ensure ws is stopped + _ = ws.Close() + + time.Sleep(time.Minute) + } + + s.Fire("disconnect") + + return nil +} + +// reader - receive offers in the loop, will exit on ws.Close +func (s *Server) reader(ws *websocket.Conn, peerID string) error { + var v Message + if err := ws.ReadJSON(&v); err != nil { + return err + } + + s.Fire(&v) + + s.mu.Lock() + share, ok := s.shares[v.InfoHash] + s.mu.Unlock() + + // skip any unknown shares + if !ok || v.OfferId == "" || v.Offer == nil { + return nil + } + + s.Fire("new offer: " + v.OfferId) + + cipher, err := NewCipher(share.name, share.pwd, v.OfferId) + if err != nil { + s.Fire(err) + return nil + } + + enc, err := base64.StdEncoding.DecodeString(v.Offer.SDP) + if err != nil { + s.Fire(err) + return nil + } + + offer, err := cipher.Decrypt(enc) + if err != nil { + s.Fire(err) + return nil + } + + answer, err := s.Exchange(share.src, string(offer)) + if err != nil { + s.Fire(err) + return nil + } + + enc = cipher.Encrypt([]byte(answer)) + + raw := fmt.Sprintf( + `{"action":"announce","info_hash":"%s","peer_id":"%s","offer_id":"%s","answer":{"type":"answer","sdp":"%s"},"to_peer_id":"%s"}`, + v.InfoHash, peerID, v.OfferId, base64.StdEncoding.EncodeToString(enc), v.PeerId, + ) + return ws.WriteMessage(websocket.TextMessage, []byte(raw)) +} + +func (s *Server) writer(ws *websocket.Conn, peerID string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if len(s.shares) == 0 { + return ws.Close() + } + + for hash := range s.shares { + msg := fmt.Sprintf( + `{"action":"announce","info_hash":"%s","peer_id":"%s","offers":[],"numwant":10}`, + hash, peerID, + ) + if err := ws.WriteMessage(websocket.TextMessage, []byte(msg)); err != nil { + return err + } + } + + return nil +} + +func (s *Server) HasShares() bool { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.shares) > 0 +} + +type Message struct { + Action string `json:"action"` + InfoHash string `json:"info_hash"` + + // Announce msg + Numwant int `json:"numwant,omitempty"` + PeerId string `json:"peer_id,omitempty"` + Offers []struct { + OfferId string `json:"offer_id"` + Offer struct { + Type string `json:"type"` + SDP string `json:"sdp"` + } `json:"offer"` + } `json:"offers,omitempty"` + + // Interval msg + Interval int `json:"interval,omitempty"` + Complete int `json:"complete,omitempty"` + Incomplete int `json:"incomplete,omitempty"` + + // Offer msg + OfferId string `json:"offer_id,omitempty"` + Offer *struct { + Type string `json:"type"` + SDP string `json:"sdp"` + } `json:"offer,omitempty"` + + // Answer msg + ToPeerId string `json:"to_peer_id,omitempty"` + Answer *struct { + Type string `json:"type"` + SDP string `json:"sdp"` + } `json:"answer,omitempty"` +} diff --git a/installs_on_host/go2rtc/pkg/wyoming/README.md b/installs_on_host/go2rtc/pkg/wyoming/README.md new file mode 100644 index 0000000..ff17d07 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/wyoming/README.md @@ -0,0 +1,14 @@ +## Default wake words + +- alexa_v0.1 +- hey_jarvis_v0.1 +- hey_mycroft_v0.1 +- hey_rhasspy_v0.1 +- ok_nabu_v0.1 + +## Useful Links + +- https://github.com/rhasspy/wyoming-satellite +- https://github.com/rhasspy/wyoming-openwakeword +- https://github.com/fwartner/home-assistant-wakewords-collection +- https://github.com/esphome/micro-wake-word-models/tree/main?tab=readme-ov-file diff --git a/installs_on_host/go2rtc/pkg/wyoming/api.go b/installs_on_host/go2rtc/pkg/wyoming/api.go new file mode 100644 index 0000000..ce297a2 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/wyoming/api.go @@ -0,0 +1,99 @@ +package wyoming + +import ( + "bufio" + "encoding/json" + "io" + "net" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +type API struct { + conn net.Conn + rd *bufio.Reader +} + +func DialAPI(address string) (*API, error) { + conn, err := net.DialTimeout("tcp", address, core.ConnDialTimeout) + if err != nil { + return nil, err + } + + return NewAPI(conn), nil +} + +const Version = "1.5.4" + +func NewAPI(conn net.Conn) *API { + return &API{conn: conn, rd: bufio.NewReader(conn)} +} + +func (w *API) WriteEvent(evt *Event) (err error) { + hdr := EventHeader{ + Type: evt.Type, + Version: Version, + DataLength: len(evt.Data), + PayloadLength: len(evt.Payload), + } + + buf, err := json.Marshal(hdr) + if err != nil { + return err + } + + buf = append(buf, '\n') + buf = append(buf, evt.Data...) + buf = append(buf, evt.Payload...) + + _, err = w.conn.Write(buf) + return err +} + +func (w *API) ReadEvent() (*Event, error) { + data, err := w.rd.ReadBytes('\n') + if err != nil { + return nil, err + } + + var hdr EventHeader + if err = json.Unmarshal(data, &hdr); err != nil { + return nil, err + } + + evt := Event{Type: hdr.Type} + + if hdr.DataLength > 0 { + data = make([]byte, hdr.DataLength) + if _, err = io.ReadFull(w.rd, data); err != nil { + return nil, err + } + evt.Data = string(data) + } + + if hdr.PayloadLength > 0 { + evt.Payload = make([]byte, hdr.PayloadLength) + if _, err = io.ReadFull(w.rd, evt.Payload); err != nil { + return nil, err + } + } + + return &evt, nil +} + +func (w *API) Close() error { + return w.conn.Close() +} + +type Event struct { + Type string + Data string + Payload []byte +} + +type EventHeader struct { + Type string `json:"type"` + Version string `json:"version"` + DataLength int `json:"data_length,omitempty"` + PayloadLength int `json:"payload_length,omitempty"` +} diff --git a/installs_on_host/go2rtc/pkg/wyoming/backchannel.go b/installs_on_host/go2rtc/pkg/wyoming/backchannel.go new file mode 100644 index 0000000..e0569fe --- /dev/null +++ b/installs_on_host/go2rtc/pkg/wyoming/backchannel.go @@ -0,0 +1,63 @@ +package wyoming + +import ( + "fmt" + "net" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +type Backchannel struct { + core.Connection + api *API +} + +func newBackchannel(conn net.Conn) *Backchannel { + return &Backchannel{ + core.Connection{ + ID: core.NewID(), + FormatName: "wyoming", + Medias: []*core.Media{ + { + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecPCML, ClockRate: 22050}, + }, + }, + }, + Transport: conn, + }, + NewAPI(conn), + } +} + +func (b *Backchannel) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + return nil, core.ErrCantGetTrack +} + +func (b *Backchannel) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + sender := core.NewSender(media, codec) + sender.Handler = func(pkt *rtp.Packet) { + ts := time.Now().Nanosecond() + evt := &Event{ + Type: "audio-chunk", + Data: fmt.Sprintf(`{"rate":22050,"width":2,"channels":1,"timestamp":%d}`, ts), + Payload: pkt.Payload, + } + _ = b.api.WriteEvent(evt) + } + sender.HandleRTP(track) + b.Senders = append(b.Senders, sender) + return nil +} + +func (b *Backchannel) Start() error { + for { + if _, err := b.api.ReadEvent(); err != nil { + return err + } + } +} diff --git a/installs_on_host/go2rtc/pkg/wyoming/expr.go b/installs_on_host/go2rtc/pkg/wyoming/expr.go new file mode 100644 index 0000000..f2f5893 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/wyoming/expr.go @@ -0,0 +1,138 @@ +package wyoming + +import ( + "bytes" + "fmt" + "os" + "time" + + "github.com/AlexxIT/go2rtc/pkg/expr" + "github.com/AlexxIT/go2rtc/pkg/wav" +) + +type env struct { + *satellite + Type string + Data string +} + +func (s *satellite) handleEvent(evt *Event) { + switch evt.Type { + case "describe": + // {"asr": [], "tts": [], "handle": [], "intent": [], "wake": [], "satellite": {"name": "my satellite", "attribution": {"name": "", "url": ""}, "installed": true, "description": "my satellite", "version": "1.4.1", "area": null, "snd_format": null}} + data := fmt.Sprintf(`{"satellite":{"name":%q,"attribution":{"name":"go2rtc","url":"https://github.com/AlexxIT/go2rtc"},"installed":true}}`, s.srv.Name) + s.WriteEvent("info", data) + case "run-satellite": + s.Detect() + case "pause-satellite": + s.Stop() + case "detect": // WAKE_WORD_START {"names": null} + case "detection": // WAKE_WORD_END {"name": "ok_nabu_v0.1", "timestamp": 17580, "speaker": null} + case "transcribe": // STT_START {"language": "en"} + case "voice-started": // STT_VAD_START {"timestamp": 1160} + case "voice-stopped": // STT_VAD_END {"timestamp": 2470} + s.Pause() + case "transcript": // STT_END {"text": "how are you"} + case "synthesize": // TTS_START {"text": "Sorry, I couldn't understand that", "voice": {"language": "en"}} + case "audio-start": // TTS_END {"rate": 22050, "width": 2, "channels": 1, "timestamp": 0} + case "audio-chunk": // {"rate": 22050, "width": 2, "channels": 1, "timestamp": 0} + case "audio-stop": // {"timestamp": 2.880000000000002} + // run async because PlayAudio takes some time + go func() { + s.PlayAudio() + s.WriteEvent("played") + s.Detect() + }() + case "error": + s.Detect() + case "internal-run": + s.WriteEvent("run-pipeline", `{"start_stage":"wake","end_stage":"tts"}`) + s.Stream() + case "internal-detection": + s.WriteEvent("run-pipeline", `{"start_stage":"asr","end_stage":"tts"}`) + s.Stream() + } +} + +func (s *satellite) handleScript(evt *Event) { + var script string + if s.srv.Event != nil { + script = s.srv.Event[evt.Type] + } + + s.srv.Trace("event=%s data=%s payload size=%d", evt.Type, evt.Data, len(evt.Payload)) + + if script == "" { + s.handleEvent(evt) + return + } + + // run async because script can have sleeps + go func() { + e := &env{satellite: s, Type: evt.Type, Data: evt.Data} + if res, err := expr.Eval(script, e); err != nil { + s.srv.Trace("event=%s expr error=%s", evt.Type, err) + s.handleEvent(evt) + } else { + s.srv.Trace("event=%s expr result=%v", evt.Type, res) + } + }() +} + +func (s *satellite) Detect() bool { + return s.setMicState(stateWaitVAD) +} + +func (s *satellite) Stream() bool { + return s.setMicState(stateActive) +} + +func (s *satellite) Pause() bool { + return s.setMicState(stateIdle) +} + +func (s *satellite) Stop() bool { + s.micStop() + return true +} + +func (s *satellite) WriteEvent(args ...string) bool { + if len(args) == 0 { + return false + } + evt := &Event{Type: args[0]} + if len(args) > 1 { + evt.Data = args[1] + } + if err := s.api.WriteEvent(evt); err != nil { + return false + } + return true +} + +func (s *satellite) PlayAudio() bool { + return s.playAudio(sndCodec, bytes.NewReader(s.sndAudio)) +} + +func (s *satellite) PlayFile(path string) bool { + f, err := os.Open(path) + if err != nil { + return false + } + + codec, err := wav.ReadHeader(f) + if err != nil { + return false + } + + return s.playAudio(codec, f) +} + +func (e *env) Sleep(s string) bool { + d, err := time.ParseDuration(s) + if err != nil { + return false + } + time.Sleep(d) + return true +} diff --git a/installs_on_host/go2rtc/pkg/wyoming/mic.go b/installs_on_host/go2rtc/pkg/wyoming/mic.go new file mode 100644 index 0000000..4fb03b4 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/wyoming/mic.go @@ -0,0 +1,35 @@ +package wyoming + +import ( + "fmt" + "net" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +func (s *Server) HandleMic(conn net.Conn) { + defer conn.Close() + + var closed core.Waiter + var timestamp int + + api := NewAPI(conn) + mic := newMicConsumer(func(chunk []byte) { + data := fmt.Sprintf(`{"rate":16000,"width":2,"channels":1,"timestamp":%d}`, timestamp) + evt := &Event{Type: "audio-chunk", Data: data, Payload: chunk} + if err := api.WriteEvent(evt); err != nil { + closed.Done(nil) + } + + timestamp += len(chunk) / 2 + }) + mic.RemoteAddr = api.conn.RemoteAddr().String() + + if err := s.MicHandler(mic); err != nil { + s.Error("mic error: %s", err) + return + } + + _ = closed.Wait() + _ = mic.Stop() +} diff --git a/installs_on_host/go2rtc/pkg/wyoming/producer.go b/installs_on_host/go2rtc/pkg/wyoming/producer.go new file mode 100644 index 0000000..0945133 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/wyoming/producer.go @@ -0,0 +1,65 @@ +package wyoming + +import ( + "net" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +type Producer struct { + core.Connection + api *API +} + +func newProducer(conn net.Conn) *Producer { + return &Producer{ + core.Connection{ + ID: core.NewID(), + FormatName: "wyoming", + Medias: []*core.Media{ + { + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + {Name: core.CodecPCML, ClockRate: 16000}, + }, + }, + }, + Transport: conn, + }, + NewAPI(conn), + } +} + +func (p *Producer) Start() error { + var seq uint16 + var ts uint32 + + for { + evt, err := p.api.ReadEvent() + if err != nil { + return err + } + + if evt.Type != "audio-chunk" { + continue + } + + p.Recv += len(evt.Payload) + + pkt := &core.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: true, + SequenceNumber: seq, + Timestamp: ts, + }, + Payload: evt.Payload, + } + p.Receivers[0].WriteRTP(pkt) + + seq++ + ts += uint32(len(evt.Payload) / 2) + } +} diff --git a/installs_on_host/go2rtc/pkg/wyoming/satellite.go b/installs_on_host/go2rtc/pkg/wyoming/satellite.go new file mode 100644 index 0000000..0c0e6f3 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/wyoming/satellite.go @@ -0,0 +1,275 @@ +package wyoming + +import ( + "context" + "fmt" + "io" + "net" + "sync" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/pcm" + "github.com/AlexxIT/go2rtc/pkg/pcm/s16le" + "github.com/pion/rtp" +) + +type Server struct { + Name string + Event map[string]string + + VADThreshold int16 + WakeURI string + + MicHandler func(cons core.Consumer) error + SndHandler func(prod core.Producer) error + + Trace func(format string, v ...any) + Error func(format string, v ...any) +} + +func (s *Server) Serve(l net.Listener) error { + for { + conn, err := l.Accept() + if err != nil { + return err + } + + go s.Handle(conn) + } +} + +func (s *Server) Handle(conn net.Conn) { + api := NewAPI(conn) + sat := newSatellite(api, s) + defer sat.Close() + + for { + evt, err := api.ReadEvent() + if err != nil { + return + } + + switch evt.Type { + case "ping": // {"text": null} + _ = api.WriteEvent(&Event{Type: "pong", Data: evt.Data}) + case "audio-start": // TTS_END {"rate": 22050, "width": 2, "channels": 1, "timestamp": 0} + sat.sndAudio = sat.sndAudio[:0] + case "audio-chunk": // {"rate": 22050, "width": 2, "channels": 1, "timestamp": 0} + sat.sndAudio = append(sat.sndAudio, evt.Payload...) + default: + sat.handleScript(evt) + } + } +} + +// states like http.ConnState +const ( + stateError = -2 + stateClosed = -1 + stateNew = 0 + stateIdle = 1 + stateWaitVAD = 2 // aka wait VAD + stateWaitWakeWord = 3 + stateActive = 4 +) + +type satellite struct { + api *API + srv *Server + + micState int8 + micTS int + micMu sync.Mutex + sndAudio []byte + + mic *micConsumer + wake *WakeWord +} + +func newSatellite(api *API, srv *Server) *satellite { + sat := &satellite{api: api, srv: srv} + return sat +} + +func (s *satellite) Close() error { + s.Stop() + return s.api.Close() +} + +const wakeTimeout = 5 * 2 * 16000 // 5 seconds + +func (s *satellite) setMicState(state int8) bool { + s.micMu.Lock() + defer s.micMu.Unlock() + + if s.micState == stateNew { + s.mic = newMicConsumer(s.onMicChunk) + s.mic.RemoteAddr = s.api.conn.RemoteAddr().String() + if err := s.srv.MicHandler(s.mic); err != nil { + s.micState = stateError + s.srv.Error("can't get mic: %w", err) + _ = s.api.Close() + } else { + s.micState = stateIdle + } + } + + if s.micState < stateIdle { + return false + } + + s.micState = state + s.micTS = 0 + return true +} + +func (s *satellite) micStop() { + s.micMu.Lock() + + s.micState = stateClosed + if s.mic != nil { + _ = s.mic.Stop() + s.mic = nil + } + if s.wake != nil { + _ = s.wake.Close() + s.wake = nil + } + + s.micMu.Unlock() +} + +func (s *satellite) onMicChunk(chunk []byte) { + s.micMu.Lock() + defer s.micMu.Unlock() + + if s.micState == stateIdle { + return + } + + if s.micState == stateWaitVAD { + // tests show that values over 1000 are most likely speech + if s.srv.VADThreshold == 0 || s16le.PeaksRMS(chunk) > s.srv.VADThreshold { + if s.wake == nil && s.srv.WakeURI != "" { + s.wake, _ = DialWakeWord(s.srv.WakeURI) + } + if s.wake == nil { + // some problems with wake word - redirect to HA + s.micState = stateIdle + go s.handleScript(&Event{Type: "internal-run"}) + } else { + s.micState = stateWaitWakeWord + } + s.micTS = 0 + } + } + + if s.micState == stateWaitWakeWord { + if s.wake.Detection != "" { + // check if wake word detected + s.micState = stateIdle + go s.handleScript(&Event{Type: "internal-detection", Data: `{"name":"` + s.wake.Detection + `"}`}) + } else if err := s.wake.WriteChunk(chunk); err != nil { + // wake word service failed + s.micState = stateWaitVAD + _ = s.wake.Close() + s.wake = nil + } else if s.micTS > wakeTimeout { + // wake word detection timeout + s.micState = stateWaitVAD + } + } else if s.wake != nil { + _ = s.wake.Close() + s.wake = nil + } + + if s.micState == stateActive { + data := fmt.Sprintf(`{"rate":16000,"width":2,"channels":1,"timestamp":%d}`, s.micTS) + evt := &Event{Type: "audio-chunk", Data: data, Payload: chunk} + _ = s.api.WriteEvent(evt) + } + + s.micTS += len(chunk) / 2 +} + +func (s *satellite) playAudio(codec *core.Codec, rd io.Reader) bool { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + prod := pcm.OpenSync(codec, rd) + prod.OnClose(cancel) + + if err := s.srv.SndHandler(prod); err != nil { + return false + } else { + <-ctx.Done() + return true + } +} + +type micConsumer struct { + core.Connection + onData func(chunk []byte) + onClose func() +} + +func newMicConsumer(onData func(chunk []byte)) *micConsumer { + medias := []*core.Media{ + { + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: pcm.ConsumerCodecs(), + }, + } + + return &micConsumer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "wyoming", + Protocol: "tcp", + Medias: medias, + }, + onData: onData, + } +} + +func (c *micConsumer) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + src := track.Codec + dst := &core.Codec{ + Name: core.CodecPCML, + ClockRate: 16000, + Channels: 1, + } + sender := core.NewSender(media, dst) + sender.Handler = pcm.TranscodeHandler(dst, src, + repack(func(packet *core.Packet) { + c.onData(packet.Payload) + }), + ) + sender.HandleRTP(track) + c.Senders = append(c.Senders, sender) + return nil +} + +func (c *micConsumer) Stop() error { + if c.onClose != nil { + c.onClose() + } + return c.Connection.Stop() +} + +func repack(handler core.HandlerFunc) core.HandlerFunc { + const PacketSize = 2 * 16000 / 50 // 20ms + + var buf []byte + + return func(pkt *rtp.Packet) { + buf = append(buf, pkt.Payload...) + + for len(buf) >= PacketSize { + pkt = &core.Packet{Payload: buf[:PacketSize]} + buf = buf[PacketSize:] + handler(pkt) + } + } +} diff --git a/installs_on_host/go2rtc/pkg/wyoming/snd.go b/installs_on_host/go2rtc/pkg/wyoming/snd.go new file mode 100644 index 0000000..e26ca7e --- /dev/null +++ b/installs_on_host/go2rtc/pkg/wyoming/snd.go @@ -0,0 +1,40 @@ +package wyoming + +import ( + "bytes" + "net" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/pcm" +) + +func (s *Server) HandleSnd(conn net.Conn) { + defer conn.Close() + + var snd []byte + + api := NewAPI(conn) + for { + evt, err := api.ReadEvent() + if err != nil { + return + } + + s.Trace("event: %s data: %s payload: %d", evt.Type, evt.Data, len(evt.Payload)) + + switch evt.Type { + case "audio-start": + snd = snd[:0] + case "audio-chunk": + snd = append(snd, evt.Payload...) + case "audio-stop": + prod := pcm.OpenSync(sndCodec, bytes.NewReader(snd)) + if err = s.SndHandler(prod); err != nil { + s.Error("snd error: %s", err) + return + } + } + } +} + +var sndCodec = &core.Codec{Name: core.CodecPCML, ClockRate: 22050} diff --git a/installs_on_host/go2rtc/pkg/wyoming/wakeword.go b/installs_on_host/go2rtc/pkg/wyoming/wakeword.go new file mode 100644 index 0000000..4c728f2 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/wyoming/wakeword.go @@ -0,0 +1,120 @@ +package wyoming + +import ( + "encoding/json" + "fmt" + "net/url" +) + +type WakeWord struct { + *API + names []string + send int + + Detection string +} + +func DialWakeWord(rawURL string) (*WakeWord, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + api, err := DialAPI(u.Host) + if err != nil { + return nil, err + } + + names := u.Query()["name"] + if len(names) == 0 { + names = []string{"ok_nabu_v0.1"} + } + + wake := &WakeWord{API: api, names: names} + if err = wake.Start(); err != nil { + _ = wake.Close() + return nil, err + } + + go wake.handle() + return wake, nil +} + +func (w *WakeWord) handle() { + defer w.Close() + + for { + evt, err := w.ReadEvent() + if err != nil { + return + } + + if evt.Type == "detection" { + var data struct { + Name string `json:"name"` + } + if err = json.Unmarshal([]byte(evt.Data), &data); err != nil { + return + } + w.Detection = data.Name + } + } +} + +//func (w *WakeWord) Describe() error { +// if err := w.WriteEvent(&Event{Type: "describe"}); err != nil { +// return err +// } +// +// evt, err := w.ReadEvent() +// if err != nil { +// return err +// } +// +// var info struct { +// Wake []struct { +// Models []struct { +// Name string `json:"name"` +// } `json:"models"` +// } `json:"wake"` +// } +// if err = json.Unmarshal(evt.Data, &info); err != nil { +// return err +// } +// +// return nil +//} + +func (w *WakeWord) Start() error { + msg := struct { + Names []string `json:"names"` + }{ + Names: w.names, + } + data, err := json.Marshal(msg) + if err != nil { + return err + } + evt := &Event{Type: "detect", Data: string(data)} + if err := w.WriteEvent(evt); err != nil { + return err + } + + evt = &Event{Type: "audio-start", Data: audioData(0)} + return w.WriteEvent(evt) +} + +func (w *WakeWord) Close() error { + return w.conn.Close() +} + +func (w *WakeWord) WriteChunk(payload []byte) error { + evt := &Event{Type: "audio-chunk", Data: audioData(w.send), Payload: payload} + w.send += len(payload) + return w.WriteEvent(evt) +} + +func audioData(send int) string { + // timestamp in ms = send / 2 * 1000 / 16000 = send / 32 + return fmt.Sprintf(`{"rate":16000,"width":2,"channels":1,"timestamp":%d}`, send/32) +} diff --git a/installs_on_host/go2rtc/pkg/wyoming/wyoming.go b/installs_on_host/go2rtc/pkg/wyoming/wyoming.go new file mode 100644 index 0000000..0c8eeba --- /dev/null +++ b/installs_on_host/go2rtc/pkg/wyoming/wyoming.go @@ -0,0 +1,26 @@ +package wyoming + +import ( + "net" + "net/url" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +func Dial(rawURL string) (core.Producer, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + conn, err := net.DialTimeout("tcp", u.Host, core.ConnDialTimeout) + if err != nil { + return nil, err + } + + if u.Query().Get("backchannel") != "1" { + return newProducer(conn), nil + } else { + return newBackchannel(conn), nil + } +} diff --git a/installs_on_host/go2rtc/pkg/wyze/backchannel.go b/installs_on_host/go2rtc/pkg/wyze/backchannel.go new file mode 100644 index 0000000..37472c1 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/wyze/backchannel.go @@ -0,0 +1,55 @@ +package wyze + +import ( + "fmt" + + "github.com/AlexxIT/go2rtc/pkg/aac" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/tutk" + "github.com/pion/rtp" +) + +func (p *Producer) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + if err := p.client.StartIntercom(); err != nil { + return fmt.Errorf("wyze: failed to enable intercom: %w", err) + } + + // Get the camera's audio codec info (what it sent us = what it accepts) + tutkCodec, sampleRate, channels := p.client.GetBackchannelCodec() + if tutkCodec == 0 { + return fmt.Errorf("wyze: no audio codec detected from camera") + } + + if p.client.verbose { + fmt.Printf("[Wyze] Intercom enabled, using codec=0x%04x rate=%d ch=%d\n", tutkCodec, sampleRate, channels) + } + + sender := core.NewSender(media, track.Codec) + + // Track our own timestamp - camera expects timestamps starting from 0 + // and incrementing by frame duration in microseconds + var timestamp uint32 = 0 + samplesPerFrame := tutk.GetSamplesPerFrame(tutkCodec) + frameDurationUS := samplesPerFrame * 1000000 / sampleRate + + sender.Handler = func(pkt *rtp.Packet) { + if err := p.client.WriteAudio(tutkCodec, pkt.Payload, timestamp, sampleRate, channels); err == nil { + p.Send += len(pkt.Payload) + } + timestamp += frameDurationUS + } + + switch track.Codec.Name { + case core.CodecAAC: + if track.Codec.IsRTP() { + sender.Handler = aac.RTPToADTS(codec, sender.Handler) + } else { + sender.Handler = aac.EncodeToADTS(codec, sender.Handler) + } + } + + sender.HandleRTP(track) + p.Senders = append(p.Senders, sender) + + return nil +} diff --git a/installs_on_host/go2rtc/pkg/wyze/client.go b/installs_on_host/go2rtc/pkg/wyze/client.go new file mode 100644 index 0000000..0fe878e --- /dev/null +++ b/installs_on_host/go2rtc/pkg/wyze/client.go @@ -0,0 +1,618 @@ +package wyze + +import ( + "crypto/rand" + "encoding/binary" + "encoding/json" + "fmt" + "net" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "github.com/AlexxIT/go2rtc/pkg/tutk" + "github.com/AlexxIT/go2rtc/pkg/tutk/dtls" +) + +const ( + FrameSize1080P = 0 + FrameSize360P = 1 + FrameSize720P = 2 + FrameSize2K = 3 + FrameSizeFloodlight = 4 +) + +const ( + BitrateMax uint16 = 0xF0 + BitrateSD uint16 = 0x3C +) + +const ( + MediaTypeVideo = 1 + MediaTypeAudio = 2 + MediaTypeReturnAudio = 3 + MediaTypeRDT = 4 +) + +const ( + KCmdAuth = 10000 + KCmdChallenge = 10001 + KCmdChallengeResp = 10002 + KCmdAuthResult = 10003 + KCmdControlChannel = 10010 + KCmdControlChannelResp = 10011 + KCmdSetResolutionDB = 10052 + KCmdSetResolutionDBRes = 10053 + KCmdSetResolution = 10056 + KCmdSetResolutionResp = 10057 +) + +type Client struct { + conn *dtls.DTLSConn + + host string + uid string + enr string + mac string + model string + + authKey string + verbose bool + + closed bool + closeMu sync.Mutex + + hasAudio bool + hasIntercom bool + + audioCodecID byte + audioSampleRate uint32 + audioChannels uint8 +} + +type AuthResponse struct { + ConnectionRes string `json:"connectionRes"` + CameraInfo map[string]any `json:"cameraInfo"` +} + +func Dial(rawURL string) (*Client, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, fmt.Errorf("wyze: invalid URL: %w", err) + } + + query := u.Query() + + if query.Get("dtls") != "true" { + return nil, fmt.Errorf("wyze: only DTLS cameras are supported") + } + + c := &Client{ + host: u.Host, + uid: query.Get("uid"), + enr: query.Get("enr"), + mac: query.Get("mac"), + model: query.Get("model"), + verbose: query.Get("verbose") == "true", + } + + c.authKey = string(dtls.CalculateAuthKey(c.enr, c.mac)) + + if c.verbose { + fmt.Printf("[Wyze] Connecting to %s (UID: %s)\n", c.host, c.uid) + } + + if err := c.connect(); err != nil { + c.Close() + return nil, err + } + + if err := c.doAVLogin(); err != nil { + c.Close() + return nil, err + } + + if err := c.doKAuth(); err != nil { + c.Close() + return nil, err + } + + if c.verbose { + fmt.Printf("[Wyze] Connection established\n") + } + + return c, nil +} + +func (c *Client) SupportsAudio() bool { + return c.hasAudio +} + +func (c *Client) SupportsIntercom() bool { + return c.hasIntercom +} + +func (c *Client) SetBackchannelCodec(codecID byte, sampleRate uint32, channels uint8) { + c.audioCodecID = codecID + c.audioSampleRate = sampleRate + c.audioChannels = channels +} + +func (c *Client) GetBackchannelCodec() (codecID byte, sampleRate uint32, channels uint8) { + return c.audioCodecID, c.audioSampleRate, c.audioChannels +} + +func (c *Client) SetResolution(quality byte) error { + var frameSize uint8 + var bitrate uint16 + + switch quality { + case 0: // Auto/HD - use model's best + frameSize = c.hdFrameSize() + bitrate = BitrateMax + case FrameSize360P: // 1 = SD/360P + frameSize = FrameSize360P + bitrate = BitrateSD + case FrameSize720P: // 2 = 720P + frameSize = FrameSize720P + bitrate = BitrateMax + case FrameSize2K: // 3 = 2K + if c.is2K() { + frameSize = FrameSize2K + } else { + frameSize = c.hdFrameSize() + } + bitrate = BitrateMax + case FrameSizeFloodlight: // 4 = Floodlight + frameSize = c.hdFrameSize() + bitrate = BitrateMax + default: + frameSize = quality + bitrate = BitrateMax + } + + if c.verbose { + fmt.Printf("[Wyze] SetResolution: quality=%d frameSize=%d bitrate=%d model=%s\n", quality, frameSize, bitrate, c.model) + } + + // Use K10052 (doorbell format) for certain models + if c.useDoorbellResolution() { + k10052 := c.buildK10052(frameSize, bitrate) + _, err := c.conn.WriteAndWaitIOCtrl(k10052, c.matchHL(KCmdSetResolutionDBRes), 5*time.Second) + return err + } + + k10056 := c.buildK10056(frameSize, bitrate) + _, err := c.conn.WriteAndWaitIOCtrl(k10056, c.matchHL(KCmdSetResolutionResp), 5*time.Second) + return err +} + +func (c *Client) StartVideo() error { + k10010 := c.buildK10010(MediaTypeVideo, true) + _, err := c.conn.WriteAndWaitIOCtrl(k10010, c.matchHL(KCmdControlChannelResp), 5*time.Second) + return err +} + +func (c *Client) StartAudio() error { + k10010 := c.buildK10010(MediaTypeAudio, true) + _, err := c.conn.WriteAndWaitIOCtrl(k10010, c.matchHL(KCmdControlChannelResp), 5*time.Second) + return err +} + +func (c *Client) StartIntercom() error { + if c.conn == nil { + return fmt.Errorf("connection is nil") + } + + if c.conn.IsBackchannelReady() { + return nil + } + + k10010 := c.buildK10010(MediaTypeReturnAudio, true) + if _, err := c.conn.WriteAndWaitIOCtrl(k10010, c.matchHL(KCmdControlChannelResp), 5*time.Second); err != nil { + return fmt.Errorf("enable return audio: %w", err) + } + + if c.verbose { + fmt.Printf("[Wyze] Speaker channel enabled, waiting for readiness...\n") + } + + return c.conn.AVServStart() +} + +func (c *Client) StopIntercom() error { + if c.conn == nil || !c.conn.IsBackchannelReady() { + return nil + } + + k10010 := c.buildK10010(MediaTypeReturnAudio, false) + c.conn.WriteIOCtrl(k10010) + + return c.conn.AVServStop() +} + +func (c *Client) ReadPacket() (*tutk.Packet, error) { + return c.conn.AVRecvFrameData() +} + +func (c *Client) WriteAudio(codec byte, payload []byte, timestamp uint32, sampleRate uint32, channels uint8) error { + if !c.conn.IsBackchannelReady() { + return fmt.Errorf("speaker channel not connected") + } + + if c.verbose { + fmt.Printf("[Wyze] WriteAudio: codec=0x%02x, payload=%d bytes, rate=%d, ch=%d\n", codec, len(payload), sampleRate, channels) + } + + return c.conn.AVSendAudioData(codec, payload, timestamp, sampleRate, channels) +} + +func (c *Client) SetDeadline(t time.Time) error { + if c.conn != nil { + return c.conn.SetDeadline(t) + } + return nil +} + +func (c *Client) Protocol() string { + return "wyze/dtls" +} + +func (c *Client) RemoteAddr() net.Addr { + if c.conn != nil { + return c.conn.RemoteAddr() + } + return nil +} + +func (c *Client) Close() error { + c.closeMu.Lock() + if c.closed { + c.closeMu.Unlock() + return nil + } + c.closed = true + c.closeMu.Unlock() + + if c.verbose { + fmt.Printf("[Wyze] Closing connection\n") + } + + c.StopIntercom() + + if c.conn != nil { + c.conn.Close() + } + + if c.verbose { + fmt.Printf("[Wyze] Connection closed\n") + } + + return nil +} + +func (c *Client) connect() error { + host := c.host + port := 0 + + if idx := strings.Index(host, ":"); idx > 0 { + if p, err := strconv.Atoi(host[idx+1:]); err == nil { + port = p + } + host = host[:idx] + } + + conn, err := dtls.DialDTLS(host, port, c.uid, c.authKey, c.enr, c.verbose) + if err != nil { + return fmt.Errorf("wyze: connect failed: %w", err) + } + + c.conn = conn + if c.verbose { + fmt.Printf("[Wyze] Connected to %s (IOTC + DTLS)\n", conn.RemoteAddr()) + } + + return nil +} + +func (c *Client) doAVLogin() error { + if c.verbose { + fmt.Printf("[Wyze] Sending AV Login\n") + } + + if err := c.conn.AVClientStart(5 * time.Second); err != nil { + return fmt.Errorf("wyze: av login failed: %w", err) + } + + if c.verbose { + fmt.Printf("[Wyze] AV Login response received\n") + } + return nil +} + +func (c *Client) doKAuth() error { + // Step 1: K10000 -> K10001 (Challenge) + data, err := c.conn.WriteAndWaitIOCtrl(c.buildK10000(), c.matchHL(KCmdChallenge), 5*time.Second) + if err != nil { + return fmt.Errorf("wyze: K10001 failed: %w", err) + } + + hlData := c.extractHL(data) + challenge, status, err := c.parseK10001(hlData) + if err != nil { + return fmt.Errorf("wyze: K10001 parse failed: %w", err) + } + + if c.verbose { + fmt.Printf("[Wyze] K10001 challenge received, status=%d\n", status) + } + + // Step 2: K10002 -> K10003 (Auth) + data, err = c.conn.WriteAndWaitIOCtrl(c.buildK10002(challenge, status), c.matchHL(KCmdAuthResult), 5*time.Second) + if err != nil { + return fmt.Errorf("wyze: K10002 failed: %w", err) + } + hlData = c.extractHL(data) + + // Parse K10003 response + authResp, err := c.parseK10003(hlData) + if err != nil { + return fmt.Errorf("wyze: K10003 parse failed: %w", err) + } + + if c.verbose && authResp != nil { + if jsonBytes, err := json.MarshalIndent(authResp, "", " "); err == nil { + fmt.Printf("[Wyze] K10003 response:\n%s\n", jsonBytes) + } + } + + // Extract audio capability from cameraInfo + if authResp != nil && authResp.CameraInfo != nil { + if channelResult, ok := authResp.CameraInfo["channelRequestResult"].(map[string]any); ok { + if audio, ok := channelResult["audio"].(string); ok { + c.hasAudio = audio == "1" + } else { + c.hasAudio = true + } + } else { + c.hasAudio = true + } + } else { + c.hasAudio = true + } + + if c.verbose { + fmt.Printf("[Wyze] K10003 auth success\n") + } + + c.hasIntercom = c.conn.HasTwoWayStreaming() + + if c.verbose { + fmt.Printf("[Wyze] K-auth complete\n") + } + + return nil +} + +func (c *Client) buildK10000() []byte { + json := []byte(`{"cameraInfo":{"audioEncoderList":[137,138,140]}}`) // 137=PCMU, 138=PCMA, 140=PCM + b := make([]byte, 16+len(json)) + copy(b, "HL") // magic + b[2] = 5 // version + binary.LittleEndian.PutUint16(b[4:], KCmdAuth) // 10000 + binary.LittleEndian.PutUint16(b[6:], uint16(len(json))) // payload len + copy(b[16:], json) + return b +} + +func (c *Client) buildK10002(challenge []byte, status byte) []byte { + resp := generateChallengeResponse(challenge, c.enr, status) + sessionID := make([]byte, 4) + rand.Read(sessionID) + b := make([]byte, 38) + copy(b, "HL") // magic + b[2] = 5 // version + binary.LittleEndian.PutUint16(b[4:], KCmdChallengeResp) // 10002 + b[6] = 22 // payload len + copy(b[16:], resp[:16]) // challenge response + copy(b[32:], sessionID) // random session ID + b[36] = 1 // video enabled/disabled + b[37] = 1 // audio enabled/disabled + return b +} + +func (c *Client) buildK10010(mediaType byte, enabled bool) []byte { + b := make([]byte, 18) + copy(b, "HL") // magic + b[2] = 5 // version + binary.LittleEndian.PutUint16(b[4:], KCmdControlChannel) // 10010 + binary.LittleEndian.PutUint16(b[6:], 2) // payload len + b[16] = mediaType // 1=video, 2=audio, 3=return audio + b[17] = 1 // 1=enable, 2=disable + if !enabled { + b[17] = 2 + } + return b +} + +func (c *Client) buildK10052(frameSize uint8, bitrate uint16) []byte { + b := make([]byte, 22) + copy(b, "HL") // magic + b[2] = 5 // version + binary.LittleEndian.PutUint16(b[4:], KCmdSetResolutionDB) // 10052 + binary.LittleEndian.PutUint16(b[6:], 6) // payload len + binary.LittleEndian.PutUint16(b[16:], bitrate) // bitrate (2 bytes) + b[18] = frameSize + 1 // frame size (1 byte) + // b[19] = fps, b[20:22] = zeros + return b +} + +func (c *Client) buildK10056(frameSize uint8, bitrate uint16) []byte { + b := make([]byte, 21) + copy(b, "HL") // magic + b[2] = 5 // version + binary.LittleEndian.PutUint16(b[4:], KCmdSetResolution) // 10056 + binary.LittleEndian.PutUint16(b[6:], 5) // payload len + b[16] = frameSize + 1 // frame size + binary.LittleEndian.PutUint16(b[17:], bitrate) // bitrate + // b[19:21] = FPS (0 = auto) + return b +} + +func (c *Client) parseK10001(data []byte) (challenge []byte, status byte, err error) { + if c.verbose { + fmt.Printf("[Wyze] parseK10001: received %d bytes\n", len(data)) + } + + if len(data) < 33 { + return nil, 0, fmt.Errorf("data too short: %d bytes", len(data)) + } + + if data[0] != 'H' || data[1] != 'L' { + return nil, 0, fmt.Errorf("invalid HL magic: %x %x", data[0], data[1]) + } + + cmdID := binary.LittleEndian.Uint16(data[4:]) + if cmdID != KCmdChallenge { + return nil, 0, fmt.Errorf("expected cmdID 10001, got %d", cmdID) + } + + status = data[16] + challenge = make([]byte, 16) + copy(challenge, data[17:33]) + + return challenge, status, nil +} + +func (c *Client) parseK10003(data []byte) (*AuthResponse, error) { + if c.verbose { + fmt.Printf("[Wyze] parseK10003: received %d bytes\n", len(data)) + } + + if len(data) < 16 { + return &AuthResponse{}, nil + } + + if data[0] != 'H' || data[1] != 'L' { + return &AuthResponse{}, nil + } + + cmdID := binary.LittleEndian.Uint16(data[4:]) + textLen := binary.LittleEndian.Uint16(data[6:]) + + if cmdID != KCmdAuthResult { + return &AuthResponse{}, nil + } + + if len(data) > 16 && textLen > 0 { + jsonData := data[16:] + for i := range jsonData { + if jsonData[i] == '{' { + var resp AuthResponse + if err := json.Unmarshal(jsonData[i:], &resp); err == nil { + if c.verbose { + fmt.Printf("[Wyze] parseK10003: parsed JSON\n") + } + return &resp, nil + } + break + } + } + } + + return &AuthResponse{}, nil +} + +func (c *Client) useDoorbellResolution() bool { + switch c.model { + case "WYZEDB3", "WVOD1", "HL_WCO2", "WYZEC1": + return true + } + return false +} + +func (c *Client) hdFrameSize() uint8 { + if c.isFloodlight() { + return FrameSizeFloodlight + } + if c.is2K() { + return FrameSize2K + } + return FrameSize1080P +} + +func (c *Client) is2K() bool { + switch c.model { + case "HL_CAM3P", "HL_PANP", "HL_CAM4", "HL_DB2", "HL_CFL2": + return true + } + return false +} + +func (c *Client) isFloodlight() bool { + return c.model == "HL_CFL2" +} + +func (c *Client) matchHL(expectCmd uint16) func([]byte) bool { + return func(data []byte) bool { + hlData := c.extractHL(data) + if hlData == nil { + return false + } + cmd, _, ok := tutk.ParseHL(hlData) + return ok && cmd == expectCmd + } +} + +func (c *Client) extractHL(data []byte) []byte { + // Try offset 32 (magicIOCtrl, protoVersion) + if hlData := tutk.FindHL(data, 32); hlData != nil { + return hlData + } + // Try offset 36 (magicChannelMsg) + if len(data) >= 36 && data[16] == 0x00 { + return tutk.FindHL(data, 36) + } + return nil +} + +const ( + statusDefault byte = 1 + statusENR16 byte = 3 + statusENR32 byte = 6 +) + +func generateChallengeResponse(challengeBytes []byte, enr string, status byte) []byte { + var secretKey []byte + + switch status { + case statusDefault: + secretKey = []byte("FFFFFFFFFFFFFFFF") + case statusENR16: + if len(enr) >= 16 { + secretKey = []byte(enr[:16]) + } else { + secretKey = make([]byte, 16) + copy(secretKey, enr) + } + case statusENR32: + if len(enr) >= 16 { + firstKey := []byte(enr[:16]) + challengeBytes = tutk.XXTEADecryptVar(challengeBytes, firstKey) + } + if len(enr) >= 32 { + secretKey = []byte(enr[16:32]) + } else if len(enr) > 16 { + secretKey = make([]byte, 16) + copy(secretKey, []byte(enr[16:])) + } else { + secretKey = []byte("FFFFFFFFFFFFFFFF") + } + default: + secretKey = []byte("FFFFFFFFFFFFFFFF") + } + + return tutk.XXTEADecryptVar(challengeBytes, secretKey) +} diff --git a/installs_on_host/go2rtc/pkg/wyze/cloud.go b/installs_on_host/go2rtc/pkg/wyze/cloud.go new file mode 100644 index 0000000..17f914a --- /dev/null +++ b/installs_on_host/go2rtc/pkg/wyze/cloud.go @@ -0,0 +1,337 @@ +package wyze + +import ( + "crypto/md5" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +const ( + baseURLAuth = "https://auth-prod.api.wyze.com" + baseURLAPI = "https://api.wyzecam.com" + appName = "com.hualai.WyzeCam" + appVersion = "2.50.0" +) + +type Cloud struct { + client *http.Client + apiKey string + keyID string + accessToken string + phoneID string + cameras []*Camera +} + +type Camera struct { + MAC string `json:"mac"` + P2PID string `json:"p2p_id"` + ENR string `json:"enr"` + IP string `json:"ip"` + Nickname string `json:"nickname"` + ProductModel string `json:"product_model"` + ProductType string `json:"product_type"` + DTLS int `json:"dtls"` + FirmwareVer string `json:"firmware_ver"` + IsOnline bool `json:"is_online"` +} + +type deviceListResponse struct { + Code string `json:"code"` + Msg string `json:"msg"` + Data struct { + DeviceList []deviceInfo `json:"device_list"` + } `json:"data"` +} + +type deviceInfo struct { + MAC string `json:"mac"` + ENR string `json:"enr"` + Nickname string `json:"nickname"` + ProductModel string `json:"product_model"` + ProductType string `json:"product_type"` + FirmwareVer string `json:"firmware_ver"` + ConnState int `json:"conn_state"` + DeviceParams deviceParams `json:"device_params"` +} + +type deviceParams struct { + P2PID string `json:"p2p_id"` + P2PType int `json:"p2p_type"` + IP string `json:"ip"` + DTLS int `json:"dtls"` +} + +type p2pInfoResponse struct { + Code string `json:"code"` + Msg string `json:"msg"` + Data map[string]any `json:"data"` +} + +type loginResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + UserID string `json:"user_id"` + MFAOptions []string `json:"mfa_options"` + SMSSessionID string `json:"sms_session_id"` + EmailSessionID string `json:"email_session_id"` +} + +func NewCloud(apiKey, keyID string) *Cloud { + return &Cloud{ + client: &http.Client{Timeout: 30 * time.Second}, + phoneID: generatePhoneID(), + apiKey: apiKey, + keyID: keyID, + } +} + +func (c *Cloud) Login(email, password string) error { + payload := map[string]string{ + "email": strings.TrimSpace(email), + "password": hashPassword(password), + } + + jsonData, _ := json.Marshal(payload) + + req, err := http.NewRequest("POST", baseURLAuth+"/api/user/login", strings.NewReader(string(jsonData))) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Apikey", c.apiKey) + req.Header.Set("Keyid", c.keyID) + req.Header.Set("User-Agent", "go2rtc") + + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + var errResp apiError + _ = json.Unmarshal(body, &errResp) + if errResp.hasError() { + return fmt.Errorf("wyze: login failed (code %s): %s", errResp.code(), errResp.message()) + } + + var result loginResponse + if err := json.Unmarshal(body, &result); err != nil { + return fmt.Errorf("wyze: failed to parse login response: %w", err) + } + + if len(result.MFAOptions) > 0 { + return &AuthError{ + Message: "MFA required", + NeedsMFA: true, + MFAType: strings.Join(result.MFAOptions, ","), + } + } + + if result.AccessToken == "" { + return errors.New("wyze: no access token in response") + } + + c.accessToken = result.AccessToken + + return nil +} + +func (c *Cloud) GetCameraList() ([]*Camera, error) { + payload := map[string]any{ + "access_token": c.accessToken, + "phone_id": c.phoneID, + "app_name": appName, + "app_ver": appName + "___" + appVersion, + "app_version": appVersion, + "phone_system_type": 1, + "sc": "9f275790cab94a72bd206c8876429f3c", + "sv": "9d74946e652647e9b6c9d59326aef104", + "ts": time.Now().UnixMilli(), + } + + jsonData, _ := json.Marshal(payload) + + req, err := http.NewRequest("POST", baseURLAPI+"/app/v2/home_page/get_object_list", strings.NewReader(string(jsonData))) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var result deviceListResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("wyze: failed to parse device list: %w", err) + } + + if result.Code != "1" { + return nil, fmt.Errorf("wyze: API error: %s - %s", result.Code, result.Msg) + } + + c.cameras = nil + for _, dev := range result.Data.DeviceList { + if dev.ProductType != "Camera" { + continue + } + if dev.DeviceParams.IP == "" { + continue // skip cameras without IP (gwell protocol) + } + + c.cameras = append(c.cameras, &Camera{ + MAC: dev.MAC, + P2PID: dev.DeviceParams.P2PID, + ENR: dev.ENR, + IP: dev.DeviceParams.IP, + Nickname: dev.Nickname, + ProductModel: dev.ProductModel, + ProductType: dev.ProductType, + DTLS: dev.DeviceParams.DTLS, + FirmwareVer: dev.FirmwareVer, + IsOnline: dev.ConnState == 1, + }) + } + + return c.cameras, nil +} + +func (c *Cloud) GetCamera(id string) (*Camera, error) { + if c.cameras == nil { + if _, err := c.GetCameraList(); err != nil { + return nil, err + } + } + + id = strings.ToUpper(id) + for _, cam := range c.cameras { + if strings.ToUpper(cam.MAC) == id || strings.EqualFold(cam.Nickname, id) { + return cam, nil + } + } + + return nil, fmt.Errorf("wyze: camera not found: %s", id) +} + +func (c *Cloud) GetP2PInfo(mac string) (map[string]any, error) { + payload := map[string]any{ + "access_token": c.accessToken, + "phone_id": c.phoneID, + "device_mac": mac, + "app_name": appName, + "app_ver": appName + "___" + appVersion, + "app_version": appVersion, + "phone_system_type": 1, + "sc": "9f275790cab94a72bd206c8876429f3c", + "sv": "9d74946e652647e9b6c9d59326aef104", + "ts": time.Now().UnixMilli(), + } + + jsonData, _ := json.Marshal(payload) + + req, err := http.NewRequest("POST", baseURLAPI+"/app/v2/device/get_iotc_info", strings.NewReader(string(jsonData))) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var result p2pInfoResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, err + } + + if result.Code != "1" { + return nil, fmt.Errorf("wyze: API error: %s - %s", result.Code, result.Msg) + } + + return result.Data, nil +} + +type apiError struct { + Code string `json:"code"` + ErrorCode int `json:"errorCode"` + Msg string `json:"msg"` + Description string `json:"description"` +} + +func (e *apiError) hasError() bool { + if e.Code == "1" || e.Code == "0" { + return false + } + if e.Code == "" && e.ErrorCode == 0 { + return false + } + return e.Code != "" || e.ErrorCode != 0 +} + +func (e *apiError) message() string { + if e.Msg != "" { + return e.Msg + } + return e.Description +} + +func (e *apiError) code() string { + if e.Code != "" { + return e.Code + } + return fmt.Sprintf("%d", e.ErrorCode) +} + +type AuthError struct { + Message string `json:"message"` + NeedsMFA bool `json:"needs_mfa,omitempty"` + MFAType string `json:"mfa_type,omitempty"` +} + +func (e *AuthError) Error() string { + return e.Message +} + +func generatePhoneID() string { + return core.RandString(16, 16) // 16 hex chars +} + +func hashPassword(password string) string { + encoded := strings.TrimSpace(password) + if strings.HasPrefix(strings.ToLower(encoded), "md5:") { + return encoded[4:] + } + for range 3 { + hash := md5.Sum([]byte(encoded)) + encoded = hex.EncodeToString(hash[:]) + } + return encoded +} diff --git a/installs_on_host/go2rtc/pkg/wyze/producer.go b/installs_on_host/go2rtc/pkg/wyze/producer.go new file mode 100644 index 0000000..16219c4 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/wyze/producer.go @@ -0,0 +1,277 @@ +package wyze + +import ( + "fmt" + "net/url" + "time" + + "github.com/AlexxIT/go2rtc/pkg/aac" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/h264/annexb" + "github.com/AlexxIT/go2rtc/pkg/h265" + "github.com/AlexxIT/go2rtc/pkg/tutk" + "github.com/pion/rtp" +) + +type Producer struct { + core.Connection + client *Client + model string +} + +func NewProducer(rawURL string) (*Producer, error) { + client, err := Dial(rawURL) + if err != nil { + return nil, err + } + + u, _ := url.Parse(rawURL) + query := u.Query() + + // 0 = HD (default), 1 = SD/360P, 2 = 720P, 3 = 2K, 4 = Floodlight + var quality byte + switch s := query.Get("subtype"); s { + case "", "hd": + quality = 0 + case "sd": + quality = FrameSize360P + default: + quality = core.ParseByte(s) + } + + medias, err := probe(client, quality) + if err != nil { + _ = client.Close() + return nil, err + } + + prod := &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "wyze", + Protocol: client.Protocol(), + RemoteAddr: client.RemoteAddr().String(), + Source: rawURL, + Medias: medias, + Transport: client, + }, + client: client, + model: query.Get("model"), + } + + return prod, nil +} + +func (p *Producer) Start() error { + for { + if p.client.verbose { + fmt.Println("[Wyze] Reading packet...") + } + + _ = p.client.SetDeadline(time.Now().Add(core.ConnDeadline)) + pkt, err := p.client.ReadPacket() + if err != nil { + return err + } + if pkt == nil { + continue + } + + var name string + var pkt2 *core.Packet + + switch codecID := pkt.Codec; codecID { + case tutk.CodecH264: + name = core.CodecH264 + pkt2 = &core.Packet{ + Header: rtp.Header{SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, + Payload: annexb.EncodeToAVCC(pkt.Payload), + } + + case tutk.CodecH265: + name = core.CodecH265 + pkt2 = &core.Packet{ + Header: rtp.Header{SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, + Payload: annexb.EncodeToAVCC(pkt.Payload), + } + + case tutk.CodecPCMU: + name = core.CodecPCMU + pkt2 = &core.Packet{ + Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, + Payload: pkt.Payload, + } + + case tutk.CodecPCMA: + name = core.CodecPCMA + pkt2 = &core.Packet{ + Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, + Payload: pkt.Payload, + } + + case tutk.CodecAACADTS, tutk.CodecAACAlt, tutk.CodecAACRaw, tutk.CodecAACLATM: + name = core.CodecAAC + payload := pkt.Payload + if aac.IsADTS(payload) { + payload = payload[aac.ADTSHeaderLen(payload):] + } + pkt2 = &core.Packet{ + Header: rtp.Header{Version: aac.RTPPacketVersionAAC, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, + Payload: payload, + } + + case tutk.CodecOpus: + name = core.CodecOpus + pkt2 = &core.Packet{ + Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, + Payload: pkt.Payload, + } + + case tutk.CodecPCML: + name = core.CodecPCML + pkt2 = &core.Packet{ + Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, + Payload: pkt.Payload, + } + + case tutk.CodecMP3: + name = core.CodecMP3 + pkt2 = &core.Packet{ + Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, + Payload: pkt.Payload, + } + + case tutk.CodecMJPEG: + name = core.CodecJPEG + pkt2 = &core.Packet{ + Header: rtp.Header{SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, + Payload: pkt.Payload, + } + + default: + continue + } + + for _, recv := range p.Receivers { + if recv.Codec.Name == name { + recv.WriteRTP(pkt2) + break + } + } + } +} + +func probe(client *Client, quality byte) ([]*core.Media, error) { + client.SetResolution(quality) + client.SetDeadline(time.Now().Add(core.ProbeTimeout)) + + var vcodec, acodec *core.Codec + var tutkAudioCodec byte + + for { + if client.verbose { + fmt.Println("[Wyze] Probing for codecs...") + } + + pkt, err := client.ReadPacket() + if err != nil { + return nil, fmt.Errorf("wyze: probe: %w", err) + } + if pkt == nil || len(pkt.Payload) < 5 { + continue + } + + switch pkt.Codec { + case tutk.CodecH264: + if vcodec == nil { + buf := annexb.EncodeToAVCC(pkt.Payload) + if len(buf) >= 5 && h264.NALUType(buf) == h264.NALUTypeSPS { + vcodec = h264.AVCCToCodec(buf) + } + } + case tutk.CodecH265: + if vcodec == nil { + buf := annexb.EncodeToAVCC(pkt.Payload) + if len(buf) >= 5 && h265.NALUType(buf) == h265.NALUTypeVPS { + vcodec = h265.AVCCToCodec(buf) + } + } + case tutk.CodecPCMU: + if acodec == nil { + acodec = &core.Codec{Name: core.CodecPCMU, ClockRate: pkt.SampleRate, Channels: pkt.Channels} + tutkAudioCodec = pkt.Codec + } + case tutk.CodecPCMA: + if acodec == nil { + acodec = &core.Codec{Name: core.CodecPCMA, ClockRate: pkt.SampleRate, Channels: pkt.Channels} + tutkAudioCodec = pkt.Codec + } + case tutk.CodecAACAlt, tutk.CodecAACADTS, tutk.CodecAACRaw, tutk.CodecAACLATM: + if acodec == nil { + config := aac.EncodeConfig(aac.TypeAACLC, pkt.SampleRate, pkt.Channels, false) + acodec = aac.ConfigToCodec(config) + tutkAudioCodec = pkt.Codec + } + case tutk.CodecOpus: + if acodec == nil { + acodec = &core.Codec{Name: core.CodecOpus, ClockRate: 48000, Channels: 2} + tutkAudioCodec = pkt.Codec + } + case tutk.CodecPCML: + if acodec == nil { + acodec = &core.Codec{Name: core.CodecPCML, ClockRate: pkt.SampleRate, Channels: pkt.Channels} + tutkAudioCodec = pkt.Codec + } + case tutk.CodecMP3: + if acodec == nil { + acodec = &core.Codec{Name: core.CodecMP3, ClockRate: pkt.SampleRate, Channels: pkt.Channels} + tutkAudioCodec = pkt.Codec + } + case tutk.CodecMJPEG: + if vcodec == nil { + vcodec = &core.Codec{Name: core.CodecJPEG, ClockRate: 90000, PayloadType: core.PayloadTypeRAW} + } + } + + if vcodec != nil && (acodec != nil || !client.SupportsAudio()) { + break + } + } + + _ = client.SetDeadline(time.Time{}) + + medias := []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{vcodec}, + }, + } + + if acodec != nil { + medias = append(medias, &core.Media{ + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{acodec}, + }) + + if client.SupportsIntercom() { + client.SetBackchannelCodec(tutkAudioCodec, acodec.ClockRate, uint8(acodec.Channels)) + medias = append(medias, &core.Media{ + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{acodec.Clone()}, + }) + } + } + + if client.verbose { + fmt.Printf("[Wyze] Probed codecs: video=%s audio=%s\n", vcodec.Name, acodec.Name) + if client.SupportsIntercom() { + fmt.Printf("[Wyze] Intercom supported, audio send codec=%s\n", acodec.Name) + } + } + + return medias, nil +} diff --git a/installs_on_host/go2rtc/pkg/xiaomi/cloud.go b/installs_on_host/go2rtc/pkg/xiaomi/cloud.go new file mode 100644 index 0000000..0dcfd24 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/xiaomi/cloud.go @@ -0,0 +1,568 @@ +package xiaomi + +import ( + "bytes" + "crypto/md5" + "crypto/rand" + "crypto/rc4" + "crypto/sha1" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +type Cloud struct { + client *http.Client + + sid string + cookies string // for auth + ssecurity []byte // for encryption + + userID string + passToken string + + auth map[string]string +} + +func NewCloud(sid string) *Cloud { + return &Cloud{ + client: &http.Client{Timeout: 15 * time.Second}, + sid: sid, + } +} + +func (c *Cloud) Login(username, password string) error { + res, err := c.client.Get("https://account.xiaomi.com/pass/serviceLogin?_json=true&sid=" + c.sid) + if err != nil { + return err + } + + var v1 struct { + Qs string `json:"qs"` + Sign string `json:"_sign"` + Sid string `json:"sid"` + Callback string `json:"callback"` + } + if _, err = readLoginResponse(res.Body, &v1); err != nil { + return err + } + + hash := fmt.Sprintf("%X", md5.Sum([]byte(password))) + + form := url.Values{ + "_json": {"true"}, + "hash": {hash}, + "sid": {v1.Sid}, + "callback": {v1.Callback}, + "_sign": {v1.Sign}, + "qs": {v1.Qs}, + "user": {username}, + } + cookies := "deviceId=" + core.RandString(16, 62) + + // login after captcha + if c.auth != nil && c.auth["captcha_code"] != "" { + form.Set("captCode", c.auth["captcha_code"]) + cookies += "; ick=" + c.auth["ick"] + } + + req := Request{ + Method: "POST", + URL: "https://account.xiaomi.com/pass/serviceLoginAuth2", + Body: form, + RawCookies: cookies, + }.Encode() + + res, err = c.client.Do(req) + if err != nil { + return err + } + + var v2 struct { + Ssecurity []byte `json:"ssecurity"` + PassToken string `json:"passToken"` + Location string `json:"location"` + + CaptchaURL string `json:"captchaURL"` + NotificationURL string `json:"notificationUrl"` + } + body, err := readLoginResponse(res.Body, &v2) + if err != nil { + return err + } + + // save auth for two-step verification + c.auth = map[string]string{ + "username": username, + "password": password, + } + + if v2.CaptchaURL != "" { + return c.getCaptcha(v2.CaptchaURL) + } + + if v2.NotificationURL != "" { + return c.authStart(v2.NotificationURL) + } + + if v2.Location == "" { + return fmt.Errorf("xiaomi: %s", body) + } + + c.auth = nil + c.ssecurity = v2.Ssecurity + c.passToken = v2.PassToken + + return c.finishAuth(v2.Location) +} + +func (c *Cloud) LoginWithCaptcha(captcha string) error { + if c.auth == nil || c.auth["ick"] == "" { + panic("wrong login step") + } + + c.auth["captcha_code"] = captcha + + // check if captcha after verify + if c.auth["flag"] != "" { + return c.sendTicket() + } + + return c.Login(c.auth["username"], c.auth["password"]) +} + +func (c *Cloud) LoginWithVerify(ticket string) error { + if c.auth == nil || c.auth["flag"] == "" { + panic("wrong login step") + } + + req := Request{ + Method: "POST", + URL: "https://account.xiaomi.com/identity/auth/verify" + c.verifyName(), + RawParams: "_flag" + c.auth["flag"] + "&ticket=" + ticket + "&trust=false&_json=true", + RawCookies: "identity_session=" + c.auth["identity_session"], + }.Encode() + + res, err := c.client.Do(req) + if err != nil { + return err + } + + var v1 struct { + Location string `json:"location"` + } + body, err := readLoginResponse(res.Body, &v1) + if err != nil { + return err + } + if v1.Location == "" { + return fmt.Errorf("xiaomi: %s", body) + } + + return c.finishAuth(v1.Location) +} + +func (c *Cloud) getCaptcha(captchaURL string) error { + res, err := c.client.Get("https://account.xiaomi.com" + captchaURL) + if err != nil { + return err + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return err + } + + c.auth["ick"] = findCookie(res, "ick") + + return &LoginError{ + Captcha: body, + } +} + +func (c *Cloud) authStart(notificationURL string) error { + rawURL := strings.Replace(notificationURL, "/fe/service/identity/authStart", "/identity/list", 1) + res, err := c.client.Get(rawURL) + if err != nil { + return err + } + + var v1 struct { + Code int `json:"code"` + Flag int `json:"flag"` + } + if _, err = readLoginResponse(res.Body, &v1); err != nil { + return err + } + + c.auth["flag"] = strconv.Itoa(v1.Flag) + c.auth["identity_session"] = findCookie(res, "identity_session") + + return c.sendTicket() +} + +func findCookie(res *http.Response, name string) string { + for _, cookie := range res.Cookies() { + if cookie.Name == name { + return cookie.Value + } + } + return "" +} + +func (c *Cloud) verifyName() string { + switch c.auth["flag"] { + case "4": + return "Phone" + case "8": + return "Email" + } + return "" +} + +func (c *Cloud) sendTicket() error { + name := c.verifyName() + cookies := "identity_session=" + c.auth["identity_session"] + + req := Request{ + URL: "https://account.xiaomi.com/identity/auth/verify" + name, + RawParams: "_flag=" + c.auth["flag"] + "&_json=true", + RawCookies: cookies, + }.Encode() + + res, err := c.client.Do(req) + if err != nil { + return err + } + + var v1 struct { + Code int `json:"code"` + MaskedPhone string `json:"maskedPhone"` + MaskedEmail string `json:"maskedEmail"` + } + if _, err = readLoginResponse(res.Body, &v1); err != nil { + return err + } + + // verify after captcha + captCode := c.auth["captcha_code"] + if captCode != "" { + cookies += "; ick=" + c.auth["ick"] + } + + form := url.Values{ + "_json": {"true"}, + "icode": {captCode}, + "retry": {"0"}, + } + + req = Request{ + Method: "POST", + URL: "https://account.xiaomi.com/identity/auth/send" + name + "Ticket", + Body: form, + RawCookies: cookies, + }.Encode() + + res, err = c.client.Do(req) + if err != nil { + return err + } + + var v2 struct { + Code int `json:"code"` + CaptchaURL string `json:"captchaURL"` + } + body, err := readLoginResponse(res.Body, &v2) + if err != nil { + return err + } + + if v2.CaptchaURL != "" { + return c.getCaptcha(v2.CaptchaURL) + } + + if v2.Code != 0 { + return fmt.Errorf("xiaomi: %s", body) + } + + return &LoginError{ + VerifyPhone: v1.MaskedPhone, + VerifyEmail: v1.MaskedEmail, + } +} + +type LoginError struct { + Captcha []byte `json:"captcha,omitempty"` + VerifyPhone string `json:"verify_phone,omitempty"` + VerifyEmail string `json:"verify_email,omitempty"` +} + +func (l *LoginError) Error() string { + return "" +} + +func (c *Cloud) finishAuth(location string) error { + res, err := c.client.Get(location) + if err != nil { + return err + } + defer res.Body.Close() + + // LoginWithVerify + // - userId, cUserId, serviceToken from cookies + // - passToken from redirect cookies + // - ssecurity from extra header + // LoginWithToken + // - userId, cUserId, serviceToken from cookies + var cUserId, serviceToken string + + for res != nil { + for _, cookie := range res.Cookies() { + switch cookie.Name { + case "userId": + c.userID = cookie.Value + case "cUserId": + cUserId = cookie.Value + case "serviceToken": + serviceToken = cookie.Value + case "passToken": + c.passToken = cookie.Value + } + } + + if s := res.Header.Get("Extension-Pragma"); s != "" { + var v1 struct { + Ssecurity []byte `json:"ssecurity"` + } + if err = json.Unmarshal([]byte(s), &v1); err != nil { + return err + } + c.ssecurity = v1.Ssecurity + } + + res = res.Request.Response + } + + c.cookies = fmt.Sprintf("userId=%s; cUserId=%s; serviceToken=%s", c.userID, cUserId, serviceToken) + + return nil +} + +func (c *Cloud) LoginWithToken(userID, passToken string) error { + req, err := http.NewRequest("GET", "https://account.xiaomi.com/pass/serviceLogin?_json=true&sid="+c.sid, nil) + if err != nil { + return err + } + + req.Header.Set("Cookie", fmt.Sprintf("userId=%s; passToken=%s", userID, passToken)) + + res, err := c.client.Do(req) + if err != nil { + return err + } + + var v1 struct { + Ssecurity []byte `json:"ssecurity"` + PassToken string `json:"passToken"` + Location string `json:"location"` + } + if _, err = readLoginResponse(res.Body, &v1); err != nil { + return err + } + + c.ssecurity = v1.Ssecurity + c.passToken = v1.PassToken + + return c.finishAuth(v1.Location) +} + +func (c *Cloud) UserToken() (string, string) { + return c.userID, c.passToken +} + +func (c *Cloud) Request(baseURL, apiURL, params string, headers map[string]string) ([]byte, error) { + form := url.Values{"data": {params}} + + nonce := genNonce() + signedNonce := genSignedNonce(c.ssecurity, nonce) + + // 1. gen hash for data param + form.Set("rc4_hash__", genSignature64("POST", apiURL, form, signedNonce)) + + // 2. encrypt data and hash params + for _, v := range form { + ciphertext, err := crypt(signedNonce, []byte(v[0])) + if err != nil { + return nil, err + } + v[0] = base64.StdEncoding.EncodeToString(ciphertext) + } + + // 3. add signature for encrypted data and hash params + form.Set("signature", genSignature64("POST", apiURL, form, signedNonce)) + + // 4. add nonce + form.Set("_nonce", base64.StdEncoding.EncodeToString(nonce)) + + req, err := http.NewRequest("POST", baseURL+apiURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + + req.Header.Set("Cookie", c.cookies) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + for k, v := range headers { + req.Header.Set(k, v) + } + + res, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, errors.New(res.Status) + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + ciphertext, err := base64.StdEncoding.DecodeString(string(body)) + if err != nil { + return nil, err + } + + plaintext, err := crypt(signedNonce, ciphertext) + if err != nil { + return nil, err + } + + var res1 struct { + Code int `json:"code"` + Message string `json:"message"` + Result json.RawMessage `json:"result"` + } + if err = json.Unmarshal(plaintext, &res1); err != nil { + return nil, err + } + + if res1.Code != 0 { + return nil, errors.New("xiaomi: " + res1.Message) + } + + return res1.Result, nil +} + +func readLoginResponse(rc io.ReadCloser, v any) ([]byte, error) { + defer rc.Close() + + body, err := io.ReadAll(rc) + if err != nil { + return nil, err + } + + body, ok := bytes.CutPrefix(body, []byte("&&&START&&&")) + if !ok { + return nil, fmt.Errorf("xiaomi: %s", body) + } + + return body, json.Unmarshal(body, &v) +} + +func genNonce() []byte { + ts := time.Now().Unix() / 60 + + nonce := make([]byte, 12) + _, _ = rand.Read(nonce[:8]) + binary.BigEndian.PutUint32(nonce[8:], uint32(ts)) + return nonce +} + +func genSignedNonce(ssecurity, nonce []byte) []byte { + hasher := sha256.New() + hasher.Write(ssecurity) + hasher.Write(nonce) + return hasher.Sum(nil) +} + +func crypt(key, plaintext []byte) ([]byte, error) { + cipher, err := rc4.NewCipher(key) + if err != nil { + return nil, err + } + + tmp := make([]byte, 1024) + cipher.XORKeyStream(tmp, tmp) + + ciphertext := make([]byte, len(plaintext)) + cipher.XORKeyStream(ciphertext, plaintext) + + return ciphertext, nil +} + +func genSignature64(method, path string, values url.Values, signedNonce []byte) string { + s := method + "&" + path + "&data=" + values.Get("data") + if values.Has("rc4_hash__") { + s += "&rc4_hash__=" + values.Get("rc4_hash__") + } + s += "&" + base64.StdEncoding.EncodeToString(signedNonce) + + hasher := sha1.New() + hasher.Write([]byte(s)) + signature := hasher.Sum(nil) + + return base64.StdEncoding.EncodeToString(signature) +} + +type Request struct { + Method string + URL string + RawParams string + Body url.Values + Headers url.Values + RawCookies string +} + +func (r Request) Encode() *http.Request { + if r.RawParams != "" { + r.URL += "?" + r.RawParams + } + + var body io.Reader + if r.Body != nil { + body = strings.NewReader(r.Body.Encode()) + } + + req, err := http.NewRequest(r.Method, r.URL, body) + if err != nil { + return nil + } + + if r.Headers != nil { + req.Header = http.Header(r.Headers) + } + if r.Body != nil { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + if r.RawCookies != "" { + req.Header.Set("Cookie", r.RawCookies) + } + + return req +} diff --git a/installs_on_host/go2rtc/pkg/xiaomi/crypto/crypto.go b/installs_on_host/go2rtc/pkg/xiaomi/crypto/crypto.go new file mode 100644 index 0000000..16d6d1b --- /dev/null +++ b/installs_on_host/go2rtc/pkg/xiaomi/crypto/crypto.go @@ -0,0 +1,68 @@ +package crypto + +import ( + "crypto/rand" + "encoding/hex" + + "golang.org/x/crypto/chacha20" + "golang.org/x/crypto/nacl/box" +) + +func GenerateKey() ([]byte, []byte, error) { + public, private, err := box.GenerateKey(rand.Reader) + if err != nil { + return nil, nil, err + } + return public[:], private[:], err +} + +func CalcSharedKey(devicePublicB64, clientPrivateB64 string) ([]byte, error) { + var sharedKey, publicKey, privateKey [32]byte + if _, err := hex.Decode(publicKey[:], []byte(devicePublicB64)); err != nil { + return nil, err + } + if _, err := hex.Decode(privateKey[:], []byte(clientPrivateB64)); err != nil { + return nil, err + } + box.Precompute(&sharedKey, &publicKey, &privateKey) + return sharedKey[:], nil +} + +func Encode(src, key32 []byte) ([]byte, error) { + dst := make([]byte, len(src)+8) + + if _, err := rand.Read(dst[:8]); err != nil { + return nil, err + } + + nonce12 := make([]byte, 12) + copy(nonce12[4:], dst[:8]) + + c, err := chacha20.NewUnauthenticatedCipher(key32, nonce12) + if err != nil { + return nil, err + } + + c.XORKeyStream(dst[8:], src) + + return dst, nil +} + +func Decode(src, key32 []byte) ([]byte, error) { + return DecodeNonce(src[8:], src[:8], key32) +} + +func DecodeNonce(src, nonce8, key32 []byte) ([]byte, error) { + nonce12 := make([]byte, 12) + copy(nonce12[4:], nonce8) + + c, err := chacha20.NewUnauthenticatedCipher(key32, nonce12) + if err != nil { + return nil, err + } + + dst := make([]byte, len(src)) + c.XORKeyStream(dst, src) + + return dst, nil +} diff --git a/installs_on_host/go2rtc/pkg/xiaomi/legacy/client.go b/installs_on_host/go2rtc/pkg/xiaomi/legacy/client.go new file mode 100644 index 0000000..57bd0a0 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/xiaomi/legacy/client.go @@ -0,0 +1,271 @@ +package legacy + +import ( + "encoding/binary" + "errors" + "fmt" + "net/url" + + "github.com/AlexxIT/go2rtc/pkg/tutk" + "github.com/AlexxIT/go2rtc/pkg/xiaomi/crypto" +) + +func NewClient(rawURL string) (*Client, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + query := u.Query() + model := query.Get("model") + + var username, password string + var key []byte + + if query.Has("sign") { + // Legacy with encryption + key, err = crypto.CalcSharedKey(query.Get("device_public"), query.Get("client_private")) + if err != nil { + return nil, err + } + + username = fmt.Sprintf( + `{"public_key":"%s","sign":"%s","account":"admin"}`, + query.Get("client_public"), query.Get("sign"), + ) + } else if model == ModelMijia || model == ModelXiaobai { + username = "admin" + password = query.Get("password") + } else if model == ModelDafang || model == ModelXiaofang { + username = "admin" + } else { + return nil, fmt.Errorf("xiaomi: unsupported model: %s", model) + } + + conn, err := tutk.Dial(u.Host, query.Get("uid"), username, password) + if err != nil { + return nil, err + } + + if model == ModelDafang || model == ModelXiaofang { + err = xiaofangLogin(conn, query.Get("password")) + if err != nil { + _ = conn.Close() + return nil, err + } + } + + c := &Client{ + Conn: conn, + key: key, + model: model, + } + + return c, nil +} + +func xiaofangLogin(conn *tutk.Conn, password string) error { + data := tutk.ICAM(0x0400be) // ask login + if err := conn.WriteCommand(0x0100, data); err != nil { + return err + } + + _, data, err := conn.ReadCommand() // login request + if err != nil { + return err + } + + enc := data[24:] // data[23] == 3 + tutk.XXTEADecrypt(enc, enc, []byte(password)) + + enc = append(enc, 0, 0, 0, 0, 1, 1, 1) + data = tutk.ICAM(0x0400c0, enc...) // login response + if err = conn.WriteCommand(0x0100, data); err != nil { + return err + } + + _, data, err = conn.ReadCommand() + return err +} + +type Client struct { + *tutk.Conn + key []byte + model string +} + +func (c *Client) Version() string { + return fmt.Sprintf("%s (%s)", c.Conn.Version(), c.model) +} + +func (c *Client) ReadPacket() (hdr, payload []byte, err error) { + hdr, payload, err = c.Conn.ReadPacket() + if err != nil { + return + } + if c.key != nil { + if c.model == ModelAqaraG2 && hdr[0] == tutk.CodecH265 { + payload, err = DecodeVideo(payload, c.key) + } else { + // ModelAqaraG2: audio AAC + // ModelIMILABA1: video HEVC, audio PCMA + payload, err = crypto.Decode(payload, c.key) + } + } + return +} + +const ( + cmdVideoStart = 0x01ff + cmdVideoStop = 0x02ff + cmdAudioStart = 0x0300 + cmdAudioStop = 0x0301 + cmdStreamCtrlReq = 0x0320 +) + +func (c *Client) WriteCommandJSON(ctrlType uint32, format string, a ...any) error { + if len(a) > 0 { + format = fmt.Sprintf(format, a...) + } + return c.WriteCommand(ctrlType, []byte(format)) +} + +func (c *Client) StartMedia(video, audio string) error { + switch c.model { + case ModelAqaraG2: + // 0 - 1920x1080, 1 - 1280x720, 2 - ? + switch video { + case "", "fhd": + video = "0" + case "hd": + video = "1" + case "sd": + video = "2" + } + + return errors.Join( + c.WriteCommandJSON(cmdVideoStart, `{}`), + c.WriteCommandJSON(0x0605, `{"channel":%s}`, video), + c.WriteCommandJSON(0x0704, `{}`), // don't know why + ) + + case ModelIMILABA1, ModelMijia: + // 0 - auto, 1 - low, 3 - hd + switch video { + case "", "hd": + video = "3" + case "sd": + video = "1" // 2 is also low quality + case "auto": + video = "0" + } + + // quality after start + return errors.Join( + c.WriteCommandJSON(cmdAudioStart, `{}`), + c.WriteCommandJSON(cmdVideoStart, `{}`), + c.WriteCommandJSON(cmdStreamCtrlReq, `{"videoquality":%s}`, video), + ) + + case ModelXiaobai: + // 00030000 7b7d audio on + // 01030000 7b7d audio off + // 20030000 0000000001000000 fhd (1920x1080) + // 20030000 0000000002000000 hd (1280x720) + // 20030000 0000000004000000 low (640x360) + // 20030000 00000000ff000000 auto (1920x1080) + // ff010000 7b7d video tart + // ff020000 7b7d video stop + + var b byte + switch video { + case "", "fhd": + b = 1 + case "hd": + b = 2 + case "sd": + b = 4 + case "auto": + b = 0xff + } + + // quality before start + return errors.Join( + c.WriteCommandJSON(cmdAudioStart, `{}`), + c.WriteCommand(cmdStreamCtrlReq, []byte{0, 0, 0, 0, b, 0, 0, 0}), + c.WriteCommandJSON(cmdVideoStart, `{}`), + ) + + case ModelDafang, ModelXiaofang: + // 00010000 4943414d 95010400000000000000000600000000000000d20400005a07 - 90k bitrate + // 00010000 4943414d 95010400000000000000000600000000000000d20400001e07 - 30k bitrate + //var b byte + //switch video { + //case "", "hd": + // b = 0x5a // bitrate 90k + //case "sd": + // b = 0x1e // bitrate 30k + //} + //data := tutk.ICAM(0x040195, 0xd2, 4, 0, 0, b, 7) + //if err := c.WriteCommand(0x100, data); err != nil { + // return err + //} + return nil + } + + return fmt.Errorf("xiaomi: unsupported model: %s", c.model) +} + +func (c *Client) StopMedia() error { + return errors.Join( + c.WriteCommandJSON(cmdVideoStop, `{}`), + c.WriteCommand(cmdVideoStop, make([]byte, 8)), + ) +} + +func DecodeVideo(data, key []byte) ([]byte, error) { + if string(data[:4]) == "\x00\x00\x00\x01" || data[8] == 0 { + return data, nil + } + + if data[8] != 1 { + // Support could be added, but I haven't seen such cameras. + return nil, fmt.Errorf("xiaomi: unsupported encryption") + } + + nonce8 := data[:8] + i1 := binary.LittleEndian.Uint32(data[9:]) + i2 := binary.LittleEndian.Uint32(data[13:]) + data = data[17:] + src := data[i1 : i1+i2] + + for i := 32; i+16 < len(src); i += 160 { + dst, err := crypto.DecodeNonce(src[i:i+16], nonce8, key) + if err != nil { + return nil, err + } + copy(src[i:], dst) // copy result in same place + } + + return data, nil +} + +const ( + ModelAqaraG2 = "lumi.camera.gwagl01" + ModelIMILABA1 = "chuangmi.camera.ipc019e" + ModelLoockV1 = "loock.cateye.v01" + ModelXiaobai = "chuangmi.camera.xiaobai" + ModelXiaofang = "isa.camera.isc5" + // ModelMijia support miss format for new fw and legacy format for old fw + ModelMijia = "chuangmi.camera.v2" + // ModelDafang support miss format for new fw and legacy format for old fw + ModelDafang = "isa.camera.df3" +) + +func Supported(model string) bool { + switch model { + case ModelAqaraG2, ModelIMILABA1, ModelLoockV1, ModelXiaobai, ModelXiaofang: + return true + } + return false +} diff --git a/installs_on_host/go2rtc/pkg/xiaomi/legacy/producer.go b/installs_on_host/go2rtc/pkg/xiaomi/legacy/producer.go new file mode 100644 index 0000000..92375fa --- /dev/null +++ b/installs_on_host/go2rtc/pkg/xiaomi/legacy/producer.go @@ -0,0 +1,216 @@ +package legacy + +import ( + "net/url" + "time" + + "github.com/AlexxIT/go2rtc/pkg/aac" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/h264/annexb" + "github.com/AlexxIT/go2rtc/pkg/h265" + "github.com/AlexxIT/go2rtc/pkg/tutk" + "github.com/pion/rtp" +) + +func Dial(rawURL string) (*Producer, error) { + client, err := NewClient(rawURL) + if err != nil { + return nil, err + } + + u, _ := url.Parse(rawURL) + query := u.Query() + + err = client.StartMedia(query.Get("subtype"), "") + if err != nil { + _ = client.Close() + return nil, err + } + + medias, err := probe(client) + if err != nil { + _ = client.Close() + return nil, err + } + + c := &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "xiaomi/legacy", + Protocol: "tutk+udp", + RemoteAddr: client.RemoteAddr().String(), + UserAgent: client.Version(), + Medias: medias, + Transport: client, + }, + client: client, + } + return c, nil +} + +type Producer struct { + core.Connection + client *Client +} + +const codecXiaobaiPCMA = 1 // chuangmi.camera.xiaobai + +func probe(client *Client) ([]*core.Media, error) { + _ = client.SetDeadline(time.Now().Add(15 * time.Second)) + + var vcodec, acodec *core.Codec + + for { + // 0 5000 codec + // 2 0000 codec params + // 4 01 active clients + // 5 34 unknown const + // 6 0600 unknown seq(s) + // 8 80026801 unknown fixed + // 12 ed8d5c69 time in sec + // 16 4c03 time in 1/1000 + // 18 0000 + hdr, payload, err := client.ReadPacket() + if err != nil { + return nil, err + } + + switch codec := hdr[0]; codec { + case tutk.CodecH264, tutk.CodecH265: + if vcodec == nil { + avcc := annexb.EncodeToAVCC(payload) + if codec == tutk.CodecH264 { + if h264.NALUType(avcc) == h264.NALUTypeSPS { + vcodec = h264.AVCCToCodec(avcc) + } + } else { + if h265.NALUType(avcc) == h265.NALUTypeVPS { + vcodec = h265.AVCCToCodec(avcc) + } + } + } + case tutk.CodecPCMA, codecXiaobaiPCMA: + if acodec == nil { + acodec = &core.Codec{Name: core.CodecPCMA, ClockRate: 8000} + } + case tutk.CodecPCML: + if acodec == nil { + acodec = &core.Codec{Name: core.CodecPCML, ClockRate: 8000} + } + case tutk.CodecAACLATM: + if acodec == nil { + acodec = aac.ADTSToCodec(payload) + if acodec != nil { + acodec.PayloadType = core.PayloadTypeRAW + } + } + } + + if vcodec != nil && acodec != nil { + break + } + } + + medias := []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{vcodec}, + }, + { + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{acodec}, + }, + } + return medias, nil +} + +func (c *Producer) Protocol() string { + return "tutk+udp" +} + +func (c *Producer) Start() error { + var audioTS uint32 + var videoSeq, audioSeq uint16 + + for { + _ = c.client.SetDeadline(time.Now().Add(5 * time.Second)) + hdr, payload, err := c.client.ReadPacket() + if err != nil { + return err + } + + n := len(payload) + c.Recv += n + + // TODO: rewrite this + var name string + var pkt *core.Packet + + switch codec := hdr[0]; codec { + case tutk.CodecH264, tutk.CodecH265: + pkt = &core.Packet{ + Header: rtp.Header{ + SequenceNumber: videoSeq, + Timestamp: core.Now90000(), + }, + Payload: annexb.EncodeToAVCC(payload), + } + videoSeq++ + + if codec == tutk.CodecH264 { + name = core.CodecH264 + } else { + name = core.CodecH265 + } + + case tutk.CodecPCMA, tutk.CodecPCML, codecXiaobaiPCMA: + pkt = &core.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: true, + SequenceNumber: audioSeq, + Timestamp: audioTS, + }, + Payload: payload, + } + audioSeq++ + + switch codec { + case tutk.CodecPCMA, codecXiaobaiPCMA: + name = core.CodecPCMA + audioTS += uint32(n) + case tutk.CodecPCML: + name = core.CodecPCML + audioTS += uint32(n / 2) // because 16bit + } + + case tutk.CodecAACLATM: + pkt = &core.Packet{ + Header: rtp.Header{ + SequenceNumber: audioSeq, + Timestamp: audioTS, + }, + Payload: payload, + } + audioSeq++ + + name = core.CodecAAC + audioTS += 1024 + } + + for _, recv := range c.Receivers { + if recv.Codec.Name == name { + recv.WriteRTP(pkt) + break + } + } + } +} + +func (c *Producer) Stop() error { + _ = c.client.StopMedia() + return c.Connection.Stop() +} diff --git a/installs_on_host/go2rtc/pkg/xiaomi/miss/backchannel.go b/installs_on_host/go2rtc/pkg/xiaomi/miss/backchannel.go new file mode 100644 index 0000000..02ea3bb --- /dev/null +++ b/installs_on_host/go2rtc/pkg/xiaomi/miss/backchannel.go @@ -0,0 +1,74 @@ +package miss + +import ( + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/opus" + "github.com/AlexxIT/go2rtc/pkg/pcm" + "github.com/pion/rtp" +) + +func (p *Producer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error { + if err := p.client.StartSpeaker(); err != nil { + return err + } + // TODO: check this!!! + time.Sleep(time.Second) + + sender := core.NewSender(media, track.Codec) + + switch track.Codec.Name { + case core.CodecPCMA: + var buf []byte + + if p.client.SpeakerCodec() == codecPCM { + dst := &core.Codec{Name: core.CodecPCML, ClockRate: 8000} + transcode := pcm.Transcode(dst, track.Codec) + + sender.Handler = func(pkt *rtp.Packet) { + buf = append(buf, transcode(pkt.Payload)...) + const size = 2 * 8000 * 0.040 // 16bit 40ms + for len(buf) >= size { + p.Send += size + _ = p.client.WriteAudio(codecPCM, buf[:size]) + buf = buf[size:] + } + } + } else { + sender.Handler = func(pkt *rtp.Packet) { + buf = append(buf, pkt.Payload...) + const size = 8000 * 0.040 // 8bit 40 ms + for len(buf) >= size { + p.Send += size + _ = p.client.WriteAudio(codecPCMA, buf[:size]) + buf = buf[size:] + } + } + } + case core.CodecOpus: + if p.client.SpeakerCodec() == codecOPUS { + var buf []byte + sender.Handler = func(pkt *rtp.Packet) { + if buf == nil { + buf = pkt.Payload + } else { + // convert two 20ms to one 40ms + buf = opus.JoinFrames(buf, pkt.Payload) + p.Send += len(buf) + _ = p.client.WriteAudio(codecOPUS, buf) + buf = nil + } + } + } else { + sender.Handler = func(pkt *rtp.Packet) { + p.Send += len(pkt.Payload) + _ = p.client.WriteAudio(codecOPUS, pkt.Payload) + } + } + } + + sender.HandleRTP(track) + p.Senders = append(p.Senders, sender) + return nil +} diff --git a/installs_on_host/go2rtc/pkg/xiaomi/miss/client.go b/installs_on_host/go2rtc/pkg/xiaomi/miss/client.go new file mode 100644 index 0000000..3113632 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/xiaomi/miss/client.go @@ -0,0 +1,338 @@ +package miss + +import ( + "bytes" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "net" + "net/url" + "time" + + "github.com/AlexxIT/go2rtc/pkg/tutk" + "github.com/AlexxIT/go2rtc/pkg/xiaomi/crypto" + "github.com/AlexxIT/go2rtc/pkg/xiaomi/miss/cs2" +) + +const ( + codecH264 = 4 + codecH265 = 5 + codecPCM = 1024 + codecPCMU = 1026 + codecPCMA = 1027 + codecOPUS = 1032 +) + +type Conn interface { + Protocol() string + Version() string + ReadCommand() (cmd uint32, data []byte, err error) + WriteCommand(cmd uint32, data []byte) error + ReadPacket() (hdr, payload []byte, err error) + WritePacket(hdr, payload []byte) error + RemoteAddr() net.Addr + SetDeadline(t time.Time) error + Close() error +} + +func NewClient(rawURL string) (*Client, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + // 1. Check if we can create shared key. + query := u.Query() + key, err := crypto.CalcSharedKey(query.Get("device_public"), query.Get("client_private")) + if err != nil { + return nil, err + } + + model := query.Get("model") + + // 2. Check if this vendor supported. + var conn Conn + switch s := query.Get("vendor"); s { + case "cs2": + conn, err = cs2.Dial(u.Host, query.Get("transport")) + case "tutk": + conn, err = tutk.Dial(u.Host, query.Get("uid"), "Miss", "client") + default: + err = fmt.Errorf("miss: unsupported vendor %s", s) + } + + if err != nil { + return nil, err + } + + err = login(conn, query.Get("client_public"), query.Get("sign")) + if err != nil { + _ = conn.Close() + return nil, err + } + + return &Client{Conn: conn, key: key, model: model}, nil +} + +type Client struct { + Conn + key []byte + model string +} + +const ( + cmdAuthReq = 0x100 + cmdAuthRes = 0x101 + cmdVideoStart = 0x102 + cmdVideoStop = 0x103 + cmdAudioStart = 0x104 + cmdAudioStop = 0x105 + cmdSpeakerStartReq = 0x106 + cmdSpeakerStartRes = 0x107 + cmdSpeakerStop = 0x108 + cmdStreamCtrlReq = 0x109 + cmdStreamCtrlRes = 0x10A + cmdGetAudioFormatReq = 0x10B + cmdGetAudioFormatRes = 0x10C + cmdPlaybackReq = 0x10D + cmdPlaybackRes = 0x10E + cmdDevInfoReq = 0x110 + cmdDevInfoRes = 0x111 + cmdMotorReq = 0x112 + cmdMotorRes = 0x113 + cmdEncoded = 0x1001 +) + +func login(conn Conn, clientPublic, sign string) error { + s := fmt.Sprintf(`{"public_key":"%s","sign":"%s","uuid":"","support_encrypt":0}`, clientPublic, sign) + if err := conn.WriteCommand(cmdAuthReq, []byte(s)); err != nil { + return err + } + + _, data, err := conn.ReadCommand() + if err != nil { + return err + } + + if !bytes.Contains(data, []byte(`"result":"success"`)) { + return fmt.Errorf("miss: auth: %s", data) + } + + return nil +} + +func (c *Client) Version() string { + return fmt.Sprintf("%s (%s)", c.Conn.Version(), c.model) +} + +func (c *Client) WriteCommand(data []byte) error { + data, err := crypto.Encode(data, c.key) + if err != nil { + return err + } + return c.Conn.WriteCommand(cmdEncoded, data) +} + +const ( + ModelDafang = "isa.camera.df3" + ModelLoockV2 = "loock.cateye.v02" + ModelC200 = "chuangmi.camera.046c04" + ModelC300 = "chuangmi.camera.72ac1" + // ModelXiaofang looks like it has the same firmware as the ModelDafang. + // There is also an older model "isa.camera.isc5" that only works with the legacy protocol. + ModelXiaofang = "isa.camera.isc5c1" +) + +func (c *Client) StartMedia(channel, quality, audio string) error { + switch c.model { + case ModelDafang, ModelXiaofang: + var q, a byte + if quality == "sd" { + q = 1 // 0 - hd, 1 - sd, default - hd + } + if audio != "0" { + a = 1 // 0 - off, 1 - on, default - on + } + + return errors.Join( + c.WriteCommand(dafangVideoQuality(q)), + c.WriteCommand(dafangVideoStart(1, a)), + ) + } + + // 0 - auto, 1 - sd, 2 - hd, default - hd + switch quality { + case "", "hd": + // Some models have broken codec settings in quality 3. + // Some models have low quality in quality 2. + // Different models require different default quality settings. + switch c.model { + case ModelC200, ModelC300: + quality = "3" + default: + quality = "2" + } + case "sd": + quality = "1" + case "auto": + quality = "0" + } + + if audio == "" { + audio = "1" + } + + data := binary.BigEndian.AppendUint32(nil, cmdVideoStart) + switch channel { + case "", "0": + data = fmt.Appendf(data, `{"videoquality":%s,"enableaudio":%s}`, quality, audio) + default: + data = fmt.Appendf(data, `{"videoquality":-1,"videoquality2":%s,"enableaudio":%s}`, quality, audio) + } + return c.WriteCommand(data) +} + +func (c *Client) StopMedia() error { + data := binary.BigEndian.AppendUint32(nil, cmdVideoStop) + return c.WriteCommand(data) +} + +func (c *Client) StartAudio() error { + data := binary.BigEndian.AppendUint32(nil, cmdAudioStart) + return c.WriteCommand(data) +} + +func (c *Client) StartSpeaker() error { + data := binary.BigEndian.AppendUint32(nil, cmdSpeakerStartReq) + return c.WriteCommand(data) +} + +// SpeakerCodec if the camera model has a non-standard two-way codec. +func (c *Client) SpeakerCodec() uint32 { + switch c.model { + case ModelDafang, ModelXiaofang, "isa.camera.hlc6": + return codecPCM + case "chuangmi.camera.72ac1": + return codecOPUS + } + return 0 +} + +const hdrSize = 32 + +func (c *Client) ReadPacket() (*Packet, error) { + hdr, payload, err := c.Conn.ReadPacket() + if err != nil { + return nil, fmt.Errorf("miss: read media: %w", err) + } + + if len(hdr) < hdrSize { + return nil, fmt.Errorf("miss: packet header too small") + } + + payload, err = crypto.Decode(payload, c.key) + if err != nil { + return nil, err + } + + pkt := &Packet{ + CodecID: binary.LittleEndian.Uint32(hdr[4:]), + Sequence: binary.LittleEndian.Uint32(hdr[8:]), + Flags: binary.LittleEndian.Uint32(hdr[12:]), + Payload: payload, + } + + switch c.model { + case ModelDafang, ModelXiaofang, ModelLoockV2: + // Dafang has ts in sec + // LoockV2 has ts in msec for video, but zero ts for audio + pkt.Timestamp = uint64(time.Now().UnixMilli()) + default: + pkt.Timestamp = binary.LittleEndian.Uint64(hdr[16:]) + } + + return pkt, nil +} + +func (c *Client) WriteAudio(codecID uint32, payload []byte) error { + payload, err := crypto.Encode(payload, c.key) // new payload will have new size! + if err != nil { + return err + } + + n := uint32(len(payload)) + + header := make([]byte, hdrSize) + binary.LittleEndian.PutUint32(header, n) + binary.LittleEndian.PutUint32(header[4:], codecID) + binary.LittleEndian.PutUint64(header[16:], uint64(time.Now().UnixMilli())) // not really necessary + return c.Conn.WritePacket(header, payload) +} + +type Packet struct { + //Length uint32 + CodecID uint32 + Sequence uint32 + Flags uint32 + Timestamp uint64 // msec + //TimestampS uint32 + //Reserved uint32 + Payload []byte +} + +func (p *Packet) SampleRate() uint32 { + // flag: 1 0011 000 - sample rate 16000 + // flag: 100 00 01 0000 000 - sample rate 8000 + v := (p.Flags >> 3) & 0b1111 + if v != 0 { + return 16000 + } + return 8000 +} + +//func (p *Packet) AudioUnknown1() byte { +// return byte((p.Flags >> 7) & 0b11) +//} +// +//func (p *Packet) AudioUnknown2() byte { +// return byte((p.Flags >> 9) & 0b11) +//} + +func dafangRaw(cmd uint32, args ...byte) []byte { + payload := tutk.ICAM(cmd, args...) + + data := make([]byte, 4+len(payload)*2) + copy(data, "\x7f\xff\xff\xff") + hex.Encode(data[4:], payload) + return data +} + +// DafangVideoQuality 0 - hd, 1 - sd +func dafangVideoQuality(quality uint8) []byte { + return dafangRaw(0xff07d5, quality) +} + +func dafangVideoStart(video, audio uint8) []byte { + return dafangRaw(0xff07d8, video, audio) +} + +//func dafangLeft() []byte { +// return dafangRaw(0xff2404, 2, 0, 5) +//} +// +//func dafangRight() []byte { +// return dafangRaw(0xff2404, 1, 0, 5) +//} +// +//func dafangUp() []byte { +// return dafangRaw(0xff2404, 0, 2, 5) +//} +// +//func dafangDown() []byte { +// return dafangRaw(0xff2404, 0, 1, 5) +//} +// +//func dafangStop() []byte { +// return dafangRaw(0xff2404, 0, 0, 5) +//} diff --git a/installs_on_host/go2rtc/pkg/xiaomi/miss/cs2/conn.go b/installs_on_host/go2rtc/pkg/xiaomi/miss/cs2/conn.go new file mode 100644 index 0000000..2c1b395 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/xiaomi/miss/cs2/conn.go @@ -0,0 +1,506 @@ +package cs2 + +import ( + "bufio" + "bytes" + "encoding/binary" + "fmt" + "io" + "net" + "sync" + "sync/atomic" + "time" +) + +func Dial(host, transport string) (*Conn, error) { + conn, err := handshake(host, transport) + if err != nil { + return nil, err + } + + _, isTCP := conn.(*tcpConn) + + c := &Conn{ + Conn: conn, + isTCP: isTCP, + channels: [4]*dataChannel{ + newDataChannel(0, 10), nil, newDataChannel(250, 100), nil, + }, + } + go c.worker() + return c, nil +} + +type Conn struct { + net.Conn + isTCP bool + + err error + seqCh0 uint16 + seqCh3 uint16 + + channels [4]*dataChannel + + cmdMu sync.Mutex + cmdAck func() +} + +const ( + magic = 0xF1 + magicDrw = 0xD1 + magicTCP = 0x68 + msgLanSearch = 0x30 + msgPunchPkt = 0x41 + msgP2PRdyUDP = 0x42 + msgP2PRdyTCP = 0x43 + msgDrw = 0xD0 + msgDrwAck = 0xD1 + msgPing = 0xE0 + msgPong = 0xE1 + msgClose = 0xF0 + msgCloseAck = 0xF1 +) + +func handshake(host, transport string) (net.Conn, error) { + conn, err := newUDPConn(host, 32108) + if err != nil { + return nil, err + } + + _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) + + req := []byte{magic, msgLanSearch, 0, 0} + res, err := conn.(*udpConn).WriteUntil(req, func(res []byte) bool { + return res[1] == msgPunchPkt + }) + if err != nil { + _ = conn.Close() + return nil, err + } + + var msgUDP, msgTCP byte + + if transport == "" || transport == "udp" { + msgUDP = msgP2PRdyUDP + } + if transport == "" || transport == "tcp" { + msgTCP = msgP2PRdyTCP + } + + res, err = conn.(*udpConn).WriteUntil(res, func(res []byte) bool { + return res[1] == msgUDP || res[1] == msgTCP + }) + if err != nil { + _ = conn.Close() + return nil, err + } + + _ = conn.SetDeadline(time.Time{}) + + if res[1] == msgTCP { + _ = conn.Close() + //host := fmt.Sprintf("%d.%d.%d.%d:%d", b[31], b[30], b[29], b[28], uint16(b[27])<<8|uint16(b[26])) + return newTCPConn(conn.RemoteAddr().String()) + } + + return conn, nil +} + +func (c *Conn) worker() { + defer func() { + c.channels[0].Close() + c.channels[2].Close() + }() + + var keepaliveTS time.Time // only for TCP + + buf := make([]byte, 1200) + + for { + n, err := c.Conn.Read(buf) + if err != nil { + c.err = fmt.Errorf("%s: %w", "cs2", err) + return + } + + // 0 f1d0 magic + // 2 005d size = total size + 4 + // 4 d1 magic + // 5 00 channel + // 6 0000 seq + switch buf[1] { + case msgDrw: + ch := buf[5] + channel := c.channels[ch] + + if c.isTCP { + // For TCP we should send ping every second to keep connection alive. + // Based on PCAP analysis: official Mi Home app sends PING every ~1s. + if now := time.Now(); now.After(keepaliveTS) { + _, _ = c.Conn.Write([]byte{magic, msgPing, 0, 0}) + keepaliveTS = now.Add(time.Second) + } + + err = channel.Push(buf[8:n]) + } else { + var pushed int + + seqHI, seqLO := buf[6], buf[7] + seq := uint16(seqHI)<<8 | uint16(seqLO) + pushed, err = channel.PushSeq(seq, buf[8:n]) + + if pushed >= 0 { + // For UDP we should send ACK. + ack := []byte{magic, msgDrwAck, 0, 6, magicDrw, ch, 0, 1, seqHI, seqLO} + _, _ = c.Conn.Write(ack) + } + } + + if err != nil { + c.err = fmt.Errorf("%s: %w", "cs2", err) + return + } + + case msgPing: + _, _ = c.Conn.Write([]byte{magic, msgPong, 0, 0}) + case msgPong, msgP2PRdyUDP, msgP2PRdyTCP, msgClose, msgCloseAck: // skip it + case msgDrwAck: // only for UDP + if c.cmdAck != nil { + c.cmdAck() + } + default: + fmt.Printf("%s: unknown msg: %x\n", "cs2", buf[:n]) + } + } +} + +func (c *Conn) Protocol() string { + if c.isTCP { + return "cs2+tcp" + } + return "cs2+udp" +} + +func (c *Conn) Version() string { + return "CS2" +} + +func (c *Conn) Error() error { + if c.err != nil { + return c.err + } + return io.EOF +} + +func (c *Conn) ReadCommand() (cmd uint32, data []byte, err error) { + buf, ok := c.channels[0].Pop() + if !ok { + return 0, nil, c.Error() + } + cmd = binary.LittleEndian.Uint32(buf) + data = buf[4:] + return +} + +func (c *Conn) WriteCommand(cmd uint32, data []byte) error { + c.cmdMu.Lock() + defer c.cmdMu.Unlock() + + req := marshalCmd(0, c.seqCh0, cmd, data) + c.seqCh0++ + + if c.isTCP { + _, err := c.Conn.Write(req) + return err + } + + var repeat atomic.Int32 + repeat.Store(5) + + timeout := time.NewTicker(time.Second) + defer timeout.Stop() + + c.cmdAck = func() { + repeat.Store(0) + timeout.Reset(1) + } + + for { + if _, err := c.Conn.Write(req); err != nil { + return err + } + <-timeout.C + r := repeat.Add(-1) + if r < 0 { + return nil + } + if r == 0 { + return fmt.Errorf("%s: can't send command %d", "cs2", cmd) + } + } +} + +const hdrSize = 32 + +func (c *Conn) ReadPacket() (hdr, payload []byte, err error) { + data, ok := c.channels[2].Pop() + if !ok { + return nil, nil, c.Error() + } + return data[:hdrSize], data[hdrSize:], nil +} + +func (c *Conn) WritePacket(hdr, payload []byte) error { + const offset = 12 + + n := hdrSize + uint32(len(payload)) + req := make([]byte, n+offset) + req[0] = magic + req[1] = msgDrw + binary.BigEndian.PutUint16(req[2:], uint16(n+8)) + + req[4] = magicDrw + req[5] = 3 // channel + binary.BigEndian.PutUint16(req[6:], c.seqCh3) + c.seqCh3++ + binary.BigEndian.PutUint32(req[8:], n) + copy(req[offset:], hdr) + copy(req[offset+hdrSize:], hdr) + + _, err := c.Conn.Write(req) + return err +} + +func marshalCmd(channel byte, seq uint16, cmd uint32, payload []byte) []byte { + size := len(payload) + req := make([]byte, 4+4+4+4+size) + + // 1. message header (4 bytes) + req[0] = magic + req[1] = msgDrw + binary.BigEndian.PutUint16(req[2:], uint16(4+4+4+size)) + + // 2. drw? header (4 bytes) + req[4] = magicDrw + req[5] = channel + binary.BigEndian.PutUint16(req[6:], seq) + + // 3. payload size (4 bytes) + binary.BigEndian.PutUint32(req[8:], uint32(4+size)) + + // 4. payload command (4 bytes) + binary.BigEndian.PutUint32(req[12:], cmd) + + // 5. payload + copy(req[16:], payload) + + return req +} + +func newUDPConn(host string, port int) (net.Conn, error) { + // We using raw net.UDPConn, because RemoteAddr should be changed during handshake. + conn, err := net.ListenUDP("udp", nil) + if err != nil { + return nil, err + } + + addr, err := net.ResolveUDPAddr("udp", host) + if err != nil { + addr = &net.UDPAddr{IP: net.ParseIP(host), Port: port} + } + + return &udpConn{UDPConn: conn, addr: addr}, nil +} + +type udpConn struct { + *net.UDPConn + addr *net.UDPAddr +} + +func (c *udpConn) Read(b []byte) (n int, err error) { + var addr *net.UDPAddr + for { + n, addr, err = c.UDPConn.ReadFromUDP(b) + if err != nil { + return 0, err + } + + if string(addr.IP) == string(c.addr.IP) || n >= 8 { + //log.Printf("<- %x", b[:n]) + return + } + } +} + +func (c *udpConn) Write(b []byte) (n int, err error) { + //log.Printf("-> %x", b) + return c.UDPConn.WriteToUDP(b, c.addr) +} + +func (c *udpConn) RemoteAddr() net.Addr { + return c.addr +} + +func (c *udpConn) WriteUntil(req []byte, ok func(res []byte) bool) ([]byte, error) { + var t *time.Timer + t = time.AfterFunc(1, func() { + if _, err := c.Write(req); err == nil && t != nil { + t.Reset(time.Second) + } + }) + defer t.Stop() + + buf := make([]byte, 1200) + + for { + n, addr, err := c.UDPConn.ReadFromUDP(buf) + if err != nil { + return nil, err + } + + if string(addr.IP) != string(c.addr.IP) || n < 16 { + continue // skip messages from another IP + } + + if ok(buf[:n]) { + c.addr.Port = addr.Port + return buf[:n], nil + } + } +} + +func newTCPConn(addr string) (net.Conn, error) { + conn, err := net.DialTimeout("tcp", addr, 3*time.Second) + if err != nil { + return nil, err + } + return &tcpConn{conn.(*net.TCPConn), bufio.NewReader(conn)}, nil +} + +type tcpConn struct { + *net.TCPConn + rd *bufio.Reader +} + +func (c *tcpConn) Read(p []byte) (n int, err error) { + tmp := make([]byte, 8) + if _, err = io.ReadFull(c.rd, tmp); err != nil { + return + } + n = int(binary.BigEndian.Uint16(tmp)) + if len(p) < n { + return 0, fmt.Errorf("tcp: buffer too small") + } + _, err = io.ReadFull(c.rd, p[:n]) + //log.Printf("<- %x%x", tmp, p[:n]) + return +} + +func (c *tcpConn) Write(req []byte) (n int, err error) { + n = len(req) + buf := make([]byte, 8+n) + binary.BigEndian.PutUint16(buf, uint16(n)) + buf[2] = magicTCP + copy(buf[8:], req) + //log.Printf("-> %x", buf) + _, err = c.TCPConn.Write(buf) + return +} + +func newDataChannel(pushSize, popSize int) *dataChannel { + c := &dataChannel{} + if pushSize > 0 { + c.pushBuf = make(map[uint16][]byte, pushSize) + c.pushSize = pushSize + } + if popSize >= 0 { + c.popBuf = make(chan []byte, popSize) + } + return c +} + +type dataChannel struct { + waitSeq uint16 + pushBuf map[uint16][]byte + pushSize int + + waitData []byte + waitSize int + popBuf chan []byte +} + +func (c *dataChannel) Push(b []byte) error { + c.waitData = append(c.waitData, b...) + + for len(c.waitData) > 4 { + // Every new data starts with size. There can be several data inside one packet. + if c.waitSize == 0 { + c.waitSize = int(binary.BigEndian.Uint32(c.waitData)) + c.waitData = c.waitData[4:] + } + if c.waitSize > len(c.waitData) { + break + } + + select { + case c.popBuf <- c.waitData[:c.waitSize]: + default: + return fmt.Errorf("pop buffer is full") + } + + c.waitData = c.waitData[c.waitSize:] + c.waitSize = 0 + } + return nil +} + +func (c *dataChannel) Pop() ([]byte, bool) { + data, ok := <-c.popBuf + return data, ok +} + +func (c *dataChannel) Close() { + close(c.popBuf) +} + +// PushSeq returns how many seq were processed. +// Returns 0 if seq was saved or processed earlier. +// Returns -1 if seq could not be saved (buffer full or disabled). +func (c *dataChannel) PushSeq(seq uint16, data []byte) (int, error) { + diff := int16(seq - c.waitSeq) + // Check if this is seq from the future. + if diff > 0 { + // Support disabled buffer. + if c.pushSize == 0 { + return -1, nil // couldn't save seq + } + // Check if we don't have this seq in the buffer. + if c.pushBuf[seq] == nil { + // Check if there is enough space in the buffer. + if len(c.pushBuf) == c.pushSize { + return -1, nil // couldn't save seq + } + c.pushBuf[seq] = bytes.Clone(data) + //log.Printf("push buf wait=%d seq=%d len=%d", c.waitSeq, seq, len(c.pushBuf)) + } + return 0, nil + } + + // Check if this is seq from the past. + if diff < 0 { + return 0, nil + } + + for i := 1; ; i++ { + if err := c.Push(data); err != nil { + return i, err + } + c.waitSeq++ + // Check if we have next seq in the buffer. + if data = c.pushBuf[c.waitSeq]; data != nil { + delete(c.pushBuf, c.waitSeq) + } else { + return i, nil + } + } +} diff --git a/installs_on_host/go2rtc/pkg/xiaomi/miss/producer.go b/installs_on_host/go2rtc/pkg/xiaomi/miss/producer.go new file mode 100644 index 0000000..dcea284 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/xiaomi/miss/producer.go @@ -0,0 +1,204 @@ +package miss + +import ( + "fmt" + "net/url" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/h264/annexb" + "github.com/AlexxIT/go2rtc/pkg/h265" + "github.com/pion/rtp" +) + +type Producer struct { + core.Connection + client *Client +} + +func Dial(rawURL string) (core.Producer, error) { + client, err := NewClient(rawURL) + if err != nil { + return nil, err + } + + u, _ := url.Parse(rawURL) + query := u.Query() + + err = client.StartMedia(query.Get("channel"), query.Get("subtype"), query.Get("audio")) + if err != nil { + _ = client.Close() + return nil, err + } + + medias, err := probe(client, query.Get("audio") != "0") + if err != nil { + _ = client.Close() + return nil, err + } + + return &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "xiaomi/miss", + Protocol: client.Protocol(), + RemoteAddr: client.RemoteAddr().String(), + UserAgent: client.Version(), + Medias: medias, + Transport: client, + }, + client: client, + }, nil +} + +func probe(client *Client, audio bool) ([]*core.Media, error) { + _ = client.SetDeadline(time.Now().Add(15 * time.Second)) + + var vcodec, acodec *core.Codec + + for { + pkt, err := client.ReadPacket() + if err != nil { + if vcodec != nil { + err = fmt.Errorf("no audio") + } else if acodec != nil { + err = fmt.Errorf("no video") + } + return nil, fmt.Errorf("xiaomi: probe: %w", err) + } + + switch pkt.CodecID { + case codecH264: + if vcodec == nil { + buf := annexb.EncodeToAVCC(pkt.Payload) + if h264.NALUType(buf) == h264.NALUTypeSPS { + vcodec = h264.AVCCToCodec(buf) + } + } + case codecH265: + if vcodec == nil { + buf := annexb.EncodeToAVCC(pkt.Payload) + if h265.NALUType(buf) == h265.NALUTypeVPS { + vcodec = h265.AVCCToCodec(buf) + } + } + case codecPCMA: + if acodec == nil { + acodec = &core.Codec{Name: core.CodecPCMA, ClockRate: pkt.SampleRate()} + } + case codecOPUS: + if acodec == nil { + acodec = &core.Codec{Name: core.CodecOpus, ClockRate: 48000, Channels: 2} + } + } + + if vcodec != nil && (acodec != nil || !audio) { + break + } + } + + _ = client.SetDeadline(time.Time{}) + + medias := []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{vcodec}, + }, + } + + if acodec != nil { + medias = append(medias, &core.Media{ + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{acodec}, + }) + + medias = append(medias, &core.Media{ + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{acodec.Clone()}, + }) + } + + return medias, nil +} + +const timestamp40ms = 48000 * 0.040 + +func (p *Producer) Start() error { + var audioTS uint32 + + for { + _ = p.client.SetDeadline(time.Now().Add(10 * time.Second)) + pkt, err := p.client.ReadPacket() + if err != nil { + return err + } + + p.Recv += len(pkt.Payload) + + // TODO: rewrite this + var name string + var pkt2 *core.Packet + + switch pkt.CodecID { + case codecH264, codecH265: + pkt2 = &core.Packet{ + Header: rtp.Header{ + SequenceNumber: uint16(pkt.Sequence), + Timestamp: TimeToRTP(pkt.Timestamp, 90000), + }, + Payload: annexb.EncodeToAVCC(pkt.Payload), + } + if pkt.CodecID == codecH264 { + name = core.CodecH264 + } else { + name = core.CodecH265 + } + case codecPCMA: + name = core.CodecPCMA + pkt2 = &core.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: true, + SequenceNumber: uint16(pkt.Sequence), + Timestamp: audioTS, + }, + Payload: pkt.Payload, + } + audioTS += uint32(len(pkt.Payload)) + case codecOPUS: + name = core.CodecOpus + pkt2 = &core.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: true, + SequenceNumber: uint16(pkt.Sequence), + Timestamp: audioTS, + }, + Payload: pkt.Payload, + } + // known cameras sends packets with 40ms long + audioTS += timestamp40ms + } + + for _, recv := range p.Receivers { + if recv.Codec.Name == name { + recv.WriteRTP(pkt2) + break + } + } + } +} + +func (p *Producer) Stop() error { + _ = p.client.StopMedia() + return p.Connection.Stop() +} + +// TimeToRTP convert time in milliseconds to RTP time +func TimeToRTP(timeMS, clockRate uint64) uint32 { + return uint32(timeMS * clockRate / 1000) +} diff --git a/installs_on_host/go2rtc/pkg/xiaomi/producer.go b/installs_on_host/go2rtc/pkg/xiaomi/producer.go new file mode 100644 index 0000000..d0290f2 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/xiaomi/producer.go @@ -0,0 +1,23 @@ +package xiaomi + +import ( + "strings" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/xiaomi/legacy" + "github.com/AlexxIT/go2rtc/pkg/xiaomi/miss" +) + +func Dial(rawURL string) (core.Producer, error) { + // Format: xiaomi/miss + if strings.Contains(rawURL, "vendor") { + return miss.Dial(rawURL) + } + + // Format: xiaomi/legacy + return legacy.Dial(rawURL) +} + +func IsLegacy(model string) bool { + return legacy.Supported(model) +} diff --git a/installs_on_host/go2rtc/pkg/xnet/net.go b/installs_on_host/go2rtc/pkg/xnet/net.go new file mode 100644 index 0000000..1636150 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/xnet/net.go @@ -0,0 +1,64 @@ +package xnet + +import ( + "net" + "strconv" +) + +// Docker has common docker addresses (class B): +// https://en.wikipedia.org/wiki/Private_network +// - docker0 172.17.0.1/16 +// - br-xxxx 172.18.0.1/16 +// - hassio 172.30.32.1/23 +var Docker = net.IPNet{ + IP: []byte{172, 16, 0, 0}, + Mask: []byte{255, 240, 0, 0}, +} + +// ParseUnspecifiedPort will return port if address is unspecified +// ex. ":8555" or "0.0.0.0:8555" +func ParseUnspecifiedPort(address string) int { + host, port, err := net.SplitHostPort(address) + if err != nil { + return 0 + } + + if host != "" && host != "0.0.0.0" && host != "[::]" { + return 0 + } + + i, _ := strconv.Atoi(port) + return i +} + +func IPNets(ipFilter func(ip net.IP) bool) ([]*net.IPNet, error) { + ifaces, err := net.Interfaces() + if err != nil { + return nil, err + } + + var nets []*net.IPNet + + for _, iface := range ifaces { + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { + continue + } + + addrs, _ := iface.Addrs() // range on nil slice is OK + for _, addr := range addrs { + switch v := addr.(type) { + case *net.IPNet: + ip := v.IP.To4() + if ip == nil { + continue + } + if ipFilter != nil && !ipFilter(ip) { + continue + } + nets = append(nets, v) + } + } + } + + return nets, nil +} diff --git a/installs_on_host/go2rtc/pkg/xnet/tls/tls.go b/installs_on_host/go2rtc/pkg/xnet/tls/tls.go new file mode 100644 index 0000000..b4b6f60 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/xnet/tls/tls.go @@ -0,0 +1,63 @@ +package tls + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net" + "time" +) + +func CreateCertificate() (*tls.Certificate, error) { + // 1. Generate an RSA private key + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, err + } + + // 2. Define the certificate template + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + return nil, err + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"home"}, + CommonName: "localhost", + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), // Valid for 1 year + + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + + // Add localhost as a valid IP and DNS name + IPAddresses: []net.IP{[]byte{127, 0, 0, 1}}, + DNSNames: []string{"localhost"}, + } + + // 3. Create a self-signed certificate + // The parent is the template itself, and we use the generated public and private keys. + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) + if err != nil { + return nil, err + } + + derBytes = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + keyBytes := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)}) + + cert, err := tls.X509KeyPair(derBytes, keyBytes) + if err != nil { + return nil, err + } + + return &cert, nil +} diff --git a/installs_on_host/go2rtc/pkg/y4m/README.md b/installs_on_host/go2rtc/pkg/y4m/README.md new file mode 100644 index 0000000..ff97813 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/y4m/README.md @@ -0,0 +1,19 @@ +## Planar YUV formats + +Packed YUV - yuyv422 - YUYV 4:2:2 +Semi-Planar - nv12 - Y/CbCr 4:2:0 +Planar YUV - yuv420p - Planar YUV 4:2:0 - aka. [cosited](https://manned.org/yuv4mpeg.5) + +``` +[video4linux2,v4l2 @ 0x55fddc42a940] Raw : yuyv422 : YUYV 4:2:2 : 1920x1080 +[video4linux2,v4l2 @ 0x55fddc42a940] Raw : nv12 : Y/CbCr 4:2:0 : 1920x1080 +[video4linux2,v4l2 @ 0x55fddc42a940] Raw : yuv420p : Planar YUV 4:2:0 : 1920x1080 +``` + +## Useful links + +- https://learn.microsoft.com/en-us/windows/win32/medfound/recommended-8-bit-yuv-formats-for-video-rendering +- https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Video_concepts +- https://fourcc.org/yuv.php#YV12 +- https://docs.kernel.org/userspace-api/media/v4l/pixfmt-yuv-planar.html +- https://gist.github.com/Jim-Bar/3cbba684a71d1a9d468a6711a6eddbeb diff --git a/installs_on_host/go2rtc/pkg/y4m/consumer.go b/installs_on_host/go2rtc/pkg/y4m/consumer.go new file mode 100644 index 0000000..dd9b46e --- /dev/null +++ b/installs_on_host/go2rtc/pkg/y4m/consumer.go @@ -0,0 +1,65 @@ +package y4m + +import ( + "fmt" + "io" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +type Consumer struct { + core.Connection + wr *core.WriteBuffer +} + +func NewConsumer() *Consumer { + wr := core.NewWriteBuffer(nil) + return &Consumer{ + core.Connection{ + ID: core.NewID(), + Transport: wr, + FormatName: "yuv4mpegpipe", + Medias: []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecRAW}, + }, + }, + }, + }, + wr, + } +} + +func (c *Consumer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error { + sender := core.NewSender(media, track.Codec) + sender.Handler = func(packet *rtp.Packet) { + if n, err := c.wr.Write([]byte(frameHdr)); err == nil { + c.Send += n + } + if n, err := c.wr.Write(packet.Payload); err == nil { + c.Send += n + } + } + + hdr := fmt.Sprintf( + "YUV4MPEG2 W%s H%s C%s\n", + core.Between(track.Codec.FmtpLine, "width=", ";"), + core.Between(track.Codec.FmtpLine, "height=", ";"), + core.Between(track.Codec.FmtpLine, "colorspace=", ";"), + ) + if _, err := c.wr.Write([]byte(hdr)); err != nil { + return err + } + + sender.HandleRTP(track) + c.Senders = append(c.Senders, sender) + return nil +} + +func (c *Consumer) WriteTo(wr io.Writer) (int64, error) { + return c.wr.WriteTo(wr) +} diff --git a/installs_on_host/go2rtc/pkg/y4m/producer.go b/installs_on_host/go2rtc/pkg/y4m/producer.go new file mode 100644 index 0000000..ee2dd73 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/y4m/producer.go @@ -0,0 +1,83 @@ +package y4m + +import ( + "bufio" + "errors" + "io" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +func Open(r io.Reader) (*Producer, error) { + rd := bufio.NewReaderSize(r, core.BufferSize) + b, err := rd.ReadBytes('\n') + if err != nil { + return nil, err + } + + b = b[:len(b)-1] // remove \n + + fmtp := ParseHeader(b) + + if GetSize(fmtp) == 0 { + return nil, errors.New("y4m: unsupported format: " + string(b)) + } + + medias := []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + { + Name: core.CodecRAW, + ClockRate: 90000, + FmtpLine: fmtp, + PayloadType: core.PayloadTypeRAW, + }, + }, + }, + } + return &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "yuv4mpegpipe", + Medias: medias, + SDP: string(b), + Transport: r, + }, + rd: rd, + }, nil +} + +type Producer struct { + core.Connection + rd *bufio.Reader +} + +func (c *Producer) Start() error { + size := GetSize(c.Medias[0].Codecs[0].FmtpLine) + + for { + if _, err := c.rd.Discard(len(frameHdr)); err != nil { + return err + } + + frame := make([]byte, size) + if _, err := io.ReadFull(c.rd, frame); err != nil { + return err + } + + c.Recv += size + + if len(c.Receivers) == 0 { + continue + } + + pkt := &rtp.Packet{ + Header: rtp.Header{Timestamp: core.Now90000()}, + Payload: frame, + } + c.Receivers[0].WriteRTP(pkt) + } +} diff --git a/installs_on_host/go2rtc/pkg/y4m/y4m.go b/installs_on_host/go2rtc/pkg/y4m/y4m.go new file mode 100644 index 0000000..24c4316 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/y4m/y4m.go @@ -0,0 +1,149 @@ +package y4m + +import ( + "bytes" + "image" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +const FourCC = "YUV4" + +const frameHdr = "FRAME\n" + +func ParseHeader(b []byte) (fmtp string) { + for b != nil { + // YUV4MPEG2 W1280 H720 F24:1 Ip A1:1 C420mpeg2 XYSCSS=420MPEG2 + // https://manned.org/yuv4mpeg.5 + // https://github.com/FFmpeg/FFmpeg/blob/master/libavformat/yuv4mpegenc.c + key := b[0] + + var value string + if i := bytes.IndexByte(b, ' '); i > 0 { + value = string(b[1:i]) + b = b[i+1:] + } else { + value = string(b[1:]) + b = nil + } + + switch key { + case 'W': + fmtp = "width=" + value + case 'H': + fmtp += ";height=" + value + case 'C': + fmtp += ";colorspace=" + value + } + } + return +} + +func GetSize(fmtp string) int { + w := core.Atoi(core.Between(fmtp, "width=", ";")) + h := core.Atoi(core.Between(fmtp, "height=", ";")) + + switch core.Between(fmtp, "colorspace=", ";") { + case "mono": + return w * h + case "420mpeg2", "420jpeg": + return w * h * 3 / 2 + case "422": + return w * h * 2 + case "444": + return w * h * 3 + } + + return 0 +} + +func NewImage(fmtp string) func(frame []byte) image.Image { + w := core.Atoi(core.Between(fmtp, "width=", ";")) + h := core.Atoi(core.Between(fmtp, "height=", ";")) + rect := image.Rect(0, 0, w, h) + + switch core.Between(fmtp, "colorspace=", ";") { + case "mono": + return func(frame []byte) image.Image { + return &image.Gray{ + Pix: frame, + Stride: w, + Rect: rect, + } + } + case "420mpeg2", "420jpeg": + i1 := w * h + i2 := i1 + i1/4 + i3 := i2 + i1/4 + + return func(frame []byte) image.Image { + return &image.YCbCr{ + Y: frame[:i1], + Cb: frame[i1:i2], + Cr: frame[i2:i3], + YStride: w, + CStride: w / 2, + SubsampleRatio: image.YCbCrSubsampleRatio420, + Rect: rect, + } + } + case "422": + i1 := w * h + i2 := i1 + i1/2 + i3 := i2 + i1/2 + + return func(frame []byte) image.Image { + return &image.YCbCr{ + Y: frame[:i1], + Cb: frame[i1:i2], + Cr: frame[i2:i3], + YStride: w, + CStride: w / 2, + SubsampleRatio: image.YCbCrSubsampleRatio422, + Rect: rect, + } + } + case "444": + i1 := w * h + i2 := i1 + i1 + i3 := i2 + i1 + + return func(frame []byte) image.Image { + return &image.YCbCr{ + Y: frame[:i1], + Cb: frame[i1:i2], + Cr: frame[i2:i3], + YStride: w, + CStride: w, + SubsampleRatio: image.YCbCrSubsampleRatio444, + Rect: rect, + } + } + } + + return nil +} + +// HasSameColor checks if all pixels has same color +func HasSameColor(img image.Image) bool { + var pix []byte + + switch img := img.(type) { + case *image.Gray: + pix = img.Pix + case *image.YCbCr: + pix = img.Y + } + + if len(pix) == 0 { + return false + } + + i0 := pix[0] + for _, i := range pix { + if i != i0 { + return false + } + } + return true +} diff --git a/installs_on_host/go2rtc/pkg/yaml/yaml.go b/installs_on_host/go2rtc/pkg/yaml/yaml.go new file mode 100644 index 0000000..4672cb4 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/yaml/yaml.go @@ -0,0 +1,230 @@ +package yaml + +import ( + "bytes" + "errors" + + "gopkg.in/yaml.v3" +) + +func Unmarshal(in []byte, out interface{}) (err error) { + return yaml.Unmarshal(in, out) +} + +func Encode(v any, indent int) ([]byte, error) { + b := bytes.NewBuffer(nil) + e := yaml.NewEncoder(b) + e.SetIndent(indent) + + if err := e.Encode(v); err != nil { + return nil, err + } + + return b.Bytes(), nil +} + +func Patch(in []byte, path []string, value any) ([]byte, error) { + out, err := patch(in, path, value) + if err != nil { + return nil, err + } + + // validate + if err = yaml.Unmarshal(out, map[string]any{}); err != nil { + return nil, err + } + + return out, nil +} + +func patch(in []byte, path []string, value any) ([]byte, error) { + var root yaml.Node + if err := yaml.Unmarshal(in, &root); err != nil { + // invalid yaml + return nil, err + } + + // empty in + if len(root.Content) != 1 { + return addToEnd(in, path, value) + } + + // yaml is not dict + if root.Content[0].Kind != yaml.MappingNode { + return nil, errors.New("yaml: can't patch") + } + + // dict items list + nodes := root.Content[0].Content + + n := len(path) - 1 + + // parent node key/value + pKey, pVal := findNode(nodes, path[:n]) + if pKey == nil { + // no parent node + return addToEnd(in, path, value) + } + + var paste []byte + + if value != nil { + // nil value means delete key + var err error + v := map[string]any{path[n]: value} + if paste, err = Encode(v, 2); err != nil { + return nil, err + } + } + + iKey, _ := findNode(pVal.Content, path[n:]) + if iKey != nil { + // key item not nil (replace value) + paste = addIndent(paste, iKey.Column-1) + + i0, i1 := nodeBounds(in, iKey) + return join(in[:i0], paste, in[i1:]), nil + } + + if pVal.Content != nil { + // parent value not nil (use first child indent) + paste = addIndent(paste, pVal.Column-1) + } else { + // parent value is nil (use parent indent + 2) + paste = addIndent(paste, pKey.Column+1) + } + + _, i1 := nodeBounds(in, pKey) + return join(in[:i1], paste, in[i1:]), nil +} + +func findNode(nodes []*yaml.Node, keys []string) (key, value *yaml.Node) { + for i, name := range keys { + for j := 0; j < len(nodes); j += 2 { + if nodes[j].Value == name { + if i < len(keys)-1 { + nodes = nodes[j+1].Content + break + } + return nodes[j], nodes[j+1] + } + } + } + return nil, nil +} + +func nodeBounds(in []byte, node *yaml.Node) (offset0, offset1 int) { + // start from next line after node + offset0 = lineOffset(in, node.Line) + offset1 = lineOffset(in, node.Line+1) + + if offset1 < 0 { + return offset0, len(in) + } + + for i := offset1; i < len(in); { + indent, length := parseLine(in[i:]) + if indent+1 != length { + if node.Column < indent+1 { + offset1 = i + length + } else { + break + } + } + i += length + } + + return +} + +func addToEnd(in []byte, path []string, value any) ([]byte, error) { + if len(path) != 2 || value == nil { + return nil, errors.New("yaml: path not exist") + } + + v := map[string]map[string]any{ + path[0]: {path[1]: value}, + } + paste, err := Encode(v, 2) + if err != nil { + return nil, err + } + + return join(in, paste), nil +} + +func join(items ...[]byte) []byte { + n := len(items) - 1 + for _, b := range items { + n += len(b) + } + + buf := make([]byte, 0, n) + for _, b := range items { + if len(b) == 0 { + continue + } + if n = len(buf); n > 0 && buf[n-1] != '\n' { + buf = append(buf, '\n') + } + buf = append(buf, b...) + } + + return buf +} + +func addPrefix(src, pre []byte) (dst []byte) { + for len(src) > 0 { + dst = append(dst, pre...) + i := bytes.IndexByte(src, '\n') + 1 + if i == 0 { + dst = append(dst, src...) + break + } + dst = append(dst, src[:i]...) + src = src[i:] + } + + return +} + +func addIndent(in []byte, indent int) (dst []byte) { + pre := make([]byte, indent) + for i := 0; i < indent; i++ { + pre[i] = ' ' + } + return addPrefix(in, pre) +} + +func lineOffset(in []byte, line int) (offset int) { + for l := 1; ; l++ { + if l == line { + return offset + } + + i := bytes.IndexByte(in[offset:], '\n') + 1 + if i == 0 { + break + } + offset += i + } + return -1 +} + +func parseLine(b []byte) (indent int, length int) { + prefix := true + for ; length < len(b); length++ { + switch b[length] { + case ' ': + if prefix { + indent++ + } + case '\n': + length++ + return + default: + prefix = false + } + } + return +} diff --git a/installs_on_host/go2rtc/pkg/yaml/yaml_test.go b/installs_on_host/go2rtc/pkg/yaml/yaml_test.go new file mode 100644 index 0000000..264546a --- /dev/null +++ b/installs_on_host/go2rtc/pkg/yaml/yaml_test.go @@ -0,0 +1,109 @@ +package yaml + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPatch(t *testing.T) { + tests := []struct { + name string + src string + path []string + value any + expect string + }{ + { + name: "empty config", + src: "", + path: []string{"streams", "camera1"}, + value: "val1", + expect: "streams:\n camera1: val1\n", + }, + { + name: "empty main key", + src: "#dummy", + path: []string{"streams", "camera1"}, + value: "val1", + expect: "#dummy\nstreams:\n camera1: val1\n", + }, + { + name: "single line value", + src: "streams:\n camera1: url1\n camera2: url2", + path: []string{"streams", "camera1"}, + value: "val1", + expect: "streams:\n camera1: val1\n camera2: url2", + }, + { + name: "next line value", + src: "streams:\n camera1:\n url1\n camera2: url2", + path: []string{"streams", "camera1"}, + value: "val1", + expect: "streams:\n camera1: val1\n camera2: url2", + }, + { + name: "two lines value", + src: "streams:\n camera1: url1\n url2\n camera2: url2", + path: []string{"streams", "camera1"}, + value: "val1", + expect: "streams:\n camera1: val1\n camera2: url2", + }, + { + name: "next two lines value", + src: "streams:\n camera1:\n url1\n url2\n camera2: url2", + path: []string{"streams", "camera1"}, + value: "val1", + expect: "streams:\n camera1: val1\n camera2: url2", + }, + { + name: "add array", + src: "", + path: []string{"streams", "camera1"}, + value: []string{"val1", "val2"}, + expect: "streams:\n camera1:\n - val1\n - val2\n", + }, + { + name: "remove value", + src: "streams:\n camera1: url1\n camera2: url2", + path: []string{"streams", "camera1"}, + value: nil, + expect: "streams:\n camera2: url2", + }, + { + name: "add pairings", + src: "homekit:\n camera1:\nstreams:\n camera1: url1", + path: []string{"homekit", "camera1", "pairings"}, + value: []string{"val1"}, + expect: "homekit:\n camera1:\n pairings:\n - val1\nstreams:\n camera1: url1", + }, + { + name: "remove pairings", + src: "homekit:\n camera1:\n pairings:\n - val1\nstreams:\n camera1: url1", + path: []string{"homekit", "camera1", "pairings"}, + value: nil, + expect: "homekit:\n camera1:\nstreams:\n camera1: url1", + }, + { + name: "no new line", + src: "streams:\n camera1: url1", + path: []string{"streams", "camera1"}, + value: "val1", + expect: "streams:\n camera1: val1\n", + }, + { + name: "no new line", + src: "streams:\n camera1: url1\nhomekit:\n camera1:\n name: dummy", + path: []string{"homekit", "camera1", "pairings"}, + value: []string{"val1"}, + expect: "streams:\n camera1: url1\nhomekit:\n camera1:\n name: dummy\n pairings:\n - val1\n", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b, err := Patch([]byte(tt.src), tt.path, tt.value) + require.NoError(t, err) + require.Equal(t, tt.expect, string(b)) + }) + } +} diff --git a/installs_on_host/go2rtc/pkg/yandex/session.go b/installs_on_host/go2rtc/pkg/yandex/session.go new file mode 100644 index 0000000..bd0e3a2 --- /dev/null +++ b/installs_on_host/go2rtc/pkg/yandex/session.go @@ -0,0 +1,203 @@ +package yandex + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/http/cookiejar" + "strings" + "sync" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +type Session struct { + token string + client *http.Client +} + +var sessions = map[string]*Session{} +var sessionsMu sync.Mutex + +func GetSession(token string) (*Session, error) { + sessionsMu.Lock() + defer sessionsMu.Unlock() + + if session, ok := sessions[token]; ok { + return session, nil + } + + session := &Session{token: token} + if err := session.Login(); err != nil { + return nil, err + } + + sessions[token] = session + + return session, nil +} + +func (s *Session) Login() error { + req, err := http.NewRequest( + "POST", "https://mobileproxy.passport.yandex.net/1/bundle/auth/x_token/", + strings.NewReader("type=x-token&retpath=https%3A%2F%2Fwww.yandex.ru"), + ) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Ya-Consumer-Authorization", "OAuth "+s.token) + + res, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + + var auth struct { + PassportHost string `json:"passport_host"` + Status string `json:"status"` + TrackId string `json:"track_id"` + } + if err = json.NewDecoder(res.Body).Decode(&auth); err != nil { + return err + } + + if auth.Status != "ok" { + return errors.New("yandex: login error: " + auth.Status) + } + + s.client = &http.Client{Timeout: 15 * time.Second} + s.client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + s.client.Jar, _ = cookiejar.New(nil) + + res, err = s.client.Get(auth.PassportHost + "/auth/session/?track_id=" + auth.TrackId) + if err != nil { + return err + } + + s.client.CheckRedirect = nil + + return nil +} + +func (s *Session) Get(url string) (*http.Response, error) { + return s.client.Get(url) +} + +func (s *Session) GetCSRF() (string, error) { + res, err := s.Get("https://yandex.ru/quasar") + if err != nil { + return "", err + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return "", err + } + + token := core.Between(string(body), `"csrfToken2":"`, `"`) + return token, nil +} + +func (s *Session) GetCookieString(url string) string { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "" + } + for _, cookie := range s.client.Jar.Cookies(req.URL) { + req.AddCookie(cookie) + } + return req.Header.Get("Cookie") +} + +func (s *Session) GetDevices() ([]Device, error) { + res, err := s.Get("https://iot.quasar.yandex.ru/m/v3/user/devices") + if err != nil { + return nil, err + } + + var data struct { + Households []struct { + All []Device `json:"all"` + } `json:"households"` + } + + if err = json.NewDecoder(res.Body).Decode(&data); err != nil { + return nil, err + } + + var devices []Device + for _, household := range data.Households { + devices = append(devices, household.All...) + } + return devices, nil +} + +func (s *Session) GetSnapshotURL(deviceID string) (string, error) { + devices, err := s.GetDevices() + if err != nil { + return "", err + } + + for _, device := range devices { + if device.Id == deviceID { + return device.Parameters.SnapshotUrl, nil + } + } + + return "", errors.New("yandex: can't get snapshot url for device: " + deviceID) +} + +func (s *Session) WebrtcCreateRoom(deviceID string) (*Room, error) { + csrf, err := s.GetCSRF() + if err != nil { + return nil, err + } + + req, err := http.NewRequest( + "POST", "https://iot.quasar.yandex.ru/m/v3/user/devices/"+deviceID+"/webrtc/create-room", + strings.NewReader(`{"protocol":"whip"}`), + ) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", "application/json") + req.Header.Add("X-CSRF-Token", csrf) + + res, err := s.client.Do(req) + if err != nil { + return nil, err + } + + var data struct { + Result Room `json:"result"` + } + if err = json.NewDecoder(res.Body).Decode(&data); err != nil { + return nil, err + } + + return &data.Result, nil +} + +type Device struct { + Id string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Parameters struct { + SnapshotUrl string `json:"snapshot_url,omitempty"` + } `json:"parameters"` +} + +type Room struct { + ServiceUrl string `json:"service_url"` + ServiceName string `json:"service_name"` + RoomId string `json:"room_id"` + ParticipantId string `json:"participant_id"` + Credentials string `json:"jwt"` +} diff --git a/installs_on_host/go2rtc/scripts/README.md b/installs_on_host/go2rtc/scripts/README.md new file mode 100644 index 0000000..8abb0c7 --- /dev/null +++ b/installs_on_host/go2rtc/scripts/README.md @@ -0,0 +1,104 @@ +# Scripts + +This folder contains a script for building binaries for all platforms. + +The project has no `CGO` dependencies, so building is as simple as possible using the `go build` command. + +The project has to use the latest versions of go due to dependencies on third-party go libraries. Such as `pion/webrtc` or `golang.org/x`. Unfortunately, this breaks compatibility with older versions of operating systems. + +The project uses [UPX](https://github.com/upx/upx) to compress binaries for Linux. The project does not use compression for Windows due to false antivirus alarms. The project does not use compression for macOS due to broken result. + +## Useful commands + +``` +go get -u +go mod tidy +go mod why github.com/pion/rtcp +go list -deps .\cmd\go2rtc_rtsp\ +./goweight +``` + +## Dependencies + +``` +- gopkg.in/yaml.v3 + - github.com/kr/pretty +- github.com/AlexxIT/go2rtc/pkg/hap + - github.com/tadglines/go-pkgs + - golang.org/x/crypto +- github.com/AlexxIT/go2rtc/pkg/mdns + - github.com/miekg/dns +- github.com/AlexxIT/go2rtc/pkg/pcm + - github.com/sigurn/crc16 + - github.com/sigurn/crc8 +- github.com/pion/ice/v2 + - github.com/google/uuid + - github.com/wlynxg/anet +- github.com/rs/zerolog + - github.com/mattn/go-colorable + - github.com/mattn/go-isatty +- github.com/stretchr/testify + - github.com/davecgh/go-spew + - github.com/pmezard/go-difflib +- ??? + - golang.org/x/mod + - golang.org/x/net + - golang.org/x/sys + - golang.org/x/tools +``` + +## Licenses + +- github.com/asticode/go-astits - MIT +- github.com/eclipse/paho.mqtt.golang - EPL-2.0 +- github.com/expr-lang/expr - MIT +- github.com/gorilla/websocket - BSD-2 +- github.com/mattn/go-isatty - MIT +- github.com/miekg/dns - BSD-3 +- github.com/pion/dtls - MIT +- github.com/pion/ice - MIT +- github.com/pion/interceptor - MIT +- github.com/pion/rtcp - MIT +- github.com/pion/rtp - MIT +- github.com/pion/sdp - MIT +- github.com/pion/srtp - MIT +- github.com/pion/stun - MIT +- github.com/pion/webrtc - MIT +- github.com/rs/zerolog - MIT +- github.com/sigurn/crc16 - MIT +- github.com/sigurn/crc8 - MIT +- github.com/stretchr/testify - MIT +- github.com/tadglines/go-pkgs - Apache +- golang.org/x/crypto - BSD-3 +- gopkg.in/yaml.v3 - MIT and Apache +- github.com/asticode/go-astikit - MIT +- github.com/davecgh/go-spew - ISC (BSD/MIT like) +- github.com/google/uuid - BSD-3 +- github.com/kr/pretty - MIT +- github.com/mattn/go-colorable - MIT +- github.com/pion/datachannel - MIT +- github.com/pion/logging - MIT +- github.com/pion/mdns - MIT +- github.com/pion/randutil - MIT +- github.com/pion/sctp - MIT +- github.com/pmezard/go-difflib - ??? +- github.com/wlynxg/anet - BSD-3 +- golang.org/x/mod - BSD-3 +- golang.org/x/net - BSD-3 +- golang.org/x/sync - BSD-3 +- golang.org/x/sys - BSD-3 +- golang.org/x/tools - BSD-3 + +## Virus + +- https://go.dev/doc/faq#virus +- https://groups.google.com/g/golang-nuts/c/lPwiWYaApSU + +## Useful links + +- https://github.com/golang-standards/project-layout +- https://github.com/micro/micro +- https://github.com/golang/go/wiki/GoArm +- https://gist.github.com/asukakenji/f15ba7e588ac42795f421b48b8aede63 +- https://en.wikipedia.org/wiki/AArch64 +- https://stackoverflow.com/questions/22267189/what-does-the-w-flag-mean-when-passed-in-via-the-ldflags-option-to-the-go-comman diff --git a/installs_on_host/go2rtc/scripts/build.cmd b/installs_on_host/go2rtc/scripts/build.cmd new file mode 100644 index 0000000..37ccd44 --- /dev/null +++ b/installs_on_host/go2rtc/scripts/build.cmd @@ -0,0 +1,68 @@ +@ECHO OFF + +@SET GOOS=windows +@SET GOARCH=amd64 +@SET FILENAME=go2rtc_win64.zip +go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% go2rtc.exe + +@SET GOOS=windows +@SET GOARCH=386 +@SET FILENAME=go2rtc_win32.zip +go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% go2rtc.exe + +@SET GOOS=windows +@SET GOARCH=arm64 +@SET FILENAME=go2rtc_win_arm64.zip +go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% go2rtc.exe + +@SET GOOS=linux +@SET GOARCH=amd64 +@SET FILENAME=go2rtc_linux_amd64 +go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx --best --lzma %FILENAME% + +@SET GOOS=linux +@SET GOARCH=386 +@SET FILENAME=go2rtc_linux_i386 +go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx --best --lzma %FILENAME% + +@SET GOOS=linux +@SET GOARCH=arm64 +@SET FILENAME=go2rtc_linux_arm64 +go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx --best --lzma %FILENAME% + +@SET GOOS=linux +@SET GOARCH=arm +@SET GOARM=7 +@SET FILENAME=go2rtc_linux_arm +go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx --best --lzma %FILENAME% + +@SET GOOS=linux +@SET GOARCH=arm +@SET GOARM=6 +@SET FILENAME=go2rtc_linux_armv6 +go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx --best --lzma %FILENAME% + +@SET GOOS=linux +@SET GOARCH=mipsle +@SET FILENAME=go2rtc_linux_mipsel +go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx --best --lzma %FILENAME% + +@SET GOOS=darwin +@SET GOARCH=amd64 +@SET FILENAME=go2rtc_mac_amd64.zip +go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% go2rtc + +@SET GOOS=darwin +@SET GOARCH=arm64 +@SET FILENAME=go2rtc_mac_arm64.zip +go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% go2rtc + +@SET GOOS=freebsd +@SET GOARCH=amd64 +@SET FILENAME=go2rtc_freebsd_amd64.zip +go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% go2rtc + +@SET GOOS=freebsd +@SET GOARCH=arm64 +@SET FILENAME=go2rtc_freebsd_arm64.zip +go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% go2rtc diff --git a/installs_on_host/go2rtc/scripts/build.sh b/installs_on_host/go2rtc/scripts/build.sh new file mode 100755 index 0000000..ed188c2 --- /dev/null +++ b/installs_on_host/go2rtc/scripts/build.sh @@ -0,0 +1,47 @@ +#!/bin/sh + +set -e # Exit immediately if a command exits with a non-zero status. +set -u # Treat unset variables as an error when substituting. + +check_command() { + if ! command -v "$1" >/dev/null + then + echo "Error: $1 could not be found. Please install it." >&2 + return 1 + fi +} + +build_zip() { + go build -ldflags "-s -w" -trimpath -o $2 + 7z a -mx9 -sdel $1 $2 +} + +build_upx() { + go build -ldflags "-s -w" -trimpath -o $1 + upx --best --lzma $1 +} + +check_command go +check_command 7z +check_command upx + +export CGO_ENABLED=0 + +set -x # Print commands and their arguments as they are executed. + +GOOS=windows GOARCH=amd64 build_zip go2rtc_win64.zip go2rtc.exe +GOOS=windows GOARCH=386 build_zip go2rtc_win32.zip go2rtc.exe +GOOS=windows GOARCH=arm64 build_zip go2rtc_win_arm64.zip go2rtc.exe + +GOOS=linux GOARCH=amd64 build_upx go2rtc_linux_amd64 +GOOS=linux GOARCH=386 build_upx go2rtc_linux_i386 +GOOS=linux GOARCH=arm64 build_upx go2rtc_linux_arm64 +GOOS=linux GOARCH=mipsle build_upx go2rtc_linux_mipsel +GOOS=linux GOARCH=arm GOARM=7 build_upx go2rtc_linux_arm +GOOS=linux GOARCH=arm GOARM=6 build_upx go2rtc_linux_armv6 + +GOOS=darwin GOARCH=amd64 build_zip go2rtc_mac_amd64.zip go2rtc +GOOS=darwin GOARCH=arm64 build_zip go2rtc_mac_arm64.zip go2rtc + +GOOS=freebsd GOARCH=amd64 build_zip go2rtc_freebsd_amd64.zip go2rtc +GOOS=freebsd GOARCH=arm64 build_zip go2rtc_freebsd_arm64.zip go2rtc diff --git a/installs_on_host/go2rtc/website/.vitepress/config.js b/installs_on_host/go2rtc/website/.vitepress/config.js new file mode 100644 index 0000000..792f2e7 --- /dev/null +++ b/installs_on_host/go2rtc/website/.vitepress/config.js @@ -0,0 +1,188 @@ +import {defineConfig} from 'vitepress'; + +function replace_link(md) { + md.core.ruler.after('inline', 'replace-link', function (state) { + for (const block of state.tokens) { + if (block.type === 'inline' && block.children) { + for (const token of block.children) { + const href = token.attrGet('href'); + if (href && href.indexOf('README.md') >= 0) { + // token.attrJoin('style', 'color:red;'); + token.attrSet('href', href.replace('README.md', 'index.md')); + } + } + } + } + return true; + }); +} + +export default defineConfig({ + title: 'go2rtc', + description: 'Ultimate camera streaming application', + head: [ + // first line (green bold) of Telegram card, autodetect from hostname + ['meta', { property: 'og:site_name', content: 'go2rtc.org' }], + // second line of Telegram card (black bold), autodetect from site description + ['meta', { property: 'og:title', content: 'go2rtc - Ultimate camera streaming application' }], + // third line of Telegram card, autodetect from site description + ['meta', { property: 'og:description', content: 'Support alsa, doorbird, dvrip, eseecloud, ffmpeg, gopro, hass, hls, homekit, mjpeg, mp4, mpegts, nest, onvif, ring, roborock, rtmp, rtsp, tapo, vigi, tuya, v4l2, webrtc, wyze, xiaomi.' }], + ['meta', { property: 'og:url', content: 'https://go2rtc.org/' }], + ['meta', { property: 'og:image', content: 'https://go2rtc.org/images/logo.png' }], + // important for Telegram - the image will be at the bottom and large + ['meta', { property: 'twitter:card', content: 'summary_large_image' }], + ], + sitemap: {hostname: 'https://go2rtc.org'}, + + themeConfig: { + nav: [ + {text: 'Home', link: '/'}, + ], + sidebar: [ + { + items: [ + {text: 'Installation', link: '/#installation'}, + {text: 'Configuration', link: '/#configuration'}, + {text: 'Security', link: '/#security'}, + ], + }, + { + text: 'Features', + items: [ + {text: 'Streaming input', link: '/#streaming-input'}, + {text: 'Streaming output', link: '/#streaming-output'}, + {text: 'Streaming ingest', link: '/#streaming-ingest'}, + {text: 'Two-way audio', link: '/#two-way-audio'}, + {text: 'Stream to camera', link: '/#stream-to-camera'}, + {text: 'Publish stream', link: '/#publish-stream'}, + {text: 'Preload stream', link: '/#preload-stream'}, + {text: 'Streaming stats', link: '/#streaming-stats'}, + ], + collapsed: false, + }, + { + text: 'Codecs', + items: [ + {text: 'Codecs filters', link: '/#codecs-filters'}, + {text: 'Codecs madness', link: '/#codecs-madness'}, + {text: 'Built-in transcoding', link: '/#built-in-transcoding'}, + {text: 'Codecs negotiation', link: '/#codecs-negotiation'}, + ], + collapsed: true, + }, + { + text: 'Other', + items: [ + {text: 'Projects using go2rtc', link: '/#projects-using-go2rtc'}, + {text: 'Camera experience', link: '/#camera-experience'}, + {text: 'Tips', link: '/#tips'}, + ], + collapsed: true, + }, + { + text: 'Core modules', + items: [ + {text: 'app', link: '/internal/app/'}, + {text: 'api', link: '/internal/api/'}, + {text: 'streams', link: '/internal/streams/'}, + ], + collapsed: false, + }, + { + text: 'Main modules', + items: [ + {text: 'http', link: '/internal/http/'}, + {text: 'mjpeg', link: '/internal/mjpeg/'}, + {text: 'mp4', link: '/internal/mp4/'}, + {text: 'rtsp', link: '/internal/rtsp/'}, + {text: 'webrtc', link: '/internal/webrtc/'}, + ], + collapsed: false, + }, + { + text: 'Other modules', + items: [ + {text: 'hls', link: '/internal/hls/'}, + {text: 'homekit', link: '/internal/homekit/'}, + {text: 'onvif', link: '/internal/onvif/'}, + {text: 'rtmp', link: '/internal/rtmp/'}, + {text: 'webtorrent', link: '/internal/webtorrent/'}, + {text: 'wyoming', link: '/internal/wyoming/'}, + ], + collapsed: false, + }, + { + text: 'Script sources', + items: [ + {text: 'echo', link: '/internal/echo/'}, + {text: 'exec', link: '/internal/exec/'}, + {text: 'expr', link: '/internal/expr/'}, + {text: 'ffmpeg', link: '/internal/ffmpeg/'}, + ], + collapsed: false, + }, + { + text: 'Other sources', + items: [ + {text: 'alsa', link: '/internal/alsa/'}, + {text: 'bubble', link: '/internal/bubble/'}, + {text: 'doorbird', link: '/internal/doorbird/'}, + {text: 'dvrip', link: '/internal/dvrip/'}, + {text: 'eseecloud', link: '/internal/eseecloud/'}, + {text: 'flussonic', link: '/internal/flussonic/'}, + {text: 'gopro', link: '/internal/gopro/'}, + {text: 'hass', link: '/internal/hass/'}, + {text: 'isapi', link: '/internal/isapi/'}, + {text: 'ivideon', link: '/internal/ivideon/'}, + {text: 'kasa', link: '/internal/kasa/'}, + {text: 'mpeg', link: '/internal/mpeg/'}, + {text: 'multitrans', link: '/internal/multitrans/'}, + {text: 'nest', link: '/internal/nest/'}, + {text: 'ring', link: '/internal/ring/'}, + {text: 'roborock', link: '/internal/roborock/'}, + {text: 'tapo', link: '/internal/tapo/'}, + {text: 'tuya', link: '/internal/tuya/'}, + {text: 'v4l2', link: '/internal/v4l2/'}, + {text: 'wyze', link: '/internal/wyze/'}, + {text: 'xiaomi', link: '/internal/xiaomi/'}, + {text: 'yandex', link: '/internal/yandex/'}, + ], + collapsed: false, + }, + { + text: 'Helper modules', + items: [ + {text: 'debug', link: '/internal/debug/'}, + {text: 'ngrok', link: '/internal/ngrok/'}, + {text: 'pinggy', link: '/internal/pinggy/'}, + {text: 'srtp', link: '/internal/srtp/'}, + ], + collapsed: false, + }, + + ], + socialLinks: [ + {icon: 'github', link: 'https://github.com/AlexxIT/go2rtc'} + ], + outline: [2, 3], + search: {provider: 'local'}, + }, + + rewrites(id) { + // change file names + return id.replace('README.md', 'index.md'); + }, + + markdown: { + config: (md) => { + // change markdown links + md.use(replace_link); + } + }, + + srcDir: '..', + srcExclude: ['examples/', 'pkg/'], + + // cleanUrls: true, + ignoreDeadLinks: true, +}); diff --git a/installs_on_host/go2rtc/website/README.md b/installs_on_host/go2rtc/website/README.md new file mode 100644 index 0000000..81a2ad0 --- /dev/null +++ b/installs_on_host/go2rtc/website/README.md @@ -0,0 +1,9 @@ +# WebSite + +These are the sources of the [go2rtc.org](https://go2rtc.org/) website. It's content published on GitHub Pages and is a mirror of [alexxit.github.io/go2rtc/](http://alexxit.github.io/go2rtc/). + +The site contains: + +- Project's documentation, which is compiled via [vitepress](https://github.com/vuejs/vitepress) from `README.md` files located in the root of the repository, as well as in the `internal` folder. +- Project's API in OpenAPI format, and the [Redoc](https://github.com/Redocly/redoc) viewer +- Project's assets (logo). diff --git a/installs_on_host/go2rtc/website/api/index.html b/installs_on_host/go2rtc/website/api/index.html new file mode 100644 index 0000000..2f67678 --- /dev/null +++ b/installs_on_host/go2rtc/website/api/index.html @@ -0,0 +1,19 @@ + + + + API | go2rtc + + + + + + + + + + \ No newline at end of file diff --git a/installs_on_host/go2rtc/website/api/openapi.yaml b/installs_on_host/go2rtc/website/api/openapi.yaml new file mode 100644 index 0000000..b611057 --- /dev/null +++ b/installs_on_host/go2rtc/website/api/openapi.yaml @@ -0,0 +1,1197 @@ +openapi: 3.1.0 + +info: + title: go2rtc + version: 1.9.13 + license: { name: MIT,url: https://opensource.org/licenses/MIT } + contact: { url: https://github.com/AlexxIT/go2rtc } + description: | + Ultimate camera streaming application with support RTSP, RTMP, HTTP-FLV, WebRTC, MSE, HLS, MP4, MJPEG, HomeKit, FFmpeg, etc. + +servers: + - url: http://localhost:1984 + +tags: + - name: Application + description: "[Module: API](https://github.com/AlexxIT/go2rtc#module-api)" + - name: Config + description: "[Configuration](https://github.com/AlexxIT/go2rtc#configuration)" + - name: Streams list + description: "[Module: Streams](https://github.com/AlexxIT/go2rtc#module-streams)" + - name: Consume stream + - name: HLS + - name: Snapshot + - name: Produce stream + - name: WebSocket + description: "WebSocket API endpoint: `/api/ws` (see `api/README.md`)" + - name: Discovery + - name: HomeKit + - name: ONVIF + - name: RTSPtoWebRTC + - name: WebTorrent + description: "[Module: WebTorrent](https://github.com/AlexxIT/go2rtc#module-webtorrent)" + - name: FFmpeg + - name: Debug + +components: + parameters: + stream_src_path: + name: src + in: path + description: Source stream name + required: true + schema: { type: string } + example: camera1 + + stream_dst_path: + name: dst + in: path + description: Destination stream name + required: true + schema: { type: string } + example: camera1 + + stream_src_query: + name: src + in: query + description: Source stream name + required: true + schema: { type: string } + example: camera1 + + hls_session_id_path: + name: id + in: path + description: HLS session ID (passed as query param `id`) + required: true + schema: { type: string } + example: DvmHdd9w + + mp4_filter: + name: mp4 + in: query + description: MP4 codecs filter + required: false + schema: + type: string + enum: [ "", flac, all ] + example: flac + + video_filter: + name: video + in: query + description: Video codecs filter + schema: + type: string + enum: [ "", all, h264, h265, mjpeg ] + example: h264,h265 + + audio_filter: + name: audio + in: query + description: Audio codecs filter + schema: + type: string + enum: [ "", all, aac, opus, pcm, pcmu, pcma ] + example: aac + + responses: + discovery: + description: "" + content: + application/json: + example: { streams: [ { "name": "Camera 1","url": "..." } ] } + + webtorrent: + description: "" + content: + application/json: + example: { share: AKDypPy4zz, pwd: H0Km1HLTTP } + +paths: + /api: + get: + summary: Get application info + tags: [ Application ] + responses: + "200": + description: "" + content: + application/json: + schema: + type: object + properties: + config_path: { type: string, example: "/config/go2rtc.yaml" } + host: { type: string, example: "192.168.1.123:1984" } + rtsp: + type: object + properties: + listen: { type: string, example: ":8554" } + default_query: { type: string, example: "video&audio" } + version: { type: string, example: "1.9.12" } + + /api/exit: + post: + summary: Close application + tags: [ Application ] + parameters: + - name: code + in: query + description: Application exit code + required: false + schema: { type: integer } + example: 100 + responses: + default: + description: "" + + /api/restart: + post: + summary: Restart daemon + description: Restarts the daemon. + tags: [ Application ] + responses: + default: + description: "" + + /api/log: + get: + summary: Get in-memory logs buffer + description: | + Returns current log output from the in-memory circular buffer. + tags: [ Application ] + responses: + "200": + description: OK + content: + application/jsonlines: + example: | + {"level":"info","version":"1.9.13","platform":"linux/amd64","revision":"dfe4755","time":1766841087331,"message":"go2rtc"} + delete: + summary: Clear in-memory logs buffer + tags: [ Application ] + responses: + "200": + description: "" + content: + text/plain: { example: "" } + + /api/config: + get: + summary: Get main config file content + tags: [ Config ] + responses: + "200": + description: "" + content: + application/yaml: { example: "streams:..." } + "404": + description: Config file not found + post: + summary: Rewrite main config file + tags: [ Config ] + requestBody: + content: + "*/*": { example: "streams:..." } + responses: + default: + description: "" + patch: + summary: Merge changes to main config file + tags: [ Config ] + requestBody: + content: + "*/*": { example: "streams:..." } + responses: + default: + description: "" + + + + /api/streams: + get: + summary: Get all streams info + tags: [ Streams list ] + responses: + "200": + description: "" + content: + application/json: + schema: + type: object + additionalProperties: + type: object + properties: + producers: + type: array + consumers: + type: array + put: + summary: Create new stream + tags: [ Streams list ] + parameters: + - name: src + in: query + description: Stream source (URI) + required: true + schema: { type: string } + example: "rtsp://rtsp:12345678@192.168.1.123/av_stream/ch0" + - name: name + in: query + description: Stream name + required: false + schema: { type: string } + example: camera1 + responses: + default: + description: "" + patch: + summary: Update stream source + tags: [ Streams list ] + parameters: + - name: src + in: query + description: Stream source (URI) + required: true + schema: { type: string } + example: "rtsp://rtsp:12345678@192.168.1.123/av_stream/ch0" + - name: name + in: query + description: Stream name + required: true + schema: { type: string } + example: camera1 + responses: + default: + description: "" + delete: + summary: Delete stream + tags: [ Streams list ] + parameters: + - name: src + in: query + description: Stream name + required: true + schema: { type: string } + example: camera1 + responses: + default: + description: "" + post: + summary: Send stream from source to destination + description: "[Stream to camera](https://github.com/AlexxIT/go2rtc#stream-to-camera)" + tags: [ Streams list ] + parameters: + - name: src + in: query + description: Stream source (URI) + required: true + schema: { type: string } + example: "ffmpeg:http://example.com/song.mp3#audio=pcma#input=file" + - name: dst + in: query + description: Destination stream name + required: true + schema: { type: string } + example: camera1 + responses: + default: + description: "" + + /api/streams.dot: + get: + summary: Get streams graph in Graphviz DOT format + tags: [ Streams list ] + parameters: + - name: src + in: query + description: Stream name filter. Repeat `src` to include multiple streams. + required: false + schema: { type: string } + example: camera1 + responses: + "200": + description: OK + content: + text/vnd.graphviz: + example: "digraph { ... }" + + /api/preload: + get: + summary: Get all preloaded streams + tags: [ Streams list ] + responses: + "200": + description: "" + content: + application/json: + schema: + type: object + additionalProperties: + type: object + properties: + consumer: + type: object + query: + type: string + example: "video&audio" + put: + summary: Preload new stream + tags: [ Streams list ] + parameters: + - name: src + in: query + description: Stream source (name) + required: true + schema: { type: string } + example: "camera1" + - name: video + in: query + description: Video codecs filter + required: false + schema: { type: string } + example: all,h264,h265,... + - name: audio + in: query + description: Audio codecs filter + required: false + schema: { type: string } + example: all,aac,opus,... + - name: microphone + in: query + description: Microphone codecs filter + required: false + schema: { type: string } + example: all,aac,opus,... + responses: + default: + description: "" + delete: + summary: Delete preloaded stream + tags: [ Streams list ] + parameters: + - name: src + in: query + description: Stream source (name) + required: true + schema: { type: string } + example: "camera1" + responses: + default: + description: "" + + /api/schemes: + get: + summary: Get supported source URL schemes + tags: [ Streams list ] + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: { type: string } + example: [ rtsp, rtmp, webrtc, ffmpeg, hass ] + + + /api/streams?src={src}: + get: + summary: Get stream info in JSON format + tags: [ Consume stream ] + parameters: + - $ref: "#/components/parameters/stream_src_path" + responses: + "200": + description: "" + content: + application/json: + schema: + type: object + additionalProperties: + type: object + properties: + producers: + type: array + items: { type: object } + consumers: + type: array + items: { type: object } + + /api/webrtc?src={src}: + post: + summary: Get stream in WebRTC format (WHEP) + description: "[Module: WebRTC](https://github.com/AlexxIT/go2rtc#module-webrtc)" + tags: [ Consume stream ] + parameters: + - $ref: "#/components/parameters/stream_src_path" + requestBody: + description: | + Support: + - JSON format (`Content-Type: application/json`) + - WHEP standard (`Content-Type: application/sdp`) + - raw SDP (`Content-Type: anything`) + required: true + content: + application/json: { example: { type: offer, sdp: "v=0..." } } + "application/sdp": { example: "v=0..." } + "*/*": { example: "v=0..." } + responses: + "200": + description: "Response on JSON or raw SDP" + content: + application/json: { example: { type: answer, sdp: "v=0..." } } + application/sdp: { example: "v=0..." } + "201": + description: "Response on `Content-Type: application/sdp`" + content: + application/sdp: { example: "v=0..." } + + /api/stream.mp4?src={src}: + get: + summary: Get stream in MP4 format (HTTP progressive) + description: "[Module: MP4](https://github.com/AlexxIT/go2rtc#module-mp4)" + tags: [ Consume stream ] + parameters: + - $ref: "#/components/parameters/stream_src_path" + - name: duration + in: query + description: Limit the length of the stream in seconds + required: false + schema: { type: string } + example: 15 + - name: filename + in: query + description: Download as a file with this name + required: false + schema: { type: string } + example: camera1.mp4 + - name: rotate + in: query + description: "Rotate video (degrees). Supported values: 90, 180, 270." + required: false + schema: { type: integer, enum: [ 90, 180, 270 ] } + - name: scale + in: query + description: Scale video in format `width:height` + required: false + schema: { type: string, example: "1280:720" } + - $ref: "#/components/parameters/mp4_filter" + - $ref: "#/components/parameters/video_filter" + - $ref: "#/components/parameters/audio_filter" + responses: + 200: + description: "" + content: { video/mp4: { example: "" } } + + /api/stream.m3u8?src={src}: + get: + summary: Get stream in HLS format + description: "[Module: HLS](https://github.com/AlexxIT/go2rtc#module-hls)" + tags: [ Consume stream, HLS ] + parameters: + - $ref: "#/components/parameters/stream_src_path" + - $ref: "#/components/parameters/mp4_filter" + - $ref: "#/components/parameters/video_filter" + - $ref: "#/components/parameters/audio_filter" + responses: + 200: + description: "" + content: { application/vnd.apple.mpegurl: { example: "" } } + + /api/hls/playlist.m3u8?id={id}: + get: + summary: Get HLS media playlist for an active session + tags: [ HLS ] + parameters: + - $ref: "#/components/parameters/hls_session_id_path" + responses: + "200": + description: OK + content: + application/vnd.apple.mpegurl: { example: "" } + "404": + description: Session not found + + /api/hls/segment.ts?id={id}: + get: + summary: Get HLS MPEG-TS segment for an active session + tags: [ HLS ] + parameters: + - $ref: "#/components/parameters/hls_session_id_path" + responses: + "200": + description: OK + content: + video/mp2t: { example: "" } + "404": + description: Segment or session not found + + /api/hls/init.mp4?id={id}: + get: + summary: Get HLS fMP4 init segment for an active session + tags: [ HLS ] + parameters: + - $ref: "#/components/parameters/hls_session_id_path" + responses: + "200": + description: OK + content: + video/mp4: { example: "" } + "404": + description: Segment or session not found + + /api/hls/segment.m4s?id={id}: + get: + summary: Get HLS fMP4 media segment for an active session + tags: [ HLS ] + parameters: + - $ref: "#/components/parameters/hls_session_id_path" + responses: + "200": + description: OK + content: + video/iso.segment: { example: "" } + "404": + description: Segment or session not found + + /api/stream.mjpeg?src={src}: + get: + summary: Get stream in MJPEG format + description: "[Module: MJPEG](https://github.com/AlexxIT/go2rtc#module-mjpeg)" + tags: [ Consume stream ] + parameters: + - $ref: "#/components/parameters/stream_src_path" + responses: + 200: + description: "" + content: { multipart/x-mixed-replace: { example: "" } } + + /api/stream.ascii?src={src}: + get: + summary: Get stream in ASCII-art format (ANSI escape codes) + description: "[Module: MJPEG](https://github.com/AlexxIT/go2rtc#module-mjpeg)" + tags: [ Consume stream ] + parameters: + - $ref: "#/components/parameters/stream_src_path" + - name: color + in: query + description: Foreground mode (`8`, `256`, `rgb` or ANSI SGR code) + required: false + schema: { type: string } + - name: back + in: query + description: Background mode (`8`, `256`, `rgb` or ANSI SGR code) + required: false + schema: { type: string } + - name: text + in: query + description: Charset preset (empty/default, `block`) or custom characters + required: false + schema: { type: string } + responses: + "200": + description: OK + content: + text/plain: { example: "" } + "404": + description: Stream not found + + /api/stream.y4m?src={src}: + get: + summary: Get stream in YUV4MPEG2 format (y4m) + tags: [ Consume stream ] + parameters: + - $ref: "#/components/parameters/stream_src_path" + responses: + "200": + description: OK + content: + application/octet-stream: { example: "" } + "404": + description: Stream not found + + /api/stream.ts?src={src}: + get: + summary: Get stream in MPEG-TS format + tags: [ Consume stream ] + parameters: + - $ref: "#/components/parameters/stream_src_path" + responses: + "200": + description: OK + content: + video/mp2t: { example: "" } + "404": + description: Stream not found + + /api/stream.aac?src={src}: + get: + summary: Get stream audio in AAC (ADTS) format + tags: [ Consume stream ] + parameters: + - $ref: "#/components/parameters/stream_src_path" + responses: + "200": + description: OK + content: + audio/aac: { example: "" } + "404": + description: Stream not found + + /api/stream.flv?src={src}: + get: + summary: Get stream in FLV format + tags: [ Consume stream ] + parameters: + - $ref: "#/components/parameters/stream_src_path" + responses: + "200": + description: OK + content: + video/x-flv: { example: "" } + "404": + description: Stream not found + + /api/frame.jpeg?src={src}: + get: + summary: Get snapshot in JPEG format + description: "[Module: MJPEG](https://github.com/AlexxIT/go2rtc#module-mjpeg)" + tags: [ Snapshot ] + parameters: + - $ref: "#/components/parameters/stream_src_path" + - name: name + in: query + description: Optional stream name to create/update if `src` is a URL + required: false + schema: { type: string } + - name: width + in: query + description: "Scale output width (alias: `w`)" + required: false + schema: { type: integer, minimum: 1 } + - name: height + in: query + description: "Scale output height (alias: `h`)" + required: false + schema: { type: integer, minimum: 1 } + - name: rotate + in: query + description: "Rotate output (degrees). Supported values: 90, 180, 270." + required: false + schema: { type: integer, enum: [ 90, 180, 270 ] } + - name: hardware + in: query + description: "Hardware acceleration engine for FFmpeg snapshot transcoding (alias: `hw`)" + required: false + schema: { type: string } + responses: + "200": + description: "" + content: + image/jpeg: { example: "" } + + /api/frame.mp4?src={src}: + get: + summary: Get snapshot in MP4 format + description: "[Module: MP4](https://github.com/AlexxIT/go2rtc#module-mp4)" + tags: [ Snapshot ] + parameters: + - $ref: "#/components/parameters/stream_src_path" + - name: filename + in: query + description: Download as a file with this name + required: false + schema: { type: string } + example: camera1.mp4 + responses: + 200: + description: "" + content: + video/mp4: { example: "" } + + + + /api/webrtc?dst={dst}: + post: + summary: Post stream in WebRTC format (WHIP) + description: "[Incoming: WebRTC/WHIP](https://github.com/AlexxIT/go2rtc#incoming-webrtcwhip)" + tags: [ Produce stream ] + parameters: + - $ref: "#/components/parameters/stream_dst_path" + responses: + "201": + description: Created + headers: + Location: + description: Resource URL for session + schema: { type: string } + content: + application/sdp: { example: "v=0..." } + "404": + description: Stream not found + + /api/stream?dst={dst}: + post: + summary: Post stream in auto-detected format + description: | + Incoming source with automatic format detection. Use for pushing a stream into an existing `dst` stream. + tags: [ Produce stream ] + parameters: + - $ref: "#/components/parameters/stream_dst_path" + responses: + default: + description: "" + + /api/stream.flv?dst={dst}: + post: + summary: Post stream in FLV format + description: "[Incoming sources](https://github.com/AlexxIT/go2rtc#incoming-sources)" + tags: [ Produce stream ] + parameters: + - $ref: "#/components/parameters/stream_dst_path" + responses: + default: + description: "" + + /api/stream.ts?dst={dst}: + post: + summary: Post stream in MPEG-TS format + description: "[Incoming sources](https://github.com/AlexxIT/go2rtc#incoming-sources)" + tags: [ Produce stream ] + parameters: + - $ref: "#/components/parameters/stream_dst_path" + responses: + default: + description: "" + + /api/stream.mjpeg?dst={dst}: + post: + summary: Post stream in MJPEG format + description: "[Incoming sources](https://github.com/AlexxIT/go2rtc#incoming-sources)" + tags: [ Produce stream ] + parameters: + - $ref: "#/components/parameters/stream_dst_path" + responses: + default: + description: "" + + + /api/ffmpeg: + post: + summary: Play file/live/TTS into a stream via FFmpeg + description: | + Helper endpoint for "stream to camera" scenarios. + Exactly one of `file`, `live`, `text` should be provided. + tags: [ FFmpeg ] + parameters: + - name: dst + in: query + description: Destination stream name + required: true + schema: { type: string } + example: camera1 + - name: file + in: query + description: Input URL to treat as file (`#input=file`) + required: false + schema: { type: string } + example: "http://example.com/song.mp3" + - name: live + in: query + description: Live input URL + required: false + schema: { type: string } + example: "http://example.com/live.mp3" + - name: text + in: query + description: Text-to-speech phrase + required: false + schema: { type: string } + example: "Hello" + - name: voice + in: query + description: Optional TTS voice (engine-dependent) + required: false + schema: { type: string } + responses: + "200": + description: OK + "400": + description: Invalid parameters + "404": + description: Stream not found + + + /api/dvrip: + get: + summary: DVRIP cameras discovery + description: "[Source: DVRIP](https://github.com/AlexxIT/go2rtc#source-dvrip)" + tags: [ Discovery ] + responses: + "200": { $ref: "#/components/responses/discovery" } + + /api/ffmpeg/devices: + get: + summary: FFmpeg USB devices discovery + description: "[Source: FFmpeg Device](https://github.com/AlexxIT/go2rtc#source-ffmpeg-device)" + tags: [ Discovery ] + responses: + "200": { $ref: "#/components/responses/discovery" } + + /api/ffmpeg/hardware: + get: + summary: FFmpeg hardware transcoding discovery + description: "[Hardware acceleration](https://github.com/AlexxIT/go2rtc/wiki/Hardware-acceleration)" + tags: [ Discovery ] + responses: + "200": { $ref: "#/components/responses/discovery" } + + /api/v4l2: + get: + summary: V4L2 video devices discovery (Linux) + tags: [ Discovery ] + responses: + "200": { $ref: "#/components/responses/discovery" } + + /api/alsa: + get: + summary: ALSA audio devices discovery (Linux) + tags: [ Discovery ] + responses: + "200": { $ref: "#/components/responses/discovery" } + + /api/gopro: + get: + summary: GoPro cameras discovery + tags: [ Discovery ] + responses: + "200": { $ref: "#/components/responses/discovery" } + + /api/ring: + get: + summary: Ring cameras discovery + description: | + Provide either `email`/`password` (and optional `code` for 2FA) or `refresh_token`. + If 2FA is required, returns a JSON prompt instead of sources. + tags: [ Discovery ] + parameters: + - name: email + in: query + required: false + schema: { type: string } + - name: password + in: query + required: false + schema: { type: string } + - name: code + in: query + required: false + schema: { type: string } + - name: refresh_token + in: query + required: false + schema: { type: string } + responses: + "200": + description: OK + content: + application/json: { example: "" } + + /api/tuya: + get: + summary: Tuya cameras discovery + tags: [ Discovery ] + parameters: + - name: region + in: query + description: Tuya API host (region) + required: true + schema: { type: string } + example: "openapi.tuyaus.com" + - name: email + in: query + required: true + schema: { type: string } + - name: password + in: query + required: true + schema: { type: string } + responses: + "200": { $ref: "#/components/responses/discovery" } + "400": + description: Invalid parameters + "404": + description: No cameras found + + /api/hass: + get: + summary: Home Assistant cameras discovery + description: "[Source: Hass](https://github.com/AlexxIT/go2rtc#source-hass)" + tags: [ Discovery ] + responses: + "200": { $ref: "#/components/responses/discovery" } + "404": { description: No Hass config } + + /api/discovery/homekit: + get: + summary: HomeKit cameras discovery + description: "[Source: HomeKit](https://github.com/AlexxIT/go2rtc#source-homekit)" + tags: [ Discovery ] + responses: + "200": { $ref: "#/components/responses/discovery" } + + /api/nest: + get: + summary: Nest cameras discovery + tags: [ Discovery ] + parameters: + - name: client_id + in: query + required: true + schema: { type: string } + - name: client_secret + in: query + required: true + schema: { type: string } + - name: refresh_token + in: query + required: true + schema: { type: string } + - name: project_id + in: query + required: true + schema: { type: string } + responses: + "200": { $ref: "#/components/responses/discovery" } + + /api/onvif: + get: + summary: ONVIF cameras discovery + description: "[Source: ONVIF](https://github.com/AlexxIT/go2rtc#source-onvif)" + tags: [ Discovery ] + parameters: + - name: src + in: query + description: Optional ONVIF device URL to enumerate profiles + required: false + schema: { type: string } + example: "onvif://user:pass@192.168.1.50:80" + responses: + "200": { $ref: "#/components/responses/discovery" } + + /api/roborock: + get: + summary: Roborock vacuums discovery (requires prior auth) + description: "[Source: Roborock](https://github.com/AlexxIT/go2rtc#source-roborock)" + tags: [ Discovery ] + responses: + "200": { $ref: "#/components/responses/discovery" } + "404": + description: No auth + post: + summary: Roborock login and discovery + tags: [ Discovery ] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + username: { type: string } + password: { type: string } + required: [ username, password ] + responses: + "200": { $ref: "#/components/responses/discovery" } + + /api/homekit: + get: + summary: Get HomeKit servers state + tags: [ HomeKit ] + parameters: + - name: id + in: query + description: Optional stream name (server ID) + required: false + schema: { type: string } + responses: + "200": + description: OK + content: + application/json: { example: "" } + "404": + description: Server not found + post: + summary: Pair HomeKit camera and create/update stream + tags: [ HomeKit ] + parameters: + - name: id + in: query + description: Stream name to create/update + required: true + schema: { type: string } + - name: src + in: query + description: HomeKit URL (without pin) + required: true + schema: { type: string } + - name: pin + in: query + description: HomeKit PIN + required: true + schema: { type: string } + responses: + "200": + description: OK + delete: + summary: Unpair HomeKit camera and delete stream + tags: [ HomeKit ] + parameters: + - name: id + in: query + description: Stream name / server ID + required: true + schema: { type: string } + responses: + "200": + description: OK + "404": + description: Stream not found + + /api/homekit/accessories: + get: + summary: Get HomeKit accessories JSON for a stream + tags: [ HomeKit ] + parameters: + - name: id + in: query + description: Stream name + required: true + schema: { type: string } + responses: + "200": + description: OK + content: + application/json: { example: { } } + "404": + description: Stream not found + + /pair-setup: + post: + summary: HomeKit Pair Setup (HAP) + description: HomeKit Accessory Protocol endpoint (TLV8). + tags: [ HomeKit ] + requestBody: + required: true + content: + application/pairing+tlv8: { example: "" } + responses: + "200": + description: OK + content: + application/pairing+tlv8: { example: "" } + + /pair-verify: + post: + summary: HomeKit Pair Verify (HAP) + description: HomeKit Accessory Protocol endpoint (TLV8). + tags: [ HomeKit ] + requestBody: + required: true + content: + application/pairing+tlv8: { example: "" } + responses: + "200": + description: OK + content: + application/pairing+tlv8: { example: "" } + + + /onvif/: + get: + summary: ONVIF server implementation + description: Simple realisation of the ONVIF protocol. Accepts any suburl requests + tags: [ ONVIF ] + responses: + default: + description: "" + + + + /stream/: + get: + summary: RTSPtoWebRTC server implementation + description: Simple API for support [RTSPtoWebRTC](https://www.home-assistant.io/integrations/rtsp_to_webrtc/) integration + tags: [ RTSPtoWebRTC ] + responses: + default: + description: "" + + + /api/ws: + get: + summary: WebSocket endpoint + description: | + Upgrade to WebSocket and exchange JSON messages: + - Request: `{ "type": "...", "value": ... }` + - Response: `{ "type": "...", "value": ... }` + + Supported message types depend on enabled modules (see `api/README.md`). + tags: [ WebSocket ] + parameters: + - name: src + in: query + description: Stream name (consumer) + required: false + schema: { type: string } + - name: dst + in: query + description: Stream name (producer) + required: false + schema: { type: string } + responses: + "101": { description: Switching Protocols } + + + + /api/webtorrent?src={src}: + get: + summary: Get WebTorrent share info + tags: [ WebTorrent ] + parameters: + - $ref: "#/components/parameters/stream_src_path" + responses: + 200: { $ref: "#/components/responses/webtorrent" } + post: + summary: Add WebTorrent share + tags: [ WebTorrent ] + parameters: + - $ref: "#/components/parameters/stream_src_path" + responses: + 200: { $ref: "#/components/responses/webtorrent" } + delete: + summary: Delete WebTorrent share + tags: [ WebTorrent ] + parameters: + - $ref: "#/components/parameters/stream_src_path" + responses: + default: { description: "" } + + /api/webtorrent: + get: + summary: Get all WebTorrent shares info + tags: [ WebTorrent ] + responses: + 200: { $ref: "#/components/responses/discovery" } + + + + /api/stack: + get: + summary: Show list unknown goroutines + tags: [ Debug ] + responses: + 200: + description: "" + content: { text/plain: { example: "" } } diff --git a/installs_on_host/go2rtc/website/favicon.ico b/installs_on_host/go2rtc/website/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..aade0bf3438e35db84c61a249cbf299a3fb72a4b GIT binary patch literal 15086 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 literal 0 HcmV?d00001 diff --git a/installs_on_host/go2rtc/website/icons/android-chrome-512x512.png b/installs_on_host/go2rtc/website/icons/android-chrome-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..6647f6bec105be962964f991ee75d948b99b3f5c GIT binary patch literal 18796 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

0Pqq9{~Vsf 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 bnd{^KKUIwFT1?)2AXj+0`njxgN@xNA4`aT7 literal 0 HcmV?d00001 diff --git a/installs_on_host/go2rtc/website/icons/apple-touch-icon-180x180.png b/installs_on_host/go2rtc/website/icons/apple-touch-icon-180x180.png new file mode 100644 index 0000000000000000000000000000000000000000..b3a60506ac8dd6ea1321d01b149b384df2825c0a GIT binary patch literal 6005 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 literal 0 HcmV?d00001 diff --git a/installs_on_host/go2rtc/website/icons/favicon.ico b/installs_on_host/go2rtc/website/icons/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..aade0bf3438e35db84c61a249cbf299a3fb72a4b GIT binary patch literal 15086 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 (V>NMCxWS-A4gtl`&L@v#dZ|` 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¨s>TNOQnjB}D+}sduc|P4 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 z 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

    dVbZdPB!YhC1adJ^l8 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%0D*>e64|??iPaIK#e&_{t%@OpkL&Iu*Ro_% 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 K$NR;9-}`@RyJeOD literal 0 HcmV?d00001 diff --git a/installs_on_host/go2rtc/website/images/logo.png b/installs_on_host/go2rtc/website/images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..2f5bab6f8625a6774f5e94eb234d6f5564aa3f98 GIT binary patch literal 38124 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�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�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 z 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

    |zvqc#xCkM@57G+; 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 z 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%0Dw!~}h$t)qpKlr|5d=Pc$1jwnv6knP@OQ>T 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 z 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≷j{o>XZb*s_Ea!wpu>&cASi6L>U*y=A1SiT8 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

    N9o7* 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 + + + + webtorrent - go2rtc + + + + +

    + + + + + \ No newline at end of file diff --git a/installs_on_host/go2rtc/www/README.md b/installs_on_host/go2rtc/www/README.md new file mode 100644 index 0000000..6fd49ce --- /dev/null +++ b/installs_on_host/go2rtc/www/README.md @@ -0,0 +1,69 @@ +# www + +This folder contains static HTTP and JS content that is embedded into the application during build. An external developer can use it as a basis for integrating go2rtc into their project or for developing a custom web interface for go2rtc. + +## HTTP API + +`www/stream.html` - universal viewer with support params in URL: + +- multiple streams on page `src=camera1&src=camera2...` +- stream technology autoselection `mode=webrtc,webrtc/tcp,mse,hls,mp4,mjpeg` +- stream technology comparison `src=camera1&mode=webrtc&mode=mse&mode=mp4` +- player width setting in pixels `width=320px` or percents `width=50%` + +`www/webrtc.html` - WebRTC viewer with support two way audio and params in URL: + +- `media=video+audio` - simple viewer +- `media=video+audio+microphone` - two way audio from camera +- `media=camera+microphone` - stream from browser +- `media=display+speaker` - stream from desktop + +## JavaScript API + +- You can write your viewer from the scratch +- You can extend the built-in viewer - `www/video-rtc.js` +- Check example - `www/video-stream.js` +- Check example - https://github.com/AlexxIT/WebRTC + +`video-rtc.js` features: + +- support technologies: + - WebRTC over UDP or TCP + - MSE or HLS or MP4 or MJPEG over WebSocket +- automatic selection best technology according on: + - codecs inside your stream + - current browser capabilities + - current network configuration +- automatic stop stream while browser or page not active +- automatic stop stream while player not inside page viewport +- automatic reconnection + +Technology selection based on priorities: + +1. Video and Audio better than just Video +2. H265 better than H264 +3. WebRTC better than MSE, than HLS, than MJPEG + +## Browser support + +[ECMAScript 2019 (ES10)](https://caniuse.com/?search=es10) supported by [iOS 12](https://en.wikipedia.org/wiki/IOS_12) (iPhone 5S, iPad Air, iPad Mini 2, etc.). + +But [ECMAScript 2017 (ES8)](https://caniuse.com/?search=es8) almost fine (`es6 + async`) and recommended for [React+TypeScript](https://github.com/typescript-cheatsheets/react). + +## Known problems + +- Autoplay doesn't work for WebRTC in Safari [read more](https://developer.apple.com/documentation/webkit/delivering_video_content_for_safari/). + +## Useful links + +- https://www.webrtc-experiment.com/DetectRTC/ +- https://divtable.com/table-styler/ +- https://www.chromium.org/audio-video/ +- https://web.dev/i18n/en/fast-playback-with-preload/#manual_buffering +- https://developer.mozilla.org/en-US/docs/Web/API/Media_Source_Extensions_API +- https://chromium.googlesource.com/external/w3c/web-platform-tests/+/refs/heads/master/media-source/mediasource-is-type-supported.html +- https://googlechrome.github.io/samples/media/sourcebuffer-changetype.html +- https://chromestatus.com/feature/5100845653819392 +- https://developer.apple.com/documentation/webkit/delivering_video_content_for_safari +- https://dirask.com/posts/JavaScript-supported-Audio-Video-MIME-Types-by-MediaRecorder-Chrome-and-Firefox-jERn81 +- https://privacycheck.sec.lrz.de/active/fp_cpt/fp_can_play_type.html diff --git a/installs_on_host/go2rtc/www/add.html b/installs_on_host/go2rtc/www/add.html new file mode 100644 index 0000000..a2e0d85 --- /dev/null +++ b/installs_on_host/go2rtc/www/add.html @@ -0,0 +1,569 @@ + + + + + + add - go2rtc + + + + + + + + +
    + +
    +
    + + + +
    +
    + + + + +
    +
    +
    + + + + +
    +
    + + + + +
    +
    + + +
    +
    +
    + + + + +
    +
    +
    + + + + +
    +
    +
    + + + + +
    +
    +
    + + + + +
    +
    + + + + + +
    +
    +
    + + + +
    +
    +
    + + + + +
    +
    +
    + + + + +
    +
    + + +
    +
    +
    + + + + +
    +
    + + + + +
    +
    + + +
    +
    +
    + + + + +
    +
    + + + + +
    +
    +
    + + + + +
    +
    + + + +
    + +
    +
    + + + + +
    +
    +
    + + + + +
    +

    + API Key required: Get your API Key +

    +
    + + + + + +
    +
    + + +
    +
    +
    + + + + +
    +
    + + + +
    +
    + + + +
    +
    + + + +
    +
    + + + +
    +
    +
    + + + + +
    +
    +
    + +
    + + + \ No newline at end of file diff --git a/installs_on_host/go2rtc/www/config.html b/installs_on_host/go2rtc/www/config.html new file mode 100644 index 0000000..026b5be --- /dev/null +++ b/installs_on_host/go2rtc/www/config.html @@ -0,0 +1,1230 @@ + + + + + + config - go2rtc + + + + + + +
    +
    + + +
    +
    +
    + + + + + + + diff --git a/installs_on_host/go2rtc/www/hls.html b/installs_on_host/go2rtc/www/hls.html new file mode 100644 index 0000000..70f5cdc --- /dev/null +++ b/installs_on_host/go2rtc/www/hls.html @@ -0,0 +1,37 @@ + + + + + hls - go2rtc + + + + + + + + \ No newline at end of file diff --git a/installs_on_host/go2rtc/www/index.html b/installs_on_host/go2rtc/www/index.html new file mode 100644 index 0000000..69126e6 --- /dev/null +++ b/installs_on_host/go2rtc/www/index.html @@ -0,0 +1,157 @@ + + + + + + go2rtc + + + + + + +
    +
    + + modes + + + + +
    + + + + + + + + + + +
    onlinecommands
    +
    +
    + + + + + diff --git a/installs_on_host/go2rtc/www/links.html b/installs_on_host/go2rtc/www/links.html new file mode 100644 index 0000000..13e08ed --- /dev/null +++ b/installs_on_host/go2rtc/www/links.html @@ -0,0 +1,268 @@ + + + + + + links - go2rtc + + + + + + +
    + + + + + + +
    +

    Play audio

    + + + +
    + + + / cameras with two way audio support +
    + + +
    +

    Publish stream

    +
    YouTube:  rtmps://xxx.rtmp.youtube.com/live2/xxxx-xxxx-xxxx-xxxx-xxxx
    +Telegram: rtmps://xxx-x.rtmp.t.me/s/xxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxx
    + + + / Telegram RTMPS server +
    + + +
    +

    WebRTC Magic

    + + + + + +
    +
  1. webrtc.html local WebRTC viewer
  2. + +
  3. + share link + copy link + delete + external WebRTC viewer +
  4. +
    + +
    + + + diff --git a/installs_on_host/go2rtc/www/log.html b/installs_on_host/go2rtc/www/log.html new file mode 100644 index 0000000..5809c45 --- /dev/null +++ b/installs_on_host/go2rtc/www/log.html @@ -0,0 +1,145 @@ + + + + + + log - go2rtc + + + + + + +
    +
    + + + +
    + + + + + + + + + + +
    TimeLevelMessage
    +
    + + + + + diff --git a/installs_on_host/go2rtc/www/main.js b/installs_on_host/go2rtc/www/main.js new file mode 100644 index 0000000..d562917 --- /dev/null +++ b/installs_on_host/go2rtc/www/main.js @@ -0,0 +1,135 @@ +document.head.innerHTML += ` + +`; + +document.body.innerHTML = ` +
    + +
    +` + document.body.innerHTML; diff --git a/installs_on_host/go2rtc/www/net.html b/installs_on_host/go2rtc/www/net.html new file mode 100644 index 0000000..1c2a697 --- /dev/null +++ b/installs_on_host/go2rtc/www/net.html @@ -0,0 +1,74 @@ + + + + + + net - go2rtc + + + + + + + +
    + + + + + diff --git a/installs_on_host/go2rtc/www/schema.json b/installs_on_host/go2rtc/www/schema.json new file mode 100644 index 0000000..27fee57 --- /dev/null +++ b/installs_on_host/go2rtc/www/schema.json @@ -0,0 +1,750 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "go2rtc", + "type": "object", + "additionalProperties": false, + "definitions": { + "listen": { + "type": "string", + "anyOf": [ + { + "type": "string", + "pattern": ":[0-9]{1,5}$" + }, + { + "type": "string", + "const": "" + } + ] + }, + "log_level": { + "type": "string", + "enum": [ + "trace", + "debug", + "info", + "warn", + "error", + "fatal", + "panic", + "disabled" + ] + }, + "source": { + "type": "string", + "examples": [ + "rtsp://username:password@192.168.1.123/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif", + "rtsp://username:password@192.168.1.123/stream1", + "rtsp://username:password@192.168.1.123/h264Preview_01_main", + "rtmp://192.168.1.123/bcs/channel0_main.bcs?channel=0&stream=0&user=username&password=password", + "http://192.168.1.123/flv?port=1935&app=bcs&stream=channel0_main.bcs&user=username&password=password", + "http://username:password@192.168.1.123/cgi-bin/snapshot.cgi?channel=1", + "ffmpeg:media.mp4#video=h264#hardware#width=1920#height=1080#rotate=180#audio=copy", + "ffmpeg:virtual?video=testsrc&size=4K#video=h264#hardware#bitrate=50M", + "exec:ffmpeg -re -i media.mp4 -c copy -rtsp_transport tcp -f rtsp {output}", + "onvif://username:password@192.168.1.123:80?subtype=0", + "tapo://password@192.168.1.123:8800?channel=0&subtype=0" + ] + } + }, + "properties": { + "api": { + "type": "object", + "properties": { + "listen": { + "type": "string", + "default": ":1984", + "examples": [ + "127.0.0.1:1984" + ] + }, + "username": { + "description": "Basic auth for WebUI", + "type": "string", + "examples": [ + "admin" + ] + }, + "password": { + "type": "string" + }, + "local_auth": { + "description": "Enable auth check for localhost requests", + "type": "boolean", + "default": false + }, + "base_path": { + "description": "API prefix for serving on suburl (/api => /rtc/api)", + "type": "string", + "examples": [ + "/rtc" + ] + }, + "static_dir": { + "description": "Folder for static files (custom web interface)", + "type": "string", + "examples": [ + "www" + ] + }, + "origin": { + "description": "Allow CORS requests (only * supported)", + "type": "string", + "enum": [ + "*", + "" + ] + }, + "tls_listen": { + "type": "string" + }, + "tls_cert": { + "type": "string", + "examples": [ + "-----BEGIN CERTIFICATE-----", + "/ssl/fullchain.pem" + ] + }, + "tls_key": { + "type": "string", + "examples": [ + "-----BEGIN PRIVATE KEY-----", + "/ssl/privkey.pem" + ] + }, + "unix_listen": { + "type": "string", + "examples": [ + "/tmp/go2rtc.sock" + ] + }, + "allow_paths": { + "description": "Allow only these HTTP paths (full paths, including base_path)", + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "/api", + "/api/streams", + "/api/webrtc" + ] + ] + } + } + }, + "app": { + "type": "object", + "properties": { + "modules": { + "description": "Enable only these modules (empty / omitted means all)", + "type": "array", + "items": { + "type": "string", + "enum": [ + "api", + "ws", + "http", + "rtsp", + "webrtc", + "mp4", + "hls", + "mjpeg", + "hass", + "homekit", + "onvif", + "rtmp", + "webtorrent", + "wyoming", + "echo", + "exec", + "expr", + "ffmpeg", + "alsa", + "v4l2", + "bubble", + "doorbird", + "dvrip", + "eseecloud", + "flussonic", + "gopro", + "isapi", + "ivideon", + "kasa", + "mpeg", + "nest", + "ring", + "roborock", + "tapo", + "tuya", + "xiaomi", + "yandex", + "debug", + "ngrok", + "pinggy", + "srtp" + ] + } + } + } + }, + "env": { + "description": "Config variables that can be referenced as ${NAME} / ${NAME:default}", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "echo": { + "type": "object", + "properties": { + "allow_paths": { + "description": "Allow only these binaries for echo: URLs (exact cmd name/path)", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "exec": { + "type": "object", + "properties": { + "allow_paths": { + "description": "Allow only these binaries for exec: URLs (exact cmd name/path)", + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "ffmpeg", + "/usr/bin/ffmpeg" + ] + ] + } + } + }, + "ffmpeg": { + "type": "object", + "properties": { + "bin": { + "type": "string", + "default": "ffmpeg" + }, + "global": { + "type": "string", + "default": "-hide_banner" + }, + "file": { + "type": "string", + "default": "-re -i {input}" + }, + "http": { + "type": "string", + "default": "-fflags nobuffer -flags low_delay -i {input}" + }, + "rtsp": { + "type": "string", + "default": "-fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i {input}" + }, + "rtsp/udp": { + "type": "string", + "default": "-fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -i {input}" + } + }, + "additionalProperties": { + "description": "FFmpeg template", + "type": "string" + } + }, + "hass": { + "type": "object", + "properties": { + "config": { + "description": "Home Assistant config directory path", + "type": "string", + "examples": [ + "/config" + ] + } + } + }, + "homekit": { + "type": "object", + "additionalProperties": { + "type": [ + "object", + "null" + ], + "properties": { + "pin": { + "description": "HomeKit pairing PIN", + "type": "string", + "default": "19550224", + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9]{8}$" + }, + { + "type": "string", + "pattern": "^[0-9]{3}-[0-9]{2}-[0-9]{3}$" + } + ] + }, + "name": { + "type": "string" + }, + "device_id": { + "type": "string" + }, + "device_private": { + "type": "string" + }, + "category_id": { + "description": "Accessory category: `bridge`, `doorbell` or numeric ID", + "type": "string", + "default": "camera", + "anyOf": [ + { + "type": "string", + "enum": [ + "bridge", + "camera", + "doorbell" + ] + }, + { + "type": "string", + "pattern": "^[0-9]+$" + }, + { + "type": "string", + "const": "" + } + ] + }, + "pairings": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "log": { + "type": "object", + "properties": { + "format": { + "description": "Log format: color/json/text or empty for autodetect", + "type": "string", + "default": "color", + "enum": [ + "", + "color", + "json", + "text" + ] + }, + "level": { + "description": "Defaul log level", + "default": "info", + "$ref": "#/definitions/log_level" + }, + "output": { + "description": "Log output: stdout/stderr/file[:path] or empty (memory only)", + "type": "string", + "default": "stdout", + "anyOf": [ + { + "type": "string", + "enum": [ + "", + "stdout", + "stderr" + ] + }, + { + "type": "string", + "pattern": "^file(:.+)?$", + "examples": [ + "file", + "file:go2rtc.log" + ] + } + ] + }, + "time": { + "type": "string", + "default": "UNIXMS", + "anyOf": [ + { + "type": "string", + "enum": [ + "", + "UNIXMS", + "UNIXMICRO", + "UNIXNANO", + "2006-01-02T15:04:05Z07:00", + "2006-01-02T15:04:05.999999999Z07:00" + ] + }, + { + "type": "string" + } + ] + }, + "api": { + "$ref": "#/definitions/log_level" + }, + "echo": { + "$ref": "#/definitions/log_level" + }, + "exec": { + "description": "Value `exec: debug` will print stderr", + "$ref": "#/definitions/log_level" + }, + "expr": { + "$ref": "#/definitions/log_level" + }, + "ffmpeg": { + "description": "Will only be displayed with `exec: debug` setting", + "default": "error", + "$ref": "#/definitions/log_level" + }, + "hass": { + "$ref": "#/definitions/log_level" + }, + "hls": { + "$ref": "#/definitions/log_level" + }, + "homekit": { + "$ref": "#/definitions/log_level" + }, + "mjpeg": { + "$ref": "#/definitions/log_level" + }, + "mp4": { + "$ref": "#/definitions/log_level" + }, + "ngrok": { + "$ref": "#/definitions/log_level" + }, + "onvif": { + "$ref": "#/definitions/log_level" + }, + "rtmp": { + "$ref": "#/definitions/log_level" + }, + "rtsp": { + "$ref": "#/definitions/log_level" + }, + "streams": { + "$ref": "#/definitions/log_level" + }, + "webrtc": { + "$ref": "#/definitions/log_level" + }, + "webtorrent": { + "$ref": "#/definitions/log_level" + }, + "wyoming": { + "$ref": "#/definitions/log_level" + } + } + }, + "ngrok": { + "type": "object", + "properties": { + "command": { + "type": "string", + "examples": [ + "ngrok tcp 8555 --authtoken xxx", + "ngrok start --all --config ngrok.yaml" + ] + } + } + }, + "pinggy": { + "type": "object", + "properties": { + "tunnel": { + "description": "Expose local address via Pinggy", + "type": "string", + "examples": [ + "http://127.0.0.1:1984", + "tcp://192.168.1.123:554" + ] + } + } + }, + "preload": { + "description": "Preload streams on startup (map stream name => probe query, default `video&audio`)", + "type": "object", + "additionalProperties": { + "type": "string", + "examples": [ + "video&audio", + "video" + ] + } + }, + "publish": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string", + "examples": [ + "rtmp://xxx.rtmp.youtube.com/live2/xxxx-xxxx-xxxx-xxxx-xxxx", + "rtmps://xxx-x.rtmp.t.me/s/xxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxx" + ] + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "rtmp": { + "type": "object", + "properties": { + "listen": { + "type": "string", + "examples": [ + ":1935" + ] + } + } + }, + "rtsp": { + "type": "object", + "properties": { + "listen": { + "type": "string", + "default": ":8554" + }, + "username": { + "type": "string", + "examples": [ + "admin" + ] + }, + "password": { + "type": "string" + }, + "default_query": { + "type": "string", + "default": "video&audio" + }, + "pkt_size": { + "type": "integer" + } + } + }, + "srtp": { + "description": "SRTP server for HomeKit", + "type": "object", + "properties": { + "listen": { + "type": "string", + "default": ":8443" + } + } + }, + "streams": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/definitions/source" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/source" + } + }, + { + "type": "null" + } + ] + } + }, + "xiaomi": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "webrtc": { + "type": "object", + "properties": { + "listen": { + "type": "string", + "default": ":8555", + "examples": [ + ":8555/udp" + ] + }, + "candidates": { + "type": "array", + "items": { + "type": "string", + "examples": [ + "216.58.210.174:8555", + "stun:8555", + "home.duckdns.org:8555" + ] + } + }, + "ice_servers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "urls": { + "type": "array", + "items": { + "type": "string", + "examples": [ + "stun:stun.l.google.com:19302", + "turn:123.123.123.123:3478" + ] + } + }, + "username": { + "type": "string" + }, + "credential": { + "type": "string" + } + } + } + }, + "filters": { + "type": "object", + "properties": { + "candidates": { + "description": "Keep only these candidates", + "type": "array", + "items": { + "type": "string" + } + }, + "interfaces": { + "description": "Keep only these interfaces", + "type": "array", + "items": { + "type": "string" + } + }, + "ips": { + "description": "Keep only these IP-addresses", + "type": "array", + "items": { + "type": "string" + } + }, + "networks": { + "description": "Use only these network types", + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp4", + "tcp6", + "udp4", + "udp6" + ] + } + }, + "udp_ports": { + "description": "Use only these UDP ports range [min, max]", + "type": "array", + "items": { + "type": "integer" + }, + "maxItems": 2, + "minItems": 2 + } + } + } + } + }, + "webtorrent": { + "type": "object", + "properties": { + "trackers": { + "type": "array", + "items": { + "type": "string" + } + }, + "shares": { + "additionalProperties": { + "type": "object", + "properties": { + "pwd": { + "type": "string", + "minLength": 4 + }, + "src": { + "type": "string" + } + } + } + } + } + }, + "wyoming": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "listen": { + "description": "Listen address for Wyoming server", + "type": "string" + }, + "name": { + "description": "Optional satellite name (default: stream name)", + "type": "string" + }, + "mode": { + "description": "Optional mode: mic / snd / default", + "type": "string", + "enum": [ + "", + "mic", + "snd" + ] + }, + "event": { + "description": "Event handlers (map event type => expr script)", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "wake_uri": { + "description": "Optional WAKE service URI (ex. tcp://host:port?name=...)", + "type": "string", + "examples": [ + "tcp://192.168.1.23:10400" + ] + }, + "vad_threshold": { + "description": "Optional VAD threshold (0.1..3.5 typical)", + "type": "number" + } + } + } + } + } +} diff --git a/installs_on_host/go2rtc/www/static.go b/installs_on_host/go2rtc/www/static.go new file mode 100644 index 0000000..064fec3 --- /dev/null +++ b/installs_on_host/go2rtc/www/static.go @@ -0,0 +1,8 @@ +package www + +import "embed" + +//go:embed *.html +//go:embed *.js +//go:embed *.json +var Static embed.FS diff --git a/installs_on_host/go2rtc/www/stream.html b/installs_on_host/go2rtc/www/stream.html new file mode 100644 index 0000000..de7ad12 --- /dev/null +++ b/installs_on_host/go2rtc/www/stream.html @@ -0,0 +1,67 @@ + + + + + + + + stream - go2rtc + + + + + + + diff --git a/installs_on_host/go2rtc/www/video-rtc.js b/installs_on_host/go2rtc/www/video-rtc.js new file mode 100644 index 0000000..953fdae --- /dev/null +++ b/installs_on_host/go2rtc/www/video-rtc.js @@ -0,0 +1,695 @@ +/** + * VideoRTC v1.6.0 - Video player for go2rtc streaming application. + * + * All modern web technologies are supported in almost any browser except Apple Safari. + * + * Support: + * - ECMAScript 2017 (ES8) = ES6 + async + * - RTCPeerConnection for Safari iOS 11.0+ + * - IntersectionObserver for Safari iOS 12.2+ + * - ManagedMediaSource for Safari 17+ + * + * Doesn't support: + * - MediaSource for Safari iOS + * - Customized built-in elements (extends HTMLVideoElement) because Safari + * - Autoplay for WebRTC in Safari + */ +export class VideoRTC extends HTMLElement { + constructor() { + super(); + + this.DISCONNECT_TIMEOUT = 5000; + this.RECONNECT_TIMEOUT = 15000; + + this.CODECS = [ + 'avc1.640029', // H.264 high 4.1 (Chromecast 1st and 2nd Gen) + 'avc1.64002A', // H.264 high 4.2 (Chromecast 3rd Gen) + 'avc1.640033', // H.264 high 5.1 (Chromecast with Google TV) + 'hvc1.1.6.L153.B0', // H.265 main 5.1 (Chromecast Ultra) + 'mp4a.40.2', // AAC LC + 'mp4a.40.5', // AAC HE + 'flac', // FLAC (PCM compatible) + 'opus', // OPUS Chrome, Firefox + ]; + + /** + * [config] Supported modes (webrtc, webrtc/tcp, mse, hls, mp4, mjpeg). + * @type {string} + */ + this.mode = 'webrtc,mse,hls,mjpeg'; + + /** + * [Config] Requested medias (video, audio, microphone). + * @type {string} + */ + this.media = 'video,audio'; + + /** + * [config] Run stream when not displayed on the screen. Default `false`. + * @type {boolean} + */ + this.background = false; + + /** + * [config] Run stream only when player in the viewport. Stop when user scroll out player. + * Value is percentage of visibility from `0` (not visible) to `1` (full visible). + * Default `0` - disable; + * @type {number} + */ + this.visibilityThreshold = 0; + + /** + * [config] Run stream only when browser page on the screen. Stop when user change browser + * tab or minimise browser windows. + * @type {boolean} + */ + this.visibilityCheck = true; + + /** + * [config] WebRTC configuration + * @type {RTCConfiguration} + */ + this.pcConfig = { + bundlePolicy: 'max-bundle', + iceServers: [{urls: ['stun:stun.cloudflare.com:3478', 'stun:stun.l.google.com:19302']}], + sdpSemantics: 'unified-plan', // important for Chromecast 1 + }; + + /** + * [info] WebSocket connection state. Values: CONNECTING, OPEN, CLOSED + * @type {number} + */ + this.wsState = WebSocket.CLOSED; + + /** + * [info] WebRTC connection state. + * @type {number} + */ + this.pcState = WebSocket.CLOSED; + + /** + * @type {HTMLVideoElement} + */ + this.video = null; + + /** + * @type {WebSocket} + */ + this.ws = null; + + /** + * @type {string|URL} + */ + this.wsURL = ''; + + /** + * @type {RTCPeerConnection} + */ + this.pc = null; + + /** + * @type {number} + */ + this.connectTS = 0; + + /** + * @type {string} + */ + this.mseCodecs = ''; + + /** + * [internal] Disconnect TimeoutID. + * @type {number} + */ + this.disconnectTID = 0; + + /** + * [internal] Reconnect TimeoutID. + * @type {number} + */ + this.reconnectTID = 0; + + /** + * [internal] Handler for receiving Binary from WebSocket. + * @type {Function} + */ + this.ondata = null; + + /** + * [internal] Handlers list for receiving JSON from WebSocket. + * @type {Object.} + */ + this.onmessage = null; + } + + /** + * Set video source (WebSocket URL). Support relative path. + * @param {string|URL} value + */ + set src(value) { + if (typeof value !== 'string') value = value.toString(); + if (value.startsWith('http')) { + value = 'ws' + value.substring(4); + } else if (value.startsWith('/')) { + value = 'ws' + location.origin.substring(4) + value; + } + + this.wsURL = value; + + this.onconnect(); + } + + /** + * Play video. Support automute when autoplay blocked. + * https://developer.chrome.com/blog/autoplay/ + */ + play() { + this.video.play().catch(() => { + if (!this.video.muted) { + this.video.muted = true; + this.video.play().catch(er => { + console.warn(er); + }); + } + }); + } + + /** + * Send message to server via WebSocket + * @param {Object} value + */ + send(value) { + if (this.ws) this.ws.send(JSON.stringify(value)); + } + + /** @param {Function} isSupported */ + codecs(isSupported) { + return this.CODECS + .filter(codec => this.media.includes(codec.includes('vc1') ? 'video' : 'audio')) + .filter(codec => isSupported(`video/mp4; codecs="${codec}"`)).join(); + } + + /** + * `CustomElement`. Invoked each time the custom element is appended into a + * document-connected element. + */ + connectedCallback() { + if (this.disconnectTID) { + clearTimeout(this.disconnectTID); + this.disconnectTID = 0; + } + + // because video autopause on disconnected from DOM + if (this.video) { + const seek = this.video.seekable; + if (seek.length > 0) { + this.video.currentTime = seek.end(seek.length - 1); + } + this.play(); + } else { + this.oninit(); + } + + this.onconnect(); + } + + /** + * `CustomElement`. Invoked each time the custom element is disconnected from the + * document's DOM. + */ + disconnectedCallback() { + if (this.background || this.disconnectTID) return; + if (this.wsState === WebSocket.CLOSED && this.pcState === WebSocket.CLOSED) return; + + this.disconnectTID = setTimeout(() => { + if (this.reconnectTID) { + clearTimeout(this.reconnectTID); + this.reconnectTID = 0; + } + + this.disconnectTID = 0; + + this.ondisconnect(); + }, this.DISCONNECT_TIMEOUT); + } + + /** + * Creates child DOM elements. Called automatically once on `connectedCallback`. + */ + oninit() { + this.video = document.createElement('video'); + this.video.controls = true; + this.video.playsInline = true; + this.video.preload = 'auto'; + + this.video.style.display = 'block'; // fix bottom margin 4px + this.video.style.width = '100%'; + this.video.style.height = '100%'; + + this.appendChild(this.video); + + this.video.addEventListener('error', ev => { + const err = this.video.error; + // https://developer.mozilla.org/en-US/docs/Web/API/MediaError/code + const MEDIA_ERRORS = { + 1: 'MEDIA_ERR_ABORTED', + 2: 'MEDIA_ERR_NETWORK', + 3: 'MEDIA_ERR_DECODE', + 4: 'MEDIA_ERR_SRC_NOT_SUPPORTED' + }; + console.error('[VideoRTC] Video error:', { + error: err ? MEDIA_ERRORS[err.code] : 'unknown', + message: err ? err.message : 'unknown', + codecs: this.mseCodecs || 'not set', + readyState: this.video.readyState, + networkState: this.video.networkState, + currentTime: this.video.currentTime + }); + if (this.ws) this.ws.close(); // run reconnect for broken MSE stream + }); + + // all Safari lies about supported audio codecs + const m = window.navigator.userAgent.match(/Version\/(\d+).+Safari/); + if (m) { + // AAC from v13, FLAC from v14, OPUS - unsupported + const skip = m[1] < '13' ? 'mp4a.40.2' : m[1] < '14' ? 'flac' : 'opus'; + this.CODECS.splice(this.CODECS.indexOf(skip)); + } + + if (this.background) return; + + if ('hidden' in document && this.visibilityCheck) { + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + this.disconnectedCallback(); + } else if (this.isConnected) { + this.connectedCallback(); + } + }); + } + + if ('IntersectionObserver' in window && this.visibilityThreshold) { + const observer = new IntersectionObserver(entries => { + entries.forEach(entry => { + if (!entry.isIntersecting) { + this.disconnectedCallback(); + } else if (this.isConnected) { + this.connectedCallback(); + } + }); + }, {threshold: this.visibilityThreshold}); + observer.observe(this); + } + } + + /** + * Connect to WebSocket. Called automatically on `connectedCallback`. + * @return {boolean} true if the connection has started. + */ + onconnect() { + if (!this.isConnected || !this.wsURL || this.ws || this.pc) return false; + + // CLOSED or CONNECTING => CONNECTING + this.wsState = WebSocket.CONNECTING; + + this.connectTS = Date.now(); + + this.ws = new WebSocket(this.wsURL); + this.ws.binaryType = 'arraybuffer'; + this.ws.addEventListener('open', () => this.onopen()); + this.ws.addEventListener('close', () => this.onclose()); + + return true; + } + + ondisconnect() { + this.wsState = WebSocket.CLOSED; + if (this.ws) { + this.ws.close(); + this.ws = null; + } + + this.pcState = WebSocket.CLOSED; + if (this.pc) { + this.pc.getSenders().forEach(sender => { + if (sender.track) sender.track.stop(); + }); + this.pc.close(); + this.pc = null; + } + + this.video.src = ''; + this.video.srcObject = null; + } + + /** + * @returns {Array.} of modes (mse, webrtc, etc.) + */ + onopen() { + // CONNECTING => OPEN + this.wsState = WebSocket.OPEN; + + this.ws.addEventListener('message', ev => { + if (typeof ev.data === 'string') { + const msg = JSON.parse(ev.data); + for (const mode in this.onmessage) { + this.onmessage[mode](msg); + } + } else { + this.ondata(ev.data); + } + }); + + this.ondata = null; + this.onmessage = {}; + + const modes = []; + + if (this.mode.includes('mse') && ('MediaSource' in window || 'ManagedMediaSource' in window)) { + modes.push('mse'); + this.onmse(); + } else if (this.mode.includes('hls') && this.video.canPlayType('application/vnd.apple.mpegurl')) { + modes.push('hls'); + this.onhls(); + } else if (this.mode.includes('mp4')) { + modes.push('mp4'); + this.onmp4(); + } + + if (this.mode.includes('webrtc') && 'RTCPeerConnection' in window) { + modes.push('webrtc'); + this.onwebrtc(); + } + + if (this.mode.includes('mjpeg')) { + if (modes.length) { + this.onmessage['mjpeg'] = msg => { + if (msg.type !== 'error' || msg.value.indexOf(modes[0]) !== 0) return; + this.onmjpeg(); + }; + } else { + modes.push('mjpeg'); + this.onmjpeg(); + } + } + + return modes; + } + + /** + * @return {boolean} true if reconnection has started. + */ + onclose() { + if (this.wsState === WebSocket.CLOSED) return false; + + // CONNECTING, OPEN => CONNECTING + this.wsState = WebSocket.CONNECTING; + this.ws = null; + + // reconnect no more than once every X seconds + const delay = Math.max(this.RECONNECT_TIMEOUT - (Date.now() - this.connectTS), 0); + + this.reconnectTID = setTimeout(() => { + this.reconnectTID = 0; + this.onconnect(); + }, delay); + + return true; + } + + onmse() { + /** @type {MediaSource} */ + let ms; + + if ('ManagedMediaSource' in window) { + const MediaSource = window.ManagedMediaSource; + + ms = new MediaSource(); + ms.addEventListener('sourceopen', () => { + this.send({type: 'mse', value: this.codecs(MediaSource.isTypeSupported)}); + }, {once: true}); + + this.video.disableRemotePlayback = true; + this.video.srcObject = ms; + } else { + ms = new MediaSource(); + ms.addEventListener('sourceopen', () => { + URL.revokeObjectURL(this.video.src); + this.send({type: 'mse', value: this.codecs(MediaSource.isTypeSupported)}); + }, {once: true}); + + this.video.src = URL.createObjectURL(ms); + this.video.srcObject = null; + } + + this.play(); + + this.mseCodecs = ''; + + this.onmessage['mse'] = msg => { + if (msg.type !== 'mse') return; + + this.mseCodecs = msg.value; + + const sb = ms.addSourceBuffer(msg.value); + sb.mode = 'segments'; // segments or sequence + sb.addEventListener('updateend', () => { + if (!sb.updating && bufLen > 0) { + try { + const data = buf.slice(0, bufLen); + sb.appendBuffer(data); + bufLen = 0; + } catch (e) { + // console.debug(e); + } + } + + if (!sb.updating && sb.buffered && sb.buffered.length) { + const end = sb.buffered.end(sb.buffered.length - 1); + const start = end - 5; + const start0 = sb.buffered.start(0); + if (start > start0) { + sb.remove(start0, start); + ms.setLiveSeekableRange(start, end); + } + if (this.video.currentTime < start) { + this.video.currentTime = start; + } + const gap = end - this.video.currentTime; + this.video.playbackRate = gap > 0.1 ? gap : 0.1; + // console.debug('VideoRTC.buffered', gap, this.video.playbackRate, this.video.readyState); + } + }); + + const buf = new Uint8Array(2 * 1024 * 1024); + let bufLen = 0; + + this.ondata = data => { + if (sb.updating || bufLen > 0) { + const b = new Uint8Array(data); + buf.set(b, bufLen); + bufLen += b.byteLength; + // console.debug('VideoRTC.buffer', b.byteLength, bufLen); + } else { + try { + sb.appendBuffer(data); + } catch (e) { + // console.debug(e); + } + } + }; + }; + } + + onwebrtc() { + const pc = new RTCPeerConnection(this.pcConfig); + + pc.addEventListener('icecandidate', ev => { + if (ev.candidate && this.mode.includes('webrtc/tcp') && ev.candidate.protocol === 'udp') return; + + const candidate = ev.candidate ? ev.candidate.toJSON().candidate : ''; + this.send({type: 'webrtc/candidate', value: candidate}); + }); + + pc.addEventListener('connectionstatechange', () => { + if (pc.connectionState === 'connected') { + const tracks = pc.getTransceivers() + .filter(tr => tr.currentDirection === 'recvonly') // skip inactive + .map(tr => tr.receiver.track); + /** @type {HTMLVideoElement} */ + const video2 = document.createElement('video'); + video2.addEventListener('loadeddata', () => this.onpcvideo(video2), {once: true}); + video2.srcObject = new MediaStream(tracks); + } else if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') { + pc.close(); // stop next events + + this.pcState = WebSocket.CLOSED; + this.pc = null; + + this.onconnect(); + } + }); + + this.onmessage['webrtc'] = msg => { + switch (msg.type) { + case 'webrtc/candidate': + if (this.mode.includes('webrtc/tcp') && msg.value.includes(' udp ')) return; + + pc.addIceCandidate({candidate: msg.value, sdpMid: '0'}).catch(er => { + console.warn(er); + }); + break; + case 'webrtc/answer': + pc.setRemoteDescription({type: 'answer', sdp: msg.value}).catch(er => { + console.warn(er); + }); + break; + case 'error': + if (!msg.value.includes('webrtc/offer')) return; + pc.close(); + } + }; + + this.createOffer(pc).then(offer => { + this.send({type: 'webrtc/offer', value: offer.sdp}); + }); + + this.pcState = WebSocket.CONNECTING; + this.pc = pc; + } + + /** + * @param pc {RTCPeerConnection} + * @return {Promise} + */ + async createOffer(pc) { + try { + if (this.media.includes('microphone')) { + const media = await navigator.mediaDevices.getUserMedia({audio: true}); + media.getTracks().forEach(track => { + pc.addTransceiver(track, {direction: 'sendonly'}); + }); + } + } catch (e) { + console.warn(e); + } + + for (const kind of ['video', 'audio']) { + if (this.media.includes(kind)) { + pc.addTransceiver(kind, {direction: 'recvonly'}); + } + } + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + return offer; + } + + /** + * @param video2 {HTMLVideoElement} + */ + onpcvideo(video2) { + if (this.pc) { + // Video+Audio > Video, H265 > H264, Video > Audio, WebRTC > MSE + let rtcPriority = 0, msePriority = 0; + + /** @type {MediaStream} */ + const stream = video2.srcObject; + if (stream.getVideoTracks().length > 0) { + // not the best, but a pretty simple way to check a codec + const isH265Supported = this.pc.remoteDescription.sdp.includes('H265/90000'); + rtcPriority += isH265Supported ? 0x240 : 0x220; + } + if (stream.getAudioTracks().length > 0) rtcPriority += 0x102; + + if (this.mseCodecs.includes('hvc1.')) msePriority += 0x230; + if (this.mseCodecs.includes('avc1.')) msePriority += 0x210; + if (this.mseCodecs.includes('mp4a.')) msePriority += 0x101; + + if (rtcPriority >= msePriority) { + this.video.srcObject = stream; + this.play(); + + this.pcState = WebSocket.OPEN; + + this.wsState = WebSocket.CLOSED; + if (this.ws) { + this.ws.close(); + this.ws = null; + } + } else { + this.pcState = WebSocket.CLOSED; + if (this.pc) { + this.pc.close(); + this.pc = null; + } + } + } + + video2.srcObject = null; + } + + onmjpeg() { + this.ondata = data => { + this.video.controls = false; + this.video.poster = 'data:image/jpeg;base64,' + VideoRTC.btoa(data); + }; + + this.send({type: 'mjpeg'}); + } + + onhls() { + this.onmessage['hls'] = msg => { + if (msg.type !== 'hls') return; + + const url = 'http' + this.wsURL.substring(2, this.wsURL.indexOf('/ws')) + '/hls/'; + const playlist = msg.value.replace('hls/', url); + this.video.src = 'data:application/vnd.apple.mpegurl;base64,' + btoa(playlist); + this.play(); + }; + + this.send({type: 'hls', value: this.codecs(type => this.video.canPlayType(type))}); + } + + onmp4() { + /** @type {HTMLCanvasElement} **/ + const canvas = document.createElement('canvas'); + /** @type {CanvasRenderingContext2D} */ + let context; + + /** @type {HTMLVideoElement} */ + const video2 = document.createElement('video'); + video2.autoplay = true; + video2.playsInline = true; + video2.muted = true; + + video2.addEventListener('loadeddata', () => { + if (!context) { + canvas.width = video2.videoWidth; + canvas.height = video2.videoHeight; + context = canvas.getContext('2d'); + } + + context.drawImage(video2, 0, 0, canvas.width, canvas.height); + + this.video.controls = false; + this.video.poster = canvas.toDataURL('image/jpeg'); + }); + + this.ondata = data => { + video2.src = 'data:video/mp4;base64,' + VideoRTC.btoa(data); + }; + + this.send({type: 'mp4', value: this.codecs(this.video.canPlayType)}); + } + + static btoa(buffer) { + const bytes = new Uint8Array(buffer); + const len = bytes.byteLength; + let binary = ''; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary); + } +} diff --git a/installs_on_host/go2rtc/www/video-stream.js b/installs_on_host/go2rtc/www/video-stream.js new file mode 100644 index 0000000..5b7c1ea --- /dev/null +++ b/installs_on_host/go2rtc/www/video-stream.js @@ -0,0 +1,103 @@ +import {VideoRTC} from './video-rtc.js'; + +/** + * This is example, how you can extend VideoRTC player for your app. + * Also you can check this example: https://github.com/AlexxIT/WebRTC + */ +class VideoStream extends VideoRTC { + set divMode(value) { + this.querySelector('.mode').innerText = value; + this.querySelector('.status').innerText = ''; + } + + set divError(value) { + const state = this.querySelector('.mode').innerText; + if (state !== 'loading') return; + this.querySelector('.mode').innerText = 'error'; + this.querySelector('.status').innerText = value; + } + + /** + * Custom GUI + */ + oninit() { + console.debug('stream.oninit'); + super.oninit(); + + this.innerHTML = ` + +
    +
    +
    +
    + `; + + const info = this.querySelector('.info'); + this.insertBefore(this.video, info); + } + + onconnect() { + console.debug('stream.onconnect'); + const result = super.onconnect(); + if (result) this.divMode = 'loading'; + return result; + } + + ondisconnect() { + console.debug('stream.ondisconnect'); + super.ondisconnect(); + } + + onopen() { + console.debug('stream.onopen'); + const result = super.onopen(); + + this.onmessage['stream'] = msg => { + console.debug('stream.onmessge', msg); + switch (msg.type) { + case 'error': + this.divError = msg.value; + break; + case 'mse': + case 'hls': + case 'mp4': + case 'mjpeg': + this.divMode = msg.type.toUpperCase(); + break; + } + }; + + return result; + } + + onclose() { + console.debug('stream.onclose'); + return super.onclose(); + } + + onpcvideo(ev) { + console.debug('stream.onpcvideo'); + super.onpcvideo(ev); + + if (this.pcState !== WebSocket.CLOSED) { + this.divMode = 'RTC'; + } + } +} + +customElements.define('video-stream', VideoStream); diff --git a/installs_on_host/go2rtc/www/webrtc-sync.html b/installs_on_host/go2rtc/www/webrtc-sync.html new file mode 100644 index 0000000..89d742b --- /dev/null +++ b/installs_on_host/go2rtc/www/webrtc-sync.html @@ -0,0 +1,66 @@ + + + + + webrtc - go2rtc + + + + + + + \ No newline at end of file diff --git a/installs_on_host/go2rtc/www/webrtc.html b/installs_on_host/go2rtc/www/webrtc.html new file mode 100644 index 0000000..dc12178 --- /dev/null +++ b/installs_on_host/go2rtc/www/webrtc.html @@ -0,0 +1,107 @@ + + + + + webrtc - go2rtc + + + + + + + \ No newline at end of file