From 2df807da2b71b77f1e3ad981acbfb04722f5b624 Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Thu, 28 Aug 2025 11:06:47 -0700 Subject: [PATCH 01/20] Add SkyStrip weather forecast usermod --- usermods/usermod_v2_skystrip/FAQ.md | 97 +++++ usermods/usermod_v2_skystrip/cloud_view.cpp | 218 ++++++++++ usermods/usermod_v2_skystrip/cloud_view.h | 27 ++ usermods/usermod_v2_skystrip/delta_view.cpp | 176 ++++++++ usermods/usermod_v2_skystrip/delta_view.h | 27 ++ usermods/usermod_v2_skystrip/interfaces.h | 54 +++ usermods/usermod_v2_skystrip/library.json | 4 + .../open_weather_map_source.cpp | 406 ++++++++++++++++++ .../open_weather_map_source.h | 43 ++ usermods/usermod_v2_skystrip/readme.md | 32 ++ .../usermod_v2_skystrip/rest_json_client.cpp | 61 +++ .../usermod_v2_skystrip/rest_json_client.h | 34 ++ usermods/usermod_v2_skystrip/skymodel.cpp | 159 +++++++ usermods/usermod_v2_skystrip/skymodel.h | 47 ++ .../usermod_v2_skystrip/temperature_view.cpp | 195 +++++++++ .../usermod_v2_skystrip/temperature_view.h | 29 ++ .../usermod_v2_skystrip/test_pattern_view.cpp | 179 ++++++++ .../usermod_v2_skystrip/test_pattern_view.h | 29 ++ .../usermod_v2_skystrip.cpp | 252 +++++++++++ .../usermod_v2_skystrip/usermod_v2_skystrip.h | 51 +++ usermods/usermod_v2_skystrip/util.cpp | 25 ++ usermods/usermod_v2_skystrip/util.h | 149 +++++++ usermods/usermod_v2_skystrip/wind_view.cpp | 127 ++++++ usermods/usermod_v2_skystrip/wind_view.h | 27 ++ wled00/const.h | 1 + 25 files changed, 2449 insertions(+) create mode 100644 usermods/usermod_v2_skystrip/FAQ.md create mode 100644 usermods/usermod_v2_skystrip/cloud_view.cpp create mode 100644 usermods/usermod_v2_skystrip/cloud_view.h create mode 100644 usermods/usermod_v2_skystrip/delta_view.cpp create mode 100644 usermods/usermod_v2_skystrip/delta_view.h create mode 100644 usermods/usermod_v2_skystrip/interfaces.h create mode 100644 usermods/usermod_v2_skystrip/library.json create mode 100644 usermods/usermod_v2_skystrip/open_weather_map_source.cpp create mode 100644 usermods/usermod_v2_skystrip/open_weather_map_source.h create mode 100644 usermods/usermod_v2_skystrip/readme.md create mode 100644 usermods/usermod_v2_skystrip/rest_json_client.cpp create mode 100644 usermods/usermod_v2_skystrip/rest_json_client.h create mode 100644 usermods/usermod_v2_skystrip/skymodel.cpp create mode 100644 usermods/usermod_v2_skystrip/skymodel.h create mode 100644 usermods/usermod_v2_skystrip/temperature_view.cpp create mode 100644 usermods/usermod_v2_skystrip/temperature_view.h create mode 100644 usermods/usermod_v2_skystrip/test_pattern_view.cpp create mode 100644 usermods/usermod_v2_skystrip/test_pattern_view.h create mode 100644 usermods/usermod_v2_skystrip/usermod_v2_skystrip.cpp create mode 100644 usermods/usermod_v2_skystrip/usermod_v2_skystrip.h create mode 100644 usermods/usermod_v2_skystrip/util.cpp create mode 100644 usermods/usermod_v2_skystrip/util.h create mode 100644 usermods/usermod_v2_skystrip/wind_view.cpp create mode 100644 usermods/usermod_v2_skystrip/wind_view.h diff --git a/usermods/usermod_v2_skystrip/FAQ.md b/usermods/usermod_v2_skystrip/FAQ.md new file mode 100644 index 0000000000..5b905fc13c --- /dev/null +++ b/usermods/usermod_v2_skystrip/FAQ.md @@ -0,0 +1,97 @@ +# SkyStrip Interpretation Guide + +This FAQ explains how to read the various HSV-based views of the +`usermod_v2_skystrip` module. Each view maps weather data onto hue, +saturation, and value (brightness) along the LED strip. + +## Cloud View (CV) + +Markers for sunrise or sunset show as orange pixels. During +precipitation, hue denotes type—deep blue for rain, lavender for snow, +and indigo for mixed—while value scales with probability. In the +absence of precipitation, hue differentiates day from night: daylight +clouds appear pale yellow, nighttime clouds desaturate toward +white. For clouds, saturation is low and value grows with coverage, +keeping even thin clouds visible. Thus, a bright blue pixel highlights +likely rain, whereas a soft yellow glow marks daytime cloud cover. + +## Wind View (WV) + +The hue encodes wind direction around the compass: blue (240°) points +north, orange (~30°) east, yellow (~60°) south, and green (~120°) +west, with intermediate shades for diagonal winds. Saturation rises +with gustiness—calm breezes stay washed out while strong gusts drive +the color toward full intensity. Value scales with wind strength, +boosting brightness as the highest of sustained speed or gust +approaches 50 mph (or equivalent). For example, a saturated blue pixel +indicates gusty north winds, while a dim pastel green suggests a +gentle westerly breeze. + +The mapping between wind direction and hue can be approximated as: + +| Direction | Hue (°) | Color | +|-----------|---------|--------| +| N | 240 | Blue | +| NE | 300 | Purple | +| E | 30 | Orange | +| SE | 45 | Gold | +| S | 60 | Yellow | +| SW | 90 | Lime | +| W | 120 | Green | +| NW | 180 | Cyan | +| N | 240 | Blue | + + +## Temperature View (TV) + +Hue follows a cold-to-hot gradient: deep blues near 14 °F transition +through cyan and green to warm yellows at 77 °F and reds above +100 °F. Saturation reflects humidity via dew‑point spread; muggy air +produces soft desaturated colors, whereas dry air yields vivid +tones. Value is fixed at mid‑brightness, but local time markers (e.g., +noon, midnight) temporarily darken pixels to mark time. A bright +orange‑red pixel thus signifies hot, dry conditions around 95 °F, +whereas a pale cyan pixel indicates a cool, humid day near 50 °F. + +Approximate temperature-to-hue mapping: + +| Temp (°F) | Hue (°) | Color | +|-----------|---------|------------| +| ≤14 | 240 | Deep blue | +| 32 | 210 | Blue-cyan | +| 50 | 180 | Cyan | +| 68 | 150 | Green-cyan | +| 77 | 60 | Yellow | +| 95 | 30 | Orange | +| ≥100 | 0 | Red | + + +## 24 Hour Delta View (DV) + +Hue represents the temperature change relative to the previous day: +blues for cooling, greens for steady conditions, and yellows through +reds for warming. Saturation encodes humidity trend—the color +intensifies as the air grows drier and fades toward pastels when +becoming more humid. Value increases with the magnitude of change, +combining temperature and humidity shifts, so bright pixels flag +larger swings. A dim blue pixel therefore means a slight cool‑down +with more moisture, while a bright saturated red indicates rapid +warming coupled with drying. + +Approximate mapping of day-to-day deltas to color attributes: + +| Temperature | Hue (Color) | | Humidity | Saturation | +|-------------|-------------| |------------|------------| +| Cooling | Blue tones | | More humid | Low/Pastel | +| Steady | Green | | Stable | Medium | +| Warming | Yellow→Red | | Drier | High/Vivid | + + +## Test Pattern View (TP) + +This diagnostic view simply interpolates hue, saturation, and value +between configured start and end points along the segment. Hue shifts +steadily from the starting hue to the ending hue, with saturation and +brightness following the same linear ramp. It carries no weather +meaning; a common example is a gradient from black to white to verify +LED orientation. diff --git a/usermods/usermod_v2_skystrip/cloud_view.cpp b/usermods/usermod_v2_skystrip/cloud_view.cpp new file mode 100644 index 0000000000..154bee8965 --- /dev/null +++ b/usermods/usermod_v2_skystrip/cloud_view.cpp @@ -0,0 +1,218 @@ +#include "cloud_view.h" +#include "skymodel.h" +#include "util.h" +#include "wled.h" +#include +#include +#include + +static constexpr int16_t DEFAULT_SEG_ID = -1; // -1 means disabled +const char CFG_SEG_ID[] = "SegmentId"; + +static bool isDay(const SkyModel &m, time_t t) { + const time_t MAXTT = std::numeric_limits::max(); + if (m.sunrise_ == 0 && m.sunset_ == MAXTT) + return true; // 24h day + if (m.sunset_ == 0 && m.sunrise_ == MAXTT) + return false; // 24h night + constexpr time_t DAY = 24 * 60 * 60; + time_t sr = m.sunrise_; + time_t ss = m.sunset_; + while (t >= ss) { + sr += DAY; + ss += DAY; + } + while (t < sr) { + sr -= DAY; + ss -= DAY; + } + return t >= sr && t < ss; +} + +CloudView::CloudView() : segId_(DEFAULT_SEG_ID) { + DEBUG_PRINTLN("SkyStrip: CV::CTOR"); + snprintf(debugPixelString, sizeof(debugPixelString), "%s:\\n", + name().c_str()); + debugPixelString[sizeof(debugPixelString) - 1] = '\0'; +} + +void CloudView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) { + if (dbgPixelIndex < 0) { + snprintf(debugPixelString, sizeof(debugPixelString), "%s:\\n", + name().c_str()); + debugPixelString[sizeof(debugPixelString) - 1] = '\0'; + } + if (segId_ == DEFAULT_SEG_ID) + return; + if (model.cloud_cover_forecast.empty()) + return; + if (segId_ < 0 || segId_ >= strip.getMaxSegments()) + return; + + Segment &seg = strip.getSegment((uint8_t)segId_); + seg.freeze = true; + int start = seg.start; + int end = seg.stop - 1; + int len = end - start + 1; + if (len == 0) + return; + + constexpr double kHorizonSec = 48.0 * 3600.0; + const double step = (len > 1) ? (kHorizonSec / double(len - 1)) : 0.0; + + const time_t markerTol = time_t(std::llround(step * 0.5)); + const time_t sunrise = model.sunrise_; + const time_t sunset = model.sunset_; + constexpr time_t DAY = 24 * 60 * 60; + const time_t MAXTT = std::numeric_limits::max(); + + long offset = skystrip::util::current_offset(); + + bool useSunrise = (sunrise != 0 && sunrise != MAXTT); + bool useSunset = (sunset != 0 && sunset != MAXTT); + time_t sunriseTOD = 0; + time_t sunsetTOD = 0; + if (useSunrise) + sunriseTOD = (sunrise + offset) % DAY; + if (useSunset) + sunsetTOD = (sunset + offset) % DAY; + + auto nearTOD = [&](time_t a, time_t b) { + time_t diff = (a >= b) ? (a - b) : (b - a); + if (diff <= markerTol) + return true; + return (DAY - diff) <= markerTol; + }; + + auto isMarker = [&](time_t t) { + if (!useSunrise && !useSunset) + return false; + time_t tod = (t + offset) % DAY; + if (useSunrise && nearTOD(tod, sunriseTOD)) + return true; + if (useSunset && nearTOD(tod, sunsetTOD)) + return true; + return false; + }; + + constexpr float kCloudMaskThreshold = 0.05f; + constexpr float kDayHue = 60.f; + constexpr float kNightHue = 300.f; + constexpr float kDaySat = 0.30f; + constexpr float kNightSat = 0.00f; + constexpr float kDayVMax = 0.40f; + // slightly higher night maximum so low clouds are more visible + constexpr float kNightVMax= 0.40f; + + // Brightness floor as a fraction of Vmax so mid/low clouds stay visible. + constexpr float kDayVMinFrac = 0.50f; // try 0.40–0.60 to taste + constexpr float kNightVMinFrac = 0.50f; // night can be a bit lower if preferred + + constexpr float kMarkerHue= 25.f; + constexpr float kMarkerSat= 0.60f; + constexpr float kMarkerVal= 0.50f; + + for (int i = 0; i < len; ++i) { + const time_t t = now + time_t(std::llround(step * i)); + double clouds, precipTypeVal, precipProb; + if (!skystrip::util::estimateCloudAt(model, t, step, clouds)) + continue; + if (!skystrip::util::estimatePrecipTypeAt(model, t, step, precipTypeVal)) + precipTypeVal = 0.0; + if (!skystrip::util::estimatePrecipProbAt(model, t, step, precipProb)) + precipProb = 0.0; + + float clouds01 = skystrip::util::clamp01(float(clouds / 100.0)); + int p = int(std::round(precipTypeVal)); + bool daytime = isDay(model, t); + int idx = seg.reverse ? (end - i) : (start + i); + + float hue = 0.f, sat = 0.f, val = 0.f; + if (isMarker(t)) { + // always put the sunrise sunset markers in + hue = kMarkerHue; + sat = kMarkerSat; + val = kMarkerVal; + } else if (p != 0 && precipProb > 0.0) { + // precipitation has next priority: rain=blue, snow=lavender, + // mixed=indigo-ish blend + constexpr float kHueRain = 210.f; // deep blue + constexpr float kSatRain = 1.00f; + + constexpr float kHueSnow = 285.f; // lavender for snow + constexpr float kSatSnow = 0.35f; // pastel-ish (tune to taste) + + float ph, ps; + if (p == 1) { + // rain + ph = kHueRain; + ps = kSatRain; + } else if (p == 2) { + // snow → lavender + ph = kHueSnow; + ps = kSatSnow; + } else { + // mixed → halfway between blue and lavender + ph = 0.5f * (kHueRain + kHueSnow); // ~247.5° (indigo-ish) + ps = 0.5f * (kSatRain + kSatSnow); // ~0.675 + } + + float pv = skystrip::util::clamp01(float(precipProb)); + pv = 0.3f + 0.7f * pv; // brightness ramp + hue = ph; + sat = ps; + val = pv; + } else { + // finally show daytime or nightime clouds + if (clouds01 < kCloudMaskThreshold) { + hue = 0.f; + sat = 0.f; + val = 0.f; + } else { + float vmax = daytime ? kDayVMax : kNightVMax; + float vmin = (daytime ? kDayVMinFrac : kNightVMinFrac) * vmax; + // Use sqrt curve to boost brightness at lower cloud coverage + val = vmin + (vmax - vmin) * sqrtf(clouds01); + hue = daytime ? kDayHue : kNightHue; + sat = daytime ? kDaySat : kNightSat; + } + } + + uint32_t col = skystrip::util::hsv2rgb(hue, sat, val); + strip.setPixelColor(idx, skystrip::util::blinkDebug(i, dbgPixelIndex, col)); + + if (dbgPixelIndex >= 0) { + static time_t lastDebug = 0; + if (now - lastDebug > 1 && i == dbgPixelIndex) { + char nowbuf[20]; + skystrip::util::fmt_local(nowbuf, sizeof(nowbuf), now); + char dbgbuf[20]; + skystrip::util::fmt_local(dbgbuf, sizeof(dbgbuf), t); + snprintf(debugPixelString, sizeof(debugPixelString), + "%s: nowtm=%s dbgndx=%d dbgtm=%s day=%d clouds01=%.2f precip=%d pop=%.2f H=%.0f S=%.0f V=%.0f\\n", + name().c_str(), nowbuf, i, dbgbuf, daytime, clouds01, p, + precipProb, hue, sat * 100, val * 100); + lastDebug = now; + } + } + } +} + +void CloudView::addToConfig(JsonObject &subtree) { + subtree[FPSTR(CFG_SEG_ID)] = segId_; +} + +void CloudView::appendConfigData(Print &s) { + // Keep the hint INLINE (BEFORE the input = 4th arg): + s.print(F("addInfo('SkyStrip:CloudView:SegmentId',1,''," + "' (-1 disables)'" + ");")); +} + +bool CloudView::readFromConfig(JsonObject &subtree, bool startup_complete, + bool &invalidate_history) { + bool configComplete = !subtree.isNull(); + configComplete &= + getJsonValue(subtree[FPSTR(CFG_SEG_ID)], segId_, DEFAULT_SEG_ID); + return configComplete; +} diff --git a/usermods/usermod_v2_skystrip/cloud_view.h b/usermods/usermod_v2_skystrip/cloud_view.h new file mode 100644 index 0000000000..f07115ba9f --- /dev/null +++ b/usermods/usermod_v2_skystrip/cloud_view.h @@ -0,0 +1,27 @@ +#pragma once + +#include "interfaces.h" +#include "skymodel.h" + +class SkyModel; + +class CloudView : public IDataViewT { +public: + CloudView(); + ~CloudView() override = default; + + void view(time_t now, SkyModel const & model, int16_t dbgPixelIndex) override; + std::string name() const override { return "CV"; } + void appendDebugPixel(Print& s) const override { s.print(debugPixelString); } + + void addToConfig(JsonObject& subtree) override; + void appendConfigData(Print& s) override; + bool readFromConfig(JsonObject& subtree, + bool startup_complete, + bool& invalidate_history) override; + const char* configKey() const override { return "CloudView"; } + +private: + int16_t segId_; + char debugPixelString[128]; +}; diff --git a/usermods/usermod_v2_skystrip/delta_view.cpp b/usermods/usermod_v2_skystrip/delta_view.cpp new file mode 100644 index 0000000000..9dcc0102e8 --- /dev/null +++ b/usermods/usermod_v2_skystrip/delta_view.cpp @@ -0,0 +1,176 @@ +#include +#include + +#include "delta_view.h" +#include "skymodel.h" +#include "util.h" +#include "wled.h" + +static constexpr int16_t DEFAULT_SEG_ID = -1; // -1 means disabled +const char CFG_SEG_ID[] = "SegmentId"; + +struct Stop { + double f; + float h; +}; +// Delta color ramp (°F) +static const Stop kStopsF[] = { + {-20, 240.f}, // very cooling (blue) + {-10, 210.f}, // cooling + {-5, 180.f}, // slight cooling (cyan) + {0, 120.f}, // neutral (green) + {5, 60.f}, // slight warming (yellow) + {10, 30.f}, // warming (orange) + {20, 0.f}, // very warming (red) +}; + +static float hueForDeltaF(double f) { + if (f <= kStopsF[0].f) + return kStopsF[0].h; + for (size_t i = 1; i < sizeof(kStopsF) / sizeof(kStopsF[0]); ++i) { + if (f <= kStopsF[i].f) { + const auto &A = kStopsF[i - 1]; + const auto &B = kStopsF[i]; + const double u = (f - A.f) / (B.f - A.f); + return float(skystrip::util::lerp(A.h, B.h, u)); + } + } + return kStopsF[sizeof(kStopsF) / sizeof(kStopsF[0]) - 1].h; +} + +static inline float satFromDewDiffDelta(float delta) { + constexpr float kMinSat = 0.30f; + constexpr float kMaxDelta = 15.0f; // +/-15F covers typical range + float u = skystrip::util::clamp01((delta + kMaxDelta) / (2.f * kMaxDelta)); + return kMinSat + (1.f - kMinSat) * u; +} + +static inline float intensityFromDeltas(double tempDelta, float humidDelta) { + constexpr float kMaxTempDelta = 20.0f; // +/-20F covers intensity range + constexpr float kMaxHumDelta = 15.0f; // +/-15F covers typical humidity range + float uT = skystrip::util::clamp01(float(std::fabs(tempDelta)) / kMaxTempDelta); + float uH = skystrip::util::clamp01(std::fabs(humidDelta) / kMaxHumDelta); + return skystrip::util::clamp01(std::sqrt(uT * uT + uH * uH)) * 0.9; +} + +DeltaView::DeltaView() : segId_(DEFAULT_SEG_ID) { + DEBUG_PRINTLN("SkyStrip: DV::CTOR"); + snprintf(debugPixelString, sizeof(debugPixelString), "%s:\\n", + name().c_str()); + debugPixelString[sizeof(debugPixelString) - 1] = '\0'; +} + +void DeltaView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) { + if (dbgPixelIndex < 0) { + snprintf(debugPixelString, sizeof(debugPixelString), "%s:\\n", + name().c_str()); + debugPixelString[sizeof(debugPixelString) - 1] = '\0'; + } + if (segId_ == DEFAULT_SEG_ID) + return; + if (model.temperature_forecast.empty()) + return; + if (segId_ < 0 || segId_ >= strip.getMaxSegments()) + return; + + Segment &seg = strip.getSegment((uint8_t)segId_); + seg.freeze = true; + int start = seg.start; + int end = seg.stop - 1; + int len = end - start + 1; + if (len == 0) + return; + + constexpr double kHorizonSec = 48.0 * 3600.0; + const double step = (len > 1) ? (kHorizonSec / double(len - 1)) : 0.0; + const time_t day = 24 * 3600; + + for (uint16_t i = 0; i < len; ++i) { + const time_t t = now + time_t(std::llround(step * i)); + int idx = seg.reverse ? (end - i) : (start + i); + + double tempNow, tempPrev; + bool foundTempNow = + skystrip::util::estimateAt(model.temperature_forecast, t, step, tempNow); + bool foundTempPrev = + skystrip::util::estimateAt(model.temperature_forecast, t - day, step, tempPrev); + + if (!foundTempNow || !foundTempPrev) { + if (dbgPixelIndex >= 0) { + static time_t lastDebug = 0; + if (now - lastDebug > 1 && i == dbgPixelIndex) { + char nowbuf[20]; + skystrip::util::fmt_local(nowbuf, sizeof(nowbuf), now); + char dbgbuf[20]; + skystrip::util::fmt_local(dbgbuf, sizeof(dbgbuf), t); + char prvbuf[20]; + skystrip::util::fmt_local(prvbuf, sizeof(prvbuf), t - day); + snprintf(debugPixelString, sizeof(debugPixelString), + "%s: nowtm=%s dbgndx=%d dbgtm=%s prvtm=%s " + "foundTempPrev=%d foundTempNow=%d\\n", + name().c_str(), nowbuf, i, dbgbuf, prvbuf, foundTempPrev, + foundTempNow); + lastDebug = now; + } + } + strip.setPixelColor(idx, 0); + continue; + } + double deltaT = tempNow - tempPrev; + + double dewNow, dewPrev; + float sat = 1.0f; + float spreadDelta = 0.f; + if (skystrip::util::estimateAt(model.dew_point_forecast, t, step, dewNow) && + skystrip::util::estimateAt(model.dew_point_forecast, t - day, step, dewPrev)) { + float spreadNow = float(tempNow - dewNow); + float spreadPrev = float(tempPrev - dewPrev); + spreadDelta = spreadNow - spreadPrev; + sat = satFromDewDiffDelta(spreadDelta); + } + + float hue = hueForDeltaF(deltaT); + float val = intensityFromDeltas(deltaT, spreadDelta); + uint32_t col = skystrip::util::hsv2rgb(hue, sat, val); + + if (dbgPixelIndex >= 0) { + static time_t lastDebug = 0; + if (now - lastDebug > 1 && i == dbgPixelIndex) { + char nowbuf[20]; + skystrip::util::fmt_local(nowbuf, sizeof(nowbuf), now); + char dbgbuf[20]; + skystrip::util::fmt_local(dbgbuf, sizeof(dbgbuf), t); + char prvbuf[20]; + skystrip::util::fmt_local(prvbuf, sizeof(prvbuf), t - day); + snprintf(debugPixelString, sizeof(debugPixelString), + "%s: nowtm=%s dbgndx=%d dbgtm=%s prvtm=%s " + "dT=%.1f dSpread=%.1f " + "H=%.0f S=%.0f V=%.0f\\n", + name().c_str(), nowbuf, i, dbgbuf, prvbuf, deltaT, spreadDelta, + hue, sat * 100, val * 100); + lastDebug = now; + } + } + + strip.setPixelColor(idx, skystrip::util::blinkDebug(i, dbgPixelIndex, col)); + } +} + +void DeltaView::addToConfig(JsonObject &subtree) { + subtree[FPSTR(CFG_SEG_ID)] = segId_; +} + +void DeltaView::appendConfigData(Print &s) { + // Keep the hint INLINE (BEFORE the input = 4th arg): + s.print(F("addInfo('SkyStrip:DeltaView:SegmentId',1,''," + "' (-1 disables)'" + ");")); +} + +bool DeltaView::readFromConfig(JsonObject &subtree, bool startup_complete, + bool &invalidate_history) { + bool configComplete = !subtree.isNull(); + configComplete &= + getJsonValue(subtree[FPSTR(CFG_SEG_ID)], segId_, DEFAULT_SEG_ID); + return configComplete; +} diff --git a/usermods/usermod_v2_skystrip/delta_view.h b/usermods/usermod_v2_skystrip/delta_view.h new file mode 100644 index 0000000000..46a7d3a436 --- /dev/null +++ b/usermods/usermod_v2_skystrip/delta_view.h @@ -0,0 +1,27 @@ +#pragma once + +#include "interfaces.h" +#include "skymodel.h" + +class SkyModel; + +class DeltaView : public IDataViewT { +public: + DeltaView(); + ~DeltaView() override = default; + + void view(time_t now, SkyModel const & model, int16_t dbgPixelIndex) override; + std::string name() const override { return "DV"; } + void appendDebugPixel(Print& s) const override { s.print(debugPixelString); } + + void addToConfig(JsonObject& subtree) override; + void appendConfigData(Print& s) override; + bool readFromConfig(JsonObject& subtree, + bool startup_complete, + bool& invalidate_history) override; + const char* configKey() const override { return "DeltaView"; } + +private: + int16_t segId_; + char debugPixelString[256]; +}; diff --git a/usermods/usermod_v2_skystrip/interfaces.h b/usermods/usermod_v2_skystrip/interfaces.h new file mode 100644 index 0000000000..44f60bbb93 --- /dev/null +++ b/usermods/usermod_v2_skystrip/interfaces.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include +#include "wled.h" + +/// Config interface +struct IConfigurable { + virtual ~IConfigurable() = default; + virtual void addToConfig(JsonObject& root) = 0; + virtual void appendConfigData(Print& s) {} + virtual bool readFromConfig(JsonObject& root, + bool startup_complete, + bool& invalidate_history) = 0; + virtual const char* configKey() const = 0; +}; + +/// Templated data source interface +/// @tparam ModelType The concrete data model type +template +class IDataSourceT : public IConfigurable { +public: + virtual ~IDataSourceT() = default; + + /// Fetch new data, nullptr if no new data + virtual std::unique_ptr fetch(std::time_t now) = 0; + + /// Backfill older history if needed, nullptr if no new data + virtual std::unique_ptr checkhistory(std::time_t now, std::time_t oldestTstamp) = 0; + + /// Force the internal schedule to fetch ASAP (e.g. after ON or re-enable) + virtual void reload(std::time_t now) = 0; + + /// Identify the source (optional) + virtual std::string name() const = 0; +}; + +/// Templated data view interface +/// @tparam ModelType The concrete data model type +template +class IDataViewT : public IConfigurable { +public: + virtual ~IDataViewT() = default; + + /// Render the model to output (LEDs, serial, etc.) + virtual void view(std::time_t now, const ModelType& model, int16_t dbgPixelIndex) = 0; + + /// Identify the view (optional) + virtual std::string name() const = 0; + + /// Append DebugPixel info + virtual void appendDebugPixel(Print& s) const = 0; +}; diff --git a/usermods/usermod_v2_skystrip/library.json b/usermods/usermod_v2_skystrip/library.json new file mode 100644 index 0000000000..8ad4ede90d --- /dev/null +++ b/usermods/usermod_v2_skystrip/library.json @@ -0,0 +1,4 @@ +{ + "name": "usermod_v2_skystrip", + "build": { "libArchive": false } +} diff --git a/usermods/usermod_v2_skystrip/open_weather_map_source.cpp b/usermods/usermod_v2_skystrip/open_weather_map_source.cpp new file mode 100644 index 0000000000..d668a5fb56 --- /dev/null +++ b/usermods/usermod_v2_skystrip/open_weather_map_source.cpp @@ -0,0 +1,406 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "open_weather_map_source.h" +#include "skymodel.h" +#include "util.h" + +static constexpr const char* DEFAULT_API_BASE = "https://api.openweathermap.org"; +static constexpr const char * DEFAULT_API_KEY = ""; +static constexpr const char * DEFAULT_LOCATION = ""; +static constexpr const double DEFAULT_LATITUDE = 37.80486; +static constexpr const double DEFAULT_LONGITUDE = -122.2716; +static constexpr unsigned DEFAULT_INTERVAL_SEC = 3600; // 1 hour + +// - these are user visible in the webapp settings UI +// - they are scoped to this module, don't need to be globally unique +// +const char CFG_API_BASE[] = "ApiBase"; +const char CFG_API_KEY[] = "ApiKey"; +const char CFG_LATITUDE[] = "Latitude"; +const char CFG_LONGITUDE[] = "Longitude"; +const char CFG_INTERVAL_SEC[] = "IntervalSec"; +const char CFG_LOCATION[] = "Location"; + +// keep commas; encode spaces etc. +static void urlEncode(const char* src, char* dst, size_t dstSize) { + static const char hex[] = "0123456789ABCDEF"; + size_t di = 0; + for (size_t i = 0; src[i] && di + 4 < dstSize; ++i) { + unsigned char c = static_cast(src[i]); + if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || + c == '~' || c == ',') { + dst[di++] = c; + } else if (c == ' ') { + dst[di++] = '%'; dst[di++] = '2'; dst[di++] = '0'; + } else { + dst[di++] = '%'; dst[di++] = hex[c >> 4]; dst[di++] = hex[c & 0xF]; + } + } + dst[di] = '\0'; +} + +// Normalize "Oakland, CA, USA" → "Oakland,CA,US" in-place +static void normalizeLocation(char* q) { + // trim spaces and commas + size_t len = strlen(q); + char* out = q; + for (size_t i = 0; i < len; ++i) { + if (q[i] != ' ') *out++ = q[i]; + } + *out = '\0'; + len = strlen(q); + if (len >= 4 && strcasecmp(q + len - 4, ",USA") == 0) { + q[len - 2] = 'U'; + q[len - 1] = 'S'; + q[len] = '\0'; + } +} + +static bool parseCoordToken(char* token, double& out) { + while (isspace(*token)) ++token; + bool neg = false; + if (*token == 's' || *token == 'S' || *token == 'w' || *token == 'W') { + neg = true; ++token; + } else if (*token == 'n' || *token == 'N' || *token == 'e' || *token == 'E') { + ++token; + } + while (isspace(*token)) ++token; + char* end = token + strlen(token); + while (end > token && isspace(end[-1])) --end; + if (end > token) { + char c = end[-1]; + if (c == 's' || c == 'S' || c == 'w' || c == 'W') { neg = true; --end; } + else if (c == 'n' || c == 'N' || c == 'e' || c == 'E') { --end; } + } + *end = '\0'; + for (char* p = token; *p; ++p) { + if (*p == '\"' || *p == '\'' ) *p = ' '; + if ((unsigned char)*p == 0xC2 || (unsigned char)*p == 0xB0) *p = ' '; + } + char* rest = nullptr; + double deg = strtod(token, &rest); + if (rest == token) return false; + bool negNum = deg < 0; deg = fabs(deg); + double min = 0, sec = 0; + if (*rest) { + min = strtod(rest, &rest); + if (*rest) { + sec = strtod(rest, &rest); + } + } + if (negNum) neg = true; + out = deg + min / 60.0 + sec / 3600.0; + if (neg) out = -out; + return true; +} + +static bool parseLatLon(const char* s, double& lat, double& lon) { + char buf[64]; + strncpy(buf, s, sizeof(buf)); + buf[sizeof(buf)-1] = '\0'; + char *a = nullptr, *b = nullptr; + char *comma = strchr(buf, ','); + if (comma) { + *comma = '\0'; + a = buf; b = comma + 1; + } else { + char *space = strrchr(buf, ' '); + if (!space) return false; + *space = '\0'; + a = buf; b = space + 1; + } + if (!parseCoordToken(a, lat)) return false; + if (!parseCoordToken(b, lon)) return false; + return true; +} + +OpenWeatherMapSource::OpenWeatherMapSource() + : apiBase_(DEFAULT_API_BASE) + , apiKey_(DEFAULT_API_KEY) + , location_(DEFAULT_LOCATION) + , latitude_(DEFAULT_LATITUDE) + , longitude_(DEFAULT_LONGITUDE) + , intervalSec_(DEFAULT_INTERVAL_SEC) + , lastFetch_(0) + , lastHistFetch_(0) { + DEBUG_PRINTF("SkyStrip: %s::CTOR\n", name().c_str()); + +} + +void OpenWeatherMapSource::addToConfig(JsonObject& subtree) { + subtree[FPSTR(CFG_API_BASE)] = apiBase_; + subtree[FPSTR(CFG_API_KEY)] = apiKey_; + subtree[FPSTR(CFG_LOCATION)] = location_; + subtree[FPSTR(CFG_LATITUDE)] = latitude_; + subtree[FPSTR(CFG_LONGITUDE)] = longitude_; + subtree[FPSTR(CFG_INTERVAL_SEC)] = intervalSec_; +} + +bool OpenWeatherMapSource::readFromConfig(JsonObject &subtree, + bool running, + bool& invalidate_history) { + // note what the prior values of latitude_ and longitude_ are + double oldLatitude = latitude_; + double oldLongitude = longitude_; + + bool configComplete = !subtree.isNull(); + configComplete &= getJsonValue(subtree[FPSTR(CFG_API_BASE)], apiBase_, DEFAULT_API_BASE); + configComplete &= getJsonValue(subtree[FPSTR(CFG_API_KEY)], apiKey_, DEFAULT_API_KEY); + configComplete &= getJsonValue(subtree[FPSTR(CFG_LOCATION)], location_, DEFAULT_LOCATION); + configComplete &= getJsonValue(subtree[FPSTR(CFG_LATITUDE)], latitude_, DEFAULT_LATITUDE); + configComplete &= getJsonValue(subtree[FPSTR(CFG_LONGITUDE)], longitude_, DEFAULT_LONGITUDE); + configComplete &= getJsonValue(subtree[FPSTR(CFG_INTERVAL_SEC)], intervalSec_, DEFAULT_INTERVAL_SEC); + + // If the location changed update lat/long via parsing or lookup + if (location_ == lastLocation_) { + // if the user changed the lat and long directly clear the location + if (latitude_ != oldLatitude || longitude_ != oldLongitude) + location_ = ""; + } else { + lastLocation_ = location_; + if (location_.length() > 0) { + double lat = 0, lon = 0; + if (parseLatLon(location_.c_str(), lat, lon)) { + latitude_ = lat; + longitude_ = lon; + } else if (running) { + int matches = 0; + bool ok = geocodeOWM(location_, lat, lon, &matches); + latitude_ = ok ? lat : 0.0; + longitude_ = ok ? lon : 0.0; + } + } + } + + // if the lat/long changed we need to invalidate_history + if (latitude_ != oldLatitude || longitude_ != oldLongitude) + invalidate_history = true; + + return configComplete; +} + +void OpenWeatherMapSource::composeApiUrl(char* buf, size_t len) const { + if (!buf || len == 0) return; + (void)snprintf(buf, len, + "%s/data/3.0/onecall?exclude=minutely,daily,alerts&units=imperial&lat=%.6f&lon=%.6f&appid=%s", + apiBase_.c_str(), latitude_, longitude_, apiKey_.c_str()); + buf[len - 1] = '\0'; +} + +std::unique_ptr OpenWeatherMapSource::fetch(std::time_t now) { + // Wait for scheduled time + if ((now - lastFetch_) < static_cast(intervalSec_)) + return nullptr; + + lastFetch_ = now; + + // Fetch JSON + char url[256]; + composeApiUrl(url, sizeof(url)); + DEBUG_PRINTF("SkyStrip: %s::fetch URL: %s\n", name().c_str(), url); + + auto doc = getJson(url); + if (!doc) { + DEBUG_PRINTF("SkyStrip: %s::fetch failed: no JSON\n", name().c_str()); + return nullptr; + } + + // Top-level object + JsonObject root = doc->as(); + + if (!root.containsKey("hourly")) { + DEBUG_PRINTF("SkyStrip: %s::fetch failed: no \"hourly\" field\n", name().c_str()); + return nullptr; + } + + time_t sunrise = 0; + time_t sunset = 0; + if (root.containsKey("current")) { + JsonObject cur = root["current"].as(); + if (cur.containsKey("sunrise") && cur.containsKey("sunset")) { + sunrise = cur["sunrise"].as(); + sunset = cur["sunset"].as(); + } else { + bool night = false; + JsonArray wArrCur = cur["weather"].as(); + if (!wArrCur.isNull() && wArrCur.size() > 0) { + const char* icon = wArrCur[0]["icon"] | ""; + size_t ilen = strlen(icon); + if (ilen > 0 && icon[ilen-1] == 'n') night = true; + } + if (night) { + sunrise = std::numeric_limits::max(); + sunset = 0; + } else { + sunrise = 0; + sunset = std::numeric_limits::max(); + } + } + } + + // Iterate the hourly array + JsonArray hourly = root["hourly"].as(); + auto model = ::make_unique(); + model->lcl_tstamp = now; + model->sunrise_ = sunrise; + model->sunset_ = sunset; + for (JsonObject hour : hourly) { + time_t dt = hour["dt"].as(); + model->temperature_forecast.push_back({ dt, hour["temp"].as() }); + model->dew_point_forecast.push_back({ dt, hour["dew_point"].as() }); + model->wind_speed_forecast.push_back({ dt, hour["wind_speed"].as() }); + model->wind_dir_forecast.push_back({ dt, hour["wind_deg"].as() }); + model->wind_gust_forecast.push_back({ dt, hour["wind_gust"].as() }); + model->cloud_cover_forecast.push_back({ dt, hour["clouds"].as() }); + JsonArray wArr = hour["weather"].as(); + bool hasRain = false, hasSnow = false; + if (hour.containsKey("rain")) { + double v = hour["rain"]["1h"] | 0.0; + if (v > 0.0) hasRain = true; + } + if (hour.containsKey("snow")) { + double v = hour["snow"]["1h"] | 0.0; + if (v > 0.0) hasSnow = true; + } + if (!hasRain && !hasSnow && !wArr.isNull() && wArr.size() > 0) { + const char* main = wArr[0]["main"] | ""; + if (strcasecmp(main, "rain") == 0 || strcasecmp(main, "drizzle") == 0 || + strcasecmp(main, "thunderstorm") == 0) + hasRain = true; + else if (strcasecmp(main, "snow") == 0) + hasSnow = true; + } + int ptype = hasRain && hasSnow ? 3 : (hasSnow ? 2 : (hasRain ? 1 : 0)); + model->precip_type_forecast.push_back({ dt, double(ptype) }); + model->precip_prob_forecast.push_back({ dt, hour["pop"].as() }); + } + + return model; +} + +std::unique_ptr OpenWeatherMapSource::checkhistory(time_t now, std::time_t oldestTstamp) { + if (oldestTstamp == 0) return nullptr; + if ((now - lastHistFetch_) < 15) return nullptr; + lastHistFetch_ = now; + + static constexpr time_t HISTORY_SEC = 24 * 60 * 60; + if (oldestTstamp <= now - HISTORY_SEC) return nullptr; + + time_t fetchDt = oldestTstamp - 3600; + char url[256]; + snprintf(url, sizeof(url), + "%s/data/3.0/onecall/timemachine?lat=%.6f&lon=%.6f&dt=%ld&units=imperial&appid=%s", + apiBase_.c_str(), latitude_, longitude_, (long)fetchDt, apiKey_.c_str()); + DEBUG_PRINTF("SkyStrip: %s::checkhistory URL: %s\n", name().c_str(), url); + + auto doc = getJson(url); + if (!doc) { + DEBUG_PRINTF("SkyStrip: %s::checkhistory failed: no JSON\n", name().c_str()); + return nullptr; + } + + JsonObject root = doc->as(); + JsonArray hourly = root["hourly"].as(); + if (hourly.isNull()) hourly = root["data"].as(); + if (hourly.isNull()) { + DEBUG_PRINTF("SkyStrip: %s::checkhistory failed: no hourly/data field\n", name().c_str()); + return nullptr; + } + + auto model = ::make_unique(); + model->lcl_tstamp = now; + model->sunrise_ = 0; + model->sunset_ = 0; + for (JsonObject hour : hourly) { + time_t dt = hour["dt"].as(); + if (dt >= oldestTstamp) continue; + model->temperature_forecast.push_back({ dt, hour["temp"].as() }); + model->dew_point_forecast.push_back({ dt, hour["dew_point"].as() }); + model->wind_speed_forecast.push_back({ dt, hour["wind_speed"].as() }); + model->wind_dir_forecast.push_back({ dt, hour["wind_deg"].as() }); + model->wind_gust_forecast.push_back({ dt, hour["wind_gust"].as() }); + model->cloud_cover_forecast.push_back({ dt, hour["clouds"].as() }); + JsonArray wArr = hour["weather"].as(); + bool hasRain = false, hasSnow = false; + if (hour.containsKey("rain")) { + double v = hour["rain"]["1h"] | 0.0; + if (v > 0.0) hasRain = true; + } + if (hour.containsKey("snow")) { + double v = hour["snow"]["1h"] | 0.0; + if (v > 0.0) hasSnow = true; + } + if (!hasRain && !hasSnow && !wArr.isNull() && wArr.size() > 0) { + const char* main = wArr[0]["main"] | ""; + if (strcasecmp(main, "rain") == 0 || strcasecmp(main, "drizzle") == 0 || + strcasecmp(main, "thunderstorm") == 0) + hasRain = true; + else if (strcasecmp(main, "snow") == 0) + hasSnow = true; + } + int ptype = hasRain && hasSnow ? 3 : (hasSnow ? 2 : (hasRain ? 1 : 0)); + model->precip_type_forecast.push_back({ dt, double(ptype) }); + model->precip_prob_forecast.push_back({ dt, hour["pop"].as() }); + } + + if (model->temperature_forecast.empty()) return nullptr; + return model; +} + +void OpenWeatherMapSource::reload(std::time_t now) { + const std::time_t iv = static_cast(intervalSec_); + // Force next fetch to be eligible immediately + lastFetch_ = (now >= iv) ? (now - iv) : 0; + + // If you later add backoff/jitter, clear it here too. + // backoffExp_ = 0; nextRetryAt_ = 0; + DEBUG_PRINTF("SkyStrip: %s::reload (interval=%u)\n", name().c_str(), intervalSec_); +} + +// Returns true iff exactly one match; sets lat/lon. Otherwise zeros them. +bool OpenWeatherMapSource::geocodeOWM(std::string const & rawQuery, + double& lat, double& lon, + int* outMatches) +{ + lat = lon = 0; + char q[128]; + strncpy(q, rawQuery.c_str(), sizeof(q)); + q[sizeof(q)-1] = '\0'; + normalizeLocation(q); + if (q[0] == '\0') { if (outMatches) *outMatches = 0; return false; } + + resetRateLimit(); // we might have done a fetch right before + + char enc[256]; + urlEncode(q, enc, sizeof(enc)); + char url[512]; + snprintf(url, sizeof(url), + "%s/geo/1.0/direct?q=%s&limit=5&appid=%s", + apiBase_.c_str(), enc, apiKey_.c_str()); + DEBUG_PRINTF("SkyStrip: %s::geocodeOWM URL: %s\n", name().c_str(), url); + + auto doc = getJson(url); + resetRateLimit(); // we want to do a fetch immediately after ... + if (!doc || !doc->is()) { + if (outMatches) *outMatches = -1; + DEBUG_PRINTF("SkyStrip: %s::geocodeOWM failed\n", name().c_str()); + return false; + } + + JsonArray arr = doc->as(); + DEBUG_PRINTF("SkyStrip: %s::geocodeOWM %d matches found\n", name().c_str(), arr.size()); + if (outMatches) *outMatches = arr.size(); + if (arr.size() == 1) { + lat = arr[0]["lat"] | 0.0; + lon = arr[0]["lon"] | 0.0; + return true; + } + return false; +} diff --git a/usermods/usermod_v2_skystrip/open_weather_map_source.h b/usermods/usermod_v2_skystrip/open_weather_map_source.h new file mode 100644 index 0000000000..9c64dff430 --- /dev/null +++ b/usermods/usermod_v2_skystrip/open_weather_map_source.h @@ -0,0 +1,43 @@ +#pragma once + +#include +#include +#include +#include "interfaces.h" +#include "rest_json_client.h" + +class SkyModel; + +class OpenWeatherMapSource : public RestJsonClient, public IDataSourceT { +public: + OpenWeatherMapSource(); + + ~OpenWeatherMapSource() override = default; + + // IDataSourceT + std::unique_ptr fetch(std::time_t now) override; + std::unique_ptr checkhistory(std::time_t now, std::time_t oldestTstamp) override; + void reload(std::time_t now) override; + std::string name() const override { return "OWM"; } + + // IConfigurable + void addToConfig(JsonObject& subtree) override; + bool readFromConfig(JsonObject& subtree, + bool startup_complete, + bool& invalidate_history) override; + const char* configKey() const override { return "OpenWeatherMap"; } + + void composeApiUrl(char* buf, size_t len) const; + bool geocodeOWM(std::string const& rawQuery, double& lat, double& lon, int* outMatches = nullptr); + +private: + std::string apiBase_; + std::string apiKey_; + std::string location_; + double latitude_; + double longitude_; + unsigned int intervalSec_; + std::time_t lastFetch_; + std::time_t lastHistFetch_; + std::string lastLocation_; +}; diff --git a/usermods/usermod_v2_skystrip/readme.md b/usermods/usermod_v2_skystrip/readme.md new file mode 100644 index 0000000000..cb6ae43c1f --- /dev/null +++ b/usermods/usermod_v2_skystrip/readme.md @@ -0,0 +1,32 @@ +# SkyStrip + +This usermod displays the weather forecast on several parallel LED strips. +It currently includes Cloud, Wind, Temperature, 24 Hour Delta, and TestPattern views. + +## Installation + +Add `usermod_v2_skystrip` to `custom_usermods` in your PlatformIO environment. + +## Configuration + +Acquire an API key from +[OpenWeatherMap](https://openweathermap.org/api/one-call-3). The skystrip +module makes one API call per hour plus 24 calls when initially started +up; it should remain comfortably under the free-tier limit of 1000 per +day. + +Enter the latitude and longitude for the desired forecast. There are +several ways to do this: +1. Enter the latitude and longitude as signed floating point numbers + in the `Latitude` and `Longitude` config fields. +2. Enter a combined lat/long string in the `Location` field, examples: +- `54.9352° S, 67.6059° W` +- `-54.9352, -67.6059` +- `-54.9352 -67.6059` +- `S54°42'7", W67°40'33"` +3. Enter a geo location string like `oakland,ca,us` in the `Location` field. + +## Interpretation + +Please see the [Interpretation FAQ](./FAQ.md) for more information on how to +interpret the forecast views. diff --git a/usermods/usermod_v2_skystrip/rest_json_client.cpp b/usermods/usermod_v2_skystrip/rest_json_client.cpp new file mode 100644 index 0000000000..8399b57b88 --- /dev/null +++ b/usermods/usermod_v2_skystrip/rest_json_client.cpp @@ -0,0 +1,61 @@ +#include "wled.h" + +#include "rest_json_client.h" + +RestJsonClient::RestJsonClient() + : lastFetchMs_(static_cast(-static_cast(RATE_LIMIT_MS))) + , doc_(MAX_JSON_SIZE) { +} + +void RestJsonClient::resetRateLimit() { + // pretend we just made the last fetch RATE_LIMIT_MS ago + lastFetchMs_ = millis() - static_cast(-static_cast(RATE_LIMIT_MS)); +} + +DynamicJsonDocument* RestJsonClient::getJson(const char* url) { + // enforce a basic rate limit to prevent runaway software from making bursts + // of API calls (looks like DoS and get's our API key turned off ...) + unsigned long now_ms = millis(); + if (now_ms - lastFetchMs_ < RATE_LIMIT_MS) { + DEBUG_PRINTLN("SkyStrip: RestJsonClient::getJson: RATE LIMITED"); + return nullptr; + } + lastFetchMs_ = now_ms; + + // Determine whether to use HTTP or HTTPS based on URL scheme + bool is_https = (strncmp(url, "https://", 8) == 0); + WiFiClient plainClient; + WiFiClientSecure secureClient; + WiFiClient* client = nullptr; + if (is_https) { + secureClient.setInsecure(); + client = &secureClient; + } else { + client = &plainClient; + } + + // Begin request + if (!http_.begin(*client, url)) { + http_.end(); + DEBUG_PRINTLN(F("SkyStrip: RestJsonClient::getJson: trouble initiating request")); + return nullptr; + } + DEBUG_PRINTF("SkyStrip: RestJsonClient::getJson: free heap before GET: %u\n", ESP.getFreeHeap()); + int code = http_.GET(); + if (code <= 0) { + http_.end(); + DEBUG_PRINTF("SkyStrip: RestJsonClient::getJson: http get error code: %d\n", code); + return nullptr; + } + + int len = http_.getSize(); + DEBUG_PRINTF("SkyStrip: RestJsonClient::getJson: expecting up to %d bytes, free heap before deserialization: %u\n", len, ESP.getFreeHeap()); + doc_.clear(); + auto err = deserializeJson(doc_, http_.getStream()); + http_.end(); + if (err) { + DEBUG_PRINTF("SkyStrip: RestJsonClient::getJson: deserialization error: %s; free heap: %u\n", err.c_str(), ESP.getFreeHeap()); + return nullptr; + } + return &doc_; +} diff --git a/usermods/usermod_v2_skystrip/rest_json_client.h b/usermods/usermod_v2_skystrip/rest_json_client.h new file mode 100644 index 0000000000..c87828bf1e --- /dev/null +++ b/usermods/usermod_v2_skystrip/rest_json_client.h @@ -0,0 +1,34 @@ +#pragma once + +// Lightweight REST client that reuses a fixed JSON buffer to avoid +// heap fragmentation caused by repeated allocations. + +#include +#include +#include "wled.h" + +#if defined(ARDUINO_ARCH_ESP8266) +#include +#else +#include +#endif + +class RestJsonClient { +public: + RestJsonClient(); + virtual ~RestJsonClient() = default; + + // Returns pointer to internal document on success, nullptr on failure. + DynamicJsonDocument* getJson(const char* url); + + void resetRateLimit(); + +protected: + static constexpr unsigned RATE_LIMIT_MS = 10u * 1000u; // 10 seconds + static constexpr size_t MAX_JSON_SIZE = 32 * 1024; // 32kB fixed buffer + +private: + HTTPClient http_; + unsigned long lastFetchMs_; + DynamicJsonDocument doc_; +}; diff --git a/usermods/usermod_v2_skystrip/skymodel.cpp b/usermods/usermod_v2_skystrip/skymodel.cpp new file mode 100644 index 0000000000..0d833f7170 --- /dev/null +++ b/usermods/usermod_v2_skystrip/skymodel.cpp @@ -0,0 +1,159 @@ +#include +#include +#include + +#include "wled.h" + +#include "skymodel.h" +#include "util.h" + +namespace { + static constexpr time_t HISTORY_SEC = 25 * 60 * 60; // keep an extra history point + // Preallocate enough space for forecast (48h) plus backfilled history (~24h) + // without imposing a hard cap; vectors can still grow beyond this reserve. + static constexpr size_t MAX_POINTS = 80; + +template +void mergeSeries(Series ¤t, Series &&fresh, time_t now) { + if (fresh.empty()) return; + + if (current.empty()) { + current = std::move(fresh); + } else if (fresh.back().tstamp < current.front().tstamp) { + // Fresh points are entirely earlier than current data; prepend in-place. + fresh.reserve(current.size() + fresh.size()); + fresh.insert(fresh.end(), current.begin(), current.end()); + current = std::move(fresh); + } else { + auto it = std::lower_bound(current.begin(), current.end(), fresh.front().tstamp, + [](const DataPoint& dp, time_t t){ return dp.tstamp < t; }); + current.erase(it, current.end()); + current.insert(current.end(), fresh.begin(), fresh.end()); + } + + time_t cutoff = now - HISTORY_SEC; + auto itCut = std::lower_bound(current.begin(), current.end(), cutoff, + [](const DataPoint& dp, time_t t){ return dp.tstamp < t; }); + current.erase(current.begin(), itCut); +} +} // namespace + +SkyModel::SkyModel() { + temperature_forecast.reserve(MAX_POINTS); + dew_point_forecast.reserve(MAX_POINTS); + wind_speed_forecast.reserve(MAX_POINTS); + wind_gust_forecast.reserve(MAX_POINTS); + wind_dir_forecast.reserve(MAX_POINTS); + cloud_cover_forecast.reserve(MAX_POINTS); + precip_type_forecast.reserve(MAX_POINTS); + precip_prob_forecast.reserve(MAX_POINTS); +} + +SkyModel & SkyModel::update(time_t now, SkyModel && other) { + lcl_tstamp = other.lcl_tstamp; + + mergeSeries(temperature_forecast, std::move(other.temperature_forecast), now); + mergeSeries(dew_point_forecast, std::move(other.dew_point_forecast), now); + mergeSeries(wind_speed_forecast, std::move(other.wind_speed_forecast), now); + mergeSeries(wind_gust_forecast, std::move(other.wind_gust_forecast), now); + mergeSeries(wind_dir_forecast, std::move(other.wind_dir_forecast), now); + mergeSeries(cloud_cover_forecast, std::move(other.cloud_cover_forecast), now); + mergeSeries(precip_type_forecast, std::move(other.precip_type_forecast), now); + mergeSeries(precip_prob_forecast, std::move(other.precip_prob_forecast), now); + + if (!(other.sunrise_ == 0 && other.sunset_ == 0)) { + sunrise_ = other.sunrise_; + sunset_ = other.sunset_; + } + +#ifdef WLED_DEBUG + emitDebug(now, DEBUGOUT); +#endif + + return *this; +} + +void SkyModel::invalidate_history(time_t now) { + temperature_forecast.clear(); + dew_point_forecast.clear(); + wind_speed_forecast.clear(); + wind_gust_forecast.clear(); + wind_dir_forecast.clear(); + cloud_cover_forecast.clear(); + precip_type_forecast.clear(); + precip_prob_forecast.clear(); + sunrise_ = 0; + sunset_ = 0; +} + +time_t SkyModel::oldest() const { + time_t out = std::numeric_limits::max(); + auto upd = [&](const std::vector& s){ + if (!s.empty() && s.front().tstamp < out) out = s.front().tstamp; + }; + upd(temperature_forecast); + upd(dew_point_forecast); + upd(wind_speed_forecast); + upd(wind_gust_forecast); + upd(wind_dir_forecast); + upd(cloud_cover_forecast); + upd(precip_type_forecast); + upd(precip_prob_forecast); + if (out == std::numeric_limits::max()) return 0; + return out; +} + +// Streamed/line-by-line variant to keep packets small. +template +static inline void emitSeriesMDHM(Print &out, time_t now, const char *label, + const Series &s) { + char tb[20]; + skystrip::util::fmt_local(tb, sizeof(tb), now); + char line[256]; + int len = snprintf(line, sizeof(line), "SkyModel: now=%s: %s(%u):[\n", + tb, label, (unsigned)s.size()); + out.write((const uint8_t*)line, len); + + if (s.empty()) { + len = snprintf(line, sizeof(line), "SkyModel: ]\n"); + out.write((const uint8_t*)line, len); + return; + } + + size_t i = 0; + size_t off = 0; + for (const auto& dp : s) { + if (i % 6 == 0) { + off = snprintf(line, sizeof(line), "SkyModel:"); + } + skystrip::util::fmt_local(tb, sizeof(tb), dp.tstamp); + off += snprintf(line + off, sizeof(line) - off, + " (%s, %6.2f)", tb, dp.value); + if (i % 6 == 5 || i == s.size() - 1) { + if (i == s.size() - 1) off += snprintf(line + off, sizeof(line) - off, " ]"); + line[off++] = '\n'; + out.write((const uint8_t*)line, off); + } + ++i; + } +} + +void SkyModel::emitDebug(time_t now, Print& out) const { + emitSeriesMDHM(out, now, " temp", temperature_forecast); + emitSeriesMDHM(out, now, " dwpt", dew_point_forecast); + emitSeriesMDHM(out, now, " wspd", wind_speed_forecast); + emitSeriesMDHM(out, now, " wgst", wind_gust_forecast); + emitSeriesMDHM(out, now, " wdir", wind_dir_forecast); + emitSeriesMDHM(out, now, " clds", cloud_cover_forecast); + emitSeriesMDHM(out, now, " prcp", precip_type_forecast); + emitSeriesMDHM(out, now, " pop", precip_prob_forecast); + + char tb[20]; + char line[64]; + skystrip::util::fmt_local(tb, sizeof(tb), sunrise_); + int len = snprintf(line, sizeof(line), "SkyModel: sunrise %s\n", tb); + out.write((const uint8_t*)line, len); + skystrip::util::fmt_local(tb, sizeof(tb), sunset_); + len = snprintf(line, sizeof(line), "SkyModel: sunset %s\n", tb); + out.write((const uint8_t*)line, len); +} diff --git a/usermods/usermod_v2_skystrip/skymodel.h b/usermods/usermod_v2_skystrip/skymodel.h new file mode 100644 index 0000000000..166999efcd --- /dev/null +++ b/usermods/usermod_v2_skystrip/skymodel.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include + +class Print; + +#include "interfaces.h" + +struct DataPoint { + time_t tstamp; + double value; +}; + +class SkyModel { +public: + SkyModel(); + + // move-only + SkyModel(const SkyModel &) = delete; + SkyModel &operator=(const SkyModel &) = delete; + + SkyModel(SkyModel &&) noexcept = default; + SkyModel &operator=(SkyModel &&) noexcept = default; + + ~SkyModel() = default; + + SkyModel & update(time_t now, SkyModel && other); // use std::move + void invalidate_history(time_t now); + time_t oldest() const; + void emitDebug(time_t now, Print& out) const; + + std::time_t lcl_tstamp{0}; // update timestamp from our clock + std::vector temperature_forecast; + std::vector dew_point_forecast; + std::vector wind_speed_forecast; + std::vector wind_gust_forecast; + std::vector wind_dir_forecast; + std::vector cloud_cover_forecast; + std::vector precip_type_forecast; // 0 none, 1 rain, 2 snow, 3 mixed + std::vector precip_prob_forecast; // 0..1 probability of precip + + // sunrise/sunset times from current data + time_t sunrise_{0}; + time_t sunset_{0}; +}; diff --git a/usermods/usermod_v2_skystrip/temperature_view.cpp b/usermods/usermod_v2_skystrip/temperature_view.cpp new file mode 100644 index 0000000000..1b84fdc495 --- /dev/null +++ b/usermods/usermod_v2_skystrip/temperature_view.cpp @@ -0,0 +1,195 @@ +#include "temperature_view.h" +#include "skymodel.h" +#include "util.h" +#include "wled.h" // Segment, strip, RGBW32 +#include +#include + +static constexpr int16_t DEFAULT_SEG_ID = -1; // -1 means disabled + +// - these are user visible in the webapp settings UI +// - they are scoped to this module, don't need to be globally unique +// +const char CFG_SEG_ID[] = "SegmentId"; + +// Map dew-point depression (°F) -> saturation multiplier. +// dd<=2°F -> minSat ; dd>=25°F -> 1.0 ; smooth in between. +static inline float satFromDewSpreadF(float tempF, float dewF) { + float dd = tempF - dewF; + if (dd < 0.f) + dd = 0.f; // guard bad inputs + constexpr float kMinSat = 0.40f; // floor (muggy look) + constexpr float kMaxSpread = 25.0f; // “very dry” cap + float u = skystrip::util::clamp01(dd / kMaxSpread); + float eased = u * u * (3.f - 2.f * u); // smoothstep + return kMinSat + (1.f - kMinSat) * eased; +} + +struct Stop { + double f; + float h; +}; +// Cold→Hot ramp in °F: 14,32,50,68,77,86,95,104 +static const Stop kStopsF[] = { + {14, 234.9f}, // deep blue + {32, 207.0f}, // blue/cyan + {50, 180.0f}, // cyan + {68, 138.8f}, // greenish + {77, 60.0f}, // yellow + {86, 38.8f}, // orange + {95, 18.8f}, // orange-red + {104, 0.0f}, // red +}; + +static float hueForTempF(double f) { + if (f <= kStopsF[0].f) + return kStopsF[0].h; + for (size_t i = 1; i < sizeof(kStopsF) / sizeof(kStopsF[0]); ++i) { + if (f <= kStopsF[i].f) { + const auto &A = kStopsF[i - 1]; + const auto &B = kStopsF[i]; + const double u = (f - A.f) / (B.f - A.f); + return float(skystrip::util::lerp(A.h, B.h, u)); + } + } + return kStopsF[sizeof(kStopsF) / sizeof(kStopsF[0]) - 1].h; +} + +TemperatureView::TemperatureView() : segId_(DEFAULT_SEG_ID) { + DEBUG_PRINTLN("SkyStrip: TV::CTOR"); + snprintf(debugPixelString, sizeof(debugPixelString), "%s:\\n", + name().c_str()); + debugPixelString[sizeof(debugPixelString) - 1] = '\0'; +} + +void TemperatureView::view(time_t now, SkyModel const &model, + int16_t dbgPixelIndex) { + if (dbgPixelIndex < 0) { + snprintf(debugPixelString, sizeof(debugPixelString), "%s:\\n", + name().c_str()); + debugPixelString[sizeof(debugPixelString) - 1] = '\0'; + } + if (segId_ == DEFAULT_SEG_ID) + return; // disabled + if (model.temperature_forecast.empty()) + return; // nothing to render + + if (segId_ < 0 || segId_ >= strip.getMaxSegments()) + return; + Segment &seg = strip.getSegment((uint8_t)segId_); + seg.freeze = true; + int start = seg.start; + int end = seg.stop - 1; // inclusive + int len = end - start + 1; + if (len == 0) + return; + + constexpr double kHorizonSec = 48.0 * 3600.0; + const double step = (len > 1) ? (kHorizonSec / double(len - 1)) : 0.0; + constexpr time_t DAY = 24 * 60 * 60; + const long tzOffset = skystrip::util::current_offset(); + + // Returns [0,1] marker weight based on proximity to local-time markers. + // Markers: 12a/12p (double width), plus 3a/3p, 6a/6p, 9a/9p (normal width). + // Width=1 → fades to 0 at 1 pixel; width=2 → fades to 0 at 2 pixels. + auto markerWeight = [&](time_t t) { + if (step <= 0.0) + return 0.f; + + time_t local = t + tzOffset; // convert to local seconds + time_t s = local % DAY; // seconds since local midnight + if (s < 0) + s += DAY; + + // Seconds-of-day for markers + per-marker width multipliers. + static const time_t kMarkers[] = {0 * 3600, 3 * 3600, 6 * 3600, + 9 * 3600, 12 * 3600, 15 * 3600, + 18 * 3600, 21 * 3600}; + static const float dayTW = 2.0f; + static const float majorTW = 1.6f; + static const float minorTW = 0.8f; + static const float kWidth[] = { + dayTW, minorTW, minorTW, minorTW, // midnight, 3a, 6a, 9a + majorTW, minorTW, minorTW, minorTW // noon, 3p, 6p, 9p + }; + + constexpr time_t HALF_DAY = DAY / 2; + float w = 0.f; + + const size_t N = sizeof(kMarkers) / sizeof(kMarkers[0]); + for (size_t i = 0; i < N; ++i) { + time_t m = kMarkers[i]; + time_t d = (s > m) ? (s - m) : (m - s); + if (d > HALF_DAY) + d = DAY - d; // wrap on 24h circle + float wi = 1.f - float(d) / (float(step) * kWidth[i]); + if (wi > w) + w = wi; // max of all marker influences + } + + return (w > 0.f) ? w : 0.f; + }; + + for (uint16_t i = 0; i < len; ++i) { + const time_t t = now + time_t(std::llround(step * i)); + int idx = seg.reverse ? (end - i) : (start + i); + + double tempF = 0.f; + double dewF = 0.f; + float hue = 0.f; + float sat = 1.0f; + constexpr float val = 0.5f; + uint32_t col = 0; + if (skystrip::util::estimateTempAt(model, t, step, tempF)) { + hue = hueForTempF(tempF); + if (skystrip::util::estimateDewPtAt(model, t, step, dewF)) { + sat = satFromDewSpreadF((float)tempF, (float)dewF); + } + col = skystrip::util::hsv2rgb(hue, sat, val); + } + + float m = markerWeight(t); + if (m > 0.f) { + uint8_t blend = uint8_t(std::lround(m * 255.f)); + col = color_blend(col, 0, blend); + } + + if (dbgPixelIndex >= 0) { + static time_t lastDebug = 0; + if (now - lastDebug > 1 && i == dbgPixelIndex) { + char nowbuf[20]; + skystrip::util::fmt_local(nowbuf, sizeof(nowbuf), now); + char dbgbuf[20]; + skystrip::util::fmt_local(dbgbuf, sizeof(dbgbuf), t); + snprintf(debugPixelString, sizeof(debugPixelString), + "%s: nowtm=%s dbgndx=%d dbgtm=%s " + "tempF=%.1f dewF=%.1f " + "H=%.0f S=%.0f V=%.0f\\n", + name().c_str(), nowbuf, i, dbgbuf, tempF, dewF, hue, sat * 100, + val * 100); + lastDebug = now; + } + } + + strip.setPixelColor(idx, skystrip::util::blinkDebug(i, dbgPixelIndex, col)); + } +} + +void TemperatureView::addToConfig(JsonObject &subtree) { + subtree[FPSTR(CFG_SEG_ID)] = segId_; +} + +void TemperatureView::appendConfigData(Print &s) { + // Keep the hint INLINE (BEFORE the input = 4th arg): + s.print(F("addInfo('SkyStrip:TemperatureView:SegmentId',1,''," + "' (-1 disables)'" + ");")); +} + +bool TemperatureView::readFromConfig(JsonObject &subtree, bool startup_complete, + bool &invalidate_history) { + bool configComplete = !subtree.isNull(); + configComplete &= + getJsonValue(subtree[FPSTR(CFG_SEG_ID)], segId_, DEFAULT_SEG_ID); + return configComplete; +} diff --git a/usermods/usermod_v2_skystrip/temperature_view.h b/usermods/usermod_v2_skystrip/temperature_view.h new file mode 100644 index 0000000000..e47b9970d1 --- /dev/null +++ b/usermods/usermod_v2_skystrip/temperature_view.h @@ -0,0 +1,29 @@ +#pragma once + +#include "interfaces.h" +#include "skymodel.h" + +class SkyModel; + +class TemperatureView : public IDataViewT { +public: + TemperatureView(); + ~TemperatureView() override = default; + + // IDataViewT + void view(time_t now, SkyModel const & model, int16_t dbgPixelIndex) override; + std::string name() const override { return "TV"; } + void appendDebugPixel(Print& s) const override { s.print(debugPixelString); } + + // IConfigurable + void addToConfig(JsonObject& subtree) override; + void appendConfigData(Print& s) override; + bool readFromConfig(JsonObject& subtree, + bool startup_complete, + bool& invalidate_history) override; + const char* configKey() const override { return "TemperatureView"; } + +private: + int16_t segId_; // -1 means disabled + char debugPixelString[128]; +}; diff --git a/usermods/usermod_v2_skystrip/test_pattern_view.cpp b/usermods/usermod_v2_skystrip/test_pattern_view.cpp new file mode 100644 index 0000000000..b0e3b00c5d --- /dev/null +++ b/usermods/usermod_v2_skystrip/test_pattern_view.cpp @@ -0,0 +1,179 @@ +#include +#include +#include +#include + +#include "wled.h" + +#include "skymodel.h" +#include "test_pattern_view.h" +#include "util.h" + +static constexpr int16_t DEFAULT_SEG_ID = -1; // -1 means disabled +const char CFG_SEG_ID[] = "SegmentId"; +// legacy individual HSV components +const char CFG_START_HUE[] PROGMEM = "StartHue"; +const char CFG_START_SAT[] PROGMEM = "StartSat"; +const char CFG_START_VAL[] PROGMEM = "StartVal"; +const char CFG_END_HUE[] PROGMEM = "EndHue"; +const char CFG_END_SAT[] PROGMEM = "EndSat"; +const char CFG_END_VAL[] PROGMEM = "EndVal"; + +// combined HSV strings (hue 0-360, sat/val 0-100%) +const char CFG_START_HSV[] PROGMEM = "StartHSV"; +const char CFG_END_HSV[] PROGMEM = "EndHSV"; + +namespace { + +void formatHSV(char *out, size_t len, float h, float s, float v) { + // store saturation/value as percentages for readability + snprintf(out, len, "H:%.0f S:%.0f V:%.0f", h, s * 100.f, v * 100.f); +} + +bool parseHSV(const char *in, float &h, float &s, float &v) { + if (!in) + return false; + + char buf[64]; + strncpy(buf, in, sizeof(buf)); + buf[sizeof(buf) - 1] = '\0'; + + float values[3] = {0.f, 0.f, 0.f}; + bool found[3] = {false, false, false}; + char *saveptr; + for (char *tok = strtok_r(buf, ", \t\r\n", &saveptr); tok; + tok = strtok_r(nullptr, ", \t\r\n", &saveptr)) { + char *sep = strpbrk(tok, "=:"); + if (sep) { + char key = tolower((unsigned char)tok[0]); + float val = atof(sep + 1); + if (key == 'h') { + values[0] = val; + found[0] = true; + } else if (key == 's') { + values[1] = val; + found[1] = true; + } else if (key == 'v') { + values[2] = val; + found[2] = true; + } + } else { + for (int i = 0; i < 3; ++i) { + if (!found[i]) { + values[i] = atof(tok); + found[i] = true; + break; + } + } + } + } + + if (found[0] && found[1] && found[2]) { + h = values[0]; + s = values[1] / 100.f; + v = values[2] / 100.f; + return true; + } + return false; +} + +} // namespace + +TestPatternView::TestPatternView() + : segId_(DEFAULT_SEG_ID), startHue_(0.f), startSat_(0.f), startVal_(0.f), + endHue_(0.f), endSat_(0.f), endVal_(1.f) { + DEBUG_PRINTLN("SkyStrip: TP::CTOR"); + snprintf(debugPixelString, sizeof(debugPixelString), "%s:\\n", + name().c_str()); + debugPixelString[sizeof(debugPixelString) - 1] = '\0'; +} + +void TestPatternView::view(time_t now, SkyModel const &model, + int16_t dbgPixelIndex) { + if (dbgPixelIndex < 0) { + snprintf(debugPixelString, sizeof(debugPixelString), "%s:\\n", + name().c_str()); + debugPixelString[sizeof(debugPixelString) - 1] = '\0'; + } + if (segId_ == DEFAULT_SEG_ID) + return; + if (segId_ < 0 || segId_ >= strip.getMaxSegments()) + return; + + Segment &seg = strip.getSegment((uint8_t)segId_); + seg.freeze = true; + int start = seg.start; + int end = seg.stop - 1; + int len = end - start + 1; + if (len == 0) + return; + + for (int i = 0; i < len; ++i) { + float u = (len > 1) ? float(i) / float(len - 1) : 0.f; + float h = startHue_ + (endHue_ - startHue_) * u; + float s = startSat_ + (endSat_ - startSat_) * u; + float v = startVal_ + (endVal_ - startVal_) * u; + uint32_t col = skystrip::util::hsv2rgb(h, s, v); + if (dbgPixelIndex >= 0) { + static time_t lastDebug = 0; + if (now - lastDebug > 1 && i == dbgPixelIndex) { + char nowbuf[20]; + skystrip::util::fmt_local(nowbuf, sizeof(nowbuf), now); + snprintf(debugPixelString, sizeof(debugPixelString), + "%s: nowtm=%s dbgndx=%d H=%.0f S=%.0f V=%.0f\\n", + name().c_str(), nowbuf, i, h, s * 100, v * 100); + lastDebug = now; + } + } + int idx = seg.reverse ? (end - i) : (start + i); + strip.setPixelColor(idx, skystrip::util::blinkDebug(i, dbgPixelIndex, col)); + } +} + +void TestPatternView::addToConfig(JsonObject &subtree) { + subtree[FPSTR(CFG_SEG_ID)] = segId_; + + char buf[32]; + formatHSV(buf, sizeof(buf), startHue_, startSat_, startVal_); + subtree[FPSTR(CFG_START_HSV)] = buf; + formatHSV(buf, sizeof(buf), endHue_, endSat_, endVal_); + subtree[FPSTR(CFG_END_HSV)] = buf; +} + +void TestPatternView::appendConfigData(Print &s) { + // Keep the hint INLINE (BEFORE the input = 4th arg): + s.print(F("addInfo('SkyStrip:TestPatternView:SegmentId',1,''," + "' (-1 disables)'" + ");")); +} + +bool TestPatternView::readFromConfig(JsonObject &subtree, bool startup_complete, + bool &invalidate_history) { + bool configComplete = !subtree.isNull(); + configComplete &= + getJsonValue(subtree[FPSTR(CFG_SEG_ID)], segId_, DEFAULT_SEG_ID); + + bool parsed = false; + if (!subtree[FPSTR(CFG_START_HSV)].isNull()) { + parsed = parseHSV(subtree[FPSTR(CFG_START_HSV)], startHue_, startSat_, + startVal_); + configComplete &= parsed; + } else { + configComplete &= + getJsonValue(subtree[FPSTR(CFG_START_HUE)], startHue_, 0.f); + configComplete &= + getJsonValue(subtree[FPSTR(CFG_START_SAT)], startSat_, 0.f); + configComplete &= + getJsonValue(subtree[FPSTR(CFG_START_VAL)], startVal_, 0.f); + } + + if (!subtree[FPSTR(CFG_END_HSV)].isNull()) { + parsed = parseHSV(subtree[FPSTR(CFG_END_HSV)], endHue_, endSat_, endVal_); + configComplete &= parsed; + } else { + configComplete &= getJsonValue(subtree[FPSTR(CFG_END_HUE)], endHue_, 0.f); + configComplete &= getJsonValue(subtree[FPSTR(CFG_END_SAT)], endSat_, 0.f); + configComplete &= getJsonValue(subtree[FPSTR(CFG_END_VAL)], endVal_, 1.f); + } + return configComplete; +} diff --git a/usermods/usermod_v2_skystrip/test_pattern_view.h b/usermods/usermod_v2_skystrip/test_pattern_view.h new file mode 100644 index 0000000000..fe0e824afd --- /dev/null +++ b/usermods/usermod_v2_skystrip/test_pattern_view.h @@ -0,0 +1,29 @@ +#pragma once + +#include "interfaces.h" +#include "skymodel.h" + +class SkyModel; + +class TestPatternView : public IDataViewT { +public: + TestPatternView(); + ~TestPatternView() override = default; + + void view(time_t now, SkyModel const & model, int16_t dbgPixelIndex) override; + std::string name() const override { return "TP"; } + void appendDebugPixel(Print& s) const override { s.print(debugPixelString); } + + void addToConfig(JsonObject& subtree) override; + void appendConfigData(Print& s) override; + bool readFromConfig(JsonObject& subtree, + bool startup_complete, + bool& invalidate_history) override; + const char* configKey() const override { return "TestPatternView"; } + +private: + int16_t segId_; + char debugPixelString[128]; + float startHue_, startSat_, startVal_; + float endHue_, endSat_, endVal_; +}; diff --git a/usermods/usermod_v2_skystrip/usermod_v2_skystrip.cpp b/usermods/usermod_v2_skystrip/usermod_v2_skystrip.cpp new file mode 100644 index 0000000000..b97c9ad843 --- /dev/null +++ b/usermods/usermod_v2_skystrip/usermod_v2_skystrip.cpp @@ -0,0 +1,252 @@ +#include +#include +#include + +#include "usermod_v2_skystrip.h" +#include "interfaces.h" +#include "util.h" + +#include "skymodel.h" +#include "open_weather_map_source.h" +#include "temperature_view.h" +#include "wind_view.h" +#include "cloud_view.h" +#include "delta_view.h" +#include "test_pattern_view.h" + +const char CFG_NAME[] = "SkyStrip"; +const char CFG_ENABLED[] = "Enabled"; +const char CFG_PIXEL_DBG_NAME[] = "DebugPixel"; +const char CFG_DBG_PIXEL_INDEX[] = "Index"; + +static SkyStrip skystrip_usermod; +REGISTER_USERMOD(skystrip_usermod); + +// Don't handle the loop function for SAFETY_DELAY_MSECS. If we've +// coded a deadlock or crash in the loop handler this will give us a +// chance to offMode the device so we can use the OTA update to fix +// the problem. +const time_t SAFETY_DELAY_MSECS = 10 * 1000; + +// runs before readFromConfig() and setup() +SkyStrip::SkyStrip() { + DEBUG_PRINTLN(F("SkyStrip::SkyStrip CTOR")); + sources_.push_back(::make_unique()); + model_ = ::make_unique(); + views_.push_back(::make_unique()); + views_.push_back(::make_unique()); + views_.push_back(::make_unique()); + views_.push_back(::make_unique()); + views_.push_back(::make_unique()); +} + +void SkyStrip::setup() { + // NOTE - it's a really bad idea to crash or deadlock in this + // method; you won't be able to use OTA update and will have to + // resort to a serial connection to unbrick your controller ... + + // NOTE - if you are using UDP logging the DEBUG_PRINTLNs in this + // routine will likely not show up because this is prior to WiFi + // being up. + + DEBUG_PRINTLN(F("SkyStrip::setup starting")); + + uint32_t now_ms = millis(); + safeToStart_ = now_ms + SAFETY_DELAY_MSECS; + + // Serial.begin(115200); + + // Print version number + DEBUG_PRINT(F("SkyStrip version: ")); + DEBUG_PRINTLN(SKYSTRIP_VERSION); + + // Start a nice chase so we know its booting + showBooting(); + + state_ = SkyStripState::Setup; + + DEBUG_PRINTLN(F("SkyStrip::setup finished")); +} + +void SkyStrip::loop() { + uint32_t now_ms = millis(); + + // init edge baselines once + if (!edgeInit_) { + lastOff_ = offMode; + lastEnabled_ = enabled_; + edgeInit_ = true; + } + + time_t const now = skystrip::util::time_now_utc(); + + // defer a short bit after reboot + if (state_ == SkyStripState::Setup) { + if (now_ms < safeToStart_) { + return; + } else { + DEBUG_PRINTLN(F("SkyStrip::loop SkyStripState is Running")); + state_ = SkyStripState::Running; + doneBooting(); + reloadSources(now); // load right away + } + } + + // detect OFF->ON and disabled->enabled edges + const bool becameOn = (lastOff_ && !offMode); + const bool becameEnabled = (!lastEnabled_ && enabled_); + if (becameOn || becameEnabled) { + reloadSources(now); + } + lastOff_ = offMode; + lastEnabled_ = enabled_; + + // make sure we are enabled, on, and ready + if (!enabled_ || offMode || strip.isUpdating()) return; + + // check the sources for updates, apply to model if found + for (auto &source : sources_) { + if (auto frmsrc = source->fetch(now)) { + // this happens relatively infrequently, once an hour + model_->update(now, std::move(*frmsrc)); + } + if (auto hist = source->checkhistory(now, model_->oldest())) { + model_->update(now, std::move(*hist)); + } + } +} + +void SkyStrip::handleOverlayDraw() { + // this happens a hundred times a second + time_t now = skystrip::util::time_now_utc(); + for (auto &view : views_) { + view->view(now, *model_, dbgPixelIndex_); + } +} + +// called by WLED when settings are saved +void SkyStrip::addToConfig(JsonObject& root) { + JsonObject top = root.createNestedObject(FPSTR(CFG_NAME)); + + // write our state + top[FPSTR(CFG_ENABLED)] = enabled_; + + // write the sources + for (auto& src : sources_) { + JsonObject sub = top.createNestedObject(src->configKey()); + src->addToConfig(sub); + } + + // write the views + for (auto& vw : views_) { + JsonObject sub = top.createNestedObject(vw->configKey()); + vw->addToConfig(sub); + } + + JsonObject sub = top.createNestedObject(FPSTR(CFG_PIXEL_DBG_NAME)); + sub[FPSTR(CFG_DBG_PIXEL_INDEX)] = dbgPixelIndex_; +} + +void SkyStrip::appendConfigData(Print& s) { + for (auto& src : sources_) { + src->appendConfigData(s); + } + + for (auto& vw : views_) { + vw->appendConfigData(s); + } + + // Keep the hint INLINE (BEFORE the input = 4th arg): + s.print(F( + "addInfo('SkyStrip:DebugPixel:Index',1,''," + "' (-1 disables)'" + ");" + )); + + // Open a read-only textarea region for the pixel debugging + s.print(F( + "addInfo('SkyStrip:DebugPixel:Index',1," + "'
'" + ");" + )); +} + +// called by WLED when settings are restored +bool SkyStrip::readFromConfig(JsonObject& root) { + JsonObject top = root[FPSTR(CFG_NAME)]; + if (top.isNull()) return false; + + bool ok = true; + bool invalidate_history = false; + + // It is not safe to make API calls during startup + bool startup_complete = state_ == SkyStripState::Running; + + ok &= getJsonValue(top[FPSTR(CFG_ENABLED)], enabled_, false); + + JsonObject sub = top[FPSTR(CFG_PIXEL_DBG_NAME)]; + ok &= getJsonValue(sub[FPSTR(CFG_DBG_PIXEL_INDEX)], dbgPixelIndex_, -1); + + // read the sources + for (auto& src : sources_) { + JsonObject sub = top[src->configKey()]; + ok &= src->readFromConfig(sub, startup_complete, invalidate_history); + } + + // read the views + for (auto& vw : views_) { + JsonObject sub = top[vw->configKey()]; + ok &= vw->readFromConfig(sub, startup_complete, invalidate_history); + } + + if (invalidate_history) { + time_t const now = skystrip::util::time_now_utc(); + model_->invalidate_history(now); + if (startup_complete) reloadSources(now); // not safe during startup + } + + return ok; +} + +void SkyStrip::showBooting() { + Segment& seg = strip.getMainSegment(); + seg.setMode(28); // Set to chase + seg.speed = 200; + // seg.intensity = 255; // preserve user's settings via webapp + seg.setPalette(128); + seg.setColor(0, 0x404060); + seg.setColor(1, 0x000000); + seg.setColor(2, 0x303040); +} + +void SkyStrip::doneBooting() { + Segment& seg = strip.getMainSegment(); + seg.freeze = true; // stop any further segment animation + seg.setMode(0); // static palette/color mode + // seg.intensity = 255; // preserve user's settings via webapp +} + +void SkyStrip::reloadSources(std::time_t now) { + char nowBuf[20]; + skystrip::util::fmt_local(nowBuf, sizeof(nowBuf), now); + DEBUG_PRINTF("SkyStrip::ReloadSources at %s\n", nowBuf); + + for (auto &src : sources_) src->reload(now); +} diff --git a/usermods/usermod_v2_skystrip/usermod_v2_skystrip.h b/usermods/usermod_v2_skystrip/usermod_v2_skystrip.h new file mode 100644 index 0000000000..f87ca1ae9c --- /dev/null +++ b/usermods/usermod_v2_skystrip/usermod_v2_skystrip.h @@ -0,0 +1,51 @@ +#pragma once +#include + +#include "interfaces.h" +#include "wled.h" + +#define SKYSTRIP_VERSION "0.0.1" + +class SkyModel; + +enum class SkyStripState { + Initial, // initial state + Setup, // setup() has completed + Running // after a short delay to allow offMode +}; + +class SkyStrip : public Usermod { +private: + bool enabled_ = false; + int16_t dbgPixelIndex_ = -1; // if >=0 show periodic debugging for that pixel + SkyStripState state_ = SkyStripState::Initial; + uint32_t safeToStart_ = 0; + uint32_t lastLoop_ = 0; + bool edgeInit_ = false; + bool lastOff_ = false; + bool lastEnabled_ = false; + + std::vector>> sources_; + std::unique_ptr model_; + std::vector>> views_; + +public: + SkyStrip(); + ~SkyStrip() override = default; + void setup() override; + void loop() override; + void handleOverlayDraw() override; + void addToConfig(JsonObject &obj) override; + void appendConfigData(Print& s) override; + bool readFromConfig(JsonObject &obj) override; + uint16_t getId() override { return USERMOD_ID_SKYSTRIP; }; + + // for other usermods + inline void enable(bool enable) { enabled_ = enable; } + inline bool isEnabled() { return enabled_; } + +protected: + void showBooting(); + void doneBooting(); + void reloadSources(std::time_t now); +}; diff --git a/usermods/usermod_v2_skystrip/util.cpp b/usermods/usermod_v2_skystrip/util.cpp new file mode 100644 index 0000000000..4be62a7d81 --- /dev/null +++ b/usermods/usermod_v2_skystrip/util.cpp @@ -0,0 +1,25 @@ +#include "util.h" + +namespace skystrip { +namespace util { + +uint32_t hsv2rgb(float h, float s, float v) { + float c = v * s; + float hh = h / 60.f; + float x = c * (1.f - fabsf(fmodf(hh, 2.f) - 1.f)); + float r1, g1, b1; + if (hh < 1.f) { r1 = c; g1 = x; b1 = 0.f; } + else if (hh < 2.f) { r1 = x; g1 = c; b1 = 0.f; } + else if (hh < 3.f) { r1 = 0.f; g1 = c; b1 = x; } + else if (hh < 4.f) { r1 = 0.f; g1 = x; b1 = c; } + else if (hh < 5.f) { r1 = x; g1 = 0.f; b1 = c; } + else { r1 = c; g1 = 0.f; b1 = x; } + float m = v - c; + uint8_t r = uint8_t(lrintf((r1 + m) * 255.f)); + uint8_t g = uint8_t(lrintf((g1 + m) * 255.f)); + uint8_t b = uint8_t(lrintf((b1 + m) * 255.f)); + return RGBW32(r, g, b, 0); +} + +} // namespace util +} // namespace skystrip diff --git a/usermods/usermod_v2_skystrip/util.h b/usermods/usermod_v2_skystrip/util.h new file mode 100644 index 0000000000..6200086876 --- /dev/null +++ b/usermods/usermod_v2_skystrip/util.h @@ -0,0 +1,149 @@ +#pragma once + +#include "skymodel.h" +#include "wled.h" +#include +#include +#include + +namespace skystrip { +namespace util { + +// UTC now from WLED’s clock (same source the UI uses) +inline time_t time_now_utc() { return (time_t)toki.getTime().sec; } + +// Current UTC→local offset in seconds (derived from WLED’s own localTime) +inline long current_offset() { + long off = (long)localTime - (long)toki.getTime().sec; + // sanity clamp ±15h (protects against early-boot junk) + if (off < -54000 || off > 54000) + off = 0; + return off; +} + +// Format any UTC epoch using WLED’s *current* offset +inline void fmt_local(char *out, size_t n, time_t utc_ts, + const char *fmt = "%m-%d %H:%M") { + const time_t local_sec = utc_ts + current_offset(); + struct tm tmLocal; + gmtime_r(&local_sec, &tmLocal); // local_sec is already local seconds + strftime(out, n, fmt, &tmLocal); +} + +// Clamp to [0,1] +template inline T clamp01(T v) { + return v < T(0) ? T(0) : (v > T(1) ? T(1) : v); +} + +// Linear interpolation +inline double lerp(double a, double b, double t) { return a + (b - a) * t; } + +// Forecast interpolation helper +static constexpr int GRACE_SEC = 60 * 60 * 3; // fencepost + slide +template +bool estimateAt(const Series &v, time_t t, double step, double &out) { + if (v.empty()) + return false; + // if it's too far away we didn't find estimate + if (t < v.front().tstamp - GRACE_SEC) + return false; + if (t > v.back().tstamp + GRACE_SEC) + return false; + // just off the end uses end value + if (t <= v.front().tstamp) { + out = v.front().value; + return true; + } + if (t >= v.back().tstamp) { + out = v.back().value; + return true; + } + // otherwise interpolate + for (size_t i = 1; i < v.size(); ++i) { + if (t <= v[i].tstamp) { + const auto &a = v[i - 1]; + const auto &b = v[i]; + const double span = double(b.tstamp - a.tstamp); + const double u = clamp01(span > 0 ? double(t - a.tstamp) / span : 0.0); + out = lerp(a.value, b.value, u); + return true; + } + } + return false; +} + +inline bool estimateTempAt(const SkyModel &m, time_t t, double step, + double &outF) { + return estimateAt(m.temperature_forecast, t, step, outF); +} +inline bool estimateDewPtAt(const SkyModel &m, time_t t, double step, + double &outFdp) { + return estimateAt(m.dew_point_forecast, t, step, outFdp); +} +inline bool estimateSpeedAt(const SkyModel &m, time_t t, double step, + double &out) { + return estimateAt(m.wind_speed_forecast, t, step, out); +} +inline bool estimateDirAt(const SkyModel &m, time_t t, double step, + double &out) { + return estimateAt(m.wind_dir_forecast, t, step, out); +} +inline bool estimateGustAt(const SkyModel &m, time_t t, double step, + double &out) { + return estimateAt(m.wind_gust_forecast, t, step, out); +} +inline bool estimateCloudAt(const SkyModel &m, time_t t, double step, + double &out) { + return estimateAt(m.cloud_cover_forecast, t, step, out); +} +inline bool estimatePrecipTypeAt(const SkyModel &m, time_t t, double step, + double &out) { + return estimateAt(m.precip_type_forecast, t, step, out); +} +inline bool estimatePrecipProbAt(const SkyModel &m, time_t t, double step, + double &out) { + return estimateAt(m.precip_prob_forecast, t, step, out); +} + +uint32_t hsv2rgb(float h, float s, float v); + +inline uint32_t applySaturation(uint32_t col, float sat) { + if (sat < 0.f) + sat = 0.f; + else if (sat > 1.f) + sat = 1.f; + + const float r = float((col >> 16) & 0xFF); + const float g = float((col >> 8) & 0xFF); + const float b = float((col) & 0xFF); + + const float y = 0.2627f * r + 0.6780f * g + 0.0593f * b; + + auto mixc = [&](float c) { + float v = y + sat * (c - y); + if (v < 0.f) + v = 0.f; + if (v > 255.f) + v = 255.f; + return v; + }; + + const uint8_t r2 = uint8_t(lrintf(mixc(r))); + const uint8_t g2 = uint8_t(lrintf(mixc(g))); + const uint8_t b2 = uint8_t(lrintf(mixc(b))); + return RGBW32(r2, g2, b2, 0); +} + +// Blink a specific pixel between its color and a gray debug color. +// Call this at setPixel time to highlight dbgPixelIndex once per second. +inline uint32_t blinkDebug(int i, int16_t dbgPixelIndex, uint32_t col) { + if (dbgPixelIndex >= 0 && i == dbgPixelIndex) { + static const uint32_t dbgCol = hsv2rgb(0.f, 0.f, 0.4f); + if ((millis() / 1000) & 1) + return dbgCol; + } + return col; +} + +} // namespace util +} // namespace skystrip diff --git a/usermods/usermod_v2_skystrip/wind_view.cpp b/usermods/usermod_v2_skystrip/wind_view.cpp new file mode 100644 index 0000000000..89880acc3e --- /dev/null +++ b/usermods/usermod_v2_skystrip/wind_view.cpp @@ -0,0 +1,127 @@ +#include "wind_view.h" +#include "skymodel.h" +#include "util.h" +#include "wled.h" +#include +#include + +static constexpr int16_t DEFAULT_SEG_ID = -1; // -1 means disabled +const char CFG_SEG_ID[] = "SegmentId"; + +static inline float hueFromDir(float dir) { + float hue; + if (dir <= 90.f) + hue = 240.f + dir * ((30.f + 360.f - 240.f) / 90.f); + else if (dir <= 180.f) + hue = 30.f + (dir - 90.f) * ((60.f - 30.f) / 90.f); + else if (dir <= 270.f) + hue = 60.f + (dir - 180.f) * ((120.f - 60.f) / 90.f); + else + hue = 120.f + (dir - 270.f) * ((240.f - 120.f) / 90.f); + hue = fmodf(hue, 360.f); + return hue; +} + +static inline float satFromGustDiff(float speed, float gust) { + float diff = gust - speed; + if (diff < 0.f) + diff = 0.f; + constexpr float kMinSat = 0.40f; + constexpr float kMaxDiff = 20.0f; + float u = skystrip::util::clamp01(diff / kMaxDiff); + float eased = u * u * (3.f - 2.f * u); + return kMinSat + (1.f - kMinSat) * eased; +} + +WindView::WindView() : segId_(DEFAULT_SEG_ID) { + DEBUG_PRINTLN("SkyStrip: WV::CTOR"); + snprintf(debugPixelString, sizeof(debugPixelString), "%s:\\n", + name().c_str()); + debugPixelString[sizeof(debugPixelString) - 1] = '\0'; +} + +void WindView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) { + if (dbgPixelIndex < 0) { + snprintf(debugPixelString, sizeof(debugPixelString), "%s:\\n", + name().c_str()); + debugPixelString[sizeof(debugPixelString) - 1] = '\0'; + } + if (segId_ == DEFAULT_SEG_ID) + return; + if (model.wind_speed_forecast.empty()) + return; + if (segId_ < 0 || segId_ >= strip.getMaxSegments()) + return; + + Segment &seg = strip.getSegment((uint8_t)segId_); + seg.freeze = true; + int start = seg.start; + int end = seg.stop - 1; + int len = end - start + 1; + if (len == 0) + return; + + constexpr double kHorizonSec = 48.0 * 3600.0; + const double step = (len > 1) ? (kHorizonSec / double(len - 1)) : 0.0; + + for (uint16_t i = 0; i < len; ++i) { + const time_t t = now + time_t(std::llround(step * i)); + double spd, dir, gst; + if (!skystrip::util::estimateSpeedAt(model, t, step, spd)) + continue; + if (!skystrip::util::estimateDirAt(model, t, step, dir)) + continue; + if (!skystrip::util::estimateGustAt(model, t, step, gst)) + gst = spd; + float hue = hueFromDir((float)dir); + float sat = satFromGustDiff((float)spd, (float)gst); + + // Boost low winds with a floor so sub-10 values aren't lost to + // quantization/gamma. + float u = skystrip::util::clamp01(float(std::max(spd, gst)) / 50.f); + constexpr float kMinV = + 0.18f; // visible floor when wind > 0 (tune 0.12–0.22) + float val = (u <= 0.f) ? 0.f : (kMinV + (1.f - kMinV) * u); + + uint32_t col = skystrip::util::hsv2rgb(hue, sat, val); + + if (dbgPixelIndex >= 0) { + static time_t lastDebug = 0; + if (now - lastDebug > 1 && i == dbgPixelIndex) { + char nowbuf[20]; + skystrip::util::fmt_local(nowbuf, sizeof(nowbuf), now); + char dbgbuf[20]; + skystrip::util::fmt_local(dbgbuf, sizeof(dbgbuf), t); + snprintf(debugPixelString, sizeof(debugPixelString), + "%s: nowtm=%s dbgndx=%d dbgtm=%s " + "spd=%.0f gst=%.0f dir=%.0f " + "H=%.0f S=%.0f V=%.0f\\n", + name().c_str(), nowbuf, i, dbgbuf, spd, gst, dir, hue, + sat * 100, val * 100); + lastDebug = now; + } + } + + int idx = seg.reverse ? (end - i) : (start + i); + strip.setPixelColor(idx, skystrip::util::blinkDebug(i, dbgPixelIndex, col)); + } +} + +void WindView::addToConfig(JsonObject &subtree) { + subtree[FPSTR(CFG_SEG_ID)] = segId_; +} + +void WindView::appendConfigData(Print &s) { + // Keep the hint INLINE (BEFORE the input = 4th arg): + s.print(F("addInfo('SkyStrip:WindView:SegmentId',1,''," + "' (-1 disables)'" + ");")); +} + +bool WindView::readFromConfig(JsonObject &subtree, bool startup_complete, + bool &invalidate_history) { + bool configComplete = !subtree.isNull(); + configComplete &= + getJsonValue(subtree[FPSTR(CFG_SEG_ID)], segId_, DEFAULT_SEG_ID); + return configComplete; +} diff --git a/usermods/usermod_v2_skystrip/wind_view.h b/usermods/usermod_v2_skystrip/wind_view.h new file mode 100644 index 0000000000..2e41e442b1 --- /dev/null +++ b/usermods/usermod_v2_skystrip/wind_view.h @@ -0,0 +1,27 @@ +#pragma once + +#include "interfaces.h" +#include "skymodel.h" + +class SkyModel; + +class WindView : public IDataViewT { +public: + WindView(); + ~WindView() override = default; + + void view(time_t now, SkyModel const & model, int16_t dbgPixelIndex) override; + std::string name() const override { return "WV"; } + void appendDebugPixel(Print& s) const override { s.print(debugPixelString); } + + void addToConfig(JsonObject& subtree) override; + void appendConfigData(Print& s) override; + bool readFromConfig(JsonObject& subtree, + bool startup_complete, + bool& invalidate_history) override; + const char* configKey() const override { return "WindView"; } + +private: + int16_t segId_; + char debugPixelString[128]; +}; diff --git a/wled00/const.h b/wled00/const.h index 1abf245396..e91b31dfed 100644 --- a/wled00/const.h +++ b/wled00/const.h @@ -199,6 +199,7 @@ static_assert(WLED_MAX_BUSSES <= 32, "WLED_MAX_BUSSES exceeds hard limit"); #define USERMOD_ID_RF433 56 //Usermod "usermod_v2_RF433.h" #define USERMOD_ID_BRIGHTNESS_FOLLOW_SUN 57 //Usermod "usermod_v2_brightness_follow_sun.h" #define USERMOD_ID_USER_FX 58 //Usermod "user_fx" +#define USERMOD_ID_SKYSTRIP 59 //Usermod "usermod_v2_skystrip.h" //Access point behavior #define AP_BEHAVIOR_BOOT_NO_CONN 0 //Open AP when no connection after boot From 315b5ab758d8142e149b638fdc79839a715ecaef Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Thu, 28 Aug 2025 14:30:12 -0700 Subject: [PATCH 02/20] Fix CodeRabbit issues: - Avoid leaving segments permanently frozen; restore previous freeze state - Fix negative modulo in local-time marker logic - Handle invalid segments safely - Clarify duplicate "N" table row - Fix country normalizeLocation bug - Don't include the API key in logging - Tighten wording; avoid hardcoding OWM plan limits - Fix rate-limit reset logic - Harden rate-limit check and keep informative logging - Add network timeout to avoid long blocking on bad links - Check non-2xx status codes before parsing - Right-size JSON buffer per target - Fix potential buffer overrun in emitSeriesMDHM - Use float instead of double for DataPoint.value - Restore seg.freeze after rendering (and avoid freezing on zero-length) - use PROGMEM to reduce RAM use - Sanitize HSV inputs - Clamp HSV values in conversion to RGB - Clarify SAFETY_DELAY_MS type - Clamp and wrap HSV inputs to prevent overflow/underflow artifacts - Normalize wind direction to [0,360) before mapping to hue - fixed compiler warning about shadowed variables - delay initial checkhistory after fetch --- usermods/usermod_v2_skystrip/FAQ.md | 2 +- usermods/usermod_v2_skystrip/cloud_view.cpp | 12 +-- usermods/usermod_v2_skystrip/delta_view.cpp | 6 +- .../open_weather_map_source.cpp | 86 +++++++++++++------ usermods/usermod_v2_skystrip/readme.md | 7 +- .../usermod_v2_skystrip/rest_json_client.cpp | 17 ++-- .../usermod_v2_skystrip/rest_json_client.h | 8 +- usermods/usermod_v2_skystrip/skymodel.cpp | 18 +++- usermods/usermod_v2_skystrip/skymodel.h | 2 +- .../usermod_v2_skystrip/temperature_view.cpp | 10 +-- .../usermod_v2_skystrip/test_pattern_view.cpp | 15 ++-- .../usermod_v2_skystrip.cpp | 22 ++--- usermods/usermod_v2_skystrip/util.cpp | 18 +++- usermods/usermod_v2_skystrip/util.h | 13 +++ usermods/usermod_v2_skystrip/wind_view.cpp | 9 +- 15 files changed, 161 insertions(+), 84 deletions(-) diff --git a/usermods/usermod_v2_skystrip/FAQ.md b/usermods/usermod_v2_skystrip/FAQ.md index 5b905fc13c..dc9a193054 100644 --- a/usermods/usermod_v2_skystrip/FAQ.md +++ b/usermods/usermod_v2_skystrip/FAQ.md @@ -39,7 +39,7 @@ The mapping between wind direction and hue can be approximated as: | SW | 90 | Lime | | W | 120 | Green | | NW | 180 | Cyan | -| N | 240 | Blue | +| N | 240 | Blue | (wraps around) ## Temperature View (TV) diff --git a/usermods/usermod_v2_skystrip/cloud_view.cpp b/usermods/usermod_v2_skystrip/cloud_view.cpp index 154bee8965..6bfd87064e 100644 --- a/usermods/usermod_v2_skystrip/cloud_view.cpp +++ b/usermods/usermod_v2_skystrip/cloud_view.cpp @@ -7,7 +7,7 @@ #include static constexpr int16_t DEFAULT_SEG_ID = -1; // -1 means disabled -const char CFG_SEG_ID[] = "SegmentId"; +const char CFG_SEG_ID[] PROGMEM = "SegmentId"; static bool isDay(const SkyModel &m, time_t t) { const time_t MAXTT = std::numeric_limits::max(); @@ -50,12 +50,12 @@ void CloudView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) { return; Segment &seg = strip.getSegment((uint8_t)segId_); - seg.freeze = true; int start = seg.start; int end = seg.stop - 1; int len = end - start + 1; - if (len == 0) + if (len <= 0) return; + skystrip::util::FreezeGuard freezeGuard(seg); constexpr double kHorizonSec = 48.0 * 3600.0; const double step = (len > 1) ? (kHorizonSec / double(len - 1)) : 0.0; @@ -73,9 +73,9 @@ void CloudView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) { time_t sunriseTOD = 0; time_t sunsetTOD = 0; if (useSunrise) - sunriseTOD = (sunrise + offset) % DAY; + sunriseTOD = (((sunrise + offset) % DAY) + DAY) % DAY; // normalize to [0, DAY) if (useSunset) - sunsetTOD = (sunset + offset) % DAY; + sunsetTOD = (((sunset + offset) % DAY) + DAY) % DAY; // normalize to [0, DAY) auto nearTOD = [&](time_t a, time_t b) { time_t diff = (a >= b) ? (a - b) : (b - a); @@ -87,7 +87,7 @@ void CloudView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) { auto isMarker = [&](time_t t) { if (!useSunrise && !useSunset) return false; - time_t tod = (t + offset) % DAY; + time_t tod = (((t + offset) % DAY) + DAY) % DAY; // normalize to [0, DAY) if (useSunrise && nearTOD(tod, sunriseTOD)) return true; if (useSunset && nearTOD(tod, sunsetTOD)) diff --git a/usermods/usermod_v2_skystrip/delta_view.cpp b/usermods/usermod_v2_skystrip/delta_view.cpp index 9dcc0102e8..1ff10aee17 100644 --- a/usermods/usermod_v2_skystrip/delta_view.cpp +++ b/usermods/usermod_v2_skystrip/delta_view.cpp @@ -7,7 +7,7 @@ #include "wled.h" static constexpr int16_t DEFAULT_SEG_ID = -1; // -1 means disabled -const char CFG_SEG_ID[] = "SegmentId"; +const char CFG_SEG_ID[] PROGMEM = "SegmentId"; struct Stop { double f; @@ -74,12 +74,12 @@ void DeltaView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) { return; Segment &seg = strip.getSegment((uint8_t)segId_); - seg.freeze = true; int start = seg.start; int end = seg.stop - 1; int len = end - start + 1; - if (len == 0) + if (len <= 0) return; + skystrip::util::FreezeGuard freezeGuard(seg); constexpr double kHorizonSec = 48.0 * 3600.0; const double step = (len > 1) ? (kHorizonSec / double(len - 1)) : 0.0; diff --git a/usermods/usermod_v2_skystrip/open_weather_map_source.cpp b/usermods/usermod_v2_skystrip/open_weather_map_source.cpp index d668a5fb56..6b978d3822 100644 --- a/usermods/usermod_v2_skystrip/open_weather_map_source.cpp +++ b/usermods/usermod_v2_skystrip/open_weather_map_source.cpp @@ -20,12 +20,12 @@ static constexpr unsigned DEFAULT_INTERVAL_SEC = 3600; // 1 hour // - these are user visible in the webapp settings UI // - they are scoped to this module, don't need to be globally unique // -const char CFG_API_BASE[] = "ApiBase"; -const char CFG_API_KEY[] = "ApiKey"; -const char CFG_LATITUDE[] = "Latitude"; -const char CFG_LONGITUDE[] = "Longitude"; -const char CFG_INTERVAL_SEC[] = "IntervalSec"; -const char CFG_LOCATION[] = "Location"; +const char CFG_API_BASE[] PROGMEM = "ApiBase"; +const char CFG_API_KEY[] PROGMEM = "ApiKey"; +const char CFG_LATITUDE[] PROGMEM = "Latitude"; +const char CFG_LONGITUDE[] PROGMEM = "Longitude"; +const char CFG_INTERVAL_SEC[] PROGMEM = "IntervalSec"; +const char CFG_LOCATION[] PROGMEM = "Location"; // keep commas; encode spaces etc. static void urlEncode(const char* src, char* dst, size_t dstSize) { @@ -46,6 +46,27 @@ static void urlEncode(const char* src, char* dst, size_t dstSize) { dst[di] = '\0'; } +// Redact the API key in a URL by replacing the value after "appid=" with '*'. +static void redactApiKeyInUrl(const char* in, char* out, size_t outLen) { + if (!in || !out || outLen == 0) return; + const char* p = strstr(in, "appid="); + if (!p) { + strncpy(out, in, outLen); + out[outLen - 1] = '\0'; + return; + } + size_t prefixLen = (size_t)(p - in) + 6; // include "appid=" + if (prefixLen >= outLen) { + // Not enough space; best effort copy and terminate + strncpy(out, in, outLen); + out[outLen - 1] = '\0'; + return; + } + memcpy(out, in, prefixLen); + out[prefixLen] = '*'; + out[prefixLen + 1] = '\0'; +} + // Normalize "Oakland, CA, USA" → "Oakland,CA,US" in-place static void normalizeLocation(char* q) { // trim spaces and commas @@ -57,9 +78,8 @@ static void normalizeLocation(char* q) { *out = '\0'; len = strlen(q); if (len >= 4 && strcasecmp(q + len - 4, ",USA") == 0) { - q[len - 2] = 'U'; - q[len - 1] = 'S'; - q[len] = '\0'; + // Truncate the trailing 'A' so ",USA" → ",US" without corrupting chars + q[len - 1] = '\0'; } } @@ -200,11 +220,14 @@ std::unique_ptr OpenWeatherMapSource::fetch(std::time_t now) { return nullptr; lastFetch_ = now; + lastHistFetch_ = now; // history fetches should wait // Fetch JSON char url[256]; composeApiUrl(url, sizeof(url)); - DEBUG_PRINTF("SkyStrip: %s::fetch URL: %s\n", name().c_str(), url); + char redacted[256]; + redactApiKeyInUrl(url, redacted, sizeof(redacted)); + DEBUG_PRINTF("SkyStrip: %s::fetch URL: %s\n", name().c_str(), redacted); auto doc = getJson(url); if (!doc) { @@ -253,12 +276,12 @@ std::unique_ptr OpenWeatherMapSource::fetch(std::time_t now) { model->sunset_ = sunset; for (JsonObject hour : hourly) { time_t dt = hour["dt"].as(); - model->temperature_forecast.push_back({ dt, hour["temp"].as() }); - model->dew_point_forecast.push_back({ dt, hour["dew_point"].as() }); - model->wind_speed_forecast.push_back({ dt, hour["wind_speed"].as() }); - model->wind_dir_forecast.push_back({ dt, hour["wind_deg"].as() }); - model->wind_gust_forecast.push_back({ dt, hour["wind_gust"].as() }); - model->cloud_cover_forecast.push_back({ dt, hour["clouds"].as() }); + model->temperature_forecast.push_back({ dt, (float)hour["temp"].as() }); + model->dew_point_forecast.push_back({ dt, (float)hour["dew_point"].as() }); + model->wind_speed_forecast.push_back({ dt, (float)hour["wind_speed"].as() }); + model->wind_dir_forecast.push_back({ dt, (float)hour["wind_deg"].as() }); + model->wind_gust_forecast.push_back({ dt, (float)hour["wind_gust"].as() }); + model->cloud_cover_forecast.push_back({ dt, (float)hour["clouds"].as() }); JsonArray wArr = hour["weather"].as(); bool hasRain = false, hasSnow = false; if (hour.containsKey("rain")) { @@ -278,10 +301,13 @@ std::unique_ptr OpenWeatherMapSource::fetch(std::time_t now) { hasSnow = true; } int ptype = hasRain && hasSnow ? 3 : (hasSnow ? 2 : (hasRain ? 1 : 0)); - model->precip_type_forecast.push_back({ dt, double(ptype) }); - model->precip_prob_forecast.push_back({ dt, hour["pop"].as() }); + model->precip_type_forecast.push_back({ dt, (float)ptype }); + model->precip_prob_forecast.push_back({ dt, (float)hour["pop"].as() }); } + // Stagger history fetch to avoid back-to-back GETs in same loop iteration + // and reduce risk of watchdog resets. Enforce at least 15s before history. + lastHistFetch_ = skystrip::util::time_now_utc(); return model; } @@ -298,7 +324,9 @@ std::unique_ptr OpenWeatherMapSource::checkhistory(time_t now, std::ti snprintf(url, sizeof(url), "%s/data/3.0/onecall/timemachine?lat=%.6f&lon=%.6f&dt=%ld&units=imperial&appid=%s", apiBase_.c_str(), latitude_, longitude_, (long)fetchDt, apiKey_.c_str()); - DEBUG_PRINTF("SkyStrip: %s::checkhistory URL: %s\n", name().c_str(), url); + char redacted[256]; + redactApiKeyInUrl(url, redacted, sizeof(redacted)); + DEBUG_PRINTF("SkyStrip: %s::checkhistory URL: %s\n", name().c_str(), redacted); auto doc = getJson(url); if (!doc) { @@ -321,12 +349,12 @@ std::unique_ptr OpenWeatherMapSource::checkhistory(time_t now, std::ti for (JsonObject hour : hourly) { time_t dt = hour["dt"].as(); if (dt >= oldestTstamp) continue; - model->temperature_forecast.push_back({ dt, hour["temp"].as() }); - model->dew_point_forecast.push_back({ dt, hour["dew_point"].as() }); - model->wind_speed_forecast.push_back({ dt, hour["wind_speed"].as() }); - model->wind_dir_forecast.push_back({ dt, hour["wind_deg"].as() }); - model->wind_gust_forecast.push_back({ dt, hour["wind_gust"].as() }); - model->cloud_cover_forecast.push_back({ dt, hour["clouds"].as() }); + model->temperature_forecast.push_back({ dt, (float)hour["temp"].as() }); + model->dew_point_forecast.push_back({ dt, (float)hour["dew_point"].as() }); + model->wind_speed_forecast.push_back({ dt, (float)hour["wind_speed"].as() }); + model->wind_dir_forecast.push_back({ dt, (float)hour["wind_deg"].as() }); + model->wind_gust_forecast.push_back({ dt, (float)hour["wind_gust"].as() }); + model->cloud_cover_forecast.push_back({ dt, (float)hour["clouds"].as() }); JsonArray wArr = hour["weather"].as(); bool hasRain = false, hasSnow = false; if (hour.containsKey("rain")) { @@ -346,8 +374,8 @@ std::unique_ptr OpenWeatherMapSource::checkhistory(time_t now, std::ti hasSnow = true; } int ptype = hasRain && hasSnow ? 3 : (hasSnow ? 2 : (hasRain ? 1 : 0)); - model->precip_type_forecast.push_back({ dt, double(ptype) }); - model->precip_prob_forecast.push_back({ dt, hour["pop"].as() }); + model->precip_type_forecast.push_back({ dt, (float)ptype }); + model->precip_prob_forecast.push_back({ dt, (float)hour["pop"].as() }); } if (model->temperature_forecast.empty()) return nullptr; @@ -384,7 +412,9 @@ bool OpenWeatherMapSource::geocodeOWM(std::string const & rawQuery, snprintf(url, sizeof(url), "%s/geo/1.0/direct?q=%s&limit=5&appid=%s", apiBase_.c_str(), enc, apiKey_.c_str()); - DEBUG_PRINTF("SkyStrip: %s::geocodeOWM URL: %s\n", name().c_str(), url); + char redacted[512]; + redactApiKeyInUrl(url, redacted, sizeof(redacted)); + DEBUG_PRINTF("SkyStrip: %s::geocodeOWM URL: %s\n", name().c_str(), redacted); auto doc = getJson(url); resetRateLimit(); // we want to do a fetch immediately after ... diff --git a/usermods/usermod_v2_skystrip/readme.md b/usermods/usermod_v2_skystrip/readme.md index cb6ae43c1f..36b04bb573 100644 --- a/usermods/usermod_v2_skystrip/readme.md +++ b/usermods/usermod_v2_skystrip/readme.md @@ -10,10 +10,9 @@ Add `usermod_v2_skystrip` to `custom_usermods` in your PlatformIO environment. ## Configuration Acquire an API key from -[OpenWeatherMap](https://openweathermap.org/api/one-call-3). The skystrip -module makes one API call per hour plus 24 calls when initially started -up; it should remain comfortably under the free-tier limit of 1000 per -day. +[OpenWeatherMap](https://openweathermap.org/api/one-call-3). The SkyStrip +module makes one API call per hour, plus up to 24 calls on first startup. +This typically stays within free-tier limits, but check your current plan. Enter the latitude and longitude for the desired forecast. There are several ways to do this: diff --git a/usermods/usermod_v2_skystrip/rest_json_client.cpp b/usermods/usermod_v2_skystrip/rest_json_client.cpp index 8399b57b88..2a5f059528 100644 --- a/usermods/usermod_v2_skystrip/rest_json_client.cpp +++ b/usermods/usermod_v2_skystrip/rest_json_client.cpp @@ -8,16 +8,19 @@ RestJsonClient::RestJsonClient() } void RestJsonClient::resetRateLimit() { - // pretend we just made the last fetch RATE_LIMIT_MS ago - lastFetchMs_ = millis() - static_cast(-static_cast(RATE_LIMIT_MS)); + // pretend we fetched RATE_LIMIT_MS ago (allow immediate next call) + lastFetchMs_ = millis() - RATE_LIMIT_MS; } DynamicJsonDocument* RestJsonClient::getJson(const char* url) { // enforce a basic rate limit to prevent runaway software from making bursts // of API calls (looks like DoS and get's our API key turned off ...) unsigned long now_ms = millis(); - if (now_ms - lastFetchMs_ < RATE_LIMIT_MS) { - DEBUG_PRINTLN("SkyStrip: RestJsonClient::getJson: RATE LIMITED"); + // compute elapsed using unsigned arithmetic to avoid signed underflow + unsigned long elapsed = now_ms - lastFetchMs_; + if (elapsed < RATE_LIMIT_MS) { + unsigned long remaining = RATE_LIMIT_MS - elapsed; + DEBUG_PRINTF("SkyStrip: RestJsonClient::getJson: RATE LIMITED (%lu ms remaining)\n", remaining); return nullptr; } lastFetchMs_ = now_ms; @@ -42,9 +45,11 @@ DynamicJsonDocument* RestJsonClient::getJson(const char* url) { } DEBUG_PRINTF("SkyStrip: RestJsonClient::getJson: free heap before GET: %u\n", ESP.getFreeHeap()); int code = http_.GET(); - if (code <= 0) { + // Treat network errors (<=0) and non-2xx statuses as failures. + // Optionally consider 204 (No Content) as failure since there is no body to parse. + if (code <= 0 || code < 200 || code >= 300 || code == 204) { http_.end(); - DEBUG_PRINTF("SkyStrip: RestJsonClient::getJson: http get error code: %d\n", code); + DEBUG_PRINTF("SkyStrip: RestJsonClient::getJson: HTTP error/status: %d\n", code); return nullptr; } diff --git a/usermods/usermod_v2_skystrip/rest_json_client.h b/usermods/usermod_v2_skystrip/rest_json_client.h index c87828bf1e..059c40d81a 100644 --- a/usermods/usermod_v2_skystrip/rest_json_client.h +++ b/usermods/usermod_v2_skystrip/rest_json_client.h @@ -25,8 +25,12 @@ class RestJsonClient { protected: static constexpr unsigned RATE_LIMIT_MS = 10u * 1000u; // 10 seconds - static constexpr size_t MAX_JSON_SIZE = 32 * 1024; // 32kB fixed buffer - +#if defined(ARDUINO_ARCH_ESP8266) + static constexpr size_t MAX_JSON_SIZE = 16 * 1024; // 16kB on 8266 +#else + static constexpr size_t MAX_JSON_SIZE = 32 * 1024; // 32kB on ESP32 +#endif + private: HTTPClient http_; unsigned long lastFetchMs_; diff --git a/usermods/usermod_v2_skystrip/skymodel.cpp b/usermods/usermod_v2_skystrip/skymodel.cpp index 0d833f7170..d555c65242 100644 --- a/usermods/usermod_v2_skystrip/skymodel.cpp +++ b/usermods/usermod_v2_skystrip/skymodel.cpp @@ -122,15 +122,25 @@ static inline void emitSeriesMDHM(Print &out, time_t now, const char *label, size_t i = 0; size_t off = 0; + const size_t cap = sizeof(line); for (const auto& dp : s) { if (i % 6 == 0) { - off = snprintf(line, sizeof(line), "SkyModel:"); + int n = snprintf(line, cap, "SkyModel:"); + off = (n < 0) ? 0u : ((size_t)n >= cap ? cap - 1 : (size_t)n); } skystrip::util::fmt_local(tb, sizeof(tb), dp.tstamp); - off += snprintf(line + off, sizeof(line) - off, - " (%s, %6.2f)", tb, dp.value); + if (off < cap) { + size_t rem = cap - off; + int n = snprintf(line + off, rem, " (%s, %6.2f)", tb, dp.value); + if (n > 0) off += ((size_t)n >= rem ? rem - 1 : (size_t)n); + } if (i % 6 == 5 || i == s.size() - 1) { - if (i == s.size() - 1) off += snprintf(line + off, sizeof(line) - off, " ]"); + if (i == s.size() - 1 && off < cap) { + size_t rem = cap - off; + int n = snprintf(line + off, rem, " ]"); + if (n > 0) off += ((size_t)n >= rem ? rem - 1 : (size_t)n); + } + if (off >= cap) off = cap - 1; // ensure space for newline line[off++] = '\n'; out.write((const uint8_t*)line, off); } diff --git a/usermods/usermod_v2_skystrip/skymodel.h b/usermods/usermod_v2_skystrip/skymodel.h index 166999efcd..f49b41ec67 100644 --- a/usermods/usermod_v2_skystrip/skymodel.h +++ b/usermods/usermod_v2_skystrip/skymodel.h @@ -10,7 +10,7 @@ class Print; struct DataPoint { time_t tstamp; - double value; + float value; }; class SkyModel { diff --git a/usermods/usermod_v2_skystrip/temperature_view.cpp b/usermods/usermod_v2_skystrip/temperature_view.cpp index 1b84fdc495..0924cf536a 100644 --- a/usermods/usermod_v2_skystrip/temperature_view.cpp +++ b/usermods/usermod_v2_skystrip/temperature_view.cpp @@ -10,7 +10,7 @@ static constexpr int16_t DEFAULT_SEG_ID = -1; // -1 means disabled // - these are user visible in the webapp settings UI // - they are scoped to this module, don't need to be globally unique // -const char CFG_SEG_ID[] = "SegmentId"; +const char CFG_SEG_ID[] PROGMEM = "SegmentId"; // Map dew-point depression (°F) -> saturation multiplier. // dd<=2°F -> minSat ; dd>=25°F -> 1.0 ; smooth in between. @@ -77,12 +77,12 @@ void TemperatureView::view(time_t now, SkyModel const &model, if (segId_ < 0 || segId_ >= strip.getMaxSegments()) return; Segment &seg = strip.getSegment((uint8_t)segId_); - seg.freeze = true; int start = seg.start; int end = seg.stop - 1; // inclusive int len = end - start + 1; - if (len == 0) + if (len <= 0) return; + skystrip::util::FreezeGuard freezeGuard(seg); constexpr double kHorizonSec = 48.0 * 3600.0; const double step = (len > 1) ? (kHorizonSec / double(len - 1)) : 0.0; @@ -97,9 +97,7 @@ void TemperatureView::view(time_t now, SkyModel const &model, return 0.f; time_t local = t + tzOffset; // convert to local seconds - time_t s = local % DAY; // seconds since local midnight - if (s < 0) - s += DAY; + time_t s = (((local % DAY) + DAY) % DAY); // seconds since local midnight (normalized) // Seconds-of-day for markers + per-marker width multipliers. static const time_t kMarkers[] = {0 * 3600, 3 * 3600, 6 * 3600, diff --git a/usermods/usermod_v2_skystrip/test_pattern_view.cpp b/usermods/usermod_v2_skystrip/test_pattern_view.cpp index b0e3b00c5d..92b78e6e48 100644 --- a/usermods/usermod_v2_skystrip/test_pattern_view.cpp +++ b/usermods/usermod_v2_skystrip/test_pattern_view.cpp @@ -10,7 +10,7 @@ #include "util.h" static constexpr int16_t DEFAULT_SEG_ID = -1; // -1 means disabled -const char CFG_SEG_ID[] = "SegmentId"; +const char CFG_SEG_ID[] PROGMEM = "SegmentId"; // legacy individual HSV components const char CFG_START_HUE[] PROGMEM = "StartHue"; const char CFG_START_SAT[] PROGMEM = "StartSat"; @@ -70,8 +70,13 @@ bool parseHSV(const char *in, float &h, float &s, float &v) { if (found[0] && found[1] && found[2]) { h = values[0]; - s = values[1] / 100.f; - v = values[2] / 100.f; + // wrap hue to [0,360) + float hh = fmodf(h, 360.f); + if (hh < 0.f) hh += 360.f; + h = hh; + // clamp saturation/value to [0,1] + s = skystrip::util::clamp01(values[1] / 100.f); + v = skystrip::util::clamp01(values[2] / 100.f); return true; } return false; @@ -101,12 +106,12 @@ void TestPatternView::view(time_t now, SkyModel const &model, return; Segment &seg = strip.getSegment((uint8_t)segId_); - seg.freeze = true; int start = seg.start; int end = seg.stop - 1; int len = end - start + 1; - if (len == 0) + if (len <= 0) return; + skystrip::util::FreezeGuard freezeGuard(seg); for (int i = 0; i < len; ++i) { float u = (len > 1) ? float(i) / float(len - 1) : 0.f; diff --git a/usermods/usermod_v2_skystrip/usermod_v2_skystrip.cpp b/usermods/usermod_v2_skystrip/usermod_v2_skystrip.cpp index b97c9ad843..cf33e1c22b 100644 --- a/usermods/usermod_v2_skystrip/usermod_v2_skystrip.cpp +++ b/usermods/usermod_v2_skystrip/usermod_v2_skystrip.cpp @@ -14,19 +14,19 @@ #include "delta_view.h" #include "test_pattern_view.h" -const char CFG_NAME[] = "SkyStrip"; -const char CFG_ENABLED[] = "Enabled"; -const char CFG_PIXEL_DBG_NAME[] = "DebugPixel"; -const char CFG_DBG_PIXEL_INDEX[] = "Index"; +const char CFG_NAME[] PROGMEM = "SkyStrip"; +const char CFG_ENABLED[] PROGMEM = "Enabled"; +const char CFG_PIXEL_DBG_NAME[] PROGMEM = "DebugPixel"; +const char CFG_DBG_PIXEL_INDEX[] PROGMEM = "Index"; static SkyStrip skystrip_usermod; REGISTER_USERMOD(skystrip_usermod); -// Don't handle the loop function for SAFETY_DELAY_MSECS. If we've +// Don't handle the loop function for SAFETY_DELAY_MS. If we've // coded a deadlock or crash in the loop handler this will give us a // chance to offMode the device so we can use the OTA update to fix // the problem. -const time_t SAFETY_DELAY_MSECS = 10 * 1000; +const uint32_t SAFETY_DELAY_MS = 10u * 1000u; // runs before readFromConfig() and setup() SkyStrip::SkyStrip() { @@ -52,7 +52,7 @@ void SkyStrip::setup() { DEBUG_PRINTLN(F("SkyStrip::setup starting")); uint32_t now_ms = millis(); - safeToStart_ = now_ms + SAFETY_DELAY_MSECS; + safeToStart_ = now_ms + SAFETY_DELAY_MS; // Serial.begin(115200); @@ -206,14 +206,14 @@ bool SkyStrip::readFromConfig(JsonObject& root) { // read the sources for (auto& src : sources_) { - JsonObject sub = top[src->configKey()]; - ok &= src->readFromConfig(sub, startup_complete, invalidate_history); + JsonObject sub1 = top[src->configKey()]; + ok &= src->readFromConfig(sub1, startup_complete, invalidate_history); } // read the views for (auto& vw : views_) { - JsonObject sub = top[vw->configKey()]; - ok &= vw->readFromConfig(sub, startup_complete, invalidate_history); + JsonObject sub2 = top[vw->configKey()]; + ok &= vw->readFromConfig(sub2, startup_complete, invalidate_history); } if (invalidate_history) { diff --git a/usermods/usermod_v2_skystrip/util.cpp b/usermods/usermod_v2_skystrip/util.cpp index 4be62a7d81..e1f8ec037f 100644 --- a/usermods/usermod_v2_skystrip/util.cpp +++ b/usermods/usermod_v2_skystrip/util.cpp @@ -4,8 +4,14 @@ namespace skystrip { namespace util { uint32_t hsv2rgb(float h, float s, float v) { + // Normalize inputs to safe ranges + if (s < 0.f) s = 0.f; else if (s > 1.f) s = 1.f; + if (v < 0.f) v = 0.f; else if (v > 1.f) v = 1.f; + float hh = fmodf(h, 360.f); + if (hh < 0.f) hh += 360.f; + float c = v * s; - float hh = h / 60.f; + hh = hh / 60.f; float x = c * (1.f - fabsf(fmodf(hh, 2.f) - 1.f)); float r1, g1, b1; if (hh < 1.f) { r1 = c; g1 = x; b1 = 0.f; } @@ -15,9 +21,13 @@ uint32_t hsv2rgb(float h, float s, float v) { else if (hh < 5.f) { r1 = x; g1 = 0.f; b1 = c; } else { r1 = c; g1 = 0.f; b1 = x; } float m = v - c; - uint8_t r = uint8_t(lrintf((r1 + m) * 255.f)); - uint8_t g = uint8_t(lrintf((g1 + m) * 255.f)); - uint8_t b = uint8_t(lrintf((b1 + m) * 255.f)); + // Clamp final channels into [0,1] to avoid rounding drift outside range + float rf = r1 + m; if (rf < 0.f) rf = 0.f; else if (rf > 1.f) rf = 1.f; + float gf = g1 + m; if (gf < 0.f) gf = 0.f; else if (gf > 1.f) gf = 1.f; + float bf = b1 + m; if (bf < 0.f) bf = 0.f; else if (bf > 1.f) bf = 1.f; + uint8_t r = uint8_t(lrintf(rf * 255.f)); + uint8_t g = uint8_t(lrintf(gf * 255.f)); + uint8_t b = uint8_t(lrintf(bf * 255.f)); return RGBW32(r, g, b, 0); } diff --git a/usermods/usermod_v2_skystrip/util.h b/usermods/usermod_v2_skystrip/util.h index 6200086876..4f15fc5e51 100644 --- a/usermods/usermod_v2_skystrip/util.h +++ b/usermods/usermod_v2_skystrip/util.h @@ -9,6 +9,19 @@ namespace skystrip { namespace util { +// RAII guard to temporarily freeze a segment during rendering and always +// restore its original freeze state on exit (including early returns). +struct FreezeGuard { + Segment &seg; + bool prev; + explicit FreezeGuard(Segment &s, bool freezeNow = true) : seg(s), prev(s.freeze) { + seg.freeze = freezeNow; + } + ~FreezeGuard() { seg.freeze = prev; } + FreezeGuard(const FreezeGuard &) = delete; + FreezeGuard &operator=(const FreezeGuard &) = delete; +}; + // UTC now from WLED’s clock (same source the UI uses) inline time_t time_now_utc() { return (time_t)toki.getTime().sec; } diff --git a/usermods/usermod_v2_skystrip/wind_view.cpp b/usermods/usermod_v2_skystrip/wind_view.cpp index 89880acc3e..ecf6684027 100644 --- a/usermods/usermod_v2_skystrip/wind_view.cpp +++ b/usermods/usermod_v2_skystrip/wind_view.cpp @@ -6,9 +6,12 @@ #include static constexpr int16_t DEFAULT_SEG_ID = -1; // -1 means disabled -const char CFG_SEG_ID[] = "SegmentId"; +const char CFG_SEG_ID[] PROGMEM = "SegmentId"; static inline float hueFromDir(float dir) { + // Normalize direction to [0, 360) + dir = fmodf(dir, 360.f); + if (dir < 0.f) dir += 360.f; float hue; if (dir <= 90.f) hue = 240.f + dir * ((30.f + 360.f - 240.f) / 90.f); @@ -54,12 +57,12 @@ void WindView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) { return; Segment &seg = strip.getSegment((uint8_t)segId_); - seg.freeze = true; int start = seg.start; int end = seg.stop - 1; int len = end - start + 1; - if (len == 0) + if (len <= 0) return; + skystrip::util::FreezeGuard freezeGuard(seg); constexpr double kHorizonSec = 48.0 * 3600.0; const double step = (len > 1) ? (kHorizonSec / double(len - 1)) : 0.0; From 3a570750db91bc93f96bf703b3039fc2e422e284 Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Thu, 28 Aug 2025 17:25:16 -0700 Subject: [PATCH 03/20] Add missing header include --- usermods/usermod_v2_skystrip/util.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/usermods/usermod_v2_skystrip/util.cpp b/usermods/usermod_v2_skystrip/util.cpp index e1f8ec037f..a3b036be6e 100644 --- a/usermods/usermod_v2_skystrip/util.cpp +++ b/usermods/usermod_v2_skystrip/util.cpp @@ -1,3 +1,5 @@ +#include + #include "util.h" namespace skystrip { From 44e983f955fc8f1688312dd789d00e63f843ed32 Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Thu, 28 Aug 2025 18:34:49 -0700 Subject: [PATCH 04/20] Fix base API url --- usermods/usermod_v2_skystrip/open_weather_map_source.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/usermods/usermod_v2_skystrip/open_weather_map_source.cpp b/usermods/usermod_v2_skystrip/open_weather_map_source.cpp index 6b978d3822..e2bd7babd3 100644 --- a/usermods/usermod_v2_skystrip/open_weather_map_source.cpp +++ b/usermods/usermod_v2_skystrip/open_weather_map_source.cpp @@ -10,7 +10,7 @@ #include "skymodel.h" #include "util.h" -static constexpr const char* DEFAULT_API_BASE = "https://api.openweathermap.org"; +static constexpr const char* DEFAULT_API_BASE = "http://api.openweathermap.org"; static constexpr const char * DEFAULT_API_KEY = ""; static constexpr const char * DEFAULT_LOCATION = ""; static constexpr const double DEFAULT_LATITUDE = 37.80486; From 9a6a0a94ca548495dfe8f40f30a94ea15cb77466 Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Fri, 29 Aug 2025 08:58:40 -0700 Subject: [PATCH 05/20] Improve documentation and comments: - Polish README list formatting and examples for consistency - Improve FAQ - Mark variable as unused - Improve comments to clarify semantics - Improve temperature mapping documentation in the README --- usermods/usermod_v2_skystrip/FAQ.md | 62 +++++++++++-------- usermods/usermod_v2_skystrip/cloud_view.cpp | 1 - .../open_weather_map_source.cpp | 2 + usermods/usermod_v2_skystrip/readme.md | 18 +++--- .../usermod_v2_skystrip/rest_json_client.cpp | 2 + usermods/usermod_v2_skystrip/util.h | 2 +- 6 files changed, 50 insertions(+), 37 deletions(-) diff --git a/usermods/usermod_v2_skystrip/FAQ.md b/usermods/usermod_v2_skystrip/FAQ.md index dc9a193054..2d6122ede3 100644 --- a/usermods/usermod_v2_skystrip/FAQ.md +++ b/usermods/usermod_v2_skystrip/FAQ.md @@ -4,6 +4,7 @@ This FAQ explains how to read the various HSV-based views of the `usermod_v2_skystrip` module. Each view maps weather data onto hue, saturation, and value (brightness) along the LED strip. + ## Cloud View (CV) Markers for sunrise or sunset show as orange pixels. During @@ -15,6 +16,7 @@ white. For clouds, saturation is low and value grows with coverage, keeping even thin clouds visible. Thus, a bright blue pixel highlights likely rain, whereas a soft yellow glow marks daytime cloud cover. + ## Wind View (WV) The hue encodes wind direction around the compass: blue (240°) points @@ -41,29 +43,33 @@ The mapping between wind direction and hue can be approximated as: | NW | 180 | Cyan | | N | 240 | Blue | (wraps around) +Note: Hues wrap at 360°, so “N” repeats at the boundary. -## Temperature View (TV) -Hue follows a cold-to-hot gradient: deep blues near 14 °F transition -through cyan and green to warm yellows at 77 °F and reds above -100 °F. Saturation reflects humidity via dew‑point spread; muggy air -produces soft desaturated colors, whereas dry air yields vivid -tones. Value is fixed at mid‑brightness, but local time markers (e.g., -noon, midnight) temporarily darken pixels to mark time. A bright -orange‑red pixel thus signifies hot, dry conditions around 95 °F, -whereas a pale cyan pixel indicates a cool, humid day near 50 °F. - -Approximate temperature-to-hue mapping: +## Temperature View (TV) -| Temp (°F) | Hue (°) | Color | -|-----------|---------|------------| -| ≤14 | 240 | Deep blue | -| 32 | 210 | Blue-cyan | -| 50 | 180 | Cyan | -| 68 | 150 | Green-cyan | -| 77 | 60 | Yellow | -| 95 | 30 | Orange | -| ≥100 | 0 | Red | +Hue follows a calibrated cold→hot gradient tuned for pleasing segment +appearance: deep blues near 14 °F transition through cyan and green to +warm yellows at 77 °F and reds at ~104 °F and above. Saturation +reflects humidity via dew‑point spread; muggy air produces softer, +desaturated colors, whereas dry air yields vivid tones. Value is fixed +at mid‑brightness, but local time markers (e.g., noon, midnight) +temporarily darken pixels to mark time. A bright orange‑red pixel thus +signifies hot, dry conditions around 95 °F, whereas a pale cyan pixel +indicates a cool, humid day near 50 °F. + +The actual temperature→hue stops used by the renderer are: + +| Temp (°F) | Hue (°) | Color | +|-----------|---------|-------------| +| ≤14 | 234.9 | Deep blue | +| 32 | 207.0 | Blue/cyan | +| 50 | 180.0 | Cyan | +| 68 | 138.8 | Greenish | +| 77 | 60.0 | Yellow | +| 86 | 38.8 | Orange | +| 95 | 18.8 | Orange‑red | +| ≥104 | 0.0 | Red | ## 24 Hour Delta View (DV) @@ -80,11 +86,17 @@ warming coupled with drying. Approximate mapping of day-to-day deltas to color attributes: -| Temperature | Hue (Color) | | Humidity | Saturation | -|-------------|-------------| |------------|------------| -| Cooling | Blue tones | | More humid | Low/Pastel | -| Steady | Green | | Stable | Medium | -| Warming | Yellow→Red | | Drier | High/Vivid | +| Temperature | Hue (Color) | +|-------------|-------------| +| Cooling | Blue tones | +| Steady | Green | +| Warming | Yellow→Red | + +| Humidity | Saturation | +|------------|------------| +| More humid | Low/Pastel | +| Stable | Medium | +| Drier | High/Vivid | ## Test Pattern View (TP) diff --git a/usermods/usermod_v2_skystrip/cloud_view.cpp b/usermods/usermod_v2_skystrip/cloud_view.cpp index 6bfd87064e..e65645cfab 100644 --- a/usermods/usermod_v2_skystrip/cloud_view.cpp +++ b/usermods/usermod_v2_skystrip/cloud_view.cpp @@ -101,7 +101,6 @@ void CloudView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) { constexpr float kDaySat = 0.30f; constexpr float kNightSat = 0.00f; constexpr float kDayVMax = 0.40f; - // slightly higher night maximum so low clouds are more visible constexpr float kNightVMax= 0.40f; // Brightness floor as a fraction of Vmax so mid/low clouds stay visible. diff --git a/usermods/usermod_v2_skystrip/open_weather_map_source.cpp b/usermods/usermod_v2_skystrip/open_weather_map_source.cpp index e2bd7babd3..c4fc042b87 100644 --- a/usermods/usermod_v2_skystrip/open_weather_map_source.cpp +++ b/usermods/usermod_v2_skystrip/open_weather_map_source.cpp @@ -219,6 +219,8 @@ std::unique_ptr OpenWeatherMapSource::fetch(std::time_t now) { if ((now - lastFetch_) < static_cast(intervalSec_)) return nullptr; + // Update lastFetch_ and lastHistFetch_ upfront to reduce API + // thrash if things don't work out lastFetch_ = now; lastHistFetch_ = now; // history fetches should wait diff --git a/usermods/usermod_v2_skystrip/readme.md b/usermods/usermod_v2_skystrip/readme.md index 36b04bb573..cabd48cc9e 100644 --- a/usermods/usermod_v2_skystrip/readme.md +++ b/usermods/usermod_v2_skystrip/readme.md @@ -14,16 +14,14 @@ Acquire an API key from module makes one API call per hour, plus up to 24 calls on first startup. This typically stays within free-tier limits, but check your current plan. -Enter the latitude and longitude for the desired forecast. There are -several ways to do this: -1. Enter the latitude and longitude as signed floating point numbers - in the `Latitude` and `Longitude` config fields. -2. Enter a combined lat/long string in the `Location` field, examples: -- `54.9352° S, 67.6059° W` -- `-54.9352, -67.6059` -- `-54.9352 -67.6059` -- `S54°42'7", W67°40'33"` -3. Enter a geo location string like `oakland,ca,us` in the `Location` field. +Enter the latitude and longitude for the desired forecast. You can: +1. Enter signed floating-point values in the `Latitude` and `Longitude` fields. +2. Enter a combined lat/long string in the `Location` field, for example: + - `54.9352° S, 67.6059° W` + - `-54.9352, -67.6059` + - `-54.9352 -67.6059` + - `S54°42'7", W67°40'33"` +3. Enter a geo-location string (e.g., `oakland,ca,us`) in the `Location` field. ## Interpretation diff --git a/usermods/usermod_v2_skystrip/rest_json_client.cpp b/usermods/usermod_v2_skystrip/rest_json_client.cpp index 2a5f059528..c2ae671c58 100644 --- a/usermods/usermod_v2_skystrip/rest_json_client.cpp +++ b/usermods/usermod_v2_skystrip/rest_json_client.cpp @@ -12,6 +12,8 @@ void RestJsonClient::resetRateLimit() { lastFetchMs_ = millis() - RATE_LIMIT_MS; } +// Returned DynamicJsonDocument* is owned by the client and is +// invalidated on the next getJson() call DynamicJsonDocument* RestJsonClient::getJson(const char* url) { // enforce a basic rate limit to prevent runaway software from making bursts // of API calls (looks like DoS and get's our API key turned off ...) diff --git a/usermods/usermod_v2_skystrip/util.h b/usermods/usermod_v2_skystrip/util.h index 4f15fc5e51..6779443597 100644 --- a/usermods/usermod_v2_skystrip/util.h +++ b/usermods/usermod_v2_skystrip/util.h @@ -54,7 +54,7 @@ inline double lerp(double a, double b, double t) { return a + (b - a) * t; } // Forecast interpolation helper static constexpr int GRACE_SEC = 60 * 60 * 3; // fencepost + slide template -bool estimateAt(const Series &v, time_t t, double step, double &out) { +bool estimateAt(const Series &v, time_t t, double /* step */, double &out) { if (v.empty()) return false; // if it's too far away we didn't find estimate From a77c26028090d6fefc7d4fdb84e68d2694e0480c Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Fri, 29 Aug 2025 08:47:34 -0700 Subject: [PATCH 06/20] Code review improvements: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Don't define USERMOD_ID_SKYSTRIP globally - Use signed loop index to match len type - Expose/read a socket timeout; ensure it’s applied on WiFiClient before begin() - Guard against oversized payloads vs. JSON doc capacity - Avoid wraparound trick in RestJsonClient ctor; initialize via reset for clarity - Make RestJsonClient non-copyable/non-movable - Preserve tail data in SkyModel mergeSeries - Fix wind-direction interpolation (circular wrap-around bug) --- usermods/usermod_v2_skystrip/delta_view.cpp | 2 +- .../usermod_v2_skystrip/rest_json_client.cpp | 24 +++++++++++++-- .../usermod_v2_skystrip/rest_json_client.h | 15 ++++++++++ usermods/usermod_v2_skystrip/skymodel.cpp | 13 +++++--- .../usermod_v2_skystrip/usermod_v2_skystrip.h | 2 ++ usermods/usermod_v2_skystrip/util.h | 30 +++++++++++++++++-- wled00/const.h | 1 - 7 files changed, 77 insertions(+), 10 deletions(-) diff --git a/usermods/usermod_v2_skystrip/delta_view.cpp b/usermods/usermod_v2_skystrip/delta_view.cpp index 1ff10aee17..d9678c67e9 100644 --- a/usermods/usermod_v2_skystrip/delta_view.cpp +++ b/usermods/usermod_v2_skystrip/delta_view.cpp @@ -85,7 +85,7 @@ void DeltaView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) { const double step = (len > 1) ? (kHorizonSec / double(len - 1)) : 0.0; const time_t day = 24 * 3600; - for (uint16_t i = 0; i < len; ++i) { + for (int i = 0; i < len; ++i) { const time_t t = now + time_t(std::llround(step * i)); int idx = seg.reverse ? (end - i) : (start + i); diff --git a/usermods/usermod_v2_skystrip/rest_json_client.cpp b/usermods/usermod_v2_skystrip/rest_json_client.cpp index c2ae671c58..1e8f5c7c7b 100644 --- a/usermods/usermod_v2_skystrip/rest_json_client.cpp +++ b/usermods/usermod_v2_skystrip/rest_json_client.cpp @@ -3,8 +3,16 @@ #include "rest_json_client.h" RestJsonClient::RestJsonClient() - : lastFetchMs_(static_cast(-static_cast(RATE_LIMIT_MS))) - , doc_(MAX_JSON_SIZE) { + : doc_(MAX_JSON_SIZE) { + // Allow an immediate first request + resetRateLimit(); +} + +RestJsonClient::RestJsonClient(uint32_t socketTimeoutMs) + : doc_(MAX_JSON_SIZE) { + // Allow an immediate first request + resetRateLimit(); + socketTimeoutMs_ = socketTimeoutMs; } void RestJsonClient::resetRateLimit() { @@ -40,6 +48,10 @@ DynamicJsonDocument* RestJsonClient::getJson(const char* url) { } // Begin request + if (client) { + // Apply socket (Stream) timeout before using HTTPClient. + client->setTimeout(socketTimeoutMs_); + } if (!http_.begin(*client, url)) { http_.end(); DEBUG_PRINTLN(F("SkyStrip: RestJsonClient::getJson: trouble initiating request")); @@ -57,6 +69,14 @@ DynamicJsonDocument* RestJsonClient::getJson(const char* url) { int len = http_.getSize(); DEBUG_PRINTF("SkyStrip: RestJsonClient::getJson: expecting up to %d bytes, free heap before deserialization: %u\n", len, ESP.getFreeHeap()); + if (len > 0) { + const size_t cap = doc_.capacity(); + if ((size_t)len > cap) { + http_.end(); + DEBUG_PRINTF("SkyStrip: RestJsonClient::getJson: response too large (%d > %u)\n", len, (unsigned)cap); + return nullptr; + } + } doc_.clear(); auto err = deserializeJson(doc_, http_.getStream()); http_.end(); diff --git a/usermods/usermod_v2_skystrip/rest_json_client.h b/usermods/usermod_v2_skystrip/rest_json_client.h index 059c40d81a..6a79f34dda 100644 --- a/usermods/usermod_v2_skystrip/rest_json_client.h +++ b/usermods/usermod_v2_skystrip/rest_json_client.h @@ -16,13 +16,26 @@ class RestJsonClient { public: RestJsonClient(); + // Optionally construct with a specific socket timeout (ms). + explicit RestJsonClient(uint32_t socketTimeoutMs); virtual ~RestJsonClient() = default; + // Non-copyable, non-movable to avoid duplicating HTTPClient and large JSON buffer. + RestJsonClient(const RestJsonClient&) = delete; + RestJsonClient& operator=(const RestJsonClient&) = delete; + RestJsonClient(RestJsonClient&&) = delete; + RestJsonClient& operator=(RestJsonClient&&) = delete; + // Returns pointer to internal document on success, nullptr on failure. DynamicJsonDocument* getJson(const char* url); void resetRateLimit(); + // Configure/read the underlying socket (Stream) timeout in milliseconds. + // This is applied to the WiFiClient/WiFiClientSecure before HTTPClient.begin(). + void setSocketTimeoutMs(uint32_t ms) { socketTimeoutMs_ = ms; } + uint32_t socketTimeoutMs() const { return socketTimeoutMs_; } + protected: static constexpr unsigned RATE_LIMIT_MS = 10u * 1000u; // 10 seconds #if defined(ARDUINO_ARCH_ESP8266) @@ -35,4 +48,6 @@ class RestJsonClient { HTTPClient http_; unsigned long lastFetchMs_; DynamicJsonDocument doc_; + static constexpr uint32_t DEFAULT_SOCKET_TIMEOUT_MS = 7000u; // 7 seconds + uint32_t socketTimeoutMs_ = DEFAULT_SOCKET_TIMEOUT_MS; }; diff --git a/usermods/usermod_v2_skystrip/skymodel.cpp b/usermods/usermod_v2_skystrip/skymodel.cpp index d555c65242..2374d1765a 100644 --- a/usermods/usermod_v2_skystrip/skymodel.cpp +++ b/usermods/usermod_v2_skystrip/skymodel.cpp @@ -25,10 +25,15 @@ void mergeSeries(Series ¤t, Series &&fresh, time_t now) { fresh.insert(fresh.end(), current.begin(), current.end()); current = std::move(fresh); } else { - auto it = std::lower_bound(current.begin(), current.end(), fresh.front().tstamp, - [](const DataPoint& dp, time_t t){ return dp.tstamp < t; }); - current.erase(it, current.end()); - current.insert(current.end(), fresh.begin(), fresh.end()); + // Precisely locate the overlap window: erase only [start, end) and insert fresh there. + auto start = std::lower_bound( + current.begin(), current.end(), fresh.front().tstamp, + [](const DataPoint& dp, time_t t) { return dp.tstamp < t; }); // first current >= front + auto end = std::upper_bound( + current.begin(), current.end(), fresh.back().tstamp, + [](time_t t, const DataPoint& dp) { return t < dp.tstamp; }); // first current > back + current.erase(start, end); + current.insert(start, fresh.begin(), fresh.end()); } time_t cutoff = now - HISTORY_SEC; diff --git a/usermods/usermod_v2_skystrip/usermod_v2_skystrip.h b/usermods/usermod_v2_skystrip/usermod_v2_skystrip.h index f87ca1ae9c..12827813f5 100644 --- a/usermods/usermod_v2_skystrip/usermod_v2_skystrip.h +++ b/usermods/usermod_v2_skystrip/usermod_v2_skystrip.h @@ -4,6 +4,8 @@ #include "interfaces.h" #include "wled.h" +#define USERMOD_ID_SKYSTRIP 559 + #define SKYSTRIP_VERSION "0.0.1" class SkyModel; diff --git a/usermods/usermod_v2_skystrip/util.h b/usermods/usermod_v2_skystrip/util.h index 6779443597..eb444af8a2 100644 --- a/usermods/usermod_v2_skystrip/util.h +++ b/usermods/usermod_v2_skystrip/util.h @@ -97,9 +97,35 @@ inline bool estimateSpeedAt(const SkyModel &m, time_t t, double step, double &out) { return estimateAt(m.wind_speed_forecast, t, step, out); } -inline bool estimateDirAt(const SkyModel &m, time_t t, double step, +inline bool estimateDirAt(const SkyModel &m, time_t t, double /*step*/, double &out) { - return estimateAt(m.wind_dir_forecast, t, step, out); + const auto &v = m.wind_dir_forecast; + if (v.empty()) return false; + if (t < v.front().tstamp - GRACE_SEC) return false; + if (t > v.back().tstamp + GRACE_SEC) return false; + if (t <= v.front().tstamp) { out = fmod(v.front().value, 360.0); if (out < 0) out += 360.0; return true; } + if (t >= v.back().tstamp) { out = fmod(v.back().value, 360.0); if (out < 0) out += 360.0; return true; } + + for (size_t i = 1; i < v.size(); ++i) { + if (t <= v[i].tstamp) { + const auto &a = v[i-1]; + const auto &b = v[i]; + const double span = double(b.tstamp - a.tstamp); + const double u = clamp01(span > 0 ? double(t - a.tstamp) / span : 0.0); + double aAng = a.value; + double bAng = b.value; + // shortest signed angular difference in (-180,180] + double delta = bAng - aAng; + delta = fmod(delta + 540.0, 360.0) - 180.0; + double val = aAng + u * delta; + // normalize to [0,360) + val = fmod(val, 360.0); + if (val < 0) val += 360.0; + out = val; + return true; + } + } + return false; } inline bool estimateGustAt(const SkyModel &m, time_t t, double step, double &out) { diff --git a/wled00/const.h b/wled00/const.h index e91b31dfed..1abf245396 100644 --- a/wled00/const.h +++ b/wled00/const.h @@ -199,7 +199,6 @@ static_assert(WLED_MAX_BUSSES <= 32, "WLED_MAX_BUSSES exceeds hard limit"); #define USERMOD_ID_RF433 56 //Usermod "usermod_v2_RF433.h" #define USERMOD_ID_BRIGHTNESS_FOLLOW_SUN 57 //Usermod "usermod_v2_brightness_follow_sun.h" #define USERMOD_ID_USER_FX 58 //Usermod "user_fx" -#define USERMOD_ID_SKYSTRIP 59 //Usermod "usermod_v2_skystrip.h" //Access point behavior #define AP_BEHAVIOR_BOOT_NO_CONN 0 //Open AP when no connection after boot From abcd7b3acd1b89bb531a2e4d5501a3d4d5614194 Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Fri, 29 Aug 2025 10:18:18 -0700 Subject: [PATCH 07/20] Use segment virtualization APIs instead of raw start/stop --- usermods/usermod_v2_skystrip/cloud_view.cpp | 14 +++++++------- usermods/usermod_v2_skystrip/delta_view.cpp | 13 ++++++------- usermods/usermod_v2_skystrip/temperature_view.cpp | 13 ++++++------- usermods/usermod_v2_skystrip/test_pattern_view.cpp | 11 +++++------ usermods/usermod_v2_skystrip/wind_view.cpp | 13 ++++++------- 5 files changed, 30 insertions(+), 34 deletions(-) diff --git a/usermods/usermod_v2_skystrip/cloud_view.cpp b/usermods/usermod_v2_skystrip/cloud_view.cpp index e65645cfab..19e51a0b31 100644 --- a/usermods/usermod_v2_skystrip/cloud_view.cpp +++ b/usermods/usermod_v2_skystrip/cloud_view.cpp @@ -50,12 +50,12 @@ void CloudView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) { return; Segment &seg = strip.getSegment((uint8_t)segId_); - int start = seg.start; - int end = seg.stop - 1; - int len = end - start + 1; + int len = seg.virtualLength(); if (len <= 0) return; - skystrip::util::FreezeGuard freezeGuard(seg); + // Initialize segment drawing parameters so virtualLength()/mapping are valid + seg.beginDraw(); + skystrip::util::FreezeGuard freezeGuard(seg, false); constexpr double kHorizonSec = 48.0 * 3600.0; const double step = (len > 1) ? (kHorizonSec / double(len - 1)) : 0.0; @@ -124,7 +124,7 @@ void CloudView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) { float clouds01 = skystrip::util::clamp01(float(clouds / 100.0)); int p = int(std::round(precipTypeVal)); bool daytime = isDay(model, t); - int idx = seg.reverse ? (end - i) : (start + i); + float hue = 0.f, sat = 0.f, val = 0.f; if (isMarker(t)) { @@ -178,8 +178,8 @@ void CloudView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) { } uint32_t col = skystrip::util::hsv2rgb(hue, sat, val); - strip.setPixelColor(idx, skystrip::util::blinkDebug(i, dbgPixelIndex, col)); - + seg.setPixelColor(i, skystrip::util::blinkDebug(i, dbgPixelIndex, col)); + if (dbgPixelIndex >= 0) { static time_t lastDebug = 0; if (now - lastDebug > 1 && i == dbgPixelIndex) { diff --git a/usermods/usermod_v2_skystrip/delta_view.cpp b/usermods/usermod_v2_skystrip/delta_view.cpp index d9678c67e9..0b987367fa 100644 --- a/usermods/usermod_v2_skystrip/delta_view.cpp +++ b/usermods/usermod_v2_skystrip/delta_view.cpp @@ -74,12 +74,12 @@ void DeltaView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) { return; Segment &seg = strip.getSegment((uint8_t)segId_); - int start = seg.start; - int end = seg.stop - 1; - int len = end - start + 1; + int len = seg.virtualLength(); if (len <= 0) return; - skystrip::util::FreezeGuard freezeGuard(seg); + // Initialize segment drawing parameters so virtualLength()/mapping are valid + seg.beginDraw(); + skystrip::util::FreezeGuard freezeGuard(seg, false); constexpr double kHorizonSec = 48.0 * 3600.0; const double step = (len > 1) ? (kHorizonSec / double(len - 1)) : 0.0; @@ -87,7 +87,6 @@ void DeltaView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) { for (int i = 0; i < len; ++i) { const time_t t = now + time_t(std::llround(step * i)); - int idx = seg.reverse ? (end - i) : (start + i); double tempNow, tempPrev; bool foundTempNow = @@ -113,7 +112,7 @@ void DeltaView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) { lastDebug = now; } } - strip.setPixelColor(idx, 0); + seg.setPixelColor(i, 0); continue; } double deltaT = tempNow - tempPrev; @@ -152,7 +151,7 @@ void DeltaView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) { } } - strip.setPixelColor(idx, skystrip::util::blinkDebug(i, dbgPixelIndex, col)); + seg.setPixelColor(i, skystrip::util::blinkDebug(i, dbgPixelIndex, col)); } } diff --git a/usermods/usermod_v2_skystrip/temperature_view.cpp b/usermods/usermod_v2_skystrip/temperature_view.cpp index 0924cf536a..f26dc5eb7b 100644 --- a/usermods/usermod_v2_skystrip/temperature_view.cpp +++ b/usermods/usermod_v2_skystrip/temperature_view.cpp @@ -77,12 +77,12 @@ void TemperatureView::view(time_t now, SkyModel const &model, if (segId_ < 0 || segId_ >= strip.getMaxSegments()) return; Segment &seg = strip.getSegment((uint8_t)segId_); - int start = seg.start; - int end = seg.stop - 1; // inclusive - int len = end - start + 1; + int len = seg.virtualLength(); if (len <= 0) return; - skystrip::util::FreezeGuard freezeGuard(seg); + // Initialize segment drawing parameters so virtualLength()/mapping are valid + seg.beginDraw(); + skystrip::util::FreezeGuard freezeGuard(seg, false); constexpr double kHorizonSec = 48.0 * 3600.0; const double step = (len > 1) ? (kHorizonSec / double(len - 1)) : 0.0; @@ -128,9 +128,8 @@ void TemperatureView::view(time_t now, SkyModel const &model, return (w > 0.f) ? w : 0.f; }; - for (uint16_t i = 0; i < len; ++i) { + for (int i = 0; i < len; ++i) { const time_t t = now + time_t(std::llround(step * i)); - int idx = seg.reverse ? (end - i) : (start + i); double tempF = 0.f; double dewF = 0.f; @@ -169,7 +168,7 @@ void TemperatureView::view(time_t now, SkyModel const &model, } } - strip.setPixelColor(idx, skystrip::util::blinkDebug(i, dbgPixelIndex, col)); + seg.setPixelColor(i, skystrip::util::blinkDebug(i, dbgPixelIndex, col)); } } diff --git a/usermods/usermod_v2_skystrip/test_pattern_view.cpp b/usermods/usermod_v2_skystrip/test_pattern_view.cpp index 92b78e6e48..d6fa90e591 100644 --- a/usermods/usermod_v2_skystrip/test_pattern_view.cpp +++ b/usermods/usermod_v2_skystrip/test_pattern_view.cpp @@ -106,12 +106,12 @@ void TestPatternView::view(time_t now, SkyModel const &model, return; Segment &seg = strip.getSegment((uint8_t)segId_); - int start = seg.start; - int end = seg.stop - 1; - int len = end - start + 1; + int len = seg.virtualLength(); if (len <= 0) return; - skystrip::util::FreezeGuard freezeGuard(seg); + // Initialize segment drawing parameters so virtualLength()/mapping are valid + seg.beginDraw(); + skystrip::util::FreezeGuard freezeGuard(seg, false); for (int i = 0; i < len; ++i) { float u = (len > 1) ? float(i) / float(len - 1) : 0.f; @@ -130,8 +130,7 @@ void TestPatternView::view(time_t now, SkyModel const &model, lastDebug = now; } } - int idx = seg.reverse ? (end - i) : (start + i); - strip.setPixelColor(idx, skystrip::util::blinkDebug(i, dbgPixelIndex, col)); + seg.setPixelColor(i, skystrip::util::blinkDebug(i, dbgPixelIndex, col)); } } diff --git a/usermods/usermod_v2_skystrip/wind_view.cpp b/usermods/usermod_v2_skystrip/wind_view.cpp index ecf6684027..b271c86996 100644 --- a/usermods/usermod_v2_skystrip/wind_view.cpp +++ b/usermods/usermod_v2_skystrip/wind_view.cpp @@ -57,17 +57,17 @@ void WindView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) { return; Segment &seg = strip.getSegment((uint8_t)segId_); - int start = seg.start; - int end = seg.stop - 1; - int len = end - start + 1; + int len = seg.virtualLength(); if (len <= 0) return; - skystrip::util::FreezeGuard freezeGuard(seg); + // Initialize segment drawing parameters so virtualLength()/mapping are valid + seg.beginDraw(); + skystrip::util::FreezeGuard freezeGuard(seg, false); constexpr double kHorizonSec = 48.0 * 3600.0; const double step = (len > 1) ? (kHorizonSec / double(len - 1)) : 0.0; - for (uint16_t i = 0; i < len; ++i) { + for (int i = 0; i < len; ++i) { const time_t t = now + time_t(std::llround(step * i)); double spd, dir, gst; if (!skystrip::util::estimateSpeedAt(model, t, step, spd)) @@ -105,8 +105,7 @@ void WindView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) { } } - int idx = seg.reverse ? (end - i) : (start + i); - strip.setPixelColor(idx, skystrip::util::blinkDebug(i, dbgPixelIndex, col)); + seg.setPixelColor(i, skystrip::util::blinkDebug(i, dbgPixelIndex, col)); } } From 2d2c73d33dd70446574b43b96cc9934f7bf62c2a Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Fri, 29 Aug 2025 12:23:33 -0700 Subject: [PATCH 08/20] skystrip: Fix formatting issues --- usermods/usermod_v2_skystrip/FAQ.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/usermods/usermod_v2_skystrip/FAQ.md b/usermods/usermod_v2_skystrip/FAQ.md index 2d6122ede3..b4e014a9f0 100644 --- a/usermods/usermod_v2_skystrip/FAQ.md +++ b/usermods/usermod_v2_skystrip/FAQ.md @@ -41,7 +41,7 @@ The mapping between wind direction and hue can be approximated as: | SW | 90 | Lime | | W | 120 | Green | | NW | 180 | Cyan | -| N | 240 | Blue | (wraps around) +| N | 240 | Blue | Note: Hues wrap at 360°, so “N” repeats at the boundary. From 440f21a72512e1bd47b715dd111090b7252f681b Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Fri, 29 Aug 2025 12:29:33 -0700 Subject: [PATCH 09/20] skystrip: Fix urlEncode bounds check to avoid premature truncation --- .../open_weather_map_source.cpp | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/usermods/usermod_v2_skystrip/open_weather_map_source.cpp b/usermods/usermod_v2_skystrip/open_weather_map_source.cpp index c4fc042b87..819603c43a 100644 --- a/usermods/usermod_v2_skystrip/open_weather_map_source.cpp +++ b/usermods/usermod_v2_skystrip/open_weather_map_source.cpp @@ -30,20 +30,35 @@ const char CFG_LOCATION[] PROGMEM = "Location"; // keep commas; encode spaces etc. static void urlEncode(const char* src, char* dst, size_t dstSize) { static const char hex[] = "0123456789ABCDEF"; + if (!dst || dstSize == 0) return; size_t di = 0; - for (size_t i = 0; src[i] && di + 4 < dstSize; ++i) { + if (!src) { dst[0] = '\0'; return; } + for (size_t i = 0; src[i]; ++i) { unsigned char c = static_cast(src[i]); + // Unreserved characters per RFC 3986 (plus ',') are copied as-is if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~' || c == ',') { - dst[di++] = c; + if (di + 1 < dstSize) { + dst[di++] = c; + } else { + break; // no room for this char plus NUL + } } else if (c == ' ') { - dst[di++] = '%'; dst[di++] = '2'; dst[di++] = '0'; + if (di + 3 < dstSize) { + dst[di++] = '%'; dst[di++] = '2'; dst[di++] = '0'; + } else { + break; // not enough room for %20 + NUL + } } else { - dst[di++] = '%'; dst[di++] = hex[c >> 4]; dst[di++] = hex[c & 0xF]; + if (di + 3 < dstSize) { + dst[di++] = '%'; dst[di++] = hex[c >> 4]; dst[di++] = hex[c & 0xF]; + } else { + break; // not enough room for %XY + NUL + } } } - dst[di] = '\0'; + if (di < dstSize) dst[di] = '\0'; else dst[dstSize - 1] = '\0'; } // Redact the API key in a URL by replacing the value after "appid=" with '*'. From 35ccfbfc6640b7b37baf6099c572f5b55ff2d1a4 Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Fri, 29 Aug 2025 12:32:59 -0700 Subject: [PATCH 10/20] skystrip: Avoid undefined behavior: cast to unsigned char for ctype checks --- usermods/usermod_v2_skystrip/open_weather_map_source.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/usermods/usermod_v2_skystrip/open_weather_map_source.cpp b/usermods/usermod_v2_skystrip/open_weather_map_source.cpp index 819603c43a..982111d17b 100644 --- a/usermods/usermod_v2_skystrip/open_weather_map_source.cpp +++ b/usermods/usermod_v2_skystrip/open_weather_map_source.cpp @@ -99,16 +99,16 @@ static void normalizeLocation(char* q) { } static bool parseCoordToken(char* token, double& out) { - while (isspace(*token)) ++token; + while (isspace((unsigned char)*token)) ++token; bool neg = false; if (*token == 's' || *token == 'S' || *token == 'w' || *token == 'W') { neg = true; ++token; } else if (*token == 'n' || *token == 'N' || *token == 'e' || *token == 'E') { ++token; } - while (isspace(*token)) ++token; + while (isspace((unsigned char)*token)) ++token; char* end = token + strlen(token); - while (end > token && isspace(end[-1])) --end; + while (end > token && isspace((unsigned char)end[-1])) --end; if (end > token) { char c = end[-1]; if (c == 's' || c == 'S' || c == 'w' || c == 'W') { neg = true; --end; } From 3cbae81926f1a3f16cc7cc39c9144737c6bbd55c Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Fri, 29 Aug 2025 12:34:33 -0700 Subject: [PATCH 11/20] skystrip: Guard against truncated input in parseLatLon --- usermods/usermod_v2_skystrip/open_weather_map_source.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/usermods/usermod_v2_skystrip/open_weather_map_source.cpp b/usermods/usermod_v2_skystrip/open_weather_map_source.cpp index 982111d17b..626e269813 100644 --- a/usermods/usermod_v2_skystrip/open_weather_map_source.cpp +++ b/usermods/usermod_v2_skystrip/open_weather_map_source.cpp @@ -138,6 +138,8 @@ static bool parseCoordToken(char* token, double& out) { static bool parseLatLon(const char* s, double& lat, double& lon) { char buf[64]; + if (s == nullptr) return false; + if (strlen(s) >= sizeof(buf)) return false; strncpy(buf, s, sizeof(buf)); buf[sizeof(buf)-1] = '\0'; char *a = nullptr, *b = nullptr; From d51eef6061bc184a718b6fd8a0986856a4fef6fa Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Fri, 29 Aug 2025 12:36:09 -0700 Subject: [PATCH 12/20] skystrip: Print 64-bit-safe timemachine dt --- .../open_weather_map_source.cpp | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/usermods/usermod_v2_skystrip/open_weather_map_source.cpp b/usermods/usermod_v2_skystrip/open_weather_map_source.cpp index 626e269813..c155649ab2 100644 --- a/usermods/usermod_v2_skystrip/open_weather_map_source.cpp +++ b/usermods/usermod_v2_skystrip/open_weather_map_source.cpp @@ -82,6 +82,29 @@ static void redactApiKeyInUrl(const char* in, char* out, size_t outLen) { out[prefixLen + 1] = '\0'; } +// Convert time_t to decimal string without relying on %lld support. +static void timeToDecimal(char* out, size_t outLen, std::time_t v) { + if (!out || outLen == 0) return; + // Handle sign (though our use is non-negative) + unsigned long long u; + bool neg = false; + if ((long long)v < 0) { neg = true; u = (unsigned long long)(-(long long)v); } + else { u = (unsigned long long)(long long)v; } + char tmp[32]; + size_t n = 0; + do { + tmp[n++] = (char)('0' + (u % 10)); + u /= 10; + } while (u && n < sizeof(tmp)); + size_t pos = 0; + if (neg && pos + 1 < outLen) out[pos++] = '-'; + // reverse digits into output + while (n > 0 && pos + 1 < outLen) { + out[pos++] = tmp[--n]; + } + out[pos] = '\0'; +} + // Normalize "Oakland, CA, USA" → "Oakland,CA,US" in-place static void normalizeLocation(char* q) { // trim spaces and commas @@ -340,9 +363,11 @@ std::unique_ptr OpenWeatherMapSource::checkhistory(time_t now, std::ti time_t fetchDt = oldestTstamp - 3600; char url[256]; + char dtbuf[24]; + timeToDecimal(dtbuf, sizeof(dtbuf), fetchDt); snprintf(url, sizeof(url), - "%s/data/3.0/onecall/timemachine?lat=%.6f&lon=%.6f&dt=%ld&units=imperial&appid=%s", - apiBase_.c_str(), latitude_, longitude_, (long)fetchDt, apiKey_.c_str()); + "%s/data/3.0/onecall/timemachine?lat=%.6f&lon=%.6f&dt=%s&units=imperial&appid=%s", + apiBase_.c_str(), latitude_, longitude_, dtbuf, apiKey_.c_str()); char redacted[256]; redactApiKeyInUrl(url, redacted, sizeof(redacted)); DEBUG_PRINTF("SkyStrip: %s::checkhistory URL: %s\n", name().c_str(), redacted); From a3f700db399cd3feb8493386f65f8843d90ca652 Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Sat, 30 Aug 2025 14:45:41 -0700 Subject: [PATCH 13/20] skystrip: Fix lat/long round-off update bug --- .../open_weather_map_source.cpp | 14 ++++++++++++-- .../usermod_v2_skystrip/usermod_v2_skystrip.cpp | 5 +++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/usermods/usermod_v2_skystrip/open_weather_map_source.cpp b/usermods/usermod_v2_skystrip/open_weather_map_source.cpp index c155649ab2..74a8b45d38 100644 --- a/usermods/usermod_v2_skystrip/open_weather_map_source.cpp +++ b/usermods/usermod_v2_skystrip/open_weather_map_source.cpp @@ -121,6 +121,12 @@ static void normalizeLocation(char* q) { } } +// Treat two coordinates as equal if they differ by less than ~1 meter. +// 1e-5 degrees ≈ 1.11 meters at the equator; adequate for our purposes. +static inline bool nearlyEqualCoord(double a, double b, double eps = 1e-5) { + return fabs(a - b) <= eps; +} + static bool parseCoordToken(char* token, double& out) { while (isspace((unsigned char)*token)) ++token; bool neg = false; @@ -221,7 +227,7 @@ bool OpenWeatherMapSource::readFromConfig(JsonObject &subtree, // If the location changed update lat/long via parsing or lookup if (location_ == lastLocation_) { // if the user changed the lat and long directly clear the location - if (latitude_ != oldLatitude || longitude_ != oldLongitude) + if (!nearlyEqualCoord(latitude_, oldLatitude) || !nearlyEqualCoord(longitude_, oldLongitude)) location_ = ""; } else { lastLocation_ = location_; @@ -240,8 +246,12 @@ bool OpenWeatherMapSource::readFromConfig(JsonObject &subtree, } // if the lat/long changed we need to invalidate_history - if (latitude_ != oldLatitude || longitude_ != oldLongitude) + if (!nearlyEqualCoord(latitude_, oldLatitude) || !nearlyEqualCoord(longitude_, oldLongitude)) { + DEBUG_PRINTF("SkyStrip::OWM::readFromConfig lat/long changed" + " oldLat=%f, newLat=%f, oldLng=%f, newLng=%f\n", + oldLatitude, latitude_, oldLongitude, longitude_); invalidate_history = true; + } return configComplete; } diff --git a/usermods/usermod_v2_skystrip/usermod_v2_skystrip.cpp b/usermods/usermod_v2_skystrip/usermod_v2_skystrip.cpp index cf33e1c22b..f79345de7b 100644 --- a/usermods/usermod_v2_skystrip/usermod_v2_skystrip.cpp +++ b/usermods/usermod_v2_skystrip/usermod_v2_skystrip.cpp @@ -208,15 +208,20 @@ bool SkyStrip::readFromConfig(JsonObject& root) { for (auto& src : sources_) { JsonObject sub1 = top[src->configKey()]; ok &= src->readFromConfig(sub1, startup_complete, invalidate_history); + DEBUG_PRINTF("SkyStrip:readFromConfig: after source %s invalidate_history=%d\n", + src->name().c_str(), invalidate_history); } // read the views for (auto& vw : views_) { JsonObject sub2 = top[vw->configKey()]; ok &= vw->readFromConfig(sub2, startup_complete, invalidate_history); + DEBUG_PRINTF("SkyStrip:readFromConfig: after view %s invalidate_history=%d\n", + vw->name().c_str(), invalidate_history); } if (invalidate_history) { + DEBUG_PRINTLN(F("SkyStrip::readFromConfig invalidating history")); time_t const now = skystrip::util::time_now_utc(); model_->invalidate_history(now); if (startup_complete) reloadSources(now); // not safe during startup From fb1f3f62becac346d69de6bd05ecaaa8c4363808 Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Tue, 2 Sep 2025 10:51:18 -0700 Subject: [PATCH 14/20] skystrip: Improve documentation / comments --- usermods/usermod_v2_skystrip/FAQ.md | 2 +- usermods/usermod_v2_skystrip/readme.md | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/usermods/usermod_v2_skystrip/FAQ.md b/usermods/usermod_v2_skystrip/FAQ.md index b4e014a9f0..3efa01cf66 100644 --- a/usermods/usermod_v2_skystrip/FAQ.md +++ b/usermods/usermod_v2_skystrip/FAQ.md @@ -72,7 +72,7 @@ The actual temperature→hue stops used by the renderer are: | ≥104 | 0.0 | Red | -## 24 Hour Delta View (DV) +## 24-Hour Delta View (DV) Hue represents the temperature change relative to the previous day: blues for cooling, greens for steady conditions, and yellows through diff --git a/usermods/usermod_v2_skystrip/readme.md b/usermods/usermod_v2_skystrip/readme.md index cabd48cc9e..eaf7bffe44 100644 --- a/usermods/usermod_v2_skystrip/readme.md +++ b/usermods/usermod_v2_skystrip/readme.md @@ -27,3 +27,16 @@ Enter the latitude and longitude for the desired forecast. You can: Please see the [Interpretation FAQ](./FAQ.md) for more information on how to interpret the forecast views. + +## Hardware/Platform notes + +- SkyStrip was developed/tested using the + [Athom esp32-based LED strip controller](https://www.athom.tech/blank-1/wled-esp32-rf433-music-addressable-led-strip-controller). +- I use a display consisting of 4 parallel 1-meter long + [WS2815 strips](https://www.superlightingled.com/dc12v-ws2815-upgraded-ws2812b-1m-144-leds-individually-addressable-digital-led-strip-lights-dual-signal-wires-waterproof-dream-color-programmable-5050-rgb-flexible-led-ribbon-light-p-2134:fd57dd8a8ac1ee0e78f5493a35b28792.html) +- SkyStrip makes 25 API calls to the + [OpenWeatherMap One Call API](https://openweathermap.org/api/one-call-3) + when it first starts running and one API call per hour after that. +- Based on comparisons with a baseline build SkyStrip uses: + * RAM: +2080 bytes + * Flash: +153,812 bytes From 36c9bcd0b14011cb7a274a477f999a757e1dbbe1 Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Tue, 2 Sep 2025 12:04:38 -0700 Subject: [PATCH 15/20] skystrip: Further improve documentation --- usermods/usermod_v2_skystrip/readme.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/usermods/usermod_v2_skystrip/readme.md b/usermods/usermod_v2_skystrip/readme.md index eaf7bffe44..f8c7bdd063 100644 --- a/usermods/usermod_v2_skystrip/readme.md +++ b/usermods/usermod_v2_skystrip/readme.md @@ -32,8 +32,13 @@ interpret the forecast views. - SkyStrip was developed/tested using the [Athom esp32-based LED strip controller](https://www.athom.tech/blank-1/wled-esp32-rf433-music-addressable-led-strip-controller). -- I use a display consisting of 4 parallel 1-meter long - [WS2815 strips](https://www.superlightingled.com/dc12v-ws2815-upgraded-ws2812b-1m-144-leds-individually-addressable-digital-led-strip-lights-dual-signal-wires-waterproof-dream-color-programmable-5050-rgb-flexible-led-ribbon-light-p-2134:fd57dd8a8ac1ee0e78f5493a35b28792.html) +- Display used for development: four + [WS2815 LED strips](https://www.superlightingled.com/dc12v-ws2815-upgraded-ws2812b-1m-144-leds-individually-addressable-digital-led-strip-lights-dual-signal-wires-waterproof-dream-color-programmable-5050-rgb-flexible-led-ribbon-light-p-2134:fd57dd8a8ac1ee0e78f5493a35b28792.html) + , each 1 m long, + 12 V, 5050 RGB, 144 LEDs/m, individually addressable with + dual-signal (backup) line; arranged side‑by‑side (physically + parallel). Any equivalent WS281x‑compatible strip of similar density + should work; adjust power and wiring accordingly. - SkyStrip makes 25 API calls to the [OpenWeatherMap One Call API](https://openweathermap.org/api/one-call-3) when it first starts running and one API call per hour after that. From e37322d4bc6db8f4718ee27ad39e6de365b2d9a6 Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Tue, 2 Sep 2025 12:25:51 -0700 Subject: [PATCH 16/20] skystrip: Even further doc improvements --- usermods/usermod_v2_skystrip/readme.md | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/usermods/usermod_v2_skystrip/readme.md b/usermods/usermod_v2_skystrip/readme.md index f8c7bdd063..57e4e7c500 100644 --- a/usermods/usermod_v2_skystrip/readme.md +++ b/usermods/usermod_v2_skystrip/readme.md @@ -1,7 +1,7 @@ # SkyStrip This usermod displays the weather forecast on several parallel LED strips. -It currently includes Cloud, Wind, Temperature, 24 Hour Delta, and TestPattern views. +It currently includes Cloud, Wind, Temperature, 24-Hour Delta, and TestPattern views. ## Installation @@ -23,6 +23,10 @@ Enter the latitude and longitude for the desired forecast. You can: - `S54°42'7", W67°40'33"` 3. Enter a geo-location string (e.g., `oakland,ca,us`) in the `Location` field. +Note: If you edit both fields, the Location string takes precedence and will +update Latitude/Longitude. If you change Latitude/Longitude directly without +changing Location, the Location field is cleared. + ## Interpretation Please see the [Interpretation FAQ](./FAQ.md) for more information on how to @@ -32,16 +36,10 @@ interpret the forecast views. - SkyStrip was developed/tested using the [Athom esp32-based LED strip controller](https://www.athom.tech/blank-1/wled-esp32-rf433-music-addressable-led-strip-controller). -- Display used for development: four - [WS2815 LED strips](https://www.superlightingled.com/dc12v-ws2815-upgraded-ws2812b-1m-144-leds-individually-addressable-digital-led-strip-lights-dual-signal-wires-waterproof-dream-color-programmable-5050-rgb-flexible-led-ribbon-light-p-2134:fd57dd8a8ac1ee0e78f5493a35b28792.html) - , each 1 m long, - 12 V, 5050 RGB, 144 LEDs/m, individually addressable with - dual-signal (backup) line; arranged side‑by‑side (physically - parallel). Any equivalent WS281x‑compatible strip of similar density - should work; adjust power and wiring accordingly. -- SkyStrip makes 25 API calls to the - [OpenWeatherMap One Call API](https://openweathermap.org/api/one-call-3) - when it first starts running and one API call per hour after that. +- Display used for development: four WS2815 12 V 5050 RGB LED strips, + 1 m each, 144 LEDs/m, individually addressable with dual‑signal (backup) line; + arranged side‑by‑side (physically parallel). Any equivalent WS281x‑compatible + strip of similar density should work; adjust power and wiring accordingly. - Based on comparisons with a baseline build SkyStrip uses: - * RAM: +2080 bytes - * Flash: +153,812 bytes + - RAM: +2080 bytes + - Flash: +153,812 bytes From 8f088611d76a1655bd135db061657327daa214de Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Tue, 16 Sep 2025 11:45:41 -0700 Subject: [PATCH 17/20] skystrip: Fix segment freeze semantics --- usermods/usermod_v2_skystrip/cloud_view.cpp | 22 +++++-- usermods/usermod_v2_skystrip/cloud_view.h | 3 + usermods/usermod_v2_skystrip/delta_view.cpp | 22 +++++-- usermods/usermod_v2_skystrip/delta_view.h | 3 + usermods/usermod_v2_skystrip/interfaces.h | 4 ++ .../usermod_v2_skystrip/temperature_view.cpp | 22 +++++-- .../usermod_v2_skystrip/temperature_view.h | 3 + .../usermod_v2_skystrip/test_pattern_view.cpp | 22 +++++-- .../usermod_v2_skystrip/test_pattern_view.h | 3 + .../usermod_v2_skystrip.cpp | 8 ++- usermods/usermod_v2_skystrip/util.h | 58 +++++++++++++++---- usermods/usermod_v2_skystrip/wind_view.cpp | 22 +++++-- usermods/usermod_v2_skystrip/wind_view.h | 3 + 13 files changed, 159 insertions(+), 36 deletions(-) diff --git a/usermods/usermod_v2_skystrip/cloud_view.cpp b/usermods/usermod_v2_skystrip/cloud_view.cpp index 19e51a0b31..ee1b444234 100644 --- a/usermods/usermod_v2_skystrip/cloud_view.cpp +++ b/usermods/usermod_v2_skystrip/cloud_view.cpp @@ -42,20 +42,28 @@ void CloudView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) { name().c_str()); debugPixelString[sizeof(debugPixelString) - 1] = '\0'; } - if (segId_ == DEFAULT_SEG_ID) + if (segId_ == DEFAULT_SEG_ID) { + freezeHandle_.release(); return; + } if (model.cloud_cover_forecast.empty()) return; - if (segId_ < 0 || segId_ >= strip.getMaxSegments()) + if (segId_ < 0 || segId_ >= strip.getMaxSegments()) { + freezeHandle_.release(); return; + } - Segment &seg = strip.getSegment((uint8_t)segId_); + Segment *segPtr = freezeHandle_.acquire(segId_); + if (!segPtr) + return; + Segment &seg = *segPtr; int len = seg.virtualLength(); - if (len <= 0) + if (len <= 0) { + freezeHandle_.release(); return; + } // Initialize segment drawing parameters so virtualLength()/mapping are valid seg.beginDraw(); - skystrip::util::FreezeGuard freezeGuard(seg, false); constexpr double kHorizonSec = 48.0 * 3600.0; const double step = (len > 1) ? (kHorizonSec / double(len - 1)) : 0.0; @@ -197,6 +205,10 @@ void CloudView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) { } } +void CloudView::deactivate() { + freezeHandle_.release(); +} + void CloudView::addToConfig(JsonObject &subtree) { subtree[FPSTR(CFG_SEG_ID)] = segId_; } diff --git a/usermods/usermod_v2_skystrip/cloud_view.h b/usermods/usermod_v2_skystrip/cloud_view.h index f07115ba9f..6d345b98dc 100644 --- a/usermods/usermod_v2_skystrip/cloud_view.h +++ b/usermods/usermod_v2_skystrip/cloud_view.h @@ -2,6 +2,7 @@ #include "interfaces.h" #include "skymodel.h" +#include "util.h" class SkyModel; @@ -13,6 +14,7 @@ class CloudView : public IDataViewT { void view(time_t now, SkyModel const & model, int16_t dbgPixelIndex) override; std::string name() const override { return "CV"; } void appendDebugPixel(Print& s) const override { s.print(debugPixelString); } + void deactivate() override; void addToConfig(JsonObject& subtree) override; void appendConfigData(Print& s) override; @@ -24,4 +26,5 @@ class CloudView : public IDataViewT { private: int16_t segId_; char debugPixelString[128]; + skystrip::util::SegmentFreezeHandle freezeHandle_; }; diff --git a/usermods/usermod_v2_skystrip/delta_view.cpp b/usermods/usermod_v2_skystrip/delta_view.cpp index 0b987367fa..37124a8e75 100644 --- a/usermods/usermod_v2_skystrip/delta_view.cpp +++ b/usermods/usermod_v2_skystrip/delta_view.cpp @@ -66,20 +66,28 @@ void DeltaView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) { name().c_str()); debugPixelString[sizeof(debugPixelString) - 1] = '\0'; } - if (segId_ == DEFAULT_SEG_ID) + if (segId_ == DEFAULT_SEG_ID) { + freezeHandle_.release(); return; + } if (model.temperature_forecast.empty()) return; - if (segId_ < 0 || segId_ >= strip.getMaxSegments()) + if (segId_ < 0 || segId_ >= strip.getMaxSegments()) { + freezeHandle_.release(); return; + } - Segment &seg = strip.getSegment((uint8_t)segId_); + Segment *segPtr = freezeHandle_.acquire(segId_); + if (!segPtr) + return; + Segment &seg = *segPtr; int len = seg.virtualLength(); - if (len <= 0) + if (len <= 0) { + freezeHandle_.release(); return; + } // Initialize segment drawing parameters so virtualLength()/mapping are valid seg.beginDraw(); - skystrip::util::FreezeGuard freezeGuard(seg, false); constexpr double kHorizonSec = 48.0 * 3600.0; const double step = (len > 1) ? (kHorizonSec / double(len - 1)) : 0.0; @@ -155,6 +163,10 @@ void DeltaView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) { } } +void DeltaView::deactivate() { + freezeHandle_.release(); +} + void DeltaView::addToConfig(JsonObject &subtree) { subtree[FPSTR(CFG_SEG_ID)] = segId_; } diff --git a/usermods/usermod_v2_skystrip/delta_view.h b/usermods/usermod_v2_skystrip/delta_view.h index 46a7d3a436..92a8c8ec53 100644 --- a/usermods/usermod_v2_skystrip/delta_view.h +++ b/usermods/usermod_v2_skystrip/delta_view.h @@ -2,6 +2,7 @@ #include "interfaces.h" #include "skymodel.h" +#include "util.h" class SkyModel; @@ -13,6 +14,7 @@ class DeltaView : public IDataViewT { void view(time_t now, SkyModel const & model, int16_t dbgPixelIndex) override; std::string name() const override { return "DV"; } void appendDebugPixel(Print& s) const override { s.print(debugPixelString); } + void deactivate() override; void addToConfig(JsonObject& subtree) override; void appendConfigData(Print& s) override; @@ -24,4 +26,5 @@ class DeltaView : public IDataViewT { private: int16_t segId_; char debugPixelString[256]; + skystrip::util::SegmentFreezeHandle freezeHandle_; }; diff --git a/usermods/usermod_v2_skystrip/interfaces.h b/usermods/usermod_v2_skystrip/interfaces.h index 44f60bbb93..3d89e53c45 100644 --- a/usermods/usermod_v2_skystrip/interfaces.h +++ b/usermods/usermod_v2_skystrip/interfaces.h @@ -51,4 +51,8 @@ class IDataViewT : public IConfigurable { /// Append DebugPixel info virtual void appendDebugPixel(Print& s) const = 0; + + /// Allow view to release any persistent resources (e.g. segment freeze) + /// when the usermod is inactive. + virtual void deactivate() {} }; diff --git a/usermods/usermod_v2_skystrip/temperature_view.cpp b/usermods/usermod_v2_skystrip/temperature_view.cpp index f26dc5eb7b..59cb674bfe 100644 --- a/usermods/usermod_v2_skystrip/temperature_view.cpp +++ b/usermods/usermod_v2_skystrip/temperature_view.cpp @@ -69,20 +69,28 @@ void TemperatureView::view(time_t now, SkyModel const &model, name().c_str()); debugPixelString[sizeof(debugPixelString) - 1] = '\0'; } - if (segId_ == DEFAULT_SEG_ID) + if (segId_ == DEFAULT_SEG_ID) { + freezeHandle_.release(); return; // disabled + } if (model.temperature_forecast.empty()) return; // nothing to render - if (segId_ < 0 || segId_ >= strip.getMaxSegments()) + if (segId_ < 0 || segId_ >= strip.getMaxSegments()) { + freezeHandle_.release(); + return; + } + Segment *segPtr = freezeHandle_.acquire(segId_); + if (!segPtr) return; - Segment &seg = strip.getSegment((uint8_t)segId_); + Segment &seg = *segPtr; int len = seg.virtualLength(); - if (len <= 0) + if (len <= 0) { + freezeHandle_.release(); return; + } // Initialize segment drawing parameters so virtualLength()/mapping are valid seg.beginDraw(); - skystrip::util::FreezeGuard freezeGuard(seg, false); constexpr double kHorizonSec = 48.0 * 3600.0; const double step = (len > 1) ? (kHorizonSec / double(len - 1)) : 0.0; @@ -172,6 +180,10 @@ void TemperatureView::view(time_t now, SkyModel const &model, } } +void TemperatureView::deactivate() { + freezeHandle_.release(); +} + void TemperatureView::addToConfig(JsonObject &subtree) { subtree[FPSTR(CFG_SEG_ID)] = segId_; } diff --git a/usermods/usermod_v2_skystrip/temperature_view.h b/usermods/usermod_v2_skystrip/temperature_view.h index e47b9970d1..eed84eae00 100644 --- a/usermods/usermod_v2_skystrip/temperature_view.h +++ b/usermods/usermod_v2_skystrip/temperature_view.h @@ -2,6 +2,7 @@ #include "interfaces.h" #include "skymodel.h" +#include "util.h" class SkyModel; @@ -14,6 +15,7 @@ class TemperatureView : public IDataViewT { void view(time_t now, SkyModel const & model, int16_t dbgPixelIndex) override; std::string name() const override { return "TV"; } void appendDebugPixel(Print& s) const override { s.print(debugPixelString); } + void deactivate() override; // IConfigurable void addToConfig(JsonObject& subtree) override; @@ -26,4 +28,5 @@ class TemperatureView : public IDataViewT { private: int16_t segId_; // -1 means disabled char debugPixelString[128]; + skystrip::util::SegmentFreezeHandle freezeHandle_; }; diff --git a/usermods/usermod_v2_skystrip/test_pattern_view.cpp b/usermods/usermod_v2_skystrip/test_pattern_view.cpp index d6fa90e591..d208dc0f87 100644 --- a/usermods/usermod_v2_skystrip/test_pattern_view.cpp +++ b/usermods/usermod_v2_skystrip/test_pattern_view.cpp @@ -100,18 +100,26 @@ void TestPatternView::view(time_t now, SkyModel const &model, name().c_str()); debugPixelString[sizeof(debugPixelString) - 1] = '\0'; } - if (segId_ == DEFAULT_SEG_ID) + if (segId_ == DEFAULT_SEG_ID) { + freezeHandle_.release(); return; - if (segId_ < 0 || segId_ >= strip.getMaxSegments()) + } + if (segId_ < 0 || segId_ >= strip.getMaxSegments()) { + freezeHandle_.release(); return; + } - Segment &seg = strip.getSegment((uint8_t)segId_); + Segment *segPtr = freezeHandle_.acquire(segId_); + if (!segPtr) + return; + Segment &seg = *segPtr; int len = seg.virtualLength(); - if (len <= 0) + if (len <= 0) { + freezeHandle_.release(); return; + } // Initialize segment drawing parameters so virtualLength()/mapping are valid seg.beginDraw(); - skystrip::util::FreezeGuard freezeGuard(seg, false); for (int i = 0; i < len; ++i) { float u = (len > 1) ? float(i) / float(len - 1) : 0.f; @@ -134,6 +142,10 @@ void TestPatternView::view(time_t now, SkyModel const &model, } } +void TestPatternView::deactivate() { + freezeHandle_.release(); +} + void TestPatternView::addToConfig(JsonObject &subtree) { subtree[FPSTR(CFG_SEG_ID)] = segId_; diff --git a/usermods/usermod_v2_skystrip/test_pattern_view.h b/usermods/usermod_v2_skystrip/test_pattern_view.h index fe0e824afd..033fbb0f86 100644 --- a/usermods/usermod_v2_skystrip/test_pattern_view.h +++ b/usermods/usermod_v2_skystrip/test_pattern_view.h @@ -2,6 +2,7 @@ #include "interfaces.h" #include "skymodel.h" +#include "util.h" class SkyModel; @@ -13,6 +14,7 @@ class TestPatternView : public IDataViewT { void view(time_t now, SkyModel const & model, int16_t dbgPixelIndex) override; std::string name() const override { return "TP"; } void appendDebugPixel(Print& s) const override { s.print(debugPixelString); } + void deactivate() override; void addToConfig(JsonObject& subtree) override; void appendConfigData(Print& s) override; @@ -26,4 +28,5 @@ class TestPatternView : public IDataViewT { char debugPixelString[128]; float startHue_, startSat_, startVal_; float endHue_, endSat_, endVal_; + skystrip::util::SegmentFreezeHandle freezeHandle_; }; diff --git a/usermods/usermod_v2_skystrip/usermod_v2_skystrip.cpp b/usermods/usermod_v2_skystrip/usermod_v2_skystrip.cpp index f79345de7b..3b52854e6c 100644 --- a/usermods/usermod_v2_skystrip/usermod_v2_skystrip.cpp +++ b/usermods/usermod_v2_skystrip/usermod_v2_skystrip.cpp @@ -118,6 +118,13 @@ void SkyStrip::loop() { void SkyStrip::handleOverlayDraw() { // this happens a hundred times a second + if (!enabled_) { + for (auto &view : views_) view->deactivate(); + return; + } + if (offMode) { + return; + } time_t now = skystrip::util::time_now_utc(); for (auto &view : views_) { view->view(now, *model_, dbgPixelIndex_); @@ -243,7 +250,6 @@ void SkyStrip::showBooting() { void SkyStrip::doneBooting() { Segment& seg = strip.getMainSegment(); - seg.freeze = true; // stop any further segment animation seg.setMode(0); // static palette/color mode // seg.intensity = 255; // preserve user's settings via webapp } diff --git a/usermods/usermod_v2_skystrip/util.h b/usermods/usermod_v2_skystrip/util.h index eb444af8a2..f96cd337c7 100644 --- a/usermods/usermod_v2_skystrip/util.h +++ b/usermods/usermod_v2_skystrip/util.h @@ -9,17 +9,55 @@ namespace skystrip { namespace util { -// RAII guard to temporarily freeze a segment during rendering and always -// restore its original freeze state on exit (including early returns). -struct FreezeGuard { - Segment &seg; - bool prev; - explicit FreezeGuard(Segment &s, bool freezeNow = true) : seg(s), prev(s.freeze) { - seg.freeze = freezeNow; +// Tracks freeze ownership for a single view so it only mutates +// its own segment’s freeze flag. +class SegmentFreezeHandle { +public: + SegmentFreezeHandle() = default; + ~SegmentFreezeHandle() { release(); } + + Segment *acquire(int16_t segId) { + if (segId < 0) { + release(); + return nullptr; + } + uint8_t maxSeg = strip.getMaxSegments(); + if (segId >= maxSeg) { + release(); + return nullptr; + } + Segment &seg = strip.getSegment((uint8_t)segId); + if (active_ && heldId_ == segId) { + seg_ = &seg; + if (!seg.freeze) { + seg.freeze = true; + } + return seg_; + } + + release(); + + prevFreeze_ = seg.freeze; + seg.freeze = true; + active_ = true; + heldId_ = segId; + seg_ = &seg; + return seg_; + } + + void release() { + if (!active_) return; + if (seg_) seg_->freeze = prevFreeze_; + seg_ = nullptr; + heldId_ = -1; + active_ = false; } - ~FreezeGuard() { seg.freeze = prev; } - FreezeGuard(const FreezeGuard &) = delete; - FreezeGuard &operator=(const FreezeGuard &) = delete; + +private: + bool active_ = false; + int16_t heldId_ = -1; + bool prevFreeze_ = false; + Segment *seg_ = nullptr; }; // UTC now from WLED’s clock (same source the UI uses) diff --git a/usermods/usermod_v2_skystrip/wind_view.cpp b/usermods/usermod_v2_skystrip/wind_view.cpp index b271c86996..ef1507a70d 100644 --- a/usermods/usermod_v2_skystrip/wind_view.cpp +++ b/usermods/usermod_v2_skystrip/wind_view.cpp @@ -49,20 +49,28 @@ void WindView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) { name().c_str()); debugPixelString[sizeof(debugPixelString) - 1] = '\0'; } - if (segId_ == DEFAULT_SEG_ID) + if (segId_ == DEFAULT_SEG_ID) { + freezeHandle_.release(); return; + } if (model.wind_speed_forecast.empty()) return; - if (segId_ < 0 || segId_ >= strip.getMaxSegments()) + if (segId_ < 0 || segId_ >= strip.getMaxSegments()) { + freezeHandle_.release(); return; + } - Segment &seg = strip.getSegment((uint8_t)segId_); + Segment *segPtr = freezeHandle_.acquire(segId_); + if (!segPtr) + return; + Segment &seg = *segPtr; int len = seg.virtualLength(); - if (len <= 0) + if (len <= 0) { + freezeHandle_.release(); return; + } // Initialize segment drawing parameters so virtualLength()/mapping are valid seg.beginDraw(); - skystrip::util::FreezeGuard freezeGuard(seg, false); constexpr double kHorizonSec = 48.0 * 3600.0; const double step = (len > 1) ? (kHorizonSec / double(len - 1)) : 0.0; @@ -109,6 +117,10 @@ void WindView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) { } } +void WindView::deactivate() { + freezeHandle_.release(); +} + void WindView::addToConfig(JsonObject &subtree) { subtree[FPSTR(CFG_SEG_ID)] = segId_; } diff --git a/usermods/usermod_v2_skystrip/wind_view.h b/usermods/usermod_v2_skystrip/wind_view.h index 2e41e442b1..b53b7a0e4d 100644 --- a/usermods/usermod_v2_skystrip/wind_view.h +++ b/usermods/usermod_v2_skystrip/wind_view.h @@ -2,6 +2,7 @@ #include "interfaces.h" #include "skymodel.h" +#include "util.h" class SkyModel; @@ -13,6 +14,7 @@ class WindView : public IDataViewT { void view(time_t now, SkyModel const & model, int16_t dbgPixelIndex) override; std::string name() const override { return "WV"; } void appendDebugPixel(Print& s) const override { s.print(debugPixelString); } + void deactivate() override; void addToConfig(JsonObject& subtree) override; void appendConfigData(Print& s) override; @@ -24,4 +26,5 @@ class WindView : public IDataViewT { private: int16_t segId_; char debugPixelString[128]; + skystrip::util::SegmentFreezeHandle freezeHandle_; }; From 7deb02b93f9b172f5681b1ca2705c123cf1e2f01 Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Fri, 19 Sep 2025 22:04:32 -0700 Subject: [PATCH 18/20] skystrip: Don't gate fetching with strip.isUpdating --- usermods/usermod_v2_skystrip/usermod_v2_skystrip.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/usermods/usermod_v2_skystrip/usermod_v2_skystrip.cpp b/usermods/usermod_v2_skystrip/usermod_v2_skystrip.cpp index 3b52854e6c..7627d8302c 100644 --- a/usermods/usermod_v2_skystrip/usermod_v2_skystrip.cpp +++ b/usermods/usermod_v2_skystrip/usermod_v2_skystrip.cpp @@ -101,8 +101,8 @@ void SkyStrip::loop() { lastOff_ = offMode; lastEnabled_ = enabled_; - // make sure we are enabled, on, and ready - if (!enabled_ || offMode || strip.isUpdating()) return; + // make sure we are enabled and on + if (!enabled_ || offMode) return; // check the sources for updates, apply to model if found for (auto &source : sources_) { From 027626c669f7b07aeae68cd6abe04d1dcfe4ae11 Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Fri, 19 Sep 2025 22:25:11 -0700 Subject: [PATCH 19/20] skystrip: Fix washed out areas with minimum saturation increase --- usermods/usermod_v2_skystrip/delta_view.cpp | 2 +- usermods/usermod_v2_skystrip/temperature_view.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/usermods/usermod_v2_skystrip/delta_view.cpp b/usermods/usermod_v2_skystrip/delta_view.cpp index 37124a8e75..f850855d9a 100644 --- a/usermods/usermod_v2_skystrip/delta_view.cpp +++ b/usermods/usermod_v2_skystrip/delta_view.cpp @@ -39,7 +39,7 @@ static float hueForDeltaF(double f) { } static inline float satFromDewDiffDelta(float delta) { - constexpr float kMinSat = 0.30f; + constexpr float kMinSat = 0.45f; constexpr float kMaxDelta = 15.0f; // +/-15F covers typical range float u = skystrip::util::clamp01((delta + kMaxDelta) / (2.f * kMaxDelta)); return kMinSat + (1.f - kMinSat) * u; diff --git a/usermods/usermod_v2_skystrip/temperature_view.cpp b/usermods/usermod_v2_skystrip/temperature_view.cpp index 59cb674bfe..73c41028a3 100644 --- a/usermods/usermod_v2_skystrip/temperature_view.cpp +++ b/usermods/usermod_v2_skystrip/temperature_view.cpp @@ -18,7 +18,7 @@ static inline float satFromDewSpreadF(float tempF, float dewF) { float dd = tempF - dewF; if (dd < 0.f) dd = 0.f; // guard bad inputs - constexpr float kMinSat = 0.40f; // floor (muggy look) + constexpr float kMinSat = 0.55f; // floor (muggy look) constexpr float kMaxSpread = 25.0f; // “very dry” cap float u = skystrip::util::clamp01(dd / kMaxSpread); float eased = u * u * (3.f - 2.f * u); // smoothstep From 946c486de8f0d4ffb6ba618add11e648dbbba322 Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Fri, 19 Sep 2025 22:35:07 -0700 Subject: [PATCH 20/20] skystrip: Suppress view of calm wind --- usermods/usermod_v2_skystrip/wind_view.cpp | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/usermods/usermod_v2_skystrip/wind_view.cpp b/usermods/usermod_v2_skystrip/wind_view.cpp index ef1507a70d..5400f68407 100644 --- a/usermods/usermod_v2_skystrip/wind_view.cpp +++ b/usermods/usermod_v2_skystrip/wind_view.cpp @@ -84,6 +84,16 @@ void WindView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) { continue; if (!skystrip::util::estimateGustAt(model, t, step, gst)) gst = spd; + + // save for debug pixel reporting + double raw_spd = spd; + double raw_gst = gst; + + constexpr double kCalmMph = 5.0; + if (spd < kCalmMph && gst < kCalmMph) { + spd = 0.0; + gst = 0.0; + } float hue = hueFromDir((float)dir); float sat = satFromGustDiff((float)spd, (float)gst); @@ -107,7 +117,7 @@ void WindView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) { "%s: nowtm=%s dbgndx=%d dbgtm=%s " "spd=%.0f gst=%.0f dir=%.0f " "H=%.0f S=%.0f V=%.0f\\n", - name().c_str(), nowbuf, i, dbgbuf, spd, gst, dir, hue, + name().c_str(), nowbuf, i, dbgbuf, raw_spd, raw_gst, dir, hue, sat * 100, val * 100); lastDebug = now; }