Prefer ATT MTU 517 and chunk oversized responses so CLIENT_INFO/BATTERY work with many pods; reset the goTool parser after timeouts so partial frames do not poison the next exchange. Co-authored-by: Cursor <cursoragent@cursor.com>
368 lines
8.6 KiB
Go
368 lines
8.6 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"sync"
|
|
"time"
|
|
|
|
"tinygo.org/x/bluetooth"
|
|
|
|
pb "powerpod/gotool/pb"
|
|
uartframe "powerpod/gotool/uart"
|
|
)
|
|
|
|
const (
|
|
bleServiceUUID = "6e400001-b5a3-f393-e0a9-e50e24dcca9e"
|
|
bleCharUUID = "6e400002-b5a3-f393-e0a9-e50e24dcca9e"
|
|
bleDefaultName = "Powerpod"
|
|
)
|
|
|
|
type bleLink struct {
|
|
device bluetooth.Device
|
|
char bluetooth.DeviceCharacteristic
|
|
mu sync.Mutex
|
|
parserMu sync.Mutex
|
|
parser *uartframe.Parser
|
|
respCh chan []byte
|
|
streamCh chan []byte
|
|
quiet bool
|
|
attMTU int // negotiated ATT MTU (0 if unknown)
|
|
}
|
|
|
|
func openBLE(name string, settle time.Duration) (*bleLink, error) {
|
|
if name == "" {
|
|
name = bleDefaultName
|
|
}
|
|
adapter := bluetooth.DefaultAdapter
|
|
if err := adapter.Enable(); err != nil {
|
|
return nil, fmt.Errorf("enable bluetooth adapter: %w (check BlueZ / permissions)", err)
|
|
}
|
|
|
|
log.Printf("BLE scanning for %q…", name)
|
|
var foundAddr bluetooth.Address
|
|
var found bool
|
|
go func() {
|
|
time.Sleep(15 * time.Second)
|
|
_ = adapter.StopScan()
|
|
}()
|
|
err := adapter.Scan(func(a *bluetooth.Adapter, result bluetooth.ScanResult) {
|
|
if result.LocalName() != name {
|
|
return
|
|
}
|
|
foundAddr = result.Address
|
|
found = true
|
|
_ = a.StopScan()
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan: %w", err)
|
|
}
|
|
if !found {
|
|
return nil, fmt.Errorf("device %q not found (check DIP8=BLE and advertising)", name)
|
|
}
|
|
return connectBLEDevice(foundAddr, settle)
|
|
}
|
|
|
|
func openBLEAddress(addrStr string, settle time.Duration) (*bleLink, error) {
|
|
adapter := bluetooth.DefaultAdapter
|
|
if err := adapter.Enable(); err != nil {
|
|
return nil, fmt.Errorf("enable bluetooth adapter: %w (check BlueZ / permissions)", err)
|
|
}
|
|
if mac, err := bluetooth.ParseMAC(addrStr); err == nil {
|
|
var addr bluetooth.Address
|
|
addr.Set(mac.String())
|
|
return connectBLEDevice(addr, settle)
|
|
}
|
|
// Fallback: scan until address string matches
|
|
log.Printf("BLE resolving address %s via scan…", addrStr)
|
|
var foundAddr bluetooth.Address
|
|
var found bool
|
|
go func() {
|
|
time.Sleep(15 * time.Second)
|
|
_ = adapter.StopScan()
|
|
}()
|
|
err := adapter.Scan(func(a *bluetooth.Adapter, result bluetooth.ScanResult) {
|
|
if result.Address.String() != addrStr {
|
|
return
|
|
}
|
|
foundAddr = result.Address
|
|
found = true
|
|
_ = a.StopScan()
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan for address: %w", err)
|
|
}
|
|
if !found {
|
|
return nil, fmt.Errorf("BLE address %s not found", addrStr)
|
|
}
|
|
return connectBLEDevice(foundAddr, settle)
|
|
}
|
|
|
|
func connectBLEDevice(addr bluetooth.Address, settle time.Duration) (*bleLink, error) {
|
|
svcUUID, err := bluetooth.ParseUUID(bleServiceUUID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
chrUUID, err := bluetooth.ParseUUID(bleCharUUID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
adapter := bluetooth.DefaultAdapter
|
|
log.Printf("BLE connecting to %s…", addr.String())
|
|
device, err := adapter.Connect(addr, bluetooth.ConnectionParams{})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("connect: %w", err)
|
|
}
|
|
|
|
services, err := device.DiscoverServices([]bluetooth.UUID{svcUUID})
|
|
if err != nil || len(services) == 0 {
|
|
_ = device.Disconnect()
|
|
return nil, fmt.Errorf("discover service: %v", err)
|
|
}
|
|
chars, err := services[0].DiscoverCharacteristics([]bluetooth.UUID{chrUUID})
|
|
if err != nil || len(chars) == 0 {
|
|
_ = device.Disconnect()
|
|
return nil, fmt.Errorf("discover characteristic: %v", err)
|
|
}
|
|
char := chars[0]
|
|
|
|
attMTU := 0
|
|
if mtu, err := char.GetMTU(); err == nil {
|
|
attMTU = int(mtu)
|
|
log.Printf("BLE ATT MTU=%d", attMTU)
|
|
if attMTU < otaMinATTMTU {
|
|
log.Printf("warning: MTU %d < %d — OTA (200 B chunks) and large stream ticks may fail",
|
|
attMTU, otaMinATTMTU)
|
|
}
|
|
}
|
|
|
|
link := &bleLink{
|
|
device: device,
|
|
char: char,
|
|
parser: uartframe.NewParser(),
|
|
respCh: make(chan []byte, 8),
|
|
streamCh: make(chan []byte, 64),
|
|
attMTU: attMTU,
|
|
}
|
|
|
|
if err := char.EnableNotifications(func(buf []byte) {
|
|
link.onNotify(buf)
|
|
}); err != nil {
|
|
_ = device.Disconnect()
|
|
return nil, fmt.Errorf("enable notifications: %w (CCCD required before commands)", err)
|
|
}
|
|
|
|
if settle <= 0 {
|
|
settle = time.Second
|
|
}
|
|
log.Printf("BLE connected; settling %v…", settle)
|
|
time.Sleep(settle)
|
|
|
|
return link, nil
|
|
}
|
|
|
|
func (b *bleLink) onNotify(buf []byte) {
|
|
b.parserMu.Lock()
|
|
defer b.parserMu.Unlock()
|
|
for _, by := range buf {
|
|
payload, ok, err := b.parser.Feed(by)
|
|
if err != nil || !ok {
|
|
continue
|
|
}
|
|
if len(payload) == 0 {
|
|
continue
|
|
}
|
|
if payload[0] == byte(pb.MessageType_BLE_STREAM_TICK) {
|
|
select {
|
|
case b.streamCh <- payload:
|
|
default:
|
|
}
|
|
continue
|
|
}
|
|
select {
|
|
case b.respCh <- payload:
|
|
default:
|
|
// Prefer dropping the oldest queued frame so a late/stale reply
|
|
// does not block the matching response for the in-flight command.
|
|
select {
|
|
case <-b.respCh:
|
|
default:
|
|
}
|
|
select {
|
|
case b.respCh <- payload:
|
|
default:
|
|
log.Printf("BLE: response channel full, dropping frame type=0x%02x", payload[0])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (b *bleLink) resetParser() {
|
|
b.parserMu.Lock()
|
|
b.parser = uartframe.NewParser()
|
|
b.parserMu.Unlock()
|
|
}
|
|
|
|
func (b *bleLink) Close() error {
|
|
return b.device.Disconnect()
|
|
}
|
|
|
|
func (b *bleLink) exchange(cmdID byte, cmdName string) ([]byte, error) {
|
|
return b.exchangePayload([]byte{cmdID}, cmdName)
|
|
}
|
|
|
|
func (b *bleLink) exchangePayload(payload []byte, cmdName string) ([]byte, error) {
|
|
return b.exchangePayloadTimeout(payload, cmdName, readTimeout)
|
|
}
|
|
|
|
func (b *bleLink) exchangePayloadTimeout(payload []byte, cmdName string, timeout time.Duration) ([]byte, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
if len(payload) == 0 {
|
|
return nil, fmt.Errorf("empty payload")
|
|
}
|
|
expect := payload[0]
|
|
if timeout <= 0 {
|
|
timeout = readTimeout
|
|
}
|
|
|
|
frame, err := uartframe.EncodeFrame(payload)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encode frame: %w", err)
|
|
}
|
|
if !b.quiet {
|
|
log.Printf("BLE sending %s (%d bytes)", cmdName, len(frame))
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case <-b.respCh:
|
|
default:
|
|
goto sent
|
|
}
|
|
}
|
|
sent:
|
|
if _, err := b.char.WriteWithoutResponse(frame); err != nil {
|
|
return nil, fmt.Errorf("BLE write: %w (check DIP8=BLE)", err)
|
|
}
|
|
|
|
deadline := time.Now().Add(timeout)
|
|
for {
|
|
remain := time.Until(deadline)
|
|
if remain <= 0 {
|
|
// Drop a half-parsed frame (e.g. aborted multi-notify) so the next
|
|
// exchange does not treat a new 0xAA as payload data.
|
|
b.resetParser()
|
|
return nil, fmt.Errorf("BLE read timeout for %s (check DIP8=BLE / -ble)", cmdName)
|
|
}
|
|
timer := time.NewTimer(remain)
|
|
select {
|
|
case resp := <-b.respCh:
|
|
timer.Stop()
|
|
if len(resp) == 0 {
|
|
continue
|
|
}
|
|
if resp[0] != expect {
|
|
// Stale reply (e.g. late BATTERY_STATUS after a short timeout).
|
|
if !b.quiet {
|
|
log.Printf("BLE: discard type=0x%02x while waiting for 0x%02x (%s)", resp[0], expect, cmdName)
|
|
}
|
|
continue
|
|
}
|
|
if !b.quiet {
|
|
log.Printf("BLE response (%d bytes)", len(resp))
|
|
}
|
|
return resp, nil
|
|
case <-timer.C:
|
|
b.resetParser()
|
|
return nil, fmt.Errorf("BLE read timeout for %s (check DIP8=BLE / -ble)", cmdName)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (b *bleLink) NextStreamTick(timeout time.Duration) ([]byte, error) {
|
|
if timeout <= 0 {
|
|
timeout = 5 * time.Second
|
|
}
|
|
timer := time.NewTimer(timeout)
|
|
defer timer.Stop()
|
|
select {
|
|
case p := <-b.streamCh:
|
|
return p, nil
|
|
case <-timer.C:
|
|
return nil, fmt.Errorf("BLE stream tick timeout")
|
|
}
|
|
}
|
|
|
|
// ensureOTAMTU rejects OTA when the negotiated ATT MTU cannot carry 200 B chunks.
|
|
func (b *bleLink) ensureOTAMTU() error {
|
|
mtu := b.attMTU
|
|
if mtu <= 0 {
|
|
if v, err := b.char.GetMTU(); err == nil {
|
|
mtu = int(v)
|
|
b.attMTU = mtu
|
|
}
|
|
}
|
|
if mtu < otaMinATTMTU {
|
|
return fmt.Errorf("BLE ATT MTU %d < %d required for OTA (negotiate larger MTU)", mtu, otaMinATTMTU)
|
|
}
|
|
maxWrite := mtu - 3
|
|
if maxWrite < 216 {
|
|
return fmt.Errorf("BLE max write %d < 216 needed for 200 B OTA frames", maxWrite)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *bleLink) writeMsg(msg *pb.UartMessage) error {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
frame, err := encodeUartMessage(msg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if b.attMTU > 0 && len(frame) > b.attMTU-3 {
|
|
return fmt.Errorf("OTA frame %d exceeds BLE max write %d (MTU=%d)",
|
|
len(frame), b.attMTU-3, b.attMTU)
|
|
}
|
|
if _, err := b.char.WriteWithoutResponse(frame); err != nil {
|
|
return fmt.Errorf("BLE write: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *bleLink) readFrame(timeout time.Duration) ([]byte, error) {
|
|
if timeout <= 0 {
|
|
timeout = readTimeout
|
|
}
|
|
timer := time.NewTimer(timeout)
|
|
defer timer.Stop()
|
|
select {
|
|
case p := <-b.respCh:
|
|
return p, nil
|
|
case <-timer.C:
|
|
return nil, fmt.Errorf("BLE read timeout")
|
|
}
|
|
}
|
|
|
|
func (b *bleLink) flushInput() {
|
|
for {
|
|
select {
|
|
case <-b.respCh:
|
|
default:
|
|
goto streams
|
|
}
|
|
}
|
|
streams:
|
|
for {
|
|
select {
|
|
case <-b.streamCh:
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (b *bleLink) chunkPace() time.Duration { return otaHostChunkPaceBLE }
|