Files
powerpods/goTool/host_managed.go
T
skrueckenandCursor aa67edfe94 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>
2026-10-23 22:40:28 +02:00

469 lines
10 KiB
Go

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{},
}
}