Expose the command via goTool CLI/REST and dashboard controls so log verbosity can be tuned without reflashing. Co-authored-by: Cursor <cursoragent@cursor.com>
438 lines
12 KiB
Go
438 lines
12 KiB
Go
package main
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"strconv"
|
||
|
||
"powerpod/gotool/pb"
|
||
)
|
||
|
||
const otaMaxFirmwareSize = 2 * 1024 * 1024
|
||
|
||
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"`
|
||
// SlavesOnly: with all_clients, push to ESP-NOW slaves only (master BMA456 unchanged).
|
||
SlavesOnly bool `json:"slaves_only"`
|
||
}
|
||
|
||
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"`
|
||
}
|
||
|
||
type echoPingAPIRequest struct {
|
||
ClientID uint32 `json:"client_id"`
|
||
}
|
||
|
||
type echoPingAPIResponse struct {
|
||
Success bool `json:"success"`
|
||
ClientID uint32 `json:"client_id,omitempty"`
|
||
TimestampUs uint64 `json:"timestamp_us,omitempty"`
|
||
RttMs float64 `json:"rtt_ms"`
|
||
EspRttUs uint32 `json:"esp_rtt_us"`
|
||
Error string `json:"error,omitempty"`
|
||
}
|
||
|
||
type findMeAPIRequest struct {
|
||
ClientID uint32 `json:"client_id"`
|
||
}
|
||
|
||
type findMeAPIResponse struct {
|
||
Success bool `json:"success"`
|
||
ClientID uint32 `json:"client_id,omitempty"`
|
||
Error string `json:"error,omitempty"`
|
||
}
|
||
|
||
type restartAPIRequest struct {
|
||
ClientID uint32 `json:"client_id"`
|
||
}
|
||
|
||
type restartAPIResponse struct {
|
||
Success bool `json:"success"`
|
||
ClientID uint32 `json:"client_id,omitempty"`
|
||
Error string `json:"error,omitempty"`
|
||
}
|
||
|
||
type logLevelAPIResponse struct {
|
||
Success bool `json:"success"`
|
||
Level uint32 `json:"level"`
|
||
Error string `json:"error,omitempty"`
|
||
}
|
||
|
||
type logLevelAPIRequest struct {
|
||
Write bool `json:"write"`
|
||
Level uint32 `json:"level"`
|
||
}
|
||
|
||
type otaAPIResponse struct {
|
||
Success bool `json:"success"`
|
||
BytesWritten uint32 `json:"bytes_written,omitempty"`
|
||
TargetSlot uint32 `json:"target_slot,omitempty"`
|
||
Error string `json:"error,omitempty"`
|
||
}
|
||
|
||
func mountServeAPI(mux *http.ServeMux, link *managedSerial, hub *wsHub, streamCtl *accelStreamCtl, tapCtl *tapNotifyCtl) {
|
||
mountLiveStreamAPI(mux, hub)
|
||
mountAccelStreamAPI(mux, link, hub, streamCtl)
|
||
mountTapAPI(mux, link, hub, tapCtl)
|
||
mountLedRingAPI(mux, link)
|
||
mountBatteryAPI(mux, link)
|
||
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)
|
||
})
|
||
mux.HandleFunc("/api/echo-ping", func(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
serveEchoPing(w, r, link)
|
||
})
|
||
mux.HandleFunc("/api/find-me", func(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
serveFindMe(w, r, link)
|
||
})
|
||
mux.HandleFunc("/api/restart", func(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
serveRestart(w, r, link)
|
||
})
|
||
mux.HandleFunc("/api/log-level", func(w http.ResponseWriter, r *http.Request) {
|
||
switch r.Method {
|
||
case http.MethodGet:
|
||
serveLogLevelGet(w, r, link)
|
||
case http.MethodPost:
|
||
serveLogLevelPost(w, r, link)
|
||
default:
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
}
|
||
})
|
||
mux.HandleFunc("/api/ota", func(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
serveOTAUpload(w, r, link, hub)
|
||
})
|
||
}
|
||
|
||
func serveOTAUpload(w http.ResponseWriter, r *http.Request, link *managedSerial, hub *wsHub) {
|
||
if err := r.ParseMultipartForm(otaMaxFirmwareSize); err != nil {
|
||
writeJSON(w, http.StatusBadRequest, otaAPIResponse{Error: "invalid form"})
|
||
return
|
||
}
|
||
file, _, err := r.FormFile("firmware")
|
||
if err != nil {
|
||
writeJSON(w, http.StatusBadRequest, otaAPIResponse{Error: "firmware file required"})
|
||
return
|
||
}
|
||
defer file.Close()
|
||
|
||
data, err := io.ReadAll(io.LimitReader(file, otaMaxFirmwareSize))
|
||
if err != nil {
|
||
writeJSON(w, http.StatusBadRequest, otaAPIResponse{Error: err.Error()})
|
||
return
|
||
}
|
||
if len(data) == 0 {
|
||
writeJSON(w, http.StatusBadRequest, otaAPIResponse{Error: "empty firmware"})
|
||
return
|
||
}
|
||
|
||
var last OTAProgress
|
||
err = runOTAUpload(link, data, func(p OTAProgress) {
|
||
last = p
|
||
if hub != nil {
|
||
hub.broadcastRaw(p)
|
||
}
|
||
})
|
||
if err != nil {
|
||
if hub != nil {
|
||
hub.broadcastRaw(OTAProgress{Type: "ota_progress", Phase: "error", Message: err.Error()})
|
||
}
|
||
status := http.StatusServiceUnavailable
|
||
if errors.Is(err, errOTAInProgress) {
|
||
status = http.StatusConflict
|
||
}
|
||
writeJSON(w, status, otaAPIResponse{Error: err.Error()})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, otaAPIResponse{
|
||
Success: true,
|
||
BytesWritten: last.Bytes,
|
||
TargetSlot: last.Slot,
|
||
})
|
||
}
|
||
|
||
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
|
||
}
|
||
if body.AllClients && body.SlavesOnly {
|
||
updated, err := applyDeadzoneToSlaves(link, body.Deadzone)
|
||
if err != nil {
|
||
writeJSON(w, http.StatusServiceUnavailable, deadzoneAPIResponse{
|
||
Error: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, deadzoneAPIResponse{
|
||
Deadzone: body.Deadzone,
|
||
Success: updated > 0,
|
||
SlavesUpdated: updated,
|
||
})
|
||
return
|
||
}
|
||
|
||
req := &pb.AccelDeadzoneRequest{
|
||
Write: true,
|
||
Deadzone: body.Deadzone,
|
||
ClientId: body.ClientID,
|
||
AllClients: body.AllClients,
|
||
}
|
||
// client_id 0 without all_clients: master BMA456 only (same as CLI -client 0).
|
||
resp, err := link.AccelDeadzone(req)
|
||
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(),
|
||
})
|
||
}
|
||
|
||
// applyDeadzoneToSlaves sets deadzone on each registered slave via per-client UART/ESP-NOW.
|
||
// Does not change the master's local BMA456 (use client_id 0 for that).
|
||
func applyDeadzoneToSlaves(link *managedSerial, deadzone uint32) (uint32, error) {
|
||
clients, err := link.listClients()
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
var updated uint32
|
||
for _, c := range clients {
|
||
resp, err := link.AccelDeadzone(&pb.AccelDeadzoneRequest{
|
||
Write: true,
|
||
Deadzone: deadzone,
|
||
ClientId: c.GetId(),
|
||
})
|
||
if err != nil {
|
||
continue
|
||
}
|
||
if resp.GetSuccess() {
|
||
updated++
|
||
}
|
||
}
|
||
if len(clients) == 0 {
|
||
return 0, fmt.Errorf("no slaves registered")
|
||
}
|
||
if updated == 0 {
|
||
return 0, fmt.Errorf("deadzone not applied to any slave")
|
||
}
|
||
return updated, nil
|
||
}
|
||
|
||
func serveLogLevelGet(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||
resp, err := link.SetLogLevel(false, 0)
|
||
if err != nil {
|
||
writeJSON(w, http.StatusServiceUnavailable, logLevelAPIResponse{Error: err.Error()})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, logLevelAPIResponse{
|
||
Success: resp.GetSuccess(),
|
||
Level: resp.GetLevel(),
|
||
})
|
||
}
|
||
|
||
func serveLogLevelPost(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||
var body logLevelAPIRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||
writeJSON(w, http.StatusBadRequest, logLevelAPIResponse{Error: "invalid JSON"})
|
||
return
|
||
}
|
||
if !body.Write {
|
||
writeJSON(w, http.StatusBadRequest, logLevelAPIResponse{Error: "write must be true"})
|
||
return
|
||
}
|
||
if body.Level > 5 {
|
||
writeJSON(w, http.StatusBadRequest, logLevelAPIResponse{Error: "level must be 0–5"})
|
||
return
|
||
}
|
||
resp, err := link.SetLogLevel(true, body.Level)
|
||
if err != nil {
|
||
writeJSON(w, http.StatusServiceUnavailable, logLevelAPIResponse{Error: err.Error()})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, logLevelAPIResponse{
|
||
Success: resp.GetSuccess(),
|
||
Level: resp.GetLevel(),
|
||
})
|
||
}
|
||
|
||
func serveRestart(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||
var body restartAPIRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||
writeJSON(w, http.StatusBadRequest, restartAPIResponse{Error: "invalid JSON"})
|
||
return
|
||
}
|
||
if err := link.Restart(body.ClientID); err != nil {
|
||
writeJSON(w, http.StatusServiceUnavailable, restartAPIResponse{
|
||
ClientID: body.ClientID,
|
||
Error: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, restartAPIResponse{Success: true, ClientID: body.ClientID})
|
||
}
|
||
|
||
func serveFindMe(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||
var body findMeAPIRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||
writeJSON(w, http.StatusBadRequest, findMeAPIResponse{Error: "invalid JSON"})
|
||
return
|
||
}
|
||
if err := link.FindMe(body.ClientID); err != nil {
|
||
writeJSON(w, http.StatusServiceUnavailable, findMeAPIResponse{
|
||
ClientID: body.ClientID,
|
||
Error: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, findMeAPIResponse{Success: true, ClientID: body.ClientID})
|
||
}
|
||
|
||
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 serveEchoPing(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||
var body echoPingAPIRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||
writeJSON(w, http.StatusBadRequest, echoPingAPIResponse{Error: "invalid JSON"})
|
||
return
|
||
}
|
||
if body.ClientID == 0 {
|
||
writeJSON(w, http.StatusBadRequest, echoPingAPIResponse{Error: "client_id required"})
|
||
return
|
||
}
|
||
result, err := link.EchoPing(body.ClientID)
|
||
if err != nil {
|
||
writeJSON(w, http.StatusServiceUnavailable, echoPingAPIResponse{Error: err.Error()})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, echoPingAPIResponse{
|
||
Success: result.Success,
|
||
ClientID: result.ClientID,
|
||
TimestampUs: result.TimestampUs,
|
||
RttMs: result.RttMs,
|
||
EspRttUs: result.EspRttUs,
|
||
})
|
||
}
|
||
|
||
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)
|
||
}
|