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("&", "&");
s.replace("<", "<");
s.replace(">", ">");
s.replace(""", "\"");
s.replace("'", "'");
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);
}
- 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]);
No Responses