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 parser *uartframe.Parser respCh chan []byte streamCh chan []byte quiet bool } 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] if mtu, err := char.GetMTU(); err == nil { log.Printf("BLE ATT MTU=%d", mtu) if mtu < 203 { log.Printf("warning: MTU %d < 203 — large frames/stream ticks may fail", mtu) } } link := &bleLink{ device: device, char: char, parser: uartframe.NewParser(), respCh: make(chan []byte, 8), streamCh: make(chan []byte, 64), } 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) { 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) 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 { 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: 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") } }