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
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<name>` | 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:<name>` | 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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:<name>` (through embeddedci.com); see [Connection](#connection). |
| `--connection` | (saved `set-connection` target) | Network address, pod name (`benchpod-a1b2c3`), device path, `usb`, or `embeddedci:<name>` (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. |
Expand Down Expand Up @@ -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 <address|device|usb|embeddedci:name>`
### `benchpod set-connection <address|name|device|usb|embeddedci:name>`

Save the default connection (a network address, a device path, `usb` or
`embeddedci:<name>`) 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`
Expand Down
20 changes: 18 additions & 2 deletions cmd/benchpod-cli/connection.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:<name>
// /dev/tty..., COM3, \\.\COM3 -> USB console (Device set)
// usb (legacy: serial) -> USB console, auto-detect (Device == "")
// usb:<name> -> USB, the pod with that name when several are plugged in (Pod set)
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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 <address|device|usb|embeddedci:name>",
Use: "set-connection <address|name|device|usb|embeddedci:name>",
Aliases: []string{"set-bench-pod"},
Short: `Store the default connection: a network address, a device path, "usb", or embeddedci:<name>`,
Short: `Store the default connection: a network address, a pod name, a device path, "usb", or embeddedci:<name>`,
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:<name> 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),
Expand Down Expand Up @@ -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 {
Expand Down
159 changes: 159 additions & 0 deletions cmd/benchpod-cli/connection_name.go
Original file line number Diff line number Diff line change
@@ -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:<name>, 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-<id>, 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
}
Loading
Loading