xexus.dev

xexus.dev
LCD Clock with Buttons

xexus.nexus

This simple clock runs on a Raspberry Pi Pico-W, displaying time on a 20x4 LCD Module fitted with an I2C backpack interface. The clock includes a row of four programmable buttons on the side, and runs on standard USB 5V power. The container is an old puzzle box, poorly decorated with a fabric ribbon to mask my less than stellar wood cutting skills.

Parts

The links below go to the sites where I ordered the parts for this project, though you can find these parts at various websites as well.

Project Notes

The Raspberry Pi Pico board features a dual core RP2040 processor. The timer running the clock runs on one core thread, while communication with other devies runs simultaneously in a seperate thread on the other core. Originally the timer ran on the first thread, however the clock would fall behind waiting for a reply from other devices. After working with CodeGPT (powered by ChatGPT), I moved the communication functions to the second thread and it's been keeping good time since.

The buttons connect to General Purpose Input/Output (GPIO) pins programmed for input signals programmed to detect when the signal drops from high to low. These buttons are intended for testing, but can be repurposed if needed.

  • One button manually resets the device
  • Another button sends a new query to refresh the time manually.
  • A third button invokes a function to send a command via http to manually activate a chime on a different clock (see my Clock with Chime example)
  • The last button invokes a function to send a command via http to manually activate a motor that turn the head of a doll (see The Doll).

The MicroPython code

  • The secrets.py import contains three lines to store my Access Point's SSID, Password, and Static IP:
    SSID = "My_SSID"
    PW = "My_Password"
    StaticIP = "My_Static_IP_Address"
  • When the Wifi module attempts to connect, it should connect on the first try. However if the device was already connected prior to rebooting, it may need a second attempt before the router accepts the connection. My code automatically reboots if it doesn't successfully connect after 15 seconds.
  • While the clock timer is running, an infinite loop listens for the button presses. The KeyboardInterrupt exception it there to stop the clock before making additional edits to the code. Thonny is a popular IDE for working with MicroPython, however it does not allow making changes while the code (the timer function in this case) is running.
  • As the time advances, new characters overwrites the old characters as needed on the LCD Display. Days of the week include spaces to ensure no residual characters are left over when a shorter day name replaces a longer one. The lcd.clear() command overwrites all 80 characters with blank spaces, clearing the screen before adding new text.

Future Revisions

Originally I used this as a master clock to control the Doll and the Chime functions remotely. Later I added the timers to both remote devices, and only used the buttons for testing. With a little reprogramming and the addition of a buzzer, the buttons could be repurposed to set one or more alarms or activate other remote devices. With calls to additional API sites, the display could show additional information like weather or reminders.


# built-in imports
from machine import I2C, Pin, Timer  # Used to control I2C, GPIO pins, and hardware timers
import _thread  # Enables multithreading for non-blocking operations
import utime  # Provides time-related functions

# additional downloaded imports
from lcd_api import LcdApi  # API for controlling the LCD display
from pico_i2c_lcd import I2cLcd  # LCD driver for I2C LCDs

# wifi related built-in imports
import network  # Handles WiFi connections
import secrets  # Custom secrets file for storing WiFi credentials
import urequests  # Allows HTTP requests for API calls
import socket  # Socket operations for network communication

# I2C details for the LCD display (I2C Address, rows, and columns)
I2C_ADDR = 0x3F
I2C_NUM_ROWS = 4
I2C_NUM_COLS = 20

# I2C initialization for communication with the LCD
i2c = I2C(1, sda=Pin(26), scl=Pin(27), freq=400000)
lcd = I2cLcd(i2c, I2C_ADDR, I2C_NUM_ROWS, I2C_NUM_COLS)

# Timer initialization for periodic updates
timer = Timer()

# Define a state dictionary to hold mutable values for the clock and LCD state
state = {
    'time_stamp': 0,  # Timestamp used for clock counting
    'hourAP': 0,  # Tracks whether the hour is AM or PM
    'zonemod': '',  # Timezone abbreviation (e.g., 'EST', 'PST')
    'lcd_update': True  # Flag to control whether the LCD is updated
}

# WiFi credentials from the secrets.py file
SSID = secrets.SSID
PW = secrets.PW

# Setup for buttons, each pin is pulled up internally
button_list = [18, 19, 20, 21]
buttons = [Pin(v, Pin.IN, Pin.PULL_UP) for v in button_list]

# Function to overwrite all four lines of the 20x4 LCD display
def redraw_display(text0, text1, text2, text3):
    lcd.clear()  # Clear the display
    lcd.move_to(0, 0)  # Move cursor to first line
    lcd.putstr(text0)  # Print first line of text
    lcd.move_to(0, 1)  # Move cursor to second line
    lcd.putstr(text1)  # Print second line of text
    lcd.move_to(0, 2)  # Move cursor to third line
    lcd.putstr(text2)  # Print third line of text
    lcd.move_to(0, 3)  # Move cursor to fourth line
    lcd.putstr(text3)  # Print fourth line of text
    utime.sleep(1)     # Wait for 1 second for the user to see the display update

# Initializing the LCD display with a message
redraw_display("-"*20, "Initializing...", "Please Wait", "-"*20)

# Create a WLAN object for WiFi connection
wlan = network.WLAN(network.STA_IF)
wlan.active(True)  # Activate the WLAN interface

# Connect to WiFi and provide feedback on the LCD display
wlan.connect(ssid=SSID, key=PW)
print('Waiting for connection')
redraw_display("-"*20, "Connecting to:", SSID, "-"*20)

# Keep attempting to connect to WiFi, and display progress
busy_meter = 0
while not wlan.isconnected():
    lcd.move_to(0, 2)
    lcd.putstr("*" * busy_meter)  # Show progress on LCD by printing stars
    busy_meter += 1
    utime.sleep(0.5)  # Sleep for 0.5 seconds between retries
    if busy_meter == 16: machine.reset()  # Reset the device if not connected after 16 attempts

# After successful connection, show IP address and connected status
status = wlan.ifconfig()
print('Connection to', SSID, 'successfully established!')
print('IP-address: ' + status[0])
redraw_display("-"*20, "Connected to:", SSID, "-"*20)

# Function to get time from World Time API
def get_time():
    print("\n\n2. Querying the current time:")
    r = urequests.get("http://worldtimeapi.org/api/timezone/America/New_York")  # API call to get time
    # Parse the date and time fields from the API response
    rt_year, rt_month, rt_day, rt_hour, rt_mins, rt_secs = (
        int(r.json()['datetime'][0:4]), int(r.json()['datetime'][5:7]), 
        int(r.json()['datetime'][8:10]), int(r.json()['datetime'][11:13]), 
        int(r.json()['datetime'][14:16]), int(r.json()['datetime'][17:19])
    )
    zonemod = r.json()['abbreviation']  # Get timezone abbreviation
    # Create a tuple of the current time data
    rt_time_tuple = (rt_year, rt_month, rt_day, rt_hour, rt_mins, rt_secs, int(r.json()['day_of_week']), int(r.json()['day_of_year']))
    print("Initial rt_time_tuple", rt_time_tuple)
    print("=====")
    return utime.mktime(rt_time_tuple), zonemod  # Return timestamp and timezone

# Function to update the clock display
def clock(state):
    time = utime.localtime(state['time_stamp'])  # Get current time from the timestamp

    # Determine AM/PM for the clock
    if time[3] in [0, 12]: 
        state['hourAP'] = 12
    else: 
        state['hourAP'] = time[3] % 12
    AMPM = "PM" if time[3] >= 12 else "AM"  # AM or PM determination

    # Update the LCD if the flag is set
    if state['lcd_update']:
        lcd.move_to(0, 1)
        lcd.putstr(dotw[time[6]])  # Display the day of the week
        lcd.move_to(10, 1)
        lcd.putstr(f"{time[0]:04d}/{time[1]:02d}/{time[2]:02d}")  # Display the date
        lcd.move_to(0, 2)
        lcd.putstr(f"{state['hourAP']:02d}:{time[4]:02d}:{time[5]:02d} {AMPM} ({state['zonemod']})")  # Display the time

    # Increment the timestamp for the next second
    state['time_stamp'] += 1

    # Trigger actions at specific times (e.g., hourly or on the hour)
    if ((time[3] == 0) and (time[4] == 0) and (time[5] == 0)):
        send_to_chime(state['hourAP'])  # Hourly chime
        send_to_doll("Activate")  # Example external action
    elif (time[4] == 0) and (time[5] == 0):  # Sync time every hour
        send_to_chime(state['hourAP'])
        state['time_stamp'], state['zonemod'] = get_time()

# Timer setup to periodically call the `clock()` function
def update_clock_callback(timer_instance):
    #print("Timer callback triggered")  # Log when the callback is triggered
    clock(state)  # Call the clock function with the state

# Initialize the timer
timer.init(freq=1, mode=Timer.PERIODIC, callback=update_clock_callback)  # Timer ticks every 1 second

# Threaded function to send a command to a "doll" system
def send_to_doll(command):
    _thread.start_new_thread(_send_to_doll_thread, (command,))  # Start a new thread for the task

def _send_to_doll_thread(command):
    try:
        doll_url = "http://10.3.107.3:8080"  # Example URL for the command
        data = {'command': command}  # Payload for the POST request
        response = urequests.post(doll_url, json=data)  # Send the request
        print("Sending to doll...")
        if response.status_code == 200:
            print("Command sent successfully.")
        else:
            print("Failed to send command.")
    except Exception as e:
        print(f"Doll Error: {e}")  # Log the specific error
        pass # Ensure the code continues execution

# Threaded function to send a chime signal
def send_to_chime(hourAP):
    _thread.start_new_thread(_send_to_chime_thread, (hourAP,))  # Start a new thread for the task

def _send_to_chime_thread(hourAP):
    try:
        chime_url = "http://10.3.107.4:8080"  # Example URL for the chime
        data = {'command': hourAP}  # Payload for the POST request
        response = urequests.post(chime_url, json=data)
        print("Sending to chime...")
        if response.status_code == 200:
            print("hourAP sent successfully.")
        else:
            print("Failed to send hourAP.")
    except Exception as e:
        print(f"Chime Error: {e}")  # Log the specific error
        pass # Ensure the code continues execution

# Initialize the LCD and fetch the initial time
lcd.clear()
dotw = ["Monday   ", "Tuesday  ", "Wednesday", "Thursday ", "Friday   ", "Saturday ", "Sunday   "]  # List of days with spaces to make each day 9 characters long
state['time_stamp'], state['zonemod'] = get_time()  # Get the initial timestamp and timezone

# Main loop to handle button presses
action = ""
print("Starting main loop")  # Log the start of the main loop

while True:
    try:
        # Button 0: Reboot the system
        if buttons[0].value() == 0:
            print("Reboot button pressed")  # Log the button press
            timer.deinit()  # Stop the timer
            lcd.clear()  # Clear the display
            redraw_display("="*20, "Rebooting...", "(maybe twice)", "="*20)
            utime.sleep(1.5)
            machine.reset()  # Reset the device

        # Button 1: Resync time with the API
        if buttons[1].value() == 0:
            print("Resync button pressed")  # Log the button press
            state['lcd_update'] = False  # Stop LCD updates
            redraw_display("-"*20, "Resyncing...", "(maybe twice)", "-"*20)
            state['time_stamp'], state['zonemod'] = get_time()  # Resync time
            lcd.clear()
            state['lcd_update'] = True  # Resume LCD updates

        # Button 2: Send to Chime
        if buttons[2].value() == 0:
            print("Button 2 pressed: Activating Chime")  # Log the button press
            state['lcd_update'] = False
            send_to_chime(hourAP)
            redraw_display(" "*20, "Activating Chime", " "*20, " "*20)
            utime.sleep(2)
            lcd.clear()
            state['lcd_update'] = True

        # Button 3: Send to Doll
        if buttons[3].value() == 0:
            print("Button 3 pressed: Activating Doll")  # Log the button press
            state['lcd_update'] = False
            send_to_doll("Activate")
            redraw_display(" "*20, "Activating Doll", " "*20, " "*20)
            utime.sleep(2)
            lcd.clear()
            state['lcd_update'] = True

    except KeyboardInterrupt:
        # Stop the timer and display a message when the program is interrupted
        print("Program interrupted, stopping the timer")  # Log the interruption
        timer.deinit()
        redraw_display("-"*20, "Clock Stopped", " "*20, "-"*20)