
Latest posts made by PaulMcGuinness_UK
-
M5Dial Example App - Home Assistant, RSS News and OpenWeatherMapposted in Arduino
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]); -
Simple Mesh implimentationposted in PROJECTS
Any suggestions on a simple library to add MESH capabilities to an M5StickC
I don't want it to interfere with normal WiFi operation, and I just need to broadcast small packets of data between multiple M5StickC's that are not connected to WiFi to one that is
Essentially (M5StickC x 10 not on WiFi) ---> MESH ----> M5StickC on WiFi
-
UnitV AI Cameraposted in Units
I am looking for a simple example that does the following:-
- Define the kmodel to use (e.g. "face", "car" etc.)
- Each time a object is detected, send metadata through the Grove port so I can process the events on an m5StickC