powerpods/goTool/api_serve.go
simon 490e0ee61f Add UART SET_LOG_LEVEL for runtime master ESP-IDF logging.
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>
2026-06-06 18:03:34 +02:00

438 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 05"})
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)
}