🤖Have you ever tried Chat.M5Stack.com before asking??😎
    M5Stack Community
    • Categories
    • Recent
    • Tags
    • Popular
    • Users
    • Groups
    • Search
    • Register
    • Login

    M5Dial Example App - Home Assistant, RSS News and OpenWeatherMap

    Arduino
    1
    2
    41
    Loading More Posts
    • Oldest to Newest
    • Newest to Oldest
    • Most Votes
    Reply
    • Reply as topic
    Log in to reply
    This topic has been deleted. Only users with topic management privileges can see it.
    • P
      PaulMcGuinness_UK
      last edited by

      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("&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);
      }
      
      

      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]);
      
      
      1 Reply Last reply Reply Quote 0
      • P
        PaulMcGuinness_UK
        last edited by

        7e5ce5b2-8d9d-48f9-ae34-7fa5884c1e12-image.png

        1 Reply Last reply Reply Quote 0
        • First post
          Last post