Daily briefing brick

A paper-slip morning brick: date, weather, to-dos, optional calendar. Short BOOT pages; hold BOOT rotates.

Daily briefing brick
DifficultyIntermediate
Build time1h 20m
Est. cost$20
Parts4
Files5
Steps4

Things you'll need

QtyPartType
1
Waveshare ESP32-C6-LCD-1.47 (silhouette slab)
Waveshare ESP32-C6-LCD-1.47 (silhouette slab) 172×320 ST7789, GPIO8 WS2812, TF slot, BOOT/RESET. No touch or IMU. Amazon B0DK5J6LX3.
electronics
1
Paper-brick housing
Paper-brick housing Cream slab that reads as a stacked paper brick. Print in natural PLA.
printed
1
USB-C cable Data-capable cable for flash and power.
electronics
1
PLA or PETG About 12–20 g for the snap shells.
consumable

Tools & software

1
PlatformIO or Arduino IDE Flash over USB-C. Serial 115200.

Files & downloads

Download all

Flash writes a precompiled image over USB-C in Chrome or Edge. The .ino below is the source — you do not need Arduino IDE unless you want to change the sketch.

What you're building

A morning paper brick: date, weather, to-dos, and an optional calendar. The screen is too small for a whole newspaper, so the brick pages. Short BOOT is next page. Hold BOOT rotates. Every 8 seconds it advances while things are working.

RGB: warm white while fetching, green when ready, red on error. You will use that as a status light while you debug Wi-Fi.

What you will learn

  • How a device learns the date from the internet (NTP).
  • How a weather API returns a week of highs and lows as JSON.
  • Why API keys are secrets, and why the published key is a fake.
  • How to page content when the screen cannot hold everything.
  • How a program refuses to join a placeholder Wi-Fi name.

Meet the silhouette slab

This project uses the Waveshare ESP32-C6-LCD-1.47 — a slim rectangle with a 1.47 inch screen.

  • A 172×320 color screen. You can turn it portrait or landscape in software.
  • No touch. No motion sensor. Interaction is the BOOT button (a short press vs a ~0.7 second hold).
  • A single RGB LED on the board (GPIO8) that can glow any color.
  • Wi-Fi that is 2.4 GHz only.
  • Keep the backlight at 50%. Full brightness can leave a heat spot on the panel.

The program is a .ino file (Arduino-style C++). You will not memorize it. You will learn the ideas, then see the small piece of code that does each job.

Note

Word to know — GPIO. It means “general purpose input/output” — a pin the chip can turn on or read. BOOT is GPIO9. The RGB LED is GPIO8. You do not rewire anything; the board already connected those pins.

The finished gadget

Daily briefing brick
Daily briefing brick on Silhouette slab (ESP32-C6).

Build it

1

Print the brick

Natural or cream PLA, 0.2 mm. It should look like stacked paper, not a robot.

Print the brick
2

Fill in Wi-Fi (needed for weather and time)

Replace YOUR_WIFI_SSID_HERE / YOUR_WIFI_PASSWORD_HERE with a 2.4 GHz network. Set WEATHER_LAT / WEATHER_LON and the timezone string. Leave the Google key as a placeholder unless a teacher sets up a classroom key.

3

Flash the slab

Flash means copy the program onto the chip. Easiest path: use the attached daily-briefing.bin with the on-page Flash to board button (Chrome or Edge, USB-C). To change the code later: Arduino IDE → ESP32C6 Dev Module, USB CDC On Boot, 115200 — or unzip platformio.zip next to the .ino and run python3 -m platformio run -t upload. Keep the backlight at 50%. This slab has no touch screen and no motion sensor — you press BOOT (the button on GPIO9) to interact.

Flash the slab
4

Read the morning

Short BOOT pages DATE → WEATHER → TO DO → CALENDAR. Hold BOOT rotates. refresh on serial asks the internet again.

Read the morning
Warning

The published sketch will not join Wi-Fi until you edit the two strings. That is a safety feature, not a bug.

Lesson 1 — The internet knows what day it is

Phones look effortless. This slab has to ask. After Wi-Fi works, it calls NTP (pool.ntp.org) and applies TZ_POSIX (a timezone recipe). Then “Monday” and “Sep 18” are just formatting that date into text.

If Wi-Fi fails, there is no honest date. The error banner is the program telling you the truth.

Lesson 2 — A week of weather is an array

Open-Meteo can send daily highs, lows, and weather codes — not just “now.” JSON arrays are lists: temperature_2m_max[0] is today, [1] is tomorrow. The weather page prints a few of those slots.

A weather code is an integer that means clear / rain / snow. The program maps the number to a short English word. That is a lookup table again — same skill as DELAY → red on the airport board.

Sticky notes at the top

daily-briefing.ino
#define WIFI_SSID "YOUR_WIFI_SSID_HERE"
#define WIFI_PASSWORD "YOUR_WIFI_PASSWORD_HERE"
#define WEATHER_LAT 41.8781
#define WEATHER_LON -87.6298
#define TZ_POSIX "CST6CDT,M3.2.0,M11.1.0"
#define GOOGLE_API_KEY "YOUR_GOOGLE_API_KEY_HERE"  // leave this fake

Lesson 3 — API keys are keys. Do not publish them.

A calendar page needs permission from Google. That permission is an API key — a password for a program. If you paste a real key into a public project, strangers can spend your quota.

This firmware checks: if the key is still YOUR_GOOGLE_API_KEY_HERE, skip calendar and say so on screen. That is how grown-up software treats secrets: detect the placeholder, do not pretend it worked.

Refuse the fake network and the fake key

daily-briefing.ino
// Wi-Fi: if the SSID is still YOUR_WIFI_SSID_HERE, do not WiFi.begin()
// Calendar: if the key is still YOUR_GOOGLE_API_KEY_HERE, skip that page

Try this

  • Leave the Wi-Fi placeholder, flash, and read the red banner. Then fill it in and watch the LED go green.
  • Change TZ_POSIX if you are not in Central time. Watch the date page.
  • Edit the baked-in to-do list — those are just strings in the sketch.

Talking to the board (serial)

Your computer can talk to the board over the same USB-C cable. That text chat is called serial. In Arduino IDE or PlatformIO, open the Serial Monitor at 115200 baud (that number is the speed — both sides must match).

Type a word, press Enter, and the board answers. Try help first. Then try status.

Commands for this project: help · status · refresh · page · rotate

Tip

If you see nothing, pick the right USB port, set 115200, and press the board’s RESET once. On a Mac the port often looks like /dev/cu.usbmodem….


The complete program (reference)

Everything above taught the ideas. Below is the full sketch so you can search, copy, and tinker. It is long on purpose — that is a real program, not a toy snippet. Scroll inside the box. The downloadable .ino is the same file.

Wi-Fi and API fields are placeholders (YOUR_WIFI_SSID_HERE, YOUR_WIFI_PASSWORD_HERE). Never paste a real password into a public copy.

daily-briefing.ino (complete)

daily-briefing.ino
// Daily Briefing — ESP32-C6-LCD-1.47 (LCD-only slab, Amazon B0DK5J6LX3)
//
// The 172×320 panel cannot hold a full slip at once. After Wi-Fi / NTP /
// Open-Meteo, the brick pages: DATE → WEATHER → TO DO → CALENDAR.
// Short BOOT (GPIO9) = next page. Hold BOOT ~0.7s = flip portrait/landscape.
// C6 has no touch and no IMU — paging is BOOT (or a tap in the Mac sim).
// RGB: warm-white fetch, green ready, red error. Calendar skipped unless
// GOOGLE_API_KEY is set. Auto-advance every 8s while READY.
//
// Arduino IDE: ESP32C6 Dev Module, USB CDC On Boot, 115200.
// Serial: help | status | refresh | page | rotate
// Sim: python .cursor/skills/board-firmware-sim/scripts/serve_sim.py board-sketches/daily-briefing

#include <Arduino.h>
#include <Arduino_GFX_Library.h>
#include <Adafruit_NeoPixel.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <Preferences.h>
#include <time.h>
#include <ctype.h>
#include <math.h>

#if __has_include("c6_slab_pins.h")
#include "c6_slab_pins.h"
#else
static const int C6_LCD_W = 172;
static const int C6_LCD_H = 320;
static const int C6_LCD_MOSI_PIN = 6;
static const int C6_LCD_SCLK_PIN = 7;
static const int C6_LCD_CS_PIN = 14;
static const int C6_LCD_DC_PIN = 15;
static const int C6_LCD_RST_PIN = 21;
static const int C6_LCD_BL_PIN = 22;
static const int C6_RGB_PIN = 8;
static const int C6_BOOT_PIN = 9;
static const int C6_BL_PWM = 128;
#endif

#define WIFI_SSID "YOUR_WIFI_SSID_HERE"
#define WIFI_PASSWORD "YOUR_WIFI_PASSWORD_HERE"

#define WEATHER_LAT 41.8781
#define WEATHER_LON -87.6298
#define TEMP_UNIT "fahrenheit"

#define TZ_POSIX "CST6CDT,M3.2.0,M11.1.0"

#define GOOGLE_API_KEY "YOUR_GOOGLE_API_KEY_HERE"
#define GOOGLE_CALENDAR_ID "primary"

#define PAGE_DWELL_MS 8000UL
#define BOOT_LONG_MS 700UL

#ifndef RGB565
#define RGB565(r, g, b) \
  (uint16_t)((((r) & 0xF8) << 8) | (((g) & 0xFC) << 3) | ((b) >> 3))
#endif

static const uint16_t COL_PAPER = RGB565(250, 244, 230);
static const uint16_t COL_INK = RGB565(36, 32, 28);
static const uint16_t COL_MUTED = RGB565(120, 104, 88);
static const uint16_t COL_RULE = RGB565(180, 160, 140);
static const uint16_t COL_STAMP = RGB565(176, 48, 40);

enum BriefState { ST_FETCHING, ST_READY, ST_ERROR };
enum Page : uint8_t { PAGE_DATE = 0, PAGE_WEATHER, PAGE_TODO, PAGE_CAL, PAGE_COUNT };
static const char *STATE_NAME[] = {"FETCHING", "READY", "ERROR"};
static const char *PAGE_NAME[] = {"DATE", "WEATHER", "TODO", "CAL"};

struct Weather {
  bool ok = false;
  float tMax = 0;
  float tMin = 0;
  int code = -1;
  char summary[24] = "";
  char isoDate[12] = "";
};

struct CalEvent {
  char when[8];
  char title[28];
};

static Arduino_DataBus *bus = new Arduino_ESP32SPI(
    C6_LCD_DC_PIN, C6_LCD_CS_PIN, C6_LCD_SCLK_PIN, C6_LCD_MOSI_PIN, GFX_NOT_DEFINED);
static Arduino_GFX *gfx = new Arduino_ST7789(
    bus, C6_LCD_RST_PIN, 0 /* rotation */, true /* IPS */,
    C6_LCD_W, C6_LCD_H, 34, 0, 34, 0);

static Adafruit_NeoPixel rgb(1, C6_RGB_PIN, NEO_GRB + NEO_KHZ800);
static Preferences prefs;

static BriefState g_state = ST_FETCHING;
static Page g_page = PAGE_DATE;
static bool g_landscape = false;
static Weather g_wx;
static CalEvent g_events[3];
static int g_eventCount = 0;
static bool g_wifiOk = false;
static bool g_ntpOk = false;
static bool g_calConfigured = false;
static bool g_calOk = false;
static char g_lastError[48] = "";
static char g_dateLine[24] = "";
static char g_timeLine[8] = "";
static unsigned long g_lastFetchMs = 0;
static unsigned long g_pageAt = 0;
static uint8_t g_rgbR = 0, g_rgbG = 0, g_rgbB = 0;
static String g_serialLine;

static bool g_bootDown = false;
static bool g_bootLong = false;
static unsigned long g_bootDownAt = 0;

static int sw() { return gfx->width(); }
static int sh() { return gfx->height(); }

static bool placeholderWifi() {
  return WIFI_SSID[0] == '\0' ||
         strcmp(WIFI_SSID, "YOUR_WIFI_SSID_HERE") == 0 ||
         strcmp(WIFI_SSID, "YOUR_WIFI_SSID") == 0;
}

static bool calendarConfigured() {
  return GOOGLE_API_KEY[0] != '\0' &&
         strcmp(GOOGLE_API_KEY, "YOUR_GOOGLE_API_KEY_HERE") != 0 &&
         strcmp(GOOGLE_API_KEY, "YOUR_GOOGLE_API_KEY") != 0;
}

static void setRgb(uint8_t r, uint8_t g, uint8_t b) {
  g_rgbR = r;
  g_rgbG = g;
  g_rgbB = b;
  rgb.setPixelColor(0, rgb.Color(r, g, b));
  rgb.show();
}

static void setState(BriefState s) {
  g_state = s;
  if (s == ST_FETCHING) {
    setRgb(255, 196, 120);
  } else if (s == ST_READY) {
    setRgb(16, 200, 72);
  } else {
    setRgb(220, 24, 24);
  }
}

static const char *weatherText(int code) {
  if (code == 0) return "Clear";
  if (code == 1) return "Mainly clear";
  if (code == 2) return "Partly cloudy";
  if (code == 3) return "Overcast";
  if (code == 45 || code == 48) return "Fog";
  if (code >= 51 && code <= 57) return "Drizzle";
  if (code >= 61 && code <= 67) return "Rain";
  if (code >= 71 && code <= 77) return "Snow";
  if (code >= 80 && code <= 82) return "Showers";
  if (code >= 85 && code <= 86) return "Snow showers";
  if (code >= 95 && code <= 99) return "Thunder";
  return "Weather";
}

static void applyOrient() {
  gfx->setRotation(g_landscape ? 1 : 0);
}

static void persistOrient() {
  prefs.putUChar("land", g_landscape ? 1 : 0);
}

static void centerText(const char *s, int y, uint16_t color, uint8_t size) {
  gfx->setTextSize(size);
  gfx->setTextColor(color);
  const int w = (int)strlen(s) * 6 * size;
  int x = (sw() - w) / 2;
  if (x < 2) x = 2;
  gfx->setCursor(x, y);
  gfx->print(s);
}

static void dashH(int y) {
  for (int x = 8; x < sw() - 8; x += 5) {
    gfx->drawFastHLine(x, y, 3, COL_RULE);
  }
}

static void paperBackground() {
  gfx->fillScreen(COL_PAPER);
  gfx->drawRect(1, 1, sw() - 2, sh() - 2, COL_RULE);
}

static void drawDots(int active) {
  const int n = PAGE_COUNT;
  const int pitch = 12;
  const int x0 = (sw() - (n - 1) * pitch) / 2;
  const int y = sh() - 12;
  for (int i = 0; i < n; i++) {
    const int x = x0 + i * pitch;
    if (i == active) {
      gfx->fillCircle(x, y, 3, COL_STAMP);
    } else {
      gfx->drawCircle(x, y, 3, COL_RULE);
    }
  }
}

static void stampDateTime() {
  time_t now = time(nullptr);
  struct tm local = {};
  if (g_ntpOk && now > 1600000000 && localtime_r(&now, &local)) {
    strftime(g_dateLine, sizeof(g_dateLine), "%a %d %b", &local);
    strftime(g_timeLine, sizeof(g_timeLine), "%H:%M", &local);
    return;
  }
  if (g_wx.isoDate[0]) {
    snprintf(g_dateLine, sizeof(g_dateLine), "%s", g_wx.isoDate);
    snprintf(g_timeLine, sizeof(g_timeLine), "--:--");
    return;
  }
  snprintf(g_dateLine, sizeof(g_dateLine), "date ?");
  snprintf(g_timeLine, sizeof(g_timeLine), "--:--");
}

static void drawBanner(const char *title, const char *sub) {
  paperBackground();
  centerText("PRINTPAL", g_landscape ? 28 : 88, COL_STAMP, 1);
  centerText(title, g_landscape ? 56 : 120, COL_INK, 2);
  if (sub && sub[0]) {
    centerText(sub, g_landscape ? 88 : 160, COL_MUTED, 1);
  }
}

static void drawDatePage() {
  stampDateTime();
  paperBackground();
  centerText("PRINTPAL", 10, COL_STAMP, 1);
  dashH(22);
  if (g_landscape) {
    centerText(g_dateLine, 40, COL_MUTED, 1);
    centerText(g_timeLine, 62, COL_INK, 4);
    centerText("morning briefing", 118, COL_MUTED, 1);
  } else {
    centerText("TODAY", 36, COL_MUTED, 1);
    centerText(g_dateLine, 56, COL_INK, 2);
    centerText(g_timeLine, 120, COL_INK, 4);
    centerText("morning briefing", 190, COL_MUTED, 1);
    centerText("BOOT next page", 230, COL_RULE, 1);
    centerText("hold BOOT rotate", 246, COL_RULE, 1);
  }
  drawDots(PAGE_DATE);
}

static void drawWeatherPage() {
  paperBackground();
  centerText("WEATHER", 10, COL_MUTED, 1);
  dashH(22);

  char hi[12], lo[12];
  const char *unit = (strcmp(TEMP_UNIT, "celsius") == 0) ? "C" : "F";
  if (g_wx.ok) {
    snprintf(hi, sizeof(hi), "%d", (int)lroundf(g_wx.tMax));
    snprintf(lo, sizeof(lo), "%d", (int)lroundf(g_wx.tMin));
  } else {
    snprintf(hi, sizeof(hi), "--");
    snprintf(lo, sizeof(lo), "--");
  }

  if (g_landscape) {
    gfx->setTextSize(4);
    gfx->setTextColor(COL_INK);
    gfx->setCursor(12, 48);
    gfx->print(hi);
    gfx->setTextSize(1);
    gfx->setTextColor(COL_MUTED);
    gfx->setCursor(12, 88);
    gfx->print("HIGH ");
    gfx->print(unit);
    gfx->setTextSize(3);
    gfx->setTextColor(COL_INK);
    gfx->setCursor(sw() / 2 + 8, 56);
    gfx->print(lo);
    gfx->setTextSize(1);
    gfx->setTextColor(COL_MUTED);
    gfx->setCursor(sw() / 2 + 8, 88);
    gfx->print("LOW");
    centerText(g_wx.ok ? g_wx.summary : "no report", 118, COL_INK, 1);
  } else {
    centerText(hi, 48, COL_INK, 5);
    centerText("HIGH", 96, COL_MUTED, 1);
    char loLine[16];
    snprintf(loLine, sizeof(loLine), "low %s %s", lo, unit);
    centerText(loLine, 140, COL_INK, 2);
    centerText(g_wx.ok ? g_wx.summary : "no report", 180, COL_MUTED, 1);
  }
  drawDots(PAGE_WEATHER);
}

static void drawTodoPage() {
  paperBackground();
  centerText("TO DO", 10, COL_MUTED, 1);
  dashH(22);
  const int n = g_landscape ? 3 : 4;
  const int box = 10;
  int y = g_landscape ? 40 : 48;
  const int gap = g_landscape ? 28 : 36;
  for (int i = 0; i < n; i++) {
    gfx->drawRect(12, y, box, box, COL_INK);
    gfx->drawFastHLine(28, y + 6, sw() - 44, COL_RULE);
    y += gap;
  }
  if (!g_landscape) {
    centerText("write on the glass", sh() - 36, COL_MUTED, 1);
  }
  drawDots(PAGE_TODO);
}

static void drawCalPage() {
  paperBackground();
  centerText("TODAY", 10, COL_MUTED, 1);
  dashH(22);
  if (!g_calConfigured) {
    centerText("calendar off", g_landscape ? 56 : 100, COL_INK, 1);
    centerText("set GOOGLE_API_KEY", g_landscape ? 76 : 124, COL_MUTED, 1);
  } else if (g_eventCount == 0) {
    centerText(g_calOk ? "no events" : "cal failed", g_landscape ? 64 : 120, COL_INK, 1);
  } else {
    int y = g_landscape ? 36 : 40;
    const int step = g_landscape ? 28 : 36;
    gfx->setTextSize(1);
    for (int i = 0; i < g_eventCount; i++) {
      gfx->setTextColor(COL_STAMP);
      gfx->setCursor(12, y);
      gfx->print(g_events[i].when);
      gfx->setTextColor(COL_INK);
      gfx->setCursor(12, y + 12);
      gfx->print(g_events[i].title);
      y += step;
    }
  }
  drawDots(PAGE_CAL);
}

static void drawCurrent() {
  if (g_state == ST_FETCHING) {
    drawBanner("FETCHING", "wifi + ntp + wx");
    return;
  }
  if (g_state == ST_ERROR && !g_wifiOk) {
    drawBanner("NO WIFI", "check WIFI_SSID");
    return;
  }
  if (g_page == PAGE_DATE) {
    drawDatePage();
  } else if (g_page == PAGE_WEATHER) {
    drawWeatherPage();
  } else if (g_page == PAGE_TODO) {
    drawTodoPage();
  } else {
    drawCalPage();
  }
}

static void nextPage() {
  if (g_state == ST_FETCHING) {
    return;
  }
  g_page = (Page)((g_page + 1) % PAGE_COUNT);
  g_pageAt = millis();
  drawCurrent();
  Serial.printf("page %s\n", PAGE_NAME[g_page]);
}

static void toggleOrient() {
  g_landscape = !g_landscape;
  applyOrient();
  persistOrient();
  drawCurrent();
  Serial.printf("orient %s\n", g_landscape ? "landscape" : "portrait");
}

static bool connectWifi() {
  if (WiFi.status() == WL_CONNECTED) {
    g_wifiOk = true;
    return true;
  }
  if (placeholderWifi()) {
    g_wifiOk = false;
    snprintf(g_lastError, sizeof(g_lastError), "edit WIFI_SSID");
    return false;
  }
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  const unsigned long start = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - start < 15000) {
    delay(200);
  }
  g_wifiOk = WiFi.status() == WL_CONNECTED;
  if (!g_wifiOk) {
    snprintf(g_lastError, sizeof(g_lastError), "wifi timeout");
  }
  return g_wifiOk;
}

static bool syncNtp() {
  configTzTime(TZ_POSIX, "pool.ntp.org", "time.nist.gov");
  const unsigned long start = millis();
  while (millis() - start < 8000) {
    time_t now = time(nullptr);
    if (now > 1600000000) {
      g_ntpOk = true;
      return true;
    }
    delay(200);
  }
  g_ntpOk = false;
  return false;
}

static String urlEncode(const String &s) {
  String out;
  out.reserve(s.length() * 3);
  for (size_t i = 0; i < s.length(); i++) {
    const unsigned char c = (unsigned char)s[i];
    if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') {
      out += (char)c;
    } else {
      char buf[4];
      snprintf(buf, sizeof(buf), "%%%02X", c);
      out += buf;
    }
  }
  return out;
}

static bool httpsGet(const String &url, String &body, int timeoutMs = 8000) {
  WiFiClientSecure client;
  client.setInsecure();
  HTTPClient http;
  http.setTimeout(timeoutMs);
  if (!http.begin(client, url)) {
    snprintf(g_lastError, sizeof(g_lastError), "http begin failed");
    return false;
  }
  const int code = http.GET();
  body = http.getString();
  http.end();
  if (code != 200) {
    snprintf(g_lastError, sizeof(g_lastError), "http %d", code);
    return false;
  }
  return true;
}

static bool fetchWeather() {
  g_wx = Weather{};
  String url = String("https://api.open-meteo.com/v1/forecast?latitude=") +
               String(WEATHER_LAT, 4) + "&longitude=" + String(WEATHER_LON, 4) +
               "&daily=temperature_2m_max,temperature_2m_min,weather_code" +
               "&temperature_unit=" + TEMP_UNIT +
               "&timezone=auto&forecast_days=1";
  String body;
  if (!httpsGet(url, body)) {
    return false;
  }
  JsonDocument doc;
  if (deserializeJson(doc, body)) {
    snprintf(g_lastError, sizeof(g_lastError), "wx json");
    return false;
  }
  JsonArray tmax = doc["daily"]["temperature_2m_max"];
  JsonArray tmin = doc["daily"]["temperature_2m_min"];
  JsonArray codes = doc["daily"]["weather_code"];
  if (codes.isNull()) {
    codes = doc["daily"]["weathercode"];
  }
  if (tmax.isNull() || tmin.isNull() || tmax.size() < 1) {
    snprintf(g_lastError, sizeof(g_lastError), "wx missing daily");
    return false;
  }
  g_wx.tMax = tmax[0].as<float>();
  g_wx.tMin = tmin[0].as<float>();
  g_wx.code = codes.isNull() ? -1 : codes[0].as<int>();
  strncpy(g_wx.summary, weatherText(g_wx.code), sizeof(g_wx.summary) - 1);
  const char *iso = doc["daily"]["time"][0];
  if (iso) {
    strncpy(g_wx.isoDate, iso, sizeof(g_wx.isoDate) - 1);
  }
  g_wx.ok = true;
  return true;
}

static bool fetchCalendar() {
  g_eventCount = 0;
  g_calOk = false;
  time_t now = time(nullptr);
  if (now < 1600000000) {
    now = 1600000000;
  }
  struct tm gmt = {};
  gmtime_r(&now, &gmt);
  char tmin[32];
  strftime(tmin, sizeof(tmin), "%Y-%m-%dT%H:%M:%SZ", &gmt);
  time_t end = now + 24 * 60 * 60;
  gmtime_r(&end, &gmt);
  char tmax[32];
  strftime(tmax, sizeof(tmax), "%Y-%m-%dT%H:%M:%SZ", &gmt);

  String url = String("https://www.googleapis.com/calendar/v3/calendars/") +
               urlEncode(GOOGLE_CALENDAR_ID) + "/events?key=" +
               urlEncode(GOOGLE_API_KEY) + "&timeMin=" + urlEncode(tmin) +
               "&timeMax=" + urlEncode(tmax) +
               "&singleEvents=true&orderBy=startTime&maxResults=3";
  String body;
  if (!httpsGet(url, body, 8000)) {
    return false;
  }
  JsonDocument doc;
  if (deserializeJson(doc, body)) {
    return false;
  }
  JsonArray items = doc["items"];
  if (items.isNull()) {
    g_calOk = true;
    return true;
  }
  for (JsonObject it : items) {
    if (g_eventCount >= 3) {
      break;
    }
    const char *dateTime = it["start"]["dateTime"];
    const char *summary = it["summary"];
    if (dateTime && strlen(dateTime) >= 16) {
      g_events[g_eventCount].when[0] = dateTime[11];
      g_events[g_eventCount].when[1] = dateTime[12];
      g_events[g_eventCount].when[2] = ':';
      g_events[g_eventCount].when[3] = dateTime[14];
      g_events[g_eventCount].when[4] = dateTime[15];
      g_events[g_eventCount].when[5] = '\0';
    } else {
      snprintf(g_events[g_eventCount].when, sizeof(g_events[g_eventCount].when),
               "all");
    }
    strncpy(g_events[g_eventCount].title, summary ? summary : "(no title)",
            sizeof(g_events[g_eventCount].title) - 1);
    g_eventCount++;
  }
  g_calOk = true;
  return true;
}

static void runBriefing() {
  g_lastError[0] = '\0';
  g_eventCount = 0;
  g_calOk = false;
  g_calConfigured = calendarConfigured();
  g_page = PAGE_DATE;
  setState(ST_FETCHING);
  drawBanner("FETCHING", "wifi + ntp + wx");

  if (!connectWifi()) {
    setState(ST_ERROR);
    drawBanner("NO WIFI", "check WIFI_SSID");
    g_lastFetchMs = millis();
    return;
  }

  syncNtp();
  const bool wxOk = fetchWeather();
  if (g_calConfigured) {
    fetchCalendar();
  }

  if (wxOk) {
    setState(ST_READY);
    g_lastError[0] = '\0';
  } else {
    setState(ST_ERROR);
  }
  g_lastFetchMs = millis();
  g_pageAt = millis();
  drawCurrent();
}

static void printHelp() {
  Serial.println(F("Daily Briefing — one idea per screen"));
  Serial.println(F("  help     this list"));
  Serial.println(F("  status   wifi, weather, page"));
  Serial.println(F("  refresh  fetch again"));
  Serial.println(F("  page     next screen"));
  Serial.println(F("  rotate   portrait <-> landscape"));
  Serial.println(F("BOOT short = next page. Hold BOOT = rotate."));
  Serial.println(F("C6 has no touch / IMU."));
}

static void printStatus() {
  Serial.printf("state: %s\n", STATE_NAME[g_state]);
  Serial.printf("page: %s\n", PAGE_NAME[g_page]);
  Serial.printf("orient: %s  %dx%d\n", g_landscape ? "landscape" : "portrait", sw(), sh());
  Serial.printf("wifi: %s", g_wifiOk ? "connected" : "down");
  if (g_wifiOk) {
    Serial.printf("  %s  %s", WiFi.SSID().c_str(),
                  WiFi.localIP().toString().c_str());
  }
  Serial.println();
  Serial.printf("ntp: %s\n", g_ntpOk ? "ok" : "no");
  if (g_wx.ok) {
    Serial.printf("weather: %.0f/%.0f %s\n", g_wx.tMax, g_wx.tMin, g_wx.summary);
  } else {
    Serial.println(F("weather: none"));
  }
  if (!g_calConfigured) {
    Serial.println(F("calendar: skipped (placeholder key)"));
  } else {
    Serial.printf("calendar: %s  events=%d\n", g_calOk ? "ok" : "fail",
                  g_eventCount);
  }
  Serial.printf("rgb: %u,%u,%u\n", g_rgbR, g_rgbG, g_rgbB);
  if (g_lastError[0]) {
    Serial.printf("error: %s\n", g_lastError);
  }
}

static void handleCommand(String cmd) {
  cmd.trim();
  cmd.toLowerCase();
  if (cmd.length() == 0) {
    return;
  }
  if (cmd == "help" || cmd == "?") {
    printHelp();
  } else if (cmd == "status") {
    printStatus();
  } else if (cmd == "refresh") {
    Serial.println(F("refresh"));
    runBriefing();
  } else if (cmd == "page") {
    nextPage();
  } else if (cmd == "rotate" || cmd == "orient") {
    toggleOrient();
  } else {
    Serial.println(F("unknown — try help"));
  }
}

static void pollSerial() {
  while (Serial.available()) {
    const char c = (char)Serial.read();
    if (c == '\r') {
      continue;
    }
    if (c == '\n') {
      handleCommand(g_serialLine);
      g_serialLine = "";
    } else if (g_serialLine.length() < 48) {
      g_serialLine += c;
    }
  }
}

static void pollBoot() {
  const bool down = digitalRead(C6_BOOT_PIN) == LOW;
  const unsigned long now = millis();
  if (down && !g_bootDown) {
    g_bootDown = true;
    g_bootLong = false;
    g_bootDownAt = now;
  }
  if (down && g_bootDown && !g_bootLong && (now - g_bootDownAt) >= BOOT_LONG_MS) {
    g_bootLong = true;
    toggleOrient();
  }
  if (!down && g_bootDown) {
    if (!g_bootLong && (now - g_bootDownAt) > 40) {
      nextPage();
    }
    g_bootDown = false;
  }
}

void setup() {
  Serial.begin(115200);
  delay(200);

  pinMode(C6_BOOT_PIN, INPUT_PULLUP);
  pinMode(C6_LCD_BL_PIN, OUTPUT);
  analogWrite(C6_LCD_BL_PIN, C6_BL_PWM);

  rgb.begin();
  rgb.setBrightness(48);
  rgb.clear();
  rgb.show();

  prefs.begin("briefing", false);
  g_landscape = prefs.getUChar("land", 0) != 0;

  if (!gfx->begin()) {
    Serial.println(F("GFX begin failed"));
  }
  applyOrient();

  while (digitalRead(C6_BOOT_PIN) == LOW) {
    delay(10);
  }
  delay(40);

  Serial.println(F("Daily Briefing ready — type help"));
  runBriefing();
}

void loop() {
  pollSerial();
  pollBoot();
  if (g_state != ST_FETCHING && (millis() - g_pageAt) >= PAGE_DWELL_MS) {
    nextPage();
  }
  delay(20);
}

Board and housing

Licensed under CC BY-SA 4.0. Check with the author before commercial use.