Merge branch 'AlexxIT:master' into master

This commit is contained in:
huynhquangtoan
2024-12-31 10:16:29 +07:00
committed by GitHub
23 changed files with 480 additions and 104 deletions
+4 -3
View File
@@ -170,7 +170,7 @@ Available modules:
- [api](#module-api) - HTTP API (important for WebRTC support)
- [rtsp](#module-rtsp) - RTSP Server (important for FFmpeg support)
- [webrtc](#module-webrtc) - WebRTC Server
- [mp4](#module-mp4) - MSE, MP4 stream and MP4 shapshot Server
- [mp4](#module-mp4) - MSE, MP4 stream and MP4 snapshot Server
- [hls](#module-hls) - HLS TS or fMP4 stream Server
- [mjpeg](#module-mjpeg) - MJPEG Server
- [ffmpeg](#source-ffmpeg) - FFmpeg integration
@@ -648,10 +648,11 @@ This source type support Roborock vacuums with cameras. Known working models:
- Roborock S6 MaxV - only video (the vacuum has no microphone)
- Roborock S7 MaxV - video and two way audio
- Roborock Qrevo MaxV - video and two way audio
Source support load Roborock credentials from Home Assistant [custom integration](https://github.com/humbertogontijo/homeassistant-roborock). Otherwise, you need to log in to your Roborock account (MiHome account is not supported). Go to: go2rtc WebUI > Add webpage. Copy `roborock://...` source for your vacuum and paste it to `go2rtc.yaml` config.
Source support load Roborock credentials from Home Assistant [custom integration](https://github.com/humbertogontijo/homeassistant-roborock) or the [core integration](https://www.home-assistant.io/integrations/roborock). Otherwise, you need to log in to your Roborock account (MiHome account is not supported). Go to: go2rtc WebUI > Add webpage. Copy `roborock://...` source for your vacuum and paste it to `go2rtc.yaml` config.
If you have graphic pin for your vacuum - add it as numeric pin (lines: 123, 456, 678) to the end of the roborock-link.
If you have graphic pin for your vacuum - add it as numeric pin (lines: 123, 456, 789) to the end of the roborock-link.
#### Source: WebRTC
+3 -3
View File
@@ -19,15 +19,15 @@ go2rtc -c log.format=text -c /config/go2rtc.yaml -c rtsp.listen='' -c /usr/local
## Environment variables
Also go2rtc support templates for using environment variables in any part of config:
There is support for loading external variables into the config. First, they will be attempted to be loaded from [credential files](https://systemd.io/CREDENTIALS). If `CREDENTIALS_DIRECTORY` is not set, then the key will be loaded from an environment variable. If no environment variable is set, then the string will be left as-is.
```yaml
streams:
camera1: rtsp://rtsp:${CAMERA_PASSWORD}@192.168.1.123/av_stream/ch0
rtsp:
username: ${RTSP_USER:admin} # "admin" if env "RTSP_USER" not set
password: ${RTSP_PASS:secret} # "secret" if env "RTSP_PASS" not set
username: ${RTSP_USER:admin} # "admin" if "RTSP_USER" not set
password: ${RTSP_PASS:secret} # "secret" if "RTSP_PASS" not set
```
## JSON Schema
+36
View File
@@ -0,0 +1,36 @@
package doorbird
import (
"net/url"
"github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/doorbird"
)
func Init() {
streams.RedirectFunc("doorbird", func(rawURL string) (string, error) {
u, err := url.Parse(rawURL)
if err != nil {
return "", err
}
// https://www.doorbird.com/downloads/api_lan.pdf
switch u.Query().Get("media") {
case "video":
u.Path = "/bha-api/video.cgi"
case "audio":
u.Path = "/bha-api/audio-receive.cgi"
default:
return "", nil
}
u.Scheme = "http"
return u.String(), nil
})
streams.HandleFunc("doorbird", func(source string) (core.Producer, error) {
return doorbird.Dial(source)
})
}
+5
View File
@@ -179,6 +179,7 @@ func parseArgs(s string) *ffmpeg.Args {
Version: verAV,
}
var source = s
var query url.Values
if i := strings.IndexByte(s, '#'); i >= 0 {
query = streams.ParseQuery(s[i+1:])
@@ -221,6 +222,10 @@ func parseArgs(s string) *ffmpeg.Args {
default:
s += "?video&audio"
}
s += "&source=ffmpeg:" + url.QueryEscape(source)
for _, v := range query["query"] {
s += "&" + v
}
args.Input = inputTemplate("rtsp", s, query)
} else if i = strings.Index(s, "?"); i > 0 {
switch s[:i] {
+4
View File
@@ -14,6 +14,7 @@ import (
"github.com/AlexxIT/go2rtc/pkg/image"
"github.com/AlexxIT/go2rtc/pkg/magic"
"github.com/AlexxIT/go2rtc/pkg/mpjpeg"
"github.com/AlexxIT/go2rtc/pkg/pcm"
"github.com/AlexxIT/go2rtc/pkg/tcp"
)
@@ -87,6 +88,9 @@ func do(req *http.Request) (core.Producer, error) {
return image.Open(res)
case ct == "multipart/x-mixed-replace":
return mpjpeg.Open(res.Body)
//https://www.iana.org/assignments/media-types/audio/basic
case ct == "audio/basic":
return pcm.Open(res.Body)
}
return magic.Open(res.Body)
+10
View File
@@ -70,6 +70,9 @@ func onvifDeviceService(w http.ResponseWriter, r *http.Request) {
// important for Hass: Media section
res = onvif.GetCapabilitiesResponse(r.Host)
case onvif.ActionGetServices:
res = onvif.GetServicesResponse(r.Host)
case onvif.ActionGetSystemDateAndTime:
// important for Hass
res = onvif.GetSystemDateAndTimeResponse()
@@ -97,6 +100,9 @@ func onvifDeviceService(w http.ResponseWriter, r *http.Request) {
// important for Hass: H264 codec, width, height
res = onvif.GetProfilesResponse(streams.GetAll())
case onvif.ActionGetVideoSources:
res = onvif.GetVideoSourcesResponse(streams.GetAll())
case onvif.ActionGetStreamUri:
host, _, err := net.SplitHostPort(r.Host)
if err != nil {
@@ -107,6 +113,10 @@ func onvifDeviceService(w http.ResponseWriter, r *http.Request) {
uri := "rtsp://" + host + ":" + rtsp.Port + "/" + onvif.FindTagValue(b, "ProfileToken")
res = onvif.GetStreamUriResponse(uri)
case onvif.ActionGetSnapshotUri:
uri := "http://" + r.Host + "/api/frame.jpeg?src=" + onvif.FindTagValue(b, "ProfileToken")
res = onvif.GetSnapshotUriResponse(uri)
default:
http.Error(w, "unsupported action", http.StatusBadRequest)
log.Debug().Msgf("[onvif] unsupported request:\n%s", b)
+13 -2
View File
@@ -147,6 +147,7 @@ func tcpHandler(conn *rtsp.Conn) {
var closer func()
trace := log.Trace().Enabled()
level := zerolog.WarnLevel
conn.Listen(func(msg any) {
if trace {
@@ -188,8 +189,18 @@ func tcpHandler(conn *rtsp.Conn) {
conn.PacketSize = uint16(core.Atoi(s))
}
// param name like ffmpeg style https://ffmpeg.org/ffmpeg-protocols.html
if s := query.Get("log_level"); s != "" {
if lvl, err := zerolog.ParseLevel(s); err == nil {
level = lvl
}
}
// will help to protect looping requests to same source
conn.Connection.Source = query.Get("source")
if err := stream.AddConsumer(conn); err != nil {
log.Warn().Err(err).Str("stream", name).Msg("[rtsp]")
log.WithLevel(level).Err(err).Str("stream", name).Msg("[rtsp]")
return
}
@@ -227,7 +238,7 @@ func tcpHandler(conn *rtsp.Conn) {
if err := conn.Accept(); err != nil {
if err != io.EOF {
log.Warn().Err(err).Caller().Send()
log.WithLevel(level).Err(err).Caller().Send()
}
if closer != nil {
closer()
+8 -2
View File
@@ -22,6 +22,12 @@ func (s *Stream) AddConsumer(cons core.Consumer) (err error) {
producers:
for prodN, prod := range s.producers {
// check for loop request, ex. `camera1: ffmpeg:camera1`
if info, ok := cons.(core.Info); ok && prod.url == info.GetSource() {
log.Trace().Msgf("[streams] skip cons=%d prod=%d", consN, prodN)
continue
}
if prodErrors[prodN] != nil {
log.Trace().Msgf("[streams] skip cons=%d prod=%d", consN, prodN)
continue
@@ -129,7 +135,7 @@ func formatError(consMedias, prodMedias []*core.Media, prodErrors []error) error
for _, media := range prodMedias {
if media.Direction == core.DirectionRecvonly {
for _, codec := range media.Codecs {
prod = appendString(prod, codec.PrintName())
prod = appendString(prod, media.Kind+":"+codec.PrintName())
}
}
}
@@ -137,7 +143,7 @@ func formatError(consMedias, prodMedias []*core.Media, prodErrors []error) error
for _, media := range consMedias {
if media.Direction == core.DirectionSendonly {
for _, codec := range media.Codecs {
cons = appendString(cons, codec.PrintName())
cons = appendString(cons, media.Kind+":"+codec.PrintName())
}
}
}
+1 -1
View File
@@ -103,7 +103,7 @@ func (s *Stream) Play(source string) error {
}
func (s *Stream) AddInternalProducer(conn core.Producer) {
producer := &Producer{conn: conn, state: stateInternal}
producer := &Producer{conn: conn, state: stateInternal, url: "internal"}
s.mu.Lock()
s.producers = append(s.producers, producer)
s.mu.Unlock()
+1 -1
View File
@@ -76,7 +76,7 @@ func (s *Stream) RemoveConsumer(cons core.Consumer) {
}
func (s *Stream) AddProducer(prod core.Producer) {
producer := &Producer{conn: prod, state: stateExternal}
producer := &Producer{conn: prod, state: stateExternal, url: "external"}
s.mu.Lock()
s.producers = append(s.producers, producer)
s.mu.Unlock()
+4
View File
@@ -15,4 +15,8 @@ func Init() {
streams.HandleFunc("tapo", func(source string) (core.Producer, error) {
return tapo.Dial(source)
})
streams.HandleFunc("vigi", func(source string) (core.Producer, error) {
return tapo.Dial(source)
})
}
+3 -1
View File
@@ -6,6 +6,7 @@ import (
"github.com/AlexxIT/go2rtc/internal/app"
"github.com/AlexxIT/go2rtc/internal/bubble"
"github.com/AlexxIT/go2rtc/internal/debug"
"github.com/AlexxIT/go2rtc/internal/doorbird"
"github.com/AlexxIT/go2rtc/internal/dvrip"
"github.com/AlexxIT/go2rtc/internal/echo"
"github.com/AlexxIT/go2rtc/internal/exec"
@@ -36,7 +37,7 @@ import (
)
func main() {
app.Version = "1.9.6"
app.Version = "1.9.7"
// 1. Core modules: app, api/ws, streams
@@ -82,6 +83,7 @@ func main() {
bubble.Init() // bubble source
expr.Init() // expr source
gopro.Init() // gopro source
doorbird.Init() // doorbird source
// 6. Helper modules
+5
View File
@@ -25,6 +25,7 @@ type Info interface {
SetSource(string)
SetURL(string)
WithRequest(*http.Request)
GetSource() string
}
// Connection just like webrtc.PeerConnection
@@ -123,6 +124,10 @@ func (c *Connection) WithRequest(r *http.Request) {
c.UserAgent = r.UserAgent()
}
func (c *Connection) GetSource() string {
return c.Source
}
// Create like os.Create, init Consumer with existing Transport
func Create(w io.Writer) (*Connection, error) {
return &Connection{Transport: w}, nil
+93
View File
@@ -0,0 +1,93 @@
package doorbird
import (
"fmt"
"net"
"net/url"
"time"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/pion/rtp"
)
type Client struct {
core.Connection
conn net.Conn
}
func Dial(rawURL string) (*Client, error) {
u, err := url.Parse(rawURL)
if err != nil {
return nil, err
}
user := u.User.Username()
pass, _ := u.User.Password()
if u.Port() == "" {
u.Host += ":80"
}
conn, err := net.DialTimeout("tcp", u.Host, core.ConnDialTimeout)
if err != nil {
return nil, err
}
s := fmt.Sprintf("POST /bha-api/audio-transmit.cgi?http-user=%s&http-password=%s HTTP/1.0\r\n", user, pass) +
"Content-Type: audio/basic\r\n" +
"Content-Length: 9999999\r\n" +
"Connection: Keep-Alive\r\n" +
"Cache-Control: no-cache\r\n" +
"\r\n"
_ = conn.SetWriteDeadline(time.Now().Add(core.ConnDeadline))
if _, err = conn.Write([]byte(s)); err != nil {
return nil, err
}
medias := []*core.Media{
{
Kind: core.KindAudio,
Direction: core.DirectionSendonly,
Codecs: []*core.Codec{
{Name: core.CodecPCMU, ClockRate: 8000},
},
},
}
return &Client{
core.Connection{
ID: core.NewID(),
FormatName: "doorbird",
Protocol: "http",
URL: rawURL,
Medias: medias,
Transport: conn,
},
conn,
}, nil
}
func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) {
return nil, core.ErrCantGetTrack
}
func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error {
sender := core.NewSender(media, track.Codec)
sender.Handler = func(pkt *rtp.Packet) {
_ = c.conn.SetWriteDeadline(time.Now().Add(core.ConnDeadline))
if n, err := c.conn.Write(pkt.Payload); err == nil {
c.Send += n
}
}
sender.HandleRTP(track)
c.Senders = append(c.Senders, sender)
return nil
}
func (c *Client) Start() (err error) {
_, err = c.conn.Read(nil)
return
}
+27 -15
View File
@@ -140,23 +140,29 @@ func (c *Producer) probe() error {
// 1. Empty video/audio flag
// 2. MedaData without stereo key for AAC
// 3. Audio header after Video keyframe tag
waitType := []byte{TagData}
timeout := time.Now().Add(core.ProbeTimeout)
for len(waitType) != 0 && time.Now().Before(timeout) {
// OpenIPC camera sends:
// 1. Empty video/audio flag
// 2. No MetaData packet
// 3. Sends a video packet in more than 3 seconds
waitVideo := true
waitAudio := true
timeout := time.Now().Add(time.Second * 5)
for (waitVideo || waitAudio) && time.Now().Before(timeout) {
pkt, err := c.readPacket()
if err != nil {
return err
}
if i := bytes.IndexByte(waitType, pkt.PayloadType); i < 0 {
continue
} else {
waitType = append(waitType[:i], waitType[i+1:]...)
}
//log.Printf("%d %0.20s", pkt.PayloadType, pkt.Payload)
switch pkt.PayloadType {
case TagAudio:
if !waitAudio {
continue
}
_ = pkt.Payload[1] // bounds
codecID := pkt.Payload[0] >> 4 // SoundFormat
@@ -179,8 +185,13 @@ func (c *Producer) probe() error {
Codecs: []*core.Codec{codec},
}
c.Medias = append(c.Medias, media)
waitAudio = false
case TagVideo:
if !waitVideo {
continue
}
var codec *core.Codec
if isExHeader(pkt.Payload) {
@@ -213,19 +224,20 @@ func (c *Producer) probe() error {
Codecs: []*core.Codec{codec},
}
c.Medias = append(c.Medias, media)
waitVideo = false
case TagData:
if !bytes.Contains(pkt.Payload, []byte("onMetaData")) {
waitType = append(waitType, TagData)
continue
}
// Dahua cameras doesn't send videocodecid
if bytes.Contains(pkt.Payload, []byte("videocodecid")) ||
bytes.Contains(pkt.Payload, []byte("width")) ||
bytes.Contains(pkt.Payload, []byte("framerate")) {
waitType = append(waitType, TagVideo)
if !bytes.Contains(pkt.Payload, []byte("videocodecid")) &&
!bytes.Contains(pkt.Payload, []byte("width")) &&
!bytes.Contains(pkt.Payload, []byte("framerate")) {
waitVideo = false
}
if bytes.Contains(pkt.Payload, []byte("audiocodecid")) {
waitType = append(waitType, TagAudio)
if !bytes.Contains(pkt.Payload, []byte("audiocodecid")) {
waitAudio = false
}
}
}
+105 -31
View File
@@ -16,6 +16,7 @@ const (
ActionGetServiceCapabilities = "GetServiceCapabilities"
ActionGetProfiles = "GetProfiles"
ActionGetStreamUri = "GetStreamUri"
ActionGetSnapshotUri = "GetSnapshotUri"
ActionSystemReboot = "SystemReboot"
ActionGetServices = "GetServices"
@@ -45,23 +46,49 @@ func GetRequestAction(b []byte) string {
func GetCapabilitiesResponse(host string) string {
return `<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope">
<s:Body>
<tds:GetCapabilitiesResponse xmlns:tds="http://www.onvif.org/ver10/device/wsdl">
<tds:Capabilities xmlns:tt="http://www.onvif.org/ver10/schema">
<tt:Device>
<tt:XAddr>http://` + host + `/onvif/device_service</tt:XAddr>
</tt:Device>
<tt:Media>
<tt:XAddr>http://` + host + `/onvif/media_service</tt:XAddr>
<tt:StreamingCapabilities>
<tt:RTPMulticast>false</tt:RTPMulticast>
<tt:RTP_TCP>false</tt:RTP_TCP>
<tt:RTP_RTSP_TCP>true</tt:RTP_RTSP_TCP>
</tt:StreamingCapabilities>
</tt:Media>
</tds:Capabilities>
</tds:GetCapabilitiesResponse>
</s:Body>
<s:Body>
<tds:GetCapabilitiesResponse xmlns:tds="http://www.onvif.org/ver10/device/wsdl">
<tds:Capabilities xmlns:tt="http://www.onvif.org/ver10/schema">
<tt:Device>
<tt:XAddr>http://` + host + `/onvif/device_service</tt:XAddr>
</tt:Device>
<tt:Media>
<tt:XAddr>http://` + host + `/onvif/media_service</tt:XAddr>
<tt:StreamingCapabilities>
<tt:RTPMulticast>false</tt:RTPMulticast>
<tt:RTP_TCP>false</tt:RTP_TCP>
<tt:RTP_RTSP_TCP>true</tt:RTP_RTSP_TCP>
</tt:StreamingCapabilities>
</tt:Media>
</tds:Capabilities>
</tds:GetCapabilitiesResponse>
</s:Body>
</s:Envelope>`
}
func GetServicesResponse(host string) string {
return `<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope">
<s:Body>
<tds:GetServicesResponse xmlns:tds="http://www.onvif.org/ver10/device/wsdl">
<tds:Service>
<tds:Namespace>http://www.onvif.org/ver10/device/wsdl</tds:Namespace>
<tds:XAddr>http://` + host + `/onvif/device_service</tds:XAddr>
<tds:Version>
<tds:Major>2</tds:Major>
<tds:Minor>5</tds:Minor>
</tds:Version>
</tds:Service>
<tds:Service>
<tds:Namespace>http://www.onvif.org/ver10/media/wsdl</tds:Namespace>
<tds:XAddr>http://` + host + `/onvif/media_service</tds:XAddr>
<tds:Version>
<tds:Major>2</tds:Major>
<tds:Minor>5</tds:Minor>
</tds:Version>
</tds:Service>
</tds:GetServicesResponse>
</s:Body>
</s:Envelope>`
}
@@ -142,7 +169,7 @@ func GetServiceCapabilitiesResponse() string {
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope">
<s:Body>
<trt:GetServiceCapabilitiesResponse xmlns:trt="http://www.onvif.org/ver10/media/wsdl">
<trt:Capabilities SnapshotUri="false" Rotation="false" VideoSourceMode="false" OSD="false" TemporaryOSDText="false" EXICompression="false">
<trt:Capabilities SnapshotUri="true" Rotation="false" VideoSourceMode="false" OSD="false" TemporaryOSDText="false" EXICompression="false">
<trt:StreamingCapabilities RTPMulticast="false" RTP_TCP="false" RTP_RTSP_TCP="true" NonAggregateControl="false" NoRTSPStreaming="false" />
</trt:Capabilities>
</trt:GetServiceCapabilitiesResponse>
@@ -170,21 +197,55 @@ func GetProfilesResponse(names []string) string {
for i, name := range names {
buf.WriteString(`
<trt:Profiles token="` + name + `" fixed="true">
<tt:Name>` + name + `</tt:Name>
<tt:VideoEncoderConfiguration token="` + strconv.Itoa(i) + `">
<tt:Encoding>H264</tt:Encoding>
<tt:Resolution>
<tt:Width>1920</tt:Width>
<tt:Height>1080</tt:Height>
</tt:Resolution>
</tt:VideoEncoderConfiguration>
</trt:Profiles>`)
<trt:Profiles token="` + name + `" fixed="true">
<trt:Name>` + name + `</trt:Name>
<trt:VideoEncoderConfiguration token="` + strconv.Itoa(i) + `">
<trt:Name>` + name + `</trt:Name>
<trt:Encoding>H264</trt:Encoding>
<trt:Resolution>
<trt:Width>1920</trt:Width>
<trt:Height>1080</trt:Height>
</trt:Resolution>
<trt:RateControl>
</trt:RateControl>
</trt:VideoEncoderConfiguration>
<trt:VideoSourceConfiguration token="` + strconv.Itoa(i) + `">
<trt:Name>` + name + `</trt:Name>
<trt:SourceToken>` + strconv.Itoa(i) + `</trt:SourceToken>
<trt:Bounds x="0" y="0" width="1920" height="1080"></trt:Bounds>
</trt:VideoSourceConfiguration>
</trt:Profiles>`)
}
buf.WriteString(`
</trt:GetProfilesResponse>
</s:Body>
</trt:GetProfilesResponse>
</s:Body>
</s:Envelope>`)
return buf.String()
}
func GetVideoSourcesResponse(names []string) string {
buf := bytes.NewBuffer(nil)
buf.WriteString(`<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope">
<s:Body>
<trt:GetVideoSourcesResponse xmlns:trt="http://www.onvif.org/ver10/media/wsdl">`)
for i, _ := range names {
buf.WriteString(`
<trt:VideoSources token="` + strconv.Itoa(i) + `">
<trt:Resolution>
<trt:Width>1920</trt:Width>
<trt:Height>1080</trt:Height>
</trt:Resolution>
</trt:VideoSources>`)
}
buf.WriteString(`
</trt:GetVideoSourcesResponse >
</s:Body>
</s:Envelope>`)
return buf.String()
@@ -196,9 +257,22 @@ func GetStreamUriResponse(uri string) string {
<s:Body>
<trt:GetStreamUriResponse xmlns:trt="http://www.onvif.org/ver10/media/wsdl">
<trt:MediaUri>
<tt:Uri xmlns:tt="http://www.onvif.org/ver10/schema">` + uri + `</tt:Uri>
<trt:Uri>` + uri + `</trt:Uri>
</trt:MediaUri>
</trt:GetStreamUriResponse>
</s:Body>
</s:Envelope>`
}
func GetSnapshotUriResponse(uri string) string {
return `<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope">
<s:Body>
<trt:GetSnapshotUriResponse xmlns:trt="http://www.onvif.org/ver10/media/wsdl">
<trt:MediaUri>
<trt:Uri>` + uri + `</trt:Uri>
</trt:MediaUri>
</trt:GetSnapshotUriResponse>
</s:Body>
</s:Envelope>`
}
+55
View File
@@ -0,0 +1,55 @@
package pcm
import (
"io"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/pion/rtp"
)
type Producer struct {
core.Connection
rd io.Reader
}
func Open(rd io.Reader) (*Producer, error) {
medias := []*core.Media{
{
Kind: core.KindAudio,
Direction: core.DirectionRecvonly,
Codecs: []*core.Codec{
{Name: core.CodecPCMU, ClockRate: 8000},
},
},
}
return &Producer{
core.Connection{
ID: core.NewID(),
FormatName: "pcm",
Medias: medias,
Transport: rd,
},
rd,
}, nil
}
func (c *Producer) Start() error {
for {
payload := make([]byte, 1024)
if _, err := io.ReadFull(c.rd, payload); err != nil {
return err
}
c.Recv += 1024
if len(c.Receivers) == 0 {
continue
}
pkt := &rtp.Packet{
Header: rtp.Header{Timestamp: core.Now90000()},
Payload: payload,
}
c.Receivers[0].WriteRTP(pkt)
}
}
+7 -6
View File
@@ -117,10 +117,6 @@ func (c *Conn) acceptCommand(b []byte) error {
}
}
if c.App == "" {
return fmt.Errorf("rtmp: read command %x", b)
}
payload := amf.EncodeItems(
"_result", tID,
map[string]any{"fmsVer": "FMS/3,0,1,123"},
@@ -129,9 +125,16 @@ func (c *Conn) acceptCommand(b []byte) error {
return c.writeMessage(3, TypeCommand, 0, payload)
case CommandReleaseStream:
// if app is empty - will use key as app
if c.App == "" && len(items) == 4 {
c.App, _ = items[3].(string)
}
payload := amf.EncodeItems("_result", tID, nil)
return c.writeMessage(3, TypeCommand, 0, payload)
case CommandFCPublish: // no response
case CommandCreateStream:
payload := amf.EncodeItems("_result", tID, nil, 1)
return c.writeMessage(3, TypeCommand, 0, payload)
@@ -140,8 +143,6 @@ func (c *Conn) acceptCommand(b []byte) error {
c.Intent = cmd
c.streamID = 1
case CommandFCPublish: // no response
default:
println("rtmp: unknown command: " + cmd)
}
+9 -2
View File
@@ -70,8 +70,15 @@ func UnmarshalSDP(rawSDP []byte) ([]*core.Media, error) {
// Check buggy SDP with fmtp for H264 on another track
// https://github.com/AlexxIT/WebRTC/issues/419
for _, codec := range media.Codecs {
if codec.Name == core.CodecH264 && codec.FmtpLine == "" {
codec.FmtpLine = findFmtpLine(codec.PayloadType, sd.MediaDescriptions)
switch codec.Name {
case core.CodecH264:
if codec.FmtpLine == "" {
codec.FmtpLine = findFmtpLine(codec.PayloadType, sd.MediaDescriptions)
}
case core.CodecOpus:
// fix OPUS for some cameras https://datatracker.ietf.org/doc/html/rfc7587
codec.ClockRate = 48000
codec.Channels = 2
}
}
+4 -4
View File
@@ -149,7 +149,7 @@ func (c *Conn) Accept() error {
}
const transport = "RTP/AVP/TCP;unicast;interleaved="
if strings.HasPrefix(tr, transport) {
if tr = core.Between(tr, "interleaved=", ";"); tr != "" {
c.session = core.RandString(8, 10)
c.state = StateSetup
@@ -157,13 +157,13 @@ func (c *Conn) Accept() error {
if i := reqTrackID(req); i >= 0 && i < len(c.Senders) {
// mark sender as SETUP
c.Senders[i].Media.ID = MethodSetup
tr = fmt.Sprintf("RTP/AVP/TCP;unicast;interleaved=%d-%d", i*2, i*2+1)
res.Header.Set("Transport", tr)
tr = fmt.Sprintf("%d-%d", i*2, i*2+1)
res.Header.Set("Transport", transport+tr)
} else {
res.Status = "400 Bad Request"
}
} else {
res.Header.Set("Transport", tr[:len(transport)+3])
res.Header.Set("Transport", transport+tr)
}
} else {
res.Status = "461 Unsupported transport"
+8
View File
@@ -3,6 +3,7 @@ package shell
import (
"os"
"os/signal"
"path/filepath"
"regexp"
"strings"
"syscall"
@@ -51,6 +52,13 @@ func ReplaceEnvVars(text string) string {
dok = true
}
if dir, vok := os.LookupEnv("CREDENTIALS_DIRECTORY"); vok {
value, err := os.ReadFile(filepath.Join(dir, key))
if err == nil {
return strings.TrimSpace(string(value))
}
}
if value, vok := os.LookupEnv(key); vok {
return value
}
+74 -32
View File
@@ -27,7 +27,7 @@ import (
type Client struct {
core.Listener
url string
url *url.URL
medias []*core.Media
receivers []*core.Receiver
@@ -52,17 +52,15 @@ type cbcMode interface {
SetIV([]byte)
}
func Dial(url string) (*Client, error) {
var err error
c := &Client{url: url}
if c.conn1, err = c.newConn(); err != nil {
return nil, err
}
return c, nil
}
func (c *Client) newConn() (net.Conn, error) {
u, err := url.Parse(c.url)
// Dial support different urls:
// - tapo://{cloud-password}@192.168.1.123 - auth to Tapo cameras
// with cloud password (autodetect hash method)
// - tapo://admin:{hashed-cloud-password}@192.168.1.123 - auth to Tapo cameras
// with pre-hashed cloud password
// - vigi://admin:{password}@192.168.1.123 - auth to Vigi cameras with password
// for admin account (other not supported)
func Dial(rawURL string) (*Client, error) {
u, err := url.Parse(rawURL)
if err != nil {
return nil, err
}
@@ -71,21 +69,31 @@ func (c *Client) newConn() (net.Conn, error) {
u.Host += ":8800"
}
req, err := http.NewRequest("POST", "http://"+u.Host+"/stream", nil)
c := &Client{url: u}
if c.conn1, err = c.newConn(); err != nil {
return nil, err
}
return c, nil
}
func (c *Client) newConn() (net.Conn, error) {
req, err := http.NewRequest("POST", "http://"+c.url.Host+"/stream", nil)
if err != nil {
return nil, err
}
query := u.Query()
query := c.url.Query()
if deviceId := query.Get("deviceId"); deviceId != "" {
req.URL.RawQuery = "deviceId=" + deviceId
}
req.URL.User = u.User
req.Header.Set("Content-Type", "multipart/mixed; boundary=--client-stream-boundary--")
conn, res, err := dial(req)
username := c.url.User.Username()
password, _ := c.url.User.Password()
conn, res, err := dial(req, c.url.Scheme, username, password)
if err != nil {
return nil, err
}
@@ -95,7 +103,7 @@ func (c *Client) newConn() (net.Conn, error) {
}
if c.decrypt == nil {
c.newDectypter(res)
c.newDectypter(res, c.url.Scheme, username, password)
}
channel := query.Get("channel")
@@ -119,14 +127,18 @@ func (c *Client) newConn() (net.Conn, error) {
return conn, nil
}
func (c *Client) newDectypter(res *http.Response) {
username := res.Request.URL.User.Username()
password, _ := res.Request.URL.User.Password()
func (c *Client) newDectypter(res *http.Response, brand, username, password string) {
exchange := res.Header.Get("Key-Exchange")
nonce := core.Between(exchange, `nonce="`, `"`)
// extract nonce from response
// cipher="AES_128_CBC" username="admin" padding="PKCS7_16" algorithm="MD5" nonce="***"
nonce := res.Header.Get("Key-Exchange")
nonce = core.Between(nonce, `nonce="`, `"`)
if brand == "tapo" && password == "" {
if strings.Contains(exchange, `encrypt_type="3"`) {
password = fmt.Sprintf("%32X", sha256.Sum256([]byte(username)))
} else {
password = fmt.Sprintf("%16X", md5.Sum([]byte(username)))
}
username = "admin"
}
key := md5.Sum([]byte(nonce + ":" + password))
iv := md5.Sum([]byte(username + ":" + nonce))
@@ -263,16 +275,12 @@ func (c *Client) Request(conn net.Conn, body []byte) (string, error) {
}
}
func dial(req *http.Request) (net.Conn, *http.Response, error) {
func dial(req *http.Request, brand, username, password string) (net.Conn, *http.Response, error) {
conn, err := net.DialTimeout("tcp", req.URL.Host, core.ConnDialTimeout)
if err != nil {
return nil, nil, err
}
username := req.URL.User.Username()
password, _ := req.URL.User.Password()
req.URL.User = nil
if err = req.Write(conn); err != nil {
return nil, nil, err
}
@@ -291,7 +299,7 @@ func dial(req *http.Request) (net.Conn, *http.Response, error) {
return nil, nil, fmt.Errorf("Expected StatusCode to be %d, received %d", http.StatusUnauthorized, res.StatusCode)
}
if password == "" {
if brand == "tapo" && password == "" {
// support cloud password in place of username
if strings.Contains(auth, `encrypt_type="3"`) {
password = fmt.Sprintf("%32X", sha256.Sum256([]byte(username)))
@@ -299,6 +307,8 @@ func dial(req *http.Request) (net.Conn, *http.Response, error) {
password = fmt.Sprintf("%16X", md5.Sum([]byte(username)))
}
username = "admin"
} else if brand == "vigi" && username == "admin" {
password = securityEncode(password)
}
realm := tcp.Between(auth, `realm="`, `"`)
@@ -331,7 +341,39 @@ func dial(req *http.Request) (net.Conn, *http.Response, error) {
return nil, nil, err
}
req.URL.User = url.UserPassword(username, password)
return conn, res, nil
}
const (
keyShort = "RDpbLfCPsJZ7fiv"
keyLong = "yLwVl0zKqws7LgKPRQ84Mdt708T1qQ3Ha7xv3H7NyU84p21BriUWBU43odz3iP4rBL3cD02KZciXTysVXiV8ngg6vL48rPJyAUw0HurW20xqxv9aYb4M9wK1Ae0wlro510qXeU07kV57fQMc8L6aLgMLwygtc0F10a0Dg70TOoouyFhdysuRMO51yY5ZlOZZLEal1h0t9YQW0Ko7oBwmCAHoic4HYbUyVeU3sfQ1xtXcPcf1aT303wAQhv66qzW"
)
func securityEncode(s string) string {
size := len(s)
var n int // max
if size > len(keyShort) {
n = size
} else {
n = len(keyShort)
}
b := make([]byte, n)
for i := 0; i < n; i++ {
c1 := 187
c2 := 187
if i >= size {
c1 = int(keyShort[i])
} else if i >= len(keyShort) {
c2 = int(s[i])
} else {
c1 = int(keyShort[i])
c2 = int(s[i])
}
b[i] = keyLong[(c1^c2)%len(keyLong)]
}
return string(b)
}
+1 -1
View File
@@ -77,7 +77,7 @@ func (c *Client) Stop() error {
func (c *Client) MarshalJSON() ([]byte, error) {
info := &core.Connection{
ID: core.ID(c),
FormatName: "tapo",
FormatName: c.url.Scheme,
Protocol: "http",
Medias: c.medias,
Recv: c.recv,