//go:build pluto && windows // Package plutosdr provides a direct libiio-based TX driver for ADALM-Pluto. // Pure Go on Windows — loads libiio.dll via syscall.LoadLibrary at runtime. // No CGO, no C compiler required. // // Build: go build -tags pluto ./cmd/fmrtx // Requires: libiio installed (https://github.com/analogdevicesinc/libiio/releases) package plutosdr import ( "context" "fmt" "sync" "sync/atomic" "syscall" "time" "unsafe" "github.com/jan/fm-rds-tx/internal/output" "github.com/jan/fm-rds-tx/internal/platform" ) // iioLib holds function pointers loaded from libiio.dll type iioLib struct { dll *syscall.DLL // Context pCreateCtxFromURI *syscall.Proc pDestroyCtx *syscall.Proc // Device pCtxFindDevice *syscall.Proc pDeviceFindChannel *syscall.Proc pChannelEnable *syscall.Proc pChannelDisable *syscall.Proc pChannelIsEnabled *syscall.Proc // Attributes pChannelAttrWriteLongLong *syscall.Proc pChannelAttrWriteBool *syscall.Proc pDeviceAttrWriteLongLong *syscall.Proc // Buffer pCreateBuffer *syscall.Proc pDestroyBuffer *syscall.Proc pBufferPush *syscall.Proc pBufferStep *syscall.Proc pBufferStart *syscall.Proc pBufferEnd *syscall.Proc pBufferFirst *syscall.Proc } var dllSearchPaths = []string{ "libiio", "iio", `C:\Program Files\libiio\libiio.dll`, `C:\Program Files (x86)\libiio\libiio.dll`, } func loadIIOLib() (*iioLib, error) { var dll *syscall.DLL var lastErr error for _, path := range dllSearchPaths { dll, lastErr = syscall.LoadDLL(path) if dll != nil { break } } if dll == nil { return nil, fmt.Errorf("cannot load libiio.dll: %v", lastErr) } p := func(name string) *syscall.Proc { proc, _ := dll.FindProc(name) return proc } return &iioLib{ dll: dll, pCreateCtxFromURI: p("iio_create_context_from_uri"), pDestroyCtx: p("iio_context_destroy"), pCtxFindDevice: p("iio_context_find_device"), pDeviceFindChannel: p("iio_device_find_channel"), pChannelEnable: p("iio_channel_enable"), pChannelDisable: p("iio_channel_disable"), pChannelIsEnabled: p("iio_channel_is_enabled"), pChannelAttrWriteLongLong: p("iio_channel_attr_write_longlong"), pChannelAttrWriteBool: p("iio_channel_attr_write_bool"), pDeviceAttrWriteLongLong: p("iio_device_attr_write_longlong"), pCreateBuffer: p("iio_device_create_buffer"), pDestroyBuffer: p("iio_buffer_destroy"), pBufferPush: p("iio_buffer_push"), pBufferStep: p("iio_buffer_step"), pBufferStart: p("iio_buffer_start"), pBufferEnd: p("iio_buffer_end"), pBufferFirst: p("iio_buffer_first"), }, nil } // --- Driver --- type PlutoDriver struct { mu sync.Mutex lib *iioLib cfg platform.SoapyConfig ctx uintptr // iio_context* txDev uintptr // iio_device* (cf-ad9361-dds-core-lpc) phyDev uintptr // iio_device* (ad9361-phy) chanI uintptr // iio_channel* TX I chanQ uintptr // iio_channel* TX Q chanLO uintptr // iio_channel* TX LO (altvoltage1), cached for Tune() buf uintptr // iio_buffer* bufSize int // samples per buffer push started bool configured bool framesWritten atomic.Uint64 samplesWritten atomic.Uint64 underruns atomic.Uint64 lastError string lastErrorAt string initError string } func NewPlutoDriver() platform.SoapyDriver { lib, err := loadIIOLib() if err != nil { return &PlutoDriver{initError: err.Error()} } return &PlutoDriver{lib: lib} } 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() if d.lib == nil { return fmt.Errorf("libiio not loaded: %s", d.initError) } // Cleanup existing d.cleanup() d.cfg = cfg // Create IIO context via USB uri := "usb:" if cfg.Device != "" && cfg.Device != "plutosdr" { uri = cfg.Device // allow "ip:192.168.2.1" or specific USB } ctx, err := d.createContext(uri) if err != nil { return err } d.ctx = ctx // Find TX streaming device txDev := d.findDevice("cf-ad9361-dds-core-lpc") if txDev == 0 { return fmt.Errorf("pluto: TX device 'cf-ad9361-dds-core-lpc' not found") } d.txDev = txDev // Find PHY device for configuration phyDev := d.findDevice("ad9361-phy") if phyDev == 0 { return fmt.Errorf("pluto: PHY device 'ad9361-phy' not found") } d.phyDev = phyDev // --- AD9361 PHY configuration --- // The AD9364 (PlutoSDR) has TX on voltage3 (output), not voltage0 // voltage0 = RX input, voltage3 = TX output on single-channel AD9364 // Find TX PHY output channel phyChanTX := d.findChannel(phyDev, "voltage3", true) // output=true if phyChanTX == 0 { // Fallback for dual-channel AD9361: try voltage0 output phyChanTX = d.findChannel(phyDev, "voltage0", true) } if phyChanTX == 0 { return fmt.Errorf("pluto: PHY TX channel not found (tried voltage3, voltage0)") } // Sample rate — AD9361 minimum is ~2.084 MHz. // We set the hardware to this minimum and resample from composite rate. rate := int64(cfg.SampleRateHz) if rate < 2084000 { rate = 2084000 // AD9361 minimum } // Update effective rate so the engine knows the real device rate d.cfg.SampleRateHz = float64(rate) d.writeChanAttrLL(phyChanTX, "sampling_frequency", rate) // RF bandwidth — set to match our signal bandwidth (wider than composite) bw := rate if bw > 2000000 { bw = 2000000 // 2 MHz BW is enough for FM broadcast } d.writeChanAttrLL(phyChanTX, "rf_bandwidth", bw) // TX LO frequency phyChanLO := d.findChannel(phyDev, "altvoltage1", true) // TX LO d.chanLO = phyChanLO // cache for Tune() if phyChanLO != 0 { freqHz := int64(cfg.CenterFreqHz) if freqHz <= 0 { freqHz = 100000000 // 100 MHz default } d.writeChanAttrLL(phyChanLO, "frequency", freqHz) } // TX gain/attenuation // PlutoSDR TX hardwaregain: 0 dB = max power, -89.75 dB = min // Value is in dB (not millidB) as a negative number // For max power: set to 0. For safety: set to -20 or so. // cfg.GainDB from our config is 0..89 positive, we negate it and subtract from 0 attenDB := int64(0) // default = max power if cfg.GainDB > 0 { // GainDB=89 means full attenuation, GainDB=0 means max power attenDB = -int64(89 - cfg.GainDB) if attenDB > 0 { attenDB = 0 } if attenDB < -89 { attenDB = -89 } } d.writeChanAttrLL(phyChanTX, "hardwaregain", attenDB*1000) // millidB // --- TX streaming channels on cf-ad9361-dds-core-lpc --- // voltage0 (I) and voltage1 (Q) are output channels chanI := d.findChannel(txDev, "voltage0", true) chanQ := d.findChannel(txDev, "voltage1", true) if chanI == 0 || chanQ == 0 { return fmt.Errorf("pluto: TX I/Q channels not found on streaming device") } d.enableChannel(chanI) d.enableChannel(chanQ) d.chanI = chanI d.chanQ = chanQ // Create buffer — samples per push (per channel) // At 2.084 MHz with 50ms chunks = 104200 samples. Buffer must fit this. d.bufSize = int(rate) / 20 // 50ms worth if d.bufSize < 4096 { d.bufSize = 4096 } // libiio can handle large buffers; no artificial cap needed buf := d.createBuffer(txDev, d.bufSize, false) if buf == 0 { 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() lib := d.lib buf := d.buf chanI := d.chanI chanQ := d.chanQ started := d.started bufSize := d.bufSize d.mu.Unlock() if !started || buf == 0 || lib == 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 := d.bufferStep(buf) if step == 0 { return written, fmt.Errorf("pluto: buffer step is 0") } // iio_buffer_first gives the pointer to the first sample for each channel. // For interleaved buffers (step=4 with 2x int16), ptrI and ptrQ may // differ by 2 bytes (I at offset 0, Q at offset 2 within each step). // For non-interleaved, they point to separate memory regions. ptrI := d.bufferFirst(buf, chanI) ptrQ := d.bufferFirst(buf, chanQ) if ptrI == 0 || ptrQ == 0 { return written, fmt.Errorf("pluto: buffer_first returned null (I=%d Q=%d)", ptrI, ptrQ) } end := d.bufferEnd(buf) if end > 0 { bufSamples := int((end - ptrI) / uintptr(step)) if bufSamples > 0 && chunk > bufSamples { chunk = bufSamples } } for i := 0; i < chunk; i++ { s := frame.Samples[written+i] // Scale float32 [-1,+1] to int16 [-32767,+32767] *(*int16)(unsafe.Pointer(ptrI)) = int16(s.I * 32767) *(*int16)(unsafe.Pointer(ptrQ)) = int16(s.Q * 32767) ptrI += uintptr(step) ptrQ += uintptr(step) } pushed := d.bufferPush(buf) 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 == 0 { return fmt.Errorf("pluto: not configured or LO channel not available") } if d.lib.pChannelAttrWriteLongLong == nil { return fmt.Errorf("pluto: iio_channel_attr_write_longlong not loaded") } cAttr, _ := syscall.BytePtrFromString("frequency") ret, _, _ := d.lib.pChannelAttrWriteLongLong.Call( d.chanLO, uintptr(unsafe.Pointer(cAttr)), uintptr(int64(freqHz)), ) if int32(ret) < 0 { return fmt.Errorf("pluto: LO tune to %.0f Hz failed (iio rc=%d)", freqHz, int32(ret)) } return nil } 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 != 0, FramesWritten: d.framesWritten.Load(), SamplesWritten: d.samplesWritten.Load(), Underruns: d.underruns.Load(), LastError: d.lastError, LastErrorAt: d.lastErrorAt, EffectiveRate: d.cfg.SampleRateHz, } } // --- internal helpers --- func (d *PlutoDriver) cleanup() { if d.buf != 0 && d.lib.pDestroyBuffer != nil { d.lib.pDestroyBuffer.Call(d.buf) d.buf = 0 } if d.chanI != 0 { d.disableChannel(d.chanI) d.chanI = 0 } if d.chanQ != 0 { d.disableChannel(d.chanQ) d.chanQ = 0 } d.chanLO = 0 // config-only channel, no disable needed if d.ctx != 0 && d.lib.pDestroyCtx != nil { d.lib.pDestroyCtx.Call(d.ctx) d.ctx = 0 } d.configured = false } func (d *PlutoDriver) createContext(uri string) (uintptr, error) { if d.lib.pCreateCtxFromURI == nil { return 0, fmt.Errorf("iio_create_context_from_uri not found") } cURI, _ := syscall.BytePtrFromString(uri) ret, _, _ := d.lib.pCreateCtxFromURI.Call(uintptr(unsafe.Pointer(cURI))) if ret == 0 { return 0, fmt.Errorf("pluto: failed to create IIO context (uri=%s)", uri) } return ret, nil } func (d *PlutoDriver) findDevice(name string) uintptr { if d.lib.pCtxFindDevice == nil || d.ctx == 0 { return 0 } cName, _ := syscall.BytePtrFromString(name) ret, _, _ := d.lib.pCtxFindDevice.Call(d.ctx, uintptr(unsafe.Pointer(cName))) return ret } func (d *PlutoDriver) findChannel(dev uintptr, name string, isOutput bool) uintptr { if d.lib.pDeviceFindChannel == nil || dev == 0 { return 0 } cName, _ := syscall.BytePtrFromString(name) out := uintptr(0) if isOutput { out = 1 } ret, _, _ := d.lib.pDeviceFindChannel.Call(dev, uintptr(unsafe.Pointer(cName)), out) return ret } func (d *PlutoDriver) enableChannel(ch uintptr) { if d.lib.pChannelEnable != nil && ch != 0 { d.lib.pChannelEnable.Call(ch) } } func (d *PlutoDriver) disableChannel(ch uintptr) { if d.lib.pChannelDisable != nil && ch != 0 { d.lib.pChannelDisable.Call(ch) } } func (d *PlutoDriver) writeChanAttrLL(ch uintptr, attr string, val int64) { if d.lib.pChannelAttrWriteLongLong == nil || ch == 0 { return } cAttr, _ := syscall.BytePtrFromString(attr) d.lib.pChannelAttrWriteLongLong.Call(ch, uintptr(unsafe.Pointer(cAttr)), uintptr(val)) } func (d *PlutoDriver) writeDevAttrLL(dev uintptr, attr string, val int64) { if d.lib.pDeviceAttrWriteLongLong == nil || dev == 0 { return } cAttr, _ := syscall.BytePtrFromString(attr) d.lib.pDeviceAttrWriteLongLong.Call(dev, uintptr(unsafe.Pointer(cAttr)), uintptr(val)) } func (d *PlutoDriver) createBuffer(dev uintptr, sampleCount int, cyclic bool) uintptr { if d.lib.pCreateBuffer == nil || dev == 0 { return 0 } c := uintptr(0) if cyclic { c = 1 } ret, _, _ := d.lib.pCreateBuffer.Call(dev, uintptr(sampleCount), c) return ret } func (d *PlutoDriver) bufferPush(buf uintptr) int { if d.lib.pBufferPush == nil || buf == 0 { return -1 } ret, _, _ := d.lib.pBufferPush.Call(buf) return int(int32(ret)) } func (d *PlutoDriver) bufferStart(buf uintptr) uintptr { if d.lib.pBufferStart == nil { return 0 } ret, _, _ := d.lib.pBufferStart.Call(buf) return ret } func (d *PlutoDriver) bufferEnd(buf uintptr) uintptr { if d.lib.pBufferEnd == nil { return 0 } ret, _, _ := d.lib.pBufferEnd.Call(buf) return ret } func (d *PlutoDriver) bufferStep(buf uintptr) uintptr { if d.lib.pBufferStep == nil { return 0 } ret, _, _ := d.lib.pBufferStep.Call(buf) return ret } func (d *PlutoDriver) bufferFirst(buf uintptr, ch uintptr) uintptr { if d.lib.pBufferFirst == nil { return 0 } ret, _, _ := d.lib.pBufferFirst.Call(buf, ch) return ret }