From 2df807da2b71b77f1e3ad981acbfb04722f5b624 Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Thu, 28 Aug 2025 11:06:47 -0700 Subject: [PATCH 01/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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; } From bcd0ed316a55426c8b0dd6f3bc1c1683769e9789 Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Mon, 13 Oct 2025 10:57:02 -0700 Subject: [PATCH 21/27] skystrip: improve rendering of views - formatting - improve rain rendering in low PoP situations - add precip rate (in/hr) to data collection - render rain with PoP as saturation and in/hr as value - tuned max delta value down --- usermods/usermod_v2_skystrip/cloud_view.cpp | 68 ++++++++++++------- usermods/usermod_v2_skystrip/cloud_view.h | 3 + usermods/usermod_v2_skystrip/delta_view.cpp | 2 +- .../open_weather_map_source.cpp | 31 ++++----- usermods/usermod_v2_skystrip/skymodel.cpp | 5 ++ usermods/usermod_v2_skystrip/skymodel.h | 1 + usermods/usermod_v2_skystrip/util.h | 4 ++ 7 files changed, 71 insertions(+), 43 deletions(-) diff --git a/usermods/usermod_v2_skystrip/cloud_view.cpp b/usermods/usermod_v2_skystrip/cloud_view.cpp index ee1b444234..d661a92b7a 100644 --- a/usermods/usermod_v2_skystrip/cloud_view.cpp +++ b/usermods/usermod_v2_skystrip/cloud_view.cpp @@ -8,6 +8,7 @@ static constexpr int16_t DEFAULT_SEG_ID = -1; // -1 means disabled const char CFG_SEG_ID[] PROGMEM = "SegmentId"; +const char CFG_RAIN_MAX[] PROGMEM = "RainMaxInHr"; static bool isDay(const SkyModel &m, time_t t) { const time_t MAXTT = std::numeric_limits::max(); @@ -29,7 +30,7 @@ static bool isDay(const SkyModel &m, time_t t) { return t >= sr && t < ss; } -CloudView::CloudView() : segId_(DEFAULT_SEG_ID) { +CloudView::CloudView() : segId_(DEFAULT_SEG_ID), precipMaxInHr_(CloudView::DEFAULT_RAIN_MAX_INPH) { DEBUG_PRINTLN("SkyStrip: CV::CTOR"); snprintf(debugPixelString, sizeof(debugPixelString), "%s:\\n", name().c_str()); @@ -121,18 +122,21 @@ void CloudView::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)); - double clouds, precipTypeVal, precipProb; + double clouds, precipTypeVal, precipProb, precipRate; 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; + if (!skystrip::util::estimatePrecipRateAt(model, t, step, precipRate)) + precipRate = 0.0; float clouds01 = skystrip::util::clamp01(float(clouds / 100.0)); int p = int(std::round(precipTypeVal)); bool daytime = isDay(model, t); - + float precip01 = skystrip::util::clamp01(float(precipProb)); + float precipRateIn = float(precipRate); float hue = 0.f, sat = 0.f, val = 0.f; if (isMarker(t)) { @@ -140,35 +144,48 @@ void CloudView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) { hue = kMarkerHue; sat = kMarkerSat; val = kMarkerVal; - } else if (p != 0 && precipProb > 0.0) { + } else if (precip01 >= 0.10f) { // 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 kHueRain = 210.f; // deep blue + constexpr float kSatRainMin = 0.20f; + constexpr float kSatRainMax = 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; + float popScaled = + skystrip::util::clamp01((precip01 - 0.10f) / 0.90f); // maps 10%->0, 100%->1 + float satRain = + kSatRainMin + (kSatRainMax - kSatRainMin) * popScaled; + + float rateMax = precipMaxInHr_; + if (rateMax <= 0.0f) + rateMax = CloudView::DEFAULT_RAIN_MAX_INPH; + float rateNorm = (rateMax > 0.0f) ? (precipRateIn / rateMax) : 0.0f; + rateNorm = skystrip::util::clamp01(rateNorm); + + // Gentle curve so 0.1-0.5 in/hr remain distinguishable. + float rateCurve = sqrtf(rateNorm); + float valFromRate = 0.40f + 0.60f * rateCurve; + valFromRate = skystrip::util::clamp01(valFromRate); + + if (p == 1 || p == 0) { + // rain (or unspecified → default to rain treatment) + hue = kHueRain; + sat = satRain; + val = valFromRate; } else if (p == 2) { // snow → lavender - ph = kHueSnow; - ps = kSatSnow; + hue = kHueSnow; + sat = kSatSnow; + val = valFromRate; } else { // mixed → halfway between blue and lavender - ph = 0.5f * (kHueRain + kHueSnow); // ~247.5° (indigo-ish) - ps = 0.5f * (kSatRain + kSatSnow); // ~0.675 + hue = 0.5f * (kHueRain + kHueSnow); // ~247.5° (indigo-ish) + sat = 0.5f * (satRain + kSatSnow); // blended saturation + val = valFromRate; } - - 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) { @@ -187,7 +204,7 @@ void CloudView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) { uint32_t col = skystrip::util::hsv2rgb(hue, sat, val); seg.setPixelColor(i, skystrip::util::blinkDebug(i, dbgPixelIndex, col)); - + if (dbgPixelIndex >= 0) { static time_t lastDebug = 0; if (now - lastDebug > 1 && i == dbgPixelIndex) { @@ -196,9 +213,9 @@ void CloudView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) { 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", + "%s: nowtm=%s dbgndx=%d dbgtm=%s day=%d clouds01=%.2f precip=%d pop=%.2f acc=%.2fin/hr H=%.0f S=%.0f V=%.0f\\n", name().c_str(), nowbuf, i, dbgbuf, daytime, clouds01, p, - precipProb, hue, sat * 100, val * 100); + precipProb, precipRateIn, hue, sat * 100, val * 100); lastDebug = now; } } @@ -211,6 +228,7 @@ void CloudView::deactivate() { void CloudView::addToConfig(JsonObject &subtree) { subtree[FPSTR(CFG_SEG_ID)] = segId_; + subtree[FPSTR(CFG_RAIN_MAX)] = precipMaxInHr_; } void CloudView::appendConfigData(Print &s) { @@ -225,5 +243,7 @@ bool CloudView::readFromConfig(JsonObject &subtree, bool startup_complete, bool configComplete = !subtree.isNull(); configComplete &= getJsonValue(subtree[FPSTR(CFG_SEG_ID)], segId_, DEFAULT_SEG_ID); + configComplete &= + getJsonValue(subtree[FPSTR(CFG_RAIN_MAX)], precipMaxInHr_, CloudView::DEFAULT_RAIN_MAX_INPH); return configComplete; } diff --git a/usermods/usermod_v2_skystrip/cloud_view.h b/usermods/usermod_v2_skystrip/cloud_view.h index 6d345b98dc..a726836af2 100644 --- a/usermods/usermod_v2_skystrip/cloud_view.h +++ b/usermods/usermod_v2_skystrip/cloud_view.h @@ -24,7 +24,10 @@ class CloudView : public IDataViewT { const char* configKey() const override { return "CloudView"; } private: + static constexpr float DEFAULT_RAIN_MAX_INPH = 1.0f; + int16_t segId_; + float precipMaxInHr_; 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 f850855d9a..eae3fbf771 100644 --- a/usermods/usermod_v2_skystrip/delta_view.cpp +++ b/usermods/usermod_v2_skystrip/delta_view.cpp @@ -50,7 +50,7 @@ static inline float intensityFromDeltas(double tempDelta, float humidDelta) { 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; + return skystrip::util::clamp01(std::sqrt(uT * uT + uH * uH)) * 0.6; } DeltaView::DeltaView() : segId_(DEFAULT_SEG_ID) { diff --git a/usermods/usermod_v2_skystrip/open_weather_map_source.cpp b/usermods/usermod_v2_skystrip/open_weather_map_source.cpp index 74a8b45d38..de482638bf 100644 --- a/usermods/usermod_v2_skystrip/open_weather_map_source.cpp +++ b/usermods/usermod_v2_skystrip/open_weather_map_source.cpp @@ -16,6 +16,7 @@ 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 +static constexpr double MM_PER_INCH = 25.4; // - these are user visible in the webapp settings UI // - they are scoped to this module, don't need to be globally unique @@ -334,16 +335,11 @@ std::unique_ptr OpenWeatherMapSource::fetch(std::time_t now) { 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() }); + double rain1h = hour.containsKey("rain") ? (hour["rain"]["1h"] | 0.0) : 0.0; + double snow1h = hour.containsKey("snow") ? (hour["snow"]["1h"] | 0.0) : 0.0; 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; - } + bool hasRain = rain1h > 0.0; + bool hasSnow = snow1h > 0.0; if (!hasRain && !hasSnow && !wArr.isNull() && wArr.size() > 0) { const char* main = wArr[0]["main"] | ""; if (strcasecmp(main, "rain") == 0 || strcasecmp(main, "drizzle") == 0 || @@ -354,7 +350,9 @@ std::unique_ptr OpenWeatherMapSource::fetch(std::time_t now) { } int ptype = hasRain && hasSnow ? 3 : (hasSnow ? 2 : (hasRain ? 1 : 0)); model->precip_type_forecast.push_back({ dt, (float)ptype }); + double accumIn = (rain1h + snow1h) / MM_PER_INCH; model->precip_prob_forecast.push_back({ dt, (float)hour["pop"].as() }); + model->precip_inph_forecast.push_back({ dt, (float)accumIn }); } // Stagger history fetch to avoid back-to-back GETs in same loop iteration @@ -409,16 +407,11 @@ std::unique_ptr OpenWeatherMapSource::checkhistory(time_t now, std::ti 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() }); + double rain1h = hour.containsKey("rain") ? (hour["rain"]["1h"] | 0.0) : 0.0; + double snow1h = hour.containsKey("snow") ? (hour["snow"]["1h"] | 0.0) : 0.0; 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; - } + bool hasRain = rain1h > 0.0; + bool hasSnow = snow1h > 0.0; if (!hasRain && !hasSnow && !wArr.isNull() && wArr.size() > 0) { const char* main = wArr[0]["main"] | ""; if (strcasecmp(main, "rain") == 0 || strcasecmp(main, "drizzle") == 0 || @@ -429,7 +422,9 @@ std::unique_ptr OpenWeatherMapSource::checkhistory(time_t now, std::ti } int ptype = hasRain && hasSnow ? 3 : (hasSnow ? 2 : (hasRain ? 1 : 0)); model->precip_type_forecast.push_back({ dt, (float)ptype }); + double accumIn = (rain1h + snow1h) / MM_PER_INCH; model->precip_prob_forecast.push_back({ dt, (float)hour["pop"].as() }); + model->precip_inph_forecast.push_back({ dt, (float)accumIn }); } if (model->temperature_forecast.empty()) return nullptr; diff --git a/usermods/usermod_v2_skystrip/skymodel.cpp b/usermods/usermod_v2_skystrip/skymodel.cpp index 2374d1765a..488ef2ba67 100644 --- a/usermods/usermod_v2_skystrip/skymodel.cpp +++ b/usermods/usermod_v2_skystrip/skymodel.cpp @@ -52,6 +52,7 @@ SkyModel::SkyModel() { cloud_cover_forecast.reserve(MAX_POINTS); precip_type_forecast.reserve(MAX_POINTS); precip_prob_forecast.reserve(MAX_POINTS); + precip_inph_forecast.reserve(MAX_POINTS); } SkyModel & SkyModel::update(time_t now, SkyModel && other) { @@ -65,6 +66,7 @@ SkyModel & SkyModel::update(time_t now, SkyModel && other) { 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); + mergeSeries(precip_inph_forecast, std::move(other.precip_inph_forecast), now); if (!(other.sunrise_ == 0 && other.sunset_ == 0)) { sunrise_ = other.sunrise_; @@ -87,6 +89,7 @@ void SkyModel::invalidate_history(time_t now) { cloud_cover_forecast.clear(); precip_type_forecast.clear(); precip_prob_forecast.clear(); + precip_inph_forecast.clear(); sunrise_ = 0; sunset_ = 0; } @@ -104,6 +107,7 @@ time_t SkyModel::oldest() const { upd(cloud_cover_forecast); upd(precip_type_forecast); upd(precip_prob_forecast); + upd(precip_inph_forecast); if (out == std::numeric_limits::max()) return 0; return out; } @@ -162,6 +166,7 @@ void SkyModel::emitDebug(time_t now, Print& out) const { emitSeriesMDHM(out, now, " clds", cloud_cover_forecast); emitSeriesMDHM(out, now, " prcp", precip_type_forecast); emitSeriesMDHM(out, now, " pop", precip_prob_forecast); + emitSeriesMDHM(out, now, " inph", precip_inph_forecast); char tb[20]; char line[64]; diff --git a/usermods/usermod_v2_skystrip/skymodel.h b/usermods/usermod_v2_skystrip/skymodel.h index f49b41ec67..a0cda75cc3 100644 --- a/usermods/usermod_v2_skystrip/skymodel.h +++ b/usermods/usermod_v2_skystrip/skymodel.h @@ -40,6 +40,7 @@ class SkyModel { 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 + std::vector precip_inph_forecast; // liquid-equivalent accumulation in in/hr // sunrise/sunset times from current data time_t sunrise_{0}; diff --git a/usermods/usermod_v2_skystrip/util.h b/usermods/usermod_v2_skystrip/util.h index f96cd337c7..210eaf0a17 100644 --- a/usermods/usermod_v2_skystrip/util.h +++ b/usermods/usermod_v2_skystrip/util.h @@ -181,6 +181,10 @@ inline bool estimatePrecipProbAt(const SkyModel &m, time_t t, double step, double &out) { return estimateAt(m.precip_prob_forecast, t, step, out); } +inline bool estimatePrecipRateAt(const SkyModel &m, time_t t, double step, + double &out) { + return estimateAt(m.precip_inph_forecast, t, step, out); +} uint32_t hsv2rgb(float h, float s, float v); From 7efff948cdb7e1dc76d3e1100f7a0c4f97c3926c Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Fri, 24 Oct 2025 11:51:01 -0700 Subject: [PATCH 22/27] skystrip: display the SKYSTRIP_GIT_DESCRIBE in the config --- .../pio-scripts/skystrip_version.py | 53 +++++++++++++++++++ .../usermod_v2_skystrip.cpp | 6 ++- .../usermod_v2_skystrip/usermod_v2_skystrip.h | 8 ++- 3 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 usermods/usermod_v2_skystrip/pio-scripts/skystrip_version.py diff --git a/usermods/usermod_v2_skystrip/pio-scripts/skystrip_version.py b/usermods/usermod_v2_skystrip/pio-scripts/skystrip_version.py new file mode 100644 index 0000000000..a7a647079e --- /dev/null +++ b/usermods/usermod_v2_skystrip/pio-scripts/skystrip_version.py @@ -0,0 +1,53 @@ +import os +import subprocess +from pathlib import Path + +try: + Import("env") # type: ignore # pylint: disable=undefined-variable +except NameError: + env = None # pylint: disable=invalid-name + + +def append_define(target_env, define): + if not target_env: + return + try: + target_env.Append(CPPDEFINES=[define]) + except Exception as exc: # pylint: disable=broad-except + print("SkyStrip: failed to append define: {}".format(exc)) + + +def get_project_dir(): + if env: + return env.get("PROJECT_DIR", ".") + current_dir = Path(__file__).resolve().parent + for candidate in [current_dir] + list(current_dir.parents): + if (candidate / "platformio.ini").is_file() or (candidate / ".git").is_dir(): + return str(candidate) + return os.getcwd() + + +def get_git_describe(project_dir): + try: + cmd = ["git", "describe", "--tags", "--long", "--dirty", "--always"] + output = subprocess.check_output(cmd, cwd=project_dir, stderr=subprocess.STDOUT) + version = output.decode("utf-8", errors="ignore").strip() + if version: + return version + except Exception as exc: # pylint: disable=broad-except + print("SkyStrip: git describe failed: {}".format(exc)) + return "unknown" + + +project_dir = get_project_dir() +git_version = get_git_describe(project_dir) +escaped = git_version.replace('"', '\\"') +define = ("SKYSTRIP_GIT_DESCRIBE", '\\"{}\\"'.format(escaped)) + +if env and hasattr(env, "Append"): + append_define(env, define) + modules = env.get("WLED_MODULES") or [] + for dep in modules: + append_define(getattr(dep, "env", None), define) +elif __name__ == "__main__": + print(git_version) diff --git a/usermods/usermod_v2_skystrip/usermod_v2_skystrip.cpp b/usermods/usermod_v2_skystrip/usermod_v2_skystrip.cpp index 7627d8302c..4ee578c6f2 100644 --- a/usermods/usermod_v2_skystrip/usermod_v2_skystrip.cpp +++ b/usermods/usermod_v2_skystrip/usermod_v2_skystrip.cpp @@ -57,8 +57,7 @@ void SkyStrip::setup() { // Serial.begin(115200); // Print version number - DEBUG_PRINT(F("SkyStrip version: ")); - DEBUG_PRINTLN(SKYSTRIP_VERSION); + DEBUG_PRINTF("SkyStrip: version=%s\n", SKYSTRIP_GIT_DESCRIBE); // Start a nice chase so we know its booting showBooting(); @@ -137,6 +136,7 @@ void SkyStrip::addToConfig(JsonObject& root) { // write our state top[FPSTR(CFG_ENABLED)] = enabled_; + top["Version"] = SKYSTRIP_GIT_DESCRIBE; // write the sources for (auto& src : sources_) { @@ -155,6 +155,8 @@ void SkyStrip::addToConfig(JsonObject& root) { } void SkyStrip::appendConfigData(Print& s) { + s.println(F("(()=>{const NAME='SkyStrip:Version'; const input=document.querySelector(`input[name=\"${NAME}\"][type=\"text\"]`); if(!input||input.dataset.versionDecorated) return; input.dataset.versionDecorated='1'; const value=(input.value||'').trim()||'unknown'; input.readOnly=true; input.type='hidden'; let node=input.previousSibling; while(node&&node.nodeType===3){const trimmed=node.textContent.trim(); if(trimmed.length&&trimmed.toLowerCase()!=='text'){break;} const remove=node; node=node.previousSibling; remove.parentNode.removeChild(remove);} const badge=document.createElement('small'); badge.textContent=value; badge.style.marginLeft='0.5rem'; input.parentNode.insertBefore(badge, input.nextSibling);})();")); + for (auto& src : sources_) { src->appendConfigData(s); } diff --git a/usermods/usermod_v2_skystrip/usermod_v2_skystrip.h b/usermods/usermod_v2_skystrip/usermod_v2_skystrip.h index 12827813f5..f68624eea1 100644 --- a/usermods/usermod_v2_skystrip/usermod_v2_skystrip.h +++ b/usermods/usermod_v2_skystrip/usermod_v2_skystrip.h @@ -6,7 +6,13 @@ #define USERMOD_ID_SKYSTRIP 559 -#define SKYSTRIP_VERSION "0.0.1" +#define SKYSTRIP_VERSION_FALLBACK "0.0.1" + +#ifndef SKYSTRIP_GIT_DESCRIBE +#define SKYSTRIP_GIT_DESCRIBE SKYSTRIP_VERSION_FALLBACK +#endif + +#define SKYSTRIP_VERSION SKYSTRIP_GIT_DESCRIBE class SkyModel; From 88167f5cb955834421c3bd693c727656755d06b6 Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Wed, 3 Dec 2025 14:19:12 -0800 Subject: [PATCH 23/27] skystrip: more view rendering improvements - improve partial cloudiness depiction w/ dithering - simplify and improve delta view - refine temperature view, add new config color map - use distinguished temp crossings --- usermods/usermod_v2_skystrip/FAQ.md | 92 ++-- usermods/usermod_v2_skystrip/cloud_view.cpp | 52 ++- usermods/usermod_v2_skystrip/cloud_view.h | 2 + usermods/usermod_v2_skystrip/delta_view.cpp | 187 +++++--- usermods/usermod_v2_skystrip/delta_view.h | 5 + usermods/usermod_v2_skystrip/readme.md | 45 -- .../usermod_v2_skystrip/temperature_view.cpp | 398 ++++++++++++++++-- .../usermod_v2_skystrip/temperature_view.h | 13 + 8 files changed, 606 insertions(+), 188 deletions(-) delete mode 100644 usermods/usermod_v2_skystrip/readme.md diff --git a/usermods/usermod_v2_skystrip/FAQ.md b/usermods/usermod_v2_skystrip/FAQ.md index 3efa01cf66..3e1d97e0a8 100644 --- a/usermods/usermod_v2_skystrip/FAQ.md +++ b/usermods/usermod_v2_skystrip/FAQ.md @@ -12,9 +12,14 @@ 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. +white. Cloud coverage now dithers along forecast time using a +triangle-wave mask: the central band of each wave lights up, and its +width matches the cloud fraction. Sparse clouds show as occasional +dots, roughly half cover alternates on/off bands, and 100% cover stays +solid. Adjust band spacing with the `CloudWaveHalfPx` setting +(half-cycle in pixels; default 2.2). Thus, a bright blue pixel highlights +likely rain, whereas a striped soft yellow glow marks daytime cloud +cover. ## Wind View (WV) @@ -48,55 +53,46 @@ Note: Hues wrap at 360°, so “N” repeats at the boundary. ## Temperature View (TV) -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 | +Hue comes from a configurable `ColorMap` string of `center:hue` pairs +separated by `|` (hue is 0–359 degrees or a name: `magenta, purple, blue, cyan, green, yellow, orange, red`). Saturation still tracks dew‑point spread (muggy = desaturated, dry = vivid), value is fixed mid‑brightness, time markers dim pixels briefly at 3‑hour intervals, and hue is linearly interpolated between centers. + +Default 15 °F rotation (with short wraps at the ends): `-45:yellow|-30:orange|-15:red|0:magenta|15:purple|30:blue|45:cyan|60:green|75:yellow|90:orange|105:red|120:magenta|135:purple|150:blue`. The palette wraps instead of clamping at extremes. + +Primary rotation reference: + +| Center (°F) | Hue name | Hue (°) | +|-------------|----------|---------| +| 120 | magenta | 300 | +| 105 | red | 0 | +| 90 | orange | 30 | +| 75 | yellow | 60 | +| 60 | green | 130 | +| 45 | cyan | 185 | +| 30 | blue | 220 | +| 15 | purple | 275 | +| 0 | magenta | 300 | +| -15 | red | 0 | ## 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) | -|-------------|-------------| -| Cooling | Blue tones | -| Steady | Green | -| Warming | Yellow→Red | - -| Humidity | Saturation | -|------------|------------| -| More humid | Low/Pastel | -| Stable | Medium | -| Drier | High/Vivid | +Shows how much warmer or colder it is compared to the same time +yesterday. Small changes stay dark so quiet days don’t flicker. +Humidity changes are ignored. + +Default thresholds are 5 / 10 / 15 °F (configurable with +`DeltaThresholds`). Colors run from cold on the left to warm on the +right, with “no change” in the middle: + +| Change vs 24h prior | Strip color | Brightness | +|----------------------|-------------|-------------| +| More than 15° colder | Purple | Very Strong | +| 10–15° colder | Indigo | Strong | +| 5–10° colder | Cyan-blue | Medium | +| Less than 5° change | Off (blank) | Off | +| 5–10° warmer | Yellow | Medium | +| 10–15° warmer | Orange | Strong | +| More than 15° warmer | Red | Very Strong | ## Test Pattern View (TP) diff --git a/usermods/usermod_v2_skystrip/cloud_view.cpp b/usermods/usermod_v2_skystrip/cloud_view.cpp index d661a92b7a..f016061f69 100644 --- a/usermods/usermod_v2_skystrip/cloud_view.cpp +++ b/usermods/usermod_v2_skystrip/cloud_view.cpp @@ -3,12 +3,14 @@ #include "util.h" #include "wled.h" #include +#include #include #include static constexpr int16_t DEFAULT_SEG_ID = -1; // -1 means disabled const char CFG_SEG_ID[] PROGMEM = "SegmentId"; const char CFG_RAIN_MAX[] PROGMEM = "RainMaxInHr"; +const char CFG_WAVE_HALF_PX[] PROGMEM = "CloudWaveHalfPx"; static bool isDay(const SkyModel &m, time_t t) { const time_t MAXTT = std::numeric_limits::max(); @@ -30,7 +32,10 @@ static bool isDay(const SkyModel &m, time_t t) { return t >= sr && t < ss; } -CloudView::CloudView() : segId_(DEFAULT_SEG_ID), precipMaxInHr_(CloudView::DEFAULT_RAIN_MAX_INPH) { +CloudView::CloudView() + : segId_(DEFAULT_SEG_ID), + precipMaxInHr_(CloudView::DEFAULT_RAIN_MAX_INPH), + waveHalfCyclePx_(CloudView::DEFAULT_WAVE_HALF_PX) { DEBUG_PRINTLN("SkyStrip: CV::CTOR"); snprintf(debugPixelString, sizeof(debugPixelString), "%s:\\n", name().c_str()); @@ -120,6 +125,15 @@ void CloudView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) { constexpr float kMarkerSat= 0.60f; constexpr float kMarkerVal= 0.50f; + float halfWavePx = waveHalfCyclePx_; + constexpr float kMinHalfWavePx = 0.25f; + if (halfWavePx < kMinHalfWavePx) + halfWavePx = kMinHalfWavePx; + const double wavePeriodSec = step * 2.0 * double(halfWavePx); + const bool haveWave = wavePeriodSec > 0.0; + constexpr float kSolidCloudCutoff = 0.995f; + constexpr float kEdgeFeather = 0.05f; // soften edges to reduce shimmer + for (int i = 0; i < len; ++i) { const time_t t = now + time_t(std::llround(step * i)); double clouds, precipTypeVal, precipProb, precipRate; @@ -196,9 +210,32 @@ void CloudView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) { 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); + float cloudVal = vmin + (vmax - vmin) * sqrtf(clouds01); hue = daytime ? kDayHue : kNightHue; sat = daytime ? kDaySat : kNightSat; + + float mask = 1.f; + if (clouds01 < kSolidCloudCutoff && haveWave) { + double phase = fmod(double(t), wavePeriodSec); + float phase01 = + wavePeriodSec > 0.0 ? float(phase / wavePeriodSec) : 0.f; + float tri = 1.f - fabsf(1.f - 2.f * phase01); // 0->1->0 shape + float thresh = 1.f - clouds01; // center band width = clouds01 + if (thresh < 0.f) + thresh = 0.f; + + float feather = kEdgeFeather * clouds01; // scale feather with duty + + if (tri < thresh - feather) { + mask = 0.f; + } else if (tri < thresh + feather) { + float span = (feather > 0.f) ? (2.f * feather) : 1.f; + mask = skystrip::util::clamp01( + (tri - (thresh - feather)) / span); + } + } + + val = cloudVal * mask; } } @@ -229,6 +266,7 @@ void CloudView::deactivate() { void CloudView::addToConfig(JsonObject &subtree) { subtree[FPSTR(CFG_SEG_ID)] = segId_; subtree[FPSTR(CFG_RAIN_MAX)] = precipMaxInHr_; + subtree[FPSTR(CFG_WAVE_HALF_PX)] = waveHalfCyclePx_; } void CloudView::appendConfigData(Print &s) { @@ -236,6 +274,14 @@ void CloudView::appendConfigData(Print &s) { s.print(F("addInfo('SkyStrip:CloudView:SegmentId',1,''," "' (-1 disables)'" ");")); + s.print(F("addInfo('SkyStrip:CloudView:CloudWaveHalfPx',1,'',")); + char waveHint[80]; + snprintf( + waveHint, sizeof(waveHint), + "' (half-cycle px; default %.1f)'", + double(CloudView::DEFAULT_WAVE_HALF_PX)); + s.print(waveHint); + s.print(F(");")); } bool CloudView::readFromConfig(JsonObject &subtree, bool startup_complete, @@ -245,5 +291,7 @@ bool CloudView::readFromConfig(JsonObject &subtree, bool startup_complete, getJsonValue(subtree[FPSTR(CFG_SEG_ID)], segId_, DEFAULT_SEG_ID); configComplete &= getJsonValue(subtree[FPSTR(CFG_RAIN_MAX)], precipMaxInHr_, CloudView::DEFAULT_RAIN_MAX_INPH); + configComplete &= + getJsonValue(subtree[FPSTR(CFG_WAVE_HALF_PX)], waveHalfCyclePx_, CloudView::DEFAULT_WAVE_HALF_PX); return configComplete; } diff --git a/usermods/usermod_v2_skystrip/cloud_view.h b/usermods/usermod_v2_skystrip/cloud_view.h index a726836af2..9eefe729a6 100644 --- a/usermods/usermod_v2_skystrip/cloud_view.h +++ b/usermods/usermod_v2_skystrip/cloud_view.h @@ -25,9 +25,11 @@ class CloudView : public IDataViewT { private: static constexpr float DEFAULT_RAIN_MAX_INPH = 1.0f; + static constexpr float DEFAULT_WAVE_HALF_PX = 2.2f; int16_t segId_; float precipMaxInHr_; + float waveHalfCyclePx_; 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 eae3fbf771..ca9a10e72f 100644 --- a/usermods/usermod_v2_skystrip/delta_view.cpp +++ b/usermods/usermod_v2_skystrip/delta_view.cpp @@ -1,5 +1,7 @@ #include #include +#include +#include #include "delta_view.h" #include "skymodel.h" @@ -8,53 +10,121 @@ static constexpr int16_t DEFAULT_SEG_ID = -1; // -1 means disabled const char CFG_SEG_ID[] PROGMEM = "SegmentId"; +const char CFG_THRESHOLDS[] PROGMEM = "DeltaThresholds"; + +static constexpr float kDefaultThresholds[3] = {5.f, 10.f, 15.f}; +static constexpr const char kDefaultThresholdStr[] = "5:10:15"; +static constexpr float kMinThresholdF = 0.5f; +static constexpr float kMaxThresholdF = 60.0f; + +static constexpr float kDeltaSat = 0.95f; +static constexpr float kValModerate = 0.35f; +static constexpr float kValStrong = 0.65f; +static constexpr float kValBright = 1.0f; 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]; +static bool parseThresholdString(const char *in, float (&out)[3], + String &normalized) { + if (!in) + return false; + + char buf[64]; + strncpy(buf, in, sizeof(buf)); + buf[sizeof(buf) - 1] = '\0'; + + float vals[5]; + size_t count = 0; + char *saveptr; + for (char *tok = strtok_r(buf, ":, \t\r\n", &saveptr); + tok && count < sizeof(vals) / sizeof(vals[0]); + tok = strtok_r(nullptr, ":, \t\r\n", &saveptr)) { + if (*tok == '\0') + continue; + float v = atof(tok); + if (!std::isfinite(v)) + return false; + vals[count++] = v; + } + + if (count < 3) + return false; + + if (vals[0] < kMinThresholdF || vals[0] > kMaxThresholdF) + return false; + for (size_t i = 1; i < count; ++i) { + if (vals[i] <= vals[i - 1]) + return false; + if (vals[i] < kMinThresholdF || vals[i] > kMaxThresholdF) + return false; + } + + normalized = ""; + for (size_t i = 0; i < 3; ++i) { + out[i] = vals[i]; + if (i > 0) + normalized += ':'; + float rounded = std::round(vals[i]); + if (std::fabs(vals[i] - rounded) < 0.01f) + normalized += String((int)rounded); + else + normalized += String(vals[i], 1); + } + return true; +} + +static void buildHueStops(const float (&thr)[3], Stop (&stops)[7]) { + stops[0] = {-thr[2], 270.f}; // purple + stops[1] = {-thr[1], 240.f}; // indigo/blue-purple + stops[2] = {-thr[0], 200.f}; // cyan-blue + stops[3] = {0.0, 120.f}; // green (unused because < threshold) + stops[4] = {thr[0], 60.f}; // yellow + stops[5] = {thr[1], 30.f}; // orange + stops[6] = {thr[2], 0.f}; // red +} + +static float hueForDeltaF(double f, const Stop (&stops)[7]) { + if (f <= stops[0].f) + return stops[0].h; + for (size_t i = 1; i < 7; ++i) { + if (f <= stops[i].f) { + const auto &A = stops[i - 1]; + const auto &B = stops[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; + return stops[6].h; } -static inline float satFromDewDiffDelta(float delta) { - 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; -} +static inline float valueForDelta(double deltaF, const float (&thr)[3]) { + float mag = float(std::fabs(deltaF)); + const float t1 = thr[0]; + const float t2 = thr[1]; + const float t3 = thr[2]; -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.6; + if (mag < t1) + return 0.f; + if (mag < t2) + return kValModerate; + if (mag < t3) { + float u = (mag - t2) / (t3 - t2); + return float(skystrip::util::lerp(kValModerate, kValStrong, u)); + } + float over = t3 - t2; + if (over < 1.f) + over = 1.f; + float u = (mag - t3) / over; + u = skystrip::util::clamp01(u); + return float(skystrip::util::lerp(kValStrong, kValBright, u)); } DeltaView::DeltaView() : segId_(DEFAULT_SEG_ID) { DEBUG_PRINTLN("SkyStrip: DV::CTOR"); + resetThresholdsToDefault(); snprintf(debugPixelString, sizeof(debugPixelString), "%s:\\n", name().c_str()); debugPixelString[sizeof(debugPixelString) - 1] = '\0'; @@ -92,6 +162,8 @@ void DeltaView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) { constexpr double kHorizonSec = 48.0 * 3600.0; const double step = (len > 1) ? (kHorizonSec / double(len - 1)) : 0.0; const time_t day = 24 * 3600; + Stop hueStops[7]; + buildHueStops(thresholdsF_, hueStops); for (int i = 0; i < len; ++i) { const time_t t = now + time_t(std::llround(step * i)); @@ -125,20 +197,10 @@ void DeltaView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) { } 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); + float hue = hueForDeltaF(deltaT, hueStops); + float val = valueForDelta(deltaT, thresholdsF_); + uint32_t col = + (val > 0.f) ? skystrip::util::hsv2rgb(hue, kDeltaSat, val) : 0; if (dbgPixelIndex >= 0) { static time_t lastDebug = 0; @@ -151,10 +213,9 @@ void DeltaView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) { 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); + "dT=%.1f H=%.0f S=%.0f V=%.0f thr=%s\\n", + name().c_str(), nowbuf, i, dbgbuf, prvbuf, deltaT, + hue, kDeltaSat * 100, val * 100, thresholdsStr_.c_str()); lastDebug = now; } } @@ -169,6 +230,7 @@ void DeltaView::deactivate() { void DeltaView::addToConfig(JsonObject &subtree) { subtree[FPSTR(CFG_SEG_ID)] = segId_; + subtree[FPSTR(CFG_THRESHOLDS)] = thresholdsStr_; } void DeltaView::appendConfigData(Print &s) { @@ -176,6 +238,9 @@ void DeltaView::appendConfigData(Print &s) { s.print(F("addInfo('SkyStrip:DeltaView:SegmentId',1,''," "' (-1 disables)'" ");")); + s.print(F("addInfo('SkyStrip:DeltaView:DeltaThresholds',1,''," + "' (format: 5:10:15 = off/mod/strong/shout)'" + ");")); } bool DeltaView::readFromConfig(JsonObject &subtree, bool startup_complete, @@ -183,5 +248,31 @@ bool DeltaView::readFromConfig(JsonObject &subtree, bool startup_complete, bool configComplete = !subtree.isNull(); configComplete &= getJsonValue(subtree[FPSTR(CFG_SEG_ID)], segId_, DEFAULT_SEG_ID); + if (!subtree[FPSTR(CFG_THRESHOLDS)].isNull()) { + const char *cfg = subtree[FPSTR(CFG_THRESHOLDS)]; + bool parsed = applyThresholdConfig(cfg); + configComplete &= parsed; + if (!parsed) + resetThresholdsToDefault(); + } else { + resetThresholdsToDefault(); + } return configComplete; } + +void DeltaView::resetThresholdsToDefault() { + thresholdsF_[0] = kDefaultThresholds[0]; + thresholdsF_[1] = kDefaultThresholds[1]; + thresholdsF_[2] = kDefaultThresholds[2]; + thresholdsStr_ = kDefaultThresholdStr; +} + +bool DeltaView::applyThresholdConfig(const char *cfg) { + float parsed[3]; + String normalized; + if (!parseThresholdString(cfg, parsed, normalized)) + return false; + memcpy(thresholdsF_, parsed, sizeof(thresholdsF_)); + thresholdsStr_ = normalized; + return true; +} diff --git a/usermods/usermod_v2_skystrip/delta_view.h b/usermods/usermod_v2_skystrip/delta_view.h index 92a8c8ec53..22168d3a2b 100644 --- a/usermods/usermod_v2_skystrip/delta_view.h +++ b/usermods/usermod_v2_skystrip/delta_view.h @@ -24,7 +24,12 @@ class DeltaView : public IDataViewT { const char* configKey() const override { return "DeltaView"; } private: + void resetThresholdsToDefault(); + bool applyThresholdConfig(const char* cfg); + int16_t segId_; char debugPixelString[256]; skystrip::util::SegmentFreezeHandle freezeHandle_; + float thresholdsF_[3]; + String thresholdsStr_; }; diff --git a/usermods/usermod_v2_skystrip/readme.md b/usermods/usermod_v2_skystrip/readme.md deleted file mode 100644 index 57e4e7c500..0000000000 --- a/usermods/usermod_v2_skystrip/readme.md +++ /dev/null @@ -1,45 +0,0 @@ -# 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 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. 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. - -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 -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). -- 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 diff --git a/usermods/usermod_v2_skystrip/temperature_view.cpp b/usermods/usermod_v2_skystrip/temperature_view.cpp index 73c41028a3..206b13e5bc 100644 --- a/usermods/usermod_v2_skystrip/temperature_view.cpp +++ b/usermods/usermod_v2_skystrip/temperature_view.cpp @@ -3,7 +3,12 @@ #include "util.h" #include "wled.h" // Segment, strip, RGBW32 #include +#include #include +#include +#include +#include +#include static constexpr int16_t DEFAULT_SEG_ID = -1; // -1 means disabled @@ -11,6 +16,158 @@ static constexpr int16_t DEFAULT_SEG_ID = -1; // -1 means disabled // - they are scoped to this module, don't need to be globally unique // const char CFG_SEG_ID[] PROGMEM = "SegmentId"; +const char CFG_COLOR_MAP[] PROGMEM = "ColorMap"; +const char CFG_MOVE_OFFSET[] PROGMEM = "MoveOffset"; + +// Default map: 15°F centers with short wraps at the ends. +static constexpr const char kDefaultColorMapStr[] = + "-45:yellow|-30:orange|-15:red|0:magenta|15:purple|30:blue|45:cyan|" + "60:green|75:yellow|90:orange|105:red|120:magenta|135:purple|150:blue"; + +static constexpr int16_t kCenterMinF = -200; +static constexpr int16_t kCenterMaxF = 200; +static constexpr int16_t kMaxOffsetF = 400; +static constexpr size_t kMaxColorMapLen = 512; +static constexpr size_t kMaxParsedEntries = 64; +static constexpr float kValue = 0.5f; + +struct NamedColor { + const char *name; + float hue; +}; + +static const NamedColor kNamedColors[] = { + {"magenta", 300.f}, + {"purple", 275.f}, + {"blue", 220.f}, + {"cyan", 185.f}, + {"green", 130.f}, + {"yellow", 60.f}, + {"orange", 30.f}, + {"red", 0.f}, +}; + +static inline bool hueToNamedToken(float hue, String &out) { + for (const auto &nc : kNamedColors) { + if (fabsf(hue - nc.hue) < 0.5f) { + out = nc.name; + return true; + } + } + return false; +} + +static char *trimInPlace(char *s) { + while (*s && std::isspace((unsigned char)*s)) + ++s; + char *end = s + strlen(s); + while (end > s && std::isspace((unsigned char)*(end - 1))) + --end; + *end = '\0'; + return s; +} + +static bool parseHueToken(const char *tok, float &hue, String &normToken) { + if (!tok) + return false; + + for (const auto &nc : kNamedColors) { + if (strcasecmp(tok, nc.name) == 0) { + hue = nc.hue; + normToken = nc.name; + return true; + } + } + + char *endptr = nullptr; + long deg = strtol(tok, &endptr, 10); + if (!endptr || *endptr != '\0') + return false; + float h = fmodf((float)deg, 360.f); + if (h < 0.f) + h += 360.f; + hue = h; + normToken = String((int)std::lround(h)); + return true; +} + +static bool parseColorMap(const char *cfg, std::vector &out, + String &normalized) { + if (!cfg || *cfg == '\0') + return false; + + char buf[kMaxColorMapLen]; + strncpy(buf, cfg, sizeof(buf)); + buf[sizeof(buf) - 1] = '\0'; + + struct Tmp { + int16_t center; + float hue; + String token; + }; + std::vector tmp; + tmp.reserve(16); + + char *saveptr; + for (char *entry = strtok_r(buf, "|", &saveptr); entry; + entry = strtok_r(nullptr, "|", &saveptr)) { + if (tmp.size() >= kMaxParsedEntries) + return false; // protect memory from junk input + char *colon = strchr(entry, ':'); + if (!colon) + return false; + *colon = '\0'; + char *centerStr = trimInPlace(entry); + char *colorStr = trimInPlace(colon + 1); + if (!*centerStr || !*colorStr) + return false; + + char *endptr = nullptr; + long center = strtol(centerStr, &endptr, 10); + if (!endptr || *endptr != '\0') + return false; + if (center < kCenterMinF || center > kCenterMaxF) + return false; + + float hue; + String norm; + if (!parseHueToken(colorStr, hue, norm)) + return false; + + Tmp t; + t.center = (int16_t)center; + t.hue = hue; + t.token = norm; + tmp.push_back(t); + } + + if (tmp.size() < 2) + return false; + + std::sort(tmp.begin(), tmp.end(), + [](const Tmp &a, const Tmp &b) { return a.center < b.center; }); + + for (size_t i = 1; i < tmp.size(); ++i) { + if (tmp[i].center == tmp[i - 1].center) + return false; + } + + normalized = ""; + out.clear(); + out.reserve(tmp.size()); + for (size_t i = 0; i < tmp.size(); ++i) { + TemperatureView::ColorStop cs; + cs.centerF = tmp[i].center; + cs.hue = tmp[i].hue; + out.push_back(cs); + if (i > 0) + normalized += '|'; + normalized += String(tmp[i].center); + normalized += ':'; + normalized += tmp[i].token; + } + return true; +} // Map dew-point depression (°F) -> saturation multiplier. // dd<=2°F -> minSat ; dd>=25°F -> 1.0 ; smooth in between. @@ -25,38 +182,16 @@ static inline float satFromDewSpreadF(float tempF, float dewF) { 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; +static void fmtColorHex(uint32_t col, char *out, size_t n) { + uint8_t r = (col >> 16) & 0xFF; + uint8_t g = (col >> 8) & 0xFF; + uint8_t b = (col)&0xFF; + snprintf(out, n, "#%02X%02X%02X", r, g, b); } TemperatureView::TemperatureView() : segId_(DEFAULT_SEG_ID) { DEBUG_PRINTLN("SkyStrip: TV::CTOR"); + resetColorMapToDefault(); snprintf(debugPixelString, sizeof(debugPixelString), "%s:\\n", name().c_str()); debugPixelString[sizeof(debugPixelString) - 1] = '\0'; @@ -97,6 +232,60 @@ void TemperatureView::view(time_t now, SkyModel const &model, constexpr time_t DAY = 24 * 60 * 60; const long tzOffset = skystrip::util::current_offset(); + std::vector temps(len, NAN); + std::vector hues(len, 0.f); + std::vector sats(len, 1.f); + std::vector valid(len, 0); + + // Precompute temps/hues/sats so we can detect crossings. + for (int i = 0; i < len; ++i) { + const time_t t = now + time_t(std::llround(step * i)); + double tempF = 0.f; + double dewF = 0.f; + if (skystrip::util::estimateTempAt(model, t, step, tempF)) { + float hue = hueForTempF(tempF); + float sat = 1.0f; + if (skystrip::util::estimateDewPtAt(model, t, step, dewF)) { + sat = satFromDewSpreadF((float)tempF, (float)dewF); + } + temps[i] = tempF; + hues[i] = hue; + sats[i] = sat; + valid[i] = 1; + } + } + + // Mark crossings at 5°F (low boost) and 10°F (full boost) boundaries. + std::vector crossingLevel(len, 0); // 0=none,1=5°F,2=10°F + auto markCrossings = [&](double stepDeg, uint8_t level, bool dualHighlight) { + for (int i = 0; i + 1 < len; ++i) { + if (!valid[i] || !valid[i + 1]) + continue; + double a = temps[i]; + double b = temps[i + 1]; + if (a == b) + continue; + double lo = std::min(a, b); + double hi = std::max(a, b); + double boundary = std::ceil(lo / stepDeg) * stepDeg; + if (boundary > hi) + continue; + double da = fabs(a - boundary); + double db = fabs(b - boundary); + int idx = (da <= db) ? i : (i + 1); + if (crossingLevel[idx] < level) + crossingLevel[idx] = level; + if (dualHighlight) { + if (crossingLevel[i] < level) + crossingLevel[i] = level; + if (crossingLevel[i + 1] < level) + crossingLevel[i + 1] = level; + } + } + }; + markCrossings(5.0, 1, false); + markCrossings(10.0, 2, true); + // 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. @@ -139,24 +328,28 @@ void TemperatureView::view(time_t now, SkyModel const &model, for (int i = 0; i < len; ++i) { const time_t t = now + time_t(std::llround(step * i)); - double tempF = 0.f; - double dewF = 0.f; - float hue = 0.f; - float sat = 1.0f; - constexpr float val = 0.5f; + double tempF = temps[i]; + float hue = hues[i]; + float sat = sats[i]; 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); + if (valid[i]) { + float val = kValue; + if (crossingLevel[i] == 2) { + sat = 1.0f; + val = 0.9f; // strong boost for 10°F crossings + } else if (crossingLevel[i] == 1) { + sat = 1.0f; + val = 0.9f; // lighter boost for 5°F crossings } 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 (crossingLevel[i] == 0) { + 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) { @@ -166,12 +359,13 @@ void TemperatureView::view(time_t now, SkyModel const &model, skystrip::util::fmt_local(nowbuf, sizeof(nowbuf), now); char dbgbuf[20]; skystrip::util::fmt_local(dbgbuf, sizeof(dbgbuf), t); + char colbuf[10]; + fmtColorHex(col, colbuf, sizeof(colbuf)); 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); + "tempF=%.1f hue=%.0f sat=%.0f crossing=%d col=%s\\n", + name().c_str(), nowbuf, i, dbgbuf, tempF, hue, sat * 100, + crossingLevel[i], colbuf); lastDebug = now; } } @@ -185,7 +379,12 @@ void TemperatureView::deactivate() { } void TemperatureView::addToConfig(JsonObject &subtree) { + if (colorMapStr_.isEmpty() || stops_.empty()) { + resetColorMapToDefault(); + } subtree[FPSTR(CFG_SEG_ID)] = segId_; + subtree[FPSTR(CFG_COLOR_MAP)] = colorMapStr_; + subtree[FPSTR(CFG_MOVE_OFFSET)] = ""; // consumed on apply } void TemperatureView::appendConfigData(Print &s) { @@ -193,6 +392,12 @@ void TemperatureView::appendConfigData(Print &s) { s.print(F("addInfo('SkyStrip:TemperatureView:SegmentId',1,''," "' (-1 disables)'" ");")); + s.print(F("addInfo('SkyStrip:TemperatureView:ColorMap',1,''," + "' (center:hue|center:hue  | hues: 0-359 or names: magenta/purple/blue/cyan/green/yellow/orange/red)'" + ");")); + s.print(F("addInfo('SkyStrip:TemperatureView:MoveOffset',1,''," + "' (optional; enter +/- degrees F to shift all centers, applied on save)'" + ");")); } bool TemperatureView::readFromConfig(JsonObject &subtree, bool startup_complete, @@ -200,5 +405,108 @@ bool TemperatureView::readFromConfig(JsonObject &subtree, bool startup_complete, bool configComplete = !subtree.isNull(); configComplete &= getJsonValue(subtree[FPSTR(CFG_SEG_ID)], segId_, DEFAULT_SEG_ID); + int16_t moveOffset = 0; + bool hasMoveOffset = false; + + if (!subtree[FPSTR(CFG_COLOR_MAP)].isNull()) { + const char *cfg = subtree[FPSTR(CFG_COLOR_MAP)]; + bool parsed = applyColorMapConfig(cfg); + configComplete &= parsed; + if (!parsed) + resetColorMapToDefault(); + } else { + resetColorMapToDefault(); + } + + if (!subtree[FPSTR(CFG_MOVE_OFFSET)].isNull()) { + const char *ofs = subtree[FPSTR(CFG_MOVE_OFFSET)]; + if (ofs && *ofs) { + char *endptr = nullptr; + long v = strtol(ofs, &endptr, 10); + if (endptr && *endptr == '\0' && v >= -kMaxOffsetF && v <= kMaxOffsetF) { + moveOffset = (int16_t)v; + hasMoveOffset = true; + } + } + } + + if (hasMoveOffset && !stops_.empty()) { + applyOffsetToStops(moveOffset); + } + return configComplete; } + +void TemperatureView::resetColorMapToDefault() { + if (!applyColorMapConfig(kDefaultColorMapStr)) { + stops_.clear(); + colorMapStr_.clear(); + } +} + +bool TemperatureView::applyColorMapConfig(const char *cfg) { + String normalized; + std::vector parsed; + if (!parseColorMap(cfg, parsed, normalized)) + return false; + stops_.swap(parsed); + colorMapStr_ = normalized; + return true; +} + +float TemperatureView::hueForTempF(double f) const { + if (stops_.empty()) + return 0.f; + if (stops_.size() == 1) + return stops_[0].hue; + + if (f <= stops_.front().centerF) + return stops_.front().hue; + if (f >= stops_.back().centerF) + return stops_.back().hue; + + for (size_t i = 0; i + 1 < stops_.size(); ++i) { + const auto &a = stops_[i]; + const auto &b = stops_[i + 1]; + if (f <= b.centerF) { + double span = double(b.centerF - a.centerF); + double u = (span > 0.0) ? skystrip::util::clamp01((f - a.centerF) / span) : 0.0; + // shortest-path hue interpolation on the circle + float dh = b.hue - a.hue; + dh = fmodf(dh + 540.f, 360.f) - 180.f; + float h = a.hue + float(u) * dh; + h = fmodf(h, 360.f); + if (h < 0.f) + h += 360.f; + return h; + } + } + return stops_.back().hue; +} + +void TemperatureView::applyOffsetToStops(int16_t deltaF) { + if (deltaF == 0 || stops_.empty()) + return; + + String rebuilt; + rebuilt.reserve(colorMapStr_.length() + 16); + + for (size_t i = 0; i < stops_.size(); ++i) { + int32_t shifted = int32_t(stops_[i].centerF) + int32_t(deltaF); + if (shifted < kCenterMinF) + shifted = kCenterMinF; + if (shifted > kCenterMaxF) + shifted = kCenterMaxF; + stops_[i].centerF = (int16_t)shifted; + if (i > 0) + rebuilt += '|'; + rebuilt += String(stops_[i].centerF); + rebuilt += ':'; + String hueTok; + if (!hueToNamedToken(stops_[i].hue, hueTok)) { + hueTok = String((int)std::lround(stops_[i].hue)); + } + rebuilt += hueTok; + } + colorMapStr_ = rebuilt; +} diff --git a/usermods/usermod_v2_skystrip/temperature_view.h b/usermods/usermod_v2_skystrip/temperature_view.h index eed84eae00..be66370d60 100644 --- a/usermods/usermod_v2_skystrip/temperature_view.h +++ b/usermods/usermod_v2_skystrip/temperature_view.h @@ -3,6 +3,7 @@ #include "interfaces.h" #include "skymodel.h" #include "util.h" +#include class SkyModel; @@ -11,6 +12,11 @@ class TemperatureView : public IDataViewT { TemperatureView(); ~TemperatureView() override = default; + struct ColorStop { + int16_t centerF; + float hue; + }; + // IDataViewT void view(time_t now, SkyModel const & model, int16_t dbgPixelIndex) override; std::string name() const override { return "TV"; } @@ -26,6 +32,13 @@ class TemperatureView : public IDataViewT { const char* configKey() const override { return "TemperatureView"; } private: + void resetColorMapToDefault(); + bool applyColorMapConfig(const char *cfg); + float hueForTempF(double f) const; + void applyOffsetToStops(int16_t deltaF); + + std::vector stops_; + String colorMapStr_; int16_t segId_; // -1 means disabled char debugPixelString[128]; skystrip::util::SegmentFreezeHandle freezeHandle_; From 1c27bb75e005d506b2b45009988b7f6e933ac5a2 Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Mon, 8 Dec 2025 15:36:22 -0800 Subject: [PATCH 24/27] skystrip: fix location update reload bug --- .../open_weather_map_source.cpp | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/usermods/usermod_v2_skystrip/open_weather_map_source.cpp b/usermods/usermod_v2_skystrip/open_weather_map_source.cpp index de482638bf..94948c10d8 100644 --- a/usermods/usermod_v2_skystrip/open_weather_map_source.cpp +++ b/usermods/usermod_v2_skystrip/open_weather_map_source.cpp @@ -225,24 +225,34 @@ bool OpenWeatherMapSource::readFromConfig(JsonObject &subtree, 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 (!nearlyEqualCoord(latitude_, oldLatitude) || !nearlyEqualCoord(longitude_, oldLongitude)) - location_ = ""; + const bool locationFieldChanged = location_ != lastLocation_; + const bool coordsChangedDirect = + !nearlyEqualCoord(latitude_, oldLatitude) || !nearlyEqualCoord(longitude_, oldLongitude); + + // If the location string did not change but lat/lon did, treat it as manual lat/lon edit. + if (!locationFieldChanged && coordsChangedDirect) { + location_.clear(); + lastLocation_.clear(); } else { - lastLocation_ = location_; - if (location_.length() > 0) { + // Otherwise, try to derive coordinates from the location string. + if (!location_.empty()) { double lat = 0, lon = 0; if (parseLatLon(location_.c_str(), lat, lon)) { latitude_ = lat; longitude_ = lon; + lastLocation_ = location_; } else if (running) { int matches = 0; bool ok = geocodeOWM(location_, lat, lon, &matches); - latitude_ = ok ? lat : 0.0; - longitude_ = ok ? lon : 0.0; + if (ok) { + latitude_ = lat; + longitude_ = lon; + lastLocation_ = location_; + } } + } else if (locationFieldChanged) { + // User cleared the location field; ensure we treat future entries as new. + lastLocation_.clear(); } } @@ -251,6 +261,7 @@ bool OpenWeatherMapSource::readFromConfig(JsonObject &subtree, DEBUG_PRINTF("SkyStrip::OWM::readFromConfig lat/long changed" " oldLat=%f, newLat=%f, oldLng=%f, newLng=%f\n", oldLatitude, latitude_, oldLongitude, longitude_); + resetRateLimit(); // allow immediate fetch after a location change (even if history just ran) invalidate_history = true; } @@ -270,11 +281,6 @@ 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 - // Fetch JSON char url[256]; composeApiUrl(url, sizeof(url)); @@ -296,6 +302,10 @@ std::unique_ptr OpenWeatherMapSource::fetch(std::time_t now) { return nullptr; } + // Stamp fetch times only once we have usable data + lastFetch_ = now; + lastHistFetch_ = now; // history fetches should wait + time_t sunrise = 0; time_t sunset = 0; if (root.containsKey("current")) { From 9f6d88cd179edf288c8e8d9efd8148fa01eb9f26 Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Mon, 8 Dec 2025 16:01:22 -0800 Subject: [PATCH 25/27] skystrip: conditional-compile remove the 10 second safety This no longer needed, I think the base WLED does a safety revert --- usermods/usermod_v2_skystrip/usermod_v2_skystrip.cpp | 11 +++++++++++ usermods/usermod_v2_skystrip/usermod_v2_skystrip.h | 5 +++++ 2 files changed, 16 insertions(+) diff --git a/usermods/usermod_v2_skystrip/usermod_v2_skystrip.cpp b/usermods/usermod_v2_skystrip/usermod_v2_skystrip.cpp index 4ee578c6f2..815d8bda93 100644 --- a/usermods/usermod_v2_skystrip/usermod_v2_skystrip.cpp +++ b/usermods/usermod_v2_skystrip/usermod_v2_skystrip.cpp @@ -26,7 +26,9 @@ REGISTER_USERMOD(skystrip_usermod); // 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. +#ifdef SKYSTRIP_ENABLE_SAFETY_DELAY const uint32_t SAFETY_DELAY_MS = 10u * 1000u; +#endif // runs before readFromConfig() and setup() SkyStrip::SkyStrip() { @@ -51,8 +53,10 @@ void SkyStrip::setup() { DEBUG_PRINTLN(F("SkyStrip::setup starting")); +#ifdef SKYSTRIP_ENABLE_SAFETY_DELAY uint32_t now_ms = millis(); safeToStart_ = now_ms + SAFETY_DELAY_MS; +#endif // Serial.begin(115200); @@ -81,6 +85,7 @@ void SkyStrip::loop() { // defer a short bit after reboot if (state_ == SkyStripState::Setup) { +#ifdef SKYSTRIP_ENABLE_SAFETY_DELAY if (now_ms < safeToStart_) { return; } else { @@ -89,6 +94,12 @@ void SkyStrip::loop() { doneBooting(); reloadSources(now); // load right away } +#else + DEBUG_PRINTLN(F("SkyStrip::loop SkyStripState is Running")); + state_ = SkyStripState::Running; + doneBooting(); + reloadSources(now); // load right away +#endif } // detect OFF->ON and disabled->enabled edges diff --git a/usermods/usermod_v2_skystrip/usermod_v2_skystrip.h b/usermods/usermod_v2_skystrip/usermod_v2_skystrip.h index f68624eea1..3b59d06850 100644 --- a/usermods/usermod_v2_skystrip/usermod_v2_skystrip.h +++ b/usermods/usermod_v2_skystrip/usermod_v2_skystrip.h @@ -12,6 +12,9 @@ #define SKYSTRIP_GIT_DESCRIBE SKYSTRIP_VERSION_FALLBACK #endif +// Uncomment to restore the 10s startup safety delay that defers network calls. +// #define SKYSTRIP_ENABLE_SAFETY_DELAY + #define SKYSTRIP_VERSION SKYSTRIP_GIT_DESCRIBE class SkyModel; @@ -27,7 +30,9 @@ class SkyStrip : public Usermod { bool enabled_ = false; int16_t dbgPixelIndex_ = -1; // if >=0 show periodic debugging for that pixel SkyStripState state_ = SkyStripState::Initial; +#ifdef SKYSTRIP_ENABLE_SAFETY_DELAY uint32_t safeToStart_ = 0; +#endif uint32_t lastLoop_ = 0; bool edgeInit_ = false; bool lastOff_ = false; From b52179338709453549fbdb220ca3c9c178d9245e Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Sun, 14 Dec 2025 18:12:27 -0800 Subject: [PATCH 26/27] skystrip: improve the FAQ --- usermods/usermod_v2_skystrip/FAQ.md | 210 +++++++++++++++++----------- 1 file changed, 128 insertions(+), 82 deletions(-) diff --git a/usermods/usermod_v2_skystrip/FAQ.md b/usermods/usermod_v2_skystrip/FAQ.md index 3e1d97e0a8..32019f56c1 100644 --- a/usermods/usermod_v2_skystrip/FAQ.md +++ b/usermods/usermod_v2_skystrip/FAQ.md @@ -1,105 +1,151 @@ # 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. - +This FAQ explains how to interpret the SkyStrip display. +There are 4 strips, from top to bottom: +- Cloud View +- Wind View +- Temperature View +- 24 Hour Delta View ## 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. Cloud coverage now dithers along forecast time using a -triangle-wave mask: the central band of each wave lights up, and its -width matches the cloud fraction. Sparse clouds show as occasional -dots, roughly half cover alternates on/off bands, and 100% cover stays -solid. Adjust band spacing with the `CloudWaveHalfPx` setting -(half-cycle in pixels; default 2.2). Thus, a bright blue pixel highlights -likely rain, whereas a striped soft yellow glow marks daytime cloud -cover. +Displays the next 48 hour forecast of clouds and rain, with sunrise/sunset markers. + +### Features +- **Sunrise/sunset markers** appear as single orange pixels at time of sunrise/sunset. + +- **Day/night clouds** pale yellow in daylight and turn darker at night. + +- **Cloud coverage** from sparse to full. + +- **Precipitation** rain is blue, snow is purple. Pastel shades + represent less chance of precipitation, bold colors indicate it's + likely. Brightness indicates forecast accumulation. + +### Config notes +- `RainMaxInHr`: sets the precipitation rate that maps to full + brightness (default 1.0 in/hr), so lower values make lighter rates pop + brighter. +- `CloudWaveHalfPx`: half-cycle length of the dithering wave in pixels + (default 2.2); lower values make the on/off cloud bands tighter. ## 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 | +Displays the next 48 hour forecast of wind direction and velocity. + +### Features +- **Color shows where the wind is coming from.** Hue walks around the + compass so you can read direction at a glance (e.g., blue for north, + orange for east, yellow for south, green for west, with smooth + blends in between). + +- **Brightness tracks how windy it feels.** Value uses the stronger of + sustained speed or gusts, dimming calm periods and pushing toward + full brightness as winds climb toward 50 mph. + +- **Saturation highlights gustiness.** Colors stay pastel when gusts + match the steady wind, then grow more vivid as gusts pull ahead, + making choppy conditions easy to spot. + +| Direction | Color | Hue (°) | +|-----------|--------|---------| +| N | Blue | 240 | +| NE | Purple | 300 | +| E | Orange | 30 | +| SE | Gold | 45 | +| S | Yellow | 60 | +| SW | Lime | 90 | +| W | Green | 120 | +| NW | Cyan | 180 | +| N | Blue | 240 | Note: Hues wrap at 360°, so “N” repeats at the boundary. ## Temperature View (TV) -Hue comes from a configurable `ColorMap` string of `center:hue` pairs -separated by `|` (hue is 0–359 degrees or a name: `magenta, purple, blue, cyan, green, yellow, orange, red`). Saturation still tracks dew‑point spread (muggy = desaturated, dry = vivid), value is fixed mid‑brightness, time markers dim pixels briefly at 3‑hour intervals, and hue is linearly interpolated between centers. - -Default 15 °F rotation (with short wraps at the ends): `-45:yellow|-30:orange|-15:red|0:magenta|15:purple|30:blue|45:cyan|60:green|75:yellow|90:orange|105:red|120:magenta|135:purple|150:blue`. The palette wraps instead of clamping at extremes. +Displays the next 48 hours forecast of temperature and humidity. -Primary rotation reference: +### Features +- **Hue traces the temperature.** Colors shift through the palette as the + forecast warms or cools, so you can read temperature at a glance. +- **Degree crossings are marked.** Single ticks mark each 5 °F step, and + double ticks mark the 10 °F steps to anchor the scale. +- **Time notches keep you oriented.** Small notches appear every three + hours, with larger ones at noon and midnight so you can line up events + to the clock. +- **Saturation reflects humidity.** Colors wash out when it’s muggy and + stay vivid when the air is dry. -| Center (°F) | Hue name | Hue (°) | +| Center (°F) | Color | Hue (°) | |-------------|----------|---------| -| 120 | magenta | 300 | -| 105 | red | 0 | -| 90 | orange | 30 | -| 75 | yellow | 60 | -| 60 | green | 130 | -| 45 | cyan | 185 | -| 30 | blue | 220 | -| 15 | purple | 275 | -| 0 | magenta | 300 | +| -45 | yellow | 60 | +| -30 | orange | 30 | | -15 | red | 0 | +| 0 | magenta | 300 | +| 15 | purple | 275 | +| 30 | blue | 220 | +| 45 | cyan | 185 | +| 60 | green | 130 | +| 75 | yellow | 60 | +| 90 | orange | 30 | +| 105 | red | 0 | +| 120 | magenta | 300 | +| 135 | purple | 275 | +| 150 | blue | 220 | + +Colors wrap outside the 0–120 °F range—it’s “so cold it’s hot” or “so hot +it’s cold.” + +### Config notes +- `ColorMap`: pipe-separated `center:hue` pairs set the palette (hues can be + numbers or names); defaults to 15 °F steps using the table above. +- `MoveOffset`: optional integer shift, applied on save, that moves every + color stop by the same °F amount without editing each entry. ## 24-Hour Delta View (DV) -Shows how much warmer or colder it is compared to the same time -yesterday. Small changes stay dark so quiet days don’t flicker. -Humidity changes are ignored. - -Default thresholds are 5 / 10 / 15 °F (configurable with -`DeltaThresholds`). Colors run from cold on the left to warm on the -right, with “no change” in the middle: - -| Change vs 24h prior | Strip color | Brightness | -|----------------------|-------------|-------------| -| More than 15° colder | Purple | Very Strong | -| 10–15° colder | Indigo | Strong | -| 5–10° colder | Cyan-blue | Medium | -| Less than 5° change | Off (blank) | Off | -| 5–10° warmer | Yellow | Medium | -| 10–15° warmer | Orange | Strong | -| More than 15° warmer | Red | Very Strong | - - -## 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. +Shows how much warmer or colder it is forecast to be compared to the +same time 24 hours prior. + +Color indicates how much change is forecast: + +| Change vs 24h prior | Strip color | Brightness | +|---------------------|-------------|------------| +| Colder than -15° | Purple | Shout | +| -15° to -10° | Indigo | Strong | +| -10° to -5° | Cyan-blue | Moderate | +| -5° to +5° | Off (blank) | Off | +| +5° to +10° | Yellow | Moderate | +| +10° to +15° | Orange | Strong | +| Warmer than +15° | Red | Shout | + +### Config notes +- Default thresholds are 5 / 10 / 15 °F (configurable with + `DeltaThresholds`). + + +## Debug Pixel + +You can use the debug pixel to examine details of each view at a +particular point on the display. + +### Choose a pixel + +Set the **Pixel index** to the LED you want to inspect (0 is the left edge, +141 is the right), replacing the default `-1` disabled value. + +### Save and spot the blink + +Click **Save** to lock in the index. The selected pixel position blinks as +a vertical column across all strips until you turn **Debug pixel** back +off. This makes it easy to confirm you’re watching the right spot. + +### Read the per-view values + +Reopen the config screen to see the captured inputs and outputs for +that pixel in the **Debug pixel** box. Change the index and save again +any time you want a fresh sample. Set the **Pixel Index** back to +`-1` when you are done. From f61afa53e1d8bbb680a9f36ad5516e09a8e2ab46 Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Sun, 14 Dec 2025 18:46:29 -0800 Subject: [PATCH 27/27] skystrip: add explainer --- .../usermod_v2_skystrip/explainer/.gitignore | 2 + .../usermod_v2_skystrip/explainer/Makefile | 16 + .../usermod_v2_skystrip/explainer/README.md | 16 + .../usermod_v2_skystrip/explainer/build.py | 330 ++++++++++++++++++ .../explainer/template.html | 154 ++++++++ 5 files changed, 518 insertions(+) create mode 100644 usermods/usermod_v2_skystrip/explainer/.gitignore create mode 100644 usermods/usermod_v2_skystrip/explainer/Makefile create mode 100644 usermods/usermod_v2_skystrip/explainer/README.md create mode 100644 usermods/usermod_v2_skystrip/explainer/build.py create mode 100644 usermods/usermod_v2_skystrip/explainer/template.html diff --git a/usermods/usermod_v2_skystrip/explainer/.gitignore b/usermods/usermod_v2_skystrip/explainer/.gitignore new file mode 100644 index 0000000000..72f3978113 --- /dev/null +++ b/usermods/usermod_v2_skystrip/explainer/.gitignore @@ -0,0 +1,2 @@ +explainer.html + diff --git a/usermods/usermod_v2_skystrip/explainer/Makefile b/usermods/usermod_v2_skystrip/explainer/Makefile new file mode 100644 index 0000000000..227e79e7fc --- /dev/null +++ b/usermods/usermod_v2_skystrip/explainer/Makefile @@ -0,0 +1,16 @@ +HTML := explainer.html + +.PHONY: all clean open + +all: $(HTML) + +$(HTML): build.py template.html ../FAQ.md + @echo [skystrip] build explainer + python3 build.py + +open: $(HTML) + xdg-open $(HTML) || open $(HTML) + +clean: + rm -f $(HTML) + diff --git a/usermods/usermod_v2_skystrip/explainer/README.md b/usermods/usermod_v2_skystrip/explainer/README.md new file mode 100644 index 0000000000..7bb9d08428 --- /dev/null +++ b/usermods/usermod_v2_skystrip/explainer/README.md @@ -0,0 +1,16 @@ +# SkyStrip printable explainer + +This folder generates a print-friendly HTML explainer from the single +source of truth in `../FAQ.md`. + +## Build + +From the repo root: + +```sh +make -C usermods/usermod_v2_skystrip/explainer +``` + +Then open `usermods/usermod_v2_skystrip/explainer/explainer.html` and print +from your browser (or “Save as PDF”). + diff --git a/usermods/usermod_v2_skystrip/explainer/build.py b/usermods/usermod_v2_skystrip/explainer/build.py new file mode 100644 index 0000000000..0823330d23 --- /dev/null +++ b/usermods/usermod_v2_skystrip/explainer/build.py @@ -0,0 +1,330 @@ +#!/usr/bin/env python3 +""" +Build a print-friendly HTML explainer from the single source-of-truth FAQ.md. + +Usage: + python3 build.py + +Reads: + ../FAQ.md + template.html + +Writes: + explainer.html + +No external dependencies (stdlib only). This supports a small subset of +Markdown used by the SkyStrip FAQ: headings, paragraphs, unordered/ordered +lists, fenced code blocks, inline code/strong emphasis, and simple tables. +""" +from __future__ import annotations + +from dataclasses import dataclass +from html import escape +from pathlib import Path +import re + +ROOT = Path(__file__).resolve().parent +FAQ_MD = (ROOT / ".." / "FAQ.md").resolve() +TEMPLATE = ROOT / "template.html" +OUT = ROOT / "explainer.html" + +HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$") +UL_ITEM_RE = re.compile(r"^\s*[-*]\s+(.+?)\s*$") +OL_ITEM_RE = re.compile(r"^\s*(\d+)\.\s+(.+?)\s*$") +TABLE_SEPARATOR_RE = re.compile( + r"^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$" +) + +SPLIT_SECTIONS = { + "Wind View (WV)", + "Temperature View (TV)", + "24-Hour Delta View (DV)", + "24 Hour Delta View (DV)", +} + + +def render_inline(text: str) -> str: + s = escape(text) + s = re.sub(r"`([^`]+)`", r"\1", s) + s = re.sub(r"\*\*([^*]+)\*\*", r"\1", s) + return s + + +def parse_table_row(line: str) -> list[str]: + raw = line.strip() + if raw.startswith("|"): + raw = raw[1:] + if raw.endswith("|"): + raw = raw[:-1] + return [cell.strip() for cell in raw.split("|")] + + +def render_table(header: list[str], rows: list[list[str]]) -> str: + cols = max([len(header)] + [len(r) for r in rows]) if (header or rows) else 0 + header_cells = header + [""] * (cols - len(header)) + rendered_header = "\n".join(f"{render_inline(c)}" for c in header_cells) + + rendered_rows = [] + for row in rows: + row_cells = row + [""] * (cols - len(row)) + rendered_rows.append( + "" + + "".join(f"{render_inline(c)}" for c in row_cells) + + "" + ) + + return ( + "" + "" + + rendered_header + + "" + "" + + "\n".join(rendered_rows) + + "
" + ) + + +@dataclass(frozen=True) +class RenderResult: + title: str + html: str + + +@dataclass(frozen=True) +class Block: + kind: str + html: str + level: int | None = None + text: str | None = None + + +def blocks_to_html(blocks: list[Block]) -> str: + out: list[str] = [] + + preamble: list[str] = [] + current_h2: Block | None = None + section_blocks: list[Block] = [] + + def flush_section() -> None: + nonlocal current_h2, section_blocks, preamble + if current_h2 is None: + return + + title = current_h2.text or "" + body_html = "\n".join(b.html for b in section_blocks) + section_classes = ["section"] + if title: + slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") + section_classes.append(f"section-{slug}") + if title == "Wind View (WV)": + section_classes.append("page-break-after") + + if title in SPLIT_SECTIONS: + left = "\n".join(b.html for b in section_blocks if b.kind != "table") + right = "\n".join(b.html for b in section_blocks if b.kind == "table") + if right.strip(): + body_html = ( + '
' + f'
{left}
' + f'
{right}
' + "
" + ) + + out.append( + f'
{current_h2.html}{body_html}
' + ) + current_h2 = None + section_blocks = [] + + for b in blocks: + if b.kind == "heading" and b.level == 2: + if current_h2 is None: + out.extend(preamble) + preamble = [] + else: + flush_section() + current_h2 = b + continue + + if current_h2 is None: + preamble.append(b.html) + else: + section_blocks.append(b) + + if current_h2 is None: + out.extend(preamble) + else: + flush_section() + + return "\n".join(out) + + +def render_markdown(md_text: str) -> RenderResult: + lines = md_text.splitlines() + i = 0 + title: str | None = None + blocks: list[Block] = [] + + def is_table_start(idx: int) -> bool: + if idx + 1 >= len(lines): + return False + a = lines[idx] + b = lines[idx + 1] + return "|" in a and TABLE_SEPARATOR_RE.match(b.strip()) is not None + + while i < len(lines): + line = lines[i].rstrip("\n") + if not line.strip(): + i += 1 + continue + + if line.strip().startswith("```"): + i += 1 + buf = [] + while i < len(lines) and not lines[i].strip().startswith("```"): + buf.append(lines[i].rstrip("\n")) + i += 1 + if i < len(lines): + i += 1 + blocks.append( + Block( + kind="pre", + html=f"
{escape(chr(10).join(buf))}
", + ) + ) + continue + + m = HEADING_RE.match(line) + if m: + level = len(m.group(1)) + text = m.group(2).strip() + if level == 1 and title is None: + title = text + i += 1 + continue + blocks.append( + Block( + kind="heading", + level=level, + text=text, + html=f"{render_inline(text)}", + ) + ) + i += 1 + continue + + if is_table_start(i): + header = parse_table_row(lines[i]) + i += 2 + rows: list[list[str]] = [] + while i < len(lines) and lines[i].strip() and "|" in lines[i]: + if HEADING_RE.match(lines[i]) or UL_ITEM_RE.match(lines[i]) or OL_ITEM_RE.match(lines[i]): + break + rows.append(parse_table_row(lines[i])) + i += 1 + blocks.append(Block(kind="table", html=render_table(header, rows))) + continue + + ul = UL_ITEM_RE.match(line) + if ul: + items: list[str] = [] + while i < len(lines): + if not lines[i].strip(): + j = i + 1 + while j < len(lines) and not lines[j].strip(): + j += 1 + if j < len(lines) and UL_ITEM_RE.match(lines[j]): + i = j + continue + break + + mm = UL_ITEM_RE.match(lines[i]) + if mm: + items.append(mm.group(1).strip()) + i += 1 + continue + + if (lines[i].startswith(" ") or lines[i].startswith("\t")) and items: + items[-1] = f"{items[-1]} {lines[i].strip()}" + i += 1 + continue + + break + + blocks.append( + Block( + kind="list", + html="
    " + + "".join(f"
  • {render_inline(it)}
  • " for it in items) + + "
", + ) + ) + continue + + ol = OL_ITEM_RE.match(line) + if ol: + items: list[str] = [] + while i < len(lines): + if not lines[i].strip(): + j = i + 1 + while j < len(lines) and not lines[j].strip(): + j += 1 + if j < len(lines) and OL_ITEM_RE.match(lines[j]): + i = j + continue + break + + mm = OL_ITEM_RE.match(lines[i]) + if mm: + items.append(mm.group(2).strip()) + i += 1 + continue + + if (lines[i].startswith(" ") or lines[i].startswith("\t")) and items: + items[-1] = f"{items[-1]} {lines[i].strip()}" + i += 1 + continue + + break + + blocks.append( + Block( + kind="list", + html="
    " + + "".join(f"
  1. {render_inline(it)}
  2. " for it in items) + + "
", + ) + ) + continue + + # Paragraph: accumulate until blank line or another block begins. + para_lines = [line.strip()] + i += 1 + while i < len(lines) and lines[i].strip(): + nxt = lines[i].rstrip("\n") + if HEADING_RE.match(nxt) or UL_ITEM_RE.match(nxt) or OL_ITEM_RE.match(nxt) or is_table_start(i) or nxt.strip().startswith("```"): + break + para_lines.append(nxt.strip()) + i += 1 + blocks.append( + Block(kind="p", html=f"

{render_inline(' '.join(para_lines))}

") + ) + + if title is None: + title = "SkyStrip" + return RenderResult(title=title, html=blocks_to_html(blocks)) + + +def main() -> int: + md = FAQ_MD.read_text(encoding="utf-8") + rendered = render_markdown(md) + template = TEMPLATE.read_text(encoding="utf-8") + html = template.replace("{{TITLE}}", escape(rendered.title)) + html = html.replace("{{CONTENT}}", rendered.html) + OUT.write_text(html, encoding="utf-8") + print(f"Wrote {OUT}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/usermods/usermod_v2_skystrip/explainer/template.html b/usermods/usermod_v2_skystrip/explainer/template.html new file mode 100644 index 0000000000..ddb5980870 --- /dev/null +++ b/usermods/usermod_v2_skystrip/explainer/template.html @@ -0,0 +1,154 @@ + + + + + {{TITLE}} — Explainer + + + + +
+
+
{{TITLE}}
+
Printable interpretation guide
+
+
+ {{CONTENT}} +
+
+ +