Compare commits

..

No commits in common. "4bf43d8a5e0d28539ce4a4e7eacc21067703dfb2" and "0299ba44fd0e55b66b760692486e48a2e334cd73" have entirely different histories.

33 changed files with 116 additions and 2416 deletions

View File

@ -8,10 +8,9 @@ GOTOOL_RUN := cd $(GOTOOL_DIR) && go run . -port $(PORT)
.PHONY: default proto_generate proto_generate_uart proto_generate_espnow \
gotool-build gotool-proto gotool-tidy gotool-test-units \
gotool-version gotool-clients gotool-unicast-test gotool-deadzone-get gotool-deadzone-set \
gotool-test gotool-serve
gotool-test
TEST_CONFIG ?= example-lab
SERVE_ADDR ?= :8080
TEST_SCENARIO ?= smoke
default:
@ -19,8 +18,7 @@ default:
@echo "Set PORT=$(PORT) (current) for goTool targets."
proto_generate_uart:
cd main/proto && python ../../libs/nanopb/generator/nanopb_generator.py \
-I ../../libs/nanopb/generator/proto uart_messages.proto
python libs/nanopb/generator/nanopb_generator.py main/proto/uart_messages.proto
proto_generate_espnow:
python libs/nanopb/generator/nanopb_generator.py main/proto/esp_now_messages.proto
@ -64,7 +62,4 @@ gotool-test-units:
gotool-test: $(GOTOOL)
$(GOTOOL) -port $(PORT) test -config $(TEST_CONFIG) -scenario $(TEST_SCENARIO)
gotool-serve: $(GOTOOL)
$(GOTOOL) -port $(PORT) serve -addr $(SERVE_ADDR)
$(GOTOOL): gotool-build

View File

@ -26,8 +26,6 @@ go run . -port /dev/ttyUSB0 clients
| `clients` | `0x04` | Lists slaves registered on the master via ESP-NOW |
| `unicast-test` | `0x07` | Sends ESP-NOW unicast test to one slave (`-client`, `-seq`) |
| `test` | — | Run an automated scenario (JSON configs under `testdata/`) |
| `serve` | — | Web dashboard at `http://localhost:8080` (WebSocket live updates) |
| `ota` | 1619 | UART firmware upload to inactive OTA slot (200 B chunks, 4 KiB flash blocks) |
`clients` requires slaves to have responded to master discover broadcasts first.
@ -45,37 +43,6 @@ With a complete bench config, `-port` is optional for `test` (uses `uart.master`
See [`testdata/README.md`](testdata/README.md) for the JSON schema.
### Web dashboard
Polls the master over UART and pushes state to the browser via WebSocket (Alpine.js + Bootstrap 5).
```bash
go run . -port /dev/ttyUSB0 serve
go run . -port /dev/ttyUSB0 serve -addr :8080 -interval 2s
make gotool-serve PORT=/dev/ttyUSB0
```
Open [http://localhost:8080](http://localhost:8080) — shows master firmware info and the ESP-NOW client table from `CLIENT_INFO`.
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 |
|-----------|------------------|
| Nur Master | `deadzone -set -value N -client 0` |
| Einzelner Slave | `deadzone -set -value N -client ID` |
| Alle Slaves | per-slave ESP-NOW (Master bleibt unverändert; CLI `-all` setzt auch den Master) |
| 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 ota build/powerpod.bin
```
Waits for **ready** after start (~30 s erase), sends 200-byte `OTA_PAYLOAD` frames, reads **block_ack** every 4 KiB, then `OTA_END` and **success**.
```bash
go run . -port /dev/ttyUSB0 unicast-test -client 16 -seq 42
```

View File

@ -1,259 +0,0 @@
package main
import (
"encoding/json"
"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 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) {
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/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()})
}
writeJSON(w, http.StatusServiceUnavailable, 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 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)
}

View File

@ -8,43 +8,11 @@ import (
"powerpod/gotool/pb"
)
func (m *managedSerial) getVersion() (*pb.VersionResponse, error) {
payload, err := m.exchange(byte(pb.MessageType_VERSION), "VERSION")
func (s *serialPort) getVersion() (*pb.VersionResponse, error) {
payload, err := s.exchange(byte(pb.MessageType_VERSION), "VERSION")
if err != nil {
return nil, err
}
return decodeVersionPayload(payload)
}
func (m *managedSerial) listClients() ([]*pb.ClientInfo, error) {
payload, err := m.exchange(byte(pb.MessageType_CLIENT_INFO), "CLIENT_INFO")
if err != nil {
return nil, err
}
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 {
return nil, fmt.Errorf("decode: %w", err)
@ -59,7 +27,11 @@ func decodeVersionPayload(payload []byte) (*pb.VersionResponse, error) {
return ver, nil
}
func decodeClientsPayload(payload []byte) ([]*pb.ClientInfo, error) {
func (s *serialPort) listClients() ([]*pb.ClientInfo, error) {
payload, err := s.exchange(byte(pb.MessageType_CLIENT_INFO), "CLIENT_INFO")
if err != nil {
return nil, err
}
var msg pb.UartMessage
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
return nil, fmt.Errorf("decode: %w", err)
@ -74,22 +46,6 @@ func decodeClientsPayload(payload []byte) ([]*pb.ClientInfo, error) {
return info.GetClients(), nil
}
func (s *serialPort) getVersion() (*pb.VersionResponse, error) {
payload, err := s.exchange(byte(pb.MessageType_VERSION), "VERSION")
if err != nil {
return nil, err
}
return decodeVersionPayload(payload)
}
func (s *serialPort) listClients() ([]*pb.ClientInfo, error) {
payload, err := s.exchange(byte(pb.MessageType_CLIENT_INFO), "CLIENT_INFO")
if err != nil {
return nil, err
}
return decodeClientsPayload(payload)
}
func (s *serialPort) accelDeadzone(req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) {
msg := &pb.UartMessage{
Type: pb.MessageType_ACCEL_DEADZONE,

View File

@ -1,32 +0,0 @@
package main
import (
"fmt"
"os"
)
func runOTA(sp *serialPort, args []string) error {
if len(args) < 1 {
return fmt.Errorf("usage: ota <firmware.bin>")
}
data, err := os.ReadFile(args[0])
if err != nil {
return err
}
sp.mu.Lock()
defer sp.mu.Unlock()
m := &managedSerial{quiet: false, sp: sp}
return runOTAOnPortUnlocked(m, data, func(p OTAProgress) {
switch p.Phase {
case "preparing", "ready":
fmt.Println(p.Message)
case "uploading":
if p.Percent%10 == 0 {
fmt.Printf(" %s (%d%%)\n", p.Message, p.Percent)
}
case "done", "error":
fmt.Println(p.Message)
}
})
}

View File

@ -1,70 +0,0 @@
package main
import (
"embed"
"flag"
"fmt"
"io/fs"
"log"
"net/http"
"time"
"github.com/gorilla/websocket"
)
//go:embed webui/*
var webUI embed.FS
var wsUpgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
func runServe(portName string, baud int, args []string) error {
serveFlags := flag.NewFlagSet("serve", flag.ExitOnError)
addr := serveFlags.String("addr", ":8080", "HTTP listen address")
interval := serveFlags.Duration("interval", 2*time.Second, "UART poll interval")
if err := serveFlags.Parse(args); err != nil {
return err
}
if portName == "" {
return fmt.Errorf("serve requires -port (master UART)")
}
link := newManagedSerial(portName, baud)
link.quiet = true
defer link.Close()
hub := newWSHub()
stop := make(chan struct{})
defer close(stop)
go runPoller(link, portName, hub, *interval, stop)
mux := http.NewServeMux()
mountServeAPI(mux, link, hub)
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
conn, err := wsUpgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("websocket upgrade: %v", err)
return
}
hub.register(conn)
defer hub.unregister(conn)
defer conn.Close()
for {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
}
})
ui, err := fs.Sub(webUI, "webui")
if err != nil {
return err
}
mux.Handle("/", http.FileServer(http.FS(ui)))
log.Printf("dashboard http://localhost%s (UART %s @ %d baud, poll %s, auto-reconnect)",
*addr, portName, baud, interval.String())
return http.ListenAndServe(*addr, mux)
}

View File

@ -1,201 +0,0 @@
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"`
RunningPartition string `json:"running_partition,omitempty"`
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"`
LastSuccessPing uint32 `json:"last_success_ping"`
}
type DashboardState struct {
UpdatedAt string `json:"updated_at"`
SerialPort string `json:"serial_port"`
UARTConnected bool `json:"uart_connected"`
SerialOK bool `json:"serial_ok"`
SerialError string `json:"serial_error,omitempty"`
Master MasterView `json:"master"`
Clients []ClientView `json:"clients"`
}
type wsHub struct {
mu sync.RWMutex
clients map[*websocket.Conn]struct{}
state DashboardState
}
func newWSHub() *wsHub {
return &wsHub{clients: make(map[*websocket.Conn]struct{})}
}
func (h *wsHub) setState(st DashboardState) {
h.mu.Lock()
h.state = st
conns := make([]*websocket.Conn, 0, len(h.clients))
for c := range h.clients {
conns = append(conns, c)
}
h.mu.Unlock()
data, err := json.Marshal(st)
if err != nil {
return
}
for _, c := range conns {
_ = c.WriteMessage(websocket.TextMessage, data)
}
}
func (h *wsHub) register(c *websocket.Conn) {
h.mu.Lock()
h.clients[c] = struct{}{}
snap := h.state
h.mu.Unlock()
if data, err := json.Marshal(snap); err == nil {
_ = c.WriteMessage(websocket.TextMessage, data)
}
}
func (h *wsHub) unregister(c *websocket.Conn) {
h.mu.Lock()
delete(h.clients, c)
h.mu.Unlock()
}
func (h *wsHub) broadcastRaw(v any) {
h.mu.RLock()
conns := make([]*websocket.Conn, 0, len(h.clients))
for c := range h.clients {
conns = append(conns, c)
}
h.mu.RUnlock()
data, err := json.Marshal(v)
if err != nil {
return
}
for _, c := range conns {
_ = c.WriteMessage(websocket.TextMessage, data)
}
}
func pollDashboard(link *managedSerial, portName string) DashboardState {
st := DashboardState{
UpdatedAt: time.Now().Format(time.RFC3339),
SerialPort: portName,
Clients: []ClientView{},
}
ver, err := link.getVersion()
if err != nil {
return disconnectedState(portName, err)
}
st.UARTConnected = true
st.SerialOK = true
st.Master = MasterView{
Version: ver.GetVersion(),
GitHash: ver.GetGitHash(),
RunningPartition: ver.GetRunningPartition(),
OK: true,
}
if dz, err := readDeadzone(link, 0); err == nil {
st.Master.Deadzone = dz
}
clients, err := link.listClients()
if err != nil {
st.SerialOK = false
st.SerialError = err.Error()
st.UARTConnected = link.IsConnected()
return st
}
for _, c := range clients {
cv := ClientView{
ID: c.GetId(),
MAC: formatMAC(c.GetMac()),
Version: c.GetVersion(),
Available: c.GetAvailable(),
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 ""
}
return hex.EncodeToString(mac)
}
func runPoller(link *managedSerial, portName string, hub *wsHub, interval time.Duration, stop <-chan struct{}) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
uartUp := false
publish := func() {
st := pollDashboard(link, portName)
if st.UARTConnected && !uartUp {
log.Printf("UART %s connected", portName)
}
uartUp = st.UARTConnected
hub.setState(st)
}
publish()
for {
select {
case <-stop:
return
case <-ticker.C:
publish()
}
}
}

View File

@ -3,7 +3,6 @@ module powerpod/gotool
go 1.26.2
require (
github.com/gorilla/websocket v1.5.3
go.bug.st/serial v1.6.4
google.golang.org/protobuf v1.36.11
)

View File

@ -4,8 +4,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=

View File

@ -17,9 +17,7 @@ func usage() {
fmt.Fprintf(os.Stderr, " clients registered ESP-NOW slaves on the master\n")
fmt.Fprintf(os.Stderr, " deadzone get/set accelerometer deadzone (LSB)\n")
fmt.Fprintf(os.Stderr, " unicast-test send ESP-NOW unicast test to one slave\n")
fmt.Fprintf(os.Stderr, " test run automated scenario (see testdata/)\n")
fmt.Fprintf(os.Stderr, " serve web dashboard (Bootstrap + WebSocket)\n")
fmt.Fprintf(os.Stderr, " ota UART OTA upload (A/B partitions)\n\n")
fmt.Fprintf(os.Stderr, " test run automated scenario (see testdata/)\n\n")
flag.PrintDefaults()
}
@ -39,14 +37,7 @@ func main() {
switch cmd {
case "test", "autotest":
runErr = runTest(*portName, *baud, flag.Args()[1:])
case "serve", "web", "dashboard":
if *portName == "" {
fmt.Fprintf(os.Stderr, "command %q requires -port\n\n", cmd)
usage()
os.Exit(2)
}
runErr = runServe(*portName, *baud, flag.Args()[1:])
case "version", "clients", "client-info", "deadzone", "accel-deadzone", "unicast-test", "unicast_test", "ota":
case "version", "clients", "client-info", "deadzone", "accel-deadzone", "unicast-test", "unicast_test":
if *portName == "" {
fmt.Fprintf(os.Stderr, "command %q requires -port\n\n", cmd)
usage()
@ -66,8 +57,6 @@ func main() {
runErr = runDeadzone(sp, flag.Args()[1:])
case "unicast-test", "unicast_test":
runErr = runUnicastTest(sp, flag.Args()[1:])
case "ota":
runErr = runOTA(sp, flag.Args()[1:])
}
default:
fmt.Fprintf(os.Stderr, "unknown command %q\n\n", cmd)

View File

@ -1,252 +0,0 @@
package main
import (
"fmt"
"log"
"time"
"google.golang.org/protobuf/proto"
uartframe "powerpod/gotool/uart"
"powerpod/gotool/pb"
)
const (
otaHostChunkSize = 200
otaFlashBlockSize = 4096
otaPrepareTimeout = 120 * time.Second
otaDefaultTimeout = 15 * time.Second
)
const (
otaStPreparing = 1
otaStReady = 2
otaStBlockAck = 3
otaStSuccess = 4
otaStFailed = 5
)
// OTAProgress is pushed to the dashboard during web uploads.
type OTAProgress struct {
Type string `json:"type"` // always "ota_progress"
Phase string `json:"phase"` // preparing, ready, uploading, done, error
Percent int `json:"percent"`
Message string `json:"message"`
Bytes uint32 `json:"bytes_written,omitempty"`
Slot uint32 `json:"target_slot,omitempty"`
}
type otaProgressFn func(OTAProgress)
func runOTAUpload(m *managedSerial, firmware []byte, onProgress otaProgressFn) error {
m.mu.Lock()
defer m.mu.Unlock()
err := runOTAOnPortUnlocked(m, firmware, onProgress)
if err != nil {
m.invalidateLocked(err)
}
return err
}
func runOTAOnPortUnlocked(m *managedSerial, firmware []byte, onProgress otaProgressFn) error {
if len(firmware) == 0 {
return fmt.Errorf("empty firmware")
}
notify := func(phase string, percent int, msg string, extra ...OTAProgress) {
if onProgress == nil {
return
}
p := OTAProgress{Type: "ota_progress", Phase: phase, Percent: percent, Message: msg}
if len(extra) > 0 {
p.Bytes = extra[0].Bytes
p.Slot = extra[0].Slot
}
onProgress(p)
}
if m.sp == nil {
if err := m.openLocked(); err != nil {
notify("error", 0, err.Error())
return err
}
}
sp := m.sp
if err := sp.port.SetReadTimeout(otaPrepareTimeout); err != nil {
notify("error", 0, err.Error())
return err
}
defer sp.port.SetReadTimeout(readTimeout)
notify("preparing", 0, fmt.Sprintf("OTA start (%d bytes)…", len(firmware)))
if err := writeUartMessage(sp, &pb.UartMessage{
Type: pb.MessageType_OTA_START,
Payload: &pb.UartMessage_OtaStart{
OtaStart: &pb.OtaStartPayload{TotalSize: uint32(len(firmware))},
},
}, false); err != nil {
notify("error", 0, err.Error())
return err
}
ready, err := waitOtaStatus(sp, otaStReady, otaPrepareTimeout, func(msg string) {
notify("preparing", 2, msg)
})
if err != nil {
notify("error", 0, err.Error())
return err
}
notify("ready", 5, fmt.Sprintf("Ziel-Slot %d bereit", ready.GetTargetSlot()))
if err := sp.port.SetReadTimeout(otaDefaultTimeout); err != nil {
notify("error", 0, err.Error())
return err
}
var seq uint32
for offset := 0; offset < len(firmware); {
bytesInBlock := 0
for bytesInBlock < otaFlashBlockSize && offset < len(firmware) {
n := otaHostChunkSize
room := otaFlashBlockSize - bytesInBlock
if n > room {
n = room
}
if offset+n > len(firmware) {
n = len(firmware) - offset
}
chunk := firmware[offset : offset+n]
if err := writeUartMessage(sp, &pb.UartMessage{
Type: pb.MessageType_OTA_PAYLOAD,
Payload: &pb.UartMessage_OtaPayload{
OtaPayload: &pb.OtaPayload{Seq: seq, Data: chunk},
},
}, false); err != nil {
notify("error", 0, err.Error())
return err
}
seq++
offset += n
bytesInBlock += n
pct := 5 + (offset * 90 / len(firmware))
notify("uploading", pct, fmt.Sprintf("%d / %d bytes", offset, len(firmware)))
}
if bytesInBlock == otaFlashBlockSize {
st, err := waitOtaStatus(sp, otaStBlockAck, otaDefaultTimeout, nil)
if err != nil {
notify("error", 0, err.Error())
return err
}
pct := 5 + (offset * 90 / len(firmware))
notify("uploading", pct, fmt.Sprintf("Block geschrieben (%d bytes in flash)", st.GetBytesWritten()),
OTAProgress{Bytes: st.GetBytesWritten()})
}
}
if err := writeUartMessage(sp, &pb.UartMessage{
Type: pb.MessageType_OTA_END,
Payload: &pb.UartMessage_OtaEnd{
OtaEnd: &pb.OtaEndPayload{},
},
}, false); err != nil {
notify("error", 0, err.Error())
return err
}
st, err := readOtaStatus(sp)
if err != nil {
notify("error", 0, err.Error())
return err
}
if st.GetStatus() != otaStSuccess {
err := fmt.Errorf("OTA failed: status=%d error=%d", st.GetStatus(), st.GetError())
notify("error", 0, err.Error())
return err
}
notify("done", 100, fmt.Sprintf("Erfolg — %d bytes auf Slot %d (Neustart)", st.GetBytesWritten(), st.GetTargetSlot()),
OTAProgress{Bytes: st.GetBytesWritten(), Slot: st.GetTargetSlot()})
return nil
}
func writeUartMessage(sp *serialPort, msg *pb.UartMessage, logFrame bool) error {
frame, err := encodeUartMessage(msg)
if err != nil {
return err
}
if logFrame {
log.Printf("sending %s (%d frame bytes)", msg.Type, len(frame))
}
_, err = sp.port.Write(frame)
return err
}
func waitOtaStatus(sp *serialPort, want uint32, timeout time.Duration, onPreparing func(string)) (*pb.OtaStatusPayload, error) {
deadline := time.Now().Add(timeout)
for {
if time.Now().After(deadline) {
return nil, fmt.Errorf("timeout waiting for OTA status %d", want)
}
if err := sp.port.SetReadTimeout(time.Until(deadline)); err != nil {
return nil, err
}
st, err := readOtaStatus(sp)
if err != nil {
return nil, err
}
switch st.GetStatus() {
case want:
return st, nil
case otaStPreparing:
if onPreparing != nil {
onPreparing("Partition wird vorbereitet (~30s)…")
}
case otaStFailed:
return nil, fmt.Errorf("OTA failed (error=%d)", st.GetError())
}
}
}
func readOtaStatus(sp *serialPort) (*pb.OtaStatusPayload, error) {
payload, err := uartframe.ReadFrame(sp.port, nil)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
msg, err := decodeUartPayload(payload)
if err != nil {
return nil, err
}
if msg.GetType() != pb.MessageType_OTA_STATUS {
return nil, fmt.Errorf("unexpected response type %v", msg.GetType())
}
st := msg.GetOtaStatus()
if st == nil {
return nil, fmt.Errorf("missing ota_status")
}
return st, nil
}
func encodeUartMessage(msg *pb.UartMessage) ([]byte, error) {
body, err := proto.Marshal(msg)
if err != nil {
return nil, err
}
payload := append([]byte{byte(msg.Type)}, body...)
return uartframe.EncodeFrame(payload)
}
func decodeUartPayload(payload []byte) (*pb.UartMessage, error) {
if len(payload) == 0 {
return nil, fmt.Errorf("empty response")
}
var msg pb.UartMessage
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
return nil, err
}
msg.Type = pb.MessageType(payload[0])
return &msg, nil
}

View File

@ -447,13 +447,11 @@ func (x *EchoPayload) GetData() []byte {
}
type VersionResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Version uint32 `protobuf:"varint,1,opt,name=version,proto3" json:"version,omitempty"`
GitHash string `protobuf:"bytes,2,opt,name=git_hash,json=gitHash,proto3" json:"git_hash,omitempty"`
// * Active OTA app partition label, e.g. "ota_0" or "ota_1".
RunningPartition string `protobuf:"bytes,3,opt,name=running_partition,json=runningPartition,proto3" json:"running_partition,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
state protoimpl.MessageState `protogen:"open.v1"`
Version uint32 `protobuf:"varint,1,opt,name=version,proto3" json:"version,omitempty"`
GitHash string `protobuf:"bytes,2,opt,name=git_hash,json=gitHash,proto3" json:"git_hash,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *VersionResponse) Reset() {
@ -500,13 +498,6 @@ func (x *VersionResponse) GetGitHash() string {
return ""
}
func (x *VersionResponse) GetRunningPartition() string {
if x != nil {
return x.RunningPartition
}
return ""
}
type ClientInfo struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id uint32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
@ -998,10 +989,10 @@ func (x *EspNowUnicastTestResponse) GetSeq() uint32 {
return 0
}
// Host → device: begin UART OTA (erase inactive OTA slot; device replies OTA_STATUS).
type OtaStartPayload struct {
state protoimpl.MessageState `protogen:"open.v1"`
TotalSize uint32 `protobuf:"varint,1,opt,name=total_size,json=totalSize,proto3" json:"total_size,omitempty"`
BlockSize uint32 `protobuf:"varint,2,opt,name=block_size,json=blockSize,proto3" json:"block_size,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -1043,11 +1034,18 @@ func (x *OtaStartPayload) GetTotalSize() uint32 {
return 0
}
// Host → device: firmware chunk (up to 200 bytes); device buffers 4 KiB before flash write.
func (x *OtaStartPayload) GetBlockSize() uint32 {
if x != nil {
return x.BlockSize
}
return 0
}
type OtaPayload struct {
state protoimpl.MessageState `protogen:"open.v1"`
Seq uint32 `protobuf:"varint,1,opt,name=seq,proto3" json:"seq,omitempty"`
Data []byte `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"`
BlockId uint32 `protobuf:"varint,1,opt,name=block_id,json=blockId,proto3" json:"block_id,omitempty"`
ChunkId uint32 `protobuf:"varint,2,opt,name=chunk_id,json=chunkId,proto3" json:"chunk_id,omitempty"`
Data []byte `protobuf:"bytes,3,opt,name=data,proto3" json:"data,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -1082,9 +1080,16 @@ func (*OtaPayload) Descriptor() ([]byte, []int) {
return file_uart_messages_proto_rawDescGZIP(), []int{13}
}
func (x *OtaPayload) GetSeq() uint32 {
func (x *OtaPayload) GetBlockId() uint32 {
if x != nil {
return x.Seq
return x.BlockId
}
return 0
}
func (x *OtaPayload) GetChunkId() uint32 {
if x != nil {
return x.ChunkId
}
return 0
}
@ -1096,9 +1101,9 @@ func (x *OtaPayload) GetData() []byte {
return nil
}
// Host → device: no more payload; device flushes buffer and finalizes OTA.
type OtaEndPayload struct {
state protoimpl.MessageState `protogen:"open.v1"`
Status uint32 `protobuf:"varint,1,opt,name=status,proto3" json:"status,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -1133,14 +1138,16 @@ func (*OtaEndPayload) Descriptor() ([]byte, []int) {
return file_uart_messages_proto_rawDescGZIP(), []int{14}
}
// Device → host status (also used as ACK after each 4 KiB written).
// status: 1=preparing, 2=ready, 3=block_ack, 4=success, 5=failed
func (x *OtaEndPayload) GetStatus() uint32 {
if x != nil {
return x.Status
}
return 0
}
type OtaStatusPayload struct {
state protoimpl.MessageState `protogen:"open.v1"`
Status uint32 `protobuf:"varint,1,opt,name=status,proto3" json:"status,omitempty"`
BytesWritten uint32 `protobuf:"varint,2,opt,name=bytes_written,json=bytesWritten,proto3" json:"bytes_written,omitempty"`
TargetSlot uint32 `protobuf:"varint,3,opt,name=target_slot,json=targetSlot,proto3" json:"target_slot,omitempty"`
Error uint32 `protobuf:"varint,4,opt,name=error,proto3" json:"error,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -1182,27 +1189,6 @@ func (x *OtaStatusPayload) GetStatus() uint32 {
return 0
}
func (x *OtaStatusPayload) GetBytesWritten() uint32 {
if x != nil {
return x.BytesWritten
}
return 0
}
func (x *OtaStatusPayload) GetTargetSlot() uint32 {
if x != nil {
return x.TargetSlot
}
return 0
}
func (x *OtaStatusPayload) GetError() uint32 {
if x != nil {
return x.Error
}
return 0
}
var File_uart_messages_proto protoreflect.FileDescriptor
const file_uart_messages_proto_rawDesc = "" +
@ -1230,11 +1216,10 @@ const file_uart_messages_proto_rawDesc = "" +
"\apayload\"\x05\n" +
"\x03Ack\"!\n" +
"\vEchoPayload\x12\x12\n" +
"\x04data\x18\x01 \x01(\fR\x04data\"s\n" +
"\x04data\x18\x01 \x01(\fR\x04data\"F\n" +
"\x0fVersionResponse\x12\x18\n" +
"\aversion\x18\x01 \x01(\rR\aversion\x12\x19\n" +
"\bgit_hash\x18\x02 \x01(\tR\agitHash\x12+\n" +
"\x11running_partition\x18\x03 \x01(\tR\x10runningPartition\"\xc3\x01\n" +
"\bgit_hash\x18\x02 \x01(\tR\agitHash\"\xc3\x01\n" +
"\n" +
"ClientInfo\x12\x0e\n" +
"\x02id\x18\x01 \x01(\rR\x02id\x12\x1c\n" +
@ -1269,21 +1254,21 @@ const file_uart_messages_proto_rawDesc = "" +
"\x03seq\x18\x02 \x01(\rR\x03seq\"G\n" +
"\x19EspNowUnicastTestResponse\x12\x18\n" +
"\asuccess\x18\x01 \x01(\bR\asuccess\x12\x10\n" +
"\x03seq\x18\x02 \x01(\rR\x03seq\"0\n" +
"\x03seq\x18\x02 \x01(\rR\x03seq\"O\n" +
"\x0fOtaStartPayload\x12\x1d\n" +
"\n" +
"total_size\x18\x01 \x01(\rR\ttotalSize\"2\n" +
"total_size\x18\x01 \x01(\rR\ttotalSize\x12\x1d\n" +
"\n" +
"OtaPayload\x12\x10\n" +
"\x03seq\x18\x01 \x01(\rR\x03seq\x12\x12\n" +
"\x04data\x18\x02 \x01(\fR\x04data\"\x0f\n" +
"\rOtaEndPayload\"\x86\x01\n" +
"block_size\x18\x02 \x01(\rR\tblockSize\"V\n" +
"\n" +
"OtaPayload\x12\x19\n" +
"\bblock_id\x18\x01 \x01(\rR\ablockId\x12\x19\n" +
"\bchunk_id\x18\x02 \x01(\rR\achunkId\x12\x12\n" +
"\x04data\x18\x03 \x01(\fR\x04data\"'\n" +
"\rOtaEndPayload\x12\x16\n" +
"\x06status\x18\x01 \x01(\rR\x06status\"*\n" +
"\x10OtaStatusPayload\x12\x16\n" +
"\x06status\x18\x01 \x01(\rR\x06status\x12#\n" +
"\rbytes_written\x18\x02 \x01(\rR\fbytesWritten\x12\x1f\n" +
"\vtarget_slot\x18\x03 \x01(\rR\n" +
"targetSlot\x12\x14\n" +
"\x05error\x18\x04 \x01(\rR\x05error*\xdd\x01\n" +
"\x06status\x18\x01 \x01(\rR\x06status*\xdd\x01\n" +
"\vMessageType\x12\v\n" +
"\aUNKNOWN\x10\x00\x12\a\n" +
"\x03ACK\x10\x01\x12\b\n" +

View File

@ -1,122 +0,0 @@
package main
import (
"fmt"
"log"
"sync"
"time"
)
// managedSerial keeps the UART open and reconnects after I/O failures or unplug.
type managedSerial struct {
portName string
baud int
quiet bool
mu sync.Mutex
sp *serialPort
}
func newManagedSerial(portName string, baud int) *managedSerial {
return &managedSerial{
portName: portName,
baud: baud,
}
}
func (m *managedSerial) Close() error {
m.mu.Lock()
defer m.mu.Unlock()
return m.closeLocked()
}
func (m *managedSerial) IsConnected() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.sp != nil
}
func (m *managedSerial) openLocked() error {
sp, err := openSerial(m.portName, m.baud)
if err != nil {
return err
}
sp.quiet = m.quiet
m.sp = sp
if !m.quiet {
log.Printf("UART %s connected (%d baud)", m.portName, m.baud)
}
return nil
}
func (m *managedSerial) closeLocked() error {
if m.sp == nil {
return nil
}
err := m.sp.port.Close()
m.sp = nil
return err
}
func (m *managedSerial) invalidateLocked(reason error) {
if m.sp == nil {
return
}
if !m.quiet {
log.Printf("UART %s disconnected: %v", m.portName, reason)
}
_ = m.closeLocked()
}
func (m *managedSerial) withPort(fn func(*serialPort) error) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.sp == nil {
if err := m.openLocked(); err != nil {
return fmt.Errorf("%s: %w", m.portName, err)
}
}
err := fn(m.sp)
if err != nil {
m.invalidateLocked(err)
}
return err
}
func (m *managedSerial) exchangePayload(payload []byte, cmdName string) ([]byte, error) {
var resp []byte
err := m.withPort(func(sp *serialPort) error {
var e error
resp, e = sp.exchangePayloadLocked(payload, cmdName)
return e
})
return resp, err
}
func (m *managedSerial) exchange(cmdID byte, cmdName string) ([]byte, error) {
var resp []byte
err := m.withPort(func(sp *serialPort) error {
var e error
resp, e = sp.exchangeLocked(cmdID, cmdName)
return e
})
return resp, err
}
func disconnectedState(portName string, err error) DashboardState {
msg := "UART disconnected"
if err != nil {
msg = err.Error()
}
return DashboardState{
UpdatedAt: time.Now().Format(time.RFC3339),
SerialPort: portName,
UARTConnected: false,
SerialOK: false,
SerialError: msg,
Master: MasterView{OK: false, Error: msg},
Clients: []ClientView{},
}
}

View File

@ -3,7 +3,6 @@ package main
import (
"fmt"
"log"
"sync"
"time"
"go.bug.st/serial"
@ -13,9 +12,7 @@ import (
const readTimeout = 3 * time.Second
type serialPort struct {
port serial.Port
mu sync.Mutex
quiet bool
port serial.Port
}
func openSerial(portName string, baud int) (*serialPort, error) {
@ -42,12 +39,6 @@ func (s *serialPort) Close() error {
}
func (s *serialPort) exchangePayload(payload []byte, cmdName string) ([]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.exchangePayloadLocked(payload, cmdName)
}
func (s *serialPort) exchangePayloadLocked(payload []byte, cmdName string) ([]byte, error) {
if len(payload) == 0 {
return nil, fmt.Errorf("empty payload")
}
@ -56,9 +47,7 @@ func (s *serialPort) exchangePayloadLocked(payload []byte, cmdName string) ([]by
return nil, fmt.Errorf("encode frame: %w", err)
}
if !s.quiet {
log.Printf("sending %s command (%d bytes): % x", cmdName, len(frame), frame)
}
log.Printf("sending %s command (%d bytes): % x", cmdName, len(frame), frame)
if _, err := s.port.Write(frame); err != nil {
return nil, fmt.Errorf("write: %w", err)
}
@ -68,9 +57,7 @@ func (s *serialPort) exchangePayloadLocked(payload []byte, cmdName string) ([]by
return nil, fmt.Errorf("read response: %w", err)
}
if !s.quiet {
log.Printf("response payload (%d bytes): % x", len(respPayload), respPayload)
}
log.Printf("response payload (%d bytes): % x", len(respPayload), respPayload)
if len(respPayload) == 0 {
return nil, fmt.Errorf("empty response payload")
}
@ -78,20 +65,12 @@ func (s *serialPort) exchangePayloadLocked(payload []byte, cmdName string) ([]by
}
func (s *serialPort) exchange(cmdID byte, cmdName string) ([]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.exchangeLocked(cmdID, cmdName)
}
func (s *serialPort) exchangeLocked(cmdID byte, cmdName string) ([]byte, error) {
frame, err := uartframe.EncodeFrame([]byte{cmdID})
if err != nil {
return nil, fmt.Errorf("encode frame: %w", err)
}
if !s.quiet {
log.Printf("sending %s command (%d bytes): % x", cmdName, len(frame), frame)
}
log.Printf("sending %s command (%d bytes): % x", cmdName, len(frame), frame)
if _, err := s.port.Write(frame); err != nil {
return nil, fmt.Errorf("write: %w", err)
}
@ -101,9 +80,7 @@ func (s *serialPort) exchangeLocked(cmdID byte, cmdName string) ([]byte, error)
return nil, fmt.Errorf("read response: %w", err)
}
if !s.quiet {
log.Printf("response payload (%d bytes): % x", len(payload), payload)
}
log.Printf("response payload (%d bytes): % x", len(payload), payload)
if len(payload) == 0 {
return nil, fmt.Errorf("empty response payload")
}

View File

@ -1,529 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Powerpod Dashboard</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
<style>
:root {
--pp-bg: #0f1419;
--pp-surface: #1a1f26;
--pp-surface-raised: #222831;
--pp-border: #3d4449;
--pp-text: #f0f3f5;
--pp-text-secondary: #c5cdd6;
--pp-text-muted: #9aa8b5;
--pp-heading: #ffffff;
--pp-accent: #8ec8ff;
}
body {
background: var(--pp-bg);
color: var(--pp-text);
min-height: 100vh;
}
.navbar {
background: var(--pp-surface) !important;
border-bottom: 1px solid var(--pp-border);
}
.navbar-brand { color: var(--pp-heading) !important; }
.card {
background: var(--pp-surface);
border-color: var(--pp-border);
color: var(--pp-text);
}
.card-header {
background: var(--pp-surface-raised);
border-color: var(--pp-border);
color: var(--pp-heading);
font-weight: 600;
}
.text-muted { color: var(--pp-text-muted) !important; }
dl dt.text-muted {
color: var(--pp-text-secondary) !important;
font-weight: 500;
}
dl dd { color: var(--pp-text); }
.badge-online { background: #00a86b; color: #fff; }
.badge-offline { background: #5c6570; color: #f0f3f5; }
.badge.bg-secondary { background: #4a5560 !important; color: #f0f3f5; }
.mac {
font-family: ui-monospace, monospace;
font-size: 0.85rem;
color: var(--pp-accent);
}
.pp-table {
--bs-table-color: var(--pp-text);
--bs-table-bg: transparent;
--bs-table-border-color: var(--pp-border);
--bs-table-hover-color: var(--pp-heading);
--bs-table-hover-bg: rgba(255, 255, 255, 0.04);
margin-bottom: 0;
}
.pp-table thead th {
color: var(--pp-text-secondary);
font-weight: 600;
border-color: var(--pp-border);
background: var(--pp-surface-raised);
}
.pp-table tbody td {
color: var(--pp-text);
border-color: var(--pp-border);
vertical-align: middle;
}
.pp-table tbody tr:hover td { color: var(--pp-heading); }
.alert-danger {
background: rgba(220, 53, 69, 0.2);
border-color: #e35d6a;
color: #ffb3ba;
}
.alert-warning {
background: rgba(255, 193, 7, 0.15);
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; }
.config-input { max-width: 8rem; }
.config-block {
border-top: 1px solid var(--pp-border);
margin-top: 1rem;
padding-top: 1rem;
}
.progress {
background: var(--pp-surface-raised);
border: 1px solid var(--pp-border);
}
.progress-bar {
background: #2d6cdf;
}
.form-control[type="file"]::file-selector-button {
background: var(--pp-border);
border: none;
color: var(--pp-text);
margin-right: 0.5rem;
padding: 0.25rem 0.5rem;
}
</style>
</head>
<body x-data="dashboard()" x-init="connect()">
<nav class="navbar navbar-dark mb-4">
<div class="container-fluid">
<span class="navbar-brand mb-0 h1">Powerpod</span>
<span class="d-flex align-items-center gap-2">
<span class="badge rounded-pill me-1"
:class="state.uart_connected ? 'badge-online' : 'badge-offline'"
x-text="state.uart_connected ? 'UART' : 'UART off'"></span>
<span class="badge rounded-pill" :class="wsConnected ? 'badge-online' : 'badge-offline'"
x-text="wsConnected ? 'WS' : 'WS off'"></span>
<small class="text-muted ms-2" x-text="state.updated_at || '—'"></small>
</span>
</div>
</nav>
<main class="container pb-5">
<template x-if="configMsg">
<div class="alert py-2 mb-3"
:class="configMsgOk ? 'alert-success' : 'alert-danger'"
x-text="configMsg"></div>
</template>
<div class="row g-4">
<section class="col-lg-4">
<div class="card h-100">
<div class="card-header">Master (BMA456 lokal)</div>
<div class="card-body">
<p class="text-muted small mb-2" x-text="'UART ' + (state.serial_port || '—')"></p>
<p class="text-muted small mb-3">Deadzone nur am Master — Slaves bleiben unverändert.</p>
<template x-if="!state.uart_connected">
<div class="alert alert-warning py-2 mb-2"
x-text="state.serial_error || 'UART disconnected — reconnecting…'"></div>
</template>
<template x-if="state.uart_connected && !state.serial_ok">
<div class="alert alert-danger py-2" x-text="state.serial_error || 'Serial error'"></div>
</template>
<template x-if="state.master?.ok">
<dl class="row mb-0">
<dt class="col-5 text-muted">Version</dt>
<dd class="col-7" x-text="state.master.version"></dd>
<dt class="col-5 text-muted">Git</dt>
<dd class="col-7 text-break" x-text="state.master.git_hash"></dd>
<dt class="col-5 text-muted">Partition</dt>
<dd class="col-7" x-text="state.master.running_partition || '—'"></dd>
<dt class="col-5 text-muted">Deadzone</dt>
<dd class="col-7" x-text="state.master.deadzone != null ? state.master.deadzone + ' LSB' : '—'"></dd>
</dl>
</template>
<template x-if="state.master && !state.master.ok">
<div class="alert alert-warning py-2 mb-0" x-text="state.master.error"></div>
</template>
<div class="config-block">
<h6 class="text-secondary mb-2">Konfiguration Master</h6>
<label class="form-label text-muted small mb-1" for="master-dz-input">
Accel Deadzone (LSB)
</label>
<div class="d-flex flex-wrap gap-2 align-items-end">
<input id="master-dz-input" type="number"
class="form-control form-control-sm config-input"
min="0" max="4095" step="1"
placeholder="z. B. 100"
x-model.number="masterDz"
:disabled="busy || !state.uart_connected">
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="readMasterDeadzone()"
:disabled="busy || !state.uart_connected">
Lesen
</button>
<button type="button" class="btn btn-primary btn-sm"
@click="setMasterDeadzone()"
:disabled="busy || !state.uart_connected">
Setzen
</button>
</div>
<p class="text-muted small mt-2 mb-0" x-show="!state.uart_connected">
UART nicht verbunden — Eingabe gesperrt.
</p>
</div>
</div>
</div>
</section>
<section class="col-lg-8">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<span>ESP-NOW Slaves</span>
<div class="d-flex align-items-center gap-2">
<input type="number" class="form-control form-control-sm dz-input"
min="0" max="4095" placeholder="LSB"
x-model.number="allDz"
:disabled="busy">
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="setDeadzoneAll(allDz)"
:disabled="busy || !state.uart_connected">
Alle Slaves
</button>
<span class="badge bg-secondary" x-text="(state.clients || []).length + ' registered'"></span>
</div>
</div>
<p class="text-muted small px-3 pt-2 mb-0">Slaves per ESP-NOW — Master-Deadzone bleibt separat.</p>
<div class="card-body p-0 pt-2">
<div class="table-responsive">
<table class="table pp-table table-hover">
<thead>
<tr>
<th>ID</th>
<th>MAC</th>
<th>Ver</th>
<th>Status</th>
<th>Deadzone</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<template x-if="!(state.clients || []).length">
<tr><td colspan="6" class="text-muted text-center py-4">No clients</td></tr>
</template>
<template x-for="c in (state.clients || [])" :key="c.id + c.mac">
<tr>
<td x-text="c.id"></td>
<td class="mac" x-text="formatMac(c.mac)"></td>
<td x-text="c.version"></td>
<td>
<span class="badge rounded-pill"
:class="c.available ? 'badge-online' : 'badge-offline'"
x-text="c.available ? 'available' : 'inactive'"></span>
</td>
<td x-text="c.deadzone != null ? c.deadzone : '—'"></td>
<td>
<div class="d-flex flex-wrap gap-1 align-items-center">
<input type="number" class="form-control form-control-sm dz-input"
min="0" max="4095"
:placeholder="String(c.deadzone || 100)"
x-model.number="slaveDz[c.id]"
:disabled="busy">
<button type="button" class="btn btn-primary btn-sm"
@click="setDeadzone(c.id, slaveDz[c.id] ?? c.deadzone ?? 100)"
:disabled="busy || !state.uart_connected || !c.available">
Setzen
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="unicastTest(c.id)"
:disabled="busy || !state.uart_connected || !c.available"
title="ESP-NOW Unicast-Test">
Test
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</section>
<section class="col-12">
<div class="card">
<div class="card-header">Firmware OTA (A/B)</div>
<div class="card-body">
<p class="text-muted small mb-3">
Lädt eine <code>.bin</code> auf die inaktive OTA-Partition (wie <code>gotool ota</code>).
Während des Uploads pausiert das Live-Polling.
</p>
<div class="mb-3">
<input type="file" class="form-control form-control-sm" accept=".bin,application/octet-stream"
@change="otaFile = $event.target.files[0]"
:disabled="ota.active || busy || !state.uart_connected">
</div>
<div class="d-flex flex-wrap gap-2 align-items-center mb-2">
<button type="button" class="btn btn-primary btn-sm"
@click="uploadOTA()"
:disabled="ota.active || busy || !state.uart_connected || !otaFile">
Firmware hochladen
</button>
<span class="text-muted small" x-show="otaFile"
x-text="otaFile ? otaFile.name + ' (' + formatSize(otaFile.size) + ')' : ''"></span>
</div>
<template x-if="ota.active || ota.phase === 'done' || ota.phase === 'error'">
<div class="progress mb-2" style="height: 1.25rem;">
<div class="progress-bar" role="progressbar"
:style="'width: ' + ota.percent + '%'"
:class="ota.phase === 'error' ? 'bg-danger' : (ota.phase === 'done' ? 'bg-success' : '')"
x-text="ota.percent + '%'"></div>
</div>
<p class="small mb-0"
:class="ota.phase === 'error' ? 'text-danger' : (ota.phase === 'done' ? 'text-success' : 'text-muted')"
x-text="ota.message"></p>
</template>
</div>
</div>
</section>
</div>
</main>
<script>
function dashboard() {
return {
state: { master: {}, clients: [] },
ws: null,
wsConnected: false,
masterDz: 100,
allDz: 100,
slaveDz: {},
otaFile: null,
ota: { active: false, phase: '', percent: 0, message: '' },
busy: false,
configMsg: '',
configMsgOk: false,
connect() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = proto + '//' + location.host + '/ws';
const connect = () => {
this.ws = new WebSocket(url);
this.ws.onopen = () => { this.wsConnected = true; };
this.ws.onclose = () => {
this.wsConnected = false;
setTimeout(connect, 2000);
};
this.ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
if (msg.type === 'ota_progress') {
this.applyOTAProgress(msg);
return;
}
this.state = msg;
if (msg.master?.deadzone != null) {
this.masterDz = msg.master.deadzone;
}
for (const c of (msg.clients || [])) {
if (c.deadzone != null && this.slaveDz[c.id] == null) {
this.slaveDz[c.id] = c.deadzone;
}
}
} catch (_) {}
};
};
connect();
},
formatMac(hex) {
if (!hex || hex.length !== 12) return hex || '';
return hex.match(/.{2}/g).join(':');
},
formatSize(n) {
if (n == null) return '';
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KiB';
return (n / (1024 * 1024)).toFixed(2) + ' MiB';
},
applyOTAProgress(p) {
this.ota.phase = p.phase || '';
this.ota.percent = p.percent ?? 0;
this.ota.message = p.message || '';
if (p.phase === 'preparing' || p.phase === 'ready' || p.phase === 'uploading') {
this.ota.active = true;
}
if (p.phase === 'done' || p.phase === 'error') {
this.ota.active = false;
}
},
async uploadOTA() {
if (!this.otaFile) return;
this.ota = { active: true, phase: 'preparing', percent: 0, message: 'Upload startet…' };
this.busy = true;
const form = new FormData();
form.append('firmware', this.otaFile);
try {
const r = await fetch('/api/ota', { method: 'POST', body: form });
const data = await r.json();
if (!r.ok || !data.success) {
this.applyOTAProgress({
phase: 'error',
percent: 0,
message: data.error || 'OTA fehlgeschlagen'
});
return;
}
const slot = data.target_slot != null ? 'ota_' + data.target_slot : '?';
this.applyOTAProgress({
phase: 'done',
percent: 100,
message: `OK — ${data.bytes_written} Bytes nach ${slot} (Neustart zum Booten)`
});
} catch (e) {
this.applyOTAProgress({ phase: 'error', percent: 0, message: String(e) });
} finally {
this.busy = false;
this.ota.active = false;
}
},
flash(msg, ok) {
this.configMsg = msg;
this.configMsgOk = ok;
setTimeout(() => { this.configMsg = ''; }, 5000);
},
async setDeadzone(clientId, deadzone, opts = {}) {
if (deadzone == null || deadzone < 0) {
this.flash('Ungültiger Deadzone-Wert', false);
return;
}
this.busy = true;
try {
const r = await fetch('/api/deadzone', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
write: true,
deadzone: deadzone,
client_id: clientId,
all_clients: !!opts.allClients,
slaves_only: !!opts.slavesOnly
})
});
const data = await r.json();
if (!r.ok || !data.success) {
const err = data.error ||
(opts.slavesOnly ? 'Deadzone für Slaves fehlgeschlagen' : `Deadzone für Client ${clientId} fehlgeschlagen`);
this.flash(err, false);
return;
}
if (opts.slavesOnly) {
this.flash(`Slaves: Deadzone ${data.deadzone} LSB (${data.slaves_updated} per ESP-NOW, Master unverändert)`, true);
return;
}
const who = clientId === 0 ? 'Master' : `Slave ${clientId}`;
this.flash(`${who}: Deadzone ${data.deadzone} LSB gesetzt`, true);
} catch (e) {
this.flash(String(e), false);
} finally {
this.busy = false;
}
},
async readMasterDeadzone() {
this.busy = true;
try {
const r = await fetch('/api/deadzone?client_id=0');
const data = await r.json();
if (!r.ok || !data.success) {
this.flash(data.error || 'Master-Deadzone lesen fehlgeschlagen', false);
return;
}
this.masterDz = data.deadzone;
this.flash(`Master: Deadzone ${data.deadzone} LSB`, true);
} catch (e) {
this.flash(String(e), false);
} finally {
this.busy = false;
}
},
async setMasterDeadzone() {
await this.setDeadzone(0, this.masterDz);
},
async setDeadzoneAll(deadzone) {
if (deadzone == null || deadzone < 0) {
this.flash('Ungültiger Deadzone-Wert', false);
return;
}
await this.setDeadzone(0, deadzone, { allClients: true, slavesOnly: true });
},
async unicastTest(clientId) {
this.busy = true;
try {
const r = await fetch('/api/unicast-test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id: clientId, seq: Date.now() % 100000 })
});
const data = await r.json();
if (!r.ok || !data.success) {
this.flash(data.error || `Unicast-Test Slave ${clientId} fehlgeschlagen`, false);
return;
}
this.flash(`Unicast-Test Slave ${clientId} OK (seq ${data.seq})`, true);
} catch (e) {
this.flash(String(e), false);
} finally {
this.busy = false;
}
}
};
}
</script>
</body>
</html>

View File

@ -19,13 +19,10 @@ idf_component_register(
"cmd_client_info.c"
"cmd_accel_deadzone.c"
"cmd_espnow_unicast_test.c"
"cmd_ota.c"
"ota_uart.c"
"client_registry.c"
"esp_now_comm.c"
"esp_now_proto.c"
"bosch456.c"
"board_input.c"
"proto/uart_messages.pb.c"
"proto/esp_now_messages.pb.c"
"proto/pb_encode.c"
@ -43,7 +40,6 @@ idf_component_register(
esp_driver_gpio
esp_driver_uart
esp_driver_i2c
esp_adc
app_update
bma456)

View File

@ -51,11 +51,6 @@ Pins (`powerpod.h`):
| UART RX | 2 |
| LED ring | 7 |
| BMA456 INT | 10 |
| Button (Taster) | 12 |
| LiPo sense 1 (ADC) | 1 |
| LiPo sense 2 (ADC) | 12 (skipped if same as button) |
> **TODO:** GPIO assignments above are provisional; confirm pinning against the real board before release.
Startup order:
@ -63,8 +58,7 @@ Startup order:
2. **I2C bus** — IO expander `0x20`; optional **BMA456H** (`init_bma456`, same bus)
3. `esp_now_comm_init(&app_config)` — WiFi + ESP-NOW
4. `led_ring_init()`
5. `board_input_init()` — button press logs, LiPo ADC logs every **10 s**
6. **Master only:** command queue, UART, registered commands (e.g. VERSION)
5. **Master only:** command queue, UART, registered commands (e.g. VERSION)
## BMA456 accelerometer (`bosch456.c`)
@ -184,11 +178,7 @@ Host and master speak nanopb-encoded `UartMessage` inside UART frames (byte 0 =
| 4 | `CLIENT_INFO` | Implemented (`cmd_client_info.c`) — slave list from registry |
| 5 | `CLIENT_INPUT` | Planned |
| 6 | `ACCEL_DEADZONE` | Implemented (`cmd_accel_deadzone.c`) — get/set accel filter LSB |
| 16 | `OTA_START` | Implemented (`cmd_ota.c`) — begin UART OTA on inactive slot |
| 17 | `OTA_PAYLOAD` | Implemented — up to 200 B per frame; device buffers 4 KiB |
| 18 | `OTA_END` | Implemented — flush, `esp_ota_end`, set boot partition |
| 19 | `OTA_STATUS` | Device → host (prepare/ready/block ACK/success/failed) |
| 20 | `OTA_START_ESPNOW` | Planned |
| 1620 | OTA / ESP-NOW OTA | Planned |
Regenerate C code:
@ -212,32 +202,9 @@ Build embeds `POWERPOD_GIT_HASH` via `git rev-parse` in `main/CMakeLists.txt`.
- `type = VERSION`
- `version_response.version``POWERPOD_FW_VERSION`
- `version_response.git_hash` — build git hash string
- `version_response.running_partition` — active OTA label (`ota_0` / `ota_1`)
Encoding: `uart_send_uart_message()` in `uart_proto.c`.
At boot, firmware logs the running partition and OTA slot index (A/B).
### UART OTA (A/B)
Master only. Inactive app partition is selected with `esp_ota_get_next_update_partition()`; `esp_ota_begin` erases it (can take ~30 s — host should wait).
| Step | Host → device | Device → host |
|------|----------------|---------------|
| 1 | `OTA_START` + `total_size` | `OTA_STATUS` preparing, then **ready** (+ `target_slot` 0/1) |
| 2 | `OTA_PAYLOAD` chunks (**≤200 B**, `seq` optional) | `OTA_STATUS` **block_ack** only after each **4096 B** written to flash |
| 3 | `OTA_END` | `OTA_STATUS` **success** or **failed** (+ `bytes_written`) |
Implementation: `ota_uart.c` (4 KiB buffer, `esp_ota_write`), `cmd_ota.c`.
Host upload:
```bash
go run . -port /dev/ttyUSB0 ota build/powerpod.bin
```
`OtaStatusPayload.status`: `1` preparing, `2` ready, `3` block_ack, `4` success, `5` failed.
### ACCEL_DEADZONE command
Sets the **software** deadzone used by `bosch456.c` when logging accel (see [BMA456 accelerometer](#bma456-accelerometer-bosch456c)). Default **100** LSB.
@ -343,7 +310,6 @@ Target: ESP32-S3. Close serial monitor on the UART adapter port before running `
| `cmd_client_info.c/h` | CLIENT_INFO handler |
| `client_registry.c/h` | Registered slave table |
| `bosch456.c/h` | BMA456H I2C driver, accel poll, tap INT, deadzone filter |
| `board_input.c/h` | Taster GPIO12, LiPo ADC on GPIO1 / GPIO12 |
| `led_ring.c/h` | LED digit display |
| `proto/uart_messages.proto` | UART protocol schema |
| `proto/esp_now_messages.proto` | ESP-NOW protocol schema |

View File

@ -12,6 +12,4 @@ typedef struct {
char running_partition[APP_RUNNING_PARTITION_LABEL_MAX];
} app_config_t;
const app_config_t *app_config_get(void);
#endif

View File

@ -1,193 +0,0 @@
#include "board_input.h"
#include "powerpod.h"
#include "driver/gpio.h"
#include "esp_adc/adc_oneshot.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/idf_additions.h"
#include "freertos/queue.h"
#include <stdint.h>
static const char *TAG_BTN = "[BTN]";
static const char *TAG_LIPO = "[LIPO]";
#define LIPO_SAMPLE_INTERVAL_MS 10000
#define BUTTON_QUEUE_LEN 4
#define BUTTON_DEBOUNCE_MS 80
static QueueHandle_t s_button_queue;
static adc_oneshot_unit_handle_t s_adc;
static bool s_lipo1_ok;
static bool s_lipo2_ok;
static adc_channel_t s_lipo1_ch;
static adc_channel_t s_lipo2_ch;
static esp_err_t adc_init_channel(int gpio, adc_channel_t *out_ch, bool *out_ok) {
adc_unit_t unit;
esp_err_t err = adc_oneshot_io_to_channel(gpio, &unit, out_ch);
if (err != ESP_OK) {
ESP_LOGW(TAG_LIPO, "GPIO%d not an ADC channel: %s", gpio, esp_err_to_name(err));
*out_ok = false;
return err;
}
if (unit != ADC_UNIT_1) {
ESP_LOGW(TAG_LIPO, "GPIO%d on ADC unit %d (expected ADC1)", gpio, (int)unit);
}
adc_oneshot_chan_cfg_t chan_cfg = {
.atten = ADC_ATTEN_DB_12,
.bitwidth = ADC_BITWIDTH_DEFAULT,
};
err = adc_oneshot_config_channel(s_adc, *out_ch, &chan_cfg);
if (err != ESP_OK) {
ESP_LOGW(TAG_LIPO, "ADC config GPIO%d failed: %s", gpio, esp_err_to_name(err));
*out_ok = false;
return err;
}
*out_ok = true;
return ESP_OK;
}
static void lipo_monitor_task(void *param) {
(void)param;
ESP_LOGI(TAG_LIPO, "monitor task (interval %d ms)", LIPO_SAMPLE_INTERVAL_MS);
while (1) {
int raw1 = -1;
int raw2 = -1;
int mv1 = -1;
int mv2 = -1;
if (s_lipo1_ok) {
raw1 = 0;
if (adc_oneshot_read(s_adc, s_lipo1_ch, &raw1) == ESP_OK) {
mv1 = (raw1 * 3300) / 4095;
}
}
if (s_lipo2_ok) {
raw2 = 0;
if (adc_oneshot_read(s_adc, s_lipo2_ch, &raw2) == ESP_OK) {
mv2 = (raw2 * 3300) / 4095;
}
}
ESP_LOGI(TAG_LIPO,
"LIPO1 GPIO%d raw=%d (~%d mV) LIPO2 GPIO%d raw=%d (~%d mV)",
V_LIPO_1_GPIO, raw1, mv1, V_LIPO_2_GPIO, raw2, mv2);
vTaskDelay(pdMS_TO_TICKS(LIPO_SAMPLE_INTERVAL_MS));
}
}
static void IRAM_ATTR button_isr(void *arg) {
(void)arg;
uint8_t one = 1;
BaseType_t wake = pdFALSE;
if (s_button_queue != NULL) {
xQueueSendFromISR(s_button_queue, &one, &wake);
if (wake) {
portYIELD_FROM_ISR();
}
}
}
static void button_task(void *param) {
(void)param;
uint8_t evt;
while (1) {
if (xQueueReceive(s_button_queue, &evt, portMAX_DELAY) != pdTRUE) {
continue;
}
vTaskDelay(pdMS_TO_TICKS(BUTTON_DEBOUNCE_MS));
if (gpio_get_level(TASTER_GPIO) == 0) {
ESP_LOGI(TAG_BTN, "pressed (GPIO%d)", TASTER_GPIO);
}
}
}
static esp_err_t init_button(void) {
if (V_LIPO_2_GPIO == TASTER_GPIO) {
ESP_LOGW(TAG_BTN,
"GPIO%d shared with V_LIPO_2 — button only, no ADC on that pin",
TASTER_GPIO);
}
s_button_queue = xQueueCreate(BUTTON_QUEUE_LEN, sizeof(uint8_t));
if (s_button_queue == NULL) {
return ESP_ERR_NO_MEM;
}
gpio_config_t cfg = {
.pin_bit_mask = 1ULL << TASTER_GPIO,
.mode = GPIO_MODE_INPUT,
.pull_up_en = GPIO_PULLUP_ENABLE,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_NEGEDGE,
};
ESP_ERROR_CHECK(gpio_config(&cfg));
esp_err_t err = gpio_install_isr_service(0);
if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
return err;
}
err = gpio_isr_handler_add(TASTER_GPIO, button_isr, NULL);
if (err != ESP_OK) {
return err;
}
if (xTaskCreate(button_task, "btn", 2048, NULL, 2, NULL) != pdPASS) {
return ESP_ERR_NO_MEM;
}
ESP_LOGI(TAG_BTN, "ready GPIO%d (active low)", TASTER_GPIO);
return ESP_OK;
}
static esp_err_t init_lipo_adc(void) {
adc_oneshot_unit_init_cfg_t init_cfg = {
.unit_id = ADC_UNIT_1,
};
esp_err_t err = adc_oneshot_new_unit(&init_cfg, &s_adc);
if (err != ESP_OK) {
ESP_LOGW(TAG_LIPO, "ADC init failed: %s", esp_err_to_name(err));
return err;
}
adc_init_channel(V_LIPO_1_GPIO, &s_lipo1_ch, &s_lipo1_ok);
if (V_LIPO_2_GPIO == TASTER_GPIO) {
ESP_LOGW(TAG_LIPO, "LIPO2 on GPIO%d skipped (button uses same pin)",
V_LIPO_2_GPIO);
s_lipo2_ok = false;
} else {
adc_init_channel(V_LIPO_2_GPIO, &s_lipo2_ch, &s_lipo2_ok);
}
if (!s_lipo1_ok && !s_lipo2_ok) {
adc_oneshot_del_unit(s_adc);
s_adc = NULL;
return ESP_FAIL;
}
if (xTaskCreate(lipo_monitor_task, "lipo_mon", 3072, NULL, 1, NULL) != pdPASS) {
return ESP_ERR_NO_MEM;
}
return ESP_OK;
}
esp_err_t board_input_init(void) {
esp_err_t err = init_button();
if (err != ESP_OK) {
ESP_LOGW(TAG_BTN, "init failed: %s", esp_err_to_name(err));
}
err = init_lipo_adc();
if (err != ESP_OK) {
ESP_LOGW(TAG_LIPO, "init failed: %s", esp_err_to_name(err));
}
return ESP_OK;
}

View File

@ -1,12 +0,0 @@
#ifndef BOARD_INPUT_H
#define BOARD_INPUT_H
#include "esp_err.h"
/**
* Button (log on press) and LiPo ADC sampling (log every 10 s).
* TODO: Pin assignments come from powerpod.h and may not match final hardware yet.
*/
esp_err_t board_input_init(void);
#endif

View File

@ -47,7 +47,7 @@ static const char *message_type_name(uint16_t id) {
void init_cmdHandler(QueueHandle_t queue) {
cmd_queue = queue;
if (xTaskCreate(vCmdDispatcherTask, "cmd_dispatch", 8192, NULL, 5, NULL) !=
if (xTaskCreate(vCmdDispatcherTask, "cmd_dispatch", 4096, NULL, 5, NULL) !=
pdPASS) {
ESP_LOGE(TAG, "failed to create cmd_dispatch task");
}

View File

@ -1,163 +0,0 @@
#include "cmd_ota.h"
#include "ota_uart.h"
#include "uart_cmd.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/idf_additions.h"
#include <string.h>
static const char *TAG = "[OTA_CMD]";
#define OTA_PREPARE_STACK 8192
#define OTA_PREPARE_PRIO 5
static void send_ota_status(ota_uart_status_t status, uint32_t err_code) {
alox_UartMessage response;
uart_cmd_init_response(&response, alox_MessageType_OTA_STATUS,
alox_UartMessage_ota_status_tag);
response.payload.ota_status.status = (uint32_t)status;
response.payload.ota_status.bytes_written = ota_uart_bytes_written();
int slot = ota_uart_target_slot();
response.payload.ota_status.target_slot =
slot >= 0 ? (uint32_t)slot : 0;
response.payload.ota_status.error = err_code;
uart_cmd_send(&response, TAG);
}
static void ota_prepare_task(void *param) {
uint32_t total_size = (uint32_t)(uintptr_t)param;
send_ota_status(OTA_UART_ST_PREPARING, 0);
int slot = ota_uart_prepare(total_size);
if (slot < 0) {
send_ota_status(OTA_UART_ST_FAILED, 1);
vTaskDelete(NULL);
return;
}
alox_UartMessage response;
uart_cmd_init_response(&response, alox_MessageType_OTA_STATUS,
alox_UartMessage_ota_status_tag);
response.payload.ota_status.status = (uint32_t)OTA_UART_ST_READY;
response.payload.ota_status.bytes_written = 0;
response.payload.ota_status.target_slot = (uint32_t)slot;
response.payload.ota_status.error = 0;
uart_cmd_send(&response, TAG);
vTaskDelete(NULL);
}
static void handle_ota_start(const uint8_t *data, size_t len) {
alox_UartMessage uart_msg;
alox_OtaStartPayload req = alox_OtaStartPayload_init_zero;
if (uart_cmd_decode(data, len, &uart_msg) != ESP_OK) {
send_ota_status(OTA_UART_ST_FAILED, 2);
return;
}
const alox_OtaStartPayload *req_ptr =
UART_CMD_REQ(&uart_msg, alox_UartMessage_ota_start_tag, ota_start);
if (req_ptr != NULL) {
req = *req_ptr;
}
if (req.total_size == 0) {
ESP_LOGW(TAG, "OTA_START: total_size required");
send_ota_status(OTA_UART_ST_FAILED, 3);
return;
}
if (ota_uart_is_active()) {
ESP_LOGW(TAG, "OTA_START while session active");
send_ota_status(OTA_UART_ST_FAILED, 4);
return;
}
if (xTaskCreate(ota_prepare_task, "ota_prepare", OTA_PREPARE_STACK,
(void *)(uintptr_t)req.total_size, OTA_PREPARE_PRIO,
NULL) != pdPASS) {
ESP_LOGE(TAG, "failed to create ota_prepare task");
send_ota_status(OTA_UART_ST_FAILED, 5);
}
}
static void handle_ota_payload(const uint8_t *data, size_t len) {
alox_UartMessage uart_msg;
if (uart_cmd_decode(data, len, &uart_msg) != ESP_OK) {
ESP_LOGW(TAG, "OTA_PAYLOAD decode failed");
send_ota_status(OTA_UART_ST_FAILED, 10);
return;
}
const alox_OtaPayload *req_ptr =
UART_CMD_REQ(&uart_msg, alox_UartMessage_ota_payload_tag, ota_payload);
if (req_ptr == NULL) {
ESP_LOGW(TAG, "OTA_PAYLOAD: missing ota_payload (which=%u)",
(unsigned)uart_msg.which_payload);
send_ota_status(OTA_UART_ST_FAILED, 11);
return;
}
if (req_ptr->data.size == 0) {
ESP_LOGW(TAG, "OTA_PAYLOAD: empty data (seq=%lu)",
(unsigned long)req_ptr->seq);
send_ota_status(OTA_UART_ST_FAILED, 11);
return;
}
if (!ota_uart_is_active()) {
ESP_LOGW(TAG, "OTA_PAYLOAD without active session (seq=%lu)",
(unsigned long)req_ptr->seq);
send_ota_status(OTA_UART_ST_FAILED, 12);
return;
}
ota_feed_result_t r =
ota_uart_feed(req_ptr->data.bytes, req_ptr->data.size);
if (r == OTA_FEED_ERROR) {
send_ota_status(OTA_UART_ST_FAILED, 13);
return;
}
if (r == OTA_FEED_BLOCK_WRITTEN) {
ESP_LOGI(TAG, "OTA block ack (%lu bytes in flash)",
(unsigned long)ota_uart_bytes_written());
send_ota_status(OTA_UART_ST_BLOCK_ACK, 0);
}
}
static void handle_ota_end(const uint8_t *data, size_t len) {
(void)data;
(void)len;
if (!ota_uart_is_active()) {
send_ota_status(OTA_UART_ST_FAILED, 20);
return;
}
uint32_t written = ota_uart_bytes_written();
int slot = ota_uart_target_slot();
bool success = false;
esp_err_t err = ota_uart_finish(&success);
if (err != ESP_OK || !success) {
send_ota_status(OTA_UART_ST_FAILED, (uint32_t)err);
return;
}
alox_UartMessage response;
uart_cmd_init_response(&response, alox_MessageType_OTA_STATUS,
alox_UartMessage_ota_status_tag);
response.payload.ota_status.status = (uint32_t)OTA_UART_ST_SUCCESS;
response.payload.ota_status.bytes_written = written;
response.payload.ota_status.target_slot = slot >= 0 ? (uint32_t)slot : 0;
response.payload.ota_status.error = 0;
uart_cmd_send(&response, TAG);
}
void cmd_ota_register(void) {
uart_cmd_register(alox_MessageType_OTA_START, handle_ota_start);
uart_cmd_register(alox_MessageType_OTA_PAYLOAD, handle_ota_payload);
uart_cmd_register(alox_MessageType_OTA_END, handle_ota_end);
}

View File

@ -1,6 +0,0 @@
#ifndef CMD_OTA_H
#define CMD_OTA_H
void cmd_ota_register(void);
#endif

View File

@ -1,5 +1,4 @@
#include "cmd_version.h"
#include "app_config.h"
#include "uart_cmd.h"
#ifndef POWERPOD_FW_VERSION
@ -22,13 +21,6 @@ static void handle_version(const uint8_t *data, size_t len) {
response.payload.version_response.version = POWERPOD_FW_VERSION;
response.payload.version_response.git_hash.funcs.encode = uart_cmd_encode_string;
response.payload.version_response.git_hash.arg = (void *)POWERPOD_GIT_HASH;
const app_config_t *cfg = app_config_get();
if (cfg != NULL && cfg->running_partition[0] != '\0') {
response.payload.version_response.running_partition.funcs.encode =
uart_cmd_encode_string;
response.payload.version_response.running_partition.arg =
(void *)cfg->running_partition;
}
uart_cmd_send(&response, TAG);
}

View File

@ -1,184 +0,0 @@
#include "ota_uart.h"
#include "esp_log.h"
#include "esp_ota_ops.h"
#include <string.h>
static const char *TAG = "[OTA_UART]";
typedef struct {
bool active;
esp_ota_handle_t handle;
const esp_partition_t *update_partition;
uint32_t total_size;
uint32_t received;
uint32_t written;
int target_slot;
uint8_t block_buf[OTA_UART_FLASH_BLOCK_SIZE];
size_t block_len;
} ota_uart_state_t;
static ota_uart_state_t s_ota;
static int partition_slot(const esp_partition_t *part) {
if (part == NULL) {
return -1;
}
if (part->subtype == ESP_PARTITION_SUBTYPE_APP_OTA_0) {
return 0;
}
if (part->subtype == ESP_PARTITION_SUBTYPE_APP_OTA_1) {
return 1;
}
return -1;
}
bool ota_uart_is_active(void) { return s_ota.active; }
int ota_uart_target_slot(void) {
return s_ota.active ? s_ota.target_slot : -1;
}
void ota_uart_abort(void) {
if (!s_ota.active) {
return;
}
esp_ota_abort(s_ota.handle);
memset(&s_ota, 0, sizeof(s_ota));
}
static esp_err_t flush_block(void) {
if (s_ota.block_len == 0) {
return ESP_OK;
}
esp_err_t err =
esp_ota_write(s_ota.handle, s_ota.block_buf, s_ota.block_len);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_ota_write %u bytes failed: %s", (unsigned)s_ota.block_len,
esp_err_to_name(err));
return err;
}
s_ota.written += (uint32_t)s_ota.block_len;
s_ota.block_len = 0;
return ESP_OK;
}
int ota_uart_prepare(uint32_t total_size) {
if (s_ota.active) {
ESP_LOGW(TAG, "OTA already active");
return -1;
}
const esp_partition_t *running = esp_ota_get_running_partition();
const esp_partition_t *update_partition =
esp_ota_get_next_update_partition(NULL);
if (update_partition == NULL) {
ESP_LOGE(TAG, "no OTA update partition");
return -1;
}
ESP_LOGI(TAG, "running=%s, update=%s, image_size=%lu",
running != NULL ? running->label : "?",
update_partition->label, (unsigned long)total_size);
if (total_size > 0 && total_size > update_partition->size) {
ESP_LOGE(TAG, "image too large (%lu > %lu)", (unsigned long)total_size,
(unsigned long)update_partition->size);
return -1;
}
esp_ota_handle_t handle;
esp_err_t err = esp_ota_begin(update_partition, OTA_SIZE_UNKNOWN, &handle);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_ota_begin failed: %s", esp_err_to_name(err));
return -1;
}
memset(&s_ota, 0, sizeof(s_ota));
s_ota.active = true;
s_ota.handle = handle;
s_ota.update_partition = update_partition;
s_ota.total_size = total_size;
s_ota.target_slot = partition_slot(update_partition);
ESP_LOGI(TAG, "OTA prepared, target slot %d (%s) — send 4 KiB chunks",
s_ota.target_slot, update_partition->label);
return s_ota.target_slot;
}
ota_feed_result_t ota_uart_feed(const uint8_t *data, size_t len) {
if (!s_ota.active || data == NULL || len == 0) {
return OTA_FEED_ERROR;
}
if (len > OTA_UART_HOST_CHUNK_SIZE) {
ESP_LOGW(TAG, "chunk %u > %u, truncating", (unsigned)len,
OTA_UART_HOST_CHUNK_SIZE);
len = OTA_UART_HOST_CHUNK_SIZE;
}
bool block_written = false;
size_t offset = 0;
while (offset < len) {
size_t space = OTA_UART_FLASH_BLOCK_SIZE - s_ota.block_len;
size_t n = len - offset;
if (n > space) {
n = space;
}
memcpy(s_ota.block_buf + s_ota.block_len, data + offset, n);
s_ota.block_len += n;
s_ota.received += (uint32_t)n;
offset += n;
if (s_ota.block_len < OTA_UART_FLASH_BLOCK_SIZE) {
continue;
}
if (flush_block() != ESP_OK) {
ota_uart_abort();
return OTA_FEED_ERROR;
}
block_written = true;
}
return block_written ? OTA_FEED_BLOCK_WRITTEN : OTA_FEED_OK;
}
uint32_t ota_uart_bytes_written(void) { return s_ota.written; }
esp_err_t ota_uart_finish(bool *success_out) {
if (success_out != NULL) {
*success_out = false;
}
if (!s_ota.active) {
return ESP_ERR_INVALID_STATE;
}
esp_err_t err = flush_block();
if (err != ESP_OK) {
ota_uart_abort();
return err;
}
err = esp_ota_end(s_ota.handle);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_ota_end failed: %s", esp_err_to_name(err));
ota_uart_abort();
return err;
}
err = esp_ota_set_boot_partition(s_ota.update_partition);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_ota_set_boot_partition failed: %s", esp_err_to_name(err));
memset(&s_ota, 0, sizeof(s_ota));
return err;
}
ESP_LOGI(TAG, "OTA complete: %lu bytes written to %s (slot %d), reboot to run",
(unsigned long)s_ota.written, s_ota.update_partition->label,
s_ota.target_slot);
if (success_out != NULL) {
*success_out = true;
}
memset(&s_ota, 0, sizeof(s_ota));
return ESP_OK;
}

View File

@ -1,45 +0,0 @@
#ifndef OTA_UART_H
#define OTA_UART_H
#include "esp_err.h"
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#define OTA_UART_HOST_CHUNK_SIZE 200u
#define OTA_UART_FLASH_BLOCK_SIZE 4096u
/** OtaStatusPayload.status values (device → host). */
typedef enum {
OTA_UART_ST_PREPARING = 1,
OTA_UART_ST_READY = 2,
OTA_UART_ST_BLOCK_ACK = 3,
OTA_UART_ST_SUCCESS = 4,
OTA_UART_ST_FAILED = 5,
} ota_uart_status_t;
typedef enum {
OTA_FEED_OK = 0,
OTA_FEED_BLOCK_WRITTEN,
OTA_FEED_ERROR,
} ota_feed_result_t;
bool ota_uart_is_active(void);
/** 0/1 while session active, else -1. */
int ota_uart_target_slot(void);
/** Begin OTA on the inactive app partition (esp_ota_begin). Returns target slot 0/1. */
int ota_uart_prepare(uint32_t total_size);
void ota_uart_abort(void);
/** Append up to 200 bytes; flushes 4 KiB blocks to flash when full. */
ota_feed_result_t ota_uart_feed(const uint8_t *data, size_t len);
uint32_t ota_uart_bytes_written(void);
/** Flush remainder, esp_ota_end, set boot partition on success. */
esp_err_t ota_uart_finish(bool *success_out);
#endif

View File

@ -4,7 +4,6 @@
#include "cmd_espnow_unicast_test.h"
#include "cmd_client_info.h"
#include "cmd_version.h"
#include "cmd_ota.h"
#include "esp_now_comm.h"
#include "powerpod.h"
#include "driver/gpio.h"
@ -16,7 +15,6 @@
#include "esp_ota_ops.h"
#include "freertos/FreeRTOS.h"
#include "freertos/idf_additions.h"
#include "board_input.h"
#include "bosch456.h"
#include "led_ring.h"
#include "uart.h"
@ -51,8 +49,6 @@ static i2c_master_dev_handle_t io_expander;
static app_config_t app_config;
const app_config_t *app_config_get(void) { return &app_config; }
static QueueHandle_t cmd_queue;
uint8_t reverse_high_nibble_lut(uint8_t n) {
@ -126,29 +122,16 @@ void app_main(void) {
}
const esp_partition_t *running = esp_ota_get_running_partition();
int ota_slot = -1;
if (running != NULL) {
if (running->subtype == ESP_PARTITION_SUBTYPE_APP_OTA_0) {
ota_slot = 0;
} else if (running->subtype == ESP_PARTITION_SUBTYPE_APP_OTA_1) {
ota_slot = 1;
}
}
app_config.master = (master != 0);
app_config.network = network;
if (running != NULL) {
memcpy(app_config.running_partition, running->label,
sizeof(app_config.running_partition));
} else {
app_config.running_partition[0] = '\0';
}
memcpy(app_config.running_partition, running->label,
sizeof(app_config.running_partition));
ESP_LOGI(TAG, "RUNNING CONFIG:");
ESP_LOGI(TAG, "Master: %d", app_config.master);
ESP_LOGI(TAG, "Network: %d", app_config.network);
ESP_LOGI(TAG, "Running Partition: %s (OTA slot %d)",
app_config.running_partition, ota_slot);
ESP_LOGI(TAG, "Running Partition: %s", app_config.running_partition);
err = esp_now_comm_init(&app_config);
if (err != ESP_OK) {
@ -157,17 +140,14 @@ void app_main(void) {
led_ring_init();
board_input_init();
if (app_config.master) {
cmd_queue = xQueueCreate(64, sizeof(generic_msg_t));
cmd_queue = xQueueCreate(10, sizeof(generic_msg_t));
init_cmdHandler(cmd_queue);
init_uart(cmd_queue);
cmd_version_register();
cmd_client_info_register();
cmd_accel_deadzone_register();
cmd_espnow_unicast_test_register();
cmd_ota_register();
}
uint8_t current_digit = 10;

View File

@ -7,13 +7,4 @@
#define I2C_PORT 0
#define IO_EXPANDER_ADDRESS 0x20
/* TODO: Hardware pinning not finalized — verify against schematic before production. */
/** Front-panel button (active low, internal pull-up). */
#define TASTER_GPIO 12
/** LiPo voltage sense inputs (ADC1-capable GPIOs). */
#define V_LIPO_1_GPIO 1
/** Shares GPIO with TASTER on current bench wiring; second ADC is skipped in board_input.c. */
#define V_LIPO_2_GPIO 12
#endif

View File

@ -1 +0,0 @@
OtaPayload.data max_size:200

View File

@ -38,8 +38,6 @@ typedef struct _alox_EchoPayload {
typedef struct _alox_VersionResponse {
uint32_t version;
pb_callback_t git_hash;
/* * Active OTA app partition label, e.g. "ota_0" or "ota_1". */
pb_callback_t running_partition;
} alox_VersionResponse;
typedef struct _alox_ClientInfo {
@ -94,30 +92,23 @@ typedef struct _alox_EspNowUnicastTestResponse {
uint32_t seq;
} alox_EspNowUnicastTestResponse;
/* Host → device: begin UART OTA (erase inactive OTA slot; device replies OTA_STATUS). */
typedef struct _alox_OtaStartPayload {
uint32_t total_size;
uint32_t block_size;
} alox_OtaStartPayload;
/* Host → device: firmware chunk (up to 200 bytes); device buffers 4 KiB before flash write. */
typedef PB_BYTES_ARRAY_T(200) alox_OtaPayload_data_t;
typedef struct _alox_OtaPayload {
uint32_t seq;
alox_OtaPayload_data_t data;
uint32_t block_id;
uint32_t chunk_id;
pb_callback_t data;
} alox_OtaPayload;
/* Host → device: no more payload; device flushes buffer and finalizes OTA. */
typedef struct _alox_OtaEndPayload {
char dummy_field;
uint32_t status;
} alox_OtaEndPayload;
/* Device → host status (also used as ACK after each 4 KiB written).
status: 1=preparing, 2=ready, 3=block_ack, 4=success, 5=failed */
typedef struct _alox_OtaStatusPayload {
uint32_t status;
uint32_t bytes_written;
uint32_t target_slot;
uint32_t error;
} alox_OtaStatusPayload;
typedef struct _alox_UartMessage {
@ -172,7 +163,7 @@ extern "C" {
#define alox_UartMessage_init_default {_alox_MessageType_MIN, 0, {alox_Ack_init_default}}
#define alox_Ack_init_default {0}
#define alox_EchoPayload_init_default {{{NULL}, NULL}}
#define alox_VersionResponse_init_default {0, {{NULL}, NULL}, {{NULL}, NULL}}
#define alox_VersionResponse_init_default {0, {{NULL}, NULL}}
#define alox_ClientInfo_init_default {0, 0, 0, {{NULL}, NULL}, 0, 0, 0}
#define alox_ClientInfoResponse_init_default {{{NULL}, NULL}}
#define alox_ClientInput_init_default {0, 0, 0, 0}
@ -181,14 +172,14 @@ extern "C" {
#define alox_AccelDeadzoneResponse_init_default {0, 0, 0, 0}
#define alox_EspNowUnicastTestRequest_init_default {0, 0}
#define alox_EspNowUnicastTestResponse_init_default {0, 0}
#define alox_OtaStartPayload_init_default {0}
#define alox_OtaPayload_init_default {0, {0, {0}}}
#define alox_OtaStartPayload_init_default {0, 0}
#define alox_OtaPayload_init_default {0, 0, {{NULL}, NULL}}
#define alox_OtaEndPayload_init_default {0}
#define alox_OtaStatusPayload_init_default {0, 0, 0, 0}
#define alox_OtaStatusPayload_init_default {0}
#define alox_UartMessage_init_zero {_alox_MessageType_MIN, 0, {alox_Ack_init_zero}}
#define alox_Ack_init_zero {0}
#define alox_EchoPayload_init_zero {{{NULL}, NULL}}
#define alox_VersionResponse_init_zero {0, {{NULL}, NULL}, {{NULL}, NULL}}
#define alox_VersionResponse_init_zero {0, {{NULL}, NULL}}
#define alox_ClientInfo_init_zero {0, 0, 0, {{NULL}, NULL}, 0, 0, 0}
#define alox_ClientInfoResponse_init_zero {{{NULL}, NULL}}
#define alox_ClientInput_init_zero {0, 0, 0, 0}
@ -197,16 +188,15 @@ extern "C" {
#define alox_AccelDeadzoneResponse_init_zero {0, 0, 0, 0}
#define alox_EspNowUnicastTestRequest_init_zero {0, 0}
#define alox_EspNowUnicastTestResponse_init_zero {0, 0}
#define alox_OtaStartPayload_init_zero {0}
#define alox_OtaPayload_init_zero {0, {0, {0}}}
#define alox_OtaStartPayload_init_zero {0, 0}
#define alox_OtaPayload_init_zero {0, 0, {{NULL}, NULL}}
#define alox_OtaEndPayload_init_zero {0}
#define alox_OtaStatusPayload_init_zero {0, 0, 0, 0}
#define alox_OtaStatusPayload_init_zero {0}
/* Field tags (for use in manual encoding/decoding) */
#define alox_EchoPayload_data_tag 1
#define alox_VersionResponse_version_tag 1
#define alox_VersionResponse_git_hash_tag 2
#define alox_VersionResponse_running_partition_tag 3
#define alox_ClientInfo_id_tag 1
#define alox_ClientInfo_available_tag 2
#define alox_ClientInfo_used_tag 3
@ -233,12 +223,12 @@ extern "C" {
#define alox_EspNowUnicastTestResponse_success_tag 1
#define alox_EspNowUnicastTestResponse_seq_tag 2
#define alox_OtaStartPayload_total_size_tag 1
#define alox_OtaPayload_seq_tag 1
#define alox_OtaPayload_data_tag 2
#define alox_OtaStartPayload_block_size_tag 2
#define alox_OtaPayload_block_id_tag 1
#define alox_OtaPayload_chunk_id_tag 2
#define alox_OtaPayload_data_tag 3
#define alox_OtaEndPayload_status_tag 1
#define alox_OtaStatusPayload_status_tag 1
#define alox_OtaStatusPayload_bytes_written_tag 2
#define alox_OtaStatusPayload_target_slot_tag 3
#define alox_OtaStatusPayload_error_tag 4
#define alox_UartMessage_type_tag 1
#define alox_UartMessage_ack_payload_tag 2
#define alox_UartMessage_echo_payload_tag 3
@ -298,8 +288,7 @@ X(a, CALLBACK, SINGULAR, BYTES, data, 1)
#define alox_VersionResponse_FIELDLIST(X, a) \
X(a, STATIC, SINGULAR, UINT32, version, 1) \
X(a, CALLBACK, SINGULAR, STRING, git_hash, 2) \
X(a, CALLBACK, SINGULAR, STRING, running_partition, 3)
X(a, CALLBACK, SINGULAR, STRING, git_hash, 2)
#define alox_VersionResponse_CALLBACK pb_default_field_callback
#define alox_VersionResponse_DEFAULT NULL
@ -363,26 +352,25 @@ X(a, STATIC, SINGULAR, UINT32, seq, 2)
#define alox_EspNowUnicastTestResponse_DEFAULT NULL
#define alox_OtaStartPayload_FIELDLIST(X, a) \
X(a, STATIC, SINGULAR, UINT32, total_size, 1)
X(a, STATIC, SINGULAR, UINT32, total_size, 1) \
X(a, STATIC, SINGULAR, UINT32, block_size, 2)
#define alox_OtaStartPayload_CALLBACK NULL
#define alox_OtaStartPayload_DEFAULT NULL
#define alox_OtaPayload_FIELDLIST(X, a) \
X(a, STATIC, SINGULAR, UINT32, seq, 1) \
X(a, STATIC, SINGULAR, BYTES, data, 2)
#define alox_OtaPayload_CALLBACK NULL
X(a, STATIC, SINGULAR, UINT32, block_id, 1) \
X(a, STATIC, SINGULAR, UINT32, chunk_id, 2) \
X(a, CALLBACK, SINGULAR, BYTES, data, 3)
#define alox_OtaPayload_CALLBACK pb_default_field_callback
#define alox_OtaPayload_DEFAULT NULL
#define alox_OtaEndPayload_FIELDLIST(X, a) \
X(a, STATIC, SINGULAR, UINT32, status, 1)
#define alox_OtaEndPayload_CALLBACK NULL
#define alox_OtaEndPayload_DEFAULT NULL
#define alox_OtaStatusPayload_FIELDLIST(X, a) \
X(a, STATIC, SINGULAR, UINT32, status, 1) \
X(a, STATIC, SINGULAR, UINT32, bytes_written, 2) \
X(a, STATIC, SINGULAR, UINT32, target_slot, 3) \
X(a, STATIC, SINGULAR, UINT32, error, 4)
X(a, STATIC, SINGULAR, UINT32, status, 1)
#define alox_OtaStatusPayload_CALLBACK NULL
#define alox_OtaStatusPayload_DEFAULT NULL
@ -429,16 +417,16 @@ extern const pb_msgdesc_t alox_OtaStatusPayload_msg;
/* alox_ClientInfoResponse_size depends on runtime parameters */
/* alox_ClientInputResponse_size depends on runtime parameters */
/* alox_OtaPayload_size depends on runtime parameters */
#define ALOX_MAIN_PROTO_UART_MESSAGES_PB_H_MAX_SIZE alox_OtaStatusPayload_size
#define ALOX_MAIN_PROTO_UART_MESSAGES_PB_H_MAX_SIZE alox_ClientInput_size
#define alox_AccelDeadzoneRequest_size 16
#define alox_AccelDeadzoneResponse_size 20
#define alox_Ack_size 0
#define alox_ClientInput_size 22
#define alox_EspNowUnicastTestRequest_size 12
#define alox_EspNowUnicastTestResponse_size 8
#define alox_OtaEndPayload_size 0
#define alox_OtaStartPayload_size 6
#define alox_OtaStatusPayload_size 24
#define alox_OtaEndPayload_size 6
#define alox_OtaStartPayload_size 12
#define alox_OtaStatusPayload_size 6
#ifdef __cplusplus
} /* extern "C" */

View File

@ -1,7 +1,5 @@
syntax = "proto3";
import "nanopb.proto";
package alox;
enum MessageType {
@ -48,8 +46,6 @@ message EchoPayload {
message VersionResponse {
uint32 version = 1;
string git_hash = 2;
/** Active OTA app partition label, e.g. "ota_0" or "ota_1". */
string running_partition = 3;
}
message ClientInfo {
@ -104,25 +100,21 @@ message EspNowUnicastTestResponse {
uint32 seq = 2;
}
// Host device: begin UART OTA (erase inactive OTA slot; device replies OTA_STATUS).
message OtaStartPayload {
uint32 total_size = 1;
uint32 block_size = 2;
}
// Host device: firmware chunk (up to 200 bytes); device buffers 4 KiB before flash write.
message OtaPayload {
uint32 seq = 1;
bytes data = 2 [(nanopb).max_size = 200];
uint32 block_id = 1;
uint32 chunk_id = 2;
bytes data = 3;
}
// Host device: no more payload; device flushes buffer and finalizes OTA.
message OtaEndPayload {}
message OtaEndPayload {
uint32 status = 1;
}
// Device host status (also used as ACK after each 4 KiB written).
// status: 1=preparing, 2=ready, 3=block_ack, 4=success, 5=failed
message OtaStatusPayload {
uint32 status = 1;
uint32 bytes_written = 2;
uint32 target_slot = 3;
uint32 error = 4;
}

View File

@ -32,9 +32,9 @@ static bool uart_enqueue_packet(const uart_packet_t *packet) {
memcpy(msg.payload, &packet->payload[1], msg.len);
}
if (xQueueSend(uart_cmd_queue, &msg, pdMS_TO_TICKS(500)) != pdPASS) {
if (xQueueSend(uart_cmd_queue, &msg, 0) != pdPASS) {
free(msg.payload);
ESP_LOGW(TAG, "command queue full (cmd 0x%02x)", (unsigned)msg.msg_id);
ESP_LOGW(TAG, "command queue full");
return false;
}
return true;
@ -52,7 +52,7 @@ void init_uart(QueueHandle_t cmd_queue) {
.flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
};
err = uart_driver_install(UART_NUM, UART_BUF_SIZE * 2, UART_BUF_SIZE, 0, NULL, 0);
err = uart_driver_install(UART_NUM, UART_BUF_SIZE * 2, 0, 0, NULL, 0);
if (err != ESP_OK) {
ESP_LOGE(TAG, "uart_driver_install failed: %s", esp_err_to_name(err));
return;
@ -69,7 +69,7 @@ void init_uart(QueueHandle_t cmd_queue) {
return;
}
if (xTaskCreate(uart_read_task, "uart_rx", 4096, NULL, 5, NULL) != pdPASS) {
if (xTaskCreate(uart_read_task, "uart_rx", 4096, NULL, 1, NULL) != pdPASS) {
ESP_LOGE(TAG, "failed to create uart_read_task");
}
}

View File

@ -12,10 +12,10 @@
#define UART_TXD_PIN 3
#define UART_RXD_PIN 2
#define UART_BUF_SIZE 2048
#define UART_BUF_SIZE 256
#define START_MARKER 0xAA
#define STOP_MARKER 0xCC
#define MAX_BUF_SIZE 252
#define MAX_BUF_SIZE 256
#define MAX_PAYLOAD_SIZE \
MAX_BUF_SIZE - 4 // Buffer overhead, Start, Len, CRC, End