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:
Honey committed 2026-07-21 22:29:45 +02:00
commit 66464291e1
33 files changed
+2215

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
__pycache__/
*.pyc
venv/
.venv/
mock_output/
web/static/*.png
config.json
config.json.tmp
+108
View File
@@ -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
```
+87
View File
@@ -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()
+28
View File
@@ -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."""
+54
View File
@@ -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")
+35
View File
@@ -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.
View File
Whitespace-only changes.
+232
View File
@@ -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 ###
+322
View 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
View File
@@ -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
+65
View File
@@ -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())
+16
View File
@@ -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
+8
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
Flask>=3.0
Pillow>=10.0
requests>=2.31
+192
View File
@@ -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")
+13
View File
@@ -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()}
+28
View File
@@ -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 {}
+55
View File
@@ -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
+320
View File
@@ -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}"
+79
View File
@@ -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]
+108
View File
@@ -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
View File
@@ -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"
+67
View File
@@ -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
View File
Whitespace-only changes.
+76
View File
@@ -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
+38
View File
@@ -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>
+25
View File
@@ -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 %}
+37
View File
@@ -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 %}
+29
View File
@@ -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 %}
+16
View File
@@ -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) &mdash; 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 %}