# SPDX-FileCopyrightText: 2023 Kattni Rembor for Adafruit Industries # SPDX-License-Identifier: MIT """ CircuitPython Canary Day and Night Light with Optional Network-Down Detection This project uses the QT Py ESP32-S3 with the NeoPixel 5x5 LED Grid BFF, along with a 3D printed bird. The LEDs light up different colors based on the time. In the event that the internet connection fails, it will begin blinking red to notify you. If the initial test ping fails, and the subsequent pings fail over 30 times, the board will reset. Otherwise, the blinking will continue until the connection is back up. This feature is enabled by default. It can easily be disabled at the beginning of the code. """ import os import ssl import time import ipaddress import supervisor import board import wifi import microcontroller import socketpool import adafruit_requests import neopixel from adafruit_io.adafruit_io import IO_HTTP # ============ CUSTOMISATIONS ============ # Network-down detection enable or disable. # By default, the network-down detection code, and the code that blinks when the # network is down, are both enabled. If you wish to disable this feature, # including the blinking, update this to False. NETWORK_DOWN_DETECTION = True # The basic canary colors. # Red light at night is more conducive to sleep. Blue light in the morning is more # conducive to waking up. The sleep color defaults to red to promote sleep. The wake # color defaults to blue to promote wakefulness. SLEEP_COLOR = (255, 0, 0) # Red WAKE_COLOR = (0, 0, 255) # Blue # Sleep time. # This is the hour in 24-hour time at which the light should change to the # desired color for the time you intend to sleep. # Must be an integer between 0 and 23. Defaults to 20 (8pm). SLEEP_TIME = 20 # Wake time. # This is the hour in 24-hour time at which the light should change to the # desired color for the time you intend to be awake. # Must be an integer between 0 and 23. Defaults to 6 (6am). WAKE_TIME = 6 # Canary brightness customisation. # Brightness must be a float or integer between 0.0 and 1.0, where 0.0 is off, and 1.0 is max. # This is the brightness of the canary during sleep time. It defaults to 0.2, or "20%". # Increase or decrease this value to change the brightness. SLEEP_BRIGHTNESS = 0.2 # This is the brightness of the canary during wake time. It defaults to 0.7, or "70%". # Increase or decrease this value to change the brightness. WAKE_BRIGHTNESS = 0.7 # Time check interval. # This sets the time interval at which the code checks Adafruit IO for the current time. # This is included because Adafruit IO has rate limiting. It ensures that you do not # hit the rate limit, and the time check does not get throttled. # Defaults to 300 seconds (5 minutes). Must be an integer equal to or greater than 300. # Increase this value to increase the time check interval. Do not decrease this value! TIME_CHECK_INTERVAL = 300 # Checks whether the time check interval is below the minimum value and an integer. if TIME_CHECK_INTERVAL < 300 or isinstance(TIME_CHECK_INTERVAL, float): # If is below the minimum or a float, raise this error and stop the code. raise ValueError("TIME_CHECK_INTERVAL must be a integer, and greater than 300!") # Ping interval while ping is successful. # This is the interval at which the code will send a ping while the network is up and the pings # are successful. If for any reason you would prefer to slow down the ping interval, this value # can be updated. Defaults to 1 second. Must be an integer equal to or greater than 1. Increase # this value to increase the ping interval time. Do not decrease this value! UP_PING_INTERVAL = 1 # Checks whether the successful ping interval is below the minimum value and an integer. if UP_PING_INTERVAL < 1 or isinstance(UP_PING_INTERVAL, float): # If is below the minimum or a float, raise this error and stop the code. raise ValueError("UP_PING_INTERVAL must be a integer, and greater than 1!") # The blink color. # This is the color that the canary will blink to notify you that the network is down. # Defaults to red. BLINK_COLOR = (255, 0, 0) # Consecutive ping fail to blink. # This value is the number of times ping will consecutively fail before the canary begins blinking. # If the blinking is happening too often, or if the network is often flaky, this value can be # increased to extend the number of failures it takes to begin blinking. # Defaults to 10. Must be an integer greater than 1. CONSECUTIVE_PING_FAIL_TO_BLINK = 10 # The amount of time in seconds that needs to pass while the network is down AND # NETWORK_DOWN_DETECTION is DISABLED before the board resets to try again. # Defaults to 900 seconds, or 20 minutes. Must be an integer. Increase or decrease # this value to alter how long the network should be down in this specific case # before the board resets. NETWORK_DOWN_RELOAD_TIME = 900 # IP address. # This is the IP address used to ping to verify that network connectivity is still present. # To switch to a different IP, update the following. Must be a valid IPV4 address as a # string (in quotes). Defaults to one of the OpenDNS IPs. PING_IP = "208.67.222.222" # ============ HARDWARE AND CODE SET UP ============ # Instantiate the NeoPixel object. pixels = neopixel.NeoPixel(board.A3, 25) # Create helper functions def reload_on_error(delay, error_content=None, reload_type="reload"): """ Reset the board when an error is encountered. :param float delay: The delay in seconds before the board should reset. :param Exception error_content: The error encountered. Used to print the error before reset. :param str reload_type: The type of reload desired. Defaults to "reload", which invokes ``supervisor.reload()`` to soft-reload the board. To hard reset the board, set this to "reset", which invokes ``microcontroller.reset()``. """ if str(reload_type).lower().strip() not in ["reload", "reset"]: raise ValueError("Invalid reload type:", reload_type) if error_content: print("Error:\n", str(error_content)) if delay: print( f"{reload_type[0].upper() + reload_type[1:]} microcontroller in {delay} seconds." ) time.sleep(delay) if reload_type == "reload": supervisor.reload() if reload_type == "reset": microcontroller.reset() def color_time(current_hour): """ Verifies what color the LEDs should be based on the time. :param current_hour: Provide a time, hour only. The `tm_hour` part of the `io.receive_time()` object is acceptable here. """ if WAKE_TIME < SLEEP_TIME: if WAKE_TIME <= current_hour < SLEEP_TIME: pixels.brightness = WAKE_BRIGHTNESS return WAKE_COLOR pixels.brightness = SLEEP_BRIGHTNESS return SLEEP_COLOR if SLEEP_TIME <= current_hour < WAKE_TIME: pixels.brightness = SLEEP_BRIGHTNESS return SLEEP_COLOR pixels.brightness = WAKE_BRIGHTNESS return WAKE_COLOR def blink(color): """ Blink the NeoPixel LEDs a specific color. :param tuple color: The color the LEDs will blink. """ if color_time(sundial.tm_hour) == SLEEP_COLOR: pixels.brightness = SLEEP_BRIGHTNESS else: pixels.brightness = WAKE_BRIGHTNESS pixels.fill(color) time.sleep(0.5) pixels.fill((0, 0, 0)) time.sleep(0.5) # Connect to WiFi. This process can fail for various reasons. It is included in a try/except # block to ensure the project continues running when unattended. try: wifi.radio.connect(os.getenv("wifi_ssid"), os.getenv("wifi_password")) pool = socketpool.SocketPool(wifi.radio) requests = adafruit_requests.Session(pool, ssl.create_default_context()) except Exception as error: # pylint: disable=broad-except # The exceptions raised by the `wifi` module are not always clear. If you're receiving errors, # check your SSID and password before continuing. print("Wifi connection failed.") reload_on_error(5, error) # Set up IP address to use for pinging, as defined above. ip_address = ipaddress.IPv4Address(PING_IP) # Set up ping, and send initial ping. wifi_ping = wifi.radio.ping(ip=ip_address) # If the initial ping is unsuccessful, print the message. if wifi_ping is None: print("Set up test-ping failed.") # Set `initial_ping` to False to indicate the failure. initial_ping = False else: # Otherwise, print this message. print("Set up test-ping successful.") # Set `initial_ping` to True to indicate success. initial_ping = True # Set up Adafruit IO. This will provide the current time through `io.receive_time()`. io = IO_HTTP(os.getenv("aio_username"), os.getenv("aio_key"), requests) # Retrieve the time on startup. This is included to verify that the Adafruit IO set # up was successful. This process can fail for various reasons. It is included in a # try/except block to ensure the project continues to run when unattended. try: sundial = io.receive_time() # Create the sundial variable to keep the time. except Exception as error: # pylint: disable=broad-except # If the time retrieval fails with an error, print the message. print("Adafruit IO set up and/or time retrieval failed.") # Then wait 5 seconds, and soft reload the board. reload_on_error(5, error) # Initialise various time tracking variables. check_time = 0 network_down_time = time.time() ping_time = 0 ping_fail_time = time.time() # Initialise network check variable. network_check = 1 # Initialise ping fail count tracking. ping_fail_count = 0 # ============ LOOP ============ while True: # Resets current_time to the current second every time through the loop. current_time = time.time() # WiFi and IO connections can fail arbitrarily. The bulk of the loop is included in a # try/except block to ensure the project will continue to run unattended if any # failures do occur. try: # If this is the first run of the code or the time check interval has passed, continue. if not check_time or current_time - check_time >= TIME_CHECK_INTERVAL: # Send a single ping to test for network connectivity. network_check = wifi.radio.ping(ip=ip_address) # If there is network connectivity, run the time check code. if network_check is not None: # Reset `check_time` to continue tracking. check_time = time.time() # Retrieve the time and save it to `sundial`. sundial = io.receive_time() # Print the current date and time to the serial console. print(f"LED color time-check. Date and time: {sundial.tm_year}-{sundial.tm_mon}-" + f"{sundial.tm_mday} {sundial.tm_hour}:{sundial.tm_min:02}") # Provide the current hour to the `color_time` function. The returned color is # provided to `pixels.fill()` to set the LED color. pixels.fill(color_time(sundial.tm_hour)) else: print("Network check ping failed.") # If network down detection is disabled AND the network check ping failed # AND the specified network down reload time passed: print the message, # wait 3 seconds, and hard reset the board. if not NETWORK_DOWN_DETECTION and network_check is None and \ current_time - network_down_time > NETWORK_DOWN_RELOAD_TIME: print(f"Network check ping has failed for over {NETWORK_DOWN_RELOAD_TIME} seconds.") reload_on_error(3, reload_type="reset") # If network down detection is enabled, run the rest of the code. if NETWORK_DOWN_DETECTION: # If this is the first run of the code or up ping interval` time has passed, continue. if not ping_time or current_time - ping_time >= UP_PING_INTERVAL: # Reset `ping_time` to continue tracking. ping_time = time.time() # Ping to verify network connection. wifi_ping = wifi.radio.ping(ip=ip_address) # If the ping is successful, set the fail count to 0, and print IP and ping time. if wifi_ping is not None: ping_fail_count = 0 print(f"Pinging {ip_address}: {wifi_ping} ms") # If the ping has failed, and it's been one second, continue with this code. if wifi_ping is None and current_time - ping_fail_time >= 1: # Reset `ping_fail_time` to continue tracking. ping_fail_time = time.time() # Add one to the failure count tracking. ping_fail_count += 1 # Print the ping failure count. print(f"Ping failed {ping_fail_count} times") # If the ping fail count exceeds the value defined above, begin blinking the LED # to indicate that the network is down. if ping_fail_count > CONSECUTIVE_PING_FAIL_TO_BLINK: blink(BLINK_COLOR) # If the initial setup ping failed, it means the network connection was failing # from the beginning, and might require reloading the board. So, if the initial # ping failed and the ping_fail_count is greater than 30, immediately soft reload # the board. if not initial_ping and ping_fail_count > 30: reload_on_error(0) # If the initial ping succeeded, the blinking will continue until the connection # is reestablished and the pings are once again successful. # ============ ERROR HANDLING ============ # Since network-related code can fail arbitrarily in a variety of ways, this final block is # included to reset the board when an error is encountered. # When the error is thrown, wait 10 seconds, and hard reset the board. except Exception as error: # pylint: disable=broad-except reload_on_error(10, error, reload_type="reset")