diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index eb4169b..cd0f07b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,6 +5,18 @@ on: [push, pull_request] jobs: build: runs-on: ubuntu-latest + strategy: + matrix: + signal: [WWVB, DCF77, MSF, JJY] + include: + - signal: WWVB + flags: "" + - signal: DCF77 + flags: "-D SIGNAL_DCF77" + - signal: MSF + flags: "-D SIGNAL_MSF" + - signal: JJY + flags: "-D SIGNAL_JJY" steps: - uses: actions/checkout@v3 @@ -39,7 +51,11 @@ jobs: run: pio pkg install - name: Run Build + env: + PLATFORMIO_BUILD_FLAGS: ${{ matrix.flags }} run: pio run - name: Run Native Tests + env: + PLATFORMIO_BUILD_FLAGS: ${{ matrix.flags }} run: pio test -e native diff --git a/README.md b/README.md index 8eb1512..df81ff5 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# The Watch Tower WWVB transmitter +# The Watch Tower time signal transmitter ![](docs/ezgif-2a44364473c432.gif) @@ -6,8 +6,16 @@ There are some beautiful radio-controlled watches available these days from Citi In the US, these watches work by receiving a 60-bit 1-Hz signal on a 60-kHz carrier wave broadcast from Fort Collins, Colorado called [WWVB](https://en.wikipedia.org/wiki/WWVB). The broadcast is quite strong and generally covers the entire continental US, but some areas of the country can have unreliable reception. I live in the SF Bay Area in an area with high RF noise and my reception can be spotty. My watches sync often enough that it’s not an issue 363 days out of the year, but sometimes they can miss DST shifts for a day or two. The east coast is known to be even more challenging. And people who live in other countries such as Australia have generally been out of luck. +Similar systems exist in other parts of the world, such as [DCF77](https://en.wikipedia.org/wiki/DCF77) in Germany (covering much of Europe), [MSF](https://en.wikipedia.org/wiki/Time_from_NPL_(MSF)) in the UK, and [JJY](https://en.wikipedia.org/wiki/JJY) in Japan. + Wouldn’t it be great if anyone anywhere in the world could set up a home transmitter to broadcast the time so their watches were always in sync? +WatchTower supports all of these signals! By default it transmits WWVB, but it can be easily configured to transmit DCF77, MSF, or JJY. + +> [!NOTE] +> DCF77, MSF, and JJY support is experimental and has not been extensively tested. +> Please file an issue if you run into any problems. + WWVB has been around awhile and there have been various other projects ([1](https://www.instructables.com/WWVB-radio-time-signal-generator-for-ATTINY45-or-A/),[2](https://github.com/anishathalye/micro-wwvb)) that have demonstrated the feasibility of making your own WWVB transmitter. But these all had very limited range. I wanted to build something that could cover my whole watch stand and be based on a more familiar toolset for the typical hobbyist, namely USB-based 32-bit microcontroller development boards, WiFi, and Arduino. My goal was to make something approachable, reliable, and attractive enough it could sit with my watch collection. ## Is this legal? @@ -16,6 +24,9 @@ The FCC requires a license to transmit, but has an [exemption](https://www.law.c ## About WWVB +> [!NOTE] +> This section will dive into WWVB because that's the signal the WatchTower uses by default. You can check wikipedia for more details on JJY, DCF77, and MSF. + The classic WWVB transmits one bit of information per second (1Hz) and takes one minute (60 bits) to transmit a full time and date frame. ### An example diff --git a/WatchTower.ino b/WatchTower.ino index b22ec0f..2ab0229 100644 --- a/WatchTower.ino +++ b/WatchTower.ino @@ -1,4 +1,7 @@ +// ======================== // INSTRUCTIONS +// ======================== + // - Add the following dependencies to your Arduino libraries: // - Adafruit NeoPixel ~1.15.2 // - ESPUI ~2.2.4 @@ -7,7 +10,7 @@ // - WiFiManager ~2.0.17 // - ArduinoMDNS ~1.0.0 // - set the PIN_ANTENNA to desired output pin -// - set the timezone as desired +// - set the TIMEZONE as desired // - build and run the code on your device // - connect your phone to "WatchTower" to set the wifi config for the device // - connect to http://watchtower.local to view current status @@ -20,15 +23,9 @@ // - Adafruit ESP32 Feather v2 // - Arduino Nano ESP32 (via wokwi) -#include -#include -#include -#include -#include -#include -#include -#include -#include "customJS.h" +// ======================== +// Configuration +// ======================== // Flip to false to disable the built-in web ui. // You might want to do this to avoid leaving unnecessary open ports on your network. @@ -40,50 +37,83 @@ const int PIN_ANTENNA = 13; // Set to your timezone. // This is needed for computing DST if applicable // https://gist.github.com/alwynallan/24d96091655391107939 -const char *timezone = "PST8PDT,M3.2.0,M11.1.0"; // America/Los_Angeles +const char *TIMEZONE = "PST8PDT,M3.2.0,M11.1.0"; // America/Los_Angeles +// ======================== +// Includes +// ======================== -enum WWVB_T { - ZERO = 0, - ONE = 1, - MARK = 2, -}; - -const int KHZ_60 = 60000; -const char* const ntpServer = "pool.ntp.org"; - -// Configure the optional onboard neopixel -#ifdef PIN_NEOPIXEL -Adafruit_NeoPixel* const pixel = new Adafruit_NeoPixel(1, PIN_NEOPIXEL, NEO_GRB + NEO_KHZ800); -#else -Adafruit_NeoPixel* const pixel = NULL; -#endif - -const uint8_t LED_BRIGHTNESS = 10; // very dim, 0-255 -const uint32_t COLOR_READY = pixel ? pixel->Color(0, 60, 0) : 0; // green https://share.google/4WKm4XDkH9tfm3ESC -const uint32_t COLOR_LOADING = pixel ? pixel->Color(60, 32, 0) : 0; // orange https://share.google/7tT5GPxskZi8t8qmx -const uint32_t COLOR_ERROR = pixel ? pixel->Color(150, 0, 0) : 0; // red https://share.google/nx2jWYSoGtl0opkzL -const uint32_t COLOR_TRANSMIT = pixel ? pixel->Color(32, 0, 0) : 0; // dim red https://share.google/wYFYM3t1kDeOJfr1U - +#include +#include "include/StatusLED.h" +#include +#include +#include +#include +#include +#include +#include "include/RadioTimeSignal.h" +#include "include/WWVBSignal.h" +#include "include/DCF77Signal.h" +#include "include/MSFSignal.h" +#include "include/JJYSignal.h" +#include "include/WebUI.h" + + +// ======================== +// Globals +// ======================== + +// Default to WWVB if no signal is specified +WWVBSignal wwvb; +DCF77Signal dcf77; +MSFSignal msf; +JJYSignal jjy; +RadioTimeSignal* signalGenerator = &wwvb; + +StatusLED statusLED; +WebUI webUI; WiFiManager wifiManager; WiFiUDP udp; MDNS mdns(udp); +Preferences preferences; bool logicValue = 0; // TODO rename -struct timeval lastSync; -WWVB_T broadcast[60]; +unsigned long lastSync = 0; +bool networkSyncEnabled = true; +const char* const ntpServer = "pool.ntp.org"; +esp_timer_handle_t signalTimer = nullptr; + +// Shared state between timer callback and loop() +TimeCodeSymbol broadcastA[60]; +TimeCodeSymbol broadcastB[60]; +volatile const TimeCodeSymbol* activeBroadcast = broadcastA; +volatile bool transitionOccurred = false; +volatile unsigned long lastTransitionUsec = 0; +volatile int lastTransitionSecond = 0; -// ESPUI Interface IDs -uint16_t ui_time; -uint16_t ui_date; -uint16_t ui_timezone; -uint16_t ui_broadcast; -uint16_t ui_uptime; -uint16_t ui_last_sync; +// ======================== +// Helpers +// ======================== + +// Convert a logical bit into a PWM pulse width. +// Returns 50% duty cycle (128) for high, 0% for low +static inline short dutyCycle(bool logicValue) { + return logicValue ? (256*0.5) : 0; // 128 == 50% duty cycle +} + +void clearBroadcastValues(TimeCodeSymbol* buf) { + for(int i=0; i<60; ++i) { + buf[i] = (TimeCodeSymbol)-1; // -1 isn't legal but that's okay, we just need an invalid value + } +} + +// ======================== +// Callbacks +// ======================== // A callback that tracks when we last sync'ed the // time with the ntp server void time_sync_notification_cb(struct timeval *tv) { - lastSync = *tv; + lastSync = millis(); } // A callback that is called when the device @@ -93,33 +123,50 @@ void accesspointCallback(WiFiManager*) { Serial.println("Connect to SSID: WatchTower with another device to set wifi configuration."); } -// Convert a logical bit into a PWM pulse width. -// Returns 50% duty cycle (128) for high, 0% for low -static inline short dutyCycle(bool logicValue) { - return logicValue ? (256*0.5) : 0; // 128 == 50% duty cycle -} - -static inline int is_leap_year(int year) { - return (year % 4 == 0) && (year % 100 != 0 || year % 400 == 0); -} - -void clearBroadcastValues() { - for(int i=0; i(activeBroadcast); + bool level = signalGenerator->getLevelForTimeCodeSymbol( + bits[sec], now.tv_usec / 1000); + if (level != logicValue) { + ledcWrite(PIN_ANTENNA, dutyCycle(level)); + logicValue = level; + lastTransitionUsec = now.tv_usec; + lastTransitionSecond = sec; + transitionOccurred = true; } } +// ======================== +// Arduino Setup & Loop +// ======================== + void setup() { Serial.begin(115200); delay(1000); pinMode(PIN_ANTENNA, OUTPUT); - if( pixel ) { - pixel->begin(); - pixel->setBrightness(LED_BRIGHTNESS); // very dim - pixel->setPixelColor(0, COLOR_LOADING ); - pixel->show(); - } + statusLED.begin(); + statusLED.setLoading(); // E (14621) rmt: rmt_new_tx_channel(269): not able to power down in light sleep digitalWrite(PIN_ANTENNA, 0); @@ -133,28 +180,23 @@ void setup() { wifiManager.setAPCallback(accesspointCallback); wifiManager.autoConnect("WatchTower"); - clearBroadcastValues(); + preferences.begin("watchtower", false); + networkSyncEnabled = preferences.getBool("net_sync", true); + String savedSignal = preferences.getString("signal", "WWVB"); + if (savedSignal == "DCF77") signalGenerator = &dcf77; + else if (savedSignal == "MSF") signalGenerator = &msf; + else if (savedSignal == "JJY") signalGenerator = &jjy; + else signalGenerator = &wwvb; - // --- ESPUI SETUP --- - ESPUI.setVerbosity(Verbosity::Quiet); - - // Create Labels - ui_broadcast = ESPUI.label("Broadcast Waveform", ControlColor::Sunflower, ""); - ui_time = ESPUI.label("Current Time", ControlColor::Turquoise, "Loading..."); - ui_date = ESPUI.label("Date", ControlColor::Emerald, "Loading..."); - ui_timezone = ESPUI.label("Timezone", ControlColor::Peterriver, timezone); - ui_uptime = ESPUI.label("System Uptime", ControlColor::Carrot, "0s"); - ui_last_sync = ESPUI.label("Last NTP Sync", ControlColor::Alizarin, "Pending..."); - - ESPUI.setPanelWide(ui_broadcast, true); - ESPUI.setElementStyle(ui_broadcast, "font-family: monospace"); - ESPUI.setCustomJS(customJS); - - // You may disable the internal webserver by commenting out this line + clearBroadcastValues(broadcastA); + clearBroadcastValues(broadcastB); + + // --- WEB UI SETUP --- if( ENABLE_WEB_UI ) { - mdns.begin(WiFi.localIP(), "watchtower"); - Serial.println("Connect to http://watchtower.local for the console"); - ESPUI.begin("WatchTower"); + webUI.begin(TIMEZONE, signalGenerator, + wwvb, dcf77, msf, jjy, + preferences, PIN_ANTENNA, ntpServer, + networkSyncEnabled, lastSync, mdns); } // --- TIME SYNC --- @@ -162,31 +204,47 @@ void setup() { // Connect to network time server // By default, it will resync every few hours sntp_set_time_sync_notification_cb(time_sync_notification_cb); - configTzTime(timezone, ntpServer); + + if (networkSyncEnabled) { + configTzTime(TIMEZONE, ntpServer); + } else { + // When network sync is disabled, we still need to configure the timezone + // so that localtime() works correctly. + setenv("TZ", TIMEZONE, 1); + tzset(); + } struct tm timeinfo; - if(!getLocalTime(&timeinfo)){ - Serial.println("Failed to obtain time"); - if( pixel ) { - pixel->setPixelColor(0, COLOR_ERROR ); - pixel->show(); + // Only block on time if sync is enabled + if (networkSyncEnabled) { + if (getLocalTime(&timeinfo)) { + Serial.println("Got the time from NTP"); + } else { + Serial.println("Failed to obtain time"); + statusLED.setError(); + delay(3000); + ESP.restart(); } - delay(3000); - ESP.restart(); - } - Serial.println("Got the time from NTP"); - - // Start the 60khz carrier signal using 8-bit (0-255) resolution - ledcAttach(PIN_ANTENNA, KHZ_60, 8); - - // green means go - if( pixel ) { - pixel->setPixelColor(0, COLOR_READY ); - pixel->show(); - delay(3000); - pixel->clear(); - pixel->show(); + } else { + Serial.println("Network sync disabled, skipping initial time check."); } + + // Start the carrier signal using 8-bit (0-255) resolution + ledcAttach(PIN_ANTENNA, signalGenerator->getFrequency(), 8); + + statusLED.setReady(); + + // Start the high-priority signal timer (1ms interval). + // This ensures PWM transitions happen on time regardless of WiFi/ESPUI activity. + const esp_timer_create_args_t timerArgs = { + .callback = onSignalTimer, + .arg = NULL, + .dispatch_method = ESP_TIMER_TASK, + .name = "signal_timer" + }; + esp_timer_create(&timerArgs, &signalTimer); + esp_timer_start_periodic(signalTimer, 1000); // 1ms = 1000us + Serial.println("Signal timer started (1ms interval)"); } void loop() { @@ -214,32 +272,31 @@ void loop() { tomorrow_start.tv_sec = ((tomorrow_start.tv_sec / 86400) + 1) * 86400; // again, close enough localtime_r(&tomorrow_start.tv_sec, &buf_tomorrow_start); - const bool prevLogicValue = logicValue; - - logicValue = wwvbLogicSignal( - buf_now_utc.tm_hour, - buf_now_utc.tm_min, - buf_now_utc.tm_sec, - now.tv_usec/1000, - buf_now_utc.tm_yday+1, - buf_now_utc.tm_year+1900, - buf_today_start.tm_isdst, - buf_tomorrow_start.tm_isdst - ); - - // --- UI UPDATE LOGIC --- - if( logicValue != prevLogicValue ) { - ledcWrite(PIN_ANTENNA, dutyCycle(logicValue)); // Update the duty cycle of the PWM - - // light up the pixel if desired - if( pixel ) { - if( logicValue == 1 ) { - pixel->setPixelColor(0, COLOR_TRANSMIT ); // don't call show yet, the color may change - } else { - pixel->clear(); - } + static int prevMinute = -1; + if (buf_now_utc.tm_min != prevMinute) { + prevMinute = buf_now_utc.tm_min; + // Write to the INACTIVE buffer, then swap + TimeCodeSymbol* inactive = (activeBroadcast == broadcastA) ? broadcastB : broadcastA; + clearBroadcastValues(inactive); + signalGenerator->encodeMinute( + buf_now_utc, + buf_today_start.tm_isdst, + buf_tomorrow_start.tm_isdst + ); + for (int s = 0; s < 60; s++) { + inactive[s] = signalGenerator->getSymbolForSecond(s); + } + activeBroadcast = inactive; // atomic pointer swap } + + // --- HANDLE TRANSITIONS DETECTED BY TIMER CALLBACK --- + if( transitionOccurred ) { + transitionOccurred = false; + unsigned long usec = lastTransitionUsec; + + statusLED.setTransmitting(logicValue); + // do any logging after we set the bit to not slow anything down, // serial port I/O is slow! char timeStringBuff[100]; // Buffer to hold the formatted time string @@ -247,293 +304,36 @@ void loop() { char timeStringBuff3[20]; strftime(timeStringBuff, sizeof(timeStringBuff), "%A, %B %d %Y %H:%M:%S", &buf_now_local); // time strftime(timeStringBuff3, sizeof(timeStringBuff3), "%z %Z", &buf_now_local); // timezone - sprintf(timeStringBuff2,"%s.%03d%s", timeStringBuff, now.tv_usec/1000, timeStringBuff3 ); // time+millis+tz + snprintf(timeStringBuff2, sizeof(timeStringBuff2), "%s.%03d%s", timeStringBuff, now.tv_usec/1000, timeStringBuff3 ); // time+millis+tz char lastSyncStringBuff[100]; // Buffer to hold the formatted time string - struct tm buf_lastSync; - localtime_r(&lastSync.tv_sec, &buf_lastSync); - strftime(lastSyncStringBuff, sizeof(lastSyncStringBuff), "%b %d %H:%M", &buf_lastSync); + if (lastSync == 0) { + snprintf(lastSyncStringBuff, sizeof(lastSyncStringBuff), "Never"); + } else { + unsigned long secondsSinceSync = (millis() - lastSync) / 1000; + snprintf(lastSyncStringBuff, sizeof(lastSyncStringBuff), "%lus ago", secondsSinceSync); + } Serial.printf("%s [last sync %s]: %s\n",timeStringBuff2, lastSyncStringBuff, logicValue ? "1" : "0"); + static int prevSecond = -1; - if( prevSecond != buf_now_utc.tm_sec ) { + if( ENABLE_WEB_UI && prevSecond != buf_now_utc.tm_sec ) { prevSecond = buf_now_utc.tm_sec; - - // --- UPDATE THE WEB UI --- - - // Time - char buf[62]; - strftime(buf, sizeof(buf), "%H:%M:%S%z %Z", &buf_now_local); - ESPUI.print(ui_time, buf); - - // Date - strftime(buf, sizeof(buf), "%A, %B %d %Y", &buf_now_local); - ESPUI.print(ui_date, buf); - - // Broadcast window - for( int i=0; i<60; ++i ) { // TODO leap seconds - switch(broadcast[i]) { - case WWVB_T::MARK: - buf[i] = 'M'; - break; - case WWVB_T::ZERO: - buf[i] = '0'; - break; - case WWVB_T::ONE: - buf[i] = '1'; - break; - default: - buf[i] = ' '; - break; - } - } - ESPUI.print(ui_broadcast, buf); - - - // Uptime - long uptime = millis() / 1000; - int up_d = uptime / 86400; - int up_h = (uptime % 86400) / 3600; - int up_m = (uptime % 3600) / 60; - int up_s = uptime % 60; - snprintf(buf, sizeof(buf), "%03dd %02dh %02dm %02ds", up_d, up_h, up_m, up_s); - ESPUI.print(ui_uptime, buf); - - // Last Sync - strftime(buf, sizeof(buf), "%b %d %H:%M", &buf_lastSync); - ESPUI.print(ui_last_sync, buf); + webUI.update(buf_now_local, buf_now_utc, lastSync, activeBroadcast); } // Check for stale sync (24 hours) - if( now.tv_sec - lastSync.tv_sec > 60 * 60 * 24 ) { + // Only restart if it's past 12pm local time to avoid rebooting while a device is syncing + if( networkSyncEnabled && (millis() - lastSync > 24 * 60 * 60 * 1000) && buf_now_local.tm_hour >= 12 ) { Serial.println("Last sync more than 24 hours ago, rebooting."); - if( pixel ) { - pixel->setPixelColor(0, COLOR_ERROR ); - delay(3000); - } + statusLED.setError(); + delay(3000); ESP.restart(); } - if( pixel ) { - pixel->show(); - } + statusLED.show(); } } -// Returns a logical high or low to indicate whether the -// PWM signal should be high or low based on the current time -// https://www.nist.gov/pml/time-and-frequency-division/time-distribution/radio-station-wwvb/wwvb-time-code-format -bool wwvbLogicSignal( - int hour, // 0 - 23 - int minute, // 0 - 59 - int second, // 0 - 59 (leap 60) - int millis, - int yday, // days since January 1 eg. Jan 1 is 0 - int year, // year since 0, eg. 2025 - int today_start_isdst, // was this morning DST? - int tomorrow_start_isdst // is tomorrow morning DST? -) { - int leap = is_leap_year(year); - - WWVB_T bit; - switch (second) { - case 0: // mark - bit = WWVB_T::MARK; - break; - case 1: // minute 40 - bit = (WWVB_T)(((minute / 10) >> 2) & 1); - break; - case 2: // minute 20 - bit = (WWVB_T)(((minute / 10) >> 1) & 1); - break; - case 3: // minute 10 - bit = (WWVB_T)(((minute / 10) >> 0) & 1); - break; - case 4: // blank - bit = WWVB_T::ZERO; - break; - case 5: // minute 8 - bit = (WWVB_T)(((minute % 10) >> 3) & 1); - break; - case 6: // minute 4 - bit = (WWVB_T)(((minute % 10) >> 2) & 1); - break; - case 7: // minute 2 - bit = (WWVB_T)(((minute % 10) >> 1) & 1); - break; - case 8: // minute 1 - bit = (WWVB_T)(((minute % 10) >> 0) & 1); - break; - case 9: // mark - bit = WWVB_T::MARK; - break; - case 10: // blank - bit = WWVB_T::ZERO; - break; - case 11: // blank - bit = WWVB_T::ZERO; - break; - case 12: // hour 20 - bit = (WWVB_T)(((hour / 10) >> 1) & 1); - break; - case 13: // hour 10 - bit = (WWVB_T)(((hour / 10) >> 0) & 1); - break; - case 14: // blank - bit = WWVB_T::ZERO; - break; - case 15: // hour 8 - bit = (WWVB_T)(((hour % 10) >> 3) & 1); - break; - case 16: // hour 4 - bit = (WWVB_T)(((hour % 10) >> 2) & 1); - break; - case 17: // hour 2 - bit = (WWVB_T)(((hour % 10) >> 1) & 1); - break; - case 18: // hour 1 - bit = (WWVB_T)(((hour % 10) >> 0) & 1); - break; - case 19: // mark - bit = WWVB_T::MARK; - break; - case 20: // blank - bit = WWVB_T::ZERO; - break; - case 21: // blank - bit = WWVB_T::ZERO; - break; - case 22: // yday of year 200 - bit = (WWVB_T)(((yday / 100) >> 1) & 1); - break; - case 23: // yday of year 100 - bit = (WWVB_T)(((yday / 100) >> 0) & 1); - break; - case 24: // blank - bit = WWVB_T::ZERO; - break; - case 25: // yday of year 80 - bit = (WWVB_T)((((yday / 10) % 10) >> 3) & 1); - break; - case 26: // yday of year 40 - bit = (WWVB_T)((((yday / 10) % 10) >> 2) & 1); - break; - case 27: // yday of year 20 - bit = (WWVB_T)((((yday / 10) % 10) >> 1) & 1); - break; - case 28: // yday of year 10 - bit = (WWVB_T)((((yday / 10) % 10) >> 0) & 1); - break; - case 29: // mark - bit = WWVB_T::MARK; - break; - case 30: // yday of year 8 - bit = (WWVB_T)(((yday % 10) >> 3) & 1); - break; - case 31: // yday of year 4 - bit = (WWVB_T)(((yday % 10) >> 2) & 1); - break; - case 32: // yday of year 2 - bit = (WWVB_T)(((yday % 10) >> 1) & 1); - break; - case 33: // yday of year 1 - bit = (WWVB_T)(((yday % 10) >> 0) & 1); - break; - case 34: // blank - bit = WWVB_T::ZERO; - break; - case 35: // blank - bit = WWVB_T::ZERO; - break; - case 36: // UTI sign + - bit = WWVB_T::ONE; - break; - case 37: // UTI sign - - bit = WWVB_T::ZERO; - break; - case 38: // UTI sign + - bit = WWVB_T::ONE; - break; - case 39: // mark - bit = WWVB_T::MARK; - break; - case 40: // UTI correction 0.8 - bit = WWVB_T::ZERO; - break; - case 41: // UTI correction 0.4 - bit = WWVB_T::ZERO; - break; - case 42: // UTI correction 0.2 - bit = WWVB_T::ZERO; - break; - case 43: // UTI correction 0.1 - bit = WWVB_T::ZERO; - break; - case 44: // blank - bit = WWVB_T::ZERO; - break; - case 45: // year 80 - bit = (WWVB_T)((((year / 10) % 10) >> 3) & 1); - break; - case 46: // year 40 - bit = (WWVB_T)((((year / 10) % 10) >> 2) & 1); - break; - case 47: // year 20 - bit = (WWVB_T)((((year / 10) % 10) >> 1) & 1); - break; - case 48: // year 10 - bit = (WWVB_T)((((year / 10) % 10) >> 0) & 1); - break; - case 49: // mark - bit = WWVB_T::MARK; - break; - case 50: // year 8 - bit = (WWVB_T)(((year % 10) >> 3) & 1); - break; - case 51: // year 4 - bit = (WWVB_T)(((year % 10) >> 2) & 1); - break; - case 52: // year 2 - bit = (WWVB_T)(((year % 10) >> 1) & 1); - break; - case 53: // year 1 - bit = (WWVB_T)(((year % 10) >> 0) & 1); - break; - case 54: // blank - bit = WWVB_T::ZERO; - break; - case 55: // leap year - bit = leap ? WWVB_T::ONE : WWVB_T::ZERO; - break; - case 56: // leap second - bit = WWVB_T::ZERO; - break; - case 57: // dst bit 1 - bit = today_start_isdst ? WWVB_T::ONE : WWVB_T::ZERO; - break; - case 58: // dst bit 2 - bit = tomorrow_start_isdst ? WWVB_T::ONE : WWVB_T::ZERO; - break; - case 59: // mark - bit = WWVB_T::MARK; - break; - } - if(second == 0) { - clearBroadcastValues(); - } - broadcast[second] = bit; - - // Convert a wwvb zero, one, or mark to the appropriate pulse width - // zero: low 200ms, high 800ms - // one: low 500ms, high 500ms - // mark low 800ms, high 200ms - if (bit == WWVB_T::ZERO) { - return millis >= 200; - } else if (bit == WWVB_T::ONE) { - return millis >= 500; - } else { - return millis >= 800; - } -} diff --git a/customJS.h b/customJS.h index 22194cd..8c1de3f 100644 --- a/customJS.h +++ b/customJS.h @@ -19,6 +19,59 @@ function convertToTable(containerSpan) { // 'M': 80% short, 20% tall. '0': 20% short, 80% tall. const DRAW_RATIOS = { 'M': 0.8, '0': 0.2, '1': 0.5 }; + // --- Protocol Bit Group Definitions --- + // Each group: { label, start, end, weights: { bitIndex: bcdWeight } } + const PROTOCOL_GROUPS = { + WWVB: [ + { label: 'Minutes', start: 1, end: 8, weights: {1:40, 2:20, 3:10, 5:8, 6:4, 7:2, 8:1} }, + { label: 'Hours', start: 12, end: 18, weights: {12:20, 13:10, 15:8, 16:4, 17:2, 18:1} }, + { label: 'Day of year', start: 22, end: 33, weights: {22:200, 23:100, 25:80, 26:40, 27:20, 28:10, 30:8, 31:4, 32:2, 33:1} }, + { label: 'Year', start: 45, end: 53, weights: {45:80, 46:40, 47:20, 48:10, 50:8, 51:4, 52:2, 53:1} } + ], + JJY: [ + { label: 'Minutes', start: 1, end: 8, weights: {1:40, 2:20, 3:10, 5:8, 6:4, 7:2, 8:1} }, + { label: 'Hours', start: 12, end: 18, weights: {12:20, 13:10, 15:8, 16:4, 17:2, 18:1} }, + { label: 'Day of year', start: 22, end: 33, weights: {22:200, 23:100, 25:80, 26:40, 27:20, 28:10, 30:8, 31:4, 32:2, 33:1} }, + { label: 'Year', start: 41, end: 48, weights: {41:80, 42:40, 43:20, 44:10, 45:8, 46:4, 47:2, 48:1} }, + { label: 'Weekday', start: 50, end: 52, weights: {50:4, 51:2, 52:1} } + ], + DCF77: [ + { label: 'Minutes', start: 21, end: 27, weights: {21:1, 22:2, 23:4, 24:8, 25:10, 26:20, 27:40} }, + { label: 'Hours', start: 29, end: 34, weights: {29:1, 30:2, 31:4, 32:8, 33:10, 34:20} }, + { label: 'Day', start: 36, end: 41, weights: {36:1, 37:2, 38:4, 39:8, 40:10, 41:20} }, + { label: 'Weekday', start: 42, end: 44, weights: {42:1, 43:2, 44:4} }, + { label: 'Month', start: 45, end: 49, weights: {45:1, 46:2, 47:4, 48:8, 49:10} }, + { label: 'Year', start: 50, end: 57, weights: {50:1, 51:2, 52:4, 53:8, 54:10, 55:20, 56:40, 57:80} } + ], + MSF: [ + { label: 'Year', start: 17, end: 24, weights: {17:80, 18:40, 19:20, 20:10, 21:8, 22:4, 23:2, 24:1} }, + { label: 'Month', start: 25, end: 29, weights: {25:10, 26:8, 27:4, 28:2, 29:1} }, + { label: 'Day', start: 30, end: 35, weights: {30:20, 31:10, 32:8, 33:4, 34:2, 35:1} }, + { label: 'Weekday', start: 36, end: 38, weights: {36:4, 37:2, 38:1} }, + { label: 'Hours', start: 39, end: 44, weights: {39:20, 40:10, 41:8, 42:4, 43:2, 44:1} }, + { label: 'Minutes', start: 45, end: 51, weights: {45:40, 46:20, 47:10, 48:8, 49:4, 50:2, 51:1} } + ] + }; + + // --- Detect active protocol from the Signal Protocol select element --- + let activeProtocol = 'WWVB'; // default + const selectEls = document.querySelectorAll('select'); + for (const sel of selectEls) { + if (sel.value && PROTOCOL_GROUPS[sel.value]) { + activeProtocol = sel.value; + break; + } + } + const BIT_GROUPS = PROTOCOL_GROUPS[activeProtocol] || PROTOCOL_GROUPS.WWVB; + + // Build lookup maps from the group definitions + const bitToWeight = {}; + BIT_GROUPS.forEach(g => { + Object.entries(g.weights).forEach(([bit, weight]) => { + bitToWeight[parseInt(bit)] = weight; + }); + }); + // --- 1. Setup Container & Generate Table --- containerSpan.classList.add('visualized-container'); containerSpan.style.display = 'flex'; @@ -28,10 +81,42 @@ function convertToTable(containerSpan) { const dataCells = characters.map(char => `${char}`).join(''); const indexCells = characters.map((_, i) => `${String(i).padStart(2, '0')}`).join(''); + // Row 3: Label row - one per bit to preserve column alignment + const labelCells = characters.map((_, i) => { + const group = BIT_GROUPS.find(g => g.start === i); + if (group) { + const span = group.end - group.start + 1; + return `${group.label}`; + } + // Skip cells that are covered by a previous colspan + if (BIT_GROUPS.some(g => i > g.start && i <= g.end)) return ''; + return ''; + }).join(''); + + // Row 4: Weight row - individual cells to preserve alignment + const weightCells = characters.map((_, i) => { + const w = bitToWeight[i]; + return `${w !== undefined ? w : ''}`; + }).join(''); + + // Row 5: Calculated value row - one cell per group spanning the group's columns + const valueCells = characters.map((_, i) => { + const group = BIT_GROUPS.find(g => g.start === i); + if (group) { + const span = group.end - group.start + 1; + return ``; + } + if (BIT_GROUPS.some(g => i > g.start && i <= g.end)) return ''; + return ''; + }).join(''); + const tableHTML = ` ${dataCells}${indexCells} + ${labelCells} + ${weightCells} + ${valueCells}
`; @@ -39,6 +124,19 @@ function convertToTable(containerSpan) { containerSpan.innerHTML = tableHTML; const table = containerSpan.firstElementChild; + // --- Compute group values from bit data --- + BIT_GROUPS.forEach(group => { + let value = 0; + Object.entries(group.weights).forEach(([bit, weight]) => { + const idx = parseInt(bit); + if (idx < characters.length && characters[idx] === '1') { + value += weight; + } + }); + const cell = table.querySelector(`.group-value[data-group="${group.label}"]`); + if (cell) cell.textContent = value; + }); + // --- 2. Measure Dimensions --- // We map over the cells to get precise pixel measurements based on CSS rendering const cells = Array.from(table.querySelectorAll('tbody td')); @@ -80,15 +178,15 @@ function convertToTable(containerSpan) { containerSpan.prepend(canvas); } + + // re-draw the label every time it is updated const masterObserver = new MutationObserver((mutations) => { + // Waveform visualization const label = document.getElementById('l1'); - if (!label) return; - - // If it contains the table we added, ignore this update (it was us!) - if (label.querySelector('canvas')) return; - - convertToTable(label); + if (label && !label.querySelector('canvas')) { + convertToTable(label); + } }); // Start observing the entire DOM diff --git a/include/DCF77Signal.h b/include/DCF77Signal.h new file mode 100644 index 0000000..0513fc6 --- /dev/null +++ b/include/DCF77Signal.h @@ -0,0 +1,125 @@ +#ifndef DCF77_SIGNAL_H +#define DCF77_SIGNAL_H + +#include "RadioTimeSignal.h" + +class DCF77Signal : public RadioTimeSignal { +public: + int getFrequency() override { + return 77500; + } + + String getName() override { + return "DCF77"; + } + + void encodeMinute(const struct tm& timeinfo, int today_start_isdst, int tomorrow_start_isdst) override { + // DCF77 sends the UPCOMING minute. + struct tm next_min = timeinfo; + next_min.tm_min += 1; + mktime(&next_min); // Normalize + + frameBits_ = 0; + + // 0: Start of minute (0) - Implicitly 0 + // 1-14: Meteo (0) - Implicitly 0 + // 15: Call bit (0) - Implicitly 0 + // 16: Summer time announcement (0) - Implicitly 0 + + // 17: CEST (1 if DST) + // 18: CET (1 if not DST) + if (next_min.tm_isdst) { + frameBits_ |= 1ULL << (59 - 17); + } else { + frameBits_ |= 1ULL << (59 - 18); + } + + // 19: Leap second (0) - Implicitly 0 + + // 20: Start of time (1) + frameBits_ |= 1ULL << (59 - 20); + + // 21-27: Minute (BCD) + // LSB at Sec 21 (Bit 38). + // reverse8 puts LSB at Bit 7. + // 7 + 31 = 38. + frameBits_ |= (uint64_t)reverse8(to_bcd(next_min.tm_min)) << 31; + + // 28: Parity Minute + if (countSetBits(to_bcd(next_min.tm_min)) % 2 != 0) { + frameBits_ |= 1ULL << (59 - 28); + } + + // 29-34: Hour (BCD) + // LSB at Sec 29 (Bit 30). + // 7 + 23 = 30. + frameBits_ |= (uint64_t)reverse8(to_bcd(next_min.tm_hour)) << 23; + + // 35: Parity Hour + if (countSetBits(to_bcd(next_min.tm_hour)) % 2 != 0) { + frameBits_ |= 1ULL << (59 - 35); + } + + // 36-41: Day (BCD) + // LSB at Sec 36 (Bit 23). + // 7 + 16 = 23. + frameBits_ |= (uint64_t)reverse8(to_bcd(next_min.tm_mday)) << 16; + + // 42-44: Day of Week (1=Mon...7=Sun) + // tm_wday: 0=Sun, 1=Mon... + int wday = next_min.tm_wday == 0 ? 7 : next_min.tm_wday; + // LSB at Sec 42 (Bit 17). + // wday is 3 bits. + // reverse8 puts LSB at Bit 7. + // 7 + 10 = 17. + frameBits_ |= (uint64_t)reverse8(wday) << 10; + + // 45-49: Month (BCD) + // LSB at Sec 45 (Bit 14). + // 7 + 7 = 14. + frameBits_ |= (uint64_t)reverse8(to_bcd(next_min.tm_mon + 1)) << 7; + + // 50-57: Year (BCD) + // LSB at Sec 50 (Bit 9). + // 7 + 2 = 9. + frameBits_ |= (uint64_t)reverse8(to_bcd((next_min.tm_year + 1900) % 100)) << 2; + + // 58: Parity Date + // Parity of Day, WDay, Month, Year. + int p = 0; + p += countSetBits(to_bcd(next_min.tm_mday)); + p += countSetBits(wday); + p += countSetBits(to_bcd(next_min.tm_mon + 1)); + p += countSetBits(to_bcd((next_min.tm_year + 1900) % 100)); + if (p % 2 != 0) { + frameBits_ |= 1ULL << (59 - 58); + } + } + + TimeCodeSymbol getSymbolForSecond(int second) override { + if (second < 0 || second > 59) return TimeCodeSymbol::ZERO; + if (second == 59) return TimeCodeSymbol::IDLE; + + bool bitSet = (frameBits_ >> (59 - second)) & 1; + return bitSet ? TimeCodeSymbol::ONE : TimeCodeSymbol::ZERO; + } + + bool getLevelForTimeCodeSymbol(TimeCodeSymbol symbol, int millis) override { + // DCF77 + // 0: 100ms Low, 900ms High + // 1: 200ms Low, 800ms High + // IDLE: High (no modulation) + if (symbol == TimeCodeSymbol::IDLE) { + return true; // Always High + } else if (symbol == TimeCodeSymbol::ZERO) { + return millis >= 100; + } else { // ONE + return millis >= 200; + } + } + +private: + uint64_t frameBits_ = 0; +}; + +#endif // DCF77_SIGNAL_H diff --git a/include/JJYSignal.h b/include/JJYSignal.h new file mode 100644 index 0000000..3b0f66f --- /dev/null +++ b/include/JJYSignal.h @@ -0,0 +1,84 @@ +#ifndef JJY_SIGNAL_H +#define JJY_SIGNAL_H + +#include "RadioTimeSignal.h" + +class JJYSignal : public RadioTimeSignal { +public: + int getFrequency() override { + return 60000; // or 40000 + } + + String getName() override { + return "JJY"; + } + + void encodeMinute(const struct tm& timeinfo, int today_start_isdst, int tomorrow_start_isdst) override { + frameBits_ = 0; + + // Minute: 1-8 (8 bits) + // 59 - 8 = 51. + frameBits_ |= to_padded5_bcd(timeinfo.tm_min) << (59 - 8); + + // Hour: 12-18 (7 bits) + // 59 - 18 = 41. + frameBits_ |= to_padded5_bcd(timeinfo.tm_hour) << (59 - 18); + + // Day of Year: 22-30 (9 bits? No, 3 digits padded) + // 59 - 33 = 26. + frameBits_ |= to_padded5_bcd(timeinfo.tm_yday + 1) << (59 - 33); + + // Year: 41-48 (8 bits) + // 59 - 48 = 11. + frameBits_ |= to_bcd((timeinfo.tm_year + 1900) % 100) << (59 - 48); + + // Day of Week: 50-52 (3 bits) + // 59 - 52 = 7. + frameBits_ |= to_bcd(timeinfo.tm_wday) << (59 - 52); + + // Parity + // PA1 (36): Hour parity (12-18) + // txtempus: `parity(time_bits_, 59 - 18, 59 - 12) << (59 - 36)` + if (countSetBits(frameBits_, 59 - 18, 59 - 12) % 2 != 0) { + frameBits_ |= 1ULL << (59 - 36); + } + + // PA2 (37): Minute parity (1-8) + // txtempus: `parity(time_bits_, 59 - 8, 59 - 1) << (59 - 37)` + if (countSetBits(frameBits_, 59 - 8, 59 - 1) % 2 != 0) { + frameBits_ |= 1ULL << (59 - 37); + } + } + + TimeCodeSymbol getSymbolForSecond(int second) override { + if (second < 0 || second > 59) return TimeCodeSymbol::ZERO; + + // Markers + if (second == 0 || second == 9 || second == 19 || second == 29 || second == 39 || second == 49 || second == 59) { + return TimeCodeSymbol::MARK; + } + + bool bitSet = (frameBits_ >> (59 - second)) & 1; + return bitSet ? TimeCodeSymbol::ONE : TimeCodeSymbol::ZERO; + } + + bool getLevelForTimeCodeSymbol(TimeCodeSymbol symbol, int millis) override { + // JJY + // 0: 800ms High, 200ms Low + // 1: 500ms High, 500ms Low + // MARK: 200ms High, 800ms Low + + if (symbol == TimeCodeSymbol::MARK) { + return millis < 200; + } else if (symbol == TimeCodeSymbol::ONE) { + return millis < 500; + } else { // ZERO + return millis < 800; + } + } + +private: + uint64_t frameBits_ = 0; +}; + +#endif // JJY_SIGNAL_H diff --git a/include/MSFSignal.h b/include/MSFSignal.h new file mode 100644 index 0000000..fe14039 --- /dev/null +++ b/include/MSFSignal.h @@ -0,0 +1,102 @@ +#ifndef MSF_SIGNAL_H +#define MSF_SIGNAL_H + +#include "RadioTimeSignal.h" + +class MSFSignal : public RadioTimeSignal { +public: + int getFrequency() override { + return 60000; + } + + String getName() override { + return "MSF"; + } + + void encodeMinute(const struct tm& timeinfo, int today_start_isdst, int tomorrow_start_isdst) override { + // MSF sends the UPCOMING minute. + struct tm breakdown = timeinfo; + breakdown.tm_min += 1; + mktime(&breakdown); // Normalize + + aBits_ = 0b1111110; // Bits 53-59 (Marker) + + aBits_ |= to_bcd(breakdown.tm_year % 100) << (59 - 24); + aBits_ |= to_bcd(breakdown.tm_mon + 1) << (59 - 29); + aBits_ |= to_bcd(breakdown.tm_mday) << (59 - 35); + aBits_ |= to_bcd(breakdown.tm_wday) << (59 - 38); + aBits_ |= to_bcd(breakdown.tm_hour) << (59 - 44); + aBits_ |= to_bcd(breakdown.tm_min) << (59 - 51); + + bBits_ = 0; + // DUT1 (1-16) - 0 + // Summer time warning (53) - 0 + + // Year parity (17-24) + if (countSetBits(aBits_, 59 - 24, 59 - 17) % 2 == 0) { + bBits_ |= 1ULL << (59 - 54); + } + // Day parity (25-35) + if (countSetBits(aBits_, 59 - 35, 59 - 25) % 2 == 0) { + bBits_ |= 1ULL << (59 - 55); + } + // Weekday parity (36-38) + if (countSetBits(aBits_, 59 - 38, 59 - 36) % 2 == 0) { + bBits_ |= 1ULL << (59 - 56); + } + // Time parity (39-51) + if (countSetBits(aBits_, 59 - 51, 59 - 39) % 2 == 0) { + bBits_ |= 1ULL << (59 - 57); + } + + // DST (58) + if (breakdown.tm_isdst) { + bBits_ |= 1ULL << (59 - 58); + } + } + + TimeCodeSymbol getSymbolForSecond(int second) override { + if (second < 0 || second > 59) return TimeCodeSymbol::ZERO; + + if (second == 0) { + return TimeCodeSymbol::MARK; + } + + bool a = (aBits_ >> (59 - second)) & 1; + bool b = (bBits_ >> (59 - second)) & 1; + + if (!a && !b) return TimeCodeSymbol::ZERO; + else if (a && !b) return TimeCodeSymbol::ONE; + else if (!a && b) return TimeCodeSymbol::MSF_01; + else if (a && b) return TimeCodeSymbol::MSF_11; + + return TimeCodeSymbol::ZERO; // Should not reach here + } + + bool getLevelForTimeCodeSymbol(TimeCodeSymbol symbol, int millis) override { + // MSF + // 0-100: OFF + // 100-200: A (OFF if 1) + // 200-300: B (OFF if 1) + // 300+: HIGH + + if (symbol == TimeCodeSymbol::MARK) { + return millis >= 500; + } + + bool a = (symbol == TimeCodeSymbol::ONE || symbol == TimeCodeSymbol::MSF_11); + bool b = (symbol == TimeCodeSymbol::MSF_01 || symbol == TimeCodeSymbol::MSF_11); + + if (millis < 100) return false; // Always OFF 0-100 + if (millis < 200) return !a; // OFF if A=1 + if (millis < 300) return !b; // OFF if B=1 + + return true; // HIGH otherwise + } + +private: + uint64_t aBits_ = 0; + uint64_t bBits_ = 0; +}; + +#endif // MSF_SIGNAL_H diff --git a/include/RadioTimeSignal.h b/include/RadioTimeSignal.h new file mode 100644 index 0000000..9a38557 --- /dev/null +++ b/include/RadioTimeSignal.h @@ -0,0 +1,93 @@ +#ifndef RADIO_TIME_SIGNAL_H +#define RADIO_TIME_SIGNAL_H + +#include + +/** + * @brief Base class for time signal generators (WWVB, DCF77, MSF, JJY). + * + * Defines the interface for encoding the current time into a sequence of symbols + * and converting those symbols into signal levels (High/Low) for PWM output. + */ +enum class TimeCodeSymbol { + ZERO = 0, + ONE = 1, + MARK = 2, + IDLE = 3, // Used for DCF77 59th second + MSF_01 = 4, // MSF: A=0, B=1 + MSF_11 = 5 // MSF: A=1, B=1 +}; + +class RadioTimeSignal { +public: + virtual ~RadioTimeSignal() {} + + // Returns the carrier frequency in Hz + virtual int getFrequency() = 0; + + // Returns the display name of the signal + virtual String getName() = 0; + + // Encodes the signal for the upcoming minute. + // This should be called once at the beginning of each minute (second 0) + // before calling getSymbolForSecond + virtual void encodeMinute(const struct tm& timeinfo, int today_isdst, int tomorrow_isdst) = 0; + + // Returns the signal symbol for the given second (0-59) + // for the minute encoded by encodeMinute + virtual TimeCodeSymbol getSymbolForSecond(int second) = 0; + + // Returns a logical high or low to indicate whether the + // PWM signal should be high or low based on the current time + virtual bool getLevelForTimeCodeSymbol(TimeCodeSymbol symbol, int millis) = 0; + +protected: + // Helper to encode BCD (Binary Coded Decimal). + // The tens digit is in the upper nibble (bits 4-7), + // and the units digit is in the lower nibble (bits 0-3). + // Example: 25 -> 0x25 (Binary: 0010 0101) + uint64_t to_bcd(int n) { + return (((n / 10) % 10) << 4) | (n % 10); + } + + // Helper to encode BCD with a 0 bit inserted between digits (for WWVB/JJY) + // Structure: [Tens:3] [0] [Units:4] + uint64_t to_padded5_bcd(int n) { + return (((n / 100) % 10) << 10) | (((n / 10) % 10) << 5) | (n % 10); + } + + // Reverse 8 bits (for DCF77) + uint8_t reverse8(uint8_t b) { + b = (b & 0xF0) >> 4 | (b & 0x0F) << 4; + b = (b & 0xCC) >> 2 | (b & 0x33) << 2; + b = (b & 0xAA) >> 1 | (b & 0x55) << 1; + return b; + } + + bool is_leap_year(int year) { + return year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + } + + // Count set bits in a 64-bit integer + int countSetBits(uint64_t n) { + int count = 0; + while (n > 0) { + n &= (n - 1); + count++; + } + return count; + } + + // Count set bits in a range [from, to_including] + int countSetBits(uint64_t n, int from, int to_including) { + int count = 0; + for (int i = from; i <= to_including; i++) { + if ((n >> i) & 1) { + count++; + } + } + return count; + } +}; + +#endif // RADIO_TIME_SIGNAL_H diff --git a/include/StatusLED.h b/include/StatusLED.h new file mode 100644 index 0000000..61ca584 --- /dev/null +++ b/include/StatusLED.h @@ -0,0 +1,69 @@ +#pragma once + +#include + +// Wraps the optional onboard NeoPixel so that WatchTower.ino +// doesn't need #ifdef or null-check guards scattered everywhere. +// Boards without PIN_NEOPIXEL get silent no-ops. +class StatusLED { +public: + void begin() { +#ifdef PIN_NEOPIXEL + _pixel.begin(); + _pixel.setBrightness(_BRIGHTNESS); + _pixel.clear(); + _pixel.show(); +#endif + } + + void setLoading() { +#ifdef PIN_NEOPIXEL + _pixel.setPixelColor(0, _COLOR_LOADING); + _pixel.show(); +#endif + } + + void setReady() { +#ifdef PIN_NEOPIXEL + _pixel.setPixelColor(0, _COLOR_READY); + _pixel.show(); + delay(3000); + _pixel.clear(); + _pixel.show(); +#endif + } + + void setError() { +#ifdef PIN_NEOPIXEL + _pixel.setPixelColor(0, _COLOR_ERROR); + _pixel.show(); +#endif + } + + void setTransmitting(bool on) { +#ifdef PIN_NEOPIXEL + if (on) { + _pixel.setPixelColor(0, _COLOR_TRANSMIT); + } else { + _pixel.clear(); + } +#endif + } + + void show() { +#ifdef PIN_NEOPIXEL + _pixel.show(); +#endif + } + +private: +#ifdef PIN_NEOPIXEL + Adafruit_NeoPixel _pixel{1, PIN_NEOPIXEL, NEO_GRB + NEO_KHZ800}; + + static constexpr uint8_t _BRIGHTNESS = 10; // very dim, 0-255 + static constexpr uint32_t _COLOR_READY = 0x003C00; // green + static constexpr uint32_t _COLOR_LOADING = 0x3C2000; // orange + static constexpr uint32_t _COLOR_ERROR = 0x960000; // red + static constexpr uint32_t _COLOR_TRANSMIT = 0x200000; // dim red +#endif +}; diff --git a/include/WWVBSignal.h b/include/WWVBSignal.h new file mode 100644 index 0000000..56bebea --- /dev/null +++ b/include/WWVBSignal.h @@ -0,0 +1,84 @@ +#ifndef WWVB_SIGNAL_H +#define WWVB_SIGNAL_H + +#include "RadioTimeSignal.h" + +class WWVBSignal : public RadioTimeSignal { +public: + int getFrequency() override { + return 60000; + } + + String getName() override { + return "WWVB"; + } + + void encodeMinute(const struct tm& timeinfo, int today_start_isdst, int tomorrow_start_isdst) override { + frameBits_ = 0; + + // Minute: 01, 02, 03, 05, 06, 07, 08 (Bits 58-51? No, 59-sec) + frameBits_ |= to_padded5_bcd(timeinfo.tm_min) << (59 - 8); + + // Hour: 12, 13, 15, 16, 17, 18 (Bits 59-18) + frameBits_ |= to_padded5_bcd(timeinfo.tm_hour) << (59 - 18); + + // Day of Year: 22, 23, 25, 26, 27, 28, 30, 31, 32, 33 (Bits 59-33) + frameBits_ |= to_padded5_bcd(timeinfo.tm_yday + 1) << (59 - 33); + + // Year: 45, 46, 47, 48, 50, 51, 52, 53 (Bits 59-53) + frameBits_ |= to_padded5_bcd((timeinfo.tm_year + 1900) % 100) << (59 - 53); + + // Leap Year: 55 + if (is_leap_year(timeinfo.tm_year + 1900)) { + frameBits_ |= 1ULL << (59 - 55); + } + + // DST: 57, 58 + bool dst1 = false; + bool dst2 = false; + + if (today_start_isdst && tomorrow_start_isdst) { + dst1 = true; dst2 = true; + } else if (!today_start_isdst && !tomorrow_start_isdst) { + dst1 = false; dst2 = false; + } else if (!today_start_isdst && tomorrow_start_isdst) { + dst1 = true; dst2 = false; + } else { + dst1 = false; dst2 = true; + } + + if (dst1) frameBits_ |= 1ULL << (59 - 57); + if (dst2) frameBits_ |= 1ULL << (59 - 58); + } + + TimeCodeSymbol getSymbolForSecond(int second) override { + if (second < 0 || second > 59) return TimeCodeSymbol::ZERO; + + // Markers + if (second == 0 || second == 9 || second == 19 || second == 29 || second == 39 || second == 49 || second == 59) { + return TimeCodeSymbol::MARK; + } + + bool bitSet = (frameBits_ >> (59 - second)) & 1; + return bitSet ? TimeCodeSymbol::ONE : TimeCodeSymbol::ZERO; + } + + bool getLevelForTimeCodeSymbol(TimeCodeSymbol symbol, int millis) override { + // WWVB + // 0: 200ms Low, 800ms High + // 1: 500ms Low, 500ms High + // MARK: 800ms Low, 200ms High + if (symbol == TimeCodeSymbol::MARK) { + return millis >= 800; + } else if (symbol == TimeCodeSymbol::ONE) { + return millis >= 500; + } else { // ZERO + return millis >= 200; + } + } + +private: + uint64_t frameBits_ = 0; +}; + +#endif // WWVB_SIGNAL_H diff --git a/include/WebUI.h b/include/WebUI.h new file mode 100644 index 0000000..e012661 --- /dev/null +++ b/include/WebUI.h @@ -0,0 +1,269 @@ +#pragma once + +#include +#include +#include +#include +#include +#include "customJS.h" +#include "include/RadioTimeSignal.h" +#include "include/WWVBSignal.h" +#include "include/DCF77Signal.h" +#include "include/MSFSignal.h" +#include "include/JJYSignal.h" + + +// Encapsulates all ESPUI web dashboard setup and per-second updates. +// Keeps the UI plumbing out of WatchTower.ino so the main sketch +// can focus on signal generation and timing. +class WebUI { +public: + // Call from setup() to wire up all the ESPUI controls. + // Pointers/references are stored — the caller owns the objects. + void begin( + const char* timezone, + RadioTimeSignal*& signalGenerator, + WWVBSignal& wwvb, DCF77Signal& dcf77, MSFSignal& msf, JJYSignal& jjy, + Preferences& preferences, + int pinAntenna, + const char* ntpServer, + bool& networkSyncEnabledRef, + unsigned long& lastSyncRef, + MDNS& mdns + ) { + _instance = this; + _timezone = timezone; + _wwvb = &wwvb; _dcf77 = &dcf77; _msf = &msf; _jjy = &jjy; + _signalGenerator = &signalGenerator; + _preferences = &preferences; + _pinAntenna = pinAntenna; + _ntpServer = ntpServer; + _networkSyncEnabled = &networkSyncEnabledRef; + _lastSync = &lastSyncRef; + + ESPUI.setVerbosity(Verbosity::Quiet); + + // Create Labels + _ui_broadcast = ESPUI.label("Broadcast Waveform", ControlColor::Sunflower, ""); + _ui_time = ESPUI.label("Current Time", ControlColor::Turquoise, "Loading..."); + _ui_time_utc = ESPUI.addControl(ControlType::Label, "UTC", "Loading...", ControlColor::Turquoise, _ui_time); + _ui_date = ESPUI.label("Date", ControlColor::Emerald, "Loading..."); + _ui_date_utc = ESPUI.addControl(ControlType::Label, "UTC", "Loading...", ControlColor::Emerald, _ui_date); + ESPUI.label("Timezone", ControlColor::Peterriver, timezone); + _ui_uptime = ESPUI.label("System Uptime", ControlColor::Carrot, "0s"); + _ui_last_sync = ESPUI.label("Last NTP Sync", ControlColor::Alizarin, "Pending..."); + _ui_network_sync_switch = ESPUI.switcher("Network time sync", _onSyncToggle, ControlColor::Sunflower, *_networkSyncEnabled); + + _ui_manual_date = ESPUI.text("Manual Date", _onManualTime, ControlColor::Dark, ""); + ESPUI.setInputType(_ui_manual_date, "date"); + ESPUI.updateVisibility(_ui_manual_date, !*_networkSyncEnabled); + + _ui_manual_time = ESPUI.text("Manual Time", _onManualTime, ControlColor::Dark, ""); + ESPUI.setInputType(_ui_manual_time, "time"); + ESPUI.updateVisibility(_ui_manual_time, !*_networkSyncEnabled); + + _ui_signal_select = ESPUI.addControl(ControlType::Select, "Signal Protocol", "", ControlColor::Turquoise, Control::noParent, _onSignalChange); + ESPUI.addControl(ControlType::Option, "WWVB", "WWVB", ControlColor::Alizarin, _ui_signal_select); + ESPUI.addControl(ControlType::Option, "DCF77", "DCF77", ControlColor::Alizarin, _ui_signal_select); + ESPUI.addControl(ControlType::Option, "MSF", "MSF", ControlColor::Alizarin, _ui_signal_select); + ESPUI.addControl(ControlType::Option, "JJY", "JJY", ControlColor::Alizarin, _ui_signal_select); + + // Set initial selection + if (*_signalGenerator == _wwvb) ESPUI.updateSelect(_ui_signal_select, "WWVB"); + else if (*_signalGenerator == _dcf77) ESPUI.updateSelect(_ui_signal_select, "DCF77"); + else if (*_signalGenerator == _msf) ESPUI.updateSelect(_ui_signal_select, "MSF"); + else if (*_signalGenerator == _jjy) ESPUI.updateSelect(_ui_signal_select, "JJY"); + + ESPUI.setPanelWide(_ui_broadcast, true); + ESPUI.setElementStyle(_ui_broadcast, "font-family: monospace"); + + + + ESPUI.setCustomJS(customJS); + + mdns.begin(WiFi.localIP(), "watchtower"); + Serial.println("Connect to http://watchtower.local for the console"); + ESPUI.begin("WatchTower"); + } + + // Call once per second from loop() to push updated values to the browser. + void update( + const struct tm& local, + const struct tm& utc, + unsigned long lastSync, + volatile const TimeCodeSymbol* broadcast + ) { + char buf[62]; + + // Time + strftime(buf, sizeof(buf), "%H:%M:%S%z %Z", &local); + ESPUI.print(_ui_time, buf); + + // UTC Time + strftime(buf, sizeof(buf), "%H:%M:%S UTC", &utc); + ESPUI.print(_ui_time_utc, buf); + + // Date (local with timezone label) + strftime(buf, sizeof(buf), "%A, %B %d %Y (Day %j) %Z", &local); + ESPUI.print(_ui_date, buf); + + // UTC Date + strftime(buf, sizeof(buf), "%A, %B %d %Y (Day %j) UTC", &utc); + ESPUI.print(_ui_date_utc, buf); + + // Broadcast window — only show seconds that have already been transmitted. + // Future seconds are shown as spaces (stable 60-column table layout). + int sec = utc.tm_sec; + for (int i = 0; i < 60; ++i) { + if (i <= sec) { + switch (broadcast[i]) { + case TimeCodeSymbol::MARK: buf[i] = 'M'; break; + case TimeCodeSymbol::ZERO: buf[i] = '0'; break; + case TimeCodeSymbol::ONE: buf[i] = '1'; break; + case TimeCodeSymbol::IDLE: buf[i] = '-'; break; + default: buf[i] = ' '; break; + } + } else { + buf[i] = ' '; + } + } + buf[60] = '\0'; + ESPUI.print(_ui_broadcast, buf); + + // Uptime + long uptime = millis() / 1000; + int up_d = uptime / 86400; + int up_h = (uptime % 86400) / 3600; + int up_m = (uptime % 3600) / 60; + int up_s = uptime % 60; + snprintf(buf, sizeof(buf), "%03dd %02dh %02dm %02ds", up_d, up_h, up_m, up_s); + ESPUI.print(_ui_uptime, buf); + + // Last Sync + if (lastSync == 0) { + ESPUI.print(_ui_last_sync, "Never"); + } else { + unsigned long secondsSinceSync = (millis() - lastSync) / 1000; + snprintf(buf, sizeof(buf), "%lus ago", secondsSinceSync); + ESPUI.print(_ui_last_sync, buf); + } + + } + +private: + // --- Shared state (owned by WatchTower.ino, stored by pointer/ref) --- + const char* _timezone = nullptr; + const char* _ntpServer = nullptr; + int _pinAntenna = 0; + WWVBSignal* _wwvb = nullptr; + DCF77Signal* _dcf77 = nullptr; + MSFSignal* _msf = nullptr; + JJYSignal* _jjy = nullptr; + RadioTimeSignal** _signalGenerator = nullptr; + Preferences* _preferences = nullptr; + bool* _networkSyncEnabled = nullptr; + unsigned long* _lastSync = nullptr; + + String _manualDate = ""; + String _manualTime = ""; + + // --- Singleton access for ESPUI callbacks --- + // ESPUI callbacks are plain function pointers, so we use a static + // instance pointer to route them back into the class. + static WebUI* _instance; + +public: + // ESPUI control IDs and callback trampolines are public so that + // tests can simulate ESPUI events. + + // --- ESPUI control IDs --- + uint16_t _ui_time, _ui_time_utc; + uint16_t _ui_date, _ui_date_utc; + uint16_t _ui_broadcast; + uint16_t _ui_uptime, _ui_last_sync; + uint16_t _ui_network_sync_switch; + uint16_t _ui_manual_date, _ui_manual_time; + uint16_t _ui_signal_select; + + + static void _onManualTime(Control* sender, int value) { + if (_instance) _instance->handleManualTime(sender, value); + } + static void _onSignalChange(Control* sender, int value) { + if (_instance) _instance->handleSignalChange(sender, value); + } + static void _onSyncToggle(Control* sender, int value) { + if (_instance) _instance->handleSyncToggle(sender, value); + } + +private: + + void handleManualTime(Control* sender, int value) { + if (sender->id == _ui_manual_date) { + _manualDate = sender->value; + } else if (sender->id == _ui_manual_time) { + _manualTime = sender->value; + } + + struct timeval now; + gettimeofday(&now, NULL); + struct tm tm; + localtime_r(&now.tv_sec, &tm); + + if (_manualDate.length() > 0) { + strptime(_manualDate.c_str(), "%Y-%m-%d", &tm); + } + if (_manualTime.length() > 0) { + strptime(_manualTime.c_str(), "%H:%M", &tm); + tm.tm_sec = 0; + } + + tm.tm_isdst = -1; + time_t t = mktime(&tm); + if (t != -1) { + struct timeval tv = { .tv_sec = t, .tv_usec = 0 }; + settimeofday(&tv, NULL); + Serial.println("Manual time updated"); + } else { + Serial.println("Failed to set manual time"); + } + } + + void handleSignalChange(Control* sender, int value) { + if (sender->id != _ui_signal_select) return; + String selected = sender->value; + if (selected == "WWVB") *_signalGenerator = _wwvb; + else if (selected == "DCF77") *_signalGenerator = _dcf77; + else if (selected == "MSF") *_signalGenerator = _msf; + else if (selected == "JJY") *_signalGenerator = _jjy; + + _preferences->putString("signal", (*_signalGenerator)->getName()); + Serial.println("Signal changed to: " + (*_signalGenerator)->getName()); + + ledcDetach(_pinAntenna); + ledcAttach(_pinAntenna, (*_signalGenerator)->getFrequency(), 8); + } + + void handleSyncToggle(Control* sender, int value) { + if (sender->id != _ui_network_sync_switch) return; + *_networkSyncEnabled = (value == S_ACTIVE); + _preferences->putBool("net_sync", *_networkSyncEnabled); + Serial.printf("Network Sync changed to: %s\n", *_networkSyncEnabled ? "ENABLED" : "DISABLED"); + + if (*_networkSyncEnabled) { + esp_sntp_stop(); + configTzTime(_timezone, _ntpServer); + Serial.println("NTP Sync re-enabled"); + ESPUI.updateVisibility(_ui_manual_date, false); + ESPUI.updateVisibility(_ui_manual_time, false); + } else { + esp_sntp_stop(); + Serial.println("NTP Sync disabled"); + ESPUI.updateVisibility(_ui_manual_date, true); + ESPUI.updateVisibility(_ui_manual_time, true); + } + } +}; + +// The singleton instance pointer — set automatically in begin(). +WebUI* WebUI::_instance = nullptr; diff --git a/platformio.ini b/platformio.ini index 552740a..116abf7 100644 --- a/platformio.ini +++ b/platformio.ini @@ -11,6 +11,8 @@ [platformio] src_dir = . +; Environment for the Adafruit QT Py ESP32 hardware. +; Builds the production firmware for the WatchTower. [env:adafruit_qtpy_esp32] platform = https://github.com/pioarduino/platform-espressif32/releases/download/stable/platform-espressif32.zip board = adafruit_qtpy_esp32 @@ -18,7 +20,6 @@ framework = arduino board_build.partitions = default_8MB.csv monitor_speed = 115200 -build_src_filter = +<*> -<.git/> - lib_deps = tzapu/WiFiManager@2.0.17 @@ -31,13 +32,19 @@ lib_deps = arduino-libraries/ArduinoMDNS@1.0.1 ; Ignore native tests on the embedded platform as they use mocks incompatible with the hardware -test_ignore = test_native +test_ignore = test_native, test_txtempus +build_src_filter = +<*> - +; Environment for running unit tests natively on the host machine. +; Used for validating signal logic and other platform-independent code. [env:native] platform = native test_framework = unity +lib_deps = + https://github.com/hzeller/txtempus.git#34b9f3ff3e26e65f75bcbe71bed8d0638a4e3a24 + throwtheswitch/Unity@^2.5.2 +extra_scripts = pre:scripts/fix_txtempus.py build_flags = -D UNIT_TEST -I test/mocks -lib_deps = throwtheswitch/Unity@^2.5.2 build_src_filter = -<*> + diff --git a/scripts/fix_txtempus.py b/scripts/fix_txtempus.py new file mode 100644 index 0000000..10f581a --- /dev/null +++ b/scripts/fix_txtempus.py @@ -0,0 +1,30 @@ +""" +fix_txtempus.py + +Removes unnecessary source files from the txtempus library to prevent build errors +in the native environment, specifically Raspberry Pi specific implementations. +""" +import os +from os.path import join, isfile + +Import("env") + +# Path to the library in libdeps +# env['PROJECT_LIBDEPS_DIR'] gives .pio/libdeps +# env['PIOENV'] gives the environment name + +libdeps_dir = env.subst("$PROJECT_LIBDEPS_DIR") +env_name = env.subst("$PIOENV") +txtempus_dir = join(libdeps_dir, env_name, "txtempus") +files_to_remove = [ + "txtempus.cc", + "rpi-control.cc", + "sunxih3-control.cc", + "hardware-control.cc" +] + +for f in files_to_remove: + src_file = join(txtempus_dir, "src", f) + if isfile(src_file): + print(f"Removing {src_file} to avoid conflict/build error") + os.remove(src_file) diff --git a/test/mocks/Arduino.h b/test/mocks/Arduino.h index 2cc58a0..5aae189 100644 --- a/test/mocks/Arduino.h +++ b/test/mocks/Arduino.h @@ -21,10 +21,22 @@ class IPAddress { IPAddress(uint8_t, uint8_t, uint8_t, uint8_t) {} }; +#include class String { public: - String(const char* s = "") {} - String(int i) {} + std::string _s; + String(const char* s = "") : _s(s) {} + String(std::string s) : _s(s) {} + String(int i) : _s(std::to_string(i)) {} + const char* c_str() const { return _s.c_str(); } + size_t length() const { return _s.length(); } + String operator+(const String& other) const { return String(_s + other._s); } + String operator+(const char* other) const { return String(_s + other); } + friend String operator+(const char* lhs, const String& rhs) { return String(lhs + rhs._s); } + bool operator==(const String& other) const { return _s == other._s; } + bool operator==(const char* other) const { return _s == other; } + bool operator!=(const String& other) const { return _s != other._s; } + bool operator!=(const char* other) const { return _s != other; } }; void delay(unsigned long ms); diff --git a/test/mocks/ESPUI.h b/test/mocks/ESPUI.h index 497973d..8e91aab 100644 --- a/test/mocks/ESPUI.h +++ b/test/mocks/ESPUI.h @@ -2,7 +2,20 @@ #include enum Verbosity { Quiet }; -enum ControlColor { Sunflower, Turquoise, Emerald, Peterriver, Carrot, Alizarin }; +enum ControlColor { Sunflower, Turquoise, Emerald, Peterriver, Carrot, Alizarin, Dark, Wisteria, Wetasphalt }; +enum class ControlType { Label, Button, Switch, Option, Select, Text, Number, Slider, Pad, Graph }; + +struct Control { + uint16_t id; + void* ptr; + String value; // Added value + static uint16_t noParent; +}; + +// Define static member (inline for header-only mock) +inline uint16_t Control::noParent = 0; + +#define S_ACTIVE -7 class ESPUIClass { public: @@ -13,6 +26,24 @@ class ESPUIClass { void setCustomJS(const char* js) {} void begin(const char* title) {} void print(uint16_t id, const char* value) {} + + // Mock switcher + uint16_t switcher(const char* label, void (*callback)(Control*, int), ControlColor color, bool startState = false) { return 0; } + + // Mock text + uint16_t text(const char* label, void (*callback)(Control*, int), ControlColor color, const char* value) { return 0; } + + // Mock updateVisibility + void updateVisibility(uint16_t id, bool visible) {} + + // Mock setInputType + void setInputType(uint16_t id, const char* type) {} + + // Mock addControl + uint16_t addControl(ControlType type, const char* label, const char* value, ControlColor color, uint16_t parent, void (*callback)(Control*, int) = nullptr) { return 0; } + + // Mock updateSelect + void updateSelect(uint16_t id, const char* value) {} }; extern ESPUIClass ESPUI; diff --git a/test/mocks/Preferences.h b/test/mocks/Preferences.h new file mode 100644 index 0000000..d780ad2 --- /dev/null +++ b/test/mocks/Preferences.h @@ -0,0 +1,10 @@ +#pragma once + +class Preferences { +public: + void begin(const char* name, bool readOnly = false) {} + void putBool(const char* key, bool value) {} + bool getBool(const char* key, bool defaultValue = false) { return defaultValue; } + void putString(const char* key, String value) {} + String getString(const char* key, String defaultValue = "") { return defaultValue; } +}; diff --git a/test/mocks/esp_sntp.h b/test/mocks/esp_sntp.h index bfff57c..68659b2 100644 --- a/test/mocks/esp_sntp.h +++ b/test/mocks/esp_sntp.h @@ -3,3 +3,4 @@ typedef void (*sntp_sync_time_cb_t)(struct timeval *tv); void sntp_set_time_sync_notification_cb(sntp_sync_time_cb_t callback); +void esp_sntp_stop(); diff --git a/test/mocks/esp_timer.h b/test/mocks/esp_timer.h new file mode 100644 index 0000000..bef292f --- /dev/null +++ b/test/mocks/esp_timer.h @@ -0,0 +1,26 @@ +#pragma once + +// Mock esp_timer for native testing. +// In production, the real esp_timer fires the callback every 1ms. +// In tests, the test calls onSignalTimer() directly to simulate the timer. + +typedef void* esp_timer_handle_t; + +#define ESP_TIMER_TASK 0 + +typedef struct { + void (*callback)(void*); + void* arg; + int dispatch_method; + const char* name; +} esp_timer_create_args_t; + +inline int esp_timer_create(const esp_timer_create_args_t* args, esp_timer_handle_t* handle) { + (void)args; (void)handle; + return 0; +} + +inline int esp_timer_start_periodic(esp_timer_handle_t handle, uint64_t period) { + (void)handle; (void)period; + return 0; +} diff --git a/test/test_native/test_bootstrap.cpp b/test/test_native/test_bootstrap.cpp index 417d19a..933e956 100644 --- a/test/test_native/test_bootstrap.cpp +++ b/test/test_native/test_bootstrap.cpp @@ -12,6 +12,7 @@ #include "WiFiUdp.h" #include "ArduinoMDNS.h" #include "esp_sntp.h" +#include "esp_timer.h" #include "WiFi.h" #include "Esp.h" #include "SPI.h" @@ -25,6 +26,7 @@ SPIClass SPI; // Core functions void delay(unsigned long ms) {} unsigned long millis() { return 0; } +unsigned long micros() { return 0; } void pinMode(uint8_t pin, uint8_t mode) {} void digitalWrite(uint8_t pin, uint8_t val) {} @@ -36,6 +38,12 @@ int mock_gettimeofday(struct timeval *tv, void *tz) { } #define gettimeofday mock_gettimeofday +int mock_settimeofday(const struct timeval *tv, const struct timezone *tz) { + if (tv) mock_tv = *tv; + return 0; +} +#define settimeofday mock_settimeofday + // Mock Serial to support printf class SerialMock { public: @@ -60,7 +68,12 @@ SerialMock MySerial; #define Serial MySerial // ESP32 specific mocks -void ledcAttach(uint8_t pin, double freq, uint8_t resolution) {} +// Global to track last ledc frequency +int last_ledc_freq = 0; +void ledcAttach(uint8_t pin, double freq, uint8_t resolution) { + last_ledc_freq = (int)freq; +} +void ledcDetach(uint8_t pin) {} void ledcWrite(uint8_t pin, uint32_t duty) {} void configTzTime(const char* tz, const char* server1, const char* server2 = nullptr, const char* server3 = nullptr) {} bool getLocalTime(struct tm* info, uint32_t ms = 5000) { @@ -70,9 +83,10 @@ bool getLocalTime(struct tm* info, uint32_t ms = 5000) { return true; } void sntp_set_time_sync_notification_cb(sntp_sync_time_cb_t callback) {} +void esp_sntp_stop() {} // Forward declarations for functions in WatchTower.ino that are used before definition -bool wwvbLogicSignal(int, int, int, int, int, int, int, int); +// (None needed as they are in WatchTower.ino now) // Rename timezone to avoid conflict with system symbol #define timezone my_timezone @@ -119,10 +133,10 @@ void test_wifi_connection_attempt(void) { void test_serial_date_output(void) { // Arrange - // Set time to 2025-12-25 12:00:00 UTC - // 1766664000 - mock_tv.tv_sec = 1766664000; - mock_tv.tv_usec = 900000; // 900ms to ensure logicValue=1 (MARK/ONE/ZERO all high at 900ms? No wait) + // Set time to 2025-12-25 12:00:01 UTC (Second 1 is usually ZERO, which is High at 500ms for all signals) + // 1766664001 + mock_tv.tv_sec = 1766664001; + mock_tv.tv_usec = 500000; // 500ms // ZERO: low 200ms, high 800ms. So at 900ms (0.9s), it is HIGH (Wait, "low 200ms, high 800ms" usually means low FOR 200ms, then high FOR 800ms? Or low UNTIL 200ms?) // WatchTower.ino: // if (bit == WWVB_T::ZERO) return millis >= 200; @@ -134,6 +148,7 @@ void test_serial_date_output(void) { // Act setup(); // Initialize + onSignalTimer(NULL); // Simulate the hardware timer firing loop(); // Run loop once // Assert @@ -145,86 +160,189 @@ void test_serial_date_output(void) { TEST_ASSERT_NOT_NULL(strstr(MySerial.output.c_str(), "December 25 2025")); } +// Shared valid timeinfo for tests (2000-01-01 12:00:00) +const struct tm kValidTimeInfo = []{ + struct tm t = {0}; + t.tm_year = 100; // 2000 + t.tm_mon = 0; // Jan + t.tm_mday = 1; // 1st + t.tm_hour = 12; + return t; +}(); + void test_wwvb_logic_signal(void) { - // Test ZERO bit (e.g. second 4 is always ZERO/Blank) - // Expect: False for < 200ms, True for >= 200ms - TEST_ASSERT_FALSE(wwvbLogicSignal(0, 0, 4, 199, 0, 2025, 0, 0)); - TEST_ASSERT_TRUE(wwvbLogicSignal(0, 0, 4, 200, 0, 2025, 0, 0)); + WWVBSignal wwvb; + struct tm timeinfo = kValidTimeInfo; + wwvb.encodeMinute(timeinfo, 0, 0); + + // Test MARK (0s) + TimeCodeSymbol bit = wwvb.getSymbolForSecond(0); + TEST_ASSERT_EQUAL(TimeCodeSymbol::MARK, bit); + + TEST_ASSERT_FALSE(wwvb.getLevelForTimeCodeSymbol(bit, 0)); + TEST_ASSERT_FALSE(wwvb.getLevelForTimeCodeSymbol(bit, 799)); + TEST_ASSERT_TRUE(wwvb.getLevelForTimeCodeSymbol(bit, 800)); - // Test ONE bit (e.g. second 1, minute 40 -> bit 2 is 1) - // Minute 40 = 101000 binary? No. 40 / 10 = 4. 4 in binary is 100. - // Second 1 checks bit 2 of (minute/10). (4 >> 2) & 1 = 1. So it's a ONE. - // Expect: False for < 500ms, True for >= 500ms - TEST_ASSERT_FALSE(wwvbLogicSignal(0, 40, 1, 499, 0, 2025, 0, 0)); - TEST_ASSERT_TRUE(wwvbLogicSignal(0, 40, 1, 500, 0, 2025, 0, 0)); + // Test ZERO + // timeinfo.tm_sec = 1; // Not needed for encodeMinute unless we re-configure + bit = wwvb.getSymbolForSecond(1); + TEST_ASSERT_EQUAL(TimeCodeSymbol::ZERO, bit); + TEST_ASSERT_FALSE(wwvb.getLevelForTimeCodeSymbol(bit, 0)); + TEST_ASSERT_FALSE(wwvb.getLevelForTimeCodeSymbol(bit, 199)); + TEST_ASSERT_TRUE(wwvb.getLevelForTimeCodeSymbol(bit, 200)); - // Test MARK bit (e.g. second 0 is always MARK) - // Expect: False for < 800ms, True for >= 800ms - TEST_ASSERT_FALSE(wwvbLogicSignal(0, 0, 0, 799, 0, 2025, 0, 0)); - TEST_ASSERT_TRUE(wwvbLogicSignal(0, 0, 0, 800, 0, 2025, 0, 0)); + // Test ONE + // We need to find a second that is 1. + // Bit 58 is DST. If DST=1, then bit is 1. + // Re-configure for DST + timeinfo.tm_sec = 0; // Reset sec just in case + wwvb.encodeMinute(timeinfo, 1, 1); // DST on + + bit = wwvb.getSymbolForSecond(58); + // DST bit 58 is set if dst is on. + TEST_ASSERT_EQUAL(TimeCodeSymbol::ONE, bit); + TEST_ASSERT_FALSE(wwvb.getLevelForTimeCodeSymbol(bit, 0)); + TEST_ASSERT_FALSE(wwvb.getLevelForTimeCodeSymbol(bit, 499)); + TEST_ASSERT_TRUE(wwvb.getLevelForTimeCodeSymbol(bit, 500)); } void test_wwvb_frame_encoding(void) { - // Expected bits for Mar 6 2008 07:30:00 UTC from https://en.wikipedia.org/wiki/WWVB#Amplitude-modulated_time_code - // Excludes DUT bits (36-38, 40-43) which are marked as '?' - const char* expected = - "M" // 00 - "01100000" // 01-08 (Min 30) - "M" // 09 - "00" // 10-11 - "0000111" // 12-18 (Hour 7) - "M" // 19 - "00" // 20-21 - "0000110" // 22-28 (Day 66 part 1) - "M" // 29 - "0110" // 30-33 (Day 66 part 2) - "00" // 34-35 - "???" // 36-38 (DUT) - "M" // 39 - "????" // 40-43 (DUT) - "0" // 44 - "0000" // 45-48 (Year 08 part 1) - "M" // 49 - "1000" // 50-53 (Year 08 part 2) - "0" // 54 - "1" // 55 (Leap Year) - "0" // 56 (Leap Sec) - "00" // 57-58 (DST) - "M"; // 59 - - for (int i = 0; i < 60; ++i) { - if (expected[i] == '?') continue; - - // Determine bit type from wwvbLogicSignal - // ZERO: High at 200ms (Low < 200) - // ONE: High at 500ms (Low < 500) - // MARK: High at 800ms (Low < 800) - - bool at200 = wwvbLogicSignal(7, 30, i, 200, 66, 2008, 0, 0); - bool at500 = wwvbLogicSignal(7, 30, i, 500, 66, 2008, 0, 0); - bool at800 = wwvbLogicSignal(7, 30, i, 800, 66, 2008, 0, 0); - - char detected = '?'; - if (at200) { - detected = '0'; // ZERO is high after 200ms - } else if (at500) { - detected = '1'; // ONE is high after 500ms - } else if (at800) { - detected = 'M'; // MARK is high after 800ms - } else { - // Should not happen if logic is correct (MARK is high at 800) - // If it's low at 800, it's invalid or very long low pulse? - // wwvbLogicSignal returns true if millis >= threshold. - // If bit is MARK, threshold is 800. So at 800 it returns true. - detected = 'X'; - } - - char msg[32]; - snprintf(msg, sizeof(msg), "Bit %d mismatch", i); - TEST_ASSERT_EQUAL_MESSAGE(expected[i], detected, msg); - } + WWVBSignal wwvb; + // Test a specific known date/time + // Mar 6 2008 07:30:00 UTC + struct tm timeinfo = {0}; + timeinfo.tm_year = 2008 - 1900; + timeinfo.tm_mon = 2; // March + timeinfo.tm_mday = 6; + timeinfo.tm_hour = 7; + timeinfo.tm_min = 30; + timeinfo.tm_sec = 0; + timeinfo.tm_yday = 65; // Day 66 (0-indexed 65) + + wwvb.encodeMinute(timeinfo, 0, 0); + + // Day of Year 66. + // Hundreds: 0. Tens: 6. Units: 6. + // Sec 26 (Tens 40): 1. + // Sec 27 (Tens 20): 1. + // Sec 31 (Units 4): 1. + // Sec 32 (Units 2): 1. + + TEST_ASSERT_EQUAL(TimeCodeSymbol::ONE, wwvb.getSymbolForSecond(26)); + TEST_ASSERT_EQUAL(TimeCodeSymbol::ONE, wwvb.getSymbolForSecond(27)); + TEST_ASSERT_EQUAL(TimeCodeSymbol::ONE, wwvb.getSymbolForSecond(31)); + TEST_ASSERT_EQUAL(TimeCodeSymbol::ONE, wwvb.getSymbolForSecond(32)); + + // Check a ZERO bit (e.g. Sec 22 - Hundreds 200) + TEST_ASSERT_EQUAL(TimeCodeSymbol::ZERO, wwvb.getSymbolForSecond(22)); +} + +void test_dcf77_signal(void) { + DCF77Signal dcf77; + struct tm timeinfo = kValidTimeInfo; + dcf77.encodeMinute(timeinfo, 0, 0); + + // Test IDLE (59th second) + TEST_ASSERT_EQUAL(TimeCodeSymbol::IDLE, dcf77.getSymbolForSecond(59)); + TEST_ASSERT_TRUE(dcf77.getLevelForTimeCodeSymbol(TimeCodeSymbol::IDLE, 0)); + + // Test ZERO + TEST_ASSERT_EQUAL(TimeCodeSymbol::ZERO, dcf77.getSymbolForSecond(0)); + TEST_ASSERT_FALSE(dcf77.getLevelForTimeCodeSymbol(TimeCodeSymbol::ZERO, 0)); + TEST_ASSERT_TRUE(dcf77.getLevelForTimeCodeSymbol(TimeCodeSymbol::ZERO, 100)); + + // Test ONE (Bit 20 is always 1) + TEST_ASSERT_EQUAL(TimeCodeSymbol::ONE, dcf77.getSymbolForSecond(20)); + TEST_ASSERT_FALSE(dcf77.getLevelForTimeCodeSymbol(TimeCodeSymbol::ONE, 0)); + TEST_ASSERT_TRUE(dcf77.getLevelForTimeCodeSymbol(TimeCodeSymbol::ONE, 200)); +} + +void test_jjy_signal(void) { + JJYSignal jjy; + struct tm timeinfo = kValidTimeInfo; + jjy.encodeMinute(timeinfo, 0, 0); + + // Test MARK (0s) + TEST_ASSERT_EQUAL(TimeCodeSymbol::MARK, jjy.getSymbolForSecond(0)); + TEST_ASSERT_TRUE(jjy.getLevelForTimeCodeSymbol(TimeCodeSymbol::MARK, 0)); + TEST_ASSERT_FALSE(jjy.getLevelForTimeCodeSymbol(TimeCodeSymbol::MARK, 200)); + + // Test ZERO + TEST_ASSERT_EQUAL(TimeCodeSymbol::ZERO, jjy.getSymbolForSecond(1)); + TEST_ASSERT_TRUE(jjy.getLevelForTimeCodeSymbol(TimeCodeSymbol::ZERO, 0)); + TEST_ASSERT_FALSE(jjy.getLevelForTimeCodeSymbol(TimeCodeSymbol::ZERO, 800)); + + // Test ONE (Parity usually 1 if 0s?) + // Hard to force a 1 without setting time. + // Let's set minute to 1 (0000001). + // Minute bits: 0-7 (Sec 1-8). + // Sec 8 (Bit 0): 1. + timeinfo.tm_min = 1; + jjy.encodeMinute(timeinfo, 0, 0); + + TEST_ASSERT_EQUAL(TimeCodeSymbol::ONE, jjy.getSymbolForSecond(8)); + TEST_ASSERT_TRUE(jjy.getLevelForTimeCodeSymbol(TimeCodeSymbol::ONE, 0)); + TEST_ASSERT_FALSE(jjy.getLevelForTimeCodeSymbol(TimeCodeSymbol::ONE, 500)); +} + +void test_msf_signal(void) { + MSFSignal msf; + struct tm timeinfo = kValidTimeInfo; + msf.encodeMinute(timeinfo, 0, 0); + + // Test MARK (0s) + TEST_ASSERT_EQUAL(TimeCodeSymbol::MARK, msf.getSymbolForSecond(0)); + TEST_ASSERT_FALSE(msf.getLevelForTimeCodeSymbol(TimeCodeSymbol::MARK, 0)); + TEST_ASSERT_TRUE(msf.getLevelForTimeCodeSymbol(TimeCodeSymbol::MARK, 500)); + + // Test Default (second 1) -> ZERO (Placeholder implementation) + TimeCodeSymbol bit = msf.getSymbolForSecond(1); + TEST_ASSERT_EQUAL(TimeCodeSymbol::ZERO, bit); + // ZERO: 100ms Low, 900ms High + TEST_ASSERT_FALSE(msf.getLevelForTimeCodeSymbol(bit, 99)); + TEST_ASSERT_TRUE(msf.getLevelForTimeCodeSymbol(bit, 100)); +} + +void test_signal_switching(void) { + // Arrange + setup(); // Ensure UI is created + + // Act - Select DCF77 + Control sender; + sender.id = webUI._ui_signal_select; + sender.value = "DCF77"; + WebUI::_onSignalChange(&sender, S_ACTIVE); + + // Assert + TEST_ASSERT_EQUAL_STRING("DCF77", signalGenerator->getName().c_str()); + TEST_ASSERT_EQUAL(77500, last_ledc_freq); + + // Act - Select MSF + sender.value = "MSF"; + WebUI::_onSignalChange(&sender, S_ACTIVE); + + // Assert + TEST_ASSERT_EQUAL_STRING("MSF", signalGenerator->getName().c_str()); + TEST_ASSERT_EQUAL(60000, last_ledc_freq); + + // Act - Select JJY + sender.value = "JJY"; + WebUI::_onSignalChange(&sender, S_ACTIVE); + + // Assert + TEST_ASSERT_EQUAL_STRING("JJY", signalGenerator->getName().c_str()); + TEST_ASSERT_EQUAL(60000, last_ledc_freq); // or 40000 depending on impl + + // Act - Select WWVB + sender.value = "WWVB"; + WebUI::_onSignalChange(&sender, S_ACTIVE); + + // Assert + TEST_ASSERT_EQUAL_STRING("WWVB", signalGenerator->getName().c_str()); + TEST_ASSERT_EQUAL(60000, last_ledc_freq); } + int main(int argc, char **argv) { UNITY_BEGIN(); RUN_TEST(test_setup_completes); @@ -232,6 +350,10 @@ int main(int argc, char **argv) { RUN_TEST(test_serial_date_output); RUN_TEST(test_wwvb_logic_signal); RUN_TEST(test_wwvb_frame_encoding); + RUN_TEST(test_dcf77_signal); + RUN_TEST(test_jjy_signal); + RUN_TEST(test_msf_signal); + RUN_TEST(test_signal_switching); UNITY_END(); return 0; } diff --git a/test/test_txtempus/test_txtempus_compare.cpp b/test/test_txtempus/test_txtempus_compare.cpp new file mode 100644 index 0000000..af15bea --- /dev/null +++ b/test/test_txtempus/test_txtempus_compare.cpp @@ -0,0 +1,209 @@ +/* + * test_txtempus_compare.cpp + * + * Defines comparison tests between WatchTower's signal logic and a reference + * implementation from the txtempus library. Validates signal encoding for different + * protocols (WWVB, DCF77, MSF, JJY) across various dates and times. + */ + +#include +#include +#include +#include +#include + +// Include our signal classes +#include "RadioTimeSignal.h" +#include "WWVBSignal.h" +#include "DCF77Signal.h" +#include "MSFSignal.h" +#include "JJYSignal.h" + +// Include txtempus headers +#undef HIGH +#undef LOW +#include "time-signal-source.h" + +// Helper to convert txtempus modulation to boolean level at a specific millisecond +bool getTxtempusLevel(const TimeSignalSource::SecondModulation& mod, int millis) { + int current_ms = 0; + for (const auto& m : mod) { + int duration = m.duration_ms; + if (duration == 0) duration = 1000 - current_ms; // Auto-fill + + if (millis >= current_ms && millis < current_ms + duration) { + return m.power == CarrierPower::HIGH; + } + current_ms += duration; + } + return false; // Should not happen if millis < 1000 +} + +struct TestCase { + std::string name; + int year; + int month; + int day; + int hour; + int min; +}; + +std::vector test_cases = { + {"DST (Summer)", 2025, 7, 15, 12, 0}, + {"STD (Winter)", 2025, 12, 25, 12, 0}, + {"Pre-DST Transition (Spring)", 2025, 3, 9, 1, 59}, // US 2025: Mar 9 + {"Pre-STD Transition (Fall)", 2025, 11, 2, 1, 59}, // US 2025: Nov 2 + {"Leap Year (Feb 29)", 2024, 2, 29, 12, 0}, + {"Year Boundary (Dec 31)", 2025, 12, 31, 23, 59}, + {"EU DST Spring (Mar 30)", 2025, 3, 30, 1, 59}, // EU 2025: Mar 30 + {"EU DST Fall (Oct 26)", 2025, 10, 26, 2, 59}, // EU 2025: Oct 26 + {"Century Leap Year (2000)", 2000, 2, 29, 12, 0}, + {"Non-Leap Century (2100)", 2100, 2, 28, 12, 0}, + {"Stress Test (Max Bits)", 2099, 12, 31, 23, 59} +}; + +// Helper to run comparison for a specific signal and timezone +template +void run_comparison(const char* timezone, bool input_is_utc, bool add_minute, const std::vector& skip_bits = {}) { + setenv("TZ", timezone, 1); + tzset(); + + MySignalT mySignal; + RefSignalT refSignal; + + for (const auto& tc : test_cases) { + // Construct UTC time_t for the test case + struct tm tm_utc = {0}; + tm_utc.tm_year = tc.year - 1900; + tm_utc.tm_mon = tc.month - 1; + tm_utc.tm_mday = tc.day; + tm_utc.tm_hour = tc.hour; + tm_utc.tm_min = tc.min; + tm_utc.tm_sec = 0; + tm_utc.tm_isdst = 0; // UTC has no DST + + time_t t_utc = timegm(&tm_utc); + + // Prepare reference (txtempus) + refSignal.PrepareMinute(t_utc); + + // Prepare my signal inputs + struct tm tm_input; + if (input_is_utc) { + tm_input = tm_utc; + } else { + // Convert UTC to Local for the signal input + localtime_r(&t_utc, &tm_input); + } + + // Calculate DST flags for WWVB (today/tomorrow) + struct tm tm_local_now; + localtime_r(&t_utc, &tm_local_now); + + struct tm tm_today_start = tm_local_now; + tm_today_start.tm_hour = 0; + tm_today_start.tm_min = 0; + tm_today_start.tm_sec = 0; + time_t t_today_start = mktime(&tm_today_start); // mktime normalizes and sets isdst + + struct tm tm_tomorrow_start = tm_today_start; + tm_tomorrow_start.tm_mday += 1; + mktime(&tm_tomorrow_start); // Normalize + + time_t t_target = t_utc + (add_minute ? 60 : 0); + struct tm tm_target; + if (input_is_utc) { + gmtime_r(&t_target, &tm_target); + } else { + localtime_r(&t_target, &tm_target); + } + + // Configure minute at the start + mySignal.encodeMinute( + tm_target, + tm_today_start.tm_isdst, + tm_tomorrow_start.tm_isdst + ); + + for (int sec = 0; sec < 60; ++sec) { + // Skip bits if requested + bool skip = false; + for (int s : skip_bits) { + if (s == sec) { + skip = true; + break; + } + } + if (skip) continue; + + auto mod = refSignal.GetModulationForSecond(sec); + + struct tm t_sec = tm_target; + t_sec.tm_sec = sec; // Update second + + TimeCodeSymbol myBit = mySignal.getSymbolForSecond(sec); + + // Check sample points + int check_points[] = {50, 150, 250, 550, 850}; + for (int ms : check_points) { + bool myLevel = mySignal.getLevelForTimeCodeSymbol(myBit, ms); + bool refLevel = getTxtempusLevel(mod, ms); + + if (myLevel != refLevel) { + printf("FAIL: %s %s Sec %d MS %d. MyBit: %d. Time: %02d:%02d:%02d YDay: %d DST: %d\n", + tc.name.c_str(), timezone, sec, ms, (int)myBit, + t_sec.tm_hour, t_sec.tm_min, t_sec.tm_sec, t_sec.tm_yday, t_sec.tm_isdst); + } + + char msg[128]; + snprintf(msg, sizeof(msg), "%s: %s mismatch at sec %d, ms %d (Bit: %d)", + tc.name.c_str(), timezone, sec, ms, (int)myBit); + TEST_ASSERT_EQUAL_MESSAGE(refLevel, myLevel, msg); + } + } + } +} + +void test_wwvb_compare(void) { + // WWVB: UTC time, US DST rules + // Skip DST bits (57, 58) as txtempus seems to not implement DST logic (or uses UTC) + std::vector skip; + skip.push_back(57); + skip.push_back(58); + run_comparison("PST8PDT,M3.2.0,M11.1.0", true, false, skip); +} + +void test_dcf77_compare(void) { + // DCF77: CET/CEST, Local time input + // We pass false for add_minute because DCF77Signal now handles the +60s internally + run_comparison("CET-1CEST,M3.5.0,M10.5.0/3", false, false); +} + +void test_jjy_compare(void) { + // JJY: JST, Local time input (no DST) + // Skip Day of Year bits (22-34), Year bits (41-48), and Day of Week bits (50-52) as txtempus doesn't seem to implement them + std::vector skip; + for(int i=22; i<=34; ++i) skip.push_back(i); + for(int i=41; i<=48; ++i) skip.push_back(i); + for(int i=50; i<=52; ++i) skip.push_back(i); + run_comparison("JST-9", false, false, skip); +} + +void test_msf_compare(void) { + // MSF: UK Time (GMT/BST), Local time input + // Skip DUT1 bits (1-16) as txtempus sets them to 0 + std::vector skip; + for(int i=1; i<=16; ++i) skip.push_back(i); + // We pass false for add_minute because MSFSignal now handles the +60s internally + run_comparison("GMT0BST,M3.5.0/1,M10.5.0", false, false, skip); +} + +int main(int argc, char **argv) { + UNITY_BEGIN(); + RUN_TEST(test_wwvb_compare); + RUN_TEST(test_dcf77_compare); + RUN_TEST(test_jjy_compare); + RUN_TEST(test_msf_compare); + UNITY_END(); + return 0; +}