/ Shadow_Box / code.py
code.py
1 # SPDX-FileCopyrightText: 2021 Erin St Blaine for Adafruit Industries 2 # 3 # SPDX-License-Identifier: MIT 4 5 """ 6 Clock & sky colorbox for Adafruit MagTag: displays current time while 7 NeoPixels provide theme lighting for the time of day. Requires WiFi 8 internet access -- configure credentials in secrets.py. An Adafruit IO 9 user name and API key are also needed there, plus timezone and 10 geographic coords. 11 """ 12 13 # pylint: disable=import-error 14 import time 15 import json 16 import board 17 import neopixel 18 from adafruit_magtag.magtag import MagTag 19 import adafruit_fancyled.adafruit_fancyled as fancy 20 21 # UTC offset queries require some info from the secrets table... 22 try: 23 from secrets import secrets 24 except ImportError: 25 print('Please set up secrets.py with network credentials.') 26 27 28 # CONFIGURABLE SETTINGS ---------------------------------------------------- 29 30 USE_AMPM_TIME = True # Set to False to use 24-hour time (e.g. 18:00) 31 NUM_LEDS = 22 # Length of NeoPixel strip 32 BRIGHTNESS = 0.9 # NeoPixel brightness: 0.0 (off) to 1.0 (max) 33 SPIN_TIME = 10 * 60 # Seconds for NeoPixels to complete one revolution 34 # Default spin time is 10 minutes. It should be very slow...imperceptible 35 # really...as there will be pauses when network activity is occurring. 36 37 DAY_PALETTE = [ # Daylight colors 38 fancy.CRGB(0.5, 0, 1.0), # Purplish blue 39 fancy.CRGB(0, 0.5, 1.0), # Blue 40 fancy.CRGB(0, 0.5, 1.0), # Blue 41 0x1B90FF, # Cyan 42 fancy.CRGB(0, 0.8, 0.2), # Green 43 fancy.CRGB(0, 0.8, 0.2), # Green 44 0xFFEA0A, # Yellow 45 0xFFEA0A, # Yellow 46 0xFFEA0A, # Yellow 47 0xFFEA0A, # Yellow 48 0x30FEF2, # Sky blue 49 0x0C69FC, # Sky blue 50 0x1A82FF, 51 fancy.CRGB(0, 0.8, 0.8), # Green 52 fancy.CRGB(0, 0.8, 0.2), # Green 53 fancy.CRGB(0, 0.8, 0.2), # Green 54 fancy.CRGB(0.5, 0, 1.0),] # Purplish blue 55 56 NIGHT_PALETTE = [ # Starlight colors 57 fancy.CRGB(0, 0, 1.0), 58 fancy.CRGB(0, 0.2, 1.0), 59 fancy.CRGB(0, 0.1, 1.0), 60 fancy.CRGB(0, 0, 1.0), 61 0x000000, 62 0x000000, 63 0x000000, 64 fancy.CRGB(1.0, 1.0, 0.8), 65 0x000000, 66 fancy.CRGB(0.3, 0.3, 0.3), 67 fancy.CRGB(0.2, 0.2, 0.2), 68 fancy.CRGB(0.3, 0.3, 0.3), 69 0x000000, 70 0x000000, 71 0x000000, 72 0x000000] 73 74 HORIZON_PALETTE = [ # Dawn & dusk colors 75 fancy.CHSV(0.8), # Purple 76 fancy.CHSV(1.0), # Red 77 fancy.CHSV(1.0), # Red 78 fancy.CRGB(1.0, 0.5, 0.0), # Orange 79 fancy.CRGB(1.0, 0.5, 0.0), # Orange 80 fancy.CRGB(1.0, 0.8, 0.0), # Yellow 81 0xFFFFFF, # White 82 fancy.CRGB(1.0, 0.8, 0.0), # Yellow 83 fancy.CRGB(1.0, 0.5, 0.0)] # Orange 84 85 86 # SOME UTILITY FUNCTIONS --------------------------------------------------- 87 88 def hh_mm(time_struct, twelve_hour=True): 89 """ Given a time.struct_time, return a string as H:MM or HH:MM, either 90 12- or 24-hour style depending on twelve_hour flag. 91 """ 92 postfix = "" 93 if twelve_hour: 94 if time_struct.tm_hour > 12: 95 hour_string = str(time_struct.tm_hour - 12) # 13-23 -> 1-11 (pm) 96 postfix = "pm" 97 elif time_struct.tm_hour > 0: 98 hour_string = str(time_struct.tm_hour) # 1-12 99 postfix = "am" 100 else: 101 hour_string = '12' # 0 -> 12 (am) 102 postfix = "pm" 103 else: 104 hour_string = '{hh:02d}'.format(hh=time_struct.tm_hour) 105 return hour_string + ':{mm:02d}'.format(mm=time_struct.tm_min) + postfix 106 107 def parse_time(timestring): 108 """ Given a string of the format YYYY-MM-DDTHH:MM:SS.SS-HH:MM (and 109 optionally a DST flag), convert to and return a numeric value for 110 elapsed seconds since midnight (date, UTC offset and/or decimal 111 fractions of second are ignored). 112 """ 113 date_time = timestring.split('T') # Separate into date and time 114 hour_minute_second = date_time[1].split('+')[0].split('-')[0].split(':') 115 return (int(hour_minute_second[0]) * 3600 + 116 int(hour_minute_second[1]) * 60 + 117 int(hour_minute_second[2].split('.')[0])) 118 119 def blend(palette1, palette2, weight2, offset): 120 """ Given two FancyLED color palettes and a weighting (0.0 to 1.0) of 121 the second palette, plus a positional offset (where 0.0 is the start 122 of each palette), fill the NeoPixel strip with an interpolated blend 123 of the two palettes. 124 """ 125 weight2 = min(1.0, max(0.0, weight2)) # Constrain input to 0.0-1.0 126 weight1 = 1.0 - weight2 # palette1 weight (inverse of #2) 127 for i in range(NUM_LEDS): 128 position = offset + i / NUM_LEDS 129 color1 = fancy.palette_lookup(palette1, position) 130 color2 = fancy.palette_lookup(palette2, position) 131 # Blend the two colors based on weight1&2, run through gamma func: 132 color = fancy.CRGB( 133 color1[0] * weight1 + color2[0] * weight2, 134 color1[1] * weight1 + color2[1] * weight2, 135 color1[2] * weight1 + color2[2] * weight2) 136 color = fancy.gamma_adjust(color, brightness=BRIGHTNESS) 137 PIXELS[i] = color.pack() 138 PIXELS.show() 139 140 141 # ONE-TIME INITIALIZATION -------------------------------------------------- 142 143 MAGTAG = MagTag() 144 145 MAGTAG.graphics.set_background("/background.bmp") 146 147 MAGTAG.add_text( 148 text_font="Lato-Regular-74.pcf", 149 text_position=(MAGTAG.graphics.display.width // 2, 30), 150 text_anchor_point=(0.5, 0), 151 is_data=False, 152 ) 153 154 # Declare NeoPixel object on pin D10 with NUM_LEDS pixels, no auto-write. 155 # Set brightness to max as we'll be using FancyLED's brightness control. 156 PIXELS = neopixel.NeoPixel(board.D10, NUM_LEDS, brightness=0.1, 157 auto_write=False) 158 PIXELS.show() # Off at start 159 160 LAST_SYNC = time.monotonic() - 5000 # Force initial clock sync 161 LAST_MINUTE = -1 # Force initial display update 162 LAST_DAY = -1 # Force initial sun query 163 SUNRISE = 6 * 60 * 60 # Sunrise @ 6am by default 164 SUNSET = 18 * 60 * 60 # Sunset @ 6pm by default 165 UTC_OFFSET = '+00:00' # Gets updated along with time 166 SUN_FLAG = False # Triggered at midnight 167 168 # MAIN LOOP ---------------------------------------------------------------- 169 170 while True: 171 if (time.monotonic() - LAST_SYNC) > 3600: # Sync time once an hour 172 MAGTAG.network.get_local_time() 173 LAST_SYNC = time.monotonic() 174 # Sun API requires a valid UTC offset. Adafruit IO's time API 175 # offers this, but get_local_time() above (using AIO) doesn't 176 # store it anywhere. I’ll put in a feature request for the 177 # PortalBase library, but in the meantime this just makes a 178 # second request to the time API asking for that one value. 179 # Since time is synced only once per hour, the extra request 180 # isn't particularly burdensome. 181 try: 182 RESPONSE = MAGTAG.network.requests.get( 183 'https://io.adafruit.com/api/v2/%s/integrations/time/' 184 'strftime?x-aio-key=%s&tz=%s' % (secrets.get('aio_username'), 185 secrets.get('aio_key'), 186 secrets.get('timezone')) + 187 '&fmt=%25z') 188 if RESPONSE.status_code == 200: 189 # Arrives as sHHMM, convert to sHH:MM 190 print(RESPONSE.text) 191 UTC_OFFSET = RESPONSE.text[:3] + ':' + RESPONSE.text[-2:] 192 except: # pylint: disable=bare-except 193 # If query fails, prior value is kept until next query. 194 # Only changes 2X a year anyway -- worst case, if these 195 # events even align, is rise/set is off by an hour. 196 pass 197 198 NOW = time.localtime() # Current time (as time_struct) 199 200 # If minute has changed, refresh display 201 if LAST_MINUTE != NOW.tm_min: 202 MAGTAG.set_text(hh_mm(NOW, USE_AMPM_TIME), index=0) 203 LAST_MINUTE = NOW.tm_min 204 205 # If day has changed (local midnight), set flag for later sun query 206 # (it's not done at midnight, see below). 207 if LAST_DAY != NOW.tm_mday: 208 SUN_FLAG = True 209 LAST_DAY = NOW.tm_mday 210 211 # If the sun flag is set, and if the time is 3:05 am or thereabouts, 212 # query the sun API for new rise and set times for today. It's done 213 # this way (rather than at midnight) to allow for DST time jumps 214 # (which occur at 2am) and slight clock drift (corrected hourly), 215 # but still before dawn. 216 if SUN_FLAG and (NOW.tm_hour * 60 + NOW.tm_min > 185): 217 try: 218 URL = ('https://api.met.no/weatherapi/sunrise/2.0/.json?' 219 'lat=%s&lon=%s&date=%s-%s-%s&offset=%s' % 220 (secrets.get('latitude'), secrets.get('longitude'), 221 str(NOW.tm_year), '{0:0>2}'.format(NOW.tm_mon), 222 '{0:0>2}'.format(NOW.tm_mday), UTC_OFFSET)) 223 print('Fetching sun data via', URL) 224 FULL_DATA = json.loads(MAGTAG.network.fetch_data(URL)) 225 SUN_DATA = FULL_DATA['location']['time'][0] 226 SUNRISE = parse_time(SUN_DATA['sunrise']['time']) 227 SUNSET = parse_time(SUN_DATA['sunset']['time']) 228 except: # pylint: disable=bare-except 229 # If any part of the sun API query fails (whether network or 230 # bad inputs), just repeat the old sun rise/set times and we'll 231 # try again tomorrow. These only shift by seconds or minutes 232 # daily, and the LEDs are just for mood, not like we're 233 # launching a Mars rocket, errors here are not catastrophic. 234 # Very worst case is a query error on a DST time change day, 235 # in which case rise/set lights will be off by about an hour 236 # until next successful query. 237 pass 238 SUN_FLAG = False # Pass or fail, don't query again until tomorrow 239 240 # Convert NOW into elapsed seconds since midnight 241 NOW = time.mktime(NOW) - time.mktime((NOW.tm_year, NOW.tm_mon, 242 NOW.tm_mday, 0, 0, 0, 243 NOW.tm_wday, NOW.tm_yday, 244 NOW.tm_isdst)) 245 # Compare current time (in seconds since midnight) against sun rise/set 246 # times and do color fades within +/- 30 minutes of each. 247 if SUNRISE < NOW < SUNSET: # Day (ish) 248 if NOW - SUNRISE < (30 * 60): # Between sunrise & daylight 249 PALETTE1, PALETTE2 = HORIZON_PALETTE, DAY_PALETTE 250 INTERP = (NOW - SUNRISE) / (30 * 60) 251 elif SUNSET - NOW < (30 * 60): # Between daylight & sunset 252 PALETTE1, PALETTE2 = HORIZON_PALETTE, DAY_PALETTE 253 INTERP = (SUNSET - NOW) / (30 * 60) 254 else: # Full daylight 255 PALETTE1 = PALETTE2 = DAY_PALETTE # Day sky 256 INTERP = 0.0 # No fade 257 else: # Night (ish) 258 if 0 < SUNRISE - NOW < (30 * 60): # Between night & sunrise 259 PALETTE1, PALETTE2 = HORIZON_PALETTE, NIGHT_PALETTE 260 INTERP = (SUNRISE - NOW) / (30 * 60) 261 elif 0 < NOW - SUNSET < (30 * 60): # Between sunset & night 262 PALETTE1, PALETTE2 = HORIZON_PALETTE, NIGHT_PALETTE 263 INTERP = (NOW - SUNSET) / (30 * 60) 264 else: # Full night 265 PALETTE1 = PALETTE2 = NIGHT_PALETTE # Night sky 266 INTERP = 0.0 # No fade 267 268 # Update NeoPixels based on time of day 269 blend(PALETTE1, PALETTE2, INTERP, time.monotonic() / SPIN_TIME)