// Package miot provides MIoT core client for Xiaomi Home devices. // miot_network.go — network status monitor ported from py-miot/miot_network.py. package miot import ( "fmt" "net" "net/http" "os/exec" "runtime" "strings" "sync" "time" "github.com/sirupsen/logrus" ) // ============================================================================ // InterfaceStatus — network interface change event type // ============================================================================ // InterfaceStatus describes what changed on a network interface. type InterfaceStatus int const ( // InterfaceAdd means a new interface appeared. InterfaceAdd InterfaceStatus = 0 // InterfaceUpdate means an existing interface's IP/mask changed. InterfaceUpdate InterfaceStatus = 1 // InterfaceRemove means an interface was removed. InterfaceRemove InterfaceStatus = 2 ) // String returns the string representation of InterfaceStatus. func (s InterfaceStatus) String() string { switch s { case InterfaceAdd: return "add" case InterfaceUpdate: return "update" case InterfaceRemove: return "remove" default: return "unknown" } } // ============================================================================ // NetworkInfo — information about a network interface // ============================================================================ // NetworkInfo holds details about a single network interface. type NetworkInfo struct { Name string // interface name (e.g., "eth0", "wlan0") IP string // IPv4 address Netmask string // IPv4 netmask NetSeg string // network segment (CIDR network address) } // String returns a debug representation. func (n *NetworkInfo) String() string { return fmt.Sprintf("%s: %s/%s (%s)", n.Name, n.IP, n.Netmask, n.NetSeg) } // ============================================================================ // MIoTNetwork — network status monitor // ============================================================================ // MIoTNetwork monitors network connectivity using ping + HTTP detection, // periodically refreshes network interface info, and notifies subscribers. type MIoTNetwork struct { mu sync.Mutex // Detection addresses with cached response times (seconds) ipAddrMap map[string]float64 httpAddrMap map[string]float64 // HTTP client for detection httpClient *http.Client // Refresh interval refreshInterval time.Duration // Current state networkStatus bool networkInfo map[string]*NetworkInfo // Subscribers statusSubs map[string]func(status bool) infoSubs map[string]func(status InterfaceStatus, info *NetworkInfo) // Timer control stopCh chan struct{} doneCh chan struct{} } // Default network detection addresses. var ( defaultIPAddrs = []string{ "1.2.4.8", // CNNIC sDNS "8.8.8.8", // Google Public DNS "9.9.9.9", // Quad9 } defaultHTTPAddrs = []string{ "https://www.bing.com", "https://www.google.com", "https://www.baidu.com", } defaultDetectTimeout = 6 * time.Second defaultRefreshInterval = 30 * time.Second ) // NewMIoTNetwork creates a new MIoTNetwork monitor. // ipAddrList and urlAddrList can be nil to use defaults. func NewMIoTNetwork(ipAddrList, urlAddrList []string, refreshInterval time.Duration) *MIoTNetwork { if ipAddrList == nil { ipAddrList = defaultIPAddrs } if urlAddrList == nil { urlAddrList = defaultHTTPAddrs } if refreshInterval <= 0 { refreshInterval = defaultRefreshInterval } n := &MIoTNetwork{ ipAddrMap: make(map[string]float64), httpAddrMap: make(map[string]float64), httpClient: &http.Client{Timeout: defaultDetectTimeout}, refreshInterval: refreshInterval, networkStatus: false, networkInfo: make(map[string]*NetworkInfo), statusSubs: make(map[string]func(status bool)), infoSubs: make(map[string]func(status InterfaceStatus, info *NetworkInfo)), stopCh: make(chan struct{}), doneCh: make(chan struct{}), } for _, ip := range ipAddrList { n.ipAddrMap[ip] = float64(defaultDetectTimeout.Seconds()) } for _, url := range urlAddrList { n.httpAddrMap[url] = float64(defaultDetectTimeout.Seconds()) } return n } // Init starts the network monitor. func (n *MIoTNetwork) Init() { // Initial refresh n.refresh() // Start periodic timer go n.loop() } // Deinit stops the network monitor and cleans up. func (n *MIoTNetwork) Deinit() { close(n.stopCh) <-n.doneCh n.httpClient.CloseIdleConnections() n.mu.Lock() defer n.mu.Unlock() n.networkStatus = false n.networkInfo = make(map[string]*NetworkInfo) n.statusSubs = make(map[string]func(status bool)) n.infoSubs = make(map[string]func(status InterfaceStatus, info *NetworkInfo)) } // GetNetworkStatus returns the current network connectivity status. func (n *MIoTNetwork) GetNetworkStatus() bool { n.mu.Lock() defer n.mu.Unlock() return n.networkStatus } // GetNetworkInfo returns the current network interface info. func (n *MIoTNetwork) GetNetworkInfo() map[string]*NetworkInfo { n.mu.Lock() defer n.mu.Unlock() // Return a copy result := make(map[string]*NetworkInfo, len(n.networkInfo)) for k, v := range n.networkInfo { result[k] = &NetworkInfo{ Name: v.Name, IP: v.IP, Netmask: v.Netmask, NetSeg: v.NetSeg, } } return result } // SubNetworkStatus subscribes to network status changes. // key is a unique subscriber identifier. func (n *MIoTNetwork) SubNetworkStatus(key string, handler func(status bool)) { n.mu.Lock() defer n.mu.Unlock() n.statusSubs[key] = handler } // UnsubNetworkStatus unsubscribes from network status changes. func (n *MIoTNetwork) UnsubNetworkStatus(key string) { n.mu.Lock() defer n.mu.Unlock() delete(n.statusSubs, key) } // SubNetworkInfo subscribes to network interface changes. // key is a unique subscriber identifier. func (n *MIoTNetwork) SubNetworkInfo(key string, handler func(status InterfaceStatus, info *NetworkInfo)) { n.mu.Lock() defer n.mu.Unlock() n.infoSubs[key] = handler } // UnsubNetworkInfo unsubscribes from network interface changes. func (n *MIoTNetwork) UnsubNetworkInfo(key string) { n.mu.Lock() defer n.mu.Unlock() delete(n.infoSubs, key) } // Refresh triggers an immediate refresh of network status and info. func (n *MIoTNetwork) Refresh() { n.refresh() } // loop runs the periodic refresh timer. func (n *MIoTNetwork) loop() { defer close(n.doneCh) ticker := time.NewTicker(n.refreshInterval) defer ticker.Stop() for { select { case <-n.stopCh: return case <-ticker.C: n.refresh() } } } // refresh performs a full refresh: network status detection + interface info. func (n *MIoTNetwork) refresh() { newStatus := checkNetworkStatus(n) newInfo := n.getNetworkInterfaces() n.mu.Lock() oldStatus := n.networkStatus oldInfo := n.networkInfo // Update cached addresses with latest response times for ip, ts := range n.ipAddrMap { _ = ip n.ipAddrMap[ip] = ts } for url, ts := range n.httpAddrMap { _ = url n.httpAddrMap[url] = ts } // Status changed? if oldStatus != newStatus { n.networkStatus = newStatus handlers := make([]func(bool), 0, len(n.statusSubs)) for _, h := range n.statusSubs { handlers = append(handlers, h) } n.mu.Unlock() // Notify outside lock for _, h := range handlers { if h != nil { h(newStatus) } } } else { n.mu.Unlock() } // Check for interface changes n.mu.Lock() for name := range oldInfo { if newInfoInfo, ok := newInfo[name]; ok { // Update old := oldInfo[name] if old.IP != newInfoInfo.IP || old.Netmask != newInfoInfo.Netmask { n.networkInfo[name] = newInfoInfo delete(newInfo, name) n.notifyInfoSubsLocked(InterfaceUpdate, newInfoInfo) } else { delete(newInfo, name) // no change } } else { // Remove removed := n.networkInfo[name] delete(n.networkInfo, name) n.notifyInfoSubsLocked(InterfaceRemove, removed) } } // Add new interfaces for _, info := range newInfo { n.networkInfo[info.Name] = info n.notifyInfoSubsLocked(InterfaceAdd, info) } // Collect handlers handlers := make([]func(InterfaceStatus, *NetworkInfo), 0, len(n.infoSubs)) for _, h := range n.infoSubs { handlers = append(handlers, h) } n.mu.Unlock() // For new interfaces we already notified inside lock above _ = handlers } // notifyInfoSubsLocked sends info change notifications. Must hold mu. func (n *MIoTNetwork) notifyInfoSubsLocked(status InterfaceStatus, info *NetworkInfo) { for _, handler := range n.infoSubs { if handler != nil { handler(status, info) } } } // ============================================================================ // Network detection functions // ============================================================================ // checkNetworkStatus detects if the network is reachable. // Strategy: try the fastest known IP first, then fastest HTTP, then all. func checkNetworkStatus(n *MIoTNetwork) bool { n.mu.Lock() ipAddrs := make([]string, 0, len(n.ipAddrMap)) ipTimes := make(map[string]float64) for ip, ts := range n.ipAddrMap { ipAddrs = append(ipAddrs, ip) ipTimes[ip] = ts } httpAddrs := make([]string, 0, len(n.httpAddrMap)) httpTimes := make(map[string]float64) for url, ts := range n.httpAddrMap { httpAddrs = append(httpAddrs, url) httpTimes[url] = ts } n.mu.Unlock() timeout := float64(defaultDetectTimeout.Seconds()) // Try fastest IP first fastestIP := "" fastestIPTime := timeout for ip, ts := range ipTimes { if ts < fastestIPTime { fastestIP = ip fastestIPTime = ts } } if fastestIPTime < timeout && fastestIP != "" { if pingAddr(fastestIP) { return true } } // Try fastest HTTP first fastestHTTP := "" fastestHTTPTime := timeout for url, ts := range httpTimes { if ts < fastestHTTPTime { fastestHTTP = url fastestHTTPTime = ts } } if fastestHTTPTime < timeout && fastestHTTP != "" { if httpCheck(fastestHTTP) { return true } } // Full detection: ping all pingResults := pingMulti(ipAddrs) // Update cached times n.mu.Lock() for ip, ts := range pingResults { n.ipAddrMap[ip] = ts } n.mu.Unlock() for _, ok := range pingResults { if ok < timeout { return true } } // Full detection: HTTP all httpResults := httpMulti(httpAddrs) n.mu.Lock() for url, ts := range httpResults { n.httpAddrMap[url] = ts } n.mu.Unlock() for _, ok := range httpResults { if ok < timeout { return true } } return false } // pingAddr pings a single IP address and returns true if reachable. func pingAddr(ip string) bool { return pingSingle(ip) < float64(defaultDetectTimeout.Seconds()) } // pingSingle pings an IP and returns the response time in seconds, or the timeout value on failure. func pingSingle(ip string) float64 { start := time.Now() timeout := defaultDetectTimeout var cmd *exec.Cmd if runtime.GOOS == "windows" { // Windows: ping -n 1 -w cmd = exec.Command("ping", "-n", "1", "-w", fmt.Sprintf("%d", int(timeout.Seconds()*1000)), ip) } else { // Linux: ping -c 1 -W cmd = exec.Command("ping", "-c", "1", "-W", fmt.Sprintf("%d", int(timeout.Seconds())), ip) } if err := cmd.Run(); err != nil { return float64(timeout.Seconds()) } return time.Since(start).Seconds() } // pingMulti pings multiple IPs concurrently and returns response times. func pingMulti(ips []string) map[string]float64 { results := make(map[string]float64) type result struct { ip string ts float64 } ch := make(chan result, len(ips)) for _, ip := range ips { go func(addr string) { ch <- result{addr, pingSingle(addr)} }(ip) } for range ips { r := <-ch results[r.ip] = r.ts } return results } // httpCheck performs an HTTP HEAD request and returns true if successful. func httpCheck(url string) bool { client := &http.Client{Timeout: defaultDetectTimeout} start := time.Now() resp, err := client.Head(url) if err != nil { // Fallback to GET resp, err = client.Get(url) if err != nil { return false } } defer resp.Body.Close() _ = start return resp.StatusCode < 500 } // httpMulti checks multiple HTTP URLs concurrently and returns response times. func httpMulti(urls []string) map[string]float64 { results := make(map[string]float64) type result struct { url string ts float64 } ch := make(chan result, len(urls)) timeout := float64(defaultDetectTimeout.Seconds()) for _, url := range urls { go func(addr string) { start := time.Now() client := &http.Client{Timeout: defaultDetectTimeout} resp, err := client.Head(addr) if err != nil { // Fallback to GET resp, err = client.Get(addr) } if err != nil || resp.StatusCode >= 500 { ch <- result{addr, timeout} return } resp.Body.Close() ch <- result{addr, time.Since(start).Seconds()} }(url) } for range urls { r := <-ch results[r.url] = r.ts } return results } // ============================================================================ // Network interface detection // ============================================================================ // getNetworkInterfaces returns all non-loopback, non-docker IPv4 interfaces. func (n *MIoTNetwork) getNetworkInterfaces() map[string]*NetworkInfo { results := make(map[string]*NetworkInfo) ifaces, err := net.Interfaces() if err != nil { logrus.Warnf("[network] get interfaces error: %v", err) return results } for _, iface := range ifaces { name := iface.Name // Skip loopback, docker, hassio interfaces if name == "lo" || name == "hassio" || strings.HasPrefix(name, "docker") { continue } addrs, err := iface.Addrs() if err != nil { continue } for _, addr := range addrs { ipNet, ok := addr.(*net.IPNet) if !ok { continue } ip := ipNet.IP // IPv4 only, skip loopback if ip.To4() == nil || ip.IsLoopback() { continue } mask := net.IP(ipNet.Mask) netSeg := ip.Mask(ipNet.Mask) results[name] = &NetworkInfo{ Name: name, IP: ip.String(), Netmask: fmt.Sprintf("%d.%d.%d.%d", mask[0], mask[1], mask[2], mask[3]), NetSeg: netSeg.String(), } break // only first valid IPv4 per interface } } return results } // UpdateAddrList updates the ping and HTTP detection address lists. func (n *MIoTNetwork) UpdateAddrList(ipAddrList, urlAddrList []string) { n.mu.Lock() defer n.mu.Unlock() if ipAddrList != nil { newIPMap := make(map[string]float64) for _, ip := range ipAddrList { if ts, ok := n.ipAddrMap[ip]; ok { newIPMap[ip] = ts } else { newIPMap[ip] = float64(defaultDetectTimeout.Seconds()) } } n.ipAddrMap = newIPMap } if urlAddrList != nil { newURLMap := make(map[string]float64) for _, url := range urlAddrList { if ts, ok := n.httpAddrMap[url]; ok { newURLMap[url] = ts } else { newURLMap[url] = float64(defaultDetectTimeout.Seconds()) } } n.httpAddrMap = newURLMap } }