code.py
  1  # SPDX-FileCopyrightText: 2020 Phillip Burgess for Adafruit Industries
  2  #
  3  # SPDX-License-Identifier: MIT
  4  
  5  """
  6  MOON PHASE CLOCK for Adafruit Matrix Portal: displays current time, lunar
  7  phase and time of next moonrise or moonset. Requires WiFi internet access.
  8  
  9  Written by Phil 'PaintYourDragon' Burgess for Adafruit Industries.
 10  MIT license, all text above must be included in any redistribution.
 11  
 12  BDF fonts from the X.Org project. Startup 'splash' images should not be
 13  included in derivative projects, thanks. Tall splash images licensed from
 14  123RF.com, wide splash images used with permission of artist Lew Lashmit
 15  (viergacht@gmail.com). Rawr!
 16  """
 17  
 18  # pylint: disable=import-error
 19  import gc
 20  import time
 21  import math
 22  import board
 23  import busio
 24  import displayio
 25  from rtc import RTC
 26  from adafruit_matrixportal.network import Network
 27  from adafruit_matrixportal.matrix import Matrix
 28  from adafruit_bitmap_font import bitmap_font
 29  import adafruit_display_text.label
 30  import adafruit_lis3dh
 31  
 32  try:
 33      from secrets import secrets
 34  except ImportError:
 35      print('WiFi secrets are kept in secrets.py, please add them there!')
 36      raise
 37  
 38  # CONFIGURABLE SETTINGS ----------------------------------------------------
 39  
 40  TWELVE_HOUR = True # If set, use 12-hour time vs 24-hour (e.g. 3:00 vs 15:00)
 41  COUNTDOWN = False  # If set, show time to (vs time of) next rise/set event
 42  MONTH_DAY = True   # If set, use MM/DD vs DD/MM (e.g. 31/12 vs 12/31)
 43  BITPLANES = 6      # Ideally 6, but can set lower if RAM is tight
 44  
 45  
 46  # SOME UTILITY FUNCTIONS AND CLASSES ---------------------------------------
 47  
 48  def parse_time(timestring, is_dst=-1):
 49      """ Given a string of the format YYYY-MM-DDTHH:MM:SS.SS-HH:MM (and
 50          optionally a DST flag), convert to and return an equivalent
 51          time.struct_time (strptime() isn't available here). Calling function
 52          can use time.mktime() on result if epoch seconds is needed instead.
 53          Time string is assumed local time; UTC offset is ignored. If seconds
 54          value includes a decimal fraction it's ignored.
 55      """
 56      date_time = timestring.split('T')        # Separate into date and time
 57      year_month_day = date_time[0].split('-') # Separate time into Y/M/D
 58      hour_minute_second = date_time[1].split('+')[0].split('-')[0].split(':')
 59      return time.struct_time((int(year_month_day[0]),
 60                              int(year_month_day[1]),
 61                              int(year_month_day[2]),
 62                              int(hour_minute_second[0]),
 63                              int(hour_minute_second[1]),
 64                              int(hour_minute_second[2].split('.')[0]),
 65                              -1, -1, is_dst))
 66  
 67  
 68  def update_time(timezone=None):
 69      """ Update system date/time from WorldTimeAPI public server;
 70          no account required. Pass in time zone string
 71          (http://worldtimeapi.org/api/timezone for list)
 72          or None to use IP geolocation. Returns current local time as a
 73          time.struct_time and UTC offset as string. This may throw an
 74          exception on fetch_data() - it is NOT CAUGHT HERE, should be
 75          handled in the calling code because different behaviors may be
 76          needed in different situations (e.g. reschedule for later).
 77      """
 78      if timezone: # Use timezone api
 79          time_url = 'http://worldtimeapi.org/api/timezone/' + timezone
 80      else: # Use IP geolocation
 81          time_url = 'http://worldtimeapi.org/api/ip'
 82  
 83      time_data = NETWORK.fetch_data(time_url,
 84                                     json_path=[['datetime'], ['dst'],
 85                                                ['utc_offset']])
 86      time_struct = parse_time(time_data[0], time_data[1])
 87      RTC().datetime = time_struct
 88      return time_struct, time_data[2]
 89  
 90  
 91  def hh_mm(time_struct):
 92      """ Given a time.struct_time, return a string as H:MM or HH:MM, either
 93          12- or 24-hour style depending on global TWELVE_HOUR setting.
 94          This is ONLY for 'clock time,' NOT for countdown time, which is
 95          handled separately in the one spot where it's needed.
 96      """
 97      if TWELVE_HOUR:
 98          if time_struct.tm_hour > 12:
 99              hour_string = str(time_struct.tm_hour - 12) # 13-23 -> 1-11 (pm)
100          elif time_struct.tm_hour > 0:
101              hour_string = str(time_struct.tm_hour) # 1-12
102          else:
103              hour_string = '12' # 0 -> 12 (am)
104      else:
105          hour_string = '{0:0>2}'.format(time_struct.tm_hour)
106      return hour_string + ':' + '{0:0>2}'.format(time_struct.tm_min)
107  
108  
109  # pylint: disable=too-few-public-methods
110  class MoonData():
111      """ Class holding lunar data for a given day (00:00:00 to 23:59:59).
112          App uses two of these -- one for the current day, and one for the
113          following day -- then some interpolations and such can be made.
114          Elements include:
115          age      : Moon phase 'age' at midnight (start of period)
116                     expressed from 0.0 (new moon) through 0.5 (full moon)
117                     to 1.0 (next new moon).
118          midnight : Epoch time in seconds @ midnight (start of period).
119          rise     : Epoch time of moon rise within this 24-hour period.
120          set      : Epoch time of moon set within this 24-hour period.
121      """
122      def __init__(self, datetime, hours_ahead, utc_offset):
123          """ Initialize MoonData object elements (see above) from a
124              time.struct_time, hours to skip ahead (typically 0 or 24),
125              and a UTC offset (as a string) and a query to the MET Norway
126              Sunrise API (also provides lunar data), documented at:
127              https://api.met.no/weatherapi/sunrise/2.0/documentation
128          """
129          if hours_ahead:
130              # Can't change attribute in datetime struct, need to create
131              # a new one which will roll the date ahead as needed. Convert
132              # to epoch seconds and back for the offset to work
133              datetime = time.localtime(time.mktime(time.struct_time((
134                  datetime.tm_year,
135                  datetime.tm_mon,
136                  datetime.tm_mday,
137                  datetime.tm_hour + hours_ahead,
138                  datetime.tm_min,
139                  datetime.tm_sec,
140                  -1, -1, -1))))
141          # strftime() not available here
142          url = ('https://api.met.no/weatherapi/sunrise/2.0/.json?lat=' +
143                 str(LATITUDE) + '&lon=' + str(LONGITUDE) +
144                 '&date=' + str(datetime.tm_year) + '-' +
145                 '{0:0>2}'.format(datetime.tm_mon) + '-' +
146                 '{0:0>2}'.format(datetime.tm_mday) +
147                 '&offset=' + utc_offset)
148          print('Fetching moon data via', url)
149          # pylint: disable=bare-except
150          for _ in range(5): # Retries
151              try:
152                  location_data = NETWORK.fetch_data(url,
153                                                     json_path=[['location']])
154                  moon_data = location_data['time'][0]
155                  #print(moon_data)
156                  # Reconstitute JSON data into the elements we need
157                  self.age = float(moon_data['moonphase']['value']) / 100
158                  self.midnight = time.mktime(parse_time(
159                      moon_data['moonphase']['time']))
160                  if 'moonrise' in moon_data:
161                      self.rise = time.mktime(
162                          parse_time(moon_data['moonrise']['time']))
163                  else:
164                      self.rise = None
165                  if 'moonset' in moon_data:
166                      self.set = time.mktime(
167                          parse_time(moon_data['moonset']['time']))
168                  else:
169                      self.set = None
170                  return # Success!
171              except:
172                  # Moon server error (maybe), try again after 15 seconds.
173                  # (Might be a memory error, that should be handled different)
174                  time.sleep(15)
175  
176  
177  # ONE-TIME INITIALIZATION --------------------------------------------------
178  
179  MATRIX = Matrix(bit_depth=BITPLANES)
180  DISPLAY = MATRIX.display
181  ACCEL = adafruit_lis3dh.LIS3DH_I2C(busio.I2C(board.SCL, board.SDA),
182                                     address=0x19)
183  _ = ACCEL.acceleration # Dummy reading to blow out any startup residue
184  time.sleep(0.1)
185  DISPLAY.rotation = (int(((math.atan2(-ACCEL.acceleration.y,
186                                       -ACCEL.acceleration.x) + math.pi) /
187                           (math.pi * 2) + 0.875) * 4) % 4) * 90
188  
189  LARGE_FONT = bitmap_font.load_font('/fonts/helvB12.bdf')
190  SMALL_FONT = bitmap_font.load_font('/fonts/helvR10.bdf')
191  SYMBOL_FONT = bitmap_font.load_font('/fonts/6x10.bdf')
192  LARGE_FONT.load_glyphs('0123456789:')
193  SMALL_FONT.load_glyphs('0123456789:/.%')
194  SYMBOL_FONT.load_glyphs('\u21A5\u21A7')
195  
196  # Display group is set up once, then we just shuffle items around later.
197  # Order of creation here determines their stacking order.
198  GROUP = displayio.Group()
199  
200  # Element 0 is a stand-in item, later replaced with the moon phase bitmap
201  # pylint: disable=bare-except
202  try:
203      FILENAME = 'moon/splash-' + str(DISPLAY.rotation) + '.bmp'
204  
205      # CircuitPython 6 & 7 compatible
206      BITMAP = displayio.OnDiskBitmap(open(FILENAME, 'rb'))
207      TILE_GRID = displayio.TileGrid(
208          BITMAP,
209          pixel_shader=getattr(BITMAP, 'pixel_shader', displayio.ColorConverter())
210      )
211  
212      # # CircuitPython 7+ compatible
213      # BITMAP = displayio.OnDiskBitmap(FILENAME)
214      # TILE_GRID = displayio.TileGrid(BITMAP, pixel_shader=BITMAP.pixel_shader)
215  
216      GROUP.append(TILE_GRID)
217  except:
218      GROUP.append(adafruit_display_text.label.Label(SMALL_FONT, color=0xFF0000,
219                                                     text='AWOO'))
220      GROUP[0].x = (DISPLAY.width - GROUP[0].bounding_box[2] + 1) // 2
221      GROUP[0].y = DISPLAY.height // 2 - 1
222  
223  # Elements 1-4 are an outline around the moon percentage -- text labels
224  # offset by 1 pixel up/down/left/right. Initial position is off the matrix,
225  # updated on first refresh. Initial text value must be long enough for
226  # longest anticipated string later.
227  for i in range(4):
228      GROUP.append(adafruit_display_text.label.Label(SMALL_FONT, color=0,
229                                                     text='99.9%', y=-99))
230  # Element 5 is the moon percentage (on top of the outline labels)
231  GROUP.append(adafruit_display_text.label.Label(SMALL_FONT, color=0xFFFF00,
232                                                 text='99.9%', y=-99))
233  # Element 6 is the current time
234  GROUP.append(adafruit_display_text.label.Label(LARGE_FONT, color=0x808080,
235                                                 text='12:00', y=-99))
236  # Element 7 is the current date
237  GROUP.append(adafruit_display_text.label.Label(SMALL_FONT, color=0x808080,
238                                                 text='12/31', y=-99))
239  # Element 8 is a symbol indicating next rise or set
240  GROUP.append(adafruit_display_text.label.Label(SYMBOL_FONT, color=0x00FF00,
241                                                 text='x', y=-99))
242  # Element 9 is the time of (or time to) next rise/set event
243  GROUP.append(adafruit_display_text.label.Label(SMALL_FONT, color=0x00FF00,
244                                                 text='12:00', y=-99))
245  DISPLAY.show(GROUP)
246  
247  NETWORK = Network(status_neopixel=board.NEOPIXEL, debug=False)
248  NETWORK.connect()
249  
250  # LATITUDE, LONGITUDE, TIMEZONE are set up once, constant over app lifetime
251  
252  # Fetch latitude/longitude from secrets.py. If not present, use
253  # IP geolocation. This only needs to be done once, at startup!
254  try:
255      LATITUDE = secrets['latitude']
256      LONGITUDE = secrets['longitude']
257      print('Using stored geolocation: ', LATITUDE, LONGITUDE)
258  except KeyError:
259      LATITUDE, LONGITUDE = (
260          NETWORK.fetch_data('http://www.geoplugin.net/json.gp',
261                             json_path=[['geoplugin_latitude'],
262                                        ['geoplugin_longitude']]))
263      print('Using IP geolocation: ', LATITUDE, LONGITUDE)
264  
265  # Load time zone string from secrets.py, else IP geolocation for this too
266  # (http://worldtimeapi.org/api/timezone for list).
267  try:
268      TIMEZONE = secrets['timezone'] # e.g. 'America/New_York'
269  except:
270      TIMEZONE = None # IP geolocation
271  
272  # Set initial clock time, also fetch initial UTC offset while
273  # here (NOT stored in secrets.py as it may change with DST).
274  # pylint: disable=bare-except
275  try:
276      DATETIME, UTC_OFFSET = update_time(TIMEZONE)
277  except:
278      DATETIME, UTC_OFFSET = time.localtime(), '+00:00'
279  LAST_SYNC = time.mktime(DATETIME)
280  
281  # Poll server for moon data for current 24-hour period and +24 ahead
282  PERIOD = []
283  for DAY in range(2):
284      PERIOD.append(MoonData(DATETIME, DAY * 24, UTC_OFFSET))
285  # PERIOD[0] is the current 24-hour time period we're in. PERIOD[1] is the
286  # following 24 hours. Data is shifted down and new data fetched as days
287  # expire. Thought we might need a PERIOD[2] for certain circumstances but
288  # it appears not, that's changed easily enough if needed.
289  
290  
291  # MAIN LOOP ----------------------------------------------------------------
292  
293  while True:
294      gc.collect()
295      NOW = time.time() # Current epoch time in seconds
296  
297      # Sync with time server every ~12 hours
298      if NOW - LAST_SYNC > 12 * 60 * 60:
299          try:
300              DATETIME, UTC_OFFSET = update_time(TIMEZONE)
301              LAST_SYNC = time.mktime(DATETIME)
302              continue # Time may have changed; refresh NOW value
303          except:
304              # update_time() can throw an exception if time server doesn't
305              # respond. That's OK, keep running with our current time, and
306              # push sync time ahead to retry in 30 minutes (don't overwhelm
307              # the server with repeated queries).
308              LAST_SYNC += 30 * 60 # 30 minutes -> seconds
309  
310      # If PERIOD has expired, move data down and fetch new +24-hour data
311      if NOW >= PERIOD[1].midnight:
312          PERIOD[0] = PERIOD[1]
313          PERIOD[1] = MoonData(time.localtime(), 24, UTC_OFFSET)
314  
315      # Determine weighting of tomorrow's phase vs today's, using current time
316      RATIO = ((NOW - PERIOD[0].midnight) /
317               (PERIOD[1].midnight - PERIOD[0].midnight))
318      # Determine moon phase 'age'
319      # 0.0  = new moon
320      # 0.25 = first quarter
321      # 0.5  = full moon
322      # 0.75 = last quarter
323      # 1.0  = new moon
324      if PERIOD[0].age < PERIOD[1].age:
325          AGE = (PERIOD[0].age +
326                 (PERIOD[1].age - PERIOD[0].age) * RATIO) % 1.0
327      else: # Handle age wraparound (1.0 -> 0.0)
328          # If tomorrow's age is less than today's, it indicates a new moon
329          # crossover. Add 1 to tomorrow's age when computing age delta.
330          AGE = (PERIOD[0].age +
331                 (PERIOD[1].age + 1 - PERIOD[0].age) * RATIO) % 1.0
332  
333      # AGE can be used for direct lookup to moon bitmap (0 to 99) -- these
334      # images are pre-rendered for a linear timescale (solar terminator moves
335      # nonlinearly across sphere).
336      FRAME = int(AGE * 100) % 100 # Bitmap 0 to 99
337  
338      # Then use some trig to get percentage lit
339      if AGE <= 0.5: # New -> first quarter -> full
340          PERCENT = (1 - math.cos(AGE * 2 * math.pi)) * 50
341      else:          # Full -> last quarter -> new
342          PERCENT = (1 + math.cos((AGE - 0.5) * 2 * math.pi)) * 50
343  
344      # Find next rise/set event, complicated by the fact that some 24-hour
345      # periods might not have one or the other (but usually do) due to the
346      # Moon rising ~50 mins later each day. This uses a brute force approach,
347      # working backwards through the time periods to locate rise/set events
348      # that A) exist in that 24-hour period (are not None), B) are still in
349      # the future, and C) are closer than the last guess. What's left at the
350      # end is the next rise or set (and the inverse of the event type tells
351      # us whether Moon's currently risen or not).
352      NEXT_EVENT = PERIOD[1].midnight + 100000 # Force first match
353      for DAY in reversed(PERIOD):
354          if DAY.rise and NEXT_EVENT >= DAY.rise >= NOW:
355              NEXT_EVENT = DAY.rise
356              RISEN = False
357          if DAY.set and NEXT_EVENT >= DAY.set >= NOW:
358              NEXT_EVENT = DAY.set
359              RISEN = True
360  
361      if DISPLAY.rotation in (0, 180): # Horizontal 'landscape' orientation
362          CENTER_X = 48      # Text along right
363          MOON_Y = 0         # Moon at left
364          TIME_Y = 6         # Time at top right
365          EVENT_Y = 26       # Rise/set at bottom right
366      else:                  # Vertical 'portrait' orientation
367          CENTER_X = 16      # Text down center
368          if RISEN:
369              MOON_Y = 0     # Moon at top
370              EVENT_Y = 38   # Rise/set in middle
371              TIME_Y = 49    # Time/date at bottom
372          else:
373              TIME_Y = 6     # Time/date at top
374              EVENT_Y = 26   # Rise/set in middle
375              MOON_Y = 32    # Moon at bottom
376  
377      print()
378  
379      # Update moon image (GROUP[0])
380      FILENAME = 'moon/moon' + '{0:0>2}'.format(FRAME) + '.bmp'
381  
382      # CircuitPython 6 & 7 compatible
383      BITMAP = displayio.OnDiskBitmap(open(FILENAME, 'rb'))
384      TILE_GRID = displayio.TileGrid(
385          BITMAP,
386          pixel_shader=getattr(BITMAP, 'pixel_shader', displayio.ColorConverter())
387      )
388  
389      # # CircuitPython 7+ compatible
390      # BITMAP = displayio.OnDiskBitmap(FILENAME)
391      # TILE_GRID = displayio.TileGrid(BITMAP, pixel_shader=BITMAP.pixel_shader)
392  
393      TILE_GRID.x = 0
394      TILE_GRID.y = MOON_Y
395      GROUP[0] = TILE_GRID
396  
397      # Update percent value (5 labels: GROUP[1-4] for outline, [5] for text)
398      if PERCENT >= 99.95:
399          STRING = '100%'
400      else:
401          STRING = '{:.1f}'.format(PERCENT + 0.05) + '%'
402      print(NOW, STRING, 'full')
403      # Set element 5 first, use its size and position for setting others
404      GROUP[5].text = STRING
405      GROUP[5].x = 16 - GROUP[5].bounding_box[2] // 2
406      GROUP[5].y = MOON_Y + 16
407      for _ in range(1, 5):
408          GROUP[_].text = GROUP[5].text
409      GROUP[1].x, GROUP[1].y = GROUP[5].x, GROUP[5].y - 1 # Up 1 pixel
410      GROUP[2].x, GROUP[2].y = GROUP[5].x - 1, GROUP[5].y # Left
411      GROUP[3].x, GROUP[3].y = GROUP[5].x + 1, GROUP[5].y # Right
412      GROUP[4].x, GROUP[4].y = GROUP[5].x, GROUP[5].y + 1 # Down
413  
414      # Update next-event time (GROUP[8] and [9])
415      # Do this before time because we need uncorrupted NOW value
416      EVENT_TIME = time.localtime(NEXT_EVENT) # Convert to struct for later
417      if COUNTDOWN: # Show NEXT_EVENT as countdown to event
418          NEXT_EVENT -= NOW # Time until (vs time of) next rise/set
419          MINUTES = NEXT_EVENT // 60
420          STRING = str(MINUTES // 60) + ':' + '{0:0>2}'.format(MINUTES % 60)
421      else: # Show NEXT_EVENT in clock time
422          STRING = hh_mm(EVENT_TIME)
423      GROUP[9].text = STRING
424      XPOS = CENTER_X - (GROUP[9].bounding_box[2] + 6) // 2
425      GROUP[8].x = XPOS
426      if RISEN:                    # Next event is SET
427          GROUP[8].text = '\u21A7' # Downwards arrow from bar
428          GROUP[8].y = EVENT_Y - 2
429          print('Sets:', STRING)
430      else:                        # Next event is RISE
431          GROUP[8].text = '\u21A5' # Upwards arrow from bar
432          GROUP[8].y = EVENT_Y - 1
433          print('Rises:', STRING)
434      GROUP[9].x = XPOS + 6
435      GROUP[9].y = EVENT_Y
436      # Show event time in green if a.m., amber if p.m.
437      GROUP[8].color = GROUP[9].color = (0x00FF00 if EVENT_TIME.tm_hour < 12
438                                         else 0xC04000)
439  
440      # Update time (GROUP[6]) and date (GROUP[7])
441      NOW = time.localtime()
442      STRING = hh_mm(NOW)
443      GROUP[6].text = STRING
444      GROUP[6].x = CENTER_X - GROUP[6].bounding_box[2] // 2
445      GROUP[6].y = TIME_Y
446      if MONTH_DAY:
447          STRING = str(NOW.tm_mon) + '/' + str(NOW.tm_mday)
448      else:
449          STRING = str(NOW.tm_mday) + '/' + str(NOW.tm_mon)
450      GROUP[7].text = STRING
451      GROUP[7].x = CENTER_X - GROUP[7].bounding_box[2] // 2
452      GROUP[7].y = TIME_Y + 10
453  
454      DISPLAY.refresh() # Force full repaint (splash screen sometimes sticks)
455      time.sleep(5)