|
| 1 | +# SPDX-FileCopyrightText: 2025 Tim C, written for Adafruit Industries |
| 2 | +# |
| 3 | +# SPDX-License-Identifier: MIT |
| 4 | +""" |
| 5 | +Literature Quotes Clock for the Adafruit MagTag |
| 6 | +
|
| 7 | +This project displays the current time by showing a quote from a book |
| 8 | +that references the time. Every minute of the day has a different quote. |
| 9 | +The current time reference portion of the quote is accented with an outline |
| 10 | +to make it easier to see at a glance. |
| 11 | +
|
| 12 | +This project was inspired by: |
| 13 | + - https://github.com/JohannesNE/literature-clock |
| 14 | + - https://www.instructables.com/Literary-Clock-Made-From-E-reader/ |
| 15 | +
|
| 16 | +The quotes were sourced originally by The Guardian: |
| 17 | +https://www.theguardian.com/books/table/2011/apr/21/literary-clock?CMP=twt_gu |
| 18 | +""" |
| 19 | +import os |
| 20 | +import random |
| 21 | +import time |
| 22 | +import zlib |
| 23 | + |
| 24 | +import displayio |
| 25 | +import rtc |
| 26 | +import socketpool |
| 27 | +import supervisor |
| 28 | +import terminalio |
| 29 | +import wifi |
| 30 | + |
| 31 | +import adafruit_ntp |
| 32 | +from adafruit_display_text.bitmap_label import Label |
| 33 | +from adafruit_display_text import wrap_text_to_pixels |
| 34 | + |
| 35 | +# Set the current offset for your timezone |
| 36 | +TZ_OFFSET_FROM_UTC = -6 |
| 37 | + |
| 38 | + |
| 39 | +def which_line_contains(all_lines, important_passage): |
| 40 | + """ |
| 41 | + Find the line that contains the important passage |
| 42 | + :param all_lines: The lines to search |
| 43 | + :param important_passage: The passage to search for |
| 44 | + :return: The index of the line that contains the important passage |
| 45 | + or None if it was not found |
| 46 | + """ |
| 47 | + |
| 48 | + index_within_spaced_version = " ".join(all_lines).find(important_passage) |
| 49 | + |
| 50 | + working_index = 0 |
| 51 | + for i in range(len(all_lines)): |
| 52 | + line = all_lines[i] |
| 53 | + if working_index <= index_within_spaced_version < working_index + len(line): |
| 54 | + return i |
| 55 | + working_index += len(line) + 1 # extra 1 for the newline |
| 56 | + |
| 57 | + return None |
| 58 | + |
| 59 | + |
| 60 | +def find_lines_to_show(all_lines, important_passage): |
| 61 | + """ |
| 62 | + Find the line that contains ``important_passage`` and return |
| 63 | + the start of the range of 7 lines that provides the largest possible |
| 64 | + context around it. |
| 65 | +
|
| 66 | + :param all_lines: The lines to search |
| 67 | + :param important_passage: The passage to search for |
| 68 | + :return: index of the first line in a range of 7 lines with the widest context. |
| 69 | + """ |
| 70 | + if len(all_lines) <= 7: |
| 71 | + return 0 |
| 72 | + |
| 73 | + try: |
| 74 | + passage_line = which_line_contains(all_lines, important_passage) |
| 75 | + if passage_line <= 3: |
| 76 | + return 0 |
| 77 | + except TypeError as e: |
| 78 | + raise TypeError(f"ip: {important_passage} | {all_lines}") from e |
| 79 | + |
| 80 | + if passage_line >= len(all_lines) - 4: |
| 81 | + return len(all_lines) - 7 |
| 82 | + |
| 83 | + return passage_line - 3 |
| 84 | + |
| 85 | + |
| 86 | +display = supervisor.runtime.display |
| 87 | + |
| 88 | +# WIFI Setup |
| 89 | +wifi_ssid = os.getenv("CIRCUITPY_WIFI_SSID") |
| 90 | +wifi_password = os.getenv("CIRCUITPY_WIFI_PASSWORD") |
| 91 | +if wifi_ssid is None: |
| 92 | + print("WiFi credentials are kept in settings.toml, please add them there!") |
| 93 | + raise ValueError("SSID not found in environment variables") |
| 94 | + |
| 95 | +try: |
| 96 | + wifi.radio.connect(wifi_ssid, wifi_password) |
| 97 | +except ConnectionError: |
| 98 | + print("Failed to connect to WiFi with provided credentials") |
| 99 | + raise |
| 100 | + |
| 101 | +# Wait a few seconds for WIFI to finish connecting and be ready |
| 102 | +time.sleep(2) |
| 103 | + |
| 104 | +# Fetch time from NTP |
| 105 | +pool = socketpool.SocketPool(wifi.radio) |
| 106 | +ntp = adafruit_ntp.NTP( |
| 107 | + pool, |
| 108 | + server="0.adafruit.pool.ntp.org", |
| 109 | + tz_offset=TZ_OFFSET_FROM_UTC, |
| 110 | + cache_seconds=3600, |
| 111 | +) |
| 112 | + |
| 113 | +# Update the system RTC from the NTP time |
| 114 | +rtc.RTC().datetime = ntp.datetime |
| 115 | + |
| 116 | +# main group to hold all other visual elements |
| 117 | +main_group = displayio.Group() |
| 118 | + |
| 119 | +# background group used for white background behind the quote |
| 120 | +# scale 8x to save memory on the Bitmap |
| 121 | +bg_group = displayio.Group(scale=8) |
| 122 | + |
| 123 | +# Create & append Bitmap for the white background |
| 124 | +bg_bmp = displayio.Bitmap(display.width // 8, display.height // 8, 1) |
| 125 | +bg_palette = displayio.Palette(1) |
| 126 | +bg_palette[0] = 0xFFFFFF |
| 127 | +bg_tg = displayio.TileGrid(bg_bmp, pixel_shader=bg_palette) |
| 128 | +bg_group.append(bg_tg) |
| 129 | +main_group.append(bg_group) |
| 130 | + |
| 131 | +# Setup accent palette for the outlined text |
| 132 | +accent_palette = displayio.Palette(5) |
| 133 | +accent_palette[3] = 0x666666 |
| 134 | +accent_palette[4] = 0xFFFFFF |
| 135 | + |
| 136 | +# Setup BitmapLabel to show the quote |
| 137 | +quote_lbl = Label( |
| 138 | + terminalio.FONT, text="", color=0x666666, color_palette=accent_palette |
| 139 | +) |
| 140 | +quote_lbl.anchor_point = (0, 0) |
| 141 | +quote_lbl.anchored_position = (2, 2) |
| 142 | +main_group.append(quote_lbl) |
| 143 | + |
| 144 | +# Setup BitmapLabel to show book title and author |
| 145 | +book_info_lbl = Label(terminalio.FONT, text="", color=0x666666) |
| 146 | +book_info_lbl.anchor_point = (0, 1.0) # place it at the bottom of the display |
| 147 | +book_info_lbl.anchored_position = (2, display.height - 2) |
| 148 | +main_group.append(book_info_lbl) |
| 149 | + |
| 150 | +# Set main group containing visual elements to show on the display |
| 151 | +display.root_group = main_group |
| 152 | + |
| 153 | +while True: |
| 154 | + # get the current time from system RTC |
| 155 | + now = time.localtime() |
| 156 | + |
| 157 | + # break out the current hour in 24hr format |
| 158 | + hour = f"{now.tm_hour:02d}" |
| 159 | + |
| 160 | + # open the data file for the current hour |
| 161 | + with open(f"split_data_compressed/{hour}.csv.gz", "rb") as f: |
| 162 | + # read and unzip the data |
| 163 | + compressed_data = f.read() |
| 164 | + rows = zlib.decompress(compressed_data).split(b"\n") |
| 165 | + |
| 166 | + # break out the current minute |
| 167 | + current_minute = f"{now.tm_min:02d}".encode("utf-8") |
| 168 | + |
| 169 | + print(f"hour: {hour} min: {current_minute}") |
| 170 | + |
| 171 | + # list to hold possible quotes for the current time |
| 172 | + options = [] |
| 173 | + |
| 174 | + # get the previous minute also for alternate choices |
| 175 | + previous_minute = f"{now.tm_min - 1:02d}".encode("utf-8") |
| 176 | + # list to hold alternate choices |
| 177 | + alternates = [] |
| 178 | + |
| 179 | + # loop over all rows in the data from the CSV |
| 180 | + for row in rows: |
| 181 | + # if the current row is for the current time |
| 182 | + if row[3:5] == current_minute: |
| 183 | + # add the current row as a potential choice to show |
| 184 | + options.append(row) |
| 185 | + |
| 186 | + # if the current row is for the previous minute |
| 187 | + if row[3:5] == previous_minute: |
| 188 | + # add the current row as an alternate choice |
| 189 | + alternates.append(row) |
| 190 | + |
| 191 | + # if there is at least one option for the current time |
| 192 | + if len(options) > 0: |
| 193 | + # make a random choice from the possible quote options |
| 194 | + choice = random.choice(options) |
| 195 | + |
| 196 | + else: # No options for current time |
| 197 | + # use a random choice from the previous minute instead |
| 198 | + choice = random.choice(alternates) |
| 199 | + |
| 200 | + # decode the row of data from bytes to a string |
| 201 | + row_str = choice.decode("utf-8") |
| 202 | + |
| 203 | + # split the data on the pipe character |
| 204 | + parts = row_str.split("|") |
| 205 | + |
| 206 | + # extract the quote text |
| 207 | + quote = parts[2] |
| 208 | + |
| 209 | + # extract the author |
| 210 | + author = parts[4] |
| 211 | + |
| 212 | + # extract the book title |
| 213 | + title = parts[3] |
| 214 | + |
| 215 | + # set the text in the book info label to show the title and author |
| 216 | + book_info_lbl.text = f"{title} - {author}" |
| 217 | + |
| 218 | + # extract the current time reference string |
| 219 | + time_part = parts[1] |
| 220 | + |
| 221 | + # get start and end indexes of the time reference |
| 222 | + time_start_index = quote.find(time_part) |
| 223 | + time_end_index = time_start_index + len(time_part) |
| 224 | + |
| 225 | + # split the quote text into lines with a maximum width that fits |
| 226 | + # on the MagTag display |
| 227 | + quote_lines = wrap_text_to_pixels( |
| 228 | + quote, |
| 229 | + display.width - 4, |
| 230 | + terminalio.FONT, |
| 231 | + outline_accent_ranges=[ |
| 232 | + (time_start_index, time_end_index, quote_lbl.outline_size) |
| 233 | + ], |
| 234 | + ) |
| 235 | + |
| 236 | + # remove previous accents |
| 237 | + quote_lbl.clear_accent_ranges() |
| 238 | + |
| 239 | + # find the index of the first line we want to show. |
| 240 | + # only relevant for long quotes, short ones will be shown in full |
| 241 | + first_line_to_show = find_lines_to_show(quote_lines, time_part) |
| 242 | + |
| 243 | + # Temporary version of final visible quote joined with spaces instead of newlines, |
| 244 | + # so we can search for the time_part without worrying about potential newlines. |
| 245 | + shown_quote_with_spaces = " ".join( |
| 246 | + quote_lines[first_line_to_show : first_line_to_show + 7] |
| 247 | + ) |
| 248 | + |
| 249 | + # find the current time reference within the quote that will be shown |
| 250 | + time_start_index = shown_quote_with_spaces.find(time_part) |
| 251 | + time_end_index = time_start_index + len(time_part) |
| 252 | + |
| 253 | + # wrap the quote to be shown to multiple lines and set it on the label |
| 254 | + quote_lbl.text = "\n".join(quote_lines[first_line_to_show : first_line_to_show + 7]) |
| 255 | + |
| 256 | + # accent the part of the quote that references the current time |
| 257 | + quote_lbl.add_accent_range(time_start_index, time_end_index, 4, 3, "outline") |
| 258 | + |
| 259 | + # update the display and wait 60 seconds |
| 260 | + display.refresh() |
| 261 | + time.sleep(60) |
0 commit comments