Initial commit: Paperwall, a standalone e-ink dashboard

Navidrome now-playing, clock, and weather for a Waveshare epd4in0e panel,
with a local web UI and install/uninstall scripts for the Pi.
This commit is contained in:
Honey committed 2026-07-21 22:29:45 +02:00
commit 66464291e1
33 files changed
+2215

No files matched your search

View File
Whitespace-only changes.
+76
View File
@@ -0,0 +1,76 @@
"""Flask app: status/preview page + one settings form per screen + rotation settings."""
from flask import Flask, redirect, render_template, request, url_for
def create_app(config, screens: dict, scheduler) -> Flask:
app = Flask(__name__)
@app.route("/")
def index():
status = scheduler.get_status()
return render_template(
"index.html",
status=status,
screens=screens,
rotation=config.get_rotation(),
navidrome_priority=config.get_navidrome_priority(),
)
@app.route("/actions/refresh", methods=["POST"])
def refresh_now():
scheduler.request_manual_refresh()
return redirect(url_for("index"))
@app.route("/actions/switch/<screen_id>", methods=["POST"])
def switch_now(screen_id):
if screen_id in screens:
scheduler.request_switch(screen_id)
return redirect(url_for("index"))
@app.route("/settings/navidrome", methods=["GET", "POST"])
def settings_navidrome():
if request.method == "POST":
config.update_screen_settings("navidrome", {
"url": request.form.get("url", "").strip(),
"username": request.form.get("username", "").strip(),
"password": request.form.get("password", ""),
"filter_user": request.form.get("filter_user", "").strip(),
"show_progress": "show_progress" in request.form,
"show_genre": "show_genre" in request.form,
"show_source": "show_source" in request.form,
"boost_cover": "boost_cover" in request.form,
"grayscale_cover": "grayscale_cover" in request.form,
})
return redirect(url_for("settings_navidrome"))
return render_template("settings_navidrome.html", settings=config.get_screen_settings("navidrome"))
@app.route("/settings/weather", methods=["GET", "POST"])
def settings_weather():
if request.method == "POST":
lat = request.form.get("latitude", "").strip()
lon = request.form.get("longitude", "").strip()
config.update_screen_settings("weather", {
"latitude": float(lat) if lat else None,
"longitude": float(lon) if lon else None,
})
return redirect(url_for("settings_weather"))
return render_template("settings_weather.html", settings=config.get_screen_settings("weather"))
@app.route("/settings/rotation", methods=["GET", "POST"])
def settings_rotation():
if request.method == "POST":
raw = request.form.get("screens", "")
selected = [s.strip() for s in raw.split(",") if s.strip() in screens]
interval = int(request.form.get("interval_seconds", 300) or 300)
config.set_rotation(selected, interval)
config.set_navidrome_priority("navidrome_priority" in request.form)
return redirect(url_for("settings_rotation"))
return render_template(
"settings_rotation.html",
screens=screens,
rotation=config.get_rotation(),
navidrome_priority=config.get_navidrome_priority(),
)
return app
+38
View File
@@ -0,0 +1,38 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Paperwall</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 640px; margin: 2rem auto; padding: 0 1rem; color: #222; }
.brand { font-weight: 700; font-size: 1.1rem; margin-bottom: 1rem; }
nav { display: flex; gap: 1rem; margin-bottom: 1.5rem; font-size: 0.9rem; }
nav a { color: #444; text-decoration: none; }
nav a:hover { text-decoration: underline; }
h1 { font-size: 1.3rem; }
.field { margin-bottom: 1rem; }
label { display: block; font-weight: 600; margin-bottom: 0.25rem; font-size: 0.9rem; }
input[type=text], input[type=password], input[type=number] {
width: 100%; padding: 0.5rem; box-sizing: border-box; border: 1px solid #ccc; border-radius: 4px;
}
.checkbox { display: flex; align-items: center; gap: 0.5rem; font-weight: normal; margin-bottom: 0.5rem; }
.checkbox input { width: auto; }
button { padding: 0.5rem 1rem; border: 0; border-radius: 4px; background: #222; color: white; cursor: pointer; }
button.secondary { background: #eee; color: #222; }
.preview { border: 1px solid #ccc; max-width: 100%; }
.status-grid { display: grid; grid-template-columns: auto 1fr; gap: 0.25rem 1rem; font-size: 0.9rem; margin: 1rem 0; }
.actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin: 1rem 0; }
form.inline { display: inline; }
</style>
</head>
<body>
<div class="brand">Paperwall</div>
<nav>
<a href="{{ url_for('index') }}">Status</a>
<a href="{{ url_for('settings_navidrome') }}">Navidrome</a>
<a href="{{ url_for('settings_weather') }}">Weather</a>
<a href="{{ url_for('settings_rotation') }}">Rotation</a>
</nav>
{% block content %}{% endblock %}
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block content %}
<h1>Status</h1>
<img class="preview" src="{{ url_for('static', filename='preview.png') }}?t={{ status.last_render_at }}" alt="preview">
<div class="status-grid">
<div>Current screen</div><div>{{ status.current_screen or "-" }}</div>
<div>Last render</div><div>{{ status.last_render_at or "-" }}</div>
<div>Last panel write</div><div>{{ status.last_display_write_at or "-" }}</div>
<div>Navidrome priority</div><div>{{ "on" if navidrome_priority else "off" }}</div>
<div>Rotation</div><div>{{ rotation.screens | join(" → ") }} (every {{ rotation.interval_seconds }}s)</div>
</div>
<div class="actions">
<form class="inline" method="post" action="{{ url_for('refresh_now') }}">
<button type="submit">Refresh now</button>
</form>
{% for screen_id, screen in screens.items() %}
<form class="inline" method="post" action="{{ url_for('switch_now', screen_id=screen_id) }}">
<button class="secondary" type="submit">Show {{ screen.display_name }}</button>
</form>
{% endfor %}
</div>
{% endblock %}
+37
View File
@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block content %}
<h1>Navidrome</h1>
<form method="post">
<div class="field">
<label for="url">Server URL</label>
<input type="text" id="url" name="url" value="{{ settings.url or '' }}" placeholder="https://music.example.com">
</div>
<div class="field">
<label for="username">Username</label>
<input type="text" id="username" name="username" value="{{ settings.username or '' }}">
</div>
<div class="field">
<label for="password">Password</label>
<input type="password" id="password" name="password" value="{{ settings.password or '' }}">
</div>
<div class="field">
<label for="filter_user">Only show this listener (optional)</label>
<input type="text" id="filter_user" name="filter_user" value="{{ settings.filter_user or '' }}">
</div>
<div class="field">
<label>Display options</label>
<label class="checkbox"><input type="checkbox" name="show_progress" {{ "checked" if settings.show_progress }}> Progress bar and times</label>
<label class="checkbox"><input type="checkbox" name="show_genre" {{ "checked" if settings.show_genre }}> Genre</label>
<label class="checkbox"><input type="checkbox" name="show_source" {{ "checked" if settings.show_source }}> Player and listener name</label>
</div>
<div class="field">
<label>Cover art</label>
<label class="checkbox"><input type="checkbox" name="boost_cover" {{ "checked" if settings.boost_cover }}> Boost contrast for e-ink</label>
<label class="checkbox"><input type="checkbox" name="grayscale_cover" {{ "checked" if settings.grayscale_cover }}> Force grayscale</label>
</div>
<button type="submit">Save</button>
</form>
{% endblock %}
+29
View File
@@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block content %}
<h1>Rotation</h1>
<form method="post">
<div class="field">
<label>Navidrome takes over when music plays</label>
<label class="checkbox">
<input type="checkbox" name="navidrome_priority" {{ "checked" if navidrome_priority }}>
Show Navidrome immediately whenever something is playing, regardless of rotation
</label>
</div>
<div class="field">
<label for="screens">Rotation order (used when Navidrome isn't playing, or if priority is off)</label>
<input type="text" id="screens" name="screens" value="{{ rotation.screens | join(', ') }}" placeholder="clock, weather">
<p style="font-size:0.8rem;color:#666">
Comma-separated, in order. Available: {{ screens.keys() | join(', ') }}.
</p>
</div>
<div class="field">
<label for="interval_seconds">Rotation interval (seconds)</label>
<input type="number" id="interval_seconds" name="interval_seconds" value="{{ rotation.interval_seconds }}" min="10">
</div>
<button type="submit">Save</button>
</form>
{% endblock %}
+16
View File
@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block content %}
<h1>Weather</h1>
<p style="font-size:0.85rem;color:#666">Uses Open-Meteo (no API key needed) &mdash; just the coordinates for your location.</p>
<form method="post">
<div class="field">
<label for="latitude">Latitude</label>
<input type="text" id="latitude" name="latitude" value="{{ settings.latitude if settings.latitude is not none else '' }}" placeholder="e.g. 52.52">
</div>
<div class="field">
<label for="longitude">Longitude</label>
<input type="text" id="longitude" name="longitude" value="{{ settings.longitude if settings.longitude is not none else '' }}" placeholder="e.g. 13.40">
</div>
<button type="submit">Save</button>
</form>
{% endblock %}