| @@ -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 | |||
| @@ -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 | |||
| @@ -4,7 +4,9 @@ package soapysdr | |||
| import ( | |||
| "fmt" | |||
| "log" | |||
| "math" | |||
| "sort" | |||
| "unsafe" | |||
| ) | |||
| @@ -29,6 +31,13 @@ static const char* soapy_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. | |||
| // 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" | |||
| 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{ | |||
| @@ -164,6 +174,7 @@ func loadSoapyLib() (*soapyLib, error) { | |||
| fnActivateStream: sym("SoapySDRDevice_activateStream"), | |||
| fnDeactivateStream: sym("SoapySDRDevice_deactivateStream"), | |||
| fnWriteStream: sym("SoapySDRDevice_writeStream"), | |||
| fnGetLastError: sym("SoapySDR_getLastError"), | |||
| }, 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) | |||
| 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) | |||
| 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 | |||
| } | |||
| @@ -217,9 +228,30 @@ func (lib *soapyLib) makeDevice(driver, device string, args map[string]string) ( | |||
| var kw kwargs | |||
| if driver != "" { lib.kwargsSet(&kw, "driver", driver) } | |||
| 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)) | |||
| 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 | |||
| } | |||
| @@ -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 "$@" | |||