diff --git a/internal/probe/probe.go b/internal/probe/probe.go index 63bd0d7..c4df8d8 100644 --- a/internal/probe/probe.go +++ b/internal/probe/probe.go @@ -51,6 +51,14 @@ func Init() { return "" }) + // Xiaomi detector (miIO hello on UDP:54321) + detectors = append(detectors, func(r *probe.Response) string { + if r.Probes.Xiaomi != nil { + return "xiaomi" + } + return "" + }) + api.HandleFunc("api/probe", apiProbe) } @@ -129,12 +137,19 @@ func runProbe(parent context.Context, ip string) *probe.Response { resp.Probes.ONVIF = r mu.Unlock() }) + run(func() { + r, _ := probe.ProbeXiaomi(fastCtx, ip) + mu.Lock() + resp.Probes.Xiaomi = r + mu.Unlock() + }) wg.Wait() // determine reachable resp.Reachable = (resp.Probes.Ports != nil && len(resp.Probes.Ports.Open) > 0) || - resp.Probes.MDNS != nil + resp.Probes.MDNS != nil || + resp.Probes.Xiaomi != nil // determine type resp.Type = "standard" diff --git a/pkg/probe/models.go b/pkg/probe/models.go index 42afc12..a9f9448 100644 --- a/pkg/probe/models.go +++ b/pkg/probe/models.go @@ -9,12 +9,13 @@ type Response struct { } type Probes struct { - Ports *PortsResult `json:"ports"` - DNS *DNSResult `json:"dns"` - ARP *ARPResult `json:"arp"` - MDNS *MDNSResult `json:"mdns"` - HTTP *HTTPResult `json:"http"` - ONVIF *ONVIFResult `json:"onvif"` + Ports *PortsResult `json:"ports"` + DNS *DNSResult `json:"dns"` + ARP *ARPResult `json:"arp"` + MDNS *MDNSResult `json:"mdns"` + HTTP *HTTPResult `json:"http"` + ONVIF *ONVIFResult `json:"onvif"` + Xiaomi *XiaomiResult `json:"xiaomi"` } type PortsResult struct { @@ -51,3 +52,8 @@ type ONVIFResult struct { Name string `json:"name,omitempty"` Hardware string `json:"hardware,omitempty"` } + +type XiaomiResult struct { + DeviceID uint32 `json:"device_id"` + Stamp uint32 `json:"stamp"` +} diff --git a/pkg/probe/xiaomi.go b/pkg/probe/xiaomi.go new file mode 100644 index 0000000..8f15983 --- /dev/null +++ b/pkg/probe/xiaomi.go @@ -0,0 +1,60 @@ +package probe + +import ( + "context" + "encoding/binary" + "net" + "time" +) + +// miIO hello packet -- 32 bytes. Stock Xiaomi/Mijia devices listen on +// UDP:54321 and reply with the same magic 0x2131 + their device_id + stamp. +// Newer firmwares always return 0xFF in the token field, regardless of +// pairing status -- real token is only available via Mi Cloud API. +var xiaomiHello = []byte{ + 0x21, 0x31, 0x00, 0x20, + 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, +} + +// ProbeXiaomi sends miIO hello to ip:54321 and checks the reply magic. +// Returns nil, nil if the device is not a Xiaomi miIO device. +func ProbeXiaomi(ctx context.Context, ip string) (*XiaomiResult, error) { + conn, err := net.ListenPacket("udp4", ":0") + if err != nil { + return nil, err + } + defer conn.Close() + + deadline, ok := ctx.Deadline() + if !ok { + deadline = time.Now().Add(100 * time.Millisecond) + } + _ = conn.SetDeadline(deadline) + + addr := &net.UDPAddr{IP: net.ParseIP(ip), Port: 54321} + if _, err = conn.WriteTo(xiaomiHello, addr); err != nil { + return nil, err + } + + buf := make([]byte, 64) + n, _, err := conn.ReadFrom(buf) + if err != nil || n < 32 { + return nil, nil + } + + // magic must be 0x2131 -- unique miIO header + if buf[0] != 0x21 || buf[1] != 0x31 { + return nil, nil + } + + return &XiaomiResult{ + DeviceID: binary.BigEndian.Uint32(buf[8:12]), + Stamp: binary.BigEndian.Uint32(buf[12:16]), + }, nil +}