diff --git a/docs/config.orangepi-pluto-soapy.json b/docs/config.orangepi-pluto-soapy.json new file mode 100644 index 0000000..6a5e9b2 --- /dev/null +++ b/docs/config.orangepi-pluto-soapy.json @@ -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" + } +} diff --git a/internal/platform/plutosdr/available_pluto.go b/internal/platform/plutosdr/available_pluto.go index 6ca6e11..06955c6 100644 --- a/internal/platform/plutosdr/available_pluto.go +++ b/internal/platform/plutosdr/available_pluto.go @@ -1,4 +1,4 @@ -//go:build pluto && windows +//go:build pluto && (windows || linux) package plutosdr diff --git a/internal/platform/plutosdr/pluto_linux.go b/internal/platform/plutosdr/pluto_linux.go new file mode 100644 index 0000000..206c0a1 --- /dev/null +++ b/internal/platform/plutosdr/pluto_linux.go @@ -0,0 +1,364 @@ +//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" +) + +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 +} diff --git a/internal/platform/plutosdr/stub.go b/internal/platform/plutosdr/stub.go index 96c5db4..13f3144 100644 --- a/internal/platform/plutosdr/stub.go +++ b/internal/platform/plutosdr/stub.go @@ -1,4 +1,4 @@ -//go:build !pluto || !windows +//go:build !pluto || (!windows && !linux) package plutosdr diff --git a/internal/platform/soapysdr/lib_unix.go b/internal/platform/soapysdr/lib_unix.go index 3afe80b..659533d 100644 --- a/internal/platform/soapysdr/lib_unix.go +++ b/internal/platform/soapysdr/lib_unix.go @@ -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 } diff --git a/scripts/orangepi-build-libiio.sh b/scripts/orangepi-build-libiio.sh new file mode 100644 index 0000000..42c2bad --- /dev/null +++ b/scripts/orangepi-build-libiio.sh @@ -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" </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" <