From 174f3fc8222d92b3a354b8f09d45a3b3fcb3717a Mon Sep 17 00:00:00 2001 From: Patrick Atoon Date: Mon, 7 Sep 2026 14:59:23 +0200 Subject: [PATCH 01/44] Add a more configurable wordclock usermod --- .../usermod_v2_word_clock_nl/library.json | 4 + usermods/usermod_v2_word_clock_nl/readme.md | 101 +++ .../usermod_v2_word_clock_nl.cpp | 590 ++++++++++++++ .../woordklok-matrix-generator.html | 749 ++++++++++++++++++ wled00/const.h | 1 + 5 files changed, 1445 insertions(+) create mode 100644 usermods/usermod_v2_word_clock_nl/library.json create mode 100644 usermods/usermod_v2_word_clock_nl/readme.md create mode 100644 usermods/usermod_v2_word_clock_nl/usermod_v2_word_clock_nl.cpp create mode 100644 usermods/usermod_v2_word_clock_nl/woordklok-matrix-generator.html diff --git a/usermods/usermod_v2_word_clock_nl/library.json b/usermods/usermod_v2_word_clock_nl/library.json new file mode 100644 index 0000000000..3f7935cc98 --- /dev/null +++ b/usermods/usermod_v2_word_clock_nl/library.json @@ -0,0 +1,4 @@ +{ + "name": "usermod_v2_word_clock_nl", + "build": { "libArchive": false } +} \ No newline at end of file diff --git a/usermods/usermod_v2_word_clock_nl/readme.md b/usermods/usermod_v2_word_clock_nl/readme.md new file mode 100644 index 0000000000..bd4df351c7 --- /dev/null +++ b/usermods/usermod_v2_word_clock_nl/readme.md @@ -0,0 +1,101 @@ +# Word Clock NL Usermod V2 + +This usermod turns a grid of LEDs into a Dutch word clock. Each LED sits behind +one letter in a matrix of seemingly random characters. The letters form words +that can be lit to show the current time. Here is an example 12x12 matrix: + + NEUEHETHETHT + NFEYIEISISVT + VIJFKWARTNAA + AGBETIENOAEE + AINOVERVOORA + TFUHALFIEENY + OEHIBUZEVENV + NBNMTWEEELFN + DRIEVIERVIJF + NEGENZESTIEN + TWAALFACHTNT + BXNHWEUUROAD + +The matrix contains hidden words that can be combined into Dutch sentences +describing the time in five-minute steps, such as "HET IS TIEN OVER HALF +ZEVEN". The usermod brightens the letters needed for the current sentence and +darkens the other letters. + +This is what the same 12x12 matrix would look like at 6:40: + + .......HET.. + ......IS.... + ............ + ....TIEN.... + ...OVER..... + ...HALF..... + ......ZEVEN. + ............ + ............ + ............ + ............ + ............ + +The usermod does not choose the LED colors. WLED continues to control the +colors and effects; this usermod only changes their brightness based on the +time. + +These settings control the word clock: + + * `Active`: turn the word clock on or off. + * `Brightness Active`: brightness of the letters used for the current time. Use 0 for off and 255 for full brightness. + * `Brightness Inactive`: brightness of the other letters. Use 0 for off and 255 for full brightness. + * `Meander`: set to `false` when the LED strip runs left to right on every row. Set to `true` when each row alternates direction. + * `Character Matrix`: the uppercase letters in your clock face. Include all the words needed to display the Dutch time sentences, with each row placed directly after the previous row. + * `Character Matrix Width`: the number of letters in each row. It cannot be greater than the total number of letters or smaller than the longest word the clock needs to display. + * `Matrix Char Offset`: the number of letters to skip at the beginning of the matrix when matching letters to LEDs. This is useful when the first physical LEDs do not correspond to the first letters. + * `Test Hour`: the hour to display for testing, from 0 to 23. Set it to -1 to use the real time. + * `Test Minute`: the minute to display for testing, from 0 to 59. + +Words are highlighted only when they are found and fit completely within one +matrix row. The clock rounds the current time to the nearest five minutes. + + +## Installation + +1. Copy `platformio_override.sample.ini` from the main WLED folder to + `platformio_override.ini`. Update the board and serial port settings to + match your hardware. For example: + + [platformio] + default_envs = wordclock_nl + + [env:wordclock_nl] + extends = env:esp32dev + # Use `ls /dev/cu.*` to find the correct port for your connected board + upload_port = /dev/cu.wchusbserial123 + upload_speed = 921600 + monitor_port = /dev/cu.wchusbserial123 + monitor_speed = 115200 + custom_usermods = ${env:esp32dev.custom_usermods} usermod_v2_word_clock_nl + +2. Make sure `USERMOD_ID_WORDCLOCK_NL` is defined in `wled00/const.h`. + +3. Build WLED and upload it to your controller: + + npm run build + pio run -e wordclock_nl --target upload + +4. Open WLED and activate the usermod at Config > Usermods > Word Clock NL. + + +## Customization + +This usermod is designed for Dutch and uses the character matrix from the +usermod settings. To create a custom matrix, open +`woordklok-matrix-generator.html`. When the matrix is ready, click +"KOPIEER TEXT" and paste the result into `Character Matrix` in +WLED > Config > Usermods > Word Clock NL. Remove all line breaks, and set +`Character Matrix Width` to the number of columns in each row. + +Using a language other than Dutch requires changes to the code that creates and +matches time sentences in both `woordklok-matrix-generator.html` and +`usermod_v2_word_clock_nl.cpp`. This is an advanced customization. Test the +result thoroughly with the HTML generator so that every supported time is +displayed correctly. diff --git a/usermods/usermod_v2_word_clock_nl/usermod_v2_word_clock_nl.cpp b/usermods/usermod_v2_word_clock_nl/usermod_v2_word_clock_nl.cpp new file mode 100644 index 0000000000..10e46f20ee --- /dev/null +++ b/usermods/usermod_v2_word_clock_nl/usermod_v2_word_clock_nl.cpp @@ -0,0 +1,590 @@ +#include "wled.h" + +/* + * Word Clock (Dutch) + * This is a usermod for the WLED project that displays the time in words in + * Dutch. It uses the WLED V2 usermod API to integrate with the WLED system and + * apply an overlay on the LED strip to light up the characters representing + * the current time in Dutch. This assumes that each LED of the LED strip is + * arranged behind a matrix of characters, lighting up one character per LED, + * and that the character matrix contains all words needed to display the time + * in a Dutch sentence such as "HET IS KWART VOOR DRIE". + * + * These settings are available in the Config > Usermods > Word Clock NL + * Settings page: + * + * - `Active`: turn the word clock on or off. + * - `Brightness Active`: brightness of the letters used for the current + * time. Use 0 for off and 255 for full brightness. + * - `Brightness Inactive`: brightness of the other letters. Use 0 for off + * and 255 for full brightness. + * - `Meander`: set to `false` when the LED strip runs left to right on + * every row. Set to `true` when each row alternates direction. + * - `Character Matrix`: the uppercase letters in your clock face. Include + * all the words needed to display the Dutch time sentences, with each + * row placed directly after the previous row. + * - `Character Matrix Width`: the number of letters in each row. It cannot + * be greater than the total number of letters or smaller than the longest + * word the clock needs to display. + * - `Matrix Char Offset`: the number of letters to skip at the beginning of + * the matrix when matching letters to LEDs. This is useful when the first + * physical LEDs do not correspond to the first letters. + * - `Test Hour`: the hour to display for testing, from 0 to 23. Set it to + * -1 to use the real time. + * - `Test Minute`: the minute to display for testing, from 0 to 59. + */ + +class WordClockNlUsermod : public Usermod +{ +private: + // The matrix of characters that can be highlighted to display the time. + // This matrix must contain all words needed to display the time in a + // Dutch sentence, e.g. "HET IS KWART OVER TIEN". + // The characters of each row are stored sequentially, and the rows are + // stored sequentially as well, left to right, and top to bottom. + String characterMatrix = "NEUEHETHETHTNFEYIEISISVTVIJFKWARTNAAAGBETIENOAEEAINOVERVOORATFUHALFIEENYOEHIBUZEVENVNBNMTWEEELFNDRIEVIERVIJFNEGENZESTIENTWAALFACHTNTBXNHWEUUROAD"; + + // The number of characters per row + int characterMatrixWidth = 12; + + // Is the ledstrip always from left to right on each row, or does it + // meander through the rows (i.e. go from left to right on the first row, + // then continue right to left on the second row, and so on)? + bool meander = true; + + // Keep track of the last time our loop executed. + // Initialised to trigger an update on the very first loop() call. + unsigned long lastTime = ULONG_MAX - 60000UL; + + // ledMask[i] is true if the LED at index i should be on for the current time, and false if it should be off + bool* ledMask = nullptr; + + + // The words for the hours in Dutch, used to construct the sentences + // representing the time. 0 = twaalf uur, 1 = een uur, etc. + // Use PROGMEM as per the coding guidelines for usermods. + const char* sOne PROGMEM = "EEN"; + const char* sTwo PROGMEM = "TWEE"; + const char* sThree PROGMEM = "DRIE"; + const char* sFour PROGMEM = "VIER"; + const char* sFive PROGMEM = "VIJF"; + const char* sSix PROGMEM = "ZES"; + const char* sSeven PROGMEM = "ZEVEN"; + const char* sEight PROGMEM = "ACHT"; + const char* sNine PROGMEM = "NEGEN"; + const char* sTen PROGMEM = "TIEN"; + const char* sEleven PROGMEM = "ELF"; + const char* sTwelve PROGMEM = "TWAALF"; + + // Other things needed to form complete sentences + const char* sIt PROGMEM = "HET"; + const char* sIs PROGMEM = "IS"; + const char* sPast PROGMEM = "OVER"; + const char* sTo PROGMEM = "VOOR"; + const char* sHalf PROGMEM = "HALF"; + const char* sQuarter PROGMEM = "KWART"; + const char* sHour PROGMEM = "UUR"; + const char* sSpace PROGMEM = " "; + + const char* const HOUR_WORDS[12] PROGMEM = {sOne, sTwo, sThree, sFour, sFive, sSix, sSeven, sEight, sNine, sTen, sEleven, sTwelve}; + + // Set your config variables to their boot default value (this can also be done in readFromConfig() or a constructor if you prefer) + + // Is this usermod active? + bool usermodActive = false; + + // Number of leds before the first character + int ledOffset = 0; + + // Opacity (0=off, 255=full brightness) applied to LEDs that ARE part of the current time sentence. + int opacityActive = 255; + + // Opacity (0=off, 255=full brightness) applied to LEDs that are NOT part of the current time sentence. + int opacityInactive = 0; + + // Number of characters to skip at the start of the matrix when mapping to physical LEDs. + // E.g. set to 10 to skip the first row, so matrix char 10 maps to physical LED ledOffset+0. + int matrixCharOffset = 0; + + // Test time override: set testHour (0‥23) and testMinute (0‥59) to force a specific time to be + // displayed instead of the real time. Set testHour to -1 to disable (use real time). + int testHour = -1; + int testMinute = 0; + + /* + * Get the Dutch sentence representing the given the time in minutes, e.g. "HET IS KWART OVER TIEN". + * totalMinutes: 0.‥1439 + */ + String getSentenceForMinutes(int totalMinutes) { + int h = (totalMinutes / 60) % 12; // 0‥.11 (0 = twaalf uur) + int m = totalMinutes % 60; + + // Round to nearest 5 minutes + m = ((m + 2) / 5) * 5; + + // hourIndex: index of the current clock hour in HOUR_WORDS + int hourIndex = (h - 1 + 12) % 12; + + // nextHourIndex: the hour after that (used for half/voor constructions) + int nextHourIndex = h % 12; + + if (m == 0) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(HOUR_WORDS[hourIndex]) + FPSTR(sSpace) + FPSTR(sHour); + if (m == 5) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sFive) + FPSTR(sSpace) + FPSTR(sPast) + FPSTR(sSpace) + FPSTR(HOUR_WORDS[hourIndex]); + if (m == 10) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sTen) + FPSTR(sSpace) + FPSTR(sPast) + FPSTR(sSpace) + FPSTR(HOUR_WORDS[hourIndex]); + if (m == 15) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sQuarter) + FPSTR(sSpace) + FPSTR(sPast) + FPSTR(sSpace) + FPSTR(HOUR_WORDS[hourIndex]); + if (m == 20) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sTen) + FPSTR(sSpace) + FPSTR(sTo) + FPSTR(sSpace) + FPSTR(sHalf) + FPSTR(sSpace) + FPSTR(HOUR_WORDS[nextHourIndex]); + if (m == 25) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sFive) + FPSTR(sSpace) + FPSTR(sTo) + FPSTR(sSpace) + FPSTR(sHalf) + FPSTR(sSpace) + FPSTR(HOUR_WORDS[nextHourIndex]); + if (m == 30) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sHalf) + FPSTR(sSpace) + FPSTR(HOUR_WORDS[nextHourIndex]); + if (m == 35) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sFive) + FPSTR(sSpace) + FPSTR(sPast) + FPSTR(sSpace) + FPSTR(sHalf) + FPSTR(sSpace) + FPSTR(HOUR_WORDS[nextHourIndex]); + if (m == 40) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sTen) + FPSTR(sSpace) + FPSTR(sPast) + FPSTR(sSpace) + FPSTR(sHalf) + FPSTR(sSpace) + FPSTR(HOUR_WORDS[nextHourIndex]); + if (m == 45) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sQuarter) + FPSTR(sSpace) + FPSTR(sTo) + FPSTR(sSpace) + FPSTR(HOUR_WORDS[nextHourIndex]); + if (m == 50) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sTen) + FPSTR(sSpace) + FPSTR(sTo) + FPSTR(sSpace) + FPSTR(HOUR_WORDS[nextHourIndex]); + if (m == 55) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sFive) + FPSTR(sSpace) + FPSTR(sTo) + FPSTR(sSpace) + FPSTR(HOUR_WORDS[nextHourIndex]); + + return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(HOUR_WORDS[hourIndex]); + } + + String lastSentence = ""; + + /* + * Update the ledMask for the current time, by setting ledMask[i] to true if + * the LED at index i should be on for the current time, and false if it + * should be off. + */ + bool wordFitsInRow(int pos, int len) { + return (pos / characterMatrixWidth) == ((pos + len - 1) / characterMatrixWidth); + } + + // Clamp a value to the inclusive [lo, hi] range. + int clampInt(int v, int lo, int hi) { + return v < lo ? lo : (v > hi ? hi : v); + } + + // Length of the longest word that can appear in a sentence; a row must be at least this wide to ever fit a word. + int getMaxWordLength() { + int maxLen = 0; + + for (int i = 0; i < 12; i++) { + int len = strlen(HOUR_WORDS[i]); + if (len > maxLen) maxLen = len; + } + + const char* others[] = {sIt, sIs, sPast, sTo, sHalf, sQuarter, sHour}; + + for (int i = 0; i < 7; i++) { + int len = strlen(others[i]); + if (len > maxLen) maxLen = len; + } + + return maxLen; + } + + void updateLedMaskForCurrentTime() { + int nrOfLeds = characterMatrix.length(); + + // Use test time if set, otherwise use the real local time + int currentMinutes; + + if (testHour >= 0) { + currentMinutes = (testHour * 60 + testMinute) % 1440; + } else { + currentMinutes = (hour(localTime) * 60 + minute(localTime)) % 1440; + } + + // Get the sentence to display for the current time + String sentence = getSentenceForMinutes(currentMinutes); + + if (sentence.equals(lastSentence)) + return; // No need to update the mask if the sentence hasn't changed since the last update + + // Remember the current sentence for the next update + lastSentence = sentence; + + // Erase the ledMask before recomputing it + if (ledMask) + memset(ledMask, 0, nrOfLeds * sizeof(bool)); + + // Split the sentence into words and for each word, find the next + // occurrence of that word in the characterMatrix and update the + // corresponding ledMask values. + int searchFromIndex = 0; + int sentenceLength = sentence.length(); + + for (int i = 0; i < sentenceLength; ) { + // Extract the next word from the sentence + int nextSpaceIndex = sentence.indexOf(' ', i); + + if (nextSpaceIndex == -1) + nextSpaceIndex = sentenceLength; + + String word = sentence.substring(i, nextSpaceIndex); + i = nextSpaceIndex + 1; + + // Find the occurrence of the word to highlight. + // "HET" and "IS" appear multiple times in the matrix for visual variety; + // pick a random occurrence each update so the same physical LEDs are not + // always lit for these two fixed words. + // All other words are found sequentially (so e.g. "VIJF OVER VIJF" lights + // the minute VIJF first, then the hour VIJF second). + int wordIndex; + bool advanceSearchFrom = true; + + if (word == FPSTR(sIt) || word == FPSTR(sIs)) { + // Count how many times the word appears in the matrix, ignoring + // occurrences that would be split across two rows + int count = 0; + int pos = 0; + + while ((pos = characterMatrix.indexOf(word, pos)) != -1) { + if (wordFitsInRow(pos, word.length())) { + count++; + pos += word.length(); + } else { + pos += 1; + } + } + + // Pick a random occurrence (stays at 0 when only one occurrence exists) + int pick = (count > 1) ? (int)random(count) : 0; + int seen = 0; + pos = 0; + wordIndex = -1; + + while ((pos = characterMatrix.indexOf(word, pos)) != -1) { + if (wordFitsInRow(pos, word.length())) { + if (seen == pick) { + wordIndex = pos; + break; + } + seen++; + pos += word.length(); + } else { + pos += 1; + } + } + // Do not advance searchFromIndex: HET and IS are independent of word order + advanceSearchFrom = false; + } else { + wordIndex = searchFromIndex; + + while ((wordIndex = characterMatrix.indexOf(word, wordIndex)) != -1) { + if (wordFitsInRow(wordIndex, word.length())) + break; + wordIndex += 1; + } + } + + if (wordIndex == -1) { + // This should never happen if the characterMatrix contains all words + // needed to display the time in Dutch. Note that the words "VIJF" and + // "TIEN" have to occur multiple times in the characterMatrix, e.g. to + // display "VIJF OVER VIJF" or "TIEN VOOR TIEN". + Serial.println("Error: word not found in characterMatrix: " + word); + continue; + } + + // Update the ledMask values for this word + for (int j = 0; j < word.length(); j++) { + int charIndex = wordIndex + j; + + if (charIndex >= (int)characterMatrix.length()) { + Serial.println("Error: character index out of bounds: " + String(charIndex)); + continue; + } + + if (!meander) { + // If the ledstrip is always from left to right on each row, then the + // character index is the same as the led index + ledMask[charIndex] = true; + } else { + // If the ledstrip meanders through the rows, we need to convert the + // character index to a led index + int row = charIndex / characterMatrixWidth; + int col = charIndex % characterMatrixWidth; + int ledIndex; + + if (row % 2 == 0) { + // Even row: left to right + ledIndex = row * characterMatrixWidth + col; + } else { + // Odd row: right to left + ledIndex = row * characterMatrixWidth + (characterMatrixWidth - 1 - col); + } + + ledMask[ledIndex] = true; + } + } + + if (advanceSearchFrom) + searchFromIndex = wordIndex + word.length(); + } + }; + +public: + // Functions called by WLED + + /* + * setup() is called once at boot. WiFi is not yet connected at this point. + * You can use it to initialize variables, sensors or similar. + */ + void setup() { + // Initialize the ledMask with false values + ledMask = (bool*) d_malloc(characterMatrix.length() * sizeof(bool)); + + if (ledMask) + memset(ledMask, 0, characterMatrix.length() * sizeof(bool)); + } + + /* + * connected() is called every time the WiFi is (re)connected + * Use it to initialize network interfaces + */ + void connected() { + } + + /* + * loop() is called continuously. Here you can check for events, read sensors, etc. + * + * Tips: + * 1. You can use "if (WLED_CONNECTED)" to check for a successful network connection. + * Additionally, "if (WLED_MQTT_CONNECTED)" is available to check for a connection to an MQTT broker. + * + * 2. Try to avoid using the delay() function. NEVER use delays longer than 10 milliseconds. + * Instead, use a timer check as shown here. + */ + void loop() { + // Execute only once per minute + if (millis() - lastTime < 60 * 1000) + return; + + updateLedMaskForCurrentTime(); + + // Remember this update + lastTime = millis(); + } + + /* + * addToJsonInfo() can be used to add custom entries to the /json/info part of the JSON API. + * Creating an "u" object allows you to add custom key/value pairs to the Info section of the WLED web UI. + * Below it is shown how this could be used for e.g. a light sensor + */ + void addToJsonInfo(JsonObject& root) { + JsonObject user = root["u"]; + if (user.isNull()) user = root.createNestedObject("u"); + + // Current localTime so you can verify NTP has synced + char timeBuf[16]; + snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d:%02d", + hour(localTime), minute(localTime), second(localTime)); + user[F("WClock localTime")] = timeBuf; + + // The Dutch sentence currently displayed. Skip the constant "HET IS " prefix (7 chars) + // so the two visible characters in the UI show the variable part (e.g. "TIEN OVER DRIE"). + user[F("WClock sentence")] = lastSentence.length() > 7 ? lastSentence.substring(7) : (lastSentence.isEmpty() ? F("(not computed yet)") : lastSentence); + + // How many matrix LEDs are currently lit + int litCount = 0; + + if (ledMask) { + for (int i = 0; i < (int)characterMatrix.length(); i++) + if (ledMask[i]) litCount++; + } + + user[F("WClock lit LEDs")] = litCount; + } + + /* + * addToJsonState() can be used to add custom entries to the /json/state part of the JSON API (state object). + * Values in the state object may be modified by connected clients + */ + void addToJsonState(JsonObject &root) { + } + + /* + * readFromJsonState() can be used to receive data clients send to the /json/state part of the JSON API (state object). + * Values in the state object may be modified by connected clients + */ + void readFromJsonState(JsonObject &root) { + } + + /* + * addToConfig() can be used to add custom persistent settings to the cfg.json file in the "um" (usermod) object. + * It will be called by WLED when settings are actually saved (for example, LED settings are saved) + * If you want to force saving the current state, use serializeConfig() in your loop(). + * + * CAUTION: serializeConfig() will initiate a filesystem write operation. + * It might cause the LEDs to stutter and will cause flash wear if called too often. + * Use it sparingly and always in the loop, never in network callbacks! + * + * addToConfig() will make your settings editable through the Usermod Settings page automatically. + * + * Usermod Settings Overview: + * - Numeric values are treated as floats in the browser. + * - If the numeric value entered into the browser contains a decimal point, it will be parsed as a C float + * before being returned to the Usermod. The float data type has only 6-7 decimal digits of precision, and + * doubles are not supported, numbers will be rounded to the nearest float value when being parsed. + * The range accepted by the input field is +/- 1.175494351e-38 to +/- 3.402823466e+38. + * - If the numeric value entered into the browser doesn't contain a decimal point, it will be parsed as a + * C int32_t (range: -2147483648 to 2147483647) before being returned to the usermod. + * Overflows or underflows are truncated to the max/min value for an int32_t, and again truncated to the type + * used in the Usermod when reading the value from ArduinoJson. + * - Pin values can be treated differently from an integer value by using the key name "pin" + * - "pin" can contain a single or array of integer values + * - On the Usermod Settings page there is simple checking for pin conflicts and warnings for special pins + * - Red color indicates a conflict. Yellow color indicates a pin with a warning (e.g. an input-only pin) + * - Tip: use int8_t to store the pin value in the Usermod, so a -1 value (pin not set) can be used + * + * See usermod_v2_auto_save.h for an example that saves Flash space by reusing ArduinoJson key name strings + * + * If you need a dedicated settings page with custom layout for your Usermod, that takes a lot more work. + * You will have to add the setting to the HTML, xml.cpp and set.cpp manually. + * See the WLED Soundreactive fork (code and wiki) for reference. https://github.com/atuline/WLED + * + * I highly recommend checking out the basics of ArduinoJson serialization and deserialization in order to use custom settings! + */ + void addToConfig(JsonObject &root) { + JsonObject top = root.createNestedObject(F("Word Clock NL")); + top[F("active")] = usermodActive; + top[F("Brightness_Active")] = opacityActive; + top[F("Brightness_Inactive")] = opacityInactive; + top[F("meander")] = meander; + top[F("Character_Matrix")] = characterMatrix; + top[F("Character_Matrix_Width")] = characterMatrixWidth; + top[F("Matrix_Char_Offset")] = matrixCharOffset; + top[F("Test_Hour")] = testHour; + top[F("Test_Minute")] = testMinute; + } + + void appendConfigData() { + // Add hints for the Usermod Settings page, so the user knows what the settings mean + oappend(F("addInfo('Word Clock NL:Brightness_Active', 1, '(0-255)');")); + oappend(F("addInfo('Word Clock NL:Brightness_Inactive', 1, '(0-255)');")); + oappend(F("addInfo('Word Clock NL:Test_Hour', 1, '(0-23, -1 for real time)');")); + oappend(F("addInfo('Word Clock NL:Test_Minute', 1, '(0-59)');")); + // oappend(F("addInfo('Word clock NL:ledOffset', 1, 'Number of LEDs before the letters');")); + } + + /* + * readFromConfig() can be used to read back the custom settings you added with addToConfig(). + * This is called by WLED when settings are loaded (currently this only happens immediately after boot, or after saving on the Usermod Settings page) + * + * readFromConfig() is called BEFORE setup(). This means you can use your persistent values in setup() (e.g. pin assignments, buffer sizes), + * but also that if you want to write persistent values to a dynamic buffer, you'd need to allocate it here instead of in setup. + * If you don't know what that is, don't fret. It most likely doesn't affect your use case :) + * + * Return true in case the config values returned from Usermod Settings were complete, or false if you'd like WLED to save your defaults to disk (so any missing values are editable in Usermod Settings) + * + * getJsonValue() returns false if the value is missing, or copies the value into the variable provided and returns true if the value is present + * The configComplete variable is true only if the "exampleUsermod" object and all values are present. If any values are missing, WLED will know to call addToConfig() to save them + * + * This function is guaranteed to be called on boot, but could also be called every time settings are updated + */ + bool readFromConfig(JsonObject &root) { + // default settings values could be set here (or below using the 3-argument getJsonValue()) instead of in the class definition or constructor + // setting them inside readFromConfig() is slightly more robust, handling the rare but plausible use case of single value being missing after boot (e.g. if the cfg.json was manually edited and a value was removed) + + JsonObject top = root[F("Word Clock NL")]; + + bool configComplete = !top.isNull(); + + configComplete &= getJsonValue(top[F("active")], usermodActive); + getJsonValue(top[F("Brightness_Active")], opacityActive); + getJsonValue(top[F("Brightness_Inactive")], opacityInactive); + opacityActive = clampInt(opacityActive, 0, 255); + opacityInactive = clampInt(opacityInactive, 0, 255); + bool prevMeander = meander; + getJsonValue(top[F("meander")], meander); + + if (meander != prevMeander) { + lastSentence = ""; // force mask recompute + lastTime = ULONG_MAX - 60000UL; // trigger recompute on very next loop() call + } + + String prevCharacterMatrix = characterMatrix; + getJsonValue(top[F("Character_Matrix")], characterMatrix); + + if (!characterMatrix.equals(prevCharacterMatrix)) { + // Size may have changed, so the ledMask buffer must be reallocated + if (ledMask) { + free(ledMask); + ledMask = nullptr; + } + + ledMask = (bool*) d_malloc(characterMatrix.length() * sizeof(bool)); + + if (ledMask) + memset(ledMask, 0, characterMatrix.length() * sizeof(bool)); + + lastSentence = ""; // force mask recompute + lastTime = ULONG_MAX - 60000UL; // trigger recompute on very next loop() call + } + + int prevCharacterMatrixWidth = characterMatrixWidth; + getJsonValue(top[F("Character_Matrix_Width")], characterMatrixWidth); + characterMatrixWidth = clampInt(characterMatrixWidth, getMaxWordLength(), characterMatrix.length()); + + if (characterMatrixWidth != prevCharacterMatrixWidth) { + lastSentence = ""; // force mask recompute + lastTime = ULONG_MAX - 60000UL; // trigger recompute on very next loop() call + } + + getJsonValue(top[F("Matrix_Char_Offset")], matrixCharOffset); + matrixCharOffset = clampInt(matrixCharOffset, 0, characterMatrix.length()); + + int prevTestHour = testHour; + int prevTestMinute = testMinute; + getJsonValue(top[F("Test_Hour")], testHour); + getJsonValue(top[F("Test_Minute")], testMinute); + testHour = clampInt(testHour, -1, 23); + testMinute = clampInt(testMinute, 0, 59); + + if (testHour != prevTestHour || testMinute != prevTestMinute) { + lastSentence = ""; // force mask recompute + lastTime = ULONG_MAX - 60000UL; // trigger recompute on very next loop() call + } + + return configComplete; + } + + /* + * handleOverlayDraw() is called just before every show() (LED strip update frame) after effects have set the colors. + * Use this to blank out some LEDs or set them to a different color regardless of the set effect mode. + * Commonly used for custom clocks (Cronixie, 7 segment) + */ + void handleOverlayDraw() { + // Check if usermod is active + if (!usermodActive) + return; + + if (!ledMask) + return; + + int matrixLen = (int)characterMatrix.length(); + + // Loop over all leds + for (int i = matrixCharOffset; i < matrixLen; i++) { + int physIndex = ledOffset + i - matrixCharOffset; + uint32_t color = strip.getPixelColor(physIndex); + // Scale by opacityActive for lit LEDs, opacityInactive for dimmed LEDs. + int scale = ledMask[i] ? opacityActive : opacityInactive; + uint8_t r = ((color >> 16) & 0xFF) * scale / 255; + uint8_t g = ((color >> 8) & 0xFF) * scale / 255; + uint8_t b = ((color >> 0) & 0xFF) * scale / 255; + strip.setPixelColor(physIndex, RGBW32(r, g, b, 0)); + } + } + + /* + * getId() allows you to optionally give your V2 usermod an unique ID (please define it in const.h!). + * This could be used in the future for the system to determine whether your usermod is installed. + */ + uint16_t getId() { + return USERMOD_ID_WORDCLOCK_NL; + } + + // More methods can be added in the future, this example will then be extended. + // Your usermod will remain compatible as it does not need to implement all methods from the Usermod base class! +}; + +static WordClockNlUsermod usermod_v2_word_clock_nl; +REGISTER_USERMOD(usermod_v2_word_clock_nl); diff --git a/usermods/usermod_v2_word_clock_nl/woordklok-matrix-generator.html b/usermods/usermod_v2_word_clock_nl/woordklok-matrix-generator.html new file mode 100644 index 0000000000..677ad4e1f4 --- /dev/null +++ b/usermods/usermod_v2_word_clock_nl/woordklok-matrix-generator.html @@ -0,0 +1,749 @@ + + + + + + Woordklok + + + + + + +
+ + + + diff --git a/wled00/const.h b/wled00/const.h index 00a6b4d226..594cb8099e 100644 --- a/wled00/const.h +++ b/wled00/const.h @@ -252,6 +252,7 @@ static_assert(WLED_MAX_BUSSES <= 32, "WLED_MAX_BUSSES exceeds hard limit"); #define USERMOD_ID_RF433 56 //Usermod "usermod_v2_RF433.h" #define USERMOD_ID_BRIGHTNESS_FOLLOW_SUN 57 //Usermod "usermod_v2_brightness_follow_sun.h" #define USERMOD_ID_USER_FX 58 //Usermod "user_fx" +#define USERMOD_ID_WORDCLOCK_NL 59 //Usermod "usermod_v2_word_clock_nl" //Wifi encryption type #ifdef WLED_ENABLE_WPA_ENTERPRISE From 007426be74c868e006354cc20696d8f236cd9836 Mon Sep 17 00:00:00 2001 From: Patrick Atoon Date: Tue, 8 Sep 2026 12:23:09 +0200 Subject: [PATCH 02/44] wordclock: Fix review remark "Handle unavailable or rejected clipboard writes" --- .../woordklok-matrix-generator.html | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/usermods/usermod_v2_word_clock_nl/woordklok-matrix-generator.html b/usermods/usermod_v2_word_clock_nl/woordklok-matrix-generator.html index 677ad4e1f4..d1bb238f4a 100644 --- a/usermods/usermod_v2_word_clock_nl/woordklok-matrix-generator.html +++ b/usermods/usermod_v2_word_clock_nl/woordklok-matrix-generator.html @@ -624,10 +624,28 @@ const copyGridText = useCallback(() => { const text = grid.map(row => row.map(cell => cell.letter).join('')).join('\n'); - navigator.clipboard.writeText(text).then(() => { + const markCopied = () => { setCopied(true); setTimeout(() => setCopied(false), 1800); - }); + }; + const copyWithFallback = () => { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + const copiedWithFallback = document.execCommand('copy'); + document.body.removeChild(textarea); + if (copiedWithFallback) markCopied(); + }; + + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(text).then(markCopied).catch(copyWithFallback); + } else { + copyWithFallback(); + } }, [grid]); const showCurrentTime = () => { From d47152b1143ff5645bc8b023698aa10384dba81c Mon Sep 17 00:00:00 2001 From: Patrick Atoon Date: Tue, 8 Sep 2026 12:25:32 +0200 Subject: [PATCH 03/44] wordclock: Fix review comment "Store the word strings in static flash tables" --- .../usermod_v2_word_clock_nl.cpp | 91 ++++++++++--------- 1 file changed, 47 insertions(+), 44 deletions(-) diff --git a/usermods/usermod_v2_word_clock_nl/usermod_v2_word_clock_nl.cpp b/usermods/usermod_v2_word_clock_nl/usermod_v2_word_clock_nl.cpp index 10e46f20ee..a4f8312beb 100644 --- a/usermods/usermod_v2_word_clock_nl/usermod_v2_word_clock_nl.cpp +++ b/usermods/usermod_v2_word_clock_nl/usermod_v2_word_clock_nl.cpp @@ -1,5 +1,36 @@ #include "wled.h" +static const char sOne[] PROGMEM = "EEN"; +static const char sTwo[] PROGMEM = "TWEE"; +static const char sThree[] PROGMEM = "DRIE"; +static const char sFour[] PROGMEM = "VIER"; +static const char sFive[] PROGMEM = "VIJF"; +static const char sSix[] PROGMEM = "ZES"; +static const char sSeven[] PROGMEM = "ZEVEN"; +static const char sEight[] PROGMEM = "ACHT"; +static const char sNine[] PROGMEM = "NEGEN"; +static const char sTen[] PROGMEM = "TIEN"; +static const char sEleven[] PROGMEM = "ELF"; +static const char sTwelve[] PROGMEM = "TWAALF"; + +static const char sIt[] PROGMEM = "HET"; +static const char sIs[] PROGMEM = "IS"; +static const char sPast[] PROGMEM = "OVER"; +static const char sTo[] PROGMEM = "VOOR"; +static const char sHalf[] PROGMEM = "HALF"; +static const char sQuarter[] PROGMEM = "KWART"; +static const char sHour[] PROGMEM = "UUR"; +static const char sSpace[] PROGMEM = " "; + +static const char* const HOUR_WORDS[12] PROGMEM = { + sOne, sTwo, sThree, sFour, sFive, sSix, + sSeven, sEight, sNine, sTen, sEleven, sTwelve +}; + +static PGM_P getHourWord(uint8_t index) { + return reinterpret_cast(pgm_read_ptr(&HOUR_WORDS[index])); +} + /* * Word Clock (Dutch) * This is a usermod for the WLED project that displays the time in words in @@ -60,34 +91,6 @@ class WordClockNlUsermod : public Usermod bool* ledMask = nullptr; - // The words for the hours in Dutch, used to construct the sentences - // representing the time. 0 = twaalf uur, 1 = een uur, etc. - // Use PROGMEM as per the coding guidelines for usermods. - const char* sOne PROGMEM = "EEN"; - const char* sTwo PROGMEM = "TWEE"; - const char* sThree PROGMEM = "DRIE"; - const char* sFour PROGMEM = "VIER"; - const char* sFive PROGMEM = "VIJF"; - const char* sSix PROGMEM = "ZES"; - const char* sSeven PROGMEM = "ZEVEN"; - const char* sEight PROGMEM = "ACHT"; - const char* sNine PROGMEM = "NEGEN"; - const char* sTen PROGMEM = "TIEN"; - const char* sEleven PROGMEM = "ELF"; - const char* sTwelve PROGMEM = "TWAALF"; - - // Other things needed to form complete sentences - const char* sIt PROGMEM = "HET"; - const char* sIs PROGMEM = "IS"; - const char* sPast PROGMEM = "OVER"; - const char* sTo PROGMEM = "VOOR"; - const char* sHalf PROGMEM = "HALF"; - const char* sQuarter PROGMEM = "KWART"; - const char* sHour PROGMEM = "UUR"; - const char* sSpace PROGMEM = " "; - - const char* const HOUR_WORDS[12] PROGMEM = {sOne, sTwo, sThree, sFour, sFive, sSix, sSeven, sEight, sNine, sTen, sEleven, sTwelve}; - // Set your config variables to their boot default value (this can also be done in readFromConfig() or a constructor if you prefer) // Is this usermod active? @@ -128,20 +131,20 @@ class WordClockNlUsermod : public Usermod // nextHourIndex: the hour after that (used for half/voor constructions) int nextHourIndex = h % 12; - if (m == 0) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(HOUR_WORDS[hourIndex]) + FPSTR(sSpace) + FPSTR(sHour); - if (m == 5) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sFive) + FPSTR(sSpace) + FPSTR(sPast) + FPSTR(sSpace) + FPSTR(HOUR_WORDS[hourIndex]); - if (m == 10) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sTen) + FPSTR(sSpace) + FPSTR(sPast) + FPSTR(sSpace) + FPSTR(HOUR_WORDS[hourIndex]); - if (m == 15) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sQuarter) + FPSTR(sSpace) + FPSTR(sPast) + FPSTR(sSpace) + FPSTR(HOUR_WORDS[hourIndex]); - if (m == 20) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sTen) + FPSTR(sSpace) + FPSTR(sTo) + FPSTR(sSpace) + FPSTR(sHalf) + FPSTR(sSpace) + FPSTR(HOUR_WORDS[nextHourIndex]); - if (m == 25) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sFive) + FPSTR(sSpace) + FPSTR(sTo) + FPSTR(sSpace) + FPSTR(sHalf) + FPSTR(sSpace) + FPSTR(HOUR_WORDS[nextHourIndex]); - if (m == 30) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sHalf) + FPSTR(sSpace) + FPSTR(HOUR_WORDS[nextHourIndex]); - if (m == 35) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sFive) + FPSTR(sSpace) + FPSTR(sPast) + FPSTR(sSpace) + FPSTR(sHalf) + FPSTR(sSpace) + FPSTR(HOUR_WORDS[nextHourIndex]); - if (m == 40) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sTen) + FPSTR(sSpace) + FPSTR(sPast) + FPSTR(sSpace) + FPSTR(sHalf) + FPSTR(sSpace) + FPSTR(HOUR_WORDS[nextHourIndex]); - if (m == 45) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sQuarter) + FPSTR(sSpace) + FPSTR(sTo) + FPSTR(sSpace) + FPSTR(HOUR_WORDS[nextHourIndex]); - if (m == 50) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sTen) + FPSTR(sSpace) + FPSTR(sTo) + FPSTR(sSpace) + FPSTR(HOUR_WORDS[nextHourIndex]); - if (m == 55) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sFive) + FPSTR(sSpace) + FPSTR(sTo) + FPSTR(sSpace) + FPSTR(HOUR_WORDS[nextHourIndex]); - - return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(HOUR_WORDS[hourIndex]); + if (m == 0) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(getHourWord(hourIndex)) + FPSTR(sSpace) + FPSTR(sHour); + if (m == 5) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sFive) + FPSTR(sSpace) + FPSTR(sPast) + FPSTR(sSpace) + FPSTR(getHourWord(hourIndex)); + if (m == 10) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sTen) + FPSTR(sSpace) + FPSTR(sPast) + FPSTR(sSpace) + FPSTR(getHourWord(hourIndex)); + if (m == 15) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sQuarter) + FPSTR(sSpace) + FPSTR(sPast) + FPSTR(sSpace) + FPSTR(getHourWord(hourIndex)); + if (m == 20) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sTen) + FPSTR(sSpace) + FPSTR(sTo) + FPSTR(sSpace) + FPSTR(sHalf) + FPSTR(sSpace) + FPSTR(getHourWord(nextHourIndex)); + if (m == 25) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sFive) + FPSTR(sSpace) + FPSTR(sTo) + FPSTR(sSpace) + FPSTR(sHalf) + FPSTR(sSpace) + FPSTR(getHourWord(nextHourIndex)); + if (m == 30) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sHalf) + FPSTR(sSpace) + FPSTR(getHourWord(nextHourIndex)); + if (m == 35) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sFive) + FPSTR(sSpace) + FPSTR(sPast) + FPSTR(sSpace) + FPSTR(sHalf) + FPSTR(sSpace) + FPSTR(getHourWord(nextHourIndex)); + if (m == 40) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sTen) + FPSTR(sSpace) + FPSTR(sPast) + FPSTR(sSpace) + FPSTR(sHalf) + FPSTR(sSpace) + FPSTR(getHourWord(nextHourIndex)); + if (m == 45) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sQuarter) + FPSTR(sSpace) + FPSTR(sTo) + FPSTR(sSpace) + FPSTR(getHourWord(nextHourIndex)); + if (m == 50) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sTen) + FPSTR(sSpace) + FPSTR(sTo) + FPSTR(sSpace) + FPSTR(getHourWord(nextHourIndex)); + if (m == 55) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sFive) + FPSTR(sSpace) + FPSTR(sTo) + FPSTR(sSpace) + FPSTR(getHourWord(nextHourIndex)); + + return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(getHourWord(hourIndex)); } String lastSentence = ""; @@ -165,14 +168,14 @@ class WordClockNlUsermod : public Usermod int maxLen = 0; for (int i = 0; i < 12; i++) { - int len = strlen(HOUR_WORDS[i]); + int len = strlen_P(getHourWord(i)); if (len > maxLen) maxLen = len; } const char* others[] = {sIt, sIs, sPast, sTo, sHalf, sQuarter, sHour}; for (int i = 0; i < 7; i++) { - int len = strlen(others[i]); + int len = strlen_P(others[i]); if (len > maxLen) maxLen = len; } From 5586a6b31a008a15c21fa3ce4ccef25ce5b35cd6 Mon Sep 17 00:00:00 2001 From: Patrick Atoon Date: Tue, 8 Sep 2026 12:28:50 +0200 Subject: [PATCH 04/44] wordclock: Fix review comment "Carry the hour when the 5-minute rounding produces 60" --- .../usermod_v2_word_clock_nl/usermod_v2_word_clock_nl.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/usermods/usermod_v2_word_clock_nl/usermod_v2_word_clock_nl.cpp b/usermods/usermod_v2_word_clock_nl/usermod_v2_word_clock_nl.cpp index a4f8312beb..347b3c07f4 100644 --- a/usermods/usermod_v2_word_clock_nl/usermod_v2_word_clock_nl.cpp +++ b/usermods/usermod_v2_word_clock_nl/usermod_v2_word_clock_nl.cpp @@ -125,6 +125,12 @@ class WordClockNlUsermod : public Usermod // Round to nearest 5 minutes m = ((m + 2) / 5) * 5; + // Carry the hour when the 5-minute rounding produces 60 + if (m == 60) { + m = 0; + h = (h + 1) % 12; + } + // hourIndex: index of the current clock hour in HOUR_WORDS int hourIndex = (h - 1 + 12) % 12; From c268ed066d3ca0de9a80aba77a6467f9349fd444 Mon Sep 17 00:00:00 2001 From: Patrick Atoon Date: Tue, 8 Sep 2026 12:32:08 +0200 Subject: [PATCH 05/44] wordclock: Fix review comment "Fix the out-of-bounds `ledMask` write for partial odd rows" --- .../usermod_v2_word_clock_nl/usermod_v2_word_clock_nl.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/usermods/usermod_v2_word_clock_nl/usermod_v2_word_clock_nl.cpp b/usermods/usermod_v2_word_clock_nl/usermod_v2_word_clock_nl.cpp index 347b3c07f4..ea4386d39a 100644 --- a/usermods/usermod_v2_word_clock_nl/usermod_v2_word_clock_nl.cpp +++ b/usermods/usermod_v2_word_clock_nl/usermod_v2_word_clock_nl.cpp @@ -310,14 +310,16 @@ class WordClockNlUsermod : public Usermod // character index to a led index int row = charIndex / characterMatrixWidth; int col = charIndex % characterMatrixWidth; + int rowStart = row * characterMatrixWidth; + int rowLength = min(characterMatrixWidth, (int)characterMatrix.length() - rowStart); int ledIndex; if (row % 2 == 0) { // Even row: left to right - ledIndex = row * characterMatrixWidth + col; + ledIndex = rowStart + col; } else { // Odd row: right to left - ledIndex = row * characterMatrixWidth + (characterMatrixWidth - 1 - col); + ledIndex = rowStart + (rowLength - 1 - col); } ledMask[ledIndex] = true; From cec7904179218c8a307f8adf9cc44d88ce25e886 Mon Sep 17 00:00:00 2001 From: Patrick Atoon Date: Tue, 8 Sep 2026 12:36:40 +0200 Subject: [PATCH 06/44] wordclock: Fix review comment "Make `ledOffset` configurable, or remove the unused offset support" Removed all references to `ledOffset`. --- .../usermod_v2_word_clock_nl/usermod_v2_word_clock_nl.cpp | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/usermods/usermod_v2_word_clock_nl/usermod_v2_word_clock_nl.cpp b/usermods/usermod_v2_word_clock_nl/usermod_v2_word_clock_nl.cpp index ea4386d39a..f2bfbb2325 100644 --- a/usermods/usermod_v2_word_clock_nl/usermod_v2_word_clock_nl.cpp +++ b/usermods/usermod_v2_word_clock_nl/usermod_v2_word_clock_nl.cpp @@ -96,9 +96,6 @@ class WordClockNlUsermod : public Usermod // Is this usermod active? bool usermodActive = false; - // Number of leds before the first character - int ledOffset = 0; - // Opacity (0=off, 255=full brightness) applied to LEDs that ARE part of the current time sentence. int opacityActive = 255; @@ -106,7 +103,7 @@ class WordClockNlUsermod : public Usermod int opacityInactive = 0; // Number of characters to skip at the start of the matrix when mapping to physical LEDs. - // E.g. set to 10 to skip the first row, so matrix char 10 maps to physical LED ledOffset+0. + // E.g. set to 10 to skip the first row, so matrix char 10 maps to physical LED 0. int matrixCharOffset = 0; // Test time override: set testHour (0‥23) and testMinute (0‥59) to force a specific time to be @@ -472,7 +469,6 @@ class WordClockNlUsermod : public Usermod oappend(F("addInfo('Word Clock NL:Brightness_Inactive', 1, '(0-255)');")); oappend(F("addInfo('Word Clock NL:Test_Hour', 1, '(0-23, -1 for real time)');")); oappend(F("addInfo('Word Clock NL:Test_Minute', 1, '(0-59)');")); - // oappend(F("addInfo('Word clock NL:ledOffset', 1, 'Number of LEDs before the letters');")); } /* @@ -574,7 +570,7 @@ class WordClockNlUsermod : public Usermod // Loop over all leds for (int i = matrixCharOffset; i < matrixLen; i++) { - int physIndex = ledOffset + i - matrixCharOffset; + int physIndex = i - matrixCharOffset; uint32_t color = strip.getPixelColor(physIndex); // Scale by opacityActive for lit LEDs, opacityInactive for dimmed LEDs. int scale = ledMask[i] ? opacityActive : opacityInactive; From 798732206c553aab6e56d79f8daa231d5d380ac0 Mon Sep 17 00:00:00 2001 From: Patrick Atoon Date: Tue, 8 Sep 2026 12:41:19 +0200 Subject: [PATCH 07/44] wordclock: Fix review comment "Use one `ledMask` allocation path and pair `d_malloc()` with `d_free()`" --- .../usermod_v2_word_clock_nl.cpp | 39 ++++++++++++------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/usermods/usermod_v2_word_clock_nl/usermod_v2_word_clock_nl.cpp b/usermods/usermod_v2_word_clock_nl/usermod_v2_word_clock_nl.cpp index f2bfbb2325..9995c103a9 100644 --- a/usermods/usermod_v2_word_clock_nl/usermod_v2_word_clock_nl.cpp +++ b/usermods/usermod_v2_word_clock_nl/usermod_v2_word_clock_nl.cpp @@ -111,6 +111,23 @@ class WordClockNlUsermod : public Usermod int testHour = -1; int testMinute = 0; + void allocateLedMask() { + if (ledMask) { + d_free(ledMask); + ledMask = nullptr; + } + + size_t matrixLength = characterMatrix.length(); + + if (matrixLength == 0) + return; + + ledMask = (bool*) d_malloc(matrixLength * sizeof(bool)); + + if (ledMask) + memset(ledMask, 0, matrixLength * sizeof(bool)); + } + /* * Get the Dutch sentence representing the given the time in minutes, e.g. "HET IS KWART OVER TIEN". * totalMinutes: 0.‥1439 @@ -331,16 +348,17 @@ class WordClockNlUsermod : public Usermod public: // Functions called by WLED + ~WordClockNlUsermod() { + if (ledMask) + d_free(ledMask); + } + /* * setup() is called once at boot. WiFi is not yet connected at this point. * You can use it to initialize variables, sensors or similar. */ void setup() { - // Initialize the ledMask with false values - ledMask = (bool*) d_malloc(characterMatrix.length() * sizeof(bool)); - - if (ledMask) - memset(ledMask, 0, characterMatrix.length() * sizeof(bool)); + allocateLedMask(); } /* @@ -511,16 +529,7 @@ class WordClockNlUsermod : public Usermod getJsonValue(top[F("Character_Matrix")], characterMatrix); if (!characterMatrix.equals(prevCharacterMatrix)) { - // Size may have changed, so the ledMask buffer must be reallocated - if (ledMask) { - free(ledMask); - ledMask = nullptr; - } - - ledMask = (bool*) d_malloc(characterMatrix.length() * sizeof(bool)); - - if (ledMask) - memset(ledMask, 0, characterMatrix.length() * sizeof(bool)); + allocateLedMask(); lastSentence = ""; // force mask recompute lastTime = ULONG_MAX - 60000UL; // trigger recompute on very next loop() call From e7e0f0a7f77cd1e2920c2e403ffab9ca5ba2397f Mon Sep 17 00:00:00 2001 From: Patrick Atoon Date: Tue, 8 Sep 2026 12:43:48 +0200 Subject: [PATCH 08/44] wordclock: Fix review comment "Export the matrix in the format the usermod expects" --- .../usermod_v2_word_clock_nl/woordklok-matrix-generator.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/usermods/usermod_v2_word_clock_nl/woordklok-matrix-generator.html b/usermods/usermod_v2_word_clock_nl/woordklok-matrix-generator.html index d1bb238f4a..8dc8a0b2b6 100644 --- a/usermods/usermod_v2_word_clock_nl/woordklok-matrix-generator.html +++ b/usermods/usermod_v2_word_clock_nl/woordklok-matrix-generator.html @@ -623,7 +623,7 @@ const [copied, setCopied] = useState(false); const copyGridText = useCallback(() => { - const text = grid.map(row => row.map(cell => cell.letter).join('')).join('\n'); + const text = grid.map(row => row.map(cell => cell.letter).join('')).join(''); const markCopied = () => { setCopied(true); setTimeout(() => setCopied(false), 1800); From ed0aed9959069d1429ff3e19fc979a2c141c2b37 Mon Sep 17 00:00:00 2001 From: Patrick Atoon Date: Tue, 8 Sep 2026 16:03:03 +0200 Subject: [PATCH 09/44] wordclock: Freeze the existing usermod's German reference behavior --- .../lang/word_clock_language_de.h | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 usermods/usermod_v2_word_clock/lang/word_clock_language_de.h diff --git a/usermods/usermod_v2_word_clock/lang/word_clock_language_de.h b/usermods/usermod_v2_word_clock/lang/word_clock_language_de.h new file mode 100644 index 0000000000..630fb095e9 --- /dev/null +++ b/usermods/usermod_v2_word_clock/lang/word_clock_language_de.h @@ -0,0 +1,67 @@ +#pragma once + +#include + +namespace WordClockGermanReference { + +constexpr uint8_t MATRIX_WIDTH = 11; +constexpr uint8_t MATRIX_HEIGHT = 10; +constexpr uint16_t MATRIX_LENGTH = MATRIX_WIDTH * MATRIX_HEIGHT; +constexpr uint8_t MINUTE_DOT_COUNT = 4; + +// Logical 11x10 layout reconstructed from the legacy normal-wiring masks. +// Umlauts use single-byte Latin-1 escapes so each physical letter occupies one +// matrix position. Filler characters are intentionally arbitrary. +static constexpr char CHARACTER_MATRIX[] = + "ESXISTXF\xDC" "NF" + "ZEHNZWANZIG" + "DREIVIERTEL" + "VORXXXXNACH" + "HALBXELF\xDC" "NF" + "EINSXXXZWEI" + "DREIXXXVIER" + "SECHSXXACHT" + "SIEBENZW\xD6" "LF" + "ZEHNEUNXUHR"; + +static_assert(sizeof(CHARACTER_MATRIX) - 1 == MATRIX_LENGTH, "German matrix must contain 110 positions"); + +static constexpr uint16_t MINUTE_DOTS[MINUTE_DOT_COUNT] = {110, 111, 112, 113}; + +struct WordMask { + const char* word; + uint16_t start; + uint8_t length; +}; + +static constexpr WordMask WORD_MASKS[] = { + {"ES", 0, 2}, + {"IST", 3, 3}, + {"F\xDC" "NF", 7, 4}, + {"ZEHN", 11, 4}, + {"ZWANZIG", 15, 7}, + {"DREIVIERTEL", 22, 11}, + {"VOR", 33, 3}, + {"NACH", 40, 4}, + {"HALB", 44, 4}, + {"ELF", 49, 3}, + {"EIN", 55, 3}, + {"EINS", 55, 4}, + {"ZWEI", 62, 4}, + {"DREI", 66, 4}, + {"VIER", 73, 4}, + {"SECHS", 77, 5}, + {"ACHT", 84, 4}, + {"SIEBEN", 88, 6}, + {"ZW\xD6" "LF", 94, 5}, + {"NEUN", 102, 4}, + {"UHR", 107, 3} +}; + +constexpr uint16_t toMeanderIndex(uint16_t logicalIndex) { + return ((logicalIndex / MATRIX_WIDTH) % 2 == 0) + ? logicalIndex + : (logicalIndex / MATRIX_WIDTH) * MATRIX_WIDTH + MATRIX_WIDTH - 1 - (logicalIndex % MATRIX_WIDTH); +} + +} // namespace WordClockGermanReference From f1fc35157da01b77d5a5614da502efdedfd456ce Mon Sep 17 00:00:00 2001 From: Patrick Atoon Date: Tue, 8 Sep 2026 16:28:23 +0200 Subject: [PATCH 10/44] wordclock: Extract neutral types and helpers --- .../usermod_v2_word_clock/word_clock_core.h | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 usermods/usermod_v2_word_clock/word_clock_core.h diff --git a/usermods/usermod_v2_word_clock/word_clock_core.h b/usermods/usermod_v2_word_clock/word_clock_core.h new file mode 100644 index 0000000000..f470412337 --- /dev/null +++ b/usermods/usermod_v2_word_clock/word_clock_core.h @@ -0,0 +1,140 @@ +#pragma once + +#include +#include + +namespace WordClockCore { + +constexpr uint8_t MAX_PLAN_UNITS = 12; +constexpr uint8_t MAX_MINUTE_DOTS = 4; + +// Match behavior requested by a language pack for a display unit. +enum class MatchMode : uint8_t { + Sequential, // Continue searching after the previous match. + RandomOccurrence // Choose among valid occurrences of this unit. +}; + +// One language-defined item to place in the character matrix. +struct DisplayUnit { + uint16_t id; // Language-pack identifier for a word or symbol. + MatchMode matchMode; // How the generic matcher should select its occurrence. +}; + +// Fixed-capacity display plan produced for one time value. +struct DisplayPlan { + DisplayUnit units[MAX_PLAN_UNITS]{}; // Ordered units to place. + uint8_t count = 0; // Number of valid entries in units. + + bool append(uint16_t id, MatchMode matchMode = MatchMode::Sequential) { + if (count >= MAX_PLAN_UNITS) + return false; + + units[count++] = {id, matchMode}; + + return true; + } +}; + +// Time values normalized for language-specific plan generation. +struct TimeContext { + uint8_t hour24; // Current hour in the range 0-23. + uint8_t hour12; // Current hour in the range 1-12. + uint8_t nextHour12; // Following hour in the range 1-12. + uint8_t displayedMinute; // Selected five-minute phrase, from 0 to 55. + uint8_t minuteDotCount; // Remainder minutes, from 0 to 4, in dot mode. + uint16_t totalMinutes; // Normalized total minutes after nearest-rounding carry. +}; + +// Physical positions of the optional cumulative minute-dot markers. +struct MinuteDotMarkers { + uint16_t positions[MAX_MINUTE_DOTS]{}; // Raw layout positions for dots 1-4. + uint8_t count = 0; // Number of markers found: 0 or 4. + + bool enabled() const { return count == MAX_MINUTE_DOTS; } +}; + +// Build a normalized clock context. Complete dot markers select floor rounding; +// a marker-free layout selects nearest-five-minute rounding. +inline TimeContext makeTimeContext(uint16_t totalMinutes, bool minuteDotsEnabled) { + totalMinutes %= 1440; + uint8_t hour24 = totalMinutes / 60; + uint8_t minute = totalMinutes % 60; + uint8_t displayedMinute = minute; + uint8_t minuteDotCount = 0; + + if (minuteDotsEnabled) { + displayedMinute = (minute / 5) * 5; + minuteDotCount = minute % 5; + } else { + displayedMinute = ((minute + 2) / 5) * 5; + + if (displayedMinute == 60) { + displayedMinute = 0; + totalMinutes = ((totalMinutes / 60 + 1) * 60) % 1440; + hour24 = totalMinutes / 60; + } + } + + uint8_t hour12 = hour24 % 12; + + if (hour12 == 0) + hour12 = 12; + + uint8_t nextHour12 = hour12 == 12 ? 1 : hour12 + 1; + + return {hour24, hour12, nextHour12, displayedMinute, minuteDotCount, totalMinutes}; +} + +// Return whether a word lies wholly within one configured matrix row. +constexpr bool wordFitsInRow(int position, int length, int rowWidth) { + return rowWidth > 0 && length > 0 && + (position / rowWidth) == ((position + length - 1) / rowWidth); +} + +// Convert a logical matrix position to a serpentine physical position. The +// final row is allowed to be shorter than rowWidth. +inline int toMeanderIndex(int logicalIndex, int rowWidth, int matrixLength) { + if (rowWidth <= 0 || logicalIndex < 0 || logicalIndex >= matrixLength) + return -1; + + const int row = logicalIndex / rowWidth; + const int column = logicalIndex % rowWidth; + const int rowStart = row * rowWidth; + const int rowLength = (matrixLength - rowStart < rowWidth) ? + matrixLength - rowStart : rowWidth; + + return (row % 2 == 0) ? logicalIndex : rowStart + rowLength - 1 - column; +} + +// Parse the optional physical minute-dot markers from a byte-oriented layout. +// No markers is valid; otherwise exactly one each of '1', '2', '3', and '4' is +// required. Marker positions remain in the raw layout coordinate system. +inline bool parseMinuteDotMarkers(const char* layout, size_t length, MinuteDotMarkers& result) { + result = {}; + bool seen[MAX_MINUTE_DOTS] = {}; + + for (size_t index = 0; index < length; ++index) { + if (layout[index] < '1' || layout[index] > '4') + continue; + + const uint8_t marker = static_cast(layout[index] - '1'); + + if (seen[marker]) { + result = {}; + return false; + } + + seen[marker] = true; + result.positions[marker] = static_cast(index); + ++result.count; + } + + if (result.count != 0 && result.count != MAX_MINUTE_DOTS) { + result = {}; + return false; + } + + return true; +} + +} // namespace WordClockCore From f0b650411f2879392e5276c3a971df51b626f216 Mon Sep 17 00:00:00 2001 From: Patrick Atoon Date: Tue, 8 Sep 2026 16:42:24 +0200 Subject: [PATCH 11/44] wordclock: Implement the German compatibility pack --- .../lang/word_clock_language_de.h | 361 +++++++++++++++--- 1 file changed, 318 insertions(+), 43 deletions(-) diff --git a/usermods/usermod_v2_word_clock/lang/word_clock_language_de.h b/usermods/usermod_v2_word_clock/lang/word_clock_language_de.h index 630fb095e9..2112cc5c10 100644 --- a/usermods/usermod_v2_word_clock/lang/word_clock_language_de.h +++ b/usermods/usermod_v2_word_clock/lang/word_clock_language_de.h @@ -1,18 +1,25 @@ #pragma once +#include "../word_clock_core.h" + +#include #include +#include -namespace WordClockGermanReference { +#ifndef PROGMEM +#define PROGMEM +#endif -constexpr uint8_t MATRIX_WIDTH = 11; -constexpr uint8_t MATRIX_HEIGHT = 10; -constexpr uint16_t MATRIX_LENGTH = MATRIX_WIDTH * MATRIX_HEIGHT; -constexpr uint8_t MINUTE_DOT_COUNT = 4; +#ifdef ARDUINO +#include +#endif + +namespace WordClockGerman { // Logical 11x10 layout reconstructed from the legacy normal-wiring masks. // Umlauts use single-byte Latin-1 escapes so each physical letter occupies one -// matrix position. Filler characters are intentionally arbitrary. -static constexpr char CHARACTER_MATRIX[] = +// matrix position. The final four positions are the optional minute dots. +static const char DEFAULT_CHARACTER_MATRIX[] PROGMEM = "ESXISTXF\xDC" "NF" "ZEHNZWANZIG" "DREIVIERTEL" @@ -22,46 +29,314 @@ static constexpr char CHARACTER_MATRIX[] = "DREIXXXVIER" "SECHSXXACHT" "SIEBENZW\xD6" "LF" - "ZEHNEUNXUHR"; - -static_assert(sizeof(CHARACTER_MATRIX) - 1 == MATRIX_LENGTH, "German matrix must contain 110 positions"); + "ZEHNEUNXUHR" + "1234"; -static constexpr uint16_t MINUTE_DOTS[MINUTE_DOT_COUNT] = {110, 111, 112, 113}; +constexpr uint8_t DEFAULT_CHARACTER_MATRIX_WIDTH = 11; +constexpr uint8_t MINUTE_DOT_COUNT = 4; +constexpr uint16_t DEFAULT_CHARACTER_MATRIX_LENGTH = sizeof(DEFAULT_CHARACTER_MATRIX) - 1; +constexpr uint16_t LETTER_MATRIX_LENGTH = DEFAULT_CHARACTER_MATRIX_LENGTH - MINUTE_DOT_COUNT; +constexpr uint8_t MATRIX_HEIGHT = LETTER_MATRIX_LENGTH / DEFAULT_CHARACTER_MATRIX_WIDTH; -struct WordMask { - const char* word; - uint16_t start; - uint8_t length; +enum class WordId : uint16_t { + It, + Is, + Five, + Ten, + Quarter, + Past, + To, + Half, + Three, + Four, + One, + Two, + Six, + Seven, + Eight, + Nine, + Eleven, + Twelve, + Hour, + Twenty, + ThreeQuarter, + OnePlural }; -static constexpr WordMask WORD_MASKS[] = { - {"ES", 0, 2}, - {"IST", 3, 3}, - {"F\xDC" "NF", 7, 4}, - {"ZEHN", 11, 4}, - {"ZWANZIG", 15, 7}, - {"DREIVIERTEL", 22, 11}, - {"VOR", 33, 3}, - {"NACH", 40, 4}, - {"HALB", 44, 4}, - {"ELF", 49, 3}, - {"EIN", 55, 3}, - {"EINS", 55, 4}, - {"ZWEI", 62, 4}, - {"DREI", 66, 4}, - {"VIER", 73, 4}, - {"SECHS", 77, 5}, - {"ACHT", 84, 4}, - {"SIEBEN", 88, 6}, - {"ZW\xD6" "LF", 94, 5}, - {"NEUN", 102, 4}, - {"UHR", 107, 3} -}; +static const char WORD_IT[] PROGMEM = "ES"; +static const char WORD_IS[] PROGMEM = "IST"; +static const char WORD_FIVE[] PROGMEM = "F\xDC" "NF"; +static const char WORD_TEN[] PROGMEM = "ZEHN"; +static const char WORD_QUARTER[] PROGMEM = "VIERTEL"; +static const char WORD_PAST[] PROGMEM = "NACH"; +static const char WORD_TO[] PROGMEM = "VOR"; +static const char WORD_HALF[] PROGMEM = "HALB"; +static const char WORD_THREE[] PROGMEM = "DREI"; +static const char WORD_FOUR[] PROGMEM = "VIER"; +static const char WORD_ONE[] PROGMEM = "EIN"; +static const char WORD_TWO[] PROGMEM = "ZWEI"; +static const char WORD_SIX[] PROGMEM = "SECHS"; +static const char WORD_SEVEN[] PROGMEM = "SIEBEN"; +static const char WORD_EIGHT[] PROGMEM = "ACHT"; +static const char WORD_NINE[] PROGMEM = "NEUN"; +static const char WORD_ELEVEN[] PROGMEM = "ELF"; +static const char WORD_TWELVE[] PROGMEM = "ZW\xD6" "LF"; +static const char WORD_HOUR[] PROGMEM = "UHR"; +static const char WORD_TWENTY[] PROGMEM = "ZWANZIG"; +static const char WORD_THREE_QUARTER[] PROGMEM = "DREIVIERTEL"; +static const char WORD_ONE_PLURAL[] PROGMEM = "EINS"; + +// Return the flash-resident text represented by a language token. +// @param id language token to resolve +// @return pointer to the token text, or nullptr for an invalid token +inline const char* wordText(WordId id) { + switch (id) { + case WordId::It: return WORD_IT; + case WordId::Is: return WORD_IS; + case WordId::Five: return WORD_FIVE; + case WordId::Ten: return WORD_TEN; + case WordId::Quarter: return WORD_QUARTER; + case WordId::Past: return WORD_PAST; + case WordId::To: return WORD_TO; + case WordId::Half: return WORD_HALF; + case WordId::Three: return WORD_THREE; + case WordId::Four: return WORD_FOUR; + case WordId::One: return WORD_ONE; + case WordId::Two: return WORD_TWO; + case WordId::Six: return WORD_SIX; + case WordId::Seven: return WORD_SEVEN; + case WordId::Eight: return WORD_EIGHT; + case WordId::Nine: return WORD_NINE; + case WordId::Eleven: return WORD_ELEVEN; + case WordId::Twelve: return WORD_TWELVE; + case WordId::Hour: return WORD_HOUR; + case WordId::Twenty: return WORD_TWENTY; + case WordId::ThreeQuarter: return WORD_THREE_QUARTER; + case WordId::OnePlural: return WORD_ONE_PLURAL; + } + return nullptr; +} + +// Select the word for an hour, including EIN/EINS grammar. +// @param hour hour in the range 1-12 +// @param exactHour use EIN for an exact-hour phrase; otherwise use EINS +// @return language token for the selected hour +inline WordId hourWord(uint8_t hour, bool exactHour) { + if (hour == 1) + return exactHour ? WordId::One : WordId::OnePlural; + + switch (hour) { + case 2: return WordId::Two; + case 3: return WordId::Three; + case 4: return WordId::Four; + case 5: return WordId::Five; + case 6: return WordId::Six; + case 7: return WordId::Seven; + case 8: return WordId::Eight; + case 9: return WordId::Nine; + case 10: return WordId::Ten; + case 11: return WordId::Eleven; + default: return WordId::Twelve; + } +} + +// Append a token to a fixed-capacity display plan. +// @param plan destination plan +// @param id token to append +// @param occurrence zero-based matrix occurrence, or -1 for sequential search +// @return false when the plan has reached its capacity +inline bool append(WordClockCore::DisplayPlan& plan, WordId id, int8_t occurrence = -1) { + return plan.append(static_cast(id), WordClockCore::MatchMode::Sequential, occurrence); +} + +// Describe which matrix occurrence is used for an ambiguous hour word. +// @param id hour token +// @param exactHour whether the phrase is an exact-hour phrase +// @return zero-based occurrence, or -1 for normal sequential matching +inline int8_t hourOccurrence(WordId id, bool exactHour) { + if (id == WordId::Three || id == WordId::Four) + return 1; + + if (exactHour && (id == WordId::Five || id == WordId::Ten)) + return 1; + return -1; +} + +// Append an hour token with the German-specific occurrence rule attached. +// @param plan destination plan +// @param id hour token +// @param exactHour whether the phrase is an exact-hour phrase +inline bool appendHour(WordClockCore::DisplayPlan& plan, WordId id, bool exactHour) { + return append(plan, id, hourOccurrence(id, exactHour)); +} + +// Build the phrase plan for a normalized time. +// @param time rounded time context supplied by the shared core +// @param displayItIs include the optional ES IST prefix +// @param nord use VIERTEL NACH/VIERTEL VOR instead of the default quarter forms +// @param plan output sequence of tokens and occurrence metadata +// @return false if the fixed-size plan cannot hold the phrase +inline bool buildPlan(const WordClockCore::TimeContext& time, bool displayItIs, + bool nord, WordClockCore::DisplayPlan& plan) { + plan = {}; + + if (displayItIs && (!append(plan, WordId::It) || !append(plan, WordId::Is))) + return false; + + const uint8_t minute = time.displayedMinute; + const WordId currentHour = hourWord(time.hour12, minute == 0); + const WordId nextHour = hourWord(time.nextHour12, false); + + switch (minute) { + case 0: + return appendHour(plan, currentHour, true) && append(plan, WordId::Hour); + case 5: + return append(plan, WordId::Five) && append(plan, WordId::Past) && appendHour(plan, currentHour, false); + case 10: + return append(plan, WordId::Ten) && append(plan, WordId::Past) && appendHour(plan, currentHour, false); + case 15: + if (nord) + return append(plan, WordId::Quarter) && append(plan, WordId::Past) && appendHour(plan, currentHour, false); + return append(plan, WordId::Quarter) && appendHour(plan, nextHour, false); + case 20: + return append(plan, WordId::Twenty) && append(plan, WordId::Past) && appendHour(plan, currentHour, false); + case 25: + return append(plan, WordId::Five) && append(plan, WordId::To) && append(plan, WordId::Half) && appendHour(plan, nextHour, false); + case 30: + return append(plan, WordId::Half) && appendHour(plan, nextHour, false); + case 35: + return append(plan, WordId::Five) && append(plan, WordId::Past) && append(plan, WordId::Half) && appendHour(plan, nextHour, false); + case 40: + return append(plan, WordId::Twenty) && append(plan, WordId::To) && appendHour(plan, nextHour, false); + case 45: + if (nord) + return append(plan, WordId::Quarter) && append(plan, WordId::To) && appendHour(plan, nextHour, false); + return append(plan, WordId::ThreeQuarter) && appendHour(plan, nextHour, false); + case 50: + return append(plan, WordId::Ten) && append(plan, WordId::To) && appendHour(plan, nextHour, false); + case 55: + return append(plan, WordId::Five) && append(plan, WordId::To) && appendHour(plan, nextHour, false); + default: + return false; + } +} + +static_assert(LETTER_MATRIX_LENGTH % DEFAULT_CHARACTER_MATRIX_WIDTH == 0, + "Letter matrix must contain complete rows"); + +// Return the length of a flash-resident word on the target platform. +// @param word PROGMEM word pointer +// @return word length in bytes +inline size_t wordLength(const char* word) { +#ifdef ARDUINO + return strlen_P(word); +#else + return strlen(word); +#endif +} + +// Compare a language word with the default matrix at a logical position. +// @param position zero-based matrix position +// @param word flash-resident word to compare +// @param length number of bytes to compare +// @return true when the matrix contains the word at position +inline bool wordMatchesAt(int position, const char* word, size_t length) { +#ifdef ARDUINO + return strncmp_P(DEFAULT_CHARACTER_MATRIX + position, word, length) == 0; +#else + return strncmp(DEFAULT_CHARACTER_MATRIX + position, word, length) == 0; +#endif +} + +// Find the first row-contained occurrence at or after a logical position. +// @param word flash-resident word to find +// @param searchFrom zero-based position where searching begins +// @return logical matrix position, or -1 when no occurrence fits +inline int findWord(const char* word, int searchFrom) { + const size_t length = wordLength(word); + + for (int position = searchFrom; position + static_cast(length) <= LETTER_MATRIX_LENGTH; ++position) { + if (WordClockCore::wordFitsInRow(position, static_cast(length), DEFAULT_CHARACTER_MATRIX_WIDTH) && + wordMatchesAt(position, word, length)) + return position; + } + + return -1; +} + +// Find a specific row-contained occurrence by scanning the default matrix. +// @param word flash-resident word to find +// @param occurrence zero-based occurrence number +// @return logical matrix position, or -1 when that occurrence does not exist +inline int findWordOccurrence(const char* word, int occurrence) { + const size_t length = wordLength(word); + int seen = 0; + + for (int position = 0; position + static_cast(length) <= LETTER_MATRIX_LENGTH; ++position) { + if (WordClockCore::wordFitsInRow(position, static_cast(length), DEFAULT_CHARACTER_MATRIX_WIDTH) && + wordMatchesAt(position, word, length) && seen++ == occurrence) + return position; + } + + return -1; +} + +// Place a display plan into a logical/physical LED mask. The final four +// default-matrix positions are cumulative minute dots; all other units are +// matched against the letter rows and optionally converted to meander wiring. +// @param time normalized time, including the minute-dot count +// @param plan token plan to place +// @param meander reverse odd zero-based rows for physical wiring +// @param ledMask destination mask containing letters followed by dots +// @param maskLength number of entries available in ledMask +// @return false for invalid words, capacity, or out-of-range mappings +inline bool placePlan(const WordClockCore::TimeContext& time, + const WordClockCore::DisplayPlan& plan, bool meander, + bool* ledMask, size_t maskLength) { + if (ledMask == nullptr || maskLength < DEFAULT_CHARACTER_MATRIX_LENGTH) + return false; + + memset(ledMask, 0, maskLength * sizeof(bool)); + for (uint8_t dot = 0; dot < time.minuteDotCount; ++dot) + ledMask[LETTER_MATRIX_LENGTH + dot] = true; + + int searchFrom = 0; + + for (uint8_t unitIndex = 0; unitIndex < plan.count; ++unitIndex) { + const char* word = wordText(static_cast(plan.units[unitIndex].id)); + + if (word == nullptr || plan.units[unitIndex].matchMode != WordClockCore::MatchMode::Sequential) + return false; + + int position = -1; + + if (plan.units[unitIndex].occurrence >= 0) { + position = findWordOccurrence(word, plan.units[unitIndex].occurrence); + } else { + position = findWord(word, searchFrom); + } + + if (position < 0) + return false; + + const int length = static_cast(wordLength(word)); + + for (int offset = 0; offset < length; ++offset) { + int ledIndex = position + offset; + + if (meander) + ledIndex = WordClockCore::toMeanderIndex(ledIndex, DEFAULT_CHARACTER_MATRIX_WIDTH, LETTER_MATRIX_LENGTH); + + if (ledIndex < 0 || static_cast(ledIndex) >= maskLength) + return false; + + ledMask[ledIndex] = true; + } + + searchFrom = position + length; + } -constexpr uint16_t toMeanderIndex(uint16_t logicalIndex) { - return ((logicalIndex / MATRIX_WIDTH) % 2 == 0) - ? logicalIndex - : (logicalIndex / MATRIX_WIDTH) * MATRIX_WIDTH + MATRIX_WIDTH - 1 - (logicalIndex % MATRIX_WIDTH); + return true; } -} // namespace WordClockGermanReference +} // namespace WordClockGerman From 946a8c22e21e749dd9d422d97cb1b32cc179198e Mon Sep 17 00:00:00 2001 From: Patrick Atoon Date: Tue, 8 Sep 2026 17:45:03 +0200 Subject: [PATCH 12/44] wordclock: Extract Dutch language logic --- .../usermod_v2_word_clock/word_clock_core.h | 5 +- .../lang/word_clock_language_nl.h | 273 ++++++++++++++++++ .../usermod_v2_word_clock_nl.cpp | 249 +--------------- 3 files changed, 292 insertions(+), 235 deletions(-) create mode 100644 usermods/usermod_v2_word_clock_nl/lang/word_clock_language_nl.h diff --git a/usermods/usermod_v2_word_clock/word_clock_core.h b/usermods/usermod_v2_word_clock/word_clock_core.h index f470412337..9ee8f83e02 100644 --- a/usermods/usermod_v2_word_clock/word_clock_core.h +++ b/usermods/usermod_v2_word_clock/word_clock_core.h @@ -18,6 +18,7 @@ enum class MatchMode : uint8_t { struct DisplayUnit { uint16_t id; // Language-pack identifier for a word or symbol. MatchMode matchMode; // How the generic matcher should select its occurrence. + int8_t occurrence; // Zero-based occurrence to use, or -1 for normal searching. }; // Fixed-capacity display plan produced for one time value. @@ -25,11 +26,11 @@ struct DisplayPlan { DisplayUnit units[MAX_PLAN_UNITS]{}; // Ordered units to place. uint8_t count = 0; // Number of valid entries in units. - bool append(uint16_t id, MatchMode matchMode = MatchMode::Sequential) { + bool append(uint16_t id, MatchMode matchMode = MatchMode::Sequential, int8_t occurrence = -1) { if (count >= MAX_PLAN_UNITS) return false; - units[count++] = {id, matchMode}; + units[count++] = {id, matchMode, occurrence}; return true; } diff --git a/usermods/usermod_v2_word_clock_nl/lang/word_clock_language_nl.h b/usermods/usermod_v2_word_clock_nl/lang/word_clock_language_nl.h new file mode 100644 index 0000000000..6fae593ef1 --- /dev/null +++ b/usermods/usermod_v2_word_clock_nl/lang/word_clock_language_nl.h @@ -0,0 +1,273 @@ +#pragma once + +#include "../../usermod_v2_word_clock/word_clock_core.h" + +#include + +namespace WordClockDutch { + +// Default matrix used when no user-configured matrix is available. +static const char DEFAULT_CHARACTER_MATRIX[] PROGMEM = + "NEUEHETHETHT" + "NFEYIEISISVT" + "VIJFKWARTNAA" + "AGBETIENOAEE" + "AINOVERVOORA" + "TFUHALFIEENY" + "OEHIBUZEVENV" + "NBNMTWEEELFN" + "DRIEVIERVIJF" + "NEGENZESTIEN" + "TWAALFACHTNT" + "BXNHWEUUROAD"; + +constexpr uint8_t DEFAULT_CHARACTER_MATRIX_WIDTH = 12; + +enum class WordId : uint16_t { + It, + Is, + One, + Two, + Three, + Four, + Five, + Six, + Seven, + Eight, + Nine, + Ten, + Eleven, + Twelve, + Past, + To, + Half, + Quarter, + Hour +}; + +static const char WORD_IT[] PROGMEM = "HET"; +static const char WORD_IS[] PROGMEM = "IS"; +static const char WORD_ONE[] PROGMEM = "EEN"; +static const char WORD_TWO[] PROGMEM = "TWEE"; +static const char WORD_THREE[] PROGMEM = "DRIE"; +static const char WORD_FOUR[] PROGMEM = "VIER"; +static const char WORD_FIVE[] PROGMEM = "VIJF"; +static const char WORD_SIX[] PROGMEM = "ZES"; +static const char WORD_SEVEN[] PROGMEM = "ZEVEN"; +static const char WORD_EIGHT[] PROGMEM = "ACHT"; +static const char WORD_NINE[] PROGMEM = "NEGEN"; +static const char WORD_TEN[] PROGMEM = "TIEN"; +static const char WORD_ELEVEN[] PROGMEM = "ELF"; +static const char WORD_TWELVE[] PROGMEM = "TWAALF"; +static const char WORD_PAST[] PROGMEM = "OVER"; +static const char WORD_TO[] PROGMEM = "VOOR"; +static const char WORD_HALF[] PROGMEM = "HALF"; +static const char WORD_QUARTER[] PROGMEM = "KWART"; +static const char WORD_HOUR[] PROGMEM = "UUR"; + +// Return the flash-resident text represented by a language token. +// @param id language token to resolve +// @return pointer to the token text, or nullptr for an invalid token +inline const char* wordText(WordId id) { + switch (id) { + case WordId::It: return WORD_IT; + case WordId::Is: return WORD_IS; + case WordId::One: return WORD_ONE; + case WordId::Two: return WORD_TWO; + case WordId::Three: return WORD_THREE; + case WordId::Four: return WORD_FOUR; + case WordId::Five: return WORD_FIVE; + case WordId::Six: return WORD_SIX; + case WordId::Seven: return WORD_SEVEN; + case WordId::Eight: return WORD_EIGHT; + case WordId::Nine: return WORD_NINE; + case WordId::Ten: return WORD_TEN; + case WordId::Eleven: return WORD_ELEVEN; + case WordId::Twelve: return WORD_TWELVE; + case WordId::Past: return WORD_PAST; + case WordId::To: return WORD_TO; + case WordId::Half: return WORD_HALF; + case WordId::Quarter: return WORD_QUARTER; + case WordId::Hour: return WORD_HOUR; + } + return nullptr; +} + +// Select the word for an hour. +// @param hour hour in the range 1-12 +// @return language token for the selected hour +inline WordId hourWord(uint8_t hour) { + switch (hour) { + case 1: return WordId::One; + case 2: return WordId::Two; + case 3: return WordId::Three; + case 4: return WordId::Four; + case 5: return WordId::Five; + case 6: return WordId::Six; + case 7: return WordId::Seven; + case 8: return WordId::Eight; + case 9: return WordId::Nine; + case 10: return WordId::Ten; + case 11: return WordId::Eleven; + default: return WordId::Twelve; + } +} + +// Append a token to a fixed-capacity display plan. +// @param plan destination plan +// @param id token to append +// @param mode sequential or random occurrence matching behavior +// @return false when the plan has reached its capacity +inline bool append(WordClockCore::DisplayPlan& plan, WordId id, + WordClockCore::MatchMode mode = WordClockCore::MatchMode::Sequential) { + return plan.append(static_cast(id), mode); +} + +// Build the phrase plan for a normalized time. +// @param time rounded time context supplied by the shared core +// @param plan output sequence of tokens and match modes +// @return false if the fixed-size plan cannot hold the phrase +inline bool buildPlan(const WordClockCore::TimeContext& time, + WordClockCore::DisplayPlan& plan) { + plan = {}; + if (!append(plan, WordId::It, WordClockCore::MatchMode::RandomOccurrence) || + !append(plan, WordId::Is, WordClockCore::MatchMode::RandomOccurrence)) + return false; + + const WordId currentHour = hourWord(time.hour12); + const WordId nextHour = hourWord(time.nextHour12); + + switch (time.displayedMinute) { + case 0: + return append(plan, currentHour) && append(plan, WordId::Hour); + case 5: + return append(plan, WordId::Five) && append(plan, WordId::Past) && append(plan, currentHour); + case 10: + return append(plan, WordId::Ten) && append(plan, WordId::Past) && append(plan, currentHour); + case 15: + return append(plan, WordId::Quarter) && append(plan, WordId::Past) && append(plan, currentHour); + case 20: + return append(plan, WordId::Ten) && append(plan, WordId::To) && append(plan, WordId::Half) && append(plan, nextHour); + case 25: + return append(plan, WordId::Five) && append(plan, WordId::To) && append(plan, WordId::Half) && append(plan, nextHour); + case 30: + return append(plan, WordId::Half) && append(plan, nextHour); + case 35: + return append(plan, WordId::Five) && append(plan, WordId::Past) && append(plan, WordId::Half) && append(plan, nextHour); + case 40: + return append(plan, WordId::Ten) && append(plan, WordId::Past) && append(plan, WordId::Half) && append(plan, nextHour); + case 45: + return append(plan, WordId::Quarter) && append(plan, WordId::To) && append(plan, nextHour); + case 50: + return append(plan, WordId::Ten) && append(plan, WordId::To) && append(plan, nextHour); + case 55: + return append(plan, WordId::Five) && append(plan, WordId::To) && append(plan, nextHour); + default: + return false; + } +} + +// Return the length of a flash-resident word. +// @param word PROGMEM word pointer +// @return word length in bytes +inline int wordLength(const char* word) { + return static_cast(strlen_P(word)); +} + +// Return whether a word fits entirely within one matrix row. +// @param position zero-based matrix position +// @param length word length in bytes +// @param rowWidth configured matrix width +// @return true when the word does not cross a row boundary +inline bool wordFitsInRow(int position, int length, int rowWidth) { + return WordClockCore::wordFitsInRow(position, length, rowWidth); +} + +// Find the first row-contained occurrence at or after a logical position. +// @param matrix user-configured character matrix +// @param word flash-resident word to find +// @param searchFrom zero-based position where searching begins +// @param rowWidth configured matrix width +// @return logical matrix position, or -1 when no occurrence fits +inline int findWord(const String& matrix, const char* word, int searchFrom, int rowWidth) { + const String target = FPSTR(word); + const int length = target.length(); + for (int position = searchFrom; position + length <= matrix.length(); ++position) { + if (wordFitsInRow(position, length, rowWidth) && matrix.substring(position, position + length).equals(target)) + return position; + } + return -1; +} + +// Select a random valid occurrence of a word in the matrix. +// @param matrix user-configured character matrix +// @param word flash-resident word to find +// @param rowWidth configured matrix width +// @return selected logical position, or -1 when no occurrence fits +inline int findRandomWord(const String& matrix, const char* word, int rowWidth) { + const String target = FPSTR(word); + const int length = target.length(); + int count = 0; + for (int position = 0; position + length <= matrix.length(); ++position) + if (wordFitsInRow(position, length, rowWidth) && matrix.substring(position, position + length).equals(target)) + ++count; + + if (count == 0) + return -1; + + int selected = count > 1 ? random(count) : 0; + for (int position = 0; position + length <= matrix.length(); ++position) { + if (wordFitsInRow(position, length, rowWidth) && matrix.substring(position, position + length).equals(target) && selected-- == 0) + return position; + } + return -1; +} + +// Place a display plan into the logical/physical LED mask. +// @param plan token plan to place +// @param matrix user-configured character matrix +// @param rowWidth configured matrix width +// @param meander reverse odd zero-based rows for physical wiring +// @param ledMask destination mask with one entry per matrix position +// @return false for invalid words or out-of-range mappings +inline bool placePlan(const WordClockCore::DisplayPlan& plan, const String& matrix, + int rowWidth, bool meander, bool* ledMask) { + if (ledMask == nullptr) + return false; + + memset(ledMask, 0, matrix.length() * sizeof(bool)); + int searchFrom = 0; + for (uint8_t unitIndex = 0; unitIndex < plan.count; ++unitIndex) { + const char* word = wordText(static_cast(plan.units[unitIndex].id)); + int wordIndex = plan.units[unitIndex].matchMode == WordClockCore::MatchMode::RandomOccurrence + ? findRandomWord(matrix, word, rowWidth) + : findWord(matrix, word, searchFrom, rowWidth); + if (wordIndex < 0) + return false; + + const int length = wordLength(word); + for (int offset = 0; offset < length; ++offset) { + int ledIndex = wordIndex + offset; + if (meander) + ledIndex = WordClockCore::toMeanderIndex(ledIndex, rowWidth, matrix.length()); + if (ledIndex < 0 || ledIndex >= matrix.length()) + return false; + ledMask[ledIndex] = true; + } + + if (plan.units[unitIndex].matchMode == WordClockCore::MatchMode::Sequential) + searchFrom = wordIndex + length; + } + return true; +} + +// Return the longest word used by the language pack. +// @return maximum word length in bytes +inline int maxWordLength() { + int maximum = 0; + for (uint8_t id = 0; id <= static_cast(WordId::Hour); ++id) + maximum = max(maximum, wordLength(wordText(static_cast(id)))); + return maximum; +} + +} // namespace WordClockDutch diff --git a/usermods/usermod_v2_word_clock_nl/usermod_v2_word_clock_nl.cpp b/usermods/usermod_v2_word_clock_nl/usermod_v2_word_clock_nl.cpp index 9995c103a9..cac1d14911 100644 --- a/usermods/usermod_v2_word_clock_nl/usermod_v2_word_clock_nl.cpp +++ b/usermods/usermod_v2_word_clock_nl/usermod_v2_word_clock_nl.cpp @@ -1,35 +1,5 @@ #include "wled.h" - -static const char sOne[] PROGMEM = "EEN"; -static const char sTwo[] PROGMEM = "TWEE"; -static const char sThree[] PROGMEM = "DRIE"; -static const char sFour[] PROGMEM = "VIER"; -static const char sFive[] PROGMEM = "VIJF"; -static const char sSix[] PROGMEM = "ZES"; -static const char sSeven[] PROGMEM = "ZEVEN"; -static const char sEight[] PROGMEM = "ACHT"; -static const char sNine[] PROGMEM = "NEGEN"; -static const char sTen[] PROGMEM = "TIEN"; -static const char sEleven[] PROGMEM = "ELF"; -static const char sTwelve[] PROGMEM = "TWAALF"; - -static const char sIt[] PROGMEM = "HET"; -static const char sIs[] PROGMEM = "IS"; -static const char sPast[] PROGMEM = "OVER"; -static const char sTo[] PROGMEM = "VOOR"; -static const char sHalf[] PROGMEM = "HALF"; -static const char sQuarter[] PROGMEM = "KWART"; -static const char sHour[] PROGMEM = "UUR"; -static const char sSpace[] PROGMEM = " "; - -static const char* const HOUR_WORDS[12] PROGMEM = { - sOne, sTwo, sThree, sFour, sFive, sSix, - sSeven, sEight, sNine, sTen, sEleven, sTwelve -}; - -static PGM_P getHourWord(uint8_t index) { - return reinterpret_cast(pgm_read_ptr(&HOUR_WORDS[index])); -} +#include "lang/word_clock_language_nl.h" /* * Word Clock (Dutch) @@ -73,10 +43,10 @@ class WordClockNlUsermod : public Usermod // Dutch sentence, e.g. "HET IS KWART OVER TIEN". // The characters of each row are stored sequentially, and the rows are // stored sequentially as well, left to right, and top to bottom. - String characterMatrix = "NEUEHETHETHTNFEYIEISISVTVIJFKWARTNAAAGBETIENOAEEAINOVERVOORATFUHALFIEENYOEHIBUZEVENVNBNMTWEEELFNDRIEVIERVIJFNEGENZESTIENTWAALFACHTNTBXNHWEUUROAD"; + String characterMatrix = FPSTR(WordClockDutch::DEFAULT_CHARACTER_MATRIX); // The number of characters per row - int characterMatrixWidth = 12; + int characterMatrixWidth = WordClockDutch::DEFAULT_CHARACTER_MATRIX_WIDTH; // Is the ledstrip always from left to right on each row, or does it // meander through the rows (i.e. go from left to right on the first row, @@ -128,45 +98,6 @@ class WordClockNlUsermod : public Usermod memset(ledMask, 0, matrixLength * sizeof(bool)); } - /* - * Get the Dutch sentence representing the given the time in minutes, e.g. "HET IS KWART OVER TIEN". - * totalMinutes: 0.‥1439 - */ - String getSentenceForMinutes(int totalMinutes) { - int h = (totalMinutes / 60) % 12; // 0‥.11 (0 = twaalf uur) - int m = totalMinutes % 60; - - // Round to nearest 5 minutes - m = ((m + 2) / 5) * 5; - - // Carry the hour when the 5-minute rounding produces 60 - if (m == 60) { - m = 0; - h = (h + 1) % 12; - } - - // hourIndex: index of the current clock hour in HOUR_WORDS - int hourIndex = (h - 1 + 12) % 12; - - // nextHourIndex: the hour after that (used for half/voor constructions) - int nextHourIndex = h % 12; - - if (m == 0) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(getHourWord(hourIndex)) + FPSTR(sSpace) + FPSTR(sHour); - if (m == 5) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sFive) + FPSTR(sSpace) + FPSTR(sPast) + FPSTR(sSpace) + FPSTR(getHourWord(hourIndex)); - if (m == 10) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sTen) + FPSTR(sSpace) + FPSTR(sPast) + FPSTR(sSpace) + FPSTR(getHourWord(hourIndex)); - if (m == 15) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sQuarter) + FPSTR(sSpace) + FPSTR(sPast) + FPSTR(sSpace) + FPSTR(getHourWord(hourIndex)); - if (m == 20) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sTen) + FPSTR(sSpace) + FPSTR(sTo) + FPSTR(sSpace) + FPSTR(sHalf) + FPSTR(sSpace) + FPSTR(getHourWord(nextHourIndex)); - if (m == 25) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sFive) + FPSTR(sSpace) + FPSTR(sTo) + FPSTR(sSpace) + FPSTR(sHalf) + FPSTR(sSpace) + FPSTR(getHourWord(nextHourIndex)); - if (m == 30) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sHalf) + FPSTR(sSpace) + FPSTR(getHourWord(nextHourIndex)); - if (m == 35) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sFive) + FPSTR(sSpace) + FPSTR(sPast) + FPSTR(sSpace) + FPSTR(sHalf) + FPSTR(sSpace) + FPSTR(getHourWord(nextHourIndex)); - if (m == 40) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sTen) + FPSTR(sSpace) + FPSTR(sPast) + FPSTR(sSpace) + FPSTR(sHalf) + FPSTR(sSpace) + FPSTR(getHourWord(nextHourIndex)); - if (m == 45) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sQuarter) + FPSTR(sSpace) + FPSTR(sTo) + FPSTR(sSpace) + FPSTR(getHourWord(nextHourIndex)); - if (m == 50) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sTen) + FPSTR(sSpace) + FPSTR(sTo) + FPSTR(sSpace) + FPSTR(getHourWord(nextHourIndex)); - if (m == 55) return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(sFive) + FPSTR(sSpace) + FPSTR(sTo) + FPSTR(sSpace) + FPSTR(getHourWord(nextHourIndex)); - - return (String) FPSTR(sIt) + FPSTR(sSpace) + FPSTR(sIs) + FPSTR(sSpace) + FPSTR(getHourWord(hourIndex)); - } - String lastSentence = ""; /* @@ -174,175 +105,27 @@ class WordClockNlUsermod : public Usermod * the LED at index i should be on for the current time, and false if it * should be off. */ - bool wordFitsInRow(int pos, int len) { - return (pos / characterMatrixWidth) == ((pos + len - 1) / characterMatrixWidth); - } - // Clamp a value to the inclusive [lo, hi] range. int clampInt(int v, int lo, int hi) { return v < lo ? lo : (v > hi ? hi : v); } - // Length of the longest word that can appear in a sentence; a row must be at least this wide to ever fit a word. - int getMaxWordLength() { - int maxLen = 0; - - for (int i = 0; i < 12; i++) { - int len = strlen_P(getHourWord(i)); - if (len > maxLen) maxLen = len; - } - - const char* others[] = {sIt, sIs, sPast, sTo, sHalf, sQuarter, sHour}; - - for (int i = 0; i < 7; i++) { - int len = strlen_P(others[i]); - if (len > maxLen) maxLen = len; - } - - return maxLen; - } - void updateLedMaskForCurrentTime() { - int nrOfLeds = characterMatrix.length(); - - // Use test time if set, otherwise use the real local time - int currentMinutes; + const int currentMinutes = testHour >= 0 + ? (testHour * 60 + testMinute) % 1440 + : (hour(localTime) * 60 + minute(localTime)) % 1440; + const WordClockCore::TimeContext time = WordClockCore::makeTimeContext(currentMinutes, false); + WordClockCore::DisplayPlan plan; + if (!WordClockDutch::buildPlan(time, plan) || + !WordClockDutch::placePlan(plan, characterMatrix, characterMatrixWidth, meander, ledMask)) + return; - if (testHour >= 0) { - currentMinutes = (testHour * 60 + testMinute) % 1440; - } else { - currentMinutes = (hour(localTime) * 60 + minute(localTime)) % 1440; + String sentence; + for (uint8_t index = 0; index < plan.count; ++index) { + if (index > 0) sentence += ' '; + sentence += FPSTR(WordClockDutch::wordText(static_cast(plan.units[index].id))); } - - // Get the sentence to display for the current time - String sentence = getSentenceForMinutes(currentMinutes); - - if (sentence.equals(lastSentence)) - return; // No need to update the mask if the sentence hasn't changed since the last update - - // Remember the current sentence for the next update lastSentence = sentence; - - // Erase the ledMask before recomputing it - if (ledMask) - memset(ledMask, 0, nrOfLeds * sizeof(bool)); - - // Split the sentence into words and for each word, find the next - // occurrence of that word in the characterMatrix and update the - // corresponding ledMask values. - int searchFromIndex = 0; - int sentenceLength = sentence.length(); - - for (int i = 0; i < sentenceLength; ) { - // Extract the next word from the sentence - int nextSpaceIndex = sentence.indexOf(' ', i); - - if (nextSpaceIndex == -1) - nextSpaceIndex = sentenceLength; - - String word = sentence.substring(i, nextSpaceIndex); - i = nextSpaceIndex + 1; - - // Find the occurrence of the word to highlight. - // "HET" and "IS" appear multiple times in the matrix for visual variety; - // pick a random occurrence each update so the same physical LEDs are not - // always lit for these two fixed words. - // All other words are found sequentially (so e.g. "VIJF OVER VIJF" lights - // the minute VIJF first, then the hour VIJF second). - int wordIndex; - bool advanceSearchFrom = true; - - if (word == FPSTR(sIt) || word == FPSTR(sIs)) { - // Count how many times the word appears in the matrix, ignoring - // occurrences that would be split across two rows - int count = 0; - int pos = 0; - - while ((pos = characterMatrix.indexOf(word, pos)) != -1) { - if (wordFitsInRow(pos, word.length())) { - count++; - pos += word.length(); - } else { - pos += 1; - } - } - - // Pick a random occurrence (stays at 0 when only one occurrence exists) - int pick = (count > 1) ? (int)random(count) : 0; - int seen = 0; - pos = 0; - wordIndex = -1; - - while ((pos = characterMatrix.indexOf(word, pos)) != -1) { - if (wordFitsInRow(pos, word.length())) { - if (seen == pick) { - wordIndex = pos; - break; - } - seen++; - pos += word.length(); - } else { - pos += 1; - } - } - // Do not advance searchFromIndex: HET and IS are independent of word order - advanceSearchFrom = false; - } else { - wordIndex = searchFromIndex; - - while ((wordIndex = characterMatrix.indexOf(word, wordIndex)) != -1) { - if (wordFitsInRow(wordIndex, word.length())) - break; - wordIndex += 1; - } - } - - if (wordIndex == -1) { - // This should never happen if the characterMatrix contains all words - // needed to display the time in Dutch. Note that the words "VIJF" and - // "TIEN" have to occur multiple times in the characterMatrix, e.g. to - // display "VIJF OVER VIJF" or "TIEN VOOR TIEN". - Serial.println("Error: word not found in characterMatrix: " + word); - continue; - } - - // Update the ledMask values for this word - for (int j = 0; j < word.length(); j++) { - int charIndex = wordIndex + j; - - if (charIndex >= (int)characterMatrix.length()) { - Serial.println("Error: character index out of bounds: " + String(charIndex)); - continue; - } - - if (!meander) { - // If the ledstrip is always from left to right on each row, then the - // character index is the same as the led index - ledMask[charIndex] = true; - } else { - // If the ledstrip meanders through the rows, we need to convert the - // character index to a led index - int row = charIndex / characterMatrixWidth; - int col = charIndex % characterMatrixWidth; - int rowStart = row * characterMatrixWidth; - int rowLength = min(characterMatrixWidth, (int)characterMatrix.length() - rowStart); - int ledIndex; - - if (row % 2 == 0) { - // Even row: left to right - ledIndex = rowStart + col; - } else { - // Odd row: right to left - ledIndex = rowStart + (rowLength - 1 - col); - } - - ledMask[ledIndex] = true; - } - } - - if (advanceSearchFrom) - searchFromIndex = wordIndex + word.length(); - } }; public: @@ -537,7 +320,7 @@ class WordClockNlUsermod : public Usermod int prevCharacterMatrixWidth = characterMatrixWidth; getJsonValue(top[F("Character_Matrix_Width")], characterMatrixWidth); - characterMatrixWidth = clampInt(characterMatrixWidth, getMaxWordLength(), characterMatrix.length()); + characterMatrixWidth = clampInt(characterMatrixWidth, WordClockDutch::maxWordLength(), characterMatrix.length()); if (characterMatrixWidth != prevCharacterMatrixWidth) { lastSentence = ""; // force mask recompute From 12c04924a7a7a43cd4a87aecd48980bcac32d88a Mon Sep 17 00:00:00 2001 From: Patrick Atoon Date: Tue, 8 Sep 2026 17:51:06 +0200 Subject: [PATCH 13/44] wordclock: Added comments to separate matrix representation if needed for future non-Latin language packs --- .../lang/word_clock_language_de.h | 2 ++ usermods/usermod_v2_word_clock/word_clock_core.h | 10 ++++++++-- .../lang/word_clock_language_nl.h | 9 ++++++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/usermods/usermod_v2_word_clock/lang/word_clock_language_de.h b/usermods/usermod_v2_word_clock/lang/word_clock_language_de.h index 2112cc5c10..edac87085e 100644 --- a/usermods/usermod_v2_word_clock/lang/word_clock_language_de.h +++ b/usermods/usermod_v2_word_clock/lang/word_clock_language_de.h @@ -19,6 +19,8 @@ namespace WordClockGerman { // Logical 11x10 layout reconstructed from the legacy normal-wiring masks. // Umlauts use single-byte Latin-1 escapes so each physical letter occupies one // matrix position. The final four positions are the optional minute dots. +// This byte-oriented Latin-1 representation is intentional for the initial +// refactor; a future non-Latin pack should use symbol IDs instead. static const char DEFAULT_CHARACTER_MATRIX[] PROGMEM = "ESXISTXF\xDC" "NF" "ZEHNZWANZIG" diff --git a/usermods/usermod_v2_word_clock/word_clock_core.h b/usermods/usermod_v2_word_clock/word_clock_core.h index 9ee8f83e02..3b4e41eb63 100644 --- a/usermods/usermod_v2_word_clock/word_clock_core.h +++ b/usermods/usermod_v2_word_clock/word_clock_core.h @@ -5,6 +5,10 @@ namespace WordClockCore { +// The first-generation matrix API is byte-oriented: one byte represents one +// visible matrix position. A future non-Latin language can replace this with a +// symbol-ID matrix without changing the display-plan API below. + constexpr uint8_t MAX_PLAN_UNITS = 12; constexpr uint8_t MAX_MINUTE_DOTS = 4; @@ -16,7 +20,7 @@ enum class MatchMode : uint8_t { // One language-defined item to place in the character matrix. struct DisplayUnit { - uint16_t id; // Language-pack identifier for a word or symbol. + uint16_t id; // Language token, independent of matrix byte encoding. MatchMode matchMode; // How the generic matcher should select its occurrence. int8_t occurrence; // Zero-based occurrence to use, or -1 for normal searching. }; @@ -107,7 +111,9 @@ inline int toMeanderIndex(int logicalIndex, int rowWidth, int matrixLength) { return (row % 2 == 0) ? logicalIndex : rowStart + rowLength - 1 - column; } -// Parse the optional physical minute-dot markers from a byte-oriented layout. +// Parse optional physical minute-dot markers from the current byte-oriented +// layout format. A future symbol-ID layout should provide an equivalent +// language-specific parser rather than treating UTF-8 bytes as positions. // No markers is valid; otherwise exactly one each of '1', '2', '3', and '4' is // required. Marker positions remain in the raw layout coordinate system. inline bool parseMinuteDotMarkers(const char* layout, size_t length, MinuteDotMarkers& result) { diff --git a/usermods/usermod_v2_word_clock_nl/lang/word_clock_language_nl.h b/usermods/usermod_v2_word_clock_nl/lang/word_clock_language_nl.h index 6fae593ef1..20b40058aa 100644 --- a/usermods/usermod_v2_word_clock_nl/lang/word_clock_language_nl.h +++ b/usermods/usermod_v2_word_clock_nl/lang/word_clock_language_nl.h @@ -6,7 +6,8 @@ namespace WordClockDutch { -// Default matrix used when no user-configured matrix is available. +// Default byte-oriented Latin-script matrix used when no user-configured +// matrix is available. A future non-Latin pack should use symbol IDs instead. static const char DEFAULT_CHARACTER_MATRIX[] PROGMEM = "NEUEHETHETHT" "NFEYIEISISVT" @@ -22,6 +23,12 @@ static const char DEFAULT_CHARACTER_MATRIX[] PROGMEM = "BXNHWEUUROAD"; constexpr uint8_t DEFAULT_CHARACTER_MATRIX_WIDTH = 12; +constexpr uint16_t DEFAULT_CHARACTER_MATRIX_LENGTH = sizeof(DEFAULT_CHARACTER_MATRIX) - 1; +constexpr uint8_t DEFAULT_CHARACTER_MATRIX_HEIGHT = + DEFAULT_CHARACTER_MATRIX_LENGTH / DEFAULT_CHARACTER_MATRIX_WIDTH; + +static_assert(DEFAULT_CHARACTER_MATRIX_LENGTH % DEFAULT_CHARACTER_MATRIX_WIDTH == 0, + "Dutch default matrix must contain complete rows"); enum class WordId : uint16_t { It, From 08849f89b4d655a40f0bf38e6852cadaa493691c Mon Sep 17 00:00:00 2001 From: Patrick Atoon Date: Tue, 8 Sep 2026 18:00:37 +0200 Subject: [PATCH 14/44] wordclock: Use minute markers in Dutch code --- .../lang/word_clock_language_nl.h | 22 +++++++++++++++---- .../usermod_v2_word_clock_nl.cpp | 12 ++++++++-- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/usermods/usermod_v2_word_clock_nl/lang/word_clock_language_nl.h b/usermods/usermod_v2_word_clock_nl/lang/word_clock_language_nl.h index 20b40058aa..45d23661d1 100644 --- a/usermods/usermod_v2_word_clock_nl/lang/word_clock_language_nl.h +++ b/usermods/usermod_v2_word_clock_nl/lang/word_clock_language_nl.h @@ -20,14 +20,16 @@ static const char DEFAULT_CHARACTER_MATRIX[] PROGMEM = "DRIEVIERVIJF" "NEGENZESTIEN" "TWAALFACHTNT" - "BXNHWEUUROAD"; + "BXNHWEUUROAD" + "1234"; constexpr uint8_t DEFAULT_CHARACTER_MATRIX_WIDTH = 12; +constexpr uint8_t MINUTE_DOT_COUNT = 4; constexpr uint16_t DEFAULT_CHARACTER_MATRIX_LENGTH = sizeof(DEFAULT_CHARACTER_MATRIX) - 1; constexpr uint8_t DEFAULT_CHARACTER_MATRIX_HEIGHT = - DEFAULT_CHARACTER_MATRIX_LENGTH / DEFAULT_CHARACTER_MATRIX_WIDTH; + (DEFAULT_CHARACTER_MATRIX_LENGTH - MINUTE_DOT_COUNT) / DEFAULT_CHARACTER_MATRIX_WIDTH; -static_assert(DEFAULT_CHARACTER_MATRIX_LENGTH % DEFAULT_CHARACTER_MATRIX_WIDTH == 0, +static_assert((DEFAULT_CHARACTER_MATRIX_LENGTH - MINUTE_DOT_COUNT) % DEFAULT_CHARACTER_MATRIX_WIDTH == 0, "Dutch default matrix must contain complete rows"); enum class WordId : uint16_t { @@ -231,18 +233,30 @@ inline int findRandomWord(const String& matrix, const char* word, int rowWidth) } // Place a display plan into the logical/physical LED mask. +// @param time normalized time including the cumulative minute-dot count // @param plan token plan to place // @param matrix user-configured character matrix // @param rowWidth configured matrix width // @param meander reverse odd zero-based rows for physical wiring // @param ledMask destination mask with one entry per matrix position // @return false for invalid words or out-of-range mappings -inline bool placePlan(const WordClockCore::DisplayPlan& plan, const String& matrix, +inline bool placePlan(const WordClockCore::TimeContext& time, + const WordClockCore::DisplayPlan& plan, const String& matrix, int rowWidth, bool meander, bool* ledMask) { if (ledMask == nullptr) return false; memset(ledMask, 0, matrix.length() * sizeof(bool)); + WordClockCore::MinuteDotMarkers markers; + if (!WordClockCore::parseMinuteDotMarkers(matrix.c_str(), matrix.length(), markers)) + return false; + + if (markers.enabled()) { + for (uint8_t dot = 0; dot < time.minuteDotCount; ++dot) + if (markers.positions[dot] < matrix.length()) + ledMask[markers.positions[dot]] = true; + } + int searchFrom = 0; for (uint8_t unitIndex = 0; unitIndex < plan.count; ++unitIndex) { const char* word = wordText(static_cast(plan.units[unitIndex].id)); diff --git a/usermods/usermod_v2_word_clock_nl/usermod_v2_word_clock_nl.cpp b/usermods/usermod_v2_word_clock_nl/usermod_v2_word_clock_nl.cpp index cac1d14911..e4287ff004 100644 --- a/usermods/usermod_v2_word_clock_nl/usermod_v2_word_clock_nl.cpp +++ b/usermods/usermod_v2_word_clock_nl/usermod_v2_word_clock_nl.cpp @@ -114,17 +114,25 @@ class WordClockNlUsermod : public Usermod const int currentMinutes = testHour >= 0 ? (testHour * 60 + testMinute) % 1440 : (hour(localTime) * 60 + minute(localTime)) % 1440; - const WordClockCore::TimeContext time = WordClockCore::makeTimeContext(currentMinutes, false); + WordClockCore::MinuteDotMarkers markers; + + if (!WordClockCore::parseMinuteDotMarkers(characterMatrix.c_str(), characterMatrix.length(), markers)) + return; + + const WordClockCore::TimeContext time = WordClockCore::makeTimeContext(currentMinutes, markers.enabled()); WordClockCore::DisplayPlan plan; + if (!WordClockDutch::buildPlan(time, plan) || - !WordClockDutch::placePlan(plan, characterMatrix, characterMatrixWidth, meander, ledMask)) + !WordClockDutch::placePlan(time, plan, characterMatrix, characterMatrixWidth, meander, ledMask)) return; String sentence; + for (uint8_t index = 0; index < plan.count; ++index) { if (index > 0) sentence += ' '; sentence += FPSTR(WordClockDutch::wordText(static_cast(plan.units[index].id))); } + lastSentence = sentence; }; From 2069a33cddfe26bb52794941adc5014578a1f25f Mon Sep 17 00:00:00 2001 From: Patrick Atoon Date: Tue, 8 Sep 2026 18:11:24 +0200 Subject: [PATCH 15/44] wordclock: Improve function documentation --- .../lang/word_clock_language_de.h | 136 +++++++++++------- .../usermod_v2_word_clock/word_clock_core.h | 47 ++++-- .../lang/word_clock_language_nl.h | 118 +++++++++------ 3 files changed, 196 insertions(+), 105 deletions(-) diff --git a/usermods/usermod_v2_word_clock/lang/word_clock_language_de.h b/usermods/usermod_v2_word_clock/lang/word_clock_language_de.h index edac87085e..32e6885fab 100644 --- a/usermods/usermod_v2_word_clock/lang/word_clock_language_de.h +++ b/usermods/usermod_v2_word_clock/lang/word_clock_language_de.h @@ -88,9 +88,12 @@ static const char WORD_TWENTY[] PROGMEM = "ZWANZIG"; static const char WORD_THREE_QUARTER[] PROGMEM = "DREIVIERTEL"; static const char WORD_ONE_PLURAL[] PROGMEM = "EINS"; -// Return the flash-resident text represented by a language token. -// @param id language token to resolve -// @return pointer to the token text, or nullptr for an invalid token +/* + * Return the flash-resident text represented by a language token. + * + * @param id language token to resolve + * @return pointer to the token text, or nullptr for an invalid token + */ inline const char* wordText(WordId id) { switch (id) { case WordId::It: return WORD_IT; @@ -119,10 +122,13 @@ inline const char* wordText(WordId id) { return nullptr; } -// Select the word for an hour, including EIN/EINS grammar. -// @param hour hour in the range 1-12 -// @param exactHour use EIN for an exact-hour phrase; otherwise use EINS -// @return language token for the selected hour +/* + * Select the word for an hour, including EIN/EINS grammar. + * + * @param hour hour in the range 1-12 + * @param exactHour use EIN for an exact-hour phrase; otherwise use EINS + * @return language token for the selected hour + */ inline WordId hourWord(uint8_t hour, bool exactHour) { if (hour == 1) return exactHour ? WordId::One : WordId::OnePlural; @@ -142,19 +148,25 @@ inline WordId hourWord(uint8_t hour, bool exactHour) { } } -// Append a token to a fixed-capacity display plan. -// @param plan destination plan -// @param id token to append -// @param occurrence zero-based matrix occurrence, or -1 for sequential search -// @return false when the plan has reached its capacity +/* + * Append a token to a fixed-capacity display plan. + * + * @param plan destination plan + * @param id token to append + * @param occurrence zero-based matrix occurrence, or -1 for sequential search + * @return false when the plan has reached its capacity + */ inline bool append(WordClockCore::DisplayPlan& plan, WordId id, int8_t occurrence = -1) { return plan.append(static_cast(id), WordClockCore::MatchMode::Sequential, occurrence); } -// Describe which matrix occurrence is used for an ambiguous hour word. -// @param id hour token -// @param exactHour whether the phrase is an exact-hour phrase -// @return zero-based occurrence, or -1 for normal sequential matching +/* + * Describe which matrix occurrence is used for an ambiguous hour word. + * + * @param id hour token + * @param exactHour whether the phrase is an exact-hour phrase + * @return zero-based occurrence, or -1 for normal sequential matching + */ inline int8_t hourOccurrence(WordId id, bool exactHour) { if (id == WordId::Three || id == WordId::Four) return 1; @@ -164,20 +176,27 @@ inline int8_t hourOccurrence(WordId id, bool exactHour) { return -1; } -// Append an hour token with the German-specific occurrence rule attached. -// @param plan destination plan -// @param id hour token -// @param exactHour whether the phrase is an exact-hour phrase +/* + * Append an hour token with the German-specific occurrence rule attached. + * + * @param plan destination plan + * @param id hour token + * @param exactHour whether the phrase is an exact-hour phrase + * @return false if the fixed-size plan cannot hold the phrase + */ inline bool appendHour(WordClockCore::DisplayPlan& plan, WordId id, bool exactHour) { return append(plan, id, hourOccurrence(id, exactHour)); } -// Build the phrase plan for a normalized time. -// @param time rounded time context supplied by the shared core -// @param displayItIs include the optional ES IST prefix -// @param nord use VIERTEL NACH/VIERTEL VOR instead of the default quarter forms -// @param plan output sequence of tokens and occurrence metadata -// @return false if the fixed-size plan cannot hold the phrase +/* + * Build the phrase plan for a normalized time. + * + * @param time rounded time context supplied by the shared core + * @param displayItIs include the optional ES IST prefix + * @param nord use VIERTEL NACH/VIERTEL VOR instead of the default quarter forms + * @param plan output sequence of tokens and occurrence metadata + * @return false if the fixed-size plan cannot hold the phrase + */ inline bool buildPlan(const WordClockCore::TimeContext& time, bool displayItIs, bool nord, WordClockCore::DisplayPlan& plan) { plan = {}; @@ -226,9 +245,12 @@ inline bool buildPlan(const WordClockCore::TimeContext& time, bool displayItIs, static_assert(LETTER_MATRIX_LENGTH % DEFAULT_CHARACTER_MATRIX_WIDTH == 0, "Letter matrix must contain complete rows"); -// Return the length of a flash-resident word on the target platform. -// @param word PROGMEM word pointer -// @return word length in bytes +/* + * Return the length of a flash-resident word on the target platform. + * + * @param word PROGMEM word pointer + * @return word length in bytes + */ inline size_t wordLength(const char* word) { #ifdef ARDUINO return strlen_P(word); @@ -237,11 +259,14 @@ inline size_t wordLength(const char* word) { #endif } -// Compare a language word with the default matrix at a logical position. -// @param position zero-based matrix position -// @param word flash-resident word to compare -// @param length number of bytes to compare -// @return true when the matrix contains the word at position +/* + * Compare a language word with the default matrix at a logical position. + * + * @param position zero-based matrix position + * @param word flash-resident word to compare + * @param length number of bytes to compare + * @return true when the matrix contains the word at position + */ inline bool wordMatchesAt(int position, const char* word, size_t length) { #ifdef ARDUINO return strncmp_P(DEFAULT_CHARACTER_MATRIX + position, word, length) == 0; @@ -250,10 +275,13 @@ inline bool wordMatchesAt(int position, const char* word, size_t length) { #endif } -// Find the first row-contained occurrence at or after a logical position. -// @param word flash-resident word to find -// @param searchFrom zero-based position where searching begins -// @return logical matrix position, or -1 when no occurrence fits +/* + * Find the first row-contained occurrence at or after a logical position. + * + * @param word flash-resident word to find + * @param searchFrom zero-based position where searching begins + * @return logical matrix position, or -1 when no occurrence fits + */ inline int findWord(const char* word, int searchFrom) { const size_t length = wordLength(word); @@ -266,10 +294,13 @@ inline int findWord(const char* word, int searchFrom) { return -1; } -// Find a specific row-contained occurrence by scanning the default matrix. -// @param word flash-resident word to find -// @param occurrence zero-based occurrence number -// @return logical matrix position, or -1 when that occurrence does not exist +/* + * Find a specific row-contained occurrence by scanning the default matrix. + * + * @param word flash-resident word to find + * @param occurrence zero-based occurrence number + * @return logical matrix position, or -1 when that occurrence does not exist + */ inline int findWordOccurrence(const char* word, int occurrence) { const size_t length = wordLength(word); int seen = 0; @@ -283,15 +314,18 @@ inline int findWordOccurrence(const char* word, int occurrence) { return -1; } -// Place a display plan into a logical/physical LED mask. The final four -// default-matrix positions are cumulative minute dots; all other units are -// matched against the letter rows and optionally converted to meander wiring. -// @param time normalized time, including the minute-dot count -// @param plan token plan to place -// @param meander reverse odd zero-based rows for physical wiring -// @param ledMask destination mask containing letters followed by dots -// @param maskLength number of entries available in ledMask -// @return false for invalid words, capacity, or out-of-range mappings +/* + * Place a display plan into a logical/physical LED mask. The final four + * default-matrix positions are cumulative minute dots; all other units are + * matched against the letter rows and optionally converted to meander wiring. + * + * @param time normalized time, including the minute-dot count + * @param plan token plan to place + * @param meander reverse odd zero-based rows for physical wiring + * @param ledMask destination mask containing letters followed by dots + * @param maskLength number of entries available in ledMask + * @return false for invalid words, capacity, or out-of-range mappings + */ inline bool placePlan(const WordClockCore::TimeContext& time, const WordClockCore::DisplayPlan& plan, bool meander, bool* ledMask, size_t maskLength) { diff --git a/usermods/usermod_v2_word_clock/word_clock_core.h b/usermods/usermod_v2_word_clock/word_clock_core.h index 3b4e41eb63..e33bea4d34 100644 --- a/usermods/usermod_v2_word_clock/word_clock_core.h +++ b/usermods/usermod_v2_word_clock/word_clock_core.h @@ -58,8 +58,14 @@ struct MinuteDotMarkers { bool enabled() const { return count == MAX_MINUTE_DOTS; } }; -// Build a normalized clock context. Complete dot markers select floor rounding; -// a marker-free layout selects nearest-five-minute rounding. +/* + * Build a normalized clock context. Complete dot markers select floor rounding; + * a marker-free layout selects nearest-five-minute rounding. + * + * @param totalMinutes total minutes since midnight + * @param minuteDotsEnabled whether the layout has complete minute-dot markers + * @return normalized time context + */ inline TimeContext makeTimeContext(uint16_t totalMinutes, bool minuteDotsEnabled) { totalMinutes %= 1440; uint8_t hour24 = totalMinutes / 60; @@ -90,14 +96,28 @@ inline TimeContext makeTimeContext(uint16_t totalMinutes, bool minuteDotsEnabled return {hour24, hour12, nextHour12, displayedMinute, minuteDotCount, totalMinutes}; } -// Return whether a word lies wholly within one configured matrix row. +/* + * Return whether a word lies wholly within one configured matrix row. + * + * @param position logical start position of the word + * @param length length of the word in characters + * @param rowWidth configured matrix row width + * @return true if the word fits entirely within a single row, false otherwise + */ constexpr bool wordFitsInRow(int position, int length, int rowWidth) { return rowWidth > 0 && length > 0 && (position / rowWidth) == ((position + length - 1) / rowWidth); } -// Convert a logical matrix position to a serpentine physical position. The -// final row is allowed to be shorter than rowWidth. +/* + * Convert a logical matrix position to a serpentine physical position. The + * final row is allowed to be shorter than rowWidth. + * + * @param logicalIndex logical position in the matrix + * @param rowWidth configured matrix row width + * @param matrixLength total number of positions in the matrix + * @return physical position in the serpentine-wired matrix, or -1 for invalid input + */ inline int toMeanderIndex(int logicalIndex, int rowWidth, int matrixLength) { if (rowWidth <= 0 || logicalIndex < 0 || logicalIndex >= matrixLength) return -1; @@ -111,11 +131,18 @@ inline int toMeanderIndex(int logicalIndex, int rowWidth, int matrixLength) { return (row % 2 == 0) ? logicalIndex : rowStart + rowLength - 1 - column; } -// Parse optional physical minute-dot markers from the current byte-oriented -// layout format. A future symbol-ID layout should provide an equivalent -// language-specific parser rather than treating UTF-8 bytes as positions. -// No markers is valid; otherwise exactly one each of '1', '2', '3', and '4' is -// required. Marker positions remain in the raw layout coordinate system. +/* + * Parse optional physical minute-dot markers from the current byte-oriented + * layout format. A future symbol-ID layout should provide an equivalent + * language-specific parser rather than treating UTF-8 bytes as positions. + * No markers is valid; otherwise exactly one each of '1', '2', '3', and '4' is + * required. Marker positions remain in the raw layout coordinate system. + * + * @param layout byte-oriented character layout + * @param length number of bytes in the layout + * @param result output structure to hold parsed marker positions + * @return true if the markers are valid, false otherwise + */ inline bool parseMinuteDotMarkers(const char* layout, size_t length, MinuteDotMarkers& result) { result = {}; bool seen[MAX_MINUTE_DOTS] = {}; diff --git a/usermods/usermod_v2_word_clock_nl/lang/word_clock_language_nl.h b/usermods/usermod_v2_word_clock_nl/lang/word_clock_language_nl.h index 45d23661d1..1866476392 100644 --- a/usermods/usermod_v2_word_clock_nl/lang/word_clock_language_nl.h +++ b/usermods/usermod_v2_word_clock_nl/lang/word_clock_language_nl.h @@ -74,9 +74,12 @@ static const char WORD_HALF[] PROGMEM = "HALF"; static const char WORD_QUARTER[] PROGMEM = "KWART"; static const char WORD_HOUR[] PROGMEM = "UUR"; -// Return the flash-resident text represented by a language token. -// @param id language token to resolve -// @return pointer to the token text, or nullptr for an invalid token +/* + * Return the flash-resident text represented by a language token. + * + * @param id language token to resolve + * @return pointer to the token text, or nullptr for an invalid token + */ inline const char* wordText(WordId id) { switch (id) { case WordId::It: return WORD_IT; @@ -102,9 +105,12 @@ inline const char* wordText(WordId id) { return nullptr; } -// Select the word for an hour. -// @param hour hour in the range 1-12 -// @return language token for the selected hour +/* + * Select the word for an hour. + * + * @param hour hour in the range 1-12 + * @return language token for the selected hour + */ inline WordId hourWord(uint8_t hour) { switch (hour) { case 1: return WordId::One; @@ -122,20 +128,26 @@ inline WordId hourWord(uint8_t hour) { } } -// Append a token to a fixed-capacity display plan. -// @param plan destination plan -// @param id token to append -// @param mode sequential or random occurrence matching behavior -// @return false when the plan has reached its capacity +/* + * Append a token to a fixed-capacity display plan. + * + * @param plan destination plan + * @param id token to append + * @param mode sequential or random occurrence matching behavior + * @return false when the plan has reached its capacity + */ inline bool append(WordClockCore::DisplayPlan& plan, WordId id, WordClockCore::MatchMode mode = WordClockCore::MatchMode::Sequential) { return plan.append(static_cast(id), mode); } -// Build the phrase plan for a normalized time. -// @param time rounded time context supplied by the shared core -// @param plan output sequence of tokens and match modes -// @return false if the fixed-size plan cannot hold the phrase +/* + * Build the phrase plan for a normalized time. + * + * @param time rounded time context supplied by the shared core + * @param plan output sequence of tokens and match modes + * @return false if the fixed-size plan cannot hold the phrase + */ inline bool buildPlan(const WordClockCore::TimeContext& time, WordClockCore::DisplayPlan& plan) { plan = {}; @@ -176,28 +188,37 @@ inline bool buildPlan(const WordClockCore::TimeContext& time, } } -// Return the length of a flash-resident word. -// @param word PROGMEM word pointer -// @return word length in bytes +/* + * Return the length of a flash-resident word. + * + * @param word PROGMEM word pointer + * @return word length in bytes + */ inline int wordLength(const char* word) { return static_cast(strlen_P(word)); } -// Return whether a word fits entirely within one matrix row. -// @param position zero-based matrix position -// @param length word length in bytes -// @param rowWidth configured matrix width -// @return true when the word does not cross a row boundary +/* + * Return whether a word fits entirely within one matrix row. + * + * @param position zero-based matrix position + * @param length word length in bytes + * @param rowWidth configured matrix width + * @return true when the word does not cross a row boundary + */ inline bool wordFitsInRow(int position, int length, int rowWidth) { return WordClockCore::wordFitsInRow(position, length, rowWidth); } -// Find the first row-contained occurrence at or after a logical position. -// @param matrix user-configured character matrix -// @param word flash-resident word to find -// @param searchFrom zero-based position where searching begins -// @param rowWidth configured matrix width -// @return logical matrix position, or -1 when no occurrence fits +/* + * Find the first row-contained occurrence at or after a logical position. + * + * @param matrix user-configured character matrix + * @param word flash-resident word to find + * @param searchFrom zero-based position where searching begins + * @param rowWidth configured matrix width + * @return logical matrix position, or -1 when no occurrence fits + */ inline int findWord(const String& matrix, const char* word, int searchFrom, int rowWidth) { const String target = FPSTR(word); const int length = target.length(); @@ -208,11 +229,14 @@ inline int findWord(const String& matrix, const char* word, int searchFrom, int return -1; } -// Select a random valid occurrence of a word in the matrix. -// @param matrix user-configured character matrix -// @param word flash-resident word to find -// @param rowWidth configured matrix width -// @return selected logical position, or -1 when no occurrence fits +/* + * Select a random valid occurrence of a word in the matrix. + * + * @param matrix user-configured character matrix + * @param word flash-resident word to find + * @param rowWidth configured matrix width + * @return selected logical position, or -1 when no occurrence fits + */ inline int findRandomWord(const String& matrix, const char* word, int rowWidth) { const String target = FPSTR(word); const int length = target.length(); @@ -232,14 +256,17 @@ inline int findRandomWord(const String& matrix, const char* word, int rowWidth) return -1; } -// Place a display plan into the logical/physical LED mask. -// @param time normalized time including the cumulative minute-dot count -// @param plan token plan to place -// @param matrix user-configured character matrix -// @param rowWidth configured matrix width -// @param meander reverse odd zero-based rows for physical wiring -// @param ledMask destination mask with one entry per matrix position -// @return false for invalid words or out-of-range mappings +/* + * Place a display plan into the logical/physical LED mask. + * + * @param time normalized time including the cumulative minute-dot count + * @param plan token plan to place + * @param matrix user-configured character matrix + * @param rowWidth configured matrix width + * @param meander reverse odd zero-based rows for physical wiring + * @param ledMask destination mask with one entry per matrix position + * @return false for invalid words or out-of-range mappings + */ inline bool placePlan(const WordClockCore::TimeContext& time, const WordClockCore::DisplayPlan& plan, const String& matrix, int rowWidth, bool meander, bool* ledMask) { @@ -282,8 +309,11 @@ inline bool placePlan(const WordClockCore::TimeContext& time, return true; } -// Return the longest word used by the language pack. -// @return maximum word length in bytes +/* + * Return the longest word used by the language pack. + * + * @return maximum word length in bytes + */ inline int maxWordLength() { int maximum = 0; for (uint8_t id = 0; id <= static_cast(WordId::Hour); ++id) From 115fdcef7f7de5c1521fefb67f7f3d5f16f6fad5 Mon Sep 17 00:00:00 2001 From: Patrick Atoon Date: Tue, 8 Sep 2026 18:15:31 +0200 Subject: [PATCH 16/44] wordclock: Document possible future `placePlan()` changes --- .../usermod_v2_word_clock/lang/word_clock_language_de.h | 6 ++++++ usermods/usermod_v2_word_clock/word_clock_core.h | 9 +++++++++ .../lang/word_clock_language_nl.h | 6 ++++++ 3 files changed, 21 insertions(+) diff --git a/usermods/usermod_v2_word_clock/lang/word_clock_language_de.h b/usermods/usermod_v2_word_clock/lang/word_clock_language_de.h index 32e6885fab..8b9aae5987 100644 --- a/usermods/usermod_v2_word_clock/lang/word_clock_language_de.h +++ b/usermods/usermod_v2_word_clock/lang/word_clock_language_de.h @@ -16,6 +16,12 @@ namespace WordClockGerman { +/* + * This pack owns both phrase planning and matrix placement. Its + * placement delegates ordinary row and meander work to WordClockCore while + * retaining language-specific occurrence choices in the plan. + */ + // Logical 11x10 layout reconstructed from the legacy normal-wiring masks. // Umlauts use single-byte Latin-1 escapes so each physical letter occupies one // matrix position. The final four positions are the optional minute dots. diff --git a/usermods/usermod_v2_word_clock/word_clock_core.h b/usermods/usermod_v2_word_clock/word_clock_core.h index e33bea4d34..5e400d484d 100644 --- a/usermods/usermod_v2_word_clock/word_clock_core.h +++ b/usermods/usermod_v2_word_clock/word_clock_core.h @@ -40,6 +40,15 @@ struct DisplayPlan { } }; +/* + * Language-pack contract: + * + * Every language pack owns both plan generation and placement. Latin-script + * packs may delegate placement to the shared row/matrix helpers, while a + * future non-Latin pack may provide a different placement representation. + * This core intentionally does not impose a concrete matrix type on packs. + */ + // Time values normalized for language-specific plan generation. struct TimeContext { uint8_t hour24; // Current hour in the range 0-23. diff --git a/usermods/usermod_v2_word_clock_nl/lang/word_clock_language_nl.h b/usermods/usermod_v2_word_clock_nl/lang/word_clock_language_nl.h index 1866476392..c9e6a83f73 100644 --- a/usermods/usermod_v2_word_clock_nl/lang/word_clock_language_nl.h +++ b/usermods/usermod_v2_word_clock_nl/lang/word_clock_language_nl.h @@ -6,6 +6,12 @@ namespace WordClockDutch { +/* + * This pack uses the shared byte-matrix placement primitives. A future + * language with a different writing system may replace placePlan() while + * keeping the same buildPlan() and display-plan concepts. + */ + // Default byte-oriented Latin-script matrix used when no user-configured // matrix is available. A future non-Latin pack should use symbol IDs instead. static const char DEFAULT_CHARACTER_MATRIX[] PROGMEM = From b93f111111ac4c972828e7951117ef3fcab89d5e Mon Sep 17 00:00:00 2001 From: Patrick Atoon Date: Tue, 8 Sep 2026 18:39:09 +0200 Subject: [PATCH 17/44] wordclock: Update the matrix generator and helper documentation --- usermods/usermod_v2_word_clock_nl/readme.md | 6 +- ....html => word-clock-matrix-generator.html} | 200 ++++++++++++------ 2 files changed, 143 insertions(+), 63 deletions(-) rename usermods/usermod_v2_word_clock_nl/{woordklok-matrix-generator.html => word-clock-matrix-generator.html} (77%) diff --git a/usermods/usermod_v2_word_clock_nl/readme.md b/usermods/usermod_v2_word_clock_nl/readme.md index bd4df351c7..1a2aed00f2 100644 --- a/usermods/usermod_v2_word_clock_nl/readme.md +++ b/usermods/usermod_v2_word_clock_nl/readme.md @@ -89,13 +89,13 @@ matrix row. The clock rounds the current time to the nearest five minutes. This usermod is designed for Dutch and uses the character matrix from the usermod settings. To create a custom matrix, open -`woordklok-matrix-generator.html`. When the matrix is ready, click -"KOPIEER TEXT" and paste the result into `Character Matrix` in +`word-clock-matrix-generator.html`. When the matrix is ready, click +"Copy text" and paste the result into `Character Matrix` in WLED > Config > Usermods > Word Clock NL. Remove all line breaks, and set `Character Matrix Width` to the number of columns in each row. Using a language other than Dutch requires changes to the code that creates and -matches time sentences in both `woordklok-matrix-generator.html` and +matches time sentences in both `word-clock-matrix-generator.html` and `usermod_v2_word_clock_nl.cpp`. This is an advanced customization. Test the result thoroughly with the HTML generator so that every supported time is displayed correctly. diff --git a/usermods/usermod_v2_word_clock_nl/woordklok-matrix-generator.html b/usermods/usermod_v2_word_clock_nl/word-clock-matrix-generator.html similarity index 77% rename from usermods/usermod_v2_word_clock_nl/woordklok-matrix-generator.html rename to usermods/usermod_v2_word_clock_nl/word-clock-matrix-generator.html index 8dc8a0b2b6..c09e082eb9 100644 --- a/usermods/usermod_v2_word_clock_nl/woordklok-matrix-generator.html +++ b/usermods/usermod_v2_word_clock_nl/word-clock-matrix-generator.html @@ -3,7 +3,7 @@ - Woordklok + Word Clock Matrix Generator @@ -136,6 +136,28 @@ .slider-row:last-child { margin-bottom: 0; } + .language-row { + gap: 24px; + } + + .language-select { + flex: 1; + min-width: 0; + background: #0d0b07; + border: 1px solid #2a2416; + border-radius: 8px; + padding: 10px 12px; + color: #e8d5a3; + font: inherit; + letter-spacing: 0.05em; + outline: none; + } + + .language-select:focus { + border-color: #6a5a30; + box-shadow: 0 0 0 2px rgba(106,90,48,0.2); + } + .slider-label { width: 64px; font-size: 0.78rem; @@ -289,11 +311,11 @@