diff --git a/usermods/7_seg_clock_countdown/7_seg_clock_countdown.cpp b/usermods/7_seg_clock_countdown/7_seg_clock_countdown.cpp new file mode 100644 index 0000000000..8bf794d302 --- /dev/null +++ b/usermods/7_seg_clock_countdown/7_seg_clock_countdown.cpp @@ -0,0 +1,566 @@ +/* + 7-segment countdown usermod overlay + Renders a 6-digit, two-separator seven-segment display as a transparency mask + over the underlying WLED pixels. Mask bit 1 keeps the original effect/color; + mask bit 0 forces black. +*/ +#include "7_seg_clock_countdown.h" +#include "wled.h" + +/* ===== Static member definitions ===== */ +constexpr uint8_t SevenSegClockCountdown::PHYS_TO_LOG[SevenSegClockCountdown::SEGS_PER_DIGIT]; +constexpr uint8_t SevenSegClockCountdown::DIGIT_MASKS[10]; + +/* ===== Lifecycle ===== */ +// Prepare mask buffer on boot. +void SevenSegClockCountdown::setup() { + ensureMaskSize(); +} + +// Idle loop (no periodic work required). +void SevenSegClockCountdown::loop() { +} + +/* ===== Mask management ===== */ +// Ensure the mask buffer matches the panel size. +void SevenSegClockCountdown::ensureMaskSize() { + if (mask.size() != TOTAL_PANEL_LEDS) + mask.assign(TOTAL_PANEL_LEDS, 0); +} + +// Clear the mask to fully black. +void SevenSegClockCountdown::clearMask() { + std::fill(mask.begin(), mask.end(), 0); +} + +// Set a contiguous LED range in the mask to 1. +void SevenSegClockCountdown::setRangeOn(uint16_t start, uint16_t len) { + for (uint16_t i = 0; i < len; i++) { + uint16_t idx = start + i; + if (idx < mask.size()) + mask[idx] = 1; + } +} + +/* ===== Time helpers ===== */ +// Return hundredths-of-second (0..99); descending in countdown mode. +int SevenSegClockCountdown::getHundredths(int currentSeconds, bool countDown) { + unsigned long now = millis(); + if (currentSeconds != lastSecondValue) { + lastSecondValue = currentSeconds; + lastSecondMillis = now; + return countDown ? 99 : 0; + } + unsigned long delta = now - lastSecondMillis; + int hundredths; + if (countDown) { + hundredths = 99 - (int)(delta / 10); + } else { + hundredths = (int)(delta / 10); + } + if (hundredths < 0) + hundredths = 0; + if (hundredths > 99) + hundredths = 99; + return hundredths; +} + +/* ===== Mode management (save/restore base effect) ===== */ +// Restore previously saved effect mode and parameters. +void SevenSegClockCountdown::revertMode() { + Segment &selseg = strip.getSegment(0); + selseg.setMode(prevMode, false); + selseg.setColor(0, prevColor); + selseg.speed = prevSpeed; + selseg.intensity = prevIntensity; + strip.setBrightness(prevBrightness); +} + +// Save current effect mode and parameters. +void SevenSegClockCountdown::SaveMode() { + Segment &selseg = strip.getSegment(0); + prevMode = selseg.mode; + prevColor = selseg.colors[0]; + prevSpeed = selseg.speed; + prevIntensity = selseg.intensity; + prevBrightness = bri; +} + +// Apply a specific effect mode and parameters. +void SevenSegClockCountdown::setMode(uint8_t mode, uint32_t color, uint8_t speed, uint8_t intensity, uint8_t brightness) { + Segment &selseg = strip.getSegment(0); + selseg.setMode(mode, false); + selseg.setColor(0, color); + selseg.speed = speed; + selseg.intensity = intensity; + strip.setBrightness(brightness); +} + +/* ===== Drawing helpers ===== */ +// Draw HH:MM:SS into the mask with configurable separator behavior. +void SevenSegClockCountdown::drawClock() { + clearMask(); + setDigitInt(0, hour(localTime) / 10); + setDigitInt(1, hour(localTime) % 10); + setDigitInt(2, minute(localTime) / 10); + setDigitInt(3, minute(localTime) % 10); + setDigitInt(4, second(localTime) / 10); + setDigitInt(5, second(localTime) % 10); + + if (SeparatorOn && SeparatorOff) { + if (second(localTime) % 2) { + setSeparator(1, sepsOn); + setSeparator(2, sepsOn); + } + } else if (SeparatorOn) { + setSeparator(1, sepsOn); + setSeparator(2, sepsOn); + } else if (SeparatorOff) { + } else { + if (second(localTime) % 2) { + setSeparator(1, sepsOn); + setSeparator(2, sepsOn); + } + } +} + +// Light segments for a single digit from a numeric value (0..9). +void SevenSegClockCountdown::setDigitInt(uint8_t digitIndex, int8_t value) { + if (digitIndex > 5) + return; + if (value < 0) + return; + uint16_t base = digitBase(digitIndex); + uint8_t bits = DIGIT_MASKS[(uint8_t)value]; + for (uint8_t physSeg = 0; physSeg < SEGS_PER_DIGIT; physSeg++) { + uint8_t logSeg = PHYS_TO_LOG[physSeg]; + bool segOn = (bits >> logSeg) & 0x01; + if (segOn) { + uint16_t segStart = base + (uint16_t)physSeg * LEDS_PER_SEG; + setRangeOn(segStart, LEDS_PER_SEG); + } + } +} + +// Light segments for a single digit using a letter/symbol mask. +void SevenSegClockCountdown::setDigitChar(uint8_t digitIndex, char c) { + if (digitIndex > 5) + return; + uint16_t base = digitBase(digitIndex); + uint8_t bits = LETTER_MASK(c); + for (uint8_t physSeg = 0; physSeg < SEGS_PER_DIGIT; physSeg++) { + uint8_t logSeg = PHYS_TO_LOG[physSeg]; + bool segOn = (bits >> logSeg) & 0x01; + if (segOn) { + uint16_t segStart = base + (uint16_t)physSeg * LEDS_PER_SEG; + setRangeOn(segStart, LEDS_PER_SEG); + } + } +} + +// Light both dots of a separator. +void SevenSegClockCountdown::setSeparator(uint8_t which, bool on) { + uint16_t base = (which == 1) ? sep1Base() : sep2Base(); + if (on) + setRangeOn(base, SEP_LEDS); +} + +// Light a single half (upper/lower) of the separator. +void SevenSegClockCountdown::setSeparatorHalf(uint8_t which, bool upper, bool on) { + uint16_t base = (which == 1) ? sep1Base() : sep2Base(); + uint16_t halfLen = SEP_LEDS / 2; + uint16_t start = base + (upper ? 0 : halfLen); + if (on) + setRangeOn(start, halfLen); +} + +// Apply the mask to the strip (0 → black, 1 → keep pixel). +void SevenSegClockCountdown::applyMaskToStrip() { + uint16_t stripLen = strip.getLengthTotal(); + uint16_t limit = (stripLen < (uint16_t)mask.size()) ? stripLen : (uint16_t)mask.size(); + for (uint16_t i = 0; i < limit; i++) { + if (!mask[i]) + strip.setPixelColor(i, 0); + } +} + +/* ===== Target handling ===== */ +// Clamp target fields and compute targetUnix; optionally log changes. +void SevenSegClockCountdown::validateTarget(bool changed) { + targetYear = clampVal(targetYear, 1970, 2099); + targetMonth = clampVal(targetMonth, 1, 12); + targetDay = clampVal(targetDay, 1, 31); + targetHour = clampVal(targetHour, 0, 23); + targetMinute = clampVal(targetMinute, 0, 59); + tmElements_t tm; + tm.Second = 0; + tm.Minute = targetMinute; + tm.Hour = targetHour; + tm.Day = targetDay; + tm.Month = targetMonth; + tm.Year = CalendarYrToTm(targetYear); + targetUnix = makeTime(tm); + if (changed) { + char buf[24]; + snprintf(buf, sizeof(buf), "%04d-%02u-%02u-%02u-%02u", + targetYear, targetMonth, targetDay, targetHour, targetMinute); + Serial.printf("[7seg] Target changed: %s | unix=%lu\r\n", buf, (unsigned long)targetUnix); + } +} + +/* ===== Countdown rendering ===== */ +// Draw countdown (or count-up when target passed) into the mask. +void SevenSegClockCountdown::drawCountdown() { + clearMask(); + int64_t diff = (int64_t)targetUnix - (int64_t)localTime; + bool countingUp = (diff < 0); + int64_t absDiff = (diff < 0) ? -diff : diff; + if (countingUp && absDiff > 3600) { + if (ModeChanged) { + revertMode(); + ModeChanged = false; + } + drawClock(); + return; + } + if (countingUp) { + Segment &selseg = strip.getSegment(0); + if (!ModeChanged) { + SaveMode(); + setMode(FX_MODE_STATIC, 0xFF0000, 128, 128, 255); + ModeChanged = true; + IgnoreBlinking = false; + } + if (selseg.mode != FX_MODE_STATIC && ModeChanged && !IgnoreBlinking) { + IgnoreBlinking = true; + revertMode(); + } + if (millis() - lastBlink >= countdownBlinkInterval) { + BlinkToggle = !BlinkToggle; + lastBlink = millis(); + } + if (BlinkToggle && !IgnoreBlinking) { + strip.setBrightness(255); + } else if (!BlinkToggle && !IgnoreBlinking) { + strip.setBrightness(0); + } + if (absDiff < 60) { + setDigitChar(0, 'x'); + setDigitChar(1, 'x'); + setDigitInt(2, absDiff / 10); + setDigitInt(3, absDiff % 10); + setDigitChar(4, 'x'); + setDigitChar(5, 'x'); + } else if (absDiff >= 60) { + setDigitChar(0, 'x'); + setDigitChar(1, 'x'); + setDigitInt(2, absDiff / 600); + setDigitInt(3, (absDiff / 60) % 10); + setSeparator(2, sepsOn); + setDigitInt(4, (absDiff % 60) / 10); + setDigitInt(5, (absDiff % 60) % 10); + } + } + + if (!countingUp) { + if (ModeChanged) { + revertMode(); + ModeChanged = false; + } + remDays = (uint32_t)(absDiff / 86400); + remHours = (uint8_t)((absDiff % 86400) / 3600); + remMinutes = (uint8_t)((absDiff % 3600) / 60); + remSeconds = (uint8_t)(absDiff % 60); + fullHours = (uint32_t)(absDiff / 3600); + fullMinutes = (uint32_t)(absDiff / 60); + fullSeconds = (uint32_t)absDiff; + + if (remDays > 999) { + setDigitChar(0, 'o'); + setDigitInt(1, 9); + setDigitInt(2, 9); + setDigitInt(3, 9); + setDigitChar(4, 'd'); + setDigitChar(5, ' '); + } else if (remDays > 99 && remDays <= 999) { + setDigitChar(0, ' '); + setDigitInt(1, (remDays / 100) % 10); + setDigitInt(2, (remDays / 10) % 10); + setDigitInt(3, remDays % 10); + setDigitChar(4, 'd'); + setDigitChar(5, ' '); + } else if (remDays <= 99 && fullHours > 99) { + setDigitInt(0, remDays / 10); + setDigitInt(1, remDays % 10); + setSeparator(1, sepsOn); + setDigitInt(2, remHours / 10); + setDigitInt(3, remHours % 10); + setSeparator(2, sepsOn); + setDigitInt(4, remMinutes / 10); + setDigitInt(5, remMinutes % 10); + } else if (fullHours <= 99 && fullMinutes > 99) { + setDigitInt(0, fullHours / 10); + setDigitInt(1, fullHours % 10); + setSeparator(1, sepsOn); + setDigitInt(2, remMinutes / 10); + setDigitInt(3, remMinutes % 10); + setSeparator(2, sepsOn); + setDigitInt(4, remSeconds / 10); + setDigitInt(5, remSeconds % 10); + } else { + int hs = getHundredths(remSeconds, /*countDown*/ !countingUp); + + setDigitInt(0, fullMinutes / 10); + setDigitInt(1, fullMinutes % 10); + setSeparatorHalf(1, true, sepsOn); + setDigitInt(2, remSeconds / 10); + setDigitInt(3, remSeconds % 10); + setSeparator(2, sepsOn); + setDigitInt(4, hs / 10); + setDigitInt(5, hs % 10); + } + } +} + +/* ===== WLED overlay entrypoint ===== */ +// Draw the overlay (clock, countdown, or alternating) and apply mask. +void SevenSegClockCountdown::handleOverlayDraw() { + if (!enabled) + return; + if (showClock && showCountdown) { + uint32_t period = (alternatingTime > 0) ? (uint32_t)alternatingTime : 10U; + uint32_t block = (uint32_t)localTime / period; + if ((block & 1U) == 0U) + drawClock(); + else + drawCountdown(); + } else if (showClock) { + drawClock(); + } else { + drawCountdown(); + } + applyMaskToStrip(); +}; + +/* ===== UI / JSON state ===== */ +// Add compact info UI elements to the JSON info block. +void SevenSegClockCountdown::addToJsonInfo(JsonObject &root) { + JsonObject user = root["u"]; + if (user.isNull()) + user = root.createNestedObject("u"); + JsonArray infoArr = user.createNestedArray(F("7 Segment Counter")); + String uiDomString = F(""); + infoArr.add(uiDomString); + infoArr = user.createNestedArray(F("Status")); + infoArr.add(enabled ? F("active") : F("disabled")); + infoArr = user.createNestedArray(F("Clock Separators")); + if (SeparatorOn && SeparatorOff) + infoArr.add(F("Blinking")); + else if (SeparatorOn) + infoArr.add(F("Always On")); + else if (SeparatorOff) + infoArr.add(F("Always Off")); + else + infoArr.add(F("Blinking")); + infoArr = user.createNestedArray(F("Mode")); + if (showClock && showCountdown) + infoArr.add(F("Clock & Countdown")); + else if (showClock) + infoArr.add(F("Clock Only")); + else + infoArr.add(F("Countdown Only")); + infoArr = user.createNestedArray(F("Target Date")); + char buf[24]; + snprintf(buf, sizeof(buf), "%04d-%02u-%02u %02u:%02u", + targetYear, targetMonth, targetDay, targetHour, targetMinute); + infoArr.add(buf); + infoArr = user.createNestedArray(F("Total Panel LEDs")); + infoArr.add(TOTAL_PANEL_LEDS); + infoArr.add(F(" px")); +} + +// Serialize usermod state into JSON state. +void SevenSegClockCountdown::addToJsonState(JsonObject &root) { + JsonObject s = root[F("7seg")].as(); + if (s.isNull()) + s = root.createNestedObject(F("7seg")); + s[F("enabled")] = enabled; + s[F("SeparatorOn")] = SeparatorOn; + s[F("SeparatorOff")] = SeparatorOff; + s[F("targetYear")] = targetYear; + s[F("targetMonth")] = targetMonth; + s[F("targetDay")] = targetDay; + s[F("targetHour")] = targetHour; + s[F("targetMinute")] = targetMinute; + s[F("showClock")] = showClock; + s[F("showCountdown")] = showCountdown; + s[F("alternatingTime")] = alternatingTime; +} + +// Read usermod state from JSON state and validate target. +void SevenSegClockCountdown::readFromJsonState(JsonObject &root) { + JsonObject s = root[F("7seg")].as(); + if (s.isNull()) + return; + if (s.containsKey(F("enabled"))) + enabled = s[F("enabled")].as(); + bool changed = false; + if (s.containsKey(F("targetYear"))) { + targetYear = s[F("targetYear")].as(); + changed = true; + } + if (s.containsKey(F("targetMonth"))) { + targetMonth = s[F("targetMonth")].as(); + changed = true; + } + if (s.containsKey(F("targetDay"))) { + targetDay = s[F("targetDay")].as(); + changed = true; + } + if (s.containsKey(F("targetHour"))) { + targetHour = s[F("targetHour")].as(); + changed = true; + } + if (s.containsKey(F("targetMinute"))) { + targetMinute = s[F("targetMinute")].as(); + changed = true; + } + if (s.containsKey(F("showClock"))) + showClock = s[F("showClock")].as(); + if (s.containsKey(F("showCountdown"))) + showCountdown = s[F("showCountdown")].as(); + if (s.containsKey(F("alternatingTime"))) + alternatingTime = s[F("alternatingTime")].as(); + if (s.containsKey(F("SeparatorOn"))) + SeparatorOn = s[F("SeparatorOn")].as(); + if (s.containsKey(F("SeparatorOff"))) + SeparatorOff = s[F("SeparatorOff")].as(); + if (changed) + validateTarget(true); +} + +/* ===== Config persistence ===== */ +// Write persistent configuration to cfg.json. +void SevenSegClockCountdown::addToConfig(JsonObject &root) { + JsonObject top = root.createNestedObject(F("7seg")); + top[F("enabled")] = enabled; + top[F("targetYear")] = targetYear; + top[F("targetMonth")] = targetMonth; + top[F("targetDay")] = targetDay; + top[F("targetHour")] = targetHour; + top[F("targetMinute")] = targetMinute; + top[F("showClock")] = showClock; + top[F("showCountdown")] = showCountdown; + top[F("alternatingTime")] = alternatingTime; + top[F("SeparatorOn")] = SeparatorOn; + top[F("SeparatorOff")] = SeparatorOff; +} + +// Read persistent configuration from cfg.json. +bool SevenSegClockCountdown::readFromConfig(JsonObject &root) { + JsonObject top = root[F("7seg")]; + bool configComplete = !top.isNull(); + configComplete &= getJsonValue(top[F("enabled")], enabled, true); + configComplete &= getJsonValue(top[F("targetYear")], targetYear, year(localTime)); + configComplete &= getJsonValue(top[F("targetMonth")], targetMonth, (uint8_t)1); + configComplete &= getJsonValue(top[F("targetDay")], targetDay, (uint8_t)1); + configComplete &= getJsonValue(top[F("targetHour")], targetHour, (uint8_t)0); + configComplete &= getJsonValue(top[F("targetMinute")], targetMinute, (uint8_t)0); + configComplete &= getJsonValue(top[F("showClock")], showClock, true); + configComplete &= getJsonValue(top[F("showCountdown")], showCountdown, false); + configComplete &= getJsonValue(top[F("alternatingTime")], alternatingTime, (uint16_t)10); + configComplete &= getJsonValue(top[F("SeparatorOn")], SeparatorOn, true); + configComplete &= getJsonValue(top[F("SeparatorOff")], SeparatorOff, true); + validateTarget(true); + return configComplete; +} + +/* ===== MQTT integration ===== */ +// Subscribe to usermod MQTT topic on connect. +void SevenSegClockCountdown::onMqttConnect(bool sessionPresent) { + String topic = mqttDeviceTopic; + topic += "/"; topic += MQTT_Topic; topic += "/#"; + mqtt->subscribe(topic.c_str(), 0); +} + +// Handle usermod MQTT messages (simple key/value). +bool SevenSegClockCountdown::onMqttMessage(char *topic, char *payload) { + String topicStr = String(topic); + String prefix = "/"; prefix += mqttDeviceTopic; prefix += "/"; + if (!topicStr.startsWith(prefix)) + return false; + String subTopic = topicStr.substring(strlen(prefix.c_str())); + if (subTopic.indexOf(F("enabled")) >= 0) { + String payloadStr = String(payload); + enabled = (payloadStr == F("true") || payloadStr == F("1")); + return true; + } + if (subTopic.indexOf(F("targetYear")) >= 0) { + String payloadStr = String(payload); + targetYear = payloadStr.toInt(); + validateTarget(true); + return true; + } + if (subTopic.indexOf(F("targetMonth")) >= 0) { + String payloadStr = String(payload); + targetMonth = (uint8_t)payloadStr.toInt(); + validateTarget(true); + return true; + } + if (subTopic.indexOf(F("targetDay")) >= 0) { + String payloadStr = String(payload); + targetDay = (uint8_t)payloadStr.toInt(); + validateTarget(true); + return true; + } + if (subTopic.indexOf(F("targetHour")) >= 0) { + String payloadStr = String(payload); + targetHour = (uint8_t)payloadStr.toInt(); + validateTarget(true); + return true; + } + if (subTopic.indexOf(F("targetMinute")) >= 0) { + String payloadStr = String(payload); + targetMinute = (uint8_t)payloadStr.toInt(); + validateTarget(true); + return true; + } + if (subTopic.indexOf(F("showClock")) >= 0) { + String payloadStr = String(payload); + showClock = (payloadStr == F("true") || payloadStr == F("1")); + return true; + } + if (subTopic.indexOf(F("showCountdown")) >= 0) { + String payloadStr = String(payload); + showCountdown = (payloadStr == F("true") || payloadStr == F("1")); + return true; + } + if (subTopic.indexOf(F("alternatingTime")) >= 0) { + String payloadStr = String(payload); + alternatingTime = (uint16_t)payloadStr.toInt(); + return true; + } + if (subTopic.indexOf(F("SeparatorOn")) >= 0) { + String payloadStr = String(payload); + SeparatorOn = (payloadStr == F("true") || payloadStr == F("1")); + return true; + } + if (subTopic.indexOf(F("SeparatorOff")) >= 0) { + String payloadStr = String(payload); + SeparatorOff = (payloadStr == F("true") || payloadStr == F("1")); + return true; + } + return false; +} + +/* ===== Identification & registration ===== */ +// Return unique usermod identifier. +uint16_t SevenSegClockCountdown::getId() { return 0x22B8; } + +static SevenSegClockCountdown usermod; +REGISTER_USERMOD(usermod); diff --git a/usermods/7_seg_clock_countdown/7_seg_clock_countdown.h b/usermods/7_seg_clock_countdown/7_seg_clock_countdown.h new file mode 100644 index 0000000000..666b25e0d3 --- /dev/null +++ b/usermods/7_seg_clock_countdown/7_seg_clock_countdown.h @@ -0,0 +1,248 @@ +#pragma once +#include "wled.h" + + +/*-------------------------------------------------------------------------------------------------------------------------------------- + 7-Segment Clock & Countdown Usermod + + Usermod for WLED providing a configurable 7-segment style display + supporting clock and countdown modes. The display is rendered as a + logical overlay using a segment-to-LED mapping and integrates + seamlessly into the WLED usermod framework. + + Creator : DereIBims + Version : 0.1.0 + + Resources: + - 3D-print files (enclosures/mounts) and PCB design files will be available soon: + * 3D-print: MakerWorld + * PCB: GitHub, inside this usermod folder +----------------------------------------------------------------------*/ + +#define SEG_A 0 +#define SEG_B 1 +#define SEG_C 2 +#define SEG_D 3 +#define SEG_E 4 +#define SEG_F 5 +#define SEG_G 6 + +/*-------------------------------Begin modifications down here!!!------------------------ +----------------------------------------------------------------------------------------- +-----------------------------------------------------------------------------------------*/ + +#define MQTT_Topic "7seg" + +class SevenSegClockCountdown : public Usermod { +private: + // Geometry: LED counts for a single digit and the full panel. + // - One digit has 7 segments, each with LEDS_PER_SEG pixels + // - Two separator blocks exist between digit 2/3 and 4/5, each with SEP_LEDS pixels + static const uint16_t VERY_FIRST_LED = 0; + static const uint16_t LEDS_PER_SEG = 5; + static const uint8_t SEGS_PER_DIGIT = 7; + static const uint16_t SEP_LEDS = 10; + static constexpr uint16_t LEDS_PER_DIGIT = LEDS_PER_SEG * SEGS_PER_DIGIT; // 35 + static constexpr uint16_t TOTAL_PANEL_LEDS = 6 * LEDS_PER_DIGIT + 2 * SEP_LEDS; // 230 + + // Physical-to-logical segment mapping per digit: physical order F-A-B-G-E-D-C + static constexpr uint8_t PHYS_TO_LOG[SEGS_PER_DIGIT] = { + SEG_F, + SEG_A, + SEG_B, + SEG_G, + SEG_E, + SEG_D, + SEG_C}; + + // Index helpers into the linear LED stream for each digit and separator block. + static uint16_t digitBase(uint8_t d) { + switch (d) { + case 0: + return VERY_FIRST_LED; // digit 1 + case 1: + return VERY_FIRST_LED + LEDS_PER_DIGIT; // digit 2 + case 2: + return VERY_FIRST_LED + SEP_LEDS + LEDS_PER_DIGIT * 2; // digit 3 + case 3: + return VERY_FIRST_LED + SEP_LEDS + LEDS_PER_DIGIT * 3; // digit 4 + case 4: + return VERY_FIRST_LED + SEP_LEDS * 2 + LEDS_PER_DIGIT * 4; // digit 5 + case 5: + return VERY_FIRST_LED + SEP_LEDS * 2 + LEDS_PER_DIGIT * 5; // digit 6 + default: + return 0; + } + } + static constexpr uint16_t sep1Base() { return 70; } + static constexpr uint16_t sep2Base() { return 150; } + + static const unsigned long countdownBlinkInterval = 500; + + /*-------------------------------------------------------------------------------------- + --------------------------Do NOT edit anything below this line-------------------------- + --------------------------------------------------------------------------------------*/ + + /* ===== Digit bitmasks (A..G bits 0..6) ===== */ + static constexpr uint8_t DIGIT_MASKS[10] = { + 0b00111111, // 0 (A..F) + 0b00000110, // 1 (B,C) + 0b01011011, // 2 (A,B,D,E,G) + 0b01001111, // 3 (A,B,C,D,G) + 0b01100110, // 4 (B,C,F,G) + 0b01101101, // 5 (A,C,D,F,G) + 0b01111101, // 6 (A,C,D,E,F,G) + 0b00000111, // 7 (A,B,C) + 0b01111111, // 8 (A..G) + 0b01101111 // 9 (A,B,C,D,F,G) + }; + + /* ===== Runtime state (mask, flags, variables) ===== */ + std::vector mask; + bool enabled = true; + bool sepsOn = true; + bool SeparatorOn = true; + bool SeparatorOff = true; + + bool showClock = true; + bool showCountdown = false; + uint16_t alternatingTime = 10; + + int targetYear = year(localTime); + uint8_t targetMonth = month(localTime); + uint8_t targetDay = day(localTime); + uint8_t targetHour = 0; + uint8_t targetMinute = 0; + time_t targetUnix = 0; + + uint32_t fullHours = 0; + uint32_t fullMinutes = 0; + uint32_t fullSeconds = 0; + uint32_t remDays = 0; + uint8_t remHours = 0; + uint8_t remMinutes = 0; + uint8_t remSeconds = 0; + + unsigned long lastSecondMillis = 0; + int lastSecondValue = -1; + + bool IgnoreBlinking = false; + bool ModeChanged = false; + uint8_t prevMode = 0; + uint32_t prevColor = 0; + uint8_t prevSpeed = 0; + uint8_t prevIntensity = 0; + uint8_t prevBrightness = 0; + unsigned long lastBlink = 0; + bool BlinkToggle = false; + + // Returns a bitmask A..G (0..6). If the character is unsupported, fallback lights A, D, G. + uint8_t LETTER_MASK(char c) { + switch (c) { + // Uppercase + case 'A': + return 0b01110111; // A,B,C,E,F,G + case 'B': + return 0b01111100; // b-like (C,D,E,F,G) + case 'C': + return 0b00111001; // A,D,E,F + case 'D': + return 0b01011110; // d-like (B,C,D,E,G) + case 'E': + return 0b01111001; // A,D,E,F,G + case 'F': + return 0b01110001; // A,E,F,G + case 'H': + return 0b01110110; // B,C,E,F,G + case 'J': + return 0b00011110; // B,C,D + case 'L': + return 0b00111000; // D,E,F + case 'O': + return 0b00111111; // A,B,C,D,E,F (like '0') + case 'P': + return 0b01110011; // A,B,E,F,G + case 'T': + return 0b01111000; // D,E,F,G + case 'U': + return 0b00111110; // B,C,D,E,F + case 'Y': + return 0b01101110; // B,C,D,F,G + + // Lowercase + case 'a': + return 0b01011111; // A,B,C,D,E,G + case 'b': + return 0b01111100; // C,D,E,F,G + case 'c': + return 0b01011000; // D,E,G + case 'd': + return 0b01011110; // B,C,D,E,G + case 'e': + return 0b01111011; // A,D,E,F,G (with C off) + case 'f': + return 0b01110001; // A,E,F,G + case 'h': + return 0b01110100; // C,E,F,G + case 'j': + return 0b00001110; // B,C,D + case 'l': + return 0b00110000; // E,F + case 'n': + return 0b01010100; // C,E,G + case 'o': + return 0b01011100; // C,D,E,G + case 'r': + return 0b01010000; // E,G + case 't': + return 0b01111000; // D,E,F,G + case 'u': + return 0b00011100; // C,D,E + case 'y': + return 0b01101110; // B,C,D,F,G + case '-': + return 0b01000000; // G + case '_': + return 0b00001000; // D + case ' ': + return 0b00000000; // blank + + default: + return 0b01001001; // fallback: A, D, G + } + } + + static int clampVal(int v, int lo, int hi) { + return (v < lo) ? lo : (v > hi ? hi : v); + } + + /* ===== Private methods ===== */ + void ensureMaskSize(); // ensure mask matches panel size + void clearMask(); // clear mask to transparent + void setRangeOn(uint16_t start, uint16_t len); // set contiguous mask range on + int getHundredths(int currentSeconds, bool countDown); // compute hundredths (0..99) + void drawClock(); // render HH:MM:SS into mask + void revertMode(); // restore previous effect mode + void SaveMode(); // save current effect mode + void setMode(uint8_t mode, uint32_t color, uint8_t speed, uint8_t intensity, uint8_t brightness); // apply effect mode + void drawCountdown(); // render countdown/count-up into mask + void setDigitInt(uint8_t digitIndex, int8_t value); // draw numeric digit + void setDigitChar(uint8_t digitIndex, char c); // draw character/symbol + void setSeparator(uint8_t which, bool on); // set both separator dots + void setSeparatorHalf(uint8_t which, bool upperDot, bool on); // set separator half + void applyMaskToStrip(); // apply mask to physical strip + void validateTarget(bool changed = false); // clamp fields and compute targetUnix + +public: + void setup() override; // prepare usermod + void loop() override; // periodic loop (unused) + void handleOverlayDraw(); // main overlay draw entrypoint + void addToJsonInfo(JsonObject &root) override; // add compact info UI + void addToJsonState(JsonObject &root) override; // serialize state + void readFromJsonState(JsonObject &root) override; // read state + void addToConfig(JsonObject &root) override; // write persistent config + bool readFromConfig(JsonObject &root) override; // read persistent config + void onMqttConnect(bool sessionPresent) override; // subscribe on connect + bool onMqttMessage(char *topic, char *payload) override; // handle mqtt messages + uint16_t getId() override; // usermod id +}; diff --git a/usermods/7_seg_clock_countdown/README.md b/usermods/7_seg_clock_countdown/README.md new file mode 100644 index 0000000000..8313d24c2f --- /dev/null +++ b/usermods/7_seg_clock_countdown/README.md @@ -0,0 +1,171 @@ +# WLED Usermod: 7‑Segment Countdown Overlay + +## Status & resources +- 3D-print files (enclosures/mounts) and PCB design files will be published soon: + - 3D-print: MakerWorld + - PCB: GitHub, in this usermod’s folder +- Active development happens in the WLED fork by DereIBims: + - [github.com/DereIBims/WLED](https://github.com/DereIBims/WLED) + +A usermod that renders a six‑digit, two‑separator seven‑segment display as an overlay mask on top of WLED’s normal effects/colors. Lit “segments” preserve the underlying pixel color; all other pixels are forced to black. This lets you show a clock or a countdown without losing the active effect. + +## What it shows +- Clock: HH:MM:SS with configurable separator behavior (on/off/blink) +- Countdown to a target date/time + - > 999 days: `'o'ddd d` + - > 99 days: `' 'ddd d` + - ≤ 99 days: `dd:hh:mm` + - ≤ 99 hours: `hh:mm:ss` + - ≤ 99 minutes: `MM·SS:hh` (upper dot between minutes and seconds, plus hundredths 00–99) +- If the target time is in the past, it counts up: + - < 60 s after target: `xxSSxx` + - < 60 min after target: `xxMM:SS` + - ≥ 60 min after target: falls back to the current clock + +Hundredths update smoothly within the current second and are clamped to 00–99. + +## Panel geometry (defaults) +- 6 digits, each with 7 segments +- `LEDS_PER_SEG = 5` → 35 LEDs per digit +- 2 separators between digits 2/3 and 4/5 with `SEP_LEDS = 10` each +- Total panel LEDs: `6×35 + 2×10 = 230` + +A physical‑to‑logical segment map (`PHYS_TO_LOG`) allows arbitrary wiring order while you draw by logical segments A..G. + +> Note: The current implementation hard‑codes digit and separator base indices for the defaults above. If you change `LEDS_PER_SEG` or `SEP_LEDS`, update `digitBase()` and `sep1Base()/sep2Base()` accordingly in the header so indices remain correct. + +## How it works (overlay mask) +- The usermod builds a per‑LED mask (vector of 0/1). +- For each frame, digits/separators set mask entries to 1. +- During `applyMaskToStrip()`, pixels with mask 0 are cleared to black; mask 1 keeps the existing effect color. + +## Build and enable +This usermod is located at `usermods/7_seg_clock_countdown` and can be enabled by adding the folder name to your PlatformIO environment’s `custom_usermods` setting. + +- Example: `custom_usermods = 7_seg_clock_countdown` +- To include all usermods, you can also use an environment that sets `custom_usermods = *`. + +> Ensure the device time is correct (NTP/timezone) so clock/countdown display accurately. + +### Required core tweak for smooth hundredths +To guarantee hundredths update every frame without lag, the static effect must not idle the render loop. In `wled00/FX.cpp`, adjust `mode_static()` to always return `FRAMETIME`. This keeps the render loop continuous so hundredths (00–99) refresh in real time. + +## Runtime configuration +All settings live under the `7seg` object in state/config JSON. + +- `enabled` (bool): Master on/off (default: true) +- `showClock` (bool): Show clock view (default: true) +- `showCountdown` (bool): Show countdown view (default: false) +- `alternatingTime` (uint, seconds): When both views are enabled, alternates every N seconds (default: 10) +- `SeperatorOn` (bool): Force separators on in clock mode +- `SeperatorOff` (bool): Force separators off in clock mode + - Both true or both false → blinking separators once per second +- Target date/time (local time): + - `targetYear` (int, 1970–2099) + - `targetMonth` (1–12) + - `targetDay` (1–31) + - `targetHour` (0–23) + - `targetMinute` (0–59) + +On any target change, the module validates/clamps values and recomputes `targetUnix`. + +## JSON API examples +Send JSON to WLED (HTTP or WebSocket). Minimal examples below use HTTP POST to `/json/state`. + +Enable overlay and show clock only: +```json +{ + "7seg": { + "enabled": true, + "showClock": true, + "showCountdown": false, + "SeperatorOn": true, + "SeperatorOff": false + } +} +``` + +Set a countdown target and enable alternating views every 15 seconds: +```json +{ + "7seg": { + "enabled": true, + "targetYear": 2026, + "targetMonth": 1, + "targetDay": 1, + "targetHour": 0, + "targetMinute": 0, + "showClock": true, + "showCountdown": true, + "alternatingTime": 15 + } +} +``` + +Turn the overlay off: +```json +{ "7seg": { "enabled": false } } +``` + +## Custom MQTT API +Publish to your device topic with the `7seg` subpath. Topic format: +- {deviceTopic}/7seg/{key} + +Accepted keys and payloads: +- Boolean keys (payload: "true"/"1" or "false"/"0"): + - enabled + - showClock + - showCountdown + - SeperatorOn + - SeperatorOff +- Numeric keys (payload: integer): + - targetYear (1970–2099) + - targetMonth (1–12) + - targetDay (1–31) + - targetHour (0–23) + - targetMinute (0–59) + - alternatingTime (seconds) + +Examples (mosquitto_pub): +- Enable overlay: + - mosquitto_pub -h -t "wled/mystrip/7seg/enabled" -m "true" +- Clock only, separators always on: + - mosquitto_pub -h -t "wled/mystrip/7seg/showClock" -m "true" + - mosquitto_pub -h -t "wled/mystrip/7seg/showCountdown" -m "false" + - mosquitto_pub -h -t "wled/mystrip/7seg/SeperatorOn" -m "true" + - mosquitto_pub -h -t "wled/mystrip/7seg/SeperatorOff" -m "false" +- Set countdown target to 2026‑01‑01 00:00: + - mosquitto_pub -h -t "wled/mystrip/7seg/targetYear" -m "2026" + - mosquitto_pub -h -t "wled/mystrip/7seg/targetMonth" -m "1" + - mosquitto_pub -h -t "wled/mystrip/7seg/targetDay" -m "1" + - mosquitto_pub -h -t "wled/mystrip/7seg/targetHour" -m "0" + - mosquitto_pub -h -t "wled/mystrip/7seg/targetMinute" -m "0" +- Alternate clock/countdown every 15s: + - mosquitto_pub -h -t "wled/mystrip/7seg/alternatingTime" -m "15" + +Notes: +- Device topic is the WLED “Device Topic” you configured (e.g. wled/mystrip). +- Payloads are simple strings; booleans accept "true"/"1" and "false"/"0". + +## UI integration +- The usermod adds a compact block to the “Info” screen: + - A small toggle button to enable/disable the overlay + - Status line showing active/disabled + - Clock separator policy (On / Off / Blinking) + - Mode (Clock / Countdown / Both) + - Target date and total panel LEDs + +## Time source +The usermod uses WLED’s `localTime`. Configure NTP and timezone in WLED so the clock and countdown are correct. + +## Notes and limitations +- This is an overlay: it does not draw its own colors. +- For non‑default panel geometries, update the index helpers as noted above. +- Separator half‑lighting is used to place an upper dot between minutes and seconds in the ≤99‑minute view. + +## Files +- `usermods/7_seg_clock_countdown/7_seg_clock_countdown.h` — constants, geometry, masks, helpers +- `usermods/7_seg_clock_countdown/7_seg_clock_countdown.cpp` — rendering, JSON/config wiring, registration + +## License +This usermod follows the WLED project license (see repository `LICENSE`). diff --git a/usermods/7_seg_clock_countdown/library.json b/usermods/7_seg_clock_countdown/library.json new file mode 100644 index 0000000000..59d224d92c --- /dev/null +++ b/usermods/7_seg_clock_countdown/library.json @@ -0,0 +1,13 @@ +{ + "name": "7_seg_clock_countdown", + "version": "0.1.0", + "description": "7-segment clock and countdown", + "authors": [ + { + "name": "DereIBims" + } + ], + "frameworks": ["arduino"], + "platforms": ["espressif32"], + "build": { "libArchive": false } +}