mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-09-11 00:22:56 -05:00
Add an MCP Streamable HTTP server with token authentication, screenshot capture, keyboard, and mouse tools. Introduce shared control-mode and input-control coordination so MCP, PicoClaw, and local HID paths serialize ownership safely. Integrate PicoClaw gateway/runtime control handoff with PID-managed startup and focused unit coverage.
358 lines
8.9 KiB
Go
358 lines
8.9 KiB
Go
package picoclaw
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"math"
|
|
"strings"
|
|
"time"
|
|
|
|
"NanoKVM-Server/service/hid"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
const (
|
|
defaultClickHold = 40 * time.Millisecond
|
|
defaultKeyDelay = 30 * time.Millisecond
|
|
defaultDragSteps = 10
|
|
defaultScrollStep = 20 * time.Millisecond
|
|
)
|
|
|
|
func (s *Service) Actions(c *gin.Context) {
|
|
releaseMode, modeErr := s.acquireControlMode()
|
|
if modeErr != nil {
|
|
writePicoclawError(c, modeErr)
|
|
return
|
|
}
|
|
defer releaseMode()
|
|
|
|
operationCtx, releaseOperation := s.beginControlOperation(c.Request.Context())
|
|
defer releaseOperation()
|
|
|
|
sessionID, sessionErr := s.requireSessionID(c)
|
|
if sessionErr != nil {
|
|
writePicoclawError(c, sessionErr)
|
|
return
|
|
}
|
|
|
|
releaseAfter, lockErr := s.lock.AcquireTemporary(sessionID)
|
|
if lockErr != nil {
|
|
writePicoclawError(c, lockErr)
|
|
return
|
|
}
|
|
if releaseAfter {
|
|
defer s.lock.Release(sessionID)
|
|
}
|
|
|
|
actions, actionErr := normalizeActions(c)
|
|
if actionErr != nil {
|
|
writePicoclawError(c, actionErr)
|
|
return
|
|
}
|
|
|
|
result, execErr := s.executeActions(operationCtx, sessionID, actions)
|
|
if execErr != nil {
|
|
writePicoclawError(c, execErr)
|
|
return
|
|
}
|
|
|
|
writeSuccess(c, result)
|
|
}
|
|
|
|
func normalizeActions(c *gin.Context) ([]Action, *PicoclawError) {
|
|
body := bytes.NewBuffer(nil)
|
|
if _, err := body.ReadFrom(c.Request.Body); err != nil {
|
|
return nil, newPicoclawError(CodeInvalidAction, "failed to read action payload")
|
|
}
|
|
|
|
raw := body.Bytes()
|
|
if len(raw) == 0 {
|
|
return nil, newPicoclawError(CodeInvalidAction, "empty action payload")
|
|
}
|
|
|
|
var batch ActionBatch
|
|
if err := json.Unmarshal(raw, &batch); err == nil && len(batch.Actions) > 0 {
|
|
return batch.Actions, nil
|
|
}
|
|
|
|
var action Action
|
|
if err := json.Unmarshal(raw, &action); err != nil || action.Action == "" {
|
|
return nil, newPicoclawError(CodeInvalidAction, "invalid action payload")
|
|
}
|
|
|
|
return []Action{action}, nil
|
|
}
|
|
|
|
func (s *Service) executeActions(ctx context.Context, sessionID string, actions []Action) (result ActionResult, err *PicoclawError) {
|
|
startedAt := time.Now()
|
|
if len(actions) == 0 {
|
|
return ActionResult{}, newPicoclawError(CodeInvalidAction, "empty actions")
|
|
}
|
|
|
|
defer func() {
|
|
if err != nil {
|
|
s.releaseAllHIDState()
|
|
}
|
|
}()
|
|
|
|
totalWrites := 0
|
|
for idx, action := range actions {
|
|
if contextErr := controlOperationError(ctx); contextErr != nil {
|
|
contextErr.Index = &idx
|
|
return ActionResult{}, contextErr
|
|
}
|
|
if lockErr := s.lock.Ensure(sessionID); lockErr != nil {
|
|
lockErr.Index = &idx
|
|
return ActionResult{}, lockErr
|
|
}
|
|
|
|
writes, execErr := s.executeAction(ctx, action)
|
|
if execErr != nil {
|
|
execErr.Index = &idx
|
|
return ActionResult{}, execErr
|
|
}
|
|
totalWrites += writes
|
|
}
|
|
|
|
result = ActionResult{
|
|
Action: actions[0].Action,
|
|
DurationMs: time.Since(startedAt).Milliseconds(),
|
|
HIDWrites: totalWrites,
|
|
ExecutedActions: len(actions),
|
|
}
|
|
if len(actions) > 1 {
|
|
result.Action = "batch"
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Service) executeAction(ctx context.Context, action Action) (int, *PicoclawError) {
|
|
if err := controlOperationError(ctx); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
switch strings.ToLower(strings.TrimSpace(action.Action)) {
|
|
case "click":
|
|
x, y, err := normalizedPoint(action.X, action.Y)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
button, err := mouseButton(action.Button)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
writes := 0
|
|
writes += s.sendMouseMoveWithButton(x, y, 0x00, 0)
|
|
writes += s.sendMousePress(x, y, button)
|
|
if waitErr := waitForControlOperation(ctx, defaultClickHold); waitErr != nil {
|
|
return writes, waitErr
|
|
}
|
|
writes += s.sendMouseRelease(x, y)
|
|
return writes, nil
|
|
|
|
case "move":
|
|
x, y, err := normalizedPoint(action.X, action.Y)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return s.sendMouseMoveWithButton(x, y, 0x00, 0), nil
|
|
|
|
case "wait":
|
|
if action.DurationMs < 0 {
|
|
return 0, newPicoclawError(CodeInvalidAction, "wait duration must be >= 0")
|
|
}
|
|
if action.DurationMs > maxWaitDurationMS {
|
|
return 0, newPicoclawError(CodeInvalidAction, "wait duration must be <= 30000 milliseconds")
|
|
}
|
|
if waitErr := waitForControlOperation(ctx, time.Duration(action.DurationMs)*time.Millisecond); waitErr != nil {
|
|
return 0, waitErr
|
|
}
|
|
return 0, nil
|
|
|
|
case "drag":
|
|
fromX, fromY, err := normalizedNestedPoint(action.From)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
toX, toY, err := normalizedNestedPoint(action.To)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
button, err := mouseButton(action.Button)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
writes := 0
|
|
writes += s.sendMouseMoveWithButton(fromX, fromY, 0x00, 0)
|
|
writes += s.sendMousePress(fromX, fromY, button)
|
|
for step := 1; step <= defaultDragSteps; step++ {
|
|
if contextErr := controlOperationError(ctx); contextErr != nil {
|
|
return writes, contextErr
|
|
}
|
|
ratio := float64(step) / float64(defaultDragSteps)
|
|
x := fromX + (toX-fromX)*ratio
|
|
y := fromY + (toY-fromY)*ratio
|
|
writes += s.sendMouseMoveWithButton(x, y, button, 0)
|
|
}
|
|
writes += s.sendMouseRelease(toX, toY)
|
|
return writes, nil
|
|
|
|
case "scroll":
|
|
x, y := 0.5, 0.5
|
|
if action.X != nil || action.Y != nil {
|
|
var err *PicoclawError
|
|
x, y, err = normalizedPoint(action.X, action.Y)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
|
|
amount := action.Amount
|
|
if amount == 0 {
|
|
amount = 1
|
|
}
|
|
if amount < 0 {
|
|
return 0, newPicoclawError(CodeInvalidAction, "scroll amount must be > 0")
|
|
}
|
|
|
|
wheel := 1
|
|
switch strings.ToLower(strings.TrimSpace(action.Direction)) {
|
|
case "", "up":
|
|
wheel = 1
|
|
case "down":
|
|
wheel = -1
|
|
default:
|
|
return 0, newPicoclawError(CodeInvalidAction, "invalid scroll direction")
|
|
}
|
|
|
|
writes := 0
|
|
for range amount {
|
|
if contextErr := controlOperationError(ctx); contextErr != nil {
|
|
return writes, contextErr
|
|
}
|
|
writes += s.sendMouseMoveWithButton(x, y, 0x00, wheel)
|
|
writes += s.sendMouseMoveWithButton(x, y, 0x00, 0)
|
|
if waitErr := waitForControlOperation(ctx, defaultScrollStep); waitErr != nil {
|
|
return writes, waitErr
|
|
}
|
|
}
|
|
return writes, nil
|
|
|
|
case "type":
|
|
if action.Text == "" {
|
|
return 0, newPicoclawError(CodeInvalidAction, "type requires text")
|
|
}
|
|
charMap := hid.GetCharMap("")
|
|
writes := 0
|
|
for _, char := range action.Text {
|
|
if contextErr := controlOperationError(ctx); contextErr != nil {
|
|
return writes, contextErr
|
|
}
|
|
key, ok := charMap[char]
|
|
if !ok {
|
|
return 0, newPicoclawError(CodeInvalidAction, "unsupported character in type action")
|
|
}
|
|
|
|
writes += s.sendKeyboardReport([]byte{byte(key.Modifiers), 0x00, byte(key.Code), 0x00, 0x00, 0x00, 0x00, 0x00})
|
|
writes += s.sendKeyboardReport([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00})
|
|
if waitErr := waitForControlOperation(ctx, defaultKeyDelay); waitErr != nil {
|
|
return writes, waitErr
|
|
}
|
|
}
|
|
return writes, nil
|
|
|
|
case "hotkey":
|
|
report, err := buildHotkeyReport([]string(action.Keys))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
writes := 0
|
|
writes += s.sendKeyboardReport(report)
|
|
if waitErr := waitForControlOperation(ctx, defaultClickHold); waitErr != nil {
|
|
return writes, waitErr
|
|
}
|
|
writes += s.sendKeyboardReport([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00})
|
|
return writes, nil
|
|
}
|
|
|
|
return 0, newPicoclawError(CodeInvalidAction, "unknown or invalid action")
|
|
}
|
|
|
|
func toAbsoluteHidCoord(normalized float64) uint16 {
|
|
if normalized < 0 {
|
|
normalized = 0
|
|
}
|
|
if normalized > 1 {
|
|
normalized = 1
|
|
}
|
|
return uint16(math.Floor(0x7FFF*normalized)) + 1
|
|
}
|
|
|
|
func (s *Service) sendMousePress(x float64, y float64, button byte) int {
|
|
return s.sendMouseMoveWithButton(x, y, button, 0)
|
|
}
|
|
|
|
func (s *Service) sendMouseRelease(x float64, y float64) int {
|
|
return s.sendMouseMoveWithButton(x, y, 0x00, 0)
|
|
}
|
|
|
|
func (s *Service) sendMouseMoveWithButton(x float64, y float64, buttons byte, wheel int) int {
|
|
absoluteX := toAbsoluteHidCoord(x)
|
|
absoluteY := toAbsoluteHidCoord(y)
|
|
|
|
report := []byte{
|
|
buttons,
|
|
byte(absoluteX & 0xff),
|
|
byte(absoluteX >> 8),
|
|
byte(absoluteY & 0xff),
|
|
byte(absoluteY >> 8),
|
|
byte(int8(wheel)),
|
|
}
|
|
s.hid.WriteHid2(report)
|
|
return 1
|
|
}
|
|
|
|
func (s *Service) sendKeyboardReport(report []byte) int {
|
|
s.hid.WriteHid0(report)
|
|
return 1
|
|
}
|
|
|
|
func normalizedPoint(x *float64, y *float64) (float64, float64, *PicoclawError) {
|
|
if x == nil || y == nil {
|
|
return 0, 0, newPicoclawError(CodeInvalidAction, "action requires x and y")
|
|
}
|
|
if *x < 0 || *x > 1 || *y < 0 || *y > 1 {
|
|
return 0, 0, newPicoclawError(CodeInvalidAction, "coordinates must be within [0,1]")
|
|
}
|
|
return *x, *y, nil
|
|
}
|
|
|
|
func normalizedNestedPoint(point *Point) (float64, float64, *PicoclawError) {
|
|
if point == nil {
|
|
return 0, 0, newPicoclawError(CodeInvalidAction, "action requires point coordinates")
|
|
}
|
|
return normalizedPoint(point.X, point.Y)
|
|
}
|
|
|
|
func mouseButton(button string) (byte, *PicoclawError) {
|
|
switch strings.ToLower(strings.TrimSpace(button)) {
|
|
case "", "left":
|
|
return 1 << 0, nil
|
|
case "right":
|
|
return 1 << 1, nil
|
|
case "middle":
|
|
return 1 << 2, nil
|
|
case "back":
|
|
return 1 << 3, nil
|
|
case "forward":
|
|
return 1 << 4, nil
|
|
default:
|
|
return 0, newPicoclawError(CodeInvalidAction, "invalid mouse button")
|
|
}
|
|
}
|