diff --git a/usermods/usermod_v2_bartdepart/bart_station_model.cpp b/usermods/usermod_v2_bartdepart/bart_station_model.cpp new file mode 100644 index 0000000000..138a36e38e --- /dev/null +++ b/usermods/usermod_v2_bartdepart/bart_station_model.cpp @@ -0,0 +1,170 @@ +#include "bart_station_model.h" +#include "util.h" + +#include +#include +#include + +BartStationModel::Platform::Platform(const String& platformId) + : platformId_(platformId) {} + +void BartStationModel::Platform::update(const JsonObject& root) { + if (platformId_.isEmpty()) return; + + ETDBatch batch; + const char* dateStr = root["date"] | ""; + const char* timeStr = root["time"] | ""; + batch.apiTs = parseHeaderTimestamp(dateStr, timeStr); + batch.ourTs = bartdepart::util::time_now(); + + if (root["station"].is()) { + for (JsonObject station : root["station"].as()) { + if (!station["etd"].is()) continue; + for (JsonObject etd : station["etd"].as()) { + if (!etd["estimate"].is()) continue; + bool matches = false; + for (JsonObject est : etd["estimate"].as()) { + if (String(est["platform"] | "0") != platformId_) continue; + matches = true; + int mins = atoi(est["minutes"] | "0"); + time_t dep = batch.apiTs + mins * 60; + TrainColor col = parseTrainColor(est["color"] | ""); + batch.etds.push_back(ETD{dep, col}); + } + if (matches) { + String dest = etd["destination"] | ""; + if (!dest.isEmpty()) { + auto it = std::find(destinations_.begin(), destinations_.end(), dest); + if (it == destinations_.end()) destinations_.push_back(dest); + } + } + } + } + } + + std::sort(batch.etds.begin(), batch.etds.end(), + [](const ETD& a, const ETD& b){ return a.estDep < b.estDep; }); + + history_.push_back(std::move(batch)); + while (history_.size() > 5) { + history_.pop_front(); + } + + DEBUG_PRINTF("BartDepart::update platform %s: %s\n", + platformId_.c_str(), toString().c_str()); +} + +void BartStationModel::Platform::merge(const Platform& other) { + for (auto const& b : other.history_) { + history_.push_back(b); + if (history_.size() > 5) history_.pop_front(); + } + + for (auto const& d : other.destinations_) { + auto it = std::find(destinations_.begin(), destinations_.end(), d); + if (it == destinations_.end()) destinations_.push_back(d); + } +} + +time_t BartStationModel::Platform::oldest() const { + if (history_.empty()) return 0; + return history_.front().ourTs; +} + +const String& BartStationModel::Platform::platformId() const { + return platformId_; +} + +const std::deque& +BartStationModel::Platform::history() const { + return history_; +} + +const std::vector& BartStationModel::Platform::destinations() const { + return destinations_; +} + +String BartStationModel::Platform::toString() const { + if (history_.empty()) return String(); + + const ETDBatch& batch = history_.back(); + const auto& etds = batch.etds; + if (etds.empty()) return String(); + + char nowBuf[20]; + bartdepart::util::fmt_local(nowBuf, sizeof(nowBuf), batch.ourTs, "%H:%M:%S"); + int lagSecs = batch.ourTs - batch.apiTs; + + String out; + out += nowBuf; + out += ": lag "; + out += lagSecs; + out += ":"; + + time_t prevTs = batch.ourTs; + + for (const auto& e : etds) { + out += " +"; + int diff = e.estDep - prevTs; + out += diff / 60; + out += " ("; + prevTs = e.estDep; + + char depBuf[20]; + bartdepart::util::fmt_local(depBuf, sizeof(depBuf), e.estDep, "%H:%M:%S"); + out += depBuf; + out += ":"; + out += ::toString(e.color); + out += ")"; + } + return out; +} + +time_t BartStationModel::Platform::parseHeaderTimestamp(const char* dateStr, + const char* timeStr) const { + int month=0, day=0, year=0; + int hour=0, min=0, sec=0; + char ampm[3] = {0}; + sscanf(dateStr, "%d/%d/%d", &month, &day, &year); + sscanf(timeStr, "%d:%d:%d %2s", &hour, &min, &sec, ampm); + if (strcasecmp(ampm, "PM") == 0 && hour < 12) hour += 12; + if (strcasecmp(ampm, "AM") == 0 && hour == 12) hour = 0; + struct tm tm{}; + tm.tm_year = year - 1900; + tm.tm_mon = month - 1; + tm.tm_mday = day; + tm.tm_hour = hour; + tm.tm_min = min; + tm.tm_sec = sec; + return mktime(&tm) - bartdepart::util::current_offset(); // return UTC +} + +void BartStationModel::update(std::time_t now, BartStationModel&& delta) { + for (auto &p : delta.platforms) { + auto it = std::find_if(platforms.begin(), platforms.end(), + [&](const Platform& x){ return x.platformId() == p.platformId(); }); + if (it != platforms.end()) { + it->merge(p); + } else { + platforms.push_back(std::move(p)); + } + } +} + +time_t BartStationModel::oldest() const { + time_t oldest = 0; + for (auto const& p : platforms) { + time_t o = p.oldest(); + if (!oldest || (o && o < oldest)) oldest = o; + } + return oldest; +} + +std::vector BartStationModel::destinationsForPlatform(const String& platformId) const { + for (auto const& p : platforms) { + if (p.platformId() == platformId) { + return p.destinations(); + } + } + return {}; +} diff --git a/usermods/usermod_v2_bartdepart/bart_station_model.h b/usermods/usermod_v2_bartdepart/bart_station_model.h new file mode 100644 index 0000000000..2bf604d9a4 --- /dev/null +++ b/usermods/usermod_v2_bartdepart/bart_station_model.h @@ -0,0 +1,47 @@ +#pragma once + +#include "wled.h" + +#include +#include +#include + +#include "train_color.h" + +struct BartStationModel { + struct Platform { + struct ETD { + time_t estDep; + TrainColor color; + }; + struct ETDBatch { + time_t apiTs; + time_t ourTs; + std::vector etds; + }; + + explicit Platform(const String& platformId); + + void update(const JsonObject& root); + void merge(const Platform& other); + time_t oldest() const; + const String& platformId() const; + const std::deque& history() const; + const std::vector& destinations() const; + String toString() const; + + private: + String platformId_; + std::deque history_; + std::vector destinations_; + + // return UTC tstamp + time_t parseHeaderTimestamp(const char* dateStr, const char* timeStr) const; + }; + + std::vector platforms; + + void update(std::time_t now, BartStationModel&& delta); + time_t oldest() const; + std::vector destinationsForPlatform(const String& platformId) const; +}; diff --git a/usermods/usermod_v2_bartdepart/interfaces.h b/usermods/usermod_v2_bartdepart/interfaces.h new file mode 100644 index 0000000000..422afa9105 --- /dev/null +++ b/usermods/usermod_v2_bartdepart/interfaces.h @@ -0,0 +1,59 @@ +#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; + + /// Append config page info, optionally using the latest model data + virtual void appendConfigData(Print& s, const ModelType* model) { + IConfigurable::appendConfigData(s); + } +}; diff --git a/usermods/usermod_v2_bartdepart/legacy_bart_source.cpp b/usermods/usermod_v2_bartdepart/legacy_bart_source.cpp new file mode 100644 index 0000000000..3f8b713c90 --- /dev/null +++ b/usermods/usermod_v2_bartdepart/legacy_bart_source.cpp @@ -0,0 +1,95 @@ +#include "legacy_bart_source.h" +#include "util.h" + +LegacyBartSource::LegacyBartSource() { + client_.setInsecure(); +} + +void LegacyBartSource::reload(std::time_t now) { + nextFetch_ = now; + backoffMult_ = 1; +} + +static String composeUrl(const String& base, const String& key, const String& station) { + String url = base; + url += "&key="; url += key; + url += "&orig="; url += station; + return url; +} + +std::unique_ptr LegacyBartSource::fetch(std::time_t now) { + if (now == 0 || now < nextFetch_) return nullptr; + + String url = composeUrl(apiBase_, apiKey_, apiStation_); + if (!https_.begin(client_, url)) { + https_.end(); + DEBUG_PRINTLN(F("BartDepart: LegacyBartSource::fetch: trouble initiating request")); + nextFetch_ = now + updateSecs_ * backoffMult_; + if (backoffMult_ < 16) backoffMult_ *= 2; + return nullptr; + } + DEBUG_PRINTF("BartDepart: LegacyBartSource::fetch: free heap before GET: %u\n", + ESP.getFreeHeap()); + int httpCode = https_.GET(); + if (httpCode < 200 || httpCode >= 300) { + https_.end(); + DEBUG_PRINTF("BartDepart: LegacyBartSource::fetch: HTTP status not OK: %d\n", httpCode); + nextFetch_ = now + updateSecs_ * backoffMult_; + if (backoffMult_ < 16) backoffMult_ *= 2; + return nullptr; + } + String payload = https_.getString(); + https_.end(); + + size_t jsonSz = payload.length() * 2; + DynamicJsonDocument doc(jsonSz); + auto err = deserializeJson(doc, payload); + if (err) { + nextFetch_ = now + updateSecs_ * backoffMult_; + if (backoffMult_ < 16) backoffMult_ *= 2; + return nullptr; + } + + JsonObject root = doc["root"].as(); + if (root.isNull()) { + nextFetch_ = now + updateSecs_ * backoffMult_; + if (backoffMult_ < 16) backoffMult_ *= 2; + return nullptr; + } + + std::unique_ptr model(new BartStationModel()); + for (const String& pid : platformIds()) { + if (pid.isEmpty()) continue; + BartStationModel::Platform tp(pid); + tp.update(root); + model->platforms.push_back(std::move(tp)); + } + + nextFetch_ = now + updateSecs_; + backoffMult_ = 1; + return model; +} + +void LegacyBartSource::addToConfig(JsonObject& root) { + root["UpdateSecs"] = updateSecs_; + root["ApiBase"] = apiBase_; + root["ApiKey"] = apiKey_; + root["ApiStation"] = apiStation_; +} + +bool LegacyBartSource::readFromConfig(JsonObject& root, bool startup_complete, bool& invalidate_history) { + bool ok = true; + uint16_t prevUpdate = updateSecs_; + String prevBase = apiBase_; + String prevKey = apiKey_; + String prevStation= apiStation_; + + ok &= getJsonValue(root["UpdateSecs"], updateSecs_, 60); + ok &= getJsonValue(root["ApiBase"], apiBase_, apiBase_); + ok &= getJsonValue(root["ApiKey"], apiKey_, apiKey_); + ok &= getJsonValue(root["ApiStation"], apiStation_, apiStation_); + + // Only invalidate when source identity changes (base/station) + invalidate_history |= (apiBase_ != prevBase) || (apiStation_ != prevStation); + return ok; +} diff --git a/usermods/usermod_v2_bartdepart/legacy_bart_source.h b/usermods/usermod_v2_bartdepart/legacy_bart_source.h new file mode 100644 index 0000000000..24f2a96783 --- /dev/null +++ b/usermods/usermod_v2_bartdepart/legacy_bart_source.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include "interfaces.h" +#include "bart_station_model.h" +#include "wled.h" +#include "WiFiClientSecure.h" +#if defined(ARDUINO_ARCH_ESP8266) + #include +#else + #include +#endif + +class LegacyBartSource : public IDataSourceT { +private: + uint16_t updateSecs_ = 60; + String apiBase_ = "https://api.bart.gov/api/etd.aspx?cmd=etd&json=y"; + String apiKey_ = "MW9S-E7SL-26DU-VV8V"; + String apiStation_ = "19th"; + time_t nextFetch_ = 0; + uint8_t backoffMult_ = 1; + WiFiClientSecure client_; + HTTPClient https_; + +public: + LegacyBartSource(); + std::unique_ptr fetch(std::time_t now) override; + std::unique_ptr checkhistory(std::time_t now, std::time_t oldestTstamp) override { return nullptr; } + void reload(std::time_t now) override; + std::string name() const override { return "LegacyBartSource"; } + + void addToConfig(JsonObject& root) override; + bool readFromConfig(JsonObject& root, bool startup_complete, bool& invalidate_history) override; + const char* configKey() const override { return "LegacyBartSource"; } + + uint16_t updateSecs() const { return updateSecs_; } + std::vector platformIds() const { return { "1", "2", "3", "4" }; } +}; diff --git a/usermods/usermod_v2_bartdepart/library.json b/usermods/usermod_v2_bartdepart/library.json new file mode 100644 index 0000000000..f63ffd468c --- /dev/null +++ b/usermods/usermod_v2_bartdepart/library.json @@ -0,0 +1,4 @@ +{ + "name": "usermod_v2_bartdepart", + "build": { "libArchive": false } +} diff --git a/usermods/usermod_v2_bartdepart/platform_view.cpp b/usermods/usermod_v2_bartdepart/platform_view.cpp new file mode 100644 index 0000000000..d640c471d7 --- /dev/null +++ b/usermods/usermod_v2_bartdepart/platform_view.cpp @@ -0,0 +1,130 @@ +#include "platform_view.h" +#include "util.h" +#include "wled.h" + +// helper to map TrainColor enum → CRGB +static CRGB colorFromTrainColor(TrainColor tc) { + switch (tc) { + case TrainColor::Red: + return CRGB(255, 0, 0); + case TrainColor::Orange: + return CRGB(255, 150, 30); + case TrainColor::Yellow: + return CRGB(255, 255, 0); + case TrainColor::Green: + return CRGB(0, 255, 0); + case TrainColor::Blue: + return CRGB(0, 0, 255); + case TrainColor::White: + return CRGB(255, 255, 255); + default: + return CRGB(0, 0, 0); + } +} + +void PlatformView::view(std::time_t now, const BartStationModel &model, + int16_t dbgPixelIndex) { + if (segmentId_ == -1) + return; // disabled + + // find matching platform data first + const BartStationModel::Platform* match = nullptr; + for (const auto &platform : model.platforms) { + if (platform.platformId() == platformId_) { + match = &platform; + break; + } + } + if (!match) + return; + + const auto &history = match->history(); + if (match->platformId().isEmpty()) + return; + if (history.empty()) + return; + + static uint8_t frameCnt = 0; // 0..99 + bool preferFirst = frameCnt < 50; // true for first 0.5 s + frameCnt = (frameCnt + 1) % 100; + + const auto &batch = history.back(); + + if (segmentId_ < 0 || segmentId_ >= strip.getMaxSegments()) + return; + Segment &seg = strip.getSegment((uint8_t)segmentId_); + int len = seg.virtualLength(); + if (len <= 0) + return; + + // Ensure mapping is initialized (mirroring, grouping, reverse, etc.) + seg.beginDraw(); + // Do not alter freeze state during overlay draws + bartdepart::util::FreezeGuard freezeGuard(seg, false); + + auto brightness = [&](uint32_t c) { + return (uint16_t)(((c >> 16) & 0xFF) + ((c >> 8) & 0xFF) + (c & 0xFF)); + }; + + for (int i = 0; i < len; ++i) { + uint32_t bestColor = 0; + uint16_t bestB = 0; + + for (auto &e : batch.etds) { + float diff = float(updateSecs_ + e.estDep - now) * len / 3600.0f; + if (diff < 0.0f || diff >= len) + continue; + + int idx = int(floor(diff)); + float frac = diff - float(idx); + + uint8_t b = 0; + if (i == idx) { + b = uint8_t((1.0f - frac) * 255); + } else if (i == idx + 1) { + b = uint8_t(frac * 255); + } else { + continue; + } + + CRGB col = colorFromTrainColor(e.color); + uint32_t newColor = (((uint32_t)col.r * b / 255) << 16) | + (((uint32_t)col.g * b / 255) << 8) | + ((uint32_t)col.b * b / 255); + + uint16_t newB = brightness(newColor); + if ((preferFirst && (bestColor == 0 || newB > 2 * bestB)) || + (!preferFirst && newB * 2 >= bestB)) { + bestColor = newColor; + bestB = newB; + } + } + + seg.setPixelColor(i, bartdepart::util::blinkDebug(i, dbgPixelIndex, bestColor)); + } +} + +void PlatformView::appendConfigData(Print &s, const BartStationModel *model) { + // 4th arg (pre) = BEFORE input -> keep (-1 disables) right next to the field + // 3rd arg (post) = AFTER input -> show the destinations note, visually separated + s.print(F("addInfo('BartDepart:")); + s.print(configKey()); // e.g. "PlatformView1" + s.print(F(":SegmentId',1,'
")); + if (model) { + auto dests = model->destinationsForPlatform(platformId_); + if (!dests.empty()) { + s.print(F("Destinations: ")); + for (size_t i = 0; i < dests.size(); ++i) { + if (i) s.print(F(", ")); + s.print(dests[i]); + } + } else { + s.print(F("No destinations known")); + } + } else { + s.print(F("No destinations known")); + } + s.print(F("
',")); + s.print(F("' (-1 disables)'")); + s.println(F(");")); +} diff --git a/usermods/usermod_v2_bartdepart/platform_view.h b/usermods/usermod_v2_bartdepart/platform_view.h new file mode 100644 index 0000000000..37c5076f89 --- /dev/null +++ b/usermods/usermod_v2_bartdepart/platform_view.h @@ -0,0 +1,28 @@ +#pragma once + +#include "interfaces.h" +#include "bart_station_model.h" +#include "util.h" + +class PlatformView : public IDataViewT { +private: + uint16_t updateSecs_ = 60; + String platformId_; + int16_t segmentId_ = -1; + std::string configKey_; +public: + PlatformView(const String& platformId) + : platformId_(platformId), segmentId_(-1), + configKey_(std::string("PlatformView") + platformId.c_str()) {} + + void view(std::time_t now, const BartStationModel& model, int16_t dbgPixelIndex) override; + void appendDebugPixel(Print& s) const override { /* empty */ } + void addToConfig(JsonObject& root) override { root["SegmentId"] = segmentId_; } + void appendConfigData(Print& s) override { appendConfigData(s, nullptr); } + void appendConfigData(Print& s, const BartStationModel* model) override; + bool readFromConfig(JsonObject& root, bool startup_complete, bool& invalidate_history) override { + return getJsonValue(root["SegmentId"], segmentId_, segmentId_); + } + const char* configKey() const override { return configKey_.c_str(); } + std::string name() const override { return configKey_; } +}; diff --git a/usermods/usermod_v2_bartdepart/readme.md b/usermods/usermod_v2_bartdepart/readme.md new file mode 100644 index 0000000000..e06756a7ee --- /dev/null +++ b/usermods/usermod_v2_bartdepart/readme.md @@ -0,0 +1,72 @@ +# BARTdepart + +Render upcoming Bay Area Rapid Transit (BART) departures on LED segments. +Each platform gets its own segment; colored markers sweep toward “now” as +departure times approach. + +## Features + +- Virtual‑segment aware rendering (reverse, mirroring, grouping honored). +- Configurable per‑platform segment mapping; disable any platform with `-1`. +- Graceful overlay behavior (no freezing the segment while drawing). +- Simple debug tooling via a blinking debug pixel index. + +## Installation + +Add `usermod_v2_bartdepart` to your PlatformIO environment in +`platformio_override.ini`: + +```ini +custom_usermods = usermod_v2_bartdepart +``` + +Build and flash as usual: +- Build: `pio run -e ` +- Upload: `pio run -e -t upload` + +## Configuration + +Open the WLED web UI → Config → Usermods → “BartDepart”. The following keys are +available. + +- BartDepart.Enabled: enable/disable the module. + +Source: LegacyBartSource (BART ETD API) +- UpdateSecs: fetch interval in seconds (default 60). +- ApiBase: base URL for the ETD endpoint. +- ApiKey: API key. The default demo key works for testing; consider providing + your own key for reliability. +- ApiStation: station abbreviation (e.g., `19th`, `embr`, `mont`). + +Views: PlatformView1 … PlatformView4 +- SegmentId: WLED segment index to render that platform (`-1` disables). + +Notes +- Virtual Segments: Rendering uses `virtualLength()` and `beginDraw()`, so + segment reverse/mirror/group settings are respected automatically. If the + displayed direction seems wrong, toggle the segment’s Reverse or adjust + mapping settings in WLED. +- Multiple Segments: Map different platforms to different segments to visualize + each platform independently. You can also map multiple PlatformViews to the + same segment if you prefer, but overlapping markers will blend. + +## Usage + +1) Assign `SegmentId` for the platform views you want (set others to `-1`). +2) Set `ApiStation` to your station abbreviation; keep `UpdateSecs` at 60 to + start. +3) Save and reboot. After a short boot safety delay, the module fetches ETDs + and starts rendering. Dots represent upcoming trains; their color reflects + the train line. + +## Troubleshooting + +- Nothing shows: Ensure `BartDepart.Enabled = true`, a valid `SegmentId` is set + (and the segment has non‑zero length), and the station abbreviation is + correct. Confirm the controller has Wi‑Fi and time (NTP) sync. +- Direction looks reversed: Toggle the segment’s Reverse setting in WLED. +- Flicker or odd blending: Multiple departures can overlap on adjacent bins; the + renderer favors the brighter color on a short cadence. Increase pixel count or + split platforms across separate segments for more resolution. +- API errors/backoff: On fetch errors the module exponentially backs off up to + 16× `UpdateSecs`. Verify connectivity and API settings. diff --git a/usermods/usermod_v2_bartdepart/train_color.cpp b/usermods/usermod_v2_bartdepart/train_color.cpp new file mode 100644 index 0000000000..c570f11db7 --- /dev/null +++ b/usermods/usermod_v2_bartdepart/train_color.cpp @@ -0,0 +1,15 @@ +#include +#include // for strcasecmp + +#include "train_color.h" + +TrainColor parseTrainColor(const char * name) { + if (!name) return TrainColor::Unknown; + if (strcasecmp(name, "RED")==0) return TrainColor::Red; + else if (strcasecmp(name, "ORANGE")==0) return TrainColor::Orange; + else if (strcasecmp(name, "YELLOW")==0) return TrainColor::Yellow; + else if (strcasecmp(name, "GREEN")==0) return TrainColor::Green; + else if (strcasecmp(name, "BLUE")==0) return TrainColor::Blue; + else if (strcasecmp(name, "WHITE")==0) return TrainColor::White; + return TrainColor::Unknown; +} diff --git a/usermods/usermod_v2_bartdepart/train_color.h b/usermods/usermod_v2_bartdepart/train_color.h new file mode 100644 index 0000000000..e5d6a35886 --- /dev/null +++ b/usermods/usermod_v2_bartdepart/train_color.h @@ -0,0 +1,25 @@ +#pragma once + +enum class TrainColor { + Red, + Orange, + Yellow, + Green, + Blue, + White, + Unknown +}; + +inline const char * toString(TrainColor c) { + switch (c) { + case TrainColor::Red: return "RED"; + case TrainColor::Orange: return "ORANGE"; + case TrainColor::Yellow: return "YELLOW"; + case TrainColor::Green: return "GREEN"; + case TrainColor::Blue: return "BLUE"; + case TrainColor::White: return "WHITE"; + default: return "UNKNOWN"; + } +} + +TrainColor parseTrainColor(const char * name); diff --git a/usermods/usermod_v2_bartdepart/usermod_v2_bartdepart.cpp b/usermods/usermod_v2_bartdepart/usermod_v2_bartdepart.cpp new file mode 100644 index 0000000000..a5dc8eff6e --- /dev/null +++ b/usermods/usermod_v2_bartdepart/usermod_v2_bartdepart.cpp @@ -0,0 +1,156 @@ +#include +#include +#include + +#include "usermod_v2_bartdepart.h" +#include "interfaces.h" +#include "util.h" + +#include "bart_station_model.h" +#include "legacy_bart_source.h" +#include "platform_view.h" + +const char CFG_NAME[] PROGMEM = "BartDepart"; +const char CFG_ENABLED[] PROGMEM = "Enabled"; + +static BartDepart bartdepart_usermod; +REGISTER_USERMOD(bartdepart_usermod); + +// Delay after boot (milliseconds) to allow disabling before heavy work +const uint32_t SAFETY_DELAY_MS = 10u * 1000u; + +BartDepart::BartDepart() { + sources_.reserve(1); + sources_.push_back(::make_unique()); + + model_ = ::make_unique(); + + views_.reserve(4); + views_.push_back(::make_unique("1")); + views_.push_back(::make_unique("2")); + views_.push_back(::make_unique("3")); + views_.push_back(::make_unique("4")); +} + +void BartDepart::setup() { + DEBUG_PRINTLN(F("BartDepart::setup starting")); + uint32_t now_ms = millis(); + safeToStart_ = now_ms + SAFETY_DELAY_MS; + if (enabled_) showBooting(); + state_ = BartDepartState::Setup; + DEBUG_PRINTLN(F("BartDepart::setup finished")); +} + +void BartDepart::loop() { + uint32_t now_ms = millis(); + if (!edgeInit_) { + lastOff_ = offMode; + lastEnabled_ = enabled_; + edgeInit_ = true; + } + + time_t const now = bartdepart::util::time_now_utc(); + + if (state_ == BartDepartState::Setup) { + if (now_ms < safeToStart_) return; + state_ = BartDepartState::Running; + if (enabled_) doneBooting(); + if (now > 0) reloadSources(now); + } + + bool becameOn = (lastOff_ && !offMode); + bool becameEnabled = (!lastEnabled_ && enabled_); + if (becameOn || becameEnabled) { + if (now > 0) reloadSources(now); + } + lastOff_ = offMode; + lastEnabled_ = enabled_; + + if (!enabled_ || offMode || strip.isUpdating()) return; + + for (auto& src : sources_) { + if (auto data = src->fetch(now)) { + model_->update(now, std::move(*data)); + } + if (auto hist = src->checkhistory(now, model_->oldest())) { + model_->update(now, std::move(*hist)); + } + } +} + +void BartDepart::handleOverlayDraw() { + if (!enabled_ || offMode) return; + time_t now = bartdepart::util::time_now_utc(); + for (auto& view : views_) { + view->view(now, *model_, dbgPixelIndex_); + } +} + +void BartDepart::addToConfig(JsonObject& root) { + JsonObject top = root.createNestedObject(FPSTR(CFG_NAME)); + top[FPSTR(CFG_ENABLED)] = enabled_; + for (auto& src : sources_) { + JsonObject sub = top.createNestedObject(src->configKey()); + src->addToConfig(sub); + } + for (auto& vw : views_) { + JsonObject sub = top.createNestedObject(vw->configKey()); + vw->addToConfig(sub); + } +} + +void BartDepart::appendConfigData(Print& s) { + for (auto& src : sources_) { + src->appendConfigData(s); + } + + for (auto& vw : views_) { + vw->appendConfigData(s, model_.get()); + } +} + +bool BartDepart::readFromConfig(JsonObject& root) { + JsonObject top = root[FPSTR(CFG_NAME)]; + if (top.isNull()) return true; + + bool ok = true; + bool invalidate_history = false; + bool startup_complete = state_ == BartDepartState::Running; + + ok &= getJsonValue(top[FPSTR(CFG_ENABLED)], enabled_, false); + + for (auto& src : sources_) { + JsonObject sub = top[src->configKey()]; + ok &= src->readFromConfig(sub, startup_complete, invalidate_history); + } + for (auto& vw : views_) { + JsonObject sub = top[vw->configKey()]; + ok &= vw->readFromConfig(sub, startup_complete, invalidate_history); + } + + if (invalidate_history) { + model_->platforms.clear(); + if (startup_complete) reloadSources(bartdepart::util::time_now_utc()); + } + + return ok; +} + +void BartDepart::showBooting() { + Segment& seg = strip.getMainSegment(); + seg.setMode(28); + seg.speed = 200; + seg.setPalette(128); + seg.setColor(0, 0x404060); + seg.setColor(1, 0x000000); + seg.setColor(2, 0x303040); +} + +void BartDepart::doneBooting() { + Segment& seg = strip.getMainSegment(); + seg.setMode(0); +} + +void BartDepart::reloadSources(std::time_t now) { + for (auto& src : sources_) src->reload(now); +} diff --git a/usermods/usermod_v2_bartdepart/usermod_v2_bartdepart.h b/usermods/usermod_v2_bartdepart/usermod_v2_bartdepart.h new file mode 100644 index 0000000000..05fd49590d --- /dev/null +++ b/usermods/usermod_v2_bartdepart/usermod_v2_bartdepart.h @@ -0,0 +1,54 @@ +#pragma once +#include +#include + +#include "interfaces.h" +#include "wled.h" + +#define BARTDEPART2_VERSION "0.0.1" + +#define USERMOD_ID_BARTDEPART 560 + +class BartStationModel; +class LegacyBartSource; +class PlatformView; + +enum class BartDepartState { + Initial, + Setup, + Running +}; + +class BartDepart : public Usermod { +private: + bool enabled_ = false; + int16_t dbgPixelIndex_ = -1; + BartDepartState state_ = BartDepartState::Initial; + uint32_t safeToStart_ = 0; + bool edgeInit_ = false; + bool lastOff_ = false; + bool lastEnabled_ = false; + + std::vector>> sources_; + std::unique_ptr model_; + std::vector>> views_; + +public: + BartDepart(); + ~BartDepart() 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_BARTDEPART; } + + inline void enable(bool en) { enabled_ = en; } + inline bool isEnabled() { return enabled_; } + +protected: + void showBooting(); + void doneBooting(); + void reloadSources(std::time_t now); +}; diff --git a/usermods/usermod_v2_bartdepart/util.cpp b/usermods/usermod_v2_bartdepart/util.cpp new file mode 100644 index 0000000000..91a72348f4 --- /dev/null +++ b/usermods/usermod_v2_bartdepart/util.cpp @@ -0,0 +1,35 @@ +#include "util.h" + +namespace bartdepart { +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 bartdepart diff --git a/usermods/usermod_v2_bartdepart/util.h b/usermods/usermod_v2_bartdepart/util.h new file mode 100644 index 0000000000..dcdc497413 --- /dev/null +++ b/usermods/usermod_v2_bartdepart/util.h @@ -0,0 +1,89 @@ +#pragma once + +#include "wled.h" +#include +#include +#include + +namespace bartdepart { +namespace util { + +// RAII guard to temporarily freeze a segment during rendering and always +// restore its original freeze state on exit (including early returns). +struct FreezeGuard { + Segment &seg; + bool prev; + explicit FreezeGuard(Segment &s, bool freezeNow = true) : seg(s), prev(s.freeze) { + seg.freeze = freezeNow; + } + ~FreezeGuard() { seg.freeze = prev; } + FreezeGuard(const FreezeGuard &) = delete; + FreezeGuard &operator=(const FreezeGuard &) = delete; +}; + +inline time_t time_now_utc() { return (time_t)toki.getTime().sec; } + +inline time_t time_now() { return time_now_utc(); } + +inline long current_offset() { + long off = (long)localTime - (long)toki.getTime().sec; + if (off < -54000 || off > 54000) + off = 0; + return off; +} + +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); + strftime(out, n, fmt, &tmLocal); +} + +template inline T clamp01(T v) { + return v < T(0) ? T(0) : (v > T(1) ? T(1) : v); +} + +inline double lerp(double a, double b, double t) { return a + (b - a) * t; } + +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 pixel between its color and a gray debug color to mark dbgPixelIndex. +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 bartdepart