**命名统一(State → Props)** - 所有设备类型:XxxState → XxxProps,GetState() → GetProps(),FetchState() → FetchProps() - 回调接口:OnStateChanged → OnPropsChanged - Bridge 层类型别名、示例代码、文档同步更名 **新增设备类型** - KindPlug + Plug 驱动:智能插座/插头(TurnOn/TurnOff/IsOn/OnPropsChanged) - KindGateway + Gateway 驱动:网关设备类型识别 - KindOccupancySensor 从 KindSensor 独立,添加 OccupancyProps **设备分类系统改进** - classifyByModel 改为按 key 长度降序匹配(防止 sensor 先于 sensor-occupy) - 新增下划线变体识别(sensor_occupy、sensor_temp 等) - outlet 重新归类为 Plug(非 Switch) - 新增 Or() 组合过滤器 **连接桥增强** - 设备创建成功后自动订阅属性变更 - 新增 nil 防护检查 - 支持 Plug/Speaker/Gateway/Occupancy 类型的 FetchProps 刷新 - 日志级别 Info → Debug **AC 驱动增强** - 新增 HasPower()、GetElectricPower()、GetPowerConsumption() 功率接口 - ACProps 新增 ElectricPower、PowerConsumption 字段 - valMapper:Desc 为空时 fallback 用 Name,key 统一小写 **MIoT 核心** - 设备列表刷新改用 reflect.DeepEqual 全字段变更检测 - 移除 BLE/代理设备默认在线 hack - RefreshDeviceAllProps 增加离线检查 - 新增 miot_errors.go:MIoT 错误码定义 - 全局日志级别 Info→Debug/Warn(降低噪音) **示例与文档** - 所有示例代码同步 API 变更 - 传感器示例改用 Or 组合过滤器 - ARCH_PLAN 等迁移文档同步更新
605 lines
15 KiB
Go
605 lines
15 KiB
Go
// 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"
|
|
|
|
"xiaomihome/logger"
|
|
)
|
|
|
|
// ============================================================================
|
|
// 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
|
|
|
|
lgr logger.Logger
|
|
|
|
// 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{
|
|
lgr: logger.Default(),
|
|
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
|
|
}
|
|
|
|
// SetLogger sets a custom logger for MIoTNetwork.
|
|
func (n *MIoTNetwork) SetLogger(l logger.Logger) { n.lgr = l }
|
|
|
|
// 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 <ms> <ip>
|
|
cmd = exec.Command("ping", "-n", "1", "-w", fmt.Sprintf("%d", int(timeout.Seconds()*1000)), ip)
|
|
} else {
|
|
// Linux: ping -c 1 -W <seconds> <ip>
|
|
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 {
|
|
n.lgr.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
|
|
}
|
|
}
|