Binary Clock / Weather Monitor

YouTube Video

Introduction

Welcome to this tutorial on creating a Binary Clock and Weather monitor using the Raspberry Pi Pico W. This tutorial is broken into two parts. The first part will focus on building a prototype on breadboard and second part will focus on creating a custom PCB with a 3D printed enclosure. The project will use the openweater.org API and worldtime clock API to collect the time and weather data.

Components - Prototype

The following is the list of components needed:

ComponentQuantity
Raspberry Pi Pico W1
Micro USB Cable1
Breadboard1
WiresSeveral
Resistor8 (330Ω)
Resistor5 (1KΩ)
LED Red8
LED Blue5
OLED13661

Schematic Diagram

The Fritzing diagram is shown below, since we have limited space on the breadboard we will just display hours and minutes in binary.

An important note when making the connections check the pins on the OLED display as some modules the GND and VCC pins are changed.

Code

You are going to need 4 files for this project

  • config.json: In this file we will store all our private information, Wifi-Password, API Key and our city and country code.
  • ssd1306.py: The library for controlling the ssd1306 OLED display
  • urequests.py: Connecting the Pico W to the internet to use API to collect data
  • main.py: The main program that will run on boot when the Pico is powered.

You can download the code using the following link, or copy it from this webpage below.

json

{
    "ssid": "Open_Internet",
    "ssid_password": "25802580",
    "query_interval_sec": 120,
    "weather_api_key": "ce54c4b03cfc0bd0fc037188def2d98e",
    "city": "Qingdao"
    "country_code": "CN"
}

ssd1306.py

# MicroPython SSD1306 OLED driver, I2C and SPI interfaces

from micropython import const
import framebuf

# register definitions
SET_CONTRAST = const(0x81)
SET_ENTIRE_ON = const(0xA4)
SET_NORM_INV = const(0xA6)
SET_DISP = const(0xAE)
SET_MEM_ADDR = const(0x20)
SET_COL_ADDR = const(0x21)
SET_PAGE_ADDR = const(0x22)
SET_DISP_START_LINE = const(0x40)
SET_SEG_REMAP = const(0xA0)
SET_MUX_RATIO = const(0xA8)
SET_IREF_SELECT = const(0xAD)
SET_COM_OUT_DIR = const(0xC0)
SET_DISP_OFFSET = const(0xD3)
SET_COM_PIN_CFG = const(0xDA)
SET_DISP_CLK_DIV = const(0xD5)
SET_PRECHARGE = const(0xD9)
SET_VCOM_DESEL = const(0xDB)
SET_CHARGE_PUMP = const(0x8D)

# Subclassing FrameBuffer provides support for graphics primitives
# http://docs.micropython.org/en/latest/pyboard/library/framebuf.html
class SSD1306(framebuf.FrameBuffer):
    def __init__(self, width, height, external_vcc):
        self.width = width
        self.height = height
        self.external_vcc = external_vcc
        self.pages = self.height // 8
        self.buffer = bytearray(self.pages * self.width)
        super().__init__(self.buffer, self.width, self.height, framebuf.MONO_VLSB)
        self.init_display()

    def init_display(self):
        for cmd in (
            SET_DISP,  # display off
            # address setting
            SET_MEM_ADDR,
            0x00,  # horizontal
            # resolution and layout
            SET_DISP_START_LINE,  # start at line 0
            SET_SEG_REMAP | 0x01,  # column addr 127 mapped to SEG0
            SET_MUX_RATIO,
            self.height - 1,
            SET_COM_OUT_DIR | 0x08,  # scan from COM[N] to COM0
            SET_DISP_OFFSET,
            0x00,
            SET_COM_PIN_CFG,
            0x02 if self.width > 2 * self.height else 0x12,
            # timing and driving scheme
            SET_DISP_CLK_DIV,
            0x80,
            SET_PRECHARGE,
            0x22 if self.external_vcc else 0xF1,
            SET_VCOM_DESEL,
            0x30,  # 0.83*Vcc
            # display
            SET_CONTRAST,
            0xFF,  # maximum
            SET_ENTIRE_ON,  # output follows RAM contents
            SET_NORM_INV,  # not inverted
            SET_IREF_SELECT,
            0x30,  # enable internal IREF during display on
            # charge pump
            SET_CHARGE_PUMP,
            0x10 if self.external_vcc else 0x14,
            SET_DISP | 0x01,  # display on
        ):  # on
            self.write_cmd(cmd)
        self.fill(0)
        self.show()

    def poweroff(self):
        self.write_cmd(SET_DISP)

    def poweron(self):
        self.write_cmd(SET_DISP | 0x01)

    def contrast(self, contrast):
        self.write_cmd(SET_CONTRAST)
        self.write_cmd(contrast)

    def invert(self, invert):
        self.write_cmd(SET_NORM_INV | (invert & 1))

    def rotate(self, rotate):
        self.write_cmd(SET_COM_OUT_DIR | ((rotate & 1) << 3))
        self.write_cmd(SET_SEG_REMAP | (rotate & 1))

    def show(self):
        x0 = 0
        x1 = self.width - 1
        if self.width != 128:
            # narrow displays use centred columns
            col_offset = (128 - self.width) // 2
            x0 += col_offset
            x1 += col_offset
        self.write_cmd(SET_COL_ADDR)
        self.write_cmd(x0)
        self.write_cmd(x1)
        self.write_cmd(SET_PAGE_ADDR)
        self.write_cmd(0)
        self.write_cmd(self.pages - 1)
        self.write_data(self.buffer)


class SSD1306_I2C(SSD1306):
    def __init__(self, width, height, i2c, addr=0x3C, external_vcc=False):
        self.i2c = i2c
        self.addr = addr
        self.temp = bytearray(2)
        self.write_list = [b"\x40", None]  # Co=0, D/C#=1
        super().__init__(width, height, external_vcc)

    def write_cmd(self, cmd):
        self.temp[0] = 0x80  # Co=1, D/C#=0
        self.temp[1] = cmd
        self.i2c.writeto(self.addr, self.temp)

    def write_data(self, buf):
        self.write_list[1] = buf
        self.i2c.writevto(self.addr, self.write_list)


class SSD1306_SPI(SSD1306):
    def __init__(self, width, height, spi, dc, res, cs, external_vcc=False):
        self.rate = 10 * 1024 * 1024
        dc.init(dc.OUT, value=0)
        res.init(res.OUT, value=0)
        cs.init(cs.OUT, value=1)
        self.spi = spi
        self.dc = dc
        self.res = res
        self.cs = cs
        import time

        self.res(1)
        time.sleep_ms(1)
        self.res(0)
        time.sleep_ms(10)
        self.res(1)
        super().__init__(width, height, external_vcc)

    def write_cmd(self, cmd):
        self.spi.init(baudrate=self.rate, polarity=0, phase=0)
        self.cs(1)
        self.dc(0)
        self.cs(0)
        self.spi.write(bytearray([cmd]))
        self.cs(1)

    def write_data(self, buf):
        self.spi.init(baudrate=self.rate, polarity=0, phase=0)
        self.cs(1)
        self.dc(1)
        self.cs(0)
        self.spi.write(buf)
        self.cs(1)

urequests.py

import usocket

class Response:

    def __init__(self, f):
        self.raw = f
        self.encoding = "utf-8"
        self._cached = None

    def close(self):
        if self.raw:
            self.raw.close()
            self.raw = None
        self._cached = None

    @property
    def content(self):
        if self._cached is None:
            try:
                self._cached = self.raw.read()
            finally:
                self.raw.close()
                self.raw = None
        return self._cached

    @property
    def text(self):
        return str(self.content, self.encoding)

    def json(self):
        import ujson
        return ujson.loads(self.content)


def request(method, url, data=None, json=None, headers={}, stream=None):
    try:
        proto, dummy, host, path = url.split("/", 3)
    except ValueError:
        proto, dummy, host = url.split("/", 2)
        path = ""
    if proto == "http:":
        port = 80
    elif proto == "https:":
        import ussl
        port = 443
    else:
        raise ValueError("Unsupported protocol: " + proto)

    if ":" in host:
        host, port = host.split(":", 1)
        port = int(port)

    ai = usocket.getaddrinfo(host, port, 0, usocket.SOCK_STREAM)

    try:
        ai = ai[0]
    except:
        print("Count not resolve getaddrinfo for {} {}".format(host,port)) 

    s = usocket.socket(ai[0], ai[1], ai[2])
    try:
        s.connect(ai[-1])
        if proto == "https:":
            s = ussl.wrap_socket(s, server_hostname=host)
        s.write(b"%s /%s HTTP/1.0\r\n" % (method, path))
        if not "Host" in headers:
            s.write(b"Host: %s\r\n" % host)
        # Iterate over keys to avoid tuple alloc
        for k in headers:
            s.write(k)
            s.write(b": ")
            s.write(headers[k])
            s.write(b"\r\n")
        if json is not None:
            assert data is None
            import ujson
            data = ujson.dumps(json)
            s.write(b"Content-Type: application/json\r\n")
        if data:
            s.write(b"Content-Length: %d\r\n" % len(data))
        s.write(b"\r\n")
        if data:
            s.write(data)

        l = s.readline()
        #print(l)
        l = l.split(None, 2)
        status = int(l[1])
        reason = ""
        if len(l) > 2:
            reason = l[2].rstrip()
        while True:
            l = s.readline()
            if not l or l == b"\r\n":
                break
            #print(l)
            if l.startswith(b"Transfer-Encoding:"):
                if b"chunked" in l:
                    raise ValueError("Unsupported " + l)
            elif l.startswith(b"Location:") and not 200 <= status <= 299:
                raise NotImplementedError("Redirects not yet supported")
    except OSError:
        s.close()
        raise

    resp = Response(s)
    resp.status_code = status
    resp.reason = reason
    return resp


def head(url, **kw):
    return request("HEAD", url, **kw)

def get(url, **kw):
    return request("GET", url, **kw)

def post(url, **kw):
    return request("POST", url, **kw)

def put(url, **kw):
    return request("PUT", url, **kw)

def patch(url, **kw):
    return request("PATCH", url, **kw)

def delete(url, **kw):
    return request("DELETE", url, **kw)

main.py

import utime
from machine import Pin, I2C, RTC
from ssd1306 import SSD1306_I2C
import urequests
import network
import json
import time



# Load configuration
with open('config.json') as f:
    config = json.load(f)

# Check config.json has updated credentials
if config['ssid'] == 'Enter_Wifi_SSID':
    assert False, ("config.json has not been updated with your unique keys and data")

# Your OpenWeatherMap API details
weather_api_key = config['weather_api_key']
city = config['city']
country_code = config['country_code']

# Create WiFi connection and turn it on
wlan = network.WLAN(network.STA_IF)
wlan.active(True)

# Connect to WiFi router
print("Connecting to WiFi: {}".format(config['ssid']))
wlan.connect(config['ssid'], config['ssid_password'])

# Wait until WiFi is connected
while not wlan.isconnected():
    utime.sleep(1)

print("Connected to Wi-Fi:", wlan.ifconfig())

# Function to sync time with worldtimeapi.org
def sync_time_with_worldtimeapi_org(rtc, blocking=True):
    TIME_API = "http://worldtimeapi.org/api/timezone/Asia/Shanghai"

    response = None
    while True:
        try:
            response = urequests.get(TIME_API)
            break
        except:
            if blocking:
                response.close()
                continue
            else:
                response.close()
                return

    json_data = response.json()
    current_time = json_data["datetime"]
    the_date, the_time = current_time.split("T")
    year, month, mday = [int(x) for x in the_date.split("-")]
    the_time = the_time.split(".")[0]
    hours, minutes, seconds = [int(x) for x in the_time.split(":")]

    week_day = json_data["day_of_week"]
    response.close()
    rtc.datetime((year, month, mday, week_day, hours, minutes, seconds, 0))

# Function to fetch weather data
def fetch_weather():
    open_weather_map_url = f"http://api.openweathermap.org/data/2.5/weather?q={city},{country_code}&appid={weather_api_key}&units=metric"
    try:
        print("Fetching weather data from:", open_weather_map_url)
        weather_data = urequests.get(open_weather_map_url)

        if weather_data.status_code == 200:
            weather_json = weather_data.json()
            print("Weather Data:", weather_json)

            # Extracting relevant weather information
            return {
                'location': weather_json.get('name') + ' - ' + weather_json.get('sys').get('country'),
                'description': weather_json.get('weather')[0].get('main'),
                'temperature': weather_json.get('main').get('temp'),
                'pressure': weather_json.get('main').get('pressure'),
                'humidity': weather_json.get('main').get('humidity'),
                'wind_speed': weather_json.get('wind').get('speed'),
            }
        else:
            print("Weather API Error:", weather_data.status_code, weather_data.text)
    except Exception as e:
        print("An error occurred while fetching weather data:", str(e))
    return None

rtc = RTC()
sync_time_with_worldtimeapi_org(rtc)

# Define the GPIO pins for the LEDs
hour_pins = [Pin(pin, Pin.OUT) for pin in [1, 0]]  # 2 bits for hours (0-1)
hour_pins_ext = [Pin(pin, Pin.OUT) for pin in [11,4,3,2]]  # 4 bits for hours (2-5)
minute_pins = [Pin(pin, Pin.OUT) for pin in [8,7,6]]  # 3 bits for first minute (0-7)
minute_pins_ext = [Pin(pin, Pin.OUT) for pin in [21,20,10,9]]  # 4 bits for second minute (0-9)
second_pins = [Pin(pin, Pin.OUT) for pin in [15,14,13]]  # 3 bits for first second (0-7)
second_pins_ext = [Pin(pin, Pin.OUT) for pin in [12 , 5 , 19,18]]  # 4 bits for second second (0-9)

# Function to update the LEDs based on the current time
def update_leds():
    Y, M, D, W, H, Min, S, SS = rtc.datetime()  # Changed M to Min to avoid conflict
    print("Time:", H, ":", Min, ":", S)

    hour_msb = H // 10
    hour_lsb = H % 10
    minute_msb = Min // 10
    minute_lsb = Min % 10
    second_msb = S // 10
    second_lsb = S % 10

    hour_msb_binary = '{0:02b}'.format(hour_msb)
    hour_lsb_binary = '{0:04b}'.format(hour_lsb)
    hour_pins[0].value(int(hour_msb_binary[0]))
    hour_pins[1].value(int(hour_msb_binary[1]))
    for i in range(4):
        hour_pins_ext[i].value(int(hour_lsb_binary[i]))

    minute_msb_binary = '{0:03b}'.format(minute_msb)
    minute_lsb_binary = '{0:04b}'.format(minute_lsb)
    for i in range(3):
        minute_pins[i].value(int(minute_msb_binary[i]))
    for i in range(4):
        minute_pins_ext[i].value(int(minute_lsb_binary[i]))

    second_msb_binary = '{0:03b}'.format(second_msb)
    second_lsb_binary = '{0:04b}'.format(second_lsb)
    for i in range(3):
        second_pins[i].value(int(second_msb_binary[i]))
    for i in range(4):
        second_pins_ext[i].value(int(second_lsb_binary[i]))


# OLED display dimensions
WIDTH = 128
HEIGHT = 64
# Initialize I2C and OLED display
i2c = I2C(0, scl=Pin(17), sda=Pin(16), freq=400000)
display = SSD1306_I2C(WIDTH, HEIGHT, i2c)


# Function to update the OLED display with weather data
def update_display(weather_data):
    display.fill(0)  # Clear the display
    display.text('{}'.format(weather_data['location']), 0, 0)
    display.text('Temp: {} C'.format(weather_data['temperature']), 0, 10)
    display.text('Desc: {}'.format(weather_data['description']), 0, 20)
    display.show()  # Update the display

# Fetch initial weather data
weather_data = fetch_weather()
if weather_data:
    update_display(weather_data)

# Loop indefinitely, updating the LEDs every second and checking for weather updates every 10 minutes
last_weather_update = utime.time()
while True:
    update_leds()
    
    # Check if 10 minutes have passed
    if utime.time() - last_weather_update > 600:  # 600 seconds = 10 minutes
        weather_data = fetch_weather()
        if weather_data:
            update_display(weather_data)  # Update the display with new weather data
            last_weather_update = utime.time()  # Reset the timer
    
    time.sleep(1)

Code Overview:

This project combines a binary clock and a weather monitor, displaying the current time and weather information on an OLED display. Below is a breakdown of the main components of the code.

Configuration Loading

The program starts by loading configuration settings from a config.json file, which contains necessary information like Wi-Fi credentials and API keys.

# Load configuration
with open('config.json') as f:
    config = json.load(f)

# Check if configuration has been updated
if config['ssid'] == 'Enter_Wifi_SSID':
    assert False, ("config.json has not been updated with your unique keys and data")

Wi-Fi Connection

Next, the code establishes a connection to the specified Wi-Fi network. It continuously checks until the connection is successful.

# Create WiFi connection and turn it on
wlan = network.WLAN(network.STA_IF)
wlan.active(True)

# Connect to WiFi router
print("Connecting to WiFi: {}".format(config['ssid']))
wlan.connect(config['ssid'], config['ssid_password'])

# Wait until WiFi is connected
while not wlan.isconnected():
    utime.sleep(1)

print("Connected to Wi-Fi:", wlan.ifconfig())

Time Synchronization

The system synchronizes the real-time clock (RTC) with the current time from the worldtimeapi.org API, ensuring accurate timekeeping.

def sync_time_with_worldtimeapi_org(rtc):
    TIME_API = "http://worldtimeapi.org/api/timezone/Asia/Shanghai"
    response = urequests.get(TIME_API)
    json_data = response.json()
    current_time = json_data["datetime"]
    # Parse and update RTC
    ...
    rtc.datetime((year, month, mday, week_day, hours, minutes, seconds, 0))

Fetching Weather Data

The program fetches weather data from the OpenWeatherMap API using the specified city and country code. It handles errors and extracts relevant weather information.

def fetch_weather():
    open_weather_map_url = f"http://api.openweathermap.org/data/2.5/weather?q={city},{country_code}&appid={weather_api_key}&units=metric"
    weather_data = urequests.get(open_weather_map_url)
    
    if weather_data.status_code == 200:
        weather_json = weather_data.json()
        return {
            'location': weather_json.get('name') + ' - ' + weather_json.get('sys').get('country'),
            'description': weather_json.get('weather')[0].get('main'),
            'temperature': weather_json.get('main').get('temp'),
            ...
        }
    else:
        print("Weather API Error:", weather_data.status_code, weather_data.text)
    return None

LED Control

The code defines GPIO pins to control LEDs, which represent the current time in binary format. The current time is converted into binary and updated accordingly.

def update_leds():
    Y, M, D, W, H, Min, S, SS = rtc.datetime()
    hour_msb = H // 10
    hour_lsb = H % 10
    ...
    hour_pins[0].value(int(hour_msb_binary[0]))
    hour_pins[1].value(int(hour_msb_binary[1]))
    ...

OLED Display

An OLED display is initialized to show the weather information. It is updated with the latest data retrieved from the API.

def update_display(weather_data):
    display.fill(0)  # Clear the display
    display.text('{}'.format(weather_data['location']), 0, 0)
    display.text('Temp: {} C'.format(weather_data['temperature']), 0, 10)
    ...
    display.show()  # Update the display

Main Loop

Finally, the main loop continuously updates the LEDs and checks for new weather data every 10 minutes. It also updates the OLED display to reflect the current weather.

while True:
    update_leds()
    
    # Check if 10 minutes have passed
    if utime.time() - last_weather_update > 600:
        weather_data = fetch_weather()
        if weather_data:
            update_display(weather_data)  # Update the display with new weather data
            last_weather_update = utime.time()  # Reset the timer
    
    time.sleep(1)

This binary clock and weather monitor project showcases how to integrate various components to create a functional and visually appealing device. If you have any questions let me know on discord.

PCB Design

The PCB was designed using EasyEDA, a free and user-friendly web-based tool that supports circuit design, simulation, and PCB layout. I like the look of having the electronics exposed, so I have placed the resistors on the top face of the PCB.

This design includes several key features:

  • Two Push Buttons: These two push buttons will be used to display different data on the OLED display
  • Mounting Holes: The PCB is equipped with four 3mm mounting holes, making it easy to secure within an enclosure.
  • Power input Terminal: This will allow to connect 5V through DC plug or calbe depending on your needs.

PCB Top:

Order PCB (JLCPCB)

The PCB was ordered through JLCPCB. They offer great PCBs at a low cost and have promotions and coupons available throughout the year. You can sign up using here, or using the following link:

https://jlcpcb.com/?from=Nerd that will support me as a creator to keep making content that is accessible and open source at no charge to you.

Ordering the PCB is very simple:

Download the Gerber file here.

Click on Add Gerber file

leave all the settings as default given. You might want change the PCB color which you can do here:

Enter you shipping details, save to cart

Then after a few days depending on your location you will receive your great quality PCB.

Code

The following code demonstrates how to control WS2812B LED strips using the Neopixel library on the Raspberry Pi Pico. This example defines several colors and initializes multiple LED strips with the ability to control their colors.

Conclusion:

In this tutorial, we’ve explored how to create a custom PCB controller for WS2812B LEDs using either a Raspberry Pi Pico or a D1 Mini. We walked through the entire process, from designing the PCB and ordering it through JLCPCB to setting up the Raspberry Pi Pico and D1 Mini with WLED firmware.

What We’ve Learned

  • PCB Design: How to design a PCB using EasyEDA, incorporating features such as push buttons and Bluetooth connectivity.
  • Firmware Installation: How to flash WLED onto a D1 Mini to enable easy, wireless control of your LED strips.
  • LED Control: Basic programming with the Neopixel library on the Raspberry Pi Pico and WLED setup for intuitive LED management.

Benefits of the Project

  • Custom Control: With this setup, you gain precise control over your LED strips, allowing for a wide range of colors and effects.
  • Flexibility: By using either the Raspberry Pi Pico or the D1 Mini, you can choose the hardware that best fits your needs and preferences.
  • Enhanced Creativity: This project provides a solid foundation for various creative applications, from ambient lighting to interactive displays.

I encourage you to experiment with the design, modify the code, and explore the many possibilities that WLED and Neopixel control offer. Share your results, projects, or any modifications you make with our community. Your feedback and creativity help inspire others and contribute to the ongoing development of open-source projects.

Thank you for following along with this tutorial. I hope you enjoyed the process and are excited to apply what you’ve learned to your own projects. Happy building!