M5Dial Example App - Home Assistant, RSS News and OpenWeatherMap
-
Hi All,
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 ================================================ Written by Paul McGuinness License: GNU General Public License (GPL) ===== 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); }and the Include file with the settings : 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]); -

Hello! It looks like you're interested in this conversation, but you don't have an account yet.
Getting fed up of having to scroll through the same posts each visit? When you register for an account, you'll always come back to exactly where you were before, and choose to be notified of new replies (either via email, or push notification). You'll also be able to save bookmarks and upvote posts to show your appreciation to other community members.
With your input, this post could be even better 💗
Register Login