package main import ( "fmt" "log" "strings" "sync" "time" "go.bug.st/serial" "tinygo.org/x/bluetooth" ) const ( hostTransportNone = "none" hostTransportUART = "uart" hostTransportBLE = "ble" ) // errHostBusy is returned when the link is held for OTA (UART only). var errHostBusy = errUARTBusy // managedHost is a reconnecting UART or BLE host link for serve/CLI. type managedHost struct { quiet bool baud int settle time.Duration mu sync.Mutex transport string // none|uart|ble endpoint string // port path or BLE address string bleName string // last connected BLE local name (optional) sp *serialPort ble *bleLink otaActive bool uartPort string // remembered UART port for reconnect } func newManagedHost(baud int) *managedHost { if baud <= 0 { baud = defaultBaud } return &managedHost{ baud: baud, transport: hostTransportNone, settle: time.Second, } } // newManagedSerial keeps the old constructor name for call sites during migrate. func newManagedSerial(portName string, baud int) *managedHost { m := newManagedHost(baud) if portName != "" { m.uartPort = portName m.endpoint = portName m.transport = hostTransportUART } return m } func (m *managedHost) Transport() string { m.mu.Lock() defer m.mu.Unlock() if m.transport == "" { return hostTransportNone } return m.transport } func (m *managedHost) Endpoint() string { m.mu.Lock() defer m.mu.Unlock() return m.endpoint } func (m *managedHost) Close() error { m.mu.Lock() defer m.mu.Unlock() return m.disconnectLocked() } func (m *managedHost) IsConnected() bool { m.mu.Lock() defer m.mu.Unlock() switch m.transport { case hostTransportUART: return m.sp != nil case hostTransportBLE: return m.ble != nil default: return false } } func (m *managedHost) disconnectLocked() error { var err error if m.sp != nil { err = m.sp.port.Close() m.sp = nil } if m.ble != nil { e := m.ble.Close() if err == nil { err = e } m.ble = nil } m.transport = hostTransportNone return err } func (m *managedHost) Disconnect() error { m.mu.Lock() defer m.mu.Unlock() ep := m.endpoint err := m.disconnectLocked() if !m.quiet && ep != "" { log.Printf("host disconnected (%s)", ep) } return err } func (m *managedHost) ConnectUART(port string, baud int) error { if port == "" { return fmt.Errorf("UART port required") } if baud <= 0 { baud = m.baud } m.mu.Lock() defer m.mu.Unlock() if m.otaActive { return errHostBusy } _ = m.disconnectLocked() m.baud = baud m.uartPort = port m.endpoint = port m.transport = hostTransportUART if err := m.openUARTLocked(); err != nil { m.transport = hostTransportNone m.endpoint = "" return err } return nil } func (m *managedHost) ConnectBLE(address string, name string) error { if address == "" && name == "" { return fmt.Errorf("BLE address or name required") } m.mu.Lock() defer m.mu.Unlock() if m.otaActive { return errHostBusy } _ = m.disconnectLocked() var link *bleLink var err error settle := m.settle m.mu.Unlock() if address != "" { link, err = openBLEAddress(address, settle) } else { link, err = openBLE(name, settle) } m.mu.Lock() if err != nil { return err } link.quiet = m.quiet m.ble = link m.transport = hostTransportBLE if address != "" { m.endpoint = address } else { m.endpoint = name } m.bleName = name if !m.quiet { log.Printf("BLE host connected (%s)", m.endpoint) } return nil } func (m *managedHost) openUARTLocked() error { sp, err := openSerial(m.uartPort, m.baud) if err != nil { return err } sp.quiet = m.quiet m.sp = sp if !m.quiet { log.Printf("UART %s connected (%d baud)", m.uartPort, m.baud) } return nil } func (m *managedHost) openLocked() error { return m.openUARTLocked() } func (m *managedHost) closeLocked() error { return m.disconnectLocked() } func (m *managedHost) invalidateLocked(reason error) { if m.sp == nil && m.ble == nil { return } if !m.quiet { log.Printf("host %s disconnected: %v", m.endpoint, reason) } _ = m.disconnectLocked() // remember transport intent for UART reconnect if m.uartPort != "" && m.transport == hostTransportNone { // after invalidate, leave as none; poller will try Connect again via open m.transport = hostTransportUART m.endpoint = m.uartPort } } func (m *managedHost) withPort(fn func(*serialPort) error) error { return m.withPortLocked(false, fn) } func (m *managedHost) withPortPoll(fn func(*serialPort) error) error { return m.withPortLocked(true, fn) } func (m *managedHost) withPortLocked(poll bool, fn func(*serialPort) error) error { m.mu.Lock() defer m.mu.Unlock() if m.otaActive { return errHostBusy } if m.transport == hostTransportBLE { return fmt.Errorf("operation requires UART host (DIP8=0)") } if m.transport != hostTransportUART && m.uartPort == "" { return fmt.Errorf("not connected") } if m.transport == hostTransportNone && m.uartPort != "" { m.transport = hostTransportUART m.endpoint = m.uartPort } if m.sp == nil { if err := m.openUARTLocked(); err != nil { return fmt.Errorf("%s: %w", m.uartPort, err) } } err := fn(m.sp) if err != nil { m.invalidateLocked(err) } return err } func (m *managedHost) recoverAfterMasterRestart() { const bootWait = 6 * time.Second m.mu.Lock() tr := m.transport m.disconnectLocked() if tr == hostTransportUART { m.transport = hostTransportUART m.endpoint = m.uartPort } m.mu.Unlock() log.Printf("host: master restart — waiting %s for boot", bootWait) time.Sleep(bootWait) m.mu.Lock() defer m.mu.Unlock() if m.transport == hostTransportUART { if err := m.openUARTLocked(); err != nil { log.Printf("UART reconnect after master restart: %v", err) return } flushSerialInput(m.sp) log.Printf("UART %s ready after master restart", m.uartPort) } } func (m *managedHost) exchangePayload(payload []byte, cmdName string) ([]byte, error) { return m.exchangePayloadMode(false, payload, cmdName, readTimeout) } func (m *managedHost) exchangePayloadPoll(payload []byte, cmdName string) ([]byte, error) { return m.exchangePayloadMode(true, payload, cmdName, readTimeout) } func (m *managedHost) exchangePayloadMode(poll bool, payload []byte, cmdName string, timeout time.Duration) ([]byte, error) { m.mu.Lock() defer m.mu.Unlock() if m.otaActive { return nil, errHostBusy } switch m.transport { case hostTransportBLE: if m.ble == nil { return nil, fmt.Errorf("BLE not connected") } resp, err := m.ble.exchangePayload(payload, cmdName) if err != nil { m.invalidateLocked(err) } return resp, err case hostTransportUART, hostTransportNone: if m.uartPort == "" && m.sp == nil { return nil, fmt.Errorf("not connected") } if m.transport == hostTransportNone { m.transport = hostTransportUART m.endpoint = m.uartPort } if m.sp == nil { if err := m.openUARTLocked(); err != nil { return nil, fmt.Errorf("%s: %w", m.uartPort, err) } } resp, err := m.sp.exchangePayloadLocked(payload, cmdName, timeout) if err != nil { m.invalidateLocked(err) } return resp, err default: return nil, fmt.Errorf("not connected") } } func (m *managedHost) exchange(cmdID byte, cmdName string) ([]byte, error) { return m.exchangePayload([]byte{cmdID}, cmdName) } func (m *managedHost) exchangePoll(cmdID byte, cmdName string) ([]byte, error) { return m.exchangePayloadPoll([]byte{cmdID}, cmdName) } func (m *managedHost) exchangePayloadVia( portFn func(func(*serialPort) error) error, payload []byte, cmdName string, ) ([]byte, error) { // Prefer transport-aware path (works for BLE too). _ = portFn return m.exchangePayload(payload, cmdName) } func (m *managedHost) exchangeVia( portFn func(func(*serialPort) error) error, cmdID byte, cmdName string, ) ([]byte, error) { _ = portFn return m.exchange(cmdID, cmdName) } func (m *managedHost) NextStreamTick(timeout time.Duration) ([]byte, error) { m.mu.Lock() ble := m.ble tr := m.transport m.mu.Unlock() if tr != hostTransportBLE || ble == nil { return nil, fmt.Errorf("BLE stream ticks require BLE host") } return ble.NextStreamTick(timeout) } func (m *managedHost) HostStatus() map[string]any { m.mu.Lock() defer m.mu.Unlock() connected := false switch m.transport { case hostTransportUART: connected = m.sp != nil case hostTransportBLE: connected = m.ble != nil } return map[string]any{ "transport": m.transportOrNone(), "endpoint": m.endpoint, "connected": connected, "ble_name": m.bleName, } } func (m *managedHost) transportOrNone() string { if m.transport == "" { return hostTransportNone } return m.transport } func listSerialPorts() ([]string, error) { ports, err := serial.GetPortsList() if err != nil { return nil, err } if ports == nil { return []string{}, nil } return ports, nil } type bleScanDevice struct { Address string `json:"address"` Name string `json:"name"` RSSI int `json:"rssi"` } func scanBLEDevices(timeout time.Duration, nameFilter string) ([]bleScanDevice, error) { if timeout <= 0 { timeout = 5 * time.Second } adapter := bluetooth.DefaultAdapter if err := adapter.Enable(); err != nil { return nil, fmt.Errorf("enable bluetooth: %w", err) } seen := map[string]bleScanDevice{} var mu sync.Mutex go func() { time.Sleep(timeout) _ = adapter.StopScan() }() filter := strings.TrimSpace(nameFilter) _ = adapter.Scan(func(a *bluetooth.Adapter, result bluetooth.ScanResult) { name := result.LocalName() if filter != "" && !strings.Contains(strings.ToLower(name), strings.ToLower(filter)) { return } addr := result.Address.String() mu.Lock() seen[addr] = bleScanDevice{ Address: addr, Name: name, RSSI: int(result.RSSI), } mu.Unlock() }) // StopScan ends Scan; ignore error and return whatever was found. mu.Lock() defer mu.Unlock() out := make([]bleScanDevice, 0, len(seen)) for _, d := range seen { out = append(out, d) } return out, nil } func disconnectedState(portName string, err error) DashboardState { msg := "host disconnected" if err != nil { msg = err.Error() } return DashboardState{ UpdatedAt: time.Now().Format(time.RFC3339), SerialPort: portName, HostEndpoint: portName, UARTConnected: false, HostConnected: false, HostTransport: hostTransportNone, SerialOK: false, SerialError: msg, Master: MasterView{OK: false, Error: msg}, Clients: []ClientView{}, } }