24 Commits

Author SHA1 Message Date
adebaumann 2879be0ada Code commented, Serial speed moved into constant 2026-04-19 23:14:50 +02:00
adebaumann b6e4bfea33 3 Gauges, routines switched 2026-04-19 23:03:01 +02:00
adebaumann 59721477df LED Names changed 2026-04-17 22:28:16 +02:00
adebaumann 7be9b59093 Garbage collection added 2026-04-17 22:19:48 +02:00
adebaumann 9a25805522 MQTT troubleshooting 2026-04-17 22:17:49 +02:00
adebaumann 44afc207ea Speed and acceleration no longer hidden, but in config. 2026-04-17 22:09:18 +02:00
adebaumann 549a7c7d37 MQTT doesn't autodiscover properly 2026-04-17 21:24:17 +02:00
adebaumann 10ef3580b2 MQTT doesn't autodiscover properly 2026-04-17 21:21:29 +02:00
adebaumann f78d090f95 MQTT doesn't like non-ASCII... 2026-04-17 19:15:01 +02:00
adebaumann ef986c2881 Added speed and acceleration to Home Assistant 2026-04-17 19:10:43 +02:00
adebaumann 4cb4947bd1 Lowered LED-Breathe-Frequency 2026-04-17 18:53:08 +02:00
adebaumann 18093092f0 38400 baud 2026-04-16 00:49:10 +02:00
adebaumann 358ddcaeb5 Breathe and double blink added to LED effects 2026-04-15 23:07:04 +02:00
adebaumann 2282038391 Interrupt issue with FastLed circumvented 2026-04-15 22:46:39 +02:00
adebaumann f7f7b389a0 Duh, no ** deref in json... 2026-04-15 22:08:52 +02:00
adebaumann d9d17e5e5c Blinking added with Light-Effects in Home Assistant 2026-04-15 22:05:55 +02:00
adebaumann 4a7551e358 Blinking added to Arduino 2026-04-15 21:53:01 +02:00
adebaumann 19b1a7c6e5 Main routine added 2026-04-15 21:19:23 +02:00
adebaumann 036fa045f8 Discovery didn't set online status 2026-04-15 00:44:22 +02:00
adebaumann a9fc7cd0ed Logging received serial data 2026-04-14 23:20:47 +02:00
adebaumann cf2c55f5cf Code for attached ESP32-MQTT-receiver added 2026-04-14 21:17:28 +02:00
adebaumann 7aaf5ce334 Merge branch 'feature/overshoot' 2026-04-14 19:31:34 +02:00
adebaumann 3a7f98a3d2 LED now accepts ranges 2026-04-14 13:45:11 +02:00
adebaumann 6cc0cff069 Documentation updated as to serial ports 2026-04-14 13:35:34 +02:00
8 changed files with 2083 additions and 28 deletions
+45 -6
View File
@@ -4,17 +4,53 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Build & Upload
This is a single-file Arduino sketch (`Gaugecontroller.ino`). Requires the **FastLED** library (`arduino-cli lib install FastLED`). Use the Arduino IDE or `arduino-cli`:
Main firmware lives in `Gaugecontroller/Gaugecontroller.ino`. Requires the **FastLED** library (`arduino-cli lib install FastLED`). Use the Arduino IDE or `arduino-cli`:
```bash
# Compile (replace board/port as needed)
arduino-cli compile --fqbn arduino:avr:mega Gaugecontroller.ino
arduino-cli compile --fqbn arduino:avr:mega Gaugecontroller
# Upload
arduino-cli upload -p /dev/ttyACM0 --fqbn arduino:avr:mega Gaugecontroller.ino
arduino-cli upload -p /dev/ttyACM0 --fqbn arduino:avr:mega Gaugecontroller
```
Serial monitor: 115200 baud (`Serial` is both CMD_PORT and DEBUG_PORT).
Current default serial setup: `CMD_PORT` and `DEBUG_PORT` both point to `Serial1` at 38400 baud.
## Switching serial ports (debug → production)
Two `#define`s at the top of `Gaugecontroller.ino` control where commands and debug output go:
```cpp
#define CMD_PORT Serial1 // command channel (host sends SET, HOME, etc.)
#define DEBUG_PORT Serial1 // diagnostic prints (homing, boot messages)
```
**Current default:** both point to `Serial1`, so command and debug traffic share Mega pins TX1=18 / RX1=19 at 38400 baud.
**USB-only debug setup:** point both defines back at `Serial` if you want to talk to the sketch over the Arduino USB port instead:
```cpp
#define CMD_PORT Serial
#define DEBUG_PORT Serial
```
At that point the matching `begin()` call in `setup()` also needs to use the same baud rate you expect on the host side.
**Split command/debug ports:** if `CMD_PORT` and `DEBUG_PORT` do not point to the same serial port, `setup()` must initialise both. Right now it only calls:
```cpp
DEBUG_PORT.begin(38400);
```
If you split them, add a second `CMD_PORT.begin(...)` call.
Arduino Mega hardware UARTs for reference:
| Port | TX pin | RX pin |
|---------|--------|--------|
| Serial1 | 18 | 19 |
| Serial2 | 16 | 17 |
| Serial3 | 14 | 15 |
## Architecture
@@ -40,7 +76,7 @@ When `sweepEnabled`, `updateSweepTarget` bounces `targetPos` between `minPos` an
### LED strip
One shared WS2812B strip is driven from `LED_DATA_PIN` (default 6). Each gauge owns a contiguous segment of the strip; `gaugePins[i].ledCount` sets the segment length (0 = no LEDs). `TOTAL_LEDS` is computed at compile time via `constexpr sumLedCounts()` — no manual constant to keep in sync. Per-gauge offsets into the flat `leds[]` array are computed once in `setup()` into `gaugeLedOffset[]`. `FastLED.show()` is called immediately after each `LED` command.
One shared WS2812B strip is driven from `LED_DATA_PIN` (currently 22). Each gauge owns a contiguous segment of the strip; `gaugePins[i].ledCount` sets the segment length (0 = no LEDs). `TOTAL_LEDS` is computed at compile time via `constexpr sumLedCounts()` — no manual constant to keep in sync. Per-gauge offsets into the flat `leds[]` array are computed once in `setup()` into `gaugeLedOffset[]`. LED commands and effects mark the strip dirty, and `FastLED.show()` is called once per main-loop iteration if anything changed.
### Serial command protocol
@@ -56,8 +92,11 @@ Commands arrive as newline-terminated ASCII lines. Each `parse*` function in `pr
| `HOME` | `HOME <id>` / `HOMEALL` | Run homing sequence |
| `SWEEP` | `SWEEP <id> <accel> <speed>` | Start sweep (0/0 stops) |
| `POS?` | `POS?` | Query all gauges: `POS <id> <cur> <tgt> <homed> <homingState> <sweep>` |
| `LED` | `LED <id> <idx> <r> <g> <b>` | Set one LED (0-based index within gauge segment) to RGB colour (0255 each) |
| `LED` | `LED <id> <idx> <r> <g> <b>` | Set one LED (0-based index within gauge segment) to RGB colour (0255 each); `<idx>` may be a range `N-M` to set LEDs N through M in one command; also stops any active effect on those LEDs |
| `LED?` | `LED?` | Query all LEDs: one `LED <id> <idx> <r> <g> <b>` line per LED, then `OK` |
| `BLINK` | `BLINK <id> <idx> <on_ms> <off_ms> <r> <g> <b>` | Blink LED(s) at given colour; `<idx>` may be a range `N-M`; `on_ms`/`off_ms` both 0 stops blinking. 4-arg form (no colour) uses current LED colour |
| `BREATHE` | `BREATHE <id> <idx> <period_ms> <r> <g> <b>` | Smooth triangle-wave fade between black and the given colour; `<idx>` may be a range `N-M` |
| `DFLASH` | `DFLASH <id> <idx> <r> <g> <b>` | Two quick flashes (100 ms on/off each) followed by a 700 ms pause, then repeats; `<idx>` may be a range `N-M` |
| `PING` | `PING` | Responds `PONG` |
All commands reply `OK` or `ERR BAD_ID` / `ERR BAD_CMD` etc.
+318 -22
View File
@@ -2,31 +2,31 @@
#include <math.h>
#include <FastLED.h>
static const uint8_t GAUGE_COUNT = 2;
static const uint8_t GAUGE_COUNT = 3;
// LED strip — one shared WS2812B strip, segmented per gauge.
// Set LED_DATA_PIN to the digital pin driving the strip data line.
// TOTAL_LEDS is computed automatically from gaugePins[].ledCount.
// One shared WS2812B strip, split into per-gauge segments.
static const uint8_t LED_DATA_PIN = 22;
// For now: commands come over USB serial
#define CMD_PORT Serial
#define DEBUG_PORT Serial
// For now, command and debug traffic share the same serial port.
#define CMD_PORT Serial1
#define DEBUG_PORT Serial1
static const unsigned long SERIAL_BAUD = 38400;
struct GaugePins {
uint8_t dirPin;
uint8_t stepPin;
int8_t enablePin; // -1 if unused
int8_t enablePin; // -1 means there is no enable pin
bool dirInverted;
bool stepActiveHigh;
bool enableActiveLow;
uint8_t ledCount; // WS2812B LEDs on this gauge's strip segment (0 if none)
uint8_t ledCount; // LEDs assigned to this gauge
};
constexpr GaugePins gaugePins[GAUGE_COUNT] = {
// dir, step, en, dirInv, stepHigh, enActiveLow, leds
{50, 51, -1, false, true, true, 6}, // Gauge 0
{8, 9, -1, true, true, true, 6}, // Gauge 1
{50, 51, -1, false, true, true, 7}, // Gauge 0
{8, 9, -1, true, true, true, 7}, // Gauge 1
{52, 53, -1, false, true, true, 7}, // Gauge 2
};
constexpr uint8_t sumLedCounts(uint8_t i = 0) {
@@ -47,8 +47,8 @@ struct Gauge {
long targetPos = 0;
long minPos = 0;
long maxPos = 3780; // adjust to your usable travel
long homingBackoffSteps = 3700; // should exceed reverse travel slightly
long maxPos = 3780;
long homingBackoffSteps = 3800; // Deliberately a touch past full reverse travel.
float velocity = 0.0f;
float maxSpeed = 5000.0f;
@@ -70,20 +70,88 @@ struct Gauge {
bool sweepTowardMax = true;
};
enum LedFx : uint8_t { FX_BLINK = 0, FX_BREATHE = 1, FX_DFLASH = 2 };
struct BlinkState {
bool active = false;
LedFx fx = FX_BLINK;
CRGB onColor;
unsigned long lastMs = 0;
uint16_t onMs = 500;
uint16_t offMs = 500;
bool currentlyOn = false;
uint16_t periodMs = 2000;
uint16_t cyclePos = 0;
uint8_t dphase = 0;
};
Gauge gauges[GAUGE_COUNT];
String rxLine;
CRGB leds[TOTAL_LEDS];
uint8_t gaugeLedOffset[GAUGE_COUNT];
BlinkState blinkState[TOTAL_LEDS];
bool ledsDirty = false;
// Sends one-line command replies back over the control port.
//
// Serial protocol summary.
//
// Host -> controller commands (newline-terminated ASCII):
// SET <id> <pos>
// SPEED <id> <steps_per_s>
// ACCEL <id> <steps_per_s2>
// ENABLE <id> <0|1>
// ZERO <id>
// HOME <id>
// HOMEALL
// SWEEP <id> <accel> <speed>
// POS?
// LED?
// LED <id> <idx|a-b> <r> <g> <b>
// BLINK <id> <idx|a-b> <on_ms> <off_ms> [<r> <g> <b>]
// BREATHE <id> <idx|a-b> <period_ms> <r> <g> <b>
// DFLASH <id> <idx|a-b> <r> <g> <b>
// PING
//
// Controller -> host replies / events:
// READY
// Sent once from setup() after boot completes.
// OK
// Sent after a valid mutating command, and after POS?/LED? once all data lines
// for that query have been emitted.
// PONG
// Sent in response to PING.
// ERR BAD_CMD
// Sent when a complete line matches no parser.
// ERR TOO_LONG
// Sent when an input line exceeds the receive buffer limit.
// ERR BAD_ID
// Sent by commands that take a gauge id when the id is outside 0..GAUGE_COUNT-1.
// ERR BAD_SPEED
// Sent by SPEED when the requested speed is <= 0.
// ERR BAD_ACCEL
// Sent by ACCEL when the requested acceleration is <= 0.
// ERR BAD_IDX
// Sent by LED/BLINK/BREATHE/DFLASH when an LED index or range is invalid.
// ERR BAD_TIME
// Sent by BLINK/BREATHE when the timing parameter is invalid.
// POS <id> <currentPos> <targetPos> <homed> <homingState> <sweepEnabled>
// Emitted once per gauge before the trailing OK reply to POS?.
// LED <id> <idx> <r> <g> <b>
// Emitted once per configured LED before the trailing OK reply to LED?.
// HOMED <id>
// Debug event printed on DEBUG_PORT when a homing sequence settles successfully.
void sendReply(const String& s) {
CMD_PORT.println(s);
}
// Tiny float absolute-value helper to avoid dragging more machinery into the sketch.
float absf(float x) {
return (x < 0.0f) ? -x : x;
}
// Updates the cached enable state and toggles the hardware pin if one exists.
void setEnable(uint8_t id, bool en) {
if (id >= GAUGE_COUNT) return;
gauges[id].enabled = en;
@@ -95,11 +163,13 @@ void setEnable(uint8_t id, bool en) {
digitalWrite(pin, level ? HIGH : LOW);
}
// Applies the logical direction after accounting for per-gauge inversion.
void setDir(uint8_t id, bool forward) {
bool level = gaugePins[id].dirInverted ? !forward : forward;
digitalWrite(gaugePins[id].dirPin, level ? HIGH : LOW);
}
// Emits one step pulse with the polarity expected by the driver.
void pulseStep(uint8_t id) {
bool active = gaugePins[id].stepActiveHigh;
digitalWrite(gaugePins[id].stepPin, active ? HIGH : LOW);
@@ -107,6 +177,7 @@ void pulseStep(uint8_t id) {
digitalWrite(gaugePins[id].stepPin, active ? LOW : HIGH);
}
// Moves the motor by one step if the requested direction is still within allowed travel.
void doStep(uint8_t id, int dir, bool allowPastMin = false) {
Gauge& g = gauges[id];
if (!g.enabled) return;
@@ -124,6 +195,7 @@ void doStep(uint8_t id, int dir, bool allowPastMin = false) {
}
}
// Arms the homing state machine for one gauge and clears any in-flight motion.
void requestHome(uint8_t id) {
if (id >= GAUGE_COUNT) return;
Gauge& g = gauges[id];
@@ -134,12 +206,14 @@ void requestHome(uint8_t id) {
g.sweepEnabled = false;
}
// Starts the same homing sequence on every configured gauge.
void requestHomeAll() {
for (uint8_t i = 0; i < GAUGE_COUNT; i++) {
requestHome(i);
}
}
// Advances the simple homing state machine until the gauge is parked at logical zero.
void updateHoming(uint8_t id) {
Gauge& g = gauges[id];
unsigned long nowUs = micros();
@@ -150,6 +224,7 @@ void updateHoming(uint8_t id) {
return;
case HS_START:
// No endstop here; homing just walks back far enough to hit the hard stop.
g.velocity = 0.0f;
g.stepAccumulator = 0.0f;
g.homingStepsRemaining = g.homingBackoffSteps;
@@ -193,6 +268,7 @@ void updateHoming(uint8_t id) {
}
}
// Flips the sweep destination when the gauge has settled at either end of travel.
void updateSweepTarget(uint8_t id) {
Gauge& g = gauges[id];
if (!g.sweepEnabled || !g.homed || g.homingState != HS_IDLE) return;
@@ -212,6 +288,7 @@ void updateSweepTarget(uint8_t id) {
}
}
// Runs one gauge worth of motion control, including homing and optional sweeping.
void updateGauge(uint8_t id) {
Gauge& g = gauges[id];
@@ -246,6 +323,7 @@ void updateGauge(uint8_t id) {
}
float dir = (error > 0) ? 1.0f : (error < 0 ? -1.0f : 0.0f);
// Basic trapezoidal profile: brake if the remaining travel is shorter than the stop distance.
float brakingDistance = (g.velocity * g.velocity) / (2.0f * g.accel + 0.0001f);
if ((float)labs(error) <= brakingDistance) {
@@ -266,6 +344,7 @@ void updateGauge(uint8_t id) {
g.velocity = dir * 5.0f;
}
// Integrate fractional steps until there is enough to emit a real pulse.
g.stepAccumulator += g.velocity * dt;
while (g.stepAccumulator >= 1.0f) {
@@ -307,6 +386,8 @@ void updateGauge(uint8_t id) {
}
}
// Parses `SET <id> <pos>` and updates the target position.
// Replies: `OK`, `ERR BAD_ID`.
bool parseSet(const String& line) {
int id;
long pos;
@@ -326,6 +407,8 @@ bool parseSet(const String& line) {
return false;
}
// Parses `SPEED <id> <speed>` and updates the max step rate.
// Replies: `OK`, `ERR BAD_ID`, `ERR BAD_SPEED`.
bool parseSpeed(const String& line) {
int firstSpace = line.indexOf(' ');
int secondSpace = line.indexOf(' ', firstSpace + 1);
@@ -349,6 +432,8 @@ bool parseSpeed(const String& line) {
return true;
}
// Parses `ACCEL <id> <accel>` and updates the acceleration limit.
// Replies: `OK`, `ERR BAD_ID`, `ERR BAD_ACCEL`.
bool parseAccel(const String& line) {
int firstSpace = line.indexOf(' ');
int secondSpace = line.indexOf(' ', firstSpace + 1);
@@ -372,6 +457,8 @@ bool parseAccel(const String& line) {
return true;
}
// Parses `ENABLE <id> <0|1>` and toggles the selected driver.
// Replies: `OK`, `ERR BAD_ID`.
bool parseEnable(const String& line) {
int id, en;
if (sscanf(line.c_str(), "ENABLE %d %d", &id, &en) == 2) {
@@ -387,6 +474,8 @@ bool parseEnable(const String& line) {
return false;
}
// Parses `ZERO <id>` and declares the current position to be home.
// Replies: `OK`, `ERR BAD_ID`.
bool parseZero(const String& line) {
int id;
if (sscanf(line.c_str(), "ZERO %d", &id) == 1) {
@@ -408,6 +497,8 @@ bool parseZero(const String& line) {
return false;
}
// Parses `HOME <id>` or `HOMEALL` and kicks off the homing sequence.
// Replies: `OK`, `ERR BAD_ID`. Successful completion later emits debug line `HOMED <id>`.
bool parseHome(const String& line) {
int id;
if (sscanf(line.c_str(), "HOME %d", &id) == 1) {
@@ -430,6 +521,8 @@ bool parseHome(const String& line) {
return false;
}
// Parses `SWEEP <id> <accel> <speed>` and enables or disables end-to-end motion.
// Replies: `OK`, `ERR BAD_ID`.
bool parseSweep(const String& line) {
int firstSpace = line.indexOf(' ');
int secondSpace = line.indexOf(' ', firstSpace + 1);
@@ -466,6 +559,9 @@ bool parseSweep(const String& line) {
return true;
}
// Answers `POS?` with current motion state for every gauge.
// Emits one `POS <id> <cur> <tgt> <homed> <homingState> <sweep>` line per gauge,
// then replies `OK`.
bool parsePosQuery(const String& line) {
if (line == "POS?") {
for (uint8_t i = 0; i < GAUGE_COUNT; i++) {
@@ -488,6 +584,8 @@ bool parsePosQuery(const String& line) {
return false;
}
// Answers the mandatory life question: are you there?
// Reply: `PONG`.
bool parsePing(const String& line) {
if (line == "PING") {
sendReply("PONG");
@@ -496,6 +594,8 @@ bool parsePing(const String& line) {
return false;
}
// Answers `LED?` with the current RGB values for every configured LED.
// Emits one `LED <id> <idx> <r> <g> <b>` line per configured LED, then replies `OK`.
bool parseLedQuery(const String& line) {
if (line == "LED?") {
for (uint8_t i = 0; i < GAUGE_COUNT; i++) {
@@ -519,21 +619,200 @@ bool parseLedQuery(const String& line) {
return false;
}
// Parses `LED <id> <idx|a-b> <r> <g> <b>` and writes static colours.
// Replies: `OK`, `ERR BAD_ID`, `ERR BAD_IDX`.
bool parseLed(const String& line) {
int id, idx, r, g, b;
if (sscanf(line.c_str(), "LED %d %d %d %d %d", &id, &idx, &r, &g, &b) == 5) {
int id, r, g, b;
char idxToken[16];
if (sscanf(line.c_str(), "LED %d %15s %d %d %d", &id, idxToken, &r, &g, &b) == 5) {
if (id < 0 || id >= GAUGE_COUNT) { sendReply("ERR BAD_ID"); return true; }
if (idx < 0 || idx >= gaugePins[id].ledCount) { sendReply("ERR BAD_IDX"); return true; }
leds[gaugeLedOffset[id] + idx] = CRGB(constrain(r, 0, 255),
constrain(g, 0, 255),
constrain(b, 0, 255));
FastLED.show();
char* dash = strchr(idxToken, '-');
int idxFirst = atoi(idxToken);
int idxLast = dash ? atoi(dash + 1) : idxFirst;
if (idxFirst < 0 || idxLast >= gaugePins[id].ledCount || idxFirst > idxLast) {
sendReply("ERR BAD_IDX"); return true;
}
CRGB color(constrain(r, 0, 255), constrain(g, 0, 255), constrain(b, 0, 255));
for (int i = idxFirst; i <= idxLast; i++) {
blinkState[gaugeLedOffset[id] + i].active = false;
leds[gaugeLedOffset[id] + i] = color;
}
ledsDirty = true;
sendReply("OK");
return true;
}
return false;
}
// Parses `BLINK ...` and assigns a simple on/off effect to one LED or a range.
// Replies: `OK`, `ERR BAD_ID`, `ERR BAD_IDX`, `ERR BAD_TIME`.
bool parseBlink(const String& line) {
int id, onMs, offMs, r, g, b;
char idxToken[16];
// Optional RGB values let BLINK either reuse or replace the current colour.
int count = sscanf(line.c_str(), "BLINK %d %15s %d %d %d %d %d",
&id, idxToken, &onMs, &offMs, &r, &g, &b);
if (count != 4 && count != 7) return false;
if (id < 0 || id >= GAUGE_COUNT) { sendReply("ERR BAD_ID"); return true; }
char* dash = strchr(idxToken, '-');
int idxFirst = atoi(idxToken);
int idxLast = dash ? atoi(dash + 1) : idxFirst;
if (idxFirst < 0 || idxLast >= gaugePins[id].ledCount || idxFirst > idxLast) {
sendReply("ERR BAD_IDX"); return true;
}
if (onMs == 0 && offMs == 0) {
for (int i = idxFirst; i <= idxLast; i++)
blinkState[gaugeLedOffset[id] + i].active = false;
sendReply("OK");
return true;
}
if (onMs <= 0 || offMs <= 0) { sendReply("ERR BAD_TIME"); return true; }
CRGB color = (count == 7)
? CRGB(constrain(r, 0, 255), constrain(g, 0, 255), constrain(b, 0, 255))
: CRGB(0, 0, 0); // Placeholder; replaced with the live LED colour below.
unsigned long nowMs = millis();
for (int i = idxFirst; i <= idxLast; i++) {
uint8_t globalIdx = gaugeLedOffset[id] + i;
BlinkState& bs = blinkState[globalIdx];
bs.fx = FX_BLINK;
bs.onColor = (count == 7) ? color : leds[globalIdx];
bs.onMs = (uint16_t)onMs;
bs.offMs = (uint16_t)offMs;
bs.currentlyOn = true;
bs.lastMs = nowMs;
bs.active = true;
leds[globalIdx] = bs.onColor;
}
ledsDirty = true;
sendReply("OK");
return true;
}
// Parses `BREATHE ...` and assigns a triangle-wave fade effect.
// Replies: `OK`, `ERR BAD_ID`, `ERR BAD_IDX`, `ERR BAD_TIME`.
bool parseBreathe(const String& line) {
int id, periodMs, r, g, b;
char idxToken[16];
if (sscanf(line.c_str(), "BREATHE %d %15s %d %d %d %d",
&id, idxToken, &periodMs, &r, &g, &b) != 6) return false;
if (id < 0 || id >= GAUGE_COUNT) { sendReply("ERR BAD_ID"); return true; }
char* dash = strchr(idxToken, '-');
int idxFirst = atoi(idxToken);
int idxLast = dash ? atoi(dash + 1) : idxFirst;
if (idxFirst < 0 || idxLast >= gaugePins[id].ledCount || idxFirst > idxLast) {
sendReply("ERR BAD_IDX"); return true;
}
if (periodMs <= 0) { sendReply("ERR BAD_TIME"); return true; }
CRGB color(constrain(r, 0, 255), constrain(g, 0, 255), constrain(b, 0, 255));
unsigned long nowMs = millis();
for (int i = idxFirst; i <= idxLast; i++) {
uint8_t gi = gaugeLedOffset[id] + i;
BlinkState& bs = blinkState[gi];
bs.fx = FX_BREATHE;
bs.onColor = color;
bs.periodMs = (uint16_t)constrain(periodMs, 100, 30000);
bs.cyclePos = 0;
bs.lastMs = nowMs;
bs.active = true;
leds[gi] = CRGB::Black;
}
ledsDirty = true;
sendReply("OK");
return true;
}
// Parses `DFLASH ...` and assigns the double-flash pattern.
// Replies: `OK`, `ERR BAD_ID`, `ERR BAD_IDX`.
bool parseDflash(const String& line) {
int id, r, g, b;
char idxToken[16];
if (sscanf(line.c_str(), "DFLASH %d %15s %d %d %d",
&id, idxToken, &r, &g, &b) != 5) return false;
if (id < 0 || id >= GAUGE_COUNT) { sendReply("ERR BAD_ID"); return true; }
char* dash = strchr(idxToken, '-');
int idxFirst = atoi(idxToken);
int idxLast = dash ? atoi(dash + 1) : idxFirst;
if (idxFirst < 0 || idxLast >= gaugePins[id].ledCount || idxFirst > idxLast) {
sendReply("ERR BAD_IDX"); return true;
}
CRGB color(constrain(r, 0, 255), constrain(g, 0, 255), constrain(b, 0, 255));
unsigned long nowMs = millis();
for (int i = idxFirst; i <= idxLast; i++) {
uint8_t gi = gaugeLedOffset[id] + i;
BlinkState& bs = blinkState[gi];
bs.fx = FX_DFLASH;
bs.onColor = color;
bs.dphase = 0;
bs.lastMs = nowMs;
bs.active = true;
leds[gi] = color; // phase 0 = on
}
ledsDirty = true;
sendReply("OK");
return true;
}
// Advances all active LED effects and marks the strip dirty when something changed.
void updateBlink() {
unsigned long nowMs = millis();
bool changed = false;
for (uint8_t i = 0; i < GAUGE_COUNT; i++) {
for (uint8_t j = 0; j < gaugePins[i].ledCount; j++) {
uint8_t gi = gaugeLedOffset[i] + j;
BlinkState& bs = blinkState[gi];
if (!bs.active) continue;
switch (bs.fx) {
case FX_BLINK: {
uint32_t period = bs.currentlyOn ? bs.onMs : bs.offMs;
if ((nowMs - bs.lastMs) >= period) {
bs.currentlyOn = !bs.currentlyOn;
bs.lastMs = nowMs;
leds[gi] = bs.currentlyOn ? bs.onColor : CRGB::Black;
changed = true;
}
break;
}
case FX_BREATHE: {
unsigned long dt = nowMs - bs.lastMs;
if (dt < 64) break;
uint32_t newPos = (uint32_t)bs.cyclePos + dt;
bs.cyclePos = (uint16_t)(newPos % bs.periodMs);
bs.lastMs = nowMs;
// Cheap triangle wave. It does the job and nobody has complained yet.
uint16_t half = bs.periodMs >> 1;
uint8_t bri = (bs.cyclePos < half)
? (uint8_t)((uint32_t)bs.cyclePos * 255 / half)
: (uint8_t)((uint32_t)(bs.periodMs - bs.cyclePos) * 255 / half);
leds[gi] = bs.onColor;
leds[gi].nscale8(bri ? bri : 1);
changed = true;
break;
}
case FX_DFLASH: {
static const uint16_t dur[4] = {100, 100, 100, 700}; // on, off, on, longer off
if ((nowMs - bs.lastMs) >= dur[bs.dphase]) {
bs.lastMs = nowMs;
bs.dphase = (bs.dphase + 1) & 3;
leds[gi] = (bs.dphase == 0 || bs.dphase == 2) ? bs.onColor : CRGB::Black;
changed = true;
}
break;
}
}
}
}
if (changed) ledsDirty = true;
}
// Runs the command parsers in order until one claims the line.
// Reply: `ERR BAD_CMD` when no parser accepts the line.
void processLine(const String& line) {
if (parseSet(line)) return;
if (parseSpeed(line)) return;
@@ -545,11 +824,16 @@ void processLine(const String& line) {
if (parsePosQuery(line)) return;
if (parseLedQuery(line)) return;
if (parseLed(line)) return;
if (parseBlink(line)) return;
if (parseBreathe(line)) return;
if (parseDflash(line)) return;
if (parsePing(line)) return;
sendReply("ERR BAD_CMD");
}
// Reads newline-delimited commands from serial and hands complete lines to the parser.
// Reply: `ERR TOO_LONG` when the buffered line exceeds the receive limit before newline.
void readCommands() {
while (CMD_PORT.available()) {
char c = (char)CMD_PORT.read();
@@ -570,8 +854,10 @@ void readCommands() {
}
}
// Initialises pins, LED bookkeeping and the initial homing cycle.
// Reply/event: emits `READY` on CMD_PORT once boot is complete.
void setup() {
DEBUG_PORT.begin(115200);
DEBUG_PORT.begin(SERIAL_BAUD);
DEBUG_PORT.println("Gauge controller booting");
for (uint8_t i = 0; i < GAUGE_COUNT; i++) {
@@ -589,7 +875,7 @@ void setup() {
gauges[i].lastUpdateMicros = micros();
}
// Compute per-gauge LED offsets and initialise the strip.
// Flatten the per-gauge LED counts into offsets on the shared strip.
uint8_t ledOff = 0;
for (uint8_t i = 0; i < GAUGE_COUNT; i++) {
gaugeLedOffset[i] = ledOff;
@@ -602,13 +888,23 @@ void setup() {
requestHomeAll();
DEBUG_PORT.println("READY");
// Boot-complete handshake for the command channel.
sendReply("READY");
}
// Main service loop: ingest commands, advance effects, move gauges, flush LEDs.
void loop() {
readCommands();
updateBlink();
for (uint8_t i = 0; i < GAUGE_COUNT; i++) {
updateGauge(i);
}
if (ledsDirty) {
FastLED.show();
ledsDirty = false;
}
}
+59
View File
@@ -0,0 +1,59 @@
{
"debug": false,
"wifi_ssid": "MyNetwork",
"wifi_password": "MyPassword",
"mqtt_broker": "192.168.1.10",
"mqtt_port": 1883,
"mqtt_user": "mqtt_user",
"mqtt_password": "mqtt_password",
"mqtt_client_id": "gauge_controller",
"mqtt_prefix": "gauges",
"heartbeat_ms": 10000,
"rezero_interval_ms": 3600000,
"device": {
"name": "Selsyn Multi",
"model": "Chernobyl Selsyn-inspired gauge",
"manufacturer": "AdeBaumann",
"area": "Control Panels"
},
"arduino_uart": 1,
"arduino_tx_pin": 17,
"arduino_rx_pin": 16,
"arduino_baud": 115200,
"gauges": [
{
"name": "Gauge 1",
"entity_name": "Selsyn 1 Power",
"min": 0,
"max": 7300,
"max_steps": 4000,
"speed": 5000,
"acceleration": 6000,
"unit": "W",
"leds": {
"ws2812_red": [255, 0, 0],
"ws2812_green": [0, 255, 0]
}
},
{
"name": "Gauge 2",
"entity_name": "Selsyn 2 Power",
"min": 0,
"max": 7300,
"max_steps": 4000,
"speed": 5000,
"acceleration": 6000,
"unit": "W",
"leds": {
"ws2812_red": [255, 0, 0],
"ws2812_green": [0, 255, 0]
}
}
]
}
+1176
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
import gauge
+474
View File
@@ -0,0 +1,474 @@
"""
ota.py Gitea OTA updater for ESP32 / MicroPython
Call ota.update() from boot.py before importing anything else.
If the update or the subsequent boot fails, the updater retries
on the next boot rather than bricking the device.
Strategy
--------
1. Check if last boot was good (OK flag exists).
2. If good, fetch remote commit SHA and compare with local if unchanged,
skip file check entirely.
3. If new commit or failed boot, fetch ota_manifest.txt from the repo
to determine which files to sync.
4. Compare SHA1 hashes with a local manifest (.ota_manifest.json).
5. Download only changed or missing files, writing to .tmp first.
6. On success, rename .tmp files into place and update the manifest.
7. If anything fails mid-update, the manifest is not updated, so the
next boot will retry. Partially written .tmp files are cleaned up.
8. A "safety" flag file (.ota_ok) is written by main.py on successful
startup. If it is absent on boot, the previous update is suspected
bad the manifest is wiped so all files are re-fetched cleanly.
Manifest format (ota_manifest.txt)
---------------------------------
Each line specifies a file or directory to include:
boot.py # specific file
ota.py # another file
selsyn/ # entire directory (trailing slash)
lib/ # another directory
*.py # wildcard (matches anywhere)
selsyn/*.py # wildcard in subdirectory
Usage in boot.py
----------------
import ota
ota.update()
# imports of main etc. go here
Configuration
-------------
Edit the block below, or override from a local config file
(see SETTINGS_FILE). All settings can be left as module-level
constants or placed in /ota_config.json:
{
"gitea_base": "http://git.baumann.gr",
"repo_owner": "adebaumann",
"repo_name": "HomeControlPanel",
"repo_folder": "firmware",
"repo_branch": "main",
"api_token": "nicetry-nothere"
}
"""
import os
import gc
import sys
import ujson
import urequests
import utime
# ---------------------------------------------------------------------------
# Default configuration — override via /ota_config.json
# ---------------------------------------------------------------------------
GITEA_BASE = "http://git.baumann.gr" # no trailing slash
REPO_OWNER = "adrian"
REPO_NAME = "esp32-gauge"
REPO_FOLDER = "firmware" # folder inside repo to sync
REPO_BRANCH = "main"
API_TOKEN = None # set to string for private repos
WIFI_SSID = None
WIFI_PASSWORD = None
SETTINGS_FILE = "/ota_config.json"
MANIFEST_FILE = "/.ota_manifest.json"
OK_FLAG_FILE = "/.ota_ok"
OTA_MANIFEST = "ota_manifest.txt"
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
def _ts():
ms = utime.ticks_ms()
return f"{(ms // 3600000) % 24:02d}:{(ms // 60000) % 60:02d}:{(ms // 1000) % 60:02d}.{ms % 1000:03d}"
def _log(level, msg):
print(f"[{_ts()}] {level:5s} [OTA] {msg}")
def info(msg):
_log("INFO", msg)
def warn(msg):
_log("WARN", msg)
def log_err(msg):
_log("ERROR", msg)
# ---------------------------------------------------------------------------
# HTTP helpers
# ---------------------------------------------------------------------------
def _headers():
h = {"Accept": "application/json"}
if API_TOKEN:
h["Authorization"] = f"token {API_TOKEN}"
return h
# ---------------------------------------------------------------------------
# Config loader
# ---------------------------------------------------------------------------
def load_config():
global \
GITEA_BASE, \
REPO_OWNER, \
REPO_NAME, \
REPO_FOLDER, \
REPO_BRANCH, \
API_TOKEN, \
WIFI_SSID, \
WIFI_PASSWORD
try:
with open(SETTINGS_FILE) as f:
cfg = ujson.load(f)
GITEA_BASE = cfg.get("gitea_base", GITEA_BASE)
REPO_OWNER = cfg.get("repo_owner", REPO_OWNER)
REPO_NAME = cfg.get("repo_name", REPO_NAME)
REPO_FOLDER = cfg.get("repo_folder", REPO_FOLDER)
REPO_BRANCH = cfg.get("repo_branch", REPO_BRANCH)
API_TOKEN = cfg.get("api_token", API_TOKEN)
WIFI_SSID = cfg.get("wifi_ssid", WIFI_SSID)
WIFI_PASSWORD = cfg.get("wifi_password", WIFI_PASSWORD)
info(f"Config loaded from {SETTINGS_FILE}")
except OSError:
info(f"No {SETTINGS_FILE} found — using defaults")
except Exception as e:
warn(f"Config parse error: {e} — using defaults")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _match_pattern(name, pattern):
if "*" not in pattern:
return name == pattern
i, n = 0, len(pattern)
j, m = 0, len(name)
star = -1
while i < n and j < m:
if pattern[i] == "*":
star = i
i += 1
elif pattern[i] == name[j]:
i += 1
j += 1
elif star >= 0:
i = star + 1
j += 1
else:
return False
while i < n and pattern[i] == "*":
i += 1
return i == n and j == m
def _fetch_commit_sha():
url = f"{GITEA_BASE}/api/v1/repos/{REPO_OWNER}/{REPO_NAME}/branches/{REPO_BRANCH}"
try:
r = urequests.get(url, headers=_headers())
if r.status_code == 200:
data = r.json()
r.close()
return data.get("commit", {}).get("id")
r.close()
except Exception as e:
log_err(f"Failed to fetch commit: {e}")
return None
def _fetch_manifest():
url = (
f"{GITEA_BASE}/api/v1/repos/{REPO_OWNER}/{REPO_NAME}"
f"/contents/{OTA_MANIFEST}?ref={REPO_BRANCH}"
)
try:
r = urequests.get(url, headers=_headers())
try:
if r.status_code == 200:
data = r.json()
if data.get("content"):
import ubinascii
content = ubinascii.a2b_base64(data["content"]).decode()
patterns = [line.strip() for line in content.splitlines()]
return [p for p in patterns if p and not p.startswith("#")]
else:
warn(f"Manifest not found at {OTA_MANIFEST}")
finally:
r.close()
except Exception as e:
log_err(f"Failed to fetch manifest: {e}")
return None
def _fetch_dir(path):
url = (
f"{GITEA_BASE}/api/v1/repos/{REPO_OWNER}/{REPO_NAME}"
f"/contents/{path}?ref={REPO_BRANCH}"
)
return _api_get(url)
def _api_get(url):
"""GET a URL and return parsed JSON, or None on failure."""
try:
r = urequests.get(url, headers=_headers())
if r.status_code == 200:
data = r.json()
r.close()
return data
warn(f"HTTP {r.status_code} for {url}")
r.close()
except Exception as e:
log_err(f"GET {url} failed: {e}")
return None
def _download(url, dest_path):
"""Download url to dest_path. Returns True on success."""
tmp = dest_path + ".tmp"
try:
r = urequests.get(url, headers=_headers())
if r.status_code != 200:
warn(f"Download failed HTTP {r.status_code}: {url}")
r.close()
return False
with open(tmp, "wb") as f:
f.write(r.content)
r.close()
# Rename into place
try:
os.remove(dest_path)
except OSError:
pass
os.rename(tmp, dest_path)
return True
except Exception as e:
log_err(f"Download error {url}: {e}")
try:
os.remove(tmp)
except OSError:
pass
return False
def _load_manifest():
try:
with open(MANIFEST_FILE) as f:
return ujson.load(f)
except Exception:
return {}
def _save_manifest(manifest, commit_sha=None):
try:
with open(MANIFEST_FILE, "w") as f:
if commit_sha:
manifest["_commit"] = commit_sha
ujson.dump(manifest, f)
except Exception as e:
warn(f"Could not save manifest: {e}")
def _ok_flag_exists():
try:
os.stat(OK_FLAG_FILE)
return True
except OSError:
return False
def _clear_ok_flag():
try:
os.remove(OK_FLAG_FILE)
except OSError:
pass
def mark_ok():
"""
Call this from main.py after successful startup.
Signals to the OTA updater that the last update was good.
"""
try:
with open(OK_FLAG_FILE, "w") as f:
f.write("ok")
except Exception as e:
warn(f"Could not write OK flag: {e}")
# ---------------------------------------------------------------------------
# Core update logic
# ---------------------------------------------------------------------------
def _fetch_file_list():
"""
Returns list of {name, sha, download_url} dicts based on the
ota_manifest.txt patterns in the repo folder, or None on failure.
"""
manifest_patterns = _fetch_manifest()
if manifest_patterns is None:
log_err("No manifest — cannot determine what to fetch")
return None
info(f"Manifest patterns: {manifest_patterns}")
files = []
visited = set()
def fetch_matching(entries, patterns):
for entry in entries:
if entry.get("type") == "dir":
for p in patterns:
if p.endswith("/") and entry["name"].startswith(p.rstrip("/")):
sub = _fetch_dir(entry["path"])
if sub:
fetch_matching(sub, patterns)
break
else:
name = entry["name"]
for p in patterns:
p = p.rstrip("/")
if _match_pattern(name, p) or _match_pattern(entry["path"], p):
if entry["path"] not in visited:
visited.add(entry["path"])
files.append(
{
"name": entry["path"],
"sha": entry["sha"],
"download_url": entry["download_url"],
}
)
break
root = _fetch_dir(REPO_FOLDER)
if root is None:
return None
fetch_matching(root, manifest_patterns)
return files
def _do_update(commit_sha=None):
"""
Fetch file list, download changed files, update manifest.
Returns True if all succeeded (or nothing needed updating).
"""
info(
f"Checking {GITEA_BASE}/{REPO_OWNER}/{REPO_NAME}/{REPO_FOLDER} @ {REPO_BRANCH}"
)
file_list = _fetch_file_list()
if file_list is None:
log_err("Could not fetch file list — skipping update")
return False
info(f"Found {len(file_list)} file(s) to sync")
manifest = _load_manifest()
updated = []
failed = []
for entry in file_list:
name = entry["name"]
sha = entry["sha"]
if manifest.get(name) == sha:
info(f" {name} up to date")
continue
info(f" {name} updating (sha={sha[:8]}...)")
gc.collect()
ok = _download(entry["download_url"], f"/{name}")
if ok:
manifest[name] = sha
updated.append(name)
info(f" {name} OK")
else:
failed.append(name)
log_err(f" {name} FAILED")
if failed:
log_err(f"Update incomplete — {len(failed)} file(s) failed: {failed}")
_save_manifest(manifest, commit_sha)
return False
_save_manifest(manifest, commit_sha)
if updated:
info(f"Update complete — {len(updated)} file(s) updated: {updated}")
else:
info("All files up to date — nothing to do")
return True
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
def update():
"""
Main entry point. Call from boot.py before importing application code.
- If the OK flag is missing, the previous boot is assumed to have
failed wipes the manifest so everything is re-fetched cleanly.
- If the commit hash hasn't changed and last boot was good, skip
file comparison entirely.
- Runs the update.
- Clears the OK flag so main.py must re-assert it on successful start.
"""
info("=" * 40)
info("OTA updater starting")
info("=" * 40)
load_config()
ok_flag = _ok_flag_exists()
manifest = _load_manifest()
if not ok_flag:
warn("OK flag missing — last boot may have failed")
warn("Re-checking all files, will only download changed ones")
else:
info("OK flag present — last boot was good")
commit_sha = _fetch_commit_sha()
if ok_flag and commit_sha and manifest.get("_commit") == commit_sha:
info(f"Commit unchanged ({commit_sha[:8]}) — skipping file check")
info("-" * 40)
return
if commit_sha:
info(f"Remote commit: {commit_sha[:8]}")
else:
warn("Could not fetch remote commit — proceeding with file check")
# Clear the flag now; main.py must call ota.mark_ok() to re-set it
_clear_ok_flag()
success = _do_update(commit_sha)
if success:
info("OTA check complete — booting application")
else:
warn("OTA check had errors — booting with current files")
info("-" * 40)
gc.collect()
+9
View File
@@ -0,0 +1,9 @@
{
"gitea_base": "http://git.baumann.gr",
"repo_owner": "adebaumann",
"repo_name": "Selsyn_inspired_gauge",
"repo_folder": "",
"repo_branch": "main",
"wifi_ssid": "YourNetwork",
"wifi_password": "YourPassword"
}
+1
View File
@@ -0,0 +1 @@
*.py