// Display prototype: I2C-driven debug HUD on a Waveshare 172x320 TFT.
//
// Panel:  Waveshare 1.47" IPS LCD, ST7789(V3) controller, 4-wire SPI.
// Board:  Adafruit QT Py ESP32 (S3 / S2 / C3 / Pico).
//
// This device acts as an I2C SLAVE: a master writes register+value packets that
// drive a graphical HUD drawn over a full-screen background image (which bakes in
// the static housing/trapezoid art): a small battery percent readout, a rotating
// compass arrow, a big ammo number on a teal panel, and a thin load
// timeout/cooldown bar. The display only redraws the regions whose values change.
// The I2C protocol is unchanged from the prior text HUD.
//
// Pin names below (MOSI, SCK, SDA1, SCL1, A0...) are Arduino macros that resolve
// to the correct GPIO for whichever QT Py variant you compile for, so this
// sketch is portable across the family. See the wiring table at the bottom.
//
// NOTE: This sketch talks I2C over the STEMMA QT connector, which on the QT Py
// ESP32 (S3/S2) is a SECOND I2C controller exposed as Wire1 on pins SDA1/SCL1 --
// it is NOT the same bus as the SDA/SCL breakout pads (those are Wire).
//
// Libraries (install via Library Manager):
//   - Adafruit GFX Library
//   - Adafruit ST7735 and ST7789 Library
//   - Adafruit BusIO (pulled in automatically)

#include <Adafruit_GFX.h>
#include <Adafruit_ST7789.h>
#include <SPI.h>
#include <Wire.h>
#include <math.h>
#include <Fonts/FreeSans24pt7b.h>   // large number font for the ammo readout
#include "background.h"             // full-screen RGB565 backdrop (bgimage/convert.py)

// --- Display wiring (QT Py silk label -> display pin) ---
#define TFT_CS   A0   // display CS
#define TFT_DC   A1   // display DC
#define TFT_RST  A2   // display RST (set to -1 if your module auto-resets)
// DIN (data in / MOSI) -> MO  (hardware SPI, fixed pin)
// CLK (clock / SCLK)   -> SCK (hardware SPI, fixed pin)

// --- I2C slave config ---
// IMPORTANT: On the QT Py ESP32 (S3/S2) the STEMMA QT connector is a SEPARATE
// I2C controller from the SDA/SCL breakout pads. The STEMMA QT port is exposed
// as Wire1 on pins SDA1/SCL1 (e.g. GPIO 41/40 on the S3), while the SDA/SCL
// macros refer to the castellated pads on Wire. Since the master is wired to
// the STEMMA QT port, the slave MUST run on Wire1 / SDA1 / SCL1 -- otherwise it
// listens on the wrong pins and never ACKs.
#define I2C_BUS   Wire1  // STEMMA QT port
#define I2C_ADDR  0x42   // this device's address on the bus
#define I2C_SDA   SDA1   // QT Py STEMMA QT data pin
#define I2C_SCL   SCL1   // QT Py STEMMA QT clock pin
#define I2C_FREQ  400000 // 400 kHz

// I2C register map. Master writes: [reg][payload...], little-endian (LSB first).
//   REG_BATTERY     2 bytes  uint16  millivolts        (3850 -> 3.85 V)
//   REG_FLASHLIGHT  1 byte   uint8   0 = off, else on
//   REG_AMMO        2 bytes  uint16  rounds remaining
//   REG_HEADING     2 bytes  uint16  degrees 0..359
//   REG_BATTERY_PCT 1 byte   uint8   charge 0..100 %
//   REG_LOAD_TIMER  2 bytes  [state][percent]
//                            state 0 = ready, 1 = on (timeout), 2 = cooldown
//                            percent 0..100 = time remaining in the phase
#define REG_BATTERY      0x01
#define REG_FLASHLIGHT   0x02
#define REG_AMMO         0x03
#define REG_HEADING      0x04
#define REG_BATTERY_PCT  0x05
#define REG_LOAD_TIMER   0x06

// Sentinel ammo value meaning "sensor disconnected" -> shown as "--".
#define AMMO_DISCONNECTED 0xFFFF

// Load-timer phase values (must match the master).
#define LOAD_TIMER_READY     0
#define LOAD_TIMER_ACTIVE    1
#define LOAD_TIMER_COOLDOWN  2

// --- Panel geometry ---
#define TFT_WIDTH   172
#define TFT_HEIGHT  320

// Colors (ported from old_display's palette). 565 values precomputed from the
// original color565() calls, shown in the comments.
#define COLOR_BG      ST77XX_BLACK
#define COLOR_ACCENT  0x07FF   // cyan   color565(0x00, 0xFF, 0xFF)
#define COLOR_DIM     0x02AB   // teal   color565(0x00, 0x57, 0x5F)
#define COLOR_HUD_BG  0x10A3   // #10161F -- solid bg behind the compass in bg.png
#define COLOR_YELLOW  0xFFE2   //        color565(0xFF, 0xFF, 0x11)
#define COLOR_RED     0xF882   //        color565(0xFF, 0x11, 0x11)

// All the live state the HUD renders, updated over I2C.
struct DebugInfo {
  float batteryVolts;     // battery voltage
  int   batteryPct;       // battery charge, 0..100 %
  bool  flashlightOn;     // flashlight status
  int   ammoCount;        // rounds remaining
  int   compassHeading;   // degrees, 0..359
  int   loadTimerState;   // 0 ready, 1 on (timeout), 2 cooldown
  int   loadTimerPct;     // 0..100 time remaining in the active phase
};

// Hardware SPI: pass only CS / DC / RST; clock + data use the board's SPI pins.
Adafruit_ST7789 tft = Adafruit_ST7789(TFT_CS, TFT_DC, TFT_RST);

// Shared state between the I2C receive callback and loop(). g_mux guards it
// because the callback runs in the Wire driver's context, not in loop().
portMUX_TYPE g_mux = portMUX_INITIALIZER_UNLOCKED;
volatile DebugInfo g_info = { 3.85f, 100, true, 30, 273, 0, 0 };  // defaults until first update
volatile bool g_dirty = false;                         // set when a value changes
volatile bool g_gotFirstPacket = false;                // becomes true on first valid packet

// Last values actually painted to the screen, so we only redraw the regions that
// changed (the load-timer bar updates several times a second).
DebugInfo g_shown;

// --- Debug logging state ---------------------------------------------------
// The I2C callback runs in the Wire driver's context, so it only records what
// it saw into these volatiles; loop() does the actual Serial printing.
volatile uint32_t g_rxCount   = 0;     // total packets received
volatile uint8_t  g_lastReg   = 0;     // register byte of last packet
volatile int32_t  g_lastValue = -1;    // parsed value of last packet (-1 = n/a)
volatile int      g_lastBytes = 0;     // numBytes reported for last packet
volatile bool     g_lastKnown = false; // was the register recognized?
volatile bool     g_logPending = false;// set when there's a new packet to log
unsigned long g_lastRxMillis = 0;      // when we last saw any packet (loop-only)

void onI2CReceive(int numBytes);
void updateDisplay(const DebugInfo &info, bool forceFull);
void drawInitScreen();

void drawBatteryPct(int pct);
void drawBatteryVolts(float volts);
void drawLoadBar(int state, int pct);
void drawCompass(int heading);
void drawAmmo(int ammo);
void drawNoMag(bool show);
void drawThinBar(int16_t y, float widthFrac, int pct, uint16_t fillColor);
void drawCompassArrow(GFXcanvas16 &c, int cx, int cy, int base, int height, float angle, uint16_t color);

// --- HUD geometry (portrait 172x320), ported from old_display ---
#define COMPASS_CX      86     // compass arrow center
#define COMPASS_CY      76     // (raised 10px)
#define COMPASS_BOX     70     // off-screen canvas size for the arrow region
#define LOAD_BAR_Y      147    // load timeout/cooldown bar
#define AMMO_PANEL_X    20     // dim-teal ammo panel
#define AMMO_PANEL_Y    160
#define AMMO_PANEL_W    132
#define AMMO_PANEL_H    100
#define BATT_PCT_X      30     // small "NN%" battery readout, bottom-left
#define BATT_PCT_Y      293
#define BATT_PCT_W      56
#define BATT_PCT_H      20
#define BATT_V_X        86     // battery voltage readout, bottom-right (mirrors %)
#define BATT_V_Y        293
#define BATT_V_W        56
#define BATT_V_H        20
#define NOMAG_X         0      // "No Magazine" warning strip, just above the bar
#define NOMAG_W         172
#define NOMAG_H         20
#define NOMAG_Y         (LOAD_BAR_Y - NOMAG_H - 3)
#define NOMAG_BLINK_MS  500    // pulse period (on/off) while disconnected
#define AMMO_Y_NUDGE    3      // raise the ammo number this many px

void setup() {
  Serial.begin(115200);
  delay(200);
  Serial.println();
  Serial.println("=== display_proto booting ===");
  Serial.print("I2C slave address: 0x");
  Serial.println(I2C_ADDR, HEX);

  // init(172, 320) tells the library the panel size; it applies the required
  // column offset (34) so the image lands correctly on this cut of the ST7789.
  tft.init(TFT_WIDTH, TFT_HEIGHT);
  // Push the SPI clock up so each blit is fast. 80 MHz is comfortable on a QT Py
  // with short wiring; drop to 40 MHz if you see pixel glitches.
  tft.setSPISpeed(80000000);
  tft.setRotation(0);          // 0 = portrait, 172 wide x 320 tall
  tft.fillScreen(COLOR_BG);

  // Start I2C in slave mode on the STEMMA QT bus (Wire1) and register the
  // receive callback.
  I2C_BUS.begin((uint8_t)I2C_ADDR, I2C_SDA, I2C_SCL, I2C_FREQ);
  I2C_BUS.onReceive(onI2CReceive);

  Serial.println("I2C slave ready on STEMMA QT (Wire1), waiting for packets...");
}

void loop() {
  // Snapshot shared state (and any pending log info) under the lock, then do
  // the slow stuff (Serial, TFT) outside the critical section.
  DebugInfo snapshot;
  bool dirty;
  bool gotFirstPacket;
  bool logPending;
  uint32_t rxCount;
  uint8_t  lastReg;
  int32_t  lastValue;
  int      lastBytes;
  bool     lastKnown;

  portENTER_CRITICAL(&g_mux);
  snapshot = *(DebugInfo *)&g_info;
  dirty = g_dirty;
  g_dirty = false;
  gotFirstPacket = g_gotFirstPacket;
  logPending = g_logPending;
  g_logPending = false;
  rxCount   = g_rxCount;
  lastReg   = g_lastReg;
  lastValue = g_lastValue;
  lastBytes = g_lastBytes;
  lastKnown = g_lastKnown;
  portEXIT_CRITICAL(&g_mux);

  if (logPending) {
    g_lastRxMillis = millis();
    Serial.print("[RX #");
    Serial.print(rxCount);
    Serial.print("] bytes=");
    Serial.print(lastBytes);
    Serial.print(" reg=0x");
    if (lastReg < 16) Serial.print("0");
    Serial.print(lastReg, HEX);
    if (!lastKnown) {
      Serial.println("  -> UNKNOWN REGISTER");
    } else {
      Serial.print(" raw=");
      Serial.print(lastValue);
      switch (lastReg) {
        case REG_BATTERY:
          Serial.print("  -> BATTERY ");
          Serial.print(lastValue / 1000.0f, 2);
          Serial.println(" V");
          break;
        case REG_FLASHLIGHT:
          Serial.print("  -> FLASHLIGHT ");
          Serial.println(lastValue ? "ON" : "OFF");
          break;
        case REG_AMMO:
          Serial.print("  -> AMMO ");
          Serial.println(lastValue);
          break;
        case REG_HEADING:
          Serial.print("  -> HEADING ");
          Serial.print(lastValue);
          Serial.println(" deg");
          break;
        case REG_BATTERY_PCT:
          Serial.print("  -> BATTERY ");
          Serial.print(lastValue);
          Serial.println(" %");
          break;
        case REG_LOAD_TIMER:
          Serial.print("  -> LOAD state=");
          Serial.print((lastValue >> 8) & 0xFF);
          Serial.print(" pct=");
          Serial.println(lastValue & 0xFF);
          break;
      }
    }
  }

  // Show a centered "Initializing" splash until the first valid packet arrives,
  // then switch to the HUD. hudStarted forces a full redraw on the transition so
  // the splash is cleared and every field is drawn at least once.
  static bool splashShown = false;
  static bool hudStarted = false;

  if (!gotFirstPacket) {
    if (!splashShown) {
      drawInitScreen();
      splashShown = true;
    }
  } else if (!hudStarted) {
    tft.drawRGBBitmap(0, 0, background, BG_WIDTH, BG_HEIGHT);  // backdrop (static housing/border baked in)
    Serial.println("First packet received -- drawing HUD.");
    updateDisplay(snapshot, true);   // live elements composite on top
    hudStarted = true;
  } else if (dirty) {
    updateDisplay(snapshot, false);  // redraw only changed regions
  }

  // Pulse the "No Magazine" warning on/off while the ammo sensor is disconnected.
  static unsigned long lastBlink = 0;
  static bool blinkOn = true;
  if (hudStarted && (uint16_t)snapshot.ammoCount == AMMO_DISCONNECTED) {
    if (millis() - lastBlink >= NOMAG_BLINK_MS) {
      lastBlink = millis();
      blinkOn = !blinkOn;
      drawNoMag(blinkOn);
    }
  } else {
    blinkOn = true;   // reset so it reappears immediately when next disconnected
  }

  // Heartbeat: if no packets have arrived for a while, say so periodically.
  static unsigned long lastHeartbeat = 0;
  if (millis() - lastHeartbeat > 3000) {
    lastHeartbeat = millis();
    if (g_lastRxMillis == 0) {
      Serial.println("(heartbeat) no I2C packets received yet.");
    } else if (millis() - g_lastRxMillis > 3000) {
      Serial.print("(heartbeat) no I2C packets for ");
      Serial.print((millis() - g_lastRxMillis) / 1000);
      Serial.println("s.");
    }
  }
}

// I2C receive callback: parse [reg][payload] packets into the shared struct.
// Keep this short -- it runs in the Wire driver's context. Reads happen first,
// then the struct is updated inside a brief critical section.
void onI2CReceive(int numBytes) {
  if (numBytes < 1) return;

  uint8_t reg = I2C_BUS.read();
  int32_t parsedValue = -1;  // for logging; -1 means "not parsed"
  bool    known = true;      // was the register recognized + payload complete?

  switch (reg) {
    case REG_BATTERY: {
      if (I2C_BUS.available() < 2) { known = false; break; }
      uint16_t mv = (uint16_t)I2C_BUS.read() | ((uint16_t)I2C_BUS.read() << 8);
      parsedValue = mv;
      portENTER_CRITICAL(&g_mux);
      g_info.batteryVolts = mv / 1000.0f;
      g_dirty = true;
      portEXIT_CRITICAL(&g_mux);
      break;
    }
    case REG_FLASHLIGHT: {
      if (I2C_BUS.available() < 1) { known = false; break; }
      bool on = I2C_BUS.read() != 0;
      parsedValue = on ? 1 : 0;
      portENTER_CRITICAL(&g_mux);
      g_info.flashlightOn = on;
      g_dirty = true;
      portEXIT_CRITICAL(&g_mux);
      break;
    }
    case REG_AMMO: {
      if (I2C_BUS.available() < 2) { known = false; break; }
      uint16_t ammo = (uint16_t)I2C_BUS.read() | ((uint16_t)I2C_BUS.read() << 8);
      parsedValue = ammo;
      portENTER_CRITICAL(&g_mux);
      g_info.ammoCount = ammo;
      g_dirty = true;
      portEXIT_CRITICAL(&g_mux);
      break;
    }
    case REG_HEADING: {
      if (I2C_BUS.available() < 2) { known = false; break; }
      uint16_t hdg = (uint16_t)I2C_BUS.read() | ((uint16_t)I2C_BUS.read() << 8);
      parsedValue = hdg % 360;
      portENTER_CRITICAL(&g_mux);
      g_info.compassHeading = hdg % 360;
      g_dirty = true;
      portEXIT_CRITICAL(&g_mux);
      break;
    }
    case REG_BATTERY_PCT: {
      if (I2C_BUS.available() < 1) { known = false; break; }
      uint8_t pct = I2C_BUS.read();
      if (pct > 100) pct = 100;
      parsedValue = pct;
      portENTER_CRITICAL(&g_mux);
      g_info.batteryPct = pct;
      g_dirty = true;
      portEXIT_CRITICAL(&g_mux);
      break;
    }
    case REG_LOAD_TIMER: {
      if (I2C_BUS.available() < 2) { known = false; break; }
      uint8_t state = I2C_BUS.read();
      uint8_t pct = I2C_BUS.read();
      if (pct > 100) pct = 100;
      parsedValue = ((int32_t)state << 8) | pct;  // packed for logging
      portENTER_CRITICAL(&g_mux);
      g_info.loadTimerState = state;
      g_info.loadTimerPct = pct;
      g_dirty = true;
      portEXIT_CRITICAL(&g_mux);
      break;
    }
    default:
      known = false;
      break;
  }

  // Record packet metadata so loop() can log it (Serial isn't safe here).
  portENTER_CRITICAL(&g_mux);
  g_rxCount++;
  g_lastReg    = reg;
  g_lastValue  = parsedValue;
  g_lastBytes  = numBytes;
  g_lastKnown  = known;
  g_logPending = true;
  if (known) g_gotFirstPacket = true;  // valid packet -> leave the splash screen
  portEXIT_CRITICAL(&g_mux);

  // Drain anything left so the next packet starts clean.
  while (I2C_BUS.available()) I2C_BUS.read();
}

// Splash shown until the first valid I2C packet arrives. Draws "Initializing"
// centered on the panel. getTextBounds() gives the rendered size so the text
// lands dead center regardless of font metrics.
void drawInitScreen() {
  tft.fillScreen(COLOR_BG);

  const char *msg = "Initializing";
  tft.setTextSize(2);
  tft.setTextColor(COLOR_ACCENT, COLOR_BG);

  int16_t x1, y1;
  uint16_t w, h;
  tft.getTextBounds(msg, 0, 0, &x1, &y1, &w, &h);
  int16_t x = (TFT_WIDTH - (int16_t)w) / 2 - x1;
  int16_t y = (TFT_HEIGHT - (int16_t)h) / 2 - y1;

  tft.setCursor(x, y);
  tft.print(msg);
}

// Updates the HUD, redrawing only the regions whose value changed since the last
// paint (tracked in g_shown). Pass forceFull=true to draw everything, e.g. right
// after blitting the background image. The static housing/border art lives in the
// background image now; live elements are either single-blit (compass, ammo) or
// clean overdraw (bars), so updates stay flicker-free.
void updateDisplay(const DebugInfo &info, bool forceFull) {
  if (forceFull || info.batteryPct != g_shown.batteryPct) {
    drawBatteryPct(info.batteryPct);
  }

  if (forceFull || info.batteryVolts != g_shown.batteryVolts) {
    drawBatteryVolts(info.batteryVolts);
  }

  if (forceFull || info.compassHeading != g_shown.compassHeading) {
    drawCompass(info.compassHeading);
  }

  if (forceFull ||
      info.loadTimerState != g_shown.loadTimerState ||
      info.loadTimerPct   != g_shown.loadTimerPct) {
    drawLoadBar(info.loadTimerState, info.loadTimerPct);
  }

  if (forceFull || info.ammoCount != g_shown.ammoCount) {
    drawAmmo(info.ammoCount);
    drawNoMag((uint16_t)info.ammoCount == AMMO_DISCONNECTED);
  }

  g_shown = info;
}

// ---------------------------------------------------------------------------
// HUD elements, ported from old_display.ino
// ---------------------------------------------------------------------------

// A thin 3px-tall bar (old_display battery style): a dim-teal track full width,
// with the leftmost pct% filled in fillColor. Redrawing the track first erases
// the previous fill, so no clear-to-black flash.
void drawThinBar(int16_t y, float widthFrac, int pct, uint16_t fillColor) {
  if (pct < 0) pct = 0;
  if (pct > 100) pct = 100;

  int barWidth = TFT_WIDTH * widthFrac;
  int inset = (TFT_WIDTH - barWidth) / 2;

  for (int i = 0; i < 3; i++) {
    tft.drawLine(inset, y + i, inset + barWidth, y + i, COLOR_DIM);
  }

  int fillWidth = barWidth * pct / 100;
  if (fillWidth > 0) {
    for (int i = 0; i < 3; i++) {
      tft.drawLine(inset, y + i, inset + fillWidth, y + i, fillColor);
    }
  }
}

// Small "NN%" battery readout at the bottom-left, in the bright accent color.
// Rendered into a canvas seeded with the background image so changing values
// don't leave ghosting and there's no color block behind the text.
void drawBatteryPct(int pct) {
  if (pct < 0) pct = 0;
  if (pct > 100) pct = 100;

  const int16_t cw = BATT_PCT_W;
  const int16_t ch = BATT_PCT_H;

  GFXcanvas16 c(cw, ch);
  uint16_t *buf = c.getBuffer();
  for (int16_t y = 0; y < ch; y++) {
    for (int16_t x = 0; x < cw; x++) {
      buf[y * cw + x] =
          pgm_read_word(&background[(BATT_PCT_Y + y) * BG_WIDTH + (BATT_PCT_X + x)]);
    }
  }

  char txt[8];
  snprintf(txt, sizeof(txt), "%d%%", pct);

  c.setFont();          // built-in 5x7 font
  c.setTextSize(1);
  c.setTextColor(COLOR_ACCENT);
  c.setCursor(0, (ch - 8) / 2);   // vertically center the smaller text
  c.print(txt);

  tft.drawRGBBitmap(BATT_PCT_X, BATT_PCT_Y, c.getBuffer(), cw, ch);
}

// Battery voltage readout at the bottom-right, mirroring the percentage. Right-
// aligned in its box, same accent color and small built-in font. Seeded from the
// background image so changing values leave no ghosting or color block.
void drawBatteryVolts(float volts) {
  const int16_t cw = BATT_V_W;
  const int16_t ch = BATT_V_H;

  GFXcanvas16 c(cw, ch);
  uint16_t *buf = c.getBuffer();
  for (int16_t y = 0; y < ch; y++) {
    for (int16_t x = 0; x < cw; x++) {
      buf[y * cw + x] =
          pgm_read_word(&background[(BATT_V_Y + y) * BG_WIDTH + (BATT_V_X + x)]);
    }
  }

  char txt[12];
  snprintf(txt, sizeof(txt), "%.1f", volts);

  c.setFont();          // built-in 5x7 font
  c.setTextSize(1);
  c.setTextColor(COLOR_ACCENT);

  int16_t x1, y1; uint16_t w, h;
  c.getTextBounds(txt, 0, 0, &x1, &y1, &w, &h);
  c.setCursor(cw - (int16_t)w - x1, (ch - 8) / 2);   // right-aligned
  c.print(txt);

  tft.drawRGBBitmap(BATT_V_X, BATT_V_Y, c.getBuffer(), cw, ch);
}

// "No Magazine" warning, red text centered just above the load bar. Drawn
// when the ammo sensor reports disconnected; otherwise the strip is repainted
// from the background image to clear it. Seeded from the background so there's no
// color block or ghosting.
void drawNoMag(bool show) {
  const int16_t cw = NOMAG_W;
  const int16_t ch = NOMAG_H;

  GFXcanvas16 c(cw, ch);
  uint16_t *buf = c.getBuffer();
  for (int16_t y = 0; y < ch; y++) {
    for (int16_t x = 0; x < cw; x++) {
      buf[y * cw + x] =
          pgm_read_word(&background[(NOMAG_Y + y) * BG_WIDTH + (NOMAG_X + x)]);
    }
  }

  if (show) {
    const char *msg = "No Magazine";
    c.setFont();        // built-in 5x7 font
    c.setTextSize(2);
    c.setTextColor(COLOR_RED);

    int16_t x1, y1; uint16_t w, h;
    c.getTextBounds(msg, 0, 0, &x1, &y1, &w, &h);
    int16_t tx = (cw - (int16_t)w) / 2 - x1;
    int16_t ty = (ch - (int16_t)h) / 2 - y1;
    c.setCursor(tx, ty);
    c.print(msg);
  }

  tft.drawRGBBitmap(NOMAG_X, NOMAG_Y, c.getBuffer(), cw, ch);
}

// Load timeout/cooldown bar (restyled into the thin-bar aesthetic): cyan while
// the load is on (timeout counting down), yellow during cooldown, empty track
// when ready.
void drawLoadBar(int state, int pct) {
  uint16_t clr;
  switch (state) {
    case LOAD_TIMER_ACTIVE:   clr = COLOR_ACCENT; break;
    case LOAD_TIMER_COOLDOWN: clr = COLOR_YELLOW; break;
    default:                  clr = COLOR_DIM; pct = 0; break;
  }
  drawThinBar(LOAD_BAR_Y, 0.50f, pct, clr);
}

// Compass arrow rendered into an off-screen canvas (centered on the dark area
// between the housing edges), then blitted once -- so the rotating arrow never
// flickers and we don't need to erase the previous frame.
void drawCompass(int heading) {
  const int16_t box = COMPASS_BOX;
  const int16_t topLeftX = COMPASS_CX - box / 2;
  const int16_t topLeftY = COMPASS_CY - box / 2;

  GFXcanvas16 c(box, box);
  // Clear to the solid background color baked behind the compass in bg.png so the
  // erase/redraw blends in and leaves no visible square.
  c.fillScreen(COLOR_HUD_BG);
  // Draw into local canvas coords; center is (box/2, box/2).
  drawCompassArrow(c, box / 2, box / 2, 30, 30, (float)heading, COLOR_ACCENT);
  tft.drawRGBBitmap(topLeftX, topLeftY, c.getBuffer(), box, box);
}

// Concave-base "compass arrow" (ported). Draws into the given canvas so the
// caller can blit it flicker-free.
void drawCompassArrow(GFXcanvas16 &c, int cx, int cy, int base, int height, float angle, uint16_t color) {
  float radians = angle * M_PI / 180.0;

  int baseWidth = base / 2;
  int arrowHeight = height * 1.5;
  int concaveDepth = base / 4;

  int x1 = cx;                                  // tip
  int y1 = cy - (arrowHeight / 2);
  int x2 = cx - baseWidth;                       // bottom left
  int y2 = cy + (arrowHeight / 2);
  int x3 = cx + baseWidth;                       // bottom right
  int y3 = cy + (arrowHeight / 2);
  int x4 = cx;                                   // concave center
  int y4 = cy + (arrowHeight / 2) - concaveDepth;

  int rx1 = ceil(cx + (x1 - cx) * cos(radians) - (y1 - cy) * sin(radians));
  int ry1 = ceil(cy + (x1 - cx) * sin(radians) + (y1 - cy) * cos(radians));
  int rx2 = ceil(cx + (x2 - cx) * cos(radians) - (y2 - cy) * sin(radians));
  int ry2 = ceil(cy + (x2 - cx) * sin(radians) + (y2 - cy) * cos(radians));
  int rx3 = ceil(cx + (x3 - cx) * cos(radians) - (y3 - cy) * sin(radians));
  int ry3 = ceil(cy + (x3 - cx) * sin(radians) + (y3 - cy) * cos(radians));
  int rx4 = ceil(cx + (x4 - cx) * cos(radians) - (y4 - cy) * sin(radians));
  int ry4 = ceil(cy + (x4 - cx) * sin(radians) + (y4 - cy) * cos(radians));

  // Thicken by drawing parallel offset copies.
  for (int i = -1; i <= 0; i++) {
    c.drawLine(rx1 + i, ry1 + i, rx2 + i, ry2 + i, color);
    c.drawLine(rx1 + i, ry1 + i, rx3 + i, ry3 + i, color);
    c.drawLine(rx2 + i, ry2 + i, rx4 + i, ry4 + i, color);
    c.drawLine(rx3 + i, ry3 + i, rx4 + i, ry4 + i, color);
  }
}

// Big ammo number drawn with the FreeSans24pt font into a canvas (seeded with the
// background image so there's no solid color block behind it) and blitted in one
// pass. Centered within the panel; the single blit keeps it flicker-free even at
// high pot update rates.
void drawAmmo(int ammo) {
  const int16_t cw = AMMO_PANEL_W;
  const int16_t ch = AMMO_PANEL_H;

  GFXcanvas16 c(cw, ch);
  // Seed the canvas with the matching region of the background image (instead of
  // a solid fill) so the number composites onto the artwork with no color block.
  uint16_t *buf = c.getBuffer();
  for (int16_t y = 0; y < ch; y++) {
    for (int16_t x = 0; x < cw; x++) {
      buf[y * cw + x] =
          pgm_read_word(&background[(AMMO_PANEL_Y + y) * BG_WIDTH + (AMMO_PANEL_X + x)]);
    }
  }

  char num[8];
  if ((uint16_t)ammo == AMMO_DISCONNECTED) {
    snprintf(num, sizeof(num), "--");          // sensor disconnected
  } else {
    snprintf(num, sizeof(num), "%02d", ammo);  // leading zero for single digits
  }

  c.setFont(&FreeSans24pt7b);
  c.setTextSize(2);

  int16_t x1, y1; uint16_t w, h;
  c.getTextBounds(num, 0, 0, &x1, &y1, &w, &h);
  int16_t cx = (cw - (int16_t)w) / 2 - x1;
  int16_t cy = (ch - (int16_t)h) / 2 - y1 - AMMO_Y_NUDGE;

  c.setTextColor(COLOR_ACCENT);
  c.setCursor(cx, cy);
  c.print(num);

  tft.drawRGBBitmap(AMMO_PANEL_X, AMMO_PANEL_Y, c.getBuffer(), cw, ch);
}

// ---------------------------------------------------------------------------
// Wiring summary
//
//   Display pin   QT Py pad        Notes
//   -----------   --------------   -------------------------------------------
//   VCC           3V               3.3V power
//   GND           GND              ground
//   DIN (MOSI)    MO               SPI data, fixed hardware pin
//   CLK (SCLK)    SCK              SPI clock, fixed hardware pin
//   CS            A0               chip select (any GPIO; matches TFT_CS)
//   DC            A1               data/command (any GPIO; matches TFT_DC)
//   RST           A2               reset (any GPIO; or tie to 3V + set -1)
//   BL            3V               backlight on; route to a GPIO for dimming
//
// I2C bus to the master (this device is the slave at I2C_ADDR), via the
// STEMMA QT connector = Wire1 (SDA1/SCL1), NOT the SDA/SCL breakout pads:
//   STEMMA SDA    SDA1 (Wire1)     data  (add a pull-up if the bus lacks one)
//   STEMMA SCL    SCL1 (Wire1)     clock (add a pull-up if the bus lacks one)
//   common GND between master and this board is required.
//
// If you instead wire to the SDA/SCL castellated pads, change I2C_BUS to Wire
// and I2C_SDA/I2C_SCL to SDA/SCL.
// ---------------------------------------------------------------------------
