Skip to content
Closed
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
2df807d
Add SkyStrip weather forecast usermod
ksedgwic Aug 28, 2025
315b5ab
Fix CodeRabbit issues:
ksedgwic Aug 28, 2025
3a57075
Add missing header include
ksedgwic Aug 29, 2025
44e983f
Fix base API url
ksedgwic Aug 29, 2025
9a6a0a9
Improve documentation and comments:
ksedgwic Aug 29, 2025
a77c260
Code review improvements:
ksedgwic Aug 29, 2025
abcd7b3
Use segment virtualization APIs instead of raw start/stop
ksedgwic Aug 29, 2025
2d2c73d
skystrip: Fix formatting issues
ksedgwic Aug 29, 2025
440f21a
skystrip: Fix urlEncode bounds check to avoid premature truncation
ksedgwic Aug 29, 2025
35ccfbf
skystrip: Avoid undefined behavior: cast to unsigned char for ctype c…
ksedgwic Aug 29, 2025
3cbae81
skystrip: Guard against truncated input in parseLatLon
ksedgwic Aug 29, 2025
d51eef6
skystrip: Print 64-bit-safe timemachine dt
ksedgwic Aug 29, 2025
a3f700d
skystrip: Fix lat/long round-off update bug
ksedgwic Aug 30, 2025
fb1f3f6
skystrip: Improve documentation / comments
ksedgwic Sep 2, 2025
36c9bcd
skystrip: Further improve documentation
ksedgwic Sep 2, 2025
e37322d
skystrip: Even further doc improvements
ksedgwic Sep 2, 2025
8f08861
skystrip: Fix segment freeze semantics
ksedgwic Sep 16, 2025
7deb02b
skystrip: Don't gate fetching with strip.isUpdating
ksedgwic Sep 20, 2025
027626c
skystrip: Fix washed out areas with minimum saturation increase
ksedgwic Sep 20, 2025
946c486
skystrip: Suppress view of calm wind
ksedgwic Sep 20, 2025
bcd0ed3
skystrip: improve rendering of views
ksedgwic Oct 13, 2025
7efff94
skystrip: display the SKYSTRIP_GIT_DESCRIBE in the config
ksedgwic Oct 24, 2025
88167f5
skystrip: more view rendering improvements
ksedgwic Dec 3, 2025
1c27bb7
skystrip: fix location update reload bug
ksedgwic Dec 8, 2025
9f6d88c
skystrip: conditional-compile remove the 10 second safety
ksedgwic Dec 9, 2025
b521793
skystrip: improve the FAQ
ksedgwic Dec 15, 2025
f61afa5
skystrip: add explainer
ksedgwic Dec 15, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions usermods/usermod_v2_skystrip/FAQ.md
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 |

Comment thread
ksedgwic marked this conversation as resolved.
Outdated
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.
217 changes: 217 additions & 0 deletions usermods/usermod_v2_skystrip/cloud_view.cpp
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)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
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,'',"
"'&nbsp;<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;
}
27 changes: 27 additions & 0 deletions usermods/usermod_v2_skystrip/cloud_view.h
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];
};
Loading