/ Matrix_Portal_Moon_Clock / code.py
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)