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..63acb4d9cc --- /dev/null +++ b/usermods/usermod_v2_word_clock/lang/word_clock_language_de.h @@ -0,0 +1,423 @@ +#pragma once + +#include "../word_clock_core.h" + +#include +#include +#include + +#ifndef PROGMEM +#define PROGMEM +#endif + +#ifdef ARDUINO +#include +#endif + +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. +// 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" + "DREIVIERTEL" + "VORXXXXNACH" + "HALBXELF\xDC" "NF" + "EINSXXXZWEI" + "DREIXXXVIER" + "SECHSXXACHT" + "SIEBENZW\xD6" "LF" + "ZEHNEUNXUHR" + "1234"; + +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; + +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 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); +} + +/* + * Determine 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) { + // DREI and VIER also occur inside DREIVIERTEL and VIERTEL, so the standalone + // hour word must use the second occurrence in the matrix. + if (id == WordId::Three || id == WordId::Four) + return 1; + + // Select the second (hour) group for ES IST FÜNF UHR and ES IST ZEHN UHR + 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 + * @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 + */ +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 +} + +/* + * Return the longest word used by the language pack. + * @return maximum word length in bytes + */ +inline int maxWordLength() { + int maximum = 0; + + for (uint16_t id = 0; id <= static_cast(WordId::OnePlural); ++id) { + const int length = static_cast(wordLength(wordText(static_cast(id)))); + + if (length > maximum) + maximum = length; + } + + return maximum; +} + +/* + * Compare a language word with a matrix at a logical position. + * + * @param matrix configured character matrix, stored in RAM or flash as needed + * @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(const String& matrix, int position, const char* word, size_t length) { +#ifdef ARDUINO + const char* matrixPosition = matrix.c_str() + position; + + for (size_t index = 0; index < length; ++index) { + if (matrixPosition[index] != pgm_read_byte(word + index)) + return false; + } + + return true; +#else + return strncmp(matrix.c_str() + 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 String& matrix, const char* word, int searchFrom, int rowWidth) { + const size_t length = wordLength(word); + const int matrixLength = static_cast(matrix.length()); + + for (int position = searchFrom; position + static_cast(length) <= matrixLength; ++position) { + if (WordClockCore::wordFitsInRow(position, static_cast(length), rowWidth) && + wordMatchesAt(matrix, 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 String& matrix, const char* word, int occurrence, int rowWidth) { + const size_t length = wordLength(word); + const int matrixLength = static_cast(matrix.length()); + int seen = 0; + + for (int position = 0; position + static_cast(length) <= matrixLength; ++position) { + if (WordClockCore::wordFitsInRow(position, static_cast(length), rowWidth) && + wordMatchesAt(matrix, 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, const String& matrix, + int rowWidth, bool meander, bool* ledMask, size_t maskLength) { + if (ledMask == nullptr || maskLength < matrix.length()) + return false; + + memset(ledMask, 0, maskLength * 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)); + + if (word == nullptr || plan.units[unitIndex].matchMode != WordClockCore::MatchMode::Sequential) + return false; + + int position = -1; + + if (plan.units[unitIndex].occurrence >= 0) { + position = findWordOccurrence(matrix, word, plan.units[unitIndex].occurrence, rowWidth); + } else { + position = findWord(matrix, word, searchFrom, rowWidth); + } + + 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, rowWidth, static_cast(matrix.length())); + + if (ledIndex < 0 || static_cast(ledIndex) >= maskLength) + return false; + + ledMask[ledIndex] = true; + } + + searchFrom = position + length; + } + + return true; +} + +} // namespace WordClockGerman diff --git a/usermods/usermod_v2_word_clock/lang/word_clock_language_nl.h b/usermods/usermod_v2_word_clock/lang/word_clock_language_nl.h new file mode 100644 index 0000000000..780f9cf47f --- /dev/null +++ b/usermods/usermod_v2_word_clock/lang/word_clock_language_nl.h @@ -0,0 +1,335 @@ +#pragma once + +#include "../word_clock_core.h" + +#include + +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 = + "NEUEHETHETHT" + "NFEYIEISISVT" + "VIJFKWARTNAA" + "AGBETIENOAEE" + "AINOVERVOORA" + "TFUHALFIEENY" + "OEHIBUZEVENV" + "NBNMTWEEELFN" + "DRIEVIERVIJF" + "NEGENZESTIEN" + "TWAALFACHTNT" + "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 - MINUTE_DOT_COUNT) / DEFAULT_CHARACTER_MATRIX_WIDTH; + +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 { + 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 displayItIs include the optional HET IS prefix + * @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, bool displayItIs, + WordClockCore::DisplayPlan& plan) { + plan = {}; + if (displayItIs && + (!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 + * @param selectionSeed stable seed for selecting repeated word occurrences + * @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 stable valid occurrence of a repeated 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, + uint16_t selectionSeed) { + 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 ? selectionSeed % 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 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, + uint16_t selectionSeed) { + 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)); + int wordIndex = plan.units[unitIndex].matchMode == WordClockCore::MatchMode::RandomOccurrence + ? findRandomWord(matrix, word, rowWidth, selectionSeed + plan.units[unitIndex].id) + : 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/platformio_override.ini.sample b/usermods/usermod_v2_word_clock/platformio_override.ini.sample new file mode 100644 index 0000000000..3e94c48c6b --- /dev/null +++ b/usermods/usermod_v2_word_clock/platformio_override.ini.sample @@ -0,0 +1,17 @@ + [platformio] + default_envs = wordclock + + [env:wordclock] + extends = env:esp32dev + custom_usermods = ${env:esp32dev.custom_usermods} usermod_v2_word_clock + + # On Mac/Linux, 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 + + # Optional: uncomment to enable specific language + # build_flags = + # ${env:esp32dev.build_flags} + # -D WORD_CLOCK_LANGUAGE_NL \ No newline at end of file diff --git a/usermods/usermod_v2_word_clock/readme.md b/usermods/usermod_v2_word_clock/readme.md index b81cebcea9..9c15ff262a 100644 --- a/usermods/usermod_v2_word_clock/readme.md +++ b/usermods/usermod_v2_word_clock/readme.md @@ -1,39 +1,139 @@ -# Word Clock Usermod V2 +# Word Clock Usermod -This usermod drives an 11x10 pixel matrix wordclock with WLED. There are 4 additional dots for the minutes. -The visualisation is described by 4 masks with LED numbers (single dots for minutes, minutes, hours and "clock"). The index of the LEDs in the masks always starts at 0, even if the ledOffset is not 0. -There are 3 parameters that control behavior: +This usermod turns a grid of LEDs into a 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. -active: enable/disable usermod -diplayItIs: enable/disable display of "Es ist" on the clock -ledOffset: number of LEDs before the wordclock LEDs +German is selected by default for compatibility with the original WLED word +clock. Dutch can be selected at compile time with `-D WORD_CLOCK_LANGUAGE_NL`. +The language is not a runtime setting. -## Update for alternative wiring pattern +The default German matrix has 11 columns and 10 rows, plus four minute-dot +markers (`1234`) at the end of the layout. The dots are cumulative: at 12:03, +dots 1, 2, and 3 are lit. Adding these minute dots is optional; a marker-free +custom matrix uses nearest-five-minute rounding instead. -Based on this fantastic work I added an alternative wiring pattern. -The original used a long wire to connect DO to DI, from one line to the next line. +The generator can create Dutch or German example matrices. For example, a +Dutch matrix can be generated with the language selector in +`word-clock-matrix-generator.html`: -I wired my clock in meander style. So the first LED in the second line is on the right. -With this method, every other line was inverted and showed the wrong letter. +The default German matrix is 11 columns wide and 10 rows high, followed by +four minute-dot positions: + + ESXISTXFÜNF + ZEHNZWANZIG + DREIVIERTEL + VORXXXXNACH + HALBXELFÜNF + EINSXXXZWEI + DREIXXXVIER + SECHSXXACHT + SIEBENZWÖLF + ZEHNEUNXUHR + 1234 + +At 6:43 (06:40 and 3 minutes), the default German clock displays `ZWANZIG VOR SIEBEN` +and lights 3 of the minute-dot markers: + + ES.IST..... + ....ZWANZIG + ........... + VOR........ + ........... + ........... + ........... + ........... + SIEBEN..... + ........... + 123. + +The usermod does not choose 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 layout letters and optional minute markers. Include all words needed by the selected language, 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. + * `Led Offset`: the number of physical LEDs before the first word-clock letter. + * `Display It Is`: include the `ES IST` or `HET IS` prefix when supported by the selected language. + * `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. A complete `1234` marker set enables floor rounding and cumulative +minute dots; without markers, the clock rounds to the nearest five minutes. -I added a switch in usermod called "meander wiring?" to enable/disable the alternate wiring pattern. ## Installation -Copy and update the example `platformio_override.ini.sample` -from the Rotary Encoder UI usermod folder to the root directory of your particular build. -This file should be placed in the same directory as `platformio.ini`. +1. Copy `platformio_override.ini.sample` from the `usermods/usermod_v2_word_clock` + folder to `platformio_override.ini` in the top WLED folder. Update the board + and serial port settings to match your hardware. For example: + + ```ini + [platformio] + default_envs = wordclock + + [env:wordclock] + extends = env:esp32dev + custom_usermods = ${env:esp32dev.custom_usermods} usermod_v2_word_clock + + # On Mac/Linux, 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 + + # Optional: uncomment to enable specific language + # build_flags = + # ${env:esp32dev.build_flags} + # -D WORD_CLOCK_LANGUAGE_NL + ``` + +2. Build WLED and upload it to your controller: + + npm run build + pio run -e wordclock --target upload + +3. Open WLED and activate the usermod at Config > Usermods > Word Clock. + + +## Customization + +To create a custom matrix, open +`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. Remove all line breaks, and set +`Character Matrix Width` to the number of columns in each row. + +The generator supports Dutch and German configurations, but its JavaScript +language data is maintained separately from the firmware language packs. +Changing the language grammar in firmware is an advanced customization. + +The merged usermod stores settings under the `Word Clock` configuration +object. Existing settings under `WordClockUsermod` are imported and rewritten +under the new object when the merged usermod starts. +The friendly setting names `Led Offset` and `Display It Is` migrate existing +`ledOffset` and `displayItIs` values automatically. + ### Define Your Options -* `USERMOD_WORDCLOCK` - define this to have this usermod included wled00\usermods_list.cpp +The usermod is activated with `custom_usermods = usermod_v2_word_clock`. +Use `-D WORD_CLOCK_LANGUAGE_NL` to select Dutch instead of the default German. + ### PlatformIO requirements No special requirements. -## Change Log -2022/08/18 added meander wiring pattern. +## Change Log -2022/03/30 initial commit +* 2026-09-08 Rewrote logic to be more generic, added Dutch language and matrix generator +* 2022-08-18 Added meander wiring pattern. +* 2022-03-30 Initial commit diff --git a/usermods/usermod_v2_word_clock/usermod_v2_word_clock.cpp b/usermods/usermod_v2_word_clock/usermod_v2_word_clock.cpp index 5100da180d..5b145cfccc 100644 --- a/usermods/usermod_v2_word_clock/usermod_v2_word_clock.cpp +++ b/usermods/usermod_v2_word_clock/usermod_v2_word_clock.cpp @@ -1,508 +1,568 @@ #include "wled.h" +#if defined(WORD_CLOCK_LANGUAGE_NL) + #include "lang/word_clock_language_nl.h" + namespace WordClock = WordClockDutch; +#else + #ifndef WORD_CLOCK_LANGUAGE_DE + #define WORD_CLOCK_LANGUAGE_DE + #endif + #include "lang/word_clock_language_de.h" + namespace WordClock = WordClockGerman; +#endif + /* - * Usermods allow you to add own functionality to WLED more easily - * See: https://github.com/wled-dev/WLED/wiki/Add-own-functionality - * - * This usermod can be used to drive a wordclock with a 11x10 pixel matrix with WLED. There are also 4 additional dots for the minutes. - * The visualisation is described in 4 mask with LED numbers (single dots for minutes, minutes, hours and "clock/Uhr"). - * There are 2 parameters to change the behaviour: + * Word Clock Usermod + * This usermod displays the time in words using a compile-time selected + * language pack. It uses the WLED V2 usermod API to integrate with WLED 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 language-specific sentence. * - * active: enable/disable usermod - * diplayItIs: enable/disable display of "Es ist" on the clock. + * These settings are available in the Config > Usermods > Word Clock + * 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. + * - `Led Offset`: the number of physical LEDs before the first word-clock + * letter. + * - `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 WordClockUsermod : public Usermod +class WordClockUsermod : public Usermod { - private: - unsigned long lastTime = 0; - int lastTimeMinutes = -1; - - // set your config variables to their boot default value (this can also be done in readFromConfig() or a constructor if you prefer) - bool usermodActive = false; - bool displayItIs = false; - int ledOffset = 100; - bool meander = false; - bool nord = false; - - // defines for mask sizes - #define maskSizeLeds 114 - #define maskSizeMinutes 12 - #define maskSizeMinutesMea 12 - #define maskSizeHours 6 - #define maskSizeHoursMea 6 - #define maskSizeItIs 5 - #define maskSizeMinuteDots 4 - - // "minute" masks - // Normal wiring - const int maskMinutes[14][maskSizeMinutes] = - { - {107, 108, 109, -1, -1, -1, -1, -1, -1, -1, -1, -1}, // 0 - 00 - { 7, 8, 9, 10, 40, 41, 42, 43, -1, -1, -1, -1}, // 1 - 05 fünf nach - { 11, 12, 13, 14, 40, 41, 42, 43, -1, -1, -1, -1}, // 2 - 10 zehn nach - { 26, 27, 28, 29, 30, 31, 32, -1, -1, -1, -1, -1}, // 3 - 15 viertel - { 15, 16, 17, 18, 19, 20, 21, 40, 41, 42, 43, -1}, // 4 - 20 zwanzig nach - { 7, 8, 9, 10, 33, 34, 35, 44, 45, 46, 47, -1}, // 5 - 25 fünf vor halb - { 44, 45, 46, 47, -1, -1, -1, -1, -1, -1, -1, -1}, // 6 - 30 halb - { 7, 8, 9, 10, 40, 41, 42, 43, 44, 45, 46, 47}, // 7 - 35 fünf nach halb - { 15, 16, 17, 18, 19, 20, 21, 33, 34, 35, -1, -1}, // 8 - 40 zwanzig vor - { 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, -1}, // 9 - 45 dreiviertel - { 11, 12, 13, 14, 33, 34, 35, -1, -1, -1, -1, -1}, // 10 - 50 zehn vor - { 7, 8, 9, 10, 33, 34, 35, -1, -1, -1, -1, -1}, // 11 - 55 fünf vor - { 26, 27, 28, 29, 30, 31, 32, 40, 41, 42, 43, -1}, // 12 - 15 alternative viertel nach - { 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, -1, -1} // 13 - 45 alternative viertel vor - }; - - // Meander wiring - const int maskMinutesMea[14][maskSizeMinutesMea] = - { - { 99, 100, 101, -1, -1, -1, -1, -1, -1, -1, -1, -1}, // 0 - 00 - { 7, 8, 9, 10, 33, 34, 35, 36, -1, -1, -1, -1}, // 1 - 05 fünf nach - { 18, 19, 20, 21, 33, 34, 35, 36, -1, -1, -1, -1}, // 2 - 10 zehn nach - { 26, 27, 28, 29, 30, 31, 32, -1, -1, -1, -1, -1}, // 3 - 15 viertel - { 11, 12, 13, 14, 15, 16, 17, 33, 34, 35, 36, -1}, // 4 - 20 zwanzig nach - { 7, 8, 9, 10, 41, 42, 43, 44, 45, 46, 47, -1}, // 5 - 25 fünf vor halb - { 44, 45, 46, 47, -1, -1, -1, -1, -1, -1, -1, -1}, // 6 - 30 halb - { 7, 8, 9, 10, 33, 34, 35, 36, 44, 45, 46, 47}, // 7 - 35 fünf nach halb - { 11, 12, 13, 14, 15, 16, 17, 41, 42, 43, -1, -1}, // 8 - 40 zwanzig vor - { 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, -1}, // 9 - 45 dreiviertel - { 18, 19, 20, 21, 41, 42, 43, -1, -1, -1, -1, -1}, // 10 - 50 zehn vor - { 7, 8, 9, 10, 41, 42, 43, -1, -1, -1, -1, -1}, // 11 - 55 fünf vor - { 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, -1}, // 12 - 15 alternative viertel nach - { 26, 27, 28, 29, 30, 31, 32, 41, 42, 43, -1, -1} // 13 - 45 alternative viertel vor - }; - - - // hour masks - // Normal wiring - const int maskHours[13][maskSizeHours] = - { - { 55, 56, 57, -1, -1, -1}, // 01: ein - { 55, 56, 57, 58, -1, -1}, // 01: eins - { 62, 63, 64, 65, -1, -1}, // 02: zwei - { 66, 67, 68, 69, -1, -1}, // 03: drei - { 73, 74, 75, 76, -1, -1}, // 04: vier - { 51, 52, 53, 54, -1, -1}, // 05: fünf - { 77, 78, 79, 80, 81, -1}, // 06: sechs - { 88, 89, 90, 91, 92, 93}, // 07: sieben - { 84, 85, 86, 87, -1, -1}, // 08: acht - {102, 103, 104, 105, -1, -1}, // 09: neun - { 99, 100, 101, 102, -1, -1}, // 10: zehn - { 49, 50, 51, -1, -1, -1}, // 11: elf - { 94, 95, 96, 97, 98, -1} // 12: zwölf and 00: null - }; - // Meander wiring - const int maskHoursMea[13][maskSizeHoursMea] = - { - { 63, 64, 65, -1, -1, -1}, // 01: ein - { 62, 63, 64, 65, -1, -1}, // 01: eins - { 55, 56, 57, 58, -1, -1}, // 02: zwei - { 66, 67, 68, 69, -1, -1}, // 03: drei - { 73, 74, 75, 76, -1, -1}, // 04: vier - { 51, 52, 53, 54, -1, -1}, // 05: fünf - { 83, 84, 85, 86, 87, -1}, // 06: sechs - { 88, 89, 90, 91, 92, 93}, // 07: sieben - { 77, 78, 79, 80, -1, -1}, // 08: acht - {103, 104, 105, 106, -1, -1}, // 09: neun - {106, 107, 108, 109, -1, -1}, // 10: zehn - { 49, 50, 51, -1, -1, -1}, // 11: elf - { 94, 95, 96, 97, 98, -1} // 12: zwölf and 00: null - }; - - // mask "it is" - const int maskItIs[maskSizeItIs] = {0, 1, 3, 4, 5}; - - // mask minute dots - const int maskMinuteDots[maskSizeMinuteDots] = {110, 111, 112, 113}; - - // overall mask to define which LEDs are on - int maskLedsOn[maskSizeLeds] = - { - 0,0,0,0,0,0,0,0,0,0,0, - 0,0,0,0,0,0,0,0,0,0,0, - 0,0,0,0,0,0,0,0,0,0,0, - 0,0,0,0,0,0,0,0,0,0,0, - 0,0,0,0,0,0,0,0,0,0,0, - 0,0,0,0,0,0,0,0,0,0,0, - 0,0,0,0,0,0,0,0,0,0,0, - 0,0,0,0,0,0,0,0,0,0,0, - 0,0,0,0,0,0,0,0,0,0,0, - 0,0,0,0,0,0,0,0,0,0,0, - 0,0,0,0 - }; - - // update led mask - void updateLedMask(const int wordMask[], int arraySize) - { - // loop over array - for (int x=0; x < arraySize; x++) - { - // check if mask has a valid LED number - if (wordMask[x] >= 0 && wordMask[x] < maskSizeLeds) - { - // turn LED on - maskLedsOn[wordMask[x]] = 1; - } - } +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 = FPSTR(WordClock::DEFAULT_CHARACTER_MATRIX); + + // The number of characters per row + int characterMatrixWidth = WordClock::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, + // 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. + int lastRefreshMinute = -1; + + // 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; + bool* wordMask = nullptr; + + + // 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; + + bool displayItIs = false; + bool nord = false; + 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; + + // 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; + + void allocateLedMask() { + if (ledMask) { + d_free(ledMask); + ledMask = nullptr; + } + if (wordMask) { + d_free(wordMask); + wordMask = nullptr; } - // set hours - void setHours(int hours, bool fullClock) - { - int index = hours; + size_t matrixLength = characterMatrix.length(); - // handle 00:xx as 12:xx - if (hours == 0) - { - index = 12; - } + if (matrixLength == 0) + return; - // check if we get an overrun of 12 o´clock - if (hours == 13) - { - index = 1; - } + ledMask = (bool*) d_malloc(matrixLength * sizeof(bool)); + wordMask = (bool*) d_malloc(matrixLength * sizeof(bool)); - // special handling for "ein Uhr" instead of "eins Uhr" - if (hours == 1 && fullClock == true) - { - index = 0; + if (ledMask && wordMask) { + memset(ledMask, 0, matrixLength * sizeof(bool)); + memset(wordMask, 0, matrixLength * sizeof(bool)); + } else { + if (ledMask) { + d_free(ledMask); + ledMask = nullptr; } - - // update led mask - if (meander) - { - updateLedMask(maskHoursMea[index], maskSizeHoursMea); - } else { - updateLedMask(maskHours[index], maskSizeHours); + if (wordMask) { + d_free(wordMask); + wordMask = nullptr; } } + } + + String lastSentence = ""; + int lastPhraseKey = -1; + bool phraseMaskValid = false; + + void updateMinuteDots(const WordClockCore::MinuteDotMarkers& markers, uint8_t minuteDotCount) { + if (!markers.enabled() || !ledMask || !wordMask) + return; + + memcpy(ledMask, wordMask, characterMatrix.length() * sizeof(bool)); + + for (uint8_t dot = 0; dot < WordClockCore::MAX_MINUTE_DOTS; ++dot) { + const int markerIndex = markers.positions[dot]; + + if (markerIndex < 0 || static_cast(markerIndex) >= characterMatrix.length()) + continue; + + int ledIndex = markerIndex; - // set minutes - void setMinutes(int index) - { - // update led mask if (meander) - { - updateLedMask(maskMinutesMea[index], maskSizeMinutesMea); - } else { - updateLedMask(maskMinutes[index], maskSizeMinutes); - } - } + ledIndex = WordClockCore::toMeanderIndex(ledIndex, characterMatrixWidth, characterMatrix.length()); - // set minutes dot - void setSingleMinuteDots(int minutes) - { - // modulo to get minute dots - int minutesDotCount = minutes % 5; - - // check if minute dots are active - if (minutesDotCount > 0) - { - // activate all minute dots until number is reached - for (int i = 0; i < minutesDotCount; i++) - { - // activate LED - maskLedsOn[maskMinuteDots[i]] = 1; - } - } + if (ledIndex >= 0 && static_cast(ledIndex) < characterMatrix.length()) + ledMask[ledIndex] = dot < minuteDotCount; + } + } + + /* + * 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. + */ + // 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); + } + + void updateLedMaskForCurrentTime() { + const int currentMinutes = testHour >= 0 + ? (testHour * 60 + testMinute) % 1440 + : (hour(localTime) * 60 + minute(localTime)) % 1440; + WordClockCore::MinuteDotMarkers markers; + + if (!WordClockCore::parseMinuteDotMarkers(characterMatrix.c_str(), characterMatrix.length(), markers)) { + DEBUG_PRINTLN(F("Invalid word clock configuration")); + return; } - // update the display - void updateDisplay(uint8_t hours, uint8_t minutes) - { - // disable complete matrix at the bigging - for (int x = 0; x < maskSizeLeds; x++) - { - maskLedsOn[x] = 0; - } - - // display it is/es ist if activated - if (displayItIs) - { - updateLedMask(maskItIs, maskSizeItIs); - } + const WordClockCore::TimeContext time = WordClockCore::makeTimeContext(currentMinutes, markers.enabled()); + const int phraseKey = markers.enabled() + ? currentMinutes - currentMinutes % 5 + : time.hour12 * 60 + time.displayedMinute; - // set single minute dots - setSingleMinuteDots(minutes); - - // switch minutes - switch (minutes / 5) - { - case 0: - // full hour - setMinutes(0); - setHours(hours, true); - break; - case 1: - // 5 nach - setMinutes(1); - setHours(hours, false); - break; - case 2: - // 10 nach - setMinutes(2); - setHours(hours, false); - break; - case 3: - if (nord) { - // viertel nach - setMinutes(12); - setHours(hours, false); - } else { - // viertel - setMinutes(3); - setHours(hours + 1, false); - }; - break; - case 4: - // 20 nach - setMinutes(4); - setHours(hours, false); - break; - case 5: - // 5 vor halb - setMinutes(5); - setHours(hours + 1, false); - break; - case 6: - // halb - setMinutes(6); - setHours(hours + 1, false); - break; - case 7: - // 5 nach halb - setMinutes(7); - setHours(hours + 1, false); - break; - case 8: - // 20 vor - setMinutes(8); - setHours(hours + 1, false); - break; - case 9: - // viertel vor - if (nord) { - setMinutes(13); - } - // dreiviertel - else { - setMinutes(9); - } - setHours(hours + 1, false); - break; - case 10: - // 10 vor - setMinutes(10); - setHours(hours + 1, false); - break; - case 11: - // 5 vor - setMinutes(11); - setHours(hours + 1, false); - break; - } + if (phraseMaskValid && phraseKey == lastPhraseKey) { + updateMinuteDots(markers, time.minuteDotCount); + return; } - public: - //Functions called by WLED + WordClockCore::DisplayPlan plan; + const size_t matrixLength = characterMatrix.length(); + if (!ledMask || !wordMask) { + errorFlag = ERR_LOW_MEM; + return; + } - /* - * 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() - { + bool* pendingMask = (bool*) d_malloc(matrixLength * sizeof(bool)); + + if (!pendingMask) { + errorFlag = ERR_LOW_MEM; + return; } - /* - * connected() is called every time the WiFi is (re)connected - * Use it to initialize network interfaces - */ - void connected() - { + bool placementOk = false; + #if defined(WORD_CLOCK_LANGUAGE_NL) + placementOk = WordClock::buildPlan(time, displayItIs, plan) && + WordClock::placePlan(time, plan, characterMatrix, characterMatrixWidth, meander, pendingMask, + static_cast(phraseKey)); + #else + placementOk = WordClock::buildPlan(time, displayItIs, nord, plan) && + WordClock::placePlan(time, plan, characterMatrix, characterMatrixWidth, meander, pendingMask, + characterMatrix.length()); + #endif + + if (!placementOk) { + d_free(pendingMask); + DEBUG_PRINTLN(F("Invalid word clock configuration")); + return; } - /* - * 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() { - - // do it every 5 seconds - if (millis() - lastTime > 5000) - { - // check the time - int minutes = minute(localTime); - - // check if we already updated this minute - if (lastTimeMinutes != minutes) - { - // update the display with new time - updateDisplay(hourFormat12(localTime), minute(localTime)); - - // remember last update time - lastTimeMinutes = minutes; - } - - // remember last update - lastTime = millis(); - } + memcpy(ledMask, pendingMask, matrixLength * sizeof(bool)); + memcpy(wordMask, pendingMask, matrixLength * sizeof(bool)); + d_free(pendingMask); + + if (markers.enabled()) { + for (uint8_t dot = 0; dot < WordClockCore::MAX_MINUTE_DOTS; ++dot) + wordMask[markers.positions[dot]] = false; } - /* - * 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) - { + lastPhraseKey = phraseKey; + phraseMaskValid = true; + updateMinuteDots(markers, time.minuteDotCount); + + String sentence; + + for (uint8_t index = 0; index < plan.count; ++index) { + if (index > 0) sentence += ' '; + sentence += FPSTR(WordClock::wordText(static_cast(plan.units[index].id))); } - */ - - /* - * 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) - { + + lastSentence = sentence; + }; + +public: + // Functions called by WLED + + ~WordClockUsermod() { + if (ledMask) + d_free(ledMask); + if (wordMask) + d_free(wordMask); + } + + /* + * 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() { + allocateLedMask(); + } + + /* + * 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() { + if (testHour < 0 && localTime == 0) + return; + + const int currentMinute = testHour >= 0 + ? (testHour * 60 + testMinute) % 1440 + : (hour(localTime) * 60 + minute(localTime)) % 1440; + + if (currentMinute == lastRefreshMinute) + return; + + updateLedMaskForCurrentTime(); + lastRefreshMinute = currentMinute; + } + + /* + * 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 sentence is built from the words selected for the configured display options. + user[F("WClock sentence")] = 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++; } - /* - * 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) - { + 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")); + top[F("active")] = usermodActive; + top[F("Display It Is")] = displayItIs; + top[F("Led Offset")] = ledOffset; + #if defined(WORD_CLOCK_LANGUAGE_DE) + top[F("Norddeutsch")] = nord; + #endif + 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("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:Brightness_Active', 1, '(0-255)');")); + oappend(F("addInfo('Word Clock:Brightness_Inactive', 1, '(0-255)');")); + oappend(F("addInfo('Word Clock:Led Offset', 1, 'Number of LEDs before the letters');")); + oappend(F("addInfo('Word Clock:Test_Hour', 1, '(0-23, -1 for real time)');")); + oappend(F("addInfo('Word Clock:Test_Minute', 1, '(0-59)');")); + #if defined(WORD_CLOCK_LANGUAGE_DE) + oappend(F("addInfo('Word Clock:Norddeutsch', 1, 'Viertel vor instead of Dreiviertel');")); + #endif + } + + /* + * 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")]; + bool legacyConfig = top.isNull(); + JsonObject legacyTop = root[F("WordClockUsermod")]; + + if (legacyConfig && !legacyTop.isNull()) { + top = root.createNestedObject(F("Word Clock")); } - /* - * 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("WordClockUsermod")); - top[F("active")] = usermodActive; - top[F("displayItIs")] = displayItIs; - top[F("ledOffset")] = ledOffset; - top[F("Meander wiring?")] = meander; - top[F("Norddeutsch")] = nord; + bool configComplete = !top.isNull(); + + configComplete &= getJsonValue(top[F("active")], usermodActive); + bool prevDisplayItIs = displayItIs; + + if (!getJsonValue(top[F("Display It Is")], displayItIs) && + getJsonValue(legacyTop[F("displayItIs")], displayItIs)) { + top[F("Display It Is")] = displayItIs; + } + + if (!getJsonValue(top[F("Led Offset")], ledOffset) && + getJsonValue(legacyTop[F("ledOffset")], ledOffset)) { + top[F("Led Offset")] = ledOffset; } - void appendConfigData() - { - oappend(F("addInfo('WordClockUsermod:ledOffset', 1, 'Number of LEDs before the letters');")); - oappend(F("addInfo('WordClockUsermod:Norddeutsch', 1, 'Viertel vor instead of Dreiviertel');")); + #if defined(WORD_CLOCK_LANGUAGE_DE) + bool prevNord = nord; + getJsonValue(top[F("Norddeutsch")], nord); + #endif + getJsonValue(top[F("Brightness_Active")], opacityActive); + getJsonValue(top[F("Brightness_Inactive")], opacityInactive); + opacityActive = clampInt(opacityActive, 0, 255); + opacityInactive = clampInt(opacityInactive, 0, 255); + + if (displayItIs != prevDisplayItIs + #if defined(WORD_CLOCK_LANGUAGE_DE) + || nord != prevNord + #endif + ) { + lastSentence = ""; + phraseMaskValid = false; + lastRefreshMinute = -1; } - /* - * 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("WordClockUsermod")]; - - bool configComplete = !top.isNull(); - - configComplete &= getJsonValue(top[F("active")], usermodActive); - configComplete &= getJsonValue(top[F("displayItIs")], displayItIs); - configComplete &= getJsonValue(top[F("ledOffset")], ledOffset); - configComplete &= getJsonValue(top[F("Meander wiring?")], meander); - configComplete &= getJsonValue(top[F("Norddeutsch")], nord); - - return configComplete; + bool prevMeander = meander; + getJsonValue(top[F("meander")], meander); + + if (meander != prevMeander) { + lastSentence = ""; // force mask recompute + phraseMaskValid = false; + lastRefreshMinute = -1; // trigger recompute on very next loop() call } - /* - * 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 == true) - { - // loop over all leds - for (int x = 0; x < maskSizeLeds; x++) - { - // check mask - if (maskLedsOn[x] == 0) - { - // set pixel off - strip.setPixelColor(x + ledOffset, RGBW32(0,0,0,0)); - } - } + String prevCharacterMatrix = characterMatrix; + getJsonValue(top[F("Character_Matrix")], characterMatrix); + + int prevCharacterMatrixWidth = characterMatrixWidth; + getJsonValue(top[F("Character_Matrix_Width")], characterMatrixWidth); + + // Only do a basic sanity check. We're not performing a full + // validation of the character matrix here to allow the user + // some leeway in the matrix design. + if (characterMatrix.length() < WordClock::maxWordLength()) { + characterMatrix = prevCharacterMatrix; + characterMatrixWidth = prevCharacterMatrixWidth; + configComplete = false; + } else { + if (!characterMatrix.equals(prevCharacterMatrix)) { + allocateLedMask(); + + lastSentence = ""; // force mask recompute + phraseMaskValid = false; + lastRefreshMinute = -1; // trigger recompute on very next loop() call } + + characterMatrixWidth = clampInt(characterMatrixWidth, WordClock::maxWordLength(), characterMatrix.length()); } - /* - * 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; + const int stripLength = strip.getLengthTotal(); + + if (characterMatrix.length() > static_cast(stripLength)) { + ledOffset = 0; + configComplete = false; + } else { + const int maxLedOffset = stripLength - characterMatrix.length(); + ledOffset = clampInt(ledOffset, 0, maxLedOffset); + } + + if (characterMatrixWidth != prevCharacterMatrixWidth) { + lastSentence = ""; // force mask recompute + phraseMaskValid = false; + lastRefreshMinute = -1; // trigger recompute on very next loop() call } - //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! + 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 + phraseMaskValid = false; + lastRefreshMinute = -1; // 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(); + const int stripLength = strip.getLengthTotal(); + + if (ledOffset < 0 || matrixLen > stripLength - ledOffset) + return; + + // Loop over all leds + for (int i = 0; i < matrixLen; i++) { + int physIndex = ledOffset + i; + 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; + uint8_t w = ((color >> 24) & 0xFF) * scale / 255; + strip.setPixelColor(physIndex, RGBW32(r, g, b, w)); + } + } + + /* + * 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; + } + + // 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 WordClockUsermod usermod_v2_word_clock; -REGISTER_USERMOD(usermod_v2_word_clock); \ No newline at end of file +REGISTER_USERMOD(usermod_v2_word_clock); diff --git a/usermods/usermod_v2_word_clock/word-clock-matrix-generator.html b/usermods/usermod_v2_word_clock/word-clock-matrix-generator.html new file mode 100644 index 0000000000..96e15dea58 --- /dev/null +++ b/usermods/usermod_v2_word_clock/word-clock-matrix-generator.html @@ -0,0 +1,882 @@ + + + + + + Word Clock Matrix Generator + + + + + + +
+ + + + 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..5e400d484d --- /dev/null +++ b/usermods/usermod_v2_word_clock/word_clock_core.h @@ -0,0 +1,183 @@ +#pragma once + +#include +#include + +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; + +// 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 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. +}; + +// 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, int8_t occurrence = -1) { + if (count >= MAX_PLAN_UNITS) + return false; + + units[count++] = {id, matchMode, occurrence}; + + return true; + } +}; + +/* + * 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. + 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. + * + * @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; + 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. + * + * @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. + * + * @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; + + 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 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] = {}; + + 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