Files
powerpods/goTool/api_serve.go
T
simonandCursor d7441cb85f Add runtime BMA456 tap config with NVS persistence and goTool UI.
Expose multitap parameters over UART/ESP-NOW (like deadzone), store per node in NVS, and allow get/set from CLI, REST, and the dashboard without poll-overwrite while editing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-26 13:07:13 +02:00

439 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)
mountTapConfigAPI(mux, link)
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)
}