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.

on the server

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

from flask import Flask

app = Flask(__name__)
debug = os.environ.get("DEBUG") == "true"

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 = (50, 25, 0)
red = (30, 0, 0)
black = (0, 0, 0)
green = (0, 30, 0)


def times():
    now = datetime.now()
    if now.weekday() in [0, 1, 3, 4]:  # mon, tue, thu, fri
        schedule = school_schedule
    else:
        schedule = holiday_schedule
    # 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
    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

    return res


@app.route("/")
def next():
    try:
        return _next()
    except:
        raise


def _next():
    now, almost, getup, day, evening, night = 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,
        )
    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 step(next_time, upcolornow, upcolornext, bottomcolornow, bottomcolornext):
    now, almost, getup, day, evening, night = times()
    if next_time == "almost":
        next_time = almost
    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,
        }
    print(f"{now}: {command}", flush=True)
    return command


@app.route("/health")
def health():
    return "ok", 200

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

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

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

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([5, 0, 5])
    led.write()
    led.write()

wlan_connect()

for led in upled, bottomled:
    led.fill([0, 0, 3])
    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").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)

            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
00:00: Here we go
Traceback (most recent call last):
  File "main.py", line 64, in <module>
  File "main.py", line 32, in notify
  File "requests/__init__.py", line 209, in post
  File "requests/__init__.py", line 81, in request
KeyboardInterrupt:

MicroPython v1.25.0 on 2025-04-15; ESP module with ESP8266
Type "help()" for more information.
>>>

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