|
| 1 | +# SPDX-FileCopyrightText: 2020 Melissa LeBlanc-Williams, written for Adafruit Industries |
| 2 | +# |
| 3 | +# SPDX-License-Identifier: Unlicense |
| 4 | +""" |
| 5 | +`adafruit_pyportal` |
| 6 | +================================================================================ |
| 7 | +
|
| 8 | +CircuitPython driver for Adafruit PyPortal. |
| 9 | +
|
| 10 | +* Author(s): Limor Fried, Kevin J. Walters, Melissa LeBlanc-Williams |
| 11 | +
|
| 12 | +Implementation Notes |
| 13 | +-------------------- |
| 14 | +
|
| 15 | +**Hardware:** |
| 16 | +
|
| 17 | +* `Adafruit PyPortal <https://www.adafruit.com/product/4116>`_ |
| 18 | +
|
| 19 | +**Software and Dependencies:** |
| 20 | +
|
| 21 | +* Adafruit CircuitPython firmware for the supported boards: |
| 22 | + https://github.com/adafruit/circuitpython/releases |
| 23 | +
|
| 24 | +""" |
| 25 | + |
| 26 | +import os |
| 27 | +import gc |
| 28 | +import time |
| 29 | +import board |
| 30 | +import terminalio |
| 31 | +import supervisor |
| 32 | +from adafruit_portalbase import PortalBase |
| 33 | +from adafruit_pyportal.network import Network, CONTENT_JSON, CONTENT_TEXT |
| 34 | +from adafruit_pyportal.graphics import Graphics |
| 35 | +from adafruit_pyportal.peripherals import Peripherals |
| 36 | + |
| 37 | +if hasattr(board, "TOUCH_XL"): |
| 38 | + import adafruit_touchscreen |
| 39 | +elif hasattr(board, "BUTTON_CLOCK"): |
| 40 | + from adafruit_cursorcontrol.cursorcontrol import Cursor |
| 41 | + from adafruit_cursorcontrol.cursorcontrol_cursormanager import CursorManager |
| 42 | + |
| 43 | +__version__ = "0.0.0-auto.0" |
| 44 | +__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_PyPortal.git" |
| 45 | + |
| 46 | + |
| 47 | +class PyPortal(PortalBase): |
| 48 | + """Class representing the Adafruit PyPortal. |
| 49 | +
|
| 50 | + :param url: The URL of your data source. Defaults to ``None``. |
| 51 | + :param headers: The headers for authentication, typically used by Azure API's. |
| 52 | + :param json_path: The list of json traversal to get data out of. Can be list of lists for |
| 53 | + multiple data points. Defaults to ``None`` to not use json. |
| 54 | + :param regexp_path: The list of regexp strings to get data out (use a single regexp group). Can |
| 55 | + be list of regexps for multiple data points. Defaults to ``None`` to not |
| 56 | + use regexp. |
| 57 | + :param convert_image: Determine whether or not to use the AdafruitIO image converter service. |
| 58 | + Set as False if your image is already resized. Defaults to True. |
| 59 | + :param default_bg: The path to your default background image file or a hex color. |
| 60 | + Defaults to 0x000000. |
| 61 | + :param status_neopixel: The pin for the status NeoPixel. Use ``board.NEOPIXEL`` for the on-board |
| 62 | + NeoPixel. Defaults to ``None``, not the status LED |
| 63 | + :param str text_font: The path to your font file for your data text display. |
| 64 | + :param text_position: The position of your extracted text on the display in an (x, y) tuple. |
| 65 | + Can be a list of tuples for when there's a list of json_paths, for example |
| 66 | + :param text_color: The color of the text, in 0xRRGGBB format. Can be a list of colors for when |
| 67 | + there's multiple texts. Defaults to ``None``. |
| 68 | + :param text_wrap: Whether or not to wrap text (for long text data chunks). Defaults to |
| 69 | + ``False``, no wrapping. |
| 70 | + :param text_maxlen: The max length of the text for text wrapping. Defaults to 0. |
| 71 | + :param text_transform: A function that will be called on the text before display |
| 72 | + :param int text_scale: The factor to scale the default size of the text by |
| 73 | + :param json_transform: A function or a list of functions to call with the parsed JSON. |
| 74 | + Changes and additions are permitted for the ``dict`` object. |
| 75 | + :param image_json_path: The JSON traversal path for a background image to display. Defaults to |
| 76 | + ``None``. |
| 77 | + :param image_resize: What size to resize the image we got from the json_path, make this a tuple |
| 78 | + of the width and height you want. Defaults to ``None``. |
| 79 | + :param image_position: The position of the image on the display as an (x, y) tuple. Defaults to |
| 80 | + ``None``. |
| 81 | + :param image_dim_json_path: The JSON traversal path for the original dimensions of image tuple. |
| 82 | + Used with fetch(). Defaults to ``None``. |
| 83 | + :param success_callback: A function we'll call if you like, when we fetch data successfully. |
| 84 | + Defaults to ``None``. |
| 85 | + :param str caption_text: The text of your caption, a fixed text not changed by the data we get. |
| 86 | + Defaults to ``None``. |
| 87 | + :param str caption_font: The path to the font file for your caption. Defaults to ``None``. |
| 88 | + :param caption_position: The position of your caption on the display as an (x, y) tuple. |
| 89 | + Defaults to ``None``. |
| 90 | + :param caption_color: The color of your caption. Must be a hex value, e.g. ``0x808000``. |
| 91 | + :param image_url_path: The HTTP traversal path for a background image to display. |
| 92 | + Defaults to ``None``. |
| 93 | + :param esp: A passed ESP32 object, Can be used in cases where the ESP32 chip needs to be used |
| 94 | + before calling the pyportal class. Defaults to ``None``. |
| 95 | + :param busio.SPI external_spi: A previously declared spi object. Defaults to ``None``. |
| 96 | + :param debug: Turn on debug print outs. Defaults to False. |
| 97 | +
|
| 98 | + """ |
| 99 | + |
| 100 | + # pylint: disable=too-many-instance-attributes, too-many-locals, too-many-branches, too-many-statements |
| 101 | + def __init__( |
| 102 | + self, |
| 103 | + *, |
| 104 | + url=None, |
| 105 | + headers=None, |
| 106 | + json_path=None, |
| 107 | + regexp_path=None, |
| 108 | + convert_image=True, |
| 109 | + default_bg=0x000000, |
| 110 | + status_neopixel=None, |
| 111 | + text_font=terminalio.FONT, |
| 112 | + text_position=None, |
| 113 | + text_color=0x808080, |
| 114 | + text_wrap=False, |
| 115 | + text_maxlen=0, |
| 116 | + text_transform=None, |
| 117 | + text_scale=1, |
| 118 | + json_transform=None, |
| 119 | + image_json_path=None, |
| 120 | + image_resize=None, |
| 121 | + image_position=None, |
| 122 | + image_dim_json_path=None, |
| 123 | + caption_text=None, |
| 124 | + caption_font=None, |
| 125 | + caption_position=None, |
| 126 | + caption_color=0x808080, |
| 127 | + image_url_path=None, |
| 128 | + success_callback=None, |
| 129 | + esp=None, |
| 130 | + external_spi=None, |
| 131 | + debug=False |
| 132 | + ): |
| 133 | + |
| 134 | + graphics = Graphics( |
| 135 | + default_bg=default_bg, |
| 136 | + debug=debug, |
| 137 | + ) |
| 138 | + |
| 139 | + self._default_bg = default_bg |
| 140 | + |
| 141 | + if external_spi: # If SPI Object Passed |
| 142 | + spi = external_spi |
| 143 | + else: # Else: Make ESP32 connection |
| 144 | + spi = board.SPI() |
| 145 | + |
| 146 | + if image_json_path or image_url_path: |
| 147 | + if debug: |
| 148 | + print("Init image path") |
| 149 | + if not image_position: |
| 150 | + image_position = (0, 0) # default to top corner |
| 151 | + if not image_resize: |
| 152 | + image_resize = ( |
| 153 | + self.display.width, |
| 154 | + self.display.height, |
| 155 | + ) # default to full screen |
| 156 | + |
| 157 | + network = Network( |
| 158 | + status_neopixel=status_neopixel, |
| 159 | + esp=esp, |
| 160 | + external_spi=spi, |
| 161 | + extract_values=False, |
| 162 | + convert_image=convert_image, |
| 163 | + image_url_path=image_url_path, |
| 164 | + image_json_path=image_json_path, |
| 165 | + image_resize=image_resize, |
| 166 | + image_position=image_position, |
| 167 | + image_dim_json_path=image_dim_json_path, |
| 168 | + debug=debug, |
| 169 | + ) |
| 170 | + |
| 171 | + self.url = url |
| 172 | + |
| 173 | + super().__init__( |
| 174 | + network, |
| 175 | + graphics, |
| 176 | + url=url, |
| 177 | + headers=headers, |
| 178 | + json_path=json_path, |
| 179 | + regexp_path=regexp_path, |
| 180 | + json_transform=json_transform, |
| 181 | + success_callback=success_callback, |
| 182 | + debug=debug, |
| 183 | + ) |
| 184 | + |
| 185 | + # Convenience Shortcuts for compatibility |
| 186 | + self.peripherals = Peripherals( |
| 187 | + spi, display=self.display, splash_group=self.splash, debug=debug |
| 188 | + ) |
| 189 | + self.set_backlight = self.peripherals.set_backlight |
| 190 | + self.sd_check = self.peripherals.sd_check |
| 191 | + self.play_file = self.peripherals.play_file |
| 192 | + |
| 193 | + self.image_converter_url = self.network.image_converter_url |
| 194 | + self.wget = self.network.wget |
| 195 | + # pylint: disable=invalid-name |
| 196 | + self.show_QR = self.graphics.qrcode |
| 197 | + self.hide_QR = self.graphics.hide_QR |
| 198 | + # pylint: enable=invalid-name |
| 199 | + |
| 200 | + if hasattr(self.peripherals, "touchscreen"): |
| 201 | + self.touchscreen = self.peripherals.touchscreen |
| 202 | + if hasattr(self.peripherals, "mouse_cursor"): |
| 203 | + self.mouse_cursor = self.peripherals.mouse_cursor |
| 204 | + if hasattr(self.peripherals, "cursor"): |
| 205 | + self.cursor = self.peripherals.cursor |
| 206 | + |
| 207 | + # show thank you and bootup file if available |
| 208 | + for bootscreen in ("/thankyou.bmp", "/pyportal_startup.bmp"): |
| 209 | + try: |
| 210 | + os.stat(bootscreen) |
| 211 | + for i in range(100, -1, -1): # dim down |
| 212 | + self.set_backlight(i / 100) |
| 213 | + time.sleep(0.005) |
| 214 | + self.set_background(bootscreen) |
| 215 | + try: |
| 216 | + self.display.refresh(target_frames_per_second=60) |
| 217 | + except AttributeError: |
| 218 | + self.display.wait_for_frame() |
| 219 | + for i in range(100): # dim up |
| 220 | + self.set_backlight(i / 100) |
| 221 | + time.sleep(0.005) |
| 222 | + time.sleep(2) |
| 223 | + except OSError: |
| 224 | + pass # they removed it, skip! |
| 225 | + |
| 226 | + try: |
| 227 | + self.peripherals.play_file("pyportal_startup.wav") |
| 228 | + except OSError: |
| 229 | + pass # they deleted the file, no biggie! |
| 230 | + |
| 231 | + if default_bg is not None: |
| 232 | + self.graphics.set_background(default_bg) |
| 233 | + |
| 234 | + if self._debug: |
| 235 | + print("Init caption") |
| 236 | + if caption_font: |
| 237 | + self._caption_font = self._load_font(caption_font) |
| 238 | + self.set_caption(caption_text, caption_position, caption_color) |
| 239 | + |
| 240 | + if text_font: |
| 241 | + if text_position is not None and isinstance( |
| 242 | + text_position[0], (list, tuple) |
| 243 | + ): |
| 244 | + num = len(text_position) |
| 245 | + if not text_wrap: |
| 246 | + text_wrap = [0] * num |
| 247 | + if not text_maxlen: |
| 248 | + text_maxlen = [0] * num |
| 249 | + if not text_transform: |
| 250 | + text_transform = [None] * num |
| 251 | + if not isinstance(text_scale, (list, tuple)): |
| 252 | + text_scale = [text_scale] * num |
| 253 | + else: |
| 254 | + num = 1 |
| 255 | + text_position = (text_position,) |
| 256 | + text_color = (text_color,) |
| 257 | + text_wrap = (text_wrap,) |
| 258 | + text_maxlen = (text_maxlen,) |
| 259 | + text_transform = (text_transform,) |
| 260 | + text_scale = (text_scale,) |
| 261 | + for i in range(num): |
| 262 | + self.add_text( |
| 263 | + text_position=text_position[i], |
| 264 | + text_font=text_font, |
| 265 | + text_color=text_color[i], |
| 266 | + text_wrap=text_wrap[i], |
| 267 | + text_maxlen=text_maxlen[i], |
| 268 | + text_transform=text_transform[i], |
| 269 | + text_scale=text_scale[i], |
| 270 | + ) |
| 271 | + else: |
| 272 | + self._text_font = None |
| 273 | + self._text = None |
| 274 | + |
| 275 | + gc.collect() |
| 276 | + |
| 277 | + def set_caption(self, caption_text, caption_position, caption_color): |
| 278 | + # pylint: disable=line-too-long |
| 279 | + """A caption. Requires setting ``caption_font`` in init! |
| 280 | +
|
| 281 | + :param caption_text: The text of the caption. |
| 282 | + :param caption_position: The position of the caption text. |
| 283 | + :param caption_color: The color of your caption text. Must be a hex value, e.g. |
| 284 | + ``0x808000``. |
| 285 | + """ |
| 286 | + # pylint: enable=line-too-long |
| 287 | + if self._debug: |
| 288 | + print("Setting caption to", caption_text) |
| 289 | + |
| 290 | + if (not caption_text) or (not self._caption_font) or (not caption_position): |
| 291 | + return # nothing to do! |
| 292 | + |
| 293 | + index = self.add_text( |
| 294 | + text_position=caption_position, |
| 295 | + text_font=self._caption_font, |
| 296 | + text_color=caption_color, |
| 297 | + is_data=False, |
| 298 | + ) |
| 299 | + self.set_text(caption_text, index) |
| 300 | + |
| 301 | + def fetch(self, refresh_url=None, timeout=10): |
| 302 | + """Fetch data from the url we initialized with, perfom any parsing, |
| 303 | + and display text or graphics. This function does pretty much everything |
| 304 | + Optionally update the URL |
| 305 | + """ |
| 306 | + |
| 307 | + if refresh_url: |
| 308 | + self.url = refresh_url |
| 309 | + |
| 310 | + response = self.network.fetch(self.url, timeout=timeout) |
| 311 | + |
| 312 | + json_out = None |
| 313 | + content_type = self.network.check_response(response) |
| 314 | + json_path = self._json_path |
| 315 | + |
| 316 | + if content_type == CONTENT_JSON: |
| 317 | + if json_path is not None: |
| 318 | + # Drill down to the json path and set json_out as that node |
| 319 | + if isinstance(json_path, (list, tuple)) and ( |
| 320 | + not json_path or not isinstance(json_path[0], (list, tuple)) |
| 321 | + ): |
| 322 | + json_path = (json_path,) |
| 323 | + try: |
| 324 | + gc.collect() |
| 325 | + json_out = response.json() |
| 326 | + if self._debug: |
| 327 | + print(json_out) |
| 328 | + gc.collect() |
| 329 | + except ValueError: # failed to parse? |
| 330 | + print("Couldn't parse json: ", response.text) |
| 331 | + raise |
| 332 | + except MemoryError: |
| 333 | + supervisor.reload() |
| 334 | + |
| 335 | + try: |
| 336 | + filename, position = self.network.process_image( |
| 337 | + json_out, self.peripherals.sd_check() |
| 338 | + ) |
| 339 | + if filename and position is not None: |
| 340 | + self.graphics.set_background(filename, position) |
| 341 | + except ValueError as error: |
| 342 | + print("Error displaying cached image. " + error.args[0]) |
| 343 | + if self._default_bg is not None: |
| 344 | + self.graphics.set_background(self._default_bg) |
| 345 | + except KeyError as error: |
| 346 | + print("Error finding image data. '" + error.args[0] + "' not found.") |
| 347 | + self.set_background(self._default_bg) |
| 348 | + |
| 349 | + if content_type == CONTENT_JSON: |
| 350 | + values = self.network.process_json(json_out, json_path) |
| 351 | + elif content_type == CONTENT_TEXT: |
| 352 | + values = self.network.process_text(response.text, self._regexp_path) |
| 353 | + |
| 354 | + # if we have a callback registered, call it now |
| 355 | + if self._success_callback: |
| 356 | + self._success_callback(values) |
| 357 | + |
| 358 | + self._fill_text_labels(values) |
| 359 | + # Clean up |
| 360 | + json_out = None |
| 361 | + response = None |
| 362 | + gc.collect() |
| 363 | + |
| 364 | + return values |
0 commit comments