/ 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