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 }