Initial commit: Paperwall, a standalone e-ink dashboard
Navidrome now-playing, clock, and weather for a Waveshare epd4in0e panel, with a local web UI and install/uninstall scripts for the Pi.
This commit is contained in:
commit
66464291e1
33 files changed
+2215
No files matched your search
@@ -0,0 +1,8 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
mock_output/
|
||||||
|
web/static/*.png
|
||||||
|
config.json
|
||||||
|
config.json.tmp
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Paperwall
|
||||||
|
|
||||||
|
A standalone dashboard for one Waveshare `epd4in0e` (4", 6-color) e-ink panel:
|
||||||
|
Navidrome now-playing, clock, and weather, switchable/controllable from a
|
||||||
|
local web UI. Started as an InkyPi replacement, but doesn't depend on InkyPi
|
||||||
|
at all -- own venv, own vendored copy of the Waveshare driver files.
|
||||||
|
|
||||||
|
Confirmed by reading the vendor driver directly: this panel has no
|
||||||
|
partial-refresh mode at all -- every update is a full flash. So the design
|
||||||
|
here isn't about avoiding that flash, it's about only triggering it when
|
||||||
|
something real actually changed (song change, minute tick on the clock,
|
||||||
|
weather update), via hash-comparing each rendered frame against what's
|
||||||
|
currently on screen.
|
||||||
|
|
||||||
|
## Local development (no hardware needed)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv && source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python3 main.py --mock --port 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
`--mock` saves each frame to `mock_output/` as a PNG instead of writing to
|
||||||
|
real hardware. The web UI (http://localhost:8080) works identically either
|
||||||
|
way -- same status page, same settings forms, same "switch screen now"
|
||||||
|
buttons. `requirements-pi.txt` (gpiozero/lgpio/RPi.GPIO) is only needed on
|
||||||
|
the real Pi, so local dev doesn't need it -- and those generally won't even
|
||||||
|
build off a Pi anyway.
|
||||||
|
|
||||||
|
## Installing on the Pi
|
||||||
|
|
||||||
|
1. Copy the project folder onto the Pi however you like (SFTP, `scp -r`,
|
||||||
|
whatever) -- anywhere, the installer figures out its own location.
|
||||||
|
2. On the Pi:
|
||||||
|
```bash
|
||||||
|
sudo bash install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
That's the whole thing. It installs the few system packages needed to build
|
||||||
|
`lgpio` (`liblgpio-dev`, `swig`), enables SPI/I2C in `config.txt` if not
|
||||||
|
already on, creates its own venv right next to the project and installs
|
||||||
|
everything into it (`requirements.txt` + `requirements-pi.txt`), and
|
||||||
|
installs+starts `paperwall.service`. If it finds InkyPi (or anything else)
|
||||||
|
already running, it stops it first -- two things can't drive the same panel
|
||||||
|
at once -- but nothing is copied from or reused out of it. Safe to re-run.
|
||||||
|
|
||||||
|
Then open `http://<pi-ip>:8080`, fill in Navidrome credentials under
|
||||||
|
**Navidrome** and coordinates under **Weather**. Everything saves
|
||||||
|
immediately and takes effect within a few seconds -- no restart needed.
|
||||||
|
|
||||||
|
## Uninstalling
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash uninstall.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Stops the service, removes the systemd unit and the venv `install.sh`
|
||||||
|
created. Leaves the project folder and `config.json` alone -- delete those
|
||||||
|
yourself if you want it fully gone. Doesn't touch SPI/I2C or anything else
|
||||||
|
on the system.
|
||||||
|
|
||||||
|
(If you're migrating away from InkyPi specifically and want to remove *that*
|
||||||
|
too, see `uninstall_inkypi.sh` -- separate script, since it's InkyPi's
|
||||||
|
install that needs reversing, not Paperwall's.)
|
||||||
|
|
||||||
|
## Deploying to a different e-ink screen
|
||||||
|
|
||||||
|
Everything panel-specific lives behind the `Display` protocol in
|
||||||
|
`display/__init__.py` (`init()`, `show(image)`, `sleep()`) -- screens never
|
||||||
|
talk to hardware directly, they just return a `PIL.Image` sized to
|
||||||
|
`display.resolution`. To support a different panel:
|
||||||
|
|
||||||
|
1. **Waveshare panel, different model** (e.g. a monochrome one for fast
|
||||||
|
partial refresh): vendor that model's driver files into
|
||||||
|
`display/waveshare_epd/` the same way (check the Waveshare `e-Paper`
|
||||||
|
GitHub repo for whether it's in the standard
|
||||||
|
`RaspberryPi_JetsonNano/python/lib/waveshare_epd/` path or, like
|
||||||
|
`epd4in0e`, needs the "Separate Program" path instead). Write a new
|
||||||
|
driver class mirroring `display/epd4in0e_driver.py`, with the panel's
|
||||||
|
real pixel resolution. If the model exposes `displayPartial()` (common
|
||||||
|
on B/W and B/W/red panels, unlike this 6-color one), that's a real
|
||||||
|
opportunity: add a `supports_partial` flag and an optional
|
||||||
|
`show_partial(image, region)` to the `Display` protocol, and have the
|
||||||
|
scheduler use it for screens that only change a small region (e.g. just
|
||||||
|
the clock's time text) instead of always doing a full flash.
|
||||||
|
2. **Pimoroni Inky panel**: add `inky` to `requirements-pi.txt` and write a
|
||||||
|
driver using `inky.auto.auto()` instead of the Waveshare EPD classes --
|
||||||
|
simpler, since Pimoroni's library handles the hardware differences for
|
||||||
|
you.
|
||||||
|
3. Either way, update `resolution` in the new driver, and swap which driver
|
||||||
|
`main.py`'s `build_display()` constructs.
|
||||||
|
|
||||||
|
Nothing in `scheduler.py`, `config.py`, `web/`, or any screen needs to
|
||||||
|
change -- that's the point of the `Display` boundary.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
main.py entrypoint: init display, load config, start scheduler + web app
|
||||||
|
config.py config.json load/save
|
||||||
|
scheduler.py rotation / Navidrome-preemption / hash-dedup logic
|
||||||
|
display/ Display protocol + epd4in0e driver (vendored) + mock driver for dev
|
||||||
|
screens/ one module per screen (navidrome.py, clock.py, weather.py)
|
||||||
|
web/ Flask status page + settings forms
|
||||||
|
install.sh sets up venv + service on the Pi, standalone
|
||||||
|
uninstall.sh removes what install.sh set up
|
||||||
|
uninstall_inkypi.sh separate: removes InkyPi itself, if you were migrating from it
|
||||||
|
```
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Single JSON config file, loaded once and kept in memory.
|
||||||
|
|
||||||
|
Unlike InkyPi's Config (which we spent a long time debugging), writes happen
|
||||||
|
immediately on every change from the web UI, and the scheduler re-reads the
|
||||||
|
in-memory structure fresh on every tick -- there's no "did the setting
|
||||||
|
actually take effect" gap, because there's no long sleep to get stuck in.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_CONFIG_PATH = Path(__file__).resolve().parent / "config.json"
|
||||||
|
|
||||||
|
DEFAULT_ROTATION = {
|
||||||
|
"screens": ["clock", "weather"],
|
||||||
|
"interval_seconds": 300,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
def __init__(self, screens: dict, path: Path = DEFAULT_CONFIG_PATH):
|
||||||
|
self.path = path
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._data = self._load(screens)
|
||||||
|
|
||||||
|
def _load(self, screens: dict) -> dict:
|
||||||
|
if self.path.exists():
|
||||||
|
with open(self.path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
else:
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
data.setdefault("rotation", dict(DEFAULT_ROTATION))
|
||||||
|
data.setdefault("navidrome_priority", True)
|
||||||
|
data.setdefault("settings", {})
|
||||||
|
for screen_id, screen in screens.items():
|
||||||
|
data["settings"].setdefault(screen_id, {})
|
||||||
|
for key, value in screen.default_settings().items():
|
||||||
|
data["settings"][screen_id].setdefault(key, value)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
tmp_path = self.path.with_suffix(".json.tmp")
|
||||||
|
with open(tmp_path, "w") as f:
|
||||||
|
json.dump(self._data, f, indent=2)
|
||||||
|
tmp_path.replace(self.path)
|
||||||
|
logger.debug("Config written to %s", self.path)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ screens
|
||||||
|
|
||||||
|
def get_screen_settings(self, screen_id: str) -> dict:
|
||||||
|
"""Returns the live, mutable settings dict for a screen -- screens are
|
||||||
|
allowed to write ephemeral state into it (e.g. Navidrome's last-known
|
||||||
|
track for the idle fallback)."""
|
||||||
|
with self._lock:
|
||||||
|
return self._data["settings"].setdefault(screen_id, {})
|
||||||
|
|
||||||
|
def update_screen_settings(self, screen_id: str, updates: dict) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._data["settings"].setdefault(screen_id, {}).update(updates)
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
# ----------------------------------------------------------- rotation
|
||||||
|
|
||||||
|
def get_rotation(self) -> dict:
|
||||||
|
with self._lock:
|
||||||
|
return dict(self._data["rotation"])
|
||||||
|
|
||||||
|
def set_rotation(self, screens: list[str], interval_seconds: int) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._data["rotation"] = {"screens": screens, "interval_seconds": interval_seconds}
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def get_navidrome_priority(self) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
return bool(self._data["navidrome_priority"])
|
||||||
|
|
||||||
|
def set_navidrome_priority(self, enabled: bool) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._data["navidrome_priority"] = enabled
|
||||||
|
self.save()
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Display driver interface.
|
||||||
|
|
||||||
|
A driver only needs to do three things: get ready, put an image on the panel,
|
||||||
|
and go to sleep to save power. `EPD4in0eDisplay` talks to the real Waveshare
|
||||||
|
hardware; `MockDisplay` is a drop-in stand-in for developing off the Pi.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
class Display(ABC):
|
||||||
|
resolution: tuple[int, int]
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def init(self) -> None:
|
||||||
|
"""Prepare the panel for writing (called once at startup, and again
|
||||||
|
after every sleep since most EPDs need re-initializing)."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def show(self, image: Image.Image) -> None:
|
||||||
|
"""Push a full-resolution image to the panel. This is always a full
|
||||||
|
refresh on this hardware -- there is no partial-refresh path."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def sleep(self) -> None:
|
||||||
|
"""Put the panel into low-power mode between updates."""
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Driver for the Waveshare epd4in0e (4", 6-color Spectra) panel.
|
||||||
|
|
||||||
|
Thin wrapper around the vendored `waveshare_epd.epd4in0e` module -- the exact
|
||||||
|
driver already proven working under the InkyPi install on this Pi. That
|
||||||
|
driver only exposes `Clear()` and `display()`, both of which do a full
|
||||||
|
`TurnOnDisplay()` flash cycle; there is no partial-refresh path for this
|
||||||
|
panel at the hardware/SDK level, confirmed by reading the driver itself.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from display import Display
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
RESOLUTION = (400, 600)
|
||||||
|
|
||||||
|
|
||||||
|
class EPD4in0eDisplay(Display):
|
||||||
|
def __init__(self):
|
||||||
|
self.resolution = RESOLUTION
|
||||||
|
self._epd = None
|
||||||
|
|
||||||
|
def init(self) -> None:
|
||||||
|
from waveshare_epd import epd4in0e
|
||||||
|
|
||||||
|
self._epd = epd4in0e.EPD()
|
||||||
|
self._epd.init()
|
||||||
|
logger.info("epd4in0e initialized")
|
||||||
|
|
||||||
|
def show(self, image) -> None:
|
||||||
|
if self._epd is None:
|
||||||
|
self.init()
|
||||||
|
|
||||||
|
width, height = self.resolution
|
||||||
|
if image.size != (width, height):
|
||||||
|
raise ValueError(
|
||||||
|
f"Image size {image.size} does not match panel resolution {self.resolution}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Re-init: the panel goes to sleep after every show(), and needs a
|
||||||
|
# fresh init() before it will accept commands again.
|
||||||
|
self._epd.init()
|
||||||
|
|
||||||
|
# Clear first to avoid ghosting from the previous image -- also a
|
||||||
|
# full flash, there's no way around it on this panel.
|
||||||
|
self._epd.Clear()
|
||||||
|
|
||||||
|
self._epd.display(self._epd.getbuffer(image.convert("RGB")))
|
||||||
|
|
||||||
|
def sleep(self) -> None:
|
||||||
|
if self._epd is not None:
|
||||||
|
self._epd.sleep()
|
||||||
|
logger.info("epd4in0e put to sleep")
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Development stand-in for the real panel: saves each frame as a PNG instead
|
||||||
|
of writing to hardware, so the scheduler/rendering logic can be exercised off
|
||||||
|
the Pi.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from display import Display
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MockDisplay(Display):
|
||||||
|
def __init__(self, resolution: tuple[int, int], out_dir: str = "./mock_output"):
|
||||||
|
self.resolution = resolution
|
||||||
|
self.out_dir = Path(out_dir)
|
||||||
|
self.out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._frame_count = 0
|
||||||
|
|
||||||
|
def init(self) -> None:
|
||||||
|
logger.info("MockDisplay initialized (resolution=%s)", self.resolution)
|
||||||
|
|
||||||
|
def show(self, image: Image.Image) -> None:
|
||||||
|
self._frame_count += 1
|
||||||
|
path = self.out_dir / "current.png"
|
||||||
|
image.save(path)
|
||||||
|
history_path = self.out_dir / f"frame_{self._frame_count:04d}.png"
|
||||||
|
image.save(history_path)
|
||||||
|
logger.info("MockDisplay: wrote frame to %s", path)
|
||||||
|
|
||||||
|
def sleep(self) -> None:
|
||||||
|
logger.info("MockDisplay: sleep")
|
||||||
Binary file not shown.
Whitespace-only changes.
@@ -0,0 +1,232 @@
|
|||||||
|
# *****************************************************************************
|
||||||
|
# * | File : epd7in3f.py
|
||||||
|
# * | Author : Waveshare team
|
||||||
|
# * | Function : Electronic paper driver
|
||||||
|
# * | Info :
|
||||||
|
# *----------------
|
||||||
|
# * | This version: V1.0
|
||||||
|
# * | Date : 2022-10-20
|
||||||
|
# # | 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 . import epdconfig
|
||||||
|
|
||||||
|
import PIL
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
|
||||||
|
# Display resolution
|
||||||
|
EPD_WIDTH = 400
|
||||||
|
EPD_HEIGHT = 600
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class EPD:
|
||||||
|
def __init__(self):
|
||||||
|
self.reset_pin = epdconfig.RST_PIN
|
||||||
|
self.dc_pin = epdconfig.DC_PIN
|
||||||
|
self.busy_pin = epdconfig.BUSY_PIN
|
||||||
|
self.cs_pin = epdconfig.CS_PIN
|
||||||
|
self.width = EPD_WIDTH
|
||||||
|
self.height = EPD_HEIGHT
|
||||||
|
self.BLACK = 0x000000 # 0000 BGR
|
||||||
|
self.WHITE = 0xffffff # 0001
|
||||||
|
self.YELLOW = 0x00ffff # 0010
|
||||||
|
self.RED = 0x0000ff # 0011
|
||||||
|
self.BLUE = 0xff0000 # 0101
|
||||||
|
self.GREEN = 0x00ff00 # 0110
|
||||||
|
|
||||||
|
|
||||||
|
# Hardware reset
|
||||||
|
def reset(self):
|
||||||
|
epdconfig.digital_write(self.reset_pin, 1)
|
||||||
|
epdconfig.delay_ms(20)
|
||||||
|
epdconfig.digital_write(self.reset_pin, 0) # module reset
|
||||||
|
epdconfig.delay_ms(2)
|
||||||
|
epdconfig.digital_write(self.reset_pin, 1)
|
||||||
|
epdconfig.delay_ms(20)
|
||||||
|
|
||||||
|
def send_command(self, command):
|
||||||
|
epdconfig.digital_write(self.dc_pin, 0)
|
||||||
|
epdconfig.digital_write(self.cs_pin, 0)
|
||||||
|
epdconfig.spi_writebyte([command])
|
||||||
|
epdconfig.digital_write(self.cs_pin, 1)
|
||||||
|
|
||||||
|
def send_data(self, data):
|
||||||
|
epdconfig.digital_write(self.dc_pin, 1)
|
||||||
|
epdconfig.digital_write(self.cs_pin, 0)
|
||||||
|
epdconfig.spi_writebyte([data])
|
||||||
|
epdconfig.digital_write(self.cs_pin, 1)
|
||||||
|
|
||||||
|
# send a lot of data
|
||||||
|
def send_data2(self, data):
|
||||||
|
epdconfig.digital_write(self.dc_pin, 1)
|
||||||
|
epdconfig.digital_write(self.cs_pin, 0)
|
||||||
|
epdconfig.spi_writebyte2(data)
|
||||||
|
epdconfig.digital_write(self.cs_pin, 1)
|
||||||
|
|
||||||
|
def ReadBusyH(self):
|
||||||
|
logger.debug("e-Paper busy H")
|
||||||
|
while(epdconfig.digital_read(self.busy_pin) == 0): # 0: busy, 1: idle
|
||||||
|
epdconfig.delay_ms(5)
|
||||||
|
epdconfig.delay_ms(200)
|
||||||
|
logger.debug("e-Paper busy H release")
|
||||||
|
|
||||||
|
def TurnOnDisplay(self):
|
||||||
|
self.send_command(0x04) # POWER_ON
|
||||||
|
self.ReadBusyH()
|
||||||
|
|
||||||
|
self.send_command(0x06)
|
||||||
|
self.send_data(0x6F)
|
||||||
|
self.send_data(0x1F)
|
||||||
|
self.send_data(0x17)
|
||||||
|
self.send_data(0x27)
|
||||||
|
epdconfig.delay_ms(200)
|
||||||
|
|
||||||
|
self.send_command(0x12) # DISPLAY_REFRESH
|
||||||
|
self.send_data(0X00)
|
||||||
|
self.ReadBusyH()
|
||||||
|
|
||||||
|
self.send_command(0x02) # POWER_OFF
|
||||||
|
self.send_data(0X00)
|
||||||
|
self.ReadBusyH()
|
||||||
|
|
||||||
|
def init(self):
|
||||||
|
if (epdconfig.module_init() != 0):
|
||||||
|
return -1
|
||||||
|
# EPD hardware init start
|
||||||
|
self.reset()
|
||||||
|
self.ReadBusyH()
|
||||||
|
epdconfig.delay_ms(30)
|
||||||
|
|
||||||
|
self.send_command(0xAA)
|
||||||
|
self.send_data(0x49)
|
||||||
|
self.send_data(0x55)
|
||||||
|
self.send_data(0x20)
|
||||||
|
self.send_data(0x08)
|
||||||
|
self.send_data(0x09)
|
||||||
|
self.send_data(0x18)
|
||||||
|
|
||||||
|
self.send_command(0x01)
|
||||||
|
self.send_data(0x3F)
|
||||||
|
|
||||||
|
self.send_command(0x00)
|
||||||
|
self.send_data(0x5F)
|
||||||
|
self.send_data(0x69)
|
||||||
|
|
||||||
|
self.send_command(0x05)
|
||||||
|
self.send_data(0x40)
|
||||||
|
self.send_data(0x1F)
|
||||||
|
self.send_data(0x1F)
|
||||||
|
self.send_data(0x2C)
|
||||||
|
|
||||||
|
self.send_command(0x08)
|
||||||
|
self.send_data(0x6F)
|
||||||
|
self.send_data(0x1F)
|
||||||
|
self.send_data(0x1F)
|
||||||
|
self.send_data(0x22)
|
||||||
|
|
||||||
|
self.send_command(0x06)
|
||||||
|
self.send_data(0x6F)
|
||||||
|
self.send_data(0x1F)
|
||||||
|
self.send_data(0x17)
|
||||||
|
self.send_data(0x17)
|
||||||
|
|
||||||
|
self.send_command(0x03)
|
||||||
|
self.send_data(0x00)
|
||||||
|
self.send_data(0x54)
|
||||||
|
self.send_data(0x00)
|
||||||
|
self.send_data(0x44)
|
||||||
|
|
||||||
|
self.send_command(0x60)
|
||||||
|
self.send_data(0x02)
|
||||||
|
self.send_data(0x00)
|
||||||
|
|
||||||
|
self.send_command(0x30)
|
||||||
|
self.send_data(0x08)
|
||||||
|
|
||||||
|
self.send_command(0x50)
|
||||||
|
self.send_data(0x3F)
|
||||||
|
|
||||||
|
self.send_command(0x61)
|
||||||
|
self.send_data(0x01)
|
||||||
|
self.send_data(0x90)
|
||||||
|
self.send_data(0x02)
|
||||||
|
self.send_data(0x58)
|
||||||
|
|
||||||
|
self.send_command(0xE3)
|
||||||
|
self.send_data(0x2F)
|
||||||
|
|
||||||
|
self.send_command(0x84)
|
||||||
|
self.send_data(0x01)
|
||||||
|
self.ReadBusyH()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def getbuffer(self, image):
|
||||||
|
# Create a pallette with the 7 colors supported by the panel
|
||||||
|
pal_image = Image.new("P", (1,1))
|
||||||
|
pal_image.putpalette( (0,0,0, 255,255,255, 255,255,0, 255,0,0, 0,0,0, 0,0,255, 0,255,0) + (0,0,0)*249)
|
||||||
|
|
||||||
|
# Check if we need to rotate the image
|
||||||
|
imwidth, imheight = image.size
|
||||||
|
if(imwidth == self.width and imheight == self.height):
|
||||||
|
image_temp = image
|
||||||
|
elif(imwidth == self.height and imheight == self.width):
|
||||||
|
image_temp = image.rotate(90, expand=True)
|
||||||
|
else:
|
||||||
|
logger.warning("Invalid image dimensions: %d x %d, expected %d x %d" % (imwidth, imheight, self.width, self.height))
|
||||||
|
|
||||||
|
# Convert the soruce image to the 7 colors, dithering if needed
|
||||||
|
image_6color = image_temp.convert("RGB").quantize(palette=pal_image)
|
||||||
|
buf_6color = bytearray(image_6color.tobytes('raw'))
|
||||||
|
|
||||||
|
# PIL does not support 4 bit color, so pack the 4 bits of color
|
||||||
|
# into a single byte to transfer to the panel
|
||||||
|
buf = [0x00] * int(self.width * self.height / 2)
|
||||||
|
idx = 0
|
||||||
|
for i in range(0, len(buf_6color), 2):
|
||||||
|
buf[idx] = (buf_6color[i] << 4) + buf_6color[i+1]
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
return buf
|
||||||
|
|
||||||
|
def display(self, image):
|
||||||
|
self.send_command(0x10)
|
||||||
|
self.send_data2(image)
|
||||||
|
|
||||||
|
self.TurnOnDisplay()
|
||||||
|
|
||||||
|
def Clear(self, color=0x11):
|
||||||
|
self.send_command(0x10)
|
||||||
|
self.send_data2([color] * int(self.height) * int(self.width/2))
|
||||||
|
|
||||||
|
self.TurnOnDisplay()
|
||||||
|
|
||||||
|
def sleep(self):
|
||||||
|
self.send_command(0x07) # DEEP_SLEEP
|
||||||
|
self.send_data(0XA5)
|
||||||
|
|
||||||
|
epdconfig.delay_ms(2000)
|
||||||
|
epdconfig.module_exit()
|
||||||
|
### END OF FILE ###
|
||||||
|
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
# /*****************************************************************************
|
||||||
|
# * | File : epdconfig.py
|
||||||
|
# * | Author : Waveshare team
|
||||||
|
# * | Function : Hardware underlying interface
|
||||||
|
# * | Info :
|
||||||
|
# *----------------
|
||||||
|
# * | This version: V1.2
|
||||||
|
# * | Date : 2022-10-29
|
||||||
|
# * | Info :
|
||||||
|
# ******************************************************************************
|
||||||
|
# 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 os
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from ctypes import *
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RaspberryPi:
|
||||||
|
# Pin definition
|
||||||
|
RST_PIN = 17
|
||||||
|
DC_PIN = 25
|
||||||
|
CS_PIN = 8
|
||||||
|
BUSY_PIN = 24
|
||||||
|
PWR_PIN = 18
|
||||||
|
MOSI_PIN = 10
|
||||||
|
SCLK_PIN = 11
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
import spidev
|
||||||
|
import gpiozero
|
||||||
|
|
||||||
|
self.SPI = spidev.SpiDev()
|
||||||
|
self.GPIO_RST_PIN = gpiozero.LED(self.RST_PIN)
|
||||||
|
self.GPIO_DC_PIN = gpiozero.LED(self.DC_PIN)
|
||||||
|
# self.GPIO_CS_PIN = gpiozero.LED(self.CS_PIN)
|
||||||
|
self.GPIO_PWR_PIN = gpiozero.LED(self.PWR_PIN)
|
||||||
|
self.GPIO_BUSY_PIN = gpiozero.Button(self.BUSY_PIN, pull_up = False)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def digital_write(self, pin, value):
|
||||||
|
if pin == self.RST_PIN:
|
||||||
|
if value:
|
||||||
|
self.GPIO_RST_PIN.on()
|
||||||
|
else:
|
||||||
|
self.GPIO_RST_PIN.off()
|
||||||
|
elif pin == self.DC_PIN:
|
||||||
|
if value:
|
||||||
|
self.GPIO_DC_PIN.on()
|
||||||
|
else:
|
||||||
|
self.GPIO_DC_PIN.off()
|
||||||
|
# elif pin == self.CS_PIN:
|
||||||
|
# if value:
|
||||||
|
# self.GPIO_CS_PIN.on()
|
||||||
|
# else:
|
||||||
|
# self.GPIO_CS_PIN.off()
|
||||||
|
elif pin == self.PWR_PIN:
|
||||||
|
if value:
|
||||||
|
self.GPIO_PWR_PIN.on()
|
||||||
|
else:
|
||||||
|
self.GPIO_PWR_PIN.off()
|
||||||
|
|
||||||
|
def digital_read(self, pin):
|
||||||
|
if pin == self.BUSY_PIN:
|
||||||
|
return self.GPIO_BUSY_PIN.value
|
||||||
|
elif pin == self.RST_PIN:
|
||||||
|
return self.RST_PIN.value
|
||||||
|
elif pin == self.DC_PIN:
|
||||||
|
return self.DC_PIN.value
|
||||||
|
# elif pin == self.CS_PIN:
|
||||||
|
# return self.CS_PIN.value
|
||||||
|
elif pin == self.PWR_PIN:
|
||||||
|
return self.PWR_PIN.value
|
||||||
|
|
||||||
|
def delay_ms(self, delaytime):
|
||||||
|
time.sleep(delaytime / 1000.0)
|
||||||
|
|
||||||
|
def spi_writebyte(self, data):
|
||||||
|
self.SPI.writebytes(data)
|
||||||
|
|
||||||
|
def spi_writebyte2(self, data):
|
||||||
|
self.SPI.writebytes2(data)
|
||||||
|
|
||||||
|
def DEV_SPI_write(self, data):
|
||||||
|
self.DEV_SPI.DEV_SPI_SendData(data)
|
||||||
|
|
||||||
|
def DEV_SPI_nwrite(self, data):
|
||||||
|
self.DEV_SPI.DEV_SPI_SendnData(data)
|
||||||
|
|
||||||
|
def DEV_SPI_read(self):
|
||||||
|
return self.DEV_SPI.DEV_SPI_ReadData()
|
||||||
|
|
||||||
|
def module_init(self, cleanup=False):
|
||||||
|
self.GPIO_PWR_PIN.on()
|
||||||
|
|
||||||
|
if cleanup:
|
||||||
|
find_dirs = [
|
||||||
|
os.path.dirname(os.path.realpath(__file__)),
|
||||||
|
'/usr/local/lib',
|
||||||
|
'/usr/lib',
|
||||||
|
]
|
||||||
|
self.DEV_SPI = None
|
||||||
|
for find_dir in find_dirs:
|
||||||
|
val = int(os.popen('getconf LONG_BIT').read())
|
||||||
|
logging.debug("System is %d bit"%val)
|
||||||
|
if val == 64:
|
||||||
|
so_filename = os.path.join(find_dir, 'DEV_Config_64.so')
|
||||||
|
else:
|
||||||
|
so_filename = os.path.join(find_dir, 'DEV_Config_32.so')
|
||||||
|
if os.path.exists(so_filename):
|
||||||
|
self.DEV_SPI = CDLL(so_filename)
|
||||||
|
break
|
||||||
|
if self.DEV_SPI is None:
|
||||||
|
RuntimeError('Cannot find DEV_Config.so')
|
||||||
|
|
||||||
|
self.DEV_SPI.DEV_Module_Init()
|
||||||
|
|
||||||
|
else:
|
||||||
|
# SPI device, bus = 0, device = 0
|
||||||
|
self.SPI.open(0, 0)
|
||||||
|
self.SPI.max_speed_hz = 4000000
|
||||||
|
self.SPI.mode = 0b00
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def module_exit(self, cleanup=False):
|
||||||
|
logger.debug("spi end")
|
||||||
|
self.SPI.close()
|
||||||
|
|
||||||
|
self.GPIO_RST_PIN.off()
|
||||||
|
self.GPIO_DC_PIN.off()
|
||||||
|
self.GPIO_PWR_PIN.off()
|
||||||
|
logger.debug("close 5V, Module enters 0 power consumption ...")
|
||||||
|
|
||||||
|
if cleanup:
|
||||||
|
self.GPIO_RST_PIN.close()
|
||||||
|
self.GPIO_DC_PIN.close()
|
||||||
|
# self.GPIO_CS_PIN.close()
|
||||||
|
self.GPIO_PWR_PIN.close()
|
||||||
|
self.GPIO_BUSY_PIN.close()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class JetsonNano:
|
||||||
|
# Pin definition
|
||||||
|
RST_PIN = 17
|
||||||
|
DC_PIN = 25
|
||||||
|
CS_PIN = 8
|
||||||
|
BUSY_PIN = 24
|
||||||
|
PWR_PIN = 18
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
import ctypes
|
||||||
|
find_dirs = [
|
||||||
|
os.path.dirname(os.path.realpath(__file__)),
|
||||||
|
'/usr/local/lib',
|
||||||
|
'/usr/lib',
|
||||||
|
]
|
||||||
|
self.SPI = None
|
||||||
|
for find_dir in find_dirs:
|
||||||
|
so_filename = os.path.join(find_dir, 'sysfs_software_spi.so')
|
||||||
|
if os.path.exists(so_filename):
|
||||||
|
self.SPI = ctypes.cdll.LoadLibrary(so_filename)
|
||||||
|
break
|
||||||
|
if self.SPI is None:
|
||||||
|
raise RuntimeError('Cannot find sysfs_software_spi.so')
|
||||||
|
|
||||||
|
import Jetson.GPIO
|
||||||
|
self.GPIO = Jetson.GPIO
|
||||||
|
|
||||||
|
def digital_write(self, pin, value):
|
||||||
|
self.GPIO.output(pin, value)
|
||||||
|
|
||||||
|
def digital_read(self, pin):
|
||||||
|
return self.GPIO.input(self.BUSY_PIN)
|
||||||
|
|
||||||
|
def delay_ms(self, delaytime):
|
||||||
|
time.sleep(delaytime / 1000.0)
|
||||||
|
|
||||||
|
def spi_writebyte(self, data):
|
||||||
|
self.SPI.SYSFS_software_spi_transfer(data[0])
|
||||||
|
|
||||||
|
def spi_writebyte2(self, data):
|
||||||
|
for i in range(len(data)):
|
||||||
|
self.SPI.SYSFS_software_spi_transfer(data[i])
|
||||||
|
|
||||||
|
def module_init(self):
|
||||||
|
self.GPIO.setmode(self.GPIO.BCM)
|
||||||
|
self.GPIO.setwarnings(False)
|
||||||
|
self.GPIO.setup(self.RST_PIN, self.GPIO.OUT)
|
||||||
|
self.GPIO.setup(self.DC_PIN, self.GPIO.OUT)
|
||||||
|
self.GPIO.setup(self.CS_PIN, self.GPIO.OUT)
|
||||||
|
self.GPIO.setup(self.PWR_PIN, self.GPIO.OUT)
|
||||||
|
self.GPIO.setup(self.BUSY_PIN, self.GPIO.IN)
|
||||||
|
|
||||||
|
self.GPIO.output(self.PWR_PIN, 1)
|
||||||
|
|
||||||
|
self.SPI.SYSFS_software_spi_begin()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def module_exit(self):
|
||||||
|
logger.debug("spi end")
|
||||||
|
self.SPI.SYSFS_software_spi_end()
|
||||||
|
|
||||||
|
logger.debug("close 5V, Module enters 0 power consumption ...")
|
||||||
|
self.GPIO.output(self.RST_PIN, 0)
|
||||||
|
self.GPIO.output(self.DC_PIN, 0)
|
||||||
|
self.GPIO.output(self.PWR_PIN, 0)
|
||||||
|
|
||||||
|
self.GPIO.cleanup([self.RST_PIN, self.DC_PIN, self.CS_PIN, self.BUSY_PIN, self.PWR_PIN])
|
||||||
|
|
||||||
|
|
||||||
|
class SunriseX3:
|
||||||
|
# Pin definition
|
||||||
|
RST_PIN = 17
|
||||||
|
DC_PIN = 25
|
||||||
|
CS_PIN = 8
|
||||||
|
BUSY_PIN = 24
|
||||||
|
PWR_PIN = 18
|
||||||
|
Flag = 0
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
import spidev
|
||||||
|
import Hobot.GPIO
|
||||||
|
|
||||||
|
self.GPIO = Hobot.GPIO
|
||||||
|
self.SPI = spidev.SpiDev()
|
||||||
|
|
||||||
|
def digital_write(self, pin, value):
|
||||||
|
self.GPIO.output(pin, value)
|
||||||
|
|
||||||
|
def digital_read(self, pin):
|
||||||
|
return self.GPIO.input(pin)
|
||||||
|
|
||||||
|
def delay_ms(self, delaytime):
|
||||||
|
time.sleep(delaytime / 1000.0)
|
||||||
|
|
||||||
|
def spi_writebyte(self, data):
|
||||||
|
self.SPI.writebytes(data)
|
||||||
|
|
||||||
|
def spi_writebyte2(self, data):
|
||||||
|
# for i in range(len(data)):
|
||||||
|
# self.SPI.writebytes([data[i]])
|
||||||
|
self.SPI.xfer3(data)
|
||||||
|
|
||||||
|
def module_init(self):
|
||||||
|
if self.Flag == 0:
|
||||||
|
self.Flag = 1
|
||||||
|
self.GPIO.setmode(self.GPIO.BCM)
|
||||||
|
self.GPIO.setwarnings(False)
|
||||||
|
self.GPIO.setup(self.RST_PIN, self.GPIO.OUT)
|
||||||
|
self.GPIO.setup(self.DC_PIN, self.GPIO.OUT)
|
||||||
|
self.GPIO.setup(self.CS_PIN, self.GPIO.OUT)
|
||||||
|
self.GPIO.setup(self.PWR_PIN, self.GPIO.OUT)
|
||||||
|
self.GPIO.setup(self.BUSY_PIN, self.GPIO.IN)
|
||||||
|
|
||||||
|
self.GPIO.output(self.PWR_PIN, 1)
|
||||||
|
|
||||||
|
# SPI device, bus = 0, device = 0
|
||||||
|
self.SPI.open(2, 0)
|
||||||
|
self.SPI.max_speed_hz = 4000000
|
||||||
|
self.SPI.mode = 0b00
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def module_exit(self):
|
||||||
|
logger.debug("spi end")
|
||||||
|
self.SPI.close()
|
||||||
|
|
||||||
|
logger.debug("close 5V, Module enters 0 power consumption ...")
|
||||||
|
self.Flag = 0
|
||||||
|
self.GPIO.output(self.RST_PIN, 0)
|
||||||
|
self.GPIO.output(self.DC_PIN, 0)
|
||||||
|
self.GPIO.output(self.PWR_PIN, 0)
|
||||||
|
|
||||||
|
self.GPIO.cleanup([self.RST_PIN, self.DC_PIN, self.CS_PIN, self.BUSY_PIN], self.PWR_PIN)
|
||||||
|
|
||||||
|
|
||||||
|
if sys.version_info[0] == 2:
|
||||||
|
process = subprocess.Popen("cat /proc/cpuinfo | grep Raspberry", shell=True, stdout=subprocess.PIPE)
|
||||||
|
else:
|
||||||
|
process = subprocess.Popen("cat /proc/cpuinfo | grep Raspberry", shell=True, stdout=subprocess.PIPE, text=True)
|
||||||
|
output, _ = process.communicate()
|
||||||
|
if sys.version_info[0] == 2:
|
||||||
|
output = output.decode(sys.stdout.encoding)
|
||||||
|
|
||||||
|
if "Raspberry" in output:
|
||||||
|
implementation = RaspberryPi()
|
||||||
|
elif os.path.exists('/sys/bus/platform/drivers/gpio-x3'):
|
||||||
|
implementation = SunriseX3()
|
||||||
|
else:
|
||||||
|
implementation = JetsonNano()
|
||||||
|
|
||||||
|
for func in [x for x in dir(implementation) if not x.startswith('_')]:
|
||||||
|
setattr(sys.modules[__name__], func, getattr(implementation, func))
|
||||||
|
|
||||||
|
### END OF FILE ###
|
||||||
Binary file not shown.
Binary file not shown.
Executable
+123
@@ -0,0 +1,123 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Installs Paperwall on this machine, in place. Standalone -- doesn't require
|
||||||
|
# InkyPi to be installed. If InkyPi (or anything else) happens to already be
|
||||||
|
# running against the same panel, this stops it, since two processes can't
|
||||||
|
# both drive the same SPI display.
|
||||||
|
#
|
||||||
|
# Run this ON the Pi, from wherever you copied the project folder to (e.g.
|
||||||
|
# via SFTP): `sudo bash install.sh`
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Resolve the real directory this script lives in, even through symlinks.
|
||||||
|
SOURCE="${BASH_SOURCE[0]}"
|
||||||
|
while [ -h "$SOURCE" ]; do
|
||||||
|
DIR=$(cd -P "$(dirname "$SOURCE")" >/dev/null 2>&1 && pwd)
|
||||||
|
SOURCE=$(readlink "$SOURCE")
|
||||||
|
[[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE"
|
||||||
|
done
|
||||||
|
APP_DIR=$(cd -P "$(dirname "$SOURCE")" >/dev/null 2>&1 && pwd)
|
||||||
|
|
||||||
|
VENV_DIR="$APP_DIR/venv"
|
||||||
|
SERVICE_NAME="paperwall.service"
|
||||||
|
SERVICE_TARGET="/etc/systemd/system/$SERVICE_NAME"
|
||||||
|
CONFIG_TXT="/boot/firmware/config.txt"
|
||||||
|
[ -f "$CONFIG_TXT" ] || CONFIG_TXT="/boot/config.txt"
|
||||||
|
|
||||||
|
step() { echo -e "\n==> $1"; }
|
||||||
|
|
||||||
|
check_permissions() {
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
echo "ERROR: run with sudo (sudo bash install.sh)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
install_system_dependencies() {
|
||||||
|
step "Installing system dependencies (python3-venv, lgpio/spidev build deps)"
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq python3-venv python3-pip python3-dev liblgpio-dev swig
|
||||||
|
}
|
||||||
|
|
||||||
|
enable_spi_i2c() {
|
||||||
|
step "Enabling SPI/I2C in $CONFIG_TXT"
|
||||||
|
if [ ! -f "$CONFIG_TXT" ]; then
|
||||||
|
echo " WARNING: $CONFIG_TXT not found -- enable SPI/I2C manually for your platform." >&2
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -qE '^\s*dtparam=spi=on' "$CONFIG_TXT"; then
|
||||||
|
echo " SPI already enabled."
|
||||||
|
else
|
||||||
|
sed -i -E 's/^#?\s*dtparam=spi=.*/dtparam=spi=on/' "$CONFIG_TXT"
|
||||||
|
grep -qE '^\s*dtparam=spi=on' "$CONFIG_TXT" || echo "dtparam=spi=on" >> "$CONFIG_TXT"
|
||||||
|
echo " SPI enabled (reboot required)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# This panel uses both SPI chip-select lines.
|
||||||
|
if ! grep -qE '^\s*dtoverlay=spi0-2cs' "$CONFIG_TXT"; then
|
||||||
|
echo "dtoverlay=spi0-2cs" >> "$CONFIG_TXT"
|
||||||
|
echo " Added dtoverlay=spi0-2cs (reboot required)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -qE '^\s*dtparam=i2c_arm=on' "$CONFIG_TXT"; then
|
||||||
|
echo " I2C already enabled."
|
||||||
|
else
|
||||||
|
sed -i -E 's/^#?\s*dtparam=i2c_arm=.*/dtparam=i2c_arm=on/' "$CONFIG_TXT"
|
||||||
|
grep -qE '^\s*dtparam=i2c_arm=on' "$CONFIG_TXT" || echo "dtparam=i2c_arm=on" >> "$CONFIG_TXT"
|
||||||
|
echo " I2C enabled (reboot required)."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
create_venv() {
|
||||||
|
step "Creating Python virtual environment at $VENV_DIR"
|
||||||
|
python3 -m venv "$VENV_DIR"
|
||||||
|
"$VENV_DIR/bin/python" -m pip install --upgrade -q pip
|
||||||
|
"$VENV_DIR/bin/python" -m pip install -q -r "$APP_DIR/requirements.txt" -r "$APP_DIR/requirements-pi.txt"
|
||||||
|
echo " done."
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_conflicting_services() {
|
||||||
|
step "Checking for other software already driving this panel"
|
||||||
|
if systemctl list-unit-files inkypi.service >/dev/null 2>&1 && systemctl is-active --quiet inkypi.service; then
|
||||||
|
systemctl disable --now inkypi.service
|
||||||
|
echo " Stopped and disabled inkypi.service (left installed, not removed)."
|
||||||
|
else
|
||||||
|
echo " Nothing else running against the panel."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
install_service() {
|
||||||
|
step "Installing $SERVICE_NAME"
|
||||||
|
sed \
|
||||||
|
-e "s#{{APP_DIR}}#$APP_DIR#g" \
|
||||||
|
-e "s#{{PYTHON}}#$VENV_DIR/bin/python3#g" \
|
||||||
|
"$APP_DIR/paperwall.service.template" > "$SERVICE_TARGET"
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now "$SERVICE_NAME"
|
||||||
|
echo " installed and started."
|
||||||
|
}
|
||||||
|
|
||||||
|
show_status() {
|
||||||
|
step "Status"
|
||||||
|
sleep 2
|
||||||
|
systemctl status "$SERVICE_NAME" --no-pager -l | head -15 || true
|
||||||
|
ip_address=$(hostname -I | awk '{print $1}')
|
||||||
|
echo
|
||||||
|
echo "Web UI: http://$ip_address:8080 (or http://$(hostname).local:8080)"
|
||||||
|
echo "Logs: journalctl -u $SERVICE_NAME -f"
|
||||||
|
echo "Remove: sudo bash uninstall.sh"
|
||||||
|
if grep -qE '^\s*dtoverlay=spi0-2cs' "$CONFIG_TXT" 2>/dev/null; then
|
||||||
|
echo
|
||||||
|
echo "NOTE: if SPI/I2C were just newly enabled above, reboot for that to take effect:"
|
||||||
|
echo " sudo reboot"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_permissions
|
||||||
|
install_system_dependencies
|
||||||
|
enable_spi_i2c
|
||||||
|
create_venv
|
||||||
|
stop_conflicting_services
|
||||||
|
install_service
|
||||||
|
show_status
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Entrypoint: initializes the display, config, and screens, starts the
|
||||||
|
scheduler in the background, and serves the web UI in the foreground.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 main.py # real Waveshare epd4in0e hardware
|
||||||
|
python3 main.py --mock # save frames as PNGs instead (dev off the Pi)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from config import Config
|
||||||
|
from scheduler import Scheduler
|
||||||
|
from screens import create_screens
|
||||||
|
from web.app import create_app
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def build_display(mock: bool, mock_resolution: tuple[int, int]):
|
||||||
|
if mock:
|
||||||
|
from display.mock_driver import MockDisplay
|
||||||
|
|
||||||
|
return MockDisplay(resolution=mock_resolution)
|
||||||
|
|
||||||
|
from display.epd4in0e_driver import EPD4in0eDisplay
|
||||||
|
|
||||||
|
return EPD4in0eDisplay()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--mock", action="store_true", help="Use the mock display driver instead of real hardware")
|
||||||
|
parser.add_argument("--mock-width", type=int, default=600)
|
||||||
|
parser.add_argument("--mock-height", type=int, default=400)
|
||||||
|
parser.add_argument("--host", default="0.0.0.0")
|
||||||
|
parser.add_argument("--port", type=int, default=8080)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
display = build_display(args.mock, (args.mock_width, args.mock_height))
|
||||||
|
display.init()
|
||||||
|
|
||||||
|
screens = create_screens()
|
||||||
|
config = Config(screens)
|
||||||
|
scheduler = Scheduler(config, screens, display)
|
||||||
|
scheduler.start()
|
||||||
|
|
||||||
|
app = create_app(config, screens, scheduler)
|
||||||
|
|
||||||
|
try:
|
||||||
|
app.run(host=args.host, port=args.port)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
logger.info("Shutting down")
|
||||||
|
scheduler.stop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Paperwall (Navidrome / Clock / Weather e-ink dashboard)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
# Filled in by install.sh to point at this project's own self-contained venv.
|
||||||
|
ExecStart={{PYTHON}} {{APP_DIR}}/main.py --host 0.0.0.0 --port 8080
|
||||||
|
WorkingDirectory={{APP_DIR}}
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
User=root
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Additional dependencies needed only on the real Raspberry Pi, for the
|
||||||
|
# epdconfig.py driver to talk to the panel over SPI/GPIO. Not needed for
|
||||||
|
# local development with --mock (and RPi.GPIO/lgpio generally won't even
|
||||||
|
# build off a Pi), so these are kept out of requirements.txt.
|
||||||
|
gpiozero==2.0.1
|
||||||
|
lgpio==0.2.2.0
|
||||||
|
RPi.GPIO==0.7.1
|
||||||
|
spidev>=3.6
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
Flask>=3.0
|
||||||
|
Pillow>=10.0
|
||||||
|
requests>=2.31
|
||||||
+192
@@ -0,0 +1,192 @@
|
|||||||
|
"""Decides what should be on screen right now, renders it, and only pushes to
|
||||||
|
the panel when the rendered image actually changed.
|
||||||
|
|
||||||
|
Design goals (in reaction to the InkyPi issues that motivated this rewrite):
|
||||||
|
- One short tick (a few seconds), not a long sleep-until-interval -- config
|
||||||
|
changes and manual actions take effect almost immediately, no signaling
|
||||||
|
machinery needed.
|
||||||
|
- Navidrome can preempt the rotation the instant something's playing,
|
||||||
|
without needing to be "its turn" in a round-robin.
|
||||||
|
- A screen is only ever fully re-rendered+pushed to hardware when its
|
||||||
|
rendered image differs from what's currently displayed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
TICK_SECONDS = 5
|
||||||
|
PREVIEW_PATH = Path(__file__).resolve().parent / "web" / "static" / "preview.png"
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenCache:
|
||||||
|
"""Remembers a screen's last check() result so we don't re-poll faster
|
||||||
|
than its own poll_interval, even if the rotation/tick cadence is tighter."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.state = None
|
||||||
|
self.checked_at: float = 0.0
|
||||||
|
|
||||||
|
def is_stale(self, poll_interval: int) -> bool:
|
||||||
|
return (time.time() - self.checked_at) >= poll_interval
|
||||||
|
|
||||||
|
|
||||||
|
class Scheduler:
|
||||||
|
def __init__(self, config, screens: dict, display):
|
||||||
|
self.config = config
|
||||||
|
self.screens = screens
|
||||||
|
self.display = display
|
||||||
|
|
||||||
|
self._caches = {screen_id: ScreenCache() for screen_id in screens}
|
||||||
|
self._current_screen_id: str | None = None
|
||||||
|
self._rotation_index = 0
|
||||||
|
self._rotation_switched_at = time.time()
|
||||||
|
self._last_image_hash: str | None = None
|
||||||
|
|
||||||
|
self._manual_screen: str | None = None
|
||||||
|
self._force_once = False
|
||||||
|
|
||||||
|
self._wake = threading.Event()
|
||||||
|
self._running = False
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
self._status_lock = threading.Lock()
|
||||||
|
self._status = {"current_screen": None, "last_render_at": None, "last_display_write_at": None}
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- public
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
if self._thread and self._thread.is_alive():
|
||||||
|
return
|
||||||
|
self._running = True
|
||||||
|
self._thread = threading.Thread(target=self._run, daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
logger.info("Scheduler started")
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._running = False
|
||||||
|
self._wake.set()
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join()
|
||||||
|
|
||||||
|
def request_manual_refresh(self):
|
||||||
|
self._force_once = True
|
||||||
|
self._wake.set()
|
||||||
|
|
||||||
|
def request_switch(self, screen_id: str):
|
||||||
|
self._manual_screen = screen_id
|
||||||
|
self._wake.set()
|
||||||
|
|
||||||
|
def get_status(self) -> dict:
|
||||||
|
with self._status_lock:
|
||||||
|
return dict(self._status)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- internal
|
||||||
|
|
||||||
|
def _run(self):
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
self._tick()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Scheduler tick failed")
|
||||||
|
self._wake.wait(timeout=TICK_SECONDS)
|
||||||
|
self._wake.clear()
|
||||||
|
|
||||||
|
def _tick(self):
|
||||||
|
did_check = False
|
||||||
|
|
||||||
|
if self._manual_screen:
|
||||||
|
screen_id = self._manual_screen
|
||||||
|
self._manual_screen = None
|
||||||
|
state = self._check(screen_id)
|
||||||
|
did_check = True
|
||||||
|
else:
|
||||||
|
screen_id, state, checked = self._select_screen()
|
||||||
|
did_check = checked
|
||||||
|
|
||||||
|
self._current_screen_id = screen_id
|
||||||
|
screen = self.screens[screen_id]
|
||||||
|
settings = self.config.get_screen_settings(screen_id)
|
||||||
|
resolution = self.display.resolution
|
||||||
|
|
||||||
|
image = screen.render(state, settings, resolution)
|
||||||
|
image_hash = hashlib.sha256(image.tobytes()).hexdigest()
|
||||||
|
|
||||||
|
self._save_preview(image)
|
||||||
|
|
||||||
|
with self._status_lock:
|
||||||
|
self._status["current_screen"] = screen_id
|
||||||
|
self._status["last_render_at"] = time.time()
|
||||||
|
|
||||||
|
if image_hash != self._last_image_hash or self._force_once:
|
||||||
|
logger.info("Content changed (or forced) -- writing to display. screen=%s", screen_id)
|
||||||
|
self.display.show(image)
|
||||||
|
self.display.sleep()
|
||||||
|
self._last_image_hash = image_hash
|
||||||
|
self._force_once = False
|
||||||
|
with self._status_lock:
|
||||||
|
self._status["last_display_write_at"] = time.time()
|
||||||
|
else:
|
||||||
|
logger.debug("No change for screen=%s, skipping display write", screen_id)
|
||||||
|
|
||||||
|
if did_check:
|
||||||
|
self.config.save()
|
||||||
|
|
||||||
|
def _select_screen(self) -> tuple[str, object, bool]:
|
||||||
|
"""Returns (screen_id, state, did_check_happen)."""
|
||||||
|
if self.config.get_navidrome_priority() and "navidrome" in self.screens:
|
||||||
|
cache = self._caches["navidrome"]
|
||||||
|
navidrome = self.screens["navidrome"]
|
||||||
|
checked = False
|
||||||
|
if cache.is_stale(navidrome.poll_interval):
|
||||||
|
cache.state = self._check("navidrome")
|
||||||
|
cache.checked_at = time.time()
|
||||||
|
checked = True
|
||||||
|
if cache.state is not None:
|
||||||
|
return "navidrome", cache.state, checked
|
||||||
|
|
||||||
|
return self._rotation_screen()
|
||||||
|
|
||||||
|
def _rotation_screen(self) -> tuple[str, object, bool]:
|
||||||
|
rotation = self.config.get_rotation()
|
||||||
|
screen_ids = [s for s in rotation["screens"] if s in self.screens]
|
||||||
|
if not screen_ids:
|
||||||
|
# Nothing configured to rotate through -- fall back to whatever
|
||||||
|
# exists so we're never stuck showing nothing.
|
||||||
|
screen_ids = list(self.screens.keys())
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
if now - self._rotation_switched_at >= rotation["interval_seconds"]:
|
||||||
|
self._rotation_index = (self._rotation_index + 1) % len(screen_ids)
|
||||||
|
self._rotation_switched_at = now
|
||||||
|
|
||||||
|
screen_id = screen_ids[self._rotation_index % len(screen_ids)]
|
||||||
|
cache = self._caches[screen_id]
|
||||||
|
screen = self.screens[screen_id]
|
||||||
|
checked = False
|
||||||
|
if cache.is_stale(screen.poll_interval):
|
||||||
|
cache.state = self._check(screen_id)
|
||||||
|
cache.checked_at = time.time()
|
||||||
|
checked = True
|
||||||
|
return screen_id, cache.state, checked
|
||||||
|
|
||||||
|
def _check(self, screen_id: str):
|
||||||
|
screen = self.screens[screen_id]
|
||||||
|
settings = self.config.get_screen_settings(screen_id)
|
||||||
|
try:
|
||||||
|
return screen.check(settings)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("check() failed for screen=%s", screen_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _save_preview(self, image: Image.Image):
|
||||||
|
try:
|
||||||
|
PREVIEW_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
image.save(PREVIEW_PATH)
|
||||||
|
except OSError:
|
||||||
|
logger.exception("Could not write preview image")
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from screens.clock import ClockScreen
|
||||||
|
from screens.navidrome import NavidromeScreen
|
||||||
|
from screens.weather import WeatherScreen
|
||||||
|
|
||||||
|
SCREEN_CLASSES = {
|
||||||
|
ClockScreen.id: ClockScreen,
|
||||||
|
WeatherScreen.id: WeatherScreen,
|
||||||
|
NavidromeScreen.id: NavidromeScreen,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_screens() -> dict:
|
||||||
|
return {screen_id: cls() for screen_id, cls in SCREEN_CLASSES.items()}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Base interface every screen implements."""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
class Screen(ABC):
|
||||||
|
id: str
|
||||||
|
display_name: str
|
||||||
|
poll_interval: int = 60 # seconds between check() calls
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def check(self, settings: dict) -> Any:
|
||||||
|
"""Cheap call (ideally a single small request) that returns whatever
|
||||||
|
state this screen needs to render. Returning None means "nothing to
|
||||||
|
show" (only meaningful for preemptive screens like Navidrome)."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def render(self, state: Any, settings: dict, resolution: tuple[int, int]) -> Image.Image:
|
||||||
|
"""Full draw. Only called when this screen is actually about to be
|
||||||
|
shown, so it's fine for this to be more expensive than check()."""
|
||||||
|
|
||||||
|
def default_settings(self) -> dict:
|
||||||
|
"""Settings this screen needs, with defaults, merged under config.json's
|
||||||
|
settings[self.id] on first run."""
|
||||||
|
return {}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Simple full-screen clock."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
from screens.base import Screen
|
||||||
|
from screens.render_utils import load_font, text_width
|
||||||
|
|
||||||
|
LINE_GAP = 0.04 # fraction of min(width, height) between the time and date lines
|
||||||
|
|
||||||
|
WEEKDAYS_DE = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
|
||||||
|
MONTHS_DE = [
|
||||||
|
"Januar", "Februar", "März", "April", "Mai", "Juni",
|
||||||
|
"Juli", "August", "September", "Oktober", "November", "Dezember",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ClockScreen(Screen):
|
||||||
|
id = "clock"
|
||||||
|
display_name = "Clock"
|
||||||
|
poll_interval = 30
|
||||||
|
|
||||||
|
def check(self, settings: dict):
|
||||||
|
return time.strftime("%H:%M")
|
||||||
|
|
||||||
|
def render(self, state, settings: dict, resolution: tuple[int, int]) -> Image.Image:
|
||||||
|
width, height = resolution
|
||||||
|
image = Image.new("RGB", (width, height), "white")
|
||||||
|
draw = ImageDraw.Draw(image)
|
||||||
|
|
||||||
|
now = time.localtime()
|
||||||
|
time_str = state or time.strftime("%H:%M", now)
|
||||||
|
date_str = f"{WEEKDAYS_DE[now.tm_wday]}, {now.tm_mday}. {MONTHS_DE[now.tm_mon - 1]}"
|
||||||
|
|
||||||
|
scale = min(width, height)
|
||||||
|
time_font = load_font(size=int(scale * 0.32), bold=True)
|
||||||
|
date_font = load_font(size=int(scale * 0.06))
|
||||||
|
gap = int(scale * LINE_GAP)
|
||||||
|
|
||||||
|
time_left, time_top, time_right, time_bottom = draw.textbbox((0, 0), time_str, font=time_font)
|
||||||
|
time_h = time_bottom - time_top
|
||||||
|
date_left, date_top, date_right, date_bottom = draw.textbbox((0, 0), date_str, font=date_font)
|
||||||
|
date_h = date_bottom - date_top
|
||||||
|
|
||||||
|
block_h = time_h + gap + date_h
|
||||||
|
block_top = (height - block_h) / 2
|
||||||
|
|
||||||
|
tw = time_right - time_left
|
||||||
|
draw.text(((width - tw) / 2, block_top - time_top), time_str, font=time_font, fill="black")
|
||||||
|
|
||||||
|
dw = date_right - date_left
|
||||||
|
draw.text(((width - dw) / 2, block_top + time_h + gap - date_top), date_str, font=date_font, fill=(90, 90, 90))
|
||||||
|
|
||||||
|
return image
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
"""Navidrome "now playing" screen -- ported from the InkyPi plugin.
|
||||||
|
|
||||||
|
Same Subsonic API logic as before (salted-MD5 auth, filter on state=="playing"
|
||||||
|
rather than the unreliable minutesAgo, live positionMs for elapsed time), now
|
||||||
|
rendering directly with Pillow instead of HTML/CSS + headless Chromium.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from PIL import Image, ImageDraw, ImageEnhance
|
||||||
|
|
||||||
|
from screens.base import Screen
|
||||||
|
from screens.render_utils import load_font, text_width, truncate_to_width, wrap_to_lines
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SUBSONIC_API_VERSION = "1.16.1"
|
||||||
|
CLIENT_NAME = "Paperwall"
|
||||||
|
HTTP_TIMEOUT = 10
|
||||||
|
|
||||||
|
REMEMBERED_FIELDS = (
|
||||||
|
"id", "title", "artist", "album", "year", "genre",
|
||||||
|
"coverArt", "duration", "starred", "track", "username", "playerName",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NavidromeScreen(Screen):
|
||||||
|
id = "navidrome"
|
||||||
|
display_name = "Navidrome Now Playing"
|
||||||
|
poll_interval = 20
|
||||||
|
|
||||||
|
def default_settings(self) -> dict:
|
||||||
|
return {
|
||||||
|
"url": "",
|
||||||
|
"username": "",
|
||||||
|
"password": "",
|
||||||
|
"filter_user": "",
|
||||||
|
"show_progress": False,
|
||||||
|
"show_genre": True,
|
||||||
|
"show_source": True,
|
||||||
|
"boost_cover": True,
|
||||||
|
"grayscale_cover": False,
|
||||||
|
"_last_track": None,
|
||||||
|
"_last_track_at": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- check
|
||||||
|
|
||||||
|
def check(self, settings: dict):
|
||||||
|
url = (settings.get("url") or "").strip().rstrip("/")
|
||||||
|
username = (settings.get("username") or "").strip()
|
||||||
|
password = settings.get("password") or ""
|
||||||
|
if not url or not username or not password:
|
||||||
|
return None
|
||||||
|
|
||||||
|
only_user = (settings.get("filter_user") or "").strip()
|
||||||
|
try:
|
||||||
|
entry = self._fetch_now_playing(url, username, password, only_user)
|
||||||
|
except requests.exceptions.RequestException as exc:
|
||||||
|
logger.error("Navidrome request failed: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if entry is not None:
|
||||||
|
settings["_last_track"] = {k: entry.get(k) for k in REMEMBERED_FIELDS}
|
||||||
|
settings["_last_track_at"] = time.time()
|
||||||
|
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def _auth_params(self, username: str, password: str) -> dict:
|
||||||
|
salt = "%08x" % random.getrandbits(32)
|
||||||
|
token = hashlib.md5((password + salt).encode("utf-8")).hexdigest()
|
||||||
|
return {"u": username, "t": token, "s": salt, "v": SUBSONIC_API_VERSION, "c": CLIENT_NAME, "f": "json"}
|
||||||
|
|
||||||
|
def _call(self, url: str, username: str, password: str, method: str, params: dict | None = None) -> dict:
|
||||||
|
query = self._auth_params(username, password)
|
||||||
|
query.update(params or {})
|
||||||
|
response = requests.get(f"{url}/rest/{method}", params=query, timeout=HTTP_TIMEOUT)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json().get("subsonic-response", {})
|
||||||
|
if payload.get("status") != "ok":
|
||||||
|
message = payload.get("error", {}).get("message", "unknown error")
|
||||||
|
raise RuntimeError(f"Navidrome API error on {method}: {message}")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def _fetch_now_playing(self, url: str, username: str, password: str, only_user: str):
|
||||||
|
"""Return the currently-playing entry, or None.
|
||||||
|
|
||||||
|
Navidrome's `minutesAgo` counts from when the session *started*, not
|
||||||
|
since the last heartbeat, so a track over 5 minutes long would look
|
||||||
|
"stale" under a naive staleness filter while still genuinely playing.
|
||||||
|
Filtering on `state == "playing"` instead is correct -- Navidrome
|
||||||
|
expires stopped sessions server-side already.
|
||||||
|
"""
|
||||||
|
payload = self._call(url, username, password, "getNowPlaying")
|
||||||
|
entries = payload.get("nowPlaying", {}).get("entry", []) or []
|
||||||
|
|
||||||
|
if only_user:
|
||||||
|
entries = [e for e in entries if e.get("username") == only_user]
|
||||||
|
|
||||||
|
entries = [e for e in entries if e.get("state") == "playing"]
|
||||||
|
if not entries:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return min(entries, key=lambda e: e.get("minutesAgo") or 0)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- render
|
||||||
|
|
||||||
|
def render(self, state, settings: dict, resolution: tuple[int, int]) -> Image.Image:
|
||||||
|
idle = state is None
|
||||||
|
entry = state if not idle else settings.get("_last_track")
|
||||||
|
|
||||||
|
width, height = resolution
|
||||||
|
image = Image.new("RGB", (width, height), "white")
|
||||||
|
draw = ImageDraw.Draw(image)
|
||||||
|
|
||||||
|
if entry is None:
|
||||||
|
self._render_empty(draw, width, height)
|
||||||
|
return image
|
||||||
|
|
||||||
|
track = self._build_track(entry, settings, idle)
|
||||||
|
cover = None
|
||||||
|
if entry.get("coverArt"):
|
||||||
|
cover = self._fetch_cover(settings, entry["coverArt"])
|
||||||
|
|
||||||
|
landscape = width > height
|
||||||
|
if landscape:
|
||||||
|
self._render_landscape(image, draw, track, cover, settings, idle, width, height)
|
||||||
|
else:
|
||||||
|
self._render_portrait(image, draw, track, cover, settings, idle, width, height)
|
||||||
|
|
||||||
|
return image
|
||||||
|
|
||||||
|
def _build_track(self, entry: dict, settings: dict, idle: bool) -> dict:
|
||||||
|
duration = int(entry.get("duration") or 0)
|
||||||
|
if idle:
|
||||||
|
elapsed = 0
|
||||||
|
since = time.time() - float(settings.get("_last_track_at") or time.time())
|
||||||
|
minutes_ago = (int(since // 60) // 5) * 5
|
||||||
|
else:
|
||||||
|
elapsed = int((entry.get("positionMs") or 0) / 1000)
|
||||||
|
elapsed = min(elapsed, duration) if duration else elapsed
|
||||||
|
minutes_ago = entry.get("minutesAgo") or 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": entry.get("title") or "Unknown title",
|
||||||
|
"artist": entry.get("artist") or "Unknown artist",
|
||||||
|
"album": entry.get("album") or "",
|
||||||
|
"year": entry.get("year"),
|
||||||
|
"genre": entry.get("genre"),
|
||||||
|
"starred": bool(entry.get("starred")),
|
||||||
|
"player": entry.get("playerName") or "",
|
||||||
|
"listener": entry.get("username") or "",
|
||||||
|
"minutes_ago": minutes_ago,
|
||||||
|
"duration": duration,
|
||||||
|
"duration_text": _format_time(duration),
|
||||||
|
"elapsed": elapsed,
|
||||||
|
"elapsed_text": _format_time(elapsed),
|
||||||
|
"progress_pct": (elapsed / duration) if duration else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _fetch_cover(self, settings: dict, cover_art_id: str):
|
||||||
|
url = (settings.get("url") or "").strip().rstrip("/")
|
||||||
|
username = (settings.get("username") or "").strip()
|
||||||
|
password = settings.get("password") or ""
|
||||||
|
query = self._auth_params(username, password)
|
||||||
|
query.update({"id": cover_art_id, "size": 600})
|
||||||
|
try:
|
||||||
|
response = requests.get(f"{url}/rest/getCoverArt", params=query, timeout=HTTP_TIMEOUT)
|
||||||
|
response.raise_for_status()
|
||||||
|
image = Image.open(BytesIO(response.content)).convert("RGB")
|
||||||
|
except (requests.exceptions.RequestException, OSError) as exc:
|
||||||
|
logger.warning("Could not load cover art %s: %s", cover_art_id, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if settings.get("boost_cover", True):
|
||||||
|
image = ImageEnhance.Contrast(image).enhance(1.35)
|
||||||
|
image = ImageEnhance.Brightness(image).enhance(1.05)
|
||||||
|
|
||||||
|
if settings.get("grayscale_cover", False):
|
||||||
|
image = image.convert("L").convert("RGB")
|
||||||
|
|
||||||
|
return image
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- layouts
|
||||||
|
|
||||||
|
def _render_empty(self, draw: ImageDraw.ImageDraw, width: int, height: int):
|
||||||
|
font = load_font(size=int(min(width, height) * 0.06))
|
||||||
|
msg = "Nichts läuft gerade"
|
||||||
|
mw = text_width(draw, msg, font)
|
||||||
|
draw.text(((width - mw) / 2, height / 2), msg, font=font, fill=(140, 140, 140))
|
||||||
|
|
||||||
|
def _render_landscape(self, image, draw, track, cover, settings, idle, width, height):
|
||||||
|
pad = int(width * 0.025)
|
||||||
|
# Cap at a fraction of width too, not just height -- a naive full-height
|
||||||
|
# square cover can eat almost the whole canvas on a wide/short panel and
|
||||||
|
# leave the text column too narrow to wrap sensibly.
|
||||||
|
cover_size = min(height - 2 * pad, int(width * 0.5))
|
||||||
|
cover_x, cover_y = pad, pad
|
||||||
|
|
||||||
|
self._paste_cover(image, cover, cover_x, cover_y, cover_size)
|
||||||
|
|
||||||
|
meta_x = cover_x + cover_size + int(width * 0.03)
|
||||||
|
meta_w = width - meta_x - pad
|
||||||
|
self._draw_meta(draw, track, settings, idle, meta_x, pad, meta_w, height - 2 * pad, min(width, height))
|
||||||
|
|
||||||
|
def _render_portrait(self, image, draw, track, cover, settings, idle, width, height):
|
||||||
|
pad = int(width * 0.06)
|
||||||
|
cover_size = width - 2 * pad
|
||||||
|
cover_x, cover_y = pad, pad
|
||||||
|
|
||||||
|
self._paste_cover(image, cover, cover_x, cover_y, cover_size)
|
||||||
|
|
||||||
|
meta_y = cover_y + cover_size + int(height * 0.03)
|
||||||
|
meta_h = height - meta_y - pad
|
||||||
|
self._draw_meta(
|
||||||
|
draw, track, settings, idle, pad, meta_y, width - 2 * pad, meta_h, min(width, height), center=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def _paste_cover(self, image, cover, x, y, size):
|
||||||
|
if cover is not None:
|
||||||
|
fitted = _cover_fit(cover, size)
|
||||||
|
image.paste(fitted, (x, y))
|
||||||
|
else:
|
||||||
|
draw = ImageDraw.Draw(image)
|
||||||
|
draw.rectangle((x, y, x + size, y + size), outline=(60, 60, 60), width=2)
|
||||||
|
font = load_font(size=int(size * 0.3))
|
||||||
|
note = "♪"
|
||||||
|
nw = text_width(draw, note, font)
|
||||||
|
draw.text((x + (size - nw) / 2, y + size * 0.35), note, font=font, fill=(150, 150, 150))
|
||||||
|
|
||||||
|
def _draw_meta(self, draw, track, settings, idle, x, y, w, h, scale, center=False):
|
||||||
|
status_font = load_font(size=int(scale * 0.038), bold=True)
|
||||||
|
title_font = load_font(size=int(scale * 0.075), bold=True)
|
||||||
|
artist_font = load_font(size=int(scale * 0.05))
|
||||||
|
album_font = load_font(size=int(scale * 0.036))
|
||||||
|
source_font = load_font(size=int(scale * 0.03))
|
||||||
|
|
||||||
|
def centered_x(text, font):
|
||||||
|
tw = text_width(draw, text, font)
|
||||||
|
return x + (w - tw) / 2 if center else x
|
||||||
|
|
||||||
|
cursor = y
|
||||||
|
|
||||||
|
status = "ZULETZT GESPIELT" if idle else "● NOW PLAYING"
|
||||||
|
draw.text((centered_x(status, status_font), cursor), status, font=status_font, fill=(100, 100, 100))
|
||||||
|
cursor += status_font.size * 1.8
|
||||||
|
|
||||||
|
title = track["title"] + (" ★" if track["starred"] else "")
|
||||||
|
for line in wrap_to_lines(draw, title, title_font, w, max_lines=2):
|
||||||
|
draw.text((centered_x(line, title_font), cursor), line, font=title_font, fill="black")
|
||||||
|
cursor += title_font.size * 1.15
|
||||||
|
|
||||||
|
cursor += title_font.size * 0.2
|
||||||
|
artist_line = truncate_to_width(draw, track["artist"], artist_font, w)
|
||||||
|
draw.text((centered_x(artist_line, artist_font), cursor), artist_line, font=artist_font, fill=(30, 30, 30))
|
||||||
|
cursor += artist_font.size * 1.4
|
||||||
|
|
||||||
|
if track["album"]:
|
||||||
|
album_line = track["album"]
|
||||||
|
if track["year"]:
|
||||||
|
album_line += f" · {track['year']}"
|
||||||
|
album_line = truncate_to_width(draw, album_line, album_font, w)
|
||||||
|
draw.text((centered_x(album_line, album_font), cursor), album_line, font=album_font, fill=(110, 110, 110))
|
||||||
|
cursor += album_font.size * 1.4
|
||||||
|
|
||||||
|
if settings.get("show_genre", True) and track["genre"]:
|
||||||
|
genre_line = truncate_to_width(draw, track["genre"].upper(), album_font, w)
|
||||||
|
draw.text((centered_x(genre_line, album_font), cursor), genre_line, font=album_font, fill=(150, 150, 150))
|
||||||
|
cursor += album_font.size * 1.4
|
||||||
|
|
||||||
|
if settings.get("show_progress", False) and not idle and track["duration"]:
|
||||||
|
bar_y = cursor + status_font.size * 0.3
|
||||||
|
bar_h = max(3, int(scale * 0.012))
|
||||||
|
draw.rectangle((x, bar_y, x + w, bar_y + bar_h), outline=(60, 60, 60))
|
||||||
|
fill_w = int(w * min(1.0, track["progress_pct"]))
|
||||||
|
if fill_w > 0:
|
||||||
|
draw.rectangle((x, bar_y, x + fill_w, bar_y + bar_h), fill=(60, 60, 60))
|
||||||
|
cursor = bar_y + bar_h + status_font.size * 0.4
|
||||||
|
times = f"{track['elapsed_text']} / {track['duration_text']}"
|
||||||
|
draw.text((centered_x(times, source_font), cursor), times, font=source_font, fill=(110, 110, 110))
|
||||||
|
cursor += source_font.size * 1.6
|
||||||
|
elif track["duration"]:
|
||||||
|
duration_badge = track["duration_text"]
|
||||||
|
draw.text((centered_x(duration_badge, source_font), cursor), duration_badge, font=source_font, fill=(110, 110, 110))
|
||||||
|
cursor += source_font.size * 1.6
|
||||||
|
|
||||||
|
if settings.get("show_source", True):
|
||||||
|
if idle:
|
||||||
|
source_line = f"vor {track['minutes_ago']} min"
|
||||||
|
else:
|
||||||
|
parts = [p for p in (track["player"], track["listener"]) if p]
|
||||||
|
source_line = " · ".join(parts)
|
||||||
|
if source_line:
|
||||||
|
source_line = truncate_to_width(draw, source_line, source_font, w)
|
||||||
|
# Prefer pinning to the bottom of the available area, but never
|
||||||
|
# overlap whatever the cursor already drew above it -- long
|
||||||
|
# titles/metadata can push past where a fixed bottom position
|
||||||
|
# would otherwise land.
|
||||||
|
bottom_y = max(cursor + status_font.size * 0.4, y + h - source_font.size * 1.3)
|
||||||
|
draw.text((centered_x(source_line, source_font), bottom_y), source_line, font=source_font, fill=(140, 140, 140))
|
||||||
|
|
||||||
|
|
||||||
|
def _cover_fit(image: Image.Image, size: int) -> Image.Image:
|
||||||
|
"""Center-crop to a square, then resize -- mirrors the CSS object-fit: cover behavior."""
|
||||||
|
w, h = image.size
|
||||||
|
side = min(w, h)
|
||||||
|
left = (w - side) // 2
|
||||||
|
top = (h - side) // 2
|
||||||
|
cropped = image.crop((left, top, left + side, top + side))
|
||||||
|
return cropped.resize((size, size), Image.LANCZOS)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_time(seconds) -> str:
|
||||||
|
seconds = int(seconds or 0)
|
||||||
|
return f"{seconds // 60}:{seconds % 60:02d}"
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Shared PIL rendering helpers: font loading and text layout.
|
||||||
|
|
||||||
|
The panel driver dithers whatever RGB image we hand it down to its 6-color
|
||||||
|
palette (see epd4in0e.getbuffer, which quantizes with Floyd-Steinberg
|
||||||
|
dithering by default) -- so screens can draw with normal anti-aliased RGB
|
||||||
|
text and full-color cover art without worrying about the palette themselves.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import ImageFont
|
||||||
|
|
||||||
|
FONTS_DIR = Path(__file__).resolve().parent.parent / "fonts"
|
||||||
|
|
||||||
|
_font_cache: dict[tuple[str, int], ImageFont.FreeTypeFont] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def load_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
|
||||||
|
key = ("bold" if bold else "regular", size)
|
||||||
|
if key not in _font_cache:
|
||||||
|
filename = "DejaVuSans-Bold.ttf" if bold else "DejaVuSans.ttf"
|
||||||
|
_font_cache[key] = ImageFont.truetype(str(FONTS_DIR / filename), size)
|
||||||
|
return _font_cache[key]
|
||||||
|
|
||||||
|
|
||||||
|
def text_width(draw, text: str, font: ImageFont.FreeTypeFont) -> int:
|
||||||
|
left, _, right, _ = draw.textbbox((0, 0), text, font=font)
|
||||||
|
return right - left
|
||||||
|
|
||||||
|
|
||||||
|
def truncate_to_width(draw, text: str, font: ImageFont.FreeTypeFont, max_width: int) -> str:
|
||||||
|
"""Single-line ellipsis truncation, mirroring the CSS text-overflow: ellipsis look."""
|
||||||
|
if text_width(draw, text, font) <= max_width:
|
||||||
|
return text
|
||||||
|
|
||||||
|
ellipsis = "…"
|
||||||
|
lo, hi = 0, len(text)
|
||||||
|
while lo < hi:
|
||||||
|
mid = (lo + hi + 1) // 2
|
||||||
|
candidate = text[:mid].rstrip() + ellipsis
|
||||||
|
if text_width(draw, candidate, font) <= max_width:
|
||||||
|
lo = mid
|
||||||
|
else:
|
||||||
|
hi = mid - 1
|
||||||
|
return text[:lo].rstrip() + ellipsis if lo else ellipsis
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_to_lines(draw, text: str, font: ImageFont.FreeTypeFont, max_width: int, max_lines: int) -> list[str]:
|
||||||
|
"""Word-wrap up to `max_lines`, ellipsis-truncating the last line if it still
|
||||||
|
overflows -- mirrors the CSS -webkit-line-clamp behavior used in the old design."""
|
||||||
|
words = text.split()
|
||||||
|
if not words:
|
||||||
|
return []
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
current = words[0]
|
||||||
|
next_index = 1
|
||||||
|
while next_index < len(words):
|
||||||
|
word = words[next_index]
|
||||||
|
candidate = f"{current} {word}"
|
||||||
|
if text_width(draw, candidate, font) <= max_width:
|
||||||
|
current = candidate
|
||||||
|
next_index += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
lines.append(current)
|
||||||
|
current = word
|
||||||
|
next_index += 1
|
||||||
|
if len(lines) == max_lines - 1:
|
||||||
|
break
|
||||||
|
|
||||||
|
if next_index < len(words):
|
||||||
|
# Ran out of lines with words still remaining -- fold the rest into
|
||||||
|
# the last line and ellipsis-truncate it.
|
||||||
|
current = current + " " + " ".join(words[next_index:])
|
||||||
|
current = truncate_to_width(draw, current, font, max_width)
|
||||||
|
|
||||||
|
lines.append(current)
|
||||||
|
return lines[:max_lines]
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""Current weather via Open-Meteo (free, no API key -- just a lat/long)."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
from screens.base import Screen
|
||||||
|
from screens.render_utils import load_font, text_width
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
HTTP_TIMEOUT = 10
|
||||||
|
|
||||||
|
# WMO weather codes -> (German description, symbol). Not exhaustive, covers
|
||||||
|
# the common cases; unmapped codes fall back to a generic description.
|
||||||
|
WMO_CODES = {
|
||||||
|
0: ("Klarer Himmel", "☀"),
|
||||||
|
1: ("Überwiegend klar", "☀"),
|
||||||
|
2: ("Teilweise bewölkt", "⛅"),
|
||||||
|
3: ("Bedeckt", "☁"),
|
||||||
|
45: ("Nebel", "☁"),
|
||||||
|
48: ("Nebel", "☁"),
|
||||||
|
51: ("Leichter Nieselregen", "☂"),
|
||||||
|
53: ("Nieselregen", "☂"),
|
||||||
|
55: ("Starker Nieselregen", "☂"),
|
||||||
|
61: ("Leichter Regen", "☔"),
|
||||||
|
63: ("Regen", "☔"),
|
||||||
|
65: ("Starker Regen", "☔"),
|
||||||
|
71: ("Leichter Schneefall", "❄"),
|
||||||
|
73: ("Schneefall", "❄"),
|
||||||
|
75: ("Starker Schneefall", "❄"),
|
||||||
|
80: ("Regenschauer", "☔"),
|
||||||
|
81: ("Regenschauer", "☔"),
|
||||||
|
82: ("Heftige Regenschauer", "☔"),
|
||||||
|
85: ("Schneeschauer", "❄"),
|
||||||
|
86: ("Schneeschauer", "❄"),
|
||||||
|
95: ("Gewitter", "⚡"),
|
||||||
|
96: ("Gewitter mit Hagel", "⚡"),
|
||||||
|
99: ("Gewitter mit Hagel", "⚡"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class WeatherScreen(Screen):
|
||||||
|
id = "weather"
|
||||||
|
display_name = "Weather"
|
||||||
|
poll_interval = 1800 # 30 minutes -- weather doesn't need to be fresher
|
||||||
|
|
||||||
|
def default_settings(self) -> dict:
|
||||||
|
return {"latitude": None, "longitude": None}
|
||||||
|
|
||||||
|
def check(self, settings: dict):
|
||||||
|
lat = settings.get("latitude")
|
||||||
|
lon = settings.get("longitude")
|
||||||
|
if lat is None or lon is None:
|
||||||
|
logger.warning("Weather screen has no latitude/longitude configured")
|
||||||
|
return None
|
||||||
|
|
||||||
|
response = requests.get(
|
||||||
|
"https://api.open-meteo.com/v1/forecast",
|
||||||
|
params={"latitude": lat, "longitude": lon, "current_weather": "true"},
|
||||||
|
timeout=HTTP_TIMEOUT,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
current = response.json().get("current_weather") or {}
|
||||||
|
if "temperature" not in current:
|
||||||
|
return None
|
||||||
|
|
||||||
|
code = int(current.get("weathercode", -1))
|
||||||
|
description, symbol = WMO_CODES.get(code, ("Unbekannt", "?"))
|
||||||
|
return {
|
||||||
|
"temperature": current["temperature"],
|
||||||
|
"windspeed": current.get("windspeed"),
|
||||||
|
"description": description,
|
||||||
|
"symbol": symbol,
|
||||||
|
}
|
||||||
|
|
||||||
|
def render(self, state, settings: dict, resolution: tuple[int, int]) -> Image.Image:
|
||||||
|
width, height = resolution
|
||||||
|
image = Image.new("RGB", (width, height), "white")
|
||||||
|
draw = ImageDraw.Draw(image)
|
||||||
|
|
||||||
|
if not state:
|
||||||
|
font = load_font(size=int(min(width, height) * 0.06))
|
||||||
|
msg = "Kein Standort konfiguriert" if not (
|
||||||
|
settings.get("latitude") and settings.get("longitude")
|
||||||
|
) else "Wetterdaten nicht verfügbar"
|
||||||
|
mw = text_width(draw, msg, font)
|
||||||
|
draw.text(((width - mw) / 2, height / 2), msg, font=font, fill=(120, 120, 120))
|
||||||
|
return image
|
||||||
|
|
||||||
|
symbol_font = load_font(size=int(min(width, height) * 0.28))
|
||||||
|
temp_font = load_font(size=int(min(width, height) * 0.22), bold=True)
|
||||||
|
desc_font = load_font(size=int(min(width, height) * 0.055))
|
||||||
|
|
||||||
|
symbol = state["symbol"]
|
||||||
|
sw = text_width(draw, symbol, symbol_font)
|
||||||
|
draw.text(((width - sw) / 2, height * 0.14), symbol, font=symbol_font, fill="black")
|
||||||
|
|
||||||
|
temp_str = f"{round(state['temperature'])}°"
|
||||||
|
tw = text_width(draw, temp_str, temp_font)
|
||||||
|
draw.text(((width - tw) / 2, height * 0.46), temp_str, font=temp_font, fill="black")
|
||||||
|
|
||||||
|
desc = state["description"]
|
||||||
|
dw = text_width(draw, desc, desc_font)
|
||||||
|
draw.text(((width - dw) / 2, height * 0.68), desc, font=desc_font, fill=(90, 90, 90))
|
||||||
|
|
||||||
|
return image
|
||||||
Executable
+43
@@ -0,0 +1,43 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Removes what install.sh set up: the systemd service and the venv it
|
||||||
|
# created. The project folder itself (whatever you SFTP'd over) and any
|
||||||
|
# config.json with your settings are left alone -- delete manually if you
|
||||||
|
# want those gone too.
|
||||||
|
#
|
||||||
|
# Does NOT touch SPI/I2C config.txt settings or re-install/re-enable
|
||||||
|
# anything else -- Paperwall doesn't depend on or manage other software.
|
||||||
|
#
|
||||||
|
# Usage: sudo bash uninstall.sh
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
echo "Run as root (sudo bash uninstall.sh)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SOURCE="${BASH_SOURCE[0]}"
|
||||||
|
while [ -h "$SOURCE" ]; do
|
||||||
|
DIR=$(cd -P "$(dirname "$SOURCE")" >/dev/null 2>&1 && pwd)
|
||||||
|
SOURCE=$(readlink "$SOURCE")
|
||||||
|
[[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE"
|
||||||
|
done
|
||||||
|
APP_DIR=$(cd -P "$(dirname "$SOURCE")" >/dev/null 2>&1 && pwd)
|
||||||
|
|
||||||
|
SERVICE_NAME="paperwall.service"
|
||||||
|
|
||||||
|
echo "==> Stopping and disabling $SERVICE_NAME"
|
||||||
|
systemctl disable --now "$SERVICE_NAME" 2>/dev/null || echo " (not running/installed, skipping)"
|
||||||
|
|
||||||
|
echo "==> Removing systemd unit"
|
||||||
|
rm -f "/etc/systemd/system/$SERVICE_NAME"
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
echo "==> Removing virtual environment ($APP_DIR/venv)"
|
||||||
|
rm -rf "$APP_DIR/venv"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==> Paperwall service and venv removed."
|
||||||
|
echo " Project files at $APP_DIR are untouched -- delete the folder yourself if you want it fully gone."
|
||||||
|
echo " If InkyPi was stopped by install.sh and you want it back:"
|
||||||
|
echo " sudo systemctl enable --now inkypi.service"
|
||||||
Executable
+67
@@ -0,0 +1,67 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Reverses what InkyPi's install.sh did, based on reading that script
|
||||||
|
# directly (fatihak/InkyPi/install/install.sh). Run this ON the Pi, as root.
|
||||||
|
#
|
||||||
|
# Paperwall is fully standalone -- it vendors its own copy of the Waveshare
|
||||||
|
# driver files and has its own venv, so it doesn't need InkyPi installed at
|
||||||
|
# all. This script is purely optional cleanup for whenever you're done with
|
||||||
|
# InkyPi as a fallback.
|
||||||
|
#
|
||||||
|
# Deliberately leaves alone:
|
||||||
|
# - SPI/I2C being enabled in /boot/firmware/config.txt -- Paperwall
|
||||||
|
# needs those same interfaces for the same panel. Disabling them
|
||||||
|
# would break it too.
|
||||||
|
# - zramswap / earlyoom services -- these are general Pi memory-management
|
||||||
|
# helpers install.sh set up (Bookworm zramswap + earlyoom), not InkyPi
|
||||||
|
# app state. Generically useful to keep running. Uncomment below if you
|
||||||
|
# really want them gone.
|
||||||
|
# - The git clone at ~/InkyPi -- not needed by Paperwall for anything, just
|
||||||
|
# left in place in case you want InkyPi back someday. Delete manually or
|
||||||
|
# pass --purge-clone.
|
||||||
|
#
|
||||||
|
# Usage: sudo bash uninstall_inkypi.sh [--purge-clone]
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
echo "Run as root (sudo bash uninstall_inkypi.sh)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Stopping and disabling inkypi.service"
|
||||||
|
systemctl disable --now inkypi.service 2>/dev/null || echo " (not running/installed, skipping)"
|
||||||
|
|
||||||
|
echo "==> Removing systemd unit"
|
||||||
|
rm -f /etc/systemd/system/inkypi.service
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
echo "==> Removing installed app (/usr/local/inkypi, includes venv_inkypi)"
|
||||||
|
rm -rf /usr/local/inkypi
|
||||||
|
|
||||||
|
echo "==> Removing CLI executable (/usr/local/bin/inkypi)"
|
||||||
|
rm -f /usr/local/bin/inkypi
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "--purge-clone" ]]; then
|
||||||
|
echo "==> Removing git clone at ~/InkyPi (--purge-clone was passed)"
|
||||||
|
echo " NOTE: this also deletes src/config/device.json, which has your"
|
||||||
|
echo " old Navidrome credentials in plaintext -- fine if that's the point."
|
||||||
|
rm -rf ~/InkyPi
|
||||||
|
else
|
||||||
|
echo "==> Leaving ~/InkyPi in place (not needed by Paperwall, just kept as a fallback)."
|
||||||
|
echo " Re-run with --purge-clone to remove it too."
|
||||||
|
echo " NOTE: ~/InkyPi/src/config/device.json still has your old Navidrome"
|
||||||
|
echo " credentials in plaintext if you want to wipe just that file instead."
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<'MSG'
|
||||||
|
|
||||||
|
==> InkyPi app removed. Left untouched on purpose:
|
||||||
|
- SPI/I2C interfaces in /boot/firmware/config.txt (Paperwall needs them)
|
||||||
|
- zramswap / earlyoom services (general Pi memory helpers, not InkyPi-specific)
|
||||||
|
|
||||||
|
To also remove those memory-management services:
|
||||||
|
sudo systemctl disable --now zramswap
|
||||||
|
sudo apt remove -y zram-tools
|
||||||
|
sudo systemctl disable --now earlyoom
|
||||||
|
sudo apt remove -y earlyoom
|
||||||
|
MSG
|
||||||
Whitespace-only changes.
+76
@@ -0,0 +1,76 @@
|
|||||||
|
"""Flask app: status/preview page + one settings form per screen + rotation settings."""
|
||||||
|
|
||||||
|
from flask import Flask, redirect, render_template, request, url_for
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(config, screens: dict, scheduler) -> Flask:
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
status = scheduler.get_status()
|
||||||
|
return render_template(
|
||||||
|
"index.html",
|
||||||
|
status=status,
|
||||||
|
screens=screens,
|
||||||
|
rotation=config.get_rotation(),
|
||||||
|
navidrome_priority=config.get_navidrome_priority(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.route("/actions/refresh", methods=["POST"])
|
||||||
|
def refresh_now():
|
||||||
|
scheduler.request_manual_refresh()
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
@app.route("/actions/switch/<screen_id>", methods=["POST"])
|
||||||
|
def switch_now(screen_id):
|
||||||
|
if screen_id in screens:
|
||||||
|
scheduler.request_switch(screen_id)
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
@app.route("/settings/navidrome", methods=["GET", "POST"])
|
||||||
|
def settings_navidrome():
|
||||||
|
if request.method == "POST":
|
||||||
|
config.update_screen_settings("navidrome", {
|
||||||
|
"url": request.form.get("url", "").strip(),
|
||||||
|
"username": request.form.get("username", "").strip(),
|
||||||
|
"password": request.form.get("password", ""),
|
||||||
|
"filter_user": request.form.get("filter_user", "").strip(),
|
||||||
|
"show_progress": "show_progress" in request.form,
|
||||||
|
"show_genre": "show_genre" in request.form,
|
||||||
|
"show_source": "show_source" in request.form,
|
||||||
|
"boost_cover": "boost_cover" in request.form,
|
||||||
|
"grayscale_cover": "grayscale_cover" in request.form,
|
||||||
|
})
|
||||||
|
return redirect(url_for("settings_navidrome"))
|
||||||
|
return render_template("settings_navidrome.html", settings=config.get_screen_settings("navidrome"))
|
||||||
|
|
||||||
|
@app.route("/settings/weather", methods=["GET", "POST"])
|
||||||
|
def settings_weather():
|
||||||
|
if request.method == "POST":
|
||||||
|
lat = request.form.get("latitude", "").strip()
|
||||||
|
lon = request.form.get("longitude", "").strip()
|
||||||
|
config.update_screen_settings("weather", {
|
||||||
|
"latitude": float(lat) if lat else None,
|
||||||
|
"longitude": float(lon) if lon else None,
|
||||||
|
})
|
||||||
|
return redirect(url_for("settings_weather"))
|
||||||
|
return render_template("settings_weather.html", settings=config.get_screen_settings("weather"))
|
||||||
|
|
||||||
|
@app.route("/settings/rotation", methods=["GET", "POST"])
|
||||||
|
def settings_rotation():
|
||||||
|
if request.method == "POST":
|
||||||
|
raw = request.form.get("screens", "")
|
||||||
|
selected = [s.strip() for s in raw.split(",") if s.strip() in screens]
|
||||||
|
interval = int(request.form.get("interval_seconds", 300) or 300)
|
||||||
|
config.set_rotation(selected, interval)
|
||||||
|
config.set_navidrome_priority("navidrome_priority" in request.form)
|
||||||
|
return redirect(url_for("settings_rotation"))
|
||||||
|
return render_template(
|
||||||
|
"settings_rotation.html",
|
||||||
|
screens=screens,
|
||||||
|
rotation=config.get_rotation(),
|
||||||
|
navidrome_priority=config.get_navidrome_priority(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return app
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Paperwall</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, sans-serif; max-width: 640px; margin: 2rem auto; padding: 0 1rem; color: #222; }
|
||||||
|
.brand { font-weight: 700; font-size: 1.1rem; margin-bottom: 1rem; }
|
||||||
|
nav { display: flex; gap: 1rem; margin-bottom: 1.5rem; font-size: 0.9rem; }
|
||||||
|
nav a { color: #444; text-decoration: none; }
|
||||||
|
nav a:hover { text-decoration: underline; }
|
||||||
|
h1 { font-size: 1.3rem; }
|
||||||
|
.field { margin-bottom: 1rem; }
|
||||||
|
label { display: block; font-weight: 600; margin-bottom: 0.25rem; font-size: 0.9rem; }
|
||||||
|
input[type=text], input[type=password], input[type=number] {
|
||||||
|
width: 100%; padding: 0.5rem; box-sizing: border-box; border: 1px solid #ccc; border-radius: 4px;
|
||||||
|
}
|
||||||
|
.checkbox { display: flex; align-items: center; gap: 0.5rem; font-weight: normal; margin-bottom: 0.5rem; }
|
||||||
|
.checkbox input { width: auto; }
|
||||||
|
button { padding: 0.5rem 1rem; border: 0; border-radius: 4px; background: #222; color: white; cursor: pointer; }
|
||||||
|
button.secondary { background: #eee; color: #222; }
|
||||||
|
.preview { border: 1px solid #ccc; max-width: 100%; }
|
||||||
|
.status-grid { display: grid; grid-template-columns: auto 1fr; gap: 0.25rem 1rem; font-size: 0.9rem; margin: 1rem 0; }
|
||||||
|
.actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin: 1rem 0; }
|
||||||
|
form.inline { display: inline; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="brand">Paperwall</div>
|
||||||
|
<nav>
|
||||||
|
<a href="{{ url_for('index') }}">Status</a>
|
||||||
|
<a href="{{ url_for('settings_navidrome') }}">Navidrome</a>
|
||||||
|
<a href="{{ url_for('settings_weather') }}">Weather</a>
|
||||||
|
<a href="{{ url_for('settings_rotation') }}">Rotation</a>
|
||||||
|
</nav>
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Status</h1>
|
||||||
|
|
||||||
|
<img class="preview" src="{{ url_for('static', filename='preview.png') }}?t={{ status.last_render_at }}" alt="preview">
|
||||||
|
|
||||||
|
<div class="status-grid">
|
||||||
|
<div>Current screen</div><div>{{ status.current_screen or "-" }}</div>
|
||||||
|
<div>Last render</div><div>{{ status.last_render_at or "-" }}</div>
|
||||||
|
<div>Last panel write</div><div>{{ status.last_display_write_at or "-" }}</div>
|
||||||
|
<div>Navidrome priority</div><div>{{ "on" if navidrome_priority else "off" }}</div>
|
||||||
|
<div>Rotation</div><div>{{ rotation.screens | join(" → ") }} (every {{ rotation.interval_seconds }}s)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<form class="inline" method="post" action="{{ url_for('refresh_now') }}">
|
||||||
|
<button type="submit">Refresh now</button>
|
||||||
|
</form>
|
||||||
|
{% for screen_id, screen in screens.items() %}
|
||||||
|
<form class="inline" method="post" action="{{ url_for('switch_now', screen_id=screen_id) }}">
|
||||||
|
<button class="secondary" type="submit">Show {{ screen.display_name }}</button>
|
||||||
|
</form>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Navidrome</h1>
|
||||||
|
<form method="post">
|
||||||
|
<div class="field">
|
||||||
|
<label for="url">Server URL</label>
|
||||||
|
<input type="text" id="url" name="url" value="{{ settings.url or '' }}" placeholder="https://music.example.com">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" id="username" name="username" value="{{ settings.username or '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" name="password" value="{{ settings.password or '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="filter_user">Only show this listener (optional)</label>
|
||||||
|
<input type="text" id="filter_user" name="filter_user" value="{{ settings.filter_user or '' }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Display options</label>
|
||||||
|
<label class="checkbox"><input type="checkbox" name="show_progress" {{ "checked" if settings.show_progress }}> Progress bar and times</label>
|
||||||
|
<label class="checkbox"><input type="checkbox" name="show_genre" {{ "checked" if settings.show_genre }}> Genre</label>
|
||||||
|
<label class="checkbox"><input type="checkbox" name="show_source" {{ "checked" if settings.show_source }}> Player and listener name</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Cover art</label>
|
||||||
|
<label class="checkbox"><input type="checkbox" name="boost_cover" {{ "checked" if settings.boost_cover }}> Boost contrast for e-ink</label>
|
||||||
|
<label class="checkbox"><input type="checkbox" name="grayscale_cover" {{ "checked" if settings.grayscale_cover }}> Force grayscale</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Rotation</h1>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
<div class="field">
|
||||||
|
<label>Navidrome takes over when music plays</label>
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" name="navidrome_priority" {{ "checked" if navidrome_priority }}>
|
||||||
|
Show Navidrome immediately whenever something is playing, regardless of rotation
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="screens">Rotation order (used when Navidrome isn't playing, or if priority is off)</label>
|
||||||
|
<input type="text" id="screens" name="screens" value="{{ rotation.screens | join(', ') }}" placeholder="clock, weather">
|
||||||
|
<p style="font-size:0.8rem;color:#666">
|
||||||
|
Comma-separated, in order. Available: {{ screens.keys() | join(', ') }}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="interval_seconds">Rotation interval (seconds)</label>
|
||||||
|
<input type="number" id="interval_seconds" name="interval_seconds" value="{{ rotation.interval_seconds }}" min="10">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Weather</h1>
|
||||||
|
<p style="font-size:0.85rem;color:#666">Uses Open-Meteo (no API key needed) — just the coordinates for your location.</p>
|
||||||
|
<form method="post">
|
||||||
|
<div class="field">
|
||||||
|
<label for="latitude">Latitude</label>
|
||||||
|
<input type="text" id="latitude" name="latitude" value="{{ settings.latitude if settings.latitude is not none else '' }}" placeholder="e.g. 52.52">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="longitude">Longitude</label>
|
||||||
|
<input type="text" id="longitude" name="longitude" value="{{ settings.longitude if settings.longitude is not none else '' }}" placeholder="e.g. 13.40">
|
||||||
|
</div>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
Reference in new issue
Block a user