Allow the same staged firmware upload path on BLE as UART, fix distribute stack/event-group crashes, and recover sticky slave OTA sessions instead of failing with err=4. Co-authored-by: Cursor <cursoragent@cursor.com>
492 lines
11 KiB
Go
492 lines
11 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.
|
|
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
|
|
pausePoll bool // true while BLE latency tests run (pollers yield)
|
|
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
|
|
}
|
|
m.sp.flushInput()
|
|
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) beginPausePoll() bool {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.otaActive || m.pausePoll {
|
|
return false
|
|
}
|
|
m.pausePoll = true
|
|
return true
|
|
}
|
|
|
|
func (m *managedHost) endPausePoll() {
|
|
m.mu.Lock()
|
|
m.pausePoll = false
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
func (m *managedHost) PollPaused() bool {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.pausePoll || m.otaActive
|
|
}
|
|
|
|
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 || (poll && m.pausePoll) {
|
|
return nil, errHostBusy
|
|
}
|
|
switch m.transport {
|
|
case hostTransportBLE:
|
|
if m.ble == nil {
|
|
return nil, fmt.Errorf("BLE not connected")
|
|
}
|
|
resp, err := m.ble.exchangePayloadTimeout(payload, cmdName, timeout)
|
|
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{},
|
|
}
|
|
}
|