Expose OTA_SLAVE_PROGRESS on the master, track per-slave state during distribution, run ESP-NOW OTA in a background task so the host can poll while slaves update, and show master/slave progress in the dashboard with table layout and faster WebSocket refresh during uploads. Co-authored-by: Cursor <cursoragent@cursor.com>
164 lines
3.5 KiB
Go
164 lines
3.5 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// errUARTBusy is returned when the port is held for OTA (poller should not treat as unplug).
|
|
var errUARTBusy = errors.New("uart busy (OTA in progress)")
|
|
|
|
// managedSerial keeps the UART open and reconnects after I/O failures or unplug.
|
|
type managedSerial struct {
|
|
portName string
|
|
baud int
|
|
quiet bool
|
|
|
|
mu sync.Mutex
|
|
sp *serialPort
|
|
}
|
|
|
|
func newManagedSerial(portName string, baud int) *managedSerial {
|
|
return &managedSerial{
|
|
portName: portName,
|
|
baud: baud,
|
|
}
|
|
}
|
|
|
|
func (m *managedSerial) Close() error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.closeLocked()
|
|
}
|
|
|
|
func (m *managedSerial) IsConnected() bool {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.sp != nil
|
|
}
|
|
|
|
func (m *managedSerial) openLocked() error {
|
|
sp, err := openSerial(m.portName, 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.portName, m.baud)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *managedSerial) closeLocked() error {
|
|
if m.sp == nil {
|
|
return nil
|
|
}
|
|
err := m.sp.port.Close()
|
|
m.sp = nil
|
|
return err
|
|
}
|
|
|
|
func (m *managedSerial) invalidateLocked(reason error) {
|
|
if m.sp == nil {
|
|
return
|
|
}
|
|
if !m.quiet {
|
|
log.Printf("UART %s disconnected: %v", m.portName, reason)
|
|
}
|
|
_ = m.closeLocked()
|
|
}
|
|
|
|
func (m *managedSerial) withPort(fn func(*serialPort) error) error {
|
|
return m.withPortLocked(false, fn)
|
|
}
|
|
|
|
// withPortPoll is like withPort but returns errUARTBusy instead of blocking during OTA.
|
|
func (m *managedSerial) withPortPoll(fn func(*serialPort) error) error {
|
|
return m.withPortLocked(true, fn)
|
|
}
|
|
|
|
func (m *managedSerial) withPortLocked(try bool, fn func(*serialPort) error) error {
|
|
if try {
|
|
if !m.mu.TryLock() {
|
|
return errUARTBusy
|
|
}
|
|
} else {
|
|
m.mu.Lock()
|
|
}
|
|
defer m.mu.Unlock()
|
|
|
|
if m.sp == nil {
|
|
if err := m.openLocked(); err != nil {
|
|
return fmt.Errorf("%s: %w", m.portName, err)
|
|
}
|
|
}
|
|
|
|
err := fn(m.sp)
|
|
if err != nil {
|
|
m.invalidateLocked(err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (m *managedSerial) exchangePayload(payload []byte, cmdName string) ([]byte, error) {
|
|
return m.exchangePayloadVia(m.withPort, payload, cmdName)
|
|
}
|
|
|
|
func (m *managedSerial) exchangePayloadPoll(payload []byte, cmdName string) ([]byte, error) {
|
|
return m.exchangePayloadVia(m.withPortPoll, payload, cmdName)
|
|
}
|
|
|
|
func (m *managedSerial) exchangePayloadVia(
|
|
portFn func(func(*serialPort) error) error,
|
|
payload []byte, cmdName string,
|
|
) ([]byte, error) {
|
|
var resp []byte
|
|
err := portFn(func(sp *serialPort) error {
|
|
var e error
|
|
resp, e = sp.exchangePayloadLocked(payload, cmdName)
|
|
return e
|
|
})
|
|
return resp, err
|
|
}
|
|
|
|
func (m *managedSerial) exchange(cmdID byte, cmdName string) ([]byte, error) {
|
|
return m.exchangeVia(m.withPort, cmdID, cmdName)
|
|
}
|
|
|
|
func (m *managedSerial) exchangePoll(cmdID byte, cmdName string) ([]byte, error) {
|
|
return m.exchangeVia(m.withPortPoll, cmdID, cmdName)
|
|
}
|
|
|
|
func (m *managedSerial) exchangeVia(
|
|
portFn func(func(*serialPort) error) error,
|
|
cmdID byte, cmdName string,
|
|
) ([]byte, error) {
|
|
var resp []byte
|
|
err := portFn(func(sp *serialPort) error {
|
|
var e error
|
|
resp, e = sp.exchangeLocked(cmdID, cmdName)
|
|
return e
|
|
})
|
|
return resp, err
|
|
}
|
|
|
|
func disconnectedState(portName string, err error) DashboardState {
|
|
msg := "UART disconnected"
|
|
if err != nil {
|
|
msg = err.Error()
|
|
}
|
|
return DashboardState{
|
|
UpdatedAt: time.Now().Format(time.RFC3339),
|
|
SerialPort: portName,
|
|
UARTConnected: false,
|
|
SerialOK: false,
|
|
SerialError: msg,
|
|
Master: MasterView{OK: false, Error: msg},
|
|
Clients: []ClientView{},
|
|
}
|
|
}
|