Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ APP_SRC = .
ARCHS = linux/amd64 linux/arm64 linux/arm/v7 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64
RELEASE_DIR = release
# Override on the command line or in CI (matches the ldflags of the release workflow).
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
TAG ?= $(shell git describe --tags --abbrev=0 2>/dev/null || echo "")
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "")
VERSION ?= $(shell git describe --tags --exact-match 2>/dev/null)
TAG ?= $(shell git describe --tags --exact-match 2>/dev/null)
COMMIT ?= $(shell git rev-parse HEAD 2>/dev/null || echo "")
BUILD_DATE ?= $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
LDFLAGS = -ldflags "-s -w -X main.version=$(VERSION) -X main.tag=$(TAG) -X main.commit=$(COMMIT) -X main.buildDate=$(BUILD_DATE)"

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ The existing commands, their flags and their output are unchanged.

### Shell completion

Completion knows the commands and flags, the values of `--parity`, `--data-bits`, `--stop-bits` and `--baud`, the URL schemes of `--url`, the model IDs with a known schema for `--id` and `--model`, the point names of that model for `--point`, and JSON or PEM files for `--file` and `--tls-*`.

```bash
# bash (needs the bash-completion package)
mkdir -p ~/.local/share/bash-completion/completions
Expand Down Expand Up @@ -95,7 +97,7 @@ sunspecctl
├── capture Read a device and write it as a device file for serve
├── template Write a device file skeleton for the given models
├── completion Generate shell completion script (bash/zsh/fish/powershell)
└── version Print the sunspecctl version
└── version Print the version, tag, commit and build date on one line (--json: one object)
```

## Global flags
Expand Down
16 changes: 16 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# sunspecctl Releases

## v0.6.0

**Date:** 2026-11-07
**Previous release:** v0.5.1

### Added

- Shell completion completes flag values: `--parity`, `--data-bits`, `--stop-bits`, `--baud`, the URL schemes of `--url`, the model IDs with a known schema for `--id` and `--model` (with their names), the point names of the chosen model for `--point`, JSON files for `serve --file`, PEM files for `--tls-ca`, `--tls-cert` and `--tls-key`; numbers and durations no longer complete file names. The bash script is cobra's V2 script, with descriptions.

### Changed

- **Breaking for scripts that read `version`:** `version` prints one line, `sunspecctl 0.6.0 (tag v0.6.0, commit <sha>, built <date>, go-modbus 1.11.1, go-serial 0.2.1, go-sunspec 0.10.0, go1.26.10, linux/amd64)`, with the OT Fabric libraries it is built with,, instead of a `tag:`, `commit:` and `built:` block; the version has no leading `v`. `version --json` (and `--json version`, `SUNSPECCTL_FORMAT=json`) prints one line with the keys `name`, `version`, `tag`, `commit`, `built`, `libraries`, `go`, `platform`, always present and in this order; `buildDate` is now `built`. A development build reports `dev` and the commit from the Go VCS stamp (`-dirty` for a modified tree); `go install …@vX.Y.Z` reports that version.
- `make build` stamps a version and tag only when HEAD is a tag (a build off a tag reports `dev`), and the full commit SHA.

---

## v0.5.1

**Date:** 2026-11-06
Expand Down
118 changes: 118 additions & 0 deletions completion.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// SPDX-License-Identifier: MIT

package main

import (
"fmt"
"slices"
"strconv"

"github.com/otfabric/go-sunspec/registry"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)

// urlSchemes are the Modbus URL schemes --url completes.
var urlSchemes = []string{"tcp://", "tcp+tls://", "udp://", "rtu://", "ascii://", "rtuovertcp://", "rtuoverudp://", "asciiovertcp://"}

// fixedFlagValues are the value sets of the enum flags.
var fixedFlagValues = map[string][]string{
"parity": {"none", "even", "odd"},
"data-bits": {"0", "5", "6", "7", "8"},
"stop-bits": {"0", "1", "2"},
"baud": {"0", "1200", "2400", "4800", "9600", "19200", "38400", "57600", "115200"},
}

// fileFlagExtensions are the flags that name an input file of a known type.
var fileFlagExtensions = map[string][]string{
"file": {"json"},
"tls-ca": {"pem", "crt", "cer"},
"tls-cert": {"pem", "crt", "cer"},
"tls-key": {"pem", "key"},
}

// registerCompletions gives every value flag of c and its subcommands a
// completion function: the values of an enum flag, the model IDs with a known
// schema for --id and --model, the point names of that model for --point,
// files of the right type for a file flag, the URL schemes for --url, and no
// file names for a free value (a number, a duration). A command without
// positional arguments completes no file names either.
func registerCompletions(c *cobra.Command) {
if c.Runnable() && !c.HasSubCommands() && c.ValidArgs == nil && c.ValidArgsFunction == nil {
c.ValidArgsFunction = cobra.NoFileCompletions
}
c.LocalNonPersistentFlags().VisitAll(func(f *pflag.Flag) { registerFlagCompletion(c, f) })
c.PersistentFlags().VisitAll(func(f *pflag.Flag) { registerFlagCompletion(c, f) })
for _, sub := range c.Commands() {
registerCompletions(sub)
}
}

func registerFlagCompletion(c *cobra.Command, f *pflag.Flag) {
if f.Value.Type() == "bool" {
return
}
if _, ok := c.GetFlagCompletionFunc(f.Name); ok {
return
}
var fn cobra.CompletionFunc
switch {
case fixedFlagValues[f.Name] != nil:
fn = cobra.FixedCompletions(fixedFlagValues[f.Name], cobra.ShellCompDirectiveNoFileComp)
case fileFlagExtensions[f.Name] != nil:
fn = cobra.FixedCompletions(fileFlagExtensions[f.Name], cobra.ShellCompDirectiveFilterFileExt)
case f.Name == "output":
fn = cobra.FixedCompletions(nil, cobra.ShellCompDirectiveDefault)
case f.Name == "url":
fn = cobra.FixedCompletions(urlSchemes, cobra.ShellCompDirectiveNoFileComp|cobra.ShellCompDirectiveNoSpace)
case f.Name == "id" || f.Name == "model":
fn = completeModelIDs
case f.Name == "point":
fn = completePointNames
default:
fn = cobra.NoFileCompletions
}
_ = c.RegisterFlagCompletionFunc(f.Name, fn)
}

// completeModelIDs offers the model IDs go-sunspec has a schema for, with the
// model label as the description.
func completeModelIDs(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
all := registry.All()
ids := make([]int, 0, len(all))
for id := range all {
ids = append(ids, int(id))
}
slices.Sort(ids)
out := make([]string, 0, len(ids))
for _, id := range ids {
m := all[uint16(id)]
out = append(out, fmt.Sprintf("%d\t%s", id, m.Label))
}
return out, cobra.ShellCompDirectiveNoFileComp
}

// completePointNames offers the point names of the model given with --model.
func completePointNames(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
f := cmd.Flags().Lookup("model")
if f == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
id, err := strconv.ParseUint(f.Value.String(), 10, 16)
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
m := registry.ByID(uint16(id))
if m == nil || m.Group == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
out := make([]string, 0, len(m.Group.Points))
for _, p := range m.Group.Points {
if p.Label != "" {
out = append(out, p.Name+"\t"+p.Label)
} else {
out = append(out, p.Name)
}
}
return out, cobra.ShellCompDirectiveNoFileComp
}
103 changes: 103 additions & 0 deletions completion_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// SPDX-License-Identifier: MIT

package main

import (
"fmt"
"regexp"
"slices"
"strings"
"testing"

"github.com/spf13/cobra"
"github.com/spf13/pflag"
)

// complete runs the hidden __complete command and returns the candidates
// (without descriptions) and the directive line.
func complete(t *testing.T, args ...string) ([]string, string) {
t.Helper()
r := runCLI(t, append([]string{"__complete"}, args...)...)
if r.err != nil {
t.Fatalf("__complete %v: %v", args, r.err)
}
var values []string
var directive string
for _, line := range strings.Split(strings.TrimSpace(r.stdout), "\n") {
if strings.HasPrefix(line, ":") {
directive = line
continue
}
v, _, _ := strings.Cut(line, "\t")
values = append(values, v)
}
return values, directive
}

func wantCandidates(t *testing.T, args []string, want ...string) {
t.Helper()
got, _ := complete(t, args...)
for _, w := range want {
if !slices.Contains(got, w) {
t.Errorf("__complete %v = %v, missing %q", args, got, w)
}
}
}

func TestCompleteShells(t *testing.T) {
got, _ := complete(t, "completion", "")
if want := []string{"bash", "zsh", "fish", "powershell"}; !slices.Equal(got, want) {
t.Fatalf("completion shells %v, want %v", got, want)
}
}

func TestCompleteFlagValues(t *testing.T) {
wantCandidates(t, []string{"detect", "--parity", ""}, "none", "even", "odd")
wantCandidates(t, []string{"read", "--stop-bits", ""}, "1", "2")
wantCandidates(t, []string{"read", "--url", ""}, "tcp://", "rtu://")
wantCandidates(t, []string{"read-model", "--id", ""}, "1", "103")
wantCandidates(t, []string{"template", "--model", ""}, "1", "160")
wantCandidates(t, []string{"read-point", "--model", "1", "--point", ""}, "Mn", "Md", "SN")
wantCandidates(t, []string{"poll-point", "--model", "1", "--point", ""}, "Mn")
if got, _ := complete(t, "read-point", "--point", ""); len(got) != 0 {
t.Errorf("--point without --model offers %v", got)
}

got, directive := complete(t, "serve", "--file", "")
if !slices.Equal(got, []string{"json"}) || directive != fmt.Sprintf(":%d", cobra.ShellCompDirectiveFilterFileExt) {
t.Errorf("serve --file: %v %s", got, directive)
}
if _, directive := complete(t, "poll", "--interval", ""); directive != fmt.Sprintf(":%d", cobra.ShellCompDirectiveNoFileComp) {
t.Errorf("poll --interval directive %s, want no file completion", directive)
}
}

// valueSetUsage matches a usage string that lists the values of a flag.
var valueSetUsage = regexp.MustCompile(`\w+\|\w+|one of|: \w+, \w+,? (or|and) \w+|\w+ or \w+ \(`)

// TestEveryValueFlagCompletes walks every command: a flag that takes a value
// has a completion function, so a flag whose usage lists a value set cannot
// lack one.
func TestEveryValueFlagCompletes(t *testing.T) {
var walk func(c *cobra.Command)
walk = func(c *cobra.Command) {
check := func(f *pflag.Flag) {
if f.Value.Type() == "bool" || f.Name == "help" {
return
}
if _, ok := c.GetFlagCompletionFunc(f.Name); !ok {
kind := "value"
if valueSetUsage.MatchString(f.Usage) {
kind = "value set"
}
t.Errorf("%s --%s (%s flag) has no completion: %s", c.CommandPath(), f.Name, kind, f.Usage)
}
}
c.LocalFlags().VisitAll(check)
c.InheritedFlags().VisitAll(check)
for _, sub := range c.Commands() {
walk(sub)
}
}
walk(newRootCmd())
}
6 changes: 4 additions & 2 deletions docs/json-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@ With `read` and `poll`, a model that cannot be read gives `warning: partial read

## Version

One line, not indented, the keys always present and in this order:

```json
{ "version": "v0.8.0", "tag": "v0.8.0", "commit": "abc1234", "buildDate": "2026-10-31T10:00:00Z" }
{"name":"sunspecctl","version":"0.6.0","tag":"v0.6.0","commit":"1802d0f9afe8642d1e278120b76fcdfac967cb9e","built":"2026-11-07T10:00:00Z","libraries":[{"name":"go-modbus","version":"1.11.1"},{"name":"go-serial","version":"0.2.1"},{"name":"go-sunspec","version":"0.10.0"}],"go":"go1.26.10","platform":"linux/amd64"}
```

`tag`, `commit` and `buildDate` are left out when the build did not set them.
`version` has no leading `v` and is `dev` for a development build; `tag` is `""` when the build has no tag; `commit` is the full SHA (with `-dirty` for a modified tree) or `unknown`; `built` is the RFC 3339 UTC build date or `unknown`; `libraries` are the OT Fabric libraries the binary is built with (`[]` when none); `platform` is `goos/goarch`.

## Detection

Expand Down
23 changes: 19 additions & 4 deletions e2e/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package e2e

import (
"bytes"
"encoding/json"
"errors"
"os"
"os/exec"
Expand Down Expand Up @@ -42,12 +43,26 @@ func TestVersionPrintsTheBuiltVersion(t *testing.T) {
if code != 0 {
t.Fatalf("exit code %d, want 0\n%s", code, output)
}
want := os.Getenv("SUNSPECCTL_E2E_VERSION")
if want == "" {
// make e2e sets it to the tag of HEAD, empty off a tag: then the binary is a dev build.
want, ok := os.LookupEnv("SUNSPECCTL_E2E_VERSION")
if !ok {
t.Fatal("SUNSPECCTL_E2E_VERSION is not set; run make e2e")
}
if !strings.Contains(output, "sunspecctl") || !strings.Contains(output, want) {
t.Fatalf("output does not name sunspecctl %s:\n%s", want, output)
if want == "" {
want = "dev"
}
want = strings.TrimPrefix(want, "v")
if !strings.HasPrefix(output, "sunspecctl "+want+" (") || strings.Count(output, "\n") != 1 {
t.Fatalf("output is not one line naming sunspecctl %s:\n%s", want, output)
}

output, code = run(t, "version", "--json")
if code != 0 {
t.Fatalf("version --json: exit code %d, want 0\n%s", code, output)
}
var info map[string]any
if err := json.Unmarshal([]byte(output), &info); err != nil || info["name"] != "sunspecctl" || info["version"] != want {
t.Fatalf("version --json is not the build information of sunspecctl %s (%v):\n%s", want, err, output)
}
}

Expand Down
Loading
Loading