commit 66464291e1c596c4ea7063f0077b770997326e30 Author: Honey Date: Tue Jul 21 22:29:45 2026 +0200 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac0fe11 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +venv/ +.venv/ +mock_output/ +web/static/*.png +config.json +config.json.tmp diff --git a/README.md b/README.md new file mode 100644 index 0000000..0f8aabc --- /dev/null +++ b/README.md @@ -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://: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 +``` diff --git a/config.py b/config.py new file mode 100644 index 0000000..85a63c7 --- /dev/null +++ b/config.py @@ -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() diff --git a/display/__init__.py b/display/__init__.py new file mode 100644 index 0000000..d316b46 --- /dev/null +++ b/display/__init__.py @@ -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.""" diff --git a/display/epd4in0e_driver.py b/display/epd4in0e_driver.py new file mode 100644 index 0000000..cbec74f --- /dev/null +++ b/display/epd4in0e_driver.py @@ -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") diff --git a/display/mock_driver.py b/display/mock_driver.py new file mode 100644 index 0000000..276bce0 --- /dev/null +++ b/display/mock_driver.py @@ -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") diff --git a/display/waveshare_epd/DEV_Config_64.so b/display/waveshare_epd/DEV_Config_64.so new file mode 100644 index 0000000..c3886c0 Binary files /dev/null and b/display/waveshare_epd/DEV_Config_64.so differ diff --git a/display/waveshare_epd/__init__.py b/display/waveshare_epd/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/display/waveshare_epd/epd4in0e.py b/display/waveshare_epd/epd4in0e.py new file mode 100644 index 0000000..ed05584 --- /dev/null +++ b/display/waveshare_epd/epd4in0e.py @@ -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 ### + diff --git a/display/waveshare_epd/epdconfig.py b/display/waveshare_epd/epdconfig.py new file mode 100644 index 0000000..b390252 --- /dev/null +++ b/display/waveshare_epd/epdconfig.py @@ -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 ### diff --git a/fonts/DejaVuSans-Bold.ttf b/fonts/DejaVuSans-Bold.ttf new file mode 100644 index 0000000..1f22f07 Binary files /dev/null and b/fonts/DejaVuSans-Bold.ttf differ diff --git a/fonts/DejaVuSans.ttf b/fonts/DejaVuSans.ttf new file mode 100644 index 0000000..5267218 Binary files /dev/null and b/fonts/DejaVuSans.ttf differ diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..5f6bc8c --- /dev/null +++ b/install.sh @@ -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 diff --git a/main.py b/main.py new file mode 100644 index 0000000..3e394e7 --- /dev/null +++ b/main.py @@ -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()) diff --git a/paperwall.service.template b/paperwall.service.template new file mode 100644 index 0000000..462800c --- /dev/null +++ b/paperwall.service.template @@ -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 diff --git a/requirements-pi.txt b/requirements-pi.txt new file mode 100644 index 0000000..aff5128 --- /dev/null +++ b/requirements-pi.txt @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d136a6c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Flask>=3.0 +Pillow>=10.0 +requests>=2.31 diff --git a/scheduler.py b/scheduler.py new file mode 100644 index 0000000..f5fad34 --- /dev/null +++ b/scheduler.py @@ -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") diff --git a/screens/__init__.py b/screens/__init__.py new file mode 100644 index 0000000..9f44b1d --- /dev/null +++ b/screens/__init__.py @@ -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()} diff --git a/screens/base.py b/screens/base.py new file mode 100644 index 0000000..f7e4c99 --- /dev/null +++ b/screens/base.py @@ -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 {} diff --git a/screens/clock.py b/screens/clock.py new file mode 100644 index 0000000..dfb7f1a --- /dev/null +++ b/screens/clock.py @@ -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 diff --git a/screens/navidrome.py b/screens/navidrome.py new file mode 100644 index 0000000..beb7095 --- /dev/null +++ b/screens/navidrome.py @@ -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}" diff --git a/screens/render_utils.py b/screens/render_utils.py new file mode 100644 index 0000000..04cbb65 --- /dev/null +++ b/screens/render_utils.py @@ -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] diff --git a/screens/weather.py b/screens/weather.py new file mode 100644 index 0000000..f1d7fbf --- /dev/null +++ b/screens/weather.py @@ -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 diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..cc110a4 --- /dev/null +++ b/uninstall.sh @@ -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" diff --git a/uninstall_inkypi.sh b/uninstall_inkypi.sh new file mode 100755 index 0000000..53ea052 --- /dev/null +++ b/uninstall_inkypi.sh @@ -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 diff --git a/web/__init__.py b/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/app.py b/web/app.py new file mode 100644 index 0000000..8b5a2dd --- /dev/null +++ b/web/app.py @@ -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/", 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 diff --git a/web/templates/base.html b/web/templates/base.html new file mode 100644 index 0000000..77906cf --- /dev/null +++ b/web/templates/base.html @@ -0,0 +1,38 @@ + + + + + Paperwall + + + +
Paperwall
+ + {% block content %}{% endblock %} + + diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..a16682e --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block content %} +

Status

+ +preview + +
+
Current screen
{{ status.current_screen or "-" }}
+
Last render
{{ status.last_render_at or "-" }}
+
Last panel write
{{ status.last_display_write_at or "-" }}
+
Navidrome priority
{{ "on" if navidrome_priority else "off" }}
+
Rotation
{{ rotation.screens | join(" → ") }} (every {{ rotation.interval_seconds }}s)
+
+ +
+
+ +
+ {% for screen_id, screen in screens.items() %} +
+ +
+ {% endfor %} +
+{% endblock %} diff --git a/web/templates/settings_navidrome.html b/web/templates/settings_navidrome.html new file mode 100644 index 0000000..c676400 --- /dev/null +++ b/web/templates/settings_navidrome.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% block content %} +

Navidrome

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + + + +
+ +
+ + + +
+ + +
+{% endblock %} diff --git a/web/templates/settings_rotation.html b/web/templates/settings_rotation.html new file mode 100644 index 0000000..301c7f8 --- /dev/null +++ b/web/templates/settings_rotation.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block content %} +

Rotation

+ +
+
+ + +
+ +
+ + +

+ Comma-separated, in order. Available: {{ screens.keys() | join(', ') }}. +

+
+ +
+ + +
+ + +
+{% endblock %} diff --git a/web/templates/settings_weather.html b/web/templates/settings_weather.html new file mode 100644 index 0000000..7bda66b --- /dev/null +++ b/web/templates/settings_weather.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} +{% block content %} +

Weather

+

Uses Open-Meteo (no API key needed) — just the coordinates for your location.

+
+
+ + +
+
+ + +
+ +
+{% endblock %}