Compare commits
7 Commits
0299ba44fd
...
4bf43d8a5e
| Author | SHA1 | Date | |
|---|---|---|---|
| 4bf43d8a5e | |||
| 59ca269407 | |||
| 1ad527119d | |||
| 80fb9cf55e | |||
| 85aeab85c0 | |||
| caf1b8d0d8 | |||
| c4696657a7 |
9
Makefile
9
Makefile
@ -8,9 +8,10 @@ GOTOOL_RUN := cd $(GOTOOL_DIR) && go run . -port $(PORT)
|
|||||||
.PHONY: default proto_generate proto_generate_uart proto_generate_espnow \
|
.PHONY: default proto_generate proto_generate_uart proto_generate_espnow \
|
||||||
gotool-build gotool-proto gotool-tidy gotool-test-units \
|
gotool-build gotool-proto gotool-tidy gotool-test-units \
|
||||||
gotool-version gotool-clients gotool-unicast-test gotool-deadzone-get gotool-deadzone-set \
|
gotool-version gotool-clients gotool-unicast-test gotool-deadzone-get gotool-deadzone-set \
|
||||||
gotool-test
|
gotool-test gotool-serve
|
||||||
|
|
||||||
TEST_CONFIG ?= example-lab
|
TEST_CONFIG ?= example-lab
|
||||||
|
SERVE_ADDR ?= :8080
|
||||||
TEST_SCENARIO ?= smoke
|
TEST_SCENARIO ?= smoke
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@ -18,7 +19,8 @@ default:
|
|||||||
@echo "Set PORT=$(PORT) (current) for goTool targets."
|
@echo "Set PORT=$(PORT) (current) for goTool targets."
|
||||||
|
|
||||||
proto_generate_uart:
|
proto_generate_uart:
|
||||||
python libs/nanopb/generator/nanopb_generator.py main/proto/uart_messages.proto
|
cd main/proto && python ../../libs/nanopb/generator/nanopb_generator.py \
|
||||||
|
-I ../../libs/nanopb/generator/proto uart_messages.proto
|
||||||
|
|
||||||
proto_generate_espnow:
|
proto_generate_espnow:
|
||||||
python libs/nanopb/generator/nanopb_generator.py main/proto/esp_now_messages.proto
|
python libs/nanopb/generator/nanopb_generator.py main/proto/esp_now_messages.proto
|
||||||
@ -62,4 +64,7 @@ gotool-test-units:
|
|||||||
gotool-test: $(GOTOOL)
|
gotool-test: $(GOTOOL)
|
||||||
$(GOTOOL) -port $(PORT) test -config $(TEST_CONFIG) -scenario $(TEST_SCENARIO)
|
$(GOTOOL) -port $(PORT) test -config $(TEST_CONFIG) -scenario $(TEST_SCENARIO)
|
||||||
|
|
||||||
|
gotool-serve: $(GOTOOL)
|
||||||
|
$(GOTOOL) -port $(PORT) serve -addr $(SERVE_ADDR)
|
||||||
|
|
||||||
$(GOTOOL): gotool-build
|
$(GOTOOL): gotool-build
|
||||||
|
|||||||
@ -26,6 +26,8 @@ go run . -port /dev/ttyUSB0 clients
|
|||||||
| `clients` | `0x04` | Lists slaves registered on the master via ESP-NOW |
|
| `clients` | `0x04` | Lists slaves registered on the master via ESP-NOW |
|
||||||
| `unicast-test` | `0x07` | Sends ESP-NOW unicast test to one slave (`-client`, `-seq`) |
|
| `unicast-test` | `0x07` | Sends ESP-NOW unicast test to one slave (`-client`, `-seq`) |
|
||||||
| `test` | — | Run an automated scenario (JSON configs under `testdata/`) |
|
| `test` | — | Run an automated scenario (JSON configs under `testdata/`) |
|
||||||
|
| `serve` | — | Web dashboard at `http://localhost:8080` (WebSocket live updates) |
|
||||||
|
| `ota` | 16–19 | 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.
|
`clients` requires slaves to have responded to master discover broadcasts first.
|
||||||
|
|
||||||
@ -43,6 +45,37 @@ With a complete bench config, `-port` is optional for `test` (uses `uart.master`
|
|||||||
|
|
||||||
See [`testdata/README.md`](testdata/README.md) for the JSON schema.
|
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
|
```bash
|
||||||
go run . -port /dev/ttyUSB0 unicast-test -client 16 -seq 42
|
go run . -port /dev/ttyUSB0 unicast-test -client 16 -seq 42
|
||||||
```
|
```
|
||||||
|
|||||||
259
goTool/api_serve.go
Normal file
259
goTool/api_serve.go
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@ -8,11 +8,43 @@ import (
|
|||||||
"powerpod/gotool/pb"
|
"powerpod/gotool/pb"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *serialPort) getVersion() (*pb.VersionResponse, error) {
|
func (m *managedSerial) getVersion() (*pb.VersionResponse, error) {
|
||||||
payload, err := s.exchange(byte(pb.MessageType_VERSION), "VERSION")
|
payload, err := m.exchange(byte(pb.MessageType_VERSION), "VERSION")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
var msg pb.UartMessage
|
||||||
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
|
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
|
||||||
return nil, fmt.Errorf("decode: %w", err)
|
return nil, fmt.Errorf("decode: %w", err)
|
||||||
@ -27,11 +59,7 @@ func (s *serialPort) getVersion() (*pb.VersionResponse, error) {
|
|||||||
return ver, nil
|
return ver, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *serialPort) listClients() ([]*pb.ClientInfo, error) {
|
func decodeClientsPayload(payload []byte) ([]*pb.ClientInfo, error) {
|
||||||
payload, err := s.exchange(byte(pb.MessageType_CLIENT_INFO), "CLIENT_INFO")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var msg pb.UartMessage
|
var msg pb.UartMessage
|
||||||
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
|
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
|
||||||
return nil, fmt.Errorf("decode: %w", err)
|
return nil, fmt.Errorf("decode: %w", err)
|
||||||
@ -46,6 +74,22 @@ func (s *serialPort) listClients() ([]*pb.ClientInfo, error) {
|
|||||||
return info.GetClients(), nil
|
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) {
|
func (s *serialPort) accelDeadzone(req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) {
|
||||||
msg := &pb.UartMessage{
|
msg := &pb.UartMessage{
|
||||||
Type: pb.MessageType_ACCEL_DEADZONE,
|
Type: pb.MessageType_ACCEL_DEADZONE,
|
||||||
|
|||||||
32
goTool/cmd_ota.go
Normal file
32
goTool/cmd_ota.go
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
70
goTool/cmd_serve.go
Normal file
70
goTool/cmd_serve.go
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
201
goTool/dashboard.go
Normal file
201
goTool/dashboard.go
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ module powerpod/gotool
|
|||||||
go 1.26.2
|
go 1.26.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
go.bug.st/serial v1.6.4
|
go.bug.st/serial v1.6.4
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
)
|
)
|
||||||
|
|||||||
@ -4,6 +4,8 @@ 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/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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||||
|
|||||||
@ -17,7 +17,9 @@ func usage() {
|
|||||||
fmt.Fprintf(os.Stderr, " clients registered ESP-NOW slaves on the master\n")
|
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, " 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, " unicast-test send ESP-NOW unicast test to one slave\n")
|
||||||
fmt.Fprintf(os.Stderr, " test run automated scenario (see testdata/)\n\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")
|
||||||
flag.PrintDefaults()
|
flag.PrintDefaults()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -37,7 +39,14 @@ func main() {
|
|||||||
switch cmd {
|
switch cmd {
|
||||||
case "test", "autotest":
|
case "test", "autotest":
|
||||||
runErr = runTest(*portName, *baud, flag.Args()[1:])
|
runErr = runTest(*portName, *baud, flag.Args()[1:])
|
||||||
case "version", "clients", "client-info", "deadzone", "accel-deadzone", "unicast-test", "unicast_test":
|
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":
|
||||||
if *portName == "" {
|
if *portName == "" {
|
||||||
fmt.Fprintf(os.Stderr, "command %q requires -port\n\n", cmd)
|
fmt.Fprintf(os.Stderr, "command %q requires -port\n\n", cmd)
|
||||||
usage()
|
usage()
|
||||||
@ -57,6 +66,8 @@ func main() {
|
|||||||
runErr = runDeadzone(sp, flag.Args()[1:])
|
runErr = runDeadzone(sp, flag.Args()[1:])
|
||||||
case "unicast-test", "unicast_test":
|
case "unicast-test", "unicast_test":
|
||||||
runErr = runUnicastTest(sp, flag.Args()[1:])
|
runErr = runUnicastTest(sp, flag.Args()[1:])
|
||||||
|
case "ota":
|
||||||
|
runErr = runOTA(sp, flag.Args()[1:])
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
fmt.Fprintf(os.Stderr, "unknown command %q\n\n", cmd)
|
fmt.Fprintf(os.Stderr, "unknown command %q\n\n", cmd)
|
||||||
|
|||||||
252
goTool/ota_upload.go
Normal file
252
goTool/ota_upload.go
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@ -450,6 +450,8 @@ type VersionResponse struct {
|
|||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Version uint32 `protobuf:"varint,1,opt,name=version,proto3" json:"version,omitempty"`
|
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"`
|
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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -498,6 +500,13 @@ func (x *VersionResponse) GetGitHash() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *VersionResponse) GetRunningPartition() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.RunningPartition
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
type ClientInfo struct {
|
type ClientInfo struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Id uint32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
|
Id uint32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||||
@ -989,10 +998,10 @@ func (x *EspNowUnicastTestResponse) GetSeq() uint32 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Host → device: begin UART OTA (erase inactive OTA slot; device replies OTA_STATUS).
|
||||||
type OtaStartPayload struct {
|
type OtaStartPayload struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
TotalSize uint32 `protobuf:"varint,1,opt,name=total_size,json=totalSize,proto3" json:"total_size,omitempty"`
|
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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -1034,18 +1043,11 @@ func (x *OtaStartPayload) GetTotalSize() uint32 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *OtaStartPayload) GetBlockSize() uint32 {
|
// Host → device: firmware chunk (up to 200 bytes); device buffers 4 KiB before flash write.
|
||||||
if x != nil {
|
|
||||||
return x.BlockSize
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
type OtaPayload struct {
|
type OtaPayload struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
BlockId uint32 `protobuf:"varint,1,opt,name=block_id,json=blockId,proto3" json:"block_id,omitempty"`
|
Seq uint32 `protobuf:"varint,1,opt,name=seq,proto3" json:"seq,omitempty"`
|
||||||
ChunkId uint32 `protobuf:"varint,2,opt,name=chunk_id,json=chunkId,proto3" json:"chunk_id,omitempty"`
|
Data []byte `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"`
|
||||||
Data []byte `protobuf:"bytes,3,opt,name=data,proto3" json:"data,omitempty"`
|
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -1080,16 +1082,9 @@ func (*OtaPayload) Descriptor() ([]byte, []int) {
|
|||||||
return file_uart_messages_proto_rawDescGZIP(), []int{13}
|
return file_uart_messages_proto_rawDescGZIP(), []int{13}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *OtaPayload) GetBlockId() uint32 {
|
func (x *OtaPayload) GetSeq() uint32 {
|
||||||
if x != nil {
|
if x != nil {
|
||||||
return x.BlockId
|
return x.Seq
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *OtaPayload) GetChunkId() uint32 {
|
|
||||||
if x != nil {
|
|
||||||
return x.ChunkId
|
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@ -1101,9 +1096,9 @@ func (x *OtaPayload) GetData() []byte {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Host → device: no more payload; device flushes buffer and finalizes OTA.
|
||||||
type OtaEndPayload struct {
|
type OtaEndPayload struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Status uint32 `protobuf:"varint,1,opt,name=status,proto3" json:"status,omitempty"`
|
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -1138,16 +1133,14 @@ func (*OtaEndPayload) Descriptor() ([]byte, []int) {
|
|||||||
return file_uart_messages_proto_rawDescGZIP(), []int{14}
|
return file_uart_messages_proto_rawDescGZIP(), []int{14}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *OtaEndPayload) GetStatus() uint32 {
|
// Device → host status (also used as ACK after each 4 KiB written).
|
||||||
if x != nil {
|
// status: 1=preparing, 2=ready, 3=block_ack, 4=success, 5=failed
|
||||||
return x.Status
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
type OtaStatusPayload struct {
|
type OtaStatusPayload struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Status uint32 `protobuf:"varint,1,opt,name=status,proto3" json:"status,omitempty"`
|
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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -1189,6 +1182,27 @@ func (x *OtaStatusPayload) GetStatus() uint32 {
|
|||||||
return 0
|
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
|
var File_uart_messages_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
const file_uart_messages_proto_rawDesc = "" +
|
const file_uart_messages_proto_rawDesc = "" +
|
||||||
@ -1216,10 +1230,11 @@ const file_uart_messages_proto_rawDesc = "" +
|
|||||||
"\apayload\"\x05\n" +
|
"\apayload\"\x05\n" +
|
||||||
"\x03Ack\"!\n" +
|
"\x03Ack\"!\n" +
|
||||||
"\vEchoPayload\x12\x12\n" +
|
"\vEchoPayload\x12\x12\n" +
|
||||||
"\x04data\x18\x01 \x01(\fR\x04data\"F\n" +
|
"\x04data\x18\x01 \x01(\fR\x04data\"s\n" +
|
||||||
"\x0fVersionResponse\x12\x18\n" +
|
"\x0fVersionResponse\x12\x18\n" +
|
||||||
"\aversion\x18\x01 \x01(\rR\aversion\x12\x19\n" +
|
"\aversion\x18\x01 \x01(\rR\aversion\x12\x19\n" +
|
||||||
"\bgit_hash\x18\x02 \x01(\tR\agitHash\"\xc3\x01\n" +
|
"\bgit_hash\x18\x02 \x01(\tR\agitHash\x12+\n" +
|
||||||
|
"\x11running_partition\x18\x03 \x01(\tR\x10runningPartition\"\xc3\x01\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"ClientInfo\x12\x0e\n" +
|
"ClientInfo\x12\x0e\n" +
|
||||||
"\x02id\x18\x01 \x01(\rR\x02id\x12\x1c\n" +
|
"\x02id\x18\x01 \x01(\rR\x02id\x12\x1c\n" +
|
||||||
@ -1254,21 +1269,21 @@ const file_uart_messages_proto_rawDesc = "" +
|
|||||||
"\x03seq\x18\x02 \x01(\rR\x03seq\"G\n" +
|
"\x03seq\x18\x02 \x01(\rR\x03seq\"G\n" +
|
||||||
"\x19EspNowUnicastTestResponse\x12\x18\n" +
|
"\x19EspNowUnicastTestResponse\x12\x18\n" +
|
||||||
"\asuccess\x18\x01 \x01(\bR\asuccess\x12\x10\n" +
|
"\asuccess\x18\x01 \x01(\bR\asuccess\x12\x10\n" +
|
||||||
"\x03seq\x18\x02 \x01(\rR\x03seq\"O\n" +
|
"\x03seq\x18\x02 \x01(\rR\x03seq\"0\n" +
|
||||||
"\x0fOtaStartPayload\x12\x1d\n" +
|
"\x0fOtaStartPayload\x12\x1d\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"total_size\x18\x01 \x01(\rR\ttotalSize\x12\x1d\n" +
|
"total_size\x18\x01 \x01(\rR\ttotalSize\"2\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"block_size\x18\x02 \x01(\rR\tblockSize\"V\n" +
|
"OtaPayload\x12\x10\n" +
|
||||||
"\n" +
|
"\x03seq\x18\x01 \x01(\rR\x03seq\x12\x12\n" +
|
||||||
"OtaPayload\x12\x19\n" +
|
"\x04data\x18\x02 \x01(\fR\x04data\"\x0f\n" +
|
||||||
"\bblock_id\x18\x01 \x01(\rR\ablockId\x12\x19\n" +
|
"\rOtaEndPayload\"\x86\x01\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" +
|
"\x10OtaStatusPayload\x12\x16\n" +
|
||||||
"\x06status\x18\x01 \x01(\rR\x06status*\xdd\x01\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" +
|
||||||
"\vMessageType\x12\v\n" +
|
"\vMessageType\x12\v\n" +
|
||||||
"\aUNKNOWN\x10\x00\x12\a\n" +
|
"\aUNKNOWN\x10\x00\x12\a\n" +
|
||||||
"\x03ACK\x10\x01\x12\b\n" +
|
"\x03ACK\x10\x01\x12\b\n" +
|
||||||
|
|||||||
122
goTool/serial_link.go
Normal file
122
goTool/serial_link.go
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
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{},
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.bug.st/serial"
|
"go.bug.st/serial"
|
||||||
@ -13,6 +14,8 @@ const readTimeout = 3 * time.Second
|
|||||||
|
|
||||||
type serialPort struct {
|
type serialPort struct {
|
||||||
port serial.Port
|
port serial.Port
|
||||||
|
mu sync.Mutex
|
||||||
|
quiet bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func openSerial(portName string, baud int) (*serialPort, error) {
|
func openSerial(portName string, baud int) (*serialPort, error) {
|
||||||
@ -39,6 +42,12 @@ func (s *serialPort) Close() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *serialPort) exchangePayload(payload []byte, cmdName string) ([]byte, 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 {
|
if len(payload) == 0 {
|
||||||
return nil, fmt.Errorf("empty payload")
|
return nil, fmt.Errorf("empty payload")
|
||||||
}
|
}
|
||||||
@ -47,7 +56,9 @@ func (s *serialPort) exchangePayload(payload []byte, cmdName string) ([]byte, er
|
|||||||
return nil, fmt.Errorf("encode frame: %w", err)
|
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 {
|
if _, err := s.port.Write(frame); err != nil {
|
||||||
return nil, fmt.Errorf("write: %w", err)
|
return nil, fmt.Errorf("write: %w", err)
|
||||||
}
|
}
|
||||||
@ -57,7 +68,9 @@ func (s *serialPort) exchangePayload(payload []byte, cmdName string) ([]byte, er
|
|||||||
return nil, fmt.Errorf("read response: %w", err)
|
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 {
|
if len(respPayload) == 0 {
|
||||||
return nil, fmt.Errorf("empty response payload")
|
return nil, fmt.Errorf("empty response payload")
|
||||||
}
|
}
|
||||||
@ -65,12 +78,20 @@ func (s *serialPort) exchangePayload(payload []byte, cmdName string) ([]byte, er
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *serialPort) exchange(cmdID byte, cmdName string) ([]byte, error) {
|
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})
|
frame, err := uartframe.EncodeFrame([]byte{cmdID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("encode frame: %w", err)
|
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 {
|
if _, err := s.port.Write(frame); err != nil {
|
||||||
return nil, fmt.Errorf("write: %w", err)
|
return nil, fmt.Errorf("write: %w", err)
|
||||||
}
|
}
|
||||||
@ -80,7 +101,9 @@ func (s *serialPort) exchange(cmdID byte, cmdName string) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("read response: %w", err)
|
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 {
|
if len(payload) == 0 {
|
||||||
return nil, fmt.Errorf("empty response payload")
|
return nil, fmt.Errorf("empty response payload")
|
||||||
}
|
}
|
||||||
|
|||||||
529
goTool/webui/index.html
Normal file
529
goTool/webui/index.html
Normal file
@ -0,0 +1,529 @@
|
|||||||
|
<!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>
|
||||||
@ -19,10 +19,13 @@ idf_component_register(
|
|||||||
"cmd_client_info.c"
|
"cmd_client_info.c"
|
||||||
"cmd_accel_deadzone.c"
|
"cmd_accel_deadzone.c"
|
||||||
"cmd_espnow_unicast_test.c"
|
"cmd_espnow_unicast_test.c"
|
||||||
|
"cmd_ota.c"
|
||||||
|
"ota_uart.c"
|
||||||
"client_registry.c"
|
"client_registry.c"
|
||||||
"esp_now_comm.c"
|
"esp_now_comm.c"
|
||||||
"esp_now_proto.c"
|
"esp_now_proto.c"
|
||||||
"bosch456.c"
|
"bosch456.c"
|
||||||
|
"board_input.c"
|
||||||
"proto/uart_messages.pb.c"
|
"proto/uart_messages.pb.c"
|
||||||
"proto/esp_now_messages.pb.c"
|
"proto/esp_now_messages.pb.c"
|
||||||
"proto/pb_encode.c"
|
"proto/pb_encode.c"
|
||||||
@ -40,6 +43,7 @@ idf_component_register(
|
|||||||
esp_driver_gpio
|
esp_driver_gpio
|
||||||
esp_driver_uart
|
esp_driver_uart
|
||||||
esp_driver_i2c
|
esp_driver_i2c
|
||||||
|
esp_adc
|
||||||
app_update
|
app_update
|
||||||
bma456)
|
bma456)
|
||||||
|
|
||||||
|
|||||||
@ -51,6 +51,11 @@ Pins (`powerpod.h`):
|
|||||||
| UART RX | 2 |
|
| UART RX | 2 |
|
||||||
| LED ring | 7 |
|
| LED ring | 7 |
|
||||||
| BMA456 INT | 10 |
|
| 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:
|
Startup order:
|
||||||
|
|
||||||
@ -58,7 +63,8 @@ Startup order:
|
|||||||
2. **I2C bus** — IO expander `0x20`; optional **BMA456H** (`init_bma456`, same bus)
|
2. **I2C bus** — IO expander `0x20`; optional **BMA456H** (`init_bma456`, same bus)
|
||||||
3. `esp_now_comm_init(&app_config)` — WiFi + ESP-NOW
|
3. `esp_now_comm_init(&app_config)` — WiFi + ESP-NOW
|
||||||
4. `led_ring_init()`
|
4. `led_ring_init()`
|
||||||
5. **Master only:** command queue, UART, registered commands (e.g. VERSION)
|
5. `board_input_init()` — button press logs, LiPo ADC logs every **10 s**
|
||||||
|
6. **Master only:** command queue, UART, registered commands (e.g. VERSION)
|
||||||
|
|
||||||
## BMA456 accelerometer (`bosch456.c`)
|
## BMA456 accelerometer (`bosch456.c`)
|
||||||
|
|
||||||
@ -178,7 +184,11 @@ Host and master speak nanopb-encoded `UartMessage` inside UART frames (byte 0 =
|
|||||||
| 4 | `CLIENT_INFO` | Implemented (`cmd_client_info.c`) — slave list from registry |
|
| 4 | `CLIENT_INFO` | Implemented (`cmd_client_info.c`) — slave list from registry |
|
||||||
| 5 | `CLIENT_INPUT` | Planned |
|
| 5 | `CLIENT_INPUT` | Planned |
|
||||||
| 6 | `ACCEL_DEADZONE` | Implemented (`cmd_accel_deadzone.c`) — get/set accel filter LSB |
|
| 6 | `ACCEL_DEADZONE` | Implemented (`cmd_accel_deadzone.c`) — get/set accel filter LSB |
|
||||||
| 16–20 | OTA / ESP-NOW OTA | Planned |
|
| 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 |
|
||||||
|
|
||||||
Regenerate C code:
|
Regenerate C code:
|
||||||
|
|
||||||
@ -202,9 +212,32 @@ Build embeds `POWERPOD_GIT_HASH` via `git rev-parse` in `main/CMakeLists.txt`.
|
|||||||
- `type = VERSION`
|
- `type = VERSION`
|
||||||
- `version_response.version` — `POWERPOD_FW_VERSION`
|
- `version_response.version` — `POWERPOD_FW_VERSION`
|
||||||
- `version_response.git_hash` — build git hash string
|
- `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`.
|
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
|
### ACCEL_DEADZONE command
|
||||||
|
|
||||||
Sets the **software** deadzone used by `bosch456.c` when logging accel (see [BMA456 accelerometer](#bma456-accelerometer-bosch456c)). Default **100** LSB.
|
Sets the **software** deadzone used by `bosch456.c` when logging accel (see [BMA456 accelerometer](#bma456-accelerometer-bosch456c)). Default **100** LSB.
|
||||||
@ -310,6 +343,7 @@ Target: ESP32-S3. Close serial monitor on the UART adapter port before running `
|
|||||||
| `cmd_client_info.c/h` | CLIENT_INFO handler |
|
| `cmd_client_info.c/h` | CLIENT_INFO handler |
|
||||||
| `client_registry.c/h` | Registered slave table |
|
| `client_registry.c/h` | Registered slave table |
|
||||||
| `bosch456.c/h` | BMA456H I2C driver, accel poll, tap INT, deadzone filter |
|
| `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 |
|
| `led_ring.c/h` | LED digit display |
|
||||||
| `proto/uart_messages.proto` | UART protocol schema |
|
| `proto/uart_messages.proto` | UART protocol schema |
|
||||||
| `proto/esp_now_messages.proto` | ESP-NOW protocol schema |
|
| `proto/esp_now_messages.proto` | ESP-NOW protocol schema |
|
||||||
|
|||||||
@ -12,4 +12,6 @@ typedef struct {
|
|||||||
char running_partition[APP_RUNNING_PARTITION_LABEL_MAX];
|
char running_partition[APP_RUNNING_PARTITION_LABEL_MAX];
|
||||||
} app_config_t;
|
} app_config_t;
|
||||||
|
|
||||||
|
const app_config_t *app_config_get(void);
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
193
main/board_input.c
Normal file
193
main/board_input.c
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
#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;
|
||||||
|
}
|
||||||
12
main/board_input.h
Normal file
12
main/board_input.h
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
#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
|
||||||
@ -47,7 +47,7 @@ static const char *message_type_name(uint16_t id) {
|
|||||||
|
|
||||||
void init_cmdHandler(QueueHandle_t queue) {
|
void init_cmdHandler(QueueHandle_t queue) {
|
||||||
cmd_queue = queue;
|
cmd_queue = queue;
|
||||||
if (xTaskCreate(vCmdDispatcherTask, "cmd_dispatch", 4096, NULL, 5, NULL) !=
|
if (xTaskCreate(vCmdDispatcherTask, "cmd_dispatch", 8192, NULL, 5, NULL) !=
|
||||||
pdPASS) {
|
pdPASS) {
|
||||||
ESP_LOGE(TAG, "failed to create cmd_dispatch task");
|
ESP_LOGE(TAG, "failed to create cmd_dispatch task");
|
||||||
}
|
}
|
||||||
|
|||||||
163
main/cmd_ota.c
Normal file
163
main/cmd_ota.c
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
#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);
|
||||||
|
}
|
||||||
6
main/cmd_ota.h
Normal file
6
main/cmd_ota.h
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
#ifndef CMD_OTA_H
|
||||||
|
#define CMD_OTA_H
|
||||||
|
|
||||||
|
void cmd_ota_register(void);
|
||||||
|
|
||||||
|
#endif
|
||||||
@ -1,4 +1,5 @@
|
|||||||
#include "cmd_version.h"
|
#include "cmd_version.h"
|
||||||
|
#include "app_config.h"
|
||||||
#include "uart_cmd.h"
|
#include "uart_cmd.h"
|
||||||
|
|
||||||
#ifndef POWERPOD_FW_VERSION
|
#ifndef POWERPOD_FW_VERSION
|
||||||
@ -21,6 +22,13 @@ static void handle_version(const uint8_t *data, size_t len) {
|
|||||||
response.payload.version_response.version = POWERPOD_FW_VERSION;
|
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.funcs.encode = uart_cmd_encode_string;
|
||||||
response.payload.version_response.git_hash.arg = (void *)POWERPOD_GIT_HASH;
|
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);
|
uart_cmd_send(&response, TAG);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
184
main/ota_uart.c
Normal file
184
main/ota_uart.c
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
#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;
|
||||||
|
}
|
||||||
45
main/ota_uart.h
Normal file
45
main/ota_uart.h
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
#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
|
||||||
@ -4,6 +4,7 @@
|
|||||||
#include "cmd_espnow_unicast_test.h"
|
#include "cmd_espnow_unicast_test.h"
|
||||||
#include "cmd_client_info.h"
|
#include "cmd_client_info.h"
|
||||||
#include "cmd_version.h"
|
#include "cmd_version.h"
|
||||||
|
#include "cmd_ota.h"
|
||||||
#include "esp_now_comm.h"
|
#include "esp_now_comm.h"
|
||||||
#include "powerpod.h"
|
#include "powerpod.h"
|
||||||
#include "driver/gpio.h"
|
#include "driver/gpio.h"
|
||||||
@ -15,6 +16,7 @@
|
|||||||
#include "esp_ota_ops.h"
|
#include "esp_ota_ops.h"
|
||||||
#include "freertos/FreeRTOS.h"
|
#include "freertos/FreeRTOS.h"
|
||||||
#include "freertos/idf_additions.h"
|
#include "freertos/idf_additions.h"
|
||||||
|
#include "board_input.h"
|
||||||
#include "bosch456.h"
|
#include "bosch456.h"
|
||||||
#include "led_ring.h"
|
#include "led_ring.h"
|
||||||
#include "uart.h"
|
#include "uart.h"
|
||||||
@ -49,6 +51,8 @@ static i2c_master_dev_handle_t io_expander;
|
|||||||
|
|
||||||
static app_config_t app_config;
|
static app_config_t app_config;
|
||||||
|
|
||||||
|
const app_config_t *app_config_get(void) { return &app_config; }
|
||||||
|
|
||||||
static QueueHandle_t cmd_queue;
|
static QueueHandle_t cmd_queue;
|
||||||
|
|
||||||
uint8_t reverse_high_nibble_lut(uint8_t n) {
|
uint8_t reverse_high_nibble_lut(uint8_t n) {
|
||||||
@ -122,16 +126,29 @@ void app_main(void) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const esp_partition_t *running = esp_ota_get_running_partition();
|
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.master = (master != 0);
|
||||||
app_config.network = network;
|
app_config.network = network;
|
||||||
|
if (running != NULL) {
|
||||||
memcpy(app_config.running_partition, running->label,
|
memcpy(app_config.running_partition, running->label,
|
||||||
sizeof(app_config.running_partition));
|
sizeof(app_config.running_partition));
|
||||||
|
} else {
|
||||||
|
app_config.running_partition[0] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
ESP_LOGI(TAG, "RUNNING CONFIG:");
|
ESP_LOGI(TAG, "RUNNING CONFIG:");
|
||||||
ESP_LOGI(TAG, "Master: %d", app_config.master);
|
ESP_LOGI(TAG, "Master: %d", app_config.master);
|
||||||
ESP_LOGI(TAG, "Network: %d", app_config.network);
|
ESP_LOGI(TAG, "Network: %d", app_config.network);
|
||||||
ESP_LOGI(TAG, "Running Partition: %s", app_config.running_partition);
|
ESP_LOGI(TAG, "Running Partition: %s (OTA slot %d)",
|
||||||
|
app_config.running_partition, ota_slot);
|
||||||
|
|
||||||
err = esp_now_comm_init(&app_config);
|
err = esp_now_comm_init(&app_config);
|
||||||
if (err != ESP_OK) {
|
if (err != ESP_OK) {
|
||||||
@ -140,14 +157,17 @@ void app_main(void) {
|
|||||||
|
|
||||||
led_ring_init();
|
led_ring_init();
|
||||||
|
|
||||||
|
board_input_init();
|
||||||
|
|
||||||
if (app_config.master) {
|
if (app_config.master) {
|
||||||
cmd_queue = xQueueCreate(10, sizeof(generic_msg_t));
|
cmd_queue = xQueueCreate(64, sizeof(generic_msg_t));
|
||||||
init_cmdHandler(cmd_queue);
|
init_cmdHandler(cmd_queue);
|
||||||
init_uart(cmd_queue);
|
init_uart(cmd_queue);
|
||||||
cmd_version_register();
|
cmd_version_register();
|
||||||
cmd_client_info_register();
|
cmd_client_info_register();
|
||||||
cmd_accel_deadzone_register();
|
cmd_accel_deadzone_register();
|
||||||
cmd_espnow_unicast_test_register();
|
cmd_espnow_unicast_test_register();
|
||||||
|
cmd_ota_register();
|
||||||
}
|
}
|
||||||
|
|
||||||
uint8_t current_digit = 10;
|
uint8_t current_digit = 10;
|
||||||
|
|||||||
@ -7,4 +7,13 @@
|
|||||||
#define I2C_PORT 0
|
#define I2C_PORT 0
|
||||||
#define IO_EXPANDER_ADDRESS 0x20
|
#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
|
#endif
|
||||||
|
|||||||
1
main/proto/uart_messages.options
Normal file
1
main/proto/uart_messages.options
Normal file
@ -0,0 +1 @@
|
|||||||
|
OtaPayload.data max_size:200
|
||||||
@ -38,6 +38,8 @@ typedef struct _alox_EchoPayload {
|
|||||||
typedef struct _alox_VersionResponse {
|
typedef struct _alox_VersionResponse {
|
||||||
uint32_t version;
|
uint32_t version;
|
||||||
pb_callback_t git_hash;
|
pb_callback_t git_hash;
|
||||||
|
/* * Active OTA app partition label, e.g. "ota_0" or "ota_1". */
|
||||||
|
pb_callback_t running_partition;
|
||||||
} alox_VersionResponse;
|
} alox_VersionResponse;
|
||||||
|
|
||||||
typedef struct _alox_ClientInfo {
|
typedef struct _alox_ClientInfo {
|
||||||
@ -92,23 +94,30 @@ typedef struct _alox_EspNowUnicastTestResponse {
|
|||||||
uint32_t seq;
|
uint32_t seq;
|
||||||
} alox_EspNowUnicastTestResponse;
|
} alox_EspNowUnicastTestResponse;
|
||||||
|
|
||||||
|
/* Host → device: begin UART OTA (erase inactive OTA slot; device replies OTA_STATUS). */
|
||||||
typedef struct _alox_OtaStartPayload {
|
typedef struct _alox_OtaStartPayload {
|
||||||
uint32_t total_size;
|
uint32_t total_size;
|
||||||
uint32_t block_size;
|
|
||||||
} alox_OtaStartPayload;
|
} 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 {
|
typedef struct _alox_OtaPayload {
|
||||||
uint32_t block_id;
|
uint32_t seq;
|
||||||
uint32_t chunk_id;
|
alox_OtaPayload_data_t data;
|
||||||
pb_callback_t data;
|
|
||||||
} alox_OtaPayload;
|
} alox_OtaPayload;
|
||||||
|
|
||||||
|
/* Host → device: no more payload; device flushes buffer and finalizes OTA. */
|
||||||
typedef struct _alox_OtaEndPayload {
|
typedef struct _alox_OtaEndPayload {
|
||||||
uint32_t status;
|
char dummy_field;
|
||||||
} alox_OtaEndPayload;
|
} 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 {
|
typedef struct _alox_OtaStatusPayload {
|
||||||
uint32_t status;
|
uint32_t status;
|
||||||
|
uint32_t bytes_written;
|
||||||
|
uint32_t target_slot;
|
||||||
|
uint32_t error;
|
||||||
} alox_OtaStatusPayload;
|
} alox_OtaStatusPayload;
|
||||||
|
|
||||||
typedef struct _alox_UartMessage {
|
typedef struct _alox_UartMessage {
|
||||||
@ -163,7 +172,7 @@ extern "C" {
|
|||||||
#define alox_UartMessage_init_default {_alox_MessageType_MIN, 0, {alox_Ack_init_default}}
|
#define alox_UartMessage_init_default {_alox_MessageType_MIN, 0, {alox_Ack_init_default}}
|
||||||
#define alox_Ack_init_default {0}
|
#define alox_Ack_init_default {0}
|
||||||
#define alox_EchoPayload_init_default {{{NULL}, NULL}}
|
#define alox_EchoPayload_init_default {{{NULL}, NULL}}
|
||||||
#define alox_VersionResponse_init_default {0, {{NULL}, NULL}}
|
#define alox_VersionResponse_init_default {0, {{NULL}, NULL}, {{NULL}, NULL}}
|
||||||
#define alox_ClientInfo_init_default {0, 0, 0, {{NULL}, NULL}, 0, 0, 0}
|
#define alox_ClientInfo_init_default {0, 0, 0, {{NULL}, NULL}, 0, 0, 0}
|
||||||
#define alox_ClientInfoResponse_init_default {{{NULL}, NULL}}
|
#define alox_ClientInfoResponse_init_default {{{NULL}, NULL}}
|
||||||
#define alox_ClientInput_init_default {0, 0, 0, 0}
|
#define alox_ClientInput_init_default {0, 0, 0, 0}
|
||||||
@ -172,14 +181,14 @@ extern "C" {
|
|||||||
#define alox_AccelDeadzoneResponse_init_default {0, 0, 0, 0}
|
#define alox_AccelDeadzoneResponse_init_default {0, 0, 0, 0}
|
||||||
#define alox_EspNowUnicastTestRequest_init_default {0, 0}
|
#define alox_EspNowUnicastTestRequest_init_default {0, 0}
|
||||||
#define alox_EspNowUnicastTestResponse_init_default {0, 0}
|
#define alox_EspNowUnicastTestResponse_init_default {0, 0}
|
||||||
#define alox_OtaStartPayload_init_default {0, 0}
|
#define alox_OtaStartPayload_init_default {0}
|
||||||
#define alox_OtaPayload_init_default {0, 0, {{NULL}, NULL}}
|
#define alox_OtaPayload_init_default {0, {0, {0}}}
|
||||||
#define alox_OtaEndPayload_init_default {0}
|
#define alox_OtaEndPayload_init_default {0}
|
||||||
#define alox_OtaStatusPayload_init_default {0}
|
#define alox_OtaStatusPayload_init_default {0, 0, 0, 0}
|
||||||
#define alox_UartMessage_init_zero {_alox_MessageType_MIN, 0, {alox_Ack_init_zero}}
|
#define alox_UartMessage_init_zero {_alox_MessageType_MIN, 0, {alox_Ack_init_zero}}
|
||||||
#define alox_Ack_init_zero {0}
|
#define alox_Ack_init_zero {0}
|
||||||
#define alox_EchoPayload_init_zero {{{NULL}, NULL}}
|
#define alox_EchoPayload_init_zero {{{NULL}, NULL}}
|
||||||
#define alox_VersionResponse_init_zero {0, {{NULL}, NULL}}
|
#define alox_VersionResponse_init_zero {0, {{NULL}, NULL}, {{NULL}, NULL}}
|
||||||
#define alox_ClientInfo_init_zero {0, 0, 0, {{NULL}, NULL}, 0, 0, 0}
|
#define alox_ClientInfo_init_zero {0, 0, 0, {{NULL}, NULL}, 0, 0, 0}
|
||||||
#define alox_ClientInfoResponse_init_zero {{{NULL}, NULL}}
|
#define alox_ClientInfoResponse_init_zero {{{NULL}, NULL}}
|
||||||
#define alox_ClientInput_init_zero {0, 0, 0, 0}
|
#define alox_ClientInput_init_zero {0, 0, 0, 0}
|
||||||
@ -188,15 +197,16 @@ extern "C" {
|
|||||||
#define alox_AccelDeadzoneResponse_init_zero {0, 0, 0, 0}
|
#define alox_AccelDeadzoneResponse_init_zero {0, 0, 0, 0}
|
||||||
#define alox_EspNowUnicastTestRequest_init_zero {0, 0}
|
#define alox_EspNowUnicastTestRequest_init_zero {0, 0}
|
||||||
#define alox_EspNowUnicastTestResponse_init_zero {0, 0}
|
#define alox_EspNowUnicastTestResponse_init_zero {0, 0}
|
||||||
#define alox_OtaStartPayload_init_zero {0, 0}
|
#define alox_OtaStartPayload_init_zero {0}
|
||||||
#define alox_OtaPayload_init_zero {0, 0, {{NULL}, NULL}}
|
#define alox_OtaPayload_init_zero {0, {0, {0}}}
|
||||||
#define alox_OtaEndPayload_init_zero {0}
|
#define alox_OtaEndPayload_init_zero {0}
|
||||||
#define alox_OtaStatusPayload_init_zero {0}
|
#define alox_OtaStatusPayload_init_zero {0, 0, 0, 0}
|
||||||
|
|
||||||
/* Field tags (for use in manual encoding/decoding) */
|
/* Field tags (for use in manual encoding/decoding) */
|
||||||
#define alox_EchoPayload_data_tag 1
|
#define alox_EchoPayload_data_tag 1
|
||||||
#define alox_VersionResponse_version_tag 1
|
#define alox_VersionResponse_version_tag 1
|
||||||
#define alox_VersionResponse_git_hash_tag 2
|
#define alox_VersionResponse_git_hash_tag 2
|
||||||
|
#define alox_VersionResponse_running_partition_tag 3
|
||||||
#define alox_ClientInfo_id_tag 1
|
#define alox_ClientInfo_id_tag 1
|
||||||
#define alox_ClientInfo_available_tag 2
|
#define alox_ClientInfo_available_tag 2
|
||||||
#define alox_ClientInfo_used_tag 3
|
#define alox_ClientInfo_used_tag 3
|
||||||
@ -223,12 +233,12 @@ extern "C" {
|
|||||||
#define alox_EspNowUnicastTestResponse_success_tag 1
|
#define alox_EspNowUnicastTestResponse_success_tag 1
|
||||||
#define alox_EspNowUnicastTestResponse_seq_tag 2
|
#define alox_EspNowUnicastTestResponse_seq_tag 2
|
||||||
#define alox_OtaStartPayload_total_size_tag 1
|
#define alox_OtaStartPayload_total_size_tag 1
|
||||||
#define alox_OtaStartPayload_block_size_tag 2
|
#define alox_OtaPayload_seq_tag 1
|
||||||
#define alox_OtaPayload_block_id_tag 1
|
#define alox_OtaPayload_data_tag 2
|
||||||
#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_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_type_tag 1
|
||||||
#define alox_UartMessage_ack_payload_tag 2
|
#define alox_UartMessage_ack_payload_tag 2
|
||||||
#define alox_UartMessage_echo_payload_tag 3
|
#define alox_UartMessage_echo_payload_tag 3
|
||||||
@ -288,7 +298,8 @@ X(a, CALLBACK, SINGULAR, BYTES, data, 1)
|
|||||||
|
|
||||||
#define alox_VersionResponse_FIELDLIST(X, a) \
|
#define alox_VersionResponse_FIELDLIST(X, a) \
|
||||||
X(a, STATIC, SINGULAR, UINT32, version, 1) \
|
X(a, STATIC, SINGULAR, UINT32, version, 1) \
|
||||||
X(a, CALLBACK, SINGULAR, STRING, git_hash, 2)
|
X(a, CALLBACK, SINGULAR, STRING, git_hash, 2) \
|
||||||
|
X(a, CALLBACK, SINGULAR, STRING, running_partition, 3)
|
||||||
#define alox_VersionResponse_CALLBACK pb_default_field_callback
|
#define alox_VersionResponse_CALLBACK pb_default_field_callback
|
||||||
#define alox_VersionResponse_DEFAULT NULL
|
#define alox_VersionResponse_DEFAULT NULL
|
||||||
|
|
||||||
@ -352,25 +363,26 @@ X(a, STATIC, SINGULAR, UINT32, seq, 2)
|
|||||||
#define alox_EspNowUnicastTestResponse_DEFAULT NULL
|
#define alox_EspNowUnicastTestResponse_DEFAULT NULL
|
||||||
|
|
||||||
#define alox_OtaStartPayload_FIELDLIST(X, a) \
|
#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_CALLBACK NULL
|
||||||
#define alox_OtaStartPayload_DEFAULT NULL
|
#define alox_OtaStartPayload_DEFAULT NULL
|
||||||
|
|
||||||
#define alox_OtaPayload_FIELDLIST(X, a) \
|
#define alox_OtaPayload_FIELDLIST(X, a) \
|
||||||
X(a, STATIC, SINGULAR, UINT32, block_id, 1) \
|
X(a, STATIC, SINGULAR, UINT32, seq, 1) \
|
||||||
X(a, STATIC, SINGULAR, UINT32, chunk_id, 2) \
|
X(a, STATIC, SINGULAR, BYTES, data, 2)
|
||||||
X(a, CALLBACK, SINGULAR, BYTES, data, 3)
|
#define alox_OtaPayload_CALLBACK NULL
|
||||||
#define alox_OtaPayload_CALLBACK pb_default_field_callback
|
|
||||||
#define alox_OtaPayload_DEFAULT NULL
|
#define alox_OtaPayload_DEFAULT NULL
|
||||||
|
|
||||||
#define alox_OtaEndPayload_FIELDLIST(X, a) \
|
#define alox_OtaEndPayload_FIELDLIST(X, a) \
|
||||||
X(a, STATIC, SINGULAR, UINT32, status, 1)
|
|
||||||
#define alox_OtaEndPayload_CALLBACK NULL
|
#define alox_OtaEndPayload_CALLBACK NULL
|
||||||
#define alox_OtaEndPayload_DEFAULT NULL
|
#define alox_OtaEndPayload_DEFAULT NULL
|
||||||
|
|
||||||
#define alox_OtaStatusPayload_FIELDLIST(X, a) \
|
#define alox_OtaStatusPayload_FIELDLIST(X, a) \
|
||||||
X(a, STATIC, SINGULAR, UINT32, status, 1)
|
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)
|
||||||
#define alox_OtaStatusPayload_CALLBACK NULL
|
#define alox_OtaStatusPayload_CALLBACK NULL
|
||||||
#define alox_OtaStatusPayload_DEFAULT NULL
|
#define alox_OtaStatusPayload_DEFAULT NULL
|
||||||
|
|
||||||
@ -417,16 +429,16 @@ extern const pb_msgdesc_t alox_OtaStatusPayload_msg;
|
|||||||
/* alox_ClientInfoResponse_size depends on runtime parameters */
|
/* alox_ClientInfoResponse_size depends on runtime parameters */
|
||||||
/* alox_ClientInputResponse_size depends on runtime parameters */
|
/* alox_ClientInputResponse_size depends on runtime parameters */
|
||||||
/* alox_OtaPayload_size depends on runtime parameters */
|
/* alox_OtaPayload_size depends on runtime parameters */
|
||||||
#define ALOX_MAIN_PROTO_UART_MESSAGES_PB_H_MAX_SIZE alox_ClientInput_size
|
#define ALOX_MAIN_PROTO_UART_MESSAGES_PB_H_MAX_SIZE alox_OtaStatusPayload_size
|
||||||
#define alox_AccelDeadzoneRequest_size 16
|
#define alox_AccelDeadzoneRequest_size 16
|
||||||
#define alox_AccelDeadzoneResponse_size 20
|
#define alox_AccelDeadzoneResponse_size 20
|
||||||
#define alox_Ack_size 0
|
#define alox_Ack_size 0
|
||||||
#define alox_ClientInput_size 22
|
#define alox_ClientInput_size 22
|
||||||
#define alox_EspNowUnicastTestRequest_size 12
|
#define alox_EspNowUnicastTestRequest_size 12
|
||||||
#define alox_EspNowUnicastTestResponse_size 8
|
#define alox_EspNowUnicastTestResponse_size 8
|
||||||
#define alox_OtaEndPayload_size 6
|
#define alox_OtaEndPayload_size 0
|
||||||
#define alox_OtaStartPayload_size 12
|
#define alox_OtaStartPayload_size 6
|
||||||
#define alox_OtaStatusPayload_size 6
|
#define alox_OtaStatusPayload_size 24
|
||||||
|
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
} /* extern "C" */
|
} /* extern "C" */
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
syntax = "proto3";
|
syntax = "proto3";
|
||||||
|
|
||||||
|
import "nanopb.proto";
|
||||||
|
|
||||||
package alox;
|
package alox;
|
||||||
|
|
||||||
enum MessageType {
|
enum MessageType {
|
||||||
@ -46,6 +48,8 @@ message EchoPayload {
|
|||||||
message VersionResponse {
|
message VersionResponse {
|
||||||
uint32 version = 1;
|
uint32 version = 1;
|
||||||
string git_hash = 2;
|
string git_hash = 2;
|
||||||
|
/** Active OTA app partition label, e.g. "ota_0" or "ota_1". */
|
||||||
|
string running_partition = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ClientInfo {
|
message ClientInfo {
|
||||||
@ -100,21 +104,25 @@ message EspNowUnicastTestResponse {
|
|||||||
uint32 seq = 2;
|
uint32 seq = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Host → device: begin UART OTA (erase inactive OTA slot; device replies OTA_STATUS).
|
||||||
message OtaStartPayload {
|
message OtaStartPayload {
|
||||||
uint32 total_size = 1;
|
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 {
|
message OtaPayload {
|
||||||
uint32 block_id = 1;
|
uint32 seq = 1;
|
||||||
uint32 chunk_id = 2;
|
bytes data = 2 [(nanopb).max_size = 200];
|
||||||
bytes data = 3;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
message OtaEndPayload {
|
// Host → device: no more payload; device flushes buffer and finalizes OTA.
|
||||||
uint32 status = 1;
|
message 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
|
||||||
message OtaStatusPayload {
|
message OtaStatusPayload {
|
||||||
uint32 status = 1;
|
uint32 status = 1;
|
||||||
|
uint32 bytes_written = 2;
|
||||||
|
uint32 target_slot = 3;
|
||||||
|
uint32 error = 4;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,9 +32,9 @@ static bool uart_enqueue_packet(const uart_packet_t *packet) {
|
|||||||
memcpy(msg.payload, &packet->payload[1], msg.len);
|
memcpy(msg.payload, &packet->payload[1], msg.len);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (xQueueSend(uart_cmd_queue, &msg, 0) != pdPASS) {
|
if (xQueueSend(uart_cmd_queue, &msg, pdMS_TO_TICKS(500)) != pdPASS) {
|
||||||
free(msg.payload);
|
free(msg.payload);
|
||||||
ESP_LOGW(TAG, "command queue full");
|
ESP_LOGW(TAG, "command queue full (cmd 0x%02x)", (unsigned)msg.msg_id);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@ -52,7 +52,7 @@ void init_uart(QueueHandle_t cmd_queue) {
|
|||||||
.flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
|
.flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
|
||||||
};
|
};
|
||||||
|
|
||||||
err = uart_driver_install(UART_NUM, UART_BUF_SIZE * 2, 0, 0, NULL, 0);
|
err = uart_driver_install(UART_NUM, UART_BUF_SIZE * 2, UART_BUF_SIZE, 0, NULL, 0);
|
||||||
if (err != ESP_OK) {
|
if (err != ESP_OK) {
|
||||||
ESP_LOGE(TAG, "uart_driver_install failed: %s", esp_err_to_name(err));
|
ESP_LOGE(TAG, "uart_driver_install failed: %s", esp_err_to_name(err));
|
||||||
return;
|
return;
|
||||||
@ -69,7 +69,7 @@ void init_uart(QueueHandle_t cmd_queue) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (xTaskCreate(uart_read_task, "uart_rx", 4096, NULL, 1, NULL) != pdPASS) {
|
if (xTaskCreate(uart_read_task, "uart_rx", 4096, NULL, 5, NULL) != pdPASS) {
|
||||||
ESP_LOGE(TAG, "failed to create uart_read_task");
|
ESP_LOGE(TAG, "failed to create uart_read_task");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,10 +12,10 @@
|
|||||||
#define UART_TXD_PIN 3
|
#define UART_TXD_PIN 3
|
||||||
#define UART_RXD_PIN 2
|
#define UART_RXD_PIN 2
|
||||||
|
|
||||||
#define UART_BUF_SIZE 256
|
#define UART_BUF_SIZE 2048
|
||||||
#define START_MARKER 0xAA
|
#define START_MARKER 0xAA
|
||||||
#define STOP_MARKER 0xCC
|
#define STOP_MARKER 0xCC
|
||||||
#define MAX_BUF_SIZE 256
|
#define MAX_BUF_SIZE 252
|
||||||
#define MAX_PAYLOAD_SIZE \
|
#define MAX_PAYLOAD_SIZE \
|
||||||
MAX_BUF_SIZE - 4 // Buffer overhead, Start, Len, CRC, End
|
MAX_BUF_SIZE - 4 // Buffer overhead, Start, Len, CRC, End
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user