{"id":510,"date":"2026-03-06T17:05:51","date_gmt":"2026-03-06T17:05:51","guid":{"rendered":"https:\/\/www.internet-tools.co.uk\/blog\/?p=510"},"modified":"2026-03-06T17:07:04","modified_gmt":"2026-03-06T17:07:04","slug":"m5stack-m5dial-example-project","status":"publish","type":"post","link":"https:\/\/www.internet-tools.co.uk\/blog\/index.php\/2026\/03\/06\/m5stack-m5dial-example-project\/","title":{"rendered":"M5Stack &#8211; M5Dial &#8211; Example Project"},"content":{"rendered":"\n<p>I&#8217;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<\/p>\n\n\n\n<p><strong>Forecast<\/strong>: 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.<\/p>\n\n\n\n<p><strong>Home Assistant<\/strong>: You can define any measurements in the config file (for mine, I&#8217;m getting a couple of temperature sensors)<\/p>\n\n\n\n<p><strong>News RSS<\/strong>: It will show the top 10 headlines from an RSS feed.<\/p>\n\n\n\n<p>Here is the Arduino .ino code:<\/p>\n\n\n\n<pre class=\"wp-block-code has-kubio-color-5-variant-2-background-color has-background has-small-font-size\" style=\"border-width:1px\"><code>\/*\n  M5Dial Home Assistant + Weather + News Dashboard\n<sup data-fn=\"c0f8f9c1-4189-44f3-a5f1-4b2693cd60d2\" class=\"fn\"><a href=\"#c0f8f9c1-4189-44f3-a5f1-4b2693cd60d2\" id=\"c0f8f9c1-4189-44f3-a5f1-4b2693cd60d2-link\">1<\/a><\/sup>\n  ===== Arduino IDE \/ Board settings =====\n  - Install M5Stack Board Manager\n  - Select Tools -&gt; Board -&gt; \"M5Dial\"\n  - Board package: M5Stack &gt;= 3.2.2\n  - Library: M5Dial &gt;= 1.0.3\n  - Libraries:\n    - M5Dial\n    - ArduinoJson (v6)\n  - Serial Monitor: 115200\n*\/\n\n#include &lt;Arduino.h&gt;\n#include &lt;WiFi.h&gt;\n#include &lt;HTTPClient.h&gt;\n#include &lt;WiFiClient.h&gt;\n#include &lt;WiFiClientSecure.h&gt;\n#include &lt;ArduinoJson.h&gt;\n#include &lt;math.h&gt;\n#include \"M5Dial.h\"\n\n#include \"config.h\"\n\n\/\/ -----------------------------\n\/\/ Limits\n\/\/ -----------------------------\nstatic const uint8_t MAX_FORECAST_DAYS = 7;\nstatic const uint8_t MAX_NEWS_HEADLINES = 10;\n\n\/\/ -----------------------------\n\/\/ State\n\/\/ -----------------------------\nstatic size_t  gIndex = 0;\nstatic int32_t gLastEnc = INT32_MIN;\nstatic int32_t gEncAccum = 0;\nstatic const int32_t ENC_STEP = 2;    \/\/ 1 page change per 2 encoder counts\nstatic uint32_t gLastPageTurnMs = 0;\nstatic const uint32_t ENC_PAGE_DEBOUNCE_MS = 120;\n\nstatic uint32_t gLastInteractionMs = 0;\nstatic bool     gDisplayOn = true;\nstatic uint32_t gLastFetchMs = 0;\nstatic uint32_t gMetricEnteredMs = 0;\n\n\/\/ Main display values\nstatic String gValueLine1;\nstatic String gValueLine2;\nstatic String gWeatherSummary;\nstatic String gWeatherIconKey;\n\n\/\/ -----------------------------\n\/\/ Weather cache\n\/\/ -----------------------------\nstatic String gWeatherTempLine;\nstatic float  gWeatherNowC = NAN;\nstatic uint32_t gLastWeatherForecastFetchMs = 0;\nstatic bool gHasWeatherForecastCache = false;\n\nstruct DailyForecast {\n  bool valid;\n  String label;      \/\/ e.g. \"Sat 7th\"\n  String summary;    \/\/ e.g. \"Rain expected\"\n  int minTemp;\n  int maxTemp;\n};\n\nstatic DailyForecast gDailyForecast&#91;MAX_FORECAST_DAYS];\nstatic uint8_t gDailyForecastCount = 0;\n\n\/\/ Weather auto rotation page:\n\/\/ 0 = main weather page\n\/\/ 1..gDailyForecastCount = forecast pages\nstatic uint8_t gWeatherAutoPage = 0;\nstatic uint32_t gLastWeatherAutoRotateMs = 0;\n\n\/\/ -----------------------------\n\/\/ News cache\n\/\/ -----------------------------\nstatic String gNewsCaption;\nstatic String gNewsHeadlines&#91;MAX_NEWS_HEADLINES];\nstatic uint8_t gNewsHeadlineCount = 0;\nstatic uint32_t gLastNewsFetchMs = 0;\nstatic bool gHasNewsCache = false;\nstatic uint8_t gNewsAutoIndex = 0;\nstatic uint32_t gLastNewsAutoRotateMs = 0;\n\n\/\/ -----------------------------\n\/\/ Helpers\n\/\/ -----------------------------\nstatic String formatNumber(double value, uint8_t decimals) {\n  char buf&#91;32];\n  snprintf(buf, sizeof(buf), \"%.*f\", (int)decimals, value);\n  return String(buf);\n}\n\nstatic String stripDegree(const String&amp; s) {\n  String out = s;\n  out.replace(\"\u00b0\", \"\");\n  return out;\n}\n\nstatic const char* ordinalSuffix(int day) {\n  if (day &gt;= 11 &amp;&amp; day &lt;= 13) return \"th\";\n  switch (day % 10) {\n    case 1: return \"st\";\n    case 2: return \"nd\";\n    case 3: return \"rd\";\n    default: return \"th\";\n  }\n}\n\nstatic String formatDayDateLabel(int wday, int mday) {\n  static const char* names&#91;] = {\"Sun\", \"Mon\", \"Tue\", \"Wed\", \"Thu\", \"Fri\", \"Sat\"};\n  String s = (wday &gt;= 0 &amp;&amp; wday &lt; 7) ? String(names&#91;wday]) : String(\"Day\");\n  s += \" \";\n  s += String(mday);\n  s += ordinalSuffix(mday);\n  return s;\n}\n\nstatic void fadeBrightness(uint8_t fromB, uint8_t toB, uint32_t durationMs) {\n  if (durationMs == 0) {\n    M5Dial.Display.setBrightness(toB);\n    return;\n  }\n  const int steps = 12;\n  for (int i = 0; i &lt;= steps; i++) {\n    float t = (float)i \/ (float)steps;\n    uint8_t b = (uint8_t)round(fromB + (toB - fromB) * t);\n    M5Dial.Display.setBrightness(b);\n    delay(durationMs \/ steps);\n  }\n}\n\nstatic void resetAutoPages() {\n  gMetricEnteredMs = millis();\n  gWeatherAutoPage = 0;\n  gNewsAutoIndex = 0;\n  gLastWeatherAutoRotateMs = millis();\n  gLastNewsAutoRotateMs = millis();\n}\n\nstatic void markInteraction() {\n  gLastInteractionMs = millis();\n  resetAutoPages();\n\n  if (!gDisplayOn) {\n    gDisplayOn = true;\n    M5Dial.Display.setBrightness(DISPLAY_BRIGHTNESS);\n    M5Dial.Display.wakeup();\n  }\n}\n\nstatic void maybeSleepDisplay() {\n  if (gDisplayOn &amp;&amp; (millis() - gLastInteractionMs &gt; DISPLAY_SLEEP_MS)) {\n    gDisplayOn = false;\n    M5Dial.Display.setBrightness(0);\n    M5Dial.Display.sleep();\n  }\n}\n\nstatic void wifiEnsureConnected() {\n  if (WiFi.status() == WL_CONNECTED) return;\n\n  WiFi.mode(WIFI_STA);\n  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);\n\n  uint32_t start = millis();\n  while (WiFi.status() != WL_CONNECTED &amp;&amp; (millis() - start) &lt; 8000) {\n    delay(50);\n    M5Dial.update();\n    if (M5Dial.Touch.getCount() &gt; 0) markInteraction();\n    int32_t enc = M5Dial.Encoder.read();\n    if (enc != gLastEnc) {\n      gLastEnc = enc;\n      markInteraction();\n    }\n  }\n}\n\nstatic String httpGET(const String&amp; url, bool https) {\n  String payload;\n\n  if (https) {\n    WiFiClientSecure client;\n    client.setInsecure();\n    HTTPClient http;\n    if (!http.begin(client, url)) return \"\";\n    int code = http.GET();\n    if (code &gt; 0) payload = http.getString();\n    http.end();\n  } else {\n    WiFiClient client;\n    HTTPClient http;\n    if (!http.begin(client, url)) return \"\";\n    int code = http.GET();\n    if (code &gt; 0) payload = http.getString();\n    http.end();\n  }\n\n  return payload;\n}\n\nstatic String xmlDecode(String s) {\n  s.replace(\"&amp;amp;\", \"&amp;\");\n  s.replace(\"&amp;lt;\", \"&lt;\");\n  s.replace(\"&amp;gt;\", \"&gt;\");\n  s.replace(\"&amp;quot;\", \"\\\"\");\n  s.replace(\"&amp;#39;\", \"'\");\n  return s;\n}\n\nstatic String extractTagValue(const String&amp; xml, const String&amp; tag, int startPos = 0, int* outEndPos = nullptr) {\n  String open1 = \"&lt;\" + tag + \"&gt;&lt;!&#91;CDATA&#91;\";\n  String close1 = \"]]&gt;&lt;\/\" + tag + \"&gt;\";\n  String open2 = \"&lt;\" + tag + \"&gt;\";\n  String close2 = \"&lt;\/\" + tag + \"&gt;\";\n\n  int s = xml.indexOf(open1, startPos);\n  if (s &gt;= 0) {\n    s += open1.length();\n    int e = xml.indexOf(close1, s);\n    if (e &gt;= 0) {\n      if (outEndPos) *outEndPos = e + close1.length();\n      return xml.substring(s, e);\n    }\n  }\n\n  s = xml.indexOf(open2, startPos);\n  if (s &gt;= 0) {\n    s += open2.length();\n    int e = xml.indexOf(close2, s);\n    if (e &gt;= 0) {\n      if (outEndPos) *outEndPos = e + close2.length();\n      return xml.substring(s, e);\n    }\n  }\n\n  if (outEndPos) *outEndPos = -1;\n  return \"\";\n}\n\n\/\/ -----------------------------\n\/\/ Icons\n\/\/ -----------------------------\nstatic void drawIcon(IconType icon, int cx, int cy, int r) {\n  auto&amp; d = M5Dial.Display;\n  switch (icon) {\n    case IconType::Thermometer: {\n      d.drawCircle(cx, cy + r\/2, r\/3, TFT_WHITE);\n      d.drawLine(cx, cy - r, cx, cy + r\/2, TFT_WHITE);\n      d.drawRoundRect(cx - r\/6, cy - r, r\/3, (int)(r*1.5f), r\/6, TFT_WHITE);\n    } break;\n    case IconType::Droplet: {\n      d.drawCircle(cx, cy, r\/2, TFT_WHITE);\n      d.drawTriangle(cx, cy - r, cx - r\/2, cy, cx + r\/2, cy, TFT_WHITE);\n      d.drawCircle(cx, cy + r\/3, r\/2, TFT_WHITE);\n    } break;\n    case IconType::Gauge: {\n      d.drawCircle(cx, cy, r, TFT_WHITE);\n      d.drawLine(cx, cy, cx + r\/2, cy - r\/3, TFT_WHITE);\n      d.fillCircle(cx, cy, 3, TFT_WHITE);\n    } break;\n    case IconType::Weather: {\n      d.drawCircle(cx, cy, r\/2, TFT_WHITE);\n      for (int i = 0; i &lt; 8; i++) {\n        float a = (float)i * (PI \/ 4.0f);\n        int x1 = cx + (int)(cos(a) * (r * 0.65f));\n        int y1 = cy + (int)(sin(a) * (r * 0.65f));\n        int x2 = cx + (int)(cos(a) * (r * 0.95f));\n        int y2 = cy + (int)(sin(a) * (r * 0.95f));\n        d.drawLine(x1, y1, x2, y2, TFT_WHITE);\n      }\n    } break;\n    case IconType::News: {\n      d.drawRect(cx - r, cy - r, r * 2, r * 2, TFT_WHITE);\n      d.drawLine(cx - r + 4, cy - r + 8, cx + r - 4, cy - r + 8, TFT_WHITE);\n      d.drawLine(cx - r + 4, cy, cx + r - 4, cy, TFT_WHITE);\n      d.drawLine(cx - r + 4, cy + r - 8, cx + r - 4, cy + r - 8, TFT_WHITE);\n    } break;\n    default:\n      d.drawRect(cx - r, cy - r, r * 2, r * 2, TFT_WHITE);\n      break;\n  }\n}\n\n\/\/ -----------------------------\n\/\/ Home Assistant\n\/\/ -----------------------------\nstatic bool haGetEntityState(const char* entityId, String&amp; outState, String&amp; outUnit, bool&amp; outNumeric, double&amp; outNumber) {\n  outState = \"\";\n  outUnit = \"\";\n  outNumeric = false;\n  outNumber = 0;\n\n  if (!entityId || !*entityId) return false;\n\n  String url = String(HA_BASE_URL) + \"\/api\/states\/\" + entityId;\n\n  WiFiClient client;\n  HTTPClient http;\n  if (!http.begin(client, url)) return false;\n\n  http.addHeader(\"Authorization\", String(\"Bearer \") + HA_BEARER_TOKEN);\n  http.addHeader(\"Content-Type\", \"application\/json\");\n\n  int code = http.GET();\n  if (code &lt;= 0) {\n    http.end();\n    return false;\n  }\n\n  String payload = http.getString();\n  http.end();\n\n  StaticJsonDocument&lt;4096&gt; doc;\n  if (deserializeJson(doc, payload)) return false;\n\n  outState = String((const char*)(doc&#91;\"state\"] | \"\"));\n  outUnit = String((const char*)(doc&#91;\"attributes\"]&#91;\"unit_of_measurement\"] | \"\"));\n\n  char* endPtr = nullptr;\n  double v = strtod(outState.c_str(), &amp;endPtr);\n  if (endPtr &amp;&amp; endPtr != outState.c_str() &amp;&amp; *endPtr == '\\0') {\n    outNumeric = true;\n    outNumber = v;\n  }\n  return true;\n}\n\n\/\/ -----------------------------\n\/\/ Weather\n\/\/ -----------------------------\nstatic String classifySummary(bool rain, bool snow, int avgClouds) {\n  if (snow) return \"Snow expected\";\n  if (rain) return \"Rain expected\";\n  if (avgClouds &gt;= 70) return \"Mostly cloudy\";\n  return \"Staying clear\";\n}\n\nstatic bool fetchWeatherForecastCache() {\n  String urlFc =\n    String(\"https:\/\/api.openweathermap.org\/data\/2.5\/forecast?q=\") +\n    OWM_CITY + \",\" + OWM_COUNTRY +\n    \"&amp;units=\" + OWM_UNITS +\n    \"&amp;lang=\" + OWM_LANG +\n    \"&amp;appid=\" + OWM_API_KEY;\n\n  String fcJson = httpGET(urlFc, true);\n  if (fcJson.length() &lt; 10) return false;\n\n  StaticJsonDocument&lt;28000&gt; fcDoc;\n  if (deserializeJson(fcDoc, fcJson)) return false;\n\n  for (int i = 0; i &lt; MAX_FORECAST_DAYS; i++) {\n    gDailyForecast&#91;i].valid = false;\n    gDailyForecast&#91;i].label = \"\";\n    gDailyForecast&#91;i].summary = \"\";\n    gDailyForecast&#91;i].minTemp = 0;\n    gDailyForecast&#91;i].maxTemp = 0;\n  }\n  gDailyForecastCount = 0;\n\n  JsonArray list = fcDoc&#91;\"list\"].as&lt;JsonArray&gt;();\n  if (list.isNull()) return false;\n\n  long timezone = fcDoc&#91;\"city\"]&#91;\"timezone\"] | 0;\n\n  int dateKeys&#91;MAX_FORECAST_DAYS] = {0};\n  bool dayRain&#91;MAX_FORECAST_DAYS] = {false};\n  bool daySnow&#91;MAX_FORECAST_DAYS] = {false};\n  int dayCloudSum&#91;MAX_FORECAST_DAYS] = {0};\n  int dayCloudN&#91;MAX_FORECAST_DAYS] = {0};\n\n  for (JsonObject it : list) {\n    time_t fcTs = it&#91;\"dt\"] | 0;\n    time_t localFc = fcTs + timezone;\n    tm tFc;\n    gmtime_r(&amp;localFc, &amp;tFc);\n    int key = (tFc.tm_year + 1900) * 10000 + (tFc.tm_mon + 1) * 100 + tFc.tm_mday;\n\n    int idx = -1;\n    for (int i = 0; i &lt; gDailyForecastCount; i++) {\n      if (dateKeys&#91;i] == key) {\n        idx = i;\n        break;\n      }\n    }\n\n    if (idx &lt; 0) {\n      if (gDailyForecastCount &gt;= MAX_FORECAST_DAYS) continue;\n      idx = gDailyForecastCount++;\n      dateKeys&#91;idx] = key;\n      gDailyForecast&#91;idx].valid = true;\n      gDailyForecast&#91;idx].label = formatDayDateLabel(tFc.tm_wday, tFc.tm_mday);\n\n      float t = it&#91;\"main\"]&#91;\"temp\"] | NAN;\n      int ti = isnan(t) ? 0 : (int)round(t);\n      gDailyForecast&#91;idx].minTemp = ti;\n      gDailyForecast&#91;idx].maxTemp = ti;\n    }\n\n    float temp = it&#91;\"main\"]&#91;\"temp\"] | NAN;\n    if (!isnan(temp)) {\n      int ti = (int)round(temp);\n      if (ti &lt; gDailyForecast&#91;idx].minTemp) gDailyForecast&#91;idx].minTemp = ti;\n      if (ti &gt; gDailyForecast&#91;idx].maxTemp) gDailyForecast&#91;idx].maxTemp = ti;\n    }\n\n    const char* main0 = it&#91;\"weather\"]&#91;0]&#91;\"main\"] | \"\";\n    int clouds = it&#91;\"clouds\"]&#91;\"all\"] | 0;\n\n    if (!strcmp(main0, \"Rain\") || !strcmp(main0, \"Drizzle\") || !strcmp(main0, \"Thunderstorm\")) dayRain&#91;idx] = true;\n    if (!strcmp(main0, \"Snow\")) daySnow&#91;idx] = true;\n    dayCloudSum&#91;idx] += clouds;\n    dayCloudN&#91;idx]++;\n  }\n\n  for (int i = 0; i &lt; gDailyForecastCount; i++) {\n    int avgClouds = dayCloudN&#91;i] ? (dayCloudSum&#91;i] \/ dayCloudN&#91;i]) : 0;\n    gDailyForecast&#91;i].summary = classifySummary(dayRain&#91;i], daySnow&#91;i], avgClouds);\n  }\n\n  gHasWeatherForecastCache = (gDailyForecastCount &gt; 0);\n  gLastWeatherForecastFetchMs = millis();\n  return gHasWeatherForecastCache;\n}\n\nstatic bool ensureWeatherForecastCache() {\n  if (!gHasWeatherForecastCache) return fetchWeatherForecastCache();\n  if (millis() - gLastWeatherForecastFetchMs &gt;= WEATHER_FORECAST_CACHE_MS) return fetchWeatherForecastCache();\n  return true;\n}\n\nstatic bool fetchCurrentWeather(String&amp; outTempC, String&amp; outWind, String&amp; outHum, String&amp; outIcon, String&amp; outRestOfDaySummary) {\n  outTempC = outWind = outHum = outIcon = outRestOfDaySummary = \"\";\n\n  String urlNow =\n    String(\"https:\/\/api.openweathermap.org\/data\/2.5\/weather?q=\") +\n    OWM_CITY + \",\" + OWM_COUNTRY +\n    \"&amp;units=\" + OWM_UNITS +\n    \"&amp;lang=\" + OWM_LANG +\n    \"&amp;appid=\" + OWM_API_KEY;\n\n  String nowJson = httpGET(urlNow, true);\n  if (nowJson.length() &lt; 10) return false;\n\n  StaticJsonDocument&lt;8192&gt; nowDoc;\n  if (deserializeJson(nowDoc, nowJson)) return false;\n\n  double tempNow = nowDoc&#91;\"main\"]&#91;\"temp\"] | NAN;\n  double wind = nowDoc&#91;\"wind\"]&#91;\"speed\"] | NAN;\n  int hum = nowDoc&#91;\"main\"]&#91;\"humidity\"] | -1;\n  const char* icon = nowDoc&#91;\"weather\"]&#91;0]&#91;\"icon\"] | \"\";\n\n  if (isnan(tempNow) || hum &lt; 0) return false;\n\n  gWeatherNowC = (float)tempNow;\n  outTempC = String((int)round(tempNow)) + \"C\";\n  outWind = isnan(wind) ? String(\"? m\/s\") : (String(wind, 1) + \" m\/s\");\n  outHum = String(hum) + \"%\";\n  outIcon = String(icon);\n\n  if (!ensureWeatherForecastCache()) {\n    outRestOfDaySummary = \"Forecast unavailable\";\n    gWeatherTempLine = outTempC + \" (Now)\";\n    return true;\n  }\n\n  if (gDailyForecastCount &gt; 0) {\n    int todayMax = gDailyForecast&#91;0].maxTemp;\n    gWeatherTempLine = String(todayMax) + \"C (\" + String((int)round(gWeatherNowC)) + \"C Now)\";\n    outRestOfDaySummary = gDailyForecast&#91;0].summary;\n  } else {\n    gWeatherTempLine = outTempC + \" (Now)\";\n    outRestOfDaySummary = \"Forecast unavailable\";\n  }\n\n  return true;\n}\n\n\/\/ -----------------------------\n\/\/ Newsfeed\n\/\/ -----------------------------\nstatic bool fetchNewsFeed() {\n  String xml = httpGET(String(RSS_NEWS_URL), true);\n  if (xml.length() &lt; 20) return false;\n\n  int channelPos = xml.indexOf(\"&lt;channel&gt;\");\n  if (channelPos &lt; 0) channelPos = 0;\n\n  int firstItemPos = xml.indexOf(\"&lt;item&gt;\", channelPos);\n  gNewsCaption = extractTagValue(xml.substring(channelPos, firstItemPos &gt; 0 ? firstItemPos : xml.length()), \"description\");\n  gNewsCaption = xmlDecode(gNewsCaption);\n\n  gNewsHeadlineCount = 0;\n  int pos = channelPos;\n  while (gNewsHeadlineCount &lt; MAX_NEWS_HEADLINES) {\n    int itemStart = xml.indexOf(\"&lt;item&gt;\", pos);\n    if (itemStart &lt; 0) break;\n    int itemEnd = xml.indexOf(\"&lt;\/item&gt;\", itemStart);\n    if (itemEnd &lt; 0) break;\n\n    String itemXml = xml.substring(itemStart, itemEnd + 7);\n    String title = extractTagValue(itemXml, \"title\");\n    title = xmlDecode(title);\n    title.trim();\n\n    if (title.length()) {\n      gNewsHeadlines&#91;gNewsHeadlineCount++] = title;\n    }\n    pos = itemEnd + 7;\n  }\n\n  gHasNewsCache = (gNewsHeadlineCount &gt; 0);\n  gLastNewsFetchMs = millis();\n  return gHasNewsCache;\n}\n\nstatic bool ensureNewsCache() {\n  if (!gHasNewsCache) return fetchNewsFeed();\n  if (millis() - gLastNewsFetchMs &gt;= NEWS_CACHE_MS) return fetchNewsFeed();\n  return true;\n}\n\n\/\/ -----------------------------\n\/\/ Drawing helpers\n\/\/ -----------------------------\nstatic void drawWrappedTwoLinesCentered(const String&amp; text, int yTop, int maxWidth) {\n  auto&amp; d = M5Dial.Display;\n  d.setTextFont(1);\n  d.setTextDatum(top_center);\n\n  String s = text;\n  s.trim();\n\n  if (d.textWidth(s) &lt;= maxWidth) {\n    d.drawString(s, d.width() \/ 2, yTop);\n    return;\n  }\n\n  int bestPos = -1;\n  int bestBalance = 999999;\n\n  for (int i = 0; i &lt; (int)s.length(); i++) {\n    if (s&#91;i] != ' ') continue;\n    String a = s.substring(0, i);\n    String b = s.substring(i + 1);\n\n    int wa = d.textWidth(a);\n    int wb = d.textWidth(b);\n    if (wa &lt;= maxWidth &amp;&amp; wb &lt;= maxWidth) {\n      int balance = abs(wa - wb);\n      if (balance &lt; bestBalance) {\n        bestBalance = balance;\n        bestPos = i;\n      }\n    }\n  }\n\n  if (bestPos &lt; 0) {\n    String line1 = s;\n    while (line1.length() &gt; 3 &amp;&amp; d.textWidth(line1 + \"...\") &gt; maxWidth) line1.remove(line1.length() - 1);\n    line1 += \"...\";\n    d.drawString(line1, d.width() \/ 2, yTop);\n    return;\n  }\n\n  String line1 = s.substring(0, bestPos);\n  String line2 = s.substring(bestPos + 1);\n\n  d.drawString(line1, d.width() \/ 2, yTop);\n  d.drawString(line2, d.width() \/ 2, yTop + 12);\n}\n\nstatic void drawCircularTextBlock(const String&amp; text, int yStart) {\n  auto&amp; d = M5Dial.Display;\n  d.setTextColor(TFT_WHITE);\n  d.setTextDatum(top_center);\n  d.setTextFont(2);\n\n  const int limits&#91;] = {10, 14, 20, 22, 20, 14, 10};\n  const int lineCount = sizeof(limits) \/ sizeof(limits&#91;0]);\n\n  String remaining = text;\n  remaining.trim();\n\n  int y = yStart;\n  for (int line = 0; line &lt; lineCount &amp;&amp; remaining.length(); line++) {\n    int limit = limits&#91;line];\n    String out = \"\";\n\n    if ((int)remaining.length() &lt;= limit) {\n      out = remaining;\n      remaining = \"\";\n    } else {\n      int split = -1;\n      for (int i = min(limit, (int)remaining.length() - 1); i &gt;= 0; i--) {\n        if (remaining&#91;i] == ' ') {\n          split = i;\n          break;\n        }\n      }\n      if (split &lt; 0) split = limit;\n      out = remaining.substring(0, split);\n      remaining = remaining.substring(split);\n      remaining.trim();\n    }\n\n    d.drawString(out, d.width() \/ 2, y);\n    y += 18;\n  }\n\n  if (remaining.length()) {\n    String tail = remaining;\n    while (tail.length() &gt; 3 &amp;&amp; d.textWidth(tail + \"...\") &gt; 180) tail.remove(tail.length() - 1);\n    tail += \"...\";\n    d.drawString(tail, d.width() \/ 2, y);\n  }\n}\n\nstatic void drawTitle(const String&amp; rawTitle) {\n  auto&amp; d = M5Dial.Display;\n  const int margin = 10;\n  const int maxW = d.width() - (margin * 2);\n\n  d.setTextColor(TFT_WHITE);\n  d.setTextDatum(top_center);\n  d.setTextFont(2);\n\n  String title = stripDegree(rawTitle);\n  if (d.textWidth(title) &gt; maxW) {\n    d.setTextFont(1);\n    while (title.length() &gt; 3 &amp;&amp; d.textWidth(title + \"...\") &gt; maxW) title.remove(title.length() - 1);\n    title += \"...\";\n  }\n  d.drawString(title, d.width() \/ 2, 10);\n}\n\nstatic void renderWeatherMain() {\n  auto&amp; d = M5Dial.Display;\n  d.clear(TFT_BLACK);\n\n  drawTitle(\"Local Weather\");\n  drawIcon(IconType::Weather, d.width() \/ 2, 78, 26);\n\n  d.setTextDatum(middle_center);\n  d.setTextFont(4);\n  d.drawString(stripDegree(gValueLine1), d.width() \/ 2, 140);\n\n  d.setTextFont(2);\n  d.drawString(stripDegree(gValueLine2), d.width() \/ 2, 175);\n\n  drawWrappedTwoLinesCentered(stripDegree(gWeatherSummary), d.height() - 44, d.width() - 20);\n}\n\nstatic void renderWeatherForecastPage(uint8_t forecastIdx) {\n  auto&amp; d = M5Dial.Display;\n  d.clear(TFT_BLACK);\n\n  if (forecastIdx &gt;= gDailyForecastCount || !gDailyForecast&#91;forecastIdx].valid) {\n    renderWeatherMain();\n    return;\n  }\n\n  drawTitle(\"Forecast\");\n  drawIcon(IconType::Weather, d.width() \/ 2, 68, 22);\n\n  d.setTextDatum(middle_center);\n  d.setTextFont(2);\n  d.drawString(gDailyForecast&#91;forecastIdx].label, d.width() \/ 2, 112);\n\n  d.setTextFont(4);\n  String hi = String(gDailyForecast&#91;forecastIdx].maxTemp) + \"C\";\n  d.drawString(hi, d.width() \/ 2, 146);\n\n  d.setTextFont(2);\n  String lo = \"Low \" + String(gDailyForecast&#91;forecastIdx].minTemp) + \"C\";\n  d.drawString(lo, d.width() \/ 2, 176);\n\n  drawWrappedTwoLinesCentered(gDailyForecast&#91;forecastIdx].summary, 198, d.width() - 20);\n}\n\nstatic void renderNewsPage(uint8_t idx) {\n  auto&amp; d = M5Dial.Display;\n  d.clear(TFT_BLACK);\n\n  drawTitle(\"Newsfeed\");\n  drawIcon(IconType::News, d.width() \/ 2, 58, 18);\n\n  d.setTextDatum(top_center);\n  d.setTextFont(1);\n\n  String caption = gNewsCaption;\n  caption.trim();\n  if (!caption.length()) caption = \"Top headlines\";\n  while (caption.length() &gt; 3 &amp;&amp; d.textWidth(caption + \"...\") &gt; 190) caption.remove(caption.length() - 1);\n  if (gNewsCaption.length() &amp;&amp; caption != gNewsCaption) caption += \"...\";\n  d.drawString(caption, d.width() \/ 2, 82);\n\n  if (idx &lt; gNewsHeadlineCount) {\n    drawCircularTextBlock(gNewsHeadlines&#91;idx], 108);\n  } else {\n    d.setTextFont(2);\n    d.setTextDatum(middle_center);\n    d.drawString(\"No headlines\", d.width() \/ 2, 150);\n  }\n}\n\nstatic void renderCurrentMetric() {\n  auto&amp; d = M5Dial.Display;\n  d.clear(TFT_BLACK);\n\n  const MetricItem&amp; m = METRICS&#91;gIndex];\n\n  if (m.type == MetricType::Weather) {\n    if (gWeatherAutoPage == 0) {\n      renderWeatherMain();\n    } else {\n      renderWeatherForecastPage(gWeatherAutoPage - 1);\n    }\n    return;\n  }\n\n  if (m.type == MetricType::Newsfeed) {\n    renderNewsPage(gNewsAutoIndex % (gNewsHeadlineCount ? gNewsHeadlineCount : 1));\n    return;\n  }\n\n  drawTitle(String(m.title));\n  drawIcon(m.icon, d.width() \/ 2, 78, 26);\n\n  d.setTextDatum(middle_center);\n  d.setTextFont(4);\n  d.drawString(stripDegree(gValueLine1), d.width() \/ 2, 140);\n\n  d.setTextFont(2);\n  d.drawString(stripDegree(gValueLine2), d.width() \/ 2, 175);\n}\n\nstatic void setMetricIndex(size_t idx) {\n  if (METRIC_COUNT == 0) {\n    gIndex = 0;\n    return;\n  }\n\n  gIndex = idx % METRIC_COUNT;\n  gLastFetchMs = 0;\n  gEncAccum = 0;\n  resetAutoPages();\n}\n\nstatic void refreshSelectedMetricIfDue(bool force) {\n  const MetricItem&amp; m = METRICS&#91;gIndex];\n  uint32_t minIntervalMs = (uint32_t)m.refresh_s * 1000UL;\n\n  if (!force &amp;&amp; gLastFetchMs != 0 &amp;&amp; (millis() - gLastFetchMs) &lt; minIntervalMs) return;\n  gLastFetchMs = millis();\n\n  if (m.type == MetricType::Weather) {\n    String t, w, h, icon, summary;\n    if (fetchCurrentWeather(t, w, h, icon, summary)) {\n      gValueLine1 = stripDegree(gWeatherTempLine.length() ? gWeatherTempLine : t);\n      gValueLine2 = String(\"Wind \") + stripDegree(w) + \"  Hum \" + stripDegree(h);\n      gWeatherSummary = stripDegree(summary);\n      gWeatherIconKey = icon;\n    } else {\n      gValueLine1 = \"Weather\";\n      gValueLine2 = \"Unavailable\";\n      gWeatherSummary = \"Check OWM settings\";\n      gWeatherIconKey = \"\";\n    }\n  } else if (m.type == MetricType::Newsfeed) {\n    if (!ensureNewsCache()) {\n      gNewsCaption = \"News unavailable\";\n      gNewsHeadlineCount = 0;\n    }\n    gValueLine1 = \"\";\n    gValueLine2 = \"\";\n    gWeatherSummary = \"\";\n  } else {\n    String state, unit;\n    bool isNum = false;\n    double num = 0;\n\n    if (haGetEntityState(m.ha_entity_id, state, unit, isNum, num)) {\n      if (isNum) {\n        gValueLine1 = stripDegree(String(m.prefix) + formatNumber(num, m.decimals) + String(m.suffix));\n      } else {\n        gValueLine1 = stripDegree(String(m.prefix) + state + String(m.suffix));\n      }\n\n      if (m.ha_entity_id &amp;&amp;\n         (strcmp(m.ha_entity_id, \"sensor.office_temperature\") == 0 ||\n          strcmp(m.ha_entity_id, \"sensor.office_humidity\") == 0 ||\n          strcmp(m.ha_entity_id, \"sensor.thermostat_1_current_temperature\") == 0)) {\n        gValueLine2 = \"\";\n      } else {\n        if (unit.length()) gValueLine2 = stripDegree(unit);\n        else gValueLine2 = \"\";\n      }\n\n      gWeatherSummary = \"\";\n    } else {\n      gValueLine1 = \"HA\";\n      gValueLine2 = \"Unavailable\";\n      gWeatherSummary = \"\";\n    }\n  }\n\n  renderCurrentMetric();\n}\n\nstatic void animateMetricChange(size_t newIndex) {\n  if (!gDisplayOn) {\n    setMetricIndex(newIndex);\n    refreshSelectedMetricIfDue(true);\n    return;\n  }\n\n  fadeBrightness(DISPLAY_BRIGHTNESS, 0, 75);\n  setMetricIndex(newIndex);\n  refreshSelectedMetricIfDue(true);\n  fadeBrightness(0, DISPLAY_BRIGHTNESS, 75);\n}\n\nstatic void turnMetricBy(int dir) {\n  if (METRIC_COUNT == 0 || dir == 0) return;\n\n  size_t newIndex;\n  if (dir &gt; 0) {\n    newIndex = (gIndex + 1) % METRIC_COUNT;\n  } else {\n    newIndex = (gIndex == 0) ? (METRIC_COUNT - 1) : (gIndex - 1);\n  }\n  animateMetricChange(newIndex);\n}\n\nstatic void handleAutoRotate() {\n  if (!gDisplayOn) return;\n\n  const MetricItem&amp; m = METRICS&#91;gIndex];\n  if (millis() - gMetricEnteredMs &lt; AUTO_ROTATE_DELAY_MS) return;\n\n  if (m.type == MetricType::Weather) {\n    if (gDailyForecastCount == 0) return;\n\n    if (millis() - gLastWeatherAutoRotateMs &gt;= AUTO_ROTATE_INTERVAL_MS) {\n      gLastWeatherAutoRotateMs = millis();\n\n      \/\/ Cycle: main -&gt; day1 -&gt; day2 -&gt; ... -&gt; lastday -&gt; main -&gt; repeat\n      uint8_t totalPages = gDailyForecastCount + 1;\n      gWeatherAutoPage = (gWeatherAutoPage + 1) % totalPages;\n\n      renderCurrentMetric();\n    }\n  }\n\n  if (m.type == MetricType::Newsfeed) {\n    if (gNewsHeadlineCount == 0) return;\n\n    if (millis() - gLastNewsAutoRotateMs &gt;= AUTO_ROTATE_INTERVAL_MS) {\n      gLastNewsAutoRotateMs = millis();\n      gNewsAutoIndex = (gNewsAutoIndex + 1) % gNewsHeadlineCount;\n      renderCurrentMetric();\n    }\n  }\n}\n\n\/\/ -----------------------------\n\/\/ Arduino\n\/\/ -----------------------------\nvoid setup() {\n  auto cfg = M5.config();\n  M5Dial.begin(cfg, true, false);\n\n  M5Dial.Display.setBrightness(DISPLAY_BRIGHTNESS);\n  M5Dial.Display.setTextColor(TFT_WHITE);\n  M5Dial.Display.setTextDatum(middle_center);\n\n  Serial.begin(115200);\n  delay(200);\n\n  gLastInteractionMs = millis();\n  resetAutoPages();\n  gLastEnc = M5Dial.Encoder.read();\n  gEncAccum = 0;\n\n  wifiEnsureConnected();\n\n  gValueLine1 = \"Loading...\";\n  gValueLine2 = \"\";\n  gWeatherSummary = \"\";\n  renderCurrentMetric();\n\n  refreshSelectedMetricIfDue(true);\n}\n\nvoid loop() {\n  M5Dial.update();\n\n  if (M5Dial.Touch.getCount() &gt; 0) {\n    markInteraction();\n    refreshSelectedMetricIfDue(true);\n  }\n\n  int32_t enc = M5Dial.Encoder.read();\n  if (gLastEnc == INT32_MIN) gLastEnc = enc;\n\n  int32_t delta = enc - gLastEnc;\n  if (delta != 0) {\n    markInteraction();\n    gLastEnc = enc;\n    gEncAccum += delta;\n  }\n\n  \/\/ Process at most one widget change per debounce window.\n  \/\/ This prevents wrap-around overshoot and \"Weather -&gt; Office Temperature\" jumps.\n  if (millis() - gLastPageTurnMs &gt;= ENC_PAGE_DEBOUNCE_MS) {\n    if (gEncAccum &gt;= ENC_STEP) {\n      gEncAccum = 0;\n      gLastPageTurnMs = millis();\n      turnMetricBy(+1);\n    } else if (gEncAccum &lt;= -ENC_STEP) {\n      gEncAccum = 0;\n      gLastPageTurnMs = millis();\n      turnMetricBy(-1);\n    }\n  }\n\n  if (WiFi.status() != WL_CONNECTED) {\n    static uint32_t lastRetry = 0;\n    if (millis() - lastRetry &gt; WIFI_RETRY_MS) {\n      lastRetry = millis();\n      wifiEnsureConnected();\n      refreshSelectedMetricIfDue(true);\n    }\n  }\n\n  if (gDisplayOn) {\n    refreshSelectedMetricIfDue(false);\n    handleAutoRotate();\n  }\n\n  maybeSleepDisplay();\n  delay(10);\n}\n<\/code><\/pre>\n\n\n<ol class=\"wp-block-footnotes\"><li id=\"c0f8f9c1-4189-44f3-a5f1-4b2693cd60d2\">Save this as &#8220;M5Dial_HA.ino&#8221; and open it in Arduino Studio <a href=\"#c0f8f9c1-4189-44f3-a5f1-4b2693cd60d2-link\" aria-label=\"Jump to footnote reference 1\">\u21a9\ufe0e<\/a><\/li><\/ol>\n\n\n<p><\/p>\n\n\n\n<p>The settings are held in &#8220;config.h&#8221;<\/p>\n\n\n\n<pre class=\"wp-block-code has-kubio-color-5-variant-2-background-color has-background has-small-font-size\" style=\"border-width:1px\"><code>#pragma once\n#include &lt;stdint.h&gt;\n\n\/\/ =================================\n\/\/ M5Dial - Weather and News Display\n\/\/ =================================\n\n\/\/ WiFi\nstatic const char* WIFI_SSID     = \"SSID_GOES_HERE\";\nstatic const char* WIFI_PASSWORD = \"WIFI_PASSWORD_GOES_HERE\";\n\n\/\/ Home Assistant\nstatic const char* HA_BASE_URL   = \"http:\/\/homeassistant.local:8123\";  \/\/ Point to your instances of Home Assistant.\nstatic const char* HA_BEARER_TOKEN = \"HA_KEY_GOES_HERE\";\n\n\/\/ OpenWeatherMap\nstatic const char* OWM_API_KEY   = \"OPENWEATHERMAP_API_KEY_GOES_HERE\";\nstatic const char* OWM_CITY      = \"Latchingdon\";\nstatic const char* OWM_COUNTRY   = \"GB\";\nstatic const char* OWM_UNITS     = \"metric\";\nstatic const char* OWM_LANG      = \"en\";\n\n\/\/ Newsfeed\nstatic const char* RSS_NEWS_URL  = \"https:\/\/feeds.bbci.co.uk\/news\/rss.xml?edition=uk\"; \/\/ Change to any RSS News feed\n\n\/\/ UI \/ Power\nstatic const uint32_t DISPLAY_SLEEP_MS   = 60UL * 1000UL;\nstatic const uint8_t  DISPLAY_BRIGHTNESS = 80;\n\n\/\/ Refresh behaviour\nstatic const uint32_t WIFI_RETRY_MS      = 10UL * 1000UL;\n\n\/\/ Widget timing\nstatic const uint32_t WEATHER_FORECAST_CACHE_MS = 60UL * 60UL * 1000UL; \/\/ 60 mins\nstatic const uint32_t AUTO_ROTATE_DELAY_MS      = 5UL * 1000UL;         \/\/ wait 5s before auto-rotate\nstatic const uint32_t AUTO_ROTATE_INTERVAL_MS   = 3UL * 1000UL;         \/\/ pause 3s on each page\nstatic const uint32_t NEWS_CACHE_MS             = 30UL * 60UL * 1000UL; \/\/ 30 mins\n\n\/\/ =======================\n\/\/ METRIC CONFIG\n\/\/ =======================\n\nenum class MetricType : uint8_t {\n  Weather = 0,\n  HAState = 1,\n  Newsfeed = 2,\n};\n\nenum class IconType : uint8_t {\n  Weather = 0,\n  Thermometer,\n  Droplet,\n  Gauge,\n  Info,\n  News,\n};\n\nstruct MetricItem {\n  MetricType type;\n  const char* title;\n\n  \/\/ For HAState only\n  const char* ha_entity_id;\n\n  \/\/ Formatting\n  const char* prefix;\n  const char* suffix;\n  uint8_t decimals;\n\n  \/\/ Icon\n  IconType icon;\n\n  \/\/ Refresh interval (seconds) while this metric is selected\n  uint16_t refresh_s;\n};\n\nstatic const MetricItem METRICS&#91;] = {\n  {\n    MetricType::Weather,\n    \"Local Weather\",\n    nullptr,\n    \"\", \"\", 0,\n    IconType::Weather,\n    300,\n  },\n  {\n    MetricType::HAState,\n    \"Office Temperature\",\n    \"sensor.office_temperature\",\n    \"\", \"C\", 1,\n    IconType::Thermometer,\n    30,\n  },\n  {\n    MetricType::HAState,\n    \"House Temperature\",\n    \"sensor.thermostat_1_current_temperature\",\n    \"\", \"C\", 1,\n    IconType::Thermometer,\n    30,\n  },\n  {\n    MetricType::HAState,\n    \"Office Humidity\",\n    \"sensor.office_humidity\",\n    \"\", \"% RH\", 0,\n    IconType::Droplet,\n    30,\n  },\n  {\n    MetricType::Newsfeed,\n    \"Newsfeed\",\n    nullptr,\n    \"\", \"\", 0,\n    IconType::News,\n    1800,\n  },\n};\n\nstatic const size_t METRIC_COUNT = sizeof(METRICS) \/ sizeof(METRICS&#91;0]);\n<\/code><\/pre>\n\n\n\n<p><\/p>\n","protected":false},"excerpt":{"rendered":"<p>I&#8217;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 [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":513,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":"[{\"content\":\"Save this as \\\"M5Dial_HA.ino\\\" and open it in Arduino Studio\",\"id\":\"c0f8f9c1-4189-44f3-a5f1-4b2693cd60d2\"}]"},"categories":[48],"tags":[],"class_list":["post-510","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-arduino"],"_links":{"self":[{"href":"https:\/\/www.internet-tools.co.uk\/blog\/index.php\/wp-json\/wp\/v2\/posts\/510","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.internet-tools.co.uk\/blog\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.internet-tools.co.uk\/blog\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.internet-tools.co.uk\/blog\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.internet-tools.co.uk\/blog\/index.php\/wp-json\/wp\/v2\/comments?post=510"}],"version-history":[{"count":2,"href":"https:\/\/www.internet-tools.co.uk\/blog\/index.php\/wp-json\/wp\/v2\/posts\/510\/revisions"}],"predecessor-version":[{"id":514,"href":"https:\/\/www.internet-tools.co.uk\/blog\/index.php\/wp-json\/wp\/v2\/posts\/510\/revisions\/514"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.internet-tools.co.uk\/blog\/index.php\/wp-json\/wp\/v2\/media\/513"}],"wp:attachment":[{"href":"https:\/\/www.internet-tools.co.uk\/blog\/index.php\/wp-json\/wp\/v2\/media?parent=510"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.internet-tools.co.uk\/blog\/index.php\/wp-json\/wp\/v2\/categories?post=510"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.internet-tools.co.uk\/blog\/index.php\/wp-json\/wp\/v2\/tags?post=510"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}