//-------------------------------------------- // Nixie IN-1 Clock // RSP 2021.12.06 // Target: Pro Mini 5V/16M // + GPS module // + (3) dual IN-1 backpack modules // + rotary encoder // + motion sensor // // User interface // Hybrid 12/24 hour clock, GPS synchronized // Punctuation (colons) illuminate when GPS locked // Spin rotary encoder for diagnostic display (all same digits). // Press rotary encoder to enter time setting mode, spin to change UTC offset. // Motion sensor activates clock for period of time (30 min) // or disconnect motion sensor input for always-on display. // Auto-sleep after 30 minutes. // Auto cathode cleaning every hour of continuous operation. //-------------------------------------------- //#define DBG(str) Serial.println(F(str)); #define DBG(str) #define FADE_EFFECT true // digit change fade vs. instant #define MOTION_PIN 3 // motion sensor // rotary encoder #define ENCODER_A_PIN 5 // PD5 #define ENCODER_B_PIN 6 // PD6 #define ENCODER_SW_PIN 7 // PD7 // nixie tubes #define PUNC_EN_PIN 4 // enable punctuation lamps // display alternates 12/24H displays every second // AM/PM 24H // 12:00 00:00 midnight // 1:00 01:00 // 2:00 02:00 // 3:00 03:00 // 4:00 04:00 // 5:00 05:00 // 6:00 06:00 // 7:00 07:00 // 8:00 08:00 // 9:00 09:00 // 10:00 10:00 // 11:00 11:00 // 12:00 12:00 noon // 1:00 13:00 // 2:00 14:00 // 3:00 15:00 // 4:00 16:00 // 5:00 17:00 // 6:00 18:00 // 7:00 19:00 // 8:00 20:00 // 9:00 21:00 // 10:00 22:00 // 11:00 23:00 const uint8_t time_table[24] = { 12,1,2,3,4,5,6,7,8,9,10,11,12,1,2,3,4,5,6,7,8,9,10,11 }; #define BROADCAST_INSTANT '@' // all displays, instant update #define BROADCAST_FADE '*' // all displays, fade //------------------------------------------------------------ // GPS Neo-6M #include "gps.h" // brings in time.h gps GPS; //------------------------------------------------------------ // synthesized time (GPS + backup) time utc_time; //------------------------------------------------------------- // settings in EEPROM // the only setting is the UTC offset #include struct { int8_t utc_offset; // -12..14 } ee; //--------------------------------------------------------- // ENC11 rotary encoder with button press #define BUTTON_DEBOUNCE_MS 50 // contact debounce time, milliseconds #define MIN_SPIN -128 // max count limits, #define MAX_SPIN 127 // not a factor if you read the spin often enough // state information is owned by interrupt, use checkButton() and getDial() to access volatile uint8_t button_event = 0; // button pressed event struct encoder_t // encoder state information { uint8_t state; // packed bits: B7=AB convention override, B2=button, B1=B, B0=A uint8_t btn_debounce; // 1ms button debounce ticks left volatile int8_t spin; // rotary encoder movement since last read uint8_t ticks; // 1ms ticks since last encoder movement } encoder; ISR (TIMER0_COMPA_vect) // 1msec interrupt { // read the encoder // b5 = A // b6 = B // b7 = SW byte encoderPBA = PIND >> 5; // A/B states that will be used to detect spin #define ENCODER_CCW_BA 0b01 #define ENCODER_Z_BA 0b11 // detent #define ENCODER_CW_BA 0b10 // pick a default convention for A/B (rotary encoder model and A/B wiring) #define ENCODER_CONVENTION 0b00 // 0b00 or 0b11 // make a convenient pointer to encoder state machine data encoder_t *e = &encoder; byte BA = encoderPBA & 0b11; // mask to BA input pin bits // basic spin decoding // detect edge transitions and increment the spin counter appropriately if ((e->state & 0b11) == ENCODER_Z_BA && BA == (ENCODER_CCW_BA ^ ENCODER_CONVENTION) ) if (e->spin > MIN_SPIN) e->spin--; if ((e->state & 0b11) == ENCODER_Z_BA && BA == (ENCODER_CW_BA ^ ENCODER_CONVENTION) ) if (e->spin < MAX_SPIN) e->spin++; e->state = (e->state & 0b11111100) | BA; // update BA bits, don't change button state bit // debounce button if (encoderPBA & 0b100) // mask to button input pin, negative logic (0 is pressed) { // button is released if (e->btn_debounce) // in release debounce, count down debounce ticks if (--e->btn_debounce == 0) e->state &= ~0b100; // set button state 0 (released) } else { // button is pressed, trigger press event if (e->btn_debounce == 0) // if not already pressed { e->state |= 0b100; // set button state 1 (pressed) button_event = 1; // set button press event } e->btn_debounce = BUTTON_DEBOUNCE_MS; // [re]start debounce timer } } // Get dial spin int8_t getDial() // returns +/- increments since last call { noInterrupts(); // prevent interrupt interference while we fetch the data from storage int8_t result = encoder.spin; encoder.spin = 0; interrupts(); return result; } // Get button press uint8_t checkButton() // returns zero if nothing was pressed { noInterrupts(); // prevent interrupt interference while we fetch the data from storage uint8_t result = button_event; button_event = 0; interrupts(); return result; } //-------------------------------------------- // helpers void sendDP( char id, int8_t dp, boolean lead_zero = true ) { // send digit pair Serial.write(id); if (dp == -1) Serial.write("--"); // blank else { if (dp < 10) Serial.write(lead_zero ? '0':'-'); Serial.print(dp); } } void sendClok( int8_t hh, int8_t mm, int8_t ss, boolean lead_zero = true ) { // lowercase is fade, uppercase is instant update sendDP( FADE_EFFECT ? 'h':'H',hh, lead_zero ); sendDP( FADE_EFFECT ? 'm':'M',mm ); sendDP( FADE_EFFECT ? 's':'S',ss ); DBG("") // LFCR for debugging on console } boolean updateClock() // listen to GPS serial data, synthesize time if necessary { // returns true if time was changed (GPS update or backup second tick) static boolean los = true; // no GPS time for at least 1 second static uint32_t backup_tm = 0; // backup timebase static uint32_t sec_tm = 0; // 1 second tick failsafe uint32_t now_tm = millis(); boolean time_changed = false; time t; if (GPS.listen( & t )) // got a new time update from GPS ? { DBG("GPX TICK") // in sync with RTC los = false; sec_tm = backup_tm = now_tm; memcpy( & utc_time, & t, sizeof(time) ); // update display at 1 sec rate time_changed = true; } // processor fOsc is backup timebase during GPS LOS if (los |= (now_tm - sec_tm > 1100UL)) while (now_tm - backup_tm > 1000UL) { DBG("BACKUP TICK") backup_tm += 1000UL; time_changed = true; // advance clock 1 second utc_time.ss++; if (utc_time.ss > 59) { utc_time.ss = 0; utc_time.mm++; if (utc_time.mm > 59) { utc_time.mm = 0; utc_time.hh++; if (utc_time.hh > 23) utc_time.hh = 0; } } } return time_changed; } boolean runClock( uint32_t ms, boolean abortable = false ) // returns true if aborted { for (uint32_t tm = millis(); millis() - tm < ms; ) { updateClock(); if (abortable) if (digitalRead( MOTION_PIN )) return true; } return false; } boolean cleanCathodes(boolean abortable = false) // cathode de-poisoning, 10 second cycle { // returns false if aborted (motion detected) DBG("cleanCathodes") // cycle all digits at the same time for (int8_t n=9; n >= 0; n--) { Serial.write(BROADCAST_INSTANT); Serial.write(n+'0'); Serial.write(n+'0'); if (runClock(1000, abortable)) return false; // keep time up-to-date while running clean } return true; } //-------------------------------------------- // program start void setup() { pinMode(PUNC_EN_PIN, OUTPUT); digitalWrite(PUNC_EN_PIN,HIGH); pinMode(ENCODER_A_PIN, INPUT_PULLUP); pinMode(ENCODER_B_PIN, INPUT_PULLUP); pinMode(ENCODER_SW_PIN,INPUT_PULLUP); pinMode(MOTION_PIN, INPUT_PULLUP); // default unused pins pinMode( 2, INPUT_PULLUP); pinMode(10, INPUT_PULLUP); pinMode(12, INPUT_PULLUP); pinMode(13, INPUT_PULLUP); pinMode(14, INPUT_PULLUP); pinMode(15, INPUT_PULLUP); pinMode(16, INPUT_PULLUP); pinMode(17, INPUT_PULLUP); pinMode(A4, INPUT_PULLUP); pinMode(A5, INPUT_PULLUP); // Neo-6M on Serial using receive only // Dual Nixie backpacks on Serial using transmit only Serial.begin(9600); DBG("BEGIN") // load settings from EEPROM EEPROM.get( 0, ee ); if (ee.utc_offset < -14 || ee.utc_offset > 12) ee.utc_offset = 0; // initialize nixie display delay(100); // give the backpacks time to power up Serial.write(" "); // clear channel for (int8_t i=9; i >= 0; i--) { Serial.write(BROADCAST_INSTANT); Serial.write(i+'0'); Serial.write(i+'0'); delay(500); } Serial.write(BROADCAST_FADE); Serial.write("--"); // fade blank all displays delay(2000); // dramatic pause // set up 1ms interrupt for rotary encoder // hook timer0 (1 ms interrupt) by adding interrupt-on-compare-match OCR0A = 0x80; // set compare match register for middle of count TIMSK0 |= _BV(OCIE0A); // enable compare match interrupt } //-------------------------------------------- // bigloop void loop() { #define MODE_TIME 0 // mode_tm = sleep timeout #define MODE_SET 1 // mode_tm = automatic mode exit #define MODE_DIAG 2 // mode_tm = automatic mode exit #define MODE_SLEEP 3 static uint8_t mode = MODE_TIME; static uint32_t mode_tm = millis(); static boolean save_armed = false; // automatically resume time display after 30 seconds #define DIAG_TIMEOUT_MS 30000UL static uint8_t diag_value = 0; // automatically save settings after inactivity #define SETTING_TIMEOUT_MS 5000UL // auto-sleep after 30 minutes #define SLEEP_TIMEOUT_MS (30UL*60000UL) // auto-clean at top of hour after 60 minutes of operation static uint32_t poison_secs = 0; boolean top_of_hour = false; // display update pending static boolean update_nixies; // display on startup // blank display until GPS time sync static boolean got_time = false; uint32_t now_tm = millis(); //-- sync with GPS time (watch for 1 second tick) update_nixies = updateClock(); got_time |= (update_nixies && GPS.status == GPS_LOCKED); // time is good, enable display // accumulate on-time for cleaning if (update_nixies && mode == MODE_TIME) { poison_secs++; // perform clean during last 10 seconds of the hour top_of_hour = (utc_time.mm == 59 && utc_time.ss == 49); } //-- watch motion sensor // level triggered // as long as PIR sensor is triggered (or disconnected) display remains on // total on time is PIR on time plus sleep timeout time if (digitalRead( MOTION_PIN )) { // wake up //DBG("MOTION"); if (mode == MODE_SLEEP) { mode = MODE_TIME; update_nixies = true; } mode_tm = now_tm; // [re]start sleep timer } //-- watch for rotary encoder input if (int8_t spin = getDial()) // encoder spun ? { // change value DBG("SPIN"); switch (mode) { case MODE_TIME: // switch to diag mode case MODE_SLEEP: // wake up mode = MODE_DIAG; break; case MODE_DIAG: // change diagnostic diag_value = (diag_value+11+spin) % 11; // 0..9, plus 10=blank break; case MODE_SET: // change UTC offset if (spin < 0) if (ee.utc_offset > -12) ee.utc_offset--; if (spin > 0) if (ee.utc_offset < 14) ee.utc_offset++; save_armed = true; } mode_tm = now_tm; // keep mode alive update_nixies = true; } if (checkButton()) // encoder pressed ? { DBG("BUTTON"); switch (mode) { case MODE_TIME: // switch to set mode if (got_time) mode = MODE_SET; // only allowed when time is valid break; case MODE_SLEEP: // wake up mode = MODE_TIME; break; case MODE_DIAG: // exit diagnostic mode mode = MODE_TIME; break; case MODE_SET: // exit set mode if (save_armed) { save_armed = false; EEPROM.put( 0, ee ); DBG("EE WRITTEN") } mode = MODE_TIME; } mode_tm = now_tm; // keep mode alive } //-- run timers switch (mode) { case MODE_TIME: // sleep timer if (!got_time) mode_tm = now_tm; // don't sleep while looking for GPS signal if (now_tm - mode_tm > SLEEP_TIMEOUT_MS) { // go to sleep if (cleanCathodes(true)) // run cathode de-poisoning (abortable) { mode = MODE_SLEEP; poison_secs = 0; } mode_tm = now_tm; update_nixies = true; // blank or show time } break; case MODE_DIAG: // mode end timer if (now_tm - mode_tm > DIAG_TIMEOUT_MS) { mode = MODE_TIME; update_nixies = true; // show time } break; case MODE_SET: // mode end timer if (now_tm - mode_tm > SETTING_TIMEOUT_MS) { if (save_armed) { save_armed = false; EEPROM.put( 0, ee ); DBG("EE WRITTEN") } mode = MODE_TIME; update_nixies = true; // show time } } //-- auto clean cathodes every hour of continuous operation if (top_of_hour) if (poison_secs >= 55*60) // 55 minutes minimum if (mode == MODE_TIME) { cleanCathodes(); // 5 seconds, not abortable poison_secs = 0; update_nixies = true; // show time } //-- update nixies if (update_nixies) { update_nixies = false; switch (mode) { case MODE_TIME: // show time case MODE_SET: // hours only if (got_time) { // adjust UTC to local time time local_time; memcpy( & local_time, & utc_time, sizeof(time) ); int8_t n = local_time.hh + ee.utc_offset; if (n > 23) local_time.hh = n-24; else if (n < 0) local_time.hh = n + 24; else local_time.hh = n; sendClok( (local_time.ss & 1) ? time_table[local_time.hh] : local_time.hh, (mode == MODE_SET) ? -1 : local_time.mm, (mode == MODE_SET) ? -1 : local_time.ss, (local_time.ss & 2) == 0 ); } else { Serial.write(BROADCAST_INSTANT); Serial.write("--"); } // blank all displays while time is invalid break; case MODE_DIAG: // show diagnostic value in all digits Serial.write(BROADCAST_INSTANT); Serial.write(diag_value+'0'); Serial.write(diag_value+'0'); break; case MODE_SLEEP: Serial.write(BROADCAST_FADE); Serial.write("--"); // blank all displays } } // colons if (mode == MODE_DIAG) // toggle punctuation in diagnostic mode digitalWrite( PUNC_EN_PIN, (diag_value & 1) == 0 ); else // punctuation on when GPS is locked digitalWrite( PUNC_EN_PIN, (GPS.status == GPS_LOCKED) && (mode != MODE_SLEEP) ); }