function: picoclaw node, can link with gateway and execute system command
This commit is contained in:
parent
f22f6006f8
commit
3c9dca7bef
18 changed files with 1774 additions and 1 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -51,3 +51,7 @@ dist/
|
|||
# cursor
|
||||
.cursorindexingignore
|
||||
.specstory/
|
||||
|
||||
|
||||
# picoclaw-node
|
||||
cmd/picoclaw-node/config.json
|
||||
177
cmd/picoclaw-node/Makefile
Normal file
177
cmd/picoclaw-node/Makefile
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
.PHONY: all build install uninstall clean help test
|
||||
|
||||
# Build variables
|
||||
BINARY_NAME=picoclaw-node
|
||||
BUILD_DIR=build
|
||||
|
||||
# Version
|
||||
VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
LDFLAGS=-ldflags "-s -w"
|
||||
|
||||
# Go variables
|
||||
GO?=CGO_ENABLED=0 go
|
||||
GOFLAGS?=-v
|
||||
|
||||
# Golangci-lint
|
||||
GOLANGCI_LINT?=golangci-lint
|
||||
|
||||
# Installation
|
||||
INSTALL_PREFIX?=$(HOME)/.local
|
||||
INSTALL_BIN_DIR=$(INSTALL_PREFIX)/bin
|
||||
INSTALL_TMP_SUFFIX=.new
|
||||
|
||||
# Data dir (config, identity, etc.)
|
||||
OPENCLAW_HOME?=$(HOME)/.openclaw
|
||||
|
||||
# OS detection
|
||||
UNAME_S:=Linux
|
||||
UNAME_M:=aarch64
|
||||
|
||||
# Platform-specific settings
|
||||
ifeq ($(UNAME_S),Linux)
|
||||
PLATFORM=linux
|
||||
ifeq ($(UNAME_M),x86_64)
|
||||
ARCH=amd64
|
||||
else ifeq ($(UNAME_M),aarch64)
|
||||
ARCH=arm64
|
||||
else ifeq ($(UNAME_M),loongarch64)
|
||||
ARCH=loong64
|
||||
else ifeq ($(UNAME_M),riscv64)
|
||||
ARCH=riscv64
|
||||
else ifeq ($(UNAME_M),armv7l)
|
||||
ARCH=arm
|
||||
else
|
||||
ARCH=$(UNAME_M)
|
||||
endif
|
||||
else ifeq ($(UNAME_S),Darwin)
|
||||
PLATFORM=darwin
|
||||
ifeq ($(UNAME_M),x86_64)
|
||||
ARCH=amd64
|
||||
else ifeq ($(UNAME_M),arm64)
|
||||
ARCH=arm64
|
||||
else
|
||||
ARCH=$(UNAME_M)
|
||||
endif
|
||||
else
|
||||
PLATFORM=$(UNAME_S)
|
||||
ARCH=$(UNAME_M)
|
||||
endif
|
||||
|
||||
BINARY_PATH=$(BUILD_DIR)/$(BINARY_NAME)-$(PLATFORM)-$(ARCH)
|
||||
|
||||
# Default target
|
||||
all: build
|
||||
|
||||
## build: Build the go_node binary for current platform
|
||||
build:
|
||||
@echo "Building $(BINARY_NAME) for $(PLATFORM)/$(ARCH)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=$(PLATFORM) GOARCH=$(ARCH) $(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BINARY_PATH) .
|
||||
@echo "Build complete: $(BINARY_PATH)"
|
||||
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
||||
|
||||
## build-all: Build go_node for all platforms
|
||||
build-all:
|
||||
@echo "Building for multiple platforms..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 .
|
||||
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 .
|
||||
GOOS=linux GOARCH=loong64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 .
|
||||
GOOS=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 .
|
||||
GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 .
|
||||
GOOS=darwin GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 .
|
||||
GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe .
|
||||
@echo "All builds complete"
|
||||
|
||||
## install: Install go_node to system
|
||||
install: build
|
||||
@echo "Installing $(BINARY_NAME)..."
|
||||
@mkdir -p $(INSTALL_BIN_DIR)
|
||||
@cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX)
|
||||
@chmod +x $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX)
|
||||
@mv -f $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX) $(INSTALL_BIN_DIR)/$(BINARY_NAME)
|
||||
@echo "Installed binary to $(INSTALL_BIN_DIR)/$(BINARY_NAME)"
|
||||
@echo "Installation complete!"
|
||||
|
||||
## uninstall: Remove go_node from system
|
||||
uninstall:
|
||||
@echo "Uninstalling $(BINARY_NAME)..."
|
||||
@rm -f $(INSTALL_BIN_DIR)/$(BINARY_NAME)
|
||||
@echo "Removed binary from $(INSTALL_BIN_DIR)/$(BINARY_NAME)"
|
||||
@echo "Note: Only the executable has been deleted."
|
||||
@echo "To remove config and identity, run 'make uninstall-all'"
|
||||
|
||||
## uninstall-all: Remove go_node and all data (~/.openclaw)
|
||||
uninstall-all:
|
||||
@echo "Removing $(OPENCLAW_HOME)..."
|
||||
@rm -rf $(OPENCLAW_HOME)
|
||||
@make uninstall
|
||||
@echo "Complete uninstallation done!"
|
||||
|
||||
## clean: Remove build artifacts
|
||||
clean:
|
||||
@echo "Cleaning build artifacts..."
|
||||
@rm -rf $(BUILD_DIR)
|
||||
@echo "Clean complete"
|
||||
|
||||
## vet: Run go vet for static analysis
|
||||
vet:
|
||||
@$(GO) vet ./...
|
||||
|
||||
## test: Test Go code
|
||||
test:
|
||||
@$(GO) test ./...
|
||||
|
||||
## fmt: Format Go code
|
||||
fmt:
|
||||
@go fmt ./...
|
||||
|
||||
## lint: Run linters
|
||||
lint:
|
||||
@$(GOLANGCI_LINT) run
|
||||
|
||||
## fix: Fix linting issues
|
||||
fix:
|
||||
@$(GOLANGCI_LINT) run --fix
|
||||
|
||||
## deps: Download dependencies
|
||||
deps:
|
||||
@$(GO) mod download
|
||||
@$(GO) mod verify
|
||||
|
||||
## update-deps: Update dependencies
|
||||
update-deps:
|
||||
@$(GO) get -u ./...
|
||||
@$(GO) mod tidy
|
||||
|
||||
## check: Run vet, fmt, and test
|
||||
check: deps fmt vet test
|
||||
|
||||
## run: Build and run go_node
|
||||
run: build
|
||||
@$(BUILD_DIR)/$(BINARY_NAME) $(ARGS)
|
||||
|
||||
## help: Show this help message
|
||||
help:
|
||||
@echo "go_node Makefile"
|
||||
@echo ""
|
||||
@echo "Usage:"
|
||||
@echo " make [target]"
|
||||
@echo ""
|
||||
@echo "Targets:"
|
||||
@grep -E '^## ' $(MAKEFILE_LIST) | sort | awk -F': ' '{printf " %-16s %s\n", substr($$1, 4), $$2}'
|
||||
@echo ""
|
||||
@echo "Examples:"
|
||||
@echo " make build # Build for current platform"
|
||||
@echo " make install # Install to ~/.local/bin"
|
||||
@echo " make uninstall # Remove binary"
|
||||
@echo ""
|
||||
@echo "Environment Variables:"
|
||||
@echo " INSTALL_PREFIX # Installation prefix (default: ~/.local)"
|
||||
@echo " OPENCLAW_HOME # Data dir (default: ~/.openclaw)"
|
||||
@echo " VERSION # Version string (default: git describe)"
|
||||
@echo ""
|
||||
@echo "Current Configuration:"
|
||||
@echo " Platform: $(PLATFORM)/$(ARCH)"
|
||||
@echo " Binary: $(BINARY_PATH)"
|
||||
@echo " Install Prefix: $(INSTALL_PREFIX)"
|
||||
107
cmd/picoclaw-node/README.md
Normal file
107
cmd/picoclaw-node/README.md
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
# go_node
|
||||
|
||||
Go 实现的 OpenClaw node 节点,与 Android node 架构一致,通过 WebSocket 连接 Gateway,仅实现 `system.run`(执行 shell 命令)。
|
||||
|
||||
## 架构
|
||||
|
||||
- **GatewaySession**:WebSocket 连接、connect 握手、RPC 协议、`node.invoke.request` 事件处理
|
||||
- **InvokeDispatcher**:仅支持 `system.run` 命令,执行 shell 并返回 stdout/stderr
|
||||
- **DeviceIdentity**:Ed25519 设备身份、connect 时 device 签名
|
||||
|
||||
## 配置文件
|
||||
|
||||
配置通过 JSON 文件管理,支持以下路径(按优先级):
|
||||
- `-config` 参数指定的路径
|
||||
- 当前目录 `config.json`
|
||||
- `~/.openclaw/go_node.json`
|
||||
|
||||
### 配置示例
|
||||
|
||||
```json
|
||||
{
|
||||
"gateway": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 18789,
|
||||
"tls": false,
|
||||
"token": "your-gateway-token",
|
||||
"password": ""
|
||||
},
|
||||
"node": {
|
||||
"displayName": "my-node",
|
||||
"nodeId": "my-node"
|
||||
},
|
||||
"reconnect": {
|
||||
"maxRetries": 0,
|
||||
"retryIntervalMs": 5000
|
||||
},
|
||||
"exec": {
|
||||
"workDir": "/tmp/go_node_work",
|
||||
"allowedCommands": ["ls", "echo", "cat"],
|
||||
"allowAllCommands": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 | 默认 |
|
||||
|------|------|------|
|
||||
| gateway.host | Gateway 地址 | 127.0.0.1 |
|
||||
| gateway.port | Gateway 端口 | 18789 |
|
||||
| gateway.tls | 是否使用 WSS | false |
|
||||
| gateway.token | Gateway 令牌 | - |
|
||||
| gateway.password | Gateway 密码 | - |
|
||||
| node.displayName | 节点显示名 | hostname |
|
||||
| node.nodeId | 节点 ID | 同 displayName |
|
||||
| reconnect.maxRetries | 最大重连次数(0=无限) | 0 |
|
||||
| reconnect.retryIntervalMs | 重连间隔(毫秒) | 5000 |
|
||||
| exec.workDir | 工作目录,命令仅在此目录及子目录下执行,空=不限制 | - |
|
||||
| exec.allowedCommands | 可执行命令白名单(按 argv[0] 的 basename),空=允许所有 | - |
|
||||
| exec.allowAllCommands | 为 true 时忽略白名单,允许执行任意命令(含 rawCommand) | false |
|
||||
|
||||
**安全说明**:
|
||||
- `workDir` 非空时:`params.cwd` 必须在 workDir 下(相对路径或绝对路径均校验)
|
||||
- `allowedCommands` 非空时:仅白名单中的命令可执行,且禁用 rawCommand(只支持 command 数组)
|
||||
- `allowAllCommands: true` 时: bypass 白名单,允许任意命令和 rawCommand;适用于完全信任的节点环境
|
||||
|
||||
> 注意:go_node 使用 client ID `node-host`,与 openclaw node-host 相同,以便通过 gateway 的 client ID 校验。
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
go build -o go_node .
|
||||
```
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
# 生成示例配置
|
||||
./go_node -init-config
|
||||
# 或指定路径
|
||||
./go_node -init-config -config ~/.openclaw/go_node.json
|
||||
|
||||
# 使用默认 config.json 运行
|
||||
./go_node
|
||||
|
||||
# 指定配置文件
|
||||
./go_node -config /path/to/config.json
|
||||
```
|
||||
|
||||
## system.run 参数
|
||||
|
||||
与 openclaw node-host 兼容,paramsJSON 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"command": ["echo", "hello"],
|
||||
"cwd": "/tmp",
|
||||
"env": {"KEY": "value"},
|
||||
"timeoutMs": 30000
|
||||
}
|
||||
```
|
||||
|
||||
或使用 rawCommand(shell -c):
|
||||
|
||||
```json
|
||||
{
|
||||
"rawCommand": "echo hello | wc -c"
|
||||
}
|
||||
```
|
||||
BIN
cmd/picoclaw-node/camera_mock.jpg
Normal file
BIN
cmd/picoclaw-node/camera_mock.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 258 KiB |
27
cmd/picoclaw-node/config.json.example
Normal file
27
cmd/picoclaw-node/config.json.example
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"gateway": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 18790,
|
||||
"tls": false,
|
||||
"token": "",
|
||||
"password": ""
|
||||
},
|
||||
"node": {
|
||||
"displayName": "my_device",
|
||||
"nodeId": "my_device"
|
||||
},
|
||||
"reconnect": {
|
||||
"maxRetries": 0,
|
||||
"retryIntervalMs": 5000
|
||||
},
|
||||
"exec": {
|
||||
"workDir": "",
|
||||
"allowedCommands": null,
|
||||
"allowAllCommands": true
|
||||
},
|
||||
"identity": {
|
||||
"deviceId": "",
|
||||
"publicKeyB64": "",
|
||||
"privateKeyB64": ""
|
||||
}
|
||||
}
|
||||
174
cmd/picoclaw-node/config/config.go
Normal file
174
cmd/picoclaw-node/config/config.go
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/openclaw/go_node/infra"
|
||||
)
|
||||
|
||||
// Config holds gateway and node settings
|
||||
type Config struct {
|
||||
Gateway GatewayConfig `json:"gateway"`
|
||||
Node NodeConfig `json:"node"`
|
||||
Reconnect ReconnectConfig `json:"reconnect"`
|
||||
Exec ExecConfig `json:"exec"`
|
||||
Identity IdentityConfig `json:"identity,omitempty"`
|
||||
}
|
||||
|
||||
// ExecConfig holds security settings for system.run and camera mock
|
||||
type ExecConfig struct {
|
||||
WorkDir string `json:"workDir"` // base dir, commands run under this only
|
||||
AllowedCommands []string `json:"allowedCommands"` // allowed executable names, empty = allow all
|
||||
AllowAllCommands bool `json:"allowAllCommands"` // when true, bypass allowedCommands check and allow any command (including rawCommand)
|
||||
CameraMockPath string `json:"cameraMockPath"` // fixed path for camera.snap mock image (default: workDir/camera_mock.jpg)
|
||||
}
|
||||
|
||||
// ReconnectConfig holds auto-reconnect settings
|
||||
type ReconnectConfig struct {
|
||||
MaxRetries int `json:"maxRetries"` // 0 = unlimited
|
||||
RetryIntervalMs int `json:"retryIntervalMs"` // milliseconds between retries
|
||||
}
|
||||
|
||||
// GatewayConfig holds gateway connection settings
|
||||
type GatewayConfig struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
TLS bool `json:"tls"`
|
||||
Token string `json:"token"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// NodeConfig holds node identity settings
|
||||
type NodeConfig struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
NodeID string `json:"nodeId"`
|
||||
}
|
||||
|
||||
// IdentityConfig holds device identity (Ed25519 keypair) persisted in config.json
|
||||
type IdentityConfig struct {
|
||||
DeviceID string `json:"deviceId,omitempty"`
|
||||
PublicKeyB64 string `json:"publicKeyB64,omitempty"`
|
||||
PrivateKeyB64 string `json:"privateKeyB64,omitempty"`
|
||||
}
|
||||
|
||||
// Load loads config from JSON file. Path can be absolute or relative.
|
||||
// If path is empty, tries: ./config.json, ~/.openclaw/go_node.json
|
||||
func Load(path string) (*Config, error) {
|
||||
if path == "" {
|
||||
for _, p := range defaultConfigPaths() {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
path = p
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if path == "" {
|
||||
return defaultConfig(), nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config %s: %w", path, err)
|
||||
}
|
||||
|
||||
cfg.applyDefaults()
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func defaultConfigPaths() []string {
|
||||
home, _ := os.UserHomeDir()
|
||||
return []string{
|
||||
"config.json",
|
||||
filepath.Join(home, ".openclaw", "go_node.json"),
|
||||
}
|
||||
}
|
||||
|
||||
func defaultConfig() *Config {
|
||||
displayName, _ := os.Hostname()
|
||||
return &Config{
|
||||
Gateway: GatewayConfig{
|
||||
Host: "127.0.0.1",
|
||||
Port: 18789,
|
||||
TLS: false,
|
||||
},
|
||||
Node: NodeConfig{
|
||||
DisplayName: displayName,
|
||||
NodeID: displayName,
|
||||
},
|
||||
Reconnect: ReconnectConfig{
|
||||
MaxRetries: 0,
|
||||
RetryIntervalMs: 5000,
|
||||
},
|
||||
Exec: ExecConfig{
|
||||
WorkDir: "",
|
||||
AllowedCommands: nil,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) applyDefaults() {
|
||||
g := &c.Gateway
|
||||
if g.Host == "" {
|
||||
g.Host = "127.0.0.1"
|
||||
}
|
||||
if g.Port <= 0 {
|
||||
g.Port = 18789
|
||||
}
|
||||
if c.Node.DisplayName == "" {
|
||||
c.Node.DisplayName, _ = os.Hostname()
|
||||
}
|
||||
if c.Node.NodeID == "" {
|
||||
c.Node.NodeID = c.Node.DisplayName
|
||||
}
|
||||
r := &c.Reconnect
|
||||
if r.RetryIntervalMs <= 0 {
|
||||
r.RetryIntervalMs = 5000
|
||||
}
|
||||
e := &c.Exec
|
||||
if e.WorkDir != "" {
|
||||
abs, err := filepath.Abs(e.WorkDir)
|
||||
if err == nil {
|
||||
e.WorkDir = abs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocketURL returns the gateway WebSocket URL
|
||||
func (c *Config) WebSocketURL() string {
|
||||
scheme := "ws"
|
||||
if c.Gateway.TLS {
|
||||
scheme = "wss"
|
||||
}
|
||||
return fmt.Sprintf("%s://%s:%d", scheme, c.Gateway.Host, c.Gateway.Port)
|
||||
}
|
||||
|
||||
// Example writes an example config to path
|
||||
func Example(path string) error {
|
||||
cfg := defaultConfig()
|
||||
cfg.Gateway.Token = ""
|
||||
if ident, err := infra.GenerateDeviceIdentity(); err == nil {
|
||||
cfg.Identity = IdentityConfig{
|
||||
DeviceID: ident.DeviceID,
|
||||
PublicKeyB64: base64.StdEncoding.EncodeToString(ident.PublicKeyRaw),
|
||||
PrivateKeyB64: base64.StdEncoding.EncodeToString(ident.PrivateKey),
|
||||
}
|
||||
}
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
if dir != "." {
|
||||
_ = os.MkdirAll(dir, 0755)
|
||||
}
|
||||
return os.WriteFile(path, data, 0600)
|
||||
}
|
||||
93
cmd/picoclaw-node/gateway/protocol.go
Normal file
93
cmd/picoclaw-node/gateway/protocol.go
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
package gateway
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// Protocol version (see openclaw src/gateway/protocol/schema.ts)
|
||||
const ProtocolVersion = 3
|
||||
|
||||
// Frame types
|
||||
const (
|
||||
FrameTypeReq = "req"
|
||||
FrameTypeRes = "res"
|
||||
FrameTypeEvent = "event"
|
||||
)
|
||||
|
||||
// Events
|
||||
const (
|
||||
EventConnectChallenge = "connect.challenge"
|
||||
EventNodeInvokeReq = "node.invoke.request"
|
||||
)
|
||||
|
||||
// Methods
|
||||
const (
|
||||
MethodConnect = "connect"
|
||||
MethodNodeEvent = "node.event"
|
||||
MethodNodeInvokeRes = "node.invoke.result"
|
||||
)
|
||||
|
||||
// ConnectParams for node role
|
||||
type ConnectParams struct {
|
||||
MinProtocol int `json:"minProtocol"`
|
||||
MaxProtocol int `json:"maxProtocol"`
|
||||
Client ClientInfo `json:"client"`
|
||||
Role string `json:"role"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
Caps []string `json:"caps,omitempty"`
|
||||
Commands []string `json:"commands,omitempty"`
|
||||
Permissions map[string]bool `json:"permissions,omitempty"`
|
||||
Auth *AuthParams `json:"auth,omitempty"`
|
||||
Device *DeviceAuth `json:"device,omitempty"`
|
||||
Locale string `json:"locale"`
|
||||
UserAgent string `json:"userAgent,omitempty"`
|
||||
}
|
||||
|
||||
type ClientInfo struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName *string `json:"displayName,omitempty"`
|
||||
Version string `json:"version"`
|
||||
Platform string `json:"platform"`
|
||||
Mode string `json:"mode"`
|
||||
InstanceID *string `json:"instanceId,omitempty"`
|
||||
DeviceFamily *string `json:"deviceFamily,omitempty"`
|
||||
ModelIdentifier *string `json:"modelIdentifier,omitempty"`
|
||||
}
|
||||
|
||||
type AuthParams struct {
|
||||
Token string `json:"token,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
}
|
||||
|
||||
type DeviceAuth struct {
|
||||
ID string `json:"id"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
Signature string `json:"signature"`
|
||||
SignedAt int64 `json:"signedAt"`
|
||||
Nonce string `json:"nonce"`
|
||||
}
|
||||
|
||||
type RequestFrame struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type ResponseFrame struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
OK bool `json:"ok"`
|
||||
Payload json.RawMessage `json:"payload,omitempty"`
|
||||
Error *ErrorShape `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type EventFrame struct {
|
||||
Type string `json:"type"`
|
||||
Event string `json:"event"`
|
||||
Payload json.RawMessage `json:"payload,omitempty"`
|
||||
Seq *int64 `json:"seq,omitempty"`
|
||||
}
|
||||
|
||||
type ErrorShape struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
303
cmd/picoclaw-node/gateway/session.go
Normal file
303
cmd/picoclaw-node/gateway/session.go
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
crand "crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/openclaw/go_node/infra"
|
||||
)
|
||||
|
||||
// InvokeRequest is the payload of node.invoke.request event
|
||||
type InvokeRequest struct {
|
||||
ID string `json:"id"`
|
||||
NodeID string `json:"nodeId"`
|
||||
Command string `json:"command"`
|
||||
ParamsJSON string `json:"paramsJSON,omitempty"`
|
||||
TimeoutMs *int64 `json:"timeoutMs,omitempty"`
|
||||
}
|
||||
|
||||
// InvokeResult is sent via node.invoke.result
|
||||
type InvokeResult struct {
|
||||
OK bool `json:"ok"`
|
||||
Payload interface{} `json:"payload,omitempty"`
|
||||
PayloadJSON string `json:"payloadJSON,omitempty"`
|
||||
Error *ErrorShape `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// OnInvoke is called when node.invoke.request is received
|
||||
type OnInvoke func(req InvokeRequest) InvokeResult
|
||||
|
||||
// Session connects to the gateway as a node role and handles node.invoke.request
|
||||
type Session struct {
|
||||
url string
|
||||
token string
|
||||
password string
|
||||
opts ConnectOptions
|
||||
identity *infra.DeviceIdentity
|
||||
onInvoke OnInvoke
|
||||
conn *websocket.Conn
|
||||
writeMu sync.Mutex
|
||||
pending map[string]chan json.RawMessage
|
||||
pendingMu sync.Mutex
|
||||
connectNonce string
|
||||
}
|
||||
|
||||
// ConnectOptions for node role
|
||||
type ConnectOptions struct {
|
||||
Client ClientInfo
|
||||
Role string // "node"
|
||||
Scopes []string
|
||||
Caps []string
|
||||
Commands []string
|
||||
Locale string
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
// NewSession creates a new node session
|
||||
func NewSession(url, token, password string, opts ConnectOptions, identity *infra.DeviceIdentity, onInvoke OnInvoke) *Session {
|
||||
return &Session{
|
||||
url: url,
|
||||
token: token,
|
||||
password: password,
|
||||
opts: opts,
|
||||
identity: identity,
|
||||
onInvoke: onInvoke,
|
||||
pending: make(map[string]chan json.RawMessage),
|
||||
}
|
||||
}
|
||||
|
||||
// Run connects to the gateway and processes messages until ctx is done
|
||||
func (s *Session) Run(ctx context.Context) error {
|
||||
headers := http.Header{}
|
||||
headers.Set("Origin", s.url)
|
||||
if s.url[:5] == "wss:" {
|
||||
headers.Set("Origin", "https"+s.url[3:])
|
||||
}
|
||||
conn, _, err := websocket.DefaultDialer.Dial(s.url, headers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
s.conn = conn
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
_, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
continue
|
||||
}
|
||||
typ, _ := raw["type"]
|
||||
var typStr string
|
||||
_ = json.Unmarshal(typ, &typStr)
|
||||
|
||||
switch typStr {
|
||||
case FrameTypeEvent:
|
||||
s.handleEvent(data)
|
||||
case FrameTypeRes:
|
||||
s.handleResponse(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) handleEvent(data []byte) {
|
||||
var evt EventFrame
|
||||
if err := json.Unmarshal(data, &evt); err != nil {
|
||||
return
|
||||
}
|
||||
if evt.Event == EventConnectChallenge {
|
||||
var payload struct {
|
||||
Nonce string `json:"nonce"`
|
||||
}
|
||||
_ = json.Unmarshal(evt.Payload, &payload)
|
||||
s.connectNonce = payload.Nonce
|
||||
if s.connectNonce != "" {
|
||||
s.sendConnect()
|
||||
}
|
||||
return
|
||||
}
|
||||
if evt.Event == EventNodeInvokeReq && s.onInvoke != nil {
|
||||
var req struct {
|
||||
ID string `json:"id"`
|
||||
NodeID string `json:"nodeId"`
|
||||
Command string `json:"command"`
|
||||
ParamsJSON string `json:"paramsJSON"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
TimeoutMs *int64 `json:"timeoutMs"`
|
||||
}
|
||||
_ = json.Unmarshal(evt.Payload, &req)
|
||||
paramsJSON := req.ParamsJSON
|
||||
if paramsJSON == "" && len(req.Params) > 0 {
|
||||
paramsJSON = string(req.Params)
|
||||
}
|
||||
invReq := InvokeRequest{
|
||||
ID: req.ID,
|
||||
NodeID: req.NodeID,
|
||||
Command: req.Command,
|
||||
ParamsJSON: paramsJSON,
|
||||
TimeoutMs: req.TimeoutMs,
|
||||
}
|
||||
result := s.onInvoke(invReq)
|
||||
s.sendInvokeResult(invReq.ID, invReq.NodeID, result)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) handleResponse(data []byte) {
|
||||
var res struct {
|
||||
ID json.RawMessage `json:"id"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &res)
|
||||
var idStr string
|
||||
if err := json.Unmarshal(res.ID, &idStr); err != nil {
|
||||
return
|
||||
}
|
||||
s.pendingMu.Lock()
|
||||
ch := s.pending[idStr]
|
||||
delete(s.pending, idStr)
|
||||
s.pendingMu.Unlock()
|
||||
if ch != nil {
|
||||
select {
|
||||
case ch <- data:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) sendConnect() {
|
||||
auth := &AuthParams{}
|
||||
if s.token != "" {
|
||||
auth.Token = s.token
|
||||
} else if s.password != "" {
|
||||
auth.Password = s.password
|
||||
}
|
||||
|
||||
device := (*DeviceAuth)(nil)
|
||||
if s.connectNonce != "" && s.identity != nil {
|
||||
signedAtMs := time.Now().UnixMilli()
|
||||
payload := infra.BuildDeviceAuthPayload(struct {
|
||||
DeviceID string
|
||||
ClientID string
|
||||
ClientMode string
|
||||
Role string
|
||||
Scopes []string
|
||||
SignedAtMs int64
|
||||
Token string
|
||||
Nonce string
|
||||
}{
|
||||
s.identity.DeviceID,
|
||||
s.opts.Client.ID,
|
||||
s.opts.Client.Mode,
|
||||
s.opts.Role,
|
||||
s.opts.Scopes,
|
||||
signedAtMs,
|
||||
s.token,
|
||||
s.connectNonce,
|
||||
})
|
||||
sig := infra.SignDevicePayload(s.identity, payload)
|
||||
device = &DeviceAuth{
|
||||
ID: s.identity.DeviceID,
|
||||
PublicKey: infra.PublicKeyBase64URL(s.identity),
|
||||
Signature: sig,
|
||||
SignedAt: signedAtMs,
|
||||
Nonce: s.connectNonce,
|
||||
}
|
||||
}
|
||||
|
||||
params := ConnectParams{
|
||||
MinProtocol: ProtocolVersion,
|
||||
MaxProtocol: ProtocolVersion,
|
||||
Client: s.opts.Client,
|
||||
Role: s.opts.Role,
|
||||
Scopes: s.opts.Scopes,
|
||||
Caps: s.opts.Caps,
|
||||
Commands: s.opts.Commands,
|
||||
Auth: auth,
|
||||
Device: device,
|
||||
Locale: s.opts.Locale,
|
||||
UserAgent: s.opts.UserAgent,
|
||||
}
|
||||
paramsB, _ := json.Marshal(params)
|
||||
req := map[string]interface{}{
|
||||
"type": FrameTypeReq,
|
||||
"id": genID(),
|
||||
"method": MethodConnect,
|
||||
"params": json.RawMessage(paramsB),
|
||||
}
|
||||
_ = s.request(req, 15*time.Second)
|
||||
log.Printf("go_node: connect sent")
|
||||
}
|
||||
|
||||
func (s *Session) sendInvokeResult(id, nodeID string, result InvokeResult) {
|
||||
params := map[string]interface{}{
|
||||
"id": id,
|
||||
"nodeId": nodeID,
|
||||
"ok": result.OK,
|
||||
}
|
||||
if result.Payload != nil {
|
||||
params["payload"] = result.Payload
|
||||
}
|
||||
if result.PayloadJSON != "" {
|
||||
params["payloadJSON"] = result.PayloadJSON
|
||||
}
|
||||
if result.Error != nil {
|
||||
params["error"] = result.Error
|
||||
}
|
||||
req := map[string]interface{}{
|
||||
"type": FrameTypeReq,
|
||||
"id": genID(),
|
||||
"method": MethodNodeInvokeRes,
|
||||
"params": params,
|
||||
}
|
||||
if err := s.request(req, 15*time.Second); err != nil {
|
||||
log.Printf("go_node: node.invoke.result failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) request(req map[string]interface{}, timeout time.Duration) error {
|
||||
id, _ := req["id"].(string)
|
||||
ch := make(chan json.RawMessage, 1)
|
||||
s.pendingMu.Lock()
|
||||
s.pending[id] = ch
|
||||
s.pendingMu.Unlock()
|
||||
defer func() {
|
||||
s.pendingMu.Lock()
|
||||
delete(s.pending, id)
|
||||
s.pendingMu.Unlock()
|
||||
}()
|
||||
|
||||
s.writeMu.Lock()
|
||||
err := s.conn.WriteJSON(req)
|
||||
s.writeMu.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case <-time.After(timeout):
|
||||
return nil
|
||||
case <-ch:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func genID() string {
|
||||
b := make([]byte, 8)
|
||||
crand.Read(b)
|
||||
return "go-node-" + hex.EncodeToString(b)
|
||||
}
|
||||
7
cmd/picoclaw-node/go.mod
Normal file
7
cmd/picoclaw-node/go.mod
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
module github.com/openclaw/go_node
|
||||
|
||||
go 1.21
|
||||
|
||||
require github.com/gorilla/websocket v1.5.1
|
||||
|
||||
require golang.org/x/net v0.17.0 // indirect
|
||||
4
cmd/picoclaw-node/go.sum
Normal file
4
cmd/picoclaw-node/go.sum
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
|
||||
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
||||
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
35
cmd/picoclaw-node/infra/auth.go
Normal file
35
cmd/picoclaw-node/infra/auth.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BuildDeviceAuthPayload builds the string that gets signed for connect device auth
|
||||
// Format: v2|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce
|
||||
func BuildDeviceAuthPayload(params struct {
|
||||
DeviceID string
|
||||
ClientID string
|
||||
ClientMode string
|
||||
Role string
|
||||
Scopes []string
|
||||
SignedAtMs int64
|
||||
Token string
|
||||
Nonce string
|
||||
}) string {
|
||||
scopes := strings.Join(params.Scopes, ",")
|
||||
if params.Token == "" && params.Nonce == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.Join([]string{
|
||||
"v2",
|
||||
params.DeviceID,
|
||||
params.ClientID,
|
||||
params.ClientMode,
|
||||
params.Role,
|
||||
scopes,
|
||||
fmt.Sprintf("%d", params.SignedAtMs),
|
||||
params.Token,
|
||||
params.Nonce,
|
||||
}, "|")
|
||||
}
|
||||
57
cmd/picoclaw-node/infra/device_identity.go
Normal file
57
cmd/picoclaw-node/infra/device_identity.go
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
package infra
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
)
|
||||
|
||||
// DeviceIdentity holds Ed25519 keypair and derived device ID
|
||||
type DeviceIdentity struct {
|
||||
DeviceID string
|
||||
PublicKeyRaw []byte // raw 32-byte Ed25519 public key
|
||||
PrivateKey ed25519.PrivateKey
|
||||
}
|
||||
|
||||
// GenerateDeviceIdentity generates a new device identity (no file I/O; suitable for embedded)
|
||||
func GenerateDeviceIdentity() (*DeviceIdentity, error) {
|
||||
pub, priv, err := ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &DeviceIdentity{
|
||||
DeviceID: fingerprintPublicKey(pub),
|
||||
PublicKeyRaw: pub,
|
||||
PrivateKey: priv,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func fingerprintPublicKey(pub ed25519.PublicKey) string {
|
||||
h := sha256.Sum256(pub)
|
||||
return encodeHex(h[:])
|
||||
}
|
||||
|
||||
func encodeHex(b []byte) string {
|
||||
const hex = "0123456789abcdef"
|
||||
out := make([]byte, len(b)*2)
|
||||
for i, v := range b {
|
||||
out[i*2] = hex[v>>4]
|
||||
out[i*2+1] = hex[v&0xf]
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// SignDevicePayload signs the auth payload string with Ed25519, returns base64url signature
|
||||
func SignDevicePayload(ident *DeviceIdentity, payload string) string {
|
||||
sig := ed25519.Sign(ident.PrivateKey, []byte(payload))
|
||||
return base64URLEncode(sig)
|
||||
}
|
||||
|
||||
// PublicKeyBase64URL returns raw public key as base64url
|
||||
func PublicKeyBase64URL(ident *DeviceIdentity) string {
|
||||
return base64URLEncode(ident.PublicKeyRaw)
|
||||
}
|
||||
|
||||
func base64URLEncode(b []byte) string {
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
153
cmd/picoclaw-node/main.go
Normal file
153
cmd/picoclaw-node/main.go
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/openclaw/go_node/config"
|
||||
"github.com/openclaw/go_node/gateway"
|
||||
"github.com/openclaw/go_node/infra"
|
||||
"github.com/openclaw/go_node/node"
|
||||
)
|
||||
|
||||
const version = "0.1.0"
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("config", "", "path to config.json (default: config.json, ~/.openclaw/go_node.json)")
|
||||
initConfig := flag.Bool("init-config", false, "write example config.json and exit")
|
||||
flag.Parse()
|
||||
|
||||
if *initConfig {
|
||||
path := strings.TrimSpace(*configPath)
|
||||
if path == "" {
|
||||
path = "config.json"
|
||||
}
|
||||
if err := config.Example(path); err != nil {
|
||||
log.Fatalf("init config: %v", err)
|
||||
}
|
||||
log.Printf("wrote example config to %s", path)
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := config.Load(strings.TrimSpace(*configPath))
|
||||
if err != nil {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
|
||||
identity, err := identityFromConfig(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("load device identity: %v", err)
|
||||
}
|
||||
|
||||
invokeDispatcher := node.NewInvokeDispatcher(cfg.Exec)
|
||||
opts := gateway.ConnectOptions{
|
||||
Client: gateway.ClientInfo{
|
||||
ID: "node-host",
|
||||
Version: version,
|
||||
Platform: runtime.GOOS,
|
||||
Mode: "node",
|
||||
},
|
||||
Role: "node",
|
||||
Scopes: []string{},
|
||||
Caps: []string{"system", "file", "camera"},
|
||||
Commands: []string{"system.run", "file.save", "camera.snap"},
|
||||
Locale: "en-US",
|
||||
UserAgent: "OpenClawGoNode/" + version + " (" + runtime.GOOS + "; " + runtime.Version() + ")",
|
||||
}
|
||||
if cfg.Node.DisplayName != "" {
|
||||
opts.Client.DisplayName = &cfg.Node.DisplayName
|
||||
}
|
||||
if cfg.Node.NodeID != "" {
|
||||
opts.Client.InstanceID = &cfg.Node.NodeID
|
||||
}
|
||||
|
||||
onInvoke := func(req gateway.InvokeRequest) gateway.InvokeResult {
|
||||
return invokeDispatcher.HandleInvoke(req)
|
||||
}
|
||||
|
||||
sess := gateway.NewSession(
|
||||
cfg.WebSocketURL(),
|
||||
cfg.Gateway.Token,
|
||||
cfg.Gateway.Password,
|
||||
opts,
|
||||
identity,
|
||||
onInvoke,
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, os.Interrupt)
|
||||
go func() {
|
||||
<-sigCh
|
||||
log.Println("shutting down...")
|
||||
cancel()
|
||||
}()
|
||||
|
||||
interval := time.Duration(cfg.Reconnect.RetryIntervalMs) * time.Millisecond
|
||||
if interval <= 0 {
|
||||
interval = 5 * time.Second
|
||||
}
|
||||
var attempt int
|
||||
reconnect:
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
break reconnect
|
||||
}
|
||||
if attempt > 0 {
|
||||
log.Printf("go_node: reconnect attempt %d", attempt)
|
||||
} else {
|
||||
log.Printf("go_node: connecting to %s as node %q", cfg.WebSocketURL(), cfg.Node.DisplayName)
|
||||
}
|
||||
err := sess.Run(ctx)
|
||||
if ctx.Err() != nil {
|
||||
break reconnect
|
||||
}
|
||||
attempt++
|
||||
if cfg.Reconnect.MaxRetries > 0 && attempt > cfg.Reconnect.MaxRetries {
|
||||
log.Fatalf("go_node: max retries (%d) reached, last error: %v", cfg.Reconnect.MaxRetries, err)
|
||||
}
|
||||
log.Printf("go_node: connection lost: %v, retrying in %v", err, interval)
|
||||
select {
|
||||
case <-time.After(interval):
|
||||
continue
|
||||
case <-ctx.Done():
|
||||
break reconnect
|
||||
}
|
||||
}
|
||||
log.Println("go_node: exited")
|
||||
}
|
||||
|
||||
// identityFromConfig builds a DeviceIdentity from cfg.Identity.
|
||||
// If identity information is missing or invalid, it falls back to generating
|
||||
// a fresh in-memory identity (not persisted).
|
||||
func identityFromConfig(cfg *config.Config) (*infra.DeviceIdentity, error) {
|
||||
idCfg := cfg.Identity
|
||||
if idCfg.DeviceID == "" || idCfg.PublicKeyB64 == "" || idCfg.PrivateKeyB64 == "" {
|
||||
return infra.GenerateDeviceIdentity()
|
||||
}
|
||||
|
||||
pubRaw, err := base64.StdEncoding.DecodeString(idCfg.PublicKeyB64)
|
||||
if err != nil || len(pubRaw) != ed25519.PublicKeySize {
|
||||
return infra.GenerateDeviceIdentity()
|
||||
}
|
||||
privRaw, err := base64.StdEncoding.DecodeString(idCfg.PrivateKeyB64)
|
||||
if err != nil || len(privRaw) != ed25519.PrivateKeySize {
|
||||
return infra.GenerateDeviceIdentity()
|
||||
}
|
||||
|
||||
return &infra.DeviceIdentity{
|
||||
DeviceID: idCfg.DeviceID,
|
||||
PublicKeyRaw: pubRaw,
|
||||
PrivateKey: ed25519.PrivateKey(privRaw),
|
||||
}, nil
|
||||
}
|
||||
85
cmd/picoclaw-node/node/handlers/camera_snap.go
Normal file
85
cmd/picoclaw-node/node/handlers/camera_snap.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/openclaw/go_node/config"
|
||||
"github.com/openclaw/go_node/gateway"
|
||||
)
|
||||
|
||||
// CameraSnapHandler handles camera.snap command: mock by reading a fixed image file and returning base64
|
||||
type CameraSnapHandler struct {
|
||||
cfg config.ExecConfig
|
||||
}
|
||||
|
||||
// NewCameraSnapHandler creates a mock handler that reads from a fixed path
|
||||
func NewCameraSnapHandler(cfg config.ExecConfig) *CameraSnapHandler {
|
||||
return &CameraSnapHandler{cfg: cfg}
|
||||
}
|
||||
|
||||
// Handle reads the mock image from fixed path, base64-encodes, and returns camera.snap payload
|
||||
func (h *CameraSnapHandler) Handle(req gateway.InvokeRequest) gateway.InvokeResult {
|
||||
path := h.mockPath()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return gateway.InvokeResult{
|
||||
OK: false,
|
||||
Error: &gateway.ErrorShape{
|
||||
Code: "UNAVAILABLE",
|
||||
Message: fmt.Sprintf("camera.snap mock: read file %s: %v", path, err),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
width, height := 0, 0
|
||||
if cfg, _, err := image.DecodeConfig(strings.NewReader(string(data))); err == nil {
|
||||
width, height = cfg.Width, cfg.Height
|
||||
}
|
||||
|
||||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(path), "."))
|
||||
if ext == "" {
|
||||
ext = "jpg"
|
||||
}
|
||||
if ext == "jpeg" {
|
||||
ext = "jpg"
|
||||
}
|
||||
|
||||
b64 := base64.StdEncoding.EncodeToString(data)
|
||||
log.Printf("go_node: camera.snap mock path=%s bytes=%d width=%d height=%d", path, len(data), width, height)
|
||||
|
||||
out := map[string]any{
|
||||
"format": ext,
|
||||
"base64": b64,
|
||||
"width": width,
|
||||
"height": height,
|
||||
}
|
||||
payload, _ := json.Marshal(out)
|
||||
return gateway.InvokeResult{
|
||||
OK: true,
|
||||
PayloadJSON: string(payload),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *CameraSnapHandler) mockPath() string {
|
||||
if p := strings.TrimSpace(h.cfg.CameraMockPath); p != "" {
|
||||
return p
|
||||
}
|
||||
workDir := strings.TrimSpace(h.cfg.WorkDir)
|
||||
if workDir == "" {
|
||||
workDir, _ = os.Getwd()
|
||||
}
|
||||
if workDir == "" {
|
||||
return "camera_mock.jpg"
|
||||
}
|
||||
return filepath.Join(workDir, "camera_mock.jpg")
|
||||
}
|
||||
133
cmd/picoclaw-node/node/handlers/file_save.go
Normal file
133
cmd/picoclaw-node/node/handlers/file_save.go
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/openclaw/go_node/config"
|
||||
"github.com/openclaw/go_node/gateway"
|
||||
)
|
||||
|
||||
// FileSaveParams holds params for file.save (from picoclaw nodes tool)
|
||||
type FileSaveParams struct {
|
||||
Base64 string `json:"base64"`
|
||||
Format string `json:"format"`
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
|
||||
// FileSaveHandler handles file.save command: save base64-encoded image to local disk
|
||||
type FileSaveHandler struct {
|
||||
cfg config.ExecConfig
|
||||
}
|
||||
|
||||
// NewFileSaveHandler creates a handler that saves files under exec.workDir/saved
|
||||
func NewFileSaveHandler(cfg config.ExecConfig) *FileSaveHandler {
|
||||
return &FileSaveHandler{cfg: cfg}
|
||||
}
|
||||
|
||||
// Handle decodes base64 image and saves to local directory
|
||||
func (h *FileSaveHandler) Handle(req gateway.InvokeRequest) gateway.InvokeResult {
|
||||
var params FileSaveParams
|
||||
paramsJSON := strings.TrimSpace(req.ParamsJSON)
|
||||
if paramsJSON == "" {
|
||||
return gateway.InvokeResult{
|
||||
OK: false,
|
||||
Error: &gateway.ErrorShape{
|
||||
Code: "INVALID_REQUEST",
|
||||
Message: "paramsJSON required",
|
||||
},
|
||||
}
|
||||
}
|
||||
if err := json.Unmarshal([]byte(paramsJSON), ¶ms); err != nil {
|
||||
return gateway.InvokeResult{
|
||||
OK: false,
|
||||
Error: &gateway.ErrorShape{
|
||||
Code: "INVALID_REQUEST",
|
||||
Message: "invalid paramsJSON: " + err.Error(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if params.Base64 == "" {
|
||||
return gateway.InvokeResult{
|
||||
OK: false,
|
||||
Error: &gateway.ErrorShape{
|
||||
Code: "INVALID_REQUEST",
|
||||
Message: "base64 required",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
data, err := base64.StdEncoding.DecodeString(params.Base64)
|
||||
if err != nil {
|
||||
return gateway.InvokeResult{
|
||||
OK: false,
|
||||
Error: &gateway.ErrorShape{
|
||||
Code: "INVALID_REQUEST",
|
||||
Message: "base64 decode failed: " + err.Error(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
filename := strings.TrimSpace(params.Filename)
|
||||
if filename == "" {
|
||||
ext := strings.TrimSpace(strings.ToLower(params.Format))
|
||||
if ext == "" {
|
||||
ext = "bin"
|
||||
}
|
||||
filename = "file." + ext
|
||||
}
|
||||
|
||||
// Sanitize filename: remove path traversal and dangerous chars
|
||||
filename = filepath.Base(filename)
|
||||
if filename == "" || filename == "." {
|
||||
filename = "file.bin"
|
||||
}
|
||||
|
||||
saveDir := h.saveDir()
|
||||
if err := os.MkdirAll(saveDir, 0o755); err != nil {
|
||||
return gateway.InvokeResult{
|
||||
OK: false,
|
||||
Error: &gateway.ErrorShape{
|
||||
Code: "UNAVAILABLE",
|
||||
Message: "mkdir failed: " + err.Error(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
outPath := filepath.Join(saveDir, filename)
|
||||
if err := os.WriteFile(outPath, data, 0o644); err != nil {
|
||||
return gateway.InvokeResult{
|
||||
OK: false,
|
||||
Error: &gateway.ErrorShape{
|
||||
Code: "UNAVAILABLE",
|
||||
Message: "write file failed: " + err.Error(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
absPath, _ := filepath.Abs(outPath)
|
||||
log.Printf("go_node: file.save path=%s bytes=%d", absPath, len(data))
|
||||
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"path": absPath,
|
||||
"bytes": len(data),
|
||||
"format": params.Format,
|
||||
})
|
||||
return gateway.InvokeResult{
|
||||
OK: true,
|
||||
PayloadJSON: string(payload),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *FileSaveHandler) saveDir() string {
|
||||
workDir := strings.TrimSpace(h.cfg.WorkDir)
|
||||
if workDir == "" {
|
||||
return filepath.Join(os.TempDir(), "go_node_saved")
|
||||
}
|
||||
return filepath.Join(workDir, "saved")
|
||||
}
|
||||
8
cmd/picoclaw-node/node/handlers/handler.go
Normal file
8
cmd/picoclaw-node/node/handlers/handler.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package handlers
|
||||
|
||||
import "github.com/openclaw/go_node/gateway"
|
||||
|
||||
// Handler handles a specific invoke command
|
||||
type Handler interface {
|
||||
Handle(req gateway.InvokeRequest) gateway.InvokeResult
|
||||
}
|
||||
360
cmd/picoclaw-node/node/handlers/system_run.go
Normal file
360
cmd/picoclaw-node/node/handlers/system_run.go
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/openclaw/go_node/config"
|
||||
"github.com/openclaw/go_node/gateway"
|
||||
)
|
||||
|
||||
const outputCap = 200_000 // max combined stdout+stderr bytes
|
||||
|
||||
// SystemRunParams matches openclaw node-host invoke-types SystemRunParams
|
||||
type SystemRunParams struct {
|
||||
Command []string `json:"command"`
|
||||
RawCommand *string `json:"rawCommand,omitempty"`
|
||||
CWD *string `json:"cwd,omitempty"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
TimeoutMs *int64 `json:"timeoutMs,omitempty"`
|
||||
}
|
||||
|
||||
// RunResult matches openclaw invoke-types RunResult
|
||||
type RunResult struct {
|
||||
ExitCode *int `json:"exitCode,omitempty"`
|
||||
TimedOut bool `json:"timedOut"`
|
||||
Success bool `json:"success"`
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
|
||||
// SystemRunHandler handles system.run command
|
||||
type SystemRunHandler struct {
|
||||
cfg config.ExecConfig
|
||||
}
|
||||
|
||||
// NewSystemRunHandler creates a handler with exec security config
|
||||
func NewSystemRunHandler(cfg config.ExecConfig) *SystemRunHandler {
|
||||
return &SystemRunHandler{cfg: cfg}
|
||||
}
|
||||
|
||||
// Handle executes shell command and returns result
|
||||
func (h *SystemRunHandler) Handle(req gateway.InvokeRequest) gateway.InvokeResult {
|
||||
var params SystemRunParams
|
||||
paramsJSON := strings.TrimSpace(req.ParamsJSON)
|
||||
if paramsJSON == "" {
|
||||
return gateway.InvokeResult{
|
||||
OK: false,
|
||||
Error: &gateway.ErrorShape{
|
||||
Code: "INVALID_REQUEST",
|
||||
Message: "paramsJSON required",
|
||||
},
|
||||
}
|
||||
}
|
||||
if err := json.Unmarshal([]byte(paramsJSON), ¶ms); err != nil {
|
||||
return gateway.InvokeResult{
|
||||
OK: false,
|
||||
Error: &gateway.ErrorShape{
|
||||
Code: "INVALID_REQUEST",
|
||||
Message: "invalid paramsJSON: " + err.Error(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
argv := params.Command
|
||||
if len(argv) == 0 && params.RawCommand != nil {
|
||||
if !h.cfg.AllowAllCommands && len(h.cfg.AllowedCommands) > 0 {
|
||||
return gateway.InvokeResult{
|
||||
OK: false,
|
||||
Error: &gateway.ErrorShape{
|
||||
Code: "RAW_COMMAND_NOT_ALLOWED",
|
||||
Message: "RAW_COMMAND_NOT_ALLOWED: rawCommand disabled when allowedCommands is set (unless allowAllCommands=true)",
|
||||
},
|
||||
}
|
||||
}
|
||||
raw := strings.TrimSpace(*params.RawCommand)
|
||||
if raw == "" {
|
||||
return gateway.InvokeResult{
|
||||
OK: false,
|
||||
Error: &gateway.ErrorShape{
|
||||
Code: "INVALID_REQUEST",
|
||||
Message: "command required",
|
||||
},
|
||||
}
|
||||
}
|
||||
shell, args := shellExec()
|
||||
argv = append([]string{shell}, append(args, raw)...)
|
||||
}
|
||||
if len(argv) == 0 {
|
||||
return gateway.InvokeResult{
|
||||
OK: false,
|
||||
Error: &gateway.ErrorShape{
|
||||
Code: "INVALID_REQUEST",
|
||||
Message: "command required",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if errRes := h.checkAllowed(argv[0]); errRes != nil {
|
||||
return *errRes
|
||||
}
|
||||
|
||||
cwd, errRes := h.resolveCWD(params.CWD)
|
||||
if errRes != nil {
|
||||
return *errRes
|
||||
}
|
||||
|
||||
log.Printf("go_node: system.run argv=%v cwd=%s", argv, cwd)
|
||||
|
||||
timeoutMs := int64(60_000)
|
||||
if params.TimeoutMs != nil && *params.TimeoutMs > 0 {
|
||||
timeoutMs = *params.TimeoutMs
|
||||
}
|
||||
|
||||
result := runCommand(argv, &cwd, params.Env, timeoutMs)
|
||||
payload, _ := json.Marshal(result)
|
||||
return gateway.InvokeResult{
|
||||
OK: result.Success,
|
||||
PayloadJSON: string(payload),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SystemRunHandler) checkAllowed(cmd string) *gateway.InvokeResult {
|
||||
if h.cfg.AllowAllCommands || len(h.cfg.AllowedCommands) == 0 {
|
||||
return nil // ok, allow all
|
||||
}
|
||||
name := filepath.Base(strings.TrimSpace(cmd))
|
||||
for _, allowed := range h.cfg.AllowedCommands {
|
||||
if name == strings.TrimSpace(allowed) {
|
||||
return nil // ok
|
||||
}
|
||||
}
|
||||
return &gateway.InvokeResult{
|
||||
OK: false,
|
||||
Error: &gateway.ErrorShape{
|
||||
Code: "COMMAND_NOT_ALLOWED",
|
||||
Message: "COMMAND_NOT_ALLOWED: " + name + " is not in allowedCommands",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SystemRunHandler) resolveCWD(paramCWD *string) (string, *gateway.InvokeResult) {
|
||||
workDir := strings.TrimSpace(h.cfg.WorkDir)
|
||||
if workDir == "" {
|
||||
if paramCWD != nil && strings.TrimSpace(*paramCWD) != "" {
|
||||
abs, err := filepath.Abs(*paramCWD)
|
||||
if err != nil {
|
||||
return "", &gateway.InvokeResult{
|
||||
OK: false,
|
||||
Error: &gateway.ErrorShape{
|
||||
Code: "INVALID_REQUEST",
|
||||
Message: "invalid cwd: " + err.Error(),
|
||||
},
|
||||
}
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
wd, _ := os.Getwd()
|
||||
return wd, nil
|
||||
}
|
||||
if paramCWD == nil || strings.TrimSpace(*paramCWD) == "" {
|
||||
if fi, err := os.Stat(workDir); err != nil || !fi.IsDir() {
|
||||
return "", &gateway.InvokeResult{
|
||||
OK: false,
|
||||
Error: &gateway.ErrorShape{
|
||||
Code: "WORKDIR_INVALID",
|
||||
Message: "WORKDIR_INVALID: exec.workDir must be an existing directory: " + workDir,
|
||||
},
|
||||
}
|
||||
}
|
||||
return workDir, nil
|
||||
}
|
||||
if fi, err := os.Stat(workDir); err != nil || !fi.IsDir() {
|
||||
return "", &gateway.InvokeResult{
|
||||
OK: false,
|
||||
Error: &gateway.ErrorShape{
|
||||
Code: "WORKDIR_INVALID",
|
||||
Message: "WORKDIR_INVALID: exec.workDir must be an existing directory: " + workDir,
|
||||
},
|
||||
}
|
||||
}
|
||||
req := strings.TrimSpace(*paramCWD)
|
||||
var abs string
|
||||
if filepath.IsAbs(req) {
|
||||
abs = req
|
||||
} else {
|
||||
abs = filepath.Join(workDir, req)
|
||||
}
|
||||
abs, err := filepath.Abs(abs)
|
||||
if err != nil {
|
||||
return "", &gateway.InvokeResult{
|
||||
OK: false,
|
||||
Error: &gateway.ErrorShape{
|
||||
Code: "INVALID_REQUEST",
|
||||
Message: "invalid cwd: " + err.Error(),
|
||||
},
|
||||
}
|
||||
}
|
||||
rel, err := filepath.Rel(workDir, abs)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
return "", &gateway.InvokeResult{
|
||||
OK: false,
|
||||
Error: &gateway.ErrorShape{
|
||||
Code: "CWD_OUTSIDE_WORKDIR",
|
||||
Message: "CWD_OUTSIDE_WORKDIR: cwd must be under workDir " + workDir,
|
||||
},
|
||||
}
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
func shellExec() (shell string, args []string) {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "cmd.exe", []string{"/c"}
|
||||
}
|
||||
return "/bin/sh", []string{"-c"}
|
||||
}
|
||||
|
||||
func runCommand(argv []string, cwd *string, env map[string]string, timeoutMs int64) RunResult {
|
||||
cmd := exec.Command(argv[0], argv[1:]...)
|
||||
if cwd != nil && *cwd != "" {
|
||||
cmd.Dir = *cwd
|
||||
}
|
||||
if len(env) > 0 {
|
||||
cmd.Env = envToSlice(env)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
stdoutCap := &capWriter{w: &stdout, max: outputCap}
|
||||
stderrCap := &capWriter{w: &stderr, max: outputCap}
|
||||
cmd.Stdout = stdoutCap
|
||||
cmd.Stderr = stderrCap
|
||||
|
||||
err := cmd.Start()
|
||||
if err != nil {
|
||||
return RunResult{
|
||||
Success: false,
|
||||
Stdout: stdout.String(),
|
||||
Stderr: stderr.String(),
|
||||
Error: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
timeout := time.Duration(timeoutMs) * time.Millisecond
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
|
||||
select {
|
||||
case waitErr := <-done:
|
||||
if waitErr != nil {
|
||||
if exitErr, ok := waitErr.(*exec.ExitError); ok {
|
||||
code := exitErr.ExitCode()
|
||||
return RunResult{
|
||||
ExitCode: &code,
|
||||
TimedOut: false,
|
||||
Success: false,
|
||||
Stdout: truncateOutput(stdout.String(), outputCap),
|
||||
Stderr: truncateOutput(stderr.String(), outputCap),
|
||||
Truncated: stdoutCap.truncated || stderrCap.truncated,
|
||||
}
|
||||
}
|
||||
return RunResult{
|
||||
Success: false,
|
||||
Stdout: truncateOutput(stdout.String(), outputCap),
|
||||
Stderr: truncateOutput(stderr.String(), outputCap),
|
||||
Error: waitErr.Error(),
|
||||
Truncated: stdoutCap.truncated || stderrCap.truncated,
|
||||
}
|
||||
}
|
||||
ec := 0
|
||||
return RunResult{
|
||||
ExitCode: &ec,
|
||||
TimedOut: false,
|
||||
Success: true,
|
||||
Stdout: truncateOutput(stdout.String(), outputCap),
|
||||
Stderr: truncateOutput(stderr.String(), outputCap),
|
||||
Truncated: stdoutCap.truncated || stderrCap.truncated,
|
||||
}
|
||||
case <-time.After(timeout):
|
||||
cmd.Process.Kill()
|
||||
<-done
|
||||
return RunResult{
|
||||
TimedOut: true,
|
||||
Success: false,
|
||||
Stdout: truncateOutput(stdout.String(), outputCap),
|
||||
Stderr: truncateOutput(stderr.String(), outputCap),
|
||||
Error: "command timeout",
|
||||
Truncated: stdoutCap.truncated || stderrCap.truncated,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func envToSlice(m map[string]string) []string {
|
||||
base := os.Environ()
|
||||
overrides := make(map[string]string)
|
||||
for k, v := range m {
|
||||
overrides[k] = v
|
||||
}
|
||||
if len(overrides) == 0 {
|
||||
return base
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
var out []string
|
||||
for _, s := range base {
|
||||
if idx := strings.IndexByte(s, '='); idx > 0 {
|
||||
key := s[:idx]
|
||||
if v, ok := overrides[key]; ok {
|
||||
out = append(out, key+"="+v)
|
||||
seen[key] = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
for k, v := range overrides {
|
||||
if !seen[k] {
|
||||
out = append(out, k+"="+v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func truncateOutput(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return "... (truncated) " + s[len(s)-max:]
|
||||
}
|
||||
|
||||
type capWriter struct {
|
||||
w *bytes.Buffer
|
||||
max int
|
||||
written int
|
||||
truncated bool
|
||||
}
|
||||
|
||||
func (c *capWriter) Write(p []byte) (n int, err error) {
|
||||
if c.written >= c.max {
|
||||
c.truncated = true
|
||||
return len(p), nil
|
||||
}
|
||||
rem := c.max - c.written
|
||||
if len(p) > rem {
|
||||
c.w.Write(p[:rem])
|
||||
c.written += rem
|
||||
c.truncated = true
|
||||
return len(p), nil
|
||||
}
|
||||
c.w.Write(p)
|
||||
c.written += len(p)
|
||||
return len(p), nil
|
||||
}
|
||||
46
cmd/picoclaw-node/node/invoke_dispatcher.go
Normal file
46
cmd/picoclaw-node/node/invoke_dispatcher.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package node
|
||||
|
||||
import (
|
||||
"github.com/openclaw/go_node/config"
|
||||
"github.com/openclaw/go_node/gateway"
|
||||
"github.com/openclaw/go_node/node/handlers"
|
||||
)
|
||||
|
||||
// InvokeDispatcher routes node.invoke.request to registered handlers
|
||||
type InvokeDispatcher struct {
|
||||
handlers map[string]handlers.Handler
|
||||
}
|
||||
|
||||
// NewInvokeDispatcher creates a dispatcher with default handlers using exec config
|
||||
func NewInvokeDispatcher(execCfg config.ExecConfig) *InvokeDispatcher {
|
||||
return &InvokeDispatcher{
|
||||
handlers: map[string]handlers.Handler{
|
||||
"system.run": handlers.NewSystemRunHandler(execCfg),
|
||||
"file.save": handlers.NewFileSaveHandler(execCfg),
|
||||
"camera.snap": handlers.NewCameraSnapHandler(execCfg),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Register adds or overrides a handler for command
|
||||
func (d *InvokeDispatcher) Register(command string, h handlers.Handler) {
|
||||
if d.handlers == nil {
|
||||
d.handlers = make(map[string]handlers.Handler)
|
||||
}
|
||||
d.handlers[command] = h
|
||||
}
|
||||
|
||||
// HandleInvoke dispatches to the handler for the command, or returns error if unknown
|
||||
func (d *InvokeDispatcher) HandleInvoke(req gateway.InvokeRequest) gateway.InvokeResult {
|
||||
h, ok := d.handlers[req.Command]
|
||||
if !ok {
|
||||
return gateway.InvokeResult{
|
||||
OK: false,
|
||||
Error: &gateway.ErrorShape{
|
||||
Code: "UNAVAILABLE",
|
||||
Message: "command not supported",
|
||||
},
|
||||
}
|
||||
}
|
||||
return h.Handle(req)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue