Files
powerpods/goTool/ble_link.go
T
skrueckenandCursor 141176a824 Send large BLE host frames across multiple notifies.
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>
2026-11-01 23:53:40 +01:00

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 }