diff --git a/README.md b/README.md
index 70ad4712..e87e35a0 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/internal/app/README.md b/internal/app/README.md
index 2460daa2..9ec3d9fc 100644
--- a/internal/app/README.md
+++ b/internal/app/README.md
@@ -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
diff --git a/internal/doorbird/doorbird.go b/internal/doorbird/doorbird.go
new file mode 100644
index 00000000..c56fc0f9
--- /dev/null
+++ b/internal/doorbird/doorbird.go
@@ -0,0 +1,36 @@
+package doorbird
+
+import (
+ "net/url"
+
+ "github.com/AlexxIT/go2rtc/internal/streams"
+ "github.com/AlexxIT/go2rtc/pkg/core"
+ "github.com/AlexxIT/go2rtc/pkg/doorbird"
+)
+
+func Init() {
+ streams.RedirectFunc("doorbird", func(rawURL string) (string, error) {
+ u, err := url.Parse(rawURL)
+ if err != nil {
+ return "", err
+ }
+
+ // https://www.doorbird.com/downloads/api_lan.pdf
+ switch u.Query().Get("media") {
+ case "video":
+ u.Path = "/bha-api/video.cgi"
+ case "audio":
+ u.Path = "/bha-api/audio-receive.cgi"
+ default:
+ return "", nil
+ }
+
+ u.Scheme = "http"
+
+ return u.String(), nil
+ })
+
+ streams.HandleFunc("doorbird", func(source string) (core.Producer, error) {
+ return doorbird.Dial(source)
+ })
+}
diff --git a/internal/ffmpeg/ffmpeg.go b/internal/ffmpeg/ffmpeg.go
index 12a9be83..25d61e4b 100644
--- a/internal/ffmpeg/ffmpeg.go
+++ b/internal/ffmpeg/ffmpeg.go
@@ -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] {
diff --git a/internal/http/http.go b/internal/http/http.go
index a35439d5..4b0560c1 100644
--- a/internal/http/http.go
+++ b/internal/http/http.go
@@ -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)
diff --git a/internal/onvif/init.go b/internal/onvif/init.go
index 014c5e18..e5ed9a7c 100644
--- a/internal/onvif/init.go
+++ b/internal/onvif/init.go
@@ -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)
diff --git a/internal/rtsp/rtsp.go b/internal/rtsp/rtsp.go
index 230bdece..0fe135f8 100644
--- a/internal/rtsp/rtsp.go
+++ b/internal/rtsp/rtsp.go
@@ -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()
diff --git a/internal/streams/add_consumer.go b/internal/streams/add_consumer.go
index eb767691..7400ce6e 100644
--- a/internal/streams/add_consumer.go
+++ b/internal/streams/add_consumer.go
@@ -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())
}
}
}
diff --git a/internal/streams/play.go b/internal/streams/play.go
index 7ada66e6..9bec7258 100644
--- a/internal/streams/play.go
+++ b/internal/streams/play.go
@@ -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()
diff --git a/internal/streams/stream.go b/internal/streams/stream.go
index e194e0ac..569e63ee 100644
--- a/internal/streams/stream.go
+++ b/internal/streams/stream.go
@@ -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()
diff --git a/internal/tapo/tapo.go b/internal/tapo/tapo.go
index 724c9e86..88eff5c4 100644
--- a/internal/tapo/tapo.go
+++ b/internal/tapo/tapo.go
@@ -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)
+ })
}
diff --git a/main.go b/main.go
index df3468f4..d5c59ffc 100644
--- a/main.go
+++ b/main.go
@@ -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
diff --git a/pkg/core/connection.go b/pkg/core/connection.go
index 2c3f2196..cc0f43e4 100644
--- a/pkg/core/connection.go
+++ b/pkg/core/connection.go
@@ -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
diff --git a/pkg/doorbird/backchannel.go b/pkg/doorbird/backchannel.go
new file mode 100644
index 00000000..82379383
--- /dev/null
+++ b/pkg/doorbird/backchannel.go
@@ -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
+}
diff --git a/pkg/flv/producer.go b/pkg/flv/producer.go
index 66755217..7535a8a4 100644
--- a/pkg/flv/producer.go
+++ b/pkg/flv/producer.go
@@ -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
}
}
}
diff --git a/pkg/onvif/server.go b/pkg/onvif/server.go
index f8f2883c..bc3f8ffe 100644
--- a/pkg/onvif/server.go
+++ b/pkg/onvif/server.go
@@ -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 `
-
-
-
-
- http://` + host + `/onvif/device_service
-
-
- http://` + host + `/onvif/media_service
-
- false
- false
- true
-
-
-
-
-
+
+
+
+
+ http://` + host + `/onvif/device_service
+
+
+ http://` + host + `/onvif/media_service
+
+ false
+ false
+ true
+
+
+
+
+
+`
+}
+
+func GetServicesResponse(host string) string {
+ return `
+
+
+
+
+ http://www.onvif.org/ver10/device/wsdl
+ http://` + host + `/onvif/device_service
+
+ 2
+ 5
+
+
+
+ http://www.onvif.org/ver10/media/wsdl
+ http://` + host + `/onvif/media_service
+
+ 2
+ 5
+
+
+
+
`
}
@@ -142,7 +169,7 @@ func GetServiceCapabilitiesResponse() string {
-
+
@@ -170,21 +197,55 @@ func GetProfilesResponse(names []string) string {
for i, name := range names {
buf.WriteString(`
-
- ` + name + `
-
- H264
-
- 1920
- 1080
-
-
- `)
+
+ ` + name + `
+
+ ` + name + `
+ H264
+
+ 1920
+ 1080
+
+
+
+
+
+ ` + name + `
+ ` + strconv.Itoa(i) + `
+
+
+ `)
}
buf.WriteString(`
-
-
+
+
+`)
+
+ return buf.String()
+}
+
+
+func GetVideoSourcesResponse(names []string) string {
+ buf := bytes.NewBuffer(nil)
+ buf.WriteString(`
+
+
+ `)
+
+ for i, _ := range names {
+ buf.WriteString(`
+
+
+ 1920
+ 1080
+
+ `)
+ }
+
+ buf.WriteString(`
+
+
`)
return buf.String()
@@ -196,9 +257,22 @@ func GetStreamUriResponse(uri string) string {
- ` + uri + `
+ ` + uri + `
`
}
+
+func GetSnapshotUriResponse(uri string) string {
+ return `
+
+
+
+
+ ` + uri + `
+
+
+
+`
+}
diff --git a/pkg/pcm/producer.go b/pkg/pcm/producer.go
new file mode 100644
index 00000000..8a957f6d
--- /dev/null
+++ b/pkg/pcm/producer.go
@@ -0,0 +1,55 @@
+package pcm
+
+import (
+ "io"
+
+ "github.com/AlexxIT/go2rtc/pkg/core"
+ "github.com/pion/rtp"
+)
+
+type Producer struct {
+ core.Connection
+ rd io.Reader
+}
+
+func Open(rd io.Reader) (*Producer, error) {
+ medias := []*core.Media{
+ {
+ Kind: core.KindAudio,
+ Direction: core.DirectionRecvonly,
+ Codecs: []*core.Codec{
+ {Name: core.CodecPCMU, ClockRate: 8000},
+ },
+ },
+ }
+ return &Producer{
+ core.Connection{
+ ID: core.NewID(),
+ FormatName: "pcm",
+ Medias: medias,
+ Transport: rd,
+ },
+ rd,
+ }, nil
+}
+
+func (c *Producer) Start() error {
+ for {
+ payload := make([]byte, 1024)
+ if _, err := io.ReadFull(c.rd, payload); err != nil {
+ return err
+ }
+
+ c.Recv += 1024
+
+ if len(c.Receivers) == 0 {
+ continue
+ }
+
+ pkt := &rtp.Packet{
+ Header: rtp.Header{Timestamp: core.Now90000()},
+ Payload: payload,
+ }
+ c.Receivers[0].WriteRTP(pkt)
+ }
+}
diff --git a/pkg/rtmp/server.go b/pkg/rtmp/server.go
index ed727b98..3dcd4048 100644
--- a/pkg/rtmp/server.go
+++ b/pkg/rtmp/server.go
@@ -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)
}
diff --git a/pkg/rtsp/helpers.go b/pkg/rtsp/helpers.go
index 6b07342d..346ecf73 100644
--- a/pkg/rtsp/helpers.go
+++ b/pkg/rtsp/helpers.go
@@ -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
}
}
diff --git a/pkg/rtsp/server.go b/pkg/rtsp/server.go
index 7953b0dc..c96125a2 100644
--- a/pkg/rtsp/server.go
+++ b/pkg/rtsp/server.go
@@ -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"
diff --git a/pkg/shell/shell.go b/pkg/shell/shell.go
index d538b961..75df671f 100644
--- a/pkg/shell/shell.go
+++ b/pkg/shell/shell.go
@@ -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
}
diff --git a/pkg/tapo/client.go b/pkg/tapo/client.go
index 3585011c..6ccafe4e 100644
--- a/pkg/tapo/client.go
+++ b/pkg/tapo/client.go
@@ -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)
+}
diff --git a/pkg/tapo/producer.go b/pkg/tapo/producer.go
index 7d66d907..87a91ff5 100644
--- a/pkg/tapo/producer.go
+++ b/pkg/tapo/producer.go
@@ -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,