//go:build pluto && linux package plutosdr /* #cgo pkg-config: libiio #include #include #include */ import "C" import ( "context" "fmt" "log" "sync" "sync/atomic" "time" "unsafe" "github.com/jan/fm-rds-tx/internal/output" "github.com/jan/fm-rds-tx/internal/platform" ) func updateMaxDuration(dst *atomic.Uint64, d time.Duration) { v := uint64(d) for { cur := dst.Load() if v <= cur { return } if dst.CompareAndSwap(cur, v) { return } } } func durationMs(ns uint64) float64 { return float64(ns) / float64(time.Millisecond) } type PlutoDriver struct { mu sync.Mutex cfg platform.SoapyConfig ctx *C.struct_iio_context txDev *C.struct_iio_device phyDev *C.struct_iio_device chanI *C.struct_iio_channel chanQ *C.struct_iio_channel chanLO *C.struct_iio_channel buf *C.struct_iio_buffer bufSize int started bool configured bool framesWritten atomic.Uint64 samplesWritten atomic.Uint64 underruns atomic.Uint64 slowWrites atomic.Uint64 slowFills atomic.Uint64 slowPushes atomic.Uint64 maxWriteNs atomic.Uint64 maxFillNs atomic.Uint64 maxPushNs atomic.Uint64 lastError string lastErrorAt string layoutLogged bool } func NewPlutoDriver() platform.SoapyDriver { return &PlutoDriver{} } func (d *PlutoDriver) Name() string { return "pluto-iio" } func (d *PlutoDriver) Configure(_ context.Context, cfg platform.SoapyConfig) error { d.mu.Lock() defer d.mu.Unlock() d.cleanup() d.cfg = cfg uri := "usb:" if cfg.Device != "" && cfg.Device != "plutosdr" { uri = cfg.Device } if v, ok := cfg.DeviceArgs["uri"]; ok && v != "" { uri = v } cURI := C.CString(uri) defer C.free(unsafe.Pointer(cURI)) ctx := C.iio_create_context_from_uri(cURI) if ctx == nil { return fmt.Errorf("pluto: failed to create IIO context (uri=%s)", uri) } d.ctx = ctx txDev := d.findDevice("cf-ad9361-dds-core-lpc") if txDev == nil { return fmt.Errorf("pluto: TX device 'cf-ad9361-dds-core-lpc' not found") } d.txDev = txDev phyDev := d.findDevice("ad9361-phy") if phyDev == nil { return fmt.Errorf("pluto: PHY device 'ad9361-phy' not found") } d.phyDev = phyDev phyChanTX := d.findChannel(phyDev, "voltage3", true) if phyChanTX == nil { phyChanTX = d.findChannel(phyDev, "voltage0", true) } if phyChanTX == nil { return fmt.Errorf("pluto: PHY TX channel not found (tried voltage3, voltage0)") } rate := int64(cfg.SampleRateHz) if rate < 2084000 { rate = 2084000 } d.cfg.SampleRateHz = float64(rate) if err := d.writeChanAttrLL(phyChanTX, "sampling_frequency", rate); err != nil { return err } bw := rate if bw > 2000000 { bw = 2000000 } if err := d.writeChanAttrLL(phyChanTX, "rf_bandwidth", bw); err != nil { return err } phyChanLO := d.findChannel(phyDev, "altvoltage1", true) d.chanLO = phyChanLO if phyChanLO != nil { freqHz := int64(cfg.CenterFreqHz) if freqHz <= 0 { freqHz = 100000000 } if err := d.writeChanAttrLL(phyChanLO, "frequency", freqHz); err != nil { return err } } attenDB := int64(0) if cfg.GainDB > 0 { attenDB = -int64(89 - cfg.GainDB) if attenDB > 0 { attenDB = 0 } if attenDB < -89 { attenDB = -89 } } _ = d.writeChanAttrLL(phyChanTX, "hardwaregain", attenDB*1000) chanI := d.findChannel(txDev, "voltage0", true) chanQ := d.findChannel(txDev, "voltage1", true) if chanI == nil || chanQ == nil { return fmt.Errorf("pluto: TX I/Q channels not found on streaming device") } C.iio_channel_enable(chanI) C.iio_channel_enable(chanQ) d.chanI = chanI d.chanQ = chanQ d.bufSize = int(rate) / 20 if d.bufSize < 4096 { d.bufSize = 4096 } buf := C.iio_device_create_buffer(txDev, C.size_t(d.bufSize), C.bool(false)) if buf == nil { return fmt.Errorf("pluto: failed to create TX buffer (size=%d)", d.bufSize) } d.buf = buf d.configured = true return nil } func (d *PlutoDriver) Capabilities(_ context.Context) (platform.DeviceCaps, error) { return platform.DeviceCaps{ MinSampleRate: 521e3, MaxSampleRate: 61.44e6, HasGain: true, GainMinDB: -89, GainMaxDB: 0, Channels: []int{0}, }, nil } func (d *PlutoDriver) Start(_ context.Context) error { d.mu.Lock() defer d.mu.Unlock() if !d.configured { return fmt.Errorf("pluto: not configured") } if d.started { return fmt.Errorf("pluto: already started") } d.started = true return nil } func (d *PlutoDriver) Write(_ context.Context, frame *output.CompositeFrame) (int, error) { d.mu.Lock() buf := d.buf chanI := d.chanI chanQ := d.chanQ started := d.started bufSize := d.bufSize d.mu.Unlock() if !started || buf == nil { return 0, fmt.Errorf("pluto: not active") } if frame == nil || len(frame.Samples) == 0 { return 0, nil } written := 0 total := len(frame.Samples) for written < total { chunk := total - written if chunk > bufSize { chunk = bufSize } step := uintptr(C.iio_buffer_step(buf)) if step == 0 { return written, fmt.Errorf("pluto: buffer step is 0") } ptrI := uintptr(C.iio_buffer_first(buf, chanI)) ptrQ := uintptr(C.iio_buffer_first(buf, chanQ)) if ptrI == 0 || ptrQ == 0 { return written, fmt.Errorf("pluto: buffer_first returned null") } end := uintptr(C.iio_buffer_end(buf)) d.mu.Lock() if !d.layoutLogged { delta := int64(ptrQ) - int64(ptrI) span := int64(0) if end > ptrI { span = int64(end - ptrI) } log.Printf("pluto-linux: buffer layout step=%d ptrI=%#x ptrQ=%#x delta=%d end=%#x span=%d bufSize=%d", step, ptrI, ptrQ, delta, end, span, bufSize) d.layoutLogged = true } d.mu.Unlock() if end > 0 { bufSamples := int((end - ptrI) / step) if bufSamples > 0 && chunk > bufSamples { chunk = bufSamples } } fillStart := time.Now() for i := 0; i < chunk; i++ { s := frame.Samples[written+i] *(*int16)(unsafe.Pointer(ptrI)) = int16(s.I * 32767) *(*int16)(unsafe.Pointer(ptrQ)) = int16(s.Q * 32767) ptrI += step ptrQ += step } fillDur := time.Since(fillStart) pushStart := time.Now() pushed := int(C.iio_buffer_push(buf)) pushDur := time.Since(pushStart) chunkBudget := time.Duration(0) if d.cfg.SampleRateHz > 0 { chunkBudget = time.Duration(float64(chunk) / d.cfg.SampleRateHz * float64(time.Second)) } writeDur := fillDur + pushDur updateMaxDuration(&d.maxFillNs, fillDur) updateMaxDuration(&d.maxPushNs, pushDur) updateMaxDuration(&d.maxWriteNs, writeDur) slow := false if chunkBudget > 0 { if fillDur > chunkBudget/4 { d.slowFills.Add(1) slow = true } if pushDur > chunkBudget/2 { d.slowPushes.Add(1) slow = true } if writeDur > chunkBudget { d.slowWrites.Add(1) slow = true } } if slow { sw := d.slowWrites.Load() sf := d.slowFills.Load() sp := d.slowPushes.Load() if sw <= 5 || sf <= 5 || sp <= 5 || sw%20 == 0 || sf%20 == 0 || sp%20 == 0 { log.Printf("pluto-linux slow write: chunk=%d budget=%s fill=%s push=%s total=%s", chunk, chunkBudget, fillDur, pushDur, writeDur) } } if pushed < 0 { d.mu.Lock() d.lastError = fmt.Sprintf("buffer_push: %d", pushed) d.lastErrorAt = time.Now().UTC().Format(time.RFC3339) d.underruns.Add(1) d.mu.Unlock() return written, fmt.Errorf("pluto: buffer_push returned %d", pushed) } written += chunk } d.framesWritten.Add(1) d.samplesWritten.Add(uint64(written)) return written, nil } func (d *PlutoDriver) Stop(_ context.Context) error { d.mu.Lock() defer d.mu.Unlock() d.started = false return nil } func (d *PlutoDriver) Flush(_ context.Context) error { return nil } func (d *PlutoDriver) Tune(_ context.Context, freqHz float64) error { d.mu.Lock() defer d.mu.Unlock() if !d.configured || d.chanLO == nil { return fmt.Errorf("pluto: not configured or LO channel not available") } return d.writeChanAttrLL(d.chanLO, "frequency", int64(freqHz)) } func (d *PlutoDriver) Close(_ context.Context) error { d.mu.Lock() defer d.mu.Unlock() d.started = false d.cleanup() return nil } func (d *PlutoDriver) Stats() platform.RuntimeStats { d.mu.Lock() defer d.mu.Unlock() return platform.RuntimeStats{ TXEnabled: d.started, StreamActive: d.started && d.buf != nil, FramesWritten: d.framesWritten.Load(), SamplesWritten: d.samplesWritten.Load(), Underruns: d.underruns.Load(), SlowWrites: d.slowWrites.Load(), SlowFills: d.slowFills.Load(), SlowPushes: d.slowPushes.Load(), LastError: d.lastError, LastErrorAt: d.lastErrorAt, EffectiveRate: d.cfg.SampleRateHz, MaxWriteMs: durationMs(d.maxWriteNs.Load()), MaxFillMs: durationMs(d.maxFillNs.Load()), MaxPushMs: durationMs(d.maxPushNs.Load()), } } func (d *PlutoDriver) cleanup() { if d.buf != nil { C.iio_buffer_destroy(d.buf) d.buf = nil } if d.chanI != nil { C.iio_channel_disable(d.chanI) d.chanI = nil } if d.chanQ != nil { C.iio_channel_disable(d.chanQ) d.chanQ = nil } d.chanLO = nil if d.ctx != nil { C.iio_context_destroy(d.ctx) d.ctx = nil } d.txDev = nil d.phyDev = nil d.configured = false d.layoutLogged = false } func (d *PlutoDriver) findDevice(name string) *C.struct_iio_device { if d.ctx == nil { return nil } cName := C.CString(name) defer C.free(unsafe.Pointer(cName)) return C.iio_context_find_device(d.ctx, cName) } func (d *PlutoDriver) findChannel(dev *C.struct_iio_device, name string, isOutput bool) *C.struct_iio_channel { if dev == nil { return nil } cName := C.CString(name) defer C.free(unsafe.Pointer(cName)) if isOutput { return C.iio_device_find_channel(dev, cName, C.bool(true)) } return C.iio_device_find_channel(dev, cName, C.bool(false)) } func (d *PlutoDriver) writeChanAttrLL(ch *C.struct_iio_channel, attr string, val int64) error { if ch == nil { return fmt.Errorf("pluto: channel missing for attr %s", attr) } cAttr := C.CString(attr) defer C.free(unsafe.Pointer(cAttr)) ret := C.iio_channel_attr_write_longlong(ch, cAttr, C.longlong(val)) if ret < 0 { return fmt.Errorf("pluto: write attr %s failed (rc=%d)", attr, int(ret)) } return nil }