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