feat(display): mini forecast icon + Wi-Fi IP, BGR colour fix

Rework the 160x80 status screen: large predicted-weather icon in the
right window (scaled to fill), Wi-Fi/IP line at the bottom (or 'offline'),
colour-coded forecast category, and the trend arrow shown only when
rising/falling. Colours are built R/B-swapped for this BGR ST7735S panel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 23:05:13 +07:00
co-authored by Claude Opus 4.8
parent f1fe0b728f
commit 2e3af80188
+90 -35
View File
@@ -1,18 +1,41 @@
#include <Adafruit_GFX.h> #include <Adafruit_GFX.h>
#include <Adafruit_ST7735.h> #include <Adafruit_ST7735.h>
#include <SPI.h> #include <SPI.h>
#include <math.h>
#include "config.h" #include "config.h"
#include "display_ui.h" #include "display_ui.h"
#include "net.h"
// Non-static so displayRender() can reference it via extern. // Non-static so displayRender() can reference it via extern.
Adafruit_ST7735 tft = Adafruit_ST7735(TFT_CS, TFT_DC, TFT_RST); Adafruit_ST7735 tft = Adafruit_ST7735(TFT_CS, TFT_DC, TFT_RST);
// This 0.96" ST7735S panel is wired BGR (red/blue swapped), so build every
// colour with R and B exchanged and it displays as intended.
static uint16_t rgb(uint8_t r, uint8_t g, uint8_t b) { return tft.color565(b, g, r); }
static uint16_t C_MUTED, C_FRAME, C_LINE, C_SUN, C_CLOUD;
static uint16_t catColor(WxCategory c) {
switch (c) {
case WX_FINE: return rgb(143,190,138); // fine green
case WX_FAIR: return rgb(228,182,88); // amber
case WX_CHANGEABLE: return rgb(121,194,208); // mercury
case WX_RAIN: return rgb(92,134,214); // blue
case WX_STORM: return rgb(228,87,59); // vermilion
default: return C_MUTED;
}
}
void displayBegin() { void displayBegin() {
pinMode(TFT_BLK, OUTPUT); pinMode(TFT_BLK, OUTPUT);
digitalWrite(TFT_BLK, HIGH); // backlight on digitalWrite(TFT_BLK, HIGH); // backlight on
tft.initR(INITR_MINI160x80); // 0.96" ST7735S tft.initR(INITR_MINI160x80); // 0.96" ST7735S
tft.invertDisplay(false); // this panel: no inversion tft.invertDisplay(false); // this panel: no inversion
tft.setRotation(3); // landscape, 160 wide x 80 tall tft.setRotation(3); // landscape, 160 wide x 80 tall
C_MUTED = rgb(140,160,190);
C_FRAME = rgb(42,58,90);
C_LINE = rgb(121,194,208); // mercury cyan
C_SUN = rgb(240,200,70);
C_CLOUD = rgb(224,232,240);
tft.fillScreen(ST77XX_BLACK); tft.fillScreen(ST77XX_BLACK);
} }
@@ -27,33 +50,50 @@ void displaySplash(const char* line1, const char* line2) {
tft.print(line2); tft.print(line2);
} }
// Simple 34x34 weather icon at (x, y). // Weather icon scaled into a W x H box at (x,y).
static void drawIcon(WxCategory c, int x, int y) { static void drawIcon(WxCategory c, int x, int y, int W, int H) {
uint16_t sun = ST77XX_YELLOW; int cx = x + W / 2, cy = y + H / 2;
uint16_t cloud = ST77XX_WHITE; int m = (W < H ? W : H);
uint16_t rain = ST77XX_CYAN;
switch (c) { switch (c) {
case WX_FINE: case WX_FINE: {
tft.fillCircle(x + 17, y + 17, 11, sun); int r = (int)(m * 0.30f);
tft.fillCircle(cx, cy, r, C_SUN);
for (int k = 0; k < 8; k++) {
float a = k * 0.7853982f; // 45 deg steps
int x0 = cx + (int)((r + 4) * cosf(a)), y0 = cy + (int)((r + 4) * sinf(a));
int x1 = cx + (int)((r + 11) * cosf(a)), y1 = cy + (int)((r + 11) * sinf(a));
tft.drawLine(x0, y0, x1, y1, C_SUN);
}
break; break;
}
case WX_FAIR: case WX_FAIR:
tft.fillCircle(x + 12, y + 12, 8, sun); tft.fillCircle(x + (int)(W * 0.34f), y + (int)(H * 0.34f), (int)(m * 0.24f), C_SUN);
tft.fillRoundRect(x + 8, y + 18, 24, 12, 6, cloud); tft.fillRoundRect(x + (int)(W * 0.12f), y + (int)(H * 0.48f),
(int)(W * 0.76f), (int)(H * 0.40f), (int)(H * 0.20f), C_CLOUD);
break; break;
case WX_CHANGEABLE: case WX_CHANGEABLE:
tft.fillRoundRect(x + 4, y + 12, 26, 14, 7, cloud); tft.fillRoundRect(x + (int)(W * 0.06f), y + (int)(H * 0.28f),
(int)(W * 0.88f), (int)(H * 0.46f), (int)(H * 0.23f), C_CLOUD);
break; break;
case WX_RAIN: case WX_RAIN: {
tft.fillRoundRect(x + 4, y + 8, 26, 14, 7, cloud); tft.fillRoundRect(x + (int)(W * 0.06f), y + (int)(H * 0.14f),
for (int i = 0; i < 3; i++) (int)(W * 0.88f), (int)(H * 0.40f), (int)(H * 0.20f), C_CLOUD);
tft.drawFastVLine(x + 9 + i * 8, y + 24, 8, rain); int ry = y + (int)(H * 0.60f);
for (int i = 0; i < 5; i++)
tft.drawFastVLine(x + (int)(W * 0.18f) + i * (int)(W * 0.16f), ry, (int)(H * 0.26f), C_LINE);
break; break;
case WX_STORM: }
tft.fillRoundRect(x + 4, y + 8, 26, 14, 7, cloud); case WX_STORM: {
tft.fillTriangle(x + 16, y + 22, x + 12, y + 32, x + 20, y + 30, ST77XX_YELLOW); tft.fillRoundRect(x + (int)(W * 0.06f), y + (int)(H * 0.14f),
(int)(W * 0.88f), (int)(H * 0.40f), (int)(H * 0.20f), C_CLOUD);
int by = y + (int)(H * 0.58f);
tft.fillTriangle(cx, by, cx - 8, by + (int)(H * 0.30f), cx + 9, by + (int)(H * 0.22f), C_SUN);
break; break;
}
default: default:
tft.drawRect(x + 4, y + 10, 26, 18, cloud); // unknown / collecting tft.drawRoundRect(x + 2, y + (int)(H * 0.2f), W - 4, (int)(H * 0.55f), 8, C_FRAME);
tft.setTextSize(2); tft.setTextColor(C_MUTED);
tft.setCursor(cx - 6, cy - 8); tft.print("?");
break; break;
} }
} }
@@ -67,36 +107,51 @@ void displayRender(const AppState& s) {
// Time (large), top-left // Time (large), top-left
snprintf(buf, sizeof(buf), "%02u:%02u", s.now.hour, s.now.minute); snprintf(buf, sizeof(buf), "%02u:%02u", s.now.hour, s.now.minute);
tft.setTextSize(2); tft.setTextSize(2);
tft.setCursor(4, 4); tft.setCursor(2, 2);
tft.print(buf); tft.print(buf);
// Icon, top-right
drawIcon(s.haveTrend ? s.forecast.category : WX_UNKNOWN, 120, 4);
// Date // Date
snprintf(buf, sizeof(buf), "%04u-%02u-%02u", s.now.year, s.now.month, s.now.day);
tft.setTextSize(1); tft.setTextSize(1);
tft.setCursor(4, 26); snprintf(buf, sizeof(buf), "%04u-%02u-%02u", s.now.year, s.now.month, s.now.day);
tft.setCursor(2, 22);
tft.print(buf); tft.print(buf);
// Pressure + trend arrow // Pressure + trend arrow (arrow only when rising/falling)
snprintf(buf, sizeof(buf), "%.0f hPa", s.mslHpa); snprintf(buf, sizeof(buf), "%.0f hPa", s.mslHpa);
tft.setCursor(4, 40); tft.setCursor(2, 34);
tft.print(buf); tft.print(buf);
const char* arrow = (s.trend == TREND_RISING) ? "^" : (s.trend == TREND_FALLING ? "v" : "="); if (s.trend != TREND_STEADY) {
tft.setCursor(96, 40); tft.setTextColor(C_LINE);
tft.setTextColor(ST77XX_CYAN); tft.setCursor(80, 34);
tft.print(arrow); tft.print(s.trend == TREND_RISING ? "^" : "v");
tft.setTextColor(ST77XX_WHITE); tft.setTextColor(ST77XX_WHITE);
}
// Temperature // Temperature
snprintf(buf, sizeof(buf), "%.1f C", s.tempC); snprintf(buf, sizeof(buf), "%.1f C", s.tempC);
tft.setCursor(4, 52); tft.setCursor(2, 46);
tft.print(buf); tft.print(buf);
// Forecast short label (full width, bottom row) // Predicted-weather icon, top-right (fills to just above the divider)
tft.setTextColor(ST77XX_YELLOW); drawIcon(s.haveTrend ? s.forecast.category : WX_UNKNOWN, 78, 2, 78, 61);
tft.setCursor(4, 66);
// Forecast category (colour-coded)
tft.setTextSize(1);
tft.setTextColor(s.haveTrend ? catColor(s.forecast.category) : C_MUTED);
tft.setCursor(2, 56);
tft.print(s.haveTrend ? categoryShort(s.forecast.category) : "Collecting..."); tft.print(s.haveTrend ? categoryShort(s.forecast.category) : "Collecting...");
// Bottom strip: divider + Wi-Fi / IP
tft.drawFastHLine(0, 65, 160, C_FRAME);
tft.setTextSize(1);
if (netConnected()) {
tft.setTextColor(C_LINE);
tft.setCursor(2, 69);
tft.print(netIP());
} else {
tft.setTextColor(C_MUTED);
tft.setCursor(2, 69);
tft.print("WiFi: offline");
}
tft.setTextColor(ST77XX_WHITE); tft.setTextColor(ST77XX_WHITE);
} }