-
+
- Version
- Git +
- Deadzone +
diff --git a/goTool/README.md b/goTool/README.md index 7aa1ec7..39f8219 100644 --- a/goTool/README.md +++ b/goTool/README.md @@ -58,6 +58,16 @@ Open [http://localhost:8080](http://localhost:8080) — shows master firmware in If the UART device is unplugged or the port disappears, `serve` keeps running and retries on each poll interval; the UI shows **UART off** until the port is available again. +The dashboard can configure nodes using the same UART commands as the CLI: + +| UI action | CLI equivalent | +|-----------|------------------| +| Master / slave deadzone | `deadzone -set -value N -client ID` | +| Alle Slaves | `deadzone -set -value N -all` | +| Unicast test | `unicast-test -client ID` | + +HTTP API (used by the web UI): `GET/POST /api/deadzone`, `POST /api/unicast-test`. + ```bash go run . -port /dev/ttyUSB0 unicast-test -client 16 -seq 42 ``` diff --git a/goTool/api_serve.go b/goTool/api_serve.go new file mode 100644 index 0000000..12e6752 --- /dev/null +++ b/goTool/api_serve.go @@ -0,0 +1,148 @@ +package main + +import ( + "encoding/json" + "net/http" + "strconv" + + "powerpod/gotool/pb" +) + +type deadzoneAPIResponse struct { + Deadzone uint32 `json:"deadzone"` + ClientID uint32 `json:"client_id"` + Success bool `json:"success"` + SlavesUpdated uint32 `json:"slaves_updated"` + Error string `json:"error,omitempty"` +} + +type deadzoneAPIRequest struct { + Write bool `json:"write"` + Deadzone uint32 `json:"deadzone"` + ClientID uint32 `json:"client_id"` + AllClients bool `json:"all_clients"` +} + +type unicastAPIRequest struct { + ClientID uint32 `json:"client_id"` + Seq uint32 `json:"seq"` +} + +type unicastAPIResponse struct { + Success bool `json:"success"` + Seq uint32 `json:"seq"` + Error string `json:"error,omitempty"` +} + +func mountServeAPI(mux *http.ServeMux, link *managedSerial) { + mux.HandleFunc("/api/deadzone", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + serveDeadzoneGet(w, r, link) + case http.MethodPost: + serveDeadzonePost(w, r, link) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + }) + mux.HandleFunc("/api/unicast-test", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + serveUnicastTest(w, r, link) + }) +} + +func serveDeadzoneGet(w http.ResponseWriter, r *http.Request, link *managedSerial) { + clientID, err := parseUintQuery(r, "client_id", 0) + if err != nil { + writeJSON(w, http.StatusBadRequest, deadzoneAPIResponse{Error: err.Error()}) + return + } + resp, err := link.AccelDeadzone(&pb.AccelDeadzoneRequest{ + Write: false, + ClientId: clientID, + }) + if err != nil { + writeJSON(w, http.StatusServiceUnavailable, deadzoneAPIResponse{ + ClientID: clientID, + Error: err.Error(), + }) + return + } + writeJSON(w, http.StatusOK, deadzoneAPIResponse{ + Deadzone: resp.GetDeadzone(), + ClientID: resp.GetClientId(), + Success: resp.GetSuccess(), + }) +} + +func serveDeadzonePost(w http.ResponseWriter, r *http.Request, link *managedSerial) { + var body deadzoneAPIRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, deadzoneAPIResponse{Error: "invalid JSON"}) + return + } + resp, err := link.AccelDeadzone(&pb.AccelDeadzoneRequest{ + Write: true, + Deadzone: body.Deadzone, + ClientId: body.ClientID, + AllClients: body.AllClients, + }) + if err != nil { + writeJSON(w, http.StatusServiceUnavailable, deadzoneAPIResponse{ + ClientID: body.ClientID, + Error: err.Error(), + }) + return + } + writeJSON(w, http.StatusOK, deadzoneAPIResponse{ + Deadzone: resp.GetDeadzone(), + ClientID: resp.GetClientId(), + Success: resp.GetSuccess(), + SlavesUpdated: resp.GetSlavesUpdated(), + }) +} + +func serveUnicastTest(w http.ResponseWriter, r *http.Request, link *managedSerial) { + var body unicastAPIRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, unicastAPIResponse{Error: "invalid JSON"}) + return + } + if body.ClientID == 0 { + writeJSON(w, http.StatusBadRequest, unicastAPIResponse{Error: "client_id required"}) + return + } + if body.Seq == 0 { + body.Seq = 1 + } + resp, err := link.EspnowUnicastTest(body.ClientID, body.Seq) + if err != nil { + writeJSON(w, http.StatusServiceUnavailable, unicastAPIResponse{Error: err.Error()}) + return + } + writeJSON(w, http.StatusOK, unicastAPIResponse{ + Success: resp.GetSuccess(), + Seq: resp.GetSeq(), + }) +} + +func parseUintQuery(r *http.Request, key string, def uint32) (uint32, error) { + s := r.URL.Query().Get(key) + if s == "" { + return def, nil + } + v, err := strconv.ParseUint(s, 10, 32) + if err != nil { + return 0, err + } + return uint32(v), nil +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/goTool/client_api.go b/goTool/client_api.go index 1fef3a7..e9cc4b6 100644 --- a/goTool/client_api.go +++ b/goTool/client_api.go @@ -24,6 +24,26 @@ func (m *managedSerial) listClients() ([]*pb.ClientInfo, error) { return decodeClientsPayload(payload) } +func (m *managedSerial) AccelDeadzone(req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) { + var resp *pb.AccelDeadzoneResponse + err := m.withPort(func(sp *serialPort) error { + var e error + resp, e = sp.AccelDeadzone(req) + return e + }) + return resp, err +} + +func (m *managedSerial) EspnowUnicastTest(clientID, seq uint32) (*pb.EspNowUnicastTestResponse, error) { + var resp *pb.EspNowUnicastTestResponse + err := m.withPort(func(sp *serialPort) error { + var e error + resp, e = sp.EspnowUnicastTest(clientID, seq) + return e + }) + return resp, err +} + func decodeVersionPayload(payload []byte) (*pb.VersionResponse, error) { var msg pb.UartMessage if err := proto.Unmarshal(payload[1:], &msg); err != nil { diff --git a/goTool/cmd_serve.go b/goTool/cmd_serve.go index 8e069f0..ed8e40d 100644 --- a/goTool/cmd_serve.go +++ b/goTool/cmd_serve.go @@ -40,6 +40,7 @@ func runServe(portName string, baud int, args []string) error { go runPoller(link, portName, hub, *interval, stop) mux := http.NewServeMux() + mountServeAPI(mux, link) mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { conn, err := wsUpgrader.Upgrade(w, r, nil) if err != nil { diff --git a/goTool/dashboard.go b/goTool/dashboard.go index fbee1ad..216b4cd 100644 --- a/goTool/dashboard.go +++ b/goTool/dashboard.go @@ -3,24 +3,29 @@ package main import ( "encoding/hex" "encoding/json" + "fmt" "log" "sync" "time" "github.com/gorilla/websocket" + + "powerpod/gotool/pb" ) type MasterView struct { - Version uint32 `json:"version"` - GitHash string `json:"git_hash"` - OK bool `json:"ok"` - Error string `json:"error,omitempty"` + Version uint32 `json:"version"` + GitHash string `json:"git_hash"` + Deadzone uint32 `json:"deadzone,omitempty"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` } type ClientView struct { ID uint32 `json:"id"` MAC string `json:"mac"` Version uint32 `json:"version"` + Deadzone uint32 `json:"deadzone,omitempty"` Available bool `json:"available"` Used bool `json:"used"` LastPing uint32 `json:"last_ping"` @@ -100,6 +105,9 @@ func pollDashboard(link *managedSerial, portName string) DashboardState { GitHash: ver.GetGitHash(), OK: true, } + if dz, err := readDeadzone(link, 0); err == nil { + st.Master.Deadzone = dz + } clients, err := link.listClients() if err != nil { @@ -110,7 +118,7 @@ func pollDashboard(link *managedSerial, portName string) DashboardState { } for _, c := range clients { - st.Clients = append(st.Clients, ClientView{ + cv := ClientView{ ID: c.GetId(), MAC: formatMAC(c.GetMac()), Version: c.GetVersion(), @@ -118,11 +126,29 @@ func pollDashboard(link *managedSerial, portName string) DashboardState { Used: c.GetUsed(), LastPing: c.GetLastPing(), LastSuccessPing: c.GetLastSuccessPing(), - }) + } + if dz, err := readDeadzone(link, c.GetId()); err == nil { + cv.Deadzone = dz + } + st.Clients = append(st.Clients, cv) } return st } +func readDeadzone(link *managedSerial, clientID uint32) (uint32, error) { + r, err := link.AccelDeadzone(&pb.AccelDeadzoneRequest{ + Write: false, + ClientId: clientID, + }) + if err != nil { + return 0, err + } + if !r.GetSuccess() { + return 0, fmt.Errorf("deadzone read failed for client %d", clientID) + } + return r.GetDeadzone(), nil +} + func formatMAC(mac []byte) string { if len(mac) == 0 { return "" diff --git a/goTool/webui/index.html b/goTool/webui/index.html index 68f4db1..d57d6eb 100644 --- a/goTool/webui/index.html +++ b/goTool/webui/index.html @@ -92,6 +92,32 @@ border-color: #d4a012; color: #ffe08a; } + .alert-success { + background: rgba(0, 168, 107, 0.15); + border-color: #00a86b; + color: #b8f0d8; + } + + .form-control, .form-control:focus, .btn-outline-secondary { + background: var(--pp-surface-raised); + border-color: var(--pp-border); + color: var(--pp-text); + } + .form-control:focus { + box-shadow: 0 0 0 0.2rem rgba(142, 200, 255, 0.2); + border-color: var(--pp-accent); + } + .form-control::placeholder { color: var(--pp-text-muted); } + .btn-outline-secondary:hover { + background: var(--pp-border); + color: var(--pp-heading); + } + .btn-primary { + background: #2d6cdf; + border-color: #2d6cdf; + } + .btn-sm { font-size: 0.8rem; } + .dz-input { width: 5.5rem; }
@@ -110,6 +136,12 @@