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.

on the server

#!/usr/bin/env python3
# -*- coding:utf-8 -*-
import os
from datetime import datetime, timedelta

from quart import Blueprint

blueprint = Blueprint("nightlight", __name__)
debug = os.environ.get("NIGHTLIGHT_DEBUG") == "true"
holidays = os.environ.get("NIGHTLIGHT_HOLIDAYS") == "true"
brightness = int(os.environ.get("NIGHTLIGHT_BRIGHTNESS", 50))  # 1-100

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": 8,
    "day_minute": 40,
    "evening_hour": 20,
    "evening_minute": 0,
    "night_hour": 20,
    "night_minute": 30,
}

debug_schedule = []


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)]

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 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


@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),
    -1 if tomorrow is the autumn DST shift (clocks move back),
     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  # lose one hour
    elif now.date() == autumn_shift.date() - timedelta(days=1):
        return -1  # gain 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":
        # not tested yet
        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

on the MCU

#!/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)

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,
}

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)

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")


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()

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)
spawn webrepl_cli.py -p 0000 nightlight
op:repl, host:nightlight, port:8266, passwd:0000.
Remote WebREPL version: (1, 25, 0)
Use Ctrl-] to exit this shell

>>>

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