|
- //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
- 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
- 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) 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
- }
- 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
- }
|