/ adafruit_pyportal.py
adafruit_pyportal.py
1 # The MIT License (MIT) 2 # 3 # Copyright (c) 2019 Limor Fried for Adafruit Industries, Kevin J. Walters 4 # 5 # Permission is hereby granted, free of charge, to any person obtaining a copy 6 # of this software and associated documentation files (the "Software"), to deal 7 # in the Software without restriction, including without limitation the rights 8 # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 # copies of the Software, and to permit persons to whom the Software is 10 # furnished to do so, subject to the following conditions: 11 # 12 # The above copyright notice and this permission notice shall be included in 13 # all copies or substantial portions of the Software. 14 # 15 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 # THE SOFTWARE. 22 """ 23 `adafruit_pyportal` 24 ================================================================================ 25 26 CircuitPython driver for Adafruit PyPortal. 27 28 29 * Author(s): Limor Fried, Kevin J. Walters 30 31 Implementation Notes 32 -------------------- 33 34 **Hardware:** 35 36 * `Adafruit PyPortal <https://www.adafruit.com/product/4116>`_ 37 38 **Software and Dependencies:** 39 40 * Adafruit CircuitPython firmware for the supported boards: 41 https://github.com/adafruit/circuitpython/releases 42 43 * Adafruit's Bus Device library: https://github.com/adafruit/Adafruit_CircuitPython_BusDevice 44 """ 45 46 import os 47 import time 48 import gc 49 import board 50 import busio 51 from digitalio import DigitalInOut 52 import pulseio 53 import neopixel 54 from adafruit_esp32spi import adafruit_esp32spi, adafruit_esp32spi_wifimanager 55 import adafruit_esp32spi.adafruit_esp32spi_socket as socket 56 from adafruit_bitmap_font import bitmap_font 57 import adafruit_requests as requests 58 import storage 59 import displayio 60 from adafruit_display_text.label import Label 61 import audioio 62 import audiocore 63 import rtc 64 import supervisor 65 from adafruit_io.adafruit_io import IO_HTTP, AdafruitIO_RequestError 66 import adafruit_sdcard 67 68 69 if hasattr(board, "TOUCH_XL"): 70 import adafruit_touchscreen 71 elif hasattr(board, "BUTTON_CLOCK"): 72 from adafruit_cursorcontrol.cursorcontrol import Cursor 73 from adafruit_cursorcontrol.cursorcontrol_cursormanager import CursorManager 74 75 76 try: 77 from secrets import secrets 78 except ImportError: 79 print( 80 """WiFi settings are kept in secrets.py, please add them there! 81 the secrets dictionary must contain 'ssid' and 'password' at a minimum""" 82 ) 83 raise 84 85 __version__ = "0.0.0-auto.0" 86 __repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_PyPortal.git" 87 88 # pylint: disable=line-too-long 89 # pylint: disable=too-many-lines 90 # you'll need to pass in an io username, width, height, format (bit depth), io key, and then url! 91 IMAGE_CONVERTER_SERVICE = "https://io.adafruit.com/api/v2/%s/integrations/image-formatter?x-aio-key=%s&width=%d&height=%d&output=BMP%d&url=%s" 92 # you'll need to pass in an io username and key 93 TIME_SERVICE = ( 94 "https://io.adafruit.com/api/v2/%s/integrations/time/strftime?x-aio-key=%s" 95 ) 96 # our strftime is %Y-%m-%d %H:%M:%S.%L %j %u %z %Z see http://strftime.net/ for decoding details 97 # See https://apidock.com/ruby/DateTime/strftime for full options 98 TIME_SERVICE_STRFTIME = ( 99 "&fmt=%25Y-%25m-%25d+%25H%3A%25M%3A%25S.%25L+%25j+%25u+%25z+%25Z" 100 ) 101 LOCALFILE = "local.txt" 102 # pylint: enable=line-too-long 103 104 105 class Fake_Requests: 106 """For faking 'requests' using a local file instead of the network.""" 107 108 def __init__(self, filename): 109 self._filename = filename 110 with open(filename, "r") as file: 111 self.text = file.read() 112 113 def json(self): 114 """json parsed version for local requests.""" 115 import json # pylint: disable=import-outside-toplevel 116 117 return json.loads(self.text) 118 119 120 class PyPortal: 121 """Class representing the Adafruit PyPortal. 122 123 :param url: The URL of your data source. Defaults to ``None``. 124 :param headers: The headers for authentication, typically used by Azure API's. 125 :param json_path: The list of json traversal to get data out of. Can be list of lists for 126 multiple data points. Defaults to ``None`` to not use json. 127 :param regexp_path: The list of regexp strings to get data out (use a single regexp group). Can 128 be list of regexps for multiple data points. Defaults to ``None`` to not 129 use regexp. 130 :param default_bg: The path to your default background image file or a hex color. 131 Defaults to 0x000000. 132 :param status_neopixel: The pin for the status NeoPixel. Use ``board.NEOPIXEL`` for the on-board 133 NeoPixel. Defaults to ``None``, not the status LED 134 :param str text_font: The path to your font file for your data text display. 135 :param text_position: The position of your extracted text on the display in an (x, y) tuple. 136 Can be a list of tuples for when there's a list of json_paths, for example 137 :param text_color: The color of the text, in 0xRRGGBB format. Can be a list of colors for when 138 there's multiple texts. Defaults to ``None``. 139 :param text_wrap: Whether or not to wrap text (for long text data chunks). Defaults to 140 ``False``, no wrapping. 141 :param text_maxlen: The max length of the text for text wrapping. Defaults to 0. 142 :param text_transform: A function that will be called on the text before display 143 :param json_transform: A function or a list of functions to call with the parsed JSON. 144 Changes and additions are permitted for the ``dict`` object. 145 :param image_json_path: The JSON traversal path for a background image to display. Defaults to 146 ``None``. 147 :param image_resize: What size to resize the image we got from the json_path, make this a tuple 148 of the width and height you want. Defaults to ``None``. 149 :param image_position: The position of the image on the display as an (x, y) tuple. Defaults to 150 ``None``. 151 :param image_dim_json_path: The JSON traversal path for the original dimensions of image tuple. 152 Used with fetch(). Defaults to ``None``. 153 :param success_callback: A function we'll call if you like, when we fetch data successfully. 154 Defaults to ``None``. 155 :param str caption_text: The text of your caption, a fixed text not changed by the data we get. 156 Defaults to ``None``. 157 :param str caption_font: The path to the font file for your caption. Defaults to ``None``. 158 :param caption_position: The position of your caption on the display as an (x, y) tuple. 159 Defaults to ``None``. 160 :param caption_color: The color of your caption. Must be a hex value, e.g. ``0x808000``. 161 :param image_url_path: The HTTP traversal path for a background image to display. 162 Defaults to ``None``. 163 :param esp: A passed ESP32 object, Can be used in cases where the ESP32 chip needs to be used 164 before calling the pyportal class. Defaults to ``None``. 165 :param busio.SPI external_spi: A previously declared spi object. Defaults to ``None``. 166 :param debug: Turn on debug print outs. Defaults to False. 167 168 """ 169 170 # pylint: disable=too-many-instance-attributes, too-many-locals, too-many-branches, too-many-statements 171 def __init__( 172 self, 173 *, 174 url=None, 175 headers=None, 176 json_path=None, 177 regexp_path=None, 178 default_bg=0x000000, 179 status_neopixel=None, 180 text_font=None, 181 text_position=None, 182 text_color=0x808080, 183 text_wrap=False, 184 text_maxlen=0, 185 text_transform=None, 186 json_transform=None, 187 image_json_path=None, 188 image_resize=None, 189 image_position=None, 190 image_dim_json_path=None, 191 caption_text=None, 192 caption_font=None, 193 caption_position=None, 194 caption_color=0x808080, 195 image_url_path=None, 196 success_callback=None, 197 esp=None, 198 external_spi=None, 199 debug=False 200 ): 201 202 self._debug = debug 203 204 try: 205 if hasattr(board, "TFT_BACKLIGHT"): 206 self._backlight = pulseio.PWMOut( 207 board.TFT_BACKLIGHT 208 ) # pylint: disable=no-member 209 elif hasattr(board, "TFT_LITE"): 210 self._backlight = pulseio.PWMOut( 211 board.TFT_LITE 212 ) # pylint: disable=no-member 213 except ValueError: 214 self._backlight = None 215 self.set_backlight(1.0) # turn on backlight 216 217 self._url = url 218 self._headers = headers 219 if json_path: 220 if isinstance(json_path[0], (list, tuple)): 221 self._json_path = json_path 222 else: 223 self._json_path = (json_path,) 224 else: 225 self._json_path = None 226 227 self._regexp_path = regexp_path 228 self._success_callback = success_callback 229 230 if status_neopixel: 231 self.neopix = neopixel.NeoPixel(status_neopixel, 1, brightness=0.2) 232 else: 233 self.neopix = None 234 self.neo_status(0) 235 236 try: 237 os.stat(LOCALFILE) 238 self._uselocal = True 239 except OSError: 240 self._uselocal = False 241 242 if self._debug: 243 print("Init display") 244 self.splash = displayio.Group(max_size=15) 245 246 if self._debug: 247 print("Init background") 248 self._bg_group = displayio.Group(max_size=1) 249 self._bg_file = None 250 self._default_bg = default_bg 251 self.splash.append(self._bg_group) 252 253 # show thank you and bootup file if available 254 for bootscreen in ("/thankyou.bmp", "/pyportal_startup.bmp"): 255 try: 256 os.stat(bootscreen) 257 board.DISPLAY.show(self.splash) 258 for i in range(100, -1, -1): # dim down 259 self.set_backlight(i / 100) 260 time.sleep(0.005) 261 self.set_background(bootscreen) 262 try: 263 board.DISPLAY.refresh(target_frames_per_second=60) 264 except AttributeError: 265 board.DISPLAY.wait_for_frame() 266 for i in range(100): # dim up 267 self.set_backlight(i / 100) 268 time.sleep(0.005) 269 time.sleep(2) 270 except OSError: 271 pass # they removed it, skip! 272 273 self._speaker_enable = DigitalInOut(board.SPEAKER_ENABLE) 274 self._speaker_enable.switch_to_output(False) 275 if hasattr(board, "AUDIO_OUT"): 276 self.audio = audioio.AudioOut(board.AUDIO_OUT) 277 elif hasattr(board, "SPEAKER"): 278 self.audio = audioio.AudioOut(board.SPEAKER) 279 else: 280 raise AttributeError("Board does not have a builtin speaker!") 281 try: 282 self.play_file("pyportal_startup.wav") 283 except OSError: 284 pass # they deleted the file, no biggie! 285 286 if esp: # If there was a passed ESP Object 287 if self._debug: 288 print("Passed ESP32 to PyPortal") 289 self._esp = esp 290 if external_spi: # If SPI Object Passed 291 spi = external_spi 292 else: # Else: Make ESP32 connection 293 spi = busio.SPI(board.SCK, board.MOSI, board.MISO) 294 else: 295 if self._debug: 296 print("Init ESP32") 297 esp32_ready = DigitalInOut(board.ESP_BUSY) 298 esp32_gpio0 = DigitalInOut(board.ESP_GPIO0) 299 esp32_reset = DigitalInOut(board.ESP_RESET) 300 esp32_cs = DigitalInOut(board.ESP_CS) 301 spi = busio.SPI(board.SCK, board.MOSI, board.MISO) 302 303 self._esp = adafruit_esp32spi.ESP_SPIcontrol( 304 spi, esp32_cs, esp32_ready, esp32_reset, esp32_gpio0 305 ) 306 # self._esp._debug = 1 307 for _ in range(3): # retries 308 try: 309 print("ESP firmware:", self._esp.firmware_version) 310 break 311 except RuntimeError: 312 print("Retrying ESP32 connection") 313 time.sleep(1) 314 self._esp.reset() 315 else: 316 raise RuntimeError("Was not able to find ESP32") 317 requests.set_socket(socket, self._esp) 318 319 if url and not self._uselocal: 320 self._connect_esp() 321 322 if self._debug: 323 print("My IP address is", self._esp.pretty_ip(self._esp.ip_address)) 324 325 # set the default background 326 self.set_background(self._default_bg) 327 board.DISPLAY.show(self.splash) 328 329 if self._debug: 330 print("Init SD Card") 331 sd_cs = DigitalInOut(board.SD_CS) 332 self._sdcard = None 333 try: 334 self._sdcard = adafruit_sdcard.SDCard(spi, sd_cs) 335 vfs = storage.VfsFat(self._sdcard) 336 storage.mount(vfs, "/sd") 337 except OSError as error: 338 print("No SD card found:", error) 339 340 self._qr_group = None 341 # Tracks whether we've hidden the background when we showed the QR code. 342 self._qr_only = False 343 344 if self._debug: 345 print("Init caption") 346 self._caption = None 347 if caption_font: 348 self._caption_font = bitmap_font.load_font(caption_font) 349 self.set_caption(caption_text, caption_position, caption_color) 350 351 if text_font: 352 if isinstance(text_position[0], (list, tuple)): 353 num = len(text_position) 354 if not text_wrap: 355 text_wrap = [0] * num 356 if not text_maxlen: 357 text_maxlen = [0] * num 358 if not text_transform: 359 text_transform = [None] * num 360 else: 361 num = 1 362 text_position = (text_position,) 363 text_color = (text_color,) 364 text_wrap = (text_wrap,) 365 text_maxlen = (text_maxlen,) 366 text_transform = (text_transform,) 367 self._text = [None] * num 368 self._text_color = [None] * num 369 self._text_position = [None] * num 370 self._text_wrap = [None] * num 371 self._text_maxlen = [None] * num 372 self._text_transform = [None] * num 373 self._text_font = bitmap_font.load_font(text_font) 374 if self._debug: 375 print("Loading font glyphs") 376 # self._text_font.load_glyphs(b'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' 377 # b'0123456789:/-_,. ') 378 gc.collect() 379 380 for i in range(num): 381 if self._debug: 382 print("Init text area", i) 383 self._text[i] = None 384 self._text_color[i] = text_color[i] 385 self._text_position[i] = text_position[i] 386 self._text_wrap[i] = text_wrap[i] 387 self._text_maxlen[i] = text_maxlen[i] 388 self._text_transform[i] = text_transform[i] 389 else: 390 self._text_font = None 391 self._text = None 392 393 # Add any JSON translators 394 self._json_transform = [] 395 if json_transform: 396 if callable(json_transform): 397 self._json_transform.append(json_transform) 398 else: 399 self._json_transform.extend(filter(callable, json_transform)) 400 401 self._image_json_path = image_json_path 402 self._image_url_path = image_url_path 403 self._image_resize = image_resize 404 self._image_position = image_position 405 self._image_dim_json_path = image_dim_json_path 406 if image_json_path or image_url_path: 407 if self._debug: 408 print("Init image path") 409 if not self._image_position: 410 self._image_position = (0, 0) # default to top corner 411 if not self._image_resize: 412 self._image_resize = ( 413 board.DISPLAY.width, 414 board.DISPLAY.height, 415 ) # default to full screen 416 if hasattr(board, "TOUCH_XL"): 417 if self._debug: 418 print("Init touchscreen") 419 # pylint: disable=no-member 420 self.touchscreen = adafruit_touchscreen.Touchscreen( 421 board.TOUCH_XL, 422 board.TOUCH_XR, 423 board.TOUCH_YD, 424 board.TOUCH_YU, 425 calibration=((5200, 59000), (5800, 57000)), 426 size=(board.DISPLAY.width, board.DISPLAY.height), 427 ) 428 # pylint: enable=no-member 429 430 self.set_backlight(1.0) # turn on backlight 431 elif hasattr(board, "BUTTON_CLOCK"): 432 if self._debug: 433 print("Init cursor") 434 self.mouse_cursor = Cursor( 435 board.DISPLAY, display_group=self.splash, cursor_speed=8 436 ) 437 self.mouse_cursor.hide() 438 self.cursor = CursorManager(self.mouse_cursor) 439 else: 440 raise AttributeError( 441 "PyPortal module requires either a touchscreen or gamepad." 442 ) 443 444 gc.collect() 445 446 def set_headers(self, headers): 447 """Set the headers used by fetch(). 448 449 :param headers: The new header dictionary 450 """ 451 self._headers = headers 452 453 def set_background(self, file_or_color, position=None): 454 """The background image to a bitmap file. 455 456 :param file_or_color: The filename of the chosen background image, or a hex color. 457 458 """ 459 print("Set background to ", file_or_color) 460 while self._bg_group: 461 self._bg_group.pop() 462 463 if not position: 464 position = (0, 0) # default in top corner 465 466 if not file_or_color: 467 return # we're done, no background desired 468 if self._bg_file: 469 self._bg_file.close() 470 if isinstance(file_or_color, str): # its a filenme: 471 self._bg_file = open(file_or_color, "rb") 472 background = displayio.OnDiskBitmap(self._bg_file) 473 try: 474 self._bg_sprite = displayio.TileGrid( 475 background, 476 pixel_shader=displayio.ColorConverter(), 477 position=position, 478 ) 479 except TypeError: 480 self._bg_sprite = displayio.TileGrid( 481 background, 482 pixel_shader=displayio.ColorConverter(), 483 x=position[0], 484 y=position[1], 485 ) 486 elif isinstance(file_or_color, int): 487 # Make a background color fill 488 color_bitmap = displayio.Bitmap( 489 board.DISPLAY.width, board.DISPLAY.height, 1 490 ) 491 color_palette = displayio.Palette(1) 492 color_palette[0] = file_or_color 493 try: 494 self._bg_sprite = displayio.TileGrid( 495 color_bitmap, pixel_shader=color_palette, position=(0, 0) 496 ) 497 except TypeError: 498 self._bg_sprite = displayio.TileGrid( 499 color_bitmap, 500 pixel_shader=color_palette, 501 x=position[0], 502 y=position[1], 503 ) 504 else: 505 raise RuntimeError("Unknown type of background") 506 self._bg_group.append(self._bg_sprite) 507 try: 508 board.DISPLAY.refresh(target_frames_per_second=60) 509 gc.collect() 510 except AttributeError: 511 board.DISPLAY.refresh_soon() 512 gc.collect() 513 board.DISPLAY.wait_for_frame() 514 515 def set_backlight(self, val): 516 """Adjust the TFT backlight. 517 518 :param val: The backlight brightness. Use a value between ``0`` and ``1``, where ``0`` is 519 off, and ``1`` is 100% brightness. 520 521 """ 522 val = max(0, min(1.0, val)) 523 if self._backlight: 524 self._backlight.duty_cycle = int(val * 65535) 525 else: 526 board.DISPLAY.auto_brightness = False 527 board.DISPLAY.brightness = val 528 529 def preload_font(self, glyphs=None): 530 # pylint: disable=line-too-long 531 """Preload font. 532 533 :param glyphs: The font glyphs to load. Defaults to ``None``, uses alphanumeric glyphs if 534 None. 535 536 """ 537 # pylint: enable=line-too-long 538 if not glyphs: 539 glyphs = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-!,. \"'?!" 540 print("Preloading font glyphs:", glyphs) 541 if self._text_font: 542 self._text_font.load_glyphs(glyphs) 543 544 def set_caption(self, caption_text, caption_position, caption_color): 545 # pylint: disable=line-too-long 546 """A caption. Requires setting ``caption_font`` in init! 547 548 :param caption_text: The text of the caption. 549 :param caption_position: The position of the caption text. 550 :param caption_color: The color of your caption text. Must be a hex value, e.g. 551 ``0x808000``. 552 553 """ 554 # pylint: enable=line-too-long 555 if self._debug: 556 print("Setting caption to", caption_text) 557 558 if (not caption_text) or (not self._caption_font) or (not caption_position): 559 return # nothing to do! 560 561 if self._caption: 562 self._caption._update_text( # pylint: disable=protected-access 563 str(caption_text) 564 ) 565 try: 566 board.DISPLAY.refresh(target_frames_per_second=60) 567 except AttributeError: 568 board.DISPLAY.refresh_soon() 569 board.DISPLAY.wait_for_frame() 570 return 571 572 self._caption = Label(self._caption_font, text=str(caption_text)) 573 self._caption.x = caption_position[0] 574 self._caption.y = caption_position[1] 575 self._caption.color = caption_color 576 self.splash.append(self._caption) 577 578 def set_text(self, val, index=0): 579 """Display text, with indexing into our list of text boxes. 580 581 :param str val: The text to be displayed 582 :param index: Defaults to 0. 583 584 """ 585 if self._text_font: 586 string = str(val) 587 if self._text_maxlen[index]: 588 string = string[: self._text_maxlen[index]] 589 if self._text[index]: 590 # print("Replacing text area with :", string) 591 # self._text[index].text = string 592 # return 593 try: 594 text_index = self.splash.index(self._text[index]) 595 except AttributeError: 596 for i in range(len(self.splash)): 597 if self.splash[i] == self._text[index]: 598 text_index = i 599 break 600 601 self._text[index] = Label(self._text_font, text=string) 602 self._text[index].color = self._text_color[index] 603 self._text[index].x = self._text_position[index][0] 604 self._text[index].y = self._text_position[index][1] 605 self.splash[text_index] = self._text[index] 606 return 607 608 if self._text_position[index]: # if we want it placed somewhere... 609 print("Making text area with string:", string) 610 self._text[index] = Label(self._text_font, text=string) 611 self._text[index].color = self._text_color[index] 612 self._text[index].x = self._text_position[index][0] 613 self._text[index].y = self._text_position[index][1] 614 self.splash.append(self._text[index]) 615 616 def neo_status(self, value): 617 """The status NeoPixel. 618 619 :param value: The color to change the NeoPixel. 620 621 """ 622 if self.neopix: 623 self.neopix.fill(value) 624 625 def play_file(self, file_name, wait_to_finish=True): 626 """Play a wav file. 627 628 :param str file_name: The name of the wav file to play on the speaker. 629 630 """ 631 wavfile = open(file_name, "rb") 632 wavedata = audiocore.WaveFile(wavfile) 633 self._speaker_enable.value = True 634 self.audio.play(wavedata) 635 if not wait_to_finish: 636 return 637 while self.audio.playing: 638 pass 639 wavfile.close() 640 self._speaker_enable.value = False 641 642 @staticmethod 643 def _json_traverse(json, path): 644 value = json 645 for x in path: 646 value = value[x] 647 gc.collect() 648 return value 649 650 def get_local_time(self, location=None): 651 # pylint: disable=line-too-long 652 """Fetch and "set" the local time of this microcontroller to the local time at the location, using an internet time API. 653 654 :param str location: Your city and country, e.g. ``"New York, US"``. 655 656 """ 657 # pylint: enable=line-too-long 658 self._connect_esp() 659 api_url = None 660 try: 661 aio_username = secrets["aio_username"] 662 aio_key = secrets["aio_key"] 663 except KeyError: 664 raise KeyError( 665 "\n\nOur time service requires a login/password to rate-limit. Please register for a free adafruit.io account and place the user/key in your secrets file under 'aio_username' and 'aio_key'" # pylint: disable=line-too-long 666 ) 667 668 location = secrets.get("timezone", location) 669 if location: 670 print("Getting time for timezone", location) 671 api_url = (TIME_SERVICE + "&tz=%s") % (aio_username, aio_key, location) 672 else: # we'll try to figure it out from the IP address 673 print("Getting time from IP address") 674 api_url = TIME_SERVICE % (aio_username, aio_key) 675 api_url += TIME_SERVICE_STRFTIME 676 try: 677 response = requests.get(api_url, timeout=10) 678 if response.status_code != 200: 679 raise ValueError(response.text) 680 if self._debug: 681 print("Time request: ", api_url) 682 print("Time reply: ", response.text) 683 times = response.text.split(" ") 684 the_date = times[0] 685 the_time = times[1] 686 year_day = int(times[2]) 687 week_day = int(times[3]) 688 is_dst = None # no way to know yet 689 except KeyError: 690 raise KeyError( 691 "Was unable to lookup the time, try setting secrets['timezone'] according to http://worldtimeapi.org/timezones" # pylint: disable=line-too-long 692 ) 693 year, month, mday = [int(x) for x in the_date.split("-")] 694 the_time = the_time.split(".")[0] 695 hours, minutes, seconds = [int(x) for x in the_time.split(":")] 696 now = time.struct_time( 697 (year, month, mday, hours, minutes, seconds, week_day, year_day, is_dst) 698 ) 699 print(now) 700 rtc.RTC().datetime = now 701 702 # now clean up 703 response.close() 704 response = None 705 gc.collect() 706 707 def wget(self, url, filename, *, chunk_size=12000): 708 """Download a url and save to filename location, like the command wget. 709 710 :param url: The URL from which to obtain the data. 711 :param filename: The name of the file to save the data to. 712 :param chunk_size: how much data to read/write at a time. 713 714 """ 715 print("Fetching stream from", url) 716 717 self.neo_status((100, 100, 0)) 718 r = requests.get(url, stream=True) 719 720 if self._debug: 721 print(r.headers) 722 content_length = int(r.headers["content-length"]) 723 remaining = content_length 724 print("Saving data to ", filename) 725 stamp = time.monotonic() 726 file = open(filename, "wb") 727 for i in r.iter_content(min(remaining, chunk_size)): # huge chunks! 728 self.neo_status((0, 100, 100)) 729 remaining -= len(i) 730 file.write(i) 731 if self._debug: 732 print( 733 "Read %d bytes, %d remaining" 734 % (content_length - remaining, remaining) 735 ) 736 else: 737 print(".", end="") 738 if not remaining: 739 break 740 self.neo_status((100, 100, 0)) 741 file.close() 742 743 r.close() 744 stamp = time.monotonic() - stamp 745 print( 746 "Created file of %d bytes in %0.1f seconds" % (os.stat(filename)[6], stamp) 747 ) 748 self.neo_status((0, 0, 0)) 749 if not content_length == os.stat(filename)[6]: 750 raise RuntimeError 751 752 def _connect_esp(self): 753 self.neo_status((0, 0, 100)) 754 while not self._esp.is_connected: 755 # secrets dictionary must contain 'ssid' and 'password' at a minimum 756 print("Connecting to AP", secrets["ssid"]) 757 if secrets["ssid"] == "CHANGE ME" or secrets["password"] == "CHANGE ME": 758 change_me = "\n" + "*" * 45 759 change_me += "\nPlease update the 'secrets.py' file on your\n" 760 change_me += "CIRCUITPY drive to include your local WiFi\n" 761 change_me += "access point SSID name in 'ssid' and SSID\n" 762 change_me += "password in 'password'. Then save to reload!\n" 763 change_me += "*" * 45 764 raise OSError(change_me) 765 self.neo_status((100, 0, 0)) # red = not connected 766 try: 767 self._esp.connect(secrets) 768 except RuntimeError as error: 769 print("Could not connect to internet", error) 770 print("Retrying in 3 seconds...") 771 time.sleep(3) 772 773 @staticmethod 774 def image_converter_url(image_url, width, height, color_depth=16): 775 """Generate a converted image url from the url passed in, 776 with the given width and height. aio_username and aio_key must be 777 set in secrets.""" 778 try: 779 aio_username = secrets["aio_username"] 780 aio_key = secrets["aio_key"] 781 except KeyError: 782 raise KeyError( 783 "\n\nOur image converter service require a login/password to rate-limit. Please register for a free adafruit.io account and place the user/key in your secrets file under 'aio_username' and 'aio_key'" # pylint: disable=line-too-long 784 ) 785 786 return IMAGE_CONVERTER_SERVICE % ( 787 aio_username, 788 aio_key, 789 width, 790 height, 791 color_depth, 792 image_url, 793 ) 794 795 def sd_check(self): 796 """Returns True if there is an SD card preset and False 797 if there is no SD card. The _sdcard value is set in _init 798 """ 799 if self._sdcard: 800 return True 801 return False 802 803 def push_to_io(self, feed_key, data): 804 # pylint: disable=line-too-long 805 """Push data to an adafruit.io feed 806 807 :param str feed_key: Name of feed key to push data to. 808 :param data: data to send to feed 809 810 """ 811 # pylint: enable=line-too-long 812 813 try: 814 aio_username = secrets["aio_username"] 815 aio_key = secrets["aio_key"] 816 except KeyError: 817 raise KeyError( 818 "Adafruit IO secrets are kept in secrets.py, please add them there!\n\n" 819 ) 820 821 wifi = adafruit_esp32spi_wifimanager.ESPSPI_WiFiManager( 822 self._esp, secrets, None 823 ) 824 io_client = IO_HTTP(aio_username, aio_key, wifi) 825 826 while True: 827 try: 828 feed_id = io_client.get_feed(feed_key) 829 except AdafruitIO_RequestError: 830 # If no feed exists, create one 831 feed_id = io_client.create_new_feed(feed_key) 832 except RuntimeError as exception: 833 print("An error occured, retrying! 1 -", exception) 834 continue 835 break 836 837 while True: 838 try: 839 io_client.send_data(feed_id["key"], data) 840 except RuntimeError as exception: 841 print("An error occured, retrying! 2 -", exception) 842 continue 843 except NameError as exception: 844 print(feed_id["key"], data, exception) 845 continue 846 break 847 848 def fetch(self, refresh_url=None, timeout=10): 849 """Fetch data from the url we initialized with, perfom any parsing, 850 and display text or graphics. This function does pretty much everything 851 Optionally update the URL 852 """ 853 if refresh_url: 854 self._url = refresh_url 855 json_out = None 856 image_url = None 857 values = [] 858 859 gc.collect() 860 if self._debug: 861 print("Free mem: ", gc.mem_free()) # pylint: disable=no-member 862 863 r = None 864 if self._uselocal: 865 print("*** USING LOCALFILE FOR DATA - NOT INTERNET!!! ***") 866 r = Fake_Requests(LOCALFILE) 867 868 if not r: 869 self._connect_esp() 870 # great, lets get the data 871 print("Retrieving data...", end="") 872 self.neo_status((100, 100, 0)) # yellow = fetching data 873 gc.collect() 874 r = requests.get(self._url, headers=self._headers, timeout=timeout) 875 gc.collect() 876 self.neo_status((0, 0, 100)) # green = got data 877 print("Reply is OK!") 878 879 if self._debug: 880 print(r.text) 881 882 if self._image_json_path or self._json_path: 883 try: 884 gc.collect() 885 json_out = r.json() 886 gc.collect() 887 except ValueError: # failed to parse? 888 print("Couldn't parse json: ", r.text) 889 raise 890 except MemoryError: 891 supervisor.reload() 892 893 if self._regexp_path: 894 import re # pylint: disable=import-outside-toplevel 895 896 if self._image_url_path: 897 image_url = self._image_url_path 898 899 # optional JSON post processing, apply any transformations 900 # these MAY change/add element 901 for idx, json_transform in enumerate(self._json_transform): 902 try: 903 json_transform(json_out) 904 except Exception as error: 905 print("Exception from json_transform: ", idx, error) 906 raise 907 908 # extract desired text/values from json 909 if self._json_path: 910 for path in self._json_path: 911 try: 912 values.append(PyPortal._json_traverse(json_out, path)) 913 except KeyError: 914 print(json_out) 915 raise 916 elif self._regexp_path: 917 for regexp in self._regexp_path: 918 values.append(re.search(regexp, r.text).group(1)) 919 else: 920 values = r.text 921 922 if self._image_json_path: 923 try: 924 image_url = PyPortal._json_traverse(json_out, self._image_json_path) 925 except KeyError as error: 926 print("Error finding image data. '" + error.args[0] + "' not found.") 927 self.set_background(self._default_bg) 928 929 iwidth = 0 930 iheight = 0 931 if self._image_dim_json_path: 932 iwidth = int( 933 PyPortal._json_traverse(json_out, self._image_dim_json_path[0]) 934 ) 935 iheight = int( 936 PyPortal._json_traverse(json_out, self._image_dim_json_path[1]) 937 ) 938 print("image dim:", iwidth, iheight) 939 940 # we're done with the requests object, lets delete it so we can do more! 941 json_out = None 942 r = None 943 gc.collect() 944 945 if image_url: 946 try: 947 print("original URL:", image_url) 948 if iwidth < iheight: 949 image_url = self.image_converter_url( 950 image_url, 951 int( 952 self._image_resize[1] 953 * self._image_resize[1] 954 / self._image_resize[0] 955 ), 956 self._image_resize[1], 957 ) 958 else: 959 image_url = self.image_converter_url( 960 image_url, self._image_resize[0], self._image_resize[1] 961 ) 962 print("convert URL:", image_url) 963 # convert image to bitmap and cache 964 # print("**not actually wgetting**") 965 filename = "/cache.bmp" 966 chunk_size = 4096 # default chunk size is 12K (for QSPI) 967 if self._sdcard: 968 filename = "/sd" + filename 969 chunk_size = 512 # current bug in big SD writes -> stick to 1 block 970 try: 971 self.wget(image_url, filename, chunk_size=chunk_size) 972 except OSError as error: 973 print(error) 974 raise OSError( 975 """\n\nNo writable filesystem found for saving datastream. Insert an SD card or set internal filesystem to be unsafe by setting 'disable_concurrent_write_protection' in the mount options in boot.py""" # pylint: disable=line-too-long 976 ) 977 except RuntimeError as error: 978 print(error) 979 raise RuntimeError("wget didn't write a complete file") 980 if iwidth < iheight: 981 pwidth = int( 982 self._image_resize[1] 983 * self._image_resize[1] 984 / self._image_resize[0] 985 ) 986 self.set_background( 987 filename, 988 ( 989 self._image_position[0] 990 + int((self._image_resize[0] - pwidth) / 2), 991 self._image_position[1], 992 ), 993 ) 994 else: 995 self.set_background(filename, self._image_position) 996 997 except ValueError as error: 998 print("Error displaying cached image. " + error.args[0]) 999 self.set_background(self._default_bg) 1000 finally: 1001 image_url = None 1002 gc.collect() 1003 1004 # if we have a callback registered, call it now 1005 if self._success_callback: 1006 self._success_callback(values) 1007 1008 # fill out all the text blocks 1009 if self._text: 1010 for i in range(len(self._text)): 1011 string = None 1012 if self._text_transform[i]: 1013 func = self._text_transform[i] 1014 string = func(values[i]) 1015 else: 1016 try: 1017 string = "{:,d}".format(int(values[i])) 1018 except (TypeError, ValueError): 1019 string = values[i] # ok its a string 1020 if self._debug: 1021 print("Drawing text", string) 1022 if self._text_wrap[i]: 1023 if self._debug: 1024 print("Wrapping text") 1025 lines = PyPortal.wrap_nicely(string, self._text_wrap[i]) 1026 string = "\n".join(lines) 1027 self.set_text(string, index=i) 1028 if len(values) == 1: 1029 return values[0] 1030 return values 1031 1032 def show_QR( 1033 self, qr_data, *, qr_size=1, x=0, y=0, hide_background=False 1034 ): # pylint: disable=invalid-name 1035 """Display a QR code on the TFT 1036 1037 :param qr_data: The data for the QR code. 1038 :param int qr_size: The scale of the QR code. 1039 :param x: The x position of upper left corner of the QR code on the display. 1040 :param y: The y position of upper left corner of the QR code on the display. 1041 :param hide_background: Show the QR code on a black background if True. 1042 1043 """ 1044 import adafruit_miniqr # pylint: disable=import-outside-toplevel 1045 1046 # generate the QR code 1047 qrcode = adafruit_miniqr.QRCode() 1048 qrcode.add_data(qr_data) 1049 qrcode.make() 1050 1051 # monochrome (2 color) palette 1052 palette = displayio.Palette(2) 1053 palette[0] = 0xFFFFFF 1054 palette[1] = 0x000000 1055 1056 # pylint: disable=invalid-name 1057 # bitmap the size of the matrix, plus border, monochrome (2 colors) 1058 qr_bitmap = displayio.Bitmap( 1059 qrcode.matrix.width + 2, qrcode.matrix.height + 2, 2 1060 ) 1061 for i in range(qr_bitmap.width * qr_bitmap.height): 1062 qr_bitmap[i] = 0 1063 1064 # transcribe QR code into bitmap 1065 for xx in range(qrcode.matrix.width): 1066 for yy in range(qrcode.matrix.height): 1067 qr_bitmap[xx + 1, yy + 1] = 1 if qrcode.matrix[xx, yy] else 0 1068 1069 # display the QR code 1070 qr_sprite = displayio.TileGrid(qr_bitmap, pixel_shader=palette) 1071 if self._qr_group: 1072 try: 1073 self._qr_group.pop() 1074 except IndexError: # later test if empty 1075 pass 1076 else: 1077 self._qr_group = displayio.Group() 1078 self.splash.append(self._qr_group) 1079 self._qr_group.scale = qr_size 1080 self._qr_group.x = x 1081 self._qr_group.y = y 1082 self._qr_group.append(qr_sprite) 1083 if hide_background: 1084 board.DISPLAY.show(self._qr_group) 1085 self._qr_only = hide_background 1086 1087 def hide_QR(self): # pylint: disable=invalid-name 1088 """Clear any QR codes that are currently on the screen 1089 """ 1090 1091 if self._qr_only: 1092 board.DISPLAY.show(self.splash) 1093 else: 1094 try: 1095 self._qr_group.pop() 1096 except (IndexError, AttributeError): # later test if empty 1097 pass 1098 1099 # return a list of lines with wordwrapping 1100 @staticmethod 1101 def wrap_nicely(string, max_chars): 1102 """A helper that will return a list of lines with word-break wrapping. 1103 1104 :param str string: The text to be wrapped. 1105 :param int max_chars: The maximum number of characters on a line before wrapping. 1106 1107 """ 1108 string = string.replace("\n", "").replace("\r", "") # strip confusing newlines 1109 words = string.split(" ") 1110 the_lines = [] 1111 the_line = "" 1112 for w in words: 1113 if len(the_line + " " + w) <= max_chars: 1114 the_line += " " + w 1115 else: 1116 the_lines.append(the_line) 1117 the_line = "" + w 1118 if the_line: # last line remaining 1119 the_lines.append(the_line) 1120 # remove first space from first line: 1121 the_lines[0] = the_lines[0][1:] 1122 return the_lines