diff --git a/usermods/usermod_v2_skystrip/FAQ.md b/usermods/usermod_v2_skystrip/FAQ.md new file mode 100644 index 0000000000..3efa01cf66 --- /dev/null +++ b/usermods/usermod_v2_skystrip/FAQ.md @@ -0,0 +1,109 @@ +# 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 | + +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 | + + +## 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 | + + +## 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..ee1b444234 --- /dev/null +++ b/usermods/usermod_v2_skystrip/cloud_view.cpp @@ -0,0 +1,229 @@ +#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[] PROGMEM = "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) { + freezeHandle_.release(); + return; + } + if (model.cloud_cover_forecast.empty()) + return; + if (segId_ < 0 || segId_ >= strip.getMaxSegments()) { + freezeHandle_.release(); + return; + } + + Segment *segPtr = freezeHandle_.acquire(segId_); + if (!segPtr) + return; + Segment &seg = *segPtr; + int len = seg.virtualLength(); + if (len <= 0) { + freezeHandle_.release(); + return; + } + // Initialize segment drawing parameters so virtualLength()/mapping are valid + seg.beginDraw(); + + 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) + DAY) % DAY; // normalize to [0, DAY) + if (useSunset) + 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); + 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) + DAY) % DAY; // normalize to [0, 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; + 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); + + + 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); + seg.setPixelColor(i, 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::deactivate() { + freezeHandle_.release(); +} + +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..6d345b98dc --- /dev/null +++ b/usermods/usermod_v2_skystrip/cloud_view.h @@ -0,0 +1,30 @@ +#pragma once + +#include "interfaces.h" +#include "skymodel.h" +#include "util.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 deactivate() override; + + 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]; + skystrip::util::SegmentFreezeHandle freezeHandle_; +}; diff --git a/usermods/usermod_v2_skystrip/delta_view.cpp b/usermods/usermod_v2_skystrip/delta_view.cpp new file mode 100644 index 0000000000..f850855d9a --- /dev/null +++ b/usermods/usermod_v2_skystrip/delta_view.cpp @@ -0,0 +1,187 @@ +#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[] PROGMEM = "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.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 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) { + freezeHandle_.release(); + return; + } + if (model.temperature_forecast.empty()) + return; + if (segId_ < 0 || segId_ >= strip.getMaxSegments()) { + freezeHandle_.release(); + return; + } + + Segment *segPtr = freezeHandle_.acquire(segId_); + if (!segPtr) + return; + Segment &seg = *segPtr; + int len = seg.virtualLength(); + if (len <= 0) { + freezeHandle_.release(); + return; + } + // Initialize segment drawing parameters so virtualLength()/mapping are valid + seg.beginDraw(); + + 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 (int i = 0; i < len; ++i) { + const time_t t = now + time_t(std::llround(step * 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; + } + } + seg.setPixelColor(i, 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; + } + } + + seg.setPixelColor(i, skystrip::util::blinkDebug(i, dbgPixelIndex, col)); + } +} + +void DeltaView::deactivate() { + freezeHandle_.release(); +} + +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..92a8c8ec53 --- /dev/null +++ b/usermods/usermod_v2_skystrip/delta_view.h @@ -0,0 +1,30 @@ +#pragma once + +#include "interfaces.h" +#include "skymodel.h" +#include "util.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 deactivate() override; + + 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]; + skystrip::util::SegmentFreezeHandle freezeHandle_; +}; diff --git a/usermods/usermod_v2_skystrip/interfaces.h b/usermods/usermod_v2_skystrip/interfaces.h new file mode 100644 index 0000000000..3d89e53c45 --- /dev/null +++ b/usermods/usermod_v2_skystrip/interfaces.h @@ -0,0 +1,58 @@ +#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; + + /// 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/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..74a8b45d38 --- /dev/null +++ b/usermods/usermod_v2_skystrip/open_weather_map_source.cpp @@ -0,0 +1,490 @@ +#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 = "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; +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[] 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) { + static const char hex[] = "0123456789ABCDEF"; + if (!dst || dstSize == 0) return; + size_t di = 0; + 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 == ',') { + if (di + 1 < dstSize) { + dst[di++] = c; + } else { + break; // no room for this char plus NUL + } + } else if (c == ' ') { + if (di + 3 < dstSize) { + dst[di++] = '%'; dst[di++] = '2'; dst[di++] = '0'; + } else { + break; // not enough room for %20 + NUL + } + } else { + if (di + 3 < dstSize) { + dst[di++] = '%'; dst[di++] = hex[c >> 4]; dst[di++] = hex[c & 0xF]; + } else { + break; // not enough room for %XY + NUL + } + } + } + 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 '*'. +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'; +} + +// 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 + 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) { + // Truncate the trailing 'A' so ",USA" → ",US" without corrupting chars + q[len - 1] = '\0'; + } +} + +// 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; + 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((unsigned char)*token)) ++token; + char* end = token + strlen(token); + 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; } + 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]; + 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; + 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 (!nearlyEqualCoord(latitude_, oldLatitude) || !nearlyEqualCoord(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 (!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; +} + +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; + + // 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)); + 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) { + 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, (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")) { + 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, (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; +} + +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]; + char dtbuf[24]; + timeToDecimal(dtbuf, sizeof(dtbuf), fetchDt); + snprintf(url, sizeof(url), + "%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); + + 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, (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")) { + 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, (float)ptype }); + model->precip_prob_forecast.push_back({ dt, (float)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()); + 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 ... + 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..57e4e7c500 --- /dev/null +++ b/usermods/usermod_v2_skystrip/readme.md @@ -0,0 +1,45 @@ +# 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/rest_json_client.cpp b/usermods/usermod_v2_skystrip/rest_json_client.cpp new file mode 100644 index 0000000000..1e8f5c7c7b --- /dev/null +++ b/usermods/usermod_v2_skystrip/rest_json_client.cpp @@ -0,0 +1,88 @@ +#include "wled.h" + +#include "rest_json_client.h" + +RestJsonClient::RestJsonClient() + : 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() { + // pretend we fetched RATE_LIMIT_MS ago (allow immediate next call) + 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 ...) + unsigned long now_ms = millis(); + // 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; + + // 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 (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")); + return nullptr; + } + DEBUG_PRINTF("SkyStrip: RestJsonClient::getJson: free heap before GET: %u\n", ESP.getFreeHeap()); + int code = http_.GET(); + // 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 error/status: %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()); + 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(); + 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..6a79f34dda --- /dev/null +++ b/usermods/usermod_v2_skystrip/rest_json_client.h @@ -0,0 +1,53 @@ +#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(); + // 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) + 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_; + 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 new file mode 100644 index 0000000000..2374d1765a --- /dev/null +++ b/usermods/usermod_v2_skystrip/skymodel.cpp @@ -0,0 +1,174 @@ +#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 { + // 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; + 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; + const size_t cap = sizeof(line); + for (const auto& dp : s) { + if (i % 6 == 0) { + 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); + 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 < 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); + } + ++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..f49b41ec67 --- /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; + float 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..73c41028a3 --- /dev/null +++ b/usermods/usermod_v2_skystrip/temperature_view.cpp @@ -0,0 +1,204 @@ +#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[] PROGMEM = "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.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 + 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) { + freezeHandle_.release(); + return; // disabled + } + if (model.temperature_forecast.empty()) + return; // nothing to render + + if (segId_ < 0 || segId_ >= strip.getMaxSegments()) { + freezeHandle_.release(); + return; + } + Segment *segPtr = freezeHandle_.acquire(segId_); + if (!segPtr) + return; + Segment &seg = *segPtr; + int len = seg.virtualLength(); + if (len <= 0) { + freezeHandle_.release(); + return; + } + // Initialize segment drawing parameters so virtualLength()/mapping are valid + seg.beginDraw(); + + 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) + 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, + 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 (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; + 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; + } + } + + seg.setPixelColor(i, skystrip::util::blinkDebug(i, dbgPixelIndex, col)); + } +} + +void TemperatureView::deactivate() { + freezeHandle_.release(); +} + +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..eed84eae00 --- /dev/null +++ b/usermods/usermod_v2_skystrip/temperature_view.h @@ -0,0 +1,32 @@ +#pragma once + +#include "interfaces.h" +#include "skymodel.h" +#include "util.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); } + void deactivate() override; + + // 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]; + skystrip::util::SegmentFreezeHandle freezeHandle_; +}; 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..d208dc0f87 --- /dev/null +++ b/usermods/usermod_v2_skystrip/test_pattern_view.cpp @@ -0,0 +1,195 @@ +#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[] PROGMEM = "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]; + // 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; +} + +} // 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) { + freezeHandle_.release(); + return; + } + if (segId_ < 0 || segId_ >= strip.getMaxSegments()) { + freezeHandle_.release(); + return; + } + + Segment *segPtr = freezeHandle_.acquire(segId_); + if (!segPtr) + return; + Segment &seg = *segPtr; + int len = seg.virtualLength(); + if (len <= 0) { + freezeHandle_.release(); + return; + } + // Initialize segment drawing parameters so virtualLength()/mapping are valid + seg.beginDraw(); + + 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; + } + } + seg.setPixelColor(i, skystrip::util::blinkDebug(i, dbgPixelIndex, col)); + } +} + +void TestPatternView::deactivate() { + freezeHandle_.release(); +} + +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..033fbb0f86 --- /dev/null +++ b/usermods/usermod_v2_skystrip/test_pattern_view.h @@ -0,0 +1,32 @@ +#pragma once + +#include "interfaces.h" +#include "skymodel.h" +#include "util.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 deactivate() override; + + 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_; + skystrip::util::SegmentFreezeHandle freezeHandle_; +}; 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..7627d8302c --- /dev/null +++ b/usermods/usermod_v2_skystrip/usermod_v2_skystrip.cpp @@ -0,0 +1,263 @@ +#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[] 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_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 uint32_t SAFETY_DELAY_MS = 10u * 1000u; + +// 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_MS; + + // 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 and on + if (!enabled_ || offMode) 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 + 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_); + } +} + +// 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 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 + } + + 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.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..12827813f5 --- /dev/null +++ b/usermods/usermod_v2_skystrip/usermod_v2_skystrip.h @@ -0,0 +1,53 @@ +#pragma once +#include + +#include "interfaces.h" +#include "wled.h" + +#define USERMOD_ID_SKYSTRIP 559 + +#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..a3b036be6e --- /dev/null +++ b/usermods/usermod_v2_skystrip/util.cpp @@ -0,0 +1,37 @@ +#include + +#include "util.h" + +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; + 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; } + 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; + // 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); +} + +} // 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..f96cd337c7 --- /dev/null +++ b/usermods/usermod_v2_skystrip/util.h @@ -0,0 +1,226 @@ +#pragma once + +#include "skymodel.h" +#include "wled.h" +#include +#include +#include + +namespace skystrip { +namespace util { + +// 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; + } + +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) +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) { + 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) { + 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..5400f68407 --- /dev/null +++ b/usermods/usermod_v2_skystrip/wind_view.cpp @@ -0,0 +1,151 @@ +#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[] 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); + 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) { + freezeHandle_.release(); + return; + } + if (model.wind_speed_forecast.empty()) + return; + if (segId_ < 0 || segId_ >= strip.getMaxSegments()) { + freezeHandle_.release(); + return; + } + + Segment *segPtr = freezeHandle_.acquire(segId_); + if (!segPtr) + return; + Segment &seg = *segPtr; + int len = seg.virtualLength(); + if (len <= 0) { + freezeHandle_.release(); + return; + } + // Initialize segment drawing parameters so virtualLength()/mapping are valid + seg.beginDraw(); + + constexpr double kHorizonSec = 48.0 * 3600.0; + const double step = (len > 1) ? (kHorizonSec / double(len - 1)) : 0.0; + + 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)) + continue; + if (!skystrip::util::estimateDirAt(model, t, step, dir)) + 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); + + // 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, raw_spd, raw_gst, dir, hue, + sat * 100, val * 100); + lastDebug = now; + } + } + + seg.setPixelColor(i, skystrip::util::blinkDebug(i, dbgPixelIndex, col)); + } +} + +void WindView::deactivate() { + freezeHandle_.release(); +} + +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..b53b7a0e4d --- /dev/null +++ b/usermods/usermod_v2_skystrip/wind_view.h @@ -0,0 +1,30 @@ +#pragma once + +#include "interfaces.h" +#include "skymodel.h" +#include "util.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 deactivate() override; + + 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]; + skystrip::util::SegmentFreezeHandle freezeHandle_; +};