Skip to content

Commit

Permalink
Merge pull request #55 from loiccoyle/feat/epd7in5b_V2
Browse files Browse the repository at this point in the history
feat(waveshare): add `epd7in5b_v2`
  • Loading branch information
loiccoyle committed Jun 9, 2024
2 parents 28c791a + 922e0e1 commit 0f992dd
Show file tree
Hide file tree
Showing 16 changed files with 249 additions and 35 deletions.
Binary file modified tests/data/layouts/big_price_False_False_False.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/data/layouts/big_price_False_False_True.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/data/layouts/big_price_False_True_False.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/data/layouts/big_price_False_True_True.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/data/layouts/big_price_True_False_False.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/data/layouts/big_price_True_False_True.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/data/layouts/big_price_True_True_False.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/data/layouts/big_price_True_True_True.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion tests/unit/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def test_trim(self):
assert img_trim.size == (200, 133)

def test_dashboard_qrcode(self):
qrcode = utils.dashboard_qrcode(200, 200)
qrcode = utils.dashboard_qrcode((200, 200))
assert qrcode.size == (200, 200)

def test_set_verbosity(self):
Expand Down
4 changes: 2 additions & 2 deletions tinyticker/display.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def text(self, text: str, show: bool = False, **kwargs) -> Tuple[Figure, Axes]:
Returns:
The `plt.Figure` and `plt.Axes` with the text.
"""
fig, ax = _create_fig_ax((self.epd.height, self.epd.width), n_axes=1)
fig, ax = _create_fig_ax(self.epd.size, n_axes=1)
ax = ax[0]
ax.text(0, 0, text, ha="center", va="center", wrap=True, **kwargs)
if show:
Expand Down Expand Up @@ -91,5 +91,5 @@ def show_image(self, image: Image.Image) -> None:

def show(self, ticker: TickerBase, resp: TickerResponse) -> None:
layout = LAYOUTS.get(ticker.config.layout.name, LAYOUTS["default"])
image = layout.func((self.epd.height, self.epd.width), ticker, resp)
image = layout.func(self.epd.size, ticker, resp)
self.show_image(image)
37 changes: 15 additions & 22 deletions tinyticker/layouts.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Layouts are responsible for generating an image for a given dimension, ticker, and response.
"""Layouts are responsible for generating an image for a given size, ticker, and response.
They should not care about the capabilities of the display device, only about the content to display.
"""
Expand Down Expand Up @@ -37,8 +37,8 @@
}
LAYOUTS = {}

Dimensions = Tuple[int, int]
LayoutFunc = Callable[[Dimensions, TickerBase, TickerResponse], Image.Image]
Size = Tuple[int, int]
LayoutFunc = Callable[[Size, TickerBase, TickerResponse], Image.Image]

logger = logging.getLogger(__name__)

Expand All @@ -50,20 +50,18 @@ def _strip_ax(ax: Axes) -> None:
ax.grid(False)


def _create_fig_ax(
dimensions: Dimensions, n_axes: int = 1, **kwargs
) -> Tuple[Figure, np.ndarray]:
def _create_fig_ax(size: Size, n_axes: int = 1, **kwargs) -> Tuple[Figure, np.ndarray]:
"""Create the `plt.Figure` and `plt.Axes` used to plot the chart.
Args:
dimensions: the dimensions of the plot, (width, height).
size: the size of the plot, (width, height).
n_axes: the number of subplot axes to create.
**kwargs: passed to `plt.subplots`.
Returns:
The `plt.Figure` and an array of `plt.Axes`.
"""
width, height = dimensions
width, height = size
dpi = plt.rcParams.get("figure.dpi", 96)
px = 1 / dpi
fig, axes = plt.subplots(
Expand Down Expand Up @@ -172,14 +170,12 @@ def apply_layout_config(


def _historical_plot(
dimensions: Dimensions, ticker: TickerBase, resp: TickerResponse
size: Size, ticker: TickerBase, resp: TickerResponse
) -> Tuple[Figure, Tuple[Axes, Optional[Axes]]]:
if ticker.config.volume:
fig, (ax, volume_ax) = _create_fig_ax(
dimensions, n_axes=2, height_ratios=[3, 1]
)
fig, (ax, volume_ax) = _create_fig_ax(size, n_axes=2, height_ratios=[3, 1])
else:
fig, (ax,) = _create_fig_ax(dimensions, n_axes=1)
fig, (ax,) = _create_fig_ax(size, n_axes=1)
volume_ax = False

kwargs = {}
Expand Down Expand Up @@ -224,9 +220,7 @@ def _perc_change_abp(ticker: TickerBase, resp: TickerResponse) -> float:


@register
def default(
dimensions: Dimensions, ticker: TickerBase, resp: TickerResponse
) -> Image.Image:
def default(size: Size, ticker: TickerBase, resp: TickerResponse) -> Image.Image:
"""Default layout."""

perc_change = _perc_change(ticker, resp)
Expand All @@ -236,7 +230,7 @@ def default(
# calculate the delta from the average buy price
top_string += f" {_perc_change_abp(ticker, resp):+.2f}%"

fig, (ax, _) = _historical_plot(dimensions, ticker, resp)
fig, (ax, _) = _historical_plot(size, ticker, resp)

top_text = ax.text(
0,
Expand All @@ -248,7 +242,7 @@ def default(
bbox=TEXT_BBOX,
verticalalignment="top",
)
ax_height = ax.get_position().height * dimensions[1]
ax_height = ax.get_position().height * size[1]
ax.text(
0,
(ax_height - top_text.get_window_extent().height + 1) / ax_height,
Expand All @@ -265,17 +259,16 @@ def default(


@register
def big_price(
dimensions: Dimensions, ticker: TickerBase, resp: TickerResponse
) -> Image.Image:
def big_price(size: Size, ticker: TickerBase, resp: TickerResponse) -> Image.Image:
"""Big price layout."""
perc_change = _perc_change(ticker, resp)
fig, (ax, _) = _historical_plot(dimensions, ticker, resp)
fig, (ax, _) = _historical_plot(size, ticker, resp)
fig.suptitle(
f"{ticker.config.symbol} ${resp.current_price:.2f}",
fontsize=18,
weight="bold",
x=0,
y=1,
horizontalalignment="left",
)
sub_string = f"{len(resp.historical)}x{ticker.config.interval} {perc_change:+.2f}%"
Expand Down
12 changes: 7 additions & 5 deletions tinyticker/utils.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,30 @@
import argparse
import logging
import socket
from typing import Tuple

import pandas as pd
import qrcode
from PIL import Image, ImageChops


def dashboard_qrcode(epd_width: int, epd_height: int, port: int = 8000) -> Image.Image:
def dashboard_qrcode(size: Tuple[int, int], port: int = 8000) -> Image.Image:
"""Generate a qrcode pointing to the dashboard url.
Args:
epd_width: the width of the ePaper display.
epd_height: the height of the ePaper display.
sie: the sie of the image, width and height.
port: the port number on which the dashboard is hosted.
Returns:
The qrcode image.
"""
min_dim = min(size)

url = f"http://{socket.gethostname()}.local:{port}"
qr = qrcode.make(url)
qr = trim(qr)
qr = qr.resize((epd_width, epd_width))
base = Image.new("1", (epd_height, epd_width), 1)
qr = qr.resize((min_dim, min_dim))
base = Image.new("1", size, 1)
base.paste(qr, (base.size[0] // 2 - qr.size[0] // 2, 0))
return base

Expand Down
12 changes: 11 additions & 1 deletion tinyticker/waveshare_lib/_base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import logging
from abc import abstractmethod
from typing import Literal, Optional, Type
from typing import Literal, Optional, Tuple, Type

import numpy as np
from PIL import Image
Expand All @@ -17,6 +17,16 @@ class EPDBase:
@abstractmethod
def __init__(self, device: Type[RaspberryPi] = RaspberryPi) -> None: ...

@property
def size(self) -> Tuple[int, int]:
"""The width and height of the display in landscape orientation."""
# The width/height from the waveshare library are not consistent, so we wrap it in a property.
return (
(self.width, self.height)
if self.width > self.height
else (self.height, self.width)
)

@abstractmethod
def init(self) -> Literal[0, -1]:
"""Initializes the display.
Expand Down
204 changes: 204 additions & 0 deletions tinyticker/waveshare_lib/epd7in5b_V2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
# *****************************************************************************
# * | File : epd7in5b_V2.py
# * | Author : Waveshare team
# * | Function : Electronic paper driver
# * | Info :
# *----------------
# * | This version: V4.2
# * | Date : 2022-01-08
# # | Info : python demo
# -----------------------------------------------------------------------------
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documnetation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS OR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#


import logging
from typing import Type

from ._base import EPDHighlight
from .device import RaspberryPi

# Display resolution
EPD_WIDTH = 800
EPD_HEIGHT = 480

logger = logging.getLogger(__name__)


class EPD(EPDHighlight):
def __init__(self, device: Type[RaspberryPi] = RaspberryPi):
self.device = device()
self.reset_pin = self.device.RST_PIN
self.dc_pin = self.device.DC_PIN
self.busy_pin = self.device.BUSY_PIN
self.cs_pin = self.device.CS_PIN
self.width = EPD_WIDTH
self.height = EPD_HEIGHT

# Hardware reset
def reset(self):
self.device.digital_write(self.reset_pin, 1)
self.device.delay_ms(200)
self.device.digital_write(self.reset_pin, 0)
self.device.delay_ms(4)
self.device.digital_write(self.reset_pin, 1)
self.device.delay_ms(200)

def send_command(self, command):
self.device.digital_write(self.dc_pin, 0)
self.device.digital_write(self.cs_pin, 0)
self.device.spi_writebyte([command])
self.device.digital_write(self.cs_pin, 1)

def send_data(self, data):
self.device.digital_write(self.dc_pin, 1)
self.device.digital_write(self.cs_pin, 0)
self.device.spi_writebyte([data])
self.device.digital_write(self.cs_pin, 1)

def send_data2(self, data): # faster
self.device.digital_write(self.dc_pin, 1)
self.device.digital_write(self.cs_pin, 0)
self.device.spi_writebyte2(data)
self.device.digital_write(self.cs_pin, 1)

def ReadBusy(self):
logger.debug("e-Paper busy")
self.send_command(0x71)
busy = self.device.digital_read(self.busy_pin)
while busy == 0:
self.send_command(0x71)
busy = self.device.digital_read(self.busy_pin)
self.device.delay_ms(200)
logger.debug("e-Paper busy release")

def init(self):
if self.device.module_init() != 0:
return -1

self.reset()

# self.send_command(0x06) # btst
# self.send_data(0x17)
# self.send_data(0x17)
# self.send_data(0x38) # If an exception is displayed, try using 0x38
# self.send_data(0x17)

self.send_command(0x01) # POWER SETTING
self.send_data(0x07)
self.send_data(0x07) # VGH=20V,VGL=-20V
self.send_data(0x3F) # VDH=15V
self.send_data(0x3F) # VDL=-15V

self.send_command(0x04) # POWER ON
self.device.delay_ms(100)
self.ReadBusy()

self.send_command(0x00) # PANNEL SETTING
self.send_data(0x0F) # KW-3f KWR-2F BWROTP-0f BWOTP-1f

self.send_command(0x61) # tres
self.send_data(0x03) # source 800
self.send_data(0x20)
self.send_data(0x01) # gate 480
self.send_data(0xE0)

self.send_command(0x15)
self.send_data(0x00)

self.send_command(0x50) # VCOM AND DATA INTERVAL SETTING
self.send_data(0x11)
self.send_data(0x07)

self.send_command(0x60) # TCON SETTING
self.send_data(0x22)

self.send_command(0x65)
self.send_data(0x00)
self.send_data(0x00)
self.send_data(0x00)
self.send_data(0x00)

return 0

def getbuffer(self, image):
img = image
imwidth, imheight = img.size
if imwidth == self.width and imheight == self.height:
img = img.convert("1")
elif imwidth == self.height and imheight == self.width:
# image has correct dimensions, but needs to be rotated
img = img.rotate(90, expand=True).convert("1")
else:
logger.warning(
"Wrong image dimensions: must be "
+ str(self.width)
+ "x"
+ str(self.height)
)
# return a blank buffer
return bytearray([0x00] * (int(self.width / 8) * self.height))

buf = bytearray(img.tobytes("raw"))
# The bytes need to be inverted, because in the PIL world 0=black and 1=white, but
# in the e-paper world 0=white and 1=black.
# NOTE: This is the first display I've seen that needs this inversion.
# Seems like a quirk of the epd7in5_V2 displays:
# https://github.com/search?q=repo%3Awaveshareteam%2Fe-Paper+buf%5Bi%5D+%5E%3D+0xFF&type=code
for i in range(len(buf)):
buf[i] ^= 0xFF
return buf

def display(self, imageblack, highlights=None):
self.send_command(0x10)
# The black bytes need to be inverted back from what getbuffer did
for i in range(len(imageblack)):
imageblack[i] ^= 0xFF
self.send_data2(imageblack)

if highlights is not None:
self.send_command(0x13)
self.send_data2(highlights)

self.send_command(0x12)
self.device.delay_ms(100)
self.ReadBusy()

def Clear(self):
buf = [0x00] * (int(self.width / 8) * self.height)
buf2 = [0xFF] * (int(self.width / 8) * self.height)
self.send_command(0x10)
self.send_data2(buf2)

self.send_command(0x13)
self.send_data2(buf)

self.send_command(0x12)
self.device.delay_ms(100)
self.ReadBusy()

def sleep(self):
self.send_command(0x02) # POWER_OFF
self.ReadBusy()

self.send_command(0x07) # DEEP_SLEEP
self.send_data(0xA5)

self.device.delay_ms(2000)
self.device.module_exit()
Loading

0 comments on commit 0f992dd

Please sign in to comment.