Live data for my farm here: https://farma.pihrt.com/
Raspberry Pi 3+ Raspberry Pi OS connected to a monitor (HDMI)
Locally on the monitor (kiosk) or from the LAN: http://IP_RASPBERRY/
status of all printers print progress (%) remaining time ETA (end date/time) nearest print end visual alerts (color changes)
Color <-> Meaning
- Gray <-> Not printing / idle
- Yellow <-> Printing in progress
- Orange <-> Nearing completion
- Red <-> Error
- Data every 5 seconds
- Time every 1 second
Printers:
- Prusa XL (LAN)
- Prusa MK4 (LAN)
- Prusa MK3 farm (USB → PrusaLink multi-instance)
- Raise3D Pro2, Pro3 (LAN)
Devices:
- Shelly 4PM for measuring voltage, consumption, and power usage from Shelly devices (LAN).
- Shelly Plus H&T for temperature and humidity through Shelly Cloud.
Set SHELLY_CLOUD["enabled"] to True in app/config.py, then enter the Shelly Cloud server URI, authorization key, and each sensor's display name and device ID. The authorization key remains on the Raspberry Pi and is never included in status.json. The display shows a separate card for every configured sensor with temperature, humidity, and the source measurement time. Cloud responses are cached for 60 seconds by default to respect Shelly Cloud API limits; change refresh_seconds only when necessary.
The power card and each temperature/humidity card include a 12-hour history graph. The Raspberry Pi stores samples locally under app/data/history/ by default and serves them through /api/power_history and /api/sensor_history; set WALLDISPLAY_HISTORY_DIR to a persistent writable directory if the application code directory is read-only or replaced during updates. Local power samples are saved at most once per minute and sensor samples once per 10 minutes. The remote PHP receiver builds its own history from incoming live measurements and stores power_history.json and sensor_history.json beside status.json; remote power samples are limited to one per minute and sensor samples to one per 10 minutes. The browser checks remote history once per minute and redraws a graph only when its plotted sample values change. Graphs are bottom-aligned inside equal-height telemetry cards, follow their actual container width, and increase to 180 pixels high on screens narrower than 768 pixels so axes and legends remain readable.
2026-10-12 — walldisplay: Aligned power and temperature graphs inside their cards, kept complete graph labels visible on the kiosk display, and made graphs use the full available width and a larger height on phones.
2026-10-10 — walldisplay: Made telemetry history loading independent from live status rendering, retained last known Shelly readings on Cloud request failures, and moved Shelly refreshes and remote exports off the /api/status response path.
2026-10-10 — walldisplay: Added local power and sensor history storage/API, remote temperature and humidity history, and graphs in power and sensor cards.
2026-10-02 — walldisplay: Added Shelly Plus H&T support through Shelly Cloud, including configurable sensors on the Raspberry Pi, secure backend export, and temperature/humidity cards in the web display.
The remote page adds a changing query parameter to every JSON request, in addition to cache: "no-store". Its index.html also versions the JavaScript URL so a browser with a long-lived cached app.js receives this behavior immediately. This prevents browser, proxy, or CDN caches from showing an old status.json response as current data. The backend also logs a failed HTTP response from update.php; check journalctl -u wallboard if the remote timestamp stops moving.
- POST https://your_ip/update.php
- Authorization: Bearer YOUR_SECRET_TOKEN
The MK3 farm accepts both configuration formats: the current printers list and the legacy instances mapping used by early wallboard installations. This allows an application update without replacing deployed PrusaLink API keys.
- Tested in Python 3.13 runs in virtualenv (
venv) fastapiuvicornrequestspydantic
index.htmlstyle.cssapp.jslogo.pngfavicon.png
On remote webpage (SECRET_TOKEN):
index.htmlstyle.cssapp.jslogo.pngfavicon.pngupdate.php
Reverse proxy on FastAPI: GET /api/status
/api → http://127.0.0.1:8000
http://localhost/ → wallboard UIhttp://localhost/api/status → JSON data
prusa_link.py– local PrusaLink (XL, MK4, MK3)prusa_farm.py– local multi-instance MK3raise3d.py– local Raise (Pro2+, Pro3+)- only one JSON format for all printers
shelly_4pm.py- local Shelly devices (Pro 4PM)
{"generated_at":1767091183,"printers":[{"name":"XL-01","vendor":"Prusa","model":"XL 5H","state":"offline"},{"name":"MK4-01","vendor":"Prusa","model":"MK4","state":"idle","job":null,"progress":0,"time_left":null},{"name":"MK3-09","vendor":"Prusa","model":"MK3","state":"error","job":null,"progress":0,"time_left":null},{"name":"MK3-03","vendor":"Prusa","model":"MK3","state":"error","job":null,"progress":0,"time_left":null},{"name":"MK3-07","vendor":"Prusa","model":"MK3","state":"error","job":null,"progress":0,"time_left":null},{"name":"MK3-13","vendor":"Prusa","model":"MK3","state":"error","job":null,"progress":0,"time_left":null},{"name":"MK3-12","vendor":"Prusa","model":"MK3","state":"error","job":null,"progress":0,"time_left":null},{"name":"MK3-10","vendor":"Prusa","model":"MK3","state":"error","job":null,"progress":0,"time_left":null},{"name":"MK3-08","vendor":"Prusa","model":"MK3","state":"error","job":null,"progress":0,"time_left":null},{"name":"MK3-11","vendor":"Prusa","model":"MK3","state":"error","job":null,"progress":0,"time_left":null},{"name":"MK3-02","vendor":"Prusa","model":"MK3","state":"error","job":null,"progress":0,"time_left":null},{"name":"MK3-06","vendor":"Prusa","model":"MK3","state":"error","job":null,"progress":0,"time_left":null},{"name":"MK3-05","vendor":"Prusa","model":"MK3","state":"error","job":null,"progress":0,"time_left":null},{"name":"MK3-01","vendor":"Prusa","model":"MK3","state":"error","job":null,"progress":0,"time_left":null},{"name":"MK3-04","vendor":"Prusa","model":"MK3","state":"error","job":null,"progress":0,"time_left":null},{"name":"Raise-Pro2","vendor":"Raise3D","model":"Pro2+","state":"idle","job":null,"progress":0,"time_left":null,"temps":{"nozzle":21,"bed":19}},{"name":"Raise-Pro3","vendor":"Raise3D","model":"Pro3+","state":"idle","job":null,"progress":0,"time_left":null,"temps":{"nozzle":25,"bed":20}}], "power": {"power_w": 1842.5,"power_kw": 1.84,"voltage_v": 231.2,"energy_kwh": 1520.3}}
sudo apt update
sudo apt install -y python3 python3-venv nginx
cd /opt
git clone pihrt-com/walldisplay
cd walldisplay
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
sudo systemctl enable walldisplay
sudo systemctl start walldisplay
sudo apt install -y nginx
sudo mkdir /var/www/walldisplay
sudo chown -R www-data:www-data /var/www/walldisplay
sudo nano /etc/nginx/sites-available/walldisplay
server {
listen 80;
server_name _;
root /var/www/walldisplay;
index index.html;
location / {
try_files $uri $uri/ =404;
}
location /api/ {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
sudo ln -s /etc/nginx/sites-available/wallboard /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl restart nginx
sudo systemctl daemon-reexec
sudo systemctl daemon-reload
sudo systemctl enable walldisplay
sudo systemctl start walldisplay
systemctl status walldisplay
Logs:
journalctl -u wallboard -f
sudo apt install -y chromium unclutter
xset s off
xset -dpms
/opt/walldisplay/app/
├── main.py
├── aggregator.py
├── remote_export.py
├── config.py
├── printers/
├─ init.py
├─ prusa_link.py
├─ raise3d.py
└─ prusa_farm.py
└── devices/
└─ shelly_4pm.py
cd /opt/walldisplay
source venv/bin/activate
uvicorn app.main:app --host 0.0.0.0 --port 8000
curl "http://192.168.88.172:10800/v1/printer/runningstatus?token=076ffdfe5a350aac3c3da4eea43e2248"
curl "http://192.168.88.172:10800/v1/job/currentjob?token=076ffdfe5a350aac3c3da4eea43e2248"
curl "http://192.168.88.172:10800/v1/printer/nozzle1?token=076ffdfe5a350aac3c3da4eea43e2248"
curl "http://192.168.88.172:10800/v1/printer/basic?token=076ffdfe5a350aac3c3da4eea43e2248"
Where token is example Python code:
def _login(base_url: str, password: str) -> str:
ts = int(time.time() * 1000)
raw = f"password={password}×tamp={ts}"
sha1_hex = hashlib.sha1(raw.encode("utf-8")).hexdigest()
sign = hashlib.md5(sha1_hex.encode("utf-8")).hexdigest()
url = f"{base_url}:10800/v1/login"
r = requests.get(
url,
params={
"timestamp": ts,
"sign": sign
},
timeout=TIMEOUT
)
r.raise_for_status()
j = r.json()
if j.get("status") != 1:
raise RuntimeError(j)
token = j["data"]["token"]
_TOKEN_CACHE[base_url] = (token, time.time())
return token

