| @@ -0,0 +1,44 @@ | |||||
| { | |||||
| "audio": { | |||||
| "inputPath": "", | |||||
| "gain": 1.0, | |||||
| "toneLeftHz": 1000, | |||||
| "toneRightHz": 1600, | |||||
| "toneAmplitude": 0.2 | |||||
| }, | |||||
| "rds": { | |||||
| "enabled": true, | |||||
| "pi": "BEEF", | |||||
| "ps": "PLUTOOPI", | |||||
| "radioText": "Orange Pi Pluto Soapy test", | |||||
| "pty": 0 | |||||
| }, | |||||
| "fm": { | |||||
| "frequencyMHz": 100.0, | |||||
| "stereoEnabled": true, | |||||
| "pilotLevel": 0.09, | |||||
| "rdsInjection": 0.04, | |||||
| "preEmphasisTauUS": 50, | |||||
| "outputDrive": 0.5, | |||||
| "compositeRateHz": 228000, | |||||
| "maxDeviationHz": 75000, | |||||
| "limiterEnabled": true, | |||||
| "limiterCeiling": 1.0, | |||||
| "fmModulationEnabled": true, | |||||
| "mpxGain": 1.0, | |||||
| "bs412Enabled": false, | |||||
| "bs412ThresholdDBr": 0 | |||||
| }, | |||||
| "backend": { | |||||
| "kind": "soapy", | |||||
| "driver": "plutosdr", | |||||
| "device": "", | |||||
| "uri": "ip:pluto.local", | |||||
| "deviceArgs": {}, | |||||
| "outputPath": "", | |||||
| "deviceSampleRateHz": 2280000 | |||||
| }, | |||||
| "control": { | |||||
| "listenAddress": "127.0.0.1:8088" | |||||
| } | |||||
| } | |||||
| @@ -1,4 +1,4 @@ | |||||
| //go:build pluto && windows | |||||
| //go:build pluto && (windows || linux) | |||||
| package plutosdr | package plutosdr | ||||
| @@ -0,0 +1,364 @@ | |||||
| //go:build pluto && linux | |||||
| package plutosdr | |||||
| /* | |||||
| #cgo pkg-config: libiio | |||||
| #include <iio.h> | |||||
| #include <stdlib.h> | |||||
| #include <stdint.h> | |||||
| */ | |||||
| 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" | |||||
| ) | |||||
| 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 | |||||
| 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 | |||||
| } | |||||
| } | |||||
| 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 | |||||
| } | |||||
| pushed := int(C.iio_buffer_push(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 == 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(), | |||||
| LastError: d.lastError, | |||||
| LastErrorAt: d.lastErrorAt, | |||||
| EffectiveRate: d.cfg.SampleRateHz, | |||||
| } | |||||
| } | |||||
| 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 | |||||
| } | |||||
| @@ -1,4 +1,4 @@ | |||||
| //go:build !pluto || !windows | |||||
| //go:build !pluto || (!windows && !linux) | |||||
| package plutosdr | package plutosdr | ||||
| @@ -4,7 +4,9 @@ package soapysdr | |||||
| import ( | import ( | ||||
| "fmt" | "fmt" | ||||
| "log" | |||||
| "math" | "math" | ||||
| "sort" | |||||
| "unsafe" | "unsafe" | ||||
| ) | ) | ||||
| @@ -29,6 +31,13 @@ static const char* soapy_dlerror() { | |||||
| return dlerror(); | return dlerror(); | ||||
| } | } | ||||
| // Try to resolve SoapySDR_getLastError dynamically when available. | |||||
| typedef const char* (*last_error_fn)(void); | |||||
| static const char* call_last_error(void* fn) { | |||||
| if (fn == NULL) return NULL; | |||||
| return ((last_error_fn)fn)(); | |||||
| } | |||||
| // Function call trampolines — we call function pointers loaded via dlsym. | // Function call trampolines — we call function pointers loaded via dlsym. | ||||
| // These avoid the complexity of calling C function pointers from Go directly. | // These avoid the complexity of calling C function pointers from Go directly. | ||||
| @@ -102,22 +111,23 @@ static void call_kwargs_set(void* fn, void* kw, const char* key, const char* val | |||||
| import "C" | import "C" | ||||
| type soapyLib struct { | type soapyLib struct { | ||||
| handle unsafe.Pointer | |||||
| fnEnumerate unsafe.Pointer | |||||
| fnKwargsListClear unsafe.Pointer | |||||
| fnKwargsSet unsafe.Pointer | |||||
| fnMake unsafe.Pointer | |||||
| fnUnmake unsafe.Pointer | |||||
| fnSetSampleRate unsafe.Pointer | |||||
| fnSetFrequency unsafe.Pointer | |||||
| fnSetGain unsafe.Pointer | |||||
| fnGetGainRange unsafe.Pointer | |||||
| fnSetupStream unsafe.Pointer | |||||
| fnCloseStream unsafe.Pointer | |||||
| fnGetStreamMTU unsafe.Pointer | |||||
| fnActivateStream unsafe.Pointer | |||||
| fnDeactivateStream unsafe.Pointer | |||||
| fnWriteStream unsafe.Pointer | |||||
| handle unsafe.Pointer | |||||
| fnEnumerate unsafe.Pointer | |||||
| fnKwargsListClear unsafe.Pointer | |||||
| fnKwargsSet unsafe.Pointer | |||||
| fnMake unsafe.Pointer | |||||
| fnUnmake unsafe.Pointer | |||||
| fnSetSampleRate unsafe.Pointer | |||||
| fnSetFrequency unsafe.Pointer | |||||
| fnSetGain unsafe.Pointer | |||||
| fnGetGainRange unsafe.Pointer | |||||
| fnSetupStream unsafe.Pointer | |||||
| fnCloseStream unsafe.Pointer | |||||
| fnGetStreamMTU unsafe.Pointer | |||||
| fnActivateStream unsafe.Pointer | |||||
| fnDeactivateStream unsafe.Pointer | |||||
| fnWriteStream unsafe.Pointer | |||||
| fnGetLastError unsafe.Pointer | |||||
| } | } | ||||
| var libNames = []string{ | var libNames = []string{ | ||||
| @@ -164,6 +174,7 @@ func loadSoapyLib() (*soapyLib, error) { | |||||
| fnActivateStream: sym("SoapySDRDevice_activateStream"), | fnActivateStream: sym("SoapySDRDevice_activateStream"), | ||||
| fnDeactivateStream: sym("SoapySDRDevice_deactivateStream"), | fnDeactivateStream: sym("SoapySDRDevice_deactivateStream"), | ||||
| fnWriteStream: sym("SoapySDRDevice_writeStream"), | fnWriteStream: sym("SoapySDRDevice_writeStream"), | ||||
| fnGetLastError: sym("SoapySDR_getLastError"), | |||||
| }, nil | }, nil | ||||
| } | } | ||||
| @@ -192,20 +203,20 @@ func (lib *soapyLib) enumerate() ([]map[string]string, error) { | |||||
| } | } | ||||
| }() | }() | ||||
| devices := make([]map[string]string, int(length)) | |||||
| kwSize := unsafe.Sizeof(kwargs{}) | |||||
| devices := make([]map[string]string, 0, int(length)) | |||||
| kwSize := unsafe.Sizeof(C.GoKwargs{}) | |||||
| base := uintptr(ret) | base := uintptr(ret) | ||||
| for i := 0; i < int(length); i++ { | for i := 0; i < int(length); i++ { | ||||
| kw := (*kwargs)(unsafe.Pointer(base + uintptr(i)*kwSize)) | |||||
| entry := (*C.GoKwargs)(unsafe.Pointer(base + uintptr(i)*kwSize)) | |||||
| m := make(map[string]string) | m := make(map[string]string) | ||||
| for j := 0; j < int(kw.size); j++ { | |||||
| keyPtr := *(*uintptr)(unsafe.Pointer(uintptr(unsafe.Pointer(kw.keys)) + uintptr(j)*unsafe.Sizeof(uintptr(0)))) | |||||
| valPtr := *(*uintptr)(unsafe.Pointer(uintptr(unsafe.Pointer(kw.vals)) + uintptr(j)*unsafe.Sizeof(uintptr(0)))) | |||||
| if keyPtr != 0 && valPtr != 0 { | |||||
| m[C.GoString((*C.char)(unsafe.Pointer(keyPtr)))] = C.GoString((*C.char)(unsafe.Pointer(valPtr))) | |||||
| for j := 0; j < int(entry.size); j++ { | |||||
| keyPtr := *(**C.char)(unsafe.Pointer(uintptr(unsafe.Pointer(entry.keys)) + uintptr(j)*unsafe.Sizeof(uintptr(0)))) | |||||
| valPtr := *(**C.char)(unsafe.Pointer(uintptr(unsafe.Pointer(entry.vals)) + uintptr(j)*unsafe.Sizeof(uintptr(0)))) | |||||
| if keyPtr != nil && valPtr != nil { | |||||
| m[C.GoString(keyPtr)] = C.GoString(valPtr) | |||||
| } | } | ||||
| } | } | ||||
| devices[i] = m | |||||
| devices = append(devices, m) | |||||
| } | } | ||||
| return devices, nil | return devices, nil | ||||
| } | } | ||||
| @@ -217,9 +228,30 @@ func (lib *soapyLib) makeDevice(driver, device string, args map[string]string) ( | |||||
| var kw kwargs | var kw kwargs | ||||
| if driver != "" { lib.kwargsSet(&kw, "driver", driver) } | if driver != "" { lib.kwargsSet(&kw, "driver", driver) } | ||||
| if device != "" { lib.kwargsSet(&kw, "device", device) } | if device != "" { lib.kwargsSet(&kw, "device", device) } | ||||
| for k, v := range args { lib.kwargsSet(&kw, k, v) } | |||||
| keys := make([]string, 0, len(args)) | |||||
| for k := range args { | |||||
| keys = append(keys, k) | |||||
| } | |||||
| sort.Strings(keys) | |||||
| for _, k := range keys { | |||||
| lib.kwargsSet(&kw, k, args[k]) | |||||
| } | |||||
| log.Printf("soapy: makeDevice driver=%q device=%q args=%v", driver, device, args) | |||||
| ret := C.call_make(lib.fnMake, unsafe.Pointer(&kw)) | ret := C.call_make(lib.fnMake, unsafe.Pointer(&kw)) | ||||
| if ret == nil { return 0, fmt.Errorf("soapy: failed to open device") } | |||||
| if ret == nil { | |||||
| msg := "" | |||||
| if lib.fnGetLastError != nil { | |||||
| if p := C.call_last_error(lib.fnGetLastError); p != nil { | |||||
| msg = C.GoString(p) | |||||
| } | |||||
| } | |||||
| if msg != "" { | |||||
| return 0, fmt.Errorf("soapy: failed to open device: %s", msg) | |||||
| } | |||||
| return 0, fmt.Errorf("soapy: failed to open device") | |||||
| } | |||||
| return uintptr(ret), nil | return uintptr(ret), nil | ||||
| } | } | ||||
| @@ -0,0 +1,365 @@ | |||||
| #!/usr/bin/env bash | |||||
| set -Eeuo pipefail | |||||
| # Orange Pi Plus 2E / Armbian Bookworm build helper for fm-rds-tx | |||||
| # | |||||
| # Goals: | |||||
| # - install build + runtime dependencies | |||||
| # - install libiio / Pluto-related userspace bits | |||||
| # - build fmrtx for Linux ARM | |||||
| # - collect binary + shared libraries into dist/orangepi/ | |||||
| # | |||||
| # Notes: | |||||
| # - Linux Pluto build is libiio-first (`-tags pluto`). | |||||
| # - SoapySDR is optional fallback/debug tooling, not the primary Pluto path. | |||||
| # - Windows Pluto path remains separate and untouched by this script. | |||||
| # | |||||
| # Usage: | |||||
| # chmod +x scripts/orangepi-build-libiio.sh | |||||
| # ./scripts/orangepi-build-libiio.sh | |||||
| # | |||||
| # Optional env: | |||||
| # PREFIX=/opt/fm-rds-tx | |||||
| # DIST_DIR=dist/orangepi | |||||
| # GO_VERSION=1.22.12 | |||||
| # SKIP_APT=1 | |||||
| # SKIP_GO_INSTALL=1 | |||||
| # BUILD_TAGS=pluto | |||||
| # | |||||
| # If you want to install the packaged result into a target directory: | |||||
| # PREFIX=/opt/fm-rds-tx ./scripts/orangepi-build-libiio.sh | |||||
| SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" | |||||
| REPO_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)" | |||||
| DIST_DIR="${DIST_DIR:-${REPO_DIR}/dist/orangepi}" | |||||
| BUILD_DIR="${DIST_DIR}/build" | |||||
| RUNTIME_DIR="${DIST_DIR}/runtime" | |||||
| PREFIX="${PREFIX:-/opt/fm-rds-tx}" | |||||
| GO_VERSION="${GO_VERSION:-1.22.12}" | |||||
| BUILD_TAGS="${BUILD_TAGS:-pluto}" | |||||
| ARCH="$(dpkg --print-architecture 2>/dev/null || true)" | |||||
| log() { | |||||
| printf '\n[%s] %s\n' "$(date '+%H:%M:%S')" "$*" | |||||
| } | |||||
| need_cmd() { | |||||
| command -v "$1" >/dev/null 2>&1 || { | |||||
| echo "Missing required command: $1" >&2 | |||||
| exit 1 | |||||
| } | |||||
| } | |||||
| apt_install_if_missing() { | |||||
| local missing=() | |||||
| for pkg in "$@"; do | |||||
| if ! dpkg -s "$pkg" >/dev/null 2>&1; then | |||||
| missing+=("$pkg") | |||||
| fi | |||||
| done | |||||
| if ((${#missing[@]})); then | |||||
| log "Installing missing packages: ${missing[*]}" | |||||
| sudo apt-get install -y "${missing[@]}" | |||||
| fi | |||||
| } | |||||
| install_go() { | |||||
| if command -v go >/dev/null 2>&1; then | |||||
| log "Go already present: $(go version)" | |||||
| return | |||||
| fi | |||||
| if [[ "${SKIP_GO_INSTALL:-0}" == "1" ]]; then | |||||
| echo "go not found and SKIP_GO_INSTALL=1 set" >&2 | |||||
| exit 1 | |||||
| fi | |||||
| local go_arch | |||||
| case "$ARCH" in | |||||
| armhf) go_arch="armv6l" ;; | |||||
| arm64) go_arch="arm64" ;; | |||||
| amd64) go_arch="amd64" ;; | |||||
| *) | |||||
| echo "Unsupported architecture for automated Go install: $ARCH" >&2 | |||||
| exit 1 | |||||
| ;; | |||||
| esac | |||||
| local tarball="go${GO_VERSION}.linux-${go_arch}.tar.gz" | |||||
| local url="https://go.dev/dl/${tarball}" | |||||
| local tmp="/tmp/${tarball}" | |||||
| log "Installing Go ${GO_VERSION} for ${go_arch}" | |||||
| wget -O "$tmp" "$url" | |||||
| sudo rm -rf /usr/local/go | |||||
| sudo tar -C /usr/local -xzf "$tmp" | |||||
| export PATH="/usr/local/go/bin:${PATH}" | |||||
| if ! grep -q '/usr/local/go/bin' "$HOME/.profile" 2>/dev/null; then | |||||
| printf '\nexport PATH="/usr/local/go/bin:$PATH"\n' >> "$HOME/.profile" | |||||
| fi | |||||
| log "Go installed: $(/usr/local/go/bin/go version)" | |||||
| } | |||||
| resolve_lib() { | |||||
| local name="$1" | |||||
| local ldconfig_bin="" | |||||
| if command -v ldconfig >/dev/null 2>&1; then | |||||
| ldconfig_bin="$(command -v ldconfig)" | |||||
| elif [[ -x /sbin/ldconfig ]]; then | |||||
| ldconfig_bin="/sbin/ldconfig" | |||||
| elif [[ -x /usr/sbin/ldconfig ]]; then | |||||
| ldconfig_bin="/usr/sbin/ldconfig" | |||||
| fi | |||||
| if [[ -n "$ldconfig_bin" ]]; then | |||||
| "$ldconfig_bin" -p 2>/dev/null | awk -v lib="$name" '$1 == lib { print $NF; exit }' | |||||
| return 0 | |||||
| fi | |||||
| find /lib /usr/lib /usr/local/lib -name "$name" 2>/dev/null | head -n 1 | |||||
| } | |||||
| copy_lib_if_found() { | |||||
| local libname="$1" | |||||
| local path | |||||
| path="$(resolve_lib "$libname" || true)" | |||||
| if [[ -z "$path" ]]; then | |||||
| path="$(find /lib /usr/lib /usr/local/lib -name "$libname" 2>/dev/null | head -n 1 || true)" | |||||
| fi | |||||
| if [[ -n "$path" && -f "$path" ]]; then | |||||
| cp -Lv "$path" "$RUNTIME_DIR/lib/" | |||||
| else | |||||
| log "Library not found: $libname" | |||||
| fi | |||||
| } | |||||
| find_soapy_plugin_path() { | |||||
| local candidate="" | |||||
| if command -v SoapySDRUtil >/dev/null 2>&1; then | |||||
| candidate="$(SoapySDRUtil --info 2>/dev/null | awk -F': ' '/Search path:/ {print $2; exit}')" | |||||
| if [[ -n "$candidate" && -d "$candidate" ]]; then | |||||
| printf '%s\n' "$candidate" | |||||
| return 0 | |||||
| fi | |||||
| fi | |||||
| for candidate in \ | |||||
| /usr/local/lib/SoapySDR/modules0.8-3 \ | |||||
| /usr/local/lib/SoapySDR/modules0.8 \ | |||||
| /usr/lib/arm-linux-gnueabihf/SoapySDR/modules0.8-3 \ | |||||
| /usr/lib/arm-linux-gnueabihf/SoapySDR/modules0.8 \ | |||||
| /usr/lib/SoapySDR/modules0.8-3 \ | |||||
| /usr/lib/SoapySDR/modules0.8 | |||||
| do | |||||
| if [[ -d "$candidate" ]]; then | |||||
| printf '%s\n' "$candidate" | |||||
| return 0 | |||||
| fi | |||||
| done | |||||
| return 1 | |||||
| } | |||||
| copy_soapy_plugins_if_found() { | |||||
| local plugin_path | |||||
| plugin_path="$(find_soapy_plugin_path || true)" | |||||
| if [[ -n "$plugin_path" && -d "$plugin_path" ]]; then | |||||
| mkdir -p "$RUNTIME_DIR/soapy-modules" | |||||
| cp -Lv "$plugin_path"/* "$RUNTIME_DIR/soapy-modules/" 2>/dev/null || true | |||||
| printf '%s\n' "$plugin_path" > "$DIST_DIR/SOAPY_PLUGIN_PATH.txt" | |||||
| log "Copied Soapy plugins from: $plugin_path" | |||||
| else | |||||
| log "Soapy plugin path not found" | |||||
| fi | |||||
| } | |||||
| write_runner() { | |||||
| cat > "${DIST_DIR}/run-fmrtx.sh" <<'EOF' | |||||
| #!/usr/bin/env bash | |||||
| set -euo pipefail | |||||
| SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" | |||||
| SYSTEM_LIB_DIRS="/usr/local/lib:/usr/lib:/usr/lib/arm-linux-gnueabihf:/lib:/lib/arm-linux-gnueabihf" | |||||
| export LD_LIBRARY_PATH="${SCRIPT_DIR}/runtime/lib:${SYSTEM_LIB_DIRS}:${LD_LIBRARY_PATH:-}" | |||||
| if [[ -d "${SCRIPT_DIR}/runtime/soapy-modules" ]]; then | |||||
| export SOAPY_SDR_PLUGIN_PATH="${SCRIPT_DIR}/runtime/soapy-modules:${SOAPY_SDR_PLUGIN_PATH:-}" | |||||
| elif [[ -f "${SCRIPT_DIR}/SOAPY_PLUGIN_PATH.txt" ]]; then | |||||
| export SOAPY_SDR_PLUGIN_PATH="$(cat "${SCRIPT_DIR}/SOAPY_PLUGIN_PATH.txt"):${SOAPY_SDR_PLUGIN_PATH:-}" | |||||
| fi | |||||
| exec "${SCRIPT_DIR}/runtime/bin/fmrtx" "$@" | |||||
| EOF | |||||
| chmod +x "${DIST_DIR}/run-fmrtx.sh" | |||||
| } | |||||
| write_install_helper() { | |||||
| cat > "${DIST_DIR}/install.sh" <<EOF | |||||
| #!/usr/bin/env bash | |||||
| set -euo pipefail | |||||
| PREFIX="{1:-$PREFIX}" | |||||
| sudo mkdir -p "\$PREFIX/bin" "\$PREFIX/lib" | |||||
| sudo cp -v "${DIST_DIR}/runtime/bin/fmrtx" "\$PREFIX/bin/" | |||||
| sudo cp -v ${DIST_DIR}/runtime/lib/* "\$PREFIX/lib/" 2>/dev/null || true | |||||
| cat <<'EON' | |||||
| Installed. | |||||
| Run with e.g.: | |||||
| LD_LIBRARY_PATH=\$PREFIX/lib \$PREFIX/bin/fmrtx --help | |||||
| EON | |||||
| EOF | |||||
| # replace placeholder introduced to avoid accidental shell expansion confusion | |||||
| sed -i 's/{1:-/\${1:-/g' "${DIST_DIR}/install.sh" | |||||
| chmod +x "${DIST_DIR}/install.sh" | |||||
| } | |||||
| main() { | |||||
| need_cmd bash | |||||
| need_cmd uname | |||||
| need_cmd awk | |||||
| need_cmd sed | |||||
| need_cmd cp | |||||
| need_cmd mkdir | |||||
| need_cmd ldd | |||||
| mkdir -p "$BUILD_DIR" "$RUNTIME_DIR/bin" "$RUNTIME_DIR/lib" | |||||
| if [[ "${SKIP_APT:-0}" != "1" ]]; then | |||||
| log "Refreshing apt metadata" | |||||
| sudo apt-get update | |||||
| apt_install_if_missing \ | |||||
| ca-certificates \ | |||||
| curl \ | |||||
| wget \ | |||||
| git \ | |||||
| build-essential \ | |||||
| pkg-config \ | |||||
| gcc \ | |||||
| g++ \ | |||||
| make \ | |||||
| file \ | |||||
| binutils \ | |||||
| tar \ | |||||
| xz-utils \ | |||||
| libiio0 \ | |||||
| libiio-dev \ | |||||
| libusb-1.0-0 \ | |||||
| libxml2 \ | |||||
| libxml2-dev | |||||
| # Optional / best-effort packages. Not all repos expose them on every arch. | |||||
| sudo apt-get install -y soapysdr-tools libsoapysdr0.8 libsoapysdr-dev 2>/dev/null || true | |||||
| sudo apt-get install -y soapy-module-plutosdr 2>/dev/null || true | |||||
| sudo apt-get install -y iio-oscilloscope 2>/dev/null || true | |||||
| sudo apt-get install -y libusb-1.0-0-dev 2>/dev/null || true | |||||
| fi | |||||
| install_go | |||||
| need_cmd go | |||||
| export PATH="/usr/local/go/bin:${PATH}" | |||||
| export CGO_ENABLED=1 | |||||
| export GOOS=linux | |||||
| case "$ARCH" in | |||||
| armhf) | |||||
| export GOARCH=arm | |||||
| export GOARM=7 | |||||
| ;; | |||||
| arm64) | |||||
| export GOARCH=arm64 | |||||
| ;; | |||||
| amd64) | |||||
| export GOARCH=amd64 | |||||
| ;; | |||||
| *) | |||||
| echo "Unsupported architecture: $ARCH" >&2 | |||||
| exit 1 | |||||
| ;; | |||||
| esac | |||||
| log "Build environment" | |||||
| go version | |||||
| echo "ARCH=${ARCH} GOOS=${GOOS} GOARCH=${GOARCH:-} GOARM=${GOARM:-} CGO_ENABLED=${CGO_ENABLED}" | |||||
| log "Tidying modules" | |||||
| (cd "$REPO_DIR" && go mod tidy) | |||||
| log "Building fmrtx with Linux Pluto/libiio-first backend (tags: $BUILD_TAGS)" | |||||
| (cd "$REPO_DIR" && go build -v -tags "$BUILD_TAGS" -o "$RUNTIME_DIR/bin/fmrtx" ./cmd/fmrtx) | |||||
| log "Collecting runtime libraries" | |||||
| copy_lib_if_found "libiio.so.0" | |||||
| copy_lib_if_found "libSoapySDR.so.0.8" | |||||
| copy_lib_if_found "libusb-1.0.so.0" | |||||
| copy_lib_if_found "libxml2.so.2" | |||||
| copy_lib_if_found "libstdc++.so.6" | |||||
| copy_lib_if_found "libgcc_s.so.1" | |||||
| copy_lib_if_found "libm.so.6" | |||||
| copy_lib_if_found "libc.so.6" | |||||
| if [[ "$BUILD_TAGS" == *soapy* ]]; then | |||||
| copy_soapy_plugins_if_found | |||||
| else | |||||
| log "Skipping Soapy plugin copy (BUILD_TAGS=$BUILD_TAGS)" | |||||
| fi | |||||
| log "Writing helper scripts" | |||||
| write_runner | |||||
| write_install_helper | |||||
| log "Writing build manifest" | |||||
| cat > "${DIST_DIR}/BUILD-INFO.txt" <<EOF | |||||
| fm-rds-tx Orange Pi build | |||||
| ======================== | |||||
| Date: $(date -Is) | |||||
| Host: $(uname -a) | |||||
| Repo: $REPO_DIR | |||||
| Dist: $DIST_DIR | |||||
| Architecture: $ARCH | |||||
| Go: $(go version) | |||||
| Build command: | |||||
| go build -tags $BUILD_TAGS -o runtime/bin/fmrtx ./cmd/fmrtx | |||||
| Important note: | |||||
| Linux Pluto builds should prefer the native libiio path (`-tags pluto`). | |||||
| SoapySDR is optional fallback/debug infrastructure only. | |||||
| Windows Pluto handling is separate and intentionally untouched by this script. | |||||
| EOF | |||||
| log "Binary info" | |||||
| file "$RUNTIME_DIR/bin/fmrtx" || true | |||||
| log "Dynamic dependencies of built binary" | |||||
| ldd "$RUNTIME_DIR/bin/fmrtx" || true | |||||
| if [[ "$BUILD_TAGS" == *soapy* ]]; then | |||||
| log "Wrapper self-test: fmrtx --list-devices" | |||||
| "${DIST_DIR}/run-fmrtx.sh" --list-devices || true | |||||
| else | |||||
| log "Wrapper self-test: fmrtx --print-config" | |||||
| "${DIST_DIR}/run-fmrtx.sh" --print-config || true | |||||
| fi | |||||
| log "Done. Artifacts are in: $DIST_DIR" | |||||
| cat <<EOF | |||||
| Next steps: | |||||
| 1. Copy/use: $DIST_DIR/runtime/bin/fmrtx | |||||
| 2. Libraries are in: $DIST_DIR/runtime/lib/ | |||||
| 3. Launch via wrapper: | |||||
| $DIST_DIR/run-fmrtx.sh --help | |||||
| 4. Install to target prefix: | |||||
| $DIST_DIR/install.sh $PREFIX | |||||
| Reminder: | |||||
| Default Linux packaging now prefers the native libiio Pluto backend. | |||||
| Use BUILD_TAGS=soapy only when you explicitly want the Soapy path. | |||||
| Windows Pluto support is intentionally left on its separate path. | |||||
| EOF | |||||
| } | |||||
| main "$@" | |||||