More Clever Night Light in Micropython
Fleetingmore 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:
- it has a 5V output, that is required by the neopixel. I even put 2 of those in the final design,
- I could keep the huzzah for more complicated scenarios, involving feathers,
- 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.
- 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.