diff --git a/README.md b/README.md index 093c2a8..cae553d 100644 --- a/README.md +++ b/README.md @@ -793,12 +793,27 @@ BenchPod at 192.168.1.220:8080 Start DAC waveform output (network or embeddedci.com). +Give the waveform in volts with `--path`, `--amplitude-v` and `--offset-v` (or in mA on the 4-20 mA +output with `--amplitude-ma` and `--offset-ma`): firmware 3.9.1 and later calibrates it and plays it +at the DAC's full 16 bits, about 50 µV a step on the 3.3 V output. The CLI checks the pod has it +first and refuses on older firmware. `--amplitude` and `--offset` are the older 8-bit levels: 256 +steps over the output, no calibration. + +```bash +benchpod generate --waveform sine --freq 1kHz --path 3v3 --amplitude-v 1.48 --offset-v 1.65 +``` + | Flag | Default | Purpose | |---------------------|---------|-----------------------------------------------------------| | `--waveform` | (required) | `sine`, `square` or `sawtooth`. | | `--freq` | `1kHz` | Output frequency with its unit, e.g. `2kHz`; a bare number is Hz. | -| `--amplitude` | `127` | Half-scale amplitude code, 0-127. | -| `--offset` | `128` | DC offset code, 0-255. | +| `--path` | | DAC output for the volts: `3v3`, `5v` or `12v` (firmware 3.9.1+). | +| `--amplitude-v` | | Peak in volts on `--path`, 16-bit and calibrated (firmware 3.9.1+). | +| `--offset-v` | | Centre in volts on `--path`, with `--amplitude-v`. | +| `--amplitude-ma` | | Peak in mA on the 4-20 mA output (J9), 16-bit (firmware 3.9.1+). | +| `--offset-ma` | | Centre in mA on the 4-20 mA output, with `--amplitude-ma`. | +| `--amplitude` | `127` | Half-scale amplitude in 8-bit levels, 0-127. | +| `--offset` | `128` | DC offset in 8-bit levels, 0-255. | | `--duration` | `0` | How long to play, e.g. `2s` or `500ms`; `0` runs until the next command. | | `--duration-ms` | `0` | The same in ms (older form of `--duration`). | | `--sample-rate` | auto | FPGA sample-clock rate, e.g. `10MHz`; leave it out to auto-pick. | diff --git a/cmd/benchpod-cli/commands_tcp.go b/cmd/benchpod-cli/commands_tcp.go index f1faff6..48a074c 100644 --- a/cmd/benchpod-cli/commands_tcp.go +++ b/cmd/benchpod-cli/commands_tcp.go @@ -3,10 +3,12 @@ package main import ( "encoding/json" "fmt" + "math" "os" "strings" "time" + "github.com/embeddedci-com/benchpod-cli/internal/spiflash" "github.com/spf13/cobra" ) @@ -53,9 +55,10 @@ func newPingCmd(g *globalFlags) *cobra.Command { // ── generate ───────────────────────────────────────────────────────────────── func newGenerateCmd(g *globalFlags) *cobra.Command { - var waveform string + var waveform, path string var freq, sampleRate, sampleRateHz float64 var amplitude, offset, durationMS int + var amplitudeV, offsetV, amplitudeMA, offsetMA float64 var duration time.Duration cmd := &cobra.Command{ Use: "generate", @@ -73,6 +76,10 @@ func newGenerateCmd(g *globalFlags) *cobra.Command { if err != nil { return err } + calibrated, err := generateCalibrated(cmd, path, amplitudeV, offsetV, amplitudeMA, offsetMA) + if err != nil { + return err + } ctx, cancel, pod, err := g.podClient("generate", 30*time.Second) if err != nil { return err @@ -87,6 +94,27 @@ func newGenerateCmd(g *globalFlags) *cobra.Command { "offset": offset, "duration_ms": durationMS, } + if calibrated != nil { + // Firmware before 3.9.1 ignores the volts and would play its default + // full-scale 8-bit waveform instead: check before sending. + status, err := pod.client.Command(ctx, map[string]any{"cmd": "status"}) + if err != nil { + return fmt.Errorf("generate: status: %w", err) + } + ok, err := spiflash.HasCap(status, "generate16") + if err != nil { + return fmt.Errorf("generate: %w", err) + } + if !ok { + return fmt.Errorf("generate: --amplitude-v/--amplitude-ma need firmware 3.9.1 or newer " + + "(the pod does not list generate16); update it with `benchpod flash-self`, or use --amplitude/--offset levels") + } + delete(req, "amplitude") + delete(req, "offset") + for k, v := range calibrated { + req[k] = v + } + } if sampleRate > 0 { req["sample_rate_mhz"] = sampleRate } @@ -109,8 +137,13 @@ func newGenerateCmd(g *globalFlags) *cobra.Command { } cmd.Flags().StringVar(&waveform, "waveform", "", "waveform: sine|square|sawtooth (required)") cmd.Flags().Var(newFrequencyValue(1000, &freq), "freq", "output frequency, e.g. 1kHz (a bare number is Hz)") - cmd.Flags().IntVar(&litude, "amplitude", 127, "half-scale amplitude 0-127") - cmd.Flags().IntVar(&offset, "offset", 128, "DC offset 0-255") + cmd.Flags().IntVar(&litude, "amplitude", 127, "half-scale amplitude in 8-bit levels 0-127") + cmd.Flags().IntVar(&offset, "offset", 128, "DC offset in 8-bit levels 0-255") + cmd.Flags().StringVar(&path, "path", "", "DAC output for --amplitude-v/--offset-v: 3v3|5v|12v (firmware 3.9.1+)") + cmd.Flags().Float64Var(&litudeV, "amplitude-v", 0, "peak in volts on --path, played calibrated at 16 bits (firmware 3.9.1+)") + cmd.Flags().Float64Var(&offsetV, "offset-v", 0, "centre in volts on --path (with --amplitude-v)") + cmd.Flags().Float64Var(&litudeMA, "amplitude-ma", 0, "peak in mA on the 4-20 mA output, 16-bit (firmware 3.9.1+)") + cmd.Flags().Float64Var(&offsetMA, "offset-ma", 0, "centre in mA on the 4-20 mA output (with --amplitude-ma)") cmd.Flags().DurationVar(&duration, "duration", 0, "how long to output, e.g. 2s or 500ms (0 = run until next command)") cmd.Flags().IntVar(&durationMS, "duration-ms", 0, "duration in ms (0 = run until next command); same as --duration") cmd.Flags().Var(newFrequencyValue(0, &sampleRateHz), "sample-rate", "FPGA sample-clock rate, e.g. 10MHz (omit to auto-pick)") @@ -118,6 +151,43 @@ func newGenerateCmd(g *globalFlags) *cobra.Command { return cmd } +// generateCalibrated turns the volts / milliamp flags into the calibrated generate fields +// (firmware 3.9.1+), or nil when none of them is set. The 8-bit --amplitude/--offset cannot be +// mixed in. +func generateCalibrated(cmd *cobra.Command, path string, ampV, offV, ampMA, offMA float64) (map[string]any, error) { + f := cmd.Flags() + volts := f.Changed("amplitude-v") || f.Changed("offset-v") || f.Changed("path") + amps := f.Changed("amplitude-ma") || f.Changed("offset-ma") + if !volts && !amps { + return nil, nil + } + if f.Changed("amplitude") || f.Changed("offset") { + return nil, fmt.Errorf("--amplitude/--offset are 8-bit levels: use them or the volts/mA flags, not both") + } + if volts && amps { + return nil, fmt.Errorf("give volts (--path, --amplitude-v, --offset-v) or mA (--amplitude-ma, --offset-ma), not both") + } + if amps { + if !f.Changed("amplitude-ma") || !f.Changed("offset-ma") { + return nil, fmt.Errorf("--amplitude-ma and --offset-ma go together") + } + if !(ampMA > 0) { + return nil, fmt.Errorf("--amplitude-ma must be above 0") + } + return map[string]any{"amplitude_ua": math.Round(ampMA * 1000), "offset_ua": math.Round(offMA * 1000)}, nil + } + if path != "3v3" && path != "5v" && path != "12v" { + return nil, fmt.Errorf("--path must be 3v3, 5v or 12v with --amplitude-v") + } + if !f.Changed("amplitude-v") || !f.Changed("offset-v") { + return nil, fmt.Errorf("--amplitude-v and --offset-v go together with --path") + } + if !(ampV > 0) { + return nil, fmt.Errorf("--amplitude-v must be above 0") + } + return map[string]any{"path": path, "amplitude_v": ampV, "offset_v": offV}, nil +} + // ── capture / stream ───────────────────────────────────────────────────────── func newCaptureCmd(g *globalFlags) *cobra.Command { diff --git a/cmd/benchpod-cli/generate16_test.go b/cmd/benchpod-cli/generate16_test.go new file mode 100644 index 0000000..3acfebd --- /dev/null +++ b/cmd/benchpod-cli/generate16_test.go @@ -0,0 +1,81 @@ +package main + +import ( + "encoding/json" + "testing" +) + +// generate --amplitude-v/--amplitude-ma: the calibrated 16-bit form (firmware 3.9.1+). The CLI +// asks the pod for its caps first, because older firmware would ignore the volts and play its +// default full-scale 8-bit waveform. +func TestGenerateCalibrated(t *testing.T) { + t.Setenv("BENCHPOD_CONNECTION", "") + t.Setenv("BENCHPOD_TIMEOUT", "") + addr, got := fakeLANPod(t, `{"status":"ok","data":{"caps":["dac","generate16"],"bits":16}}`) + sent := func() map[string]any { + t.Helper() + var req map[string]any + if err := json.Unmarshal([]byte(<-got), &req); err != nil { + t.Fatal(err) + } + return req + } + + if code := run([]string{"generate", "--waveform", "sine", "--freq", "1kHz", "--path", "3v3", + "--amplitude-v", "1.48", "--offset-v", "1.65", "--connection", addr}); code != exitOK { + t.Fatalf("volts: exit %d", code) + } + if st := sent(); st["cmd"] != "status" { + t.Fatalf("first request = %v, want status", st) + } + req := sent() + if req["path"] != "3v3" || req["amplitude_v"] != 1.48 || req["offset_v"] != 1.65 { + t.Fatalf("volts request = %v", req) + } + if _, ok := req["amplitude"]; ok { + t.Fatalf("8-bit amplitude sent with volts: %v", req) + } + + if code := run([]string{"generate", "--waveform", "square", "--amplitude-ma", "4", "--offset-ma", "12", + "--connection", addr}); code != exitOK { + t.Fatalf("mA: exit %d", code) + } + sent() + if req := sent(); req["amplitude_ua"] != 4000.0 || req["offset_ua"] != 12000.0 || req["path"] != nil { + t.Fatalf("mA request = %v", req) + } +} + +func TestGenerateCalibratedNeedsFirmware391(t *testing.T) { + t.Setenv("BENCHPOD_CONNECTION", "") + t.Setenv("BENCHPOD_TIMEOUT", "") + addr, got := fakeLANPod(t, `{"status":"ok","data":{"caps":["dac"]}}`) + if code := run([]string{"generate", "--waveform", "sine", "--path", "5v", "--amplitude-v", "0.1", + "--offset-v", "2.5", "--connection", addr}); code == exitOK { + t.Fatal("generate with volts accepted on a pod without generate16") + } + if st := <-got; st != `{"cmd":"status"}` { + t.Fatalf("request = %s", st) + } + select { + case req := <-got: + t.Fatalf("sent %s to a pod without generate16", req) + default: + } +} + +func TestGenerateCalibratedFlagErrors(t *testing.T) { + t.Setenv("BENCHPOD_CONNECTION", "") + for _, args := range [][]string{ + {"--path", "3v3", "--amplitude-v", "1"}, // no offset + {"--path", "ext", "--amplitude-v", "1", "--offset-v", "1"}, // bad path + {"--amplitude-v", "1", "--offset-v", "1"}, // no path + {"--path", "3v3", "--amplitude-v", "1", "--offset-v", "1", "--amplitude", "5"}, // mixed with levels + {"--path", "3v3", "--amplitude-v", "1", "--offset-v", "1", "--amplitude-ma", "4", "--offset-ma", "12"}, + {"--amplitude-ma", "0", "--offset-ma", "12"}, + } { + if code := run(append([]string{"generate", "--waveform", "sine", "--connection", "127.0.0.1:1"}, args...)); code == exitOK { + t.Errorf("%v accepted", args) + } + } +}