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