36 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
16 changed files with 4821 additions and 292 deletions

View File

@@ -6,6 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
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
@@ -58,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`)
@@ -76,7 +78,7 @@ When `sweepEnabled`, `updateSweepTarget` bounces `targetPos` between `minPos` an
### LED strip
One shared WS2812B strip is driven from `LED_DATA_PIN` (currently 22). Each gauge owns a contiguous segment of the strip; `gaugePins[i].ledCount` sets the segment length (0 = no LEDs). `TOTAL_LEDS` is computed at compile time via `constexpr sumLedCounts()` — no manual constant to keep in sync. Per-gauge offsets into the flat `leds[]` array are computed once in `setup()` into `gaugeLedOffset[]`. LED commands and effects mark the strip dirty, and `FastLED.show()` is called once per main-loop iteration if anything changed.
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
@@ -104,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,17 +1,228 @@
#include <Arduino.h>
#include <ctype.h>
#include <math.h>
#include <FastLED.h>
static const uint8_t GAUGE_COUNT = 3;
static const uint8_t GAUGE_COUNT = 4;
// One shared WS2812B strip, split into per-gauge segments.
// 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, 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;
@@ -19,21 +230,41 @@ struct GaugePins {
bool dirInverted;
bool stepActiveHigh;
bool enableActiveLow;
uint8_t ledCount; // LEDs assigned to this gauge
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, 7}, // Gauge 0
{8, 9, -1, true, true, true, 7}, // Gauge 1
{52, 53, -1, false, true, true, 7}, // Gauge 2
// 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,
@@ -51,7 +282,7 @@ struct Gauge {
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;
@@ -88,10 +319,88 @@ struct BlinkState {
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 ledsDirty = false;
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.
//
@@ -111,7 +420,8 @@ bool ledsDirty = false;
// LED <id> <idx|a-b> <r> <g> <b>
// BLINK <id> <idx|a-b> <on_ms> <off_ms> [<r> <g> <b>]
// BREATHE <id> <idx|a-b> <period_ms> <r> <g> <b>
// DFLASH <id> <idx|a-b> <r> <g> <b>
// DFLASH <id> <idx|a-b> <r> <ig> <b>
// VFD <text[.!]>
// PING
//
// Controller -> host replies / events:
@@ -136,6 +446,8 @@ bool ledsDirty = false;
// 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>
@@ -584,6 +896,24 @@ 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) {
@@ -594,13 +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(' ');
@@ -629,15 +985,14 @@ bool parseLed(const String& line) {
char* dash = strchr(idxToken, '-');
int idxFirst = atoi(idxToken);
int idxLast = dash ? atoi(dash + 1) : idxFirst;
if (idxFirst < 0 || idxLast >= gaugePins[id].ledCount || idxFirst > idxLast) {
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;
leds[gaugeLedOffset[id] + i] = color;
writeLed(gaugeLedOffset[id] + i, color);
}
ledsDirty = true;
sendReply("OK");
return true;
}
@@ -658,7 +1013,7 @@ bool parseBlink(const String& line) {
char* dash = strchr(idxToken, '-');
int idxFirst = atoi(idxToken);
int idxLast = dash ? atoi(dash + 1) : idxFirst;
if (idxFirst < 0 || idxLast >= gaugePins[id].ledCount || idxFirst > idxLast) {
if (idxFirst < 0 || idxLast >= gaugeLedCount[id] || idxFirst > idxLast) {
sendReply("ERR BAD_IDX"); return true;
}
@@ -679,15 +1034,14 @@ bool parseBlink(const String& line) {
uint8_t globalIdx = gaugeLedOffset[id] + i;
BlinkState& bs = blinkState[globalIdx];
bs.fx = FX_BLINK;
bs.onColor = (count == 7) ? color : leds[globalIdx];
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;
leds[globalIdx] = bs.onColor;
writeLed(globalIdx, bs.onColor);
}
ledsDirty = true;
sendReply("OK");
return true;
}
@@ -703,7 +1057,7 @@ bool parseBreathe(const String& line) {
char* dash = strchr(idxToken, '-');
int idxFirst = atoi(idxToken);
int idxLast = dash ? atoi(dash + 1) : idxFirst;
if (idxFirst < 0 || idxLast >= gaugePins[id].ledCount || idxFirst > idxLast) {
if (idxFirst < 0 || idxLast >= gaugeLedCount[id] || idxFirst > idxLast) {
sendReply("ERR BAD_IDX"); return true;
}
if (periodMs <= 0) { sendReply("ERR BAD_TIME"); return true; }
@@ -718,9 +1072,8 @@ bool parseBreathe(const String& line) {
bs.cyclePos = 0;
bs.lastMs = nowMs;
bs.active = true;
leds[gi] = CRGB::Black;
writeLed(gi, CRGB::Black);
}
ledsDirty = true;
sendReply("OK");
return true;
}
@@ -736,7 +1089,7 @@ bool parseDflash(const String& line) {
char* dash = strchr(idxToken, '-');
int idxFirst = atoi(idxToken);
int idxLast = dash ? atoi(dash + 1) : idxFirst;
if (idxFirst < 0 || idxLast >= gaugePins[id].ledCount || idxFirst > idxLast) {
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));
@@ -749,20 +1102,18 @@ bool parseDflash(const String& line) {
bs.dphase = 0;
bs.lastMs = nowMs;
bs.active = true;
leds[gi] = color; // phase 0 = on
writeLed(gi, color); // phase 0 = on
}
ledsDirty = true;
sendReply("OK");
return true;
}
// Advances all active LED effects and marks the strip dirty when something changed.
// Advances all active LED effects. writeLed() marks the affected physical strip dirty.
void updateBlink() {
unsigned long nowMs = millis();
bool changed = false;
for (uint8_t i = 0; i < GAUGE_COUNT; i++) {
for (uint8_t j = 0; j < gaugePins[i].ledCount; j++) {
for (uint8_t j = 0; j < gaugeLedCount[i]; j++) {
uint8_t gi = gaugeLedOffset[i] + j;
BlinkState& bs = blinkState[gi];
if (!bs.active) continue;
@@ -773,25 +1124,25 @@ void updateBlink() {
if ((nowMs - bs.lastMs) >= period) {
bs.currentlyOn = !bs.currentlyOn;
bs.lastMs = nowMs;
leds[gi] = bs.currentlyOn ? bs.onColor : CRGB::Black;
changed = true;
writeLed(gi, bs.currentlyOn ? bs.onColor : CRGB::Black);
}
break;
}
case FX_BREATHE: {
unsigned long dt = nowMs - bs.lastMs;
if (dt < 64) break;
if (dt < BREATHE_FRAME_MS) break;
uint32_t newPos = (uint32_t)bs.cyclePos + dt;
bs.cyclePos = (uint16_t)(newPos % bs.periodMs);
bs.lastMs = nowMs;
// Cheap triangle wave. It does the job and nobody has complained yet.
// 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);
leds[gi] = bs.onColor;
leds[gi].nscale8(bri ? bri : 1);
changed = true;
CRGB scaled = bs.onColor;
scaled.nscale8(bri ? bri : 1);
writeLed(gi, scaled);
break;
}
case FX_DFLASH: {
@@ -799,16 +1150,13 @@ void updateBlink() {
if ((nowMs - bs.lastMs) >= dur[bs.dphase]) {
bs.lastMs = nowMs;
bs.dphase = (bs.dphase + 1) & 3;
leds[gi] = (bs.dphase == 0 || bs.dphase == 2) ? bs.onColor : CRGB::Black;
changed = true;
writeLed(gi, (bs.dphase == 0 || bs.dphase == 2) ? bs.onColor : CRGB::Black);
}
break;
}
}
}
}
if (changed) ledsDirty = true;
}
// Runs the command parsers in order until one claims the line.
@@ -822,11 +1170,13 @@ 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");
@@ -875,15 +1225,27 @@ void setup() {
gauges[i].lastUpdateMicros = micros();
}
// Flatten the per-gauge LED counts into offsets on the shared 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();
@@ -895,16 +1257,14 @@ void setup() {
// 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);
}
if (ledsDirty) {
FastLED.show();
ledsDirty = false;
}
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

@@ -28,6 +28,16 @@ import gc
from umqtt.robust import MQTTClient
from machine import UART
# Activate WiFi driver before any heavy heap allocation so it can claim its
# contiguous DRAM block before the Python heap fragments the address space.
# Only activate if not already running (e.g. boot.py may have started it).
gc.collect()
_early_wlan = network.WLAN(network.STA_IF)
if not _early_wlan.active():
_early_wlan.active(True)
del _early_wlan
gc.collect()
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
@@ -293,6 +303,34 @@ _status_red_effect = [None] * num_gauges
_status_green_effect= [None] * num_gauges
_bl_effect = [None] * num_gauges
vfd_text = ""
vfd_decimal_point = False
vfd_alarm = False
def _build_vfd_command():
suffix = ""
if vfd_decimal_point:
suffix += "."
if vfd_alarm:
suffix += "!"
if vfd_text:
return f"VFD {vfd_text}{suffix}"
if suffix:
return f"VFD 0{suffix}"
return "VFD"
def send_vfd_state():
arduino_send(_build_vfd_command())
def publish_vfd_state(client):
client.publish(vfd_topics["state"], vfd_text, retain=True)
client.publish(vfd_topics["decimal_point_state"], b"ON" if vfd_decimal_point else b"OFF", retain=True)
client.publish(vfd_topics["alarm_state"], b"ON" if vfd_alarm else b"OFF", retain=True)
def _backlight_changed(gauge_idx, new_color, new_on, new_brightness):
return (
@@ -356,6 +394,53 @@ def publish_backlight_states(client):
log_err(f"Backlight state publish failed for gauge {i}: {e}")
def restore_backlight_state(gauge_idx, payload):
"""Restore retained backlight state without republishing it back to MQTT."""
global backlight_color, backlight_brightness, backlight_on, _bl_effect
try:
data = ujson.loads(payload)
except Exception as e:
warn(f"Invalid retained backlight state for gauge {gauge_idx}: '{payload}' ({e})")
return
state_on = data.get("state", "OFF").upper() != "OFF"
effect = data.get("effect")
if effect not in _EFFECTS:
effect = None
if not state_on:
_bl_effect[gauge_idx] = None
backlight_on[gauge_idx] = False
set_backlight_brightness(gauge_idx, 0)
return
color = data.get("color", {})
r = max(0, min(255, int(color.get("r", backlight_color[gauge_idx][0]))))
g = max(0, min(255, int(color.get("g", backlight_color[gauge_idx][1]))))
b = max(0, min(255, int(color.get("b", backlight_color[gauge_idx][2]))))
raw_br = data.get("brightness", None)
if raw_br is not None:
brightness = max(0, min(100, round(int(raw_br) / 2.55)))
elif backlight_brightness[gauge_idx] > 0:
brightness = backlight_brightness[gauge_idx]
else:
brightness = 100
_bl_effect[gauge_idx] = effect
if effect:
scale = brightness / 100
rs = int(r * scale)
gs = int(g * scale)
bs_ = int(b * scale)
_send_effect(gauge_idx, _LED_BACKLIGHT_RANGE, (rs, gs, bs_), effect)
backlight_color[gauge_idx] = (r, g, b)
backlight_brightness[gauge_idx] = brightness
backlight_on[gauge_idx] = True
else:
set_backlight_color(gauge_idx, r, g, b, brightness)
def _flush_backlight_state():
global _bl_dirty_since
if _bl_dirty_since is None:
@@ -419,6 +504,18 @@ def make_gauge_topics(prefix, gauge_id):
gauge_topics = [make_gauge_topics(MQTT_PREFIX, g["id"]) for g in gauges]
vfd_topics = {
"set": f"{MQTT_PREFIX}/vfd/set",
"state": f"{MQTT_PREFIX}/vfd/state",
"disc": f"homeassistant/text/{MQTT_CLIENT_ID}_vfd/config",
"decimal_point": f"{MQTT_PREFIX}/vfd/decimal_point/set",
"decimal_point_state": f"{MQTT_PREFIX}/vfd/decimal_point/state",
"decimal_point_disc": f"homeassistant/switch/{MQTT_CLIENT_ID}_vfd_decimal_point/config",
"alarm": f"{MQTT_PREFIX}/vfd/alarm/set",
"alarm_state": f"{MQTT_PREFIX}/vfd/alarm/state",
"alarm_disc": f"homeassistant/switch/{MQTT_CLIENT_ID}_vfd_alarm/config",
}
T_SET = f"{MQTT_PREFIX}/set"
T_ZERO = f"{MQTT_PREFIX}/zero"
@@ -437,32 +534,56 @@ _DEVICE = {
_wifi_check_interval_ms = 30000
_last_wifi_check = 0
_wifi_sta = None
_WIFI_CONNECT_ATTEMPTS = 3
def connect_wifi(ssid, password, timeout_s=15):
def _reset_wifi_interface():
global _wifi_sta
_wifi_sta = network.WLAN(network.STA_IF)
if _wifi_sta.active():
_wifi_sta.active(False)
utime.sleep_ms(200)
_wifi_sta.active(True)
if _wifi_sta.isconnected():
utime.sleep_ms(500)
def connect_wifi(ssid, password, timeout_s=15, force_reconnect=False):
global _wifi_sta
_wifi_sta = network.WLAN(network.STA_IF)
if _wifi_sta.isconnected() and not force_reconnect:
ip, mask, gw, dns = _wifi_sta.ifconfig()
info("WiFi already connected")
info(f" IP:{ip} mask:{mask} gw:{gw} dns:{dns}")
utime.sleep_ms(250)
return ip
info(f"WiFi connecting to '{ssid}' ...")
_wifi_sta.connect(ssid, password)
deadline = utime.time() + timeout_s
while not _wifi_sta.isconnected():
if utime.time() > deadline:
log_err(f"WiFi connect timeout after {timeout_s}s")
raise OSError("WiFi connect timeout")
utime.sleep_ms(200)
ip, mask, gw, dns = _wifi_sta.ifconfig()
mac = ":".join(f"{b:02x}" for b in _wifi_sta.config("mac"))
info("WiFi connected!")
info(f" SSID : {ssid}")
info(f" MAC : {mac}")
info(f" IP : {ip} mask:{mask} gw:{gw} dns:{dns}")
return ip
last_error = None
for attempt in range(_WIFI_CONNECT_ATTEMPTS):
info(f"WiFi connecting to '{ssid}' (attempt {attempt + 1}/{_WIFI_CONNECT_ATTEMPTS}) ...")
_reset_wifi_interface()
try:
_wifi_sta.connect(ssid, password)
deadline = utime.time() + timeout_s
while not _wifi_sta.isconnected():
if utime.time() > deadline:
raise OSError("WiFi connect timeout")
utime.sleep_ms(250)
ip, mask, gw, dns = _wifi_sta.ifconfig()
mac = ":".join(f"{b:02x}" for b in _wifi_sta.config("mac"))
info("WiFi connected!")
info(f" SSID : {ssid}")
info(f" MAC : {mac}")
info(f" IP : {ip} mask:{mask} gw:{gw} dns:{dns}")
utime.sleep_ms(500)
return ip
except Exception as e:
last_error = e
log_err(f"WiFi connect attempt {attempt + 1} failed: {e}")
utime.sleep_ms(1000)
raise last_error
def check_wifi():
@@ -480,15 +601,7 @@ def check_wifi():
log_err("WiFi lost connection — attempting reconnect...")
try:
_wifi_sta.active(True)
_wifi_sta.connect(WIFI_SSID, WIFI_PASSWORD)
deadline = utime.time() + 15
while not _wifi_sta.isconnected():
if utime.time() > deadline:
log_err("WiFi reconnect timeout")
return
utime.sleep_ms(200)
ip, mask, gw, dns = _wifi_sta.ifconfig()
ip = connect_wifi(WIFI_SSID, WIFI_PASSWORD, timeout_s=15, force_reconnect=True)
info(f"WiFi reconnected! IP:{ip}")
except Exception as e:
log_err(f"WiFi reconnect failed: {e}")
@@ -500,13 +613,41 @@ def check_wifi():
def on_message(topic, payload):
global vfd_text, vfd_decimal_point, vfd_alarm
if client_ref is None:
return
topic = topic.decode()
payload = payload.decode().strip()
info(f"MQTT rx {topic} {payload}")
if topic == vfd_topics["set"]:
vfd_text = payload.upper()
send_vfd_state()
publish_vfd_state(client_ref)
info(f"VFD text -> {vfd_text}")
return
if topic == vfd_topics["decimal_point"]:
vfd_decimal_point = payload.upper() == "ON"
send_vfd_state()
publish_vfd_state(client_ref)
info(f"VFD decimal point -> {'ON' if vfd_decimal_point else 'OFF'}")
return
if topic == vfd_topics["alarm"]:
vfd_alarm = payload.upper() == "ON"
send_vfd_state()
publish_vfd_state(client_ref)
info(f"VFD alarm -> {'ON' if vfd_alarm else 'OFF'}")
return
for i, gt in enumerate(gauge_topics):
if topic == gt["led_bl_state"]:
restore_backlight_state(i, payload)
info(f"Gauge {i} backlight state restored")
return
if topic == gt["zero"]:
info(f"Home command received for gauge {i}")
gauge_home(i)
@@ -723,6 +864,9 @@ def on_message(topic, payload):
def _subscribe_all(c):
c.subscribe(f"{MQTT_PREFIX}/set")
c.subscribe(f"{MQTT_PREFIX}/zero")
c.subscribe(vfd_topics["set"])
c.subscribe(vfd_topics["decimal_point"])
c.subscribe(vfd_topics["alarm"])
for i in range(num_gauges):
prefix = f"{MQTT_PREFIX}/gauge{i}"
c.subscribe(f"{prefix}/set")
@@ -732,6 +876,7 @@ def _subscribe_all(c):
c.subscribe(f"{prefix}/led/red/set")
c.subscribe(f"{prefix}/led/green/set")
c.subscribe(f"{prefix}/led/backlight/set")
c.subscribe(f"{prefix}/led/backlight/state")
c.subscribe(f"{prefix}/status_led/red/set")
c.subscribe(f"{prefix}/status_led/green/set")
@@ -739,27 +884,63 @@ def _subscribe_all(c):
def connect_mqtt():
global client_ref, _mqtt_connected
info(f"Connecting to MQTT broker {MQTT_BROKER}:{MQTT_PORT} ...")
client = MQTTClient(
client_id=MQTT_CLIENT_ID,
server=MQTT_BROKER,
port=MQTT_PORT,
user=MQTT_USER,
password=MQTT_PASSWORD,
keepalive=30,
)
client.set_callback(on_message)
client.connect()
client_ref = client
_mqtt_connected = True
info(f"MQTT connected client_id={MQTT_CLIENT_ID}")
last_error = None
for attempt in range(3):
gc.collect()
try:
if client_ref is not None:
try:
client_ref.disconnect()
except Exception:
pass
client = MQTTClient(
client_id=MQTT_CLIENT_ID,
server=MQTT_BROKER,
port=MQTT_PORT,
user=MQTT_USER,
password=MQTT_PASSWORD,
keepalive=30,
)
client.set_callback(on_message)
client.connect()
client_ref = client
_mqtt_connected = True
info(f"MQTT connected client_id={MQTT_CLIENT_ID}")
return
except Exception as e:
last_error = e
log_err(f"MQTT connect attempt {attempt + 1} failed: {type(e).__name__}: {e}")
try:
client.sock.close()
except Exception:
pass
gc.collect()
utime.sleep_ms(1000)
_mqtt_connected = False
raise last_error
_mqtt_check_interval_ms = 30000
_last_mqtt_check = 0
_discovery_phases = ()
_discovery_phase_idx = 0
_discovery_queue = []
_discovery_idx = 0
_last_discovery_ms = 0
_DISCOVERY_INTERVAL_MS = 500
_DISCOVERY_INTERVAL_MS = 350
def _compact_discovery_payload(payload):
"""Trim optional HA discovery fields when RAM is tight."""
compact = dict(payload)
# Light entities are the largest payloads because they repeat effect metadata.
# Keep core functionality, but omit optional effect declarations to reduce heap use.
if compact.get("schema") == "json":
compact.pop("effect", None)
compact.pop("effect_list", None)
return compact
def check_mqtt():
@@ -803,243 +984,273 @@ def check_mqtt():
return True
except Exception as e2:
log_err(f"MQTT reconnect attempt {attempt + 1} failed: {e2}")
try:
client_ref.sock.close()
except Exception:
pass
gc.collect()
utime.sleep_ms(2000)
log_err("MQTT reconnection failed after 3 attempts")
return False
def _discovery_pause(client, count=5, delay_ms=25):
for _ in range(count):
client.check_msg()
utime.sleep_ms(delay_ms)
gc.collect()
def _publish_discovery_entity(client, topic, payload, log_msg):
client.publish(topic, ujson.dumps(payload), retain=True)
gc.collect()
client.publish(topic, ujson.dumps(_compact_discovery_payload(payload)), retain=True)
info(log_msg)
def _publish_gauge_discovery(client, dev_ref):
def _append_gauge_discovery(entries, dev_ref):
for i, g in enumerate(gauges):
gt = gauge_topics[i]
_publish_discovery_entity(
client,
gt["disc"],
{
"name": g["entity_name"],
"unique_id": f"{MQTT_CLIENT_ID}_g{i}",
"cmd_t": gt["set"],
"stat_t": gt["state"],
"avty_t": gt["status"],
"min": g["min"],
"max": g["max"],
"step": 1,
"unit_of_meas": g["unit"],
"icon": "mdi:gauge",
"dev": dev_ref,
},
f"Discovery: gauge {i} ({g['name']})",
entries.append(
(
gt["disc"],
{
"name": g["entity_name"],
"unique_id": f"{MQTT_CLIENT_ID}_g{i}",
"cmd_t": gt["set"],
"stat_t": gt["state"],
"avty_t": gt["status"],
"min": g["min"],
"max": g["max"],
"step": 1,
"unit_of_meas": g["unit"],
"icon": "mdi:gauge",
"dev": dev_ref,
},
f"Discovery: gauge {i} ({g['name']})",
)
)
_discovery_pause(client)
def _publish_speed_discovery(client, dev_ref):
def _append_speed_discovery(entries, dev_ref):
for i, g in enumerate(gauges):
gt = gauge_topics[i]
_publish_discovery_entity(
client,
gt["speed_disc"],
{
"name": f"{g['name']} Speed",
"unique_id": f"{MQTT_CLIENT_ID}_g{i}_speed",
"cmd_t": gt["speed"],
"stat_t": gt["speed_state"],
"avty_t": gt["status"],
"min": 1,
"max": 50000,
"step": 1,
"mode": "box",
"unit_of_meas": "steps/s",
"icon": "mdi:speedometer",
"entity_category": "config",
"dev": dev_ref,
},
f"Discovery: gauge {i} speed",
entries.append(
(
gt["speed_disc"],
{
"name": f"{g['name']} Speed",
"unique_id": f"{MQTT_CLIENT_ID}_g{i}_speed",
"cmd_t": gt["speed"],
"stat_t": gt["speed_state"],
"avty_t": gt["status"],
"min": 1,
"max": 50000,
"step": 1,
"mode": "box",
"unit_of_meas": "steps/s",
"icon": "mdi:speedometer",
"entity_category": "config",
"dev": dev_ref,
},
f"Discovery: gauge {i} speed",
)
)
_discovery_pause(client)
def _publish_acceleration_discovery(client, dev_ref):
def _append_acceleration_discovery(entries, dev_ref):
for i, g in enumerate(gauges):
gt = gauge_topics[i]
_publish_discovery_entity(
client,
gt["acceleration_disc"],
{
"name": f"{g['name']} Acceleration",
"unique_id": f"{MQTT_CLIENT_ID}_g{i}_acceleration",
"cmd_t": gt["acceleration"],
"stat_t": gt["acceleration_state"],
"avty_t": gt["status"],
"min": 1,
"max": 100000,
"step": 1,
"mode": "box",
"unit_of_meas": "steps/s2",
"icon": "mdi:chart-bell-curve-cumulative",
"entity_category": "config",
"dev": dev_ref,
},
f"Discovery: gauge {i} acceleration",
entries.append(
(
gt["acceleration_disc"],
{
"name": f"{g['name']} Acceleration",
"unique_id": f"{MQTT_CLIENT_ID}_g{i}_acceleration",
"cmd_t": gt["acceleration"],
"stat_t": gt["acceleration_state"],
"avty_t": gt["status"],
"min": 1,
"max": 100000,
"step": 1,
"mode": "box",
"unit_of_meas": "steps/s2",
"icon": "mdi:chart-bell-curve-cumulative",
"entity_category": "config",
"dev": dev_ref,
},
f"Discovery: gauge {i} acceleration",
)
)
_discovery_pause(client)
def _publish_indicator_led_discovery(client, dev_ref):
def _append_indicator_led_discovery(entries, dev_ref):
for i, g in enumerate(gauges):
gt = gauge_topics[i]
_publish_discovery_entity(
client,
gt["led_red_disc"],
{
"name": f"{g['name']} Dial Red LED",
"uniq_id": f"{MQTT_CLIENT_ID}_g{i}_red",
"cmd_t": gt["led_red"],
"stat_t": gt["led_red_state"],
"schema": "json",
"supported_color_modes": ["onoff"],
"effect": True,
"effect_list": _EFFECT_LIST,
"icon": "mdi:led-on",
"dev": dev_ref,
"ret": True,
},
f"Discovery: gauge {i} red LED",
entries.append(
(
gt["led_red_disc"],
{
"name": f"{g['name']} Dial Red LED",
"uniq_id": f"{MQTT_CLIENT_ID}_g{i}_red",
"cmd_t": gt["led_red"],
"stat_t": gt["led_red_state"],
"schema": "json",
"supported_color_modes": ["onoff"],
"effect": True,
"effect_list": _EFFECT_LIST,
"icon": "mdi:led-on",
"dev": dev_ref,
"ret": True,
},
f"Discovery: gauge {i} red LED",
)
)
entries.append(
(
gt["led_green_disc"],
{
"name": f"{g['name']} Dial Green LED",
"uniq_id": f"{MQTT_CLIENT_ID}_g{i}_green",
"cmd_t": gt["led_green"],
"stat_t": gt["led_green_state"],
"schema": "json",
"supported_color_modes": ["onoff"],
"effect": True,
"effect_list": _EFFECT_LIST,
"icon": "mdi:led-on",
"dev": dev_ref,
"ret": True,
},
f"Discovery: gauge {i} green LED",
)
)
_publish_discovery_entity(
client,
gt["led_green_disc"],
{
"name": f"{g['name']} Dial Green LED",
"uniq_id": f"{MQTT_CLIENT_ID}_g{i}_green",
"cmd_t": gt["led_green"],
"stat_t": gt["led_green_state"],
"schema": "json",
"supported_color_modes": ["onoff"],
"effect": True,
"effect_list": _EFFECT_LIST,
"icon": "mdi:led-on",
"dev": dev_ref,
"ret": True,
},
f"Discovery: gauge {i} green LED",
)
_discovery_pause(client)
def _publish_backlight_status_discovery(client, dev_ref):
def _append_backlight_status_discovery(entries, dev_ref):
for i, g in enumerate(gauges):
gt = gauge_topics[i]
_publish_discovery_entity(
client,
gt["led_bl_disc"],
{
"name": f"{g['name']} Backlight",
"uniq_id": f"{MQTT_CLIENT_ID}_g{i}_bl",
"cmd_t": gt["led_bl"],
"stat_t": gt["led_bl_state"],
"schema": "json",
"supported_color_modes": ["rgb"],
"effect": True,
"effect_list": _EFFECT_LIST,
"icon": "mdi:led-strip",
"dev": dev_ref,
"ret": True,
},
f"Discovery: gauge {i} backlight",
entries.append(
(
gt["led_bl_disc"],
{
"name": f"{g['name']} Backlight",
"uniq_id": f"{MQTT_CLIENT_ID}_g{i}_bl",
"cmd_t": gt["led_bl"],
"stat_t": gt["led_bl_state"],
"schema": "json",
"supported_color_modes": ["rgb"],
"effect": True,
"effect_list": _EFFECT_LIST,
"icon": "mdi:led-strip",
"dev": dev_ref,
"ret": True,
},
f"Discovery: gauge {i} backlight",
)
)
entries.append(
(
gt["status_red_disc"],
{
"name": f"{g['name']} Channel Status Red",
"uniq_id": f"{MQTT_CLIENT_ID}_g{i}_status_red",
"cmd_t": gt["status_red"],
"stat_t": gt["status_red_state"],
"schema": "json",
"supported_color_modes": ["onoff"],
"effect": True,
"effect_list": _EFFECT_LIST,
"icon": "mdi:led-on",
"dev": dev_ref,
"ret": True,
},
f"Discovery: gauge {i} status red",
)
)
entries.append(
(
gt["status_green_disc"],
{
"name": f"{g['name']} Channel Status Green",
"uniq_id": f"{MQTT_CLIENT_ID}_g{i}_status_green",
"cmd_t": gt["status_green"],
"stat_t": gt["status_green_state"],
"schema": "json",
"supported_color_modes": ["onoff"],
"effect": True,
"effect_list": _EFFECT_LIST,
"icon": "mdi:led-on",
"dev": dev_ref,
"ret": True,
},
f"Discovery: gauge {i} status green",
)
)
_publish_discovery_entity(
client,
gt["status_red_disc"],
def _append_vfd_discovery(entries, dev_ref):
entries.append(
(
vfd_topics["disc"],
{
"name": f"{g['name']} Channel Status Red",
"uniq_id": f"{MQTT_CLIENT_ID}_g{i}_status_red",
"cmd_t": gt["status_red"],
"stat_t": gt["status_red_state"],
"schema": "json",
"supported_color_modes": ["onoff"],
"effect": True,
"effect_list": _EFFECT_LIST,
"icon": "mdi:led-on",
"name": "VFD Display",
"unique_id": f"{MQTT_CLIENT_ID}_vfd",
"cmd_t": vfd_topics["set"],
"stat_t": vfd_topics["state"],
"avty_t": gauge_topics[0]["status"],
"icon": "mdi:alpha-box",
"dev": dev_ref,
"ret": True,
},
f"Discovery: gauge {i} status red",
"Discovery: VFD text",
)
_publish_discovery_entity(
client,
gt["status_green_disc"],
)
entries.append(
(
vfd_topics["decimal_point_disc"],
{
"name": f"{g['name']} Channel Status Green",
"uniq_id": f"{MQTT_CLIENT_ID}_g{i}_status_green",
"cmd_t": gt["status_green"],
"stat_t": gt["status_green_state"],
"schema": "json",
"supported_color_modes": ["onoff"],
"effect": True,
"effect_list": _EFFECT_LIST,
"icon": "mdi:led-on",
"name": "VFD Decimal Point",
"unique_id": f"{MQTT_CLIENT_ID}_vfd_decimal_point",
"cmd_t": vfd_topics["decimal_point"],
"stat_t": vfd_topics["decimal_point_state"],
"avty_t": gauge_topics[0]["status"],
"pl_on": "ON",
"pl_off": "OFF",
"icon": "mdi:circle-small",
"dev": dev_ref,
"ret": True,
},
f"Discovery: gauge {i} status green",
"Discovery: VFD decimal point",
)
_discovery_pause(client)
def publish_discovery(client):
"""Publish all HA MQTT discovery payloads for gauges and LEDs."""
_dev_ref = _DEVICE
# Clear any previously registered switch entities (migration to light).
for i in range(num_gauges):
for old_t in [
f"homeassistant/switch/{MQTT_CLIENT_ID}_g{i}_red/config",
f"homeassistant/switch/{MQTT_CLIENT_ID}_g{i}_green/config",
f"homeassistant/switch/{MQTT_CLIENT_ID}_g{i}_status_red/config",
f"homeassistant/switch/{MQTT_CLIENT_ID}_g{i}_status_green/config",
]:
client.publish(old_t, b"", retain=True)
_discovery_pause(client, count=2, delay_ms=15)
_publish_gauge_discovery(client, _dev_ref)
_publish_speed_discovery(client, _dev_ref)
_publish_acceleration_discovery(client, _dev_ref)
_publish_indicator_led_discovery(client, _dev_ref)
_publish_backlight_status_discovery(client, _dev_ref)
)
entries.append(
(
vfd_topics["alarm_disc"],
{
"name": "VFD Alarm",
"unique_id": f"{MQTT_CLIENT_ID}_vfd_alarm",
"cmd_t": vfd_topics["alarm"],
"stat_t": vfd_topics["alarm_state"],
"avty_t": gauge_topics[0]["status"],
"pl_on": "ON",
"pl_off": "OFF",
"icon": "mdi:alarm-bell",
"dev": dev_ref,
},
"Discovery: VFD alarm",
)
)
def schedule_discovery():
global _discovery_phases, _discovery_phase_idx, _last_discovery_ms
global _discovery_queue, _discovery_idx, _last_discovery_ms
_dev_ref = _DEVICE
_discovery_phases = (
lambda client: _clear_legacy_discovery(client),
lambda client: _publish_gauge_discovery(client, _dev_ref),
lambda client: _publish_speed_discovery(client, _dev_ref),
lambda client: _publish_acceleration_discovery(client, _dev_ref),
lambda client: _publish_indicator_led_discovery(client, _dev_ref),
lambda client: _publish_backlight_status_discovery(client, _dev_ref),
)
_discovery_phase_idx = 0
entries = []
_append_legacy_discovery(entries)
_append_gauge_discovery(entries, _dev_ref)
_append_speed_discovery(entries, _dev_ref)
_append_acceleration_discovery(entries, _dev_ref)
_append_indicator_led_discovery(entries, _dev_ref)
_append_backlight_status_discovery(entries, _dev_ref)
_append_vfd_discovery(entries, _dev_ref)
_discovery_queue = entries
_discovery_idx = 0
_last_discovery_ms = 0
def _clear_legacy_discovery(client):
def _append_legacy_discovery(entries):
for i in range(num_gauges):
for old_t in [
f"homeassistant/switch/{MQTT_CLIENT_ID}_g{i}_red/config",
@@ -1047,22 +1258,30 @@ def _clear_legacy_discovery(client):
f"homeassistant/switch/{MQTT_CLIENT_ID}_g{i}_status_red/config",
f"homeassistant/switch/{MQTT_CLIENT_ID}_g{i}_status_green/config",
]:
client.publish(old_t, b"", retain=True)
_discovery_pause(client, count=2, delay_ms=15)
entries.append((old_t, b"", None))
def service_discovery():
global _discovery_phase_idx, _last_discovery_ms
if client_ref is None or _discovery_phase_idx >= len(_discovery_phases):
global _discovery_idx, _last_discovery_ms
if client_ref is None or _discovery_idx >= len(_discovery_queue):
return
now = utime.ticks_ms()
if _last_discovery_ms and utime.ticks_diff(now, _last_discovery_ms) < _DISCOVERY_INTERVAL_MS:
return
_discovery_phases[_discovery_phase_idx](client_ref)
_discovery_phase_idx += 1
gc.collect()
topic, payload, log_msg = _discovery_queue[_discovery_idx]
try:
if isinstance(payload, bytes):
client_ref.publish(topic, payload, retain=True)
else:
_publish_discovery_entity(client_ref, topic, payload, log_msg)
except Exception as e:
log_err(f"Discovery publish failed for {topic}: {e}")
_discovery_idx += 1
_last_discovery_ms = utime.ticks_ms()
gc.collect()
def publish_online(client):
@@ -1076,12 +1295,14 @@ def publish_state(client):
client.publish(gt["state"], str(gauge_targets[i]))
client.publish(gt["speed_state"], str(gauge_speeds[i]), retain=True)
client.publish(gt["acceleration_state"], str(gauge_accelerations[i]), retain=True)
publish_vfd_state(client)
def apply_motion_defaults():
for i in range(num_gauges):
gauge_set_speed(i, gauge_speeds[i])
gauge_set_acceleration(i, gauge_accelerations[i])
send_vfd_state()
# ---------------------------------------------------------------------------
@@ -1090,13 +1311,30 @@ def apply_motion_defaults():
def main():
gc.collect()
info("=" * 48)
info("Gauge MQTT controller starting")
info(f"Heap free: {gc.mem_free()} bytes")
info("=" * 48)
connect_wifi(WIFI_SSID, WIFI_PASSWORD)
gc.collect()
connect_wifi(WIFI_SSID, WIFI_PASSWORD, force_reconnect=True)
connect_mqtt()
mqtt_attempts = 0
while True:
try:
connect_mqtt()
break
except Exception as e:
mqtt_attempts += 1
log_err(f"MQTT connect failed: {e} (attempt {mqtt_attempts})")
if mqtt_attempts % 3 == 0:
log_err("WiFi may be stale — forcing reconnect...")
try:
connect_wifi(WIFI_SSID, WIFI_PASSWORD, force_reconnect=True)
except Exception as we:
log_err(f"WiFi reconnect failed: {we}")
utime.sleep_ms(5000)
_subscribe_all(client_ref)
schedule_discovery()

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