From cd6f94bfcf554cb3039f32654d8fbe138f60b31f Mon Sep 17 00:00:00 2001 From: Raj Nakarja Date: Wed, 26 Aug 2026 11:55:15 +0200 Subject: [PATCH 1/2] Add device pair device pair asks the server to pair the device into a fleet, tells the user to press the pairing button, and polls the device list until the pairing completes or two minutes pass. --- internal/device/device.go | 96 +++++++++++++++++++++++++++++++ internal/device/device_test.go | 102 +++++++++++++++++++++++++++++++++ main.go | 1 + main_test.go | 2 +- 4 files changed, 200 insertions(+), 1 deletion(-) diff --git a/internal/device/device.go b/internal/device/device.go index 9d1e8cf..c3c583d 100644 --- a/internal/device/device.go +++ b/internal/device/device.go @@ -146,6 +146,102 @@ func List(invocation api.Invocation, arguments []string) error { return nil } +func Pair(invocation api.Invocation, arguments []string) error { + if len(arguments) != 2 { + return errors.New("device pair takes an IMEI and a fleet id") + } + + imei := arguments[0] + + if !validImei(imei) { + return errors.New("the IMEI is the 15-digit number printed on the device") + } + + fleetId, err := strconv.ParseInt(arguments[1], 10, 64) + + if err != nil || fleetId < 1 { + return errors.New("the fleet id is the number shown by fleet list") + } + + fleets, err := api.FetchFleets(invocation) + + if err != nil { + return err + } + + fleetName := "" + + for _, fleet := range fleets { + if fleet.Id == fleetId { + fleetName = fleet.Name + } + } + + if fleetName == "" { + return errors.New("no such fleet, fleet list shows yours") + } + + body, err := json.Marshal(map[string]int64{"fleet_id": fleetId}) + + if err != nil { + return err + } + + request, err := api.AuthenticatedRequest(invocation, http.MethodPost, "/devices/"+imei+"/pair", bytes.NewReader(body)) + + if err != nil { + return err + } + + request.Header.Set("Content-Type", "application/json") + + response, err := invocation.Client.Do(request) + + if err != nil { + return errors.New("the server could not be reached, check your internet access") + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusAccepted { + return api.ServerError(response) + } + + fmt.Fprintln(invocation.Out, "Press the pairing button on the device.") + + deadline := time.Now().Add(2 * time.Minute) + + for { + time.Sleep(2 * time.Second) + + devices, err := api.FetchDevices(invocation) + + if err != nil { + return err + } + + for _, device := range devices { + if device.Imei != imei || device.FleetId != fleetId { + continue + } + + label := imei + + if device.Name != nil && *device.Name != "" { + label = *device.Name + } + + fmt.Fprintf(invocation.Out, "Paired device %q into fleet %q.\n", api.Printable(label), api.Printable(fleetName)) + + return nil + } + + if time.Now().After(deadline) { + return errors.New("the pairing button was not pressed in time, run device pair again") + } + } +} + func Rename(invocation api.Invocation, arguments []string) error { if len(arguments) != 2 { return errors.New("device rename takes an IMEI and a new name, quoted if it has spaces") diff --git a/internal/device/device_test.go b/internal/device/device_test.go index d88b319..f98de35 100644 --- a/internal/device/device_test.go +++ b/internal/device/device_test.go @@ -3,6 +3,7 @@ package device import ( "encoding/json" "fmt" + "io" "net/http" "strings" "testing" @@ -12,6 +13,107 @@ import ( "github.com/siliconwitchery/superstack-cli/internal/api/apitest" ) +func TestDevicePair(t *testing.T) { + fleets := `[{"id":3,"name":"pilot","owner":true}]` + unpaired := `[{"imei":"111111111111111","name":null,"fleet_id":0,"last_seen_at":null}]` + paired := `[{"imei":"111111111111111","name":"roof","fleet_id":3,"last_seen_at":null}]` + + tests := []struct { + name string + arguments []string + pairStatus int + pairRefusal string + pollsToPair int + wantShown []string + wantError string + wantPairCalls int + }{ + { + name: "pairing completes when the button is pressed", + arguments: []string{"111111111111111", "3"}, + pairStatus: http.StatusAccepted, + pollsToPair: 2, + wantShown: []string{"Press the pairing button on the device.", `Paired device "roof" into fleet "pilot".`}, + wantPairCalls: 1, + }, + { + name: "a server refusal is shown", + arguments: []string{"111111111111111", "3"}, + pairStatus: http.StatusConflict, + pairRefusal: "the device is already paired, unpair it first", + wantError: "already paired", + wantPairCalls: 1, + }, + {name: "an unknown fleet is refused", arguments: []string{"111111111111111", "9"}, wantError: "no such fleet"}, + {name: "a malformed IMEI is refused", arguments: []string{"roof", "3"}, wantError: "printed on the device"}, + {name: "a wordy fleet id is refused", arguments: []string{"111111111111111", "pilot"}, wantError: "shown by fleet list"}, + {name: "missing arguments", arguments: []string{"111111111111111"}, wantError: "takes an IMEI and a fleet id"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + pairCalls := 0 + polls := 0 + + mux := http.NewServeMux() + + mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, fleets) }) + + mux.HandleFunc("POST /devices/{imei}/pair", func(w http.ResponseWriter, r *http.Request) { + pairCalls++ + + body, err := io.ReadAll(r.Body) + + if err != nil || string(body) != `{"fleet_id":3}` { + t.Errorf("the pairing sent body %q", body) + } + + if test.pairRefusal != "" { + http.Error(w, test.pairRefusal, test.pairStatus) + return + } + + w.WriteHeader(test.pairStatus) + }) + + mux.HandleFunc("GET /devices", func(w http.ResponseWriter, r *http.Request) { + polls++ + + if polls >= test.pollsToPair { + fmt.Fprint(w, paired) + return + } + + fmt.Fprint(w, unpaired) + }) + + invocation, out := apitest.LoggedInInvocation(t, mux) + + err := Pair(invocation, test.arguments) + + printed := out.String() + + if test.wantError != "" { + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("error = %v", err) + } + } else if err != nil { + t.Fatal(err) + } + + for _, want := range test.wantShown { + if !strings.Contains(printed, want) { + t.Errorf("output %q omits %q", printed, want) + } + } + + if pairCalls != test.wantPairCalls { + t.Errorf("the pairing route was called %d times, want %d", pairCalls, test.wantPairCalls) + } + }) + } +} + func TestDeviceList(t *testing.T) { now := time.Now() devices := fmt.Sprintf(`[{"imei":"111111111111111","name":"roof","fleet_id":3,"last_seen_at":%q},`+ diff --git a/main.go b/main.go index 85632a9..b3085c4 100644 --- a/main.go +++ b/main.go @@ -37,6 +37,7 @@ var sections = []dispatch.Section{ Title: "Devices", Commands: []dispatch.Command{ {Name: "device list", Arguments: "[fleet_id] [--json]", Summary: "List devices and when they were last seen", Run: device.List}, + {Name: "device pair", Arguments: " ", Summary: "Pair a device into a fleet", Run: device.Pair}, {Name: "device rename", Arguments: " ", Summary: "Rename a device", Run: device.Rename}, {Name: "device unpair", Arguments: "", Summary: "Remove a device from its fleet", Run: device.Unpair}, {Name: "device start", Arguments: "", Summary: "Start the code on a device"}, diff --git a/main_test.go b/main_test.go index 096fb2e..42b986f 100644 --- a/main_test.go +++ b/main_test.go @@ -161,7 +161,7 @@ func TestNoPartImportsAnother(t *testing.T) { func TestTheTableWiresEveryCommandOffered(t *testing.T) { wired := []string{ "account balance", "account delete", "account topup", - "device list", "device rename", "device unpair", + "device list", "device pair", "device rename", "device unpair", "fleet create", "fleet delete", "fleet list", "fleet rename", "fleet transfer", "key create", "key list", "key revoke", "login", "logout", From e15169fb1e0fe7722d8652bbee99eb0a580d2d60 Mon Sep 17 00:00:00 2001 From: Raj Nakarja Date: Wed, 26 Aug 2026 12:45:00 +0200 Subject: [PATCH 2/2] Match the pairing poll to the one-minute window --- internal/device/device.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/device/device.go b/internal/device/device.go index c3c583d..d8e604f 100644 --- a/internal/device/device.go +++ b/internal/device/device.go @@ -209,7 +209,8 @@ func Pair(invocation api.Invocation, arguments []string) error { fmt.Fprintln(invocation.Out, "Press the pairing button on the device.") - deadline := time.Now().Add(2 * time.Minute) + // The server holds the pairing open for one minute. + deadline := time.Now().Add(75 * time.Second) for { time.Sleep(2 * time.Second)