From b368a8ad609ff44494ab243b7546854ac140adb0 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Thu, 15 Oct 2026 14:52:03 -0500 Subject: [PATCH] Reach a pod by name: --connection benchpod-a1b2c3 / a1b2c3 A bare pod name was dialed as a hostname (a1c5c7:8080) and timed out, and the CLI's Go resolver does not resolve .local either. A value shaped like a pod name (^(benchpod-)?[0-9a-f]{6}(.local)?$) is now looked up with the CLI's own mDNS browse (the one discover uses), ending as soon as the pod answers and asking twice within 3 s because the pod's responder skips an answer repeated within a second. A pod not heard on the LAN falls back to usb:. set-connection saves the resolved LAN address (or usb:benchpod-) so later commands do not browse. Other hostnames still go to the system resolver. Co-Authored-By: Claude Opus 5.5 --- README.md | 19 ++- cmd/benchpod-cli/connection.go | 20 ++- cmd/benchpod-cli/connection_name.go | 159 ++++++++++++++++++++++ cmd/benchpod-cli/connection_name_test.go | 165 +++++++++++++++++++++++ cmd/benchpod-cli/identity_test.go | 4 +- cmd/benchpod-cli/podclient_test.go | 3 + cmd/benchpod-cli/root.go | 2 +- 7 files changed, 365 insertions(+), 7 deletions(-) create mode 100644 cmd/benchpod-cli/connection_name.go create mode 100644 cmd/benchpod-cli/connection_name_test.go diff --git a/README.md b/README.md index 3e0711c..d35d200 100644 --- a/README.md +++ b/README.md @@ -123,12 +123,21 @@ reach the pod; the transport is inferred from its value: | `--connection` value | Transport | |----------------------------------|--------------------------------------------------------| | `192.168.1.5[:8080]` | The pod's network TCP/JSON API (Ethernet or Wi-Fi). | +| `benchpod-a1b2c3`, `a1b2c3` | The pod with that name (as on its label and in `discover`): its address found on the LAN over mDNS, else `usb:benchpod-a1b2c3`. See below. | | `/dev/tty...`, `COM3` | USB, explicit console device path. | | `usb` | USB, the console auto-detected by probing the ports. | | `usb:` | USB, the pod with that name (`usb:benchpod-a1b2c3`) when several are plugged in; plain `usb` then lists them (not yet tested with two physical pods). | | `embeddedci:` | Through embeddedci.com to the pod with that name on your account (see below). | | *(omitted)* | the default saved by `benchpod discover --save`, `benchpod setup` or `benchpod set-connection`. | +**Pod names.** A value shaped like a pod's name, `benchpod-a1b2c3` or just +`a1b2c3` (optionally with `.local`), is looked up the way `discover` finds pods: an +mDNS browse of the LAN for up to 3 seconds, which ends as soon as the pod answers. +The CLI does this itself because the system resolver it is built with does not +resolve `.local` names. A pod that does not answer on the LAN is looked for on USB, +as `usb:benchpod-a1b2c3`. Any other hostname (`lab-pod.example.com`) goes to the +system resolver as before. + The global flags are also settable via a `BENCHPOD_*` environment variable (`BENCHPOD_CONNECTION=usb`, `BENCHPOD_TIMEOUT=2m`, `BENCHPOD_CONFIG_FILE`, `BENCHPOD_OUTPUT_FILENAME`), with precedence flag > env > default. Command flags @@ -270,6 +279,7 @@ benchpod setup --la-voltage 3.3V --yes # non-interactive # Save a default connection so later commands can omit --connection: benchpod set-connection 192.168.1.5 +benchpod set-connection benchpod-a1b2c3 # by name: saves its LAN address, or usb:benchpod-a1b2c3 benchpod set-connection usb # Find every pod on this machine and this LAN, and check each one works: @@ -614,7 +624,7 @@ and `token.json` for the embeddedci.com session. | Flag | Default | Purpose | |---------------------|----------------------------------|-------------------------------------------------------------------| -| `--connection` | (saved `set-connection` target) | Network address, device path, `usb`, or `embeddedci:` (through embeddedci.com); see [Connection](#connection). | +| `--connection` | (saved `set-connection` target) | Network address, pod name (`benchpod-a1b2c3`), device path, `usb`, or `embeddedci:` (through embeddedci.com); see [Connection](#connection). | | `--config-file` | `~/.config/benchpod-cli/config.json` | The CLI's config file, where `set-connection` saves the default. | | `--output-filename` | (stdout) | Write command output to this file instead of stdout. | | `--timeout` | `0` (per-command default) | Overall command deadline; `0` uses each command's own default. | @@ -725,11 +735,14 @@ command (`benchpod flash-self --enter-dfu`, then replug USB-C); setup does not f | `--no-register` | off | Do not register the pod with embeddedci.com (it works over the LAN without an account). | | `--yes` | off | Never prompt: register without asking, and fail with a message naming the flag to pass when an answer is needed. | -### `benchpod set-connection ` +### `benchpod set-connection ` Save the default connection (a network address, a device path, `usb` or `embeddedci:`) in the config file, so later commands can leave out -`--connection`. The Python SDK and the MCP server read the same value (see +`--connection`. A pod name (`benchpod-a1b2c3` or `a1b2c3`) is looked up on the LAN +over mDNS and saved as the pod's Ethernet or Wi-Fi address, so later commands do not +look it up again; a pod heard only on USB (or on its USB network link) is saved as +`usb:benchpod-a1b2c3`. The Python SDK and the MCP server read the same value (see [Shared config](#shared-config)). No flags. ### `benchpod discover` diff --git a/cmd/benchpod-cli/connection.go b/cmd/benchpod-cli/connection.go index 25f5e73..fa309e3 100644 --- a/cmd/benchpod-cli/connection.go +++ b/cmd/benchpod-cli/connection.go @@ -26,6 +26,7 @@ const ( // inferred from the value's shape — there is no separate address flag. // // 192.168.1.5[:port], host -> network/TCP (Addr set) +// benchpod-a1b2c3, a1b2c3 -> the pod with that name: its address found over mDNS, else usb: // /dev/tty..., COM3, \\.\COM3 -> USB console (Device set) // usb (legacy: serial) -> USB console, auto-detect (Device == "") // usb: -> USB, the pod with that name when several are plugged in (Pod set) @@ -109,6 +110,11 @@ func parseTarget(raw string) (ConnSpec, error) { if isSerialDevice(raw) { return ConnSpec{Kind: connSerial, Device: raw}, nil } + // A pod's name (benchpod-a1b2c3 or a1b2c3) is not something the OS resolver finds: look it up + // over mDNS, like discover, and fall back to that pod on USB. + if short := podShortName(raw); short != "" { + return resolvePodName(short), nil + } return ConnSpec{Kind: connNetwork, Addr: benchpodconfig.EnsurePort(raw)}, nil } @@ -173,13 +179,16 @@ const cloudCommandList = "status, ping, la voltage, la pullup, la status, la ste func newSetConnectionCmd(g *globalFlags) *cobra.Command { return &cobra.Command{ - Use: "set-connection ", + Use: "set-connection ", Aliases: []string{"set-bench-pod"}, - Short: `Store the default connection: a network address, a device path, "usb", or embeddedci:`, + Short: `Store the default connection: a network address, a pod name, a device path, "usb", or embeddedci:`, Long: "Store the default connection in the config file, so later commands can leave out\n" + "--connection. The value is what --connection takes: the pod's network address\n" + "(192.168.1.5[:8080]), a USB device path, `usb` to auto-detect the pod on USB, or\n" + "embeddedci: to go through embeddedci.com.\n\n" + + "A pod name (benchpod-a1b2c3, or a1b2c3) is looked up on the LAN over mDNS and saved\n" + + "as the pod's Ethernet or Wi-Fi address; a pod that is only on USB is saved as\n" + + "usb:benchpod-a1b2c3.\n\n" + "The Python SDK and the MCP server read the same saved connection (see the README,\n" + "\"Shared config\").", Args: cobra.ExactArgs(1), @@ -210,6 +219,13 @@ func runSetConnection(g *globalFlags, arg string) error { if cfg == nil { cfg = &benchpodconfig.Config{} } + if short := podShortName(target); short != "" { + // Save where the pod is now rather than its name, so later commands do not browse first. + target = savedPodName(short) + if spec, err = parseTarget(target); err != nil { + return err + } + } cfg.Connection = target cfg.BenchPodAddr = "" // Connection is the source of truth; drop the legacy field. if err := benchpodconfig.Save(cfgPath, cfg); err != nil { diff --git a/cmd/benchpod-cli/connection_name.go b/cmd/benchpod-cli/connection_name.go new file mode 100644 index 0000000..0f265ce --- /dev/null +++ b/cmd/benchpod-cli/connection_name.go @@ -0,0 +1,159 @@ +package main + +import ( + "context" + "fmt" + "io" + "net" + "os" + "regexp" + "strings" + "sync" + "time" + + "github.com/embeddedci-com/benchpod-cli/internal/benchpodconfig" + "github.com/embeddedci-com/benchpod-cli/internal/serialconsole" + "github.com/libp2p/zeroconf/v2" +) + +// A pod's name, as printed on its label and by `discover`: benchpod-a1b2c3, or just a1b2c3 (the +// identity short id), with an optional .local. Only this exact shape is taken as a pod name, so +// real hostnames still go to the OS resolver. +var podNamePattern = regexp.MustCompile(`^(?i:benchpod-)?[0-9a-fA-F]{6}(?i:\.local)?$`) + +// podNameQueries are the mDNS browses for a pod by name, one after the other until it answers: +// 3 seconds in all, the default of `discover --wait`. Two, because the pod does not repeat a +// multicast answer within a second of the last one (RFC 6762 rate limit, in lwIP's responder) +// and the browse itself only asks again after 4 seconds: a second command run right after the +// first would otherwise hear nothing. A browse ends as soon as the pod answers. +var podNameQueries = []time.Duration{1200 * time.Millisecond, 1800 * time.Millisecond} + +// namedPod is where a pod given by name was heard on the LAN. +type namedPod struct { + addr string // host:port to reach it from here (its USB link first, like discover) + lanAddr string // its Ethernet or Wi-Fi host:port, "" when only heard on its USB link +} + +// podShortName returns the pod's short id ("a1b2c3") when raw is a pod name, else "". +func podShortName(raw string) string { + if !podNamePattern.MatchString(raw) { + return "" + } + return serialconsole.NormalizePodName(raw) +} + +// lookupPodName browses the LAN over mDNS (the browse `discover` does) for the pod with the short +// id short. nil, nil when no such pod answered. A variable so tests can replace it. +var lookupPodName = browsePodName + +// podNameNote is where a name lookup says which way it took (stderr; tests capture it). +var podNameNote io.Writer = os.Stderr + +// podNameCache keeps one lookup per name per run: a command parses its connection several times. +var podNameCache = struct { + sync.Mutex + m map[string]*namedPod +}{m: map[string]*namedPod{}} + +// findPodByName is lookupPodName, once per name per run, and says on stderr when the pod was not +// heard on the LAN. +func findPodByName(short string) *namedPod { + podNameCache.Lock() + defer podNameCache.Unlock() + if p, ok := podNameCache.m[short]; ok { + return p + } + p, err := lookupPodName(short) + if err != nil { + p = nil + } + if p == nil { + fmt.Fprintf(podNameNote, "benchpod-%s did not answer on the LAN over mDNS; looking for it on USB (usb:benchpod-%s).\n", short, short) + } + podNameCache.m[short] = p + return p +} + +// resolvePodName turns a pod name into a connection: the pod's network address when it answers on +// the LAN (or on its USB network link), else usb:, which finds it on USB. +func resolvePodName(short string) ConnSpec { + if p := findPodByName(short); p != nil { + return ConnSpec{Kind: connNetwork, Addr: p.addr} + } + return ConnSpec{Kind: connSerial, Pod: short} +} + +// savedPodName is the connection set-connection stores for a pod given by name: its Ethernet or +// Wi-Fi address when it has one (like `discover --save`), else usb:benchpod-, which finds that +// pod on USB on any port. +func savedPodName(short string) string { + if p := findPodByName(short); p != nil && p.lanAddr != "" { + return p.lanAddr + } + return "usb:benchpod-" + short +} + +// entryNamesPod reports whether an mDNS entry (its DNS-SD instance, "BenchPod a1b2c3", and its +// hostname, "benchpod-a1b2c3.local.") is the pod with the short id short. +func entryNamesPod(instance, hostname, short string) bool { + return serialconsole.NormalizePodName(unescapeDNSSD(instance)) == short || + serialconsole.NormalizePodName(strings.TrimSuffix(hostname, ".")) == short +} + +// namedPodAt is where a pod heard on ips and port is reached and saved, or nil without an address. +func namedPodAt(ips []net.IP, port int) *namedPod { + ip := preferredPodIP(ips) + if ip == nil { + return nil + } + p := &namedPod{addr: benchpodconfig.EnsurePort(net.JoinHostPort(ip.String(), fmt.Sprint(port)))} + if lan := lanPodIP(ips); lan != nil { + p.lanAddr = benchpodconfig.EnsurePort(net.JoinHostPort(lan.String(), fmt.Sprint(port))) + } + return p +} + +// browsePodName is lookupPodName over the network: the browse discoverPods does, stopped as soon +// as the named pod answers, and asked again (podNameQueries) when it does not. +func browsePodName(short string) (*namedPod, error) { + for _, wait := range podNameQueries { + p, err := browsePodNameOnce(short, wait) + if p != nil || err != nil { + return p, err + } + } + return nil, nil +} + +// browsePodNameOnce is one mDNS browse of up to wait for the pod with the short id short. The +// library reports a pod once per browse, with the addresses of the interface it answered on +// first, so the first answer is the one there is. +func browsePodNameOnce(short string, wait time.Duration) (*namedPod, error) { + ctx, cancel := context.WithTimeout(context.Background(), wait) + defer cancel() + + entries := make(chan *zeroconf.ServiceEntry, 16) + drained := make(chan struct{}) + var found *namedPod + go func() { + defer close(drained) + for e := range entries { + if found == nil && entryNamesPod(e.Instance, e.HostName, short) { + if found = namedPodAt(e.AddrIPv4, e.Port); found != nil { + cancel() + } + } + } + }() + + // The same interface choice as discoverPods, for the same macOS reasons. + opts := []zeroconf.ClientOption{zeroconf.SelectIPTraffic(zeroconf.IPv4)} + if ifaces := multicastIfaces(); len(ifaces) > 0 { + opts = append(opts, zeroconf.SelectIfaces(ifaces)) + } + if err := zeroconf.Browse(ctx, mdnsService, mdnsDomain, entries, opts...); err != nil { + return nil, fmt.Errorf("browse %s: %w", mdnsService, err) + } + <-drained + return found, nil +} diff --git a/cmd/benchpod-cli/connection_name_test.go b/cmd/benchpod-cli/connection_name_test.go new file mode 100644 index 0000000..56dc8d1 --- /dev/null +++ b/cmd/benchpod-cli/connection_name_test.go @@ -0,0 +1,165 @@ +package main + +import ( + "net" + "path/filepath" + "testing" + + "github.com/embeddedci-com/benchpod-cli/internal/benchpodconfig" +) + +// withPodNames replaces the mDNS lookup with a fixed table (short id -> pod) for one test and +// starts from an empty cache. It returns how many lookups ran per name. +func withPodNames(t *testing.T, pods map[string]*namedPod) map[string]int { + t.Helper() + calls := map[string]int{} + old := lookupPodName + lookupPodName = func(short string) (*namedPod, error) { + calls[short]++ + return pods[short], nil + } + resetCache := func() { + podNameCache.Lock() + podNameCache.m = map[string]*namedPod{} + podNameCache.Unlock() + } + resetCache() + t.Cleanup(func() { + lookupPodName = old + resetCache() + }) + return calls +} + +func TestPodShortName(t *testing.T) { + for in, want := range map[string]string{ + "benchpod-a1c5c7": "a1c5c7", + "a1c5c7": "a1c5c7", + "BenchPod-A1C5C7": "a1c5c7", + "benchpod-a1c5c7.local": "a1c5c7", + "a1c5c7.local": "a1c5c7", + // Real hostnames and anything else keep going to the OS resolver. + "benchpod.local": "", + "benchpod-a1c5c7x": "", + "a1c5c": "", + "a1c5c7:8080": "", + "lab-a1c5c7": "", + "benchpod-g1c5c7": "", + "pod.example.com": "", + "192.168.1.5": "", + "benchpod-a1c5c7.lan": "", + "benchpod-a1c5c7.local.": "", + } { + if got := podShortName(in); got != want { + t.Errorf("podShortName(%q) = %q, want %q", in, got, want) + } + } +} + +func TestParseTargetResolvesAPodNameOnTheLAN(t *testing.T) { + withPodNames(t, map[string]*namedPod{ + "a1c5c7": {addr: "192.168.1.221:8080", lanAddr: "192.168.1.221:8080"}, + }) + for _, in := range []string{"benchpod-a1c5c7", "a1c5c7", "benchpod-a1c5c7.local"} { + spec, err := parseTarget(in) + if err != nil || !spec.IsNetwork() || spec.Addr != "192.168.1.221:8080" { + t.Errorf("parseTarget(%q) = %+v, %v; want network 192.168.1.221:8080", in, spec, err) + } + } +} + +func TestParseTargetFallsBackToUSBForAPodNotOnTheLAN(t *testing.T) { + withPodNames(t, nil) + spec, err := parseTarget("benchpod-a1c5c7") + if err != nil || !spec.IsSerial() || spec.Pod != "a1c5c7" || spec.Device != "" { + t.Fatalf("parseTarget = %+v, %v; want usb:benchpod-a1c5c7", spec, err) + } + // The network commands still go through it the way usb: does. + if spec.RequireNetwork("capture") == nil { + t.Errorf("a USB spec should need the USB network adapter for network commands") + } +} + +func TestParseTargetLooksUpAPodNameOncePerRun(t *testing.T) { + calls := withPodNames(t, nil) + for i := 0; i < 3; i++ { + if _, err := parseTarget("a1c5c7"); err != nil { + t.Fatal(err) + } + } + if _, err := parseTarget("benchpod-a1c5c7"); err != nil { + t.Fatal(err) + } + if calls["a1c5c7"] != 1 { + t.Errorf("looked up %d times, want 1", calls["a1c5c7"]) + } +} + +func TestParseTargetLeavesHostnamesAlone(t *testing.T) { + calls := withPodNames(t, nil) + spec, err := parseTarget("benchpod.local") + if err != nil || !spec.IsNetwork() || spec.Addr != "benchpod.local:8080" { + t.Fatalf("parseTarget = %+v, %v", spec, err) + } + if len(calls) != 0 { + t.Errorf("a hostname was looked up over mDNS: %v", calls) + } +} + +func TestEntryNamesPod(t *testing.T) { + for _, c := range []struct { + instance, hostname string + want bool + }{ + {`BenchPod\ a1c5c7`, "benchpod-a1c5c7.local.", true}, + {"BenchPod a1c5c7", "", true}, + {"", "benchpod-a1c5c7.local.", true}, + {"BenchPod baea06", "benchpod-baea06.local.", false}, + } { + if got := entryNamesPod(c.instance, c.hostname, "a1c5c7"); got != c.want { + t.Errorf("entryNamesPod(%q, %q) = %v, want %v", c.instance, c.hostname, got, c.want) + } + } +} + +func TestNamedPodAt(t *testing.T) { + lan, link := net.IPv4(192, 168, 1, 221), net.IPv4(10, 237, 4, 2) + if p := namedPodAt(nil, 8080); p != nil { + t.Errorf("no address: %+v, want nil", p) + } + p := namedPodAt([]net.IP{lan, link}, 8080) + if p == nil || p.addr != "10.237.4.2:8080" || p.lanAddr != "192.168.1.221:8080" { + t.Errorf("LAN and USB link: %+v, want addr on the link and the LAN address saved", p) + } + p = namedPodAt([]net.IP{link}, 8080) + if p == nil || p.addr != "10.237.4.2:8080" || p.lanAddr != "" { + t.Errorf("USB link only: %+v", p) + } +} + +func TestSetConnectionWithAPodName(t *testing.T) { + for _, c := range []struct { + name string + pods map[string]*namedPod + arg string + want string + }{ + {"on the LAN", map[string]*namedPod{"a1c5c7": {addr: "10.237.4.2:8080", lanAddr: "192.168.1.221:8080"}}, + "benchpod-a1c5c7", "192.168.1.221:8080"}, + {"on its USB link only", map[string]*namedPod{"a1c5c7": {addr: "10.237.4.2:8080"}}, + "a1c5c7", "usb:benchpod-a1c5c7"}, + {"not on the LAN", nil, "a1c5c7", "usb:benchpod-a1c5c7"}, + } { + t.Run(c.name, func(t *testing.T) { + withPodNames(t, c.pods) + cfg := filepath.Join(t.TempDir(), "config.json") + if code := run([]string{"set-connection", c.arg, "--config-file", cfg}); code != exitOK { + t.Fatalf("exit %d", code) + } + saved, err := benchpodconfig.Load(cfg) + if err != nil || saved.Connection != c.want { + t.Fatalf("saved %+v, %v; want %q", saved, err, c.want) + } + }) + } +} diff --git a/cmd/benchpod-cli/identity_test.go b/cmd/benchpod-cli/identity_test.go index c854aaf..1c0a5a8 100644 --- a/cmd/benchpod-cli/identity_test.go +++ b/cmd/benchpod-cli/identity_test.go @@ -49,7 +49,9 @@ func withFakeIdentityConsole(t *testing.T, fc *fakeIdentityConsole, tty bool) { func TestIdentityWipeOnlyOverUSB(t *testing.T) { fc := &fakeIdentityConsole{} withFakeIdentityConsole(t, fc, true) - for _, conn := range []string{"192.168.1.220", "192.168.1.220:8080", "benchpod-a1b2c3.local"} { + // A pod name that answers on the LAN is a network connection too. + withPodNames(t, map[string]*namedPod{"a1b2c3": {addr: "192.168.1.221:8080", lanAddr: "192.168.1.221:8080"}}) + for _, conn := range []string{"192.168.1.220", "192.168.1.220:8080", "benchpod-a1b2c3.local", "benchpod-a1b2c3"} { err := runIdentityWipe(&globalFlags{connection: conn}, true, strings.NewReader(""), &bytes.Buffer{}) if err == nil || !strings.Contains(err.Error(), "--connection usb") { t.Fatalf("connection %q: err = %v", conn, err) diff --git a/cmd/benchpod-cli/podclient_test.go b/cmd/benchpod-cli/podclient_test.go index 18bf154..c5a308f 100644 --- a/cmd/benchpod-cli/podclient_test.go +++ b/cmd/benchpod-cli/podclient_test.go @@ -37,6 +37,9 @@ func TestMain(m *testing.M) { return serialconsole.USBNet{}, errors.New("no pod on USB (tests)") } localUSBPod = func(string) string { return "" } + // Never browse this machine's LAN for a pod given by name. + lookupPodName = func(string) (*namedPod, error) { return nil, nil } + podNameNote = io.Discard // Never report to the real embeddedci.com, never touch the real queue. fwReportPost = func(context.Context, fwEvent) error { return errNoCloudCredential } fwReportQueuePath = func() string { return "" } diff --git a/cmd/benchpod-cli/root.go b/cmd/benchpod-cli/root.go index d2e827d..4f5a793 100644 --- a/cmd/benchpod-cli/root.go +++ b/cmd/benchpod-cli/root.go @@ -85,7 +85,7 @@ func newRootCmd() *cobra.Command { pf := root.PersistentFlags() pf.StringVar(&g.connection, "connection", "", - `how to reach the pod: its network address (192.168.1.5[:8080]), a device path (/dev/tty..., COM3), "usb" to auto-detect the pod over USB (its console, and its USB network adapter for the network commands), usb: for one of several pods on USB, or embeddedci: to go through embeddedci.com (default: the saved set-connection target)`) + `how to reach the pod: its network address (192.168.1.5[:8080]), its name (benchpod-a1b2c3 or a1b2c3, found on the LAN over mDNS, else on USB), a device path (/dev/tty..., COM3), "usb" to auto-detect the pod over USB (its console, and its USB network adapter for the network commands), usb: for one of several pods on USB, or embeddedci: to go through embeddedci.com (default: the saved set-connection target)`) pf.StringVar(&g.configFile, "config-file", "", "path to config file") pf.StringVar(&g.outputFilename, "output-filename", "", "write command output to this file instead of stdout") pf.DurationVar(&g.timeout, "timeout", 0, "overall command deadline (0 = per-command default)")