package main import ( "encoding/hex" "encoding/json" "errors" "fmt" "log" "sync" "time" "github.com/gorilla/websocket" "powerpod/gotool/pb" ) type MasterView struct { Version uint32 `json:"version"` GitHash string `json:"git_hash"` RunningPartition string `json:"running_partition,omitempty"` Deadzone uint32 `json:"deadzone,omitempty"` TapConfig *TapConfigView `json:"tap_config,omitempty"` Lipo1 lipoReadingJSON `json:"lipo1"` Lipo2 lipoReadingJSON `json:"lipo2"` BatteryAgeMs uint32 `json:"battery_age_ms,omitempty"` OK bool `json:"ok"` Error string `json:"error,omitempty"` } type TapConfigView struct { TapSensThres uint32 `json:"tap_sens_thres"` MaxGestDur uint32 `json:"max_gest_dur"` TapShockDur uint32 `json:"tap_shock_dur"` QuiteTimeAfterGest uint32 `json:"quite_time_after_gest"` WaitForTimeout uint32 `json:"wait_for_timeout"` AxisSel uint32 `json:"axis_sel"` EnableSingle bool `json:"enable_single"` EnableDouble bool `json:"enable_double"` EnableTriple bool `json:"enable_triple"` } type ClientView struct { ID uint32 `json:"id"` MAC string `json:"mac"` Version uint32 `json:"version"` Deadzone uint32 `json:"deadzone,omitempty"` TapConfig *TapConfigView `json:"tap_config,omitempty"` Available bool `json:"available"` Used bool `json:"used"` LastPing uint32 `json:"last_ping"` LastSuccessPing uint32 `json:"last_success_ping"` AccelValid bool `json:"accel_valid"` AccelX int32 `json:"accel_x"` AccelY int32 `json:"accel_y"` AccelZ int32 `json:"accel_z"` AccelAgeMs uint32 `json:"accel_age_ms"` AccelStream bool `json:"accel_stream"` TapNotifySingle bool `json:"tap_notify_single"` TapNotifyDouble bool `json:"tap_notify_double"` TapNotifyTriple bool `json:"tap_notify_triple"` LastTap string `json:"last_tap,omitempty"` LastTapAt int64 `json:"last_tap_at,omitempty"` Lipo1 lipoReadingJSON `json:"lipo1"` Lipo2 lipoReadingJSON `json:"lipo2"` BatteryAgeMs uint32 `json:"battery_age_ms,omitempty"` } type DashboardState struct { UpdatedAt string `json:"updated_at"` SerialPort string `json:"serial_port"` /** Alias of host_endpoint for older UI. */ HostEndpoint string `json:"host_endpoint"` HostTransport string `json:"host_transport"` HostConnected bool `json:"host_connected"` UARTConnected bool `json:"uart_connected"` SerialOK bool `json:"serial_ok"` SerialError string `json:"serial_error,omitempty"` /** Host: fast CACHE_STATUS poll (~16 ms) for accel + tap. */ LiveStream bool `json:"live_stream"` /** BLE live path: inter-tick stats from BleStreamTick.esp_timestamp_us. */ BleTick *BleTickView `json:"ble_tick,omitempty"` Master MasterView `json:"master"` Clients []ClientView `json:"clients"` } // BleTickView is median/mean inter-departure of BLE_STREAM_TICK (ESP timer). type BleTickView struct { PeriodMs uint32 `json:"period_ms"` MedianMs float64 `json:"median_ms"` MeanMs float64 `json:"mean_ms"` MinMs float64 `json:"min_ms"` MaxMs float64 `json:"max_ms"` Samples int `json:"samples"` LastEspUs uint64 `json:"last_esp_us,omitempty"` } const bleTickDeltaCap = 64 type bleTickTracker struct { lastEspUs uint64 haveLast bool periodMs uint32 deltas [bleTickDeltaCap]float64 next int count int } func (t *bleTickTracker) reset() { *t = bleTickTracker{} } func (t *bleTickTracker) add(espUs uint64, periodMs uint32) BleTickView { if periodMs != 0 { t.periodMs = periodMs } if t.haveLast && espUs > t.lastEspUs { dt := float64(espUs-t.lastEspUs) / 1000.0 t.deltas[t.next] = dt t.next = (t.next + 1) % bleTickDeltaCap if t.count < bleTickDeltaCap { t.count++ } } t.lastEspUs = espUs t.haveLast = true samples := make([]float64, 0, t.count) if t.count < bleTickDeltaCap { samples = append(samples, t.deltas[:t.count]...) } else { samples = append(samples, t.deltas[t.next:]...) samples = append(samples, t.deltas[:t.next]...) } st := makeLatencyStats(samples) return BleTickView{ PeriodMs: t.periodMs, MedianMs: st.MedMs, MeanMs: st.MeanMs, MinMs: st.MinMs, MaxMs: st.MaxMs, Samples: st.N, LastEspUs: espUs, } } type wsHub struct { mu sync.RWMutex writeMu sync.Mutex // gorilla websocket: one writer at a time clients map[*websocket.Conn]struct{} state DashboardState liveStream bool bleTicks bleTickTracker lastPush time.Time // throttle BLE tick WS pushes } func newWSHub() *wsHub { return &wsHub{clients: make(map[*websocket.Conn]struct{})} } func (h *wsHub) setState(st DashboardState) { h.mu.Lock() prev := h.state st.LiveStream = prev.LiveStream if st.BleTick == nil && prev.BleTick != nil && st.LiveStream { cp := *prev.BleTick st.BleTick = &cp } st.Clients = preserveClientAccel(st.Clients, prev.Clients, st.LiveStream) st.Clients = preserveClientBattery(st.Clients, prev.Clients) st.Clients = preserveClientTap(st.Clients, prev.Clients) if !st.Master.Lipo1.Valid && !st.Master.Lipo2.Valid { if prev.Master.Lipo1.Valid || prev.Master.Lipo2.Valid { st.Master.Lipo1 = prev.Master.Lipo1 st.Master.Lipo2 = prev.Master.Lipo2 st.Master.BatteryAgeMs = prev.Master.BatteryAgeMs } } h.liveStream = st.LiveStream h.state = st conns := make([]*websocket.Conn, 0, len(h.clients)) for c := range h.clients { conns = append(conns, c) } h.mu.Unlock() data, err := json.Marshal(st) if err != nil { return } for _, c := range conns { h.writeJSON(c, data) } } func (h *wsHub) register(c *websocket.Conn) { h.mu.Lock() h.clients[c] = struct{}{} snap := h.state h.mu.Unlock() if data, err := json.Marshal(snap); err == nil { h.writeJSON(c, data) } } func (h *wsHub) unregister(c *websocket.Conn) { h.mu.Lock() delete(h.clients, c) h.mu.Unlock() } // dropClient removes the client and closes the socket so the browser reconnects. func (h *wsHub) dropClient(c *websocket.Conn) { h.unregister(c) _ = c.Close() } // writeJSON sends to one client; removes+closes it on error or panic. func (h *wsHub) writeJSON(c *websocket.Conn, data []byte) { defer func() { if recover() != nil { h.dropClient(c) } }() h.writeMu.Lock() _ = c.SetWriteDeadline(time.Now().Add(2 * time.Second)) err := c.WriteMessage(websocket.TextMessage, data) h.writeMu.Unlock() if err != nil { h.dropClient(c) } } func (h *wsHub) broadcastJSON(data []byte) { h.mu.RLock() conns := make([]*websocket.Conn, 0, len(h.clients)) for c := range h.clients { conns = append(conns, c) } h.mu.RUnlock() for _, c := range conns { h.writeJSON(c, data) } } func applyAccelSamples(clients []ClientView, samples []*pb.AccelSample) []ClientView { if len(samples) == 0 { return clients } byID := make(map[uint32]*pb.AccelSample, len(samples)) for _, s := range samples { byID[s.GetClientId()] = s } out := make([]ClientView, len(clients)) for i, c := range clients { out[i] = c if !c.AccelStream { out[i].AccelValid = false continue } s, ok := byID[c.ID] if !ok { continue } out[i].AccelValid = s.GetValid() if s.GetValid() { out[i].AccelX = s.GetX() out[i].AccelY = s.GetY() out[i].AccelZ = s.GetZ() out[i].AccelAgeMs = s.GetAgeMs() } } return out } func preserveClientAccel(newClients, oldClients []ClientView, liveStream bool) []ClientView { if len(oldClients) == 0 { return newClients } oldByID := make(map[uint32]ClientView, len(oldClients)) for _, c := range oldClients { oldByID[c.ID] = c } out := make([]ClientView, len(newClients)) for i, c := range newClients { out[i] = c if !liveStream && !c.AccelStream { continue } if liveStream && !c.AccelStream { out[i].AccelValid = false out[i].AccelX = 0 out[i].AccelY = 0 out[i].AccelZ = 0 out[i].AccelAgeMs = 0 continue } prev, ok := oldByID[c.ID] if !ok || !prev.AccelValid { continue } if !c.AccelValid { out[i].AccelValid = prev.AccelValid out[i].AccelX = prev.AccelX out[i].AccelY = prev.AccelY out[i].AccelZ = prev.AccelZ out[i].AccelAgeMs = prev.AccelAgeMs } } return out } func preserveClientBattery(newClients, oldClients []ClientView) []ClientView { if len(oldClients) == 0 { return newClients } oldByID := make(map[uint32]ClientView, len(oldClients)) for _, c := range oldClients { oldByID[c.ID] = c } out := make([]ClientView, len(newClients)) for i, c := range newClients { out[i] = c if c.Lipo1.Valid || c.Lipo2.Valid { continue } prev, ok := oldByID[c.ID] if !ok { continue } if prev.Lipo1.Valid || prev.Lipo2.Valid { out[i].Lipo1 = prev.Lipo1 out[i].Lipo2 = prev.Lipo2 out[i].BatteryAgeMs = prev.BatteryAgeMs } } return out } func anyClientAccelStream(clients []ClientView) bool { for _, c := range clients { if c.AccelStream { return true } } return false } func anyClientTapNotify(clients []ClientView) bool { for _, c := range clients { if c.TapNotifySingle || c.TapNotifyDouble || c.TapNotifyTriple { return true } } return false } func tapKindLabelPB(k pb.TapKind) string { switch k { case pb.TapKind_TAP_SINGLE: return "single" case pb.TapKind_TAP_DOUBLE: return "double" case pb.TapKind_TAP_TRIPLE: return "triple" default: return "" } } func applyTapEvents(clients []ClientView, events []*pb.TapEvent) []ClientView { if len(events) == 0 { return clients } byID := make(map[uint32]*pb.TapEvent, len(events)) for _, e := range events { if e.GetValid() { byID[e.GetClientId()] = e } } if len(byID) == 0 { return clients } now := time.Now().UnixMilli() out := make([]ClientView, len(clients)) for i, c := range clients { out[i] = c if !clientTapNotifyAny(c) { continue } e, ok := byID[c.ID] if !ok { continue } out[i].LastTap = tapKindLabelPB(e.GetKind()) out[i].LastTapAt = now } return out } const clientTapDisplayMinMs = 2000 func clientTapNotifyAny(c ClientView) bool { return c.TapNotifySingle || c.TapNotifyDouble || c.TapNotifyTriple } func preserveClientTap(newClients, oldClients []ClientView) []ClientView { if len(oldClients) == 0 { return newClients } oldByID := make(map[uint32]ClientView, len(oldClients)) for _, c := range oldClients { oldByID[c.ID] = c } cutoff := time.Now().Add(-clientTapDisplayMinMs * time.Millisecond).UnixMilli() out := make([]ClientView, len(newClients)) for i, c := range newClients { out[i] = c if c.LastTap != "" { continue } prev, ok := oldByID[c.ID] if !ok || prev.LastTap == "" || prev.LastTapAt < cutoff { continue } out[i].LastTap = prev.LastTap out[i].LastTapAt = prev.LastTapAt } return out } // patchClientAccelStream updates stream flag immediately (e.g. after REST) and pushes WS. func (h *wsHub) patchClientAccelStream(clientID uint32, enabled bool) { h.mu.Lock() for i := range h.state.Clients { if h.state.Clients[i].ID != clientID { continue } h.state.Clients[i].AccelStream = enabled if !enabled { h.state.Clients[i].AccelValid = false h.state.Clients[i].AccelX = 0 h.state.Clients[i].AccelY = 0 h.state.Clients[i].AccelZ = 0 h.state.Clients[i].AccelAgeMs = 0 } break } st := h.state st.UpdatedAt = time.Now().Format(time.RFC3339) conns := make([]*websocket.Conn, 0, len(h.clients)) for c := range h.clients { conns = append(conns, c) } h.mu.Unlock() data, err := json.Marshal(st) if err != nil { return } for _, c := range conns { h.writeJSON(c, data) } } func (h *wsHub) anyAccelStreamEnabled() bool { h.mu.RLock() defer h.mu.RUnlock() return anyClientAccelStream(h.state.Clients) } func (h *wsHub) anyTapNotifyEnabled() bool { h.mu.RLock() defer h.mu.RUnlock() return anyClientTapNotify(h.state.Clients) } func (h *wsHub) liveStreamEnabled() bool { h.mu.RLock() defer h.mu.RUnlock() return h.liveStream } func (h *wsHub) snapshotClients() []ClientView { h.mu.RLock() defer h.mu.RUnlock() out := make([]ClientView, len(h.state.Clients)) copy(out, h.state.Clients) return out } // patchLiveStream toggles host CACHE_STATUS polling (~16 ms). func (h *wsHub) patchLiveStream(enabled bool) { h.mu.Lock() h.liveStream = enabled st := h.state st.LiveStream = enabled if !enabled { h.bleTicks.reset() st.BleTick = nil for i := range st.Clients { st.Clients[i].AccelValid = false st.Clients[i].AccelX = 0 st.Clients[i].AccelY = 0 st.Clients[i].AccelZ = 0 st.Clients[i].AccelAgeMs = 0 st.Clients[i].LastTap = "" st.Clients[i].LastTapAt = 0 } } h.state = st conns := make([]*websocket.Conn, 0, len(h.clients)) for c := range h.clients { conns = append(conns, c) } h.mu.Unlock() data, err := json.Marshal(st) if err != nil { return } for _, c := range conns { h.writeJSON(c, data) } } // patchClientTapNotify updates tap notify flags immediately (e.g. after REST) and pushes WS. func (h *wsHub) patchClientTapNotify(clientID uint32, single, doubleTap, triple bool) { h.mu.Lock() for i := range h.state.Clients { if h.state.Clients[i].ID != clientID { continue } h.state.Clients[i].TapNotifySingle = single h.state.Clients[i].TapNotifyDouble = doubleTap h.state.Clients[i].TapNotifyTriple = triple if !single && !doubleTap && !triple { h.state.Clients[i].LastTap = "" h.state.Clients[i].LastTapAt = 0 } break } st := h.state st.UpdatedAt = time.Now().Format(time.RFC3339) conns := make([]*websocket.Conn, 0, len(h.clients)) for c := range h.clients { conns = append(conns, c) } h.mu.Unlock() data, err := json.Marshal(st) if err != nil { return } for _, c := range conns { h.writeJSON(c, data) } } // mergeAccel updates cached accel on clients and pushes state to dashboard WebSockets. func (h *wsHub) mergeAccel(samples []*pb.AccelSample) { if !h.liveStreamEnabled() { return } h.mu.Lock() st := h.state st.Clients = applyAccelSamples(st.Clients, samples) st.UpdatedAt = time.Now().Format(time.RFC3339) h.state = st conns := make([]*websocket.Conn, 0, len(h.clients)) for c := range h.clients { conns = append(conns, c) } h.mu.Unlock() data, err := json.Marshal(st) if err != nil { return } for _, c := range conns { h.writeJSON(c, data) } } // mergeBleStreamTick applies accel/tap from a BLE tick and updates inter-tick stats. // State is updated every tick; WebSocket push is throttled (~50 Hz) so concurrent // writers (poller/battery) cannot flood or race the gorilla conn. func (h *wsHub) mergeBleStreamTick(tick *pb.BleStreamTick) { if tick == nil || !h.liveStreamEnabled() { return } cache := &pb.CacheStatusResponse{Clients: tick.GetClients()} const minPushGap = 20 * time.Millisecond h.mu.Lock() view := h.bleTicks.add(tick.GetEspTimestampUs(), tick.GetPeriodMs()) st := h.state st.Clients = applyAccelSamples(st.Clients, accelSamplesFromCacheStatus(cache)) st.Clients = applyTapEvents(st.Clients, tapEventsFromCacheStatus(cache)) st.BleTick = &view st.UpdatedAt = time.Now().Format(time.RFC3339) h.state = st push := time.Since(h.lastPush) >= minPushGap if push { h.lastPush = time.Now() } var conns []*websocket.Conn if push { conns = make([]*websocket.Conn, 0, len(h.clients)) for c := range h.clients { conns = append(conns, c) } } h.mu.Unlock() if !push { return } data, err := json.Marshal(st) if err != nil { return } for _, c := range conns { h.writeJSON(c, data) } } func (h *wsHub) mergeTap(events []*pb.TapEvent) { if len(events) == 0 || !h.liveStreamEnabled() { return } h.mu.Lock() st := h.state st.Clients = applyTapEvents(st.Clients, events) st.UpdatedAt = time.Now().Format(time.RFC3339) h.state = st conns := make([]*websocket.Conn, 0, len(h.clients)) for c := range h.clients { conns = append(conns, c) } h.mu.Unlock() data, err := json.Marshal(st) if err != nil { return } for _, c := range conns { h.writeJSON(c, data) } } func (h *wsHub) broadcastRaw(v any) { data, err := json.Marshal(v) if err != nil { return } h.broadcastJSON(data) } func pollDashboard(link *managedSerial, portName string, last *DashboardState, streamCtl *accelStreamCtl, tapCtl *tapNotifyCtl) DashboardState { ep := link.Endpoint() if ep == "" { ep = portName } tr := link.Transport() st := DashboardState{ UpdatedAt: time.Now().Format(time.RFC3339), SerialPort: ep, HostEndpoint: ep, HostTransport: tr, Clients: []ClientView{}, } if !link.IsConnected() && tr == hostTransportNone { st.SerialError = "not connected — use UART or BLE connect" return st } ver, err := link.getVersionPoll() if errors.Is(err, errUARTBusy) { return pausedPollState(ep, last) } if err != nil { return disconnectedState(ep, err) } st.UARTConnected = true st.HostConnected = true st.HostTransport = link.Transport() st.HostEndpoint = link.Endpoint() st.SerialPort = st.HostEndpoint st.SerialOK = true st.Master = MasterView{ Version: ver.GetVersion(), GitHash: ver.GetGitHash(), RunningPartition: ver.GetRunningPartition(), OK: true, } if dz, err := readDeadzonePoll(link, 0); err == nil { st.Master.Deadzone = dz } if tc, err := readTapConfigPoll(link, 0); err == nil { st.Master.TapConfig = tc } clients, err := link.listClientsPoll() if err != nil { if errors.Is(err, errUARTBusy) { return pausedPollState(portName, last) } st.SerialOK = false st.SerialError = err.Error() st.HostConnected = link.IsConnected() st.UARTConnected = st.HostConnected return st } for _, c := range clients { cv := ClientView{ ID: c.GetId(), MAC: formatMAC(c.GetMac()), Version: c.GetVersion(), Available: c.GetAvailable(), Used: c.GetUsed(), LastPing: c.GetLastPing(), LastSuccessPing: c.GetLastSuccessPing(), AccelStream: c.GetAccelStreamEnabled(), TapNotifySingle: c.GetTapNotifySingle(), TapNotifyDouble: c.GetTapNotifyDouble(), TapNotifyTriple: c.GetTapNotifyTriple(), } st.Clients = append(st.Clients, cv) } applyBatteryToState(link, &st) if last == nil || !last.LiveStream { for i, c := range clients { if dz, err := readDeadzonePoll(link, c.GetId()); err == nil { st.Clients[i].Deadzone = dz } if tc, err := readTapConfigPoll(link, c.GetId()); err == nil { st.Clients[i].TapConfig = tc } } } if last != nil { st.LiveStream = last.LiveStream } if streamCtl != nil { streamCtl.SyncFromClients(st.Clients) } if tapCtl != nil { tapCtl.SyncFromClients(st.Clients) } return st } func applyBatteryToState(link *managedSerial, st *DashboardState) { bat, err := link.BatteryStatusPoll(&pb.BatteryStatusRequest{AllClients: true}) if errors.Is(err, errUARTBusy) { return } if err != nil { log.Printf("battery poll: %v", err) return } applyBatterySamplesToState(st, batterySamplesFromPB(bat.GetSamples())) } func (h *wsHub) mergeBattery(samples []batterySampleJSON) { if len(samples) == 0 { return } h.mu.Lock() st := h.state applyBatterySamplesToState(&st, samples) st.UpdatedAt = time.Now().Format(time.RFC3339) h.state = st conns := make([]*websocket.Conn, 0, len(h.clients)) for c := range h.clients { conns = append(conns, c) } h.mu.Unlock() data, err := json.Marshal(st) if err != nil { return } for _, c := range conns { h.writeJSON(c, data) } } func runBatteryPoller(link *managedSerial, hub *wsHub, interval time.Duration, stop <-chan struct{}) { ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-stop: return case <-ticker.C: if !link.IsConnected() || link.PollPaused() { continue } bat, err := link.BatteryStatusPoll(&pb.BatteryStatusRequest{AllClients: true}) if errors.Is(err, errUARTBusy) || err != nil { continue } hub.mergeBattery(batterySamplesFromPB(bat.GetSamples())) } } } func runCacheStatusDashboardPoller(link *managedSerial, hub *wsHub, interval time.Duration, stop <-chan struct{}) { ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-stop: return case <-ticker.C: if !hub.liveStreamEnabled() || link.PollPaused() { continue } // BLE live path prefers BLE_STREAM_TICK pushes. if link.Transport() == hostTransportBLE { continue } cache, err := link.readCacheStatusPoll() if err != nil { continue } hub.mergeAccel(accelSamplesFromCacheStatus(cache)) hub.mergeTap(tapEventsFromCacheStatus(cache)) } } } // runBLEStreamDashboardPoller feeds live accel/tap from BLE_STREAM_TICK when live_stream is on. func runBLEStreamDashboardPoller(link *managedSerial, hub *wsHub, streamCtl *accelStreamCtl, stop <-chan struct{}) { _ = streamCtl for { select { case <-stop: return default: } if link.Transport() != hostTransportBLE || !hub.liveStreamEnabled() || link.PollPaused() { select { case <-stop: return case <-time.After(50 * time.Millisecond): } continue } payload, err := link.NextStreamTick(200 * time.Millisecond) if err != nil { continue } tick, err := parseBleStreamTickPayload(payload) if err != nil { continue } hub.mergeBleStreamTick(tick) } } func (h *wsHub) clientCount() int { h.mu.RLock() n := len(h.clients) h.mu.RUnlock() return n } func pausedPollState(portName string, last *DashboardState) DashboardState { if last != nil && (last.HostConnected || last.UARTConnected) { st := *last st.UpdatedAt = time.Now().Format(time.RFC3339) st.SerialPort = portName st.HostEndpoint = portName st.SerialOK = true st.HostConnected = true st.UARTConnected = true st.SerialError = "Live-Polling pausiert (OTA läuft)" return st } return disconnectedState(portName, errUARTBusy) } func readDeadzone(link *managedSerial, clientID uint32) (uint32, error) { r, err := link.AccelDeadzone(&pb.AccelDeadzoneRequest{ Write: false, ClientId: clientID, }) if err != nil { return 0, err } if !r.GetSuccess() { return 0, fmt.Errorf("deadzone read failed for client %d", clientID) } return r.GetDeadzone(), nil } func readDeadzonePoll(link *managedSerial, clientID uint32) (uint32, error) { r, err := link.AccelDeadzonePoll(&pb.AccelDeadzoneRequest{ Write: false, ClientId: clientID, }) if err != nil { return 0, err } if !r.GetSuccess() { return 0, fmt.Errorf("deadzone read failed for client %d", clientID) } return r.GetDeadzone(), nil } func tapConfigViewFromPB(r *pb.TapConfigResponse) *TapConfigView { return &TapConfigView{ TapSensThres: r.GetTapSensThres(), MaxGestDur: r.GetMaxGestDur(), TapShockDur: r.GetTapShockDur(), QuiteTimeAfterGest: r.GetQuiteTimeAfterGest(), WaitForTimeout: r.GetWaitForTimeout(), AxisSel: r.GetAxisSel(), EnableSingle: r.GetEnableSingle(), EnableDouble: r.GetEnableDouble(), EnableTriple: r.GetEnableTriple(), } } func readTapConfigPoll(link *managedSerial, clientID uint32) (*TapConfigView, error) { r, err := link.TapConfigPoll(&pb.TapConfigRequest{ Write: false, ClientId: clientID, }) if err != nil { return nil, err } if !r.GetSuccess() { return nil, fmt.Errorf("tap config read failed for client %d", clientID) } return tapConfigViewFromPB(r), nil } func formatMAC(mac []byte) string { if len(mac) == 0 { return "" } return hex.EncodeToString(mac) } func runPoller(link *managedSerial, portName string, hub *wsHub, streamCtl *accelStreamCtl, tapCtl *tapNotifyCtl, interval time.Duration, stop <-chan struct{}) { // streamCtl / tapCtl kept for external API; dashboard uses hub.state flags. ticker := time.NewTicker(interval) defer ticker.Stop() hostUp := false var lastGood DashboardState publish := func() { st := pollDashboard(link, portName, &lastGood, streamCtl, tapCtl) hub.setState(st) if st.HostConnected && st.SerialOK { hub.mu.RLock() lastGood = hub.state hub.mu.RUnlock() } if st.HostConnected && !hostUp { log.Printf("host %s connected (%s)", st.HostEndpoint, st.HostTransport) } hostUp = st.HostConnected } publish() for { select { case <-stop: return case <-ticker.C: publish() } } }