Add dynamic UART/BLE host connect for serve, dashboard, and WS.
Serve starts without a required port; REST/WS/UI can scan and switch transports, with BLE live ticks and UART-only OTA. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,468 @@
|
||||
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{},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user