refactor(display): extract tile layout logic to library helper class

This commit is contained in:
2026-02-18 11:43:46 -08:00
parent 67613120ad
commit 1961631e2c
11 changed files with 244 additions and 182 deletions

View File

@@ -1,5 +1,8 @@
# Doorbell Touch - Build Harness
## Session Handoff Note
⚠️ ALWAYS check `mise.toml` for available build tasks before compiling/uploading. Run `mise tasks` to list.
## Quick Commands
```bash
BOARD=esp32-32e-4 mise run compile # Compile

View File

@@ -1,4 +1,7 @@
#include "DisplayDriverTFT.h"
#include <KlubhausCore.h>
extern DisplayManager display;
void DisplayDriverTFT::begin() {
// Backlight
@@ -134,25 +137,19 @@ void DisplayDriverTFT::drawDashboard(const ScreenState& st) {
_tft.setCursor(DISPLAY_WIDTH - 50, 5);
_tft.printf("WiFi:%s", st.wifiSsid.length() > 0 ? "ON" : "OFF");
// Render tiles via DisplayManager (abstracted to library)
// For now, draw directly using our tile implementation
constexpr int cols = 2;
constexpr int rows = 2;
constexpr int tileW = DISPLAY_WIDTH / cols;
constexpr int tileH = (DISPLAY_HEIGHT - 30) / rows;
constexpr int margin = 8;
// Get tile layouts from library helper
int tileCount = display.calculateDashboardLayouts(30, 8);
const TileLayout* layouts = display.getTileLayouts();
const char* tileLabels[] = { "Alert", "Silent", "Status", "Reboot" };
const uint16_t tileColors[] = { 0x0280, 0x0400, 0x0440, 0x0100 };
for(int i = 0; i < 4; i++) {
int col = i % cols;
int row = i / cols;
int x = col * tileW + margin;
int y = 30 + row * tileH + margin;
int w = tileW - 2 * margin;
int h = tileH - 2 * margin;
for(int i = 0; i < tileCount && i < 4; i++) {
const TileLayout& lay = layouts[i];
int x = lay.x;
int y = lay.y;
int w = lay.w;
int h = lay.h;
// Tile background
_tft.fillRoundRect(x, y, w, h, 8, tileColors[i]);
@@ -180,7 +177,6 @@ TouchEvent DisplayDriverTFT::readTouch() {
evt.pressed = true;
evt.x = tx;
evt.y = ty;
Serial.printf("[TOUCH] x=%d, y=%d\n", tx, ty);
}
return evt;
}
@@ -196,22 +192,6 @@ void DisplayDriverTFT::transformTouch(int* x, int* y) {
*y = temp;
}
void DisplayDriverTFT::drawTileAt(int x, int y, int w, int h, const char* label, uint16_t bgColor) {
// Tile background
_tft.fillRoundRect(x, y, w, h, 8, bgColor);
// Tile border
_tft.drawRoundRect(x, y, w, h, 8, TFT_WHITE);
// Tile label
_tft.setTextColor(TFT_WHITE);
_tft.setTextSize(2);
int textLen = strlen(label);
int textW = textLen * 12; // approx width
_tft.setCursor(x + w/2 - textW/2, y + h/2 - 10);
_tft.print(label);
}
HoldState DisplayDriverTFT::updateHold(unsigned long holdMs) {
HoldState h;
TouchEvent t = readTouch();

View File

@@ -14,12 +14,11 @@ public:
uint16_t getRawTouchZ();
HoldState updateHold(unsigned long holdMs) override;
void updateHint(int x, int y, bool active) override;
int width() override { return DISPLAY_WIDTH; }
int height() override { return DISPLAY_HEIGHT; }
int width() override { return _tft.width(); }
int height() override { return _tft.height(); }
// Dashboard tiles - library handles grid math, we just draw
// Dashboard - uses transform for touch coordinate correction
void transformTouch(int* x, int* y) override;
void drawTileAt(int x, int y, int w, int h, const char* label, uint16_t bgColor) override;
private:
void drawBoot(const ScreenState& st);

View File

@@ -24,11 +24,6 @@ void loop() {
// Read touch
TouchEvent evt = display.readTouch();
// Touch debug (useful for new boards)
if(evt.pressed) {
Serial.printf("[TOUCH] pressed: x=%d, y=%d\n", evt.x, evt.y);
}
// State machine tick
logic.update();

View File

@@ -5,9 +5,11 @@
#include "board_config.h"
#include <Arduino.h>
#include <KlubhausCore.h>
// ── Globals ──
static LGFX* _gfx = nullptr;
extern DisplayManager display;
// ── Forward declarations ──
static void initDisplay();
@@ -52,9 +54,9 @@ void DisplayDriverGFX::setBacklight(bool on) {
}
}
int DisplayDriverGFX::width() { return DISP_W; }
int DisplayDriverGFX::width() { return _gfx ? _gfx->width() : DISP_W; }
int DisplayDriverGFX::height() { return DISP_H; }
int DisplayDriverGFX::height() { return _gfx ? _gfx->height() : DISP_H; }
// ── Touch handling ──
@@ -96,22 +98,6 @@ int DisplayDriverGFX::dashboardTouch(int x, int y) {
return row * cols + col;
}
void DisplayDriverGFX::drawTileAt(int x, int y, int w, int h, const char* label, uint16_t bgColor) {
// Tile background (use fillRect - LovyanGFX may not have fillRoundRect)
_gfx->fillRect(x, y, w, h, bgColor);
// Tile border
_gfx->drawRect(x, y, w, h, 0xFFFF);
// Tile label
_gfx->setTextColor(0xFFFF);
_gfx->setTextSize(2);
int textLen = strlen(label);
int textW = textLen * 12;
_gfx->setCursor(x + w/2 - textW/2, y + h/2 - 10);
_gfx->print(label);
}
HoldState DisplayDriverGFX::updateHold(unsigned long holdMs) {
HoldState state;
@@ -256,24 +242,18 @@ void DisplayDriverGFX::drawDashboard(const ScreenState& state) {
_gfx->setCursor(DISP_W - 100, 10);
_gfx->printf("WiFi:%s", state.wifiSsid.length() > 0 ? "ON" : "OFF");
// Tiles: 2 rows × 4 columns
constexpr int cols = 4;
constexpr int rows = 2;
constexpr int tileW = DISP_W / cols;
constexpr int tileH = (DISP_H - 30) / rows;
constexpr int margin = 8;
// Get tile layouts from library helper
int tileCount = display.calculateDashboardLayouts(30, 8);
const TileLayout* layouts = display.getTileLayouts();
// Draw placeholder tiles (8 total for 2x4 grid)
const char* tileLabels[] = { "1", "2", "3", "4", "5", "6", "7", "8" };
for(int i = 0; i < 8; i++) {
int col = i % cols;
int row = i / cols;
int x = col * tileW + margin;
int y = 30 + row * tileH + margin;
int w = tileW - 2 * margin;
int h = tileH - 2 * margin;
for(int i = 0; i < tileCount && i < 8; i++) {
const TileLayout& lay = layouts[i];
int x = lay.x;
int y = lay.y;
int w = lay.w;
int h = lay.h;
// Tile background
_gfx->fillRoundRect(x, y, w, h, 8, 0x0220);
@@ -281,10 +261,10 @@ void DisplayDriverGFX::drawDashboard(const ScreenState& state) {
// Tile border
_gfx->drawRoundRect(x, y, w, h, 8, 0xFFFF);
// Tile number
// Tile label
_gfx->setTextColor(0xFFFF);
_gfx->setTextSize(2);
_gfx->setCursor(x + w / 2 - 10, y + h / 2 - 10);
_gfx->setCursor(x + w/2 - 10, y + h/2 - 10);
_gfx->print(tileLabels[i]);
}
}

View File

@@ -18,9 +18,6 @@ public:
int width() override;
int height() override;
// Dashboard tiles - library handles grid math, we just draw
void drawTileAt(int x, int y, int w, int h, const char* label, uint16_t bgColor) override;
// ── Internal ──
static DisplayDriverGFX& instance();

View File

@@ -2,6 +2,117 @@
#include "IDisplayDriver.h"
#include "ScreenState.h"
#include <cmath>
/// Layout helper for dashboard tiles - computes positions based on constraints
class TileLayoutHelper {
public:
/// Calculate tile layouts for the given display dimensions
/// Returns array of TileLayout (must have capacity for DASHBOARD_TILE_COUNT)
/// Returns the number of columns and rows used
static int calculateLayouts(
int displayW,
int displayH,
int headerH,
int margin,
TileLayout* outLayouts,
int* outCols,
int* outRows
) {
int contentH = displayH - headerH;
int tileCount = DASHBOARD_TILE_COUNT;
// Determine base grid based on display aspect ratio
int cols, rows;
calculateGrid(tileCount, displayW, contentH, &cols, &rows);
// Calculate base cell sizes
int cellW = displayW / cols;
int cellH = contentH / rows;
// Simple first-fit: place tiles in order, respecting min sizes
// For more complex layouts, tiles could specify preferred positions
for(int i = 0; i < tileCount; i++) {
const DashboardTile& tile = DASHBOARD_TILES[i];
int tileCols = tile.constraint.minCols;
int tileRows = tile.constraint.minRows;
// Find next available position
int col = 0, row = 0;
findNextPosition(outLayouts, i, cols, rows, &col, &row);
// Ensure tile fits within grid
if(col + tileCols > cols) tileCols = cols - col;
if(row + tileRows > rows) tileRows = rows - row;
// Calculate pixel position
int x = col * cellW + margin;
int y = headerH + row * cellH + margin;
int w = tileCols * cellW - 2 * margin;
int h = tileRows * cellH - 2 * margin;
outLayouts[i] = {x, y, w, h, col, row, tileCols, tileRows};
}
*outCols = cols;
*outRows = rows;
return tileCount;
}
private:
/// Calculate optimal grid dimensions based on display and tile constraints
static void calculateGrid(int tileCount, int displayW, int contentH, int* outCols, int* outRows) {
// Calculate aspect ratio to determine preferred layout
float aspectRatio = (float)displayW / contentH;
// Start with simple square-ish grid
int cols = (int)std::sqrt(tileCount * aspectRatio);
if(cols < 1) cols = 1;
if(cols > tileCount) cols = tileCount;
int rows = (tileCount + cols - 1) / cols;
// For wide displays (landscape), prefer more columns
if(aspectRatio > 1.5f && tileCount <= 6) {
cols = tileCount;
rows = 1;
}
// For tall displays (portrait), prefer more rows
else if(aspectRatio < 0.8f && tileCount <= 6) {
rows = tileCount;
cols = 1;
}
*outCols = cols;
*outRows = rows;
}
/// Find next available grid position
static void findNextPosition(const TileLayout* layouts, int count, int gridCols, int gridRows, int* outCol, int* outRow) {
// Simple: find first empty cell
// Could be enhanced to pack tightly based on tile sizes
for(int r = 0; r < gridRows; r++) {
for(int c = 0; c < gridCols; c++) {
bool occupied = false;
for(int i = 0; i < count; i++) {
if(layouts[i].col <= c && c < layouts[i].col + layouts[i].cols &&
layouts[i].row <= r && r < layouts[i].row + layouts[i].rows) {
occupied = true;
break;
}
}
if(!occupied) {
*outCol = c;
*outRow = r;
return;
}
}
}
// Fallback: just use count position
*outCol = count % gridCols;
*outRow = count / gridCols;
}
};
class DisplayManager {
public:
@@ -19,8 +130,8 @@ public:
}
void render(const ScreenState& st) {
if(_drv)
_drv->render(st);
if(!_drv) return;
_drv->render(st);
}
TouchEvent readTouch() { return _drv ? _drv->readTouch() : TouchEvent {}; }
@@ -35,75 +146,6 @@ public:
int width() { return _drv ? _drv->width() : 0; }
int height() { return _drv ? _drv->height() : 0; }
/// Auto-calculate grid dimensions for dashboard tiles
/// Prefers landscape (more columns than rows) for wide displays
void getTileGrid(int tileCount, int* outRows, int* outCols) const {
if(tileCount <= 0) {
*outRows = *outCols = 0;
return;
}
// Calculate cols: try to make it wider than tall
int cols = (int)sqrt(tileCount);
if(cols * cols < tileCount) cols++;
// Make cols >= rows for landscape preference
while(cols * ((tileCount + cols - 1) / cols) < tileCount) {
cols++;
}
int rows = (tileCount + cols - 1) / cols;
// If display is wider, prefer more columns
if(_drv) {
int w = _drv->width();
int h = _drv->height();
if(w > h) {
// Landscape display - maximize columns
cols = tileCount;
rows = 1;
}
}
*outRows = rows;
*outCols = cols;
}
/// Render all dashboard tiles with auto-calculated grid
void renderDashboard(const ScreenState& st) {
if(!_drv) return;
int rows, cols;
getTileGrid(DASHBOARD_TILE_COUNT, &rows, &cols);
int dispW = _drv->width();
int dispH = _drv->height();
// Reserve space for header
int headerH = 30;
int contentH = dispH - headerH;
// Calculate tile sizes
int tileW = dispW / cols;
int tileH = contentH / rows;
int margin = 8;
for(int i = 0; i < DASHBOARD_TILE_COUNT; i++) {
int row = i / cols;
int col = i % cols;
int x = col * tileW + margin;
int y = headerH + row * tileH + margin;
int w = tileW - 2 * margin;
int h = tileH - 2 * margin;
_drv->drawTileAt(x, y, w, h, DASHBOARD_TILES[i].label, DASHBOARD_TILES[i].bgColor);
}
// Store grid for touch calculations
_gridRows = rows;
_gridCols = cols;
}
/// Handle dashboard touch - returns action for tapped tile, or NONE
TileAction handleDashboardTouch(int x, int y) const {
if(!_drv || _gridCols <= 0) return TileAction::NONE;
@@ -118,29 +160,56 @@ public:
// Check if in header area
if(y < headerH) return TileAction::NONE;
// Calculate which tile was touched
int tileW = dispW / _gridCols;
int contentH = dispH - headerH;
int tileH = contentH / _gridRows;
// Calculate which tile was touched using grid
int cellW = dispW / _gridCols;
int cellH = (dispH - headerH) / _gridRows;
int col = x / tileW;
int row = (y - headerH) / tileH;
int col = x / cellW;
int row = (y - headerH) / cellH;
// Bounds check
if(col < 0 || col >= _gridCols || row < 0 || row >= _gridRows) {
return TileAction::NONE;
}
int index = row * _gridCols + col;
if(index < 0 || index >= DASHBOARD_TILE_COUNT) {
return TileAction::NONE;
// Find which tile occupies this cell
for(int i = 0; i < _tileCount; i++) {
const TileLayout& layout = _layouts[i];
if(layout.col <= col && col < layout.col + layout.cols &&
layout.row <= row && row < layout.row + layout.rows) {
return DASHBOARD_TILES[i].action;
}
}
return DASHBOARD_TILES[index].action;
return TileAction::NONE;
}
/// Calculate and store layouts for dashboard tiles
/// Called by drivers who want to use the layout helper
int calculateDashboardLayouts(int headerH = 30, int margin = 8) {
if(!_drv) return 0;
_tileCount = TileLayoutHelper::calculateLayouts(
_drv->width(),
_drv->height(),
headerH,
margin,
_layouts,
&_gridCols,
&_gridRows
);
return _tileCount;
}
/// Get calculated layout for a specific tile
const TileLayout* getTileLayouts() const { return _layouts; }
int getGridCols() const { return _gridCols; }
int getGridRows() const { return _gridRows; }
private:
IDisplayDriver* _drv;
mutable int _gridRows = 0;
mutable int _gridCols = 0;
TileLayout _layouts[DASHBOARD_TILE_COUNT];
int _tileCount = 0;
int _gridCols = 0;
int _gridRows = 0;
};

View File

@@ -15,6 +15,7 @@ struct HoldState {
};
/// Abstract display driver — implemented per-board.
/// Drivers own all rendering. Library provides tile layout helper via TileLayoutHelper.
class IDisplayDriver {
public:
virtual ~IDisplayDriver() = default;
@@ -27,17 +28,11 @@ public:
// ── Touch ──
virtual TouchEvent readTouch() = 0;
/// Track a long-press gesture; returns progress/completion.
virtual HoldState updateHold(unsigned long holdMs) = 0;
/// Idle hint animation (e.g. pulsing ring) while alert is showing.
/// @param active If true, show "holding" animation; if false, show "idle" animation.
virtual void updateHint(int x, int y, bool active) = 0;
virtual int width() = 0;
virtual int height() = 0;
// ── Dashboard tiles ──
/// Transform raw touch coordinates (for rotated touch panels)
// ── Touch transform (for rotated panels) ──
virtual void transformTouch(int* x, int* y) { /* default: no transform */ }
/// Draw a tile at specified position (library handles grid math)
virtual void drawTileAt(int x, int y, int w, int h, const char* label, uint16_t bgColor) = 0;
};

View File

@@ -16,19 +16,35 @@ enum class TileAction {
REBOOT, // Reboot device
};
/// Dashboard tile layout constraints for flexible grid
struct TileConstraint {
uint8_t minCols = 1; // minimum columns this tile needs
uint8_t minRows = 1; // minimum rows this tile needs
uint8_t weight = 1; // priority for growing/shrinking (higher = more flexible)
};
/// Computed tile position in the grid
struct TileLayout {
int x, y; // pixel position
int w, h; // pixel size
int col, row; // grid position
int cols, rows; // grid span
};
/// Dashboard tile definitions — shared across all boards
struct DashboardTile {
const char* label;
uint16_t bgColor; // RGB565
TileAction action;
TileConstraint constraint;
};
/// Standard dashboard tiles (auto-gridded based on count)
/// Standard dashboard tiles (auto-gridded based on count and constraints)
static constexpr DashboardTile DASHBOARD_TILES[] = {
{ "Alert", 0x0280, TileAction::ALERT },
{ "Silent", 0x0400, TileAction::SILENCE },
{ "Status", 0x0440, TileAction::STATUS },
{ "Reboot", 0x0100, TileAction::REBOOT },
{ "Alert", 0x0280, TileAction::ALERT, {1, 1, 1} },
{ "Silent", 0x0400, TileAction::SILENCE, {1, 1, 1} },
{ "Status", 0x0440, TileAction::STATUS, {1, 1, 1} },
{ "Reboot", 0x0100, TileAction::REBOOT, {1, 1, 1} },
};
static constexpr int DASHBOARD_TILE_COUNT = sizeof(DASHBOARD_TILES) / sizeof(DASHBOARD_TILES[0]);

View File

@@ -27,14 +27,27 @@ arduino-cli compile --fqbn "$FQBN" --libraries ./libraries $LIBS --build-propert
[tasks.upload]
description = "Upload (uses BOARD env var)"
depends = ["compile"]
run = """
# Kill any processes using the serial port first
source ./boards/$BOARD/board-config.sh
PORT="${PORT:-$PORT}"
fuser -k "$PORT" 2>/dev/null || true
for pid in $(pgrep -f "monitor-agent.py" 2>/dev/null || true); do
kill "$pid" 2>/dev/null || true
done
rm -f "/tmp/doorbell-${BOARD}.lock" 2>/dev/null || true
sleep 1
source ./scripts/lockfile.sh
FORCE=1 TASK_NAME=upload acquire_lock || exit 1
PORT="${PORT:-$PORT}"
arduino-cli compile --fqbn "$FQBN" --libraries ./libraries $LIBS --build-property "compiler.cpp.extra_flags=$OPTS" --warnings default ./boards/$BOARD && \
arduino-cli upload --fqbn "$FQBN" --port "$PORT" ./boards/$BOARD
# Restart monitor in background
python3 ./scripts/monitor-agent.py "$BOARD" &
echo "[OK] Monitor restarted in background"
"""
[tasks.monitor-raw]
@@ -62,10 +75,25 @@ tio --map INLCRNL "$TARGET" -e
[tasks.kill]
description = "Kill running monitor/upload for BOARD"
run = """
source ./scripts/lockfile.sh
kill_locked
"""
run = '''
set +e
# Kill any processes using the serial port
source ./boards/$BOARD/board-config.sh
PORT="${PORT:-$PORT}"
fuser -k "$PORT" 2>/dev/null
# Kill monitor-agent processes for this board
for pid in $(pgrep -f "monitor-agent.py"); do
kill "$pid" 2>/dev/null
done
# Also clean up lockfile
rm -f "/tmp/doorbell-${BOARD}.lock" 2>/dev/null
sleep 1
echo "[OK] Killed processes for $BOARD"
exit 0
'''
[tasks.monitor]
description = "Monitor agent with JSON log + command pipe (Python-based)"

View File

@@ -37,8 +37,8 @@ release_lock() {
kill_locked() {
if [ ! -f "$LOCKFILE" ]; then
echo "No lockfile found: $LOCKFILE"
return 1
echo "No lockfile found: $LOCKFILE (nothing to kill)"
return 0
fi
OLD_PID=$(cat "$LOCKFILE" 2>/dev/null)