M5Stack – M5Dial – Example Project

I’ve been working on a little project to show local weather, Home Assistant measurements and RSS news headlines. These are all accessible by turning the dial

Forecast: Shows current conditions, and if you wait 5 secs, then shows the next 7 days, one at a time with a 3 second pause between.

Home Assistant: You can define any measurements in the config file (for mine, I’m getting a couple of temperature sensors)

News RSS: It will show the top 10 headlines from an RSS feed.

Here is the Arduino .ino code:

/*
  M5Dial Home Assistant + Weather + News Dashboard
1
  ===== Arduino IDE / Board settings =====
  - Install M5Stack Board Manager
  - Select Tools -> Board -> "M5Dial"
  - Board package: M5Stack >= 3.2.2
  - Library: M5Dial >= 1.0.3
  - Libraries:
    - M5Dial
    - ArduinoJson (v6)
  - Serial Monitor: 115200
*/

#include <Arduino.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <WiFiClient.h>
#include <WiFiClientSecure.h>
#include <ArduinoJson.h>
#include <math.h>
#include "M5Dial.h"

#include "config.h"

// -----------------------------
// Limits
// -----------------------------
static const uint8_t MAX_FORECAST_DAYS = 7;
static const uint8_t MAX_NEWS_HEADLINES = 10;

// -----------------------------
// State
// -----------------------------
static size_t  gIndex = 0;
static int32_t gLastEnc = INT32_MIN;
static int32_t gEncAccum = 0;
static const int32_t ENC_STEP = 2;    // 1 page change per 2 encoder counts
static uint32_t gLastPageTurnMs = 0;
static const uint32_t ENC_PAGE_DEBOUNCE_MS = 120;

static uint32_t gLastInteractionMs = 0;
static bool     gDisplayOn = true;
static uint32_t gLastFetchMs = 0;
static uint32_t gMetricEnteredMs = 0;

// Main display values
static String gValueLine1;
static String gValueLine2;
static String gWeatherSummary;
static String gWeatherIconKey;

// -----------------------------
// Weather cache
// -----------------------------
static String gWeatherTempLine;
static float  gWeatherNowC = NAN;
static uint32_t gLastWeatherForecastFetchMs = 0;
static bool gHasWeatherForecastCache = false;

struct DailyForecast {
  bool valid;
  String label;      // e.g. "Sat 7th"
  String summary;    // e.g. "Rain expected"
  int minTemp;
  int maxTemp;
};

static DailyForecast gDailyForecast[MAX_FORECAST_DAYS];
static uint8_t gDailyForecastCount = 0;

// Weather auto rotation page:
// 0 = main weather page
// 1..gDailyForecastCount = forecast pages
static uint8_t gWeatherAutoPage = 0;
static uint32_t gLastWeatherAutoRotateMs = 0;

// -----------------------------
// News cache
// -----------------------------
static String gNewsCaption;
static String gNewsHeadlines[MAX_NEWS_HEADLINES];
static uint8_t gNewsHeadlineCount = 0;
static uint32_t gLastNewsFetchMs = 0;
static bool gHasNewsCache = false;
static uint8_t gNewsAutoIndex = 0;
static uint32_t gLastNewsAutoRotateMs = 0;

// -----------------------------
// Helpers
// -----------------------------
static String formatNumber(double value, uint8_t decimals) {
  char buf[32];
  snprintf(buf, sizeof(buf), "%.*f", (int)decimals, value);
  return String(buf);
}

static String stripDegree(const String& s) {
  String out = s;
  out.replace("°", "");
  return out;
}

static const char* ordinalSuffix(int day) {
  if (day >= 11 && day <= 13) return "th";
  switch (day % 10) {
    case 1: return "st";
    case 2: return "nd";
    case 3: return "rd";
    default: return "th";
  }
}

static String formatDayDateLabel(int wday, int mday) {
  static const char* names[] = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"};
  String s = (wday >= 0 && wday < 7) ? String(names[wday]) : String("Day");
  s += " ";
  s += String(mday);
  s += ordinalSuffix(mday);
  return s;
}

static void fadeBrightness(uint8_t fromB, uint8_t toB, uint32_t durationMs) {
  if (durationMs == 0) {
    M5Dial.Display.setBrightness(toB);
    return;
  }
  const int steps = 12;
  for (int i = 0; i <= steps; i++) {
    float t = (float)i / (float)steps;
    uint8_t b = (uint8_t)round(fromB + (toB - fromB) * t);
    M5Dial.Display.setBrightness(b);
    delay(durationMs / steps);
  }
}

static void resetAutoPages() {
  gMetricEnteredMs = millis();
  gWeatherAutoPage = 0;
  gNewsAutoIndex = 0;
  gLastWeatherAutoRotateMs = millis();
  gLastNewsAutoRotateMs = millis();
}

static void markInteraction() {
  gLastInteractionMs = millis();
  resetAutoPages();

  if (!gDisplayOn) {
    gDisplayOn = true;
    M5Dial.Display.setBrightness(DISPLAY_BRIGHTNESS);
    M5Dial.Display.wakeup();
  }
}

static void maybeSleepDisplay() {
  if (gDisplayOn && (millis() - gLastInteractionMs > DISPLAY_SLEEP_MS)) {
    gDisplayOn = false;
    M5Dial.Display.setBrightness(0);
    M5Dial.Display.sleep();
  }
}

static void wifiEnsureConnected() {
  if (WiFi.status() == WL_CONNECTED) return;

  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);

  uint32_t start = millis();
  while (WiFi.status() != WL_CONNECTED && (millis() - start) < 8000) {
    delay(50);
    M5Dial.update();
    if (M5Dial.Touch.getCount() > 0) markInteraction();
    int32_t enc = M5Dial.Encoder.read();
    if (enc != gLastEnc) {
      gLastEnc = enc;
      markInteraction();
    }
  }
}

static String httpGET(const String& url, bool https) {
  String payload;

  if (https) {
    WiFiClientSecure client;
    client.setInsecure();
    HTTPClient http;
    if (!http.begin(client, url)) return "";
    int code = http.GET();
    if (code > 0) payload = http.getString();
    http.end();
  } else {
    WiFiClient client;
    HTTPClient http;
    if (!http.begin(client, url)) return "";
    int code = http.GET();
    if (code > 0) payload = http.getString();
    http.end();
  }

  return payload;
}

static String xmlDecode(String s) {
  s.replace("&amp;", "&");
  s.replace("&lt;", "<");
  s.replace("&gt;", ">");
  s.replace("&quot;", "\"");
  s.replace("&#39;", "'");
  return s;
}

static String extractTagValue(const String& xml, const String& tag, int startPos = 0, int* outEndPos = nullptr) {
  String open1 = "<" + tag + "><![CDATA[";
  String close1 = "]]></" + tag + ">";
  String open2 = "<" + tag + ">";
  String close2 = "</" + tag + ">";

  int s = xml.indexOf(open1, startPos);
  if (s >= 0) {
    s += open1.length();
    int e = xml.indexOf(close1, s);
    if (e >= 0) {
      if (outEndPos) *outEndPos = e + close1.length();
      return xml.substring(s, e);
    }
  }

  s = xml.indexOf(open2, startPos);
  if (s >= 0) {
    s += open2.length();
    int e = xml.indexOf(close2, s);
    if (e >= 0) {
      if (outEndPos) *outEndPos = e + close2.length();
      return xml.substring(s, e);
    }
  }

  if (outEndPos) *outEndPos = -1;
  return "";
}

// -----------------------------
// Icons
// -----------------------------
static void drawIcon(IconType icon, int cx, int cy, int r) {
  auto& d = M5Dial.Display;
  switch (icon) {
    case IconType::Thermometer: {
      d.drawCircle(cx, cy + r/2, r/3, TFT_WHITE);
      d.drawLine(cx, cy - r, cx, cy + r/2, TFT_WHITE);
      d.drawRoundRect(cx - r/6, cy - r, r/3, (int)(r*1.5f), r/6, TFT_WHITE);
    } break;
    case IconType::Droplet: {
      d.drawCircle(cx, cy, r/2, TFT_WHITE);
      d.drawTriangle(cx, cy - r, cx - r/2, cy, cx + r/2, cy, TFT_WHITE);
      d.drawCircle(cx, cy + r/3, r/2, TFT_WHITE);
    } break;
    case IconType::Gauge: {
      d.drawCircle(cx, cy, r, TFT_WHITE);
      d.drawLine(cx, cy, cx + r/2, cy - r/3, TFT_WHITE);
      d.fillCircle(cx, cy, 3, TFT_WHITE);
    } break;
    case IconType::Weather: {
      d.drawCircle(cx, cy, r/2, TFT_WHITE);
      for (int i = 0; i < 8; i++) {
        float a = (float)i * (PI / 4.0f);
        int x1 = cx + (int)(cos(a) * (r * 0.65f));
        int y1 = cy + (int)(sin(a) * (r * 0.65f));
        int x2 = cx + (int)(cos(a) * (r * 0.95f));
        int y2 = cy + (int)(sin(a) * (r * 0.95f));
        d.drawLine(x1, y1, x2, y2, TFT_WHITE);
      }
    } break;
    case IconType::News: {
      d.drawRect(cx - r, cy - r, r * 2, r * 2, TFT_WHITE);
      d.drawLine(cx - r + 4, cy - r + 8, cx + r - 4, cy - r + 8, TFT_WHITE);
      d.drawLine(cx - r + 4, cy, cx + r - 4, cy, TFT_WHITE);
      d.drawLine(cx - r + 4, cy + r - 8, cx + r - 4, cy + r - 8, TFT_WHITE);
    } break;
    default:
      d.drawRect(cx - r, cy - r, r * 2, r * 2, TFT_WHITE);
      break;
  }
}

// -----------------------------
// Home Assistant
// -----------------------------
static bool haGetEntityState(const char* entityId, String& outState, String& outUnit, bool& outNumeric, double& outNumber) {
  outState = "";
  outUnit = "";
  outNumeric = false;
  outNumber = 0;

  if (!entityId || !*entityId) return false;

  String url = String(HA_BASE_URL) + "/api/states/" + entityId;

  WiFiClient client;
  HTTPClient http;
  if (!http.begin(client, url)) return false;

  http.addHeader("Authorization", String("Bearer ") + HA_BEARER_TOKEN);
  http.addHeader("Content-Type", "application/json");

  int code = http.GET();
  if (code <= 0) {
    http.end();
    return false;
  }

  String payload = http.getString();
  http.end();

  StaticJsonDocument<4096> doc;
  if (deserializeJson(doc, payload)) return false;

  outState = String((const char*)(doc["state"] | ""));
  outUnit = String((const char*)(doc["attributes"]["unit_of_measurement"] | ""));

  char* endPtr = nullptr;
  double v = strtod(outState.c_str(), &endPtr);
  if (endPtr && endPtr != outState.c_str() && *endPtr == '\0') {
    outNumeric = true;
    outNumber = v;
  }
  return true;
}

// -----------------------------
// Weather
// -----------------------------
static String classifySummary(bool rain, bool snow, int avgClouds) {
  if (snow) return "Snow expected";
  if (rain) return "Rain expected";
  if (avgClouds >= 70) return "Mostly cloudy";
  return "Staying clear";
}

static bool fetchWeatherForecastCache() {
  String urlFc =
    String("https://api.openweathermap.org/data/2.5/forecast?q=") +
    OWM_CITY + "," + OWM_COUNTRY +
    "&units=" + OWM_UNITS +
    "&lang=" + OWM_LANG +
    "&appid=" + OWM_API_KEY;

  String fcJson = httpGET(urlFc, true);
  if (fcJson.length() < 10) return false;

  StaticJsonDocument<28000> fcDoc;
  if (deserializeJson(fcDoc, fcJson)) return false;

  for (int i = 0; i < MAX_FORECAST_DAYS; i++) {
    gDailyForecast[i].valid = false;
    gDailyForecast[i].label = "";
    gDailyForecast[i].summary = "";
    gDailyForecast[i].minTemp = 0;
    gDailyForecast[i].maxTemp = 0;
  }
  gDailyForecastCount = 0;

  JsonArray list = fcDoc["list"].as<JsonArray>();
  if (list.isNull()) return false;

  long timezone = fcDoc["city"]["timezone"] | 0;

  int dateKeys[MAX_FORECAST_DAYS] = {0};
  bool dayRain[MAX_FORECAST_DAYS] = {false};
  bool daySnow[MAX_FORECAST_DAYS] = {false};
  int dayCloudSum[MAX_FORECAST_DAYS] = {0};
  int dayCloudN[MAX_FORECAST_DAYS] = {0};

  for (JsonObject it : list) {
    time_t fcTs = it["dt"] | 0;
    time_t localFc = fcTs + timezone;
    tm tFc;
    gmtime_r(&localFc, &tFc);
    int key = (tFc.tm_year + 1900) * 10000 + (tFc.tm_mon + 1) * 100 + tFc.tm_mday;

    int idx = -1;
    for (int i = 0; i < gDailyForecastCount; i++) {
      if (dateKeys[i] == key) {
        idx = i;
        break;
      }
    }

    if (idx < 0) {
      if (gDailyForecastCount >= MAX_FORECAST_DAYS) continue;
      idx = gDailyForecastCount++;
      dateKeys[idx] = key;
      gDailyForecast[idx].valid = true;
      gDailyForecast[idx].label = formatDayDateLabel(tFc.tm_wday, tFc.tm_mday);

      float t = it["main"]["temp"] | NAN;
      int ti = isnan(t) ? 0 : (int)round(t);
      gDailyForecast[idx].minTemp = ti;
      gDailyForecast[idx].maxTemp = ti;
    }

    float temp = it["main"]["temp"] | NAN;
    if (!isnan(temp)) {
      int ti = (int)round(temp);
      if (ti < gDailyForecast[idx].minTemp) gDailyForecast[idx].minTemp = ti;
      if (ti > gDailyForecast[idx].maxTemp) gDailyForecast[idx].maxTemp = ti;
    }

    const char* main0 = it["weather"][0]["main"] | "";
    int clouds = it["clouds"]["all"] | 0;

    if (!strcmp(main0, "Rain") || !strcmp(main0, "Drizzle") || !strcmp(main0, "Thunderstorm")) dayRain[idx] = true;
    if (!strcmp(main0, "Snow")) daySnow[idx] = true;
    dayCloudSum[idx] += clouds;
    dayCloudN[idx]++;
  }

  for (int i = 0; i < gDailyForecastCount; i++) {
    int avgClouds = dayCloudN[i] ? (dayCloudSum[i] / dayCloudN[i]) : 0;
    gDailyForecast[i].summary = classifySummary(dayRain[i], daySnow[i], avgClouds);
  }

  gHasWeatherForecastCache = (gDailyForecastCount > 0);
  gLastWeatherForecastFetchMs = millis();
  return gHasWeatherForecastCache;
}

static bool ensureWeatherForecastCache() {
  if (!gHasWeatherForecastCache) return fetchWeatherForecastCache();
  if (millis() - gLastWeatherForecastFetchMs >= WEATHER_FORECAST_CACHE_MS) return fetchWeatherForecastCache();
  return true;
}

static bool fetchCurrentWeather(String& outTempC, String& outWind, String& outHum, String& outIcon, String& outRestOfDaySummary) {
  outTempC = outWind = outHum = outIcon = outRestOfDaySummary = "";

  String urlNow =
    String("https://api.openweathermap.org/data/2.5/weather?q=") +
    OWM_CITY + "," + OWM_COUNTRY +
    "&units=" + OWM_UNITS +
    "&lang=" + OWM_LANG +
    "&appid=" + OWM_API_KEY;

  String nowJson = httpGET(urlNow, true);
  if (nowJson.length() < 10) return false;

  StaticJsonDocument<8192> nowDoc;
  if (deserializeJson(nowDoc, nowJson)) return false;

  double tempNow = nowDoc["main"]["temp"] | NAN;
  double wind = nowDoc["wind"]["speed"] | NAN;
  int hum = nowDoc["main"]["humidity"] | -1;
  const char* icon = nowDoc["weather"][0]["icon"] | "";

  if (isnan(tempNow) || hum < 0) return false;

  gWeatherNowC = (float)tempNow;
  outTempC = String((int)round(tempNow)) + "C";
  outWind = isnan(wind) ? String("? m/s") : (String(wind, 1) + " m/s");
  outHum = String(hum) + "%";
  outIcon = String(icon);

  if (!ensureWeatherForecastCache()) {
    outRestOfDaySummary = "Forecast unavailable";
    gWeatherTempLine = outTempC + " (Now)";
    return true;
  }

  if (gDailyForecastCount > 0) {
    int todayMax = gDailyForecast[0].maxTemp;
    gWeatherTempLine = String(todayMax) + "C (" + String((int)round(gWeatherNowC)) + "C Now)";
    outRestOfDaySummary = gDailyForecast[0].summary;
  } else {
    gWeatherTempLine = outTempC + " (Now)";
    outRestOfDaySummary = "Forecast unavailable";
  }

  return true;
}

// -----------------------------
// Newsfeed
// -----------------------------
static bool fetchNewsFeed() {
  String xml = httpGET(String(RSS_NEWS_URL), true);
  if (xml.length() < 20) return false;

  int channelPos = xml.indexOf("<channel>");
  if (channelPos < 0) channelPos = 0;

  int firstItemPos = xml.indexOf("<item>", channelPos);
  gNewsCaption = extractTagValue(xml.substring(channelPos, firstItemPos > 0 ? firstItemPos : xml.length()), "description");
  gNewsCaption = xmlDecode(gNewsCaption);

  gNewsHeadlineCount = 0;
  int pos = channelPos;
  while (gNewsHeadlineCount < MAX_NEWS_HEADLINES) {
    int itemStart = xml.indexOf("<item>", pos);
    if (itemStart < 0) break;
    int itemEnd = xml.indexOf("</item>", itemStart);
    if (itemEnd < 0) break;

    String itemXml = xml.substring(itemStart, itemEnd + 7);
    String title = extractTagValue(itemXml, "title");
    title = xmlDecode(title);
    title.trim();

    if (title.length()) {
      gNewsHeadlines[gNewsHeadlineCount++] = title;
    }
    pos = itemEnd + 7;
  }

  gHasNewsCache = (gNewsHeadlineCount > 0);
  gLastNewsFetchMs = millis();
  return gHasNewsCache;
}

static bool ensureNewsCache() {
  if (!gHasNewsCache) return fetchNewsFeed();
  if (millis() - gLastNewsFetchMs >= NEWS_CACHE_MS) return fetchNewsFeed();
  return true;
}

// -----------------------------
// Drawing helpers
// -----------------------------
static void drawWrappedTwoLinesCentered(const String& text, int yTop, int maxWidth) {
  auto& d = M5Dial.Display;
  d.setTextFont(1);
  d.setTextDatum(top_center);

  String s = text;
  s.trim();

  if (d.textWidth(s) <= maxWidth) {
    d.drawString(s, d.width() / 2, yTop);
    return;
  }

  int bestPos = -1;
  int bestBalance = 999999;

  for (int i = 0; i < (int)s.length(); i++) {
    if (s[i] != ' ') continue;
    String a = s.substring(0, i);
    String b = s.substring(i + 1);

    int wa = d.textWidth(a);
    int wb = d.textWidth(b);
    if (wa <= maxWidth && wb <= maxWidth) {
      int balance = abs(wa - wb);
      if (balance < bestBalance) {
        bestBalance = balance;
        bestPos = i;
      }
    }
  }

  if (bestPos < 0) {
    String line1 = s;
    while (line1.length() > 3 && d.textWidth(line1 + "...") > maxWidth) line1.remove(line1.length() - 1);
    line1 += "...";
    d.drawString(line1, d.width() / 2, yTop);
    return;
  }

  String line1 = s.substring(0, bestPos);
  String line2 = s.substring(bestPos + 1);

  d.drawString(line1, d.width() / 2, yTop);
  d.drawString(line2, d.width() / 2, yTop + 12);
}

static void drawCircularTextBlock(const String& text, int yStart) {
  auto& d = M5Dial.Display;
  d.setTextColor(TFT_WHITE);
  d.setTextDatum(top_center);
  d.setTextFont(2);

  const int limits[] = {10, 14, 20, 22, 20, 14, 10};
  const int lineCount = sizeof(limits) / sizeof(limits[0]);

  String remaining = text;
  remaining.trim();

  int y = yStart;
  for (int line = 0; line < lineCount && remaining.length(); line++) {
    int limit = limits[line];
    String out = "";

    if ((int)remaining.length() <= limit) {
      out = remaining;
      remaining = "";
    } else {
      int split = -1;
      for (int i = min(limit, (int)remaining.length() - 1); i >= 0; i--) {
        if (remaining[i] == ' ') {
          split = i;
          break;
        }
      }
      if (split < 0) split = limit;
      out = remaining.substring(0, split);
      remaining = remaining.substring(split);
      remaining.trim();
    }

    d.drawString(out, d.width() / 2, y);
    y += 18;
  }

  if (remaining.length()) {
    String tail = remaining;
    while (tail.length() > 3 && d.textWidth(tail + "...") > 180) tail.remove(tail.length() - 1);
    tail += "...";
    d.drawString(tail, d.width() / 2, y);
  }
}

static void drawTitle(const String& rawTitle) {
  auto& d = M5Dial.Display;
  const int margin = 10;
  const int maxW = d.width() - (margin * 2);

  d.setTextColor(TFT_WHITE);
  d.setTextDatum(top_center);
  d.setTextFont(2);

  String title = stripDegree(rawTitle);
  if (d.textWidth(title) > maxW) {
    d.setTextFont(1);
    while (title.length() > 3 && d.textWidth(title + "...") > maxW) title.remove(title.length() - 1);
    title += "...";
  }
  d.drawString(title, d.width() / 2, 10);
}

static void renderWeatherMain() {
  auto& d = M5Dial.Display;
  d.clear(TFT_BLACK);

  drawTitle("Local Weather");
  drawIcon(IconType::Weather, d.width() / 2, 78, 26);

  d.setTextDatum(middle_center);
  d.setTextFont(4);
  d.drawString(stripDegree(gValueLine1), d.width() / 2, 140);

  d.setTextFont(2);
  d.drawString(stripDegree(gValueLine2), d.width() / 2, 175);

  drawWrappedTwoLinesCentered(stripDegree(gWeatherSummary), d.height() - 44, d.width() - 20);
}

static void renderWeatherForecastPage(uint8_t forecastIdx) {
  auto& d = M5Dial.Display;
  d.clear(TFT_BLACK);

  if (forecastIdx >= gDailyForecastCount || !gDailyForecast[forecastIdx].valid) {
    renderWeatherMain();
    return;
  }

  drawTitle("Forecast");
  drawIcon(IconType::Weather, d.width() / 2, 68, 22);

  d.setTextDatum(middle_center);
  d.setTextFont(2);
  d.drawString(gDailyForecast[forecastIdx].label, d.width() / 2, 112);

  d.setTextFont(4);
  String hi = String(gDailyForecast[forecastIdx].maxTemp) + "C";
  d.drawString(hi, d.width() / 2, 146);

  d.setTextFont(2);
  String lo = "Low " + String(gDailyForecast[forecastIdx].minTemp) + "C";
  d.drawString(lo, d.width() / 2, 176);

  drawWrappedTwoLinesCentered(gDailyForecast[forecastIdx].summary, 198, d.width() - 20);
}

static void renderNewsPage(uint8_t idx) {
  auto& d = M5Dial.Display;
  d.clear(TFT_BLACK);

  drawTitle("Newsfeed");
  drawIcon(IconType::News, d.width() / 2, 58, 18);

  d.setTextDatum(top_center);
  d.setTextFont(1);

  String caption = gNewsCaption;
  caption.trim();
  if (!caption.length()) caption = "Top headlines";
  while (caption.length() > 3 && d.textWidth(caption + "...") > 190) caption.remove(caption.length() - 1);
  if (gNewsCaption.length() && caption != gNewsCaption) caption += "...";
  d.drawString(caption, d.width() / 2, 82);

  if (idx < gNewsHeadlineCount) {
    drawCircularTextBlock(gNewsHeadlines[idx], 108);
  } else {
    d.setTextFont(2);
    d.setTextDatum(middle_center);
    d.drawString("No headlines", d.width() / 2, 150);
  }
}

static void renderCurrentMetric() {
  auto& d = M5Dial.Display;
  d.clear(TFT_BLACK);

  const MetricItem& m = METRICS[gIndex];

  if (m.type == MetricType::Weather) {
    if (gWeatherAutoPage == 0) {
      renderWeatherMain();
    } else {
      renderWeatherForecastPage(gWeatherAutoPage - 1);
    }
    return;
  }

  if (m.type == MetricType::Newsfeed) {
    renderNewsPage(gNewsAutoIndex % (gNewsHeadlineCount ? gNewsHeadlineCount : 1));
    return;
  }

  drawTitle(String(m.title));
  drawIcon(m.icon, d.width() / 2, 78, 26);

  d.setTextDatum(middle_center);
  d.setTextFont(4);
  d.drawString(stripDegree(gValueLine1), d.width() / 2, 140);

  d.setTextFont(2);
  d.drawString(stripDegree(gValueLine2), d.width() / 2, 175);
}

static void setMetricIndex(size_t idx) {
  if (METRIC_COUNT == 0) {
    gIndex = 0;
    return;
  }

  gIndex = idx % METRIC_COUNT;
  gLastFetchMs = 0;
  gEncAccum = 0;
  resetAutoPages();
}

static void refreshSelectedMetricIfDue(bool force) {
  const MetricItem& m = METRICS[gIndex];
  uint32_t minIntervalMs = (uint32_t)m.refresh_s * 1000UL;

  if (!force && gLastFetchMs != 0 && (millis() - gLastFetchMs) < minIntervalMs) return;
  gLastFetchMs = millis();

  if (m.type == MetricType::Weather) {
    String t, w, h, icon, summary;
    if (fetchCurrentWeather(t, w, h, icon, summary)) {
      gValueLine1 = stripDegree(gWeatherTempLine.length() ? gWeatherTempLine : t);
      gValueLine2 = String("Wind ") + stripDegree(w) + "  Hum " + stripDegree(h);
      gWeatherSummary = stripDegree(summary);
      gWeatherIconKey = icon;
    } else {
      gValueLine1 = "Weather";
      gValueLine2 = "Unavailable";
      gWeatherSummary = "Check OWM settings";
      gWeatherIconKey = "";
    }
  } else if (m.type == MetricType::Newsfeed) {
    if (!ensureNewsCache()) {
      gNewsCaption = "News unavailable";
      gNewsHeadlineCount = 0;
    }
    gValueLine1 = "";
    gValueLine2 = "";
    gWeatherSummary = "";
  } else {
    String state, unit;
    bool isNum = false;
    double num = 0;

    if (haGetEntityState(m.ha_entity_id, state, unit, isNum, num)) {
      if (isNum) {
        gValueLine1 = stripDegree(String(m.prefix) + formatNumber(num, m.decimals) + String(m.suffix));
      } else {
        gValueLine1 = stripDegree(String(m.prefix) + state + String(m.suffix));
      }

      if (m.ha_entity_id &&
         (strcmp(m.ha_entity_id, "sensor.office_temperature") == 0 ||
          strcmp(m.ha_entity_id, "sensor.office_humidity") == 0 ||
          strcmp(m.ha_entity_id, "sensor.thermostat_1_current_temperature") == 0)) {
        gValueLine2 = "";
      } else {
        if (unit.length()) gValueLine2 = stripDegree(unit);
        else gValueLine2 = "";
      }

      gWeatherSummary = "";
    } else {
      gValueLine1 = "HA";
      gValueLine2 = "Unavailable";
      gWeatherSummary = "";
    }
  }

  renderCurrentMetric();
}

static void animateMetricChange(size_t newIndex) {
  if (!gDisplayOn) {
    setMetricIndex(newIndex);
    refreshSelectedMetricIfDue(true);
    return;
  }

  fadeBrightness(DISPLAY_BRIGHTNESS, 0, 75);
  setMetricIndex(newIndex);
  refreshSelectedMetricIfDue(true);
  fadeBrightness(0, DISPLAY_BRIGHTNESS, 75);
}

static void turnMetricBy(int dir) {
  if (METRIC_COUNT == 0 || dir == 0) return;

  size_t newIndex;
  if (dir > 0) {
    newIndex = (gIndex + 1) % METRIC_COUNT;
  } else {
    newIndex = (gIndex == 0) ? (METRIC_COUNT - 1) : (gIndex - 1);
  }
  animateMetricChange(newIndex);
}

static void handleAutoRotate() {
  if (!gDisplayOn) return;

  const MetricItem& m = METRICS[gIndex];
  if (millis() - gMetricEnteredMs < AUTO_ROTATE_DELAY_MS) return;

  if (m.type == MetricType::Weather) {
    if (gDailyForecastCount == 0) return;

    if (millis() - gLastWeatherAutoRotateMs >= AUTO_ROTATE_INTERVAL_MS) {
      gLastWeatherAutoRotateMs = millis();

      // Cycle: main -> day1 -> day2 -> ... -> lastday -> main -> repeat
      uint8_t totalPages = gDailyForecastCount + 1;
      gWeatherAutoPage = (gWeatherAutoPage + 1) % totalPages;

      renderCurrentMetric();
    }
  }

  if (m.type == MetricType::Newsfeed) {
    if (gNewsHeadlineCount == 0) return;

    if (millis() - gLastNewsAutoRotateMs >= AUTO_ROTATE_INTERVAL_MS) {
      gLastNewsAutoRotateMs = millis();
      gNewsAutoIndex = (gNewsAutoIndex + 1) % gNewsHeadlineCount;
      renderCurrentMetric();
    }
  }
}

// -----------------------------
// Arduino
// -----------------------------
void setup() {
  auto cfg = M5.config();
  M5Dial.begin(cfg, true, false);

  M5Dial.Display.setBrightness(DISPLAY_BRIGHTNESS);
  M5Dial.Display.setTextColor(TFT_WHITE);
  M5Dial.Display.setTextDatum(middle_center);

  Serial.begin(115200);
  delay(200);

  gLastInteractionMs = millis();
  resetAutoPages();
  gLastEnc = M5Dial.Encoder.read();
  gEncAccum = 0;

  wifiEnsureConnected();

  gValueLine1 = "Loading...";
  gValueLine2 = "";
  gWeatherSummary = "";
  renderCurrentMetric();

  refreshSelectedMetricIfDue(true);
}

void loop() {
  M5Dial.update();

  if (M5Dial.Touch.getCount() > 0) {
    markInteraction();
    refreshSelectedMetricIfDue(true);
  }

  int32_t enc = M5Dial.Encoder.read();
  if (gLastEnc == INT32_MIN) gLastEnc = enc;

  int32_t delta = enc - gLastEnc;
  if (delta != 0) {
    markInteraction();
    gLastEnc = enc;
    gEncAccum += delta;
  }

  // Process at most one widget change per debounce window.
  // This prevents wrap-around overshoot and "Weather -> Office Temperature" jumps.
  if (millis() - gLastPageTurnMs >= ENC_PAGE_DEBOUNCE_MS) {
    if (gEncAccum >= ENC_STEP) {
      gEncAccum = 0;
      gLastPageTurnMs = millis();
      turnMetricBy(+1);
    } else if (gEncAccum <= -ENC_STEP) {
      gEncAccum = 0;
      gLastPageTurnMs = millis();
      turnMetricBy(-1);
    }
  }

  if (WiFi.status() != WL_CONNECTED) {
    static uint32_t lastRetry = 0;
    if (millis() - lastRetry > WIFI_RETRY_MS) {
      lastRetry = millis();
      wifiEnsureConnected();
      refreshSelectedMetricIfDue(true);
    }
  }

  if (gDisplayOn) {
    refreshSelectedMetricIfDue(false);
    handleAutoRotate();
  }

  maybeSleepDisplay();
  delay(10);
}
  1. Save this as “M5Dial_HA.ino” and open it in Arduino Studio ↩︎

The settings are held in “config.h”

#pragma once
#include <stdint.h>

// =================================
// M5Dial - Weather and News Display
// =================================

// WiFi
static const char* WIFI_SSID     = "SSID_GOES_HERE";
static const char* WIFI_PASSWORD = "WIFI_PASSWORD_GOES_HERE";

// Home Assistant
static const char* HA_BASE_URL   = "http://homeassistant.local:8123";  // Point to your instances of Home Assistant.
static const char* HA_BEARER_TOKEN = "HA_KEY_GOES_HERE";

// OpenWeatherMap
static const char* OWM_API_KEY   = "OPENWEATHERMAP_API_KEY_GOES_HERE";
static const char* OWM_CITY      = "Latchingdon";
static const char* OWM_COUNTRY   = "GB";
static const char* OWM_UNITS     = "metric";
static const char* OWM_LANG      = "en";

// Newsfeed
static const char* RSS_NEWS_URL  = "https://feeds.bbci.co.uk/news/rss.xml?edition=uk"; // Change to any RSS News feed

// UI / Power
static const uint32_t DISPLAY_SLEEP_MS   = 60UL * 1000UL;
static const uint8_t  DISPLAY_BRIGHTNESS = 80;

// Refresh behaviour
static const uint32_t WIFI_RETRY_MS      = 10UL * 1000UL;

// Widget timing
static const uint32_t WEATHER_FORECAST_CACHE_MS = 60UL * 60UL * 1000UL; // 60 mins
static const uint32_t AUTO_ROTATE_DELAY_MS      = 5UL * 1000UL;         // wait 5s before auto-rotate
static const uint32_t AUTO_ROTATE_INTERVAL_MS   = 3UL * 1000UL;         // pause 3s on each page
static const uint32_t NEWS_CACHE_MS             = 30UL * 60UL * 1000UL; // 30 mins

// =======================
// METRIC CONFIG
// =======================

enum class MetricType : uint8_t {
  Weather = 0,
  HAState = 1,
  Newsfeed = 2,
};

enum class IconType : uint8_t {
  Weather = 0,
  Thermometer,
  Droplet,
  Gauge,
  Info,
  News,
};

struct MetricItem {
  MetricType type;
  const char* title;

  // For HAState only
  const char* ha_entity_id;

  // Formatting
  const char* prefix;
  const char* suffix;
  uint8_t decimals;

  // Icon
  IconType icon;

  // Refresh interval (seconds) while this metric is selected
  uint16_t refresh_s;
};

static const MetricItem METRICS[] = {
  {
    MetricType::Weather,
    "Local Weather",
    nullptr,
    "", "", 0,
    IconType::Weather,
    300,
  },
  {
    MetricType::HAState,
    "Office Temperature",
    "sensor.office_temperature",
    "", "C", 1,
    IconType::Thermometer,
    30,
  },
  {
    MetricType::HAState,
    "House Temperature",
    "sensor.thermostat_1_current_temperature",
    "", "C", 1,
    IconType::Thermometer,
    30,
  },
  {
    MetricType::HAState,
    "Office Humidity",
    "sensor.office_humidity",
    "", "% RH", 0,
    IconType::Droplet,
    30,
  },
  {
    MetricType::Newsfeed,
    "Newsfeed",
    nullptr,
    "", "", 0,
    IconType::News,
    1800,
  },
};

static const size_t METRIC_COUNT = sizeof(METRICS) / sizeof(METRICS[0]);

CATEGORIES:

Tags:

No Responses

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.