mirror of
https://github.com/AlexxIT/go2rtc.git
synced 2025-09-27 04:36:12 +08:00
Compare commits
168 Commits
v0.1-alpha
...
v0.1-rc.1
Author | SHA1 | Date | |
---|---|---|---|
![]() |
d8158bc1e3 | ||
![]() |
f4f588d2c6 | ||
![]() |
e287b52808 | ||
![]() |
ff96257252 | ||
![]() |
909f21b7e4 | ||
![]() |
7d6a5b44f8 | ||
![]() |
278f7696b6 | ||
![]() |
3cbf2465ae | ||
![]() |
e9ea7a0b1f | ||
![]() |
0231fc3a90 | ||
![]() |
9ef2633840 | ||
![]() |
5a8df3e90a | ||
![]() |
a31cbec3eb | ||
![]() |
54f547977e | ||
![]() |
65d91e02bd | ||
![]() |
7fc3f0f641 | ||
![]() |
7725d5ed31 | ||
![]() |
6c1b9daa8b | ||
![]() |
6d432574bf | ||
![]() |
616f69c88b | ||
![]() |
f72440712b | ||
![]() |
ceed146fb8 | ||
![]() |
f17dadbbbf | ||
![]() |
3d4514eab9 | ||
![]() |
2629dccb81 | ||
![]() |
04f1aa2900 | ||
![]() |
0dacdea1c3 | ||
![]() |
24082b1616 | ||
![]() |
7964b1743b | ||
![]() |
49773a1ece | ||
![]() |
c97a48a73f | ||
![]() |
e03231ebb4 | ||
![]() |
649525a842 | ||
![]() |
d411c1a25c | ||
![]() |
2f0bcf4ae0 | ||
![]() |
831c504cab | ||
![]() |
12925a6bc5 | ||
![]() |
e50e929150 | ||
![]() |
d0c87e0379 | ||
![]() |
247b61790e | ||
![]() |
2ec618334a | ||
![]() |
6f9976c806 | ||
![]() |
17b3a4cf3a | ||
![]() |
ba30f46c02 | ||
![]() |
4134f2a89c | ||
![]() |
a81160bea1 | ||
![]() |
80392acb78 | ||
![]() |
5afac513b4 | ||
![]() |
2243110e08 | ||
![]() |
04a6e64650 | ||
![]() |
62c13f016b | ||
![]() |
9596c6139f | ||
![]() |
34f5b99126 | ||
![]() |
b562392d45 | ||
![]() |
eb8a4919a2 | ||
![]() |
237fbf23a1 | ||
![]() |
12a73b00cb | ||
![]() |
ce0fac959f | ||
![]() |
1b14be7033 | ||
![]() |
bbbade4097 | ||
![]() |
8f43ad2a35 | ||
![]() |
105331d50f | ||
![]() |
a45d0b507b | ||
![]() |
407ccc45bc | ||
![]() |
428628fcce | ||
![]() |
fa23bb6899 | ||
![]() |
71e1c840a7 | ||
![]() |
63b9639e86 | ||
![]() |
ae3e1372c8 | ||
![]() |
800ebb39be | ||
![]() |
3a10cb25bb | ||
![]() |
7784b0e64c | ||
![]() |
945b486fe0 | ||
![]() |
d72d7b089c | ||
![]() |
d339fbe712 | ||
![]() |
3aeb278c47 | ||
![]() |
c92c1fc3e9 | ||
![]() |
def57119f4 | ||
![]() |
b20275d2b5 | ||
![]() |
a11ca1da6e | ||
![]() |
0fb7132947 | ||
![]() |
0f9e3c97c5 | ||
![]() |
e049a17216 | ||
![]() |
217c8c2bf6 | ||
![]() |
9f0153e2a8 | ||
![]() |
b2eaf03914 | ||
![]() |
8b54444c89 | ||
![]() |
76b352d67f | ||
![]() |
e8edb65a31 | ||
![]() |
88a6208912 | ||
![]() |
14b6df68ce | ||
![]() |
77080663ee | ||
![]() |
d25d27a0ee | ||
![]() |
5460e194e8 | ||
![]() |
e4f565f343 | ||
![]() |
6b274f2a37 | ||
![]() |
f442aab176 | ||
![]() |
0e71bd4dcb | ||
![]() |
e3618d70c3 | ||
![]() |
99c4a3e34a | ||
![]() |
b78de349ab | ||
![]() |
b4990b1e90 | ||
![]() |
687bdadba6 | ||
![]() |
c0c96cfcdb | ||
![]() |
4f92608f33 | ||
![]() |
ac49bbef4d | ||
![]() |
f2aedbaf04 | ||
![]() |
1654ac8c82 | ||
![]() |
38a18cab62 | ||
![]() |
a006394e5f | ||
![]() |
1b3024b055 | ||
![]() |
9101cd4458 | ||
![]() |
62c0fcd1ed | ||
![]() |
ff810d3394 | ||
![]() |
14dae12ce2 | ||
![]() |
64247fc90f | ||
![]() |
c019dc58b1 | ||
![]() |
d3adaf05b1 | ||
![]() |
e6cfd1818b | ||
![]() |
fae4398d21 | ||
![]() |
5daf043937 | ||
![]() |
18d7b9075b | ||
![]() |
7c4497f856 | ||
![]() |
befa4ca1e6 | ||
![]() |
dd3b326f7a | ||
![]() |
e36123bb19 | ||
![]() |
9310343ad3 | ||
![]() |
e2d4fa3393 | ||
![]() |
5fea2932c1 | ||
![]() |
1fd110b70d | ||
![]() |
8377cf2655 | ||
![]() |
8f01b08d42 | ||
![]() |
97ce4c3114 | ||
![]() |
4813a64d9d | ||
![]() |
7923ec74a8 | ||
![]() |
1f0a5fb880 | ||
![]() |
c6a3ee65b8 | ||
![]() |
12b712426d | ||
![]() |
a9af245ef8 | ||
![]() |
f251129a2f | ||
![]() |
d28debabe9 | ||
![]() |
07bf00f9f6 | ||
![]() |
be6ec7dbb9 | ||
![]() |
4e575d1356 | ||
![]() |
4cbacfec0c | ||
![]() |
31e24c6e03 | ||
![]() |
401bf85a10 | ||
![]() |
f36851f83a | ||
![]() |
67522dbb19 | ||
![]() |
26b5745f0a | ||
![]() |
46f6a5d8e1 | ||
![]() |
48f58d0669 | ||
![]() |
fd0b8f3c39 | ||
![]() |
863bf503e2 | ||
![]() |
7a3a1a5336 | ||
![]() |
b851041caa | ||
![]() |
a4acde6d95 | ||
![]() |
1139d4fcad | ||
![]() |
159ad52277 | ||
![]() |
87bc07e404 | ||
![]() |
d1b29275d7 | ||
![]() |
7560bcbc83 | ||
![]() |
090c360747 | ||
![]() |
a81bf0daa8 | ||
![]() |
c7128897b8 | ||
![]() |
07def5ba04 | ||
![]() |
b7f4c63517 | ||
![]() |
92c67df7b4 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,3 +4,5 @@
|
||||
.tmp/
|
||||
|
||||
go2rtc.yaml
|
||||
|
||||
go2rtc.json
|
||||
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -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.
|
370
README.md
370
README.md
@@ -1,21 +1,30 @@
|
||||
# go2rtc
|
||||
|
||||
**go2rtc** - ultimate camera streaming application with support RTSP, WebRTC, FFmpeg, RTMP, etc.
|
||||
Ultimate camera streaming application with support RTSP, WebRTC, HomeKit, FFmpeg, RTMP, etc.
|
||||
|
||||
- zero-dependency and zero-config small [app for all OS](#installation) (Windows, macOS, Linux, ARM)
|
||||
- zero-delay for all supported protocols (lowest possible streaming latency)
|
||||
- zero-load on CPU for supported codecs
|
||||
- on the fly transcoding for unsupported codecs [via FFmpeg](#source-ffmpeg)
|
||||

|
||||
|
||||
- zero-dependency and zero-config [small app](#go2rtc-binary) for all OS (Windows, macOS, Linux, ARM)
|
||||
- zero-delay for many supported protocols (lowest possible streaming latency)
|
||||
- streaming from [RTSP](#source-rtsp), [RTMP](#source-rtmp), [MJPEG](#source-ffmpeg), [HLS/HTTP](#source-ffmpeg), [USB Cameras](#source-ffmpeg-device) and [other sources](#module-streams)
|
||||
- streaming to [RTSP](#module-rtsp), [WebRTC](#module-webrtc), [MSE/MP4](#module-mp4) or [MJPEG](#module-mjpeg)
|
||||
- first project in the World with support streaming from [HomeKit Cameras](#source-homekit)
|
||||
- on the fly transcoding for unsupported codecs via [FFmpeg](#source-ffmpeg)
|
||||
- multi-source 2-way [codecs negotiation](#codecs-negotiation)
|
||||
- streaming from private networks via [Ngrok or SSH-tunnels](#module-webrtc)
|
||||
- mixing tracks from different sources to single stream
|
||||
- auto match client supported codecs
|
||||
- 2-way audio for `ONVIF Profile T` Cameras
|
||||
- streaming from private networks via [Ngrok](#module-ngrok)
|
||||
- can be [integrated to](#module-api) any smart home platform or be used as [standalone app](#go2rtc-binary)
|
||||
|
||||
**Inspired by:**
|
||||
|
||||
- [webrtc](https://github.com/pion/webrtc) go library and whole [@pion](https://github.com/pion) team
|
||||
- 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/) multimedia framework pipeline idea
|
||||
- [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)
|
||||
|
||||
## Codecs negotiation
|
||||
|
||||
@@ -30,27 +39,45 @@ For example, you want to watch RTSP-stream from [Dahua IPC-K42](https://www.dahu
|
||||
- you can't get camera audio directly, because its audio codecs doesn't match with your browser codecs
|
||||
- so you decide to use transcoding via FFmpeg and add this setting to 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 stream with two sources - **RTSP and FFmpeg**
|
||||
|
||||
**go2rtc** automatically match codecs for you browser and all your stream sources. This called **multi-source 2-way codecs negotiation**. And this is one of the main features of this app.
|
||||
|
||||
**PS.** You can select `PCMU` or `PCMA` codec in camera setting and don't 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.
|
||||
Now you have 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&unicast=true&proto=Onvif#audio=opus
|
||||
- ffmpeg:rtsp://admin:password@192.168.1.123/cam/realmonitor?channel=1&subtype=0#audio=opus
|
||||
```
|
||||
|
||||

|
||||
**go2rtc** automatically match codecs for you browser and all your stream sources. This called **multi-source 2-way codecs negotiation**. And this is one of the main features of this app.
|
||||
|
||||
## Installation
|
||||

|
||||
|
||||
**PS.** You can select `PCMU` or `PCMA` codec in camera setting and don't 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.
|
||||
|
||||
## Fast start
|
||||
|
||||
1. Download [binary](#go2rtc-binary) or use [Docker](#go2rtc-docker) or [Home Assistant Add-on](#go2rtc-home-assistant-add-on)
|
||||
2. Open web interface: `http://localhost:1984/`
|
||||
|
||||
**Optionally:**
|
||||
|
||||
- add your [streams](#module-streams) to [config](#configuration) file
|
||||
- setup [external access](#module-webrtc) to webrtc
|
||||
- setup [external access](#module-ngrok) to web interface
|
||||
- install [ffmpeg](#source-ffmpeg) for transcoding
|
||||
|
||||
**Developers:**
|
||||
|
||||
- write your own [web interface](#module-api)
|
||||
- integrate [web api](#module-api) into your smart home platform
|
||||
|
||||
### go2rtc: Binary
|
||||
|
||||
Download binary for your OS from [latest release](https://github.com/AlexxIT/go2rtc/releases/):
|
||||
|
||||
- `go2rtc_win64.exe` - Windows 64-bit
|
||||
- `go2rtc_win32.exe` - Windows 32-bit
|
||||
- `go2rtc_win64.zip` - Windows 64-bit
|
||||
- `go2rtc_win32.zip` - Windows 32-bit
|
||||
- `go2rtc_linux_amd64` - Linux 64-bit
|
||||
- `go2rtc_linux_i386` - Linux 32-bit
|
||||
- `go2rtc_linux_arm64` - Linux ARM 64-bit (ex. Raspberry 64-bit OS)
|
||||
@@ -59,7 +86,30 @@ Download binary for your OS from [latest release](https://github.com/AlexxIT/go2
|
||||
- `go2rtc_mac_amd64` - Mac with Intel
|
||||
- `go2rtc_mac_arm64` - Mac with M1
|
||||
|
||||
Don't forget to fix the rights `chmod +x go2rtc_linux_xxx` on Linux and Mac.
|
||||
Don't forget to fix the rights `chmod +x go2rtc_xxx_xxx` on Linux and Mac.
|
||||
|
||||
### go2rtc: Home Assistant Add-on
|
||||
|
||||
[](https://my.home-assistant.io/redirect/supervisor_addon/?addon=a889bffc_go2rtc&repository_url=https%3A%2F%2Fgithub.com%2FAlexxIT%2Fhassio-addons)
|
||||
|
||||
1. Install Add-On:
|
||||
- Settings > Add-ons > Plus > Repositories > Add `https://github.com/AlexxIT/hassio-addons`
|
||||
- go2rtc > Install > Start
|
||||
2. Setup [Integration](#module-hass)
|
||||
|
||||
### go2rtc: Docker
|
||||
|
||||
Container [alexxit/go2rtc](https://hub.docker.com/r/alexxit/go2rtc) with support `amd64`, `386`, `arm64`, `arm`. This container same as [Home Assistant Add-on](#go2rtc-home-assistant-add-on), but can be used separately from the Home Assistant. Container has preinstalled [FFmpeg](#source-ffmpeg), [Ngrok](#module-ngrok) and [Python](#source-echo).
|
||||
|
||||
```yaml
|
||||
services:
|
||||
go2rtc:
|
||||
image: alexxit/go2rtc
|
||||
network_mode: host
|
||||
restart: always
|
||||
volumes:
|
||||
- "~/go2rtc.yaml:/config/go2rtc.yaml"
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -69,29 +119,34 @@ Create file `go2rtc.yaml` next to the app.
|
||||
- `api` server will start on default **1984 port**
|
||||
- `rtsp` server will start on default **8554 port**
|
||||
- `webrtc` will use random UDP port for each connection
|
||||
- `ffmpeg` will use default transcoding options (you need to install it [manually](https://ffmpeg.org/))
|
||||
- `ffmpeg` will use default transcoding options (you may install it [manually](https://ffmpeg.org/))
|
||||
|
||||
Available modules:
|
||||
|
||||
- [streams](#module-streams)
|
||||
- [api](#module-api) - HTTP API (important for WebRTC support)
|
||||
- [rtsp](#module-rtsp) - RTSP Server (important for FFmpeg support)
|
||||
- [webrtc](#module-webrtc) - WebRTC Server (important for external access)
|
||||
- [ngrok](#module-ngrok) - Ngrok integration (external access for private network)
|
||||
- [webrtc](#module-webrtc) - WebRTC Server
|
||||
- [mp4](#module-mp4) - MSE, MP4 stream and MP4 shapshot
|
||||
- [ffmpeg](#source-ffmpeg) - FFmpeg integration
|
||||
- [ngrok](#module-ngrok) - Ngrok integration (external access for private network)
|
||||
- [hass](#module-hass) - Home Assistant integration
|
||||
- [log](#module-log) - logs config
|
||||
|
||||
### Module: Streams
|
||||
|
||||
**go2rtc** support different stream source types. You can config only one link as stream source or multiple.
|
||||
**go2rtc** support different stream source types. You can config one or multiple links of any type as stream source.
|
||||
|
||||
Available source types:
|
||||
|
||||
- [rtsp](#source-rtsp) - most cameras on market
|
||||
- [rtmp](#source-rtmp)
|
||||
- [ffmpeg](#source-ffmpeg) - FFmpeg integration
|
||||
- [rtsp](#source-rtsp) - `RTSP` and `RTSPS` cameras
|
||||
- [rtmp](#source-rtmp) - `RTMP` streams
|
||||
- [ffmpeg](#source-ffmpeg) - FFmpeg integration (`MJPEG`, `HLS`, `files` and source types)
|
||||
- [ffmpeg:device](#source-ffmpeg-device) - local USB Camera or Webcam
|
||||
- [exec](#source-exec) - advanced FFmpeg and GStreamer integration
|
||||
- [echo](#source-echo) - get stream link from bash or python
|
||||
- [homekit](#source-homekit) - streaming from HomeKit Camera
|
||||
- [ivideon](#source-ivideon) - public cameras from [Ivideon](https://tv.ivideon.com/) service
|
||||
- [hass](#source-hass) - Home Assistant integration
|
||||
|
||||
#### Source: RTSP
|
||||
@@ -99,7 +154,7 @@ Available source types:
|
||||
- Support **RTSP and RTSPS** links with multiple video and audio tracks
|
||||
- Support **2-way audio** ONLY for [ONVIF Profile T](https://www.onvif.org/specs/stream/ONVIF-Streaming-Spec.pdf) cameras (back channel connection)
|
||||
|
||||
**Attention:** proprietary 2-way audio standards are not supported!
|
||||
**Attention:** other 2-way audio standards are not supported! ONVIF without Profile T is not supported!
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
@@ -117,6 +172,8 @@ streams:
|
||||
- rtsp://admin:password@192.168.1.123/cam/realmonitor?channel=1&subtype=1
|
||||
```
|
||||
|
||||
**PS.** For disable bachannel just add `#backchannel=0` to end of RTSP link.
|
||||
|
||||
#### Source: RTMP
|
||||
|
||||
You can get stream from RTMP server, for example [Frigate](https://docs.frigate.video/configuration/rtmp). Support ONLY `H264` video codec without audio.
|
||||
@@ -130,18 +187,21 @@ streams:
|
||||
|
||||
You can get any stream or 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.
|
||||
|
||||
Format: `ffmpeg:{input}#{params}`. Examples:
|
||||
- FFmpeg preistalled for **Docker** and **Hass Add-on** users
|
||||
- **Hass Add-on** users can target files from [/media](https://www.home-assistant.io/more-info/local-media/setup-media/) folder
|
||||
|
||||
Format: `ffmpeg:{input}#{param1}#{param2}#{param3}`. Examples:
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
# [FILE] all tracks will be copied without transcoding codecs
|
||||
file1: ffmpeg:~/media/BigBuckBunny.mp4
|
||||
file1: ffmpeg:/media/BigBuckBunny.mp4
|
||||
|
||||
# [FILE] video will be transcoded to H264, audio will be skipped
|
||||
file2: ffmpeg:~/media/BigBuckBunny.mp4#video=h264
|
||||
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
|
||||
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
|
||||
@@ -149,32 +209,38 @@ streams:
|
||||
# [MJPEG] video will be transcoded to H264
|
||||
mjpeg: ffmpeg:http://185.97.122.128/cgi-bin/faststream.jpg?stream=half&fps=15#video=h264
|
||||
|
||||
# [RTSP] video and audio will be copied
|
||||
rtsp: ffmpeg:rtsp://rtsp:12345678@192.168.1.123/av_stream/ch0#video=copy&audio=copy
|
||||
# [RTSP] video with rotation, should be transcoded, so select H264
|
||||
rotate: ffmpeg:rtsp://rtsp:12345678@192.168.1.123/av_stream/ch0#raw=-vf transpose=1#video=h264
|
||||
```
|
||||
|
||||
All trascoding formats has built-in templates. But you can override them via YAML config. You can also add your own formats to config and use them with source params.
|
||||
All trascoding formats has [built-in templates](https://github.com/AlexxIT/go2rtc/blob/master/cmd/ffmpeg/ffmpeg.go): `h264`, `h264/ultra`, `h264/high`, `h265`, `opus`, `pcmu`, `pcmu/16000`, `pcmu/48000`, `pcma`, `pcma/16000`, `pcma/48000`, `aac/16000`.
|
||||
|
||||
But you can override them via YAML config. You can also add your own formats to config and use them with source params.
|
||||
|
||||
```yaml
|
||||
ffmpeg:
|
||||
bin: ffmpeg # path to ffmpeg binary
|
||||
link: -hide_banner -i {input} # if input is link
|
||||
file: -hide_banner -re -stream_loop -1 -i {input} # if input not link
|
||||
rtsp: -hide_banner -fflags nobuffer -flags low_delay -rtsp_transport tcp -i {input} # if input is RTSP link
|
||||
output: -rtsp_transport tcp -f rtsp {output} # output
|
||||
h264: "-codec:v libx264 -g 30 -preset superfast -tune zerolatency -profile main -level 4.1"
|
||||
mycodec: "-any args that support ffmpeg..."
|
||||
```
|
||||
|
||||
h264: "-codec:v libx264 -g 30 -preset superfast -tune zerolatency -profile main -level 4.1"
|
||||
h264/ultra: "-codec:v libx264 -g 30 -preset ultrafast -tune zerolatency"
|
||||
h264/high: "-codec:v libx264 -g 30 -preset superfast -tune zerolatency"
|
||||
h265: "-codec:v libx265 -g 30 -preset ultrafast -tune zerolatency"
|
||||
opus: "-codec:a libopus -ar 48000 -ac 2"
|
||||
pcmu: "-codec:a pcm_mulaw -ar 8000 -ac 1"
|
||||
pcmu/16000: "-codec:a pcm_mulaw -ar 16000 -ac 1"
|
||||
pcmu/48000: "-codec:a pcm_mulaw -ar 48000 -ac 1"
|
||||
pcma: "-codec:a pcm_alaw -ar 8000 -ac 1"
|
||||
pcma/16000: "-codec:a pcm_alaw -ar 16000 -ac 1"
|
||||
pcma/48000: "-codec:a pcm_alaw -ar 48000 -ac 1"
|
||||
aac/16000: "-codec:a aac -ar 16000 -ac 1"
|
||||
Also you can use `raw` param for any additional FFmpeg arguments. As example for video rotation (`#raw=-vf transpose=1`). Remember that rotation is not possible without transcoding, so add supported codec as second param (`#video=h264`).
|
||||
|
||||
#### Source: 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
|
||||
- `resolution` 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
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
linux_usbcam: ffmpeg:device?video=0&resolution=1280x720#video=h264
|
||||
windows_webcam: ffmpeg:device?video=0#video=h264
|
||||
macos_facetime: ffmpeg:device?video=0&audio=1&resolution=1280x720&framerate=30#video=h264#audio=pcma
|
||||
```
|
||||
|
||||
#### Source: Exec
|
||||
@@ -183,26 +249,66 @@ FFmpeg source just a shortcut to exec source. You can get any stream or file or
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
stream1: exec:ffmpeg -hide_banner -re -stream_loop -1 -i ~/media/BigBuckBunny.mp4 -c copy -rtsp_transport tcp -f rtsp {output}
|
||||
stream1: exec:ffmpeg -hide_banner -re -stream_loop -1 -i /media/BigBuckBunny.mp4 -c copy -rtsp_transport tcp -f rtsp {output}
|
||||
```
|
||||
|
||||
#### Source: 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](#module-streams).
|
||||
|
||||
**Docker** and **Hass Add-on** users has preinstalled `python3`, `curl`, `jq`.
|
||||
|
||||
Check examples in [wiki](https://github.com/AlexxIT/go2rtc/wiki/Source-Echo-examples).
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
apple_hls: echo:python3 hls.py https://developer.apple.com/streaming/examples/basic-stream-osx-ios5.html
|
||||
```
|
||||
|
||||
#### Source: HomeKit
|
||||
|
||||
**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 iPhone
|
||||
- HomeKit device should be in same network with working [mDNS](https://en.wikipedia.org/wiki/Multicast_DNS) between device and go2rtc
|
||||
|
||||
go2rtc support import paired HomeKit devices from [Home Assistant](#source-hass). So you can use HomeKit camera with Hass and go2rtc simultaneously. If you using Hass, 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 reboot your HomeKit device (power off). If you still can't see it - you have a problems with mDNS.
|
||||
|
||||
If you see a device but it does not have a pair button - it is paired to some ecosystem (Apple Home, Home Assistant, HomeBridge etc). You need to delete device from that ecosystem, and it will be available for pairing. If you cannot unpair device, you will have to reset it.
|
||||
|
||||
**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).
|
||||
|
||||
#### Source: Ivideon
|
||||
|
||||
Support public cameras from service [Ivideon](https://tv.ivideon.com/).
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
quailcam: ivideon:100-tu5dkUPct39cTp9oNEN2B6/0
|
||||
```
|
||||
|
||||
#### Source: Hass
|
||||
|
||||
Support import camera links from [Home Assistant](https://www.home-assistant.io/) config files:
|
||||
|
||||
- support ONLY [Generic Camera](https://www.home-assistant.io/integrations/generic/), setup via GUI
|
||||
- support [Generic Camera](https://www.home-assistant.io/integrations/generic/), setup via GUI
|
||||
- support [HomeKit Camera](https://www.home-assistant.io/integrations/homekit_controller/)
|
||||
|
||||
```yaml
|
||||
hass:
|
||||
config: "~/.homeassistant"
|
||||
config: "/config" # skip this setting if you Hass Add-on user
|
||||
|
||||
streams:
|
||||
generic_camera: hass:Camera1 # Settings > Integrations > Integration Name
|
||||
aqara_g3: hass:Camera-Hub-G3-AB12
|
||||
```
|
||||
|
||||
### Module: API
|
||||
|
||||
The HTTP API is the main part for interacting with the application.
|
||||
The HTTP API is the main part for interacting with the application. Default address: `http://127.0.0.1:1984/`.
|
||||
|
||||
- you can use WebRTC only when HTTP API enabled
|
||||
- you can disable HTTP API with `listen: ""` and use, for example, only RTSP client/server protocol
|
||||
@@ -212,20 +318,20 @@ The HTTP API is the main part for interacting with the application.
|
||||
|
||||
```yaml
|
||||
api:
|
||||
listen: ":1984" # HTTP API port ("" - disabled)
|
||||
base_path: "" # API prefix for serve on suburl
|
||||
static_dir: "www" # folder for static files ("" - disabled)
|
||||
listen: ":1984" # HTTP API port ("" - disabled)
|
||||
base_path: "" # API prefix for serve on suburl
|
||||
static_dir: "" # folder for static files (custom web interface)
|
||||
```
|
||||
|
||||
**PS. go2rtc** don't provide HTTPS or password protection. Use [Nginx](https://nginx.org/) or [Ngrok](#module-ngrok) or [Home Assistant Add-on](#go2rtc-home-assistant-add-on) for this tasks.
|
||||
|
||||
**PS2.** You can access microphone (for 2-way audio) only with HTTPS
|
||||
|
||||
### Module: RTSP
|
||||
|
||||
You can get any stream as RTSP-stream with codecs filter:
|
||||
You can get any stream as RTSP-stream: `rtsp://192.168.1.123:8554/{stream_name}`
|
||||
|
||||
```
|
||||
rtsp://192.168.1.123/{stream_name}?video={codec}&audio={codec1}&audio={codec2}
|
||||
```
|
||||
|
||||
- you can omit the codecs, so one first video and one first audio will be selected
|
||||
- you can omit the codec filters, so one first video and one first audio will be selected
|
||||
- you can set `?video=copy` or just `?video`, so only one first video without audio will be selected
|
||||
- you can set multiple video or audio, so all of them will be selected
|
||||
|
||||
@@ -236,9 +342,9 @@ rtsp:
|
||||
|
||||
### Module: WebRTC
|
||||
|
||||
WebRTC usually works without problems in the local network. But external access may require additional settings. It depends on what type of internet do you have.
|
||||
WebRTC usually works without problems in the local network. But external access may require additional settings. It depends on what type of Internet do you have.
|
||||
|
||||
- by default, WebRTC use two random UDP ports for each connection (for video and audio)
|
||||
- by default, WebRTC use two random UDP ports for each connection (video and audio)
|
||||
- you can enable one additional TCP port for all connections and use it for external access
|
||||
|
||||
**Static public IP**
|
||||
@@ -281,13 +387,13 @@ ngrok:
|
||||
command: ...
|
||||
```
|
||||
|
||||
**Own TCP-tunnel**
|
||||
**Hard tech way 1. Own TCP-tunnel**
|
||||
|
||||
If you have personal VPS, you can create TCP-tunnel and setup in the same way as "Static public IP". But use your VPS IP-address in YAML config.
|
||||
If you have personal [VPS](https://en.wikipedia.org/wiki/Virtual_private_server), you can create TCP-tunnel and setup in the same way as "Static public IP". But use your VPS IP-address in YAML config.
|
||||
|
||||
**Using TURN-server**
|
||||
**Hard tech way 2. Using TURN-server**
|
||||
|
||||
TODO...
|
||||
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:
|
||||
@@ -302,6 +408,7 @@ webrtc:
|
||||
|
||||
With Ngrok integration you can get external access to your streams in situation when you have Internet with private IP-address.
|
||||
|
||||
- Ngrok preistalled for **Docker** and **Hass Add-on** users
|
||||
- you may need external access for two different things:
|
||||
- WebRTC stream, so you need tunnel WebRTC TCP port (ex. 8555)
|
||||
- go2rtc web interface, so you need tunnel API HTTP port (ex. 1984)
|
||||
@@ -353,14 +460,53 @@ tunnels:
|
||||
|
||||
### Module: Hass
|
||||
|
||||
go2rtc compatible with Home Assistant [RTSPtoWebRTC](https://www.home-assistant.io/integrations/rtsp_to_webrtc/) integration API.
|
||||
If you install **go2rtc** as [Hass Add-on](#go2rtc-home-assistant-add-on) - you need to use localhost IP-address. In other cases you need to use IP-address of server with **go2rtc** application.
|
||||
|
||||
- add integration with link to go2rtc HTTP API:
|
||||
- Hass > Settings > Integrations > Add Integration > RTSPtoWebRTC > `http://192.168.1.123:1984/`
|
||||
- add generic camera with RTSP link:
|
||||
- Hass > Settings > Integrations > Add Integration > Generic Camera > `rtsp://...`
|
||||
- use Picture Entity or Picture Glance lovelace card
|
||||
- open full screen card - this is should be WebRTC stream
|
||||
#### From go2rtc to Hass
|
||||
|
||||
Add any supported [stream source](#module-streams) as [Generic Camera](https://www.home-assistant.io/integrations/generic/) and view stream with built-in [Stream](https://www.home-assistant.io/integrations/stream/) integration. Technology `HLS`, supported codecs: `H264`, poor latency.
|
||||
|
||||
1. Add your stream to [go2rtc config](#configuration)
|
||||
2. Hass > Settings > Integrations > Add Integration > [Generic Camera](https://my.home-assistant.io/redirect/config_flow_start/?domain=generic) > `rtsp://127.0.0.1:8554/camera1`
|
||||
|
||||
#### From Hass to go2rtc
|
||||
|
||||
View almost any Hass camera using `WebRTC` technology, supported codecs `H264`/`PCMU`/`PCMA`/`OPUS`, best latency.
|
||||
|
||||
When the stream starts - the camera `entity_id` will be added to go2rtc "on the fly". You don't need to add cameras manually to [go2rtc config](#configuration). Some cameras (like [Nest](https://www.home-assistant.io/integrations/nest/)) have a dynamic link to the stream, it will be updated each time a stream is started from the Hass interface.
|
||||
|
||||
1. Hass > Settings > Integrations > Add Integration > [RTSPtoWebRTC](https://my.home-assistant.io/redirect/config_flow_start/?domain=rtsp_to_webrtc) > `http://127.0.0.1:1984/`
|
||||
2. Use Picture Entity or Picture Glance lovelace card
|
||||
|
||||
You can add camera `entity_id` to [go2rtc config](#configuration) if you need transcoding:
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
"camera.hall": ffmpeg:{input}#video=copy#audio=opus
|
||||
```
|
||||
|
||||
PS. Default Home Assistant lovelace cards don't support 2-way audio. You can use 2-way audio from [Add-on Web UI](https://my.home-assistant.io/redirect/supervisor_addon/?addon=a889bffc_go2rtc&repository_url=https%3A%2F%2Fgithub.com%2FAlexxIT%2Fhassio-addons). But you need use HTTPS to access the microphone. This is a browser restriction and cannot be avoided.
|
||||
|
||||
### Module: MP4
|
||||
|
||||
Provides several features:
|
||||
|
||||
1. MSE stream (fMP4 over WebSocket)
|
||||
2. Camera snapshots in MP4 format (single frame), can be sent to [Telegram](https://www.telegram.org/)
|
||||
3. Progressive MP4 stream - bad format for streaming because of high latency, doesn't work in Safari
|
||||
|
||||
### Module: MJPEG
|
||||
|
||||
**Important.** For stream as MJPEG format, your source MUST contain the MJPEG codec. If your camera outputs H264/H265 - you SHOULD use transcoding. 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:rtsp://rtsp:12345678@192.168.1.123/av_stream/ch0#video=mjpeg
|
||||
```
|
||||
|
||||
Example link to MJPEG: `http://192.168.1.123:1984/api/stream.mjpeg?src=camera1`
|
||||
|
||||
### Module: Log
|
||||
|
||||
@@ -376,3 +522,77 @@ log:
|
||||
streams: error
|
||||
webrtc: fatal
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
By default `go2rtc` start Web interface on port `1984` and RTSP on port `8554`. Both 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 port
|
||||
```
|
||||
|
||||
- local access to RTSP is not a problem for [FFmpeg](#source-ffmpeg) integration, because it runs locally on your server
|
||||
- local access to API is not a problem for [Home Assistant Add-on](#go2rtc-home-assistant-add-on), because Hass runs locally on same server and Add-on Web UI protected with Hass 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 used only for transmit encrypted media data
|
||||
- anyway you need to open this port to your local network and to the Internet in order for WebRTC to work
|
||||
|
||||
If you need Web interface protection without Home Assistant Add-on - you need to use reverse proxy, like [Nginx](https://nginx.org/), [Caddy](https://caddyserver.com/), [Ngrok](https://ngrok.com/), etc.
|
||||
|
||||
PS. Additionally WebRTC opens a lot of random UDP ports for transmit encrypted media. They work without problems on the local network. And sometimes work for external access, even if you haven't opened ports on your router. But for stable external WebRTC access, you need to configure the TCP port.
|
||||
|
||||
## Codecs madness
|
||||
|
||||
`AVC/H.264` codec can be played almost anywhere. But `HEVC/H.265` has a lot of limitations in supporting with different devices and browsers. It's all about patents and money, you can't do anything about it.
|
||||
|
||||
Device | WebRTC | MSE | MP4
|
||||
-------|--------|-----|----
|
||||
*latency* | best | medium | bad
|
||||
Desktop Chrome | H264 | H264, H265* | H264, H265*
|
||||
Desktop Safari | H264, H265* | H264 | no
|
||||
Desktop Edge | H264 | H264, H265* | H264, H265*
|
||||
Desktop Firefox | H264 | H264 | H264
|
||||
Desktop Opera | no | H264 | H264
|
||||
iPhone Safari | H264, H265* | no | no
|
||||
iPad Safari | H264, H265* | H264 | no
|
||||
Android Chrome | H264 | H264 | H264
|
||||
masOS Hass App | no | no | no
|
||||
|
||||
- WebRTC audio codecs: `PCMU/8000`, `PCMA/8000`, `OPUS/48000/2`
|
||||
- MSE/MP4 audio codecs: not supported yet (should be: `AAC`)
|
||||
- Chrome H265: [read this](https://github.com/StaZhu/enable-chromium-hevc-hardware-decoding)
|
||||
- Edge H265: [read this](https://www.reddit.com/r/MicrosoftEdge/comments/v9iw8k/enable_hevc_support_in_edge/)
|
||||
- Desktop Safari H265: Menu > Develop > Experimental > WebRTC H265
|
||||
- iOS Safari H265: Settings > Safari > Advanced > Experimental > WebRTC H265
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q. What's the difference between go2rtc, WebRTC Camera and RTSPtoWebRTC?**
|
||||
|
||||
**go2rtc** is a new version of the server-side [WebRTC Camera](https://github.com/AlexxIT/WebRTC) integration, completely rewritten from scratch, with a number of fixes and a huge number of new features. It is compatible with native Home Assistant [RTSPtoWebRTC](https://www.home-assistant.io/integrations/rtsp_to_webrtc/) integration. So you [can use](#module-hass) default lovelace Picture Entity or Picture Glance.
|
||||
|
||||
**Q. Why go2rtc is an addon and not an integration?**
|
||||
|
||||
Because **go2rtc** is more than just viewing your stream online with WebRTC. You can use it all the time for your various tasks. But every time the Hass is rebooted - all integrations are also rebooted. So your streams may be interrupted if you use them in additional tasks.
|
||||
|
||||
When **go2rtc** is released, the **WebRTC Camera** integration will be updated. And you can decide whether to use the integration or the addon.
|
||||
|
||||
**Q. Which RTSP link should I use inside Hass?**
|
||||
|
||||
You can use direct link to your cameras there (as you always do). **go2rtc** support zero-config feature. You may leave `streams` config section empty. And your streams will be created on the fly on first start from Hass. And your cameras will have multiple connections. Some from Hass directly and one from **go2rtc**.
|
||||
|
||||
Also you can specify your streams in **go2rtc** [config file](#configuration) and use RTSP links to this addon. With additional features: multi-source [codecs negotiation](#codecs-negotiation) or FFmpeg [transcoding](#source-ffmpeg) for unsupported codecs. Or use them as source for Frigate. And your cameras will have one connection from **go2rtc**. And **go2rtc** will have multiple connection - some from Hass via RTSP protocol, some from your browser via WebRTC protocol.
|
||||
|
||||
Use any config what you like.
|
||||
|
||||
**Q. What about lovelace card with support 2-way audio?**
|
||||
|
||||
At this moment I am focused on improving stability and adding new features to **go2rtc**. Maybe someone could write such a card themselves. It's not difficult, I have [some sketches](https://github.com/AlexxIT/go2rtc/blob/master/www/webrtc.html).
|
||||
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
BIN
assets/go2rtc.png
Normal file
BIN
assets/go2rtc.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 295 KiB |
@@ -1,19 +1,41 @@
|
||||
ARG BUILD_FROM
|
||||
FROM $BUILD_FROM
|
||||
|
||||
RUN apk add --no-cache git go ffmpeg
|
||||
FROM $BUILD_FROM as build
|
||||
|
||||
# 1. Build go2rtc
|
||||
RUN apk add --no-cache git go
|
||||
|
||||
RUN git clone https://github.com/AlexxIT/go2rtc \
|
||||
&& cd go2rtc \
|
||||
&& CGO_ENABLED=0 go build -ldflags "-s -w" -trimpath
|
||||
|
||||
# 2. Download ngrok
|
||||
ARG BUILD_ARCH
|
||||
|
||||
WORKDIR app
|
||||
|
||||
RUN git clone https://github.com/AlexxIT/go2rtc .
|
||||
RUN CGO_ENABLED=0 go build -ldflags "-s -w" -trimpath
|
||||
|
||||
# https://github.com/home-assistant/docker-base/blob/master/alpine/Dockerfile
|
||||
RUN if [ "${BUILD_ARCH}" = "aarch64" ]; then BUILD_ARCH="arm64"; \
|
||||
elif [ "${BUILD_ARCH}" = "armv7" ]; then BUILD_ARCH="arm"; fi \
|
||||
&& cd go2rtc \
|
||||
&& curl $(curl -s "https://raw.githubusercontent.com/ngrok/docker-ngrok/main/releases.json" | jq -r ".${BUILD_ARCH}.url") -o ngrok.zip \
|
||||
&& unzip ngrok
|
||||
|
||||
CMD [ "/app/go2rtc", "-config", "/config/go2rtc.yaml" ]
|
||||
|
||||
|
||||
# https://devopscube.com/reduce-docker-image-size/
|
||||
FROM $BUILD_FROM
|
||||
|
||||
# 3. Copy go2rtc and ngrok to release
|
||||
COPY --from=build /go2rtc/go2rtc /usr/local/bin
|
||||
COPY --from=build /go2rtc/ngrok /usr/local/bin
|
||||
|
||||
# 4. Install ffmpeg
|
||||
# apk base OK: 22 MiB in 40 packages
|
||||
# ffmpeg OK: 113 MiB in 110 packages
|
||||
# python3 OK: 161 MiB in 114 packages
|
||||
RUN apk add --no-cache ffmpeg python3
|
||||
|
||||
# 5. Copy run to release
|
||||
COPY run.sh /
|
||||
RUN chmod a+x /run.sh
|
||||
|
||||
CMD [ "/run.sh" ]
|
||||
|
14
build/hassio/run.sh
Normal file
14
build/hassio/run.sh
Normal file
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/with-contenv bashio
|
||||
|
||||
set +e
|
||||
|
||||
# set cwd for go2rtc (for config file, Hass integration, etc)
|
||||
cd /config
|
||||
|
||||
# add the feature to override go2rtc binary from Hass config folder
|
||||
export PATH="/config:$PATH"
|
||||
|
||||
while true; do
|
||||
go2rtc
|
||||
sleep 5
|
||||
done
|
@@ -34,31 +34,45 @@ func Init() {
|
||||
log = app.GetLogger("api")
|
||||
|
||||
initStatic(cfg.Mod.StaticDir)
|
||||
initWS()
|
||||
|
||||
HandleFunc("/api/frame.mp4", frameHandler)
|
||||
HandleFunc("/api/frame.raw", frameHandler)
|
||||
HandleFunc("/api/stack", stackHandler)
|
||||
HandleFunc("/api/stats", statsHandler)
|
||||
HandleFunc("/api/ws", apiWS)
|
||||
HandleFunc("api/streams", streamsHandler)
|
||||
HandleFunc("api/ws", apiWS)
|
||||
|
||||
// ensure we can listen without errors
|
||||
listener, err := net.Listen("tcp", cfg.Mod.Listen)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("[api] listen")
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().Str("addr", cfg.Mod.Listen).Msg("[api] listen")
|
||||
|
||||
go func() {
|
||||
s := http.Server{}
|
||||
|
||||
if log.Trace().Enabled() {
|
||||
s.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Trace().Stringer("url", r.URL).Msgf("[api] %s", r.Method)
|
||||
http.DefaultServeMux.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
if err = s.Serve(listener); err != nil {
|
||||
log.Fatal().Err(err).Msg("[api] Serve")
|
||||
log.Fatal().Err(err).Msg("[api] serve")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// HandleFunc handle pattern with relative path:
|
||||
// - "api/streams" => "{basepath}/api/streams"
|
||||
// - "/streams" => "/streams"
|
||||
func HandleFunc(pattern string, handler http.HandlerFunc) {
|
||||
http.HandleFunc(basePath+pattern, handler)
|
||||
if len(pattern) == 0 || pattern[0] != '/' {
|
||||
pattern = basePath + "/" + pattern
|
||||
}
|
||||
log.Trace().Str("path", pattern).Msg("[api] register path")
|
||||
http.HandleFunc(pattern, handler)
|
||||
}
|
||||
|
||||
func HandleWS(msgType string, handler WSHandler) {
|
||||
@@ -69,17 +83,33 @@ var basePath string
|
||||
var log zerolog.Logger
|
||||
var wsHandlers = make(map[string]WSHandler)
|
||||
|
||||
func statsHandler(w http.ResponseWriter, _ *http.Request) {
|
||||
v := map[string]interface{}{
|
||||
"streams": streams.All(),
|
||||
func streamsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
src := r.URL.Query().Get("src")
|
||||
name := r.URL.Query().Get("name")
|
||||
|
||||
if name == "" {
|
||||
name = src
|
||||
}
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[api.stats] marshal")
|
||||
|
||||
switch r.Method {
|
||||
case "PUT":
|
||||
streams.New(name, src)
|
||||
return
|
||||
case "DELETE":
|
||||
streams.Delete(src)
|
||||
return
|
||||
}
|
||||
if _, err = w.Write(data); err != nil {
|
||||
log.Error().Err(err).Msg("[api.stats] write")
|
||||
|
||||
var v interface{}
|
||||
if src != "" {
|
||||
v = streams.Get(src)
|
||||
} else {
|
||||
v = streams.All()
|
||||
}
|
||||
|
||||
e := json.NewEncoder(w)
|
||||
e.SetIndent("", " ")
|
||||
_ = e.Encode(v)
|
||||
}
|
||||
|
||||
func apiWS(w http.ResponseWriter, r *http.Request) {
|
||||
|
@@ -1,40 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/keyframe"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func frameHandler(w http.ResponseWriter, r *http.Request) {
|
||||
url := r.URL.Query().Get("url")
|
||||
stream := streams.Get(url)
|
||||
if stream == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var ch = make(chan []byte)
|
||||
|
||||
cons := new(keyframe.Consumer)
|
||||
cons.IsMP4 = strings.HasSuffix(r.URL.Path, ".mp4")
|
||||
cons.Listen(func(msg interface{}) {
|
||||
switch msg.(type) {
|
||||
case []byte:
|
||||
ch <- msg.([]byte)
|
||||
}
|
||||
})
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Warn().Err(err).Msg("[api.frame] add consumer")
|
||||
return
|
||||
}
|
||||
|
||||
data := <-ch
|
||||
|
||||
stream.RemoveConsumer(cons)
|
||||
|
||||
if _, err := w.Write(data); err != nil {
|
||||
log.Error().Err(err).Msg("[api.frame] write")
|
||||
}
|
||||
}
|
@@ -8,18 +8,19 @@ import (
|
||||
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 basePath != "" {
|
||||
r.URL.Path = r.URL.Path[len(basePath):]
|
||||
HandleFunc("", func(w http.ResponseWriter, r *http.Request) {
|
||||
if base > 0 {
|
||||
r.URL.Path = r.URL.Path[base:]
|
||||
}
|
||||
|
||||
fileServer.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
@@ -4,16 +4,42 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/gorilla/websocket"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type WSHandler func(ctx *Context, msg *streamer.Message)
|
||||
|
||||
var apiWsUp = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 512000,
|
||||
func initWS() {
|
||||
wsUp = &websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 512000,
|
||||
}
|
||||
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)
|
||||
// some users change Nginx external port using Docker port
|
||||
// so origin will be with a port and host without
|
||||
if i := strings.IndexByte(o.Host, ':'); i > 0 {
|
||||
return o.Host[:i] == r.Host
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var wsUp *websocket.Upgrader
|
||||
|
||||
type WSHandler func(ctx *Context, msg *streamer.Message)
|
||||
|
||||
type Context struct {
|
||||
Conn *websocket.Conn
|
||||
Request *http.Request
|
||||
@@ -24,7 +50,7 @@ type Context struct {
|
||||
}
|
||||
|
||||
func (ctx *Context) Upgrade(w http.ResponseWriter, r *http.Request) (err error) {
|
||||
ctx.Conn, err = apiWsUp.Upgrade(w, r, nil)
|
||||
ctx.Conn, err = wsUp.Upgrade(w, r, nil)
|
||||
ctx.Request = r
|
||||
return
|
||||
}
|
||||
|
@@ -3,6 +3,7 @@ package app
|
||||
import (
|
||||
"flag"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gopkg.in/yaml.v3"
|
||||
"io"
|
||||
"os"
|
||||
@@ -24,12 +25,24 @@ func Init() {
|
||||
Mod map[string]string `yaml:"log"`
|
||||
}
|
||||
|
||||
LoadConfig(&cfg)
|
||||
if data != nil {
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
println("ERROR: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
log.Logger = NewLogger(cfg.Mod["format"], cfg.Mod["level"])
|
||||
|
||||
modules = cfg.Mod
|
||||
|
||||
path, _ := os.Getwd()
|
||||
log.Debug().Str("os", runtime.GOOS).Str("arch", runtime.GOARCH).
|
||||
Str("cwd", path).Int("conf_size", len(data)).Msgf("[app]")
|
||||
}
|
||||
|
||||
func NewLogger(format string, level string) zerolog.Logger {
|
||||
var writer io.Writer = os.Stdout
|
||||
|
||||
// styles
|
||||
format := cfg.Mod["format"]
|
||||
if format != "json" {
|
||||
writer = zerolog.ConsoleWriter{
|
||||
Out: writer, TimeFormat: "15:04:05.000",
|
||||
@@ -39,36 +52,32 @@ func Init() {
|
||||
|
||||
zerolog.TimeFieldFormat = zerolog.TimeFormatUnixMs
|
||||
|
||||
lvl, err := zerolog.ParseLevel(cfg.Mod["level"])
|
||||
lvl, err := zerolog.ParseLevel(level)
|
||||
if err != nil || lvl == zerolog.NoLevel {
|
||||
lvl = zerolog.InfoLevel
|
||||
}
|
||||
|
||||
log = zerolog.New(writer).With().Timestamp().Logger().Level(lvl)
|
||||
|
||||
modules = cfg.Mod
|
||||
|
||||
log.Info().Msgf("go2rtc %s/%s", runtime.GOOS, runtime.GOARCH)
|
||||
return zerolog.New(writer).With().Timestamp().Logger().Level(lvl)
|
||||
}
|
||||
|
||||
func LoadConfig(v interface{}) {
|
||||
if data != nil {
|
||||
_ = yaml.Unmarshal(data, v)
|
||||
if err := yaml.Unmarshal(data, v); err != nil {
|
||||
log.Warn().Err(err).Msg("[app] read config")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func GetLogger(module string) zerolog.Logger {
|
||||
if s, ok := modules[module]; ok {
|
||||
lvl, err := zerolog.ParseLevel(s)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("[log]")
|
||||
return log
|
||||
if err == nil {
|
||||
return log.Level(lvl)
|
||||
}
|
||||
|
||||
return log.Level(lvl)
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
}
|
||||
|
||||
return log
|
||||
return log.Logger
|
||||
}
|
||||
|
||||
// internal
|
||||
@@ -76,8 +85,5 @@ func GetLogger(module string) zerolog.Logger {
|
||||
// data - config content
|
||||
var data []byte
|
||||
|
||||
// log - main logger
|
||||
var log zerolog.Logger
|
||||
|
||||
// modules log levels
|
||||
var modules map[string]string
|
||||
|
61
cmd/app/store/store.go
Normal file
61
cmd/app/store/store.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/rs/zerolog/log"
|
||||
"os"
|
||||
)
|
||||
|
||||
const name = "go2rtc.json"
|
||||
|
||||
var store map[string]interface{}
|
||||
|
||||
func load() {
|
||||
data, _ := os.ReadFile(name)
|
||||
if data != nil {
|
||||
if err := json.Unmarshal(data, &store); err != nil {
|
||||
// TODO: log
|
||||
log.Warn().Err(err).Msg("[app] read storage")
|
||||
}
|
||||
}
|
||||
|
||||
if store == nil {
|
||||
store = make(map[string]interface{})
|
||||
}
|
||||
}
|
||||
|
||||
func save() error {
|
||||
data, err := json.Marshal(store)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(name, data, 0644)
|
||||
}
|
||||
|
||||
func GetRaw(key string) interface{} {
|
||||
if store == nil {
|
||||
load()
|
||||
}
|
||||
|
||||
return store[key]
|
||||
}
|
||||
|
||||
func GetDict(key string) map[string]interface{} {
|
||||
raw := GetRaw(key)
|
||||
if raw != nil {
|
||||
return raw.(map[string]interface{})
|
||||
}
|
||||
|
||||
return make(map[string]interface{})
|
||||
}
|
||||
|
||||
func Set(key string, v interface{}) error {
|
||||
if store == nil {
|
||||
load()
|
||||
}
|
||||
|
||||
store[key] = v
|
||||
|
||||
return save()
|
||||
}
|
27
cmd/debug/debug.go
Normal file
27
cmd/debug/debug.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package debug
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
api.HandleFunc("api/stack", stackHandler)
|
||||
api.HandleFunc("api/exit", exitHandler)
|
||||
|
||||
streams.HandleFunc("null", nullHandler)
|
||||
}
|
||||
|
||||
func exitHandler(_ http.ResponseWriter, r *http.Request) {
|
||||
s := r.URL.Query().Get("code")
|
||||
code, _ := strconv.Atoi(s)
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func nullHandler(string) (streamer.Producer, error) {
|
||||
return nil, nil
|
||||
}
|
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package debug
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -21,6 +21,7 @@ var stackSkip = [][]byte{
|
||||
[]byte("created by net/http.(*Server).Serve"), // TODO: why two?
|
||||
|
||||
[]byte("created by github.com/AlexxIT/go2rtc/cmd/rtsp.Init"),
|
||||
[]byte("created by github.com/AlexxIT/go2rtc/cmd/srtp.Init"),
|
||||
|
||||
// webrtc/api.go
|
||||
[]byte("created by github.com/pion/ice/v2.NewTCPMuxDefault"),
|
29
cmd/echo/echo.go
Normal file
29
cmd/echo/echo.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package echo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/shell"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
log := app.GetLogger("echo")
|
||||
|
||||
streams.HandleFunc("echo", func(url string) (streamer.Producer, error) {
|
||||
args := shell.QuoteSplit(url[5:])
|
||||
|
||||
b, err := exec.Command(args[0], args[1:]...).Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b = bytes.TrimSpace(b)
|
||||
|
||||
log.Debug().Str("url", url).Msgf("[echo] %s", b)
|
||||
|
||||
return streams.GetProducer(string(b))
|
||||
})
|
||||
}
|
@@ -8,11 +8,13 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/rtsp"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
pkg "github.com/AlexxIT/go2rtc/pkg/rtsp"
|
||||
"github.com/AlexxIT/go2rtc/pkg/shell"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/rs/zerolog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -22,22 +24,22 @@ func Init() {
|
||||
return
|
||||
}
|
||||
|
||||
rtsp.OnProducer = func(prod streamer.Producer) bool {
|
||||
if conn := prod.(*pkg.Conn); conn != nil {
|
||||
if waiter := waiters[conn.URL.Path]; waiter != nil {
|
||||
waiter <- prod
|
||||
return true
|
||||
}
|
||||
rtsp.HandleFunc(func(conn *pkg.Conn) bool {
|
||||
waitersMu.Lock()
|
||||
waiter := waiters[conn.URL.Path]
|
||||
waitersMu.Unlock()
|
||||
|
||||
if waiter == nil {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
waiter <- conn
|
||||
return true
|
||||
})
|
||||
|
||||
streams.HandleFunc("exec", Handle)
|
||||
|
||||
log = app.GetLogger("exec")
|
||||
|
||||
// TODO: add sync.Mutex
|
||||
waiters = map[string]chan streamer.Producer{}
|
||||
}
|
||||
|
||||
func Handle(url string) (streamer.Producer, error) {
|
||||
@@ -49,7 +51,7 @@ func Handle(url string) (streamer.Producer, error) {
|
||||
)
|
||||
|
||||
// remove `exec:`
|
||||
args := strings.Split(url[5:], " ")
|
||||
args := shell.QuoteSplit(url[5:])
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
|
||||
if log.Trace().Enabled() {
|
||||
@@ -59,22 +61,32 @@ func Handle(url string) (streamer.Producer, error) {
|
||||
|
||||
ch := make(chan streamer.Producer)
|
||||
|
||||
waitersMu.Lock()
|
||||
waiters[path] = ch
|
||||
defer delete(waiters, path)
|
||||
waitersMu.Unlock()
|
||||
|
||||
defer func() {
|
||||
waitersMu.Lock()
|
||||
delete(waiters, path)
|
||||
waitersMu.Unlock()
|
||||
}()
|
||||
|
||||
log.Debug().Str("url", url).Msg("[exec] run")
|
||||
|
||||
ts := time.Now()
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Error().Err(err).Str("url", url).Msg("[exec]")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
select {
|
||||
case <-time.After(time.Second * 10):
|
||||
case <-time.After(time.Second * 15):
|
||||
_ = cmd.Process.Kill()
|
||||
log.Error().Str("url", url).Msg("[exec] timeout")
|
||||
return nil, errors.New("timeout")
|
||||
case prod := <-ch:
|
||||
log.Debug().Stringer("launch", time.Since(ts)).Msg("[exec] run")
|
||||
return prod, nil
|
||||
}
|
||||
}
|
||||
@@ -82,4 +94,5 @@ func Handle(url string) (streamer.Producer, error) {
|
||||
// internal
|
||||
|
||||
var log zerolog.Logger
|
||||
var waiters map[string]chan streamer.Producer
|
||||
var waiters = map[string]chan streamer.Producer{}
|
||||
var waitersMu sync.Mutex
|
||||
|
@@ -1,6 +1,43 @@
|
||||
## 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
|
||||
```
|
||||
|
||||
## 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_-_%D0%BE%D0%BF%D0%B8%D1%81%D0%B0%D0%BD%D0%B8%D0%B5_%D0%BA%D0%BB%D1%8E%D1%87%D0%B5%D0%B9_%D0%BA%D0%BE%D0%B4%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D0%BD%D0%B8%D1%8F
|
||||
- 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
|
||||
|
63
cmd/ffmpeg/device/device_darwin.go
Normal file
63
cmd/ffmpeg/device/device_darwin.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// https://trac.ffmpeg.org/wiki/Capture/Webcam
|
||||
const deviceInputPrefix = "-f avfoundation"
|
||||
|
||||
func deviceInputSuffix(videoIdx, audioIdx int) string {
|
||||
video := findMedia(streamer.KindVideo, videoIdx)
|
||||
audio := findMedia(streamer.KindAudio, audioIdx)
|
||||
switch {
|
||||
case video != nil && audio != nil:
|
||||
return `"` + video.Title + `:` + audio.Title + `"`
|
||||
case video != nil:
|
||||
return `"` + video.Title + `"`
|
||||
case audio != nil:
|
||||
return `"` + audio.Title + `"`
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func loadMedias() {
|
||||
cmd := exec.Command(
|
||||
Bin, "-hide_banner", "-list_devices", "true", "-f", "avfoundation", "-i", "dummy",
|
||||
)
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd.Stderr = &buf
|
||||
_ = cmd.Run()
|
||||
|
||||
var kind string
|
||||
|
||||
lines := strings.Split(buf.String(), "\n")
|
||||
process:
|
||||
for _, line := range lines {
|
||||
switch {
|
||||
case strings.HasSuffix(line, "video devices:"):
|
||||
kind = streamer.KindVideo
|
||||
continue
|
||||
case strings.HasSuffix(line, "audio devices:"):
|
||||
kind = streamer.KindAudio
|
||||
continue
|
||||
case strings.HasPrefix(line, "dummy"):
|
||||
break process
|
||||
}
|
||||
|
||||
// [AVFoundation indev @ 0x7fad54604380] [0] FaceTime HD Camera
|
||||
name := line[42:]
|
||||
media := loadMedia(kind, name)
|
||||
medias = append(medias, media)
|
||||
}
|
||||
}
|
||||
|
||||
func loadMedia(kind, name string) *streamer.Media {
|
||||
return &streamer.Media{
|
||||
Kind: kind, Title: name,
|
||||
}
|
||||
}
|
50
cmd/ffmpeg/device/device_linux.go
Normal file
50
cmd/ffmpeg/device/device_linux.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"io/ioutil"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// https://trac.ffmpeg.org/wiki/Capture/Webcam
|
||||
const deviceInputPrefix = "-f v4l2"
|
||||
|
||||
func deviceInputSuffix(videoIdx, audioIdx int) string {
|
||||
video := findMedia(streamer.KindVideo, videoIdx)
|
||||
return video.Title
|
||||
}
|
||||
|
||||
func loadMedias() {
|
||||
files, err := ioutil.ReadDir("/dev")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, file := range files {
|
||||
log.Trace().Msg("[ffmpeg] " + file.Name())
|
||||
if strings.HasPrefix(file.Name(), streamer.KindVideo) {
|
||||
media := loadMedia(streamer.KindVideo, "/dev/"+file.Name())
|
||||
if media != nil {
|
||||
medias = append(medias, media)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadMedia(kind, name string) *streamer.Media {
|
||||
cmd := exec.Command(
|
||||
Bin, "-hide_banner", "-f", "v4l2", "-list_formats", "all", "-i", name,
|
||||
)
|
||||
var buf bytes.Buffer
|
||||
cmd.Stderr = &buf
|
||||
_ = cmd.Run()
|
||||
|
||||
if !bytes.Contains(buf.Bytes(), []byte("Raw")) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &streamer.Media{
|
||||
Kind: kind, Title: name,
|
||||
}
|
||||
}
|
59
cmd/ffmpeg/device/device_windows.go
Normal file
59
cmd/ffmpeg/device/device_windows.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// https://trac.ffmpeg.org/wiki/DirectShow
|
||||
const deviceInputPrefix = "-f dshow"
|
||||
|
||||
func deviceInputSuffix(videoIdx, audioIdx int) string {
|
||||
video := findMedia(streamer.KindVideo, videoIdx)
|
||||
audio := findMedia(streamer.KindAudio, audioIdx)
|
||||
switch {
|
||||
case video != nil && audio != nil:
|
||||
return `video="` + video.Title + `":audio=` + audio.Title + `"`
|
||||
case video != nil:
|
||||
return `video="` + video.Title + `"`
|
||||
case audio != nil:
|
||||
return `audio="` + audio.Title + `"`
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func loadMedias() {
|
||||
cmd := exec.Command(
|
||||
Bin, "-hide_banner", "-list_devices", "true", "-f", "dshow", "-i", "",
|
||||
)
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd.Stderr = &buf
|
||||
_ = cmd.Run()
|
||||
|
||||
lines := strings.Split(buf.String(), "\r\n")
|
||||
for _, line := range lines {
|
||||
var kind string
|
||||
if strings.HasSuffix(line, "(video)") {
|
||||
kind = streamer.KindVideo
|
||||
} else if strings.HasSuffix(line, "(audio)") {
|
||||
kind = streamer.KindAudio
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
// hope we have constant prefix and suffix sizes
|
||||
// [dshow @ 00000181e8d028c0] "VMware Virtual USB Video Device" (video)
|
||||
name := line[28 : len(line)-9]
|
||||
media := loadMedia(kind, name)
|
||||
medias = append(medias, media)
|
||||
}
|
||||
}
|
||||
|
||||
func loadMedia(kind, name string) *streamer.Media {
|
||||
return &streamer.Media{
|
||||
Kind: kind, Title: name,
|
||||
}
|
||||
}
|
83
cmd/ffmpeg/device/devices.go
Normal file
83
cmd/ffmpeg/device/devices.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/rs/zerolog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
log = app.GetLogger("exec")
|
||||
|
||||
api.HandleFunc("api/devices", handle)
|
||||
}
|
||||
|
||||
func GetInput(src string) (string, error) {
|
||||
if medias == nil {
|
||||
loadMedias()
|
||||
}
|
||||
|
||||
input := deviceInputPrefix
|
||||
|
||||
var videoIdx, audioIdx int
|
||||
if i := strings.IndexByte(src, '?'); i > 0 {
|
||||
query, err := url.ParseQuery(src[i+1:])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for key, value := range query {
|
||||
switch key {
|
||||
case "video":
|
||||
videoIdx, _ = strconv.Atoi(value[0])
|
||||
case "audio":
|
||||
audioIdx, _ = strconv.Atoi(value[0])
|
||||
case "framerate":
|
||||
input += " -framerate " + value[0]
|
||||
case "resolution":
|
||||
input += " -video_size " + value[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input += " -i " + deviceInputSuffix(videoIdx, audioIdx)
|
||||
|
||||
return input, nil
|
||||
}
|
||||
|
||||
var Bin string
|
||||
var log zerolog.Logger
|
||||
var medias []*streamer.Media
|
||||
|
||||
func findMedia(kind string, index int) *streamer.Media {
|
||||
for _, media := range medias {
|
||||
if media.Kind != kind {
|
||||
continue
|
||||
}
|
||||
if index == 0 {
|
||||
return media
|
||||
}
|
||||
index--
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func handle(w http.ResponseWriter, r *http.Request) {
|
||||
if medias == nil {
|
||||
loadMedias()
|
||||
}
|
||||
|
||||
data, err := json.Marshal(medias)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[api.ffmpeg]")
|
||||
return
|
||||
}
|
||||
if _, err = w.Write(data); err != nil {
|
||||
log.Error().Err(err).Msg("[api.ffmpeg]")
|
||||
}
|
||||
}
|
@@ -3,6 +3,7 @@ package ffmpeg
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/cmd/exec"
|
||||
"github.com/AlexxIT/go2rtc/cmd/ffmpeg/device"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"net/url"
|
||||
@@ -20,21 +21,23 @@ func Init() {
|
||||
"bin": "ffmpeg",
|
||||
|
||||
// inputs
|
||||
"link": "-hide_banner -i {input}",
|
||||
"rtsp": "-hide_banner -fflags nobuffer -flags low_delay -rtsp_transport tcp -i {input}",
|
||||
"file": "-hide_banner -re -stream_loop -1 -i {input}",
|
||||
"file": "-re -stream_loop -1 -i {input}",
|
||||
"http": "-fflags nobuffer -flags low_delay -i {input}",
|
||||
"rtsp": "-fflags nobuffer -flags low_delay -rtsp_transport tcp -timeout 5000000 -i {input}",
|
||||
|
||||
// output
|
||||
"out": "-rtsp_transport tcp -f rtsp {output}",
|
||||
"output": "-rtsp_transport tcp -f rtsp {output}",
|
||||
|
||||
// `-g 30` - group of picture, GOP, keyframe interval
|
||||
// `-preset superfast` - we can't use ultrafast because it doesn't support `-profile main -level 4.1`
|
||||
// `-tune zerolatency` - for minimal latency
|
||||
// `-profile main -level 4.1` - most used streaming profile
|
||||
"h264": "-codec:v libx264 -g 30 -preset superfast -tune zerolatency -profile main -level 4.1",
|
||||
// `-pix_fmt yuv420p` - if input pix format 4:2:2
|
||||
"h264": "-codec:v libx264 -g 30 -preset superfast -tune zerolatency -profile main -level 4.1 -pix_fmt yuv420p",
|
||||
"h264/ultra": "-codec:v libx264 -g 30 -preset ultrafast -tune zerolatency",
|
||||
"h264/high": "-codec:v libx264 -g 30 -preset superfast -tune zerolatency",
|
||||
"h265": "-codec:v libx265 -g 30 -preset ultrafast -tune zerolatency",
|
||||
"mjpeg": "-codec:v mjpeg -force_duplicated_matrix 1 -huffman 0 -pix_fmt yuvj420p",
|
||||
"opus": "-codec:a libopus -ar 48000 -ac 2",
|
||||
"pcmu": "-codec:a pcm_mulaw -ar 8000 -ac 1",
|
||||
"pcmu/16000": "-codec:a pcm_mulaw -ar 16000 -ac 1",
|
||||
@@ -53,23 +56,52 @@ func Init() {
|
||||
s = s[7:] // remove `ffmpeg:`
|
||||
|
||||
var query url.Values
|
||||
var queryVideo, queryAudio bool
|
||||
if i := strings.IndexByte(s, '#'); i > 0 {
|
||||
query, _ = url.ParseQuery(s[i+1:])
|
||||
query = parseQuery(s[i+1:])
|
||||
queryVideo = query["video"] != nil
|
||||
queryAudio = query["audio"] != nil
|
||||
s = s[:i]
|
||||
} else {
|
||||
// by default query both video and audio
|
||||
queryVideo = true
|
||||
queryAudio = true
|
||||
}
|
||||
|
||||
var template string
|
||||
switch {
|
||||
case strings.HasPrefix(s, "rtsp"):
|
||||
template = tpl["rtsp"]
|
||||
case strings.Contains(s, "://"):
|
||||
template = tpl["link"]
|
||||
default:
|
||||
template = tpl["file"]
|
||||
var input string
|
||||
if i := strings.IndexByte(s, ':'); i > 0 {
|
||||
switch s[:i] {
|
||||
case "http", "https", "rtmp":
|
||||
input = strings.Replace(tpl["http"], "{input}", s, 1)
|
||||
case "rtsp", "rtsps":
|
||||
// https://ffmpeg.org/ffmpeg-protocols.html#rtsp
|
||||
// skip unnecessary input tracks
|
||||
switch {
|
||||
case queryVideo && queryAudio:
|
||||
input = "-allowed_media_types video+audio "
|
||||
case queryVideo:
|
||||
input = "-allowed_media_types video "
|
||||
case queryAudio:
|
||||
input = "-allowed_media_types audio "
|
||||
}
|
||||
|
||||
input += strings.Replace(tpl["rtsp"], "{input}", s, 1)
|
||||
}
|
||||
}
|
||||
|
||||
s = "exec:" + tpl["bin"] + " " +
|
||||
strings.Replace(template, "{input}", s, 1)
|
||||
if input == "" {
|
||||
if strings.HasPrefix(s, "device?") {
|
||||
var err error
|
||||
input, err = device.GetInput(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
input = strings.Replace(tpl["file"], "{input}", s, 1)
|
||||
}
|
||||
}
|
||||
|
||||
s = "exec:" + tpl["bin"] + " -hide_banner " + input
|
||||
|
||||
if query != nil {
|
||||
for _, raw := range query["raw"] {
|
||||
@@ -89,24 +121,40 @@ func Init() {
|
||||
|
||||
for _, audio := range query["audio"] {
|
||||
if audio == "copy" {
|
||||
s += " -codec:v copy"
|
||||
s += " -codec:a copy"
|
||||
} else {
|
||||
s += " " + tpl[audio]
|
||||
}
|
||||
}
|
||||
|
||||
if query["video"] == nil {
|
||||
s += " -vn"
|
||||
}
|
||||
if query["audio"] == nil {
|
||||
switch {
|
||||
case queryVideo && !queryAudio:
|
||||
s += " -an"
|
||||
case queryAudio && !queryVideo:
|
||||
s += " -vn"
|
||||
}
|
||||
} else {
|
||||
s += " -c copy"
|
||||
}
|
||||
|
||||
s += " " + tpl["out"]
|
||||
s += " " + tpl["output"]
|
||||
|
||||
return exec.Handle(s)
|
||||
})
|
||||
|
||||
device.Bin = cfg.Mod["bin"]
|
||||
device.Init()
|
||||
}
|
||||
|
||||
func parseQuery(s string) map[string][]string {
|
||||
query := map[string][]string{}
|
||||
for _, key := range strings.Split(s, "#") {
|
||||
var value string
|
||||
i := strings.IndexByte(key, '=')
|
||||
if i > 0 {
|
||||
key, value = key[:i], key[i+1:]
|
||||
}
|
||||
query[key] = append(query[key], value)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
153
cmd/hass/api.go
Normal file
153
cmd/hass/api.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package hass
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/cmd/webrtc"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func initAPI() {
|
||||
ok := func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"status":1,"payload":{}}`))
|
||||
}
|
||||
|
||||
// support https://www.home-assistant.io/integrations/rtsp_to_webrtc/
|
||||
api.HandleFunc("/static", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
api.HandleFunc("/streams", ok)
|
||||
|
||||
api.HandleFunc("/stream/", func(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 {
|
||||
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
|
||||
stream := streams.Get(v.Name)
|
||||
if stream == nil {
|
||||
// check if it is rtsp link to go2rtc
|
||||
stream = rtspStream(v.Channels.First.Url)
|
||||
if stream != nil {
|
||||
streams.New(v.Name, stream)
|
||||
} else {
|
||||
stream = streams.New(v.Name, "{input}")
|
||||
}
|
||||
}
|
||||
|
||||
stream.SetSource(v.Channels.First.Url)
|
||||
|
||||
ok(w, r)
|
||||
|
||||
// /stream/{id}/channel/0/webrtc
|
||||
default:
|
||||
i := strings.IndexByte(r.RequestURI[8:], '/')
|
||||
if i <= 0 {
|
||||
log.Warn().Msgf("wrong request: %s", r.RequestURI)
|
||||
return
|
||||
}
|
||||
name := r.RequestURI[8 : 8+i]
|
||||
|
||||
stream := streams.Get(name)
|
||||
if stream == nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
log.Error().Err(err).Msg("[api.hass] parse form")
|
||||
return
|
||||
}
|
||||
|
||||
s := r.FormValue("data")
|
||||
offer, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[api.hass] sdp64 decode")
|
||||
return
|
||||
}
|
||||
|
||||
s, err = webrtc.ExchangeSDP(stream, string(offer), r.UserAgent())
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[api.hass] exchange SDP")
|
||||
return
|
||||
}
|
||||
|
||||
s = base64.StdEncoding.EncodeToString([]byte(s))
|
||||
_, _ = w.Write([]byte(s))
|
||||
}
|
||||
})
|
||||
|
||||
// api from RTSPtoWebRTC
|
||||
api.HandleFunc("/stream", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
str := r.FormValue("sdp64")
|
||||
offer, err := base64.StdEncoding.DecodeString(str)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
src := r.FormValue("url")
|
||||
src, err = url.QueryUnescape(src)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
stream := streams.Get(src)
|
||||
if stream == nil {
|
||||
if stream = rtspStream(src); stream != nil {
|
||||
streams.New(src, stream)
|
||||
} else {
|
||||
stream = streams.New(src, src)
|
||||
}
|
||||
}
|
||||
|
||||
str, err = webrtc.ExchangeSDP(stream, string(offer), r.UserAgent())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
v := struct {
|
||||
Answer string `json:"sdp64"`
|
||||
}{
|
||||
Answer: base64.StdEncoding.EncodeToString([]byte(str)),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
})
|
||||
}
|
||||
|
||||
func rtspStream(url string) *streams.Stream {
|
||||
if strings.HasPrefix(url, "rtsp://") {
|
||||
if i := strings.IndexByte(url[7:], '/'); i > 0 {
|
||||
return streams.Get(url[8+i:])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type addJSON struct {
|
||||
Name string `json:"name"`
|
||||
Channels struct {
|
||||
First struct {
|
||||
//Name string `json:"name"`
|
||||
Url string `json:"url"`
|
||||
} `json:"0"`
|
||||
} `json:"channels"`
|
||||
}
|
104
cmd/hass/hass.go
104
cmd/hass/hass.go
@@ -1,20 +1,14 @@
|
||||
package hass
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/cmd/rtsp"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/cmd/webrtc"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/rs/zerolog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
@@ -26,13 +20,9 @@ func Init() {
|
||||
|
||||
app.LoadConfig(&conf)
|
||||
|
||||
log = app.GetLogger("api")
|
||||
log = app.GetLogger("hass")
|
||||
|
||||
// support https://www.home-assistant.io/integrations/rtsp_to_webrtc/
|
||||
api.HandleFunc("/static", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
api.HandleFunc("/stream", handler)
|
||||
initAPI()
|
||||
|
||||
// support load cameras from Hass config file
|
||||
filename := path.Join(conf.Mod.Config, ".storage/core.config_entries")
|
||||
@@ -41,82 +31,50 @@ func Init() {
|
||||
return
|
||||
}
|
||||
|
||||
ent := new(entries)
|
||||
if err = json.Unmarshal(data, ent); err != nil {
|
||||
storage := new(entries)
|
||||
if err = json.Unmarshal(data, storage); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
urls := map[string]string{}
|
||||
|
||||
for _, entrie := range ent.Data.Entries {
|
||||
switch entrie.Domain {
|
||||
case "generic":
|
||||
if entrie.Options.StreamSource != "" {
|
||||
urls[entrie.Title] = entrie.Options.StreamSource
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
streams.HandleFunc("hass", func(url string) (streamer.Producer, error) {
|
||||
if hurl := urls[url[5:]]; hurl != "" {
|
||||
return streams.GetProducer(hurl)
|
||||
}
|
||||
return nil, fmt.Errorf("can't get url: %s", url)
|
||||
})
|
||||
|
||||
for _, entrie := range storage.Data.Entries {
|
||||
switch entrie.Domain {
|
||||
case "generic":
|
||||
if entrie.Options.StreamSource == "" {
|
||||
continue
|
||||
}
|
||||
urls[entrie.Title] = entrie.Options.StreamSource
|
||||
|
||||
case "homekit_controller":
|
||||
if entrie.Data.ClientID == "" {
|
||||
continue
|
||||
}
|
||||
urls[entrie.Title] = fmt.Sprintf(
|
||||
"homekit://%s:%d?client_id=%s&client_private=%s%s&device_id=%s&device_public=%s",
|
||||
entrie.Data.DeviceHost, entrie.Data.DevicePort,
|
||||
entrie.Data.ClientID, entrie.Data.ClientPrivate, entrie.Data.ClientPublic,
|
||||
entrie.Data.DeviceID, entrie.Data.DevicePublic,
|
||||
)
|
||||
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
log.Info().Str("url", "hass:"+entrie.Title).Msg("[hass] load stream")
|
||||
//streams.Get("hass:" + entrie.Title)
|
||||
}
|
||||
}
|
||||
|
||||
var log zerolog.Logger
|
||||
|
||||
func handler(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
log.Error().Err(err).Msg("[api.hass] parse form")
|
||||
return
|
||||
}
|
||||
|
||||
url := r.FormValue("url")
|
||||
str := r.FormValue("sdp64")
|
||||
|
||||
offer, err := base64.StdEncoding.DecodeString(str)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[api.hass] sdp64 decode")
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: fixme
|
||||
if strings.HasPrefix(url, "rtsp://") {
|
||||
port := ":" + rtsp.Port + "/"
|
||||
i := strings.Index(url, port)
|
||||
if i > 0 {
|
||||
url = url[i+len(port):]
|
||||
}
|
||||
}
|
||||
|
||||
stream := streams.Get(url)
|
||||
str, err = webrtc.ExchangeSDP(stream, string(offer), r.UserAgent())
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[api.hass] exchange SDP")
|
||||
return
|
||||
}
|
||||
|
||||
resp := struct {
|
||||
Answer string `json:"sdp64"`
|
||||
}{
|
||||
Answer: base64.StdEncoding.EncodeToString([]byte(str)),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[api.hass] marshal JSON")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if _, err = w.Write(data); err != nil {
|
||||
log.Error().Err(err).Msg("[api.hass] write")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type entries struct {
|
||||
Data struct {
|
||||
Entries []struct {
|
||||
|
149
cmd/homekit/api.go
Normal file
149
cmd/homekit/api.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package homekit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/cmd/app/store"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/homekit"
|
||||
"github.com/AlexxIT/go2rtc/pkg/homekit/mdns"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func apiHandler(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
items := make([]interface{}, 0)
|
||||
|
||||
for name, src := range store.GetDict("streams") {
|
||||
if src := src.(string); strings.HasPrefix(src, "homekit") {
|
||||
u, err := url.Parse(src)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
device := Device{
|
||||
Name: name,
|
||||
Addr: u.Host,
|
||||
Paired: true,
|
||||
}
|
||||
items = append(items, device)
|
||||
}
|
||||
}
|
||||
|
||||
for info := range mdns.GetAll() {
|
||||
if !strings.HasSuffix(info.Name, mdns.Suffix) {
|
||||
continue
|
||||
}
|
||||
name := info.Name[:len(info.Name)-len(mdns.Suffix)]
|
||||
device := Device{
|
||||
Name: strings.ReplaceAll(name, "\\", ""),
|
||||
Addr: fmt.Sprintf("%s:%d", info.AddrV4, info.Port),
|
||||
}
|
||||
for _, field := range info.InfoFields {
|
||||
switch field[:2] {
|
||||
case "id":
|
||||
device.ID = field[3:]
|
||||
case "md":
|
||||
device.Model = field[3:]
|
||||
case "sf":
|
||||
device.Paired = field[3] == '0'
|
||||
}
|
||||
}
|
||||
items = append(items, device)
|
||||
}
|
||||
|
||||
_= json.NewEncoder(w).Encode(items)
|
||||
|
||||
case "POST":
|
||||
// TODO: post params...
|
||||
|
||||
id := r.URL.Query().Get("id")
|
||||
pin := r.URL.Query().Get("pin")
|
||||
|
||||
client, err := homekit.Pair(id, pin)
|
||||
if err != nil {
|
||||
// log error
|
||||
log.Error().Err(err).Msg("[api.homekit] pair")
|
||||
// response error
|
||||
_, err = w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
name := r.URL.Query().Get("name")
|
||||
dict := store.GetDict("streams")
|
||||
dict[name] = client.URL()
|
||||
if err = store.Set("streams", dict); err != nil {
|
||||
// log error
|
||||
log.Error().Err(err).Msg("[api.homekit] save to store")
|
||||
// response error
|
||||
_, err = w.Write([]byte(err.Error()))
|
||||
}
|
||||
|
||||
streams.New(name, client.URL())
|
||||
|
||||
case "DELETE":
|
||||
src := r.URL.Query().Get("src")
|
||||
dict := store.GetDict("streams")
|
||||
for name, rawURL := range dict {
|
||||
if name != src {
|
||||
continue
|
||||
}
|
||||
|
||||
client, err := homekit.NewClient(rawURL.(string))
|
||||
if err != nil {
|
||||
// log error
|
||||
log.Error().Err(err).Msg("[api.homekit] new client")
|
||||
// response error
|
||||
_, err = w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if err = client.Dial(); err != nil {
|
||||
// log error
|
||||
log.Error().Err(err).Msg("[api.homekit] client dial")
|
||||
// response error
|
||||
_, err = w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
go client.Handle()
|
||||
|
||||
if err = client.ListPairings(); err != nil {
|
||||
// log error
|
||||
log.Error().Err(err).Msg("[api.homekit] unpair")
|
||||
// response error
|
||||
_, err = w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if err = client.DeletePairing(client.ClientID); err != nil {
|
||||
// log error
|
||||
log.Error().Err(err).Msg("[api.homekit] unpair")
|
||||
// response error
|
||||
_, err = w.Write([]byte(err.Error()))
|
||||
}
|
||||
|
||||
delete(dict, name)
|
||||
|
||||
if err = store.Set("streams", dict); err != nil {
|
||||
// log error
|
||||
log.Error().Err(err).Msg("[api.homekit] store set")
|
||||
// response error
|
||||
_, err = w.Write([]byte(err.Error()))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Addr string `json:"addr"`
|
||||
Model string `json:"model"`
|
||||
Paired bool `json:"paired"`
|
||||
//Type string `json:"type"`
|
||||
}
|
39
cmd/homekit/homekit.go
Normal file
39
cmd/homekit/homekit.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package homekit
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/homekit"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
log = app.GetLogger("homekit")
|
||||
|
||||
streams.HandleFunc("homekit", streamHandler)
|
||||
|
||||
api.HandleFunc("api/homekit", apiHandler)
|
||||
}
|
||||
|
||||
var log zerolog.Logger
|
||||
|
||||
func streamHandler(url string) (streamer.Producer, error) {
|
||||
client, err := homekit.NewClient(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = client.Dial(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// start gorutine for reading responses from camera
|
||||
go func() {
|
||||
if err = client.Handle(); err != nil {
|
||||
log.Warn().Err(err).Msg("[homekit] client")
|
||||
}
|
||||
}()
|
||||
|
||||
return &Producer{client: client}, nil
|
||||
}
|
189
cmd/homekit/producer.go
Normal file
189
cmd/homekit/producer.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package homekit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/cmd/srtp"
|
||||
"github.com/AlexxIT/go2rtc/pkg/homekit"
|
||||
"github.com/AlexxIT/go2rtc/pkg/homekit/camera"
|
||||
pkg "github.com/AlexxIT/go2rtc/pkg/srtp"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/brutella/hap/characteristic"
|
||||
"github.com/brutella/hap/rtp"
|
||||
"net"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type Producer struct {
|
||||
streamer.Element
|
||||
|
||||
client *homekit.Client
|
||||
medias []*streamer.Media
|
||||
tracks []*streamer.Track
|
||||
|
||||
sessions []*pkg.Session
|
||||
}
|
||||
|
||||
func (c *Producer) GetMedias() []*streamer.Media {
|
||||
if c.medias == nil {
|
||||
c.medias = c.getMedias()
|
||||
}
|
||||
|
||||
return c.medias
|
||||
}
|
||||
|
||||
func (c *Producer) GetTrack(media *streamer.Media, codec *streamer.Codec) *streamer.Track {
|
||||
for _, track := range c.tracks {
|
||||
if track.Codec == codec {
|
||||
return track
|
||||
}
|
||||
}
|
||||
|
||||
track := &streamer.Track{Codec: codec, Direction: media.Direction}
|
||||
c.tracks = append(c.tracks, track)
|
||||
return track
|
||||
}
|
||||
|
||||
func (c *Producer) Start() error {
|
||||
if c.tracks == nil {
|
||||
return errors.New("producer without tracks")
|
||||
}
|
||||
|
||||
// get our server local IP-address
|
||||
host, _, err := net.SplitHostPort(c.client.LocalAddr())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// get our server SRTP port
|
||||
port, err := strconv.Atoi(srtp.Port)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// setup HomeKit stream session
|
||||
hkSession := camera.NewSession()
|
||||
hkSession.SetLocalEndpoint(host, uint16(port))
|
||||
|
||||
// create client for processing camera accessory
|
||||
cam := camera.NewClient(c.client)
|
||||
// try to start HomeKit stream
|
||||
if err = cam.StartStream2(hkSession); err != nil {
|
||||
panic(err) // TODO: fixme
|
||||
}
|
||||
|
||||
// SRTP Video Session
|
||||
vs := &pkg.Session{
|
||||
LocalSSRC: hkSession.Config.Video.RTP.Ssrc,
|
||||
RemoteSSRC: hkSession.Answer.SsrcVideo,
|
||||
Track: c.tracks[0],
|
||||
}
|
||||
if err = vs.SetKeys(
|
||||
hkSession.Offer.Video.MasterKey, hkSession.Offer.Video.MasterSalt,
|
||||
hkSession.Answer.Video.MasterKey, hkSession.Answer.Video.MasterSalt,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// SRTP Audio Session
|
||||
as := &pkg.Session{
|
||||
LocalSSRC: hkSession.Config.Audio.RTP.Ssrc,
|
||||
RemoteSSRC: hkSession.Answer.SsrcAudio,
|
||||
Track: &streamer.Track{},
|
||||
}
|
||||
if err = as.SetKeys(
|
||||
hkSession.Offer.Audio.MasterKey, hkSession.Offer.Audio.MasterSalt,
|
||||
hkSession.Answer.Audio.MasterKey, hkSession.Answer.Audio.MasterSalt,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
srtp.AddSession(vs)
|
||||
srtp.AddSession(as)
|
||||
|
||||
c.sessions = []*pkg.Session{vs, as}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Producer) Stop() error {
|
||||
err := c.client.Close()
|
||||
|
||||
for _, session := range c.sessions {
|
||||
srtp.RemoveSession(session)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Producer) getMedias() []*streamer.Media {
|
||||
var medias []*streamer.Media
|
||||
|
||||
accs, err := c.client.GetAccessories()
|
||||
acc := accs[0]
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// get supported video config (not really necessary)
|
||||
char := acc.GetCharacter(characteristic.TypeSupportedVideoStreamConfiguration)
|
||||
v1 := &rtp.VideoStreamConfiguration{}
|
||||
if err = char.ReadTLV8(v1); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
for _, hkCodec := range v1.Codecs {
|
||||
codec := &streamer.Codec{ClockRate: 90000}
|
||||
|
||||
switch hkCodec.Type {
|
||||
case rtp.VideoCodecType_H264:
|
||||
codec.Name = streamer.CodecH264
|
||||
default:
|
||||
panic(fmt.Sprintf("unknown codec: %d", hkCodec.Type))
|
||||
}
|
||||
|
||||
media := &streamer.Media{
|
||||
Kind: streamer.KindVideo, Direction: streamer.DirectionSendonly,
|
||||
Codecs: []*streamer.Codec{codec},
|
||||
}
|
||||
medias = append(medias, media)
|
||||
}
|
||||
|
||||
char = acc.GetCharacter(characteristic.TypeSupportedAudioStreamConfiguration)
|
||||
v2 := &rtp.AudioStreamConfiguration{}
|
||||
if err = char.ReadTLV8(v2); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
for _, hkCodec := range v2.Codecs {
|
||||
codec := &streamer.Codec{
|
||||
Channels: uint16(hkCodec.Parameters.Channels),
|
||||
}
|
||||
|
||||
switch hkCodec.Type {
|
||||
case rtp.AudioCodecType_AAC_ELD:
|
||||
codec.Name = streamer.CodecAAC
|
||||
default:
|
||||
panic(fmt.Sprintf("unknown codec: %d", hkCodec.Type))
|
||||
}
|
||||
|
||||
switch hkCodec.Parameters.Samplerate {
|
||||
case rtp.AudioCodecSampleRate8Khz:
|
||||
codec.ClockRate = 8000
|
||||
case rtp.AudioCodecSampleRate16Khz:
|
||||
codec.ClockRate = 16000
|
||||
case rtp.AudioCodecSampleRate24Khz:
|
||||
codec.ClockRate = 24000
|
||||
default:
|
||||
panic(fmt.Sprintf("unknown clockrate: %d", hkCodec.Parameters.Samplerate))
|
||||
}
|
||||
|
||||
media := &streamer.Media{
|
||||
Kind: streamer.KindAudio, Direction: streamer.DirectionSendonly,
|
||||
Codecs: []*streamer.Codec{codec},
|
||||
}
|
||||
medias = append(medias, media)
|
||||
}
|
||||
|
||||
return medias
|
||||
}
|
19
cmd/ivideon/ivideon.go
Normal file
19
cmd/ivideon/ivideon.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package ivideon
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/ivideon"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
streams.HandleFunc("ivideon", func(url string) (streamer.Producer, error) {
|
||||
id := strings.Replace(url[8:], "/", ":", 1)
|
||||
prod := ivideon.NewClient(id)
|
||||
if err := prod.Dial(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return prod, nil
|
||||
})
|
||||
}
|
89
cmd/mjpeg/mjpeg.go
Normal file
89
cmd/mjpeg/mjpeg.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package mjpeg
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mjpeg"
|
||||
"github.com/rs/zerolog/log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
api.HandleFunc("api/frame.jpeg", handlerKeyframe)
|
||||
api.HandleFunc("api/stream.mjpeg", handlerStream)
|
||||
}
|
||||
|
||||
func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
|
||||
src := r.URL.Query().Get("src")
|
||||
stream := streams.GetOrNew(src)
|
||||
if stream == nil {
|
||||
return
|
||||
}
|
||||
|
||||
exit := make(chan []byte)
|
||||
|
||||
cons := &mjpeg.Consumer{}
|
||||
cons.Listen(func(msg interface{}) {
|
||||
switch msg := msg.(type) {
|
||||
case []byte:
|
||||
exit <- msg
|
||||
}
|
||||
})
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
|
||||
data := <-exit
|
||||
|
||||
stream.RemoveConsumer(cons)
|
||||
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||
|
||||
if _, err := w.Write(data); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
}
|
||||
|
||||
const header = "--frame\r\nContent-Type: image/jpeg\r\nContent-Length: "
|
||||
|
||||
func handlerStream(w http.ResponseWriter, r *http.Request) {
|
||||
src := r.URL.Query().Get("src")
|
||||
stream := streams.GetOrNew(src)
|
||||
if stream == nil {
|
||||
return
|
||||
}
|
||||
|
||||
exit := make(chan struct{})
|
||||
|
||||
cons := &mjpeg.Consumer{}
|
||||
cons.Listen(func(msg interface{}) {
|
||||
switch msg := msg.(type) {
|
||||
case []byte:
|
||||
data := []byte(header + strconv.Itoa(len(msg)))
|
||||
data = append(data, 0x0D, 0x0A, 0x0D, 0x0A)
|
||||
data = append(data, msg...)
|
||||
data = append(data, 0x0D, 0x0A)
|
||||
|
||||
if _, err := w.Write(data); err != nil {
|
||||
exit <- struct{}{}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Msg("[api.mjpeg] add consumer")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", `multipart/x-mixed-replace; boundary=frame`)
|
||||
|
||||
<-exit
|
||||
|
||||
stream.RemoveConsumer(cons)
|
||||
|
||||
//log.Trace().Msg("[api.mjpeg] close")
|
||||
}
|
138
cmd/mp4/mp4.go
Normal file
138
cmd/mp4/mp4.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package mp4
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mp4"
|
||||
"github.com/rs/zerolog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
log = app.GetLogger("mp4")
|
||||
|
||||
api.HandleWS(MsgTypeMSE, handlerWS)
|
||||
|
||||
api.HandleFunc("api/frame.mp4", handlerKeyframe)
|
||||
api.HandleFunc("api/stream.mp4", handlerMP4)
|
||||
}
|
||||
|
||||
var log zerolog.Logger
|
||||
|
||||
func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
|
||||
if isChromeFirst(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
src := r.URL.Query().Get("src")
|
||||
stream := streams.GetOrNew(src)
|
||||
if stream == nil {
|
||||
return
|
||||
}
|
||||
|
||||
exit := make(chan []byte)
|
||||
|
||||
cons := &mp4.Consumer{}
|
||||
cons.Listen(func(msg interface{}) {
|
||||
switch msg := msg.(type) {
|
||||
case []byte:
|
||||
exit <- msg
|
||||
}
|
||||
})
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Msg("[api.keyframe] add consumer")
|
||||
return
|
||||
}
|
||||
|
||||
defer stream.RemoveConsumer(cons)
|
||||
|
||||
w.Header().Set("Content-Type", cons.MimeType())
|
||||
|
||||
data, err := cons.Init()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[api.keyframe] init")
|
||||
return
|
||||
}
|
||||
data = append(data, <-exit...)
|
||||
|
||||
// Apple Safari won't show frame without length
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||
|
||||
if _, err := w.Write(data); err != nil {
|
||||
log.Error().Err(err).Msg("[api.keyframe] add consumer")
|
||||
}
|
||||
}
|
||||
|
||||
func handlerMP4(w http.ResponseWriter, r *http.Request) {
|
||||
if isChromeFirst(w, r) || isSafari(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
log.Trace().Msgf("[api.mp4] %+v", r)
|
||||
|
||||
src := r.URL.Query().Get("src")
|
||||
stream := streams.GetOrNew(src)
|
||||
if stream == nil {
|
||||
return
|
||||
}
|
||||
|
||||
exit := make(chan struct{})
|
||||
|
||||
cons := &mp4.Consumer{}
|
||||
cons.Listen(func(msg interface{}) {
|
||||
switch msg := msg.(type) {
|
||||
case []byte:
|
||||
if _, err := w.Write(msg); err != nil {
|
||||
exit <- struct{}{}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Msg("[api.mp4] add consumer")
|
||||
return
|
||||
}
|
||||
|
||||
defer stream.RemoveConsumer(cons)
|
||||
|
||||
w.Header().Set("Content-Type", cons.MimeType())
|
||||
|
||||
data, err := cons.Init()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[api.mp4] init")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err = w.Write(data); err != nil {
|
||||
log.Error().Err(err).Msg("[api.mp4] write")
|
||||
return
|
||||
}
|
||||
|
||||
<-exit
|
||||
|
||||
log.Trace().Msg("[api.mp4] close")
|
||||
}
|
||||
|
||||
func isChromeFirst(w http.ResponseWriter, r *http.Request) bool {
|
||||
// Chrome 105 does two requests: without Range and with `Range: bytes=0-`
|
||||
if strings.Contains(r.UserAgent(), " Chrome/") {
|
||||
if r.Header.Values("Range") == nil {
|
||||
w.Header().Set("Content-Type", "video/mp4")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isSafari(w http.ResponseWriter, r *http.Request) bool {
|
||||
if r.Header.Get("Range") == "bytes=0-1" {
|
||||
handlerKeyframe(w, r)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
@@ -1,35 +1,34 @@
|
||||
package mse
|
||||
package mp4
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mse"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mp4"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
api.HandleWS("mse", handler)
|
||||
}
|
||||
const MsgTypeMSE = "mse" // fMP4
|
||||
|
||||
func handler(ctx *api.Context, msg *streamer.Message) {
|
||||
url := ctx.Request.URL.Query().Get("url")
|
||||
stream := streams.Get(url)
|
||||
func handlerWS(ctx *api.Context, msg *streamer.Message) {
|
||||
src := ctx.Request.URL.Query().Get("src")
|
||||
stream := streams.GetOrNew(src)
|
||||
if stream == nil {
|
||||
return
|
||||
}
|
||||
|
||||
cons := new(mse.Consumer)
|
||||
cons := &mp4.Consumer{}
|
||||
cons.UserAgent = ctx.Request.UserAgent()
|
||||
cons.RemoteAddr = ctx.Request.RemoteAddr
|
||||
|
||||
cons.Listen(func(msg interface{}) {
|
||||
switch msg.(type) {
|
||||
case *streamer.Message, []byte:
|
||||
ctx.Write(msg)
|
||||
}
|
||||
})
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Warn().Err(err).Msg("[api.mse] Add consumer")
|
||||
log.Warn().Err(err).Msg("[api.mse] add consumer")
|
||||
ctx.Error(err)
|
||||
return
|
||||
}
|
||||
@@ -38,5 +37,16 @@ func handler(ctx *api.Context, msg *streamer.Message) {
|
||||
stream.RemoveConsumer(cons)
|
||||
})
|
||||
|
||||
cons.Init()
|
||||
ctx.Write(&streamer.Message{
|
||||
Type: MsgTypeMSE, Value: cons.MimeType(),
|
||||
})
|
||||
|
||||
data, err := cons.Init()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("[api.mse] init")
|
||||
ctx.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Write(data)
|
||||
}
|
@@ -8,6 +8,8 @@ import (
|
||||
|
||||
func Init() {
|
||||
streams.HandleFunc("rtmp", handle)
|
||||
streams.HandleFunc("http", handle)
|
||||
streams.HandleFunc("https", handle)
|
||||
}
|
||||
|
||||
func handle(url string) (streamer.Producer, error) {
|
||||
|
211
cmd/rtsp/rtsp.go
211
cmd/rtsp/rtsp.go
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"github.com/rs/zerolog"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
@@ -27,25 +28,58 @@ func Init() {
|
||||
// RTSP client support
|
||||
streams.HandleFunc("rtsp", rtspHandler)
|
||||
streams.HandleFunc("rtsps", rtspHandler)
|
||||
streams.HandleFunc("rtspx", rtspHandler)
|
||||
|
||||
// RTSP server support
|
||||
address := conf.Mod.Listen
|
||||
if address != "" {
|
||||
_, Port, _ = net.SplitHostPort(address)
|
||||
|
||||
go worker(address)
|
||||
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")
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go tcpHandler(conn)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
type Handler func(conn *rtsp.Conn) bool
|
||||
|
||||
func HandleFunc(handler Handler) {
|
||||
handlers = append(handlers, handler)
|
||||
}
|
||||
|
||||
var Port string
|
||||
|
||||
var OnProducer func(conn streamer.Producer) bool // TODO: maybe rewrite...
|
||||
|
||||
// internal
|
||||
|
||||
var log zerolog.Logger
|
||||
var handlers []Handler
|
||||
|
||||
func rtspHandler(url string) (streamer.Producer, error) {
|
||||
backchannel := true
|
||||
|
||||
if i := strings.IndexByte(url, '#'); i > 0 {
|
||||
if url[i+1:] == "backchannel=0" {
|
||||
backchannel = false
|
||||
}
|
||||
url = url[:i]
|
||||
}
|
||||
|
||||
conn, err := rtsp.NewClient(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -65,108 +99,109 @@ func rtspHandler(url string) (streamer.Producer, error) {
|
||||
if err = conn.Dial(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conn.Backchannel = backchannel
|
||||
if err = conn.Describe(); err != nil {
|
||||
return nil, err
|
||||
if !backchannel {
|
||||
return nil, 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 worker(address string) {
|
||||
srv, err := tcp.NewServer(address)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[rtsp] listen")
|
||||
return
|
||||
}
|
||||
func tcpHandler(c net.Conn) {
|
||||
var name string
|
||||
var closer func()
|
||||
|
||||
log.Info().Str("addr", address).Msg("[rtsp] listen")
|
||||
trace := log.Trace().Enabled()
|
||||
|
||||
srv.Listen(func(msg interface{}) {
|
||||
switch msg.(type) {
|
||||
case net.Conn:
|
||||
var name string
|
||||
var onDisconnect func()
|
||||
conn := rtsp.NewServer(c)
|
||||
conn.Listen(func(msg interface{}) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
trace := log.Trace().Enabled()
|
||||
switch msg {
|
||||
case rtsp.MethodDescribe:
|
||||
name = conn.URL.Path[1:]
|
||||
|
||||
conn := rtsp.NewServer(msg.(net.Conn))
|
||||
conn.Listen(func(msg interface{}) {
|
||||
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:
|
||||
name = conn.URL.Path[1:]
|
||||
|
||||
log.Debug().Str("stream", name).Msg("[rtsp] new consumer")
|
||||
|
||||
stream := streams.Get(name) // TODO: rewrite
|
||||
if stream == nil {
|
||||
return
|
||||
}
|
||||
|
||||
initMedias(conn)
|
||||
|
||||
if err = stream.AddConsumer(conn); err != nil {
|
||||
log.Warn().Err(err).Str("stream", name).Msg("[rtsp]")
|
||||
return
|
||||
}
|
||||
|
||||
onDisconnect = func() {
|
||||
stream.RemoveConsumer(conn)
|
||||
}
|
||||
|
||||
case rtsp.MethodAnnounce:
|
||||
if OnProducer != nil {
|
||||
if OnProducer(conn) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
name = conn.URL.Path[1:]
|
||||
|
||||
log.Debug().Str("stream", name).Msg("[rtsp] new producer")
|
||||
|
||||
str := streams.Get(conn.URL.Path[1:])
|
||||
if str == nil {
|
||||
return
|
||||
}
|
||||
|
||||
str.AddProducer(conn)
|
||||
|
||||
onDisconnect = func() {
|
||||
str.RemoveProducer(conn)
|
||||
}
|
||||
|
||||
case streamer.StatePlaying:
|
||||
log.Debug().Str("stream", name).Msg("[rtsp] start")
|
||||
}
|
||||
})
|
||||
|
||||
if err = conn.Accept(); err != nil {
|
||||
log.Warn().Err(err).Msg("[rtsp] accept")
|
||||
stream := streams.Get(name)
|
||||
if stream == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = conn.Handle(); err != nil {
|
||||
//log.Warn().Err(err).Msg("[rtsp] handle server")
|
||||
log.Debug().Str("stream", name).Msg("[rtsp] new consumer")
|
||||
|
||||
initMedias(conn)
|
||||
|
||||
if err := stream.AddConsumer(conn); err != nil {
|
||||
log.Warn().Err(err).Str("stream", name).Msg("[rtsp]")
|
||||
return
|
||||
}
|
||||
|
||||
if onDisconnect != nil {
|
||||
onDisconnect()
|
||||
closer = func() {
|
||||
stream.RemoveConsumer(conn)
|
||||
}
|
||||
|
||||
log.Debug().Str("stream", name).Msg("[rtsp] disconnect")
|
||||
case rtsp.MethodAnnounce:
|
||||
name = conn.URL.Path[1:]
|
||||
|
||||
stream := streams.Get(name)
|
||||
if stream == nil {
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug().Str("stream", name).Msg("[rtsp] new producer")
|
||||
|
||||
stream.AddProducer(conn)
|
||||
|
||||
closer = func() {
|
||||
stream.RemoveProducer(conn)
|
||||
}
|
||||
|
||||
case streamer.StatePlaying:
|
||||
log.Debug().Str("stream", name).Msg("[rtsp] start")
|
||||
}
|
||||
})
|
||||
|
||||
srv.Serve()
|
||||
if err := conn.Accept(); err != nil {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
for _, handler := range handlers {
|
||||
if handler(conn) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if closer != nil {
|
||||
if err := conn.Handle(); err != nil {
|
||||
log.Debug().Err(err).Caller().Send()
|
||||
}
|
||||
|
||||
closer()
|
||||
|
||||
log.Debug().Str("stream", name).Msg("[rtsp] disconnect")
|
||||
}
|
||||
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
func initMedias(conn *rtsp.Conn) {
|
||||
|
59
cmd/srtp/srtp.go
Normal file
59
cmd/srtp/srtp.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package srtp
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/pkg/srtp"
|
||||
"github.com/rs/zerolog"
|
||||
"net"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
log = app.GetLogger("srtp")
|
||||
|
||||
// create SRTP server (endpoint) for receiving video from HomeKit camera
|
||||
conn, err := net.ListenPacket("udp", cfg.Mod.Listen)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("[srtp] listen")
|
||||
}
|
||||
|
||||
log.Info().Str("addr", cfg.Mod.Listen).Msg("[srtp] listen")
|
||||
|
||||
_, Port, _ = net.SplitHostPort(cfg.Mod.Listen)
|
||||
|
||||
// run server
|
||||
go func() {
|
||||
server = &srtp.Server{}
|
||||
if err = server.Serve(conn); err != nil {
|
||||
log.Warn().Err(err).Msg("[srtp] serve")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
var log zerolog.Logger
|
||||
var server *srtp.Server
|
||||
|
||||
var Port string
|
||||
|
||||
func AddSession(session *srtp.Session) {
|
||||
server.AddSession(session)
|
||||
}
|
||||
|
||||
func RemoveSession(session *srtp.Session) {
|
||||
server.RemoveSession(session)
|
||||
}
|
@@ -4,27 +4,36 @@ import (
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Handler func(url string) (streamer.Producer, error)
|
||||
|
||||
var handlers map[string]Handler
|
||||
var handlers = map[string]Handler{}
|
||||
var handlersMu sync.Mutex
|
||||
|
||||
func HandleFunc(scheme string, handler Handler) {
|
||||
if handlers == nil {
|
||||
handlers = make(map[string]Handler)
|
||||
}
|
||||
handlersMu.Lock()
|
||||
handlers[scheme] = handler
|
||||
handlersMu.Unlock()
|
||||
}
|
||||
|
||||
func getHandler(url string) Handler {
|
||||
i := strings.IndexByte(url, ':')
|
||||
if i <= 0 { // TODO: i < 4 ?
|
||||
return nil
|
||||
}
|
||||
handlersMu.Lock()
|
||||
defer handlersMu.Unlock()
|
||||
return handlers[url[:i]]
|
||||
}
|
||||
|
||||
func HasProducer(url string) bool {
|
||||
i := strings.IndexByte(url, ':')
|
||||
return handlers[url[:i]] != nil
|
||||
return getHandler(url) != nil
|
||||
}
|
||||
|
||||
func GetProducer(url string) (streamer.Producer, error) {
|
||||
i := strings.IndexByte(url, ':')
|
||||
handler := handlers[url[:i]]
|
||||
handler := getHandler(url)
|
||||
if handler == nil {
|
||||
return nil, fmt.Errorf("unsupported scheme: %s", url)
|
||||
}
|
||||
|
@@ -2,6 +2,9 @@ package streams
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type state byte
|
||||
@@ -16,21 +19,35 @@ const (
|
||||
type Producer struct {
|
||||
streamer.Element
|
||||
|
||||
url string
|
||||
url string
|
||||
template string
|
||||
|
||||
element streamer.Producer
|
||||
tracks []*streamer.Track
|
||||
|
||||
state state
|
||||
state state
|
||||
mu sync.Mutex
|
||||
restart *time.Timer
|
||||
}
|
||||
|
||||
func (p *Producer) SetSource(s string) {
|
||||
if p.template == "" {
|
||||
p.template = p.url
|
||||
}
|
||||
p.url = strings.Replace(p.template, "{input}", s, 1)
|
||||
}
|
||||
|
||||
func (p *Producer) GetMedias() []*streamer.Media {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.state == stateNone {
|
||||
log.Debug().Str("url", p.url).Msg("[streams] probe producer")
|
||||
log.Debug().Msgf("[streams] probe producer url=%s", p.url)
|
||||
|
||||
var err error
|
||||
p.element, err = GetProducer(p.url)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("url", p.url).Msg("[streams] probe producer")
|
||||
if err != nil || p.element == nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -41,11 +58,17 @@ func (p *Producer) GetMedias() []*streamer.Media {
|
||||
}
|
||||
|
||||
func (p *Producer) GetTrack(media *streamer.Media, codec *streamer.Codec) *streamer.Track {
|
||||
if p.state == stateMedias {
|
||||
p.state = stateTracks
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.state == stateNone {
|
||||
return nil
|
||||
}
|
||||
|
||||
track := p.element.GetTrack(media, codec)
|
||||
if track == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, t := range p.tracks {
|
||||
if track == t {
|
||||
@@ -53,6 +76,10 @@ func (p *Producer) GetTrack(media *streamer.Media, codec *streamer.Codec) *strea
|
||||
}
|
||||
}
|
||||
|
||||
if p.state == stateMedias {
|
||||
p.state = stateTracks
|
||||
}
|
||||
|
||||
p.tracks = append(p.tracks, track)
|
||||
|
||||
return track
|
||||
@@ -61,21 +88,89 @@ func (p *Producer) GetTrack(media *streamer.Media, codec *streamer.Codec) *strea
|
||||
// internals
|
||||
|
||||
func (p *Producer) start() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.state != stateTracks {
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug().Str("url", p.url).Msg("[streams] start producer")
|
||||
log.Debug().Msgf("[streams] start producer url=%s", p.url)
|
||||
|
||||
p.state = stateStart
|
||||
go p.element.Start()
|
||||
go func() {
|
||||
// safe read element while mu locked
|
||||
if err := p.element.Start(); err != nil {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
}
|
||||
p.reconnect()
|
||||
}()
|
||||
}
|
||||
|
||||
func (p *Producer) reconnect() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.state != stateStart {
|
||||
log.Trace().Msgf("[streams] stop reconnect url=%s", p.url)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug().Msgf("[streams] reconnect to url=%s", p.url)
|
||||
|
||||
var err error
|
||||
p.element, err = GetProducer(p.url)
|
||||
if err != nil || p.element == nil {
|
||||
log.Debug().Err(err).Caller().Send()
|
||||
// TODO: dynamic timeout
|
||||
p.restart = time.AfterFunc(30*time.Second, p.reconnect)
|
||||
return
|
||||
}
|
||||
|
||||
medias := p.element.GetMedias()
|
||||
|
||||
// convert all old producer tracks to new tracks
|
||||
for i, oldTrack := range p.tracks {
|
||||
// match new element medias with old track codec
|
||||
for _, media := range medias {
|
||||
codec := media.MatchCodec(oldTrack.Codec)
|
||||
if codec == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// move sink from old track to new track
|
||||
newTrack := p.element.GetTrack(media, codec)
|
||||
newTrack.GetSink(oldTrack)
|
||||
p.tracks[i] = newTrack
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err = p.element.Start(); err != nil {
|
||||
log.Debug().Err(err).Caller().Send()
|
||||
}
|
||||
p.reconnect()
|
||||
}()
|
||||
}
|
||||
|
||||
func (p *Producer) stop() {
|
||||
log.Debug().Str("url", p.url).Msg("[streams] stop producer")
|
||||
p.mu.Lock()
|
||||
|
||||
log.Debug().Msgf("[streams] stop producer url=%s", p.url)
|
||||
|
||||
if p.element != nil {
|
||||
_ = p.element.Stop()
|
||||
p.element = nil
|
||||
}
|
||||
if p.restart != nil {
|
||||
p.restart.Stop()
|
||||
p.restart = nil
|
||||
}
|
||||
|
||||
_ = p.element.Stop()
|
||||
p.element = nil
|
||||
p.tracks = nil
|
||||
p.state = stateNone
|
||||
p.tracks = nil
|
||||
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
@@ -2,7 +2,9 @@ package streams
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Consumer struct {
|
||||
@@ -13,45 +15,57 @@ type Consumer struct {
|
||||
type Stream struct {
|
||||
producers []*Producer
|
||||
consumers []*Consumer
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewStream(source interface{}) *Stream {
|
||||
s := new(Stream)
|
||||
|
||||
switch source := source.(type) {
|
||||
case string:
|
||||
s := new(Stream)
|
||||
prod := &Producer{url: source}
|
||||
s.producers = append(s.producers, prod)
|
||||
return s
|
||||
case []interface{}:
|
||||
s := new(Stream)
|
||||
for _, source := range source {
|
||||
prod := &Producer{url: source.(string)}
|
||||
s.producers = append(s.producers, prod)
|
||||
}
|
||||
return s
|
||||
case *Stream:
|
||||
return source
|
||||
case map[string]interface{}:
|
||||
return NewStream(source["url"])
|
||||
case nil:
|
||||
return new(Stream)
|
||||
default:
|
||||
panic("wrong source type")
|
||||
}
|
||||
}
|
||||
|
||||
return s
|
||||
func (s *Stream) SetSource(source string) {
|
||||
for _, prod := range s.producers {
|
||||
prod.SetSource(source)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Stream) AddConsumer(cons streamer.Consumer) (err error) {
|
||||
ic := len(s.consumers)
|
||||
|
||||
consumer := &Consumer{element: cons}
|
||||
var producers []*Producer // matched producers for consumer
|
||||
|
||||
// Step 1. Get consumer medias
|
||||
for icc, consMedia := range cons.GetMedias() {
|
||||
log.Trace().Stringer("media", consMedia).
|
||||
Msgf("[streams] consumer:%d:%d candidate", ic, icc)
|
||||
Msgf("[streams] consumer=%d candidate=%d", ic, icc)
|
||||
|
||||
producers:
|
||||
for ip, prod := range s.producers {
|
||||
// Step 2. Get producer medias (not tracks yet)
|
||||
for ipc, prodMedia := range prod.GetMedias() {
|
||||
log.Trace().Stringer("media", prodMedia).
|
||||
Msgf("[streams] producer:%d:%d candidate", ip, ipc)
|
||||
Msgf("[streams] producer=%d candidate=%d", ip, ipc)
|
||||
|
||||
// Step 3. Match consumer/producer codecs list
|
||||
prodCodec := prodMedia.MatchMedia(consMedia)
|
||||
@@ -61,25 +75,32 @@ func (s *Stream) AddConsumer(cons streamer.Consumer) (err error) {
|
||||
|
||||
// Step 4. Get producer track
|
||||
prodTrack := prod.GetTrack(prodMedia, prodCodec)
|
||||
if prodTrack == nil {
|
||||
log.Warn().Msg("[stream] can't get track")
|
||||
continue
|
||||
}
|
||||
|
||||
// Step 5. Add track to consumer and get new track
|
||||
consTrack := consumer.element.AddTrack(consMedia, prodTrack)
|
||||
|
||||
consumer.tracks = append(consumer.tracks, consTrack)
|
||||
producers = append(producers, prod)
|
||||
break producers
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// can't match tracks for consumer
|
||||
if len(consumer.tracks) == 0 {
|
||||
return nil
|
||||
if len(producers) == 0 {
|
||||
return errors.New("couldn't find the matching tracks")
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.consumers = append(s.consumers, consumer)
|
||||
s.mu.Unlock()
|
||||
|
||||
for _, prod := range s.producers {
|
||||
// there may be duplicates, but that's not a problem
|
||||
for _, prod := range producers {
|
||||
prod.start()
|
||||
}
|
||||
|
||||
@@ -87,7 +108,13 @@ func (s *Stream) AddConsumer(cons streamer.Consumer) (err error) {
|
||||
}
|
||||
|
||||
func (s *Stream) RemoveConsumer(cons streamer.Consumer) {
|
||||
s.mu.Lock()
|
||||
for i, consumer := range s.consumers {
|
||||
if consumer == nil {
|
||||
log.Warn().Msgf("empty consumer: %+v\n", s)
|
||||
continue
|
||||
}
|
||||
|
||||
if consumer.element == cons {
|
||||
// remove consumer pads from all producers
|
||||
for _, track := range consumer.tracks {
|
||||
@@ -100,9 +127,14 @@ func (s *Stream) RemoveConsumer(cons streamer.Consumer) {
|
||||
}
|
||||
|
||||
for _, producer := range s.producers {
|
||||
if producer == nil {
|
||||
log.Warn().Msgf("empty producer: %+v\n", s)
|
||||
continue
|
||||
}
|
||||
|
||||
var sink bool
|
||||
for _, track := range producer.tracks {
|
||||
if len(track.Sink) > 0 {
|
||||
if track.HasSink() {
|
||||
sink = true
|
||||
}
|
||||
}
|
||||
@@ -110,32 +142,44 @@ func (s *Stream) RemoveConsumer(cons streamer.Consumer) {
|
||||
producer.stop()
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Stream) AddProducer(prod streamer.Producer) {
|
||||
panic("not implemented")
|
||||
producer := &Producer{element: prod, state: stateTracks}
|
||||
s.mu.Lock()
|
||||
s.producers = append(s.producers, producer)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Stream) RemoveProducer(prod streamer.Producer) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (s *Stream) Active() bool {
|
||||
if len(s.consumers) > 0{
|
||||
return true
|
||||
}
|
||||
|
||||
for _, prod := range s.producers {
|
||||
if prod.element != nil {
|
||||
return true
|
||||
s.mu.Lock()
|
||||
for i, producer := range s.producers {
|
||||
if producer.element == prod {
|
||||
s.removeProducer(i)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
//func (s *Stream) Active() bool {
|
||||
// if len(s.consumers) > 0 {
|
||||
// return true
|
||||
// }
|
||||
//
|
||||
// for _, prod := range s.producers {
|
||||
// if prod.element != nil {
|
||||
// return true
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// return false
|
||||
//}
|
||||
|
||||
func (s *Stream) MarshalJSON() ([]byte, error) {
|
||||
var v []interface{}
|
||||
s.mu.Lock()
|
||||
for _, prod := range s.producers {
|
||||
if prod.element != nil {
|
||||
v = append(v, prod.element)
|
||||
@@ -145,6 +189,7 @@ func (s *Stream) MarshalJSON() ([]byte, error) {
|
||||
// cons.element always not nil
|
||||
v = append(v, cons.element)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if len(v) == 0 {
|
||||
v = nil
|
||||
}
|
||||
|
@@ -2,6 +2,7 @@ package streams
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/fake"
|
||||
"github.com/AlexxIT/go2rtc/pkg/rtsp"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
@@ -103,7 +104,7 @@ a=control:streamid=0
|
||||
|
||||
func TestRouting(t *testing.T) {
|
||||
prod := &fake.Producer{}
|
||||
prod.Medias, _ = streamer.UnmarshalRTSPSDP([]byte(dahuaSimple))
|
||||
prod.Medias, _ = rtsp.UnmarshalSDP([]byte(dahuaSimple))
|
||||
assert.Len(t, prod.Medias, 3)
|
||||
|
||||
HandleFunc("fake", func(url string) (streamer.Producer, error) {
|
||||
|
@@ -2,6 +2,7 @@ package streams
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/cmd/app/store"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
@@ -17,21 +18,38 @@ func Init() {
|
||||
for name, item := range cfg.Mod {
|
||||
streams[name] = NewStream(item)
|
||||
}
|
||||
|
||||
for name, item := range store.GetDict("streams") {
|
||||
streams[name] = NewStream(item)
|
||||
}
|
||||
}
|
||||
|
||||
func Get(name string) *Stream {
|
||||
if stream, ok := streams[name]; ok {
|
||||
return streams[name]
|
||||
}
|
||||
|
||||
func New(name string, source interface{}) *Stream {
|
||||
stream := NewStream(source)
|
||||
streams[name] = stream
|
||||
return stream
|
||||
}
|
||||
|
||||
func GetOrNew(src string) *Stream {
|
||||
if stream, ok := streams[src]; ok {
|
||||
return stream
|
||||
}
|
||||
|
||||
if HasProducer(name) {
|
||||
log.Info().Str("url", name).Msg("[streams] create new stream")
|
||||
stream := NewStream(name)
|
||||
streams[name] = stream
|
||||
return stream
|
||||
if !HasProducer(src) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
log.Info().Str("url", src).Msg("[streams] create new stream")
|
||||
|
||||
return New(src, src)
|
||||
}
|
||||
|
||||
func Delete(name string) {
|
||||
delete(streams, name)
|
||||
}
|
||||
|
||||
func All() map[string]interface{} {
|
||||
|
@@ -5,7 +5,6 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
||||
"github.com/pion/sdp/v3"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var candidates []string
|
||||
@@ -32,15 +31,11 @@ func addCanditates(answer string) (string, error) {
|
||||
}
|
||||
|
||||
for _, address := range candidates {
|
||||
if strings.HasPrefix(address, "stun:") {
|
||||
ip, err := webrtc.GetPublicIP()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("[webrtc] public IP")
|
||||
continue
|
||||
}
|
||||
address = ip.String() + address[4:]
|
||||
|
||||
log.Debug().Str("addr", address).Msg("[webrtc] stun public address")
|
||||
var err error
|
||||
address, err = webrtc.LookupIP(address)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("[webrtc] candidate")
|
||||
continue
|
||||
}
|
||||
|
||||
cand, err := webrtc.NewCandidate(address)
|
||||
|
@@ -8,7 +8,9 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
||||
pion "github.com/pion/webrtc/v3"
|
||||
"github.com/rs/zerolog"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
@@ -55,6 +57,8 @@ func Init() {
|
||||
|
||||
api.HandleWS(webrtc.MsgTypeOffer, offerHandler)
|
||||
api.HandleWS(webrtc.MsgTypeCandidate, candidateHandler)
|
||||
|
||||
api.HandleFunc("api/webrtc", syncHandler)
|
||||
}
|
||||
|
||||
var Port string
|
||||
@@ -63,13 +67,13 @@ var log zerolog.Logger
|
||||
var NewPConn func() (*pion.PeerConnection, error)
|
||||
|
||||
func offerHandler(ctx *api.Context, msg *streamer.Message) {
|
||||
name := ctx.Request.URL.Query().Get("url")
|
||||
stream := streams.Get(name)
|
||||
src := ctx.Request.URL.Query().Get("src")
|
||||
stream := streams.Get(src)
|
||||
if stream == nil {
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug().Str("stream", name).Msg("[webrtc] new consumer")
|
||||
log.Debug().Str("url", src).Msg("[webrtc] new consumer")
|
||||
|
||||
var err error
|
||||
|
||||
@@ -84,8 +88,8 @@ func offerHandler(ctx *api.Context, msg *streamer.Message) {
|
||||
conn.UserAgent = ctx.Request.UserAgent()
|
||||
conn.Listen(func(msg interface{}) {
|
||||
switch msg := msg.(type) {
|
||||
case streamer.EventType:
|
||||
if msg == streamer.StateNull {
|
||||
case pion.PeerConnectionState:
|
||||
if msg == pion.PeerConnectionStateClosed {
|
||||
stream.RemoveConsumer(conn)
|
||||
}
|
||||
case *streamer.Message:
|
||||
@@ -108,6 +112,7 @@ func offerHandler(ctx *api.Context, msg *streamer.Message) {
|
||||
// 2. AddConsumer, so we get new tracks
|
||||
if err = stream.AddConsumer(conn); err != nil {
|
||||
log.Warn().Err(err).Msg("[api.webrtc] add consumer")
|
||||
_ = conn.Conn.Close()
|
||||
ctx.Error(err)
|
||||
return
|
||||
}
|
||||
@@ -136,6 +141,32 @@ func offerHandler(ctx *api.Context, msg *streamer.Message) {
|
||||
ctx.Consumer = conn
|
||||
}
|
||||
|
||||
func syncHandler(w http.ResponseWriter, r *http.Request) {
|
||||
url := r.URL.Query().Get("src")
|
||||
stream := streams.Get(url)
|
||||
if stream == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// get offer
|
||||
offer, err := ioutil.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
|
||||
answer, err := ExchangeSDP(stream, string(offer), r.UserAgent())
|
||||
if err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
|
||||
// send SDP to client
|
||||
if _, err = w.Write([]byte(answer)); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
}
|
||||
|
||||
func ExchangeSDP(
|
||||
stream *streams.Stream, offer string, userAgent string,
|
||||
) (answer string, err error) {
|
||||
@@ -150,8 +181,8 @@ func ExchangeSDP(
|
||||
conn.UserAgent = userAgent
|
||||
conn.Listen(func(msg interface{}) {
|
||||
switch msg := msg.(type) {
|
||||
case streamer.EventType:
|
||||
if msg == streamer.StateNull {
|
||||
case pion.PeerConnectionState:
|
||||
if msg == pion.PeerConnectionStateClosed {
|
||||
stream.RemoveConsumer(conn)
|
||||
}
|
||||
}
|
||||
@@ -168,6 +199,7 @@ func ExchangeSDP(
|
||||
// 2. AddConsumer, so we get new tracks
|
||||
if err = stream.AddConsumer(conn); err != nil {
|
||||
log.Warn().Err(err).Msg("[api.webrtc] add consumer")
|
||||
_ = conn.Conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
|
20
go.mod
20
go.mod
@@ -3,39 +3,55 @@ module github.com/AlexxIT/go2rtc
|
||||
go 1.17
|
||||
|
||||
require (
|
||||
github.com/brutella/hap v0.0.17
|
||||
github.com/deepch/vdk v0.0.19
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/hashicorp/mdns v1.0.5
|
||||
github.com/pion/ice/v2 v2.2.6
|
||||
github.com/pion/interceptor v0.1.11
|
||||
github.com/pion/rtcp v1.2.9
|
||||
github.com/pion/rtp v1.7.13
|
||||
github.com/pion/sdp/v3 v3.0.5
|
||||
github.com/pion/srtp/v2 v2.0.10
|
||||
github.com/pion/stun v0.3.5
|
||||
github.com/pion/webrtc/v3 v3.1.43
|
||||
github.com/rs/zerolog v1.27.0
|
||||
github.com/stretchr/testify v1.7.1
|
||||
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/brutella/dnssd v1.2.3 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/go-chi/chi v1.5.4 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.12 // indirect
|
||||
github.com/mattn/go-isatty v0.0.14 // indirect
|
||||
github.com/miekg/dns v1.1.50 // indirect
|
||||
github.com/pion/datachannel v1.5.2 // indirect
|
||||
github.com/pion/dtls/v2 v2.1.5 // indirect
|
||||
github.com/pion/logging v0.2.2 // indirect
|
||||
github.com/pion/mdns v0.0.5 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/sctp v1.8.2 // indirect
|
||||
github.com/pion/srtp/v2 v2.0.10 // indirect
|
||||
github.com/pion/transport v0.13.1 // indirect
|
||||
github.com/pion/turn/v2 v2.0.8 // indirect
|
||||
github.com/pion/udp v0.1.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/xiam/to v0.0.0-20200126224905-d60d31e03561 // indirect
|
||||
golang.org/x/crypto v0.0.0-20220516162934-403b01795ae8 // indirect
|
||||
golang.org/x/mod v0.4.2 // indirect
|
||||
golang.org/x/net v0.0.0-20220630215102-69896b714898 // indirect
|
||||
golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664 // indirect
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||
)
|
||||
|
||||
replace github.com/deepch/vdk v0.0.19 => github.com/AlexxIT/vdk v0.0.18-0.20220616041030-b0d122807b2e
|
||||
replace (
|
||||
// windows support: https://github.com/brutella/dnssd/pull/35
|
||||
github.com/brutella/dnssd v1.2.2 => github.com/rblenkinsopp/dnssd v1.2.3-0.20220516082132-0923f3c787a1
|
||||
// RTP tlv8 fix
|
||||
github.com/brutella/hap v0.0.17 => github.com/AlexxIT/hap v0.0.15-0.20220823033740-ce7d1564e657
|
||||
)
|
||||
|
37
go.sum
37
go.sum
@@ -1,12 +1,18 @@
|
||||
github.com/AlexxIT/vdk v0.0.18-0.20220616041030-b0d122807b2e h1:NAgHHZB+JUN3/J4/yq1q1EAc8xwJ8bb/Qp0AcjkfzAA=
|
||||
github.com/AlexxIT/vdk v0.0.18-0.20220616041030-b0d122807b2e/go.mod h1:KqQ/KU3hOc4a62l/jPRH5Hiz5fhTq5cGCl8IqeCxWQI=
|
||||
github.com/AlexxIT/hap v0.0.15-0.20220823033740-ce7d1564e657 h1:FUzXAJfm6sRLJ8T6vfzvy/Hm3aioX8+fbxgx2VZoI78=
|
||||
github.com/AlexxIT/hap v0.0.15-0.20220823033740-ce7d1564e657/go.mod h1:c2vEL5pzjRWEx07sa32kTVjzI9bBVlstrwBwKe3DlJ0=
|
||||
github.com/brutella/dnssd v1.2.3 h1:4fBLjZjPH7SbcHhEcIJhZcC9nOhIDZ0m3rn9bjl1/i0=
|
||||
github.com/brutella/dnssd v1.2.3/go.mod h1:JoW2sJUrmVIef25G6lrLj7HS6Xdwh6q8WUIvMkkBYXs=
|
||||
github.com/cheekybits/genny v1.0.0/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wXkRAgjxjQ=
|
||||
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
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/deepch/vdk v0.0.19 h1:r6xYyBTtXEIEh+csO0XHT00sI7xLF+hQFkJE9/go5II=
|
||||
github.com/deepch/vdk v0.0.19/go.mod h1:7ydHfSkflMZxBXfWR79dMjrT54xzvLxnPaByOa9Jpzg=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/go-chi/chi v1.5.4 h1:QHdzF2szwjqVV4wmByUnTcsbIg7UGaQ0tPF2t5GcAIs=
|
||||
github.com/go-chi/chi v1.5.4/go.mod h1:uaf8YgoFazUOkPBG7fxPftUylNumIev9awIWOENIuEg=
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
@@ -28,6 +34,8 @@ github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/mdns v1.0.5 h1:1M5hW1cunYeoXOqHwEb/GBDDHAFo0Yqb/uz/beC6LbE=
|
||||
github.com/hashicorp/mdns v1.0.5/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
@@ -40,6 +48,9 @@ github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZb
|
||||
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
|
||||
github.com/miekg/dns v1.1.50 h1:DQUfb9uc6smULcREF09Uc+/Gd46YWqJd5DbpPE9xkcA=
|
||||
github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
@@ -110,7 +121,7 @@ github.com/pion/udp v0.1.0/go.mod h1:BPELIjbwE9PRbd/zxI/KYBnbo7B6+oA6YuEaNE8lths
|
||||
github.com/pion/udp v0.1.1 h1:8UAPvyqmsxK8oOjloDk4wUt63TzFe9WEJkg5lChlj7o=
|
||||
github.com/pion/udp v0.1.1/go.mod h1:6AFo+CMdKQm7UiA0eUPA8/eVCTx8jBIITLZHc9DWX5M=
|
||||
github.com/pion/webrtc/v2 v2.2.26/go.mod h1:XMZbZRNHyPDe1gzTIHFcQu02283YO45CbiwFgKvXnmc=
|
||||
github.com/pion/webrtc/v3 v3.1.41/go.mod h1:sUcW9SFPEWerDqGOBmdYEMfRvbdd7rgwo4bNzfsXww4=
|
||||
github.com/pion/webrtc/v3 v3.1.42/go.mod h1:ffD9DulDrPxyWvDPUIPAOSAWx9GUlOExiJPf7cCcMLA=
|
||||
github.com/pion/webrtc/v3 v3.1.43 h1:YT3ZTO94UT4kSBvZnRAH82+0jJPUruiKr9CEstdlQzk=
|
||||
github.com/pion/webrtc/v3 v3.1.43/go.mod h1:G/J8k0+grVsjC/rjCZ24AKoCCxcFFODgh7zThNZGs0M=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
@@ -128,7 +139,12 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
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/xiam/to v0.0.0-20200126224905-d60d31e03561 h1:SVoNK97S6JlaYlHcaC+79tg3JUlQABcc0dH2VQ4Y+9s=
|
||||
github.com/xiam/to v0.0.0-20200126224905-d60d31e03561/go.mod h1:cqbG7phSzrbdg3aj+Kn63bpVruzwDZi58CpxlZkjwzw=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
@@ -140,6 +156,8 @@ golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f/go.mod h1:IxCIyHEi3zRg3s0
|
||||
golang.org/x/crypto v0.0.0-20220516162934-403b01795ae8 h1:y+mHpWoQJNAHt26Nhh6JP7hvM71IRZureyvZhoVALIs=
|
||||
golang.org/x/crypto v0.0.0-20220516162934-403b01795ae8/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2 h1:Gz96sIWK3OalVv/I/qNygP42zyoKp3xptRVCWRFEBvo=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
@@ -152,7 +170,11 @@ golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81R
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201201195509-5d6afe98e0b7/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=
|
||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211201190559-0a0e4e1bb54c/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
@@ -164,6 +186,8 @@ golang.org/x/net v0.0.0-20220630215102-69896b714898/go.mod h1:XRhObCWvk6IyKnWLug
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190228124157-a34e9553db1e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -176,7 +200,10 @@ golang.org/x/sys v0.0.0-20200724161237-0e2f3a69832c/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -190,13 +217,17 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
|
||||
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.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
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.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2 h1:BonxutuHCTL0rBDnZlKjpGIQFTjyUVTexFOdWkB6Fg0=
|
||||
golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
|
23
main.go
23
main.go
@@ -3,13 +3,19 @@ package main
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/cmd/debug"
|
||||
"github.com/AlexxIT/go2rtc/cmd/echo"
|
||||
"github.com/AlexxIT/go2rtc/cmd/exec"
|
||||
"github.com/AlexxIT/go2rtc/cmd/ffmpeg"
|
||||
"github.com/AlexxIT/go2rtc/cmd/hass"
|
||||
"github.com/AlexxIT/go2rtc/cmd/mse"
|
||||
"github.com/AlexxIT/go2rtc/cmd/homekit"
|
||||
"github.com/AlexxIT/go2rtc/cmd/ivideon"
|
||||
"github.com/AlexxIT/go2rtc/cmd/mjpeg"
|
||||
"github.com/AlexxIT/go2rtc/cmd/mp4"
|
||||
"github.com/AlexxIT/go2rtc/cmd/ngrok"
|
||||
"github.com/AlexxIT/go2rtc/cmd/rtmp"
|
||||
"github.com/AlexxIT/go2rtc/cmd/rtsp"
|
||||
"github.com/AlexxIT/go2rtc/cmd/srtp"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/cmd/webrtc"
|
||||
"os"
|
||||
@@ -21,18 +27,27 @@ func main() {
|
||||
app.Init() // init config and logs
|
||||
streams.Init() // load streams list
|
||||
|
||||
api.Init() // init HTTP API server
|
||||
|
||||
echo.Init()
|
||||
|
||||
rtsp.Init() // add support RTSP client and RTSP server
|
||||
rtmp.Init() // add support RTMP client
|
||||
exec.Init() // add support exec scheme (depends on RTSP server)
|
||||
ffmpeg.Init() // add support ffmpeg scheme (depends on exec scheme)
|
||||
hass.Init() // add support hass scheme
|
||||
|
||||
api.Init() // init HTTP API server
|
||||
|
||||
webrtc.Init()
|
||||
mse.Init()
|
||||
mp4.Init()
|
||||
mjpeg.Init()
|
||||
|
||||
srtp.Init()
|
||||
homekit.Init()
|
||||
|
||||
ivideon.Init()
|
||||
|
||||
ngrok.Init()
|
||||
debug.Init()
|
||||
|
||||
sigs := make(chan os.Signal, 1)
|
||||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
5
pkg/README.md
Normal file
5
pkg/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
## 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
|
@@ -1,3 +1,22 @@
|
||||
# H264
|
||||
|
||||
Access Unit (AU) can contain one or multiple NAL Unit:
|
||||
|
||||
1. [SEI,] SPS, PPS, IFrame, [IFrame...]
|
||||
2. BFrame, [BFrame...]
|
||||
3. IFrame, [IFrame...]
|
||||
|
||||
## RTP H264
|
||||
|
||||
Camera | NALu
|
||||
-------|-----
|
||||
EZVIZ C3S | 7f, 8f, 28:28:28 -> 5t, 28:28:28 -> 1t, 1t, 1t, 1t
|
||||
Sonoff GK-200MP2-B | 28:28:28 -> 5t, 1t, 1t, 1t
|
||||
Dahua IPC-K42 | 7f, 8f, 28:28:28 -> 5t, 28:28:28 -> 1t, 28:28:28 -> 1t
|
||||
FFmpeg copy | 5t, 1t, 1t, 28:28:28 -> 1t, 28:28:28 -> 1t
|
||||
FFmpeg h264 | 24 -> 6:5:5:5:5t, 24 -> 1:1:1:1t, 28:28:28 -> 5f, 28:28:28 -> 5f, 28:28:28 -> 5t
|
||||
FFmpeg resize | 6f, 28:28:28 -> 5f, 28... -> 5t, 24 -> 1:1f, 24 -> 1:1t
|
||||
|
||||
## WebRTC
|
||||
|
||||
Video codec | Media string | Device
|
||||
@@ -25,3 +44,4 @@ H.264/high | avc1.6400xx | FFmpeg superfast
|
||||
- [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/)
|
||||
|
@@ -12,49 +12,71 @@ func IsAVC(codec *streamer.Codec) bool {
|
||||
return codec.PayloadType == PayloadTypeAVC
|
||||
}
|
||||
|
||||
func EncodeAVC(raw []byte) (avc []byte) {
|
||||
avc = make([]byte, len(raw)+4)
|
||||
binary.BigEndian.PutUint32(avc, uint32(len(raw)))
|
||||
copy(avc[4:], raw)
|
||||
func EncodeAVC(nals ...[]byte) (avc []byte) {
|
||||
var i, n int
|
||||
|
||||
for _, nal := range nals {
|
||||
if i = len(nal); i > 0 {
|
||||
n += 4 + i
|
||||
}
|
||||
}
|
||||
|
||||
avc = make([]byte, n)
|
||||
|
||||
n = 0
|
||||
for _, nal := range nals {
|
||||
if i = len(nal); i > 0 {
|
||||
binary.BigEndian.PutUint32(avc[n:], uint32(i))
|
||||
n += 4 + copy(avc[n+4:], nal)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func RepairAVC(track *streamer.Track) streamer.WrapperFunc {
|
||||
sps, pps := GetParameterSet(track.Codec.FmtpLine)
|
||||
sps = EncodeAVC(sps)
|
||||
pps = EncodeAVC(pps)
|
||||
ps := EncodeAVC(sps, pps)
|
||||
|
||||
return func(push streamer.WriterFunc) streamer.WriterFunc {
|
||||
return func(packet *rtp.Packet) (err error) {
|
||||
naluType := NALUType(packet.Payload)
|
||||
switch naluType {
|
||||
case NALUTypeSPS:
|
||||
sps = packet.Payload
|
||||
return
|
||||
case NALUTypePPS:
|
||||
pps = packet.Payload
|
||||
return
|
||||
if NALUType(packet.Payload) == NALUTypeIFrame {
|
||||
packet.Payload = Join(ps, packet.Payload)
|
||||
}
|
||||
|
||||
var clone rtp.Packet
|
||||
|
||||
if naluType == NALUTypeIFrame {
|
||||
clone = *packet
|
||||
clone.Payload = sps
|
||||
if err = push(&clone); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
clone = *packet
|
||||
clone.Payload = pps
|
||||
if err = push(&clone); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
clone = *packet
|
||||
clone.Payload = packet.Payload
|
||||
return push(&clone)
|
||||
return push(packet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func SplitAVC(data []byte) [][]byte {
|
||||
var nals [][]byte
|
||||
for {
|
||||
// get AVC length
|
||||
size := int(binary.BigEndian.Uint32(data)) + 4
|
||||
|
||||
// check if multiple items in one packet
|
||||
if size < len(data) {
|
||||
nals = append(nals, data[:size])
|
||||
data = data[size:]
|
||||
} else {
|
||||
nals = append(nals, data)
|
||||
break
|
||||
}
|
||||
}
|
||||
return nals
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
@@ -2,21 +2,58 @@ package h264
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
NALUTypePFrame = 1
|
||||
NALUTypeIFrame = 5
|
||||
NALUTypeSPS = 7
|
||||
NALUTypePPS = 8
|
||||
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
|
||||
}
|
||||
|
||||
func GetProfileLevelID(fmtp string) string {
|
||||
if fmtp == "" {
|
||||
return ""
|
||||
}
|
||||
return streamer.Between(fmtp, "profile-level-id=", ";")
|
||||
}
|
||||
|
||||
func GetParameterSet(fmtp string) (sps, pps []byte) {
|
||||
if fmtp == "" {
|
||||
return
|
||||
|
@@ -1,6 +1,7 @@
|
||||
package h264
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/pion/rtp"
|
||||
"github.com/pion/rtp/codecs"
|
||||
@@ -12,68 +13,68 @@ func RTPDepay(track *streamer.Track) streamer.WrapperFunc {
|
||||
depack := &codecs.H264Packet{IsAVC: true}
|
||||
|
||||
sps, pps := GetParameterSet(track.Codec.FmtpLine)
|
||||
sps = EncodeAVC(sps)
|
||||
pps = EncodeAVC(pps)
|
||||
ps := EncodeAVC(sps, pps)
|
||||
|
||||
var buffer []byte
|
||||
buf := make([]byte, 0, 512*1024) // 512K
|
||||
|
||||
return func(push streamer.WriterFunc) streamer.WriterFunc {
|
||||
return func(packet *rtp.Packet) error {
|
||||
//println(packet.SequenceNumber, packet.Payload[0]&0x1F, packet.Payload[0], packet.Payload[1], packet.Marker, packet.Timestamp)
|
||||
//log.Printf("[RTP] codec: %s, nalu: %2d, size: %6d, ts: %10d, pt: %2d, ssrc: %d, seq: %d, %v", track.Codec.Name, packet.Payload[0]&0x1F, len(packet.Payload), packet.Timestamp, packet.PayloadType, packet.SSRC, packet.SequenceNumber, packet.Marker)
|
||||
|
||||
data, err := depack.Unmarshal(packet.Payload)
|
||||
if len(data) == 0 || err != nil {
|
||||
payload, err := depack.Unmarshal(packet.Payload)
|
||||
if len(payload) == 0 || err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
naluType := NALUType(data)
|
||||
//println(naluType, len(data))
|
||||
|
||||
switch naluType {
|
||||
case NALUTypeSPS:
|
||||
//println("new SPS")
|
||||
sps = data
|
||||
return nil
|
||||
case NALUTypePPS:
|
||||
//println("new PPS")
|
||||
pps = data
|
||||
return nil
|
||||
// Fix TP-Link Tapo TC70: sends SPS and PPS with packet.Marker = true
|
||||
if packet.Marker {
|
||||
switch NALUType(payload) {
|
||||
case NALUTypeSPS, NALUTypePPS:
|
||||
buf = append(buf, payload...)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ffmpeg with `-tune zerolatency` enable option `-x264opts sliced-threads=1`
|
||||
// and every NALU will be sliced to multiple NALUs
|
||||
if len(buf) == 0 {
|
||||
// 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 nil
|
||||
}
|
||||
|
||||
payload = payload[i:]
|
||||
|
||||
if NALUType(payload) == NALUTypeIFrame {
|
||||
buf = append(buf, ps...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// collect all NALs for Access Unit
|
||||
if !packet.Marker {
|
||||
buffer = append(buffer, data...)
|
||||
buf = append(buf, payload...)
|
||||
return nil
|
||||
}
|
||||
|
||||
if buffer != nil {
|
||||
buffer = append(buffer, data...)
|
||||
data = buffer
|
||||
buffer = nil
|
||||
if len(buf) > 0 {
|
||||
payload = append(buf, payload...)
|
||||
buf = buf[:0]
|
||||
}
|
||||
|
||||
var clone rtp.Packet
|
||||
//log.Printf("[AVC] %v, len: %d", Types(payload), len(payload))
|
||||
|
||||
if naluType == NALUTypeIFrame {
|
||||
clone = *packet
|
||||
clone.Version = RTPPacketVersionAVC
|
||||
clone.Payload = sps
|
||||
if err = push(&clone); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
clone = *packet
|
||||
clone.Version = RTPPacketVersionAVC
|
||||
clone.Payload = pps
|
||||
if err = push(&clone); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
clone = *packet
|
||||
clone := *packet
|
||||
clone.Version = RTPPacketVersionAVC
|
||||
clone.Payload = data
|
||||
clone.Payload = payload
|
||||
return push(&clone)
|
||||
}
|
||||
}
|
||||
@@ -88,11 +89,12 @@ func RTPPay(mtu uint16) streamer.WrapperFunc {
|
||||
return func(packet *rtp.Packet) error {
|
||||
if packet.Version == RTPPacketVersionAVC {
|
||||
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 == len(payloads)-1,
|
||||
Marker: i == last,
|
||||
//PayloadType: packet.PayloadType,
|
||||
SequenceNumber: sequencer.NextSequenceNumber(),
|
||||
Timestamp: packet.Timestamp,
|
||||
|
3
pkg/h265/README.md
Normal file
3
pkg/h265/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
## Useful links
|
||||
|
||||
- https://datatracker.ietf.org/doc/html/rfc7798
|
35
pkg/h265/helper.go
Normal file
35
pkg/h265/helper.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package h265
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
)
|
||||
|
||||
const (
|
||||
NALUnitTypeIFrame = 19
|
||||
)
|
||||
|
||||
func NALUnitType(b []byte) byte {
|
||||
return b[4] >> 1
|
||||
}
|
||||
|
||||
func IsKeyframe(b []byte) bool {
|
||||
return NALUnitType(b) == NALUnitTypeIFrame
|
||||
}
|
||||
|
||||
func GetParameterSet(fmtp string) (vps, sps, pps []byte) {
|
||||
if fmtp == "" {
|
||||
return
|
||||
}
|
||||
|
||||
s := streamer.Between(fmtp, "sprop-vps=", ";")
|
||||
vps, _ = base64.StdEncoding.DecodeString(s)
|
||||
|
||||
s = streamer.Between(fmtp, "sprop-sps=", ";")
|
||||
sps, _ = base64.StdEncoding.DecodeString(s)
|
||||
|
||||
s = streamer.Between(fmtp, "sprop-pps=", ";")
|
||||
pps, _ = base64.StdEncoding.DecodeString(s)
|
||||
|
||||
return
|
||||
}
|
153
pkg/h265/rtp.go
Normal file
153
pkg/h265/rtp.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package h265
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/deepch/vdk/codec/h265parser"
|
||||
"github.com/pion/rtp"
|
||||
)
|
||||
|
||||
func RTPDepay(track *streamer.Track) streamer.WrapperFunc {
|
||||
vps, sps, pps := GetParameterSet(track.Codec.FmtpLine)
|
||||
|
||||
var buffer []byte
|
||||
|
||||
return func(push streamer.WriterFunc) streamer.WriterFunc {
|
||||
return func(packet *rtp.Packet) error {
|
||||
nut := (packet.Payload[0] >> 1) & 0x3f
|
||||
//fmt.Printf(
|
||||
// "[RTP] codec: %s, nalu: %2d, size: %6d, ts: %10d, pt: %2d, ssrc: %d, seq: %d\n",
|
||||
// track.Codec.Name, nut, len(packet.Payload), packet.Timestamp,
|
||||
// packet.PayloadType, packet.SSRC, packet.SequenceNumber,
|
||||
//)
|
||||
|
||||
switch nut {
|
||||
case h265parser.NAL_UNIT_UNSPECIFIED_49:
|
||||
data := packet.Payload
|
||||
switch data[2] >> 6 {
|
||||
case 2: // begin
|
||||
buffer = []byte{
|
||||
(data[0] & 0x81) | (data[2] & 0x3f << 1), data[1],
|
||||
}
|
||||
buffer = append(buffer, data[3:]...)
|
||||
return nil
|
||||
case 0: // continue
|
||||
buffer = append(buffer, data[3:]...)
|
||||
return nil
|
||||
case 1: // end
|
||||
packet.Payload = append(buffer, data[3:]...)
|
||||
}
|
||||
case h265parser.NAL_UNIT_VPS:
|
||||
vps = packet.Payload
|
||||
return nil
|
||||
case h265parser.NAL_UNIT_SPS:
|
||||
sps = packet.Payload
|
||||
return nil
|
||||
case h265parser.NAL_UNIT_PPS:
|
||||
pps = packet.Payload
|
||||
return nil
|
||||
default:
|
||||
//panic("not implemented")
|
||||
}
|
||||
|
||||
var clone rtp.Packet
|
||||
|
||||
nut = (packet.Payload[0] >> 1) & 0x3f
|
||||
if nut >= h265parser.NAL_UNIT_CODED_SLICE_BLA_W_LP && nut <= h265parser.NAL_UNIT_CODED_SLICE_CRA {
|
||||
clone = *packet
|
||||
clone.Version = h264.RTPPacketVersionAVC
|
||||
clone.Payload = h264.EncodeAVC(vps)
|
||||
if err := push(&clone); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
clone = *packet
|
||||
clone.Version = h264.RTPPacketVersionAVC
|
||||
clone.Payload = h264.EncodeAVC(sps)
|
||||
if err := push(&clone); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
clone = *packet
|
||||
clone.Version = h264.RTPPacketVersionAVC
|
||||
clone.Payload = h264.EncodeAVC(pps)
|
||||
if err := push(&clone); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
clone = *packet
|
||||
clone.Version = h264.RTPPacketVersionAVC
|
||||
clone.Payload = h264.EncodeAVC(packet.Payload)
|
||||
|
||||
return push(&clone)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SafariPay - generate Safari friendly payload for H265
|
||||
func SafariPay(mtu uint16) streamer.WrapperFunc {
|
||||
sequencer := rtp.NewRandomSequencer()
|
||||
size := int(mtu - 12) // rtp.Header size
|
||||
|
||||
var buffer []byte
|
||||
|
||||
return func(push streamer.WriterFunc) streamer.WriterFunc {
|
||||
return func(packet *rtp.Packet) error {
|
||||
if packet.Version != h264.RTPPacketVersionAVC {
|
||||
return push(packet)
|
||||
}
|
||||
|
||||
data := packet.Payload
|
||||
data[0] = 0
|
||||
data[1] = 0
|
||||
data[2] = 0
|
||||
data[3] = 1
|
||||
|
||||
var start byte
|
||||
|
||||
nut := (data[4] >> 1) & 0b111111
|
||||
//fmt.Printf("[H265] nut: %2d, size: %6d, data: %16x\n", nut, len(data), data[4:20])
|
||||
switch {
|
||||
case nut >= h265parser.NAL_UNIT_VPS && nut <= h265parser.NAL_UNIT_PPS:
|
||||
buffer = append(buffer, data...)
|
||||
return nil
|
||||
case nut >= h265parser.NAL_UNIT_CODED_SLICE_BLA_W_LP && nut <= h265parser.NAL_UNIT_CODED_SLICE_CRA:
|
||||
buffer = append([]byte{3}, buffer...)
|
||||
data = append(buffer, data...)
|
||||
start = 1
|
||||
default:
|
||||
data = append([]byte{2}, data...)
|
||||
start = 0
|
||||
}
|
||||
|
||||
for len(data) > size {
|
||||
clone := rtp.Packet{
|
||||
Header: rtp.Header{
|
||||
Version: 2,
|
||||
Marker: false,
|
||||
SequenceNumber: sequencer.NextSequenceNumber(),
|
||||
Timestamp: packet.Timestamp,
|
||||
},
|
||||
Payload: data[:size],
|
||||
}
|
||||
if err := push(&clone); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data = append([]byte{start}, data[size:]...)
|
||||
}
|
||||
|
||||
clone := rtp.Packet{
|
||||
Header: rtp.Header{
|
||||
Version: 2,
|
||||
Marker: true,
|
||||
SequenceNumber: sequencer.NextSequenceNumber(),
|
||||
Timestamp: packet.Timestamp,
|
||||
},
|
||||
Payload: data,
|
||||
}
|
||||
return push(&clone)
|
||||
}
|
||||
}
|
||||
}
|
33
pkg/homekit/README.md
Normal file
33
pkg/homekit/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Homekit
|
||||
|
||||
> 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
|
||||
|
||||
## Useful links
|
||||
|
||||
- [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)
|
62
pkg/homekit/accessory.go
Normal file
62
pkg/homekit/accessory.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package homekit
|
||||
|
||||
type Accessory struct {
|
||||
AID int `json:"aid"`
|
||||
Services []*Service `json:"services"`
|
||||
}
|
||||
|
||||
type Accessories struct {
|
||||
Accessories []*Accessory `json:"accessories"`
|
||||
}
|
||||
|
||||
type Characters struct {
|
||||
Characters []*Character `json:"characteristics"`
|
||||
}
|
||||
|
||||
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 int) *Character {
|
||||
for _, serv := range a.Services {
|
||||
for _, char := range serv.Characters {
|
||||
if char.IID == iid {
|
||||
return char
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
IID int `json:"iid"`
|
||||
Type string `json:"type"`
|
||||
Primary bool `json:"primary,omitempty"`
|
||||
Hidden bool `json:"hidden,omitempty"`
|
||||
Characters []*Character `json:"characteristics"`
|
||||
}
|
||||
|
||||
func (s *Service) GetCharacter(charType string) *Character {
|
||||
for _, char := range s.Characters {
|
||||
if char.Type == charType {
|
||||
return char
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
100
pkg/homekit/camera/client.go
Normal file
100
pkg/homekit/camera/client.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package camera
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/AlexxIT/go2rtc/pkg/homekit"
|
||||
"github.com/brutella/hap/characteristic"
|
||||
"github.com/brutella/hap/rtp"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
client *homekit.Client
|
||||
}
|
||||
|
||||
func NewClient(client *homekit.Client) *Client {
|
||||
return &Client{client: client}
|
||||
}
|
||||
|
||||
func (c *Client) StartStream2(ses *Session) (err error) {
|
||||
// Step 1. Check if camera ready (free) to stream
|
||||
var srv *homekit.Service
|
||||
if srv, err = c.GetFreeStream(); err != nil {
|
||||
return err
|
||||
}
|
||||
if srv == nil {
|
||||
return errors.New("no free streams")
|
||||
}
|
||||
|
||||
if ses.Answer, err = c.SetupEndpoins(srv, ses.Offer); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return c.SetConfig(srv, ses.Config)
|
||||
}
|
||||
|
||||
// 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 (c *Client) GetFreeStream() (srv *homekit.Service, err error) {
|
||||
var accs []*homekit.Accessory
|
||||
if accs, err = c.client.GetAccessories(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, srv = range accs[0].Services {
|
||||
for _, char := range srv.Characters {
|
||||
if char.Type == characteristic.TypeStreamingStatus {
|
||||
status := rtp.StreamingStatus{}
|
||||
if err = char.ReadTLV8(&status); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if status.Status == rtp.SessionStatusSuccess {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (c *Client) SetupEndpoins(
|
||||
srv *homekit.Service, req *rtp.SetupEndpoints,
|
||||
) (res *rtp.SetupEndpointsResponse, err error) {
|
||||
// get setup endpoint character ID
|
||||
char := srv.GetCharacter(characteristic.TypeSetupEndpoints)
|
||||
char.Event = nil
|
||||
// encode new character value
|
||||
if err = char.Write(req); err != nil {
|
||||
return
|
||||
}
|
||||
// write (put) new endpoint value to device
|
||||
if err = c.client.PutCharacters(char); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// get new endpoint value from device (response)
|
||||
if err = c.client.GetCharacter(char); err != nil {
|
||||
return
|
||||
}
|
||||
// decode new endpoint value
|
||||
res = &rtp.SetupEndpointsResponse{}
|
||||
if err = char.ReadTLV8(res); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Client) SetConfig(srv *homekit.Service, config *rtp.StreamConfiguration) (err error) {
|
||||
// get setup endpoint character ID
|
||||
char := srv.GetCharacter(characteristic.TypeSelectedStreamConfiguration)
|
||||
char.Event = nil
|
||||
// encode new character value
|
||||
if err = char.Write(config); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
// write (put) new character value to device
|
||||
return c.client.PutCharacters(char)
|
||||
}
|
103
pkg/homekit/camera/session.go
Normal file
103
pkg/homekit/camera/session.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package camera
|
||||
|
||||
import (
|
||||
cryptorand "crypto/rand"
|
||||
"encoding/binary"
|
||||
"github.com/brutella/hap/rtp"
|
||||
)
|
||||
|
||||
type Session struct {
|
||||
Offer *rtp.SetupEndpoints
|
||||
Answer *rtp.SetupEndpointsResponse
|
||||
Config *rtp.StreamConfiguration
|
||||
}
|
||||
|
||||
func NewSession() *Session {
|
||||
sessionID := RandomBytes(16)
|
||||
s := &Session{
|
||||
Offer: &rtp.SetupEndpoints{
|
||||
SessionId: sessionID,
|
||||
Video: rtp.CryptoSuite{
|
||||
MasterKey: RandomBytes(16),
|
||||
MasterSalt: RandomBytes(14),
|
||||
},
|
||||
Audio: rtp.CryptoSuite{
|
||||
MasterKey: RandomBytes(16),
|
||||
MasterSalt: RandomBytes(14),
|
||||
},
|
||||
},
|
||||
Config: &rtp.StreamConfiguration{
|
||||
Command: rtp.SessionControlCommand{
|
||||
Identifier: sessionID,
|
||||
Type: rtp.SessionControlCommandTypeStart,
|
||||
},
|
||||
Video: rtp.VideoParameters{
|
||||
CodecType: rtp.VideoCodecType_H264,
|
||||
CodecParams: rtp.VideoCodecParameters{
|
||||
Profiles: []rtp.VideoCodecProfile{
|
||||
{Id: rtp.VideoCodecProfileMain},
|
||||
},
|
||||
Levels: []rtp.VideoCodecLevel{
|
||||
{Level: rtp.VideoCodecLevel4},
|
||||
},
|
||||
Packetizations: []rtp.VideoCodecPacketization{
|
||||
{Mode: rtp.VideoCodecPacketizationModeNonInterleaved},
|
||||
},
|
||||
},
|
||||
Attributes: rtp.VideoCodecAttributes{
|
||||
Width: 1920, Height: 1080, Framerate: 30,
|
||||
},
|
||||
RTP: rtp.RTPParams{
|
||||
PayloadType: 99,
|
||||
Ssrc: RandomUint32(),
|
||||
Bitrate: 299,
|
||||
Interval: 0.5,
|
||||
ComfortNoisePayloadType: 98,
|
||||
MTU: 0,
|
||||
},
|
||||
},
|
||||
Audio: rtp.AudioParameters{
|
||||
CodecType: rtp.AudioCodecType_AAC_ELD,
|
||||
CodecParams: rtp.AudioCodecParameters{
|
||||
Channels: 1,
|
||||
Bitrate: rtp.AudioCodecBitrateVariable,
|
||||
Samplerate: rtp.AudioCodecSampleRate16Khz,
|
||||
PacketTime: 30,
|
||||
},
|
||||
RTP: rtp.RTPParams{
|
||||
PayloadType: 110,
|
||||
Ssrc: RandomUint32(),
|
||||
Bitrate: 24,
|
||||
Interval: 5,
|
||||
MTU: 13,
|
||||
},
|
||||
ComfortNoise: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Session) SetLocalEndpoint(host string, port uint16) {
|
||||
s.Offer.ControllerAddr = rtp.Addr{
|
||||
IPAddr: host,
|
||||
VideoRtpPort: port,
|
||||
AudioRtpPort: port,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) SetVideo() {
|
||||
|
||||
}
|
||||
|
||||
func RandomBytes(size int) []byte {
|
||||
data := make([]byte, size)
|
||||
_, _ = cryptorand.Read(data)
|
||||
return data
|
||||
}
|
||||
|
||||
func RandomUint32() uint32 {
|
||||
data := make([]byte, 4)
|
||||
_, _ = cryptorand.Read(data)
|
||||
return binary.BigEndian.Uint32(data)
|
||||
}
|
133
pkg/homekit/character.go
Normal file
133
pkg/homekit/character.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package homekit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"github.com/brutella/hap/characteristic"
|
||||
"github.com/brutella/hap/tlv8"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type Character struct {
|
||||
AID int `json:"aid,omitempty"`
|
||||
IID int `json:"iid"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
Value interface{} `json:"value,omitempty"`
|
||||
Event interface{} `json:"ev,omitempty"`
|
||||
Perms []string `json:"perms,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
//MaxDataLen int `json:"maxDataLen"`
|
||||
|
||||
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) {
|
||||
chars := Characters{
|
||||
Characters: []*Character{{AID: DeviceAID, IID: c.IID, Value: c.Value}},
|
||||
}
|
||||
if data, err = json.Marshal(chars); 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 interface{}) (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 interface{}) (err error) {
|
||||
switch c.Format {
|
||||
case characteristic.FormatTLV8:
|
||||
var data []byte
|
||||
if data, err = tlv8.Marshal(v); err != nil {
|
||||
return
|
||||
}
|
||||
c.Value = base64.StdEncoding.EncodeToString(data)
|
||||
|
||||
case characteristic.FormatBool:
|
||||
switch v.(type) {
|
||||
case bool:
|
||||
c.Value = v.(bool)
|
||||
case float64:
|
||||
c.Value = v.(float64) != 0
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ReadTLV8 value to right struct
|
||||
func (c *Character) ReadTLV8(v interface{}) (err error) {
|
||||
var data []byte
|
||||
if data, err = base64.StdEncoding.DecodeString(c.Value.(string)); err != nil {
|
||||
return
|
||||
}
|
||||
return tlv8.Unmarshal(data, v)
|
||||
}
|
||||
|
||||
func (c *Character) ReadBool() bool {
|
||||
return c.Value.(bool)
|
||||
}
|
732
pkg/homekit/client.go
Normal file
732
pkg/homekit/client.go
Normal file
@@ -0,0 +1,732 @@
|
||||
package homekit
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha512"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/homekit/mdns"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/brutella/hap"
|
||||
"github.com/brutella/hap/chacha20poly1305"
|
||||
"github.com/brutella/hap/curve25519"
|
||||
"github.com/brutella/hap/ed25519"
|
||||
"github.com/brutella/hap/hkdf"
|
||||
"github.com/brutella/hap/tlv8"
|
||||
"github.com/tadglines/go-pkgs/crypto/srp"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Client for HomeKit. DevicePublic can be null.
|
||||
type Client struct {
|
||||
streamer.Element
|
||||
|
||||
DeviceAddress string // including port
|
||||
DeviceID string
|
||||
DevicePublic []byte
|
||||
ClientID string
|
||||
ClientPrivate []byte
|
||||
|
||||
OnEvent func(res *http.Response)
|
||||
Output func(msg interface{})
|
||||
|
||||
conn net.Conn
|
||||
secure *Secure
|
||||
httpResponse chan *bufio.Reader
|
||||
}
|
||||
|
||||
func NewClient(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")),
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func Pair(deviceID, pin string) (*Client, error) {
|
||||
entry := mdns.GetEntry(deviceID)
|
||||
if entry == nil {
|
||||
return nil, errors.New("can't find device via mDNS")
|
||||
}
|
||||
|
||||
c := &Client{
|
||||
DeviceAddress: fmt.Sprintf("%s:%d", entry.AddrV4.String(), entry.Port),
|
||||
DeviceID: deviceID,
|
||||
ClientID: GenerateUUID(),
|
||||
ClientPrivate: GenerateKey(),
|
||||
}
|
||||
|
||||
var mfi bool
|
||||
for _, field := range entry.InfoFields {
|
||||
if field[:2] == "ff" {
|
||||
if field[3] == '1' {
|
||||
mfi = true
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return c, c.Pair(mfi, pin)
|
||||
}
|
||||
|
||||
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) DialAndServe() error {
|
||||
if err := c.Dial(); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Handle()
|
||||
}
|
||||
|
||||
func (c *Client) Dial() error {
|
||||
// update device host before dial
|
||||
if host := mdns.GetAddress(c.DeviceID); host != "" {
|
||||
c.DeviceAddress = host
|
||||
}
|
||||
|
||||
var err error
|
||||
c.conn, err = net.Dial("tcp", c.DeviceAddress)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// STEP M1: send our session public to device
|
||||
sessionPublic, sessionPrivate := curve25519.GenerateKeyPair()
|
||||
|
||||
// 1. generate payload
|
||||
// important not include other fields
|
||||
requestM1 := struct {
|
||||
State byte `tlv8:"6"`
|
||||
PublicKey []byte `tlv8:"3"`
|
||||
}{
|
||||
State: hap.M1,
|
||||
PublicKey: sessionPublic[:],
|
||||
}
|
||||
// 2. pack payload to TLV8
|
||||
buf, err := tlv8.Marshal(requestM1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. send request
|
||||
resp, err := c.Post(UriPairVerify, buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// STEP M2: unpack deviceID from response
|
||||
responseM2 := PairVerifyPayload{}
|
||||
if err = tlv8.UnmarshalReader(resp.Body, &responseM2); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 1. generate session shared key
|
||||
var deviceSessionPublic [32]byte
|
||||
copy(deviceSessionPublic[:], responseM2.PublicKey)
|
||||
sessionShared := curve25519.SharedSecret(sessionPrivate, deviceSessionPublic)
|
||||
sessionKey, err := hkdf.Sha512(
|
||||
sessionShared[:], []byte("Pair-Verify-Encrypt-Salt"),
|
||||
[]byte("Pair-Verify-Encrypt-Info"),
|
||||
)
|
||||
|
||||
// 2. decrypt M2 response with session key
|
||||
msg := responseM2.EncryptedData[:len(responseM2.EncryptedData)-16]
|
||||
var mac [16]byte
|
||||
copy(mac[:], responseM2.EncryptedData[len(msg):]) // 16 byte (MAC)
|
||||
|
||||
buf, err = chacha20poly1305.DecryptAndVerify(
|
||||
sessionKey[:], []byte("PV-Msg02"), msg, mac, nil,
|
||||
)
|
||||
|
||||
// 3. unpack payload from TLV8
|
||||
payloadM2 := PairVerifyPayload{}
|
||||
if err = tlv8.Unmarshal(buf, &payloadM2); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 4. verify signature for M2 response with device public
|
||||
// device session + device id + our session
|
||||
if c.DevicePublic != nil {
|
||||
buf = nil
|
||||
buf = append(buf, responseM2.PublicKey[:]...)
|
||||
buf = append(buf, []byte(payloadM2.Identifier)...)
|
||||
buf = append(buf, sessionPublic[:]...)
|
||||
if !ed25519.ValidateSignature(
|
||||
c.DevicePublic[:], buf, payloadM2.Signature,
|
||||
) {
|
||||
return errors.New("device public signature invalid")
|
||||
}
|
||||
}
|
||||
|
||||
// STEP M3: send our clientID to device
|
||||
// 1. generate signature with our private key
|
||||
// (our session + our ID + device session)
|
||||
buf = nil
|
||||
buf = append(buf, sessionPublic[:]...)
|
||||
buf = append(buf, []byte(c.ClientID)...)
|
||||
buf = append(buf, responseM2.PublicKey[:]...)
|
||||
signature, err := ed25519.Signature(c.ClientPrivate[:], buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. generate payload
|
||||
payloadM3 := struct {
|
||||
Identifier string `tlv8:"1"`
|
||||
Signature []byte `tlv8:"10"`
|
||||
}{
|
||||
Identifier: c.ClientID,
|
||||
Signature: signature,
|
||||
}
|
||||
// 3. pack payload to TLV8
|
||||
buf, err = tlv8.Marshal(payloadM3)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 4. encrypt payload with session key
|
||||
msg, mac, _ = chacha20poly1305.EncryptAndSeal(
|
||||
sessionKey[:], []byte("PV-Msg03"), buf, nil,
|
||||
)
|
||||
|
||||
// 4. generate request
|
||||
requestM3 := struct {
|
||||
EncryptedData []byte `tlv8:"5"`
|
||||
State byte `tlv8:"6"`
|
||||
}{
|
||||
State: hap.M3,
|
||||
EncryptedData: append(msg, mac[:]...),
|
||||
}
|
||||
// 5. pack payload to TLV8
|
||||
buf, err = tlv8.Marshal(requestM3)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err = c.Post(UriPairVerify, buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// STEP M4. Read response
|
||||
responseM4 := PairVerifyPayload{}
|
||||
if err = tlv8.UnmarshalReader(resp.Body, &responseM4); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 1. check response state
|
||||
if responseM4.State != 4 || responseM4.Status != 0 {
|
||||
return fmt.Errorf("wrong M4 response: %+v", responseM4)
|
||||
}
|
||||
|
||||
c.secure, err = NewSecure(sessionShared, false)
|
||||
//c.secure.Buffer = bytes.NewBuffer(nil)
|
||||
c.secure.Conn = c.conn
|
||||
|
||||
c.httpResponse = make(chan *bufio.Reader, 10)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// https://github.com/apple/HomeKitADK/blob/master/HAP/HAPPairingPairSetup.c
|
||||
func (c *Client) Pair(mfi bool, pin string) (err error) {
|
||||
pin = strings.ReplaceAll(pin, "-", "")
|
||||
if len(pin) != 8 {
|
||||
return fmt.Errorf("wrong PIN format: %s", pin)
|
||||
}
|
||||
pin = pin[:3] + "-" + pin[3:5] + "-" + pin[5:]
|
||||
|
||||
c.conn, err = net.Dial("tcp", c.DeviceAddress)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// STEP M1. Generate request
|
||||
reqM1 := struct {
|
||||
Method byte `tlv8:"0"`
|
||||
State byte `tlv8:"6"`
|
||||
}{
|
||||
State: hap.M1,
|
||||
}
|
||||
if mfi {
|
||||
reqM1.Method = 1 // ff=1 => method=1, ff=2 => method=0
|
||||
}
|
||||
buf, err := tlv8.Marshal(reqM1)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// STEP M1. Send request
|
||||
res, err := c.Post(UriPairSetup, buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// STEP M2. Read response
|
||||
resM2 := struct {
|
||||
Salt []byte `tlv8:"2"`
|
||||
PublicKey []byte `tlv8:"3"` // server public key, aka session.B
|
||||
State byte `tlv8:"6"`
|
||||
Error byte `tlv8:"7"`
|
||||
}{}
|
||||
if err = tlv8.UnmarshalReader(res.Body, &resM2); err != nil {
|
||||
return
|
||||
}
|
||||
if resM2.State != 2 || resM2.Error > 0 {
|
||||
return fmt.Errorf("wrong M2: %+v", resM2)
|
||||
}
|
||||
|
||||
// STEP M3. Generate session using pin
|
||||
username := []byte("Pair-Setup")
|
||||
|
||||
SRP, err := srp.NewSRP(
|
||||
"rfc5054.3072", sha512.New, keyDerivativeFuncRFC2945(username),
|
||||
)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
SRP.SaltLength = 16
|
||||
|
||||
// username: "Pair-Setup"
|
||||
// password: PIN (with dashes)
|
||||
session := SRP.NewClientSession(username, []byte(pin))
|
||||
sessionShared, err := session.ComputeKey(resM2.Salt, resM2.PublicKey)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// STEP M3. Generate request
|
||||
reqM3 := struct {
|
||||
PublicKey []byte `tlv8:"3"`
|
||||
Proof []byte `tlv8:"4"`
|
||||
State byte `tlv8:"6"`
|
||||
}{
|
||||
PublicKey: session.GetA(), // client public key, aka session.A
|
||||
Proof: session.ComputeAuthenticator(),
|
||||
State: hap.M3,
|
||||
}
|
||||
buf, err = tlv8.Marshal(reqM3)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// STEP M3. Send request
|
||||
res, err = c.Post(UriPairSetup, buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// STEP M4. Read response
|
||||
resM4 := struct {
|
||||
Proof []byte `tlv8:"4"` // server proof
|
||||
State byte `tlv8:"6"`
|
||||
Error byte `tlv8:"7"`
|
||||
}{}
|
||||
if err = tlv8.UnmarshalReader(res.Body, &resM4); err != nil {
|
||||
return
|
||||
}
|
||||
if resM4.Error == 2 {
|
||||
return fmt.Errorf("wrong PIN: %s", pin)
|
||||
}
|
||||
if resM4.State != 4 || resM4.Error > 0 {
|
||||
return fmt.Errorf("wrong M4: %+v", resM4)
|
||||
}
|
||||
|
||||
// STEP M4. Verify response
|
||||
if !session.VerifyServerAuthenticator(resM4.Proof) {
|
||||
return errors.New("verify server auth fail")
|
||||
}
|
||||
|
||||
// STEP M5. Generate signature
|
||||
saltKey, err := hkdf.Sha512(
|
||||
sessionShared, []byte("Pair-Setup-Controller-Sign-Salt"),
|
||||
[]byte("Pair-Setup-Controller-Sign-Info"),
|
||||
)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
buf = nil
|
||||
buf = append(buf, saltKey[:]...)
|
||||
buf = append(buf, []byte(c.ClientID)...)
|
||||
buf = append(buf, c.ClientPublic()...)
|
||||
|
||||
signature, err := ed25519.Signature(c.ClientPrivate, buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// STEP M5. Generate payload
|
||||
msgM5 := struct {
|
||||
Identifier string `tlv8:"1"`
|
||||
PublicKey []byte `tlv8:"3"`
|
||||
Signature []byte `tlv8:"10"`
|
||||
}{
|
||||
Identifier: c.ClientID,
|
||||
PublicKey: c.ClientPublic(),
|
||||
Signature: signature,
|
||||
}
|
||||
buf, err = tlv8.Marshal(msgM5)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// STEP M5. Encrypt payload
|
||||
sessionKey, err := hkdf.Sha512(
|
||||
sessionShared, []byte("Pair-Setup-Encrypt-Salt"),
|
||||
[]byte("Pair-Setup-Encrypt-Info"),
|
||||
)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
buf, mac, _ := chacha20poly1305.EncryptAndSeal(
|
||||
sessionKey[:], []byte("PS-Msg05"), buf, nil,
|
||||
)
|
||||
|
||||
// STEP M5. Generate request
|
||||
reqM5 := struct {
|
||||
EncryptedData []byte `tlv8:"5"`
|
||||
State byte `tlv8:"6"`
|
||||
}{
|
||||
EncryptedData: append(buf, mac[:]...),
|
||||
State: hap.M5,
|
||||
}
|
||||
buf, err = tlv8.Marshal(reqM5)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// STEP M5. Send request
|
||||
res, err = c.Post(UriPairSetup, buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// STEP M6. Read response
|
||||
resM6 := struct {
|
||||
EncryptedData []byte `tlv8:"5"`
|
||||
State byte `tlv8:"6"`
|
||||
Error byte `tlv8:"7"`
|
||||
}{}
|
||||
if err = tlv8.UnmarshalReader(res.Body, &resM6); err != nil {
|
||||
return
|
||||
}
|
||||
if resM6.State != 6 || resM6.Error > 0 {
|
||||
return fmt.Errorf("wrong M6: %+v", resM2)
|
||||
}
|
||||
|
||||
// STEP M6. Decrypt payload
|
||||
msg := resM6.EncryptedData[:len(resM6.EncryptedData)-16]
|
||||
copy(mac[:], resM6.EncryptedData[len(msg):]) // 16 byte (MAC)
|
||||
|
||||
buf, err = chacha20poly1305.DecryptAndVerify(
|
||||
sessionKey[:], []byte("PS-Msg06"), msg, mac, nil,
|
||||
)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
msgM6 := struct {
|
||||
Identifier []byte `tlv8:"1"`
|
||||
PublicKey []byte `tlv8:"3"`
|
||||
Signature []byte `tlv8:"10"`
|
||||
}{}
|
||||
if err = tlv8.Unmarshal(buf, &msgM6); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// STEP M6. Verify payload
|
||||
if saltKey, err = hkdf.Sha512(
|
||||
sessionShared, []byte("Pair-Setup-Accessory-Sign-Salt"),
|
||||
[]byte("Pair-Setup-Accessory-Sign-Info"),
|
||||
); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
buf = nil
|
||||
buf = append(buf, saltKey[:]...)
|
||||
buf = append(buf, msgM6.Identifier...)
|
||||
buf = append(buf, msgM6.PublicKey...)
|
||||
|
||||
if !ed25519.ValidateSignature(
|
||||
msgM6.PublicKey[:], buf, msgM6.Signature,
|
||||
) {
|
||||
return errors.New("wrong server signature")
|
||||
}
|
||||
|
||||
if c.DeviceID != string(msgM6.Identifier) {
|
||||
return fmt.Errorf("wrong Device ID: %s", msgM6.Identifier)
|
||||
}
|
||||
|
||||
c.DevicePublic = msgM6.PublicKey
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
if c.conn == nil {
|
||||
return nil
|
||||
}
|
||||
conn := c.conn
|
||||
c.conn = nil
|
||||
return conn.Close()
|
||||
}
|
||||
|
||||
func (c *Client) GetAccessories() ([]*Accessory, error) {
|
||||
res, err := c.Get("/accessories")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p := Accessories{}
|
||||
if err = json.Unmarshal(data, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, accs := range p.Accessories {
|
||||
for _, serv := range accs.Services {
|
||||
for _, char := range serv.Characters {
|
||||
char.AID = accs.AID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return p.Accessories, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetCharacters(query string) ([]*Character, error) {
|
||||
res, err := c.Get("/characteristics?id=" + query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ch := Characters{}
|
||||
if err = json.Unmarshal(data, &ch); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ch.Characters, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetCharacter(char *Character) error {
|
||||
query := fmt.Sprintf("%d.%d", char.AID, 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) (err error) {
|
||||
for i, char := range characters {
|
||||
if char.Event != nil {
|
||||
char = &Character{AID: char.AID, IID: char.IID, Event: char.Event}
|
||||
} else {
|
||||
char = &Character{AID: char.AID, IID: char.IID, Value: char.Value}
|
||||
}
|
||||
characters[i] = char
|
||||
}
|
||||
var data []byte
|
||||
if data, err = json.Marshal(Characters{characters}); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var res *http.Response
|
||||
if res, err = c.Put("/characteristics", data); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if res.StatusCode >= 400 {
|
||||
return errors.New("wrong response status")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Client) GetImage(width, height int) ([]byte, error) {
|
||||
res, err := c.Post(
|
||||
"/resource", []byte(fmt.Sprintf(
|
||||
`{"image-width":%d,"image-height":%d,"resource-type":"image","reason":0}`,
|
||||
width, height,
|
||||
)),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return io.ReadAll(res.Body)
|
||||
}
|
||||
|
||||
//func (c *Client) onEventData(r io.Reader) error {
|
||||
// if c.OnEvent == nil {
|
||||
// return nil
|
||||
// }
|
||||
//
|
||||
// data, err := io.ReadAll(r)
|
||||
//
|
||||
// ch := Characters{}
|
||||
// if err = json.Unmarshal(data, &ch); err != nil {
|
||||
// return err
|
||||
// }
|
||||
//
|
||||
// c.OnEvent(ch.Characters)
|
||||
//
|
||||
// return nil
|
||||
//}
|
||||
|
||||
func (c *Client) ListPairings() error {
|
||||
pReq := struct {
|
||||
Method byte `tlv8:"0"`
|
||||
State byte `tlv8:"6"`
|
||||
}{
|
||||
Method: hap.MethodListPairings,
|
||||
State: hap.M1,
|
||||
}
|
||||
data, err := tlv8.Marshal(pReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
res, err := c.Post("/pairings", data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err = io.ReadAll(res.Body)
|
||||
// TODO: don't know how to fix array of items
|
||||
var pRes struct {
|
||||
State byte `tlv8:"6"`
|
||||
Identifier string `tlv8:"1"`
|
||||
PublicKey []byte `tlv8:"3"`
|
||||
Permission byte `tlv8:"11"`
|
||||
}
|
||||
if err = tlv8.Unmarshal(data, &pRes); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) PairingsAdd(clientID string, clientPublic []byte, admin bool) error {
|
||||
pReq := struct {
|
||||
Method byte `tlv8:"0"`
|
||||
Identifier string `tlv8:"1"`
|
||||
PublicKey []byte `tlv8:"3"`
|
||||
State byte `tlv8:"6"`
|
||||
Permission byte `tlv8:"11"`
|
||||
}{
|
||||
Method: hap.MethodAddPairing,
|
||||
Identifier: clientID,
|
||||
PublicKey: clientPublic,
|
||||
State: hap.M1,
|
||||
Permission: hap.PermissionUser,
|
||||
}
|
||||
if admin {
|
||||
pReq.Permission = hap.PermissionAdmin
|
||||
}
|
||||
|
||||
data, err := tlv8.Marshal(pReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
res, err := c.Post("/pairings", data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err = io.ReadAll(res.Body)
|
||||
var pRes struct {
|
||||
State byte `tlv8:"6"`
|
||||
Unknown byte `tlv8:"7"`
|
||||
}
|
||||
if err = tlv8.Unmarshal(data, &pRes); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) DeletePairing(id string) error {
|
||||
reqM1 := struct {
|
||||
State byte `tlv8:"6"`
|
||||
Method byte `tlv8:"0"`
|
||||
Identifier string `tlv8:"1"`
|
||||
}{
|
||||
State: hap.M1,
|
||||
Method: hap.MethodDeletePairing,
|
||||
Identifier: id,
|
||||
}
|
||||
data, err := tlv8.Marshal(reqM1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
res, err := c.Post("/pairings", data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err = io.ReadAll(res.Body)
|
||||
var resM2 struct {
|
||||
State byte `tlv8:"6"`
|
||||
}
|
||||
if err = tlv8.Unmarshal(data, &resM2); err != nil {
|
||||
return err
|
||||
}
|
||||
if resM2.State != hap.M2 {
|
||||
return errors.New("wrong state")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) LocalAddr() string {
|
||||
return c.conn.LocalAddr().String()
|
||||
}
|
||||
|
||||
func DecodeKey(s string) []byte {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
data, err := hex.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return data
|
||||
}
|
90
pkg/homekit/helpers.go
Normal file
90
pkg/homekit/helpers.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package homekit
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha512"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const DeviceAID = 1 // TODO: fix someday
|
||||
|
||||
func GenerateID(name string) string {
|
||||
sum := sha512.Sum512([]byte(name))
|
||||
return fmt.Sprintf(
|
||||
"%02X:%02X:%02X:%02X:%02X:%02X",
|
||||
sum[0], sum[1], sum[2], sum[3], sum[4], sum[5],
|
||||
)
|
||||
}
|
||||
|
||||
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:]
|
||||
}
|
||||
|
||||
type PairVerifyPayload struct {
|
||||
Method byte `tlv8:"0"`
|
||||
Identifier string `tlv8:"1"`
|
||||
PublicKey []byte `tlv8:"3"`
|
||||
EncryptedData []byte `tlv8:"5"`
|
||||
State byte `tlv8:"6"`
|
||||
Status byte `tlv8:"7"`
|
||||
Signature []byte `tlv8:"10"`
|
||||
}
|
||||
|
||||
//func (c *Character) Unmarshal(value interface{}) error {
|
||||
// switch c.Format {
|
||||
// case characteristic.FormatTLV8:
|
||||
// data, err := base64.StdEncoding.DecodeString(c.Value.(string))
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// return tlv8.Unmarshal(data, value)
|
||||
// }
|
||||
// return nil
|
||||
//}
|
||||
|
||||
//func (c *Character) Marshal(value interface{}) error {
|
||||
// switch c.Format {
|
||||
// case characteristic.FormatTLV8:
|
||||
// data, err := tlv8.Marshal(value)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// c.Value = base64.StdEncoding.EncodeToString(data)
|
||||
// }
|
||||
// return nil
|
||||
//}
|
||||
|
||||
func (c *Character) String() string {
|
||||
data, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
return "ERROR"
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func UnmarshalEvent(res *http.Response) (char *Character, err error) {
|
||||
var data []byte
|
||||
if data, err = io.ReadAll(res.Body); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
ch := Characters{}
|
||||
if err = json.Unmarshal(data, &ch); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if len(ch.Characters) > 1 {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
char = ch.Characters[0]
|
||||
return
|
||||
}
|
246
pkg/homekit/http.go
Normal file
246
pkg/homekit/http.go
Normal file
@@ -0,0 +1,246 @@
|
||||
package homekit
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
const (
|
||||
MimeTLV8 = "application/pairing+tlv8"
|
||||
MimeJSON = "application/hap+json"
|
||||
|
||||
UriPairSetup = "/pair-setup"
|
||||
UriPairVerify = "/pair-verify"
|
||||
UriPairings = "/pairings"
|
||||
UriAccessories = "/accessories"
|
||||
UriCharacteristics = "/characteristics"
|
||||
UriResource = "/resource"
|
||||
)
|
||||
|
||||
func (c *Client) Write(p []byte) (r io.Reader, err error) {
|
||||
if c.secure == nil {
|
||||
if _, err = c.conn.Write(p); err == nil {
|
||||
r = bufio.NewReader(c.conn)
|
||||
}
|
||||
} else {
|
||||
if _, err = c.secure.Write(p); err == nil {
|
||||
r = <-c.httpResponse
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Client) Do(req *http.Request) (*http.Response, error) {
|
||||
if c.secure == nil {
|
||||
// insecure requests
|
||||
if err := req.Write(c.conn); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return http.ReadResponse(bufio.NewReader(c.conn), req)
|
||||
}
|
||||
|
||||
// secure support write interface to connection
|
||||
if err := req.Write(c.secure); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// get decrypted buffer from connection
|
||||
buf := <-c.httpResponse
|
||||
|
||||
return http.ReadResponse(buf, req)
|
||||
}
|
||||
|
||||
func (c *Client) Get(uri string) (*http.Response, error) {
|
||||
req, err := http.NewRequest(
|
||||
"GET", "http://"+c.DeviceAddress+uri, nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) Post(uri string, data []byte) (*http.Response, error) {
|
||||
req, err := http.NewRequest(
|
||||
"POST", "http://"+c.DeviceAddress+uri,
|
||||
bytes.NewReader(data),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch uri {
|
||||
case "/pair-verify", "/pairings":
|
||||
req.Header.Set("Content-Type", MimeTLV8)
|
||||
case UriResource:
|
||||
req.Header.Set("Content-Type", MimeJSON)
|
||||
}
|
||||
|
||||
return c.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) Put(uri string, data []byte) (*http.Response, error) {
|
||||
req, err := http.NewRequest(
|
||||
"PUT", "http://"+c.DeviceAddress+uri,
|
||||
bytes.NewReader(data),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch uri {
|
||||
case UriCharacteristics:
|
||||
req.Header.Set("Content-Type", MimeJSON)
|
||||
}
|
||||
|
||||
return c.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) Handle() (err error) {
|
||||
defer func() {
|
||||
if c.conn == nil {
|
||||
err = nil
|
||||
}
|
||||
}()
|
||||
|
||||
b := make([]byte, 512000)
|
||||
for {
|
||||
var total, content int
|
||||
header := -1
|
||||
|
||||
for {
|
||||
var n1 int
|
||||
n1, err = c.secure.Read(b[total:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if n1 == 0 {
|
||||
return io.EOF
|
||||
}
|
||||
|
||||
total += n1
|
||||
|
||||
// TODO: rewrite
|
||||
if header == -1 {
|
||||
// step 1. wait whole header
|
||||
header = bytes.Index(b[:total], []byte("\r\n\r\n"))
|
||||
if header < 0 {
|
||||
continue
|
||||
}
|
||||
header += 4
|
||||
|
||||
// step 2. check content-length
|
||||
i1 := bytes.Index(b[:total], []byte("Content-Length: "))
|
||||
if i1 < 0 {
|
||||
break
|
||||
}
|
||||
i1 += 16
|
||||
i2 := bytes.IndexByte(b[i1:total], '\r')
|
||||
content, err = strconv.Atoi(string(b[i1 : i1+i2]))
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if total >= header+content {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// copy slice to buffer
|
||||
buf := bytes.NewBuffer(make([]byte, 0, total))
|
||||
buf.Write(b[:total])
|
||||
r := bufio.NewReader(buf)
|
||||
|
||||
// EVENT/1.0 200 OK
|
||||
if b[0] == 'E' {
|
||||
if c.OnEvent == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
tp := textproto.NewReader(r)
|
||||
|
||||
var s string
|
||||
if s, err = tp.ReadLine(); err != nil {
|
||||
return err
|
||||
}
|
||||
if s != "EVENT/1.0 200 OK" {
|
||||
return errors.New("wrong response")
|
||||
}
|
||||
|
||||
var mimeHeader textproto.MIMEHeader
|
||||
if mimeHeader, err = tp.ReadMIMEHeader(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var cl int
|
||||
if cl, err = strconv.Atoi(
|
||||
mimeHeader.Get("Content-Length"),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
res := http.Response{
|
||||
StatusCode: 200,
|
||||
Proto: "EVENT/1.0",
|
||||
ProtoMajor: 1,
|
||||
ProtoMinor: 0,
|
||||
Header: http.Header(mimeHeader),
|
||||
ContentLength: int64(cl),
|
||||
Body: io.NopCloser(r),
|
||||
}
|
||||
c.OnEvent(&res)
|
||||
continue
|
||||
}
|
||||
|
||||
//if bytes.Index(b, []byte("image/jpeg")) > 0 {
|
||||
// if total, err = c.secure.Read(b); err != nil {
|
||||
// return
|
||||
// }
|
||||
// buf.Write(b[:total])
|
||||
//}
|
||||
|
||||
c.httpResponse <- r
|
||||
}
|
||||
}
|
||||
|
||||
func WriteStatusCode(w io.Writer, statusCode int) (err error) {
|
||||
body := []byte(fmt.Sprintf(
|
||||
"HTTP/1.1 %d %s\n\n", statusCode, http.StatusText(statusCode),
|
||||
))
|
||||
//print("<<<", string(body), "<<<\n")
|
||||
_, err = w.Write(body)
|
||||
return
|
||||
}
|
||||
|
||||
func WriteResponse(
|
||||
w io.Writer, statusCode int, contentType string, body []byte,
|
||||
) (err error) {
|
||||
header := fmt.Sprintf(
|
||||
"HTTP/1.1 %d %s\nContent-Type: %s\nContent-Length: %d\n\n",
|
||||
statusCode, http.StatusText(statusCode), contentType, len(body),
|
||||
)
|
||||
body = append([]byte(header), body...)
|
||||
//print("<<<", string(body), "<<<\n")
|
||||
_, err = w.Write(body)
|
||||
return
|
||||
}
|
||||
|
||||
func WriteChunked(w io.Writer, contentType string, body []byte) (err error) {
|
||||
header := fmt.Sprintf(
|
||||
"HTTP/1.1 200 OK\nContent-Type: %s\nTransfer-Encoding: chunked\n\n%x\n",
|
||||
contentType, len(body),
|
||||
)
|
||||
body = append([]byte(header), body...)
|
||||
body = append(body, "\n0\n\n"...)
|
||||
//print("<<<", string(body), "<<<\n")
|
||||
_, err = w.Write(body)
|
||||
return
|
||||
}
|
42
pkg/homekit/mdns/client.go
Normal file
42
pkg/homekit/mdns/client.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package mdns
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/hashicorp/mdns"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const Suffix = "._hap._tcp.local."
|
||||
|
||||
func GetAll() chan *mdns.ServiceEntry {
|
||||
entries := make(chan *mdns.ServiceEntry)
|
||||
params := &mdns.QueryParam{
|
||||
Service: "_hap._tcp", Entries: entries, DisableIPv6: true,
|
||||
}
|
||||
|
||||
go func() {
|
||||
_ = mdns.Query(params)
|
||||
close(entries)
|
||||
}()
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
func GetAddress(deviceID string) string {
|
||||
for entry := range GetAll() {
|
||||
if strings.Contains(entry.Info, deviceID) {
|
||||
return fmt.Sprintf("%s:%d", entry.AddrV4.String(), entry.Port)
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func GetEntry(deviceID string) *mdns.ServiceEntry {
|
||||
for entry := range GetAll() {
|
||||
if strings.Contains(entry.Info, deviceID) {
|
||||
return entry
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
53
pkg/homekit/mdns/server.go
Normal file
53
pkg/homekit/mdns/server.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package mdns
|
||||
|
||||
import (
|
||||
"github.com/hashicorp/mdns"
|
||||
"net"
|
||||
)
|
||||
|
||||
const HostHeaderTail = "._hap._tcp.local"
|
||||
|
||||
func NewServer(name string, port int, ips []net.IP, txt []string) (*mdns.Server, error) {
|
||||
if ips == nil || ips[0] == nil {
|
||||
ips = LocalIPs()
|
||||
}
|
||||
|
||||
// important to set hostName manually with any value and `.local.` tail
|
||||
// important to set ips manually
|
||||
service, _ := mdns.NewMDNSService(
|
||||
name, "_hap._tcp", "", name+".local.", port, ips, txt,
|
||||
)
|
||||
|
||||
return mdns.NewServer(&mdns.Config{Zone: service})
|
||||
}
|
||||
|
||||
func LocalIPs() []net.IP {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var ips []net.IP
|
||||
for _, iface := range ifaces {
|
||||
if iface.Flags&net.FlagUp == 0 {
|
||||
continue // interface down
|
||||
}
|
||||
if iface.Flags&net.FlagLoopback != 0 {
|
||||
continue // loopback interface
|
||||
}
|
||||
|
||||
var addrs []net.Addr
|
||||
if addrs, err = iface.Addrs(); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
switch addr := addr.(type) {
|
||||
case *net.IPNet:
|
||||
ips = append(ips, addr.IP)
|
||||
case *net.IPAddr:
|
||||
ips = append(ips, addr.IP)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ips
|
||||
}
|
410
pkg/homekit/pairing.go
Normal file
410
pkg/homekit/pairing.go
Normal file
@@ -0,0 +1,410 @@
|
||||
package homekit
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha512"
|
||||
"errors"
|
||||
"github.com/brutella/hap"
|
||||
"github.com/brutella/hap/chacha20poly1305"
|
||||
"github.com/brutella/hap/curve25519"
|
||||
"github.com/brutella/hap/ed25519"
|
||||
"github.com/brutella/hap/hkdf"
|
||||
"github.com/brutella/hap/tlv8"
|
||||
"github.com/tadglines/go-pkgs/crypto/srp"
|
||||
"net"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type pairSetupPayload struct {
|
||||
Method byte `tlv8:"0"`
|
||||
Identifier string `tlv8:"1"`
|
||||
Salt []byte `tlv8:"2"`
|
||||
PublicKey []byte `tlv8:"3"`
|
||||
Proof []byte `tlv8:"4"`
|
||||
EncryptedData []byte `tlv8:"5"`
|
||||
State byte `tlv8:"6"`
|
||||
Error byte `tlv8:"7"`
|
||||
RetryDelay byte `tlv8:"8"`
|
||||
Certificate []byte `tlv8:"9"`
|
||||
Signature []byte `tlv8:"10"`
|
||||
Permissions byte `tlv8:"11"`
|
||||
FragmentData []byte `tlv8:"13"`
|
||||
FragmentLast []byte `tlv8:"14"`
|
||||
}
|
||||
|
||||
func (s *Server) PairSetupHandler(
|
||||
conn net.Conn, req *http.Request,
|
||||
) (clientID string, err error) {
|
||||
// STEP 1. Request from iPhone
|
||||
payloadM1 := pairSetupPayload{}
|
||||
if err = tlv8.UnmarshalReader(req.Body, &payloadM1); err != nil {
|
||||
return
|
||||
}
|
||||
if payloadM1.State != hap.M1 {
|
||||
err = errors.New("wrong state")
|
||||
return
|
||||
}
|
||||
|
||||
// generate our session public and salt using PIN
|
||||
username := []byte("Pair-Setup")
|
||||
|
||||
var SRP *srp.SRP
|
||||
if SRP, err = srp.NewSRP(
|
||||
"rfc5054.3072", sha512.New,
|
||||
keyDerivativeFuncRFC2945(username),
|
||||
); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
SRP.SaltLength = 16
|
||||
var salt, verifier []byte
|
||||
if salt, verifier, err = SRP.ComputeVerifier([]byte(s.Pin)); err != nil {
|
||||
return
|
||||
}
|
||||
session := SRP.NewServerSession(username, salt, verifier)
|
||||
|
||||
// STEP 2. Response to iPhone
|
||||
payloadM2 := struct {
|
||||
Salt []byte `tlv8:"2"`
|
||||
PublicKey []byte `tlv8:"3"`
|
||||
State byte `tlv8:"6"`
|
||||
}{
|
||||
State: hap.M2,
|
||||
PublicKey: session.GetB(),
|
||||
Salt: salt,
|
||||
}
|
||||
var buf []byte
|
||||
if buf, err = tlv8.Marshal(payloadM2); err != nil {
|
||||
return
|
||||
}
|
||||
if err = WriteResponse(conn, http.StatusOK, MimeTLV8, buf); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// STEP 3. Request from iPhone
|
||||
r := bufio.NewReader(conn)
|
||||
if req, err = http.ReadRequest(r); err != nil {
|
||||
return
|
||||
}
|
||||
payloadM3 := pairSetupPayload{}
|
||||
if err = tlv8.UnmarshalReader(req.Body, &payloadM3); err != nil {
|
||||
return
|
||||
}
|
||||
if payloadM3.State != hap.M3 {
|
||||
err = errors.New("wrong state")
|
||||
return
|
||||
}
|
||||
|
||||
// important to compute key before verify client
|
||||
var sessionShared []byte
|
||||
if sessionShared, err = session.ComputeKey(payloadM3.PublicKey); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// support skip pin verify (any pin accepted)
|
||||
if s.Pin != "" && !session.VerifyClientAuthenticator(payloadM3.Proof) {
|
||||
err = errors.New("client proof is invalid")
|
||||
return
|
||||
}
|
||||
|
||||
serverProof := session.ComputeAuthenticator(payloadM3.Proof)
|
||||
|
||||
// STEP 4. Response to iPhone
|
||||
payloadM4 := struct {
|
||||
Proof []byte `tlv8:"4"`
|
||||
State byte `tlv8:"6"`
|
||||
}{
|
||||
State: hap.M4, Proof: serverProof,
|
||||
}
|
||||
if buf, err = tlv8.Marshal(payloadM4); err != nil {
|
||||
return
|
||||
}
|
||||
if err = WriteResponse(conn, http.StatusOK, MimeTLV8, buf); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// STEP 5. Request from iPhone
|
||||
if req, err = http.ReadRequest(r); err != nil {
|
||||
return
|
||||
}
|
||||
encryptedM5 := pairSetupPayload{}
|
||||
if err = tlv8.UnmarshalReader(req.Body, &encryptedM5); err != nil {
|
||||
return
|
||||
}
|
||||
if encryptedM5.State != hap.M5 {
|
||||
err = errors.New("wrong state")
|
||||
return
|
||||
}
|
||||
|
||||
msg := encryptedM5.EncryptedData[:len(encryptedM5.EncryptedData)-16]
|
||||
var mac [16]byte
|
||||
copy(mac[:], encryptedM5.EncryptedData[len(msg):]) // 16 byte (MAC)
|
||||
|
||||
// decrypt message using session shared
|
||||
var sessionKey [32]byte
|
||||
if sessionKey, err = hkdf.Sha512(
|
||||
sessionShared, []byte("Pair-Setup-Encrypt-Salt"),
|
||||
[]byte("Pair-Setup-Encrypt-Info"),
|
||||
); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if buf, err = chacha20poly1305.DecryptAndVerify(
|
||||
sessionKey[:], []byte("PS-Msg05"), msg, mac, nil,
|
||||
); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// unpack message from TLV8
|
||||
payloadM5 := struct {
|
||||
Identifier string `tlv8:"1"`
|
||||
PublicKey []byte `tlv8:"3"`
|
||||
Signature []byte `tlv8:"10"`
|
||||
}{}
|
||||
if err = tlv8.Unmarshal(buf, &payloadM5); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 3. verify client ID and Public
|
||||
var saltKey [32]byte
|
||||
if saltKey, err = hkdf.Sha512(
|
||||
sessionShared, []byte("Pair-Setup-Controller-Sign-Salt"),
|
||||
[]byte("Pair-Setup-Controller-Sign-Info"),
|
||||
); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
buf = nil
|
||||
buf = append(buf, saltKey[:]...)
|
||||
buf = append(buf, payloadM5.Identifier...)
|
||||
buf = append(buf, payloadM5.PublicKey[:]...)
|
||||
|
||||
if !ed25519.ValidateSignature(
|
||||
payloadM5.PublicKey[:], buf, payloadM5.Signature,
|
||||
) {
|
||||
err = errors.New("wrong client signature")
|
||||
return
|
||||
}
|
||||
|
||||
// 4. generate signature to our ID adn Public
|
||||
if saltKey, err = hkdf.Sha512(
|
||||
sessionShared, []byte("Pair-Setup-Accessory-Sign-Salt"),
|
||||
[]byte("Pair-Setup-Accessory-Sign-Info"),
|
||||
); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
buf = nil
|
||||
buf = append(buf, saltKey[:]...)
|
||||
buf = append(buf, []byte(s.ServerID)...)
|
||||
buf = append(buf, s.ServerPrivate[32:]...) // ServerPublic
|
||||
|
||||
var signature []byte
|
||||
if signature, err = ed25519.Signature(s.ServerPrivate, buf); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 5. pack our ID and Public
|
||||
payloadM6 := struct {
|
||||
Identifier []byte `tlv8:"1"`
|
||||
PublicKey []byte `tlv8:"3"`
|
||||
Signature []byte `tlv8:"10"`
|
||||
}{
|
||||
Identifier: []byte(s.ServerID),
|
||||
PublicKey: s.ServerPrivate[32:],
|
||||
Signature: signature,
|
||||
}
|
||||
if buf, err = tlv8.Marshal(payloadM6); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 6. encrypt message
|
||||
buf, mac, _ = chacha20poly1305.EncryptAndSeal(
|
||||
sessionKey[:], []byte("PS-Msg06"), buf, nil,
|
||||
)
|
||||
|
||||
// STEP 6. Response to iPhone
|
||||
encryptedM6 := struct {
|
||||
EncryptedData []byte `tlv8:"5"`
|
||||
State byte `tlv8:"6"`
|
||||
}{
|
||||
State: hap.M6,
|
||||
EncryptedData: append(buf, mac[:]...),
|
||||
}
|
||||
if buf, err = tlv8.Marshal(encryptedM6); err != nil {
|
||||
return
|
||||
}
|
||||
if err = WriteResponse(conn, http.StatusOK, MimeTLV8, buf); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if s.Pairings != nil {
|
||||
s.Pairings[payloadM5.Identifier] = append(
|
||||
payloadM5.PublicKey, 1, // adds admin (1) flag
|
||||
)
|
||||
}
|
||||
|
||||
clientID = payloadM5.Identifier
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func keyDerivativeFuncRFC2945(username []byte) srp.KeyDerivationFunc {
|
||||
return func(salt, pin []byte) []byte {
|
||||
h := sha512.New()
|
||||
h.Write(username)
|
||||
h.Write([]byte(":"))
|
||||
h.Write(pin)
|
||||
t2 := h.Sum(nil)
|
||||
h.Reset()
|
||||
h.Write(salt)
|
||||
h.Write(t2)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
}
|
||||
|
||||
type pairVerifyPayload struct {
|
||||
Method byte `tlv8:"0"`
|
||||
Identifier string `tlv8:"1"`
|
||||
PublicKey []byte `tlv8:"3"`
|
||||
EncryptedData []byte `tlv8:"5"`
|
||||
State byte `tlv8:"6"`
|
||||
Signature []byte `tlv8:"10"`
|
||||
}
|
||||
|
||||
func (s *Server) PairVerifyHandler(
|
||||
conn net.Conn, req *http.Request,
|
||||
) (secure *Secure, err error) {
|
||||
// STEP M1. Request from iPhone
|
||||
payloadM1 := pairVerifyPayload{}
|
||||
if err = tlv8.UnmarshalReader(req.Body, &payloadM1); err != nil {
|
||||
return
|
||||
}
|
||||
if payloadM1.State != hap.M1 {
|
||||
err = errors.New("wrong state")
|
||||
return
|
||||
}
|
||||
|
||||
var clientPublic [32]byte
|
||||
copy(clientPublic[:], payloadM1.PublicKey)
|
||||
|
||||
// Generate the key pair.
|
||||
sessionPublic, sessionPrivate := curve25519.GenerateKeyPair()
|
||||
sessionShared := curve25519.SharedSecret(sessionPrivate, clientPublic)
|
||||
|
||||
var sessionKey [32]byte
|
||||
if sessionKey, err = hkdf.Sha512(
|
||||
sessionShared[:], []byte("Pair-Verify-Encrypt-Salt"),
|
||||
[]byte("Pair-Verify-Encrypt-Info"),
|
||||
); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var buf []byte
|
||||
buf = append(buf, sessionPublic[:]...)
|
||||
buf = append(buf, s.ServerID...)
|
||||
buf = append(buf, clientPublic[:]...)
|
||||
|
||||
var signature []byte
|
||||
if signature, err = ed25519.Signature(s.ServerPrivate[:], buf); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// STEP M2. Response to iPhone
|
||||
payloadM2 := struct {
|
||||
Identifier string `tlv8:"1"`
|
||||
Signature []byte `tlv8:"10"`
|
||||
}{
|
||||
Identifier: s.ServerID,
|
||||
Signature: signature,
|
||||
}
|
||||
if buf, err = tlv8.Marshal(payloadM2); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var mac [16]byte
|
||||
buf, mac, _ = chacha20poly1305.EncryptAndSeal(
|
||||
sessionKey[:], []byte("PV-Msg02"), buf, nil,
|
||||
)
|
||||
encryptedM2 := struct {
|
||||
State byte `tlv8:"6"`
|
||||
PublicKey []byte `tlv8:"3"`
|
||||
EncryptedData []byte `tlv8:"5"`
|
||||
}{
|
||||
State: hap.M2,
|
||||
PublicKey: sessionPublic[:],
|
||||
EncryptedData: append(buf, mac[:]...),
|
||||
}
|
||||
if buf, err = tlv8.Marshal(encryptedM2); err != nil {
|
||||
return
|
||||
}
|
||||
if err = WriteResponse(conn, http.StatusOK, MimeTLV8, buf); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// STEP M3. Request from iPhone
|
||||
r := bufio.NewReader(conn)
|
||||
if req, err = http.ReadRequest(r); err != nil {
|
||||
return
|
||||
}
|
||||
encryptedM3 := pairSetupPayload{}
|
||||
if err = tlv8.UnmarshalReader(req.Body, &encryptedM3); err != nil {
|
||||
return
|
||||
}
|
||||
if encryptedM3.State != hap.M3 {
|
||||
err = errors.New("wrong state")
|
||||
return
|
||||
}
|
||||
|
||||
buf = encryptedM3.EncryptedData[:len(encryptedM3.EncryptedData)-16]
|
||||
copy(mac[:], encryptedM3.EncryptedData[len(buf):]) // 16 byte (MAC)
|
||||
|
||||
if buf, err = chacha20poly1305.DecryptAndVerify(
|
||||
sessionKey[:], []byte("PV-Msg03"), buf, mac, nil,
|
||||
); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
payloadM3 := pairVerifyPayload{}
|
||||
if err = tlv8.Unmarshal(buf, &payloadM3); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if s.Pairings != nil {
|
||||
pairing := s.Pairings[payloadM3.Identifier]
|
||||
if pairing == nil {
|
||||
err = errors.New("not paired yet")
|
||||
return
|
||||
}
|
||||
|
||||
buf = nil
|
||||
buf = append(buf, clientPublic[:]...)
|
||||
buf = append(buf, []byte(payloadM3.Identifier)...)
|
||||
buf = append(buf, sessionPublic[:]...)
|
||||
|
||||
if !ed25519.ValidateSignature(
|
||||
pairing[:32], buf, payloadM3.Signature,
|
||||
) {
|
||||
err = errors.New("signature invalid")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// STEP M4. Response to iPhone
|
||||
payloadM4 := struct {
|
||||
State byte `tlv8:"6"`
|
||||
}{
|
||||
State: hap.M4,
|
||||
}
|
||||
if buf, err = tlv8.Marshal(payloadM4); err != nil {
|
||||
return
|
||||
}
|
||||
err = WriteResponse(conn, http.StatusOK, MimeTLV8, buf)
|
||||
|
||||
if secure, err = NewSecure(sessionShared, true); err != nil {
|
||||
return
|
||||
}
|
||||
secure.Conn = conn
|
||||
|
||||
return
|
||||
}
|
137
pkg/homekit/secure.go
Normal file
137
pkg/homekit/secure.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package homekit
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"github.com/brutella/hap/chacha20poly1305"
|
||||
"github.com/brutella/hap/hkdf"
|
||||
"net"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Secure struct {
|
||||
Conn net.Conn
|
||||
|
||||
encryptKey [32]byte
|
||||
decryptKey [32]byte
|
||||
encryptCount uint64
|
||||
decryptCount uint64
|
||||
|
||||
mx sync.Mutex
|
||||
}
|
||||
|
||||
func NewSecure(sharedKey [32]byte, isServer bool) (*Secure, error) {
|
||||
salt := []byte("Control-Salt")
|
||||
|
||||
key1, err := hkdf.Sha512(
|
||||
sharedKey[:], salt, []byte("Control-Read-Encryption-Key"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key2, err := hkdf.Sha512(
|
||||
sharedKey[:], salt, []byte("Control-Write-Encryption-Key"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if isServer {
|
||||
return &Secure{encryptKey: key1, decryptKey: key2}, nil
|
||||
} else {
|
||||
return &Secure{encryptKey: key2, decryptKey: key1}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Secure) Read(b []byte) (n int, err error) {
|
||||
for {
|
||||
var length uint16
|
||||
if err = binary.Read(s.Conn, binary.LittleEndian, &length); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var enc = make([]byte, length)
|
||||
if err = binary.Read(s.Conn, binary.LittleEndian, &enc); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var mac [16]byte
|
||||
if err = binary.Read(s.Conn, binary.LittleEndian, &mac); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var nonce [8]byte
|
||||
binary.LittleEndian.PutUint64(nonce[:], s.decryptCount)
|
||||
s.decryptCount++
|
||||
|
||||
bLength := make([]byte, 2)
|
||||
binary.LittleEndian.PutUint16(bLength, length)
|
||||
|
||||
var msg []byte
|
||||
if msg, err = chacha20poly1305.DecryptAndVerify(
|
||||
s.decryptKey[:], nonce[:], enc, mac, bLength,
|
||||
); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
n += copy(b[n:], msg)
|
||||
|
||||
// Finish when all bytes fit in b
|
||||
if length < packetLengthMax {
|
||||
//fmt.Printf(">>>%s>>>\n", b[:n])
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Secure) Write(b []byte) (n int, err error) {
|
||||
s.mx.Lock()
|
||||
defer s.mx.Unlock()
|
||||
|
||||
var packetLen = len(b)
|
||||
for {
|
||||
if packetLen > packetLengthMax {
|
||||
packetLen = packetLengthMax
|
||||
}
|
||||
|
||||
//fmt.Printf("<<<%s<<<\n", b[:packetLen])
|
||||
|
||||
var nonce [8]byte
|
||||
binary.LittleEndian.PutUint64(nonce[:], s.encryptCount)
|
||||
s.encryptCount++
|
||||
|
||||
bLength := make([]byte, 2)
|
||||
binary.LittleEndian.PutUint16(bLength, uint16(packetLen))
|
||||
|
||||
var enc []byte
|
||||
var mac [16]byte
|
||||
enc, mac, err = chacha20poly1305.EncryptAndSeal(
|
||||
s.encryptKey[:], nonce[:], b[:packetLen], bLength[:],
|
||||
)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
enc = append(bLength, enc...)
|
||||
enc = append(enc, mac[:]...)
|
||||
if _, err = s.Conn.Write(enc); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
n += packetLen
|
||||
|
||||
if packetLen == packetLengthMax {
|
||||
b = b[packetLengthMax:]
|
||||
packetLen = len(b)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const (
|
||||
// packetLengthMax is the max length of encrypted packets
|
||||
packetLengthMax = 0x400
|
||||
)
|
155
pkg/homekit/server.go
Normal file
155
pkg/homekit/server.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package homekit
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/ed25519"
|
||||
"github.com/brutella/hap"
|
||||
"github.com/brutella/hap/tlv8"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
// Pin can't be null because server proof will be wrong
|
||||
Pin string `json:"-"`
|
||||
|
||||
ServerID string `json:"server_id"`
|
||||
// 32 bytes private key + 32 bytes public key
|
||||
ServerPrivate []byte `json:"server_private"`
|
||||
|
||||
// Pairings can be nil for disable pair verify check
|
||||
// ClientID: 32 bytes client public + 1 byte (isAdmin)
|
||||
Pairings map[string][]byte `json:"pairings"`
|
||||
|
||||
DefaultPlainHandler func(w io.Writer, r *http.Request) error
|
||||
DefaultSecureHandler func(w io.Writer, r *http.Request) error
|
||||
|
||||
OnPairChange func(clientID string, clientPublic []byte) `json:"-"`
|
||||
OnRequest func(w io.Writer, r *http.Request) `json:"-"`
|
||||
}
|
||||
|
||||
func GenerateKey() []byte {
|
||||
_, key, _ := ed25519.GenerateKey(nil)
|
||||
return key
|
||||
}
|
||||
|
||||
func NewServer(name string) *Server {
|
||||
return &Server{
|
||||
ServerID: GenerateID(name),
|
||||
ServerPrivate: GenerateKey(),
|
||||
Pairings: map[string][]byte{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Serve(address string) (err error) {
|
||||
var ln net.Listener
|
||||
if ln, err = net.Listen("tcp", address); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
var conn net.Conn
|
||||
if conn, err = ln.Accept(); err != nil {
|
||||
continue
|
||||
}
|
||||
go func() {
|
||||
//fmt.Printf("[%s] new connection\n", conn.RemoteAddr().String())
|
||||
s.Accept(conn)
|
||||
//fmt.Printf("[%s] close connection\n", conn.RemoteAddr().String())
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Accept(conn net.Conn) (err error) {
|
||||
defer conn.Close()
|
||||
|
||||
var req *http.Request
|
||||
r := bufio.NewReader(conn)
|
||||
if req, err = http.ReadRequest(r); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return s.HandleRequest(conn, req)
|
||||
}
|
||||
|
||||
func (s *Server) HandleRequest(conn net.Conn, req *http.Request) (err error) {
|
||||
if s.OnRequest != nil {
|
||||
s.OnRequest(conn, req)
|
||||
}
|
||||
|
||||
switch req.URL.Path {
|
||||
case UriPairSetup:
|
||||
if _, err = s.PairSetupHandler(conn, req); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
case UriPairVerify:
|
||||
var secure *Secure
|
||||
if secure, err = s.PairVerifyHandler(conn, req); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = s.HandleSecure(secure)
|
||||
|
||||
default:
|
||||
if s.DefaultPlainHandler != nil {
|
||||
err = s.DefaultPlainHandler(conn, req)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) HandleSecure(secure *Secure) (err error) {
|
||||
r := bufio.NewReader(secure)
|
||||
for {
|
||||
var req *http.Request
|
||||
if req, err = http.ReadRequest(r); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if s.OnRequest != nil {
|
||||
s.OnRequest(secure, req)
|
||||
}
|
||||
|
||||
switch req.URL.Path {
|
||||
case UriPairings:
|
||||
s.HandlePairings(secure, req)
|
||||
default:
|
||||
if err = s.DefaultSecureHandler(secure, req); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) HandlePairings(w io.Writer, r *http.Request) {
|
||||
req := struct {
|
||||
Method byte `tlv8:"0"`
|
||||
Identifier string `tlv8:"1"`
|
||||
PublicKey []byte `tlv8:"3"`
|
||||
Permission byte `tlv8:"11"`
|
||||
State byte `tlv8:"6"`
|
||||
}{}
|
||||
|
||||
if err := tlv8.UnmarshalReader(r.Body, &req); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
switch req.Method {
|
||||
case hap.MethodAddPairing, hap.MethodDeletePairing:
|
||||
res := struct {
|
||||
State byte `tlv8:"6"`
|
||||
}{
|
||||
State: hap.M2,
|
||||
}
|
||||
data, err := tlv8.Marshal(res)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err = WriteResponse(w, http.StatusOK, MimeJSON, data); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
}
|
3
pkg/httpflv/README.md
Normal file
3
pkg/httpflv/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
## Useful links
|
||||
|
||||
- https://medium.com/@nate510/don-t-use-go-s-default-http-client-4804cb19f779
|
100
pkg/httpflv/httpflv.go
Normal file
100
pkg/httpflv/httpflv.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package httpflv
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"github.com/deepch/vdk/av"
|
||||
"github.com/deepch/vdk/codec/h264parser"
|
||||
"github.com/deepch/vdk/format/flv/flvio"
|
||||
"github.com/deepch/vdk/utils/bits/pio"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func Dial(uri string) (*Conn, error) {
|
||||
req, err := http.NewRequest("GET", uri, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c := Conn{
|
||||
conn: res.Body,
|
||||
reader: bufio.NewReaderSize(res.Body, pio.RecommendBufioSize),
|
||||
buf: make([]byte, 256),
|
||||
}
|
||||
|
||||
if _, err = io.ReadFull(c.reader, c.buf[:flvio.FileHeaderLength]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
flags, n, err := flvio.ParseFileHeader(c.buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if flags&flvio.FILE_HAS_VIDEO == 0 {
|
||||
return nil, errors.New("not supported")
|
||||
}
|
||||
|
||||
if _, err = c.reader.Discard(n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
type Conn struct {
|
||||
conn io.ReadCloser
|
||||
reader *bufio.Reader
|
||||
buf []byte
|
||||
}
|
||||
|
||||
func (c *Conn) Streams() ([]av.CodecData, error) {
|
||||
for {
|
||||
tag, _, err := flvio.ReadTag(c.reader, c.buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if tag.Type != flvio.TAG_VIDEO || tag.AVCPacketType != flvio.AAC_SEQHDR {
|
||||
continue
|
||||
}
|
||||
|
||||
stream, err := h264parser.NewCodecDataFromAVCDecoderConfRecord(tag.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []av.CodecData{stream}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Conn) ReadPacket() (av.Packet, error) {
|
||||
for {
|
||||
tag, ts, err := flvio.ReadTag(c.reader, c.buf)
|
||||
if err != nil {
|
||||
return av.Packet{}, err
|
||||
}
|
||||
|
||||
if tag.Type != flvio.TAG_VIDEO || tag.AVCPacketType != flvio.AVC_NALU {
|
||||
continue
|
||||
}
|
||||
|
||||
return av.Packet{
|
||||
Idx: 0,
|
||||
Data: tag.Data,
|
||||
CompositionTime: flvio.TsToTime(tag.CompositionTime),
|
||||
IsKeyFrame: tag.FrameType == flvio.FRAME_KEY,
|
||||
Time: flvio.TsToTime(ts),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Conn) Close() (err error) {
|
||||
return c.conn.Close()
|
||||
}
|
284
pkg/ivideon/client.go
Normal file
284
pkg/ivideon/client.go
Normal file
@@ -0,0 +1,284 @@
|
||||
package ivideon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/deepch/vdk/codec/h264parser"
|
||||
"github.com/deepch/vdk/format/fmp4/fmp4io"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/pion/rtp"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
streamer.Element
|
||||
|
||||
ID string
|
||||
|
||||
conn *websocket.Conn
|
||||
medias []*streamer.Media
|
||||
tracks map[byte]*streamer.Track
|
||||
|
||||
closed bool
|
||||
|
||||
msg *message
|
||||
t0 time.Time
|
||||
|
||||
buffer chan []byte
|
||||
}
|
||||
|
||||
func NewClient(id string) *Client {
|
||||
return &Client{ID: id}
|
||||
}
|
||||
|
||||
func (c *Client) Dial() (err error) {
|
||||
resp, err := http.Get(
|
||||
"https://openapi-alpha.ivideon.com/cameras/" + c.ID +
|
||||
"/live_stream?op=GET&access_token=public&q=2&" +
|
||||
"video_codecs=h264&format=ws-fmp4",
|
||||
)
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var v liveResponse
|
||||
if err = json.Unmarshal(data, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !v.Success {
|
||||
return fmt.Errorf("wrong response: %s", data)
|
||||
}
|
||||
|
||||
c.conn, _, err = websocket.DefaultDialer.Dial(v.Result.URL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = c.getTracks(); err != nil {
|
||||
_ = c.conn.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Handle() error {
|
||||
c.buffer = make(chan []byte, 5)
|
||||
// add delay to the stream for smooth playing (not a best solution)
|
||||
c.t0 = time.Now().Add(time.Second)
|
||||
|
||||
// processing stream in separate thread for lower delay between packets
|
||||
go c.worker()
|
||||
|
||||
_, data, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
track := c.tracks[c.msg.Track]
|
||||
if track != nil {
|
||||
c.buffer <- data
|
||||
}
|
||||
|
||||
// we have one unprocessed msg after getTracks
|
||||
for {
|
||||
_, data, err = c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var msg message
|
||||
if err = json.Unmarshal(data, &msg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case "stream-init":
|
||||
continue
|
||||
|
||||
case "fragment":
|
||||
_, data, err = c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
track = c.tracks[msg.Track]
|
||||
if track != nil {
|
||||
c.buffer <- data
|
||||
}
|
||||
|
||||
default:
|
||||
return fmt.Errorf("wrong message type: %s", data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
if c.conn == nil {
|
||||
return nil
|
||||
}
|
||||
close(c.buffer)
|
||||
c.closed = true
|
||||
return c.conn.Close()
|
||||
}
|
||||
|
||||
func (c *Client) getTracks() error {
|
||||
c.tracks = map[byte]*streamer.Track{}
|
||||
|
||||
for {
|
||||
_, data, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var msg message
|
||||
if err = json.Unmarshal(data, &msg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case "stream-init":
|
||||
s := msg.CodecString
|
||||
i := strings.IndexByte(s, '.')
|
||||
if i > 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
|
||||
switch s {
|
||||
case "avc1": // avc1.4d0029
|
||||
// skip multiple identical init
|
||||
if c.tracks[msg.TrackID] != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
codec := streamer.NewCodec(streamer.CodecH264)
|
||||
codec.FmtpLine = "profile-level-id=" + msg.CodecString[i+1:]
|
||||
codec.PayloadType = h264.PayloadTypeAVC
|
||||
|
||||
i = bytes.Index(msg.Data, []byte("avcC")) - 4
|
||||
if i < 0 {
|
||||
return fmt.Errorf("wrong AVC: %s", msg.Data)
|
||||
}
|
||||
|
||||
avccLen := binary.BigEndian.Uint32(msg.Data[i:])
|
||||
data = msg.Data[i+8 : i+int(avccLen)]
|
||||
|
||||
record := h264parser.AVCDecoderConfRecord{}
|
||||
if _, err = record.Unmarshal(data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
codec.FmtpLine += ";sprop-parameter-sets=" +
|
||||
base64.StdEncoding.EncodeToString(record.SPS[0]) + "," +
|
||||
base64.StdEncoding.EncodeToString(record.PPS[0])
|
||||
|
||||
media := &streamer.Media{
|
||||
Kind: streamer.KindVideo,
|
||||
Direction: streamer.DirectionSendonly,
|
||||
Codecs: []*streamer.Codec{codec},
|
||||
}
|
||||
c.medias = append(c.medias, media)
|
||||
|
||||
track := &streamer.Track{
|
||||
Direction: streamer.DirectionSendonly,
|
||||
Codec: codec,
|
||||
}
|
||||
c.tracks[msg.TrackID] = track
|
||||
|
||||
case "mp4a": // mp4a.40.2
|
||||
}
|
||||
|
||||
case "fragment":
|
||||
c.msg = &msg
|
||||
return nil
|
||||
|
||||
default:
|
||||
return fmt.Errorf("wrong message type: %s", data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) worker() {
|
||||
var track *streamer.Track
|
||||
for _, track = range c.tracks {
|
||||
break
|
||||
}
|
||||
|
||||
for data := range c.buffer {
|
||||
moof := &fmp4io.MovieFrag{}
|
||||
if _, err := moof.Unmarshal(data, 0); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
moofLen := binary.BigEndian.Uint32(data)
|
||||
_ = moofLen
|
||||
|
||||
mdat := moof.Unknowns[0]
|
||||
if mdat.Tag() != fmp4io.MDAT {
|
||||
continue
|
||||
}
|
||||
i, _ := mdat.Pos() // offset, size
|
||||
data = data[i+8:]
|
||||
|
||||
traf := moof.Tracks[0]
|
||||
ts := uint32(traf.DecodeTime.Time)
|
||||
|
||||
//println("!!!", (time.Duration(ts) * time.Millisecond).String(), time.Since(c.t0).String())
|
||||
|
||||
for _, entry := range traf.Run.Entries {
|
||||
// synchronize framerate for WebRTC and MSE
|
||||
d := time.Duration(ts)*time.Millisecond - time.Since(c.t0)
|
||||
if d < 0 {
|
||||
d = time.Duration(entry.Duration) * time.Millisecond / 2
|
||||
}
|
||||
time.Sleep(d)
|
||||
|
||||
// can be SPS, PPS and IFrame in one packet
|
||||
packet := &rtp.Packet{
|
||||
// ivideon clockrate=1000, RTP clockrate=90000
|
||||
Header: rtp.Header{Timestamp: ts * 90},
|
||||
Payload: data[:entry.Size],
|
||||
}
|
||||
_ = track.WriteRTP(packet)
|
||||
|
||||
data = data[entry.Size:]
|
||||
ts += entry.Duration
|
||||
}
|
||||
|
||||
if len(data) != 0 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type liveResponse struct {
|
||||
Result struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"result"`
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
31
pkg/ivideon/streamer.go
Normal file
31
pkg/ivideon/streamer.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package ivideon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
)
|
||||
|
||||
func (c *Client) GetMedias() []*streamer.Media {
|
||||
return c.medias
|
||||
}
|
||||
|
||||
func (c *Client) GetTrack(media *streamer.Media, codec *streamer.Codec) *streamer.Track {
|
||||
for _, track := range c.tracks {
|
||||
if track.Codec == codec {
|
||||
return track
|
||||
}
|
||||
}
|
||||
panic(fmt.Sprintf("wrong media/codec: %+v %+v", media, codec))
|
||||
}
|
||||
|
||||
func (c *Client) Start() error {
|
||||
err := c.Handle()
|
||||
if c.closed {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) Stop() error {
|
||||
return c.Close()
|
||||
}
|
@@ -1,72 +0,0 @@
|
||||
package keyframe
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mp4"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/pion/rtp"
|
||||
)
|
||||
|
||||
var annexB = []byte{0, 0, 0, 1}
|
||||
|
||||
type Consumer struct {
|
||||
streamer.Element
|
||||
IsMP4 bool
|
||||
}
|
||||
|
||||
func (k *Consumer) GetMedias() []*streamer.Media {
|
||||
// support keyframe extraction only for one coded...
|
||||
codec := streamer.NewCodec(streamer.CodecH264)
|
||||
return []*streamer.Media{
|
||||
{
|
||||
Kind: streamer.KindVideo, Direction: streamer.DirectionRecvonly,
|
||||
Codecs: []*streamer.Codec{codec},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (k *Consumer) AddTrack(media *streamer.Media, track *streamer.Track) *streamer.Track {
|
||||
// sps and pps without AVC headers
|
||||
sps, pps := h264.GetParameterSet(track.Codec.FmtpLine)
|
||||
|
||||
push := func(packet *rtp.Packet) error {
|
||||
// TODO: remove it, unnecessary
|
||||
if packet.Version != h264.RTPPacketVersionAVC {
|
||||
panic("wrong packet type")
|
||||
}
|
||||
|
||||
switch h264.NALUType(packet.Payload) {
|
||||
case h264.NALUTypeSPS:
|
||||
sps = packet.Payload[4:] // remove AVC header
|
||||
case h264.NALUTypePPS:
|
||||
pps = packet.Payload[4:] // remove AVC header
|
||||
case h264.NALUTypeIFrame:
|
||||
if sps == nil || pps == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var data []byte
|
||||
|
||||
if k.IsMP4 {
|
||||
data = mp4.MarshalMP4(sps, pps, packet.Payload)
|
||||
} else {
|
||||
data = append(data, annexB...)
|
||||
data = append(data, sps...)
|
||||
data = append(data, annexB...)
|
||||
data = append(data, pps...)
|
||||
data = append(data, annexB...)
|
||||
data = append(data, packet.Payload[4:]...)
|
||||
}
|
||||
|
||||
k.Fire(data)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if !h264.IsAVC(track.Codec) {
|
||||
wrapper := h264.RTPDepay(track)
|
||||
push = wrapper(push)
|
||||
}
|
||||
|
||||
return track.Bind(push)
|
||||
}
|
4
pkg/mjpeg/README.md
Normal file
4
pkg/mjpeg/README.md
Normal file
@@ -0,0 +1,4 @@
|
||||
## Useful links
|
||||
|
||||
- https://www.rfc-editor.org/rfc/rfc2435
|
||||
- https://github.com/GStreamer/gst-plugins-good/blob/master/gst/rtp/gstrtpjpegdepay.c
|
95
pkg/mjpeg/consumer.go
Normal file
95
pkg/mjpeg/consumer.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package mjpeg
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/pion/rtp"
|
||||
)
|
||||
|
||||
type Consumer struct {
|
||||
streamer.Element
|
||||
|
||||
UserAgent string
|
||||
RemoteAddr string
|
||||
|
||||
codecs []*streamer.Codec
|
||||
start bool
|
||||
|
||||
send int
|
||||
}
|
||||
|
||||
func (c *Consumer) GetMedias() []*streamer.Media {
|
||||
return []*streamer.Media{{
|
||||
Kind: streamer.KindVideo,
|
||||
Direction: streamer.DirectionRecvonly,
|
||||
Codecs: []*streamer.Codec{{Name: streamer.CodecJPEG}},
|
||||
}}
|
||||
}
|
||||
|
||||
func (c *Consumer) AddTrack(media *streamer.Media, track *streamer.Track) *streamer.Track {
|
||||
var header, payload []byte
|
||||
|
||||
push := func(packet *rtp.Packet) error {
|
||||
//fmt.Printf(
|
||||
// "[RTP] codec: %s, size: %6d, ts: %10d, pt: %2d, ssrc: %d, seq: %d, mark: %v\n",
|
||||
// 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 header == nil {
|
||||
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 2560x1920 and 2560x1440
|
||||
if w == 512 && (h == 1920 || h == 1440) {
|
||||
w = 2560
|
||||
}
|
||||
|
||||
//fmt.Printf("t: %d, q: %d, w: %d, h: %d\n", t, q, w, h)
|
||||
header = MakeHeaders(t, w, h, lqt, cqt)
|
||||
}
|
||||
|
||||
// 3.1.9. JPEG Payload
|
||||
payload = append(payload, b...)
|
||||
|
||||
if packet.Marker {
|
||||
b = append(header, payload...)
|
||||
if end := b[len(b)-2:]; end[0] != 0xFF && end[1] != 0xD9 {
|
||||
b = append(b, 0xFF, 0xD9)
|
||||
}
|
||||
c.Fire(b)
|
||||
|
||||
header = nil
|
||||
payload = nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
return track.Bind(push)
|
||||
}
|
182
pkg/mjpeg/rfc2435.go
Normal file
182
pkg/mjpeg/rfc2435.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package mjpeg
|
||||
|
||||
// RFC 2435. Appendix A
|
||||
|
||||
var jpeg_luma_quantizer = []byte{
|
||||
16, 11, 10, 16, 24, 40, 51, 61,
|
||||
12, 12, 14, 19, 26, 58, 60, 55,
|
||||
14, 13, 16, 24, 40, 57, 69, 56,
|
||||
14, 17, 22, 29, 51, 87, 80, 62,
|
||||
18, 22, 37, 56, 68, 109, 103, 77,
|
||||
24, 35, 55, 64, 81, 104, 113, 92,
|
||||
49, 64, 78, 87, 103, 121, 120, 101,
|
||||
72, 92, 95, 98, 112, 100, 103, 99,
|
||||
}
|
||||
var jpeg_chroma_quantizer = []byte{
|
||||
17, 18, 24, 47, 99, 99, 99, 99,
|
||||
18, 21, 26, 66, 99, 99, 99, 99,
|
||||
24, 26, 56, 99, 99, 99, 99, 99,
|
||||
47, 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,
|
||||
}
|
||||
|
||||
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 if q > 99 {
|
||||
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(t byte, w, h uint16, lqt, cqt []byte) []byte {
|
||||
// Appendix A from https://www.rfc-editor.org/rfc/rfc2435
|
||||
p := []byte{0xFF, 0xD8}
|
||||
|
||||
p = MakeQuantHeader(p, lqt, 0)
|
||||
p = MakeQuantHeader(p, cqt, 1)
|
||||
|
||||
if t == 0 {
|
||||
t = 0x21
|
||||
} else {
|
||||
t = 0x22
|
||||
}
|
||||
|
||||
p = append(p,
|
||||
0xFF, 0xC0, 0, 17, 8,
|
||||
byte(h>>8), byte(h&0xFF),
|
||||
byte(w>>8), byte(w&0xFF),
|
||||
3, 0, t, 0, 1, 0x11, 1, 2, 0x11, 1,
|
||||
)
|
||||
|
||||
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 append(p, 0xFF, 0xDA, 0, 12, 3, 0, 0, 1, 0x11, 2, 0x11, 0, 63, 0)
|
||||
}
|
||||
|
||||
func MakeQuantHeader(p []byte, qt []byte, tableNo byte) []byte {
|
||||
p = append(p, 0xFF, 0xDB, 0, 67, tableNo)
|
||||
return append(p, qt...)
|
||||
}
|
||||
|
||||
func MakeHuffmanHeader(p, codelens, symbols []byte, tableNo, tableClass byte) []byte {
|
||||
p = append(p,
|
||||
0xFF, 0xC4, 0,
|
||||
byte(3+len(codelens)+len(symbols)),
|
||||
(tableClass<<4)|tableNo,
|
||||
)
|
||||
p = append(p, codelens...)
|
||||
return append(p, symbols...)
|
||||
}
|
23
pkg/mp4/README.md
Normal file
23
pkg/mp4/README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
## HEVC
|
||||
|
||||
Browser | avc1 | hvc1 | hev1
|
||||
------------|------|------|---
|
||||
Mac Chrome | + | - | +
|
||||
Mac Safari | + | + | -
|
||||
iOS 15? | + | + | -
|
||||
Mac Firefox | + | - | -
|
||||
iOS 12 | + | - | -
|
||||
Android 13 | + | - | -
|
||||
|
||||
```
|
||||
ffmpeg -i input-hev1.mp4 -c:v copy -tag:v hvc1 -c:a copy output-hvc1.mp4
|
||||
Stream #0:0(eng): Video: hevc (Main) (hev1 / 0x31766568), yuv420p(tv, progressive), 720x404, 164 kb/s, 29.97 fps,
|
||||
Stream #0:0(eng): Video: hevc (Main) (hvc1 / 0x31637668), yuv420p(tv, progressive), 720x404, 164 kb/s, 29.97 fps,
|
||||
```
|
||||
|
||||
## 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
|
94
pkg/mp4/const.go
Normal file
94
pkg/mp4/const.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package mp4
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"github.com/deepch/vdk/format/mp4/mp4io"
|
||||
"time"
|
||||
)
|
||||
|
||||
var matrix = [9]int32{0x10000, 0, 0, 0, 0x10000, 0, 0, 0, 0x40000000}
|
||||
var time0 = time.Date(1904, time.January, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
func FTYP() []byte {
|
||||
b := make([]byte, 0x18)
|
||||
binary.BigEndian.PutUint32(b, 0x18)
|
||||
copy(b[0x04:], "ftyp")
|
||||
copy(b[0x08:], "iso5")
|
||||
copy(b[0x10:], "iso5")
|
||||
copy(b[0x14:], "avc1")
|
||||
return b
|
||||
}
|
||||
|
||||
func MOOV() *mp4io.Movie {
|
||||
return &mp4io.Movie{
|
||||
Header: &mp4io.MovieHeader{
|
||||
PreferredRate: 1,
|
||||
PreferredVolume: 1,
|
||||
Matrix: matrix,
|
||||
NextTrackId: -1,
|
||||
Duration: 0,
|
||||
TimeScale: 1000,
|
||||
CreateTime: time0,
|
||||
ModifyTime: time0,
|
||||
PreviewTime: time0,
|
||||
PreviewDuration: time0,
|
||||
PosterTime: time0,
|
||||
SelectionTime: time0,
|
||||
SelectionDuration: time0,
|
||||
CurrentTime: time0,
|
||||
},
|
||||
MovieExtend: &mp4io.MovieExtend{
|
||||
Tracks: []*mp4io.TrackExtend{
|
||||
{
|
||||
TrackId: 1,
|
||||
DefaultSampleDescIdx: 1,
|
||||
DefaultSampleDuration: 40,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TRAK() *mp4io.Track {
|
||||
return &mp4io.Track{
|
||||
// trak > tkhd
|
||||
Header: &mp4io.TrackHeader{
|
||||
TrackId: int32(1), // change me
|
||||
Flags: 0x0007, // 7 ENABLED IN-MOVIE IN-PREVIEW
|
||||
Duration: 0, // OK
|
||||
Matrix: matrix,
|
||||
CreateTime: time0,
|
||||
ModifyTime: time0,
|
||||
},
|
||||
// trak > mdia
|
||||
Media: &mp4io.Media{
|
||||
// trak > mdia > mdhd
|
||||
Header: &mp4io.MediaHeader{
|
||||
TimeScale: 1000,
|
||||
Duration: 0,
|
||||
Language: 0x55C4,
|
||||
CreateTime: time0,
|
||||
ModifyTime: time0,
|
||||
},
|
||||
// trak > mdia > minf
|
||||
Info: &mp4io.MediaInfo{
|
||||
// trak > mdia > minf > dinf
|
||||
Data: &mp4io.DataInfo{
|
||||
Refer: &mp4io.DataRefer{
|
||||
Url: &mp4io.DataReferUrl{
|
||||
Flags: 0x000001, // self reference
|
||||
},
|
||||
},
|
||||
},
|
||||
// trak > mdia > minf > stbl
|
||||
Sample: &mp4io.SampleTable{
|
||||
SampleDesc: &mp4io.SampleDesc{},
|
||||
TimeToSample: &mp4io.TimeToSample{},
|
||||
SampleToChunk: &mp4io.SampleToChunk{},
|
||||
SampleSize: &mp4io.SampleSize{},
|
||||
ChunkOffset: &mp4io.ChunkOffset{},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
143
pkg/mp4/consumer.go
Normal file
143
pkg/mp4/consumer.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package mp4
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h265"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/pion/rtp"
|
||||
)
|
||||
|
||||
type Consumer struct {
|
||||
streamer.Element
|
||||
|
||||
UserAgent string
|
||||
RemoteAddr string
|
||||
|
||||
muxer *Muxer
|
||||
codecs []*streamer.Codec
|
||||
start bool
|
||||
|
||||
send int
|
||||
}
|
||||
|
||||
func (c *Consumer) GetMedias() []*streamer.Media {
|
||||
return []*streamer.Media{
|
||||
{
|
||||
Kind: streamer.KindVideo,
|
||||
Direction: streamer.DirectionRecvonly,
|
||||
Codecs: []*streamer.Codec{
|
||||
{Name: streamer.CodecH264, ClockRate: 90000},
|
||||
{Name: streamer.CodecH265, ClockRate: 90000},
|
||||
},
|
||||
},
|
||||
//{
|
||||
// Kind: streamer.KindAudio,
|
||||
// Direction: streamer.DirectionRecvonly,
|
||||
// Codecs: []*streamer.Codec{
|
||||
// {Name: streamer.CodecAAC, ClockRate: 16000},
|
||||
// },
|
||||
//},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Consumer) AddTrack(media *streamer.Media, track *streamer.Track) *streamer.Track {
|
||||
codec := track.Codec
|
||||
switch codec.Name {
|
||||
case streamer.CodecH264:
|
||||
c.codecs = append(c.codecs, track.Codec)
|
||||
|
||||
push := func(packet *rtp.Packet) error {
|
||||
if packet.Version != h264.RTPPacketVersionAVC {
|
||||
return nil
|
||||
}
|
||||
|
||||
if c.muxer == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !c.start {
|
||||
if h264.IsKeyframe(packet.Payload) {
|
||||
c.start = true
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
buf := c.muxer.Marshal(packet)
|
||||
c.send += len(buf)
|
||||
c.Fire(buf)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var wrapper streamer.WrapperFunc
|
||||
if h264.IsAVC(codec) {
|
||||
wrapper = h264.RepairAVC(track)
|
||||
} else {
|
||||
wrapper = h264.RTPDepay(track)
|
||||
}
|
||||
push = wrapper(push)
|
||||
|
||||
return track.Bind(push)
|
||||
|
||||
case streamer.CodecH265:
|
||||
c.codecs = append(c.codecs, track.Codec)
|
||||
|
||||
push := func(packet *rtp.Packet) error {
|
||||
if packet.Version != h264.RTPPacketVersionAVC {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !c.start {
|
||||
if h265.IsKeyframe(packet.Payload) {
|
||||
c.start = true
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
buf := c.muxer.Marshal(packet)
|
||||
c.send += len(buf)
|
||||
c.Fire(buf)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if !h264.IsAVC(codec) {
|
||||
wrapper := h265.RTPDepay(track)
|
||||
push = wrapper(push)
|
||||
}
|
||||
|
||||
return track.Bind(push)
|
||||
}
|
||||
|
||||
fmt.Printf("[rtmp] unsupported codec: %+v\n", track.Codec)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Consumer) MimeType() string {
|
||||
return c.muxer.MimeType(c.codecs)
|
||||
}
|
||||
|
||||
func (c *Consumer) Init() ([]byte, error) {
|
||||
if c.muxer == nil {
|
||||
c.muxer = &Muxer{}
|
||||
}
|
||||
return c.muxer.GetInit(c.codecs)
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
func (c *Consumer) MarshalJSON() ([]byte, error) {
|
||||
v := map[string]interface{}{
|
||||
"type": "MP4 server consumer",
|
||||
"send": c.send,
|
||||
"remote_addr": c.RemoteAddr,
|
||||
"user_agent": c.UserAgent,
|
||||
}
|
||||
|
||||
return json.Marshal(v)
|
||||
}
|
@@ -1,47 +0,0 @@
|
||||
package mp4
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
type MemoryWriter struct {
|
||||
buf []byte
|
||||
pos int
|
||||
}
|
||||
|
||||
func (m *MemoryWriter) Write(p []byte) (n int, err error) {
|
||||
minCap := m.pos + len(p)
|
||||
if minCap > cap(m.buf) { // Make sure buf has enough capacity:
|
||||
buf2 := make([]byte, len(m.buf), minCap+len(p)) // add some extra
|
||||
copy(buf2, m.buf)
|
||||
m.buf = buf2
|
||||
}
|
||||
if minCap > len(m.buf) {
|
||||
m.buf = m.buf[:minCap]
|
||||
}
|
||||
copy(m.buf[m.pos:], p)
|
||||
m.pos += len(p)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (m *MemoryWriter) Seek(offset int64, whence int) (int64, error) {
|
||||
newPos, offs := 0, int(offset)
|
||||
switch whence {
|
||||
case io.SeekStart:
|
||||
newPos = offs
|
||||
case io.SeekCurrent:
|
||||
newPos = m.pos + offs
|
||||
case io.SeekEnd:
|
||||
newPos = len(m.buf) + offs
|
||||
}
|
||||
if newPos < 0 {
|
||||
return 0, errors.New("negative result pos")
|
||||
}
|
||||
m.pos = newPos
|
||||
return int64(newPos), nil
|
||||
}
|
||||
|
||||
func (m *MemoryWriter) Bytes() []byte {
|
||||
return m.buf
|
||||
}
|
233
pkg/mp4/muxer.go
233
pkg/mp4/muxer.go
@@ -1,37 +1,210 @@
|
||||
package mp4
|
||||
|
||||
import (
|
||||
"github.com/deepch/vdk/av"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h265"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/deepch/vdk/codec/h264parser"
|
||||
"github.com/deepch/vdk/format/mp4"
|
||||
"time"
|
||||
"github.com/deepch/vdk/codec/h265parser"
|
||||
"github.com/deepch/vdk/format/fmp4/fmp4io"
|
||||
"github.com/deepch/vdk/format/mp4/mp4io"
|
||||
"github.com/deepch/vdk/format/mp4f/mp4fio"
|
||||
"github.com/pion/rtp"
|
||||
)
|
||||
|
||||
func MarshalMP4(sps, pps, frame []byte) []byte {
|
||||
writer := &MemoryWriter{}
|
||||
muxer := mp4.NewMuxer(writer)
|
||||
|
||||
stream, err := h264parser.NewCodecDataFromSPSAndPPS(sps, pps)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err = muxer.WriteHeader([]av.CodecData{stream}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
pkt := av.Packet{
|
||||
CompositionTime: time.Millisecond,
|
||||
IsKeyFrame: true,
|
||||
Duration: time.Second,
|
||||
Data: frame,
|
||||
}
|
||||
if err = muxer.WritePacket(pkt); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err = muxer.WriteTrailer(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return writer.buf
|
||||
type Muxer struct {
|
||||
fragIndex uint32
|
||||
dts uint64
|
||||
pts uint32
|
||||
data []byte
|
||||
total int
|
||||
}
|
||||
|
||||
func (m *Muxer) MimeType(codecs []*streamer.Codec) string {
|
||||
s := `video/mp4; codecs="`
|
||||
|
||||
for _, codec := range codecs {
|
||||
switch codec.Name {
|
||||
case streamer.CodecH264:
|
||||
s += "avc1." + h264.GetProfileLevelID(codec.FmtpLine)
|
||||
case streamer.CodecH265:
|
||||
// +Safari +Chrome +Edge -iOS15 -Android13
|
||||
s += "hvc1.1.6.L93.B0" // hev1.1.6.L93.B0
|
||||
}
|
||||
}
|
||||
|
||||
return s + `"`
|
||||
}
|
||||
|
||||
func (m *Muxer) GetInit(codecs []*streamer.Codec) ([]byte, error) {
|
||||
moov := MOOV()
|
||||
|
||||
for _, codec := range codecs {
|
||||
switch codec.Name {
|
||||
case streamer.CodecH264:
|
||||
sps, pps := h264.GetParameterSet(codec.FmtpLine)
|
||||
if sps == nil {
|
||||
// some dummy SPS and PPS not a problem
|
||||
sps = []byte{0x67, 0x42, 0x00, 0x0a, 0xf8, 0x41, 0xa2}
|
||||
pps = []byte{0x68, 0xce, 0x38, 0x80}
|
||||
}
|
||||
|
||||
codecData, err := h264parser.NewCodecDataFromSPSAndPPS(sps, pps)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
width := codecData.Width()
|
||||
height := codecData.Height()
|
||||
|
||||
trak := TRAK()
|
||||
trak.Media.Header.TimeScale = int32(codec.ClockRate)
|
||||
trak.Header.TrackWidth = float64(width)
|
||||
trak.Header.TrackHeight = float64(height)
|
||||
|
||||
trak.Media.Info.Video = &mp4io.VideoMediaInfo{
|
||||
Flags: 0x000001,
|
||||
}
|
||||
trak.Media.Info.Sample.SampleDesc.AVC1Desc = &mp4io.AVC1Desc{
|
||||
DataRefIdx: 1,
|
||||
HorizontalResolution: 72,
|
||||
VorizontalResolution: 72,
|
||||
Width: int16(width),
|
||||
Height: int16(height),
|
||||
FrameCount: 1,
|
||||
Depth: 24,
|
||||
ColorTableId: -1,
|
||||
Conf: &mp4io.AVC1Conf{
|
||||
Data: codecData.AVCDecoderConfRecordBytes(),
|
||||
},
|
||||
}
|
||||
|
||||
trak.Media.Handler = &mp4io.HandlerRefer{
|
||||
SubType: [4]byte{'v', 'i', 'd', 'e'},
|
||||
Name: []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 'm', 'a', 'i', 'n', 0},
|
||||
}
|
||||
|
||||
moov.Tracks = append(moov.Tracks, trak)
|
||||
|
||||
case streamer.CodecH265:
|
||||
vps, sps, pps := h265.GetParameterSet(codec.FmtpLine)
|
||||
if sps == nil {
|
||||
return nil, fmt.Errorf("empty SPS: %#v", codec)
|
||||
}
|
||||
|
||||
codecData, err := h265parser.NewCodecDataFromVPSAndSPSAndPPS(vps, sps, pps)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
width := codecData.Width()
|
||||
height := codecData.Height()
|
||||
|
||||
trak := TRAK()
|
||||
trak.Media.Header.TimeScale = int32(codec.ClockRate)
|
||||
trak.Header.TrackWidth = float64(width)
|
||||
trak.Header.TrackHeight = float64(height)
|
||||
|
||||
trak.Media.Info.Video = &mp4io.VideoMediaInfo{
|
||||
Flags: 0x000001,
|
||||
}
|
||||
trak.Media.Info.Sample.SampleDesc.HV1Desc = &mp4io.HV1Desc{
|
||||
DataRefIdx: 1,
|
||||
HorizontalResolution: 72,
|
||||
VorizontalResolution: 72,
|
||||
Width: int16(width),
|
||||
Height: int16(height),
|
||||
FrameCount: 1,
|
||||
Depth: 24,
|
||||
ColorTableId: -1,
|
||||
Conf: &mp4io.HV1Conf{
|
||||
Data: codecData.AVCDecoderConfRecordBytes(),
|
||||
},
|
||||
}
|
||||
|
||||
trak.Media.Handler = &mp4io.HandlerRefer{
|
||||
SubType: [4]byte{'v', 'i', 'd', 'e'},
|
||||
Name: []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 'm', 'a', 'i', 'n', 0},
|
||||
}
|
||||
|
||||
moov.Tracks = append(moov.Tracks, trak)
|
||||
}
|
||||
}
|
||||
|
||||
data := make([]byte, moov.Len())
|
||||
moov.Marshal(data)
|
||||
|
||||
return append(FTYP(), data...), nil
|
||||
}
|
||||
|
||||
func (m *Muxer) Rewind() {
|
||||
m.dts = 0
|
||||
m.pts = 0
|
||||
}
|
||||
|
||||
func (m *Muxer) Marshal(packet *rtp.Packet) []byte {
|
||||
trackID := uint8(1)
|
||||
|
||||
run := &mp4fio.TrackFragRun{
|
||||
Flags: 0x000b05,
|
||||
FirstSampleFlags: uint32(fmp4io.SampleNoDependencies),
|
||||
DataOffset: 0,
|
||||
Entries: []mp4io.TrackFragRunEntry{},
|
||||
}
|
||||
|
||||
moof := &mp4fio.MovieFrag{
|
||||
Header: &mp4fio.MovieFragHeader{
|
||||
Seqnum: m.fragIndex + 1,
|
||||
},
|
||||
Tracks: []*mp4fio.TrackFrag{
|
||||
{
|
||||
Header: &mp4fio.TrackFragHeader{
|
||||
Data: []byte{0x00, 0x02, 0x00, 0x20, 0x00, 0x00, 0x00, trackID, 0x01, 0x01, 0x00, 0x00},
|
||||
},
|
||||
DecodeTime: &mp4fio.TrackFragDecodeTime{
|
||||
Version: 1,
|
||||
Flags: 0,
|
||||
Time: m.dts,
|
||||
},
|
||||
Run: run,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
entry := mp4io.TrackFragRunEntry{
|
||||
//Duration: 90000,
|
||||
Size: uint32(len(packet.Payload)),
|
||||
}
|
||||
|
||||
newTime := packet.Timestamp
|
||||
if m.pts > 0 {
|
||||
//m.dts += uint64(newTime - m.pts)
|
||||
entry.Duration = newTime - m.pts
|
||||
m.dts += uint64(entry.Duration)
|
||||
}
|
||||
m.pts = newTime
|
||||
|
||||
// important before moof.Len()
|
||||
run.Entries = append(run.Entries, entry)
|
||||
|
||||
moofLen := moof.Len()
|
||||
mdatLen := 8 + len(packet.Payload)
|
||||
|
||||
// important after moof.Len()
|
||||
run.DataOffset = uint32(moofLen + 8)
|
||||
|
||||
buf := make([]byte, moofLen+mdatLen)
|
||||
moof.Marshal(buf)
|
||||
|
||||
binary.BigEndian.PutUint32(buf[moofLen:], uint32(mdatLen))
|
||||
copy(buf[moofLen+4:], "mdat")
|
||||
copy(buf[moofLen+8:], packet.Payload)
|
||||
|
||||
m.fragIndex++
|
||||
|
||||
m.total += moofLen + mdatLen
|
||||
|
||||
return buf
|
||||
}
|
||||
|
@@ -1,131 +0,0 @@
|
||||
package mse
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/deepch/vdk/av"
|
||||
"github.com/deepch/vdk/codec/h264parser"
|
||||
"github.com/deepch/vdk/format/mp4f"
|
||||
"github.com/pion/rtp"
|
||||
"time"
|
||||
)
|
||||
|
||||
const MsgTypeMSE = "mse"
|
||||
|
||||
type Consumer struct {
|
||||
streamer.Element
|
||||
|
||||
UserAgent string
|
||||
RemoteAddr string
|
||||
|
||||
muxer *mp4f.Muxer
|
||||
streams []av.CodecData
|
||||
start bool
|
||||
|
||||
send int
|
||||
}
|
||||
|
||||
func (c *Consumer) GetMedias() []*streamer.Media {
|
||||
return []*streamer.Media{
|
||||
{
|
||||
Kind: streamer.KindVideo,
|
||||
Direction: streamer.DirectionRecvonly,
|
||||
Codecs: []*streamer.Codec{
|
||||
{Name: streamer.CodecH264, ClockRate: 90000},
|
||||
},
|
||||
}, {
|
||||
Kind: streamer.KindAudio,
|
||||
Direction: streamer.DirectionRecvonly,
|
||||
Codecs: []*streamer.Codec{
|
||||
{Name: streamer.CodecAAC, ClockRate: 16000},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Consumer) AddTrack(media *streamer.Media, track *streamer.Track) *streamer.Track {
|
||||
codec := track.Codec
|
||||
switch codec.Name {
|
||||
case streamer.CodecH264:
|
||||
idx := int8(len(c.streams))
|
||||
|
||||
sps, pps := h264.GetParameterSet(codec.FmtpLine)
|
||||
stream, err := h264parser.NewCodecDataFromSPSAndPPS(sps, pps)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
c.streams = append(c.streams, stream)
|
||||
|
||||
pkt := av.Packet{Idx: idx, CompositionTime: time.Millisecond}
|
||||
|
||||
ts2time := time.Second / time.Duration(codec.ClockRate)
|
||||
|
||||
push := func(packet *rtp.Packet) error {
|
||||
if packet.Version != h264.RTPPacketVersionAVC {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch h264.NALUType(packet.Payload) {
|
||||
case h264.NALUTypeIFrame:
|
||||
c.start = true
|
||||
pkt.IsKeyFrame = true
|
||||
case h264.NALUTypePFrame:
|
||||
if !c.start {
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
pkt.Data = packet.Payload
|
||||
newTime := time.Duration(packet.Timestamp) * ts2time
|
||||
if pkt.Time > 0 {
|
||||
pkt.Duration = newTime - pkt.Time
|
||||
}
|
||||
pkt.Time = newTime
|
||||
|
||||
for _, buf := range c.muxer.WritePacketV5(pkt) {
|
||||
c.send += len(buf)
|
||||
c.Fire(buf)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if !h264.IsAVC(codec) {
|
||||
wrapper := h264.RTPDepay(track)
|
||||
push = wrapper(push)
|
||||
}
|
||||
|
||||
return track.Bind(push)
|
||||
}
|
||||
|
||||
panic("unsupported codec")
|
||||
}
|
||||
|
||||
func (c *Consumer) Init() {
|
||||
c.muxer = mp4f.NewMuxer(nil)
|
||||
if err := c.muxer.WriteHeader(c.streams); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
codecs, buf := c.muxer.GetInit(c.streams)
|
||||
c.Fire(&streamer.Message{Type: MsgTypeMSE, Value: codecs})
|
||||
|
||||
c.send += len(buf)
|
||||
c.Fire(buf)
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
func (c *Consumer) MarshalJSON() ([]byte, error) {
|
||||
v := map[string]interface{}{
|
||||
"type": "MSE server consumer",
|
||||
"send": c.send,
|
||||
"remote_addr": c.RemoteAddr,
|
||||
"user_agent": c.UserAgent,
|
||||
}
|
||||
|
||||
return json.Marshal(v)
|
||||
}
|
@@ -2,16 +2,27 @@ package rtmp
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264"
|
||||
"github.com/AlexxIT/go2rtc/pkg/httpflv"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/deepch/vdk/av"
|
||||
"github.com/deepch/vdk/codec/aacparser"
|
||||
"github.com/deepch/vdk/codec/h264parser"
|
||||
"github.com/deepch/vdk/format/rtmp"
|
||||
"github.com/pion/rtp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Conn for RTMP and RTMPT (flv over HTTP)
|
||||
type Conn interface {
|
||||
Streams() (streams []av.CodecData, err error)
|
||||
ReadPacket() (pkt av.Packet, err error)
|
||||
Close() (err error)
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
streamer.Element
|
||||
|
||||
@@ -20,7 +31,7 @@ type Client struct {
|
||||
medias []*streamer.Media
|
||||
tracks []*streamer.Track
|
||||
|
||||
conn *rtmp.Conn
|
||||
conn Conn
|
||||
closed bool
|
||||
|
||||
receive int
|
||||
@@ -31,7 +42,12 @@ func NewClient(uri string) *Client {
|
||||
}
|
||||
|
||||
func (c *Client) Dial() (err error) {
|
||||
c.conn, err = rtmp.Dial(c.URI)
|
||||
if strings.HasPrefix(c.URI, "http") {
|
||||
c.conn, err = httpflv.Dial(c.URI)
|
||||
} else {
|
||||
c.conn, err = rtmp.Dial(c.URI)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -45,10 +61,14 @@ func (c *Client) Dial() (err error) {
|
||||
for _, stream := range streams {
|
||||
switch stream.Type() {
|
||||
case av.H264:
|
||||
cd := stream.(h264parser.CodecData)
|
||||
fmtp := "sprop-parameter-sets=" +
|
||||
base64.StdEncoding.EncodeToString(cd.RecordInfo.SPS[0]) + "," +
|
||||
base64.StdEncoding.EncodeToString(cd.RecordInfo.PPS[0])
|
||||
info := stream.(h264parser.CodecData).RecordInfo
|
||||
|
||||
fmtp := fmt.Sprintf(
|
||||
"profile-level-id=%02X%02X%02X;sprop-parameter-sets=%s,%s",
|
||||
info.AVCProfileIndication, info.ProfileCompatibility, info.AVCLevelIndication,
|
||||
base64.StdEncoding.EncodeToString(info.SPS[0]),
|
||||
base64.StdEncoding.EncodeToString(info.PPS[0]),
|
||||
)
|
||||
|
||||
codec := &streamer.Codec{
|
||||
Name: streamer.CodecH264,
|
||||
@@ -70,9 +90,36 @@ func (c *Client) Dial() (err error) {
|
||||
c.tracks = append(c.tracks, track)
|
||||
|
||||
case av.AAC:
|
||||
panic("not implemented")
|
||||
// TODO: fix support
|
||||
cd := stream.(aacparser.CodecData)
|
||||
|
||||
// a=fmtp:97 streamtype=5;profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3;config=1588
|
||||
fmtp := fmt.Sprintf(
|
||||
"config=%s",
|
||||
hex.EncodeToString(cd.ConfigBytes),
|
||||
)
|
||||
|
||||
codec := &streamer.Codec{
|
||||
Name: streamer.CodecAAC,
|
||||
ClockRate: uint32(cd.Config.SampleRate),
|
||||
Channels: uint16(cd.Config.ChannelConfig),
|
||||
FmtpLine: fmtp,
|
||||
}
|
||||
|
||||
media := &streamer.Media{
|
||||
Kind: streamer.KindAudio,
|
||||
Direction: streamer.DirectionSendonly,
|
||||
Codecs: []*streamer.Codec{codec},
|
||||
}
|
||||
c.medias = append(c.medias, media)
|
||||
|
||||
track := &streamer.Track{
|
||||
Codec: codec, Direction: media.Direction,
|
||||
}
|
||||
c.tracks = append(c.tracks, track)
|
||||
|
||||
default:
|
||||
panic("unsupported codec")
|
||||
fmt.Printf("[rtmp] unsupported codec %+v\n", stream)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,22 +147,14 @@ func (c *Client) Handle() (err error) {
|
||||
|
||||
track := c.tracks[int(pkt.Idx)]
|
||||
|
||||
timestamp := uint32(pkt.Time / time.Duration(track.Codec.ClockRate))
|
||||
// convert seconds to RTP timestamp
|
||||
timestamp := uint32(pkt.Time * time.Duration(track.Codec.ClockRate) / time.Second)
|
||||
|
||||
var payloads [][]byte
|
||||
if track.Codec.Name == streamer.CodecH264 {
|
||||
payloads = splitAVC(pkt.Data)
|
||||
} else {
|
||||
payloads = [][]byte{pkt.Data}
|
||||
}
|
||||
|
||||
for _, payload := range payloads {
|
||||
packet := &rtp.Packet{
|
||||
Header: rtp.Header{Timestamp: timestamp},
|
||||
Payload: payload,
|
||||
}
|
||||
_ = track.WriteRTP(packet)
|
||||
packet := &rtp.Packet{
|
||||
Header: rtp.Header{Timestamp: timestamp},
|
||||
Payload: pkt.Data,
|
||||
}
|
||||
_ = track.WriteRTP(packet)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,21 +165,3 @@ func (c *Client) Close() error {
|
||||
c.closed = true
|
||||
return c.conn.Close()
|
||||
}
|
||||
|
||||
func splitAVC(data []byte) [][]byte {
|
||||
var nals [][]byte
|
||||
for {
|
||||
// get AVC length
|
||||
size := int(binary.BigEndian.Uint32(data))
|
||||
|
||||
// check if multiple items in one packet
|
||||
if size+4 < len(data) {
|
||||
nals = append(nals, data[:size+4])
|
||||
data = data[size+4:]
|
||||
} else {
|
||||
nals = append(nals, data)
|
||||
break
|
||||
}
|
||||
}
|
||||
return nals
|
||||
}
|
||||
|
@@ -2,6 +2,7 @@ package rtmp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"strconv"
|
||||
)
|
||||
@@ -16,7 +17,7 @@ func (c *Client) GetTrack(media *streamer.Media, codec *streamer.Codec) *streame
|
||||
return track
|
||||
}
|
||||
}
|
||||
panic("wrong codec")
|
||||
panic(fmt.Sprintf("wrong media/codec: %+v %+v", media, codec))
|
||||
}
|
||||
|
||||
func (c *Client) Start() error {
|
||||
@@ -31,7 +32,7 @@ func (c *Client) MarshalJSON() ([]byte, error) {
|
||||
v := map[string]interface{}{
|
||||
streamer.JSONReceive: c.receive,
|
||||
streamer.JSONType: "RTMP client producer",
|
||||
streamer.JSONRemoteAddr: c.conn.NetConn().RemoteAddr().String(),
|
||||
//streamer.JSONRemoteAddr: c.conn.NetConn().RemoteAddr().String(),
|
||||
"url": c.URI,
|
||||
}
|
||||
for i, media := range c.medias {
|
||||
|
248
pkg/rtsp/conn.go
248
pkg/rtsp/conn.go
@@ -48,6 +48,8 @@ type Conn struct {
|
||||
|
||||
// public
|
||||
|
||||
Backchannel bool
|
||||
|
||||
Medias []*streamer.Media
|
||||
Session string
|
||||
UserAgent string
|
||||
@@ -56,11 +58,12 @@ type Conn struct {
|
||||
// internal
|
||||
|
||||
auth *tcp.Auth
|
||||
closed bool
|
||||
conn net.Conn
|
||||
mode Mode
|
||||
reader *bufio.Reader
|
||||
sequence int
|
||||
|
||||
mode Mode
|
||||
uri string
|
||||
|
||||
tracks []*streamer.Track
|
||||
channels map[byte]*streamer.Track
|
||||
@@ -72,24 +75,10 @@ type Conn struct {
|
||||
}
|
||||
|
||||
func NewClient(uri string) (*Conn, error) {
|
||||
var err error
|
||||
|
||||
c := new(Conn)
|
||||
c.URL, err = url.Parse(uri)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if strings.IndexByte(c.URL.Host, ':') < 0 {
|
||||
c.URL.Host += ":554"
|
||||
}
|
||||
|
||||
// remove UserInfo from URL
|
||||
c.auth = tcp.NewAuth(c.URL.User)
|
||||
c.mode = ModeClientProducer
|
||||
c.URL.User = nil
|
||||
|
||||
return c, nil
|
||||
c.uri = uri
|
||||
return c, c.parseURI()
|
||||
}
|
||||
|
||||
func NewServer(conn net.Conn) *Conn {
|
||||
@@ -100,14 +89,32 @@ func NewServer(conn net.Conn) *Conn {
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Conn) parseURI() (err error) {
|
||||
c.URL, err = url.Parse(c.uri)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if strings.IndexByte(c.URL.Host, ':') < 0 {
|
||||
c.URL.Host += ":554"
|
||||
}
|
||||
|
||||
// remove UserInfo from URL
|
||||
c.auth = tcp.NewAuth(c.URL.User)
|
||||
c.URL.User = nil
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Conn) Dial() (err error) {
|
||||
//if c.state != StateClientInit {
|
||||
// panic("wrong state")
|
||||
//}
|
||||
if c.conn != nil {
|
||||
_ = c.parseURI()
|
||||
}
|
||||
|
||||
c.conn, err = net.DialTimeout(
|
||||
"tcp", c.URL.Host, 10*time.Second,
|
||||
)
|
||||
c.conn, err = net.DialTimeout("tcp", c.URL.Host, time.Second*5)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -144,7 +151,9 @@ func (c *Conn) Request(req *tcp.Request) error {
|
||||
}
|
||||
|
||||
c.sequence++
|
||||
req.Header.Set("CSeq", strconv.Itoa(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)
|
||||
|
||||
@@ -189,7 +198,7 @@ func (c *Conn) Do(req *tcp.Request) (*tcp.Response, error) {
|
||||
}
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("wrong response on %s", req.Method)
|
||||
return res, fmt.Errorf("wrong response on %s", req.Method)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
@@ -254,23 +263,27 @@ func (c *Conn) Describe() error {
|
||||
Method: MethodDescribe,
|
||||
URL: c.URL,
|
||||
Header: map[string][]string{
|
||||
"Accept": {"application/sdp"},
|
||||
"Require": {"www.onvif.org/ver20/backchannel"},
|
||||
"Accept": {"application/sdp"},
|
||||
},
|
||||
}
|
||||
|
||||
if c.Backchannel {
|
||||
req.Header.Set("Require", "www.onvif.org/ver20/backchannel")
|
||||
}
|
||||
|
||||
res, err := c.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// fix bug in Sonoff camera SDP "o=- 1 1 IN IP4 rom t_rtsplin"
|
||||
// TODO: make some universal fix
|
||||
if i := bytes.Index(res.Body, []byte("rom t_rtsplin")); i > 0 {
|
||||
res.Body[i+3] = '_'
|
||||
if val := res.Header.Get("Content-Base"); val != "" {
|
||||
c.URL, err = url.Parse(val)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
c.Medias, err = streamer.UnmarshalRTSPSDP(res.Body)
|
||||
c.Medias, err = UnmarshalSDP(res.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -318,11 +331,18 @@ func (c *Conn) SetupMedia(
|
||||
return nil, fmt.Errorf("wrong media: %v", media)
|
||||
}
|
||||
|
||||
trackURL, err := url.Parse(media.Control)
|
||||
rawURL := media.Control
|
||||
if !strings.Contains(rawURL, "://") {
|
||||
rawURL = c.URL.String()
|
||||
if !strings.HasSuffix(rawURL, "/") {
|
||||
rawURL += "/"
|
||||
}
|
||||
rawURL += media.Control
|
||||
}
|
||||
trackURL, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
trackURL = c.URL.ResolveReference(trackURL)
|
||||
|
||||
req := &tcp.Request{
|
||||
Method: MethodSetup,
|
||||
@@ -339,6 +359,24 @@ func (c *Conn) SetupMedia(
|
||||
var res *tcp.Response
|
||||
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.Dial(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.Describe(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, newMedia := range c.Medias {
|
||||
if newMedia.Control == media.Control {
|
||||
return c.SetupMedia(newMedia, newMedia.Codecs[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -355,10 +393,27 @@ func (c *Conn) SetupMedia(
|
||||
// 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
|
||||
s := res.Header.Get("Transport")
|
||||
s, ok1, ok2 := between(s, "RTP/AVP/TCP;unicast;interleaved=", "-")
|
||||
if !ok1 || !ok2 {
|
||||
panic("wrong response")
|
||||
// TODO: rewrite
|
||||
if !strings.HasPrefix(s, "RTP/AVP/TCP;") {
|
||||
// Escam Q6 has a bug:
|
||||
// Transport: RTP/AVP;unicast;destination=192.168.1.111;source=192.168.1.222;interleaved=0-1
|
||||
if !strings.Contains(s, ";interleaved=") {
|
||||
return nil, fmt.Errorf("wrong transport: %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
i := strings.Index(s, "interleaved=")
|
||||
if i < 0 {
|
||||
return nil, fmt.Errorf("wrong transport: %s", s)
|
||||
}
|
||||
|
||||
s = s[i+len("interleaved="):]
|
||||
i = strings.IndexAny(s, "-;")
|
||||
if i > 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
|
||||
ch, err = strconv.Atoi(s)
|
||||
@@ -401,39 +456,36 @@ func (c *Conn) Teardown() (err error) {
|
||||
}
|
||||
|
||||
func (c *Conn) Close() error {
|
||||
if c.conn == nil {
|
||||
if c.closed {
|
||||
return nil
|
||||
}
|
||||
if err := c.Teardown(); err != nil {
|
||||
return err
|
||||
}
|
||||
conn := c.conn
|
||||
c.conn = nil
|
||||
return conn.Close()
|
||||
c.closed = true
|
||||
return c.conn.Close()
|
||||
}
|
||||
|
||||
const transport = "RTP/AVP/TCP;unicast;interleaved="
|
||||
|
||||
func (c *Conn) Accept() error {
|
||||
//if c.state != StateServerInit {
|
||||
// panic("wrong state")
|
||||
//}
|
||||
|
||||
for {
|
||||
req, err := tcp.ReadRequest(c.reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.URL == nil {
|
||||
c.URL = req.URL
|
||||
c.UserAgent = req.Header.Get("User-Agent")
|
||||
}
|
||||
|
||||
c.Fire(req)
|
||||
|
||||
// Receiver: OPTIONS > DESCRIBE > SETUP... > PLAY > TEARDOWN
|
||||
// Sender: OPTIONS > ANNOUNCE > SETUP... > RECORD > TEARDOWN
|
||||
switch req.Method {
|
||||
case MethodOptions:
|
||||
c.URL = req.URL
|
||||
c.UserAgent = req.Header.Get("User-Agent")
|
||||
|
||||
res := &tcp.Response{
|
||||
Header: map[string][]string{
|
||||
"Public": {"OPTIONS, SETUP, TEARDOWN, DESCRIBE, PLAY, PAUSE, ANNOUNCE, RECORD"},
|
||||
@@ -449,7 +501,7 @@ func (c *Conn) Accept() error {
|
||||
return errors.New("wrong content type")
|
||||
}
|
||||
|
||||
c.Medias, err = streamer.UnmarshalRTSPSDP(req.Body)
|
||||
c.Medias, err = UnmarshalSDP(req.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -519,7 +571,7 @@ func (c *Conn) Accept() error {
|
||||
Request: req,
|
||||
}
|
||||
|
||||
if tr[:len(transport)] == transport {
|
||||
if strings.HasPrefix(tr, transport) {
|
||||
c.Session = "1" // TODO: fixme
|
||||
res.Header.Set("Transport", tr[:len(transport)+3])
|
||||
} else {
|
||||
@@ -542,15 +594,44 @@ func (c *Conn) Accept() error {
|
||||
|
||||
func (c *Conn) Handle() (err error) {
|
||||
defer func() {
|
||||
if c.conn == nil {
|
||||
if c.closed {
|
||||
err = nil
|
||||
} else {
|
||||
// may have gotten here because of the deadline
|
||||
// so close the connection to stop keepalive
|
||||
_ = c.conn.Close()
|
||||
}
|
||||
//c.Fire(streamer.StateNull)
|
||||
}()
|
||||
|
||||
//c.Fire(streamer.StatePlaying)
|
||||
var timeout time.Duration
|
||||
|
||||
switch c.mode {
|
||||
case ModeClientProducer:
|
||||
// polling frames from remote RTSP Server (ex Camera)
|
||||
timeout = time.Second * 5
|
||||
go c.keepalive()
|
||||
|
||||
case ModeServerProducer:
|
||||
// polling frames from remote RTSP Client (ex FFmpeg)
|
||||
timeout = time.Second * 15
|
||||
|
||||
case ModeServerConsumer:
|
||||
// pushing frames to remote RTSP Client (ex VLC)
|
||||
timeout = time.Second * 60
|
||||
|
||||
default:
|
||||
return fmt.Errorf("wrong RTSP conn mode: %d", c.mode)
|
||||
}
|
||||
|
||||
for {
|
||||
if c.closed {
|
||||
return
|
||||
}
|
||||
|
||||
if err = c.conn.SetReadDeadline(time.Now().Add(timeout)); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// we can read:
|
||||
// 1. RTP interleaved: `$` + 1B channel number + 2B size
|
||||
// 2. RTSP response: RTSP/1.0 200 OK
|
||||
@@ -603,7 +684,7 @@ func (c *Conn) Handle() (err error) {
|
||||
if channelID&1 == 0 {
|
||||
packet := &rtp.Packet{}
|
||||
if err = packet.Unmarshal(buf); err != nil {
|
||||
return errors.New("wrong RTP data")
|
||||
return
|
||||
}
|
||||
|
||||
track := c.channels[channelID]
|
||||
@@ -611,18 +692,19 @@ func (c *Conn) Handle() (err error) {
|
||||
_ = track.WriteRTP(packet)
|
||||
//return fmt.Errorf("wrong channelID: %d", channelID)
|
||||
} else {
|
||||
panic("wrong channelID")
|
||||
continue // TODO: maybe fix this
|
||||
//panic("wrong channelID")
|
||||
}
|
||||
} else {
|
||||
msg := &RTCP{Channel: channelID}
|
||||
|
||||
if err = msg.Header.Unmarshal(buf); err != nil {
|
||||
return errors.New("wrong RTCP data")
|
||||
return
|
||||
}
|
||||
|
||||
msg.Packets, err = rtcp.Unmarshal(buf)
|
||||
if err != nil {
|
||||
return errors.New("wrong RTCP data")
|
||||
return
|
||||
}
|
||||
|
||||
c.Fire(msg)
|
||||
@@ -630,6 +712,20 @@ func (c *Conn) Handle() (err error) {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Conn) keepalive() {
|
||||
// TODO: rewrite to RTCP
|
||||
req := &tcp.Request{Method: MethodOptions, URL: c.URL}
|
||||
for {
|
||||
time.Sleep(time.Second * 25)
|
||||
if c.closed {
|
||||
return
|
||||
}
|
||||
if err := c.Request(req); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Conn) GetChannel(media *streamer.Media) int {
|
||||
for i, m := range c.Medias {
|
||||
if m == media {
|
||||
@@ -643,20 +739,16 @@ func (c *Conn) bindTrack(
|
||||
track *streamer.Track, channel uint8, payloadType uint8,
|
||||
) *streamer.Track {
|
||||
push := func(packet *rtp.Packet) error {
|
||||
if c.conn == nil {
|
||||
if c.closed {
|
||||
return nil
|
||||
}
|
||||
packet.Header.PayloadType = payloadType
|
||||
//packet.Header.PayloadType = 100
|
||||
//packet.Header.PayloadType = 8
|
||||
//packet.Header.PayloadType = 106
|
||||
|
||||
size := packet.MarshalSize()
|
||||
|
||||
data := make([]byte, 4+size)
|
||||
data[0] = '$'
|
||||
data[1] = channel
|
||||
//data[1] = 10
|
||||
binary.BigEndian.PutUint16(data[2:], uint16(size))
|
||||
|
||||
if _, err := packet.MarshalTo(data[4:]); err != nil {
|
||||
@@ -686,17 +778,35 @@ type RTCP struct {
|
||||
Packets []rtcp.Packet
|
||||
}
|
||||
|
||||
func between(s, sub1, sub2 string) (res string, ok1 bool, ok2 bool) {
|
||||
i := strings.Index(s, sub1)
|
||||
if i >= 0 {
|
||||
ok1 = true
|
||||
s = s[i+len(sub1):]
|
||||
const sdpHeader = `v=0
|
||||
o=- 0 0 IN IP4 0.0.0.0
|
||||
s=-
|
||||
t=0 0`
|
||||
|
||||
func UnmarshalSDP(rawSDP []byte) ([]*streamer.Media, error) {
|
||||
medias, err := streamer.UnmarshalSDP(rawSDP)
|
||||
if err != nil {
|
||||
// fix SDP header for some cameras
|
||||
i := bytes.Index(rawSDP, []byte("\nm="))
|
||||
if i > 0 {
|
||||
rawSDP = append([]byte(sdpHeader), rawSDP[i:]...)
|
||||
medias, err = streamer.UnmarshalSDP(rawSDP)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
i = strings.Index(s, sub2)
|
||||
if i >= 0 {
|
||||
return s[:i], ok1, true
|
||||
// fix bug in ONVIF spec
|
||||
// https://www.onvif.org/specs/stream/ONVIF-Streaming-Spec-v241.pdf
|
||||
for _, media := range medias {
|
||||
switch media.Direction {
|
||||
case streamer.DirectionRecvonly, "":
|
||||
media.Direction = streamer.DirectionSendonly
|
||||
case streamer.DirectionSendonly:
|
||||
media.Direction = streamer.DirectionRecvonly
|
||||
}
|
||||
}
|
||||
|
||||
return s, ok1, false
|
||||
return medias, nil
|
||||
}
|
||||
|
@@ -2,6 +2,7 @@ package rtsp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"strconv"
|
||||
)
|
||||
@@ -27,13 +28,16 @@ func (c *Conn) GetTrack(media *streamer.Media, codec *streamer.Codec) *streamer.
|
||||
}
|
||||
|
||||
func (c *Conn) Start() error {
|
||||
if c.mode == ModeServerProducer {
|
||||
return nil
|
||||
switch c.mode {
|
||||
case ModeClientProducer:
|
||||
if err := c.Play(); err != nil {
|
||||
return err
|
||||
}
|
||||
case ModeServerProducer:
|
||||
default:
|
||||
return fmt.Errorf("start wrong mode: %d", c.mode)
|
||||
}
|
||||
|
||||
if err := c.Play(); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Handle()
|
||||
}
|
||||
|
||||
|
41
pkg/shell/shell.go
Normal file
41
pkg/shell/shell.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package shell
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func QuoteSplit(s string) []string {
|
||||
var a []string
|
||||
|
||||
for len(s) > 0 {
|
||||
is := strings.IndexByte(s, ' ')
|
||||
if is >= 0 {
|
||||
// skip prefix and double spaces
|
||||
if is == 0 {
|
||||
// goto next symbol
|
||||
s = s[1:]
|
||||
continue
|
||||
}
|
||||
|
||||
// check if quote in word
|
||||
if i := strings.IndexByte(s[:is], '"'); i >= 0 {
|
||||
// search quote end
|
||||
if is = strings.Index(s, `" `); is > 0 {
|
||||
is += 1
|
||||
} else {
|
||||
is = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if is >= 0 {
|
||||
a = append(a, strings.ReplaceAll(s[:is], `"`, ""))
|
||||
s = s[is+1:]
|
||||
} else {
|
||||
//add last word
|
||||
a = append(a, s)
|
||||
break
|
||||
}
|
||||
}
|
||||
return a
|
||||
}
|
61
pkg/srtp/server.go
Normal file
61
pkg/srtp/server.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package srtp
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"net"
|
||||
)
|
||||
|
||||
// Server using same UDP port for SRTP and for SRTCP as the iPhone does
|
||||
// this is not really necessary but anyway
|
||||
type Server struct {
|
||||
sessions map[uint32]*Session
|
||||
}
|
||||
|
||||
func (s *Server) AddSession(session *Session) {
|
||||
if s.sessions == nil {
|
||||
s.sessions = map[uint32]*Session{}
|
||||
}
|
||||
s.sessions[session.RemoteSSRC] = session
|
||||
}
|
||||
|
||||
func (s *Server) RemoveSession(session *Session) {
|
||||
delete(s.sessions, session.RemoteSSRC)
|
||||
}
|
||||
|
||||
func (s *Server) Serve(conn net.PacketConn) error {
|
||||
buf := make([]byte, 2048)
|
||||
for {
|
||||
n, addr, err := conn.ReadFrom(buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Multiplexing RTP Data and Control Packets on a Single Port
|
||||
// https://datatracker.ietf.org/doc/html/rfc5761
|
||||
|
||||
// this is default position for SSRC in RTP packet
|
||||
ssrc := binary.BigEndian.Uint32(buf[8:])
|
||||
session, ok := s.sessions[ssrc]
|
||||
if ok {
|
||||
if session.Write == nil {
|
||||
session.Write = func(b []byte) (int, error) {
|
||||
return conn.WriteTo(b, addr)
|
||||
}
|
||||
}
|
||||
|
||||
if err = session.HandleRTP(buf[:n]); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// this is default position for SSRC in RTCP packet
|
||||
ssrc = binary.BigEndian.Uint32(buf[4:])
|
||||
if session, ok = s.sessions[ssrc]; !ok {
|
||||
continue // skip unknown ssrc
|
||||
}
|
||||
|
||||
if err = session.HandleRTCP(buf[:n]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
97
pkg/srtp/session.go
Normal file
97
pkg/srtp/session.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package srtp
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/pion/rtcp"
|
||||
"github.com/pion/rtp"
|
||||
"github.com/pion/srtp/v2"
|
||||
)
|
||||
|
||||
type Session struct {
|
||||
LocalSSRC uint32 // outgoing SSRC
|
||||
RemoteSSRC uint32 // incoming SSRC
|
||||
|
||||
localCtx *srtp.Context // write context
|
||||
remoteCtx *srtp.Context // read context
|
||||
|
||||
Write func(b []byte) (int, error)
|
||||
Track *streamer.Track
|
||||
}
|
||||
|
||||
func (s *Session) SetKeys(
|
||||
localKey, localSalt, remoteKey, remoteSalt []byte,
|
||||
) (err error) {
|
||||
if s.localCtx, err = srtp.CreateContext(
|
||||
localKey, localSalt, GuessProfile(localKey),
|
||||
); err != nil {
|
||||
return
|
||||
}
|
||||
s.remoteCtx, err = srtp.CreateContext(
|
||||
remoteKey, remoteSalt, GuessProfile(remoteKey),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Session) HandleRTP(data []byte) (err error) {
|
||||
if data, err = s.remoteCtx.DecryptRTP(nil, data, nil); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
packet := &rtp.Packet{}
|
||||
if err = packet.Unmarshal(data); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_ = s.Track.WriteRTP(packet)
|
||||
//s.Output(core.RTP{Channel: s.Channel, Packet: packet})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Session) HandleRTCP(data []byte) (err error) {
|
||||
header := &rtcp.Header{}
|
||||
if data, err = s.remoteCtx.DecryptRTCP(nil, data, header); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var packets []rtcp.Packet
|
||||
if packets, err = rtcp.Unmarshal(data); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_ = packets
|
||||
//s.Output(core.RTCP{Channel: s.Channel + 1, Header: header, Packets: packets})
|
||||
|
||||
if header.Type == rtcp.TypeSenderReport {
|
||||
err = s.KeepAlive()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Session) KeepAlive() (err error) {
|
||||
var data []byte
|
||||
// we can send empty receiver response, but should send it to hold the connection
|
||||
rep := rtcp.ReceiverReport{SSRC: s.LocalSSRC}
|
||||
if data, err = rep.Marshal(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if data, err = s.localCtx.EncryptRTCP(nil, data, nil); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = s.Write(data)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func GuessProfile(masterKey []byte) srtp.ProtectionProfile {
|
||||
switch len(masterKey) {
|
||||
case 16:
|
||||
return srtp.ProtectionProfileAes128CmHmacSha1_80
|
||||
//case 32:
|
||||
// return srtp.ProtectionProfileAes256CmHmacSha1_80
|
||||
}
|
||||
return 0
|
||||
}
|
@@ -5,6 +5,7 @@ import (
|
||||
"github.com/pion/sdp/v3"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -24,19 +25,21 @@ const (
|
||||
CodecVP8 = "VP8"
|
||||
CodecVP9 = "VP9"
|
||||
CodecAV1 = "AV1"
|
||||
CodecJPEG = "JPEG" // payloadType: 26
|
||||
|
||||
CodecPCMU = "PCMU" // payloadType: 0
|
||||
CodecPCMA = "PCMA" // payloadType: 8
|
||||
CodecAAC = "MPEG4-GENERIC"
|
||||
CodecOpus = "OPUS" // payloadType: 111
|
||||
CodecG722 = "G722"
|
||||
CodecMPA = "MPA" // payload: 14
|
||||
)
|
||||
|
||||
func GetKind(name string) string {
|
||||
switch name {
|
||||
case CodecH264, CodecH265, CodecVP8, CodecVP9, CodecAV1:
|
||||
case CodecH264, CodecH265, CodecVP8, CodecVP9, CodecAV1, CodecJPEG:
|
||||
return KindVideo
|
||||
case CodecPCMU, CodecPCMA, CodecAAC, CodecOpus, CodecG722:
|
||||
case CodecPCMU, CodecPCMA, CodecAAC, CodecOpus, CodecG722, CodecMPA:
|
||||
return KindAudio
|
||||
}
|
||||
return ""
|
||||
@@ -46,12 +49,13 @@ func GetKind(name string) string {
|
||||
// - deepch/vdk/format/rtsp/sdp.Media
|
||||
// - pion/sdp.MediaDescription
|
||||
type Media struct {
|
||||
Kind string // video, audio
|
||||
Direction string
|
||||
Codecs []*Codec
|
||||
Kind string `json:"kind,omitempty"` // video or audio
|
||||
Direction string `json:"direction,omitempty"`
|
||||
Codecs []*Codec `json:"codecs,omitempty"`
|
||||
|
||||
MID string // TODO: fixme?
|
||||
Control string // TODO: fixme?
|
||||
MID string `json:"mid,omitempty"` // TODO: fixme?
|
||||
Control string `json:"control,omitempty"` // TODO: fixme?
|
||||
Title string `json:"title,omitempty"` // TODO: fixme?
|
||||
}
|
||||
|
||||
func (m *Media) String() string {
|
||||
@@ -71,13 +75,13 @@ func (m *Media) AV() bool {
|
||||
return m.Kind == KindVideo || m.Kind == KindAudio
|
||||
}
|
||||
|
||||
func (m *Media) MatchCodec(codec *Codec) bool {
|
||||
func (m *Media) MatchCodec(codec *Codec) *Codec {
|
||||
for _, c := range m.Codecs {
|
||||
if c.Match(codec) {
|
||||
return true
|
||||
return c
|
||||
}
|
||||
}
|
||||
return false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Media) MatchMedia(media *Media) *Codec {
|
||||
@@ -126,12 +130,14 @@ type Codec struct {
|
||||
func NewCodec(name string) *Codec {
|
||||
name = strings.ToUpper(name)
|
||||
switch name {
|
||||
case CodecH264, CodecH265, CodecVP8, CodecVP9, CodecAV1:
|
||||
case CodecH264, CodecH265, CodecVP8, CodecVP9, CodecAV1, CodecJPEG:
|
||||
return &Codec{Name: name, ClockRate: 90000}
|
||||
case CodecPCMU, CodecPCMA:
|
||||
return &Codec{Name: name, ClockRate: 8000}
|
||||
case CodecOpus:
|
||||
return &Codec{Name: name, ClockRate: 48000, Channels: 2}
|
||||
case "MJPEG":
|
||||
return &Codec{Name: CodecJPEG, ClockRate: 90000}
|
||||
}
|
||||
|
||||
panic(fmt.Sprintf("unsupported codec: %s", name))
|
||||
@@ -152,8 +158,8 @@ func (c *Codec) Clone() *Codec {
|
||||
|
||||
func (c *Codec) Match(codec *Codec) bool {
|
||||
return c.Name == codec.Name &&
|
||||
c.ClockRate == codec.ClockRate &&
|
||||
c.Channels == codec.Channels
|
||||
(c.ClockRate == codec.ClockRate || codec.ClockRate == 0) &&
|
||||
(c.Channels == codec.Channels || codec.Channels == 0)
|
||||
}
|
||||
|
||||
func UnmarshalSDP(rawSDP []byte) ([]*Media, error) {
|
||||
@@ -180,26 +186,6 @@ func UnmarshalSDP(rawSDP []byte) ([]*Media, error) {
|
||||
return medias, nil
|
||||
}
|
||||
|
||||
func UnmarshalRTSPSDP(rawSDP []byte) ([]*Media, error) {
|
||||
medias, err := UnmarshalSDP(rawSDP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// fix bug in ONVIF spec
|
||||
// https://www.onvif.org/specs/stream/ONVIF-Streaming-Spec-v241.pdf
|
||||
for _, media := range medias {
|
||||
switch media.Direction {
|
||||
case DirectionRecvonly, "":
|
||||
media.Direction = DirectionSendonly
|
||||
case DirectionSendonly:
|
||||
media.Direction = DirectionRecvonly
|
||||
}
|
||||
}
|
||||
|
||||
return medias, nil
|
||||
}
|
||||
|
||||
func MarshalSDP(medias []*Media) ([]byte, error) {
|
||||
sd := &sdp.SessionDescription{}
|
||||
|
||||
@@ -260,7 +246,8 @@ func UnmarshalCodec(md *sdp.MediaDescription, payloadType string) *Codec {
|
||||
ss := strings.Split(attr.Value[i+1:], "/")
|
||||
|
||||
c.Name = strings.ToUpper(ss[0])
|
||||
c.ClockRate = uint32(atoi(ss[1]))
|
||||
// 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
|
||||
@@ -273,13 +260,20 @@ func UnmarshalCodec(md *sdp.MediaDescription, payloadType string) *Codec {
|
||||
}
|
||||
|
||||
if c.Name == "" {
|
||||
// https://en.wikipedia.org/wiki/RTP_payload_formats
|
||||
switch payloadType {
|
||||
case "0":
|
||||
c.Name = "PCMU"
|
||||
c.Name = CodecPCMU
|
||||
c.ClockRate = 8000
|
||||
case "8":
|
||||
c.Name = "PCMA"
|
||||
c.Name = CodecPCMA
|
||||
c.ClockRate = 8000
|
||||
case "14":
|
||||
c.Name = CodecMPA
|
||||
c.ClockRate = 44100
|
||||
case "26":
|
||||
c.Name = CodecJPEG
|
||||
c.ClockRate = 90000
|
||||
default:
|
||||
c.Name = payloadType
|
||||
}
|
||||
|
@@ -3,6 +3,7 @@ package streamer
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/pion/rtp"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type WriterFunc func(packet *rtp.Packet) error
|
||||
@@ -11,34 +12,54 @@ type WrapperFunc func(push WriterFunc) WriterFunc
|
||||
type Track struct {
|
||||
Codec *Codec
|
||||
Direction string
|
||||
Sink map[*Track]WriterFunc
|
||||
sink map[*Track]WriterFunc
|
||||
sinkMu sync.Mutex
|
||||
}
|
||||
|
||||
func (t *Track) String() string {
|
||||
s := t.Codec.String()
|
||||
s += fmt.Sprintf(", sinks=%d", len(t.Sink))
|
||||
s += fmt.Sprintf(", sinks=%d", len(t.sink))
|
||||
return s
|
||||
}
|
||||
|
||||
func (t *Track) WriteRTP(p *rtp.Packet) error {
|
||||
for _, f := range t.Sink {
|
||||
t.sinkMu.Lock()
|
||||
for _, f := range t.sink {
|
||||
_ = f(p)
|
||||
}
|
||||
t.sinkMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *Track) Bind(w WriterFunc) *Track {
|
||||
if t.Sink == nil {
|
||||
t.Sink = map[*Track]WriterFunc{}
|
||||
t.sinkMu.Lock()
|
||||
|
||||
if t.sink == nil {
|
||||
t.sink = map[*Track]WriterFunc{}
|
||||
}
|
||||
|
||||
clone := &Track{
|
||||
Codec: t.Codec, Direction: t.Direction, Sink: t.Sink,
|
||||
Codec: t.Codec, Direction: t.Direction, sink: t.sink,
|
||||
}
|
||||
t.Sink[clone] = w
|
||||
t.sink[clone] = w
|
||||
|
||||
t.sinkMu.Unlock()
|
||||
|
||||
return clone
|
||||
}
|
||||
|
||||
func (t *Track) Unbind() {
|
||||
delete(t.Sink, t)
|
||||
t.sinkMu.Lock()
|
||||
delete(t.sink, t)
|
||||
t.sinkMu.Unlock()
|
||||
}
|
||||
|
||||
func (t *Track) GetSink(from *Track) {
|
||||
t.sink = from.sink
|
||||
}
|
||||
|
||||
func (t *Track) HasSink() bool {
|
||||
t.sinkMu.Lock()
|
||||
defer t.sinkMu.Unlock()
|
||||
return len(t.sink) > 0
|
||||
}
|
||||
|
@@ -47,10 +47,13 @@ func ReadResponse(r *bufio.Reader) (*Response, error) {
|
||||
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, errors.New("malformed response")
|
||||
return nil, fmt.Errorf("malformed response: %s", line)
|
||||
}
|
||||
|
||||
res := &Response{
|
||||
|
@@ -1,6 +1,7 @@
|
||||
package webrtc
|
||||
|
||||
import (
|
||||
"github.com/pion/ice/v2"
|
||||
"github.com/pion/interceptor"
|
||||
"github.com/pion/webrtc/v3"
|
||||
"net"
|
||||
@@ -21,31 +22,30 @@ func NewAPI(address string) (*webrtc.API, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if address == "" {
|
||||
return webrtc.NewAPI(
|
||||
webrtc.WithMediaEngine(m),
|
||||
webrtc.WithInterceptorRegistry(i),
|
||||
), nil
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", address)
|
||||
if err != nil {
|
||||
return webrtc.NewAPI(
|
||||
webrtc.WithMediaEngine(m),
|
||||
webrtc.WithInterceptorRegistry(i),
|
||||
), err
|
||||
}
|
||||
|
||||
s := webrtc.SettingEngine{
|
||||
//LoggerFactory: customLoggerFactory{},
|
||||
}
|
||||
s.SetNetworkTypes([]webrtc.NetworkType{
|
||||
webrtc.NetworkTypeUDP4, webrtc.NetworkTypeUDP6,
|
||||
webrtc.NetworkTypeTCP4, webrtc.NetworkTypeTCP6,
|
||||
|
||||
// disable listen on Hassio docker interfaces
|
||||
s.SetInterfaceFilter(func(name string) bool {
|
||||
return name != "hassio" && name != "docker0"
|
||||
})
|
||||
|
||||
tcpMux := webrtc.NewICETCPMux(nil, ln, 8)
|
||||
s.SetICETCPMux(tcpMux)
|
||||
// disable mDNS listener
|
||||
s.SetICEMulticastDNSMode(ice.MulticastDNSModeDisabled)
|
||||
|
||||
if address != "" {
|
||||
ln, err := net.Listen("tcp", address)
|
||||
if err == nil {
|
||||
s.SetNetworkTypes([]webrtc.NetworkType{
|
||||
webrtc.NetworkTypeUDP4, webrtc.NetworkTypeUDP6,
|
||||
webrtc.NetworkTypeTCP4, webrtc.NetworkTypeTCP6,
|
||||
})
|
||||
|
||||
tcpMux := webrtc.NewICETCPMux(nil, ln, 8)
|
||||
s.SetICETCPMux(tcpMux)
|
||||
}
|
||||
}
|
||||
|
||||
return webrtc.NewAPI(
|
||||
webrtc.WithMediaEngine(m),
|
||||
@@ -86,10 +86,6 @@ func RegisterDefaultCodecs(m *webrtc.MediaEngine) error {
|
||||
PayloadType: 98, //123,
|
||||
},
|
||||
// macOS Safari 15.1
|
||||
{
|
||||
RTPCodecCapability: webrtc.RTPCodecCapability{webrtc.MimeTypeH264, 90000, 0, "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640c1f", videoRTCPFeedback},
|
||||
PayloadType: 99,
|
||||
},
|
||||
{
|
||||
RTPCodecCapability: webrtc.RTPCodecCapability{webrtc.MimeTypeH265, 90000, 0, "", videoRTCPFeedback},
|
||||
PayloadType: 100,
|
||||
|
@@ -1,6 +1,7 @@
|
||||
package webrtc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/pion/webrtc/v3"
|
||||
)
|
||||
@@ -57,20 +58,35 @@ func (c *Conn) Init() {
|
||||
}
|
||||
}
|
||||
|
||||
panic("something wrong")
|
||||
fmt.Printf("TODO: webrtc ontrack %+v\n", remote)
|
||||
})
|
||||
|
||||
// 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
|
||||
c.Conn.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||
c.Fire(state)
|
||||
|
||||
// TODO: remove
|
||||
switch state {
|
||||
case webrtc.PeerConnectionStateConnected:
|
||||
c.Fire(streamer.StatePlaying)
|
||||
c.Fire(streamer.StatePlaying) // TODO: remove
|
||||
case webrtc.PeerConnectionStateDisconnected:
|
||||
c.Fire(streamer.StateNull)
|
||||
case webrtc.PeerConnectionStateFailed:
|
||||
c.Fire(streamer.StateNull) // TODO: remove
|
||||
// disconnect event comes earlier, than failed
|
||||
// but it comes only for success connections
|
||||
_ = c.Conn.Close()
|
||||
c.Conn = nil
|
||||
case webrtc.PeerConnectionStateFailed:
|
||||
if c.Conn != nil {
|
||||
_ = c.Conn.Close()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -126,9 +142,37 @@ func (c *Conn) GetCompleteAnswer() (answer string, err error) {
|
||||
}
|
||||
|
||||
func (c *Conn) remote() string {
|
||||
if c.Conn == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, trans := range c.Conn.GetTransceivers() {
|
||||
pair, _ := trans.Receiver().Transport().ICETransport().GetSelectedCandidatePair()
|
||||
if trans == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
receiver := trans.Receiver()
|
||||
if receiver == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
transport := receiver.Transport()
|
||||
if transport == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
iceTransport := transport.ICETransport()
|
||||
if iceTransport == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
pair, _ := iceTransport.GetSelectedCandidatePair()
|
||||
if pair == nil || pair.Remote == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
return pair.Remote.String()
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
@@ -3,6 +3,7 @@ package webrtc
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h265"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/pion/rtp"
|
||||
"github.com/pion/webrtc/v3"
|
||||
@@ -51,7 +52,8 @@ func (c *Conn) AddTrack(media *streamer.Media, track *streamer.Track) *streamer.
|
||||
return trackLocal.WriteRTP(packet)
|
||||
}
|
||||
|
||||
if codec.Name == streamer.CodecH264 {
|
||||
switch codec.Name {
|
||||
case streamer.CodecH264:
|
||||
wrapper := h264.RTPPay(1200)
|
||||
push = wrapper(push)
|
||||
|
||||
@@ -61,6 +63,15 @@ func (c *Conn) AddTrack(media *streamer.Media, track *streamer.Track) *streamer.
|
||||
wrapper = h264.RTPDepay(track)
|
||||
}
|
||||
push = wrapper(push)
|
||||
|
||||
case streamer.CodecH265:
|
||||
// SafariPay because it is the only browser in the world
|
||||
// that supports WebRTC + H265
|
||||
wrapper := h265.SafariPay(1200)
|
||||
push = wrapper(push)
|
||||
|
||||
wrapper = h265.RTPDepay(track)
|
||||
push = wrapper(push)
|
||||
}
|
||||
|
||||
track = track.Bind(push)
|
@@ -1,12 +1,15 @@
|
||||
package webrtc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/pion/ice/v2"
|
||||
"github.com/pion/stun"
|
||||
"github.com/pion/webrtc/v3"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func NewCandidate(address string) (string, error) {
|
||||
@@ -34,13 +37,47 @@ func NewCandidate(address string) (string, error) {
|
||||
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() (net.IP, error) {
|
||||
c, err := stun.Dial("udp", "stun.l.google.com:19302")
|
||||
conn, err := net.Dial("udp", "stun.l.google.com:19302")
|
||||
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)
|
||||
@@ -63,6 +100,33 @@ func GetPublicIP() (net.IP, error) {
|
||||
return xorAddr.IP, nil
|
||||
}
|
||||
|
||||
var cachedIP net.IP
|
||||
var cachedTS time.Time
|
||||
|
||||
func GetCachedPublicIP() (net.IP, error) {
|
||||
now := time.Now()
|
||||
if now.After(cachedTS) {
|
||||
newIP, err := GetPublicIP()
|
||||
if err == nil {
|
||||
cachedIP = newIP
|
||||
cachedTS = now.Add(time.Minute * 5)
|
||||
} else if cachedIP == nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return cachedIP, nil
|
||||
}
|
||||
|
||||
func IsIP(host string) bool {
|
||||
for _, i := range host {
|
||||
if i >= 'A' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func MimeType(codec *streamer.Codec) string {
|
||||
switch codec.Name {
|
||||
case streamer.CodecH264:
|
||||
|
@@ -2,11 +2,18 @@
|
||||
|
||||
- UPX-3.96 pack broken bin for `linux_mipsel`
|
||||
- UPX-3.95 pack broken bin for `mac_amd64`
|
||||
- UPX windows pack is recognised by anti-viruses as malicious
|
||||
- `aarch64` = `arm64`
|
||||
- `armv7` = `arm`
|
||||
|
||||
## Virus
|
||||
|
||||
- https://go.dev/doc/faq#virus
|
||||
- https://groups.google.com/g/golang-nuts/c/lPwiWYaApSU
|
||||
|
||||
## Useful links
|
||||
|
||||
- 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
|
||||
|
@@ -2,13 +2,13 @@
|
||||
|
||||
@SET GOOS=windows
|
||||
@SET GOARCH=amd64
|
||||
@SET FILENAME=go2rtc_win64.exe
|
||||
go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx-3.96 %FILENAME%
|
||||
@SET FILENAME=go2rtc_win64.zip
|
||||
go build -ldflags "-s -w" -trimpath && 7z a -sdel %FILENAME% go2rtc.exe
|
||||
|
||||
@SET GOOS=windows
|
||||
@SET GOARCH=386
|
||||
@SET FILENAME=go2rtc_win32.exe
|
||||
go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx-3.96 %FILENAME%
|
||||
@SET FILENAME=go2rtc_win32.zip
|
||||
go build -ldflags "-s -w" -trimpath && 7z a -sdel %FILENAME% go2rtc.exe
|
||||
|
||||
@SET GOOS=linux
|
||||
@SET GOARCH=amd64
|
||||
|
5
scripts/build_linux_amd64.cmd
Normal file
5
scripts/build_linux_amd64.cmd
Normal file
@@ -0,0 +1,5 @@
|
||||
@SET GOOS=linux
|
||||
@SET GOARCH=amd64
|
||||
@cd ..
|
||||
del go2rtc
|
||||
go build -ldflags "-s -w" -trimpath
|
5
scripts/build_linux_arm.cmd
Normal file
5
scripts/build_linux_arm.cmd
Normal file
@@ -0,0 +1,5 @@
|
||||
@SET GOOS=linux
|
||||
@SET GOARCH=arm
|
||||
@cd ..
|
||||
del go2rtc
|
||||
go build -ldflags "-s -w" -trimpath
|
5
scripts/build_linux_mips.cmd
Normal file
5
scripts/build_linux_mips.cmd
Normal file
@@ -0,0 +1,5 @@
|
||||
@ECHO OFF
|
||||
@SET GOOS=linux
|
||||
@SET GOARCH=mipsle
|
||||
cd ..
|
||||
go build -ldflags "-s -w" -trimpath && upx-3.95 go2rtc
|
5
scripts/build_mac_amd64.cmd
Normal file
5
scripts/build_mac_amd64.cmd
Normal file
@@ -0,0 +1,5 @@
|
||||
@SET GOOS=darwin
|
||||
@SET GOARCH=amd64
|
||||
@cd ..
|
||||
del go2rtc
|
||||
go build -ldflags "-s -w" -trimpath
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user