├── .github └── workflows │ └── arduino.yml ├── .gitignore ├── ChangeLog.md ├── LICENSE ├── README.md ├── clock-controller.ino ├── clock.jpg └── images.h /.github/workflows/arduino.yml: -------------------------------------------------------------------------------- 1 | name: CodeBuild 2 | 3 | on: [push, pull_request] 4 | jobs: 5 | build: 6 | name: Test of the clock-controller.ino compilation for the ESP32 target 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v2 12 | 13 | - name: Compile scetch 14 | uses: ArminJo/arduino-test-compile@v2 15 | with: 16 | arduino-board-fqbn: esp32:esp32:esp32:PSRAM=disabled,PartitionScheme=default,CPUFreq=240,FlashMode=qio,FlashFreq=80,FlashSize=4M,UploadSpeed=921600,DebugLevel=none 17 | platform-url: https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json 18 | required-libraries: Time,Timezone,ESP8266 and ESP32 OLED driver for SSD1306 displays 19 | sketch-names: clock-controller.ino 20 | 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Prerequisites 2 | *.d 3 | 4 | # Compiled Object files 5 | *.slo 6 | *.lo 7 | *.o 8 | *.obj 9 | 10 | # Precompiled Headers 11 | *.gch 12 | *.pch 13 | 14 | # Compiled Dynamic libraries 15 | *.so 16 | *.dylib 17 | *.dll 18 | 19 | # Fortran module files 20 | *.mod 21 | *.smod 22 | 23 | # Compiled Static libraries 24 | *.lai 25 | *.la 26 | *.a 27 | *.lib 28 | 29 | # Executables 30 | *.exe 31 | *.out 32 | *.app 33 | -------------------------------------------------------------------------------- /ChangeLog.md: -------------------------------------------------------------------------------- 1 | - do not call updateScreen if there is nothing to update 2 | 3 | ## v0.0.2 4 | - Create screensaver to prolong OLED life 5 | - Use GPIO 15 in a touch-button mode 6 | 7 | ## v0.0.1 8 | - First initial release 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Alex Samorukov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Slave clock controller based on ESP8266 hardware 2 | 3 | This project is intended to implement a slave clock impulse driver using ESP32 hardware 4 | with an OLED display. The time source is NTP, so no RTC module is needed. The code supports timezone 5 | and DST rules. The state of the slave clock is stored in the Non-Volatile memory to survive power loss or reboot. Board time as well as slave clock time is displayed on the OLED screen. 6 | 7 | See [my blog posts](https://smallhacks.wordpress.com/2020/09/26/esp32-based-old-clock-controller-with-ntp-sync/) for the additional details. 8 | 9 | ## Requirements 10 | 11 | ### Software 12 | 13 | - [Arduino Timezone Library](https://github.com/JChristensen/Timezone) 14 | - [Arduino Time Library ](https://playground.arduino.cc/Code/Time) 15 | - [ThingPulse OLED SSD1306 Library](https://github.com/ThingPulse/esp8266-oled-ssd1306) 16 | 17 | ### Hardware 18 | 19 | - [ESP-WROOM-32 0.96" OLED ESP32 WIFI-BT Dual-mode 2.4GHz For Wemos D1 AP STA](https://www.ebay.com/itm/ESP-WROOM-32-0-96-OLED-ESP32-WIFI-BT-Dual-mode-2-4GHz-For-Wemos-D1-AP-STA-/332196121504) 20 | - [L298N motor driver module H-Bridge](https://www.instructables.com/id/Control-DC-and-stepper-motors-with-L298N-Dual-Moto/). 21 | - 12V 1A power supply 22 | 23 | ## How it works 24 | 25 | - L298N driver is used to generate 12V impulses to drive the clock and to provide 5V power 26 | to the ESP board. It also allows to invert output polarity, so no relays needed. 27 | - After startup ESP trying to connect to WIFI and get time from the NTP 28 | - If time is synced - ESP compares it with slave time in the Non-Volatile memory and updates the slave clock 29 | - Slave status is stored in Non-Volatile memory every minute, using [Preferences](https://github.com/espressif/arduino-esp32/tree/master/libraries/Preferences) library on the [NVS partition](https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/storage/nvs_flash.html) to optimize wear-out. Probably using the I2C FRAM module for that would be a better choice. 30 | - Code respects configured timezone and automatically synced to NTP every 5m 31 | - There is a special "init mode" to sync hardware slave with the controller 32 | - At the moment we are using only one (first) channel of the L298N. The second could be used for the alarm or led backlight. 33 | 34 | ## Init mode 35 | 36 | As ESP has no information about the slave clock position - we need to sync them. To do this - connect PIN_INIT (15) pin to GND and restart ESP. It will move arrows every second. Wait until the clock shows **12:00** and immediately unplug the wire. The clock will be synced with ESP and will switch to normal mode. 37 | 38 | ## Final result (not yet in a box) 39 | 40 | ![Clock and controller](clock.jpg "Clock and controller") 41 | 42 | ## Related links 43 | - Another slave clock project, based on ESP8266: software ([gitlab.com/close2/nebenuhr](https://gitlab.com/close2/nebenuhr)) and hardware ([gitlab.com/close2/nebenuhr_hardware/](https://gitlab.com/close2/nebenuhr_hardware)) 44 | - [github.com/melka/masterclock](https://github.com/melka/masterclock]) - master clock for Lepaute 30 seconds alternating pulse slave clock and possibly other models. 45 | -------------------------------------------------------------------------------- /clock-controller.ino: -------------------------------------------------------------------------------- 1 | /** 2 | The MIT License (MIT) 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | */ 22 | 23 | #include 24 | #include 25 | #include 26 | #include // https://github.com/JChristensen/Timezone 27 | #include // https://playground.arduino.cc/Code/Time 28 | #include "SSD1306Wire.h" // SSD1306 OLED: https://github.com/ThingPulse/esp8266-oled-ssd1306 29 | #include "images.h" // Include custom images 30 | 31 | // WIFI configuration 32 | #define WIFI_SSID "myssid" 33 | #define WIFI_KEY "mypass" 34 | 35 | // NTP server name or IP, sync interval in seconds 36 | static const char ntpServerName[] = "cz.pool.ntp.org"; 37 | #define NTP_SYNC_INTERVAL 300 38 | 39 | // Screensaver to save OLED 40 | #define SCREENSAVER_TIMER 600 41 | 42 | // Time Zone (DST) settings, change to your country 43 | TimeChangeRule CEST = { "CEST", Last, Sun, Mar, 2, 120 }; // Central European Summer Time 44 | TimeChangeRule CET = { "CET ", Last, Sun, Oct, 3, 60 }; // Central European Standard Time 45 | Timezone ClockTZ(CEST, CET); 46 | 47 | // motor controller CH0 pins 48 | #define PIN_CH00 12 49 | #define PIN_CH01 13 50 | // button, using toch mode 51 | #define PIN_INIT 15 52 | #define TOUCH_THRESHOLD 5 53 | 54 | // display configuration 55 | #define DISP_I2C 0x3c 56 | #define DISP_SDA 5 57 | #define DISP_SCL 4 58 | 59 | unsigned int localPort = 8888; 60 | WiFiUDP udp; 61 | static const char hname[] = "esp-clock-controller"; 62 | 63 | /* 64 | Slave clock configuration 65 | 66 | I been not able to find specification for this clock, so experimentally 67 | If impulse is > 200ms clock sometime fails to move the arrow on next switch 68 | 69 | IMPULSE_WAIT is used in the INIT mode and if slave catching up master 70 | */ 71 | #define IMPULSE_ON 150 72 | #define IMPULSE_WAIT 850 73 | 74 | // Initialize the OLED display using Wire library 75 | SSD1306Wire display(DISP_I2C, DISP_SDA, DISP_SCL); 76 | 77 | short state = 0; 78 | int8_t show_impulse = 0; 79 | time_t last_ntp_sync = 0; 80 | time_t oled_activate = 0; 81 | time_t last_t = 0; 82 | 83 | char console_text[256]; 84 | Preferences preferences; 85 | 86 | // utility function for digital clock display: prints leading 0 87 | String twoDigits(int digits) { 88 | if (digits < 10) { 89 | String i = '0' + String(digits); 90 | return i; 91 | } else { 92 | return String(digits); 93 | } 94 | } 95 | 96 | void setup() { 97 | /* Read slave clock state from the EEPROM using Preferences lib. 98 | We have 12 * 60 (720) possible values 99 | in the clock and also we need to keep last used polarity as a sign 100 | This mean that valid values are from -721 to +721 excluding 0 101 | */ 102 | preferences.begin("clock", false); 103 | state = preferences.getShort("state", -1); 104 | log_i("Booting... Initial state is: %d", state); 105 | if (state < -721 || state > 721 || state == 0) { 106 | state = 1; // init clock on 12:00 107 | preferences.putShort("state", state); 108 | } 109 | 110 | pinMode(PIN_CH00, OUTPUT); // init GPIO to control clock motor 111 | pinMode(PIN_CH01, OUTPUT); 112 | 113 | display.init(); 114 | display.flipScreenVertically(); 115 | 116 | int initState = touchRead(PIN_INIT); 117 | 118 | 119 | // if init mode is on - state is set to 12:00 and pin must be unplugged when 120 | // slave is displaying this value 121 | if (initState <= TOUCH_THRESHOLD) { 122 | log_i("Clock init mode started"); 123 | display.clear(); 124 | display.setFont(ArialMT_Plain_16); 125 | display.drawStringMaxWidth(0, 0, 128, 126 | "Init mode"); 127 | display.setFont(ArialMT_Plain_10); 128 | 129 | display.drawStringMaxWidth(0, 18, 128, 130 | "Set clock to 12:00 and unplug when ready"); 131 | display.display(); 132 | state = 1; 133 | while (initState > TOUCH_THRESHOLD) { 134 | if (state > 0) { // move clock arrow once a second, save last polarity 135 | state = 1; 136 | digitalWrite(PIN_CH00, HIGH); 137 | digitalWrite(PIN_CH01, LOW); 138 | } else { 139 | state = -1; 140 | digitalWrite(PIN_CH00, LOW); 141 | digitalWrite(PIN_CH01, HIGH); 142 | } 143 | // invert polarity on next run 144 | state = state * -1; 145 | delay(IMPULSE_ON); 146 | digitalWrite(PIN_CH00, LOW); 147 | digitalWrite(PIN_CH01, LOW); 148 | delay(IMPULSE_WAIT); 149 | preferences.putShort("state", state); 150 | initState = touchRead(PIN_INIT); 151 | } 152 | display.clear(); 153 | } 154 | // workaround for the ESP32 SDK bug, see 155 | // https://github.com/espressif/arduino-esp32/issues/2537#issuecomment-508558849 156 | WiFi.config(INADDR_NONE, INADDR_NONE, INADDR_NONE); 157 | // set hostname 158 | WiFi.setHostname(hname); 159 | // connect to wifi 160 | sprintf(console_text, "Connecting to wifi (%s)", WIFI_SSID); 161 | WiFi.begin(WIFI_SSID, WIFI_KEY); 162 | display.clear(); 163 | display.setFont(ArialMT_Plain_10); 164 | display.drawStringMaxWidth(0, 0, 128, 165 | console_text); 166 | display.display(); 167 | while (WiFi.status() != WL_CONNECTED) { 168 | delay(10); 169 | } 170 | log_i("Connected, IP address: %s", WiFi.localIP().toString().c_str()); 171 | 172 | log_i("Starting UDP..."); 173 | udp.begin(localPort); 174 | log_i("Waiting for sync"); 175 | display.drawStringMaxWidth(0, 10, 128, 176 | "Waiting for NTP sync"); 177 | display.display(); 178 | setSyncProvider(getNtpTime); 179 | setSyncInterval(NTP_SYNC_INTERVAL); // sync with NTP 180 | while (timeStatus() == timeNotSet) { 181 | delay(10); 182 | } 183 | oled_activate = now(); 184 | touchAttachInterrupt(PIN_INIT, buttonCallback, TOUCH_THRESHOLD); 185 | display.clear(); 186 | } 187 | 188 | void buttonCallback() { 189 | // disable screensaver if active and update screen 190 | if(now() - oled_activate > SCREENSAVER_TIMER) { 191 | last_t = 0; 192 | oled_activate = now(); 193 | } 194 | } 195 | /*-------- Move arrow and update state ----------*/ 196 | 197 | void fixState(short curr_state) { 198 | char buf[16], buf2[16]; 199 | log_i("changing state from %d [%s] to %d [%s])", state, formatState(abs(state), buf, 16), curr_state, formatState(curr_state, buf2, 16)); 200 | // this should never happens. If clock is behind NTP to up to 5m - do nothing, just wait 201 | if (abs(state) > curr_state && (abs(state) - curr_state) <= 5) { 202 | log_i("Clock is behind NTP for %d minutes, ignoring", (int)(abs(state) - curr_state)); 203 | return; 204 | } 205 | if (state > 0) { 206 | state++; 207 | if (state >= 721) state = 1; 208 | show_impulse = 1; 209 | digitalWrite(PIN_CH00, HIGH); 210 | digitalWrite(PIN_CH01, LOW); 211 | } else { 212 | state--; 213 | if (state <= -721) state = -1; 214 | show_impulse = -1; 215 | digitalWrite(PIN_CH00, LOW); 216 | digitalWrite(PIN_CH01, HIGH); 217 | } 218 | updateScreen(); 219 | // invert polarity on the next run 220 | state = state * -1; 221 | delay(IMPULSE_ON); 222 | digitalWrite(PIN_CH00, LOW); 223 | digitalWrite(PIN_CH01, LOW); 224 | show_impulse = 0; 225 | updateScreen(); 226 | preferences.putShort("state", state); 227 | } 228 | 229 | // convert state variable to the human-readable format 230 | char * formatState(int mystate, char * buf, int bufsize) { 231 | mystate--; 232 | snprintf(buf, bufsize, "%2d:%02d", (mystate / 60) ? mystate / 60 : 12, mystate - (mystate / 60) * 60); 233 | return buf; 234 | } 235 | 236 | void updateScreen() { 237 | char buf[16]; 238 | display.clear(); 239 | time_t utc = now(); 240 | 241 | // screensaver activated 242 | if(utc - oled_activate > SCREENSAVER_TIMER) { 243 | // draw random pixel 244 | display.setPixel(random(display.getWidth()),random(display.getHeight())); 245 | delay(25); 246 | display.setBrightness(100); 247 | display.display(); 248 | return; 249 | } 250 | display.setBrightness(255); 251 | 252 | display.setFont(ArialMT_Plain_10); 253 | String wifi; 254 | if (WiFi.status() != WL_CONNECTED) { 255 | wifi = "wifi: n/a"; 256 | oled_activate = now(); // turn on screen if wifi is n/a 257 | } else { 258 | wifi = "wifi: " + WiFi.SSID(); 259 | } 260 | display.drawString(0, 0, wifi); 261 | 262 | time_t local_t = ClockTZ.toLocal(utc); 263 | // show NTP time 264 | String timenow = String(hour(local_t)) + ":" + twoDigits(minute(local_t)) + ":" + twoDigits(second(local_t)); 265 | display.setFont(ArialMT_Plain_16); 266 | display.drawString(2, 25, timenow); 267 | display.drawLine(75, 0, 75, display.getHeight()); 268 | char * statenow = formatState(abs(state), buf, 16); 269 | // show state of the slave clock 270 | display.drawString(85, 25, statenow); 271 | // show DST if active 272 | if (ClockTZ.locIsDST(local_t)) { 273 | String dst = "DST"; 274 | display.setFont(ArialMT_Plain_10); 275 | display.drawString(2, 50, dst); 276 | } 277 | // show NTP status text if we had any reply in the sync interval*1.5 278 | if (utc - last_ntp_sync < NTP_SYNC_INTERVAL * 1.5) { 279 | String ntp = "NTP"; 280 | display.setFont(ArialMT_Plain_10); 281 | display.drawString(30, 50, ntp); 282 | } else { // turn on screen of NTP is missing 283 | oled_activate = now(); 284 | } 285 | 286 | if (show_impulse) { 287 | if (show_impulse > 0) display.drawXbm(90, 50, 16, 8, polarity_a); 288 | if (show_impulse < 0) display.drawXbm(90, 50, 16, 8, polarity_b); 289 | } 290 | 291 | display.display(); 292 | } 293 | 294 | /*-------- Main loop ----------*/ 295 | void loop() { 296 | time_t utc = now(); 297 | time_t local_t = ClockTZ.toLocal(utc); 298 | int hour_12 = hour(local_t); 299 | if (hour_12 >= 12) hour_12 -= 12; 300 | // current 12h time in minutes, starting from 1 301 | short curr_state = hour_12 * 60 + minute(local_t) + 1; 302 | 303 | if (curr_state != abs(state)) { 304 | fixState(curr_state); 305 | delay(IMPULSE_WAIT); // cool down device :) 306 | } 307 | if (utc != last_t) { // update screen 308 | updateScreen(); 309 | last_t = now(); 310 | } 311 | } 312 | 313 | /*-------- NTP code ----------*/ 314 | 315 | const int NTP_PACKET_SIZE = 48; // NTP time is in the first 48 bytes of message 316 | byte packetBuffer[NTP_PACKET_SIZE]; //buffer to hold incoming & outgoing packets 317 | 318 | time_t getNtpTime() { 319 | IPAddress ntpServerIP; // NTP server's ip address 320 | 321 | while (udp.parsePacket() > 0); // discard any previously received packets 322 | log_i("Transmit NTP Request"); 323 | // get a random server from the pool 324 | WiFi.hostByName(ntpServerName, ntpServerIP); 325 | log_i("%s:%s", ntpServerName, ntpServerIP.toString().c_str()); 326 | sendNTPpacket(ntpServerIP); 327 | uint32_t beginWait = millis(); 328 | while (millis() - beginWait < 1500) { 329 | int size = udp.parsePacket(); 330 | if (size >= NTP_PACKET_SIZE) { 331 | log_i("Receive NTP Response"); 332 | udp.read(packetBuffer, NTP_PACKET_SIZE); // read packet into the buffer 333 | unsigned long secsSince1900; 334 | // convert four bytes starting at location 40 to a long integer 335 | secsSince1900 = (unsigned long) packetBuffer[40] << 24; 336 | secsSince1900 |= (unsigned long) packetBuffer[41] << 16; 337 | secsSince1900 |= (unsigned long) packetBuffer[42] << 8; 338 | secsSince1900 |= (unsigned long) packetBuffer[43]; 339 | last_ntp_sync = secsSince1900 - 2208988800UL; 340 | return secsSince1900 - 2208988800UL; 341 | } 342 | } 343 | log_i("No NTP Response :-("); 344 | return 0; // return 0 if unable to get the time 345 | } 346 | 347 | // send an NTP request to the time server at the given address 348 | void sendNTPpacket(IPAddress & address) { 349 | // set all bytes in the buffer to 0 350 | memset(packetBuffer, 0, NTP_PACKET_SIZE); 351 | // Initialize values needed to form NTP request 352 | // (see URL above for details on the packets) 353 | packetBuffer[0] = 0b11100011; // LI, Version, Mode 354 | packetBuffer[1] = 0; // Stratum, or type of clock 355 | packetBuffer[2] = 6; // Polling Interval 356 | packetBuffer[3] = 0xEC; // Peer Clock Precision 357 | // 8 bytes of zero for Root Delay & Root Dispersion 358 | packetBuffer[12] = 49; 359 | packetBuffer[13] = 0x4E; 360 | packetBuffer[14] = 49; 361 | packetBuffer[15] = 52; 362 | // all NTP fields have been given values, now 363 | // you can send a packet requesting a timestamp: 364 | udp.beginPacket(address, 123); //NTP requests are to port 123 365 | udp.write(packetBuffer, NTP_PACKET_SIZE); 366 | udp.endPacket(); 367 | } 368 | -------------------------------------------------------------------------------- /clock.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samm-git/clock-controller-esp/0add7f49a0758a63999cb71c8ed795da4f72fa51/clock.jpg -------------------------------------------------------------------------------- /images.h: -------------------------------------------------------------------------------- 1 | const uint8_t polarity_a[] = { 2 | 0x0F, 0x0F, 0x0F, 0x0F, 0x08, 0x01, 0x08, 0x01, 0x08, 0x01, 0x08, 0x01, 3 | 0xF8, 0x01, 0xF8, 0x01, 4 | }; 5 | 6 | const uint8_t polarity_b[] = { 7 | 0xF8, 0x01, 0xF8, 0x01, 0x08, 0x01, 0x08, 0x01, 0x08, 0x01, 0x08, 0x01, 8 | 0x0F, 0x0F, 0x0F, 0x0F, 9 | }; --------------------------------------------------------------------------------