├── project-photos ├── frame-back.jpg ├── frame-front.jpg └── pi-battery-assembly.jpg ├── pi-frame_update.timer ├── pi-frame_update.service ├── show_image.py ├── prepare_image.py ├── update_piframe.sh └── README.md /project-photos/frame-back.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhirner/pi-frame/HEAD/project-photos/frame-back.jpg -------------------------------------------------------------------------------- /project-photos/frame-front.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhirner/pi-frame/HEAD/project-photos/frame-front.jpg -------------------------------------------------------------------------------- /project-photos/pi-battery-assembly.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhirner/pi-frame/HEAD/project-photos/pi-battery-assembly.jpg -------------------------------------------------------------------------------- /pi-frame_update.timer: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Updating the pi-frame on boot. 3 | 4 | [Timer] 5 | OnBootSec=90 6 | OnUnitActiveSec=5m 7 | 8 | [Install] 9 | WantedBy=timers.target 10 | -------------------------------------------------------------------------------- /pi-frame_update.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=updating pi-frame via update_piframe.sh 3 | 4 | [Service] 5 | Type=simple 6 | ExecStart=/absolute/path/to/update_piframe.sh 7 | StandardOutput=append:/absolute/path/to/piframe.log 8 | StandardError=append:/absolute/path/to/piframe.log 9 | -------------------------------------------------------------------------------- /show_image.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # Import required modules 4 | from omni_epd import displayfactory 5 | from prepare_image import Processor 6 | from argparse import ArgumentParser 7 | 8 | # Handle argument parsing 9 | parser = ArgumentParser() 10 | parser.add_argument("image_file_path", help = "Path to the image to display") 11 | parser.add_argument("--message", "-m", help = "Banner text do overlay on top of the image") 12 | args = parser.parse_args() 13 | 14 | # Instantiate the display 15 | epd = displayfactory.load_display_driver("waveshare_epd.it8951") 16 | epd.prepare() 17 | print("show_image.py: Prepared display.") 18 | 19 | # Prepare the image 20 | proc = Processor(img_path = args.image_file_path, 21 | display_width = epd.width, 22 | display_height = epd.height, 23 | textmsg = args.message) 24 | proc.process() 25 | print("show_image.py: Prepared image.") 26 | 27 | # Show the image and close out the display 28 | epd.display(proc.img) 29 | epd.close() 30 | print("show_image.py: Updated display & exiting.") 31 | -------------------------------------------------------------------------------- /prepare_image.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # Import the required libraries 4 | from PIL import Image 5 | from PIL import ImageFont 6 | from PIL import ImageDraw 7 | 8 | class Processor(): 9 | def __init__(self, img_path = None, display_width = None, display_height = None, textmsg = None, padding_color = (0, 0, 0)): 10 | self.img = Image.open(img_path) 11 | self.display_width = display_width 12 | self.display_height = display_height 13 | self.textmsg = textmsg 14 | self.padding_color = padding_color 15 | 16 | def process(self): 17 | # Manages the overall preparation of the image, from scaling, overlaying 18 | # an optional text warning, and flipping the image horizontally. 19 | # Note that in the absence of a flip, the image & optional text are displayed 20 | # mirrored backwards. 21 | self.img = self.scale(self.img) 22 | self.img = self.center(self.img) 23 | if self.textmsg is not None and len(self.textmsg) > 1: 24 | self.img = self.add_text(self.img) 25 | self.img = self.img.transpose(method=Image.Transpose.FLIP_LEFT_RIGHT) 26 | 27 | def scale(self, img): 28 | # Resizes the image to fit the display without distorting the original aspect ratio 29 | # For portrait orientation images, scale to the screen height. 30 | # Otherwise, scale to the screen width. 31 | if self.display_width / self.display_height > img.width / img.height: 32 | new_width = img.width * self.display_height / img.height 33 | new_height = self.display_height 34 | else: 35 | new_width = self.display_width 36 | new_height = img.height * self.display_width / img.width 37 | 38 | img = img.resize((int(new_width), 39 | int(new_height))) 40 | 41 | return img 42 | 43 | def add_text(self, img): 44 | font = ImageFont.truetype("Pillow/Tests/fonts/FreeMono.ttf", 36) 45 | draw = ImageDraw.Draw(img) 46 | draw.rectangle([(0,0), (img.width,75)], 47 | fill = 0, 48 | outline = None) 49 | draw.text((10, 30), self.textmsg, font=font, fill=(255, 255, 255)) 50 | 51 | return img 52 | 53 | def center(self, img): 54 | # Center the image in the display, as not all images will fill it. 55 | padded = Image.new(img.mode, (self.display_width, self.display_height), self.padding_color) 56 | x_offset = int((self.display_width - img.width) / 2) 57 | y_offset = int((self.display_height - img.height) / 2) 58 | padded.paste(img, (x_offset, y_offset)) 59 | 60 | return padded 61 | -------------------------------------------------------------------------------- /update_piframe.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/bash 2 | 3 | # Define variables 4 | imagedir='/absolute/path/to/image/directory' # Path to the image directory. 5 | battery_critical_pct=5 # Display a low battery warning below this percentage. 6 | delay_hours=2 # The battery RTC will be set to power on again in this many hours. 7 | 8 | # Read critical status from battery. 9 | # battery_pct: Battery charge percentage, integer. 10 | battery_pct=$(echo "get battery" | nc -w 1 127.0.0.1 8423 | awk '{print $2}' | awk -F '.' '{print $1}') 11 | # battery_charging: Indicator for whether or not the battery is currently charging, boolean. 12 | # Note that the command for battery_charging has ~10% false positive rate. 13 | # Because a false positive would result in pi-frame staying powered on while 14 | # operating on battery (undersired), poll for charging status four times. If *any* values 15 | # of false are found, battery_charging will be returned as "false". 16 | battery_charging_4x=$(i=1; while [[ i -le 4 ]]; do echo "get battery_charging" | nc -w 1 127.0.0.1 8423 | awk '{print $2}'; ((i++)); done) 17 | if [[ $battery_charging_4x =~ "false" ]]; then 18 | battery_charging="false" 19 | else 20 | battery_charging="true" 21 | fi 22 | 23 | # Sync the date/time from the web to the pi and rtc. 24 | echo 'rtc_web' | nc -w 1 127.0.0.1 8423 > /dev/null 25 | battery_time=$(echo 'get rtc_time' | nc -w 1 127.0.0.1 8423 | awk '{print $2}') 26 | 27 | # Print the date & time for logging outside journalctl. 28 | echo $battery_time 29 | 30 | # Raise an alert the battery is less than the critical value. 31 | # Note that a small percentage of the time, the battery percentage cannot be read successfully. 32 | # When that occurs, the higher-level if statement prevents a blank warning message from being generated. 33 | 34 | if [[ ${battery_pct} -ge 1 ]]; then 35 | echo "Battery charge: $battery_pct %" 36 | if [[ $battery_pct -le $battery_critical_pct ]]; then 37 | warnmsg="Warning: Battery level $battery_pct %. Please recharge." 38 | fi 39 | else 40 | echo "Battery charge: Could not be determined" 41 | fi 42 | 43 | # Check the remote staging server for files to update locally. 44 | echo "Checking remote server for new photos." 45 | rsync --delete --exclude=".*" --progress --recursive --checksum\ 46 | remote-staging-server-address:/absolute/path/to/remote/images/ \ 47 | /absolute/path/to/local/images 48 | # Then back up local scripts & logs to the remote server. 49 | echo "Checking remote server for script updates." 50 | rsync --exclude=".*" --exclude="__pycache__/" --progress --recursive --checksum\ 51 | remote-staging-server-address:/absolute/path/to/remote/scripts/ \ 52 | /absolute/path/to/local/scripts 53 | 54 | # Select a random image & call the Python script to display it. 55 | echo "Images found: $(ls $imagedir | wc --lines)" 56 | imagepath=$(find $imagedir -type f -name '*.jpg' \ 57 | -or -name '*.jpeg' -or -name '*.png' \ 58 | -or -name '*.bmp' | shuf -n 1) 59 | echo "Selected image: $imagepath" 60 | eval "python /absolute/path/to/local/scripts/show_image.py --message \"$warnmsg\" $imagepath" 61 | 62 | # Schedule the next boot time for now + delay_hours. 63 | # 64 | rtc_time=$(echo "get rtc_time" | nc -w 1 127.0.0.1 8423 | awk '{print $2}') 65 | alarm_old=$(echo 'get rtc_alarm_time' | nc -w 1 127.0.0.1 8423 | awk '{print $2}') 66 | delay_seconds=$(($delay_hours * 3600)) 67 | # If there is no alarm set, or if the current setting is more than delay_hours out 68 | # of date, set a new alarm for rtc_time + delay_hours. Otherwise, simply add 69 | # delay_hours to the current alarm. 70 | if [[ $(($(date -d $alarm_old +%s) + $delay_seconds)) -le $(date -d $rtc_time +%s) ]]; then 71 | alarm_new=$(date -d "$rtc_time+$delay_hours hour" --iso-8601=seconds) 72 | else 73 | alarm_new=$(date -d "$alarm_old+$delay_hours hour" --iso-8601=seconds) 74 | fi 75 | echo "calculated alarm time: $alarm_new" 76 | echo "rtc_alarm_set $alarm_new 127" | nc -w 1 127.0.0.1 8423 77 | echo "Next scheduled boot: $(echo 'get rtc_alarm_time' | nc -w 1 127.0.0.1 8423 | awk '{print $2}')" 78 | 79 | # Sync logs to remote staging server. 80 | echo "Syncing logs to remote server." 81 | rsync --progress --checksum --append /absolute/path/to/local/piframe.log \ 82 | remote-staging-server-address:/absolute/path/to/remote/piframe.log 83 | 84 | # Determine how many users are logged in prior to automatic shutdown. 85 | # I.e.: Don't shut down while I'm logged in working. 86 | user_count=$(users | wc -w) 87 | echo "Active users found: $user_count" 88 | 89 | # If operating on battery power (i.e.: the battery is not charging) 90 | # print the next scheduled boot time & power off. 91 | if [[ $battery_charging = "false" ]]; then 92 | if [[ $user_count = 0 ]]; then 93 | echo "Powering down now." 94 | sleep 3 95 | sudo shutdown now 96 | fi 97 | elif [[ $battery_charging = "true" ]]; then 98 | echo "Power source: Plugged in." 99 | echo "Power will remain on." 100 | else 101 | echo "Power source: Uncertain." 102 | if [[ $user_count = 0 ]]; then 103 | echo "Powering down now." 104 | sleep 3 105 | sudo shutdown now 106 | fi 107 | fi 108 | 109 | echo "##############################" 110 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pi-Frame 2 | I wanted a digital picture frame, but I couldn't find one that had all the features I was looking for. I decided to build one instead. 3 | 4 | Although this repo does not aim to provide detailed step-by-step instructions, it *does* aim to provide an adequate enough summary to help out if you decide to build something similar. 5 | 6 | If you're wondering if this is a good project for you to tackle, here's a little more information about my prior knowledge & skills before I began the project: 7 | 8 | * Raspberry Pi hardware: Beginner 9 | * e-ink displays: None 10 | * General Linux use: Intermediate 11 | * Bash scripting: Beginner 12 | * Python scriptiong: Intermediate 13 | 14 | ![Photo of the completed picture frame](project-photos/frame-front.jpg) 15 | 16 | ## Project Goals 17 | What I wanted -- and what I couldn't find in a reasonably-priced commercial product -- was: 18 | 19 | * A digital picture frame with relatively large e-ink display 20 | * Automatically sync pictures from a remote server 21 | * Fully wireless, including battery power 22 | * Maximum battery life 23 | * No soldering 24 | 25 | 26 | ## How it works in practice 27 | * I upload pictures to an always-on staging server. No pre-processing is necessary, but I find the display looks best when I manually convert the images to grayscale and tinker with the contrast first. 28 | * The battery-powered Pi-Frame boots at a scheduled time (I use every 2 hours). It automatically checks for new images from the remote server, then displays a random image from local storage. 29 | * If Pi-Frame is operating on battery power, it shuts down to a zero-power state until its next scheculed boot. If charging, it remains on (to allow convenient direct access via ssh) and updates to a new image every 5 minutes. 30 | * One full update cycle (boot, check for new images, update the display, and power down) consumes roughly 1% of the battery's capacity, so it can run for approximately 100 updates on a full charge. Because I set the display to update every two hours, I expect to get about 8 days of operation out of a single charge. 31 | 32 | 33 | ## Equipment 34 | * Brains: [Raspberry Pi Zero W](https://www.raspberrypi.com/products/raspberry-pi-zero-w/) with pre-soldered header ("WH" model) 35 | * Display: [10.3-inch, 16-shade grayscale e-ink display with HAT interface from Waveshare](https://www.waveshare.com/product/10.3inch-e-paper-hat.htm) 36 | * Battery: PiSugar 2 from [PiSugar](https://www.pisugar.com/), which includes an onboard clock for automatic booting on a schedule 37 | * For framing: 38 | * A deep, shadowbox-style frame from the local arts & crafts store 39 | * A custom-cut photo mat to fit the frame & e-ink display, also from the local arts & crafts store 40 | * A small sheet of acrylic. I wanted to make a window in the back of the picture frame so I could see the status lights on the Pi and battery. 41 | * A 4.5-inch micro USB extension cable so that I could easily charge the battery without opening the frame 42 | * M 2.5 mounting screws and standoffs 43 | * A GPIO ribbon cable. This let me separate the Pi from the e-ink display's controller board, as otherwise, the stack would have been really tall and finding a thick enough frame would have been a challenge. 44 | 45 | 46 | 47 | ## Build 48 | 49 | ### Set up the Pi 50 | The Raspberry Pi folks already have a [very nice setup guide](https://www.raspberrypi.com/documentation/computers/getting-started.html). Key features that I enabled when preparing the Pi's SD card were: 51 | 52 | * Configuring wifi 53 | * Enabling ssh access for headless operation 54 | 55 | 56 | 57 | ### Connect & configure the display. 58 | 59 | Connect the display & install drivers using the [manufacturer's guide.](https://www.waveshare.com/wiki/10.3inch_e-Paper_HAT) Briefly, I: 60 | 61 | * Physically connected the e-ink driver board via the HAT. 62 | * Confirmed the driver board is physically switched to SPI mode. 63 | * Installed the [BCM2835 library](https://www.airspayce.com/mikem/bcm2835/). 64 | * Enabled the SPI interface via `raspi-config`. 65 | * Installed the Waveshare library via the latest release from Github. 66 | * Tested the display via the binary compiled above. 67 | 68 | 69 | 70 | ### Install the battery 71 | 72 | [Physically installing](https://github.com/PiSugar/PiSugar/wiki/PiSugar2#hardware-installation) the PiSugar 2 battery was a simple enough. The manufacturer also provides [a very handy API](https://github.com/PiSugar/pisugar-power-manager-rs) that allows you to poll key battery vitals (like charge percentage and whether or not it is actively charging). The API also allows you to schedule a boot time, which will become critical in the *Automate everything* section. 73 | 74 | 75 | 76 | ### Prepare a Python interface for the display 77 | #### Install the [RPi.GPIO library](https://sourceforge.net/projects/raspberry-gpio-python/). 78 | The official [wiki](https://sourceforge.net/p/raspberry-gpio-python/wiki/install/) recommends installation via the official Raspberry Pi repositories: `sudo apt install python3-rpi.gpio`. However, in my hands, this provided a library two years out-of-date vs. the [PyPI version](https://pypi.org/project/RPi.GPIO/). The older version installed from the Pi repository did not support the hardware in a way I still don't quite undertand. (When I tried to use the package from the Pi repository, in downstream steps, I was ultimately stuck with an error while importing RPi.GPIO with a traceback to it's Jetson.GPIO dependency: `Exception: Could not determine Jetson model.`) 79 | 80 | I found it *critical* to forego the older version of `RPi.GPIO` from the repository and instead install the latest version via `sudo pip install RPi.GPIO`. Note that although installation of `pip` packages using `sudo` typically isn't recommended, I found that it was the only way to get the display working. 81 | 82 | 83 | #### Install the Python controller for the e-ink display 84 | There is a [Python library](https://github.com/GregDMeyer/IT8951) for the IT8951 e-ink controller, which serves as the interface between the Raspberry Pi and the display itself. By cloning the project repository, you'll also get access to a handful of tests. As of this writing, `test/integration/test.py` provides a convenient way to make sure the display is updatable via Python. 85 | 86 | 87 | #### Install [Omni-EPD](https://github.com/robweber/omni-epd). 88 | Although the IT8951 controller library above provices all the necessary tools for printing an image to the display, I found that that Omni-EPD offers a more convenient wrapper. 89 | 90 | 91 | ### Scripting to update the display 92 | I wrote a Python script, `show_image.py` to fully manage updating the display with a single command. Called with `python show_image.py /path/to/image`, the script automates interaction with Omni-EPD. It also accepts an optional argument, `--message "some message to display`, which overlays a small banner at the top of the image. In the next section ("Automate everything"), I use the message function to display a warning when the battery is low and needs to be recharged. 93 | 94 | Another Python script, `prepare_image.py` is called as a subroutine by `show_image.py`. It uses the `PIL` library to handle the heavy lifting for image processing: scaling it to fit on the display without distortion, and overlaying the optional message described above. 95 | 96 | 97 | ### Automate everything 98 | The bash script, `update_piframe.sh` provides a single point for automatic operation of all of Pi-Frame's key operations. It: 99 | 100 | * Uses `rsync` to fetch new images or *update any of the scripts, including itself* from a remote server. By using an always-on, remote server for staging the updates, the Pi-Frame itself can be powered down most of the time to conserve its battery. I'm using another Raspberry Pi as my remote staging server, but you could use anything. 101 | * Also uses `rsync` to send logs to the remote staging server so that troubleshooting is easier, even when Pi-Frame is powered down. 102 | * Polls the battery's charge state & displays a warning message on the e-ink screen if the charge is low. 103 | * Selects a random image and calls `show_image.py` to display it. 104 | * Schedules the next boot after a user-defined delay. I use a 2-hour delay to help preserve the battery. 105 | * Finally, shut down the Pi so the battery isn't drained (but only if operating on battery power). It remains powered on while charging in case I want to connect to it via ssh. 106 | 107 | I've used a `systemd` service and timer (`pi-frame_update.service` and `pi-frame_update.timer`] to automatically execute `update_piframe.sh` on boot. As mentioned above, the logs are written to a plain-text file and synced remotely to facilitate troubleshooting rather than relying on `journalctl`. 108 | 109 | **Note:** In all of the scripts mentioned above & in the systemd timer, I've replaced file paths that I used with dummy paths, such as `/absolute/path/to/local/images/`. If you use any of these scripts, make sure you update the paths for your system. 110 | 111 | 112 | ## Final assembly 113 | As mentioned in the *Equipment* section, I used a deep, shadowbox-style frame (11-inches x 14-inches) that I bought off-the-shelf at my local arts & crafts store. I had them custom-cut a mat to fit the frame and the e-ink display. The display is mounted to the mat using electrical tape. 114 | 115 | I wanted to be able to see the status LEDs on the Pi during normal operation, so I cut a window into the back of the frame. Using nylon standoffs and screws, I mounted the Pi, battery, and display controller board to a leftover piece of acrylic I had from another project. I also drilled a hole into the acrylic so I could run a short micro USB extension cable through it; that allows me to easily charge the battery without opening the frame. 116 | 117 | After connecting everything and gluing the acrylic sheet into the cutout at the back of the frame, the project is all done! There are a few pictures of the finished product in the `project-photos` directory. 118 | --------------------------------------------------------------------------------