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
28 changes: 14 additions & 14 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ BINARY = $(BIN_DIR)/$(APP_NAME)
ARCHS = linux/amd64 linux/arm64 linux/arm/v7 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64

# Override on the command line or in CI (matches reusable release workflow ldflags).
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.0.0-dev")
TAG ?= $(shell git describe --tags --exact-match 2>/dev/null || echo "")
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
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 "unknown")
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 Expand Up @@ -117,14 +117,14 @@ build-all: ## Build the application for all architectures
cpu=$${rest%%/*}; \
variant=$${rest#*/}; \
if [ "$$os" = "windows" ]; then \
echo "Building $(APP_NAME)-$(VERSION)-$$os-$$cpu.exe..."; \
GOOS=$$os GOARCH=$$cpu go build $(LDFLAGS) -o $(RELEASE_DIR)/$(APP_NAME)-$(VERSION)-$$os-$$cpu.exe $(APP_SRC); \
echo "Building $(APP_NAME)-$(or $(VERSION),dev)-$$os-$$cpu.exe..."; \
GOOS=$$os GOARCH=$$cpu go build $(LDFLAGS) -o $(RELEASE_DIR)/$(APP_NAME)-$(or $(VERSION),dev)-$$os-$$cpu.exe $(APP_SRC); \
elif [ "$$cpu" = "arm" ] && [ "$$variant" = "v7" ]; then \
echo "Building $(APP_NAME)-$(VERSION)-$$os-armv7..."; \
GOOS=$$os GOARCH=$$cpu GOARM=7 go build $(LDFLAGS) -o $(RELEASE_DIR)/$(APP_NAME)-$(VERSION)-$$os-armv7 $(APP_SRC); \
echo "Building $(APP_NAME)-$(or $(VERSION),dev)-$$os-armv7..."; \
GOOS=$$os GOARCH=$$cpu GOARM=7 go build $(LDFLAGS) -o $(RELEASE_DIR)/$(APP_NAME)-$(or $(VERSION),dev)-$$os-armv7 $(APP_SRC); \
else \
echo "Building $(APP_NAME)-$(VERSION)-$$os-$$cpu..."; \
GOOS=$$os GOARCH=$$cpu go build $(LDFLAGS) -o $(RELEASE_DIR)/$(APP_NAME)-$(VERSION)-$$os-$$cpu $(APP_SRC); \
echo "Building $(APP_NAME)-$(or $(VERSION),dev)-$$os-$$cpu..."; \
GOOS=$$os GOARCH=$$cpu go build $(LDFLAGS) -o $(RELEASE_DIR)/$(APP_NAME)-$(or $(VERSION),dev)-$$os-$$cpu $(APP_SRC); \
fi \
done

Expand All @@ -136,15 +136,15 @@ release-all: build-all ## Package the build binaries into tar.gz (Unix) or zip (
cpu=$${rest%%/*}; \
variant=$${rest#*/}; \
if [ "$$os" = "windows" ]; then \
bin=$(APP_NAME)-$(VERSION)-$$os-$$cpu.exe; \
echo "Packaging $$bin into $(APP_NAME)-$(VERSION)-$$os-$$cpu.zip..."; \
cd $(RELEASE_DIR) && zip -q $(APP_NAME)-$(VERSION)-$$os-$$cpu.zip $$bin && cd - > /dev/null; \
bin=$(APP_NAME)-$(or $(VERSION),dev)-$$os-$$cpu.exe; \
echo "Packaging $$bin into $(APP_NAME)-$(or $(VERSION),dev)-$$os-$$cpu.zip..."; \
cd $(RELEASE_DIR) && zip -q $(APP_NAME)-$(or $(VERSION),dev)-$$os-$$cpu.zip $$bin && cd - > /dev/null; \
elif [ "$$cpu" = "arm" ] && [ "$$variant" = "v7" ]; then \
bin=$(APP_NAME)-$(VERSION)-$$os-armv7; \
bin=$(APP_NAME)-$(or $(VERSION),dev)-$$os-armv7; \
echo "Packaging $$bin.tar.gz..."; \
tar czf $(RELEASE_DIR)/$$bin.tar.gz -C $(RELEASE_DIR) $$bin; \
else \
bin=$(APP_NAME)-$(VERSION)-$$os-$$cpu; \
bin=$(APP_NAME)-$(or $(VERSION),dev)-$$os-$$cpu; \
echo "Packaging $$bin.tar.gz..."; \
tar czf $(RELEASE_DIR)/$$bin.tar.gz -C $(RELEASE_DIR) $$bin; \
fi; \
Expand Down
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Linux (amd64, arm64, armv7), macOS (Intel, Apple silicon) and Windows (amd64, ar

### Shell completion

Completion knows the subcommands and flags, and the values of enum flags such as **`--format`** (text/json/table), **`--algo`**, **`--function`**, **`--codec`**, **`--parity`**, **`--regtype`** and **`--sub-function`**.
Completion knows the subcommands and flags, the values of enum flags such as **`--format`** (text/json/table), **`--algo`**, **`--function`**, **`--codec`**, **`--parity`**, **`--regtype`**, **`--sub-function`**, **`--missing`** and **`--family`**, the URL schemes of **`--url`**, and capture, profile and certificate files for **`--input`**, **`--profile`** and **`--tls-*`**.

```bash
# bash (needs the bash-completion package)
Expand Down Expand Up @@ -237,7 +237,7 @@ modbusctl
│ └── frequencies Detect ~50 Hz frequency data in captured registers
├── discover Discover Modbus devices on subnets
├── completion Generate shell completion script (bash|zsh|fish|powershell)
└── version Print version
└── version Print version, tag, commit and build date (--json)
```

---
Expand Down Expand Up @@ -303,6 +303,14 @@ modbusctl discover
modbusctl version
```

`modbusctl version` prints one line; `--json` (or `MODBUSCTL_FORMAT=json`) prints the same as one JSON object ([json-output.md](docs/json-output.md)):

```text
modbusctl 3.6.0 (tag v3.6.0, commit 1802d0f9afe8642d1e278120b76fcdfac967cb9e, built 2026-11-07T10:00:00Z, go-modbus 1.11.1, go-modbus-capture 0.1.0, go-serial 0.2.1, go1.26.10, linux/amd64)
```

After the build date come the OT Fabric libraries the binary is built with. A development build (`go build`, `go run`, `make build` off a tag) says `dev` and takes the commit from the Go VCS stamp, with `-dirty` for a modified tree; `go install …@vX.Y.Z` reports that version.

### Transports and serial settings

Every `modbusctl client` command talks to the device named by `--url`. The scheme selects the transport:
Expand Down
17 changes: 17 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# modbusctl Releases

## v3.6.0

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

### Added

- `version --json` prints the build information as one JSON object (`name`, `version`, `tag`, `commit`, `built`, `libraries`, `go`, `platform`, always in this order); `MODBUSCTL_FORMAT=json` does the same.
- Shell completion completes the value of every flag that takes one: the enum flags that had no completion (`--function` of the `capture` commands, `--missing`, `--before-first`, `--retry-timeout`, `--data-bits`, `--stop-bits`, `--baud`, `--family`, `--value-kind`), the URL schemes of `--url`, network interfaces for `--interface`, `.mbcap`/`.jsonl`/`.csv` files for `--input`, JSON files for `--profile` and `--blocks-file`, PEM files for `--tls-ca`, `--tls-cert` and `--tls-key`; numbers and addresses no longer complete file names.

### Changed

- `version` prints one line, `modbusctl 3.6.0 (tag v3.6.0, commit <sha>, built <date>, go-modbus 1.11.1, go-modbus-capture 0.1.0, go-serial 0.2.1, go1.26.10, linux/amd64)`, with the OT Fabric libraries it is built with,, instead of a block of `version:`, `tag:`, `commit:` and `build date:` lines; the version has no leading `v`. 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. Scripts that parsed the old lines should use `version --json`.
- `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.

---

## v3.5.1

**Date:** 2026-11-05
Expand Down
2 changes: 1 addition & 1 deletion cmd/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ var completionShells = []string{completionBash, completionZsh, completionFish, c
var completionCmd = &cobra.Command{
Use: "completion [bash|zsh|fish|powershell]",
Short: "Generate shell completion script",
Long: "Output a completion script for the given shell. Install with: Bash: source <(modbusctl completion bash); Zsh: source <(modbusctl completion zsh); Fish: modbusctl completion fish | source.",
Long: "Output a completion script for bash, zsh, fish or PowerShell. Installation: see Shell completion in the README.",
ValidArgs: completionShells,
Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
RunE: func(cmd *cobra.Command, args []string) error {
Expand Down
124 changes: 124 additions & 0 deletions cmd/flag_completion.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// SPDX-License-Identifier: MIT

package cmd

import (
"net"
"slices"
"strings"

"github.com/otfabric/go-modbus/codec"
"github.com/otfabric/modbusctl/internal/cli"
"github.com/otfabric/modbusctl/internal/format"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)

// fixedFlagValues are the value sets of flags that the commands do not register a
// completion for themselves. A command that registered its own keeps it.
var fixedFlagValues = map[string][]string{
"format": formatValues(),
"function": {"0", "1", "2", "3", "4"}, // capture commands: 0 = the only one
"before-first": {"hold", "absent"},
"missing": {"exception", "zero", "silence"},
"retry-timeout": {"0", "1"},
"data-bits": {"0", "5", "6", "7", "8"},
"stop-bits": {"0", "1", "2"},
"baud": {"0", "1200", "2400", "4800", "9600", "19200", "38400", "57600", "115200"},
"family": codecFamilies(),
"value-kind": codecValueKinds(),
}

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

// anyFileFlags name a file of any type (an output path).
var anyFileFlags = []string{"output"}

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

// registerFlagCompletions gives every value flag of c and its subcommands a completion
// function: the values of an enum flag, files of the right type for a file flag, the
// network interfaces, the URL schemes, and no file names for a free value (a number,
// an address). A command without positional arguments completes no file names either.
func registerFlagCompletions(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() {
registerFlagCompletions(sub)
}
}

func registerFlagCompletion(c *cobra.Command, f *pflag.Flag) {
if f.Value.Type() == "bool" {
return
}
if _, ok := c.GetFlagCompletionFunc(f.Name); ok {
return
}
switch {
case fixedFlagValues[f.Name] != nil:
_ = cli.RegisterEnumFlagCompletion(c, f.Name, fixedFlagValues[f.Name])
case fileFlagExtensions[f.Name] != nil:
_ = c.RegisterFlagCompletionFunc(f.Name, cobra.FixedCompletions(fileFlagExtensions[f.Name], cobra.ShellCompDirectiveFilterFileExt))
case slices.Contains(anyFileFlags, f.Name):
_ = c.RegisterFlagCompletionFunc(f.Name, cobra.FixedCompletions(nil, cobra.ShellCompDirectiveDefault))
case f.Name == "url":
_ = c.RegisterFlagCompletionFunc(f.Name, cobra.FixedCompletions(urlSchemes, cobra.ShellCompDirectiveNoFileComp|cobra.ShellCompDirectiveNoSpace))
case f.Name == "interface":
_ = c.RegisterFlagCompletionFunc(f.Name, completeInterfaces)
default:
_ = c.RegisterFlagCompletionFunc(f.Name, cobra.NoFileCompletions)
}
}

func completeInterfaces(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
ifaces, err := net.Interfaces()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
names := make([]string, 0, len(ifaces))
for _, i := range ifaces {
names = append(names, i.Name)
}
return names, cobra.ShellCompDirectiveNoFileComp
}

func formatValues() []string {
vals := make([]string, 0, 3)
for v := range format.ValueDescriptions() {
vals = append(vals, v)
}
slices.Sort(vals)
return vals
}

func codecFamilies() []string {
return codecDescriptorValues(func(d codec.CodecDescriptor) string { return d.Family.String() })
}

func codecValueKinds() []string {
return codecDescriptorValues(func(d codec.CodecDescriptor) string { return d.ValueKind.String() })
}

func codecDescriptorValues(field func(codec.CodecDescriptor) string) []string {
var vals []string
for _, d := range codec.AvailableCodecDescriptors() {
if v := strings.TrimSpace(field(d)); v != "" && !slices.Contains(vals, v) {
vals = append(vals, v)
}
}
slices.Sort(vals)
return vals
}
112 changes: 112 additions & 0 deletions cmd/flag_completion_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// SPDX-License-Identifier: MIT

package cmd

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 their
// descriptions) and the directive line.
func complete(t *testing.T, args ...string) ([]string, string) {
t.Helper()
code, stdout, stderr := execute(t, append([]string{"__complete"}, args...)...)
if code != 0 {
t.Fatalf("__complete %v: exit %d, stderr %q", args, code, stderr)
}
var values []string
var directive string
for _, line := range strings.Split(strings.TrimSpace(stdout), "\n") {
if strings.HasPrefix(line, ":") {
directive = line
continue
}
if strings.HasPrefix(line, "Completion ended") {
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", "")
want := []string{"bash", "zsh", "fish", "powershell"}
if !slices.Equal(got, want) {
t.Fatalf("completion shells %v, want %v", got, want)
}
}

func TestCompleteEnumFlagPerGroup(t *testing.T) {
wantCandidates(t, []string{"client", "read", "--format", ""}, "text", "json", "table")
wantCandidates(t, []string{"client", "scan", "--algo", ""}, "safe", "smart", "deep", "sunspec")
wantCandidates(t, []string{"client", "read", "--parity", ""}, "none", "even", "odd")
wantCandidates(t, []string{"client", "sunspec", "map", "--regtype", ""}, "holding", "input")
wantCandidates(t, []string{"client", "read", "--stop-bits", ""}, "1", "2")
wantCandidates(t, []string{"client", "codecs", "--family", ""}, "float", "integer")
wantCandidates(t, []string{"client", "read", "--url", "rt"}, "rtu://", "rtuovertcp://")
wantCandidates(t, []string{"capture", "decode", "--function", ""}, "1", "2", "3", "4")
wantCandidates(t, []string{"server", "replay", "--missing", ""}, "exception", "zero", "silence")
wantCandidates(t, []string{"server", "replay", "--before-first", ""}, "hold", "absent")
wantCandidates(t, []string{"discover", "--format", ""}, "text", "json", "table")
}

func TestCompleteFileFlags(t *testing.T) {
got, directive := complete(t, "capture", "decode", "--input", "")
if !slices.Equal(got, []string{"mbcap", "jsonl", "csv"}) {
t.Fatalf("--input extensions %v", got)
}
if want := fmt.Sprintf(":%d", cobra.ShellCompDirectiveFilterFileExt); directive != want {
t.Fatalf("--input directive %q, want %q", directive, want)
}
if _, directive := complete(t, "client", "read", "--timeout", ""); directive != fmt.Sprintf(":%d", cobra.ShellCompDirectiveNoFileComp) {
t.Fatalf("--timeout directive %q, 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(rootCmd)
}
Loading
Loading