-
-
Notifications
You must be signed in to change notification settings - Fork 3.8k
Usermod: Add SkyStrip weather forecast usermod #4883
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ksedgwic
wants to merge
20
commits into
wled:main
Choose a base branch
from
ksedgwic:usermod-v2-skystrip
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 15 commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
2df807d
Add SkyStrip weather forecast usermod
ksedgwic 315b5ab
Fix CodeRabbit issues:
ksedgwic 3a57075
Add missing header include
ksedgwic 44e983f
Fix base API url
ksedgwic 9a6a0a9
Improve documentation and comments:
ksedgwic a77c260
Code review improvements:
ksedgwic abcd7b3
Use segment virtualization APIs instead of raw start/stop
ksedgwic 2d2c73d
skystrip: Fix formatting issues
ksedgwic 440f21a
skystrip: Fix urlEncode bounds check to avoid premature truncation
ksedgwic 35ccfbf
skystrip: Avoid undefined behavior: cast to unsigned char for ctype c…
ksedgwic 3cbae81
skystrip: Guard against truncated input in parseLatLon
ksedgwic d51eef6
skystrip: Print 64-bit-safe timemachine dt
ksedgwic a3f700d
skystrip: Fix lat/long round-off update bug
ksedgwic fb1f3f6
skystrip: Improve documentation / comments
ksedgwic 36c9bcd
skystrip: Further improve documentation
ksedgwic e37322d
skystrip: Even further doc improvements
ksedgwic 8f08861
skystrip: Fix segment freeze semantics
ksedgwic 7deb02b
skystrip: Don't gate fetching with strip.isUpdating
ksedgwic 027626c
skystrip: Fix washed out areas with minimum saturation increase
ksedgwic 946c486
skystrip: Suppress view of calm wind
ksedgwic File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| # SkyStrip Interpretation Guide | ||
|
|
||
| This FAQ explains how to read the various HSV-based views of the | ||
| `usermod_v2_skystrip` module. Each view maps weather data onto hue, | ||
| saturation, and value (brightness) along the LED strip. | ||
|
|
||
|
|
||
| ## Cloud View (CV) | ||
|
|
||
| Markers for sunrise or sunset show as orange pixels. During | ||
| precipitation, hue denotes type—deep blue for rain, lavender for snow, | ||
| and indigo for mixed—while value scales with probability. In the | ||
| absence of precipitation, hue differentiates day from night: daylight | ||
| clouds appear pale yellow, nighttime clouds desaturate toward | ||
| white. For clouds, saturation is low and value grows with coverage, | ||
| keeping even thin clouds visible. Thus, a bright blue pixel highlights | ||
| likely rain, whereas a soft yellow glow marks daytime cloud cover. | ||
|
|
||
|
|
||
| ## Wind View (WV) | ||
|
|
||
| The hue encodes wind direction around the compass: blue (240°) points | ||
| north, orange (~30°) east, yellow (~60°) south, and green (~120°) | ||
| west, with intermediate shades for diagonal winds. Saturation rises | ||
| with gustiness—calm breezes stay washed out while strong gusts drive | ||
| the color toward full intensity. Value scales with wind strength, | ||
| boosting brightness as the highest of sustained speed or gust | ||
| approaches 50 mph (or equivalent). For example, a saturated blue pixel | ||
| indicates gusty north winds, while a dim pastel green suggests a | ||
| gentle westerly breeze. | ||
|
|
||
| The mapping between wind direction and hue can be approximated as: | ||
|
|
||
| | Direction | Hue (°) | Color | | ||
| |-----------|---------|--------| | ||
| | N | 240 | Blue | | ||
| | NE | 300 | Purple | | ||
| | E | 30 | Orange | | ||
| | SE | 45 | Gold | | ||
| | S | 60 | Yellow | | ||
| | SW | 90 | Lime | | ||
| | W | 120 | Green | | ||
| | NW | 180 | Cyan | | ||
| | N | 240 | Blue | | ||
|
|
||
| Note: Hues wrap at 360°, so “N” repeats at the boundary. | ||
|
|
||
|
|
||
| ## Temperature View (TV) | ||
|
|
||
| Hue follows a calibrated cold→hot gradient tuned for pleasing segment | ||
| appearance: deep blues near 14 °F transition through cyan and green to | ||
| warm yellows at 77 °F and reds at ~104 °F and above. Saturation | ||
| reflects humidity via dew‑point spread; muggy air produces softer, | ||
| desaturated colors, whereas dry air yields vivid tones. Value is fixed | ||
| at mid‑brightness, but local time markers (e.g., noon, midnight) | ||
| temporarily darken pixels to mark time. A bright orange‑red pixel thus | ||
| signifies hot, dry conditions around 95 °F, whereas a pale cyan pixel | ||
| indicates a cool, humid day near 50 °F. | ||
|
|
||
| The actual temperature→hue stops used by the renderer are: | ||
|
|
||
| | Temp (°F) | Hue (°) | Color | | ||
| |-----------|---------|-------------| | ||
| | ≤14 | 234.9 | Deep blue | | ||
| | 32 | 207.0 | Blue/cyan | | ||
| | 50 | 180.0 | Cyan | | ||
| | 68 | 138.8 | Greenish | | ||
| | 77 | 60.0 | Yellow | | ||
| | 86 | 38.8 | Orange | | ||
| | 95 | 18.8 | Orange‑red | | ||
| | ≥104 | 0.0 | Red | | ||
|
|
||
|
|
||
| ## 24-Hour Delta View (DV) | ||
|
|
||
| Hue represents the temperature change relative to the previous day: | ||
| blues for cooling, greens for steady conditions, and yellows through | ||
| reds for warming. Saturation encodes humidity trend—the color | ||
| intensifies as the air grows drier and fades toward pastels when | ||
| becoming more humid. Value increases with the magnitude of change, | ||
| combining temperature and humidity shifts, so bright pixels flag | ||
| larger swings. A dim blue pixel therefore means a slight cool‑down | ||
| with more moisture, while a bright saturated red indicates rapid | ||
| warming coupled with drying. | ||
|
|
||
| Approximate mapping of day-to-day deltas to color attributes: | ||
|
|
||
| | Temperature | Hue (Color) | | ||
| |-------------|-------------| | ||
| | Cooling | Blue tones | | ||
| | Steady | Green | | ||
| | Warming | Yellow→Red | | ||
|
|
||
| | Humidity | Saturation | | ||
| |------------|------------| | ||
| | More humid | Low/Pastel | | ||
| | Stable | Medium | | ||
| | Drier | High/Vivid | | ||
|
|
||
|
|
||
| ## Test Pattern View (TP) | ||
|
|
||
| This diagnostic view simply interpolates hue, saturation, and value | ||
| between configured start and end points along the segment. Hue shifts | ||
| steadily from the starting hue to the ending hue, with saturation and | ||
| brightness following the same linear ramp. It carries no weather | ||
| meaning; a common example is a gradient from black to white to verify | ||
| LED orientation. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,217 @@ | ||
| #include "cloud_view.h" | ||
| #include "skymodel.h" | ||
| #include "util.h" | ||
| #include "wled.h" | ||
| #include <algorithm> | ||
| #include <cmath> | ||
| #include <limits> | ||
|
|
||
| static constexpr int16_t DEFAULT_SEG_ID = -1; // -1 means disabled | ||
| const char CFG_SEG_ID[] PROGMEM = "SegmentId"; | ||
|
|
||
| static bool isDay(const SkyModel &m, time_t t) { | ||
| const time_t MAXTT = std::numeric_limits<time_t>::max(); | ||
| if (m.sunrise_ == 0 && m.sunset_ == MAXTT) | ||
| return true; // 24h day | ||
| if (m.sunset_ == 0 && m.sunrise_ == MAXTT) | ||
| return false; // 24h night | ||
| constexpr time_t DAY = 24 * 60 * 60; | ||
| time_t sr = m.sunrise_; | ||
| time_t ss = m.sunset_; | ||
| while (t >= ss) { | ||
| sr += DAY; | ||
| ss += DAY; | ||
| } | ||
| while (t < sr) { | ||
| sr -= DAY; | ||
| ss -= DAY; | ||
| } | ||
| return t >= sr && t < ss; | ||
| } | ||
|
|
||
| CloudView::CloudView() : segId_(DEFAULT_SEG_ID) { | ||
| DEBUG_PRINTLN("SkyStrip: CV::CTOR"); | ||
| snprintf(debugPixelString, sizeof(debugPixelString), "%s:\\n", | ||
| name().c_str()); | ||
| debugPixelString[sizeof(debugPixelString) - 1] = '\0'; | ||
| } | ||
|
|
||
| void CloudView::view(time_t now, SkyModel const &model, int16_t dbgPixelIndex) { | ||
| if (dbgPixelIndex < 0) { | ||
| snprintf(debugPixelString, sizeof(debugPixelString), "%s:\\n", | ||
| name().c_str()); | ||
| debugPixelString[sizeof(debugPixelString) - 1] = '\0'; | ||
| } | ||
| if (segId_ == DEFAULT_SEG_ID) | ||
| return; | ||
| if (model.cloud_cover_forecast.empty()) | ||
| return; | ||
| if (segId_ < 0 || segId_ >= strip.getMaxSegments()) | ||
| return; | ||
|
|
||
| Segment &seg = strip.getSegment((uint8_t)segId_); | ||
| int len = seg.virtualLength(); | ||
| if (len <= 0) | ||
| return; | ||
| // Initialize segment drawing parameters so virtualLength()/mapping are valid | ||
| seg.beginDraw(); | ||
| skystrip::util::FreezeGuard freezeGuard(seg, false); | ||
|
|
||
| constexpr double kHorizonSec = 48.0 * 3600.0; | ||
| const double step = (len > 1) ? (kHorizonSec / double(len - 1)) : 0.0; | ||
|
|
||
| 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<time_t>::max(); | ||
|
|
||
| long offset = skystrip::util::current_offset(); | ||
|
|
||
| bool useSunrise = (sunrise != 0 && sunrise != MAXTT); | ||
| bool useSunset = (sunset != 0 && sunset != MAXTT); | ||
| time_t sunriseTOD = 0; | ||
| time_t sunsetTOD = 0; | ||
| if (useSunrise) | ||
| sunriseTOD = (((sunrise + offset) % DAY) + DAY) % DAY; // normalize to [0, DAY) | ||
| if (useSunset) | ||
| sunsetTOD = (((sunset + offset) % DAY) + DAY) % DAY; // normalize to [0, DAY) | ||
|
|
||
| auto nearTOD = [&](time_t a, time_t b) { | ||
| time_t diff = (a >= b) ? (a - b) : (b - a); | ||
| if (diff <= markerTol) | ||
| return true; | ||
| return (DAY - diff) <= markerTol; | ||
| }; | ||
|
|
||
| auto isMarker = [&](time_t t) { | ||
| if (!useSunrise && !useSunset) | ||
| return false; | ||
| time_t tod = (((t + offset) % DAY) + DAY) % DAY; // normalize to [0, DAY) | ||
| if (useSunrise && nearTOD(tod, sunriseTOD)) | ||
| return true; | ||
| if (useSunset && nearTOD(tod, sunsetTOD)) | ||
| return true; | ||
| return false; | ||
| }; | ||
|
|
||
| constexpr float kCloudMaskThreshold = 0.05f; | ||
| constexpr float kDayHue = 60.f; | ||
| constexpr float kNightHue = 300.f; | ||
| constexpr float kDaySat = 0.30f; | ||
| constexpr float kNightSat = 0.00f; | ||
| constexpr float kDayVMax = 0.40f; | ||
| constexpr float kNightVMax= 0.40f; | ||
|
|
||
| // Brightness floor as a fraction of Vmax so mid/low clouds stay visible. | ||
| constexpr float kDayVMinFrac = 0.50f; // try 0.40–0.60 to taste | ||
| constexpr float kNightVMinFrac = 0.50f; // night can be a bit lower if preferred | ||
|
|
||
| constexpr float kMarkerHue= 25.f; | ||
| constexpr float kMarkerSat= 0.60f; | ||
| constexpr float kMarkerVal= 0.50f; | ||
|
|
||
| for (int i = 0; i < len; ++i) { | ||
| const time_t t = now + time_t(std::llround(step * i)); | ||
| double clouds, precipTypeVal, precipProb; | ||
| if (!skystrip::util::estimateCloudAt(model, t, step, clouds)) | ||
| continue; | ||
| if (!skystrip::util::estimatePrecipTypeAt(model, t, step, precipTypeVal)) | ||
| precipTypeVal = 0.0; | ||
| if (!skystrip::util::estimatePrecipProbAt(model, t, step, precipProb)) | ||
| precipProb = 0.0; | ||
|
|
||
| float clouds01 = skystrip::util::clamp01(float(clouds / 100.0)); | ||
| int p = int(std::round(precipTypeVal)); | ||
| bool daytime = isDay(model, t); | ||
|
|
||
|
|
||
| float hue = 0.f, sat = 0.f, val = 0.f; | ||
| if (isMarker(t)) { | ||
| // always put the sunrise sunset markers in | ||
| hue = kMarkerHue; | ||
| sat = kMarkerSat; | ||
| val = kMarkerVal; | ||
| } else if (p != 0 && precipProb > 0.0) { | ||
| // precipitation has next priority: rain=blue, snow=lavender, | ||
| // mixed=indigo-ish blend | ||
| constexpr float kHueRain = 210.f; // deep blue | ||
| constexpr float kSatRain = 1.00f; | ||
|
|
||
| constexpr float kHueSnow = 285.f; // lavender for snow | ||
| constexpr float kSatSnow = 0.35f; // pastel-ish (tune to taste) | ||
|
|
||
| float ph, ps; | ||
| if (p == 1) { | ||
| // rain | ||
| ph = kHueRain; | ||
| ps = kSatRain; | ||
| } else if (p == 2) { | ||
| // snow → lavender | ||
| ph = kHueSnow; | ||
| ps = kSatSnow; | ||
| } else { | ||
| // mixed → halfway between blue and lavender | ||
| ph = 0.5f * (kHueRain + kHueSnow); // ~247.5° (indigo-ish) | ||
| ps = 0.5f * (kSatRain + kSatSnow); // ~0.675 | ||
| } | ||
|
|
||
| float pv = skystrip::util::clamp01(float(precipProb)); | ||
| pv = 0.3f + 0.7f * pv; // brightness ramp | ||
| hue = ph; | ||
| sat = ps; | ||
| val = pv; | ||
| } else { | ||
| // finally show daytime or nightime clouds | ||
| if (clouds01 < kCloudMaskThreshold) { | ||
| hue = 0.f; | ||
| sat = 0.f; | ||
| val = 0.f; | ||
| } else { | ||
| float vmax = daytime ? kDayVMax : kNightVMax; | ||
| float vmin = (daytime ? kDayVMinFrac : kNightVMinFrac) * vmax; | ||
| // Use sqrt curve to boost brightness at lower cloud coverage | ||
| val = vmin + (vmax - vmin) * sqrtf(clouds01); | ||
| hue = daytime ? kDayHue : kNightHue; | ||
| sat = daytime ? kDaySat : kNightSat; | ||
| } | ||
| } | ||
|
|
||
| uint32_t col = skystrip::util::hsv2rgb(hue, sat, val); | ||
| seg.setPixelColor(i, skystrip::util::blinkDebug(i, dbgPixelIndex, col)); | ||
|
|
||
| if (dbgPixelIndex >= 0) { | ||
| static time_t lastDebug = 0; | ||
| if (now - lastDebug > 1 && i == dbgPixelIndex) { | ||
| char nowbuf[20]; | ||
| skystrip::util::fmt_local(nowbuf, sizeof(nowbuf), now); | ||
| char dbgbuf[20]; | ||
| skystrip::util::fmt_local(dbgbuf, sizeof(dbgbuf), t); | ||
| snprintf(debugPixelString, sizeof(debugPixelString), | ||
| "%s: nowtm=%s dbgndx=%d dbgtm=%s day=%d clouds01=%.2f precip=%d pop=%.2f H=%.0f S=%.0f V=%.0f\\n", | ||
| name().c_str(), nowbuf, i, dbgbuf, daytime, clouds01, p, | ||
| precipProb, hue, sat * 100, val * 100); | ||
| lastDebug = now; | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| void CloudView::addToConfig(JsonObject &subtree) { | ||
| subtree[FPSTR(CFG_SEG_ID)] = segId_; | ||
| } | ||
|
|
||
| void CloudView::appendConfigData(Print &s) { | ||
| // Keep the hint INLINE (BEFORE the input = 4th arg): | ||
| s.print(F("addInfo('SkyStrip:CloudView:SegmentId',1,''," | ||
| "' <small style=\\'opacity:.8\\'>(-1 disables)</small>'" | ||
| ");")); | ||
| } | ||
|
|
||
| bool CloudView::readFromConfig(JsonObject &subtree, bool startup_complete, | ||
| bool &invalidate_history) { | ||
| bool configComplete = !subtree.isNull(); | ||
| configComplete &= | ||
| getJsonValue(subtree[FPSTR(CFG_SEG_ID)], segId_, DEFAULT_SEG_ID); | ||
| return configComplete; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| #pragma once | ||
|
|
||
| #include "interfaces.h" | ||
| #include "skymodel.h" | ||
|
|
||
| class SkyModel; | ||
|
|
||
| class CloudView : public IDataViewT<SkyModel> { | ||
| public: | ||
| CloudView(); | ||
| ~CloudView() override = default; | ||
|
|
||
| void view(time_t now, SkyModel const & model, int16_t dbgPixelIndex) override; | ||
| std::string name() const override { return "CV"; } | ||
| void appendDebugPixel(Print& s) const override { s.print(debugPixelString); } | ||
|
|
||
| void addToConfig(JsonObject& subtree) override; | ||
| void appendConfigData(Print& s) override; | ||
| bool readFromConfig(JsonObject& subtree, | ||
| bool startup_complete, | ||
| bool& invalidate_history) override; | ||
| const char* configKey() const override { return "CloudView"; } | ||
|
|
||
| private: | ||
| int16_t segId_; | ||
| char debugPixelString[128]; | ||
| }; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.