60 Commits

Author SHA1 Message Date
8bdae1da9b Update docs and firmware for ESPHome bridge migration
- Replace gauge.py (MicroPython) references with gaugecontroller.yaml (ESPHome)
- Update CLAUDE.md and README.md to document ESPHome-native API integration
- Update LED wiring docs for separate main/indicator strips (D22/D36)
- Refactor Arduino firmware to drive two WS2812 strips independently
- Add per-gauge physical offset caching for main and indicator LEDs
- Frame-limit breathe effect (16ms) to reduce unnecessary strip refreshes
2026-04-29 19:03:22 +02:00
361cf52252 More logs 2026-04-28 00:31:23 +02:00
2d63ec6006 Reverted - same problem again 2026-04-28 00:22:01 +02:00
511ee05712 check actual values for indicator LEDs 2026-04-28 00:18:32 +02:00
03ab8604ba write_action removed - worked before that... 2026-04-28 00:14:55 +02:00
edb973bb61 Reverted the whole AI bullshit 2026-04-27 23:46:45 +02:00
bffcf62cae Timing changed on Arduino script, latest version of ESP-Home script added 2026-04-27 19:20:30 +02:00
27597bceab configurable GRB/RGB LEDs per LED 2026-04-27 09:06:43 +02:00
016de2ccb4 4 Gauges 2026-04-26 23:26:00 +02:00
15257ae6f2 CFG? added 2026-04-23 00:31:17 +02:00
795eb0ecf3 Some fixes (integers for speed, Serial returns back to HA etc.) 2026-04-22 16:14:10 +02:00
558c5b18c2 extended to 4 gauges, zeroing for all gauges individually and collectively added 2026-04-22 15:54:53 +02:00
fa66dd70d4 Speed/Config and VFD added 2026-04-22 14:56:04 +02:00
b14bdf7fc3 Added "SET" implementation for gauge values 2026-04-22 14:49:59 +02:00
427dde8c72 Continued rewrite - Lights and light effects implemented 2026-04-22 14:39:01 +02:00
ad50fd2ee5 Wifi bugs... 2026-04-21 21:07:42 +02:00
ae6d72b292 Now by hand... 2026-04-21 21:05:29 +02:00
2ea19b14f8 Attempt with tcp probe 2026-04-21 20:26:31 +02:00
1f8ba45685 Attempt with tcp probe 2026-04-21 20:10:38 +02:00
5a98a3c63b No more reconnect in main 2026-04-21 20:04:11 +02:00
afe70da24d MQTT trouble,still 2026-04-21 19:57:22 +02:00
0d7ae5cedc MQTT trouble 2026-04-21 18:52:53 +02:00
b3e2ef0f81 MQTT trouble 2026-04-21 18:41:10 +02:00
6068628d13 MQTT trouble 2026-04-21 18:40:10 +02:00
64b0aa482f MQTT trouble 2026-04-21 18:34:29 +02:00
2e5e410897 More garbage collection 2026-04-21 01:43:57 +02:00
4045da8964 WiFi changes again 2026-04-21 01:39:26 +02:00
81099d9887 Top speed lowered 2026-04-21 01:32:34 +02:00
355faadc31 WiFi made more resilient 2026-04-21 01:32:11 +02:00
109f97caa0 Baccklight bug fixed 2026-04-21 01:16:34 +02:00
9e7311fb7d Resistor divider added to wiring.md 2026-04-21 01:03:51 +02:00
9912fe82cc Documented wiring and layout 2026-04-21 00:52:18 +02:00
9f5deba0ed VFD functionality added to HA 2026-04-21 00:40:18 +02:00
ef875334c5 VFD functionality successfully integrated into Gaugecontroller 2026-04-21 00:34:42 +02:00
252caf1bf7 VFD Standalone added 2026-04-21 00:14:17 +02:00
21b413eb57 Discovery troubleshooting 2026-04-20 19:59:44 +02:00
2879be0ada Code commented, Serial speed moved into constant 2026-04-19 23:14:50 +02:00
b6e4bfea33 3 Gauges, routines switched 2026-04-19 23:03:01 +02:00
59721477df LED Names changed 2026-04-17 22:28:16 +02:00
7be9b59093 Garbage collection added 2026-04-17 22:19:48 +02:00
9a25805522 MQTT troubleshooting 2026-04-17 22:17:49 +02:00
44afc207ea Speed and acceleration no longer hidden, but in config. 2026-04-17 22:09:18 +02:00
549a7c7d37 MQTT doesn't autodiscover properly 2026-04-17 21:24:17 +02:00
10ef3580b2 MQTT doesn't autodiscover properly 2026-04-17 21:21:29 +02:00
f78d090f95 MQTT doesn't like non-ASCII... 2026-04-17 19:15:01 +02:00
ef986c2881 Added speed and acceleration to Home Assistant 2026-04-17 19:10:43 +02:00
4cb4947bd1 Lowered LED-Breathe-Frequency 2026-04-17 18:53:08 +02:00
18093092f0 38400 baud 2026-04-16 00:49:10 +02:00
358ddcaeb5 Breathe and double blink added to LED effects 2026-04-15 23:07:04 +02:00
2282038391 Interrupt issue with FastLed circumvented 2026-04-15 22:46:39 +02:00
f7f7b389a0 Duh, no ** deref in json... 2026-04-15 22:08:52 +02:00
d9d17e5e5c Blinking added with Light-Effects in Home Assistant 2026-04-15 22:05:55 +02:00
4a7551e358 Blinking added to Arduino 2026-04-15 21:53:01 +02:00
19b1a7c6e5 Main routine added 2026-04-15 21:19:23 +02:00
036fa045f8 Discovery didn't set online status 2026-04-15 00:44:22 +02:00
a9fc7cd0ed Logging received serial data 2026-04-14 23:20:47 +02:00
cf2c55f5cf Code for attached ESP32-MQTT-receiver added 2026-04-14 21:17:28 +02:00
7aaf5ce334 Merge branch 'feature/overshoot' 2026-04-14 19:31:34 +02:00
3a7f98a3d2 LED now accepts ranges 2026-04-14 13:45:11 +02:00
6cc0cff069 Documentation updated as to serial ports 2026-04-14 13:35:34 +02:00
16 changed files with 6625 additions and 41 deletions

View File

@@ -4,17 +4,55 @@ 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`:
The ESP32 bridge runs ESPHome; the config is in `gaugecontroller.yaml`.
```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
@@ -22,7 +60,7 @@ The sketch controls `GAUGE_COUNT` stepper-motor gauges using a trapezoidal veloc
### Key data structures
- `GaugePins` — hardware pin mapping per gauge (dir, step, enable, active-high/low polarity flags, `ledCount`). Declared `constexpr` so `TOTAL_LEDS` can be computed from it at compile time. Configured in the `gaugePins[]` array at the top.
- `GaugePins` — hardware pin mapping per gauge (dir, step, enable, active-high/low polarity flags, `ledOrder` string). Declared `constexpr` so `TOTAL_LEDS` can be computed from it at compile time. Configured in the `gaugePins[]` array at the top.
- `Gauge` — per-gauge runtime state: position, target, velocity, accel, homing state machine, sweep mode.
### Motion control (`updateGauge`)
@@ -40,7 +78,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.
Two LED strips are driven: main backlight/status LEDs on `LED_DATA_PIN` (currently 22) and dial indicator LEDs on `INDICATOR_LED_DATA_PIN` (currently 36). The serial protocol still exposes one logical per-gauge LED segment: `0-2` backlight, `3-4` indicators, `5-6` status. `gaugePins[i].ledOrder` is a per-LED type string (one char per LED, `'G'` = GRB-ordered, `'R'` = RGB-ordered) and its length defines the logical LED count. `TOTAL_LEDS`, `TOTAL_MAIN_LEDS`, and `TOTAL_INDICATOR_LEDS` are computed at compile time. Per-gauge logical and physical offsets are cached in `setup()`. LED writes dirty only their physical strip, and the loop flushes each FastLED controller independently with `showLeds()`.
### Serial command protocol
@@ -56,8 +94,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.
@@ -65,6 +106,6 @@ All commands reply `OK` or `ERR BAD_ID` / `ERR BAD_CMD` etc.
### Adding gauges
1. Increment `GAUGE_COUNT`.
2. Add a `constexpr GaugePins` entry to `gaugePins[]` (including `ledCount`).
2. Add a `constexpr GaugePins` entry to `gaugePins[]` (including the `ledOrder` string — one char per LED, `'G'` for GRB or `'R'` for RGB).
3. Tune `maxPos` and `homingBackoffSteps` in the corresponding `Gauge` default or at runtime.
4. `TOTAL_LEDS` and `gaugeLedOffset[]` update automatically — no manual changes needed.
4. `TOTAL_LEDS`, `gaugeLedOffset[]`, and `gaugeLedCount[]` update automatically — no manual changes needed.

View File

@@ -1,39 +1,270 @@
#include <Arduino.h>
#include <ctype.h>
#include <math.h>
#include <FastLED.h>
static const uint8_t GAUGE_COUNT = 2;
static const uint8_t GAUGE_COUNT = 4;
// 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.
// Backlight/status LEDs and indicator LEDs use separate data strips because
// their LED chipsets are not compatible on one chain. The command protocol
// still exposes one logical LED segment per gauge.
static const uint8_t LED_DATA_PIN = 22;
static const uint8_t INDICATOR_LED_DATA_PIN = 36;
static const uint8_t BREATHE_FRAME_MS = 16;
// 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;
namespace vfd {
constexpr uint8_t kDataPin = 46;
constexpr uint8_t kClockPin = 47;
constexpr uint8_t kLatchPin = 48;
constexpr int8_t kBlankPin = 49; // Set to -1 if BL/OE is not connected
constexpr bool kBlankActiveHigh = true;
constexpr unsigned long kDigitHoldMicros = 2000;
constexpr uint8_t kDigitCount = 4;
constexpr uint8_t kSegmentCount = 7;
constexpr uint8_t kDriverBits = 20;
constexpr uint8_t kSegmentStartBit = 0; // HVOut1 -> bit 0
constexpr uint8_t kPointSegmentBit = 7; // HVOut8 -> bit 7
constexpr uint8_t kBellSegmentBit = 8; // HVOut9 -> bit 8
constexpr uint8_t kGridStartBit = 9; // HVOut10 -> bit 9
constexpr uint8_t kIndicatorGridBit = 13; // HVOut14 -> bit 13
char displayBuffer[kDigitCount] = {' ', ' ', ' ', ' '};
bool pointEnabled = false;
bool bellEnabled = false;
uint8_t currentPhase = 0;
uint8_t encodeCharacter(char c) {
switch (c) {
case '0': return 0b0111111;
case '1': return 0b0000110;
case '2': return 0b1011011;
case '3': return 0b1001111;
case '4': return 0b1100110;
case '5': return 0b1101101;
case '6': return 0b1111101;
case '7': return 0b0000111;
case '8': return 0b1111111;
case '9': return 0b1101111;
case 'A':
case 'a': return 0b1110111;
case 'B':
case 'b': return 0b1111100;
case 'C':
case 'c': return 0b0111001;
case 'D':
case 'd': return 0b1011110;
case 'E':
case 'e': return 0b1111001;
case 'F':
case 'f': return 0b1110001;
case '-': return 0b1000000;
default: return 0;
}
}
void shiftDriverWord(uint32_t word) {
digitalWrite(kLatchPin, HIGH);
digitalWrite(kClockPin, HIGH);
for (int8_t bit = kDriverBits - 1; bit >= 0; --bit) {
digitalWrite(kDataPin, (word >> bit) & 0x1U ? HIGH : LOW);
digitalWrite(kClockPin, LOW);
digitalWrite(kClockPin, HIGH);
}
digitalWrite(kLatchPin, LOW);
digitalWrite(kLatchPin, HIGH);
}
void setBlanked(bool blanked) {
if (kBlankPin < 0) return;
const bool level = kBlankActiveHigh ? blanked : !blanked;
digitalWrite(kBlankPin, level ? HIGH : LOW);
}
void writeText(const char* text) {
for (uint8_t i = 0; i < kDigitCount; ++i) {
displayBuffer[i] = ' ';
}
size_t len = strlen(text);
if (len > kDigitCount) {
text += len - kDigitCount;
len = kDigitCount;
}
const uint8_t start = kDigitCount - len;
for (uint8_t i = 0; i < len; ++i) {
displayBuffer[start + i] = text[i];
}
}
void clear() {
writeText("");
pointEnabled = false;
bellEnabled = false;
}
bool parseCommand(const String& command) {
char displayText[16];
size_t inputIndex = 0;
size_t displayIndex = 0;
if (command.length() == 0) {
return false;
}
if (command[inputIndex] == '-') {
if (displayIndex + 1 >= sizeof(displayText)) {
return false;
}
displayText[displayIndex++] = command[inputIndex++];
}
const size_t digitStart = inputIndex;
while (inputIndex < static_cast<size_t>(command.length()) &&
isxdigit(static_cast<unsigned char>(command[inputIndex]))) {
if (displayIndex + 1 >= sizeof(displayText)) {
return false;
}
displayText[displayIndex++] = toupper(static_cast<unsigned char>(command[inputIndex]));
++inputIndex;
}
if (inputIndex == digitStart) {
return false;
}
bool newPointEnabled = false;
bool newBellEnabled = false;
while (inputIndex < static_cast<size_t>(command.length())) {
if (command[inputIndex] == '.') {
newPointEnabled = true;
} else if (command[inputIndex] == '!') {
newBellEnabled = true;
} else {
return false;
}
++inputIndex;
}
displayText[displayIndex] = '\0';
writeText(displayText);
pointEnabled = newPointEnabled;
bellEnabled = newBellEnabled;
return true;
}
void renderDigit(uint8_t digitIndex) {
uint32_t word = 0;
const uint8_t segments = encodeCharacter(displayBuffer[digitIndex]);
for (uint8_t segment = 0; segment < kSegmentCount; ++segment) {
if ((segments >> segment) & 0x1U) {
word |= (1UL << (kSegmentStartBit + segment));
}
}
word |= (1UL << (kGridStartBit + digitIndex));
shiftDriverWord(word);
}
void renderIndicator() {
uint32_t word = 1UL << kIndicatorGridBit;
if (pointEnabled) {
word |= 1UL << kPointSegmentBit;
}
if (bellEnabled) {
word |= 1UL << kBellSegmentBit;
}
shiftDriverWord(word);
}
void begin() {
pinMode(kDataPin, OUTPUT);
pinMode(kClockPin, OUTPUT);
pinMode(kLatchPin, OUTPUT);
if (kBlankPin >= 0) {
pinMode(kBlankPin, OUTPUT);
}
digitalWrite(kDataPin, LOW);
digitalWrite(kClockPin, HIGH);
digitalWrite(kLatchPin, HIGH);
setBlanked(true);
writeText("0");
shiftDriverWord(0);
}
void refresh() {
setBlanked(true);
if (currentPhase < kDigitCount) {
renderDigit(currentPhase);
} else if (pointEnabled || bellEnabled) {
renderIndicator();
} else {
shiftDriverWord(0);
}
setBlanked(false);
delayMicroseconds(kDigitHoldMicros);
setBlanked(true);
currentPhase = (currentPhase + 1) % (kDigitCount + 1);
}
} // namespace vfd
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)
const char* ledOrder; // one char per LED: 'G' = GRB, 'R' = RGB; length defines ledCount
};
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
// dir, step, en, dirInv, stepHigh, enActiveLow, ledOrder
{50, 51, -1, false, true, true, "RRRGGRR"}, // Gauge 0
{8, 9, -1, true, true, true, "GGGRRRR"}, // Gauge 1
{52, 53, -1, false, true, true, "GGGRRRR"}, // Gauge 2
{48, 49, -1, false, true, true, "GGGRRRR"}, // Gauge 3
};
constexpr uint8_t cstrLen(const char* s) {
return *s ? uint8_t(1 + cstrLen(s + 1)) : uint8_t(0);
}
constexpr uint8_t sumLedCounts(uint8_t i = 0) {
return i >= GAUGE_COUNT ? 0 : gaugePins[i].ledCount + sumLedCounts(i + 1);
return i >= GAUGE_COUNT ? 0 : cstrLen(gaugePins[i].ledOrder) + sumLedCounts(i + 1);
}
static const uint8_t TOTAL_LEDS = sumLedCounts();
constexpr bool isIndicatorLedIndex(uint8_t localIdx) {
return localIdx == 3 || localIdx == 4;
}
constexpr uint8_t countIndicatorLedsForGauge(uint8_t gaugeIdx) {
return (cstrLen(gaugePins[gaugeIdx].ledOrder) > 3 ? 1 : 0) +
(cstrLen(gaugePins[gaugeIdx].ledOrder) > 4 ? 1 : 0);
}
constexpr uint8_t sumIndicatorLedCounts(uint8_t i = 0) {
return i >= GAUGE_COUNT ? 0 : countIndicatorLedsForGauge(i) + sumIndicatorLedCounts(i + 1);
}
static const uint8_t TOTAL_INDICATOR_LEDS = sumIndicatorLedCounts();
static const uint8_t TOTAL_MAIN_LEDS = TOTAL_LEDS - TOTAL_INDICATOR_LEDS;
enum HomingState : uint8_t {
HS_IDLE,
HS_START,
@@ -47,11 +278,11 @@ 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;
float maxSpeed = 4000.0f;
float accel = 6000.0f;
float homingSpeed = 500.0f;
@@ -70,20 +301,169 @@ 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];
CRGB logicalLeds[TOTAL_LEDS];
CRGB mainLeds[TOTAL_MAIN_LEDS];
CRGB indicatorLeds[TOTAL_INDICATOR_LEDS];
CLEDController* mainLedController = nullptr;
CLEDController* indicatorLedController = nullptr;
uint8_t gaugeLedOffset[GAUGE_COUNT];
uint8_t gaugeLedCount[GAUGE_COUNT];
uint8_t gaugeMainLedOffset[GAUGE_COUNT];
uint8_t gaugeIndicatorLedOffset[GAUGE_COUNT];
BlinkState blinkState[TOTAL_LEDS];
bool mainLedsDirty = false;
bool indicatorLedsDirty = false;
// FastLED drives the shared strip as RGB. Each gauge's ledOrder string marks per-LED
// type ('R' = RGB, 'G' = GRB); writes to GRB-ordered LEDs pre-swap R and G to compensate.
inline bool ledNeedsRgSwap(uint8_t globalIdx) {
for (uint8_t i = 0; i < GAUGE_COUNT; i++) {
uint8_t off = gaugeLedOffset[i];
if (globalIdx >= off && globalIdx < off + gaugeLedCount[i]) {
char c = gaugePins[i].ledOrder[globalIdx - off];
return c == 'G' || c == 'g';
}
}
return false;
}
inline CRGB encodeForStrip(uint8_t globalIdx, CRGB color) {
if (ledNeedsRgSwap(globalIdx)) {
uint8_t tmp = color.r;
color.r = color.g;
color.g = tmp;
}
return color;
}
bool ledPhysicalIndex(uint8_t globalIdx, bool& indicatorStrip, uint8_t& physicalIdx) {
for (uint8_t i = 0; i < GAUGE_COUNT; i++) {
uint8_t off = gaugeLedOffset[i];
if (globalIdx < off || globalIdx >= off + gaugeLedCount[i]) continue;
uint8_t localIdx = globalIdx - off;
indicatorStrip = isIndicatorLedIndex(localIdx);
if (indicatorStrip) {
physicalIdx = gaugeIndicatorLedOffset[i] + (localIdx - 3);
} else {
physicalIdx = gaugeMainLedOffset[i] + localIdx - (localIdx > 4 ? 2 : 0);
}
return true;
}
return false;
}
inline void writeLed(uint8_t globalIdx, CRGB color) {
logicalLeds[globalIdx] = color;
bool indicatorStrip = false;
uint8_t physicalIdx = 0;
if (!ledPhysicalIndex(globalIdx, indicatorStrip, physicalIdx)) return;
if (indicatorStrip) {
indicatorLeds[physicalIdx] = encodeForStrip(globalIdx, color);
indicatorLedsDirty = true;
} else {
mainLeds[physicalIdx] = encodeForStrip(globalIdx, color);
mainLedsDirty = true;
}
}
inline CRGB readLed(uint8_t globalIdx) {
return logicalLeds[globalIdx];
}
void showDirtyLeds() {
if (mainLedsDirty && mainLedController != nullptr) {
mainLedController->showLeds(255);
mainLedsDirty = false;
}
if (indicatorLedsDirty && indicatorLedController != nullptr) {
indicatorLedController->showLeds(255);
indicatorLedsDirty = 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> <ig> <b>
// VFD <text[.!]>
// 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.
// ERR BAD_VFD
// Sent by VFD when the text payload is malformed.
// 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 +475,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 +489,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 +507,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 +518,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 +536,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 +580,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 +600,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 +635,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 +656,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 +698,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 +719,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 +744,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 +769,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 +786,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 +809,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 +833,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 +871,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 +896,26 @@ bool parsePosQuery(const String& line) {
return false;
}
// Answers `CFG?` with speed and acceleration for every gauge.
// Emits one `CFG <id> <maxSpeed> <accel>` line per gauge, then replies `OK`.
bool parseCfgQuery(const String& line) {
if (line == "CFG?") {
for (uint8_t i = 0; i < GAUGE_COUNT; i++) {
CMD_PORT.print("CFG ");
CMD_PORT.print(i);
CMD_PORT.print(' ');
CMD_PORT.print((int)gauges[i].maxSpeed);
CMD_PORT.print(' ');
CMD_PORT.println((int)gauges[i].accel);
}
sendReply("OK");
return true;
}
return false;
}
// Answers the mandatory life question: are you there?
// Reply: `PONG`.
bool parsePing(const String& line) {
if (line == "PING") {
sendReply("PONG");
@@ -496,11 +924,39 @@ bool parsePing(const String& line) {
return false;
}
// Parses `VFD <text>` where <text> is up to four hex characters with optional `.` and `!` suffixes.
// Replies: `OK`, `ERR BAD_VFD`.
bool parseVfd(const String& line) {
if (line == "VFD") {
vfd::clear();
sendReply("OK");
return true;
}
if (!line.startsWith("VFD ")) return false;
const String payload = line.substring(4);
if (payload.length() == 0) {
vfd::clear();
sendReply("OK");
return true;
}
if (vfd::parseCommand(payload)) {
sendReply("OK");
} else {
sendReply("ERR BAD_VFD");
}
return true;
}
// 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++) {
for (uint8_t j = 0; j < gaugePins[i].ledCount; j++) {
const CRGB& c = leds[gaugeLedOffset[i] + j];
for (uint8_t j = 0; j < gaugeLedCount[i]; j++) {
CRGB c = readLed(gaugeLedOffset[i] + j);
CMD_PORT.print("LED ");
CMD_PORT.print(i);
CMD_PORT.print(' ');
@@ -519,21 +975,192 @@ 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 >= gaugeLedCount[id] || 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;
writeLed(gaugeLedOffset[id] + i, color);
}
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 >= gaugeLedCount[id] || 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 : readLed(globalIdx);
bs.onMs = (uint16_t)onMs;
bs.offMs = (uint16_t)offMs;
bs.currentlyOn = true;
bs.lastMs = nowMs;
bs.active = true;
writeLed(globalIdx, bs.onColor);
}
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 >= gaugeLedCount[id] || 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;
writeLed(gi, CRGB::Black);
}
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 >= gaugeLedCount[id] || 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;
writeLed(gi, color); // phase 0 = on
}
sendReply("OK");
return true;
}
// Advances all active LED effects. writeLed() marks the affected physical strip dirty.
void updateBlink() {
unsigned long nowMs = millis();
for (uint8_t i = 0; i < GAUGE_COUNT; i++) {
for (uint8_t j = 0; j < gaugeLedCount[i]; 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;
writeLed(gi, bs.currentlyOn ? bs.onColor : CRGB::Black);
}
break;
}
case FX_BREATHE: {
unsigned long dt = nowMs - bs.lastMs;
if (dt < BREATHE_FRAME_MS) break;
uint32_t newPos = (uint32_t)bs.cyclePos + dt;
bs.cyclePos = (uint16_t)(newPos % bs.periodMs);
bs.lastMs = nowMs;
// Triangle wave brightness; frame-limited so breathe remains smooth
// without refreshing the LED strips on every service-loop pass.
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);
CRGB scaled = bs.onColor;
scaled.nscale8(bri ? bri : 1);
writeLed(gi, scaled);
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;
writeLed(gi, (bs.dphase == 0 || bs.dphase == 2) ? bs.onColor : CRGB::Black);
}
break;
}
}
}
}
}
// 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;
@@ -543,13 +1170,20 @@ void processLine(const String& line) {
if (parseHome(line)) return;
if (parseSweep(line)) return;
if (parsePosQuery(line)) return;
if (parseCfgQuery(line)) return;
if (parseLedQuery(line)) return;
if (parseLed(line)) return;
if (parseBlink(line)) return;
if (parseBreathe(line)) return;
if (parseDflash(line)) return;
if (parseVfd(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 +1204,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,26 +1225,46 @@ void setup() {
gauges[i].lastUpdateMicros = micros();
}
// Compute per-gauge LED offsets and initialise the strip.
// Flatten the per-gauge LED counts into logical offsets and separate
// physical offsets for the main and indicator strips.
uint8_t ledOff = 0;
uint8_t mainLedOff = 0;
uint8_t indicatorLedOff = 0;
for (uint8_t i = 0; i < GAUGE_COUNT; i++) {
gaugeLedCount[i] = cstrLen(gaugePins[i].ledOrder);
gaugeLedOffset[i] = ledOff;
ledOff += gaugePins[i].ledCount;
gaugeMainLedOffset[i] = mainLedOff;
gaugeIndicatorLedOffset[i] = indicatorLedOff;
ledOff += gaugeLedCount[i];
indicatorLedOff += countIndicatorLedsForGauge(i);
mainLedOff += gaugeLedCount[i] - countIndicatorLedsForGauge(i);
}
FastLED.addLeds<WS2812B, LED_DATA_PIN, GRB>(leds, TOTAL_LEDS);
mainLedController = &FastLED.addLeds<WS2812, LED_DATA_PIN, RGB>(mainLeds, TOTAL_MAIN_LEDS);
indicatorLedController = &FastLED.addLeds<WS2812B, INDICATOR_LED_DATA_PIN, RGB>(indicatorLeds, TOTAL_INDICATOR_LEDS);
FastLED.setBrightness(255);
FastLED.show();
mainLedController->showLeds(255);
indicatorLedController->showLeds(255);
vfd::begin();
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();
vfd::refresh();
updateBlink();
for (uint8_t i = 0; i < GAUGE_COUNT; i++) {
updateGauge(i);
}
showDirtyLeds();
}

View File

@@ -1,3 +1,79 @@
# arduino_gauge_controller
A dedicated gauge controller for Arduinos.
A dedicated gauge controller for Arduinos.
## Overview
This repository contains:
- `Gaugecontroller/Gaugecontroller.ino`: the Arduino Mega firmware for the stepper gauges, LEDs, and integrated HV5812-based VFD
- `gaugecontroller.yaml`: the ESPHome-based ESP32 firmware that exposes the gauges and VFD to Home Assistant via the native API
## VFD Support
The integrated gauge controller now includes a 4-digit VFD with:
- 4 alphanumeric digits
- decimal point indicator
- alarm bell indicator
On the merged Arduino firmware, the HV5812 control pins are:
- `D46` -> `DATA`
- `D47` -> `CLOCK`
- `D48` -> `STROBE`
- `D49` -> `BLANK/OE`
The standalone VFD sketch used `D51/D52/D53/D49`, but `51/52/53` conflict with the gauge stepper pins in the integrated controller.
## Arduino Serial Commands
The merged Arduino firmware accepts:
- `VFD`
clears the display and turns off decimal point and alarm bell
- `VFD 1234`
- `VFD 0123`
- `VFD DEAD`
- `VFD 8888.`
- `VFD BEEF!`
- `VFD 12AF.!`
Rules:
- up to 4 characters are displayed
- valid characters are `0-9`, `A-F`, and `-`
- `.` enables the decimal point
- `!` enables the alarm bell
- shorter values are right-aligned
- leading zeroes are preserved if they are part of the input
## Home Assistant Integration
The ESPHome firmware in `gaugecontroller.yaml` exposes entities to Home Assistant via the native API:
### Gauge Controls
- Number entities for each gauge's target value (with unit conversion)
- Number entities for speed and acceleration (diagnostic)
- Rezero buttons for each gauge and all gauges
### VFD Display
- `VFD Display`: text entity for the displayed value
- `VFD Decimal Point`: switch entity
- `VFD Alarm`: switch entity
The display is intentionally exposed as a text entity rather than a numeric entity so that:
- leading zeroes are preserved
- hexadecimal values like `DEAD` or `BEEF` work
- clearing the display is possible with an empty value
### LED Controls
- RGB light entity for each gauge's backlight with effects (Blink, Breathe, Double Flash)
- Binary light entities for each gauge's red/green indicators and status lights
### Diagnostics
- WiFi signal sensor
- Uptime sensor
- IP address and SSID text sensors
- Arduino Last Message sensor

323
Rewire_checklist.md Normal file
View File

@@ -0,0 +1,323 @@
# Rewire Checklist
This is a practical rebuild checklist for the current integrated setup:
- `Arduino Mega 2560`
- `ESP32` running `gauge.py`
- `HV5812P`
- 4-digit VFD with decimal point and alarm bell
- 3 gauge drivers
- WS2812B LED chain
Use this to rebuild the bench wiring from scratch.
## 1. Power Off Everything
- disconnect all power supplies
- disconnect USB power if it is currently feeding any board
- do not move wires while the VFD high-voltage supply is live
## 2. Place The Main Parts
- place the `Arduino Mega 2560`
- place the `ESP32`
- place the `HV5812P`
- place the 3 gauge driver boards
- place the WS2812B strip connection point
- place the VFD tube connection point
## 3. Establish A Common Ground First
Before anything else, create one common logic ground network.
Connect:
- `Mega GND` -> ground rail
- `ESP32 GND` -> same ground rail
- `HV5812P GND` -> same ground rail
- `Gauge driver 0 logic GND` -> same ground rail
- `Gauge driver 1 logic GND` -> same ground rail
- `Gauge driver 2 logic GND` -> same ground rail
- `WS2812B GND` -> same ground rail
If your VFD high-voltage supply has a ground/reference return:
- `VFD HV supply return` -> same common ground rail
Do not continue until this common ground is in place.
## 4. Wire The Arduino Mega Power
Connect:
- regulated `5V logic supply` -> `Mega 5V`
- ground rail -> `Mega GND`
Do not use the Mega to power motors.
## 5. Wire The ESP32 Power
Power the ESP32 in the way your board expects.
Typical options:
- via board USB
- via board `5V/VIN` if your ESP32 board has its own regulator
- via regulated `3.3V` if it is a bare module that requires that
Also connect:
- `ESP32 GND` -> common ground rail
Do not feed raw `5V` into a bare `3.3V-only` ESP32 module.
## 6. Wire The ESP32 UART To The Mega
Connect:
- `ESP32 GPIO17 (TX)` -> `Mega pin 19 (RX1)`
- `ESP32 GPIO16 (RX)` <- `Mega pin 18 (TX1)`
- `ESP32 GND` -> `Mega GND`
This UART link is used by `gauge.py`.
## 7. Wire The HV5812P Logic Side
Connect:
- `Mega D46` -> `HV5812P DATA IN / DIN`
- `Mega D47` -> `HV5812P CLOCK / CLK`
- `Mega D48` -> `HV5812P STROBE / LATCH`
- `Mega D49` -> `HV5812P BLANKING / OE`
- `Mega 5V` -> `HV5812P VDD`
- `Mega GND` -> `HV5812P GND`
Do not connect:
- `Mega 5V` -> `HV5812P VPP`
## 8. Wire The HV5812P High-Voltage Side
Connect:
- `VFD high-voltage positive supply` -> `HV5812P VPP`
- `VFD high-voltage supply return / reference` -> common ground rail
At this stage:
- `VDD` must be `5V`
- `VPP` must be your VFD high-voltage rail
## 9. Wire The HV5812P Outputs To The VFD
Connect these one by one:
- `HVOut1` -> VFD segment `A`
- `HVOut2` -> VFD segment `B`
- `HVOut3` -> VFD segment `C`
- `HVOut4` -> VFD segment `D`
- `HVOut5` -> VFD segment `E`
- `HVOut6` -> VFD segment `F`
- `HVOut7` -> VFD segment `G`
- `HVOut8` -> VFD decimal point segment
- `HVOut9` -> VFD alarm bell segment
- `HVOut10` -> VFD digit 1 grid
- `HVOut11` -> VFD digit 2 grid
- `HVOut12` -> VFD digit 3 grid
- `HVOut13` -> VFD digit 4 grid
- `HVOut14` -> VFD indicator grid between digits 2 and 3
## 10. Wire The VFD Filament
Wire the VFD filament/heater exactly as required by your tube.
This checklist cannot specify the exact filament supply because it depends on the actual tube.
Required reminder:
- do not power the filament from an Arduino GPIO
- use the correct filament supply for the tube
## 11. Wire Gauge Driver 0
Connect:
- `Mega D50` -> `Gauge driver 0 DIR`
- `Mega D51` -> `Gauge driver 0 STEP`
- `Mega GND` -> `Gauge driver 0 logic GND`
Then connect the motor side of that driver to:
- its motor power supply
- its gauge motor
according to the driver board you are using.
## 12. Wire Gauge Driver 1
Connect:
- `Mega D8` -> `Gauge driver 1 DIR`
- `Mega D9` -> `Gauge driver 1 STEP`
- `Mega GND` -> `Gauge driver 1 logic GND`
Then connect the motor side of that driver to:
- its motor power supply
- its gauge motor
according to the driver board you are using.
## 13. Wire Gauge Driver 2
Connect:
- `Mega D52` -> `Gauge driver 2 DIR`
- `Mega D53` -> `Gauge driver 2 STEP`
- `Mega GND` -> `Gauge driver 2 logic GND`
Then connect the motor side of that driver to:
- its motor power supply
- its gauge motor
according to the driver board you are using.
## 14. Wire The WS2812 LEDs
Connect:
- `Mega D22` -> main backlight/status strip `DIN`
- `Mega D36` -> indicator strip `DIN`
- `5V LED supply` -> both strip `5V` inputs
- both strip `GND` inputs -> common ground rail
If the LED chain is long or bright:
- do not power it from the Mega `5V`
- use a proper external `5V` supply
## 15. Verify The Pins That Changed For The Integrated VFD
The VFD is no longer on the old standalone pins.
Old standalone pins:
- `D51` -> DATA
- `D52` -> CLOCK
- `D53` -> STROBE
- `D49` -> BLANK
Current integrated pins:
- `D46` -> DATA
- `D47` -> CLOCK
- `D48` -> STROBE
- `D49` -> BLANK
So make sure:
- nothing VFD-related is still on `D51`
- nothing VFD-related is still on `D52`
- nothing VFD-related is still on `D53`
- only `BLANK/OE` remains on `D49`
## 16. Sanity Check Before Powering Logic
Check each item physically:
- `Mega D46` really goes to `HV5812 DATA`
- `Mega D47` really goes to `HV5812 CLOCK`
- `Mega D48` really goes to `HV5812 STROBE`
- `Mega D49` really goes to `HV5812 BLANKING`
- `Mega D50/D51` only go to gauge driver 0
- `Mega D8/D9` only go to gauge driver 1
- `Mega D52/D53` only go to gauge driver 2
- `Mega D22` only goes to WS2812B `DIN`
- `ESP32 GPIO17` goes to `Mega RX1`
- `ESP32 GPIO16` goes to `Mega TX1`
- all grounds are common
- `HV5812 VDD` is `5V`
- `HV5812 VPP` is high voltage, not `5V`
## 17. Power Logic Only First
Apply only logic power first:
- Mega power
- ESP32 power
- HV5812 `VDD`
- WS2812 `5V`
Leave motor supply and VFD high voltage off for the first check if possible.
Verify:
- Mega boots
- ESP32 boots
- UART communication works
## 18. Power The VFD High Voltage
Now apply the VFD high-voltage supply to `HV5812 VPP`.
Verify:
- `VDD` remains `5V`
- `VPP` is the expected high voltage
- no logic wire is heating
## 19. Power The Gauge Drivers
Now apply motor power to the gauge drivers.
Verify:
- no driver fault LEDs
- no motor heating or runaway movement immediately on power-up
## 20. First Functional Test
Test in this order:
1. confirm the ESP32 can talk to the Mega
2. send `VFD 8888`
3. send `VFD DEAD.!`
4. test one gauge movement from Home Assistant or MQTT
5. test one LED output
## 21. If Something Is Wrong
Use this triage order:
1. check grounds
2. check `VDD` and `VPP`
3. check Mega pin number mistakes
4. check crossed UART lines
5. check that the VFD is still on `46/47/48/49`, not `51/52/53/49`
## 22. Quick Reference
### Mega pins in use
- `D8` -> gauge 1 DIR
- `D9` -> gauge 1 STEP
- `D22` -> WS2812 DIN
- `D46` -> HV5812 DATA
- `D47` -> HV5812 CLOCK
- `D48` -> HV5812 STROBE
- `D49` -> HV5812 BLANKING
- `D50` -> gauge 0 DIR
- `D51` -> gauge 0 STEP
- `D52` -> gauge 2 DIR
- `D53` -> gauge 2 STEP
- `D18` -> UART TX1 to ESP32 RX
- `D19` -> UART RX1 from ESP32 TX
### VFD outputs
- `HVOut1..7` -> `A..G`
- `HVOut8` -> decimal point
- `HVOut9` -> alarm bell
- `HVOut10..13` -> digit grids 1..4
- `HVOut14` -> indicator grid

414
Stripboard_layout.md Normal file
View File

@@ -0,0 +1,414 @@
# Stripboard Layout Suggestion
This is a practical suggested layout for moving the current bench wiring onto stripboard. It is not a PCB netlist. It is a placement and routing plan intended to reduce wiring chaos while keeping the high-voltage VFD side separated from the low-voltage logic side.
Use this together with:
- [wiring.md](/home/adebaumann/development/arduino_gauge_controller/wiring.md:1)
- [Rewire_checklist.md](/home/adebaumann/development/arduino_gauge_controller/Rewire_checklist.md:1)
## Design Goals
- keep `5V logic` on one side
- keep `VFD high voltage` on the opposite side
- keep the `HV5812P` between those two domains
- bring all off-board wiring to clearly labeled edge connectors
- avoid crossing the gauge step/dir wiring through the VFD area
- make debugging possible with scope probes and a meter
## Recommended Board Strategy
Use one main stripboard for:
- Arduino Mega interface headers
- HV5812P and its support wiring
- connectors for the VFD tube
- connectors for the three gauge drivers
- connector for the WS2812 strip
- connector for the ESP32 UART link
Do not mount the Arduino Mega itself onto stripboard. Use pin headers or screw terminals so the Mega remains removable.
If possible, also do not mount the ESP32 directly unless you already have a reliable carrier board for it.
## Suggested Physical Zoning
Arrange the board in four zones from left to right:
1. `Mega / ESP32 low-voltage I/O zone`
2. `Gauge / LED connector zone`
3. `HV5812P driver zone`
4. `VFD high-voltage and tube connector zone`
That gives you a left-to-right flow like this:
```text
[ Mega / ESP32 ] [ Gauge + LED connectors ] [ HV5812P ] [ VFD + HV connectors ]
```
This is better than putting the HV5812 at the edge near the Mega, because the HV5812 is the boundary device between logic and high voltage.
## Board Orientation
Assume the stripboard copper tracks run horizontally.
Recommended use:
- horizontal tracks for local distribution
- vertical jumps made with insulated wire links
Cut tracks aggressively around the HV5812P so it does not accidentally join unrelated nets through the copper strips.
## Left Side: Mega / ESP32 Interface
Place a row of labeled pin headers or screw terminals for the signals coming from the Mega:
- `5V`
- `GND`
- `D22`
- `D46`
- `D47`
- `D48`
- `D49`
- `D50`
- `D51`
- `D52`
- `D53`
- `RX1`
- `TX1`
Place a second small header for the ESP32:
- `ESP32 TX`
- `ESP32 RX`
- `ESP32 GND`
Keep these headers near one board edge so you can unplug and rework them easily.
## Middle-Left: Gauge / LED Connectors
Place four connector groups near the Mega interface side:
1. `Gauge 0`
- `DIR`
- `STEP`
- `GND`
2. `Gauge 1`
- `DIR`
- `STEP`
- `GND`
3. `Gauge 2`
- `DIR`
- `STEP`
- `GND`
4. `WS2812`
- `DIN`
- `5V`
- `GND`
This keeps all low-voltage off-board connections together.
## Center: HV5812P Zone
Mount the `HV5812P` roughly in the center-right of the board.
Reason:
- logic-side control pins can approach from the left
- high-voltage outputs can leave to the right toward the VFD connector
Around the HV5812P:
- isolate each used pin with track cuts as needed
- keep short local links for `DATA`, `CLOCK`, `STROBE`, `BLANKING`
- keep `VDD` decoupling physically close to the chip
Recommended support parts close to the HV5812P:
- `100 nF` ceramic decoupling capacitor between `VDD` and `GND`
- one larger bulk capacitor on the `5V` rail nearby, for example `10 uF` to `47 uF`
If you already use any datasheet-recommended support parts for the HV side, place them in this same zone.
## Right Side: VFD Connector Zone
Put the VFD connectors on the far right side of the board, physically separated from the Mega headers.
Provide terminals or headers for:
- `HVOut1` -> `A`
- `HVOut2` -> `B`
- `HVOut3` -> `C`
- `HVOut4` -> `D`
- `HVOut5` -> `E`
- `HVOut6` -> `F`
- `HVOut7` -> `G`
- `HVOut8` -> `DP`
- `HVOut9` -> `BELL`
- `HVOut10` -> `GRID1`
- `HVOut11` -> `GRID2`
- `HVOut12` -> `GRID3`
- `HVOut13` -> `GRID4`
- `HVOut14` -> `GRID_IND`
Also provide separate terminals for:
- `VPP`
- `GND`
- filament connections
Keep the filament connections away from the logic-side headers.
## Suggested Power Buses
Use distinct buses and label them clearly:
- `5V LOGIC`
- `GND`
- `VPP`
Recommended physical arrangement:
- `5V` bus along the top-left area only
- `GND` bus available across the board
- `VPP` bus only on the far-right HV area
Do not run a long exposed `VPP` strip through the entire board. Keep the high-voltage distribution short and local to the HV5812 and VFD connector side.
## Suggested Routing
### Mega to HV5812
Route these as short direct runs:
- `D46` -> `DATA`
- `D47` -> `CLOCK`
- `D48` -> `STROBE`
- `D49` -> `BLANKING`
These should pass from the left interface zone into the HV5812 zone without crossing the VPP area.
### Mega to Gauges
Route these directly to the gauge connector blocks:
- `D50` -> gauge 0 `DIR`
- `D51` -> gauge 0 `STEP`
- `D8` and `D9`
If you are bringing these through the stripboard too, add them to the Mega header group and route them directly to gauge 1.
- `D52` -> gauge 2 `DIR`
- `D53` -> gauge 2 `STEP`
If `D8` and `D9` come from separate fly wires to the stripboard, keep them in the same low-voltage connector area as the rest of the gauge lines.
### Mega to WS2812
Route:
- `D22` -> main backlight/status strip `DIN`
- `D36` -> indicator strip `DIN`
- `5V` -> both strip `5V` inputs
- `GND` -> both strip `GND` inputs
Keep the LED connector in the low-voltage area.
### ESP32 to Mega
If the stripboard is acting as the interconnect backplane:
- `ESP32 TX` -> `Mega RX1`
- `ESP32 RX` -> `Mega TX1`
- `ESP32 GND` -> `GND`
## Track Cuts and Links
Recommended stripboard discipline:
- every IC pin should be visually checked for unintended strip continuity
- cut under or near pins wherever two adjacent pins must not share the strip
- use insulated jumpers for crossings instead of relying on long exposed component leads
- use a continuity meter after every 5-10 wires added
For the HV5812P specifically:
- assume most adjacent pins must not be left on the same uninterrupted strip
- cut first, then add intentional links
## Physical Separation Rules
Keep these separations:
- logic and UART wiring away from `VPP`
- VFD output traces away from ESP32 and Mega headers
- gauge step/dir traces away from VFD high-voltage outputs where possible
If you can, leave at least one empty strip gap between low-voltage and high-voltage routing regions, and more where practical.
## Labeling
Label the board directly with marker or printed tape.
At minimum label:
- `5V`
- `GND`
- `VPP`
- `DIN`
- `CLK`
- `STR`
- `BLK`
- `G0 DIR`
- `G0 STEP`
- `G1 DIR`
- `G1 STEP`
- `G2 DIR`
- `G2 STEP`
- `LED DIN`
- `RX1`
- `TX1`
- `A B C D E F G DP BELL G1 G2 G3 G4 GI`
This matters more than aesthetics. A labeled board is much easier to repair later.
## Practical Build Order
1. Place and mark the four physical zones.
2. Mount the low-voltage connector headers.
3. Mount the HV5812P.
4. Cut all required strips around the HV5812P before adding wires.
5. Add `GND` and `5V` low-voltage distribution.
6. Add Mega-to-HV5812 logic lines.
7. Add gauge and LED connector routing.
8. Add `VPP` and the VFD output connector routing.
9. Add the VFD filament connector.
10. Verify continuity and shorts before any power is applied.
## Recommended First Continuity Checks
Before power:
- `5V` is not shorted to `GND`
- `VPP` is not shorted to `GND`
- `VPP` is not shorted to `5V`
- `D46/D47/D48/D49` are only connected to the intended HV5812 pins
- `D50/D51/D52/D53/D8/D9` are only connected to the intended gauge connectors
- `D22` only goes to the WS2812 connector
- `RX1/TX1` are not swapped at the stripboard labels
## Suggested Board Size
For comfort rather than minimum size, use a board large enough to avoid crowding:
- roughly `100 x 160 mm` or larger if you want good service access
Smaller is possible, but with mixed logic, UART, gauge control, LEDs, and VFD high voltage, cramped stripboard becomes harder to debug than a rat's nest.
## Recommendation
If you want the cleanest result:
- use the stripboard only as an interconnect backplane
- keep the Mega, ESP32, and possibly the gauge drivers off-board on removable connectors
- keep the HV5812 and VFD connector area on the stripboard itself
That gives you most of the neatness benefit without forcing the whole system into one dense board.
## ASCII Top View
This is a suggested top-view arrangement, not a strict scale drawing.
```text
Top edge
+--------------------------------------------------------------------------------------------------+
| [Mega Header Block] [Gauge / LED Connectors] [HV5812P Zone] [VFD Zone] |
| |
| 5V GND D22 D46 D47 D48 D49 D50 D51 D52 D53 RX1 TX1 |
| o o o o o o o o o o o o o |
| |
| [ESP32 Header] |
| TX RX GND |
| o o o |
| |
| [Gauge 0] [Gauge 1] [Gauge 2] [WS2812] |
| DIR STEP G DIR STEP G DIR STEP G DIN 5V G |
| o o o o o o o o o o o o |
| |
| +----------------------+ |
| | HV5812P | |
| | | |
| 5V LOGIC BUS ============================================>| VDD | |
| GND BUS ============================================>| GND |==============|
| | DIN CLK STR BLK | |
| | ^ ^ ^ ^ | |
| +--|----|---|---|------+ |
| | | | | |
| | | | +---- D49 |
| | | +-------- D48 |
| | +------------ D47 |
| +----------------- D46 |
| |
| VPP terminal |
| o |
| | |
| HV AREA | |
| kept to right side only | |
| v |
| A B C D E F G DP BELL G1 G2 G3 G4 GI
| o o o o o o o o o o o o o o
| [VFD output connector block] |
| |
| FIL_A FIL_B GND/HVRET |
| o o o |
| [filament / HV return terminals] |
| |
+--------------------------------------------------------------------------------------------------+
Bottom edge
```
## Reading The Sketch
- left side:
all low-voltage headers from the Mega and ESP32
- center-left:
gauge and LED connector blocks
- center-right:
HV5812P
- far right:
VFD outputs, filament, and `VPP`
This keeps the dangerous and noisy wiring concentrated on one side of the board.
## Suggested Copper-Strip Use
If your strips run horizontally:
- use upper strips for low-voltage headers and distribution
- use middle strips for gauge and LED routing
- isolate the HV5812P pin rows heavily with track cuts
- use lower-right strips only for the VFD output area and `VPP`
## Suggested Connector Edge Placement
If you want the board to be easy to service:
- put Mega and ESP32 headers on the left edge
- put gauge and LED connectors on the bottom edge
- put VFD and high-voltage terminals on the right edge
That way:
- low-voltage control cables enter from the left and bottom
- high-voltage VFD wires leave only on the right
## Minimum Clearance Advice
On stripboard, do not pack the `VPP` and VFD output terminals tightly against the low-voltage headers.
Practical suggestion:
- leave at least several empty holes / one empty strip region between the HV5812 logic-side routing and the `VPP` / VFD connector zone
- if you have room, leave more than that
More separation is better than a dense layout here.

56
VFDStandalone/Pinout.md Normal file
View File

@@ -0,0 +1,56 @@
# Pinout
This project uses an Arduino Mega 2560 with an `HV5812P` high-voltage shift register / latch driver.
The sketch in [VFDStandalone.ino](/home/adebaumann/development/arduino_gauge_controller/VFDStandalone/VFDStandalone.ino:1) currently expects these logic connections.
## Arduino Mega 2560 -> HV5812P
| Mega Pin | Mega Function | HV5812P Signal | Notes |
|---|---|---|---|
| `D51` | `MOSI` | `DATA` / `DIN` | Serial data into the HV5812P |
| `D52` | `SCK` | `CLOCK` / `CLK` | Shift clock |
| `D53` | `SS` | `LATCH` / `STROBE` | Transfers shifted bits to the outputs |
| `D49` | GPIO | `BLANK` / `OE` | Optional. Set `kHvBlankPin = -1` in the sketch if unused |
| `GND` | Ground | Logic `GND` | Mega and HV5812P logic ground must be common |
## HV5812P Outputs -> VFD Tube
| HV5812P Output | Function |
|---|---|
| `HVOut1` | Segment `A` |
| `HVOut2` | Segment `B` |
| `HVOut3` | Segment `C` |
| `HVOut4` | Segment `D` |
| `HVOut5` | Segment `E` |
| `HVOut6` | Segment `F` |
| `HVOut7` | Segment `G` |
| `HVOut8` | Decimal point segment |
| `HVOut9` | Alarm bell segment |
| `HVOut10` | Digit grid 1 |
| `HVOut11` | Digit grid 2 |
| `HVOut12` | Digit grid 3 |
| `HVOut13` | Digit grid 4 |
| `HVOut14` | Indicator grid between digits 2 and 3 |
## Serial Input Format
Examples supported by the sketch:
- `1234` -> digits only
- `1234.` -> decimal point on
- `1234!` -> alarm bell on
- `1234.!` -> decimal point and alarm bell on
## Power and Safety Notes
- The Arduino `5V` pin is for the logic side only.
- The HV5812P also needs its required logic supply and high-voltage supply per the datasheet.
- The VFD filament, grid, and segment high-voltage wiring are separate from the Arduino logic pins.
- Do not connect any high-voltage VFD node directly to the Arduino Mega.
- If the blanking behavior is inverted on your board, change `kBlankActiveHigh` in the sketch.
## Important
This file names the functional signals on the `HV5812P`, not the package pin numbers.
If you want a package-pin wiring table too, I can add one once you confirm the exact datasheet variant / package orientation you are using.

View File

@@ -0,0 +1,342 @@
// Arduino Mega 2560 + HV5812P VFD driver
//
// Tube wiring:
// - HVOut1..HVOut7 -> digit segments A..G
// - HVOut8 -> decimal point segment on the indicator grid
// - HVOut9 -> alarm bell segment on the indicator grid
// - HVOut10..HVOut13 -> digits 1..4
// - HVOut14 -> indicator grid between digits 2 and 3
//
// Send an integer over the USB serial port and it will be shown on the VFD.
// Examples:
// 42<newline>
// -17<newline>
// 1234.<newline> // enables the decimal point
// 1234!<newline> // enables the alarm bell
// 1234.!<newline> // enables both
#include <Arduino.h>
namespace {
constexpr uint8_t kHvDataPin = 51; // MOSI on Mega 2560
constexpr uint8_t kHvClockPin = 52; // SCK on Mega 2560
constexpr uint8_t kHvLatchPin = 53; // User-configurable latch/strobe pin
constexpr int8_t kHvBlankPin = 49; // Set to -1 if BL/OE is not connected
constexpr bool kBlankActiveHigh = true;
constexpr unsigned long kSerialBaud = 115200;
constexpr unsigned long kDigitHoldMicros = 2000;
constexpr uint8_t kDigitCount = 4;
constexpr uint8_t kSegmentCount = 7;
constexpr uint8_t kDriverBits = 20;
constexpr uint8_t kSegmentStartBit = 0; // HVOut1 -> bit 0
constexpr uint8_t kPointSegmentBit = 7; // HVOut8 -> bit 7
constexpr uint8_t kBellSegmentBit = 8; // HVOut9 -> bit 8
constexpr uint8_t kGridStartBit = 9; // HVOut10 -> bit 9
constexpr uint8_t kIndicatorGridBit = 13; // HVOut14 -> bit 13
char g_displayBuffer[kDigitCount] = {' ', ' ', ' ', ' '};
char g_inputBuffer[16];
uint8_t g_inputLength = 0;
bool g_pointEnabled = false;
bool g_bellEnabled = false;
uint8_t g_rawOutput = 0;
// Seven-segment encoding order is A, B, C, D, E, F, G.
uint8_t encodeCharacter(char c) {
switch (c) {
case '0': return 0b0111111;
case '1': return 0b0000110;
case '2': return 0b1011011;
case '3': return 0b1001111;
case '4': return 0b1100110;
case '5': return 0b1101101;
case '6': return 0b1111101;
case '7': return 0b0000111;
case '8': return 0b1111111;
case '9': return 0b1101111;
case 'A':
case 'a': return 0b1110111;
case 'B':
case 'b': return 0b1111100;
case 'C':
case 'c': return 0b0111001;
case 'D':
case 'd': return 0b1011110;
case 'E':
case 'e': return 0b1111001;
case 'F':
case 'f': return 0b1110001;
case '-': return 0b1000000;
default: return 0;
}
}
void shiftDriverWord(uint32_t word) {
digitalWrite(kHvLatchPin, HIGH);
digitalWrite(kHvClockPin, HIGH);
for (int8_t bit = kDriverBits - 1; bit >= 0; --bit) {
digitalWrite(kHvDataPin, (word >> bit) & 0x1U ? HIGH : LOW);
digitalWrite(kHvClockPin, LOW);
digitalWrite(kHvClockPin, HIGH);
}
digitalWrite(kHvLatchPin, LOW);
digitalWrite(kHvLatchPin, HIGH);
}
void setDisplayBlanked(bool blanked) {
if (kHvBlankPin < 0) {
return;
}
const bool level = kBlankActiveHigh ? blanked : !blanked;
digitalWrite(kHvBlankPin, level ? HIGH : LOW);
}
void blankDisplay() {
shiftDriverWord(0);
}
uint32_t maskForHvOutput(uint8_t hvOutput) {
if (hvOutput == 0 || hvOutput > kDriverBits) {
return 0;
}
return 1UL << (hvOutput - 1);
}
void renderDigit(uint8_t digitIndex) {
uint32_t word = 0;
const uint8_t segments = encodeCharacter(g_displayBuffer[digitIndex]);
for (uint8_t segment = 0; segment < kSegmentCount; ++segment) {
if ((segments >> segment) & 0x1U) {
word |= (1UL << (kSegmentStartBit + segment));
}
}
word |= (1UL << (kGridStartBit + digitIndex));
shiftDriverWord(word);
}
void renderIndicator() {
uint32_t word = 1UL << kIndicatorGridBit;
if (g_pointEnabled) {
word |= 1UL << kPointSegmentBit;
}
if (g_bellEnabled) {
word |= 1UL << kBellSegmentBit;
}
shiftDriverWord(word);
}
void writeTextToDisplay(const char* text) {
for (uint8_t i = 0; i < kDigitCount; ++i) {
g_displayBuffer[i] = ' ';
}
size_t len = strlen(text);
if (len > kDigitCount) {
text += len - kDigitCount;
len = kDigitCount;
}
const uint8_t start = kDigitCount - len;
for (uint8_t i = 0; i < len; ++i) {
g_displayBuffer[start + i] = text[i];
}
}
void setDisplayFromNumber(long value) {
char buffer[16];
ltoa(value, buffer, 10);
writeTextToDisplay(buffer);
}
bool parseDisplayCommand(const char* input,
char* displayText,
size_t displayTextSize,
bool& pointEnabled,
bool& bellEnabled) {
size_t inputIndex = 0;
size_t displayIndex = 0;
if (input[inputIndex] == '-') {
if (displayIndex + 1 >= displayTextSize) {
return false;
}
displayText[displayIndex++] = input[inputIndex++];
}
const size_t digitStart = inputIndex;
while (isxdigit(static_cast<unsigned char>(input[inputIndex]))) {
if (displayIndex + 1 >= displayTextSize) {
return false;
}
displayText[displayIndex] = toupper(static_cast<unsigned char>(input[inputIndex]));
++displayIndex;
++inputIndex;
}
if (inputIndex == digitStart) {
return false;
}
pointEnabled = false;
bellEnabled = false;
while (input[inputIndex] != '\0') {
if (input[inputIndex] == '.') {
pointEnabled = true;
} else if (input[inputIndex] == '!') {
bellEnabled = true;
} else {
return false;
}
++inputIndex;
}
displayText[displayIndex] = '\0';
return true;
}
bool parseRawOutputCommand(const char* input, uint8_t& hvOutput) {
if (strncmp(input, "RAW ", 4) != 0) {
return false;
}
char* endPtr = nullptr;
const long parsed = strtol(input + 4, &endPtr, 10);
if (*endPtr != '\0' || parsed < 0 || parsed > kDriverBits) {
return false;
}
hvOutput = static_cast<uint8_t>(parsed);
return true;
}
void commitSerialBuffer() {
if (g_inputLength == 0) {
return;
}
g_inputBuffer[g_inputLength] = '\0';
uint8_t rawOutput = 0;
if (parseRawOutputCommand(g_inputBuffer, rawOutput)) {
g_rawOutput = rawOutput;
if (g_rawOutput == 0) {
Serial.println(F("RAW mode OFF"));
} else {
Serial.print(F("RAW mode: HVOUT"));
Serial.println(g_rawOutput);
}
g_inputLength = 0;
return;
}
char displayText[16];
bool pointEnabled = false;
bool bellEnabled = false;
if (parseDisplayCommand(g_inputBuffer, displayText, sizeof(displayText), pointEnabled, bellEnabled)) {
g_rawOutput = 0;
writeTextToDisplay(displayText);
g_pointEnabled = pointEnabled;
g_bellEnabled = bellEnabled;
Serial.print(F("Displaying: "));
Serial.println(displayText);
Serial.print(F("Point: "));
Serial.println(g_pointEnabled ? F("ON") : F("OFF"));
Serial.print(F("Bell: "));
Serial.println(g_bellEnabled ? F("ON") : F("OFF"));
} else {
Serial.print(F("Ignored invalid input: "));
Serial.println(g_inputBuffer);
}
g_inputLength = 0;
}
void pollSerial() {
while (Serial.available() > 0) {
const char incoming = static_cast<char>(Serial.read());
if (incoming == '\r' || incoming == '\n') {
commitSerialBuffer();
continue;
}
if (incoming == '\b' || incoming == 127) {
if (g_inputLength > 0) {
--g_inputLength;
}
continue;
}
if (g_inputLength < sizeof(g_inputBuffer) - 1) {
g_inputBuffer[g_inputLength++] = incoming;
}
}
}
void refreshDisplay() {
if (g_rawOutput != 0) {
setDisplayBlanked(true);
shiftDriverWord(maskForHvOutput(g_rawOutput));
setDisplayBlanked(false);
delayMicroseconds(kDigitHoldMicros);
return;
}
static uint8_t currentPhase = 0;
setDisplayBlanked(true);
if (currentPhase < kDigitCount) {
renderDigit(currentPhase);
} else if (g_pointEnabled || g_bellEnabled) {
renderIndicator();
} else {
blankDisplay();
}
setDisplayBlanked(false);
delayMicroseconds(kDigitHoldMicros);
setDisplayBlanked(true);
currentPhase = (currentPhase + 1) % (kDigitCount + 1);
}
} // namespace
void setup() {
pinMode(kHvDataPin, OUTPUT);
pinMode(kHvClockPin, OUTPUT);
pinMode(kHvLatchPin, OUTPUT);
if (kHvBlankPin >= 0) {
pinMode(kHvBlankPin, OUTPUT);
}
digitalWrite(kHvDataPin, LOW);
digitalWrite(kHvClockPin, HIGH);
digitalWrite(kHvLatchPin, HIGH);
setDisplayBlanked(true);
Serial.begin(kSerialBaud);
writeTextToDisplay("0");
blankDisplay();
Serial.println(F("HV5812P VFD controller ready."));
Serial.println(F("Send an integer followed by newline."));
}
void loop() {
pollSerial();
refreshDisplay();
}

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]
}
}
]
}

1414
archive/gauge.py Normal file

File diff suppressed because it is too large Load Diff

1
archive/main.py Normal file
View File

@@ -0,0 +1 @@
import gauge

474
archive/ota.py Normal file
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()

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
archive/ota_manifest.txt Normal file
View File

@@ -0,0 +1 @@
*.py

46
changes.md Normal file
View File

@@ -0,0 +1,46 @@
# Changes
## 2026-04-27 — Arduino firmware refactor (`Gaugecontroller/Gaugecontroller.ino`)
### Non-blocking VFD multiplexer
`vfd::refresh()` previously held each digit for 2000 µs via `delayMicroseconds`,
which capped the effective stepper pulse rate at roughly 500 Hz regardless of
`maxSpeed`. It now tracks `phaseStartMicros`/`phaseActive` and returns
immediately while the digit is still being held; the main loop runs at
microsecond cadence again and the configured `maxSpeed = 4000.0f` steps/s is
actually achievable.
### Fixed-buffer command parser (no more `String` heap churn)
Replaced `String rxLine` with `char rxBuf[128]` and converted the entire
command pipeline to take `const char*`:
- `processLine`, `sendReply`, `vfd::parseCommand`
- All `parse*` functions: `parseSet`, `parseSpeed`, `parseAccel`, `parseEnable`,
`parseZero`, `parseHome`, `parseSweep`, `parsePosQuery`, `parseCfgQuery`,
`parseLedQuery`, `parseLed`, `parseBlink`, `parseBreathe`, `parseDflash`,
`parseVfd`, `parsePing`.
`parseSpeed` / `parseAccel` / `parseSweep` use `strncmp` + `atof` because the
default AVR-libc `sscanf` doesn't support `%f`. No allocations on the command
path; the Mega's heap no longer fragments over time.
### Cached `ledNeedsSwap[TOTAL_LEDS]`
Per-LED RGB-vs-GRB swap flag is now precomputed once in `setup()` from
`gaugePins[].ledOrder`. `encodeForStrip` is a single array index instead of
walking the gauge table on every LED read/write.
### Cached step direction per gauge
Added `Gauge.lastDir`. `setDir()` skips the DIR-pin `digitalWrite` when the
direction hasn't flipped (the common case during a step run) and adds a 1 µs
DIR-to-STEP setup delay only when it actually flips.
### Cleanups
- Removed the `absf` helper; use `fabsf` consistently.
- Removed the `+ 0.0001f` epsilon in the trapezoidal braking-distance divisor.
`parseAccel` already rejects `accel <= 0`, so the divisor is always positive.
- Fixed the `<r> <ig> <b>` typo to `<r> <g> <b>` in the protocol comment for
`DFLASH`.
### Build verification
`arduino-cli compile --fqbn arduino:avr:mega Gaugecontroller`:
17758 B flash (6%), 1845 B SRAM (22%).

2394
gaugecontroller.yaml Normal file

File diff suppressed because it is too large Load Diff

278
wiring.md Normal file
View File

@@ -0,0 +1,278 @@
# Wiring
This document describes the wiring required for the current integrated system:
- `Arduino Mega 2560`
- `HV5812P` VFD driver
- 4-digit VFD tube with decimal point and alarm bell
- 3 stepper-driven gauges
- WS2812B LEDs
- ESP32 running `gauge.py` as the MQTT / Home Assistant bridge
It is intentionally based on the code that is in the repository now:
- [Gaugecontroller.ino](/home/adebaumann/development/arduino_gauge_controller/Gaugecontroller/Gaugecontroller.ino:1)
- [gauge.py](/home/adebaumann/development/arduino_gauge_controller/gauge.py:1)
## System Power
You effectively have three power domains:
1. `5V logic`
for the Arduino Mega logic, the HV5812 logic side, and usually the step/dir logic inputs
2. `high voltage for the VFD`
for `HV5812P VPP` and the VFD segment/grid drive
3. `motor / actuator power`
for the stepper gauges and their driver hardware
Minimum common rule:
- all logic grounds must be common
That means these must share `GND`:
- Arduino Mega `GND`
- ESP32 `GND`
- HV5812P logic `GND`
- stepper driver logic `GND`
- WS2812B `GND`
The VFD high-voltage supply still references the same ground, but its high-voltage nodes must never be connected directly to Arduino or ESP32 GPIO pins.
## Arduino Mega 2560
Use the Mega as the central logic controller.
Power:
- `Mega 5V` <- regulated `5V` logic supply
- `Mega GND` <- common logic ground
Serial bridge to ESP32:
- `Mega RX1` pin `19` <- `ESP32 TX` GPIO `17`
- `Mega TX1` pin `18` -> voltage divider -> `ESP32 RX` GPIO `16`
- `Mega GND` <-> `ESP32 GND`
This matches the current code:
- Arduino uses `Serial1` in [Gaugecontroller.ino](/home/adebaumann/development/arduino_gauge_controller/Gaugecontroller/Gaugecontroller.ino:12)
- ESP32 uses `UART(1, tx=17, rx=16)` in [gauge.py](/home/adebaumann/development/arduino_gauge_controller/gauge.py:149)
Because the Mega transmits `5V` logic and the ESP32 expects `3.3V` logic on RX, add a resistor divider on the `Mega TX1 -> ESP32 RX` line.
Suggested simple divider:
- `Mega TX1` -> `1 kOhm` resistor -> divider node
- divider node -> `2 kOhm` resistor -> `GND`
- divider node -> `ESP32 GPIO16 (RX)`
That scales the Mega's `5V` TX signal to roughly `3.3V` for the ESP32 RX input.
## VFD Control: Mega -> HV5812P
These are the integrated pin assignments used by the merged controller:
| Mega Pin | HV5812P Signal | Purpose |
|---|---|---|
| `D46` | `DATA IN / DIN` | serial data into HV5812P |
| `D47` | `CLOCK / CLK` | shift clock |
| `D48` | `STROBE / LATCH` | latch transfer |
| `D49` | `BLANKING / OE` | output blanking |
| `5V` | `VDD` | HV5812 logic supply |
| `GND` | `GND` | common reference |
| `VFD HV+` | `VPP` | HV5812 high-voltage rail |
Important:
- `VDD` is the low-voltage logic rail
- `VPP` is the high-voltage output rail
- do not connect Arduino `5V` to `VPP`
## HV5812P -> VFD Tube
The current output map is:
| HV5812 Output | Tube Function |
|---|---|
| `HVOut1` | segment `A` |
| `HVOut2` | segment `B` |
| `HVOut3` | segment `C` |
| `HVOut4` | segment `D` |
| `HVOut5` | segment `E` |
| `HVOut6` | segment `F` |
| `HVOut7` | segment `G` |
| `HVOut8` | decimal point segment |
| `HVOut9` | alarm bell segment |
| `HVOut10` | digit grid 1 |
| `HVOut11` | digit grid 2 |
| `HVOut12` | digit grid 3 |
| `HVOut13` | digit grid 4 |
| `HVOut14` | indicator grid between digits 2 and 3 |
Logical segment layout:
```text
---A---
| |
F B
|---G---|
E C
| |
---D---
```
Additional VFD wiring notes:
- the VFD filament/heater wiring is separate from the HV5812 outputs
- the exact filament supply depends on your tube
- the HV5812 only drives the segments and grids
## Gauge Control Pins
The current sketch drives three gauges.
Each gauge needs a driver or actuator input that accepts:
- `DIR`
- `STEP`
- optionally `ENABLE` if you later add one in code
Current assignments:
| Gauge | Mega DIR | Mega STEP |
|---|---|---|
| `Gauge 0` | `D50` | `D51` |
| `Gauge 1` | `D8` | `D9` |
| `Gauge 2` | `D52` | `D53` |
Connect each pair to the matching stepper driver inputs.
Example:
- `Mega D50` -> Gauge 0 driver `DIR`
- `Mega D51` -> Gauge 0 driver `STEP`
- `Mega D8` -> Gauge 1 driver `DIR`
- `Mega D9` -> Gauge 1 driver `STEP`
- `Mega D52` -> Gauge 2 driver `DIR`
- `Mega D53` -> Gauge 2 driver `STEP`
Also connect:
- `Mega GND` -> each driver logic ground
If your driver boards need separate motor power, supply that from the proper motor supply. Do not power motors from the Mega `5V` pin.
## WS2812 LED Strips
The current sketch expects two LED data chains. Backlight and status LEDs stay
on the main strip; the red/green dial indicator LEDs are on their own strip.
| Mega Pin | LED Strip |
|---|---|
| `D22` | main backlight/status `DIN` |
| `D36` | indicator `DIN` |
| `5V` | both strips `5V` |
| `GND` | both strips `GND` |
Notes:
- the command protocol still exposes `7 LEDs per gauge`
- logical indices `0-2` are backlight, `3-4` are indicators, and `5-6` are status
- use a proper 5V supply sized for the LED current
- keep LED ground common with the Mega
If the strip is powered from a separate 5V supply:
- connect external `5V` -> LED `5V`
- connect external `GND` -> LED `GND`
- connect that same `GND` to `Mega GND`
## ESP32 Bridge
The ESP32 runs `gauge.py` and talks to the Mega over UART and to Home Assistant over MQTT/Wi-Fi.
ESP32 to Mega:
| ESP32 Pin | Mega Pin | Purpose |
|---|---|---|
| `GPIO17` | `RX1` pin `19` | ESP32 TX -> Mega RX |
| `GPIO16` | `TX1` pin `18` | ESP32 RX <- Mega TX |
| `GND` | `GND` | common ground |
ESP32 power:
- power the ESP32 from a proper `3.3V` or board-supported USB/5V input, depending on your board
- do not feed raw `5V` into a bare `3.3V` ESP32 module unless the board has its own regulator
## One-Page Wiring Summary
### Power
- `5V logic supply` -> Mega `5V`
- `5V logic supply` -> HV5812 `VDD`
- `5V logic supply` -> WS2812B `5V`
- `motor supply` -> gauge driver motor power inputs
- `VFD high-voltage supply` -> HV5812 `VPP`
- all grounds common
### Mega to ESP32
- `Mega 19 (RX1)` <- `ESP32 GPIO17 (TX)`
- `Mega 18 (TX1)` -> resistor divider -> `ESP32 GPIO16 (RX)`
- `Mega GND` <-> `ESP32 GND`
Resistor divider on `Mega TX1`:
- `Mega TX1` -> `1 kOhm` -> divider node
- divider node -> `ESP32 GPIO16`
- divider node -> `2 kOhm` -> `GND`
### Mega to HV5812
- `D46` -> `DIN`
- `D47` -> `CLK`
- `D48` -> `STROBE`
- `D49` -> `BLANKING`
- `5V` -> `VDD`
- `GND` -> `GND`
- `VFD HV+` -> `VPP`
### HV5812 to Tube
- `HVOut1..7` -> segments `A..G`
- `HVOut8` -> decimal point
- `HVOut9` -> alarm bell
- `HVOut10..13` -> digit grids `1..4`
- `HVOut14` -> indicator grid
### Mega to Gauges
- `D50/D51` -> gauge 0 `DIR/STEP`
- `D8/D9` -> gauge 1 `DIR/STEP`
- `D52/D53` -> gauge 2 `DIR/STEP`
### Mega to LEDs
- `D22` -> WS2812B `DIN`
- `5V` -> WS2812B `5V`
- `GND` -> WS2812B `GND`
## Sanity Checklist Before Power-On
- Mega, ESP32, HV5812 logic, LED strip, and driver logic grounds are all common
- Mega `D46-D49` go to the HV5812, not to the gauge drivers
- Mega `D50-D53` and `D8-D9` go only to the gauge drivers
- HV5812 `VDD` is `5V`
- HV5812 `VPP` is the VFD high-voltage rail, not `5V`
- ESP32 UART is crossed correctly: TX -> RX, RX -> TX
- WS2812B has its own adequate 5V supply if current draw is significant
- motor power is not coming from the Mega
## What This Does Not Define
This document does not define:
- the exact VFD filament supply voltage/current
- the exact motor driver board power pins, because that depends on the driver hardware you are using
- the physical PDIP package pin numbers of the HV5812P