diff --git a/.github/workflows/micropython.yml b/.github/workflows/micropython.yml index c6be5a4..218fbad 100644 --- a/.github/workflows/micropython.yml +++ b/.github/workflows/micropython.yml @@ -63,22 +63,32 @@ jobs: shell: bash run: | source $CI_PROJECT_ROOT/ci/micropython.sh && ci_debug + source $(dpkg -L virtualenvwrapper | grep "virtualenvwrapper.sh") + mkvirtualenv "dir2uf2" ci_cmake_build ${{ matrix.name }} mv "$CI_BUILD_ROOT/${{ matrix.name }}.uf2" "$CI_BUILD_ROOT/$RELEASE_FILE.uf2" + mv "$CI_BUILD_ROOT/${{ matrix.name }}-with-filesystem.uf2" "$CI_BUILD_ROOT/$RELEASE_FILE-with-filesystem.uf2" - name: "Artifacts: Upload .uf2" uses: actions/upload-artifact@v4 with: name: ${{ env.RELEASE_FILE }}.uf2 path: ${{ env.CI_BUILD_ROOT }}/${{ env.RELEASE_FILE }}.uf2 + + - name: "Artifacts: Upload .uf2 (With Filesystem)" + uses: actions/upload-artifact@v4 + with: + name: ${{ env.RELEASE_FILE }}.uf2 + path: ${{ env.CI_BUILD_ROOT }}/${{ env.RELEASE_FILE }}-with-filesystem.uf2 - name: "Release: Upload .uf2" if: github.event_name == 'release' - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: softprops/action-gh-release@v1 + with: + files: ${{ env.CI_BUILD_ROOT }}/${{ env.RELEASE_FILE }}.uf2 + + - name: "Release: Upload .uf2 (With Filesystem)" + if: github.event_name == 'release' + uses: softprops/action-gh-release@v1 with: - asset_path: ${{ env.CI_BUILD_ROOT }}/${{ env.RELEASE_FILE }}.uf2 - upload_url: ${{ github.event.release.upload_url }} - asset_name: ${{ env.RELEASE_FILE }}.uf2 - asset_content_type: application/octet-stream + files: ${{ env.CI_BUILD_ROOT }}/${{ env.RELEASE_FILE }}-with-filesystem.uf2 diff --git a/boards/pico2_w_inky/manifest.txt b/boards/pico2_w_inky/manifest.txt new file mode 100644 index 0000000..05cb090 --- /dev/null +++ b/boards/pico2_w_inky/manifest.txt @@ -0,0 +1,3 @@ +*.py +lib/* +lib/*/* \ No newline at end of file diff --git a/boards/pico_w_inky/manifest.txt b/boards/pico_w_inky/manifest.txt new file mode 100644 index 0000000..05cb090 --- /dev/null +++ b/boards/pico_w_inky/manifest.txt @@ -0,0 +1,3 @@ +*.py +lib/* +lib/*/* \ No newline at end of file diff --git a/ci/micropython.sh b/ci/micropython.sh index 445e06a..6466293 100644 --- a/ci/micropython.sh +++ b/ci/micropython.sh @@ -70,7 +70,7 @@ function ci_micropython_build_mpy_cross { } function ci_apt_install_build_deps { - sudo apt update && sudo apt install ccache + sudo apt update && sudo apt install ccache python3-virtualenvwrapper virtualenvwrapper } function ci_prepare_all { @@ -98,7 +98,8 @@ function ci_cmake_configure { log_warning "Invalid board: $MICROPY_BOARD_DIR" return 1 fi - cmake -S $CI_BUILD_ROOT/micropython/ports/rp2 -B build-$BOARD \ + BUILD_DIR="$CI_BUILD_ROOT/build-$BOARD" + cmake -S $CI_BUILD_ROOT/micropython/ports/rp2 -B "$BUILD_DIR" \ -DPICOTOOL_FORCE_FETCH_FROM_GIT=1 \ -DPICO_BUILD_DOCS=0 \ -DPICO_NO_COPRO_DIS=1 \ @@ -112,13 +113,23 @@ function ci_cmake_configure { function ci_cmake_build { BOARD=$1 + MICROPY_BOARD_DIR=$CI_PROJECT_ROOT/boards/$BOARD + EXAMPLES_DIR=$CI_PROJECT_ROOT/examples/inkylauncher/ + TOOLS_DIR=$CI_BUILD_ROOT/tools + BUILD_DIR="$CI_BUILD_ROOT/build-$BOARD" ccache --zero-stats || true - cmake --build build-$BOARD -j 2 + cmake --build $BUILD_DIR -j 2 ccache --show-stats || true - if [ -d "tools/py_decl" ]; then + if [ -d "$TOOLS_DIR/py_decl" ]; then log_inform "Tools found, verifying .uf2 with py_decl..." - python3 tools/py_decl/py_decl.py --to-json --verify "build-$BOARD/firmware.uf2" + python3 "$TOOLS_DIR/py_decl/py_decl.py" --to-json --verify "$BUILD_DIR/firmware.uf2" fi log_inform "Copying .uf2 to $(pwd)/$BOARD.uf2" - cp build-$BOARD/firmware.uf2 $BOARD.uf2 + cp "$BUILD_DIR/firmware.uf2" $BOARD.uf2 + + if [ -f "$MICROPY_BOARD_DIR/manifest.txt" ] && [ -d "$TOOLS_DIR/dir2uf2" ]; then + log_inform "Creating $(pwd)/$BOARD-with-filesystem.uf2" + python3 -m pip install littlefs-python==0.12.0 + $TOOLS_DIR/dir2uf2/dir2uf2 --fs-compact --sparse --append-to "$(pwd)/$BOARD.uf2" --manifest "$MICROPY_BOARD_DIR/manifest.txt" --filename with-filesystem.uf2 "$EXAMPLES_DIR" + fi } diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..7f4c5be --- /dev/null +++ b/examples/README.md @@ -0,0 +1,110 @@ +# Pico Inky Frame MicroPython Examples + +- [PicoGraphics](#picographics) +- [Examples](#examples) + - [Button Test](#button-test) + - [Carbon Intensity](#carbon-intensity) + - [Daily Activity](#daily-activity) + - [Dithering](#dithering) + - [Image Gallery](#image-gallery) + - [LED PWM](#led-pwm) + - [News](#news) + - [PlaceKitten](#placekitten) + - [Quote of the Day](#quote-of-the-day) + - [Random Joke](#random-joke) + - [RTC Demo](#rtc-demo) + - [SD Card Test](#sd-card-test) + - [XKCD Daily](#xkcd-daily) + +## PicoGraphics + +You can draw on Inky Frame using our tiny PicoGraphics display library. +- [PicoGraphics MicroPython function reference](../../modules/picographics) + +## Examples + +The wireless examples need `network_manager.py` and `WIFI_CONFIG.py` from the `common` directory to be saved to your Pico W. Open up `WIFI_CONFIG.py` in Thonny to add your wifi details (and save it when you're done). + +You'll also need to install the `micropython-urllib.urequest` library using Thonny's 'Tools' > 'Manage Packages' or `common/lib/urllib` which contains a compiled `.mpy` version that uses less RAM. You should place this directory in `lib` on your Pico W. + +### Button Test +[button_test.py](button_test.py) + +Inky Frame's buttons (and the RTC alarm, busy signal from the screen and external trigger from the hack header) are connected to a shift register to help conserve pins, and to allow these inputs to wake the board up from sleep. + +This example demonstrates a simple way of reading when a button has been pushed by reading the shift register and checking if the bit in a specific position is 0 or 1. + +### Carbon Intensity +[carbon_intensity.py](carbon_intensity.py) + +This example connects to the Carbon Intensity API to give you a regional forecast of how your (UK) electricity is being generated and its carbon impact. + +Find out more at https://carbonintensity.org.uk/ + +### Daily Activity +[inky_frame_daily_activity.py](inky_frame_daily_activity.py) + +Generate a random activity from Bored API. + +### Dithering +[inky_frame_dithering.py](inky_frame_dithering.py) + +A basic example showing automatic dithering in action, as PicoGraphics tries to use Inky Frame's limited colour palette to match arbitrary colours. + +### Image Gallery +[/image_gallery](../inky_frame/image_gallery) + +This photo frame example displays local images on Inky Frame and lets you switch between them with the buttons. Use `image_gallery.py` if your images are stored on your Pico, or `image_gallery_sd.py` if the images are on your SD card. + +### LED PWM +[led_pwm.py](led_pwm.py) + +A basic example showing how you can control the brightness of Inky Frame's LEDs using PWM. + +### News +[inky_frame_news.py](inky_frame_news.py) + +Display headlines from BBC News. + +### PlaceKitten +[inky_frame_placekitten.py](inky_frame_placekitten.py) + +Download a random (from a small subset) image from PlaceKitten. + +### Quote of the Day +[inky_frame_quote_of_the_day.py](inky_frame_quote_of_the_day.py) + +Load the WikiQuotes Quote of the Day and display it. + +### Random Joke +[inky_frame_random_joke.py](inky_frame_random_joke.py) + +Load a random joke from JokeAPI.dev and display it. + +Jokes are rendered into images "offline" by our feed2image service for two reasons: + +1. Saves the Pico W having to process them +2. JokeAPI.dev needs TLS1.3 which Pico W does not support! + +For bugs/contributions or to complain about a joke, see: https://github.com/pimoroni/feed2image + +### RTC Demo +[inky_frame_rtc_demo.py](inky_frame_rtc_demo.py) + +A basic example that sets the time/date from an NTP server, syncs the Inky and Pico RTCs and and makes Inky Frame wake up on a timer. + +### SD Card Test +[sd_test.py](sd_test.py) + +This simple example shows how to read and write from the SD card on Inky Frame. + +### XKCD Daily +[inky_frame_xkcd_daily.py](inky_frame_xkcd_daily.py) + +Download and display the daily webcomic from https://xkcd.com/ + +The webcomic is rendered "offline" by our feed2image service since xkcd.com requires TLS1.3! + +For bugs/contributions see: https://github.com/pimoroni/feed2image + + diff --git a/examples/button_demo.py b/examples/button_demo.py new file mode 100644 index 0000000..8feed68 --- /dev/null +++ b/examples/button_demo.py @@ -0,0 +1,86 @@ +# This example shows you a simple, non-interrupt way of reading Inky Frame's buttons with a loop that checks to see if buttons are pressed. + +from pimoroni import ShiftRegister +# from picographics import PicoGraphics, DISPLAY_INKY_FRAME as DISPLAY # 5.7" +# from picographics import PicoGraphics, DISPLAY_INKY_FRAME_4 as DISPLAY # 4.0" +from picographics import PicoGraphics, DISPLAY_INKY_FRAME_7 as DISPLAY # 7.3" +from machine import Pin + +display = PicoGraphics(display=DISPLAY) + +display.set_font("bitmap8") + +# Inky Frame uses a shift register to read the buttons +SR_CLOCK = 8 +SR_LATCH = 9 +SR_OUT = 10 + +sr = ShiftRegister(SR_CLOCK, SR_LATCH, SR_OUT) + +# set up the button LEDs +button_a_led = Pin(11, Pin.OUT) +button_b_led = Pin(12, Pin.OUT) +button_c_led = Pin(13, Pin.OUT) +button_d_led = Pin(14, Pin.OUT) +button_e_led = Pin(15, Pin.OUT) + + +# a handy function we can call to clear the screen +# display.set_pen(1) is white and display.set_pen(0) is black +def clear(): + display.set_pen(1) + display.clear() + + +# set up +clear() +display.set_pen(0) +display.text("Press any button!", 10, 10, scale=4) +display.update() + +while True: + button_a_led.off() + button_b_led.off() + button_c_led.off() + button_d_led.off() + button_e_led.off() + + # read the shift register + # we can tell which button has been pressed by checking if a specific bit is 0 or 1 + result = sr.read() + button_a = sr[7] + button_b = sr[6] + button_c = sr[5] + button_d = sr[4] + button_e = sr[3] + + if button_a == 1: # if a button press is detected then... + button_a_led.on() + clear() # clear to white + display.set_pen(4) # change the pen colour + display.text("Button A pressed", 10, 10, scale=4) # display some text on the screen + display.update() # update the display + elif button_b == 1: + button_b_led.on() + clear() + display.set_pen(6) + display.text("Button B pressed", 10, 50, scale=4) + display.update() + elif button_c == 1: + button_c_led.on() + clear() + display.set_pen(5) + display.text("Button C pressed", 10, 90, scale=4) + display.update() + elif button_d == 1: + button_d_led.on() + clear() + display.set_pen(2) + display.text("Button D pressed", 10, 130, scale=4) + display.update() + elif button_e == 1: + button_e_led.on() + clear() + display.set_pen(3) + display.text("Button E pressed", 10, 170, scale=4) + display.update() diff --git a/examples/button_test.py b/examples/button_test.py new file mode 100644 index 0000000..62c44db --- /dev/null +++ b/examples/button_test.py @@ -0,0 +1,59 @@ +# This example allows you to test Inky Frame's buttons +# It does not update the screen. + +from pimoroni import ShiftRegister +from machine import Pin +import time + + +# Inky Frame uses a shift register to read the buttons +SR_CLOCK = 8 +SR_LATCH = 9 +SR_OUT = 10 + +sr = ShiftRegister(SR_CLOCK, SR_LATCH, SR_OUT) + + +# Simple class to debounce button input and handle LED +class Button: + def __init__(self, idx, led, debounce=50): + self.led = Pin(led, Pin.OUT) # LEDs are just regular IOs + self.led.on() + self._idx = idx + self._debounce_time = debounce + self._changed = time.ticks_ms() + self._last_value = None + + def debounced(self): + return time.ticks_ms() - self._changed > self._debounce_time + + def get(self, sr): + value = sr[self._idx] + if value != self._last_value and self.debounced(): + self._last_value = value + self._changed = time.ticks_ms() + return value + + +button_a = Button(7, 11) +button_b = Button(6, 12) +button_c = Button(5, 13) +button_d = Button(4, 14) +button_e = Button(3, 15) + + +while True: + sr.read() + + if button_a.get(sr): + button_a.led.toggle() + if button_b.get(sr): + button_b.led.toggle() + if button_c.get(sr): + button_c.led.toggle() + if button_d.get(sr): + button_d.led.toggle() + if button_e.get(sr): + button_e.led.toggle() + + time.sleep(1.0 / 60) # Poll 60 times/second diff --git a/examples/carbon_intensity.py b/examples/carbon_intensity.py new file mode 100644 index 0000000..20c4142 --- /dev/null +++ b/examples/carbon_intensity.py @@ -0,0 +1,149 @@ +""" +This example connects to the Carbon Intensity API to give you a regional +forecast of how your (UK) electricity is being generated and its carbon impact. + +Carbon Intensity API only reports data from the UK National Grid. + +Find out more about what the numbers mean at: +https://carbonintensity.org.uk/ + +Make sure to uncomment the correct size for your display! + +""" + +from picographics import PicoGraphics, DISPLAY_INKY_FRAME_4 as DISPLAY # 4.0" +# from picographics import PicoGraphics, DISPLAY_INKY_FRAME as DISPLAY # 5.7" +# from picographics import PicoGraphics, DISPLAY_INKY_FRAME_7 as DISPLAY # 7.3" +import urequests +import inky_frame +import uasyncio +from network_manager import NetworkManager +import WIFI_CONFIG + +# Set (the first half) of your UK postcode here +POSTCODE = "S9" + +URL = "https://api.carbonintensity.org.uk/regional/postcode/" + str(POSTCODE) + + +def get_data(): + global region, forecast, index, power_list, datetime_to, datetime_from + print(f"Requesting URL: {URL}") + r = urequests.get(URL) + # open the json data + j = r.json() + print("Data obtained!") + print(j) + + # parse the json data + region = j["data"][0]["shortname"] + + forecast = j["data"][0]["data"][0]["intensity"]["forecast"] + index = j["data"][0]["data"][0]["intensity"]["index"] + + power_list = [] + for power in j["data"][0]["data"][0]["generationmix"]: + power_list.append(power['perc']) + + datetime_to = j["data"][0]["data"][0]["to"].split("T") + datetime_from = j["data"][0]["data"][0]["from"].split("T") + + # close the socket + r.close() + + +def draw(): + global graphics + # we're setting up our PicoGraphics buffer after we've made our RAM intensive https request + graphics = PicoGraphics(DISPLAY) + w, h = graphics.get_bounds() + graphics.set_pen(inky_frame.WHITE) + graphics.clear() + + # draw lines + graphics.set_pen(inky_frame.BLACK) + graphics.line(0, int((h / 100) * 0), w, int((h / 100) * 0)) + graphics.line(0, int((h / 100) * 50), w, int((h / 100) * 50)) + graphics.set_font("bitmap8") + graphics.text('100%', w - 40, 10, scale=2) + graphics.text('50%', w - 40, int((h / 100) * 50 + 10), scale=2) + + # draw bars + bar_colours = [ + inky_frame.ORANGE, + inky_frame.RED, + inky_frame.ORANGE, + inky_frame.RED, + inky_frame.BLUE, + inky_frame.ORANGE, + inky_frame.GREEN, + inky_frame.GREEN, + inky_frame.GREEN + ] + for p in power_list: + graphics.set_pen(bar_colours[power_list.index(p)]) + graphics.rectangle(int(power_list.index(p) * w / 9), int(h - p * (h / 100)), + int(w / 9), int(h / 100 * p)) + + # draw labels + graphics.set_font('sans') + # once in white for a background + graphics.set_pen(inky_frame.WHITE) + labels = ['biomass', 'coal', 'imports', 'gas', 'nuclear', 'other', 'hydro', 'solar', 'wind'] + graphics.set_thickness(4) + for label in labels: + graphics.text(f'{label}', int((labels.index(label) * w / 9) + (w / 9) / 2), h - 10, angle=270, scale=1) + # again in black + graphics.set_pen(inky_frame.BLACK) + labels = ['biomass', 'coal', 'imports', 'gas', 'nuclear', 'other', 'hydro', 'solar', 'wind'] + graphics.set_thickness(2) + for label in labels: + graphics.text(f'{label}', int((labels.index(label) * w / 9) + (w / 9) / 2), h - 10, angle=270, scale=1) + + # draw header + graphics.set_thickness(3) + graphics.set_pen(inky_frame.GREEN) + if index in ['high', 'very high']: + graphics.set_pen(inky_frame.RED) + if index in ['moderate']: + graphics.set_pen(inky_frame.ORANGE) + graphics.set_font("sans") + graphics.text('Carbon Intensity', 10, 35, scale=1.2, angle=0) + + # draw small text + graphics.set_pen(inky_frame.BLACK) + graphics.set_font("bitmap8") + graphics.text(f'Region: {region}', int((w / 2) + 30), 10, scale=2) + graphics.text(f'{forecast} gCO2/kWh ({index})', int((w / 2) + 30), 30, scale=2) + graphics.text(f'{datetime_from[0]} {datetime_from[1]} to {datetime_to[1]}', int((w / 2) + 30), 50, scale=2) + + graphics.update() + + +def status_handler(mode, status, ip): + print(mode, status, ip) + + +inky_frame.led_busy.on() +network_manager = NetworkManager(WIFI_CONFIG.COUNTRY, status_handler=status_handler) + +# connect to wifi +try: + uasyncio.get_event_loop().run_until_complete(network_manager.client(WIFI_CONFIG.SSID, WIFI_CONFIG.PSK)) +except ImportError: + print("Add WIFI_CONFIG.py with your WiFi credentials") + +get_data() +draw() + +# Go to sleep if on battery power +inky_frame.turn_off() + +# Or comment out the line above and uncomment this one to wake up and update every half hour +# inky_frame.sleep_for(30) + +""" +Pico W RAM seems insufficient to decode a https request whilst having a PicoGraphics instance active. +If you are running off USB and want this to update periodically, you could incorporate a machine.reset() +to reset the Pico and start afresh every time. +""" diff --git a/examples/display_png.py b/examples/display_png.py new file mode 100644 index 0000000..7b3cd58 --- /dev/null +++ b/examples/display_png.py @@ -0,0 +1,36 @@ +from picographics import PicoGraphics, DISPLAY_INKY_FRAME as DISPLAY # 5.7" +# from picographics import PicoGraphics, DISPLAY_INKY_FRAME_4 as DISPLAY # 4.0" +# from picographics import PicoGraphics, DISPLAY_INKY_FRAME_7 as DISPLAY # 7.3" +import pngdec + +# Create a PicoGraphics instance +graphics = PicoGraphics(DISPLAY) +WIDTH, HEIGHT = graphics.get_bounds() + +# Set the font +graphics.set_font("bitmap8") + +# Create an instance of the PNG Decoder +png = pngdec.PNG(graphics) + +# Clear the screen +graphics.set_pen(1) +graphics.clear() +graphics.set_pen(0) + +# Few lines of text. +graphics.text("PNG Pencil", 70, 100, WIDTH, 3) + +# Open our PNG File from flash. In this example we're using a cartoon pencil. +# You can use Thonny to transfer PNG Images to your Inky Frame. +try: + png.open_file("pencil_256x256.png") + + # Decode our PNG file and set the X and Y + png.decode(200, 100) + +except OSError: + graphics.text("Unable to find PNG file! Copy 'pencil_256x256.png' to your Inky Frame using Thonny :)", 10, 70, WIDTH, 3) + +# Start the screen update +graphics.update() diff --git a/examples/image_gallery/600x448/jwst1.jpg b/examples/image_gallery/600x448/jwst1.jpg new file mode 100644 index 0000000..6bfbb68 Binary files /dev/null and b/examples/image_gallery/600x448/jwst1.jpg differ diff --git a/examples/image_gallery/600x448/jwst2.jpg b/examples/image_gallery/600x448/jwst2.jpg new file mode 100644 index 0000000..5d0868d Binary files /dev/null and b/examples/image_gallery/600x448/jwst2.jpg differ diff --git a/examples/image_gallery/600x448/jwst3.jpg b/examples/image_gallery/600x448/jwst3.jpg new file mode 100644 index 0000000..d46e764 Binary files /dev/null and b/examples/image_gallery/600x448/jwst3.jpg differ diff --git a/examples/image_gallery/600x448/jwst4.jpg b/examples/image_gallery/600x448/jwst4.jpg new file mode 100644 index 0000000..0b9c751 Binary files /dev/null and b/examples/image_gallery/600x448/jwst4.jpg differ diff --git a/examples/image_gallery/600x448/jwst5.jpg b/examples/image_gallery/600x448/jwst5.jpg new file mode 100644 index 0000000..05d86f1 Binary files /dev/null and b/examples/image_gallery/600x448/jwst5.jpg differ diff --git a/examples/image_gallery/640x400/jwst1.jpg b/examples/image_gallery/640x400/jwst1.jpg new file mode 100644 index 0000000..202e747 Binary files /dev/null and b/examples/image_gallery/640x400/jwst1.jpg differ diff --git a/examples/image_gallery/640x400/jwst2.jpg b/examples/image_gallery/640x400/jwst2.jpg new file mode 100644 index 0000000..103341e Binary files /dev/null and b/examples/image_gallery/640x400/jwst2.jpg differ diff --git a/examples/image_gallery/640x400/jwst3.jpg b/examples/image_gallery/640x400/jwst3.jpg new file mode 100644 index 0000000..4b66d65 Binary files /dev/null and b/examples/image_gallery/640x400/jwst3.jpg differ diff --git a/examples/image_gallery/640x400/jwst4.jpg b/examples/image_gallery/640x400/jwst4.jpg new file mode 100644 index 0000000..f1a541e Binary files /dev/null and b/examples/image_gallery/640x400/jwst4.jpg differ diff --git a/examples/image_gallery/640x400/jwst5.jpg b/examples/image_gallery/640x400/jwst5.jpg new file mode 100644 index 0000000..21a70f3 Binary files /dev/null and b/examples/image_gallery/640x400/jwst5.jpg differ diff --git a/examples/image_gallery/800x480/jwst1.jpg b/examples/image_gallery/800x480/jwst1.jpg new file mode 100644 index 0000000..c70f67a Binary files /dev/null and b/examples/image_gallery/800x480/jwst1.jpg differ diff --git a/examples/image_gallery/800x480/jwst2.jpg b/examples/image_gallery/800x480/jwst2.jpg new file mode 100644 index 0000000..375775f Binary files /dev/null and b/examples/image_gallery/800x480/jwst2.jpg differ diff --git a/examples/image_gallery/800x480/jwst3.jpg b/examples/image_gallery/800x480/jwst3.jpg new file mode 100644 index 0000000..38876f1 Binary files /dev/null and b/examples/image_gallery/800x480/jwst3.jpg differ diff --git a/examples/image_gallery/800x480/jwst4.jpg b/examples/image_gallery/800x480/jwst4.jpg new file mode 100644 index 0000000..21f0ee7 Binary files /dev/null and b/examples/image_gallery/800x480/jwst4.jpg differ diff --git a/examples/image_gallery/800x480/jwst5.jpg b/examples/image_gallery/800x480/jwst5.jpg new file mode 100644 index 0000000..6d243d8 Binary files /dev/null and b/examples/image_gallery/800x480/jwst5.jpg differ diff --git a/examples/image_gallery/README.md b/examples/image_gallery/README.md new file mode 100644 index 0000000..ae36d84 --- /dev/null +++ b/examples/image_gallery/README.md @@ -0,0 +1,45 @@ +# Image Gallery + +- [Image transfer instructions](#image-transfer-instructions) + - [image\_gallery.py](#image_gallerypy) + - [image\_gallery\_sd.py / image\_gallery\_sd\_random.py](#image_gallery_sdpy--image_gallery_sd_randompy) +- [Image Credits](#image-credits) + +Some example programs to display images on your Inky Frame, plus sample images in different sizes. + +Use: +640x400 for Inky Frame 4.0" +600x448 for Inky Frame 5.7" +800x480 for Inky Frame 7.3" + +If you want to use your own images, they will need to be the correct dimensions for your screen and saved *without progressive encoding*. + +## Image transfer instructions + +In all cases the images will need to be copied to the root of your Pico W or SD card. + +### image_gallery.py + +Copy the images to your Pico W using Thonny. + +### image_gallery_sd.py / image_gallery_sd_random.py + +Pop an SD card into your computer to copy the images across. + +Alternatively, you can transfer them using Thonny, but you will have to mount the SD card using the REPL first: + +```python +import os +import sdcard +from machine import Pin, SPI +sd_spi = SPI(0, sck=Pin(18, Pin.OUT), mosi=Pin(19, Pin.OUT), miso=Pin(16, Pin.OUT)) +sd = sdcard.SDCard(sd_spi, Pin(22)) +os.mount(sd, "/sd") +``` + +## Image Credits + +Sample images from the Webb Space Telescope (credit: NASA, ESA, CSA, and STScI). +Find more gorgeous images and info @ https://webbtelescope.org/ + +... and Raspberry Pi <3 diff --git a/examples/image_gallery/image_gallery.py b/examples/image_gallery/image_gallery.py new file mode 100644 index 0000000..60b31e4 --- /dev/null +++ b/examples/image_gallery/image_gallery.py @@ -0,0 +1,75 @@ +""" +An offline image gallery that switches between five jpg images. + +Copy images into the root of your Pico's flash using Thonny. + +If you want to use your own images they must be the screen dimensions +(or smaller) and saved as *non-progressive* jpgs. + +Make sure to uncomment the correct size for your display! +""" + +# from picographics import PicoGraphics, DISPLAY_INKY_FRAME as DISPLAY # 5.7" +# from picographics import PicoGraphics, DISPLAY_INKY_FRAME_4 as DISPLAY # 4.0" +from picographics import PicoGraphics, DISPLAY_INKY_FRAME_7 as DISPLAY # 7.3" +import inky_frame +import jpegdec + +# you can change your file names here +IMAGE_A = "jwst1.jpg" +IMAGE_B = "jwst2.jpg" +IMAGE_C = "jwst3.jpg" +IMAGE_D = "jwst4.jpg" +IMAGE_E = "jwst5.jpg" + +# set up the display +graphics = PicoGraphics(DISPLAY) + +# Create a new JPEG decoder for our PicoGraphics +j = jpegdec.JPEG(graphics) + + +def display_image(filename): + + # Open the JPEG file + j.open_file(filename) + + # Decode the JPEG + j.decode(0, 0, jpegdec.JPEG_SCALE_FULL) + + # Display the result + graphics.update() + + +print('Press a button to display an image!') + +while True: + inky_frame.button_a.led_off() + inky_frame.button_b.led_off() + inky_frame.button_c.led_off() + inky_frame.button_d.led_off() + inky_frame.button_e.led_off() + + if inky_frame.button_a.read(): + print('Refreshing image A.') + inky_frame.button_a.led_on() + display_image(IMAGE_A) + elif inky_frame.button_b.read(): + print('Refreshing image B.') + inky_frame.button_b.led_on() + display_image(IMAGE_B) + elif inky_frame.button_c.read(): + print('Refreshing image C.') + inky_frame.button_c.led_on() + display_image(IMAGE_C) + elif inky_frame.button_d.read(): + print('Refreshing image D.') + inky_frame.button_d.led_on() + display_image(IMAGE_D) + elif inky_frame.button_e.read(): + print('Refreshing image E.') + inky_frame.button_e.led_on() + display_image(IMAGE_E) + + # Go to sleep if on battery power + inky_frame.turn_off() diff --git a/examples/image_gallery/image_gallery_sd.py b/examples/image_gallery/image_gallery_sd.py new file mode 100644 index 0000000..659ca29 --- /dev/null +++ b/examples/image_gallery/image_gallery_sd.py @@ -0,0 +1,84 @@ +""" +An offline image gallery that switches between five jpg images on your SD card. + +Copy images to the root of your SD card by plugging it into a computer. + +If you want to use your own images they must be the screen dimensions +(or smaller) and saved as *non-progressive* jpgs. + +Make sure to uncomment the correct size for your display! +""" + +# from picographics import PicoGraphics, DISPLAY_INKY_FRAME as DISPLAY # 5.7" +# from picographics import PicoGraphics, DISPLAY_INKY_FRAME_4 as DISPLAY # 4.0" +from picographics import PicoGraphics, DISPLAY_INKY_FRAME_7 as DISPLAY # 7.3" +from machine import Pin, SPI +import jpegdec +import sdcard +import os +import inky_frame + +# you can change your file names here +IMAGE_A = "sd/jwst1.jpg" +IMAGE_B = "sd/jwst2.jpg" +IMAGE_C = "sd/jwst3.jpg" +IMAGE_D = "sd/jwst4.jpg" +IMAGE_E = "sd/jwst5.jpg" + +# set up the display +graphics = PicoGraphics(DISPLAY) + +# set up the SD card +sd_spi = SPI(0, sck=Pin(18, Pin.OUT), mosi=Pin(19, Pin.OUT), miso=Pin(16, Pin.OUT)) +sd = sdcard.SDCard(sd_spi, Pin(22)) +os.mount(sd, "/sd") + +# Create a new JPEG decoder for our PicoGraphics +j = jpegdec.JPEG(graphics) + + +def display_image(filename): + + # Open the JPEG file + j.open_file(filename) + + # Decode the JPEG + j.decode(0, 0, jpegdec.JPEG_SCALE_FULL) + + # Display the result + graphics.update() + + +# setup +print('Press a button to display an image!') + +while True: + inky_frame.button_a.led_off() + inky_frame.button_b.led_off() + inky_frame.button_c.led_off() + inky_frame.button_d.led_off() + inky_frame.button_e.led_off() + + if inky_frame.button_a.read(): + print('Refreshing image A.') + inky_frame.button_a.led_on() + display_image(IMAGE_A) + elif inky_frame.button_b.read(): + print('Refreshing image B.') + inky_frame.button_b.led_on() + display_image(IMAGE_B) + elif inky_frame.button_c.read(): + print('Refreshing image C.') + inky_frame.button_c.led_on() + display_image(IMAGE_C) + elif inky_frame.button_d.read(): + print('Refreshing image D.') + inky_frame.button_d.led_on() + display_image(IMAGE_D) + elif inky_frame.button_e.read(): + print('Refreshing image E.') + inky_frame.button_e.led_on() + display_image(IMAGE_E) + + # Go to sleep if on battery power + inky_frame.turn_off() diff --git a/examples/image_gallery/image_gallery_sd_random.py b/examples/image_gallery/image_gallery_sd_random.py new file mode 100644 index 0000000..965610a --- /dev/null +++ b/examples/image_gallery/image_gallery_sd_random.py @@ -0,0 +1,67 @@ +""" +An offline image gallery that displays a random image from your SD card +and updates on a timer. + +Copy images to the root of your SD card by plugging it into a computer. + +If you want to use your own images they must be the screen dimensions +(or smaller) and saved as *non-progressive* jpgs. + +Make sure to uncomment the correct size for your display! +""" + +# from picographics import PicoGraphics, DISPLAY_INKY_FRAME as DISPLAY # 5.7" +# from picographics import PicoGraphics, DISPLAY_INKY_FRAME_4 as DISPLAY # 4.0" +from picographics import PicoGraphics, DISPLAY_INKY_FRAME_7 as DISPLAY # 7.3" +from machine import Pin, SPI +import jpegdec +import sdcard +import os +import inky_frame +import random + +# how often to change image (in minutes) +UPDATE_INTERVAL = 60 + +# set up the display +graphics = PicoGraphics(DISPLAY) + +# set up the SD card +sd_spi = SPI(0, sck=Pin(18, Pin.OUT), mosi=Pin(19, Pin.OUT), miso=Pin(16, Pin.OUT)) +sd = sdcard.SDCard(sd_spi, Pin(22)) +os.mount(sd, "/sd") + +# Create a new JPEG decoder for our PicoGraphics +j = jpegdec.JPEG(graphics) + + +def display_image(filename): + + # Open the JPEG file + j.open_file(filename) + + # Decode the JPEG + j.decode(0, 0, jpegdec.JPEG_SCALE_FULL) + + # Display the result + graphics.update() + + +inky_frame.led_busy.on() + +# Get a list of files that are in the directory +files = os.listdir("/sd") +# remove files from the list that aren't .jpgs or .jpegs +files = [f for f in files if f.endswith(".jpg") or f.endswith(".jpeg")] + +while True: + # pick a random file + file = files[random.randrange(len(files))] + + # Open the file + print(f"Displaying /sd/{file}") + display_image("/sd/" + file) + + # Sleep or wait for a bit + print(f"Sleeping for {UPDATE_INTERVAL} minutes") + inky_frame.sleep_for(UPDATE_INTERVAL) diff --git a/examples/inky_frame_daily_activity.py b/examples/inky_frame_daily_activity.py new file mode 100644 index 0000000..a21e6b3 --- /dev/null +++ b/examples/inky_frame_daily_activity.py @@ -0,0 +1,143 @@ +import gc +import WIFI_CONFIG +from network_manager import NetworkManager +import uasyncio +import ujson +from urllib import urequest +# from picographics import PicoGraphics, DISPLAY_INKY_FRAME as DISPLAY # 5.7" +# from picographics import PicoGraphics, DISPLAY_INKY_FRAME_4 as DISPLAY # 4.0" +from picographics import PicoGraphics, DISPLAY_INKY_FRAME_7 as DISPLAY # 7.3" +from machine import Pin +from pimoroni_i2c import PimoroniI2C +from pcf85063a import PCF85063A +import time + + +I2C_SDA_PIN = 4 +I2C_SCL_PIN = 5 +HOLD_VSYS_EN_PIN = 2 + +# set up and enable vsys hold so we don't go to sleep +hold_vsys_en_pin = Pin(HOLD_VSYS_EN_PIN, Pin.OUT) +hold_vsys_en_pin.value(True) + +# intialise the pcf85063a real time clock chip +i2c = PimoroniI2C(I2C_SDA_PIN, I2C_SCL_PIN, 100000) +rtc = PCF85063A(i2c) + +# Length of time between updates in Seconds. +# Frequent updates will reduce battery life! +UPDATE_INTERVAL = 60 * 1 + +# API URL +URL = "https://www.boredapi.com/api/activity" + + +def status_handler(mode, status, ip): + print(mode, status, ip) + + +network_manager = NetworkManager(WIFI_CONFIG.COUNTRY, status_handler=status_handler) + +gc.collect() +graphics = PicoGraphics(DISPLAY) +WIDTH, HEIGHT = graphics.get_bounds() +gc.collect() + + +def display_quote(text, ox, oy, scale, wordwrap): + # Processing text is memory intensive + # so we'll do it one char at a time as we draw to the screen + line_height = 8 * scale + html = False + html_tag = "" + word = "" + space_width = graphics.measure_text(" ", scale=scale) + x = ox + y = oy + for char in text: + if char in "[]": + continue + if char == "<": + html = True + html_tag = "" + continue + if char == ">": + html = False + continue + if html: + if char in "/ ": + continue + html_tag += char + continue + if char in (" ", "\n") or html_tag == "br": + w = graphics.measure_text(word, scale=scale) + if x + w > wordwrap or char == "\n" or html_tag == "br": + x = ox + y += line_height + + graphics.text(word, x, y, scale=scale) + word = "" + html_tag = "" + x += w + space_width + continue + + word += char + + # Last word + w = graphics.measure_text(word, scale=scale) + if x + w > wordwrap: + x = ox + y += line_height + + graphics.text(word, x, y, scale=scale) + + +rtc.enable_timer_interrupt(True) + +while True: + # Connect to WiFi + uasyncio.get_event_loop().run_until_complete(network_manager.client(WIFI_CONFIG.SSID, WIFI_CONFIG.PSK)) + + # Clear the screen + graphics.set_pen(1) + graphics.clear() + graphics.set_pen(0) + + # Grab the data + socket = urequest.urlopen(URL) + j = ujson.load(socket) + socket.close() + + text = [j['activity'], j['type'], j['participants']] + + # Page lines! + graphics.set_pen(3) + graphics.line(0, 65, WIDTH, 65) + for i in range(2, 13): + graphics.line(0, i * 35, WIDTH, i * 35) + + # Page margin + graphics.set_pen(4) + graphics.line(50, 0, 50, HEIGHT) + graphics.set_pen(0) + + # Main text + graphics.set_font("cursive") + graphics.set_pen(4) + graphics.set_font("cursive") + graphics.text("Activity Idea", 55, 30, WIDTH - 20, 2) + graphics.set_pen(0) + graphics.set_font("bitmap8") + display_quote(text[0], 55, 170, 5, WIDTH - 20) + + graphics.set_pen(2) + graphics.text("Activity Type: " + text[1], 55, HEIGHT - 45, WIDTH - 20, 2) + graphics.text("Participants: " + str(text[2]), 400, HEIGHT - 45, WIDTH - 20, 2) + + graphics.update() + + # Time to have a little nap until the next update + rtc.set_timer(UPDATE_INTERVAL) + hold_vsys_en_pin.init(Pin.IN) + time.sleep(UPDATE_INTERVAL) diff --git a/examples/inky_frame_dithering.py b/examples/inky_frame_dithering.py new file mode 100644 index 0000000..15c947e --- /dev/null +++ b/examples/inky_frame_dithering.py @@ -0,0 +1,68 @@ +# from picographics import PicoGraphics, DISPLAY_INKY_FRAME as DISPLAY # 5.7" +# from picographics import PicoGraphics, DISPLAY_INKY_FRAME_4 as DISPLAY # 4.0" +from picographics import PicoGraphics, DISPLAY_INKY_FRAME_7 as DISPLAY # 7.3" + +graphics = PicoGraphics(DISPLAY) + +WIDTH, HEIGHT = graphics.get_bounds() + +graphics.set_pen(1) +graphics.clear() + +w = int(WIDTH / 8) + +# Solid Colours + +for p in range(8): + graphics.set_pen(p) + graphics.rectangle(w * p, 0, w, 50) + +# "Greydient" + +for x in range(WIDTH): + g = int(x / float(WIDTH) * 255) + graphics.set_pen(graphics.create_pen(g, g, g)) + for y in range(30): + graphics.pixel(x, 60 + y) + +# Rainbow Gradient + +for x in range(WIDTH): + h = x / float(WIDTH) + graphics.set_pen(graphics.create_pen_hsv(h, 1.0, 1.0)) + for y in range(100): + graphics.pixel(x, 100 + y) + +# Block Colours & Text + +graphics.set_pen(graphics.create_pen(128, 128, 0)) +graphics.rectangle(0, 210, 200, 100) +graphics.set_pen(graphics.create_pen(200, 200, 200)) +graphics.text("Hello", 10, 220) +graphics.text("Hello", 10, 240, scale=4.0) + +graphics.set_pen(graphics.create_pen(0, 128, 128)) +graphics.rectangle(200, 210, 200, 100) +graphics.set_pen(graphics.create_pen(200, 200, 200)) +graphics.text("Hello", 210, 220) +graphics.text("Hello", 210, 240, scale=4.0) + +graphics.set_pen(graphics.create_pen(128, 0, 128)) +graphics.rectangle(400, 210, 200, 100) +graphics.set_pen(graphics.create_pen(200, 200, 200)) +graphics.text("Hello", 410, 220) +graphics.text("Hello", 410, 240, scale=4.0) + +# Red, Green and Blue gradients + +for x in range(WIDTH): + g = int(x / float(WIDTH) * 255) + for y in range(20): + graphics.set_pen(graphics.create_pen(g, 0, 0)) + graphics.pixel(x, 320 + y) + graphics.set_pen(graphics.create_pen(0, g, 0)) + graphics.pixel(x, 350 + y) + graphics.set_pen(graphics.create_pen(0, 0, g)) + graphics.pixel(x, 380 + y) + +graphics.update() diff --git a/examples/inky_frame_news.py b/examples/inky_frame_news.py new file mode 100644 index 0000000..b59df09 --- /dev/null +++ b/examples/inky_frame_news.py @@ -0,0 +1,192 @@ +# from picographics import PicoGraphics, DISPLAY_INKY_FRAME as DISPLAY # 5.7" +# from picographics import PicoGraphics, DISPLAY_INKY_FRAME_4 as DISPLAY # 4.0" +from picographics import PicoGraphics, DISPLAY_INKY_FRAME_7 as DISPLAY # 7.3" +from network_manager import NetworkManager +import uasyncio +from urllib import urequest +import WIFI_CONFIG +import gc +import qrcode +from machine import Pin +from pimoroni_i2c import PimoroniI2C +from pcf85063a import PCF85063A +import time + +I2C_SDA_PIN = 4 +I2C_SCL_PIN = 5 +HOLD_VSYS_EN_PIN = 2 + +# set up and enable vsys hold so we don't go to sleep +hold_vsys_en_pin = Pin(HOLD_VSYS_EN_PIN, Pin.OUT) +hold_vsys_en_pin.value(True) + +# Uncomment one URL to use (Top Stories, World News and technology) +# URL = "http://feeds.bbci.co.uk/news/rss.xml" +# URL = "http://feeds.bbci.co.uk/news/world/rss.xml" +URL = "http://feeds.bbci.co.uk/news/technology/rss.xml" + +# Length of time between updates in Seconds. +# Frequent updates will reduce battery life! +UPDATE_INTERVAL = 60 * 1 + +graphics = PicoGraphics(DISPLAY) +WIDTH, HEIGHT = graphics.get_bounds() +graphics.set_font("bitmap8") +code = qrcode.QRCode() + +# intialise the pcf85063a real time clock chip +i2c = PimoroniI2C(I2C_SDA_PIN, I2C_SCL_PIN, 100000) +rtc = PCF85063A(i2c) + + +def status_handler(mode, status, ip): + print(mode, status, ip) + + +network_manager = NetworkManager(WIFI_CONFIG.COUNTRY, status_handler=status_handler) + + +def read_until(stream, char): + result = b"" + while True: + c = stream.read(1) + if c == char: + return result + result += c + + +def discard_until(stream, c): + while stream.read(1) != c: + pass + + +def parse_xml_stream(s, accept_tags, group_by, max_items=3): + tag = [] + text = b"" + count = 0 + current = {} + while True: + char = s.read(1) + if len(char) == 0: + break + + if char == b"<": + next_char = s.read(1) + + # Discard stuff like ") + continue + + # Detect ") # Discard ]> + gc.collect() + + elif next_char == b"/": + current_tag = read_until(s, b">") + top_tag = tag[-1] + + # Populate our result dict + if top_tag in accept_tags: + current[top_tag.decode("utf-8")] = text.decode("utf-8") + + # If we've found a group of items, yield the dict + elif top_tag == group_by: + yield current + current = {} + count += 1 + if count == max_items: + return + tag.pop() + text = b"" + gc.collect() + continue + + else: + current_tag = read_until(s, b">") + tag += [next_char + current_tag.split(b" ")[0]] + text = b"" + gc.collect() + + else: + text += char + + +def measure_qr_code(size, code): + w, h = code.get_size() + module_size = int(size / w) + return module_size * w, module_size + + +def draw_qr_code(ox, oy, size, code): + size, module_size = measure_qr_code(size, code) + graphics.set_pen(1) + graphics.rectangle(ox, oy, size, size) + graphics.set_pen(0) + for x in range(size): + for y in range(size): + if code.get_module(x, y): + graphics.rectangle(ox + x * module_size, oy + y * module_size, module_size, module_size) + + +def get_rss(): + try: + stream = urequest.urlopen(URL) + output = list(parse_xml_stream(stream, [b"title", b"description", b"guid", b"pubDate"], b"item")) + return output + + except OSError as e: + print(e) + return False + + +rtc.enable_timer_interrupt(True) + +while True: + # Connect to WiFi + uasyncio.get_event_loop().run_until_complete(network_manager.client(WIFI_CONFIG.SSID, WIFI_CONFIG.PSK)) + + # Gets Feed Data + feed = get_rss() + + # Clear the screen + graphics.set_pen(1) + graphics.clear() + graphics.set_pen(0) + + # Title + graphics.text("Headlines from BBC News:", 10, 10, 300, 2) + + # Draws 3 articles from the feed if they're available. + if feed: + graphics.set_pen(4) + graphics.text(feed[0]["title"], 10, 40, WIDTH - 150, 3 if graphics.measure_text(feed[0]["title"]) < 650 else 2) + graphics.text(feed[1]["title"], 130, 180, WIDTH - 140, 3 if graphics.measure_text(feed[1]["title"]) < 650 else 2) + graphics.text(feed[2]["title"], 10, 320, WIDTH - 150, 3 if graphics.measure_text(feed[2]["title"]) < 650 else 2) + + graphics.set_pen(3) + graphics.text(feed[0]["description"], 10, 110 if graphics.measure_text(feed[0]["title"]) < 650 else 90, WIDTH - 150, 2) + graphics.text(feed[1]["description"], 130, 250 if graphics.measure_text(feed[1]["title"]) < 650 else 230, WIDTH - 145, 2) + graphics.text(feed[2]["description"], 10, 395 if graphics.measure_text(feed[2]["title"]) < 650 else 375, WIDTH - 150, 2) + + code.set_text(feed[0]["guid"]) + draw_qr_code(WIDTH - 110, 40, 100, code) + code.set_text(feed[1]["guid"]) + draw_qr_code(10, 180, 100, code) + code.set_text(feed[2]["guid"]) + draw_qr_code(WIDTH - 110, 320, 100, code) + + else: + graphics.set_pen(4) + graphics.text("Error: Unable to get feed :(", 10, 40, WIDTH - 150, 4) + + graphics.update() + + # Time to have a little nap until the next update + rtc.set_timer(UPDATE_INTERVAL) + hold_vsys_en_pin.init(Pin.IN) + time.sleep(UPDATE_INTERVAL) diff --git a/examples/inky_frame_placekitten.py b/examples/inky_frame_placekitten.py new file mode 100644 index 0000000..8f07249 --- /dev/null +++ b/examples/inky_frame_placekitten.py @@ -0,0 +1,70 @@ +import gc +import uos +import random +import machine +import jpegdec +import uasyncio +import sdcard +import WIFI_CONFIG +from urllib import urequest +from network_manager import NetworkManager +# from picographics import PicoGraphics, DISPLAY_INKY_FRAME as DISPLAY # 5.7" +# from picographics import PicoGraphics, DISPLAY_INKY_FRAME_4 as DISPLAY # 4.0" +from picographics import PicoGraphics, DISPLAY_INKY_FRAME_7 as DISPLAY # 7.3" + +""" +random placekitten (from a very small set) + +You *must* insert an SD card into Inky Frame! +We need somewhere to save the jpg for display. +""" + +gc.collect() # We're really gonna need that RAM! + + +def status_handler(mode, status, ip): + print(mode, status, ip) + + +network_manager = NetworkManager(WIFI_CONFIG.COUNTRY, status_handler=status_handler) +uasyncio.get_event_loop().run_until_complete(network_manager.client(WIFI_CONFIG.SSID, WIFI_CONFIG.PSK)) + + +graphics = PicoGraphics(DISPLAY) + +WIDTH, HEIGHT = graphics.get_bounds() +FILENAME = "/sd/placekitten.jpg" +ENDPOINT = "http://placecats.com/{0}/{1}" + + +sd_spi = machine.SPI(0, sck=machine.Pin(18, machine.Pin.OUT), mosi=machine.Pin(19, machine.Pin.OUT), miso=machine.Pin(16, machine.Pin.OUT)) +sd = sdcard.SDCard(sd_spi, machine.Pin(22)) +uos.mount(sd, "/sd") +gc.collect() # Claw back some RAM! + +url = ENDPOINT.format(WIDTH, HEIGHT + random.randint(0, 10)) + +socket = urequest.urlopen(url) + +# Stream the image data from the socket onto disk in 1024 byte chunks +# the 600x448-ish jpeg will be roughly ~24k, we really don't have the RAM! +data = bytearray(1024) +with open(FILENAME, "wb") as f: + while True: + if socket.readinto(data) == 0: + break + f.write(data) +socket.close() +gc.collect() # We really are tight on RAM! + + +jpeg = jpegdec.JPEG(graphics) +gc.collect() # For good measure... + +graphics.set_pen(1) +graphics.clear() + +jpeg.open_file(FILENAME) +jpeg.decode() + +graphics.update() diff --git a/examples/inky_frame_quote_of_the_day.py b/examples/inky_frame_quote_of_the_day.py new file mode 100644 index 0000000..ed07297 --- /dev/null +++ b/examples/inky_frame_quote_of_the_day.py @@ -0,0 +1,171 @@ +import gc +import time +import ujson +import uasyncio +import WIFI_CONFIG +from urllib import urequest +from network_manager import NetworkManager +# from picographics import PicoGraphics, DISPLAY_INKY_FRAME as DISPLAY # 5.7" +# from picographics import PicoGraphics, DISPLAY_INKY_FRAME_4 as DISPLAY # 4.0" +from picographics import PicoGraphics, DISPLAY_INKY_FRAME_7 as DISPLAY # 7.3" + + +ENDPOINT = "https://en.wikiquote.org/w/api.php?format=json&action=expandtemplates&prop=wikitext&text={{{{Wikiquote:Quote%20of%20the%20day/{3}%20{2},%20{0}}}}}" +MONTHNAMES = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] + + +last_date = "" + + +def parse_qotd(text): + print(text) + text = text.split("\n") + author = text[8].split("|")[2][5:-4] + text = text[6][2:] + gc.collect() + return text, author + + +def status_handler(mode, status, ip): + print(mode, status, ip) + + +network_manager = NetworkManager(WIFI_CONFIG.COUNTRY, status_handler=status_handler) + +gc.collect() +graphics = PicoGraphics(DISPLAY) +WIDTH, HEIGHT = graphics.get_bounds() +graphics.set_font("bitmap8") +gc.collect() + + +BADCHARS = { + "’": "'", + "—": "", + "…": "..." +} + + +def display_quote(text, ox, oy, scale, wordwrap): + # Processing text is memory intensive + # so we'll do it one char at a time as we draw to the screen + line_height = 9 * scale + html_tag = "" + word = "" + extra_text = "" + space_width = graphics.measure_text(" ", scale=scale) + x = ox + y = oy + i = -1 + while True: + if len(extra_text) == 0: + i += 1 + if i >= len(text): + break + + if len(extra_text) > 0: + char = extra_text[0] + extra_text = extra_text[1:] + else: + char = text[i] + + if char in BADCHARS: + word += BADCHARS[char] + continue + + # Unpick stuff like [[word]] and [[disambiguation|word]] + # and [[w:wikipedia_page|word]] + # test cases: July 8th 2022, July 12th 2022 + if char == "[": + if text[i:i + 2] == "[[": + link = False + if text[i + 2:i + 4] == "w:": + link = True + i += 2 + end = text[i:].index("]]") + if "|" in text[i + 2:i + end]: + parts = text[i + 2:i + end].split("|") + word = parts[1] + if not link: + extra_text = " (" + parts[0] + ")" + else: + word = text[i + 2:i + end] + i += end + 1 + continue + + if char == "&": + if text[i:i + 5] == "&": + word += "&" + i += 4 + continue + + if char == "<": + j = i + text[i:].index(">") + html_tag = text[i + 1:j].replace("/", "").strip() + i = j + continue + + if char in (" ", "\n") or html_tag == "br": + w = graphics.measure_text(word, scale=scale) + if x + w > wordwrap or char == "\n" or html_tag == "br": + x = ox + y += line_height + + graphics.text(word, x, y, scale=scale) + word = "" + html_tag = "" + x += w + space_width + continue + + word += char + + # Last word + w = graphics.measure_text(word, scale=scale) + if x + w > wordwrap: + x = ox + y += line_height + + graphics.text(word, x, y, scale=scale) + + +while True: + gc.collect() + + uasyncio.get_event_loop().run_until_complete(network_manager.client(WIFI_CONFIG.SSID, WIFI_CONFIG.PSK)) + + date = list(time.localtime())[:3] + date.append(MONTHNAMES[date[1] - 1]) + + if "{3} {2}, {0}".format(*date) == last_date: + time.sleep(60) + continue + + url = ENDPOINT.format(*date) + print("Requesting URL: {}".format(url)) + socket = urequest.urlopen(url) + j = ujson.load(socket) + socket.close() + + text = j['expandtemplates']['wikitext'] + del j + gc.collect() + + text, author = parse_qotd(text) + + print(text) + + graphics.set_pen(1) + graphics.clear() + graphics.set_pen(0) + graphics.text("QoTD - {2} {3} {0:04d}".format(*date), 10, 10, scale=3) + + display_quote(text, 10, 40, 2, wordwrap=WIDTH - 20) + + graphics.text(author, 10, HEIGHT - 20, scale=2) + + graphics.update() + gc.collect() + + last_date = "{3} {2}, {0}".format(*date) + + time.sleep(60) diff --git a/examples/inky_frame_random_joke.py b/examples/inky_frame_random_joke.py new file mode 100644 index 0000000..f67c778 --- /dev/null +++ b/examples/inky_frame_random_joke.py @@ -0,0 +1,90 @@ +import gc +import uos +import random +import machine +import jpegdec +import WIFI_CONFIG +import uasyncio +from network_manager import NetworkManager +# from picographics import PicoGraphics, DISPLAY_INKY_FRAME as DISPLAY # 5.7" +# from picographics import PicoGraphics, DISPLAY_INKY_FRAME_4 as DISPLAY # 4.0" +from picographics import PicoGraphics, DISPLAY_INKY_FRAME_7 as DISPLAY # 7.3" +from urllib import urequest + + +gc.collect() # We're really gonna need that RAM! + + +def status_handler(mode, status, ip): + print(mode, status, ip) + + +network_manager = NetworkManager(WIFI_CONFIG.COUNTRY, status_handler=status_handler) +uasyncio.get_event_loop().run_until_complete(network_manager.client(WIFI_CONFIG.SSID, WIFI_CONFIG.PSK)) + + +graphics = PicoGraphics(DISPLAY) + +WIDTH, HEIGHT = graphics.get_bounds() +FILENAME = "/sd/random-joke.jpg" + +JOKE_IDS = "https://pimoroni.github.io/feed2image/jokeapi-ids.txt" +JOKE_IMG = "https://pimoroni.github.io/feed2image/jokeapi-{}-{}x{}.jpg" + +import sdcard # noqa: E402 - putting this at the top causes an MBEDTLS OOM error!? +sd_spi = machine.SPI(0, sck=machine.Pin(18, machine.Pin.OUT), mosi=machine.Pin(19, machine.Pin.OUT), miso=machine.Pin(16, machine.Pin.OUT)) +sd = sdcard.SDCard(sd_spi, machine.Pin(22)) +uos.mount(sd, "/sd") +gc.collect() # Claw back some RAM! + +# We don't have the RAM to store the list of Joke IDs in memory. +# the first line of `jokeapi-ids.txt` is a COUNT of IDs. +# Grab it, then pick a random line between 0 and COUNT. +# Seek to that line and ...y'know... there's our totally random joke ID + +socket = urequest.urlopen(JOKE_IDS) + +# Get the first line, which is a count of the joke IDs +number_of_lines = int(socket.readline().decode("ascii")) +print("Total jokes {}".format(number_of_lines)) + +# Pick a random joke (by its line number) +line = random.randint(0, number_of_lines) +print("Getting ID from line {}".format(line)) + +for x in range(line): # Throw away lines to get where we need + socket.readline() + +# Read our chosen joke ID! +random_joke_id = int(socket.readline().decode("ascii")) +socket.close() + +print("Random joke ID: {}".format(random_joke_id)) + +url = JOKE_IMG.format(random_joke_id, WIDTH, HEIGHT) + +socket = urequest.urlopen(url) + +# Stream the image data from the socket onto disk in 1024 byte chunks +# the 600x448-ish jpeg will be roughly ~24k, we really don't have the RAM! +data = bytearray(1024) +with open(FILENAME, "wb") as f: + while True: + if socket.readinto(data) == 0: + break + f.write(data) +socket.close() +del data +gc.collect() # We really are tight on RAM! + + +jpeg = jpegdec.JPEG(graphics) +gc.collect() # For good measure... + +graphics.set_pen(1) +graphics.clear() + +jpeg.open_file(FILENAME) +jpeg.decode() + +graphics.update() diff --git a/examples/inky_frame_rtc_demo.py b/examples/inky_frame_rtc_demo.py new file mode 100644 index 0000000..004d0b7 --- /dev/null +++ b/examples/inky_frame_rtc_demo.py @@ -0,0 +1,88 @@ +import time +import uasyncio +import WIFI_CONFIG +import inky_frame +from network_manager import NetworkManager +# from picographics import PicoGraphics, DISPLAY_INKY_FRAME as DISPLAY # 5.7" +# from picographics import PicoGraphics, DISPLAY_INKY_FRAME_4 as DISPLAY # 4.0" +from picographics import PicoGraphics, DISPLAY_INKY_FRAME_7 as DISPLAY # 7.3" + +# Set tz_offset to be the number of hours off of UTC for your local zone. +# Examples: tz_offset = -7 # Pacific time (PST) +# tz_offset = 1 # CEST (Paris) +tz_offset = 0 +tz_seconds = tz_offset * 3600 + +# Sync the Inky (always on) RTC to the Pico W so that "time.localtime()" works. +inky_frame.pcf_to_pico_rtc() + +# Avoid running code unless we've been triggered by an event +# Keeps this example from locking up Thonny when we want to tweak the code +if inky_frame.woken_by_rtc() or inky_frame.woken_by_button(): + graphics = PicoGraphics(DISPLAY) + WIDTH, HEIGHT = graphics.get_bounds() + + graphics.set_pen(1) + graphics.clear() + + # Look, just because this is an RTC demo, + # doesn't mean we can't make it rainbow. + for x in range(WIDTH): + h = x / WIDTH + p = graphics.create_pen_hsv(h, 1.0, 1.0) + graphics.set_pen(p) + graphics.line(x, 0, x, HEIGHT) + + graphics.set_pen(0) + graphics.rectangle(0, 0, WIDTH, 14) + graphics.set_pen(1) + graphics.text("Inky Frame", 1, 0) + graphics.set_pen(0) + + def status_handler(mode, status, ip): + print(mode, status, ip) + + year, month, day, hour, minute, second, dow, _ = time.localtime(time.time() + tz_seconds) + + # Connect to the network and get the time if it's not set + if year < 2023: + connected = False + network_manager = NetworkManager(WIFI_CONFIG.COUNTRY, status_handler=status_handler, client_timeout=60) + + t_start = time.time() + try: + uasyncio.get_event_loop().run_until_complete(network_manager.client(WIFI_CONFIG.SSID, WIFI_CONFIG.PSK)) + connected = True + except RuntimeError: + pass + t_end = time.time() + + if connected: + inky_frame.set_time() + + graphics.text("Setting time from network...", 0, 40) + graphics.text(f"Connection took: {t_end-t_start}s", 0, 60) + else: + graphics.text("Failed to connect!", 0, 40) + + # Display the date and time + year, month, day, hour, minute, second, dow, _ = time.localtime(time.time() + tz_seconds) + + date_time = f"{year:04}/{month:02}/{day:02} {hour:02}:{minute:02}:{second:02}" + + graphics.set_font("bitmap8") + + text_scale = 8 if WIDTH == 800 else 6 + text_height = 8 * text_scale + + offset_left = (WIDTH - graphics.measure_text(date_time, scale=text_scale)) // 2 + offset_top = (HEIGHT - text_height) // 2 + + graphics.set_pen(graphics.create_pen(50, 50, 50)) + graphics.text(date_time, offset_left + 2, offset_top + 2, scale=text_scale) + graphics.set_pen(1) + graphics.text(date_time, offset_left, offset_top, scale=text_scale) + + graphics.update() + + inky_frame.sleep_for(2) diff --git a/examples/inky_frame_xkcd_daily.py b/examples/inky_frame_xkcd_daily.py new file mode 100644 index 0000000..69e0909 --- /dev/null +++ b/examples/inky_frame_xkcd_daily.py @@ -0,0 +1,78 @@ +import gc +import uos +import machine +import jpegdec +import uasyncio +import sdcard +import WIFI_CONFIG +from urllib import urequest +from network_manager import NetworkManager +# from picographics import PicoGraphics, DISPLAY_INKY_FRAME as DISPLAY # 5.7" +# from picographics import PicoGraphics, DISPLAY_INKY_FRAME_4 as DISPLAY # 4.0" +from picographics import PicoGraphics, DISPLAY_INKY_FRAME_7 as DISPLAY # 7.3" + +""" +xkcd daily + +You *must* insert an SD card into Inky Frame! +We need somewhere to save the jpg for display. + +Fetches a pre-processed XKCD daily image from: +https://pimoroni.github.io/feed2image/xkcd-daily.jpg + +See https://xkcd.com/ for more webcomics! +""" + +gc.collect() # We're really gonna need that RAM! + + +def status_handler(mode, status, ip): + print(mode, status, ip) + + +network_manager = NetworkManager("GB", status_handler=status_handler) +uasyncio.get_event_loop().run_until_complete(network_manager.client(WIFI_CONFIG.SSID, WIFI_CONFIG.PSK)) + + +graphics = PicoGraphics(DISPLAY) + +WIDTH, HEIGHT = graphics.get_bounds() +FILENAME = "/sd/xkcd-daily.jpg" +ENDPOINT = "https://pimoroni.github.io/feed2image/xkcd-daily.jpg" + + +sd_spi = machine.SPI(0, sck=machine.Pin(18, machine.Pin.OUT), mosi=machine.Pin(19, machine.Pin.OUT), miso=machine.Pin(16, machine.Pin.OUT)) +sd = sdcard.SDCard(sd_spi, machine.Pin(22)) +uos.mount(sd, "/sd") +gc.collect() # Claw back some RAM! + + +url = ENDPOINT + +if (WIDTH, HEIGHT) != (600, 448): + url = url.replace("xkcd-", f"xkcd-{WIDTH}x{HEIGHT}-") + +socket = urequest.urlopen(url) + +# Stream the image data from the socket onto disk in 1024 byte chunks +# the 600x448-ish jpeg will be roughly ~24k, we really don't have the RAM! +data = bytearray(1024) +with open(FILENAME, "wb") as f: + while True: + if socket.readinto(data) == 0: + break + f.write(data) +socket.close() +gc.collect() # We really are tight on RAM! + + +jpeg = jpegdec.JPEG(graphics) +gc.collect() # For good measure... + +graphics.set_pen(1) +graphics.clear() + +jpeg.open_file(FILENAME) +jpeg.decode() + +graphics.update() diff --git a/examples/inkylauncher/daily_activity.py b/examples/inkylauncher/daily_activity.py new file mode 100644 index 0000000..265c4d4 --- /dev/null +++ b/examples/inkylauncher/daily_activity.py @@ -0,0 +1,127 @@ +import gc +import ujson +from urllib import urequest + +# Length of time between updates in Seconds. +# Frequent updates will reduce battery life! +UPDATE_INTERVAL = 240 + +# API URL +URL = "https://www.boredapi.com/api/activity" + +graphics = None +text = None + +gc.collect() + + +def display_quote(text, ox, oy, scale, wordwrap): + # Processing text is memory intensive + # so we'll do it one char at a time as we draw to the screen + line_height = 8 * scale + html = False + html_tag = "" + word = "" + space_width = graphics.measure_text(" ", scale=scale) + x = ox + y = oy + for char in text: + if char in "[]": + continue + if char == "<": + html = True + html_tag = "" + continue + if char == ">": + html = False + continue + if html: + if char in "/ ": + continue + html_tag += char + continue + if char in (" ", "\n") or html_tag == "br": + w = graphics.measure_text(word, scale=scale) + if x + w > wordwrap or char == "\n" or html_tag == "br": + x = ox + y += line_height + + graphics.text(word, x, y, scale=scale) + word = "" + html_tag = "" + x += w + space_width + continue + + word += char + + # Last word + w = graphics.measure_text(word, scale=scale) + if x + w > wordwrap: + x = ox + y += line_height + + graphics.text(word, x, y, scale=scale) + + +def update(): + global text + gc.collect() + + try: + # Grab the data + socket = urequest.urlopen(URL) + j = ujson.load(socket) + socket.close() + text = [j['activity'], j['type'], j['participants']] + gc.collect() + except OSError: + pass + + +def draw(): + global text + + WIDTH, HEIGHT = graphics.get_bounds() + + # Clear the screen + graphics.set_pen(1) + graphics.clear() + graphics.set_pen(0) + + # Page lines! + graphics.set_pen(3) + graphics.line(0, 65, WIDTH, 65) + for i in range(2, 13): + graphics.line(0, i * 35, WIDTH, i * 35) + + gc.collect() + + # Page margin + graphics.set_pen(4) + graphics.line(50, 0, 50, HEIGHT) + graphics.set_pen(0) + + # Main text + graphics.set_font("cursive") + graphics.set_pen(4) + graphics.set_font("cursive") + graphics.text("Activity Idea", 55, 30, WIDTH - 20, 2) + graphics.set_pen(0) + graphics.set_font("bitmap8") + + if text: + display_quote(text[0], 55, 170, 5, WIDTH - 20) + gc.collect() + graphics.set_pen(2) + graphics.text("Activity Type: " + text[1], 55, HEIGHT - 45, WIDTH - 20, 2) + graphics.text("Participants: " + str(text[2]), 400, HEIGHT - 45, WIDTH - 20, 2) + else: + graphics.set_pen(4) + graphics.rectangle(0, (HEIGHT // 2) - 20, WIDTH, 40) + graphics.set_pen(1) + graphics.text("Unable to get activity data!", 5, (HEIGHT // 2) - 15, WIDTH, 2) + graphics.text("Check your network settings in secrets.py", 5, (HEIGHT // 2) + 2, WIDTH, 2) + + graphics.update() + + gc.collect() diff --git a/examples/inkylauncher/inky_helper.py b/examples/inkylauncher/inky_helper.py new file mode 100644 index 0000000..2d680ae --- /dev/null +++ b/examples/inkylauncher/inky_helper.py @@ -0,0 +1,154 @@ +from pimoroni_i2c import PimoroniI2C +from pcf85063a import PCF85063A +import math +from machine import Pin, PWM, Timer +import time +import inky_frame +import json +import network +import os + +# Pin setup for VSYS_HOLD needed to sleep and wake. +HOLD_VSYS_EN_PIN = 2 +hold_vsys_en_pin = Pin(HOLD_VSYS_EN_PIN, Pin.OUT) + +# intialise the pcf85063a real time clock chip +I2C_SDA_PIN = 4 +I2C_SCL_PIN = 5 +i2c = PimoroniI2C(I2C_SDA_PIN, I2C_SCL_PIN, 100000) +rtc = PCF85063A(i2c) + +led_warn = Pin(6, Pin.OUT) + +# set up for the network LED +network_led_pwm = PWM(Pin(7)) +network_led_pwm.freq(1000) +network_led_pwm.duty_u16(0) + + +# set the brightness of the network led +def network_led(brightness): + brightness = max(0, min(100, brightness)) # clamp to range + # gamma correct the brightness (gamma 2.8) + value = int(pow(brightness / 100.0, 2.8) * 65535.0 + 0.5) + network_led_pwm.duty_u16(value) + + +network_led_timer = Timer(-1) +network_led_pulse_speed_hz = 1 + + +def network_led_callback(t): + # updates the network led brightness based on a sinusoid seeded by the current time + brightness = (math.sin(time.ticks_ms() * math.pi * 2 / (1000 / network_led_pulse_speed_hz)) * 40) + 60 + value = int(pow(brightness / 100.0, 2.8) * 65535.0 + 0.5) + network_led_pwm.duty_u16(value) + + +# set the network led into pulsing mode +def pulse_network_led(speed_hz=1): + global network_led_timer, network_led_pulse_speed_hz + network_led_pulse_speed_hz = speed_hz + network_led_timer.deinit() + network_led_timer.init(period=50, mode=Timer.PERIODIC, callback=network_led_callback) + + +# turn off the network led and disable any pulsing animation that's running +def stop_network_led(): + global network_led_timer + network_led_timer.deinit() + network_led_pwm.duty_u16(0) + + +def sleep(t): + # Time to have a little nap until the next update + rtc.clear_timer_flag() + rtc.set_timer(t, ttp=rtc.TIMER_TICK_1_OVER_60HZ) + rtc.enable_timer_interrupt(True) + + # Set the HOLD VSYS pin to an input + # this allows the device to go into sleep mode when on battery power. + hold_vsys_en_pin.init(Pin.IN) + + # Regular time.sleep for those powering from USB + time.sleep(60 * t) + + +# Turns off the button LEDs +def clear_button_leds(): + inky_frame.button_a.led_off() + inky_frame.button_b.led_off() + inky_frame.button_c.led_off() + inky_frame.button_d.led_off() + inky_frame.button_e.led_off() + + +def network_connect(SSID, PSK): + # Enable the Wireless + wlan = network.WLAN(network.STA_IF) + wlan.active(True) + + # Number of attempts to make before timeout + max_wait = 10 + + # Sets the Wireless LED pulsing and attempts to connect to your local network. + pulse_network_led() + wlan.config(pm=0xa11140) # Turn WiFi power saving off for some slow APs + wlan.connect(SSID, PSK) + + while max_wait > 0: + if wlan.status() < 0 or wlan.status() >= 3: + break + max_wait -= 1 + print('waiting for connection...') + time.sleep(1) + + stop_network_led() + network_led_pwm.duty_u16(30000) + + # Handle connection error. Switches the Warn LED on. + if wlan.status() != 3: + stop_network_led() + led_warn.on() + + +state = {"run": None} +app = None + + +def file_exists(filename): + try: + return (os.stat(filename)[0] & 0x4000) == 0 + except OSError: + return False + + +def clear_state(): + if file_exists("state.json"): + os.remove("state.json") + + +def save_state(data): + with open("/state.json", "w") as f: + f.write(json.dumps(data)) + f.flush() + + +def load_state(): + global state + data = json.loads(open("/state.json", "r").read()) + if type(data) is dict: + state = data + + +def update_state(running): + global state + state['run'] = running + save_state(state) + + +def launch_app(app_name): + global app + app = __import__(app_name) + print(app) + update_state(app_name) diff --git a/examples/inkylauncher/lib/logging.mpy b/examples/inkylauncher/lib/logging.mpy new file mode 100644 index 0000000..fc00426 Binary files /dev/null and b/examples/inkylauncher/lib/logging.mpy differ diff --git a/examples/inkylauncher/lib/sdcard.mpy b/examples/inkylauncher/lib/sdcard.mpy new file mode 100644 index 0000000..7f3fd6b Binary files /dev/null and b/examples/inkylauncher/lib/sdcard.mpy differ diff --git a/examples/inkylauncher/lib/tinyweb/server.mpy b/examples/inkylauncher/lib/tinyweb/server.mpy new file mode 100644 index 0000000..a841634 Binary files /dev/null and b/examples/inkylauncher/lib/tinyweb/server.mpy differ diff --git a/examples/inkylauncher/lib/tinyweb/server.py b/examples/inkylauncher/lib/tinyweb/server.py new file mode 100644 index 0000000..c61b8b0 --- /dev/null +++ b/examples/inkylauncher/lib/tinyweb/server.py @@ -0,0 +1,662 @@ +""" +Tiny Web - pretty simple and powerful web server for tiny platforms like ESP8266 / ESP32 +MIT license +(C) Konstantin Belyalov 2017-2018 +""" +import logging +import uasyncio as asyncio +import uasyncio.core +import ujson as json +import gc +import uos as os +import sys +import uerrno as errno +import usocket as socket + + +log = logging.getLogger('WEB') + +type_gen = type((lambda: (yield))()) # noqa: E275 + +# uasyncio v3 is shipped with MicroPython 1.13, and contains some subtle +# but breaking changes. See also https://github.com/peterhinch/micropython-async/blob/master/v3/README.md +IS_UASYNCIO_V3 = hasattr(asyncio, "__version__") and asyncio.__version__ >= (3,) + + +def urldecode_plus(s): + """Decode urlencoded string (including '+' char). + Returns decoded string + """ + s = s.replace('+', ' ') + arr = s.split('%') + res = arr[0] + for it in arr[1:]: + if len(it) >= 2: + res += chr(int(it[:2], 16)) + it[2:] + elif len(it) == 0: + res += '%' + else: + res += it + return res + + +def parse_query_string(s): + """Parse urlencoded string into dict. + Returns dict + """ + res = {} + pairs = s.split('&') + for p in pairs: + vals = [urldecode_plus(x) for x in p.split('=', 1)] + if len(vals) == 1: + res[vals[0]] = '' + else: + res[vals[0]] = vals[1] + return res + + +class HTTPException(Exception): + """HTTP protocol exceptions""" + + def __init__(self, code=400): + self.code = code + + +class request: + """HTTP Request class""" + + def __init__(self, _reader): + self.reader = _reader + self.headers = {} + self.method = b'' + self.path = b'' + self.query_string = b'' + + async def read_request_line(self): + """Read and parse first line (AKA HTTP Request Line). + Function is generator. + Request line is something like: + GET /something/script?param1=val1 HTTP/1.1 + """ + while True: + rl = await self.reader.readline() + # skip empty lines + if rl == b'\r\n' or rl == b'\n': + continue + break + rl_frags = rl.split() + if len(rl_frags) != 3: + raise HTTPException(400) + self.method = rl_frags[0] + url_frags = rl_frags[1].split(b'?', 1) + self.path = url_frags[0] + if len(url_frags) > 1: + self.query_string = url_frags[1] + + async def read_headers(self, save_headers=[]): + """Read and parse HTTP headers until \r\n\r\n: + Optional argument 'save_headers' controls which headers to save. + This is done mostly to deal with memory constrains. + Function is generator. + HTTP headers could be like: + Host: google.com + Content-Type: blah + \r\n + """ + while True: + gc.collect() + line = await self.reader.readline() + if line == b'\r\n': + break + frags = line.split(b':', 1) + if len(frags) != 2: + raise HTTPException(400) + if frags[0] in save_headers: + self.headers[frags[0]] = frags[1].strip() + + async def read_parse_form_data(self): + """Read HTTP form data (payload), if any. + Function is generator. + Returns: + - dict of key / value pairs + - None in case of no form data present + """ + # TODO: Probably there is better solution how to handle + # request body, at least for simple urlencoded forms - by processing + # chunks instead of accumulating payload. + gc.collect() + if b'Content-Length' not in self.headers: + return {} + # Parse payload depending on content type + if b'Content-Type' not in self.headers: + # Unknown content type, return unparsed, raw data + return {} + size = int(self.headers[b'Content-Length']) + if size > self.params['max_body_size'] or size < 0: + raise HTTPException(413) + data = await self.reader.readexactly(size) + # Use only string before ';', e.g: + # application/x-www-form-urlencoded; charset=UTF-8 + ct = self.headers[b'Content-Type'].split(b';', 1)[0] + try: + if ct == b'application/json': + return json.loads(data) + elif ct == b'application/x-www-form-urlencoded': + return parse_query_string(data.decode()) + except ValueError: + # Re-generate exception for malformed form data + raise HTTPException(400) + + +class response: + """HTTP Response class""" + + def __init__(self, _writer): + self.writer = _writer + self.send = _writer.awrite + self.code = 200 + self.version = '1.0' + self.headers = {} + + async def _send_headers(self): + """Compose and send: + - HTTP request line + - HTTP headers following by \r\n. + This function is generator. + P.S. + Because of usually we have only a few HTTP headers (2-5) it doesn't make sense + to send them separately - sometimes it could increase latency. + So combining headers together and send them as single "packet". + """ + # Request line + hdrs = 'HTTP/{} {} MSG\r\n'.format(self.version, self.code) + # Headers + for k, v in self.headers.items(): + hdrs += '{}: {}\r\n'.format(k, v) + hdrs += '\r\n' + # Collect garbage after small mallocs + gc.collect() + await self.send(hdrs) + + async def error(self, code, msg=None): + """Generate HTTP error response + This function is generator. + Arguments: + code - HTTP response code + Example: + # Not enough permissions. Send HTTP 403 - Forbidden + await resp.error(403) + """ + self.code = code + if msg: + self.add_header('Content-Length', len(msg)) + await self._send_headers() + if msg: + await self.send(msg) + + async def redirect(self, location, msg=None): + """Generate HTTP redirect response to 'location'. + Basically it will generate HTTP 302 with 'Location' header + Arguments: + location - URL to redirect to + Example: + # Redirect to /something + await resp.redirect('/something') + """ + self.code = 302 + self.add_header('Location', location) + if msg: + self.add_header('Content-Length', len(msg)) + await self._send_headers() + if msg: + await self.send(msg) + + def add_header(self, key, value): + """Add HTTP response header + Arguments: + key - header name + value - header value + Example: + resp.add_header('Content-Encoding', 'gzip') + """ + self.headers[key] = value + + def add_access_control_headers(self): + """Add Access Control related HTTP response headers. + This is required when working with RestApi (JSON requests) + """ + self.add_header('Access-Control-Allow-Origin', self.params['allowed_access_control_origins']) + self.add_header('Access-Control-Allow-Methods', self.params['allowed_access_control_methods']) + self.add_header('Access-Control-Allow-Headers', self.params['allowed_access_control_headers']) + + async def start_html(self): + """Start response with HTML content type. + This function is generator. + Example: + await resp.start_html() + await resp.send('

Hello, world!

') + """ + self.add_header('Content-Type', 'text/html') + await self._send_headers() + + async def send_file(self, filename, content_type=None, content_encoding=None, max_age=2592000, buf_size=128): + """Send local file as HTTP response. + This function is generator. + Arguments: + filename - Name of file which exists in local filesystem + Keyword arguments: + content_type - Filetype. By default - None means auto-detect. + max_age - Cache control. How long browser can keep this file on disk. + By default - 30 days + Set to 0 - to disable caching. + Example 1: Default use case: + await resp.send_file('images/cat.jpg') + Example 2: Disable caching: + await resp.send_file('static/index.html', max_age=0) + Example 3: Override content type: + await resp.send_file('static/file.bin', content_type='application/octet-stream') + """ + try: + # Get file size + stat = os.stat(filename) + slen = str(stat[6]) + self.add_header('Content-Length', slen) + # Find content type + if content_type: + self.add_header('Content-Type', content_type) + # Add content-encoding, if any + if content_encoding: + self.add_header('Content-Encoding', content_encoding) + # Since this is static content is totally make sense + # to tell browser to cache it, however, you can always + # override it by setting max_age to zero + self.add_header('Cache-Control', 'max-age={}, public'.format(max_age)) + with open(filename) as f: + await self._send_headers() + gc.collect() + buf = bytearray(min(stat[6], buf_size)) + while True: + size = f.readinto(buf) + if size == 0: + break + await self.send(buf, sz=size) + except OSError as e: + # special handling for ENOENT / EACCESS + if e.args[0] in (errno.ENOENT, errno.EACCES): + raise HTTPException(404) + else: + raise + + +async def restful_resource_handler(req, resp, param=None): + """Handler for RESTful API endpoins""" + # Gather data - query string, JSON in request body... + data = await req.read_parse_form_data() + # Add parameters from URI query string as well + # This one is actually for simply development of RestAPI + if req.query_string != b'': + data.update(parse_query_string(req.query_string.decode())) + # Call actual handler + _handler, _kwargs = req.params['_callmap'][req.method] + # Collect garbage before / after handler execution + gc.collect() + if param: + res = _handler(data, param, **_kwargs) + else: + res = _handler(data, **_kwargs) + gc.collect() + # Handler result could be: + # 1. generator - in case of large payload + # 2. string - just string :) + # 2. dict - meaning client what tinyweb to convert it to JSON + # it can also return error code together with str / dict + # res = {'blah': 'blah'} + # res = {'blah': 'blah'}, 201 + if isinstance(res, type_gen): + # Result is generator, use chunked response + # NOTICE: HTTP 1.0 by itself does not support chunked responses, so, making workaround: + # Response is HTTP/1.1 with Connection: close + resp.version = '1.1' + resp.add_header('Connection', 'close') + resp.add_header('Content-Type', 'application/json') + resp.add_header('Transfer-Encoding', 'chunked') + resp.add_access_control_headers() + await resp._send_headers() + # Drain generator + for chunk in res: + chunk_len = len(chunk.encode('utf-8')) + await resp.send('{:x}\r\n'.format(chunk_len)) + await resp.send(chunk) + await resp.send('\r\n') + gc.collect() + await resp.send('0\r\n\r\n') + else: + if type(res) is tuple: + resp.code = res[1] + res = res[0] + elif res is None: + raise Exception('Result expected') + # Send response + if type(res) is dict: + res_str = json.dumps(res) + else: + res_str = res + resp.add_header('Content-Type', 'application/json') + resp.add_header('Content-Length', str(len(res_str))) + resp.add_access_control_headers() + await resp._send_headers() + await resp.send(res_str) + + +class webserver: + + def __init__(self, request_timeout=3, max_concurrency=3, backlog=16, debug=False): + """Tiny Web Server class. + Keyword arguments: + request_timeout - Time for client to send complete request + after that connection will be closed. + max_concurrency - How many connections can be processed concurrently. + It is very important to limit this number because of + memory constrain. + Default value depends on platform + backlog - Parameter to socket.listen() function. Defines size of + pending to be accepted connections queue. + Must be greater than max_concurrency + debug - Whether send exception info (text + backtrace) + to client together with HTTP 500 or not. + """ + self.loop = asyncio.get_event_loop() + self.request_timeout = request_timeout + self.max_concurrency = max_concurrency + self.backlog = backlog + self.debug = debug + self.explicit_url_map = {} + self.catch_all_handler = None + self.parameterized_url_map = {} + # Currently opened connections + self.conns = {} + # Statistics + self.processed_connections = 0 + + def _find_url_handler(self, req): + """Helper to find URL handler. + Returns tuple of (function, opts, param) or (None, None) if not found. + """ + # First try - lookup in explicit (non parameterized URLs) + if req.path in self.explicit_url_map: + return self.explicit_url_map[req.path] + # Second try - strip last path segment and lookup in another map + idx = req.path.rfind(b'/') + 1 + path2 = req.path[:idx] + if len(path2) > 0 and path2 in self.parameterized_url_map: + # Save parameter into request + req._param = req.path[idx:].decode() + return self.parameterized_url_map[path2] + + if self.catch_all_handler: + return self.catch_all_handler + + # No handler found + return (None, None) + + async def _handle_request(self, req, resp): + await req.read_request_line() + # Find URL handler + req.handler, req.params = self._find_url_handler(req) + if not req.handler: + # No URL handler found - read response and issue HTTP 404 + await req.read_headers() + raise HTTPException(404) + # req.params = params + # req.handler = han + resp.params = req.params + # Read / parse headers + await req.read_headers(req.params['save_headers']) + + async def _handler(self, reader, writer): + """Handler for TCP connection with + HTTP/1.0 protocol implementation + """ + gc.collect() + + try: + req = request(reader) + resp = response(writer) + # Read HTTP Request with timeout + await asyncio.wait_for(self._handle_request(req, resp), + self.request_timeout) + + # OPTIONS method is handled automatically + if req.method == b'OPTIONS': + resp.add_access_control_headers() + # Since we support only HTTP 1.0 - it is important + # to tell browser that there is no payload expected + # otherwise some webkit based browsers (Chrome) + # treat this behavior as an error + resp.add_header('Content-Length', '0') + await resp._send_headers() + return + + # Ensure that HTTP method is allowed for this path + if req.method not in req.params['methods']: + raise HTTPException(405) + + # Handle URL + gc.collect() + if hasattr(req, '_param'): + await req.handler(req, resp, req._param) + else: + await req.handler(req, resp) + # Done here + except (asyncio.CancelledError, asyncio.TimeoutError): + pass + except OSError as e: + # Do not send response for connection related errors - too late :) + # P.S. code 32 - is possible BROKEN PIPE error (TODO: is it true?) + if e.args[0] not in (errno.ECONNABORTED, errno.ECONNRESET, 32): + try: + await resp.error(500) + except Exception as e: + log.exc(e, "") + except HTTPException as e: + try: + await resp.error(e.code) + except Exception as e: + log.exc(e) + except Exception as e: + # Unhandled expection in user's method + log.error(req.path.decode()) + log.exc(e, "") + try: + await resp.error(500) + # Send exception info if desired + if self.debug: + sys.print_exception(e, resp.writer.s) + except Exception: + pass + finally: + await writer.aclose() + # Max concurrency support - + # if queue is full schedule resume of TCP server task + if len(self.conns) == self.max_concurrency: + self.loop.create_task(self._server_coro) + # Delete connection, using socket as a key + del self.conns[id(writer.s)] + + def add_route(self, url, f, **kwargs): + """Add URL to function mapping. + Arguments: + url - url to map function with + f - function to map + Keyword arguments: + methods - list of allowed methods. Defaults to ['GET', 'POST'] + save_headers - contains list of HTTP headers to be saved. Case sensitive. Default - empty. + max_body_size - Max HTTP body size (e.g. POST form data). Defaults to 1024 + allowed_access_control_headers - Default value for the same name header. Defaults to * + allowed_access_control_origins - Default value for the same name header. Defaults to * + """ + if url == '' or '?' in url: + raise ValueError('Invalid URL') + # Initial params for route + params = {'methods': ['GET'], + 'save_headers': [], + 'max_body_size': 1024, + 'allowed_access_control_headers': '*', + 'allowed_access_control_origins': '*', + } + params.update(kwargs) + params['allowed_access_control_methods'] = ', '.join(params['methods']) + # Convert methods/headers to bytestring + params['methods'] = [x.encode() for x in params['methods']] + params['save_headers'] = [x.encode() for x in params['save_headers']] + # If URL has a parameter + if url.endswith('>'): + idx = url.rfind('<') + path = url[:idx] + idx += 1 + param = url[idx:-1] + if path.encode() in self.parameterized_url_map: + raise ValueError('URL exists') + params['_param_name'] = param + self.parameterized_url_map[path.encode()] = (f, params) + + if url.encode() in self.explicit_url_map: + raise ValueError('URL exists') + self.explicit_url_map[url.encode()] = (f, params) + + def add_resource(self, cls, url, **kwargs): + """Map resource (RestAPI) to URL + Arguments: + cls - Resource class to map to + url - url to map to class + kwargs - User defined key args to pass to the handler. + Example: + class myres(): + def get(self, data): + return {'hello': 'world'} + app.add_resource(myres, '/api/myres') + """ + methods = [] + callmap = {} + # Create instance of resource handler, if passed as just class (not instance) + try: + obj = cls() + except TypeError: + obj = cls + # Get all implemented HTTP methods and make callmap + for m in ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']: + fn = m.lower() + if hasattr(obj, fn): + methods.append(m) + callmap[m.encode()] = (getattr(obj, fn), kwargs) + self.add_route(url, restful_resource_handler, + methods=methods, + save_headers=['Content-Length', 'Content-Type'], + _callmap=callmap) + + def catchall(self): + """Decorator for catchall() + Example: + @app.catchall() + def catchall_handler(req, resp): + response.code = 404 + await response.start_html() + await response.send('

My custom 404!

\n') + """ + params = {'methods': [b'GET'], 'save_headers': [], 'max_body_size': 1024, 'allowed_access_control_headers': '*', 'allowed_access_control_origins': '*'} + + def _route(f): + self.catch_all_handler = (f, params) + return f + return _route + + def route(self, url, **kwargs): + """Decorator for add_route() + Example: + @app.route('/') + def index(req, resp): + await resp.start_html() + await resp.send('

Hello, world!

\n') + """ + def _route(f): + self.add_route(url, f, **kwargs) + return f + return _route + + def resource(self, url, method='GET', **kwargs): + """Decorator for add_resource() method + Examples: + @app.resource('/users') + def users(data): + return {'a': 1} + @app.resource('/messages/') + async def index(data, topic_id): + yield '{' + yield '"topic_id": "{}",'.format(topic_id) + yield '"message": "test",' + yield '}' + """ + def _resource(f): + self.add_route(url, restful_resource_handler, + methods=[method], + save_headers=['Content-Length', 'Content-Type'], + _callmap={method.encode(): (f, kwargs)}) + return f + return _resource + + async def _tcp_server(self, host, port, backlog): + """TCP Server implementation. + Opens socket for accepting connection and + creates task for every new accepted connection + """ + addr = socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM)[0][-1] + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setblocking(False) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(addr) + sock.listen(backlog) + try: + while True: + if IS_UASYNCIO_V3: + yield uasyncio.core._io_queue.queue_read(sock) + else: + yield asyncio.IORead(sock) + csock, caddr = sock.accept() + csock.setblocking(False) + # Start handler / keep it in the map - to be able to + # shutdown gracefully - by close all connections + self.processed_connections += 1 + hid = id(csock) + handler = self._handler(asyncio.StreamReader(csock), + asyncio.StreamWriter(csock, {})) + self.conns[hid] = handler + self.loop.create_task(handler) + # In case of max concurrency reached - temporary pause server: + # 1. backlog must be greater than max_concurrency, otherwise + # client will got "Connection Reset" + # 2. Server task will be resumed whenever one active connection finished + if len(self.conns) == self.max_concurrency: + # Pause + yield False + except asyncio.CancelledError: + return + finally: + sock.close() + + def run(self, host="127.0.0.1", port=8081, loop_forever=True): + """Run Web Server. By default it runs forever. + Keyword arguments: + host - host to listen on. By default - localhost (127.0.0.1) + port - port to listen on. By default - 8081 + loop_forever - run loo.loop_forever(), otherwise caller must run it by itself. + """ + self._server_coro = self._tcp_server(host, port, self.backlog) + self.loop.create_task(self._server_coro) + if loop_forever: + self.loop.run_forever() + + def shutdown(self): + """Gracefully shutdown Web Server""" + asyncio.cancel(self._server_coro) + for hid, coro in self.conns.items(): + asyncio.cancel(coro) diff --git a/examples/inkylauncher/lib/urllib/urequest.mpy b/examples/inkylauncher/lib/urllib/urequest.mpy new file mode 100644 index 0000000..8cf56fb Binary files /dev/null and b/examples/inkylauncher/lib/urllib/urequest.mpy differ diff --git a/examples/inkylauncher/main.py b/examples/inkylauncher/main.py new file mode 100644 index 0000000..6bfcc6f --- /dev/null +++ b/examples/inkylauncher/main.py @@ -0,0 +1,168 @@ +import gc +import time +from machine import reset +import inky_helper as ih + +# Uncomment the line for your Inky Frame display size +# from picographics import PicoGraphics, DISPLAY_INKY_FRAME_4 as DISPLAY # 4.0" +# from picographics import PicoGraphics, DISPLAY_INKY_FRAME as DISPLAY # 5.7" +from picographics import PicoGraphics, DISPLAY_INKY_FRAME_7 as DISPLAY # 7.3" + +# Create a secrets.py with your Wifi details to be able to get the time +# +# secrets.py should contain: +# WIFI_SSID = "Your WiFi SSID" +# WIFI_PASSWORD = "Your WiFi password" + +# A short delay to give USB chance to initialise +time.sleep(0.5) + +# Setup for the display. +graphics = PicoGraphics(DISPLAY) +WIDTH, HEIGHT = graphics.get_bounds() +graphics.set_font("bitmap8") + + +def launcher(): + + # Apply an offset for the Inky Frame 5.7". + if HEIGHT == 448: + y_offset = 20 + # Inky Frame 7.3" + elif HEIGHT == 480: + y_offset = 35 + # Inky Frame 4" + else: + y_offset = 0 + + # Draws the menu + graphics.set_pen(1) + graphics.clear() + graphics.set_pen(0) + + graphics.set_pen(graphics.create_pen(255, 215, 0)) + graphics.rectangle(0, 0, WIDTH, 50) + graphics.set_pen(0) + title = "Launcher" + title_len = graphics.measure_text(title, 4) // 2 + graphics.text(title, (WIDTH // 2 - title_len), 10, WIDTH, 4) + + graphics.set_pen(4) + graphics.rectangle(30, HEIGHT - (340 + y_offset), WIDTH - 100, 50) + graphics.set_pen(1) + graphics.text("A. NASA Picture of the Day", 35, HEIGHT - (325 + y_offset), 600, 3) + + graphics.set_pen(6) + graphics.rectangle(30, HEIGHT - (280 + y_offset), WIDTH - 150, 50) + graphics.set_pen(1) + graphics.text("B. Word Clock", 35, HEIGHT - (265 + y_offset), 600, 3) + + graphics.set_pen(2) + graphics.rectangle(30, HEIGHT - (220 + y_offset), WIDTH - 200, 50) + graphics.set_pen(1) + graphics.text("C. Daily Activity", 35, HEIGHT - (205 + y_offset), 600, 3) + + graphics.set_pen(3) + graphics.rectangle(30, HEIGHT - (160 + y_offset), WIDTH - 250, 50) + graphics.set_pen(1) + graphics.text("D. Headlines", 35, HEIGHT - (145 + y_offset), 600, 3) + + graphics.set_pen(0) + graphics.rectangle(30, HEIGHT - (100 + y_offset), WIDTH - 300, 50) + graphics.set_pen(1) + graphics.text("E. Random Joke", 35, HEIGHT - (85 + y_offset), 600, 3) + + graphics.set_pen(graphics.create_pen(220, 220, 220)) + graphics.rectangle(WIDTH - 100, HEIGHT - (340 + y_offset), 70, 50) + graphics.rectangle(WIDTH - 150, HEIGHT - (280 + y_offset), 120, 50) + graphics.rectangle(WIDTH - 200, HEIGHT - (220 + y_offset), 170, 50) + graphics.rectangle(WIDTH - 250, HEIGHT - (160 + y_offset), 220, 50) + graphics.rectangle(WIDTH - 300, HEIGHT - (100 + y_offset), 270, 50) + + graphics.set_pen(0) + note = "Hold A + E, then press Reset, to return to the Launcher" + note_len = graphics.measure_text(note, 2) // 2 + graphics.text(note, (WIDTH // 2 - note_len), HEIGHT - 30, 600, 2) + + ih.led_warn.on() + graphics.update() + ih.led_warn.off() + + # Now we've drawn the menu to the screen, we wait here for the user to select an app. + # Then once an app is selected, we set that as the current app and reset the device and load into it. + + # You can replace any of the included examples with one of your own, + # just replace the name of the app in the line "ih.update_last_app("nasa_apod")" + + while True: + if ih.inky_frame.button_a.read(): + ih.inky_frame.button_a.led_on() + ih.update_state("nasa_apod") + time.sleep(0.5) + reset() + if ih.inky_frame.button_b.read(): + ih.inky_frame.button_b.led_on() + ih.update_state("word_clock") + time.sleep(0.5) + reset() + if ih.inky_frame.button_c.read(): + ih.inky_frame.button_c.led_on() + ih.update_state("daily_activity") + time.sleep(0.5) + reset() + if ih.inky_frame.button_d.read(): + ih.inky_frame.button_d.led_on() + ih.update_state("news_headlines") + time.sleep(0.5) + reset() + if ih.inky_frame.button_e.read(): + ih.inky_frame.button_e.led_on() + ih.update_state("random_joke") + time.sleep(0.5) + reset() + + +# Turn any LEDs off that may still be on from last run. +ih.clear_button_leds() +ih.led_warn.off() + +if ih.inky_frame.button_a.read() and ih.inky_frame.button_e.read(): + launcher() + +ih.clear_button_leds() + +if ih.file_exists("state.json"): + # Loads the JSON and launches the app + ih.load_state() + ih.launch_app(ih.state['run']) + + # Passes the the graphics object from the launcher to the app + ih.app.graphics = graphics + ih.app.WIDTH = WIDTH + ih.app.HEIGHT = HEIGHT + +else: + launcher() + +try: + from secrets import WIFI_SSID, WIFI_PASSWORD + ih.network_connect(WIFI_SSID, WIFI_PASSWORD) +except ImportError: + print("Create secrets.py with your WiFi credentials") + +# Get some memory back, we really need it! +gc.collect() + +# The main loop executes the update and draw function from the imported app, +# and then goes to sleep ZzzzZZz + +file = ih.file_exists("state.json") + +print(file) + +while True: + ih.app.update() + ih.led_warn.on() + ih.app.draw() + ih.led_warn.off() + ih.sleep(ih.app.UPDATE_INTERVAL) diff --git a/examples/inkylauncher/nasa_apod.py b/examples/inkylauncher/nasa_apod.py new file mode 100644 index 0000000..66b577b --- /dev/null +++ b/examples/inkylauncher/nasa_apod.py @@ -0,0 +1,101 @@ +import gc +import jpegdec +from urllib import urequest +from ujson import load + +gc.collect() + +graphics = None +WIDTH = None +HEIGHT = None + +FILENAME = "nasa-apod-daily" + +# A Demo Key is used in this example and is IP rate limited. You can get your own API Key from https://api.nasa.gov/ +API_URL = "https://api.nasa.gov/planetary/apod?api_key=DEMO_KEY" + +# Length of time between updates in minutes. +# Frequent updates will reduce battery life! +UPDATE_INTERVAL = 240 + +# Variable for storing the NASA APOD Title +apod_title = None + + +def show_error(text): + graphics.set_pen(4) + graphics.rectangle(0, 10, WIDTH, 35) + graphics.set_pen(1) + graphics.text(text, 5, 16, 400, 2) + + +def update(): + global apod_title + + if HEIGHT == 448: + # Image for Inky Frame 5.7 + IMG_URL = "https://pimoroni.github.io/feed2image/nasa-apod-daily.jpg" + elif HEIGHT == 400: + # Image for Inky Frame 4.0 + IMG_URL = "https://pimoroni.github.io/feed2image/nasa-apod-640x400-daily.jpg" + elif HEIGHT == 480: + # Image for Inky Frame 7.3 + IMG_URL = "https://pimoroni.github.io/feed2image/nasa-apod-800x480-daily.jpg" + + try: + # Grab the data + socket = urequest.urlopen(API_URL) + gc.collect() + j = load(socket) + socket.close() + apod_title = j['title'] + gc.collect() + except OSError as e: + print(e) + apod_title = "Image Title Unavailable" + + try: + # Grab the image + socket = urequest.urlopen(IMG_URL) + + gc.collect() + + data = bytearray(1024) + with open(FILENAME, "wb") as f: + while True: + if socket.readinto(data) == 0: + break + f.write(data) + socket.close() + del data + gc.collect() + except OSError as e: + print(e) + show_error("Unable to download image") + + +def draw(): + jpeg = jpegdec.JPEG(graphics) + gc.collect() # For good measure... + + graphics.set_pen(1) + graphics.clear() + + try: + jpeg.open_file(FILENAME) + jpeg.decode() + except OSError: + graphics.set_pen(4) + graphics.rectangle(0, (HEIGHT // 2) - 20, WIDTH, 40) + graphics.set_pen(1) + graphics.text("Unable to display image!", 5, (HEIGHT // 2) - 15, WIDTH, 2) + graphics.text("Check your network settings in secrets.py", 5, (HEIGHT // 2) + 2, WIDTH, 2) + + graphics.set_pen(0) + graphics.rectangle(0, HEIGHT - 25, WIDTH, 25) + graphics.set_pen(1) + graphics.text(apod_title, 5, HEIGHT - 20, WIDTH, 2) + + gc.collect() + + graphics.update() diff --git a/examples/inkylauncher/news_headlines.py b/examples/inkylauncher/news_headlines.py new file mode 100644 index 0000000..6aad9b0 --- /dev/null +++ b/examples/inkylauncher/news_headlines.py @@ -0,0 +1,170 @@ +from urllib import urequest +import gc +import qrcode + +# Uncomment one URL to use (Top Stories, World News and technology) +# URL = "https://feeds.bbci.co.uk/news/rss.xml" +# URL = "https://feeds.bbci.co.uk/news/world/rss.xml" +URL = "https://feeds.bbci.co.uk/news/technology/rss.xml" + +# Length of time between updates in minutes. +# Frequent updates will reduce battery life! +UPDATE_INTERVAL = 90 + +graphics = None +WIDTH = None +HEIGHT = None +code = qrcode.QRCode() + + +def read_until(stream, find): + result = b"" + while len(c := stream.read(1)) > 0: + if c == find: + return result + result += c + + +def discard_until(stream, find): + _ = read_until(stream, find) + + +def parse_xml_stream(s, accept_tags, group_by, max_items=3): + tag = [] + text = b"" + count = 0 + current = {} + + while True: + char = s.read(1) + if len(char) == 0: + break + + if char == b"<": + next_char = s.read(1) + + # Discard stuff like ") + continue + + # Detect ") # Discard ]> + gc.collect() + + elif next_char == b"/": + current_tag = read_until(s, b">") + top_tag = tag[-1] + + # Populate our result dict + if top_tag in accept_tags: + current[top_tag.decode("utf-8")] = text.decode("utf-8") + + # If we've found a group of items, yield the dict + elif top_tag == group_by: + yield current + current = {} + count += 1 + if count == max_items: + return + tag.pop() + text = b"" + gc.collect() + continue + + else: + current_tag = read_until(s, b">") + if not current_tag.endswith(b"/"): + tag += [next_char + current_tag.split(b" ")[0]] + text = b"" + + else: + text += char + + +def measure_qr_code(size, code): + w, h = code.get_size() + module_size = int(size / w) + return module_size * w, module_size + + +def draw_qr_code(ox, oy, size, code): + size, module_size = measure_qr_code(size, code) + graphics.set_pen(1) + graphics.rectangle(ox, oy, size, size) + graphics.set_pen(0) + for x in range(size): + for y in range(size): + if code.get_module(x, y): + graphics.rectangle(ox + x * module_size, oy + y * module_size, module_size, module_size) + + +# A function to get the data from an RSS Feed, this in case BBC News. +def get_rss(): + try: + stream = urequest.urlopen(URL) + output = list(parse_xml_stream(stream, [b"title", b"description", b"guid", b"pubDate"], b"item")) + return output + + except OSError as e: + print(e) + return [] + + +feed = None + + +def update(): + global feed + # Gets Feed Data + feed = get_rss() + + +def draw(): + global feed + graphics.set_font("bitmap8") + + # Clear the screen + graphics.set_pen(1) + graphics.clear() + graphics.set_pen(0) + + # Draws 2 articles from the feed if they're available. + if len(feed) > 0: + + # Title + graphics.set_pen(graphics.create_pen(200, 0, 0)) + graphics.rectangle(0, 0, WIDTH, 40) + graphics.set_pen(1) + graphics.text("Headlines from BBC News:", 10, 10, 320, 3) + + graphics.set_pen(4) + graphics.text(feed[0]["title"], 10, 70, WIDTH - 150, 3 if graphics.measure_text(feed[0]["title"]) < WIDTH else 2) + graphics.text(feed[1]["title"], 130, 260, WIDTH - 140, 3 if graphics.measure_text(feed[1]["title"]) < WIDTH else 2) + + graphics.set_pen(3) + graphics.text(feed[0]["description"], 10, 135 if graphics.measure_text(feed[0]["title"]) < 650 else 90, WIDTH - 150, 2) + graphics.text(feed[1]["description"], 130, 320 if graphics.measure_text(feed[1]["title"]) < 650 else 230, WIDTH - 145, 2) + + graphics.line(10, 215, WIDTH - 10, 215) + + code.set_text(feed[0]["guid"]) + draw_qr_code(WIDTH - 110, 65, 100, code) + code.set_text(feed[1]["guid"]) + draw_qr_code(10, 265, 100, code) + + graphics.set_pen(graphics.create_pen(200, 0, 0)) + graphics.rectangle(0, HEIGHT - 20, WIDTH, 20) + + else: + graphics.set_pen(4) + graphics.rectangle(0, (HEIGHT // 2) - 20, WIDTH, 40) + graphics.set_pen(1) + graphics.text("Unable to display news feed!", 5, (HEIGHT // 2) - 15, WIDTH, 2) + graphics.text("Check your network settings in secrets.py", 5, (HEIGHT // 2) + 2, WIDTH, 2) + + graphics.update() diff --git a/examples/inkylauncher/random_joke.py b/examples/inkylauncher/random_joke.py new file mode 100644 index 0000000..86388c1 --- /dev/null +++ b/examples/inkylauncher/random_joke.py @@ -0,0 +1,121 @@ +import gc +import random +from urllib import urequest +from ujson import load +import qrcode + +gc.collect() # We're really gonna need that RAM! + +graphics = None + +WIDTH = 0 +HEIGHT = 0 + +FILENAME = "random-joke.jpg" + +JOKE_IDS = "https://pimoroni.github.io/feed2image/jokeapi-ids.txt" +JOKE_IMG = "https://pimoroni.github.io/feed2image/jokeapi-{}.json" + +UPDATE_INTERVAL = 60 + +gc.collect() # Claw back some RAM! + +# We don't have the RAM to store the list of Joke IDs in memory. +# the first line of `jokeapi-ids.txt` is a COUNT of IDs. +# Grab it, then pick a random line between 0 and COUNT. +# Seek to that line and ...y'know... there's our totally random joke ID + +joke = [] + + +def measure_qr_code(size, code): + w, h = code.get_size() + module_size = int(size / w) + return module_size * w, module_size + + +def draw_qr_code(ox, oy, size, code): + size, module_size = measure_qr_code(size, code) + graphics.set_pen(1) + graphics.rectangle(ox, oy, size, size) + graphics.set_pen(0) + for x in range(size): + for y in range(size): + if code.get_module(x, y): + graphics.rectangle(ox + x * module_size, oy + y * module_size, module_size, module_size) + + +def update(): + global joke + + try: + socket = urequest.urlopen(JOKE_IDS) + except OSError: + return + + # Get the first line, which is a count of the joke IDs + number_of_lines = int(socket.readline().decode("ascii")) + print("Total jokes {}".format(number_of_lines)) + + # Pick a random joke (by its line number) + line = random.randint(0, number_of_lines) + print("Getting ID from line {}".format(line)) + + for x in range(line): # Throw away lines to get where we need + socket.readline() + + # Read our chosen joke ID! + random_joke_id = int(socket.readline().decode("ascii")) + socket.close() + + print("Random joke ID: {}".format(random_joke_id)) + + url = JOKE_IMG.format(random_joke_id) + + gc.collect() + + # Grab the data + try: + socket = urequest.urlopen(url) + gc.collect() + j = load(socket) + socket.close() + joke = j + del j + gc.collect() + except OSError as e: + print(e) + + +def draw(): + + code = qrcode.QRCode() + graphics.set_pen(1) + graphics.clear() + + if joke: + if joke['type'] == "single": + graphics.set_pen(4) + graphics.text(joke['joke'], 10, 10, WIDTH - 75, 5) + if joke['type'] == "twopart": + graphics.set_pen(4) + graphics.text(joke['setup'], 10, 10, WIDTH - 75, 5) + graphics.set_pen(3) + graphics.text(joke['delivery'], 10, 250, WIDTH - 75, 5) + + graphics.set_pen(0) + # Donate link QR + code.set_text("https://github.com/sponsors/Sv443") + draw_qr_code(WIDTH - 75, HEIGHT - 75, 75, code) + + graphics.text("curated by jokeapi.dev", 10, HEIGHT - 15, WIDTH, 1) + graphics.text("donate <3", WIDTH - 65, HEIGHT - 12, WIDTH, 1) + + else: + graphics.set_pen(4) + graphics.rectangle(0, (HEIGHT // 2) - 20, WIDTH, 40) + graphics.set_pen(1) + graphics.text("Unable to display random joke!", 5, (HEIGHT // 2) - 15, WIDTH, 2) + graphics.text("Check your network settings in secrets.py", 5, (HEIGHT // 2) + 2, WIDTH, 2) + + graphics.update() diff --git a/examples/inkylauncher/secrets.py b/examples/inkylauncher/secrets.py new file mode 100644 index 0000000..9454e28 --- /dev/null +++ b/examples/inkylauncher/secrets.py @@ -0,0 +1,3 @@ +# secrets.py should contain: +WIFI_SSID = "" +WIFI_PASSWORD = "" diff --git a/examples/inkylauncher/word_clock.py b/examples/inkylauncher/word_clock.py new file mode 100644 index 0000000..621a7e3 --- /dev/null +++ b/examples/inkylauncher/word_clock.py @@ -0,0 +1,101 @@ +import machine +import ntptime + +# Length of time between updates in minutes. +UPDATE_INTERVAL = 15 +graphics = None + +rtc = machine.RTC() +time_string = None +words = ["it", "d", "is", "m", "about", "l", "half", "c", "quarter", "b", "to", "u", "past", "n", "one", + "two", "three", "four", "five", "six", "eleven", "ten", "nine", "eight", "seven", "rm", "twelve", "rt", "O'Clock", "q"] + + +def approx_time(hours, minutes): + nums = {0: "twelve", 1: "one", 2: "two", + 3: "three", 4: "four", 5: "five", 6: "six", + 7: "seven", 8: "eight", 9: "nine", 10: "ten", + 11: "eleven", 12: "twelve"} + + if hours == 12: + hours = 0 + if minutes > 0 and minutes < 8: + return "it is about " + nums[hours] + " O'Clock" + elif minutes >= 8 and minutes < 23: + return "it is about quarter past " + nums[hours] + elif minutes >= 23 and minutes < 38: + return "it is about half past " + nums[hours] + elif minutes >= 38 and minutes < 53: + return "it is about quarter to " + nums[hours + 1] + else: + return "it is about " + nums[hours + 1] + " O'Clock" + + +def update(): + global time_string + # grab the current time from the ntp server and update the Pico RTC + try: + ntptime.settime() + except OSError: + print("Unable to contact NTP server") + + current_t = rtc.datetime() + time_string = approx_time(current_t[4] - 12 if current_t[4] > 12 else current_t[4], current_t[5]) + + # Splits the string into an array of words for displaying later + time_string = time_string.split() + + print(time_string) + + +def draw(): + global time_string + graphics.set_font("bitmap8") + + WIDTH, HEIGHT = graphics.get_bounds() + + # Clear the screen + graphics.set_pen(1) + graphics.clear() + graphics.set_pen(6) + + # Values for the layout and spacing + if WIDTH == 640: # Inky Frame 4.0" + default_x = 5 + x = default_x + y = 10 + line_space = 70 + letter_space = 40 + elif WIDTH == 800: + default_x = 5 + x = default_x + y = 70 + line_space = 60 + letter_space = 50 + else: # Inky Frame 5.7" + default_x = 20 + x = default_x + y = 40 + line_space = 65 + letter_space = 35 + + scale = 5 + spacing = 2 + + for word in words: + + if word in time_string: + graphics.set_pen(0) + else: + graphics.set_pen(graphics.create_pen(220, 220, 220)) + + for letter in word: + text_length = graphics.measure_text(letter, scale, spacing) + if not x + text_length <= WIDTH: + y += line_space + x = default_x + + graphics.text(letter.upper(), x, y, 640, scale=scale, spacing=spacing) + x += letter_space + + graphics.update() diff --git a/examples/led_pwm.py b/examples/led_pwm.py new file mode 100644 index 0000000..d6189fd --- /dev/null +++ b/examples/led_pwm.py @@ -0,0 +1,33 @@ +# Control the brightness of Inky Frame's LEDs using PWM +# More about PWM / frequency / duty cycle here: +# https://projects.raspberrypi.org/en/projects/getting-started-with-the-pico/7 + +from machine import Pin, PWM +from time import sleep + +led_activity = PWM(Pin(6)) + +# we're just PWMing the activity LED in this demo +# but here are the pins for the other LEDs for reference +led_connect = PWM(Pin(7)) +led_a = PWM(Pin(11)) +led_b = PWM(Pin(12)) +led_c = PWM(Pin(13)) +led_d = PWM(Pin(14)) +led_e = PWM(Pin(15)) + +led_activity.freq(1000) + +leds = [led_activity, led_connect, led_a, led_b, led_c, led_d, led_e] +n = 0 + +while True: + for _ in range(2): + for duty in range(65025, 2): + leds[n].duty_u16(duty) + sleep(0.0001) + for duty in range(65025, 0, -2): + leds[n].duty_u16(duty) + sleep(0.0001) + n += 1 + n %= len(leds) diff --git a/examples/pencil_256x256.png b/examples/pencil_256x256.png new file mode 100644 index 0000000..def3fd9 Binary files /dev/null and b/examples/pencil_256x256.png differ diff --git a/examples/sd_test.py b/examples/sd_test.py new file mode 100644 index 0000000..d76d486 --- /dev/null +++ b/examples/sd_test.py @@ -0,0 +1,26 @@ +# This simple example shows how to read and write from the SD card on Inky Frame. +from machine import Pin, SPI +import sdcard +import os + +# set up the SD card +sd_spi = SPI(0, sck=Pin(18, Pin.OUT), mosi=Pin(19, Pin.OUT), miso=Pin(16, Pin.OUT)) +sd = sdcard.SDCard(sd_spi, Pin(22)) +os.mount(sd, "/sd") + +# create a file and add some text +# if this file already exists it will be overwritten +with open("/sd/inkytest.txt", "w") as f: + f.write("Hello Inky!\r\n") + f.close() + +# open the file and append some text (useful for logging!) +with open("/sd/inkytest.txt", "a") as f: + f.write("We're appending some text\r\n") + f.close() + +# read the file and print the contents to the console +with open("/sd/inkytest.txt", "r") as f: + data = f.read() + print(data) + f.close()