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
27 changes: 14 additions & 13 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ 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")
# VERSION and TAG only when HEAD is exactly on a tag; otherwise the binary reports dev.
VERSION ?= $(shell git describe --tags --exact-match 2>/dev/null)
TAG ?= $(shell git describe --tags --exact-match 2>/dev/null || echo "")
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
COMMIT ?= $(shell git rev-parse HEAD 2>/dev/null)
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 @@ -135,14 +136,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 @@ -154,15 +155,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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ From source (Go 1.26+): `go install github.com/otfabric/profinetctl@latest`

### Shell completion

Commands, flags, the values of `--format`, `--mode`, `--mrp-role` and `im read --record`, the Ethernet interfaces of `--interface`, and GSDML, GSDX, model, expected-link and pcap files complete.

```bash
# bash (needs the bash-completion package)
mkdir -p ~/.local/share/bash-completion/completions
Expand Down Expand Up @@ -133,7 +135,7 @@ A missing privilege exits with code 4 and the error code `permission_denied`. `p
| `io status [--settle D]`, `io monitor [--interval D] [--changes]`, `io statistics [--duration D]` | a snapshot of the inputs and output statuses; the inputs at an interval (only changes with `--changes`); the cyclic counters over 5 s |
| `alarms` | establish an AR and stream the device's alarms |
| `disconnect --ar UUID --session-key N` | release an AR another controller left behind |
| `version`, `completion SHELL` | version; shell completion for bash, zsh, fish and PowerShell |
| `version [--json]`, `completion SHELL` | the version on one line (or JSON), with the go-profinet version; shell completion for bash, zsh, fish and PowerShell |

Every command has `--help`. A device is selected by `--station NAME` (resolved with DCP Identify, which waits `--wait`, default 1s; several answers are an error) or `--mac`; `--ip` overrides the RPC address it reports.

Expand Down
38 changes: 38 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,44 @@ ending with a `---` line and no `---` line inside a section

---

## v0.9.0

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

### Summary

`version` prints one line, as every OT Fabric CLI does, or JSON with
`--json` (or `-o json`). Shell completion completes `--mrp-role`, the I&M
records and files by type, and no longer offers file names for addresses,
names and numbers.

### Added

- `version --json`, and `version` with `--format json`/`-o json` or
`PROFINETCTL_FORMAT=json`: `{"name","version","tag","commit","built","libraries","go","platform"}`,
every key present; `libraries` lists go-profinet with its version.
- Completion of `--mrp-role` (client, manager, off) and `im read --record`
(0-4), next to `--format`, `--mode` and `--interface`. GSDML arguments
and `--gsdml` complete `.xml` files, `gsdml gsdx` `.gsdx`, `--model` and
`--expect` `.json`, `--pcap` `.pcap`; `--data-file` and `--output-file`
any file. Every other value flag and argument completes nothing instead
of file names.

### Changed

- `version` prints one line: `profinetctl 0.9.0 (tag v0.9.0, commit <sha>,
built <date>, go-profinet 0.7.0, go1.26.10, linux/amd64)`, instead of one
line per field. No leading `v`; the full commit (from the build, or from
Go's build information with `-dirty` for a modified tree); the tag is left
out when there is none; a development build (a pseudo-version too) is
`dev`, built `unknown`. Scripts that read `profinetctl version: …` should
use `version --json`.
- `make build` injects the full commit SHA, and a version and tag only when
HEAD is exactly on a tag (otherwise the binary reports `dev`).

---

## v0.8.0

**Date:** 2026-11-05
Expand Down
43 changes: 40 additions & 3 deletions cmd/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@

package cmd

import "github.com/spf13/cobra"
import (
"github.com/spf13/cobra"

clihelp "github.com/otfabric/profinetctl/internal/cli"
)

const (
completionBash = "bash"
Expand All @@ -24,8 +28,9 @@ func newCompletionCmd() *cobra.Command {
Fish: profinetctl completion fish | source
PowerShell: profinetctl completion powershell | Out-String | Invoke-Expression

Flags with a fixed set of values (--format, --mode, …) complete to those
values.`,
Flags with a fixed set of values (--format, --mode, --mrp-role, --record) complete
to those values, --interface to the Ethernet interfaces; GSDML, GSDX, model, expected
link and pcap files complete by type.`,
ValidArgs: completionShells,
Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
RunE: func(cmd *cobra.Command, args []string) error {
Expand All @@ -43,3 +48,35 @@ values.`,
},
}
}

// registerCompletions completes what no command completes itself: --mrp-role and the
// I&M records, the flags and arguments that take a file (files of its type), and every
// other flag that takes a value (an address, a name, a number) and every other argument
// nothing.
func registerCompletions(root *cobra.Command) {
mrpRoles, imRecords := []string{"client", "manager", "off"}, []string{"0", "1", "2", "3", "4"}
var walk func(c *cobra.Command)
walk = func(c *cobra.Command) {
if c.Flags().Lookup("mrp-role") != nil {
_ = clihelp.RegisterEnumFlagCompletion(c, "mrp-role", mrpRoles)
}
if c.Name() == "read" && c.Parent() != nil && c.Parent().Name() == "im" {
_ = clihelp.RegisterEnumFlagCompletion(c, "record", imRecords)
}
for _, sub := range c.Commands() {
walk(sub)
}
}
walk(root)
gsdml, model := []string{"xml"}, []string{"json"}
clihelp.CompleteTree(root, clihelp.Completion{
Files: map[string][]string{
"pcap": {"pcap"}, "gsdml": gsdml, "model": model, "expect": model,
"data-file": {}, "output-file": {},
},
Args: map[string][]string{
"gsdml inspect": gsdml, "gsdml validate": gsdml, "gsdml check": gsdml,
"gsdml modules": gsdml, "gsdml submodules": gsdml, "gsdml gsdx": {"gsdx"},
},
})
}
86 changes: 86 additions & 0 deletions cmd/completion_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// SPDX-License-Identifier: MIT

package cmd

import (
"regexp"
"strings"
"testing"

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

// completeLine runs the hidden __complete command and returns the candidates (descriptions
// cut off) followed by the directive line (":4").
func completeLine(n *testNet, args ...string) string {
n.t.Helper()
out := n.ok(append([]string{cobra.ShellCompRequestCmd}, args...)...)
var words []string
for line := range strings.SplitSeq(strings.TrimSpace(out), "\n") {
words = append(words, strings.SplitN(line, "\t", 2)[0])
}
return strings.Join(words, " ")
}

func TestCompletionCandidates(t *testing.T) {
n := newTestNet(t)
for _, c := range []struct {
args []string
want string
}{
{[]string{"completion", ""}, "bash zsh fish powershell :4"},
{[]string{"discover", "--format", ""}, "json table text :4"},
{[]string{"version", "-o", "j"}, "json :4"},
{[]string{"connect", "--mrp-role", ""}, "client manager off :4"},
{[]string{"im", "read", "--record", ""}, "0 1 2 3 4 :4"},
{[]string{"reset", "--mode", "fac"}, "factory :4"},
{[]string{"gsdml", "inspect", ""}, "xml :8"},
{[]string{"gsdml", "gsdx", ""}, "gsdx :8"},
{[]string{"connect", "--gsdml", ""}, "xml :8"},
{[]string{"connect", "--model", ""}, "json :8"},
{[]string{"topology", "check", "--expect", ""}, "json :8"},
{[]string{"discover", "--pcap", ""}, "pcap :8"},
{[]string{"record", "write", "--data-file", ""}, ":0"},
{[]string{"identify", "--station", ""}, ":4"},
{[]string{"ip", "set", ""}, ":4"},
{[]string{"connect", "--send-clock", ""}, ":4"},
} {
if got := completeLine(n, c.args...); got != c.want {
t.Errorf("completion of %v: %q, want %q", c.args, got, c.want)
}
}
}

// valueSet matches a usage that lists the values of a flag: "text, json or table",
// "a role: client, manager or off", "one of …", "a|b".
var valueSet = regexp.MustCompile(`(^|: )[a-z0-9-]+( \([^)]*\))?(, [a-z0-9-]+( \([^)]*\))?)*,? or [a-z0-9-]+|\bone of\b|\b[a-z0-9-]+\|[a-z0-9-]+\b`)

// Every flag that takes a value completes, and one whose usage lists its values
// completes to them, not to file names.
func TestEveryFlagCompletes(t *testing.T) {
n := newTestNet(t)
var walk func(c *cobra.Command)
walk = func(c *cobra.Command) {
c.LocalFlags().VisitAll(func(f *pflag.Flag) {
if f.NoOptDefVal != "" || f.Name == "help" {
return
}
fn, ok := c.GetFlagCompletionFunc(f.Name)
if !ok {
t.Errorf("%s --%s has no completion", c.CommandPath(), f.Name)
return
}
if valueSet.MatchString(f.Usage) || f.Name == "mode" {
values, dir := fn(c, nil, "")
if len(values) < 2 || dir != cobra.ShellCompDirectiveNoFileComp {
t.Errorf("%s --%s (%q) completes %q, directive %d", c.CommandPath(), f.Name, f.Usage, values, dir)
}
}
})
for _, s := range c.Commands() {
walk(s)
}
}
walk(NewRootCmd(n))
}
43 changes: 36 additions & 7 deletions cmd/dcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ import (
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"

"github.com/otfabric/go-profinet/dcp"
"github.com/otfabric/go-profinet/pcap"

"github.com/otfabric/profinetctl/internal/buildinfo"
)

func TestInterfaces(t *testing.T) {
Expand Down Expand Up @@ -318,22 +321,48 @@ func TestPcap(t *testing.T) {

func TestVersionAndCompletion(t *testing.T) {
n := newTestNet(t)
SetBuildMeta("v1.2.3", "v1.2.3", "abc1234", "2026-10-26")
defer SetBuildMeta("dev", "", "", "")
out := n.ok("version")
for _, want := range []string{"profinetctl version: v1.2.3", "tag: v1.2.3", "commit: abc1234", "build date: 2026-10-26", "go version:", "platform:"} {
if !strings.Contains(out, want) {
t.Fatalf("version lacks %q:\n%s", want, out)
const sha = "1802d0f9afe8642d1e278120b76fcdfac967cb9e"
SetBuildMeta("v1.2.3", "v1.2.3", sha, "2026-10-26T12:00:00Z")
defer SetBuildMeta("", "", "", "")
libText, libJSON := "", []string{}
for _, l := range buildinfo.Get("profinetctl").Libraries {
libText += l.Name + " " + l.Version + ", "
libJSON = append(libJSON, `{"name":"`+l.Name+`","version":"`+l.Version+`"}`)
}
// The libraries come from the build info, which test binaries of Go 1.26 lack:
// the e2e suite checks them on the real binary.
platform := runtime.GOOS + "/" + runtime.GOARCH
text := "profinetctl 1.2.3 (tag v1.2.3, commit " + sha + ", built 2026-10-26T12:00:00Z, " + libText + runtime.Version() + ", " + platform + ")\n"
js := `{"name":"profinetctl","version":"1.2.3","tag":"v1.2.3","commit":"` + sha + `","built":"2026-10-26T12:00:00Z","libraries":[` +
strings.Join(libJSON, ",") + `],"go":"` + runtime.Version() + `","platform":"` + platform + "\"}\n"
if out := n.ok("version"); out != text {
t.Fatalf("version:\n got %q\nwant %q", out, text)
}
for _, args := range [][]string{{"version", "--json"}, {"version", "-o", "json"}, {"version", "--format", "table", "--json"}} {
if out := n.ok(args...); out != js {
t.Fatalf("%v:\n got %q\nwant %q", args, out, js)
}
}
if out := n.ok("version", "--format", "table"); out != text {
t.Fatalf("version --format table: %q", out)
}
t.Setenv(EnvFormat, "json")
if out := n.ok("version"); out != js {
t.Fatalf("%s=json version: %q", EnvFormat, out)
}
t.Setenv(EnvFormat, "")
SetBuildMeta("", "", "", "")
if out := n.ok("version"); strings.Contains(out, "tag ") || !strings.Contains(out, "built unknown, ") || strings.Count(out, "\n") != 1 {
t.Fatalf("version of a development build: %q", out)
}
for _, sh := range []string{"bash", "zsh", "fish", "powershell"} {
if out := n.ok("completion", sh); !strings.Contains(out, "profinetctl") {
t.Fatalf("%s completion", sh)
}
}
n.fails(2, "", "completion", "tcsh")
// Completion of flags with fixed values.
out = n.ok("__complete", "reset", "--mode", "")
out := n.ok("__complete", "reset", "--mode", "")
if !strings.Contains(out, "factory") || !strings.Contains(out, "communication") {
t.Fatalf("mode completion %q", out)
}
Expand Down
Loading
Loading