diff --git a/usermods/usermod_v2_skystrip/FAQ.md b/usermods/usermod_v2_skystrip/FAQ.md new file mode 100644 index 0000000000..32019f56c1 --- /dev/null +++ b/usermods/usermod_v2_skystrip/FAQ.md @@ -0,0 +1,151 @@ +# SkyStrip Interpretation Guide + +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) + +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) + +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) + +Displays the next 48 hours forecast of temperature and humidity. + +### 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) | Color | Hue (°) | +|-------------|----------|---------| +| -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 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. diff --git a/usermods/usermod_v2_skystrip/cloud_view.cpp b/usermods/usermod_v2_skystrip/cloud_view.cpp new file mode 100644 index 0000000000..f016061f69 --- /dev/null +++ b/usermods/usermod_v2_skystrip/cloud_view.cpp @@ -0,0 +1,297 @@ +#include "cloud_view.h" +#include "skymodel.h" +#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(); + 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), + 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()); + 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; + + 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; + 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)) { + // always put the sunrise sunset markers in + hue = kMarkerHue; + sat = kMarkerSat; + val = kMarkerVal; + } 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 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 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 + hue = kHueSnow; + sat = kSatSnow; + val = valFromRate; + } else { + // mixed → halfway between blue and lavender + hue = 0.5f * (kHueRain + kHueSnow); // ~247.5° (indigo-ish) + sat = 0.5f * (satRain + kSatSnow); // blended saturation + val = valFromRate; + } + } 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 + 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; + } + } + + 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 acc=%.2fin/hr H=%.0f S=%.0f V=%.0f\\n", + name().c_str(), nowbuf, i, dbgbuf, daytime, clouds01, p, + precipProb, precipRateIn, hue, sat * 100, val * 100); + lastDebug = now; + } + } + } +} + +void CloudView::deactivate() { + freezeHandle_.release(); +} + +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) { + // Keep the hint INLINE (BEFORE the input = 4th arg): + 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, + bool &invalidate_history) { + 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); + 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 new file mode 100644 index 0000000000..9eefe729a6 --- /dev/null +++ b/usermods/usermod_v2_skystrip/cloud_view.h @@ -0,0 +1,35 @@ +#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: + 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 new file mode 100644 index 0000000000..ca9a10e72f --- /dev/null +++ b/usermods/usermod_v2_skystrip/delta_view.cpp @@ -0,0 +1,278 @@ +#include +#include +#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"; +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; +}; + +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 stops[6].h; +} + +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]; + + 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'; +} + +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; + Stop hueStops[7]; + buildHueStops(thresholdsF_, hueStops); + + 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; + + 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; + 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 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; + } + } + + 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_; + subtree[FPSTR(CFG_THRESHOLDS)] = thresholdsStr_; +} + +void DeltaView::appendConfigData(Print &s) { + // Keep the hint INLINE (BEFORE the input = 4th arg): + 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, + bool &invalidate_history) { + 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 new file mode 100644 index 0000000000..22168d3a2b --- /dev/null +++ b/usermods/usermod_v2_skystrip/delta_view.h @@ -0,0 +1,35 @@ +#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: + 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/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}} +
+
+ + 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..94948c10d8 --- /dev/null +++ b/usermods/usermod_v2_skystrip/open_weather_map_source.cpp @@ -0,0 +1,495 @@ +#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 +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 +// +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); + + 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 { + // 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); + 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(); + } + } + + // 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_); + resetRateLimit(); // allow immediate fetch after a location change (even if history just ran) + 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; + + // 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; + } + + // 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")) { + 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() }); + 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 = 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 || + 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 }); + 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 + // 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() }); + 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 = 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 || + 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 }); + 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; + 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/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/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..488ef2ba67 --- /dev/null +++ b/usermods/usermod_v2_skystrip/skymodel.cpp @@ -0,0 +1,179 @@ +#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); + precip_inph_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); + mergeSeries(precip_inph_forecast, std::move(other.precip_inph_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(); + precip_inph_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); + upd(precip_inph_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); + emitSeriesMDHM(out, now, " inph", precip_inph_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..a0cda75cc3 --- /dev/null +++ b/usermods/usermod_v2_skystrip/skymodel.h @@ -0,0 +1,48 @@ +#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 + std::vector precip_inph_forecast; // liquid-equivalent accumulation in in/hr + + // 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..206b13e5bc --- /dev/null +++ b/usermods/usermod_v2_skystrip/temperature_view.cpp @@ -0,0 +1,512 @@ +#include "temperature_view.h" +#include "skymodel.h" +#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 + +// - 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"; +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. +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; +} + +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'; +} + +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(); + + 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. + 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 = temps[i]; + float hue = hues[i]; + float sat = sats[i]; + uint32_t col = 0; + 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); + } + + 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) { + 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 colbuf[10]; + fmtColorHex(col, colbuf, sizeof(colbuf)); + snprintf(debugPixelString, sizeof(debugPixelString), + "%s: nowtm=%s dbgndx=%d dbgtm=%s " + "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; + } + } + + seg.setPixelColor(i, skystrip::util::blinkDebug(i, dbgPixelIndex, col)); + } +} + +void TemperatureView::deactivate() { + freezeHandle_.release(); +} + +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) { + // Keep the hint INLINE (BEFORE the input = 4th arg): + 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, + bool &invalidate_history) { + 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 new file mode 100644 index 0000000000..be66370d60 --- /dev/null +++ b/usermods/usermod_v2_skystrip/temperature_view.h @@ -0,0 +1,45 @@ +#pragma once + +#include "interfaces.h" +#include "skymodel.h" +#include "util.h" +#include + +class SkyModel; + +class TemperatureView : public IDataViewT { +public: + 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"; } + 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: + 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_; +}; 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..815d8bda93 --- /dev/null +++ b/usermods/usermod_v2_skystrip/usermod_v2_skystrip.cpp @@ -0,0 +1,276 @@ +#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. +#ifdef SKYSTRIP_ENABLE_SAFETY_DELAY +const uint32_t SAFETY_DELAY_MS = 10u * 1000u; +#endif + +// 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")); + +#ifdef SKYSTRIP_ENABLE_SAFETY_DELAY + uint32_t now_ms = millis(); + safeToStart_ = now_ms + SAFETY_DELAY_MS; +#endif + + // Serial.begin(115200); + + // Print version number + DEBUG_PRINTF("SkyStrip: version=%s\n", SKYSTRIP_GIT_DESCRIBE); + + // 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) { +#ifdef SKYSTRIP_ENABLE_SAFETY_DELAY + if (now_ms < safeToStart_) { + return; + } else { + DEBUG_PRINTLN(F("SkyStrip::loop SkyStripState is Running")); + state_ = SkyStripState::Running; + 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 + 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_; + top["Version"] = SKYSTRIP_GIT_DESCRIBE; + + // 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) { + 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); + } + + 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..3b59d06850 --- /dev/null +++ b/usermods/usermod_v2_skystrip/usermod_v2_skystrip.h @@ -0,0 +1,64 @@ +#pragma once +#include + +#include "interfaces.h" +#include "wled.h" + +#define USERMOD_ID_SKYSTRIP 559 + +#define SKYSTRIP_VERSION_FALLBACK "0.0.1" + +#ifndef SKYSTRIP_GIT_DESCRIBE +#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; + +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; +#ifdef SKYSTRIP_ENABLE_SAFETY_DELAY + uint32_t safeToStart_ = 0; +#endif + 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..210eaf0a17 --- /dev/null +++ b/usermods/usermod_v2_skystrip/util.h @@ -0,0 +1,230 @@ +#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); +} +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); + +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_; +};