From bbe5229cefaf9ed07b1505624a3fbcdb66bb42d3 Mon Sep 17 00:00:00 2001 From: Ton Sharp Date: Thu, 26 Mar 2026 03:10:21 +0200 Subject: [PATCH 1/3] xiaomi: improve loock.cateye.v01 compatibility --- internal/xiaomi/xiaomi.go | 7 ++++++- pkg/xiaomi/legacy/client.go | 21 ++++++++++++++++++++- pkg/xiaomi/legacy/producer.go | 20 ++++++++++++++++---- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/internal/xiaomi/xiaomi.go b/internal/xiaomi/xiaomi.go index 1e578420c..9fc3539d2 100644 --- a/internal/xiaomi/xiaomi.go +++ b/internal/xiaomi/xiaomi.go @@ -101,6 +101,11 @@ func getCameraURL(url *url.URL) (string, error) { // The getMissURL request has a fallback to getP2PURL. // But for known models we can save one request to the cloud. + // Loock v01 may run legacy on old firmware and miss on newer firmware, + // so always try miss first for this model. + if model == "loock.cateye.v01" { + return getMissURL(url) + } if xiaomi.IsLegacy(model) { return getLegacyURL(url) } @@ -136,7 +141,7 @@ func getLegacyURL(url *url.URL) (string, error) { query.Set("uid", v.UID) - if v.Sign != "" { + if v.Sign != "" && query.Get("model") != "loock.cateye.v01" { query.Set("client_public", hex.EncodeToString(clientPublic)) query.Set("client_private", hex.EncodeToString(clientPrivate)) query.Set("device_public", v.PublicKey) diff --git a/pkg/xiaomi/legacy/client.go b/pkg/xiaomi/legacy/client.go index 57bd0a087..f0b243d53 100644 --- a/pkg/xiaomi/legacy/client.go +++ b/pkg/xiaomi/legacy/client.go @@ -33,7 +33,7 @@ func NewClient(rawURL string) (*Client, error) { `{"public_key":"%s","sign":"%s","account":"admin"}`, query.Get("client_public"), query.Get("sign"), ) - } else if model == ModelMijia || model == ModelXiaobai { + } else if model == ModelMijia || model == ModelXiaobai || model == ModelLoockV1 { username = "admin" password = query.Get("password") } else if model == ModelDafang || model == ModelXiaofang { @@ -149,6 +149,25 @@ func (c *Client) StartMedia(video, audio string) error { c.WriteCommandJSON(0x0704, `{}`), // don't know why ) + case ModelLoockV1: + // CatY firmware variants behave differently. + // Send a wide set of known-safe start commands and ignore partial failures. + switch video { + case "", "hd": + video = "3" + case "sd": + video = "1" + case "auto": + video = "0" + } + + _ = c.WriteCommandJSON(cmdAudioStart, `{}`) + _ = c.WriteCommandJSON(cmdVideoStart, `{}`) + _ = c.WriteCommandJSON(cmdStreamCtrlReq, `{"videoquality":%s}`, video) + _ = c.WriteCommandJSON(0x0605, `{"channel":1}`) + _ = c.WriteCommandJSON(0x0704, `{}`) + return nil + case ModelIMILABA1, ModelMijia: // 0 - auto, 1 - low, 3 - hd switch video { diff --git a/pkg/xiaomi/legacy/producer.go b/pkg/xiaomi/legacy/producer.go index 92375faf6..b1d6708da 100644 --- a/pkg/xiaomi/legacy/producer.go +++ b/pkg/xiaomi/legacy/producer.go @@ -57,7 +57,7 @@ type Producer struct { const codecXiaobaiPCMA = 1 // chuangmi.camera.xiaobai func probe(client *Client) ([]*core.Media, error) { - _ = client.SetDeadline(time.Now().Add(15 * time.Second)) + _ = client.SetDeadline(time.Now().Add(20 * time.Second)) var vcodec, acodec *core.Codec @@ -73,6 +73,11 @@ func probe(client *Client) ([]*core.Media, error) { // 18 0000 hdr, payload, err := client.ReadPacket() if err != nil { + // Some battery/doorbell devices can provide video without audio. + // Don't fail probing if we already detected video. + if vcodec != nil { + break + } return nil, err } @@ -118,11 +123,13 @@ func probe(client *Client) ([]*core.Media, error) { Direction: core.DirectionRecvonly, Codecs: []*core.Codec{vcodec}, }, - { + } + if acodec != nil { + medias = append(medias, &core.Media{ Kind: core.KindAudio, Direction: core.DirectionRecvonly, Codecs: []*core.Codec{acodec}, - }, + }) } return medias, nil } @@ -132,11 +139,16 @@ func (c *Producer) Protocol() string { } func (c *Producer) Start() error { + timeout := 5 * time.Second + if c.client.model == ModelLoockV1 { + timeout = 20 * time.Second + } + var audioTS uint32 var videoSeq, audioSeq uint16 for { - _ = c.client.SetDeadline(time.Now().Add(5 * time.Second)) + _ = c.client.SetDeadline(time.Now().Add(timeout)) hdr, payload, err := c.client.ReadPacket() if err != nil { return err From 2b6b6457983f1a056b6e40020b88ee73732cc582 Mon Sep 17 00:00:00 2001 From: Ton Sharp Date: Thu, 26 Mar 2026 12:08:55 +0200 Subject: [PATCH 2/3] ModelLoockV1: clean up --- internal/xiaomi/xiaomi.go | 7 +------ pkg/xiaomi/legacy/client.go | 2 +- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/internal/xiaomi/xiaomi.go b/internal/xiaomi/xiaomi.go index 9fc3539d2..1e578420c 100644 --- a/internal/xiaomi/xiaomi.go +++ b/internal/xiaomi/xiaomi.go @@ -101,11 +101,6 @@ func getCameraURL(url *url.URL) (string, error) { // The getMissURL request has a fallback to getP2PURL. // But for known models we can save one request to the cloud. - // Loock v01 may run legacy on old firmware and miss on newer firmware, - // so always try miss first for this model. - if model == "loock.cateye.v01" { - return getMissURL(url) - } if xiaomi.IsLegacy(model) { return getLegacyURL(url) } @@ -141,7 +136,7 @@ func getLegacyURL(url *url.URL) (string, error) { query.Set("uid", v.UID) - if v.Sign != "" && query.Get("model") != "loock.cateye.v01" { + if v.Sign != "" { query.Set("client_public", hex.EncodeToString(clientPublic)) query.Set("client_private", hex.EncodeToString(clientPrivate)) query.Set("device_public", v.PublicKey) diff --git a/pkg/xiaomi/legacy/client.go b/pkg/xiaomi/legacy/client.go index f0b243d53..829f6b351 100644 --- a/pkg/xiaomi/legacy/client.go +++ b/pkg/xiaomi/legacy/client.go @@ -33,7 +33,7 @@ func NewClient(rawURL string) (*Client, error) { `{"public_key":"%s","sign":"%s","account":"admin"}`, query.Get("client_public"), query.Get("sign"), ) - } else if model == ModelMijia || model == ModelXiaobai || model == ModelLoockV1 { + } else if model == ModelMijia || model == ModelXiaobai { username = "admin" password = query.Get("password") } else if model == ModelDafang || model == ModelXiaofang { From 492e7a783fd2e6cf557f44f9152fdd7f08be317c Mon Sep 17 00:00:00 2001 From: Ton Sharp Date: Sun, 29 Mar 2026 20:36:33 +0300 Subject: [PATCH 3/3] xiaomi: add CatY experimental modes and diagnostics --- pkg/tutk/conn.go | 29 ++++++++- pkg/xiaomi/legacy/client.go | 124 +++++++++++++++++++++++++++++++++++- 2 files changed, 150 insertions(+), 3 deletions(-) diff --git a/pkg/tutk/conn.go b/pkg/tutk/conn.go index e0610690b..3cbff1caf 100644 --- a/pkg/tutk/conn.go +++ b/pkg/tutk/conn.go @@ -10,6 +10,21 @@ import ( ) func Dial(host, uid, username, password string) (*Conn, error) { + return DialWithConfig(host, uid, username, password, nil) +} + +func DialWithLocalAddr(host, uid, username, password string, localAddr *net.UDPAddr) (*Conn, error) { + return DialWithConfig(host, uid, username, password, &DialConfig{LocalAddr: localAddr}) +} + +type PreConnectFunc func(conn *net.UDPConn, addr *net.UDPAddr) error + +type DialConfig struct { + LocalAddr *net.UDPAddr + PreConnect PreConnectFunc +} + +func DialWithConfig(host, uid, username, password string, cfg *DialConfig) (*Conn, error) { addr, err := net.ResolveUDPAddr("udp", host) if err != nil { // Default port for listening incoming LAN connections. @@ -17,13 +32,25 @@ func Dial(host, uid, username, password string) (*Conn, error) { addr = &net.UDPAddr{IP: net.ParseIP(host), Port: 32761} } - udpConn, err := net.ListenUDP("udp", nil) + var localAddr *net.UDPAddr + if cfg != nil { + localAddr = cfg.LocalAddr + } + + udpConn, err := net.ListenUDP("udp", localAddr) if err != nil { return nil, err } c := &Conn{UDPConn: udpConn, addr: addr} + if cfg != nil && cfg.PreConnect != nil { + if err = cfg.PreConnect(udpConn, addr); err != nil { + _ = c.Close() + return nil, err + } + } + sid := GenSessionID() _ = c.SetDeadline(time.Now().Add(5 * time.Second)) diff --git a/pkg/xiaomi/legacy/client.go b/pkg/xiaomi/legacy/client.go index 829f6b351..c05d26075 100644 --- a/pkg/xiaomi/legacy/client.go +++ b/pkg/xiaomi/legacy/client.go @@ -2,9 +2,13 @@ package legacy import ( "encoding/binary" + "encoding/hex" "errors" "fmt" + "net" "net/url" + "strconv" + "time" "github.com/AlexxIT/go2rtc/pkg/tutk" "github.com/AlexxIT/go2rtc/pkg/xiaomi/crypto" @@ -18,6 +22,8 @@ func NewClient(rawURL string) (*Client, error) { query := u.Query() model := query.Get("model") + rawMode := query.Get("xraw") + var localAddr *net.UDPAddr var username, password string var key []byte @@ -42,12 +48,43 @@ func NewClient(rawURL string) (*Client, error) { return nil, fmt.Errorf("xiaomi: unsupported model: %s", model) } - conn, err := tutk.Dial(u.Host, query.Get("uid"), username, password) + if port := query.Get("xport"); port != "" { + // Experimental: force direct host port from URL query. + if host := u.Hostname(); net.ParseIP(host) != nil { + u.Host = net.JoinHostPort(host, port) + } + } else if model == ModelLoockV1 && query.Get("xdirect") == "1" { + // Experimental CatY mode based on captured Mi Home LAN traffic. + if host := u.Hostname(); net.ParseIP(host) != nil { + u.Host = net.JoinHostPort(host, "6666") + } + } + if model == ModelLoockV1 && rawMode != "" && rawMode != "3" { + // Experimental: replay a small subset of observed Mi Home UDP payloads. + // This is best-effort and intentionally ignored on error. + _ = loockRawKick(u.Host, query.Get("xlocal"), rawMode) + } + if localPort := query.Get("xlocal"); localPort != "" { + port, err := strconv.Atoi(localPort) + if err != nil { + return nil, fmt.Errorf("xiaomi: invalid xlocal: %w", err) + } + localAddr = &net.UDPAddr{Port: port} + } + + cfg := &tutk.DialConfig{LocalAddr: localAddr} + if model == ModelLoockV1 && rawMode == "3" { + cfg.PreConnect = func(conn *net.UDPConn, addr *net.UDPAddr) error { + return loockRawKickConn(conn, addr, rawMode) + } + } + + conn, err := tutk.DialWithConfig(u.Host, query.Get("uid"), username, password, cfg) if err != nil { return nil, err } - if model == ModelDafang || model == ModelXiaofang { + if model == ModelDafang || model == ModelXiaofang || (model == ModelLoockV1 && query.Get("xskiplogin") != "1") { err = xiaofangLogin(conn, query.Get("password")) if err != nil { _ = conn.Close() @@ -64,6 +101,89 @@ func NewClient(rawURL string) (*Client, error) { return c, nil } +func loockRawKick(hostport, localPort, mode string) error { + host := hostport + port := "6666" + if h, p, err := net.SplitHostPort(hostport); err == nil { + host, port = h, p + } + if net.ParseIP(host) == nil { + return nil + } + + addr, err := net.ResolveUDPAddr("udp", net.JoinHostPort(host, port)) + if err != nil { + return err + } + + var localAddr *net.UDPAddr + if localPort != "" { + p, err := strconv.Atoi(localPort) + if err != nil { + return err + } + localAddr = &net.UDPAddr{Port: p} + } + + conn, err := net.ListenUDP("udp", localAddr) + if err != nil { + return err + } + defer conn.Close() + + if err = loockRawKickConn(conn, addr, mode); err != nil { + return err + } + + return nil +} + +func loockRawKickConn(conn *net.UDPConn, addr *net.UDPAddr, mode string) error { + payloadHex, loops, delay := loockRawPayload(mode) + + _ = conn.SetDeadline(time.Now().Add(200 * time.Millisecond)) + for i := 0; i < loops; i++ { + for _, s := range payloadHex { + b, err := hex.DecodeString(s) + if err != nil { + continue + } + _, _ = conn.WriteToUDP(b, addr) + + buf := make([]byte, 2048) + _, _, _ = conn.ReadFromUDP(buf) // ignore result, this is just a wake/kick attempt + time.Sleep(delay) + } + } + return nil +} + +func loockRawPayload(mode string) (payloadHex []string, loops int, delay time.Duration) { + // Payloads captured from Mi Home <-> CatY local LAN session. + // They are protocol-ciphertext and may vary by session/device state. + switch mode { + case "2", "3": + // Replay a longer startup burst captured from phone traffic. + payloadHex = []string{ + "6e4c9d8c40d140ca3d2da82dc0e6cadcfb4bde8b775484ae0ef4ab8815d1af5c6e2e8d8c40d040ca3e6d3b1f40a4cbd8637f06e9a741f6d72d6e280c30e4fad86e2e8d8c40d040ca2d4d280c40e4cad8206c726168656943", + "6e6c5df840db30cb3d2da82d20eecafcf7729d2c306140ca8dbd280c3fe5ba946e2ead8e40c060ca2d6d280c40e4cad8e8f8dba72386b65d0a3dc573f6e59dff481dab8f799732cd0e5a0aae0782f8ce685dfbd972d307f80e292b1f73d2accf6d18fb0d24f777e85e2e1b2a52804cff78a8beda2793d3c90e7abe6f67d199da48ccabee7ab767f81e3ebb0f86c65dfa6878fb8920a707a85b7c3b5f4685a8fa3808abcd27c703891e0cbe4f0783acaa1d5dbeb4239342bd0e3a2e7f73dc88df18adeece721352e80e3f1b8a57d1d9fa6d08ff25243692ec5b791e3a128bed3e6e2e8d8c40d040ca2dad2f0c40e4cbd86e2e8d8c40d040ca2d6d280c40e4cad86e2e8d8c40d040ca2d6d280c40e4cad86e2e8d8c40d040ca2d6d280c40e4cad86e2e8d8c40d040ca2d6d280c40e4cad84dadc9183ac517386d7c280c47e4d858dab9eaf81592d3be195c4dafa7d2ed4ddd6bebab32a5c71d0860bb1b44b7bd2e6e2e8d8c40d040ca4b4c1b0b518bcd7c6e2e8d8c40d040ca2d6d280c40e4cad86e2e8d8c40d040ca2d6d280c40e4cad86e2e8d8c40d040ca2d6d280c40e4cad86e2e8d8c40d040ca2d6d280c40e4cad86e2e8d8c40d040ca2d6d280c40e4cad86e2e8d8c40d040ca2d6d280c40e4cad86e2e8d8c40d040ca2d6d280c40e4cad86e2e8d8c40d040ca2d6d280c40e4cad86e2e8d8c40d040ca2d6d280c40e4cad86e2e8d8c40d040ca2d6d280c40e4cad86e2e8d8c40d040ca2d6d280c40e4cad86e2e8d8c40d040ca2d6d280c40e4cad82e2e8d8c406041b42d6c280c40e4cad86e2e8d8c40d070ca2d8d290c40e4cbd8436861726c696e6c5df840db30cb3d2ca82d20eecafcf7729d2c306340ca8dbd280c3fe5ba946e2ead8e40d060ca2d6d280c40e4cad8e8f8dba72386b65d0a1dc573f6e59cff481dab8f799732cd0e5a0aae0782f8ce685dfbd972d307f80e292b1f73d2accf6d18fb0d24f777e85e2e1b2a52804cff78a8beda2793d3c90e7abe6f67d199da48ccabee7ab767f81e3ebb0f86c65dfa6878fb8920a707a85b7c3b5f4685a8fa3808abcd27c703891e0cbe4f0783acaa1d5dbeb4239342bd0e3a2e7f73dc88df18adeece721352e80e3f1b8a57d1d9fa6d08ff25243692ec5b791e3a128bed3e6e2e8d8c40d040ca2dad2f0c40e4cbd86e2e8d8c40d040ca2d6d280c40e4cad86e2e8d8c40d040ca2d6d280c40e4cad86e2e8d8c40d040ca2d6d280c40e4cad86e2e8d8c40d040ca2d6d280c40e4cad84dadc9183ac517386d7c280c47e4d858dab9eaf81592d3be195c4dafa7d2ed4ddd6bebab32a5c71d0860bb1b44b7bd2e6e2e8d8c40d040ca4b4c1b0b518bcd7c6e2e8d8c40d040ca2d6d280c40e4cad86e2e8d8c40d040ca2d6d280c40e4cad86e2e8d8c40d040ca2d6d280c40e4cad86e2e8d8c40d040ca2d6d280c40e4cad86e2e8d8c40d040ca2d6d280c40e4cad86e2e8d8c40d040ca2d6d280c40e4cad86e2e8d8c40d040ca2d6d280c40e4cad86e2e8d8c40d040ca2d6d280c40e4cad86e2e8d8c40d040ca2d6d280c40e4cad86e2e8d8c40d040ca2d6d280c40e4cad86e2e8d8c40d040ca2d6d280c40e4cad86e2e8d8c40d040ca2d6d280c40e4cad82e2e8d8c406041b42d6c280c40e4cad86e2e8d8c40d070ca2d8d290c40e4cbd8436861726c69", + "4e6d9d8c40d140ca3d2da82d00e6cadafb4bde8b775484ae0ef4ab8815d1af5cf7729d2c30d140ca9e7d3b1f3fa5bb94627144684e6d9d8c40d140ca3d2da82d00e6cadafb4bde8b775484ae0ef4ab8815d1af5cf7729d2c30d140ca9e7d3b1f3fa5bb9462714468", + "4e6d9d8c40d140ca3d2da82d00e6cadafb4bde8b775484ae0ef4ab8815d1af5cf7729d2c30d140ca9e7d3b1f3fa5bb9462714468", + } + loops = 2 + delay = 10 * time.Millisecond + default: + payloadHex = []string{ + "6e4c9d8c40d140ca3d2da82dc0e6cadcfb4bde8b775484ae0ef4ab8815d1af5c6e2e8d8c40d040ca3e6d3b1f40a4cbd8637f06e9a741f6d72d6e280c30e4fad86e2e8d8c40d040ca2d4d280c40e4cad8206c726168656943", + "4e6d9d8c40d140ca3d2da82d00e6cadafb4bde8b775484ae0ef4ab8815d1af5cf7729d2c30d140ca9e7d3b1f3fa5bb9462714468", + } + loops = 3 + delay = 15 * time.Millisecond + } + + return +} + func xiaofangLogin(conn *tutk.Conn, password string) error { data := tutk.ICAM(0x0400be) // ask login if err := conn.WriteCommand(0x0100, data); err != nil {