Konubinix' opinionated web of thoughts

More Clever Night Light in Micropython

Fleeting

more clever night light using a Adafruit Feather HUZZAH with ESP8266 with micropython

Trying to avoid the mess of dealing with android version discrepancies of more clever night light in kivy, and trying to have a much faster crash recovery than in more clever night light.

The logic is handled by a server in my stack that the device requests to find out how much time to sleep and what color to set. That way, most of the fix and features will go to the server and I will barely have to wake up the device to change its code.

I extracted a piezzo speaker from an old remote and used it to play a short tune when it is wake up time.

How it works

The device — a wemos d1 running micropython — wakes up, connects to WiFi, and asks the server “what should I do?”. The server looks at the current time, decides which phase of the day we are in, and replies with either “set these LED colors and sleep for N seconds” or “wait N seconds then switch to these colors”. The device obeys, then asks again.

This document walks through the system the way a day unfolds: first the schedules that define the rhythm, then the transitions that give each phase its color, the melody that signals wake-up time, and finally the device code that ties it all together.

The daily rhythm

A day has five phases, each signaled by a different LED color:

  1. Night — both LEDs off (black). The child should be sleeping.
  2. Almost — bottom LED orange. It is getting close to wake-up time.
  3. Get up — top LED green, and on school days a melody plays.
  4. Day — both LEDs off. No need for a signal during the day.
  5. Evening — bottom LED red. Time to wind down for bed.

School days and holidays follow different schedules. On school days the wake-up sequence starts at 6:30; on holidays it shifts to 7:30.

Schedules

The server picks the right schedule based on the day of the week. Holidays override everything when the NIGHTLIGHT_HOLIDAYS flag is set.

school_schedule = {
    "almost_hour": 6,
    "almost_minute": 30,
    "getup_hour": 7,
    "getup_minute": 0,
    "day_hour": 7,
    "day_minute": 40,
    "evening_hour": 20,
    "evening_minute": 0,
    "night_hour": 20,
    "night_minute": 30,
}

holiday_schedule = {
    "almost_hour": 7,
    "almost_minute": 30,
    "getup_hour": 8,
    "getup_minute": 0,
    "day_hour": 9,
    "day_minute": 30,
    "evening_hour": 20,
    "evening_minute": 0,
    "night_hour": 20,
    "night_minute": 30,
}

debug_schedule = []

The times() function figures out which schedule applies right now and returns all the transition timestamps. It handles the tricky case of schedule changes at night — if it is past tonight’s bedtime, tomorrow’s schedule (school or holiday) kicks in for the morning transitions.

def times():
    now = datetime.now()
    if not holidays and now.weekday() in [0, 1, 3, 4]:  # mon, tue, thu, fri
        schedule = school_schedule
        current_schedule = "school"
    else:
        schedule = holiday_schedule
        current_schedule = "holiday"
    # but, if past the night of today's schedule, use the other values from the
    # other schedule, keeping the night of this schedule to avoid issues like when
    # they are different
    night = now.replace(
        hour=schedule["night_hour"],
        minute=schedule["night_minute"],
        second=0,
    )
    if now >= night:
        if now.weekday() in [6, 2]:  # sun, wed, change holidays -> school
            # change of schedule
            schedule = school_schedule
        elif now.weekday() in [1, 4]:  # tue, fri, change school -> holidays
            # change of schedule
            schedule = holiday_schedule

    almost = now.replace(
        hour=schedule["almost_hour"],
        minute=schedule["almost_minute"],
        second=0,
    )
    getup = now.replace(
        hour=schedule["getup_hour"],
        minute=schedule["getup_minute"],
        second=0,
    )
    day = now.replace(
        hour=schedule["day_hour"],
        minute=schedule["day_minute"],
        second=0,
    )
    evening = now.replace(
        hour=schedule["evening_hour"],
        minute=schedule["evening_minute"],
        second=0,
    )
    # don't recompute night for we are sure to use the schedule of today for it

    res = now, almost, getup, day, evening, night, current_schedule
    if debug:
        if not debug_schedule or debug_schedule[-1] < now:
            debug_schedule.clear()
            debug_schedule.extend(
                [(now + timedelta(minutes=(i + 1))).replace(second=0) for i in range(5)]
            )
            print(debug_schedule)
        return [now] + debug_schedule + ["school"]

    return res

What happens at each transition

Each phase gets a pair of LED colors (top and bottom). The server also precomputes what the next phase’s colors will be, so the device can transition smoothly.

orange = [int(i * brightness / 100) for i in (50, 25, 0)]
red = [int(i * brightness / 100) for i in (30, 0, 0)]
black = [int(i * brightness / 100) for i in (0, 0, 0)]
green = [int(i * brightness / 100) for i in (0, 30, 0)]

The route handler maps the current phase to its colors and tells the device either to sleep until the next transition or to wait and then switch. It also handles DST changes by adjusting the “almost” time on the day before a clock shift.

@blueprint.route("/nightlight")
async def next():
    try:
        return _next()
    except:
        raise


def _next():
    now, almost, getup, day, evening, night, schedule = times()
    if now < almost:
        # night
        return step(
            next_time="almost",
            upcolornow=black,
            bottomcolornow=black,
            upcolornext=black,
            bottomcolornext=orange,
        )
    elif now < getup:
        # almost
        return step(
            next_time="getup",
            upcolornow=black,
            bottomcolornow=orange,
            upcolornext=green,
            bottomcolornext=black,
            tunenext=MELODY,
        )
    elif now < day:
        # getup
        return step(
            next_time="day",
            upcolornow=green,
            bottomcolornow=black,
            upcolornext=black,
            bottomcolornext=black,
        )
    elif now < evening:
        # day
        return step(
            next_time="evening",
            upcolornow=black,
            bottomcolornow=black,
            upcolornext=black,
            bottomcolornext=red,
        )
    elif now < night:
        # evening
        return step(
            next_time="night",
            upcolornow=black,
            bottomcolornow=red,
            upcolornext=black,
            bottomcolornext=black,
        )
    else:
        # night
        return step(
            next_time="almost",
            upcolornow=black,
            bottomcolornow=black,
            upcolornext=black,
            bottomcolornext=orange,
        )


def is_day_before_dst_change(now):
    """
    Returns -1 if tomorrow is the spring DST shift (clocks move forward so one
    hour less),
    +1 if tomorrow is the autumn DST shift (clocks move back so one hour more),
     0 otherwise.
    """
    year = now.year

    # Find last Sunday of March (spring forward) and October (fall back)
    def last_sunday(year, month):
        # Start from the last day of the month, go backward to Sunday
        dt = (
            datetime(year, month + 1, 1) - timedelta(days=1)
            if month < 12
            else datetime(year, 12, 31)
        )
        while dt.weekday() != 6:  # 6 = Sunday
            dt -= timedelta(days=1)
        return dt

    spring_shift = last_sunday(year, 3)
    autumn_shift = last_sunday(year, 10)

    if now.date() == spring_shift.date() - timedelta(days=1):
        return -1  # gain one hour
    elif now.date() == autumn_shift.date() - timedelta(days=1):
        return +1  # lose one hour
    else:
        return 0


def step(
    next_time, upcolornow, upcolornext, bottomcolornow, bottomcolornext, tunenext=None
):
    now, almost, getup, day, evening, night, schedule = times()
    if next_time == "almost":
        next_time = almost + timedelta(hours=is_day_before_dst_change(now))
    if next_time == "getup":
        next_time = getup
    if next_time == "day":
        next_time = day
    if next_time == "evening":
        next_time = evening
    if next_time == "night":
        next_time = night

    duration = (next_time - now).total_seconds()
    if duration < 0:
        duration = duration + (24 * 3600)

    if duration > (20 if debug else 200):
        command = {
            "sleep": duration - (10 if debug else 100),
            "upcolor": upcolornow,
            "bottomcolor": bottomcolornow,
        }
    else:
        command = {
            "in": duration,
            "upcolor": upcolornext,
            "bottomcolor": bottomcolornext,
            "tune": tunenext if schedule == "school" else None,
        }
    print(f"{now}: {command}", flush=True)
    return command

Waking up with music

A piezo speaker extracted from an old remote plays a short tune when it is time to get up — but only on school days. The melody is defined on the server so it can be updated without reflashing the device: the server sends the note list as part of the “get up” command.

quarter = 0.6
dotted = quarter * 1.5
eighth = quarter * 0.5
whole = quarter * 4

MELODY = [
    ("A5", eighth),
    ("C5", eighth),
    ("D5", eighth),
    ("G5", eighth),
    ("E5", dotted),
    ("A4", eighth),
    ("B4", quarter),
    ("C5", eighth),
    ("A4", eighth),
    ("B4", eighth),
    ("C5", eighth),
    ("D5", eighth),
    ("F5", eighth),
    ("E5", dotted),
    ("E5", eighth),
    ("D5", eighth),
    ("C#5", eighth),
    ("A4", eighth),
    ("E4", eighth),
    ("A5", eighth),
    ("C5", eighth),
    ("D5", eighth),
    ("G5", eighth),
    ("E5", dotted),
    ("A4", eighth),
    ("B4", quarter),
    ("C5", eighth),
    ("A4", eighth),
    ("B4", eighth),
    ("C5", eighth),
    ("D5", eighth),
    ("B4", eighth),
    ("A4", dotted),
    ("E5", eighth),
    ("D5", eighth),
    ("C#5", eighth),
    ("A4", eighth),
    ("E4", eighth),
    ("A5", eighth),
    ("C5", eighth),
    ("D5", eighth),
    ("G5", eighth),
    ("E5", dotted),
    ("A4", eighth),
    ("B4", quarter),
    ("C5", eighth),
    ("A4", eighth),
    ("B4", eighth),
    ("C5", eighth),
    ("D5", eighth),
    ("F5", eighth),
    ("E5", dotted),
    ("E5", eighth),
    ("D5", eighth),
    ("C#5", eighth),
    ("A4", eighth),
    ("E4", eighth),
    ("A5", eighth),
    ("C5", eighth),
    ("D5", eighth),
    ("G5", eighth),
    ("E5", dotted),
    ("A4", eighth),
    ("B4", quarter),
    ("C5", eighth),
    ("A4", eighth),
    ("B4", eighth),
    ("C5", eighth),
    ("D5", eighth),
    ("B4", eighth),
    ("A4", whole),
]

On the device side, a frequency table maps note names to Hz. Two small functions drive the piezo buzzer: play_note sounds a single note, play_melody sequences through the list.

NOTES = {
    # Octave 4
    "C4": 262,
    "C#4": 277,
    "D4": 294,
    "D#4": 311,
    "E4": 330,
    "F4": 349,
    "F#4": 370,
    "G4": 392,
    "G#4": 415,
    "A4": 440,
    "A#4": 466,
    "B4": 494,
    # Octave 5
    "C5": 523,
    "C#5": 554,
    "D5": 587,
    "D#5": 622,
    "E5": 659,
    "F5": 698,
    "F#5": 740,
    "G5": 784,
    "G#5": 830,
    "A5": 880,
    "A#5": 932,
    "B5": 988,
    # Octave 6
    "C6": 1046,
    "C#6": 1109,
    "D6": 1175,
    "D#6": 1245,
    "E6": 1319,
    "F6": 1397,
    "F#6": 1480,
    "G6": 1568,
    "G#6": 1661,
    "A6": 1760,
    "A#6": 1865,
    "B6": 1976,
    # Octave 7
    "C7": 2093,
    "C#7": 2217,
    "D7": 2349,
    "D#7": 2489,
    "E7": 2637,
    "F7": 2794,
    "F#7": 2960,
    "G7": 3136,
    "G#7": 3322,
    "A7": 3520,
    "A#7": 3729,
    "B7": 3951,
    # Octave 8
    "C8": 4186,
    "C#8": 4435,
    "D8": 4698,
    "D#8": 4978,
    "E8": 5274,
    "F8": 5588,
    "F#8": 5920,
    "G8": 6272,
    "G#8": 6645,
    "A8": 7040,
    "A#8": 7459,
    "B8": 7902,
}

The melody and its player live on the device too: play_note drives the buzzer at the right frequency for each note, and play_melody steps through the list.

quarter = 0.6
dotted = quarter * 1.5
eighth = quarter * 0.5
whole = quarter * 4

MELODY = [    ("A5", eighth),    ("C5", eighth),    ("D5", eighth),    ("G5", eighth),    ("E5", dotted),    ("A4", eighth),    ("B4", quarter),    ("C5", eighth),    ("A4", eighth),    ("B4", eighth),    ("C5", eighth),    ("D5", eighth),    ("F5", eighth),        ("E5", dotted),    ("E5", eighth),    ("D5", eighth),    ("C#5", eighth),    ("A4", eighth),    ("E4", eighth),    ("A5", eighth),    ("C5", eighth),    ("D5", eighth),    ("G5", eighth),    ("E5", dotted),    ("A4", eighth),    ("B4", quarter),    ("C5", eighth),    ("A4", eighth),    ("B4", eighth),    ("C5", eighth),    ("D5", eighth),    ("B4", eighth),        ("A4", dotted),    ("E5", eighth),    ("D5", eighth),    ("C#5", eighth),    ("A4", eighth),    ("E4", eighth),    ("A5", eighth),    ("C5", eighth),    ("D5", eighth),    ("G5", eighth),    ("E5", dotted),    ("A4", eighth),    ("B4", quarter),    ("C5", eighth),    ("A4", eighth),    ("B4", eighth),    ("C5", eighth),    ("D5", eighth),    ("F5", eighth),        ("E5", dotted),    ("E5", eighth),    ("D5", eighth),    ("C#5", eighth),    ("A4", eighth),    ("E4", eighth),    ("A5", eighth),    ("C5", eighth),    ("D5", eighth),    ("G5", eighth),    ("E5", dotted),    ("A4", eighth),    ("B4", quarter),    ("C5", eighth),    ("A4", eighth),    ("B4", eighth),    ("C5", eighth),    ("D5", eighth),    ("B4", eighth),       ("A4", whole),]

def play_note(note, duration=0.2):
    if note == None:  # rest
        buzzer.duty(0)
    else:
        freq = NOTES[note]
        buzzer.freq(freq)
        buzzer.duty(2)
    time.sleep(duration)
    buzzer.duty(0)
    time.sleep(0.02)  # small pause between notes


def play_melody(melody=MELODY):
    for note, duration in melody:
        play_note(note, duration)

The device

The device is a wemos d1 with two neopixel LEDs and a piezo buzzer. It connects to WiFi, fetches commands from the server, sets LED colors, and sleeps to save power. On error it waits five minutes and retries.

Booting up

On power-up the device lights both LEDs purple (a visual “I’m alive” signal), connects to WiFi (LEDs turn blue while connecting), waits ten seconds for stability, then clears the LEDs and enters the main loop.

First the hardware is set up: imports, WiFi, two neopixel strips (one on top, one at the bottom), and a PWM-driven piezo buzzer.

#!/usr/bin/env python3
# -*- coding:utf-8 -*-
import gc
import sys
import time
import os

import esp
import machine
import neopixel
import network
import ntptime
import requests
from machine import PWM, Pin
buzzer = PWM(Pin(5))
buzzer.duty(0)

wlan = network.WLAN(network.STA_IF)
wlan.active(True)

upled = neopixel.NeoPixel(machine.Pin(4), 1)
bottomled = neopixel.NeoPixel(machine.Pin(0), 1)

A few helpers handle logging and notifications. notify sends a message to a monitoring endpoint so crashes and reconnections show up in the logs.

def format_message(msg):
    # _, _, _, h, m, _, _, _ = time.localtime()
    # return f"{h:02d}:{m:02d}: {msg}"
    return f"{msg}"

def say(message):
    print(format_message(message))

def notify(message):
    say(message)
    try:
        requests.post("http://home:9705/nightlight",
                      data=f"{network.hostname()}: {format_message(message)}")
    except Exception as e:
        say(f"Could not notify with message: {message}, {e}")

def wlan_connect():
    wlan.connect()
    for i in range(30):
        if wlan.isconnected():
            break
        else:
            say("waiting 1s to be connected")
            time.sleep(1)
    if i > 9:
        notify(f"connected after {i} iterations")

The actual boot sequence: purple LEDs as a heartbeat, blue while connecting to WiFi, then clear and go. A ten-second pause lets the network settle before entering the main loop.

say("Hello")
for led in upled, bottomled:
    led.fill([255, 0, 255])
    led.write()
    led.write()

wlan_connect()

for led in upled, bottomled:
    led.fill([0, 0, 255])
    led.write()
    led.write()

# * SLEEP_LIGHT - light sleep, shuts down the WiFi Modem circuit and suspends the processor periodically.
# The system enters the set sleep mode automatically when possible.
try:
    esp.sleep_type(esp.SLEEP_LIGHT)
except:
    notify(f"Could not set the sleep light type on {sys.platform}")


if "nowait" not in os.listdir():
    notify("Waiting a bit before starting")
    time.sleep(10)

notify("Here we go")

for led in upled, bottomled:
    led.fill([0, 0, 0])
    led.write()
    led.write()

The main loop

The device enters an endless cycle: connect to WiFi, fetch the next command, obey it (set colors and sleep, or wait then transition), and repeat. Light sleep saves power between transitions. If the server is unreachable the device waits five minutes and tries again.

def setcolors(command):
    upcolor = command["upcolor"]
    bottomcolor = command["bottomcolor"]
    say(f"Setting up:{upcolor}, bottom:{bottomcolor}")
    upled.fill(upcolor)
    upled.write()
    upled.write()

    bottomled.fill(bottomcolor)
    bottomled.write()
    bottomled.write()

while True:
    try:
        wlan_connect()
        command = requests.get("http://home:9911/nightlight").json()
        notify(f"Got: {command}")
        if "sleep" in command:
            setcolors(command)
            if "keepconnected" not in os.listdir():
                wlan.disconnect()

            # we cannot sleep longer than 2**32 / 1000 / 3600 hours ~ 1 hour due
            # to the fact internally micropython uses us and the processor is
            # 32bits. Therefore we may want to split this into several sleep
            # commands.
            time_to_wait = int(command["sleep"])
            while time_to_wait > 0:
                duration = min(time_to_wait, 3600)
                time_to_wait = time_to_wait - duration
                sleep_time = duration * 1000
                say(f"Now, sleeping for {sleep_time}ms, still {time_to_wait}s to go after")
                machine.lightsleep(sleep_time)
        elif "in" in command:
            say(f"Waiting for {int(command['in'])}")
            time.sleep(int(command["in"]))
            setcolors(command)
            if command.get("tune"):
                play_melody(command["tune"])
            else:
                say("wait a bit before asking again, but be precise. It won't take long")
                time.sleep(3) # if debug else 300)
        else:
            raise NotImplementedError()
    except Exception as e:
        notify(f"Failed: {e}, waiting for 5m before trying again")
        time.sleep(300)

Trying it

So far, so good. I eventually moved the code to a wemos d1. This had several advantages:

  1. it has a 5V output, that is required by the neopixel. I even put 2 of those in the final design,
  2. I could keep the huzzah for more complicated scenarios, involving feathers,
  3. this was an opportunity to test how fast I could recover from a crash of device. It was only a 600K download (the micropython firmware) and a couple of minutes to setup. This was much more practical than the initial version.
  4. I could easily use another device (esp32c3), put the code into it to try updates before sending them to the device “in production”.

Now, only time will tell if the device will manage to deal with.

Notes linking here