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
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,30 @@ Available actions:
- `turn-left` - Turn the robot left
- `turn-right` - Turn the robot right

#### Create WebRTC session
#### Join WebRTC session

```bash
menlo robot session # Use default robot
menlo robot session --robot-id <robot-id> # Use specific robot
```

This opens a LiveKit meet session with the robot, returning an SFU endpoint, WebRTC token, and a join URL.
#### Set default robot

```bash
menlo robot connect # Interactive selection
menlo robot connect <robot-id> # Set directly
```

Same as `menlo config default-robot`. Sets the default robot for the CLI.

#### Download snapshot

```bash
menlo robot snapshot # Use default robot
menlo robot snapshot --robot-id <robot-id> # Use specific robot
```

Downloads the latest snapshot image from the robot and saves it to `~/.config/menlo/snapshot/{robot-id}/latest.jpeg`.

### menlo config

Expand Down
39 changes: 39 additions & 0 deletions internal/clients/platform/platform.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"time"

"github.com/menloresearch/cli/internal/config"
Expand Down Expand Up @@ -224,6 +227,42 @@ func (c *Client) SendSemanticCommand(robotID, command string) error {
return nil
}

// GetSnapshot downloads the latest snapshot image for a robot
func (c *Client) GetSnapshot(robotID string) (string, error) {
resp, err := c.doRequest("GET", "v1/robots/"+robotID+"/snapshot", nil)
if err != nil {
return "", err
}
defer closeBody(resp)

// Get config dir for snapshot storage
configDir, err := config.ConfigDir()
if err != nil {
return "", err
}
snapshotDir := filepath.Join(configDir, "snapshot", robotID)

// Create directory if it doesn't exist
if err := os.MkdirAll(snapshotDir, 0755); err != nil {
return "", err
}

// Save the image
imagePath := filepath.Join(snapshotDir, "latest.jpeg")
outFile, err := os.Create(imagePath)
if err != nil {
return "", err
}
defer func() { _ = outFile.Close() }()

_, err = io.Copy(outFile, resp.Body)
if err != nil {
return "", err
}

return imagePath, nil
}

// CreateSession creates a new session for a robot and returns WebRTC credentials
func (c *Client) CreateSession(robotID string) (*SessionResponse, error) {
resp, err := c.doRequest("POST", "v1/robots/"+robotID+"/session", nil)
Expand Down
113 changes: 85 additions & 28 deletions internal/commands/robot.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,35 +84,14 @@ var robotStatusCmd = &cobra.Command{
}

var robotActionCmd = &cobra.Command{
Use: "action <action>",
Short: "Send an action to a robot",
Long: `Available actions:
forward Move the robot forward
backward Move the robot backward
left Move the robot left
right Move the robot right
turn-left Turn the robot left
turn-right Turn the robot right

Examples:
menlo robot action forward
menlo robot action left --robot-id <robot-id>`,
Args: cobra.ExactArgs(1),
Use: "action <action>",
Short: "Send an action to a robot",
ValidArgs: platform.ValidSemanticCommands,
Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
ValidArgsFunction: cobra.NoFileCompletions,
RunE: func(cmd *cobra.Command, args []string) error {
action := args[0]

// Validate action
valid := false
for _, v := range platform.ValidSemanticCommands {
if action == v {
valid = true
break
}
}
if !valid {
return fmt.Errorf("invalid action: %s. Valid actions: forward, backward, left, right, turn-left, turn-right", action)
}

robotID, err := cmd.Flags().GetString("robot-id")
if err != nil {
return err
Expand Down Expand Up @@ -148,8 +127,8 @@ Examples:

var robotSessionCmd = &cobra.Command{
Use: "session",
Short: "Create a WebRTC session for a robot",
Long: `Create a session to connect to a robot via WebRTC.
Short: "Join a WebRTC session for a robot",
Long: `Join a session to connect to a robot via WebRTC.
Returns an SFU endpoint and WebRTC token for connecting.

Examples:
Expand Down Expand Up @@ -188,6 +167,27 @@ Examples:
},
}

var robotConnectCmd = &cobra.Command{
Use: "connect [robot-id]",
Short: "Set or show the default robot",
Long: `Set or show the default robot. Same as 'menlo config default-robot'.

Examples:
menlo robot connect # Interactive selection
menlo robot connect <robot-id> # Set directly`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
// Interactive mode - show selection
return runRobotSelector()
}

// Set robot ID directly
robotID := args[0]
return saveDefaultRobot(robotID)
},
}

// selectRobotInteractive prompts user to select a robot from list
func selectRobotInteractive() (string, error) {
client, err := platform.NewClient()
Expand Down Expand Up @@ -280,13 +280,70 @@ func generateMeetLink(sfuEndpoint, token string) string {
return baseURL + "?" + params.Encode()
}

var robotSnapshotCmd = &cobra.Command{
Use: "snapshot",
Short: "Download latest snapshot from a robot",
Long: `Download the latest snapshot image from a robot.
The image is saved to ~/.config/menlo/snapshot/{robot-id}/latest.jpeg

Examples:
menlo robot snapshot
menlo robot snapshot --robot-id <robot-id>`,
RunE: func(cmd *cobra.Command, args []string) error {
robotID, err := cmd.Flags().GetString("robot-id")
if err != nil {
return err
}

// If no robot ID provided, try default
if robotID == "" {
cfg, err := config.Load()
if err != nil {
if !config.IsNotExist(err) {
return err
}
}
if cfg != nil {
robotID = cfg.DefaultRobotID
}
}

// If still no robot ID, ask user to select
if robotID == "" {
robotID, err = selectRobotInteractive()
if err != nil {
return err
}
if robotID == "" {
return nil
}
}

client, err := platform.NewClient()
if err != nil {
return err
}

path, err := client.GetSnapshot(robotID)
if err != nil {
return err
}

fmt.Printf("Snapshot saved to: %s\n", path)
return nil
},
}

func init() {
robotStatusCmd.Flags().String("robot-id", "", "Robot ID")
robotActionCmd.Flags().String("robot-id", "", "Robot ID")
robotSessionCmd.Flags().String("robot-id", "", "Robot ID")
robotSnapshotCmd.Flags().String("robot-id", "", "Robot ID")
robotCmd.AddCommand(robotListCmd)
robotCmd.AddCommand(robotStatusCmd)
robotCmd.AddCommand(robotActionCmd)
robotCmd.AddCommand(robotSessionCmd)
robotCmd.AddCommand(robotSnapshotCmd)
robotCmd.AddCommand(robotConnectCmd)
rootCmd.AddCommand(robotCmd)
}
Loading