Kids weather dashboard for Raspberry Pi kiosk

What-to-wear weather dashboard (Open-Meteo + inline SVG kid), Chromium
kiosk launcher, overnight screen-off schedule with tap-to-wake, and an
idempotent install/uninstall script.
This commit is contained in:
rich committed 2026-10-22 15:15:58 -04:00
commit c98a5e5ed6
8 files changed
+954

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
.DS_Store
__pycache__/
shots/
+73
View File
@@ -0,0 +1,73 @@
# Kids weather dashboard
A Raspberry Pi kiosk that shows today's weather and a cartoon kid dressed for it:
coat, rain boots, sun hat, whatever the day needs. It's built for the official 800×480
touchscreen and works on any screen.
- **Weather:** [Open-Meteo](https://open-meteo.com) (free, no API key), refreshed every 10 minutes.
If the internet drops, it keeps showing the last forecast.
- **Outfit:** based on the "feels like" temperature for the hours ahead (7am–6pm), plus rain, snow, UV and wind.
After 7pm it switches to tomorrow, so clothes can be picked the night before.
- **Night mode:** the screen turns off overnight (default 21:00–06:30). Tapping it wakes it for a minute.
## Install on a Pi
You need Raspberry Pi OS **with desktop** (Bookworm or Trixie). Copy the repo over and run the installer
**as the desktop user** (not root):
```sh
rsync -a --exclude .git ~/kids-dashboard/ pi@<pi-address>:kids-dashboard-src/
ssh pi@<pi-address> 'kids-dashboard-src/install.sh && sudo reboot'
```
The installer:
1. Installs `chromium`, `wlopm`, `python3`, `cron` and `curl` if they're missing.
2. Switches the desktop to labwc (Wayland) and turns on desktop autologin, using `raspi-config`.
3. Copies the dashboard to `~/kids-dashboard/`. Writes `config.js` (location) and `screen.conf` (night schedule).
4. Adds the kiosk to `~/.config/labwc/autostart`. If Chromium crashes, `lwrespawn` restarts it.
5. Sets up the night-off cron jobs and the `tap-wake` systemd user service.
It's safe to re-run, and settings you don't pass are kept from the last run. To push an update, rsync again and re-run `install.sh`.
The dashboard reloads by itself.
### Options
| Option | Default | |
|---|---|---|
| `--lat N --lon N` | detected from public IP | Location for the forecast |
| `--off HHMM` / `--on HHMM` | `2100` / `0630` | When the screen goes off and comes back |
| `--awake SECONDS` | `60` | How long a tap keeps the screen on at night |
| `--no-night-off` / `--night-off` | night-off on | Keep the screen on 24/7, or turn night-off back on |
| `--uninstall` | | Remove everything (packages and autologin are left alone) |
## Layout
```
dashboard/index.html the whole dashboard: one file, inline SVG art, no dependencies
bin/kiosk.sh launches Chromium full-screen with its own profile
bin/screen.sh screen on|off|auto (auto = whatever the schedule says right now)
bin/tap-wake.py watches the touchscreen; wakes the screen when it's off at night
systemd/tap-wake.service user service for tap-wake.py
install.sh installer / updater / uninstaller
```
## Handy commands (on the Pi)
```sh
~/kids-dashboard/screen.sh on # turn the screen on by hand
systemctl --user status tap-wake # is tap-to-wake running?
XDG_RUNTIME_DIR=/run/user/$(id -u) WAYLAND_DISPLAY=wayland-0 \
grim /tmp/shot.png # screenshot of what's on screen
```
## Trying out the art
Open `dashboard/index.html?demo=LOW,HIGH,CODE[,RAIN%][,UV]` in any browser to fake a day's weather.
`CODE` is a [WMO weather code](https://open-meteo.com/en/docs#weathervariables).
- `?demo=22,30,73,60`: snow day (coat, snow pants, hat, gloves, scarf, snow boots)
- `?demo=50,64,61,80`: rain (raincoat, rain boots, umbrella)
- `?demo=72,88,0,0,8`: hot and sunny (t-shirt, shorts, sun hat, sunglasses)
The clothing thresholds live in `outfit()` in `index.html`. Temperatures are in °F.
Executable
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# Launches the kids weather dashboard full-screen. Started by ~/.config/labwc/autostart.
DIR=$(dirname "$(readlink -f "$0")")
PROFILE="$HOME/.config/kids-dashboard-chromium"
PREF="$PROFILE/Default/Preferences"
# after a power cut, stop Chromium from showing the "restore pages?" bubble
[ -f "$PREF" ] && sed -i 's/"exited_cleanly":false/"exited_cleanly":true/; s/"exit_type":"[^"]*"/"exit_type":"Normal"/' "$PREF"
exec chromium --kiosk --user-data-dir="$PROFILE" \
--ozone-platform=wayland --noerrdialogs --disable-infobars --no-first-run \
--hide-crash-restore-bubble --password-store=basic --disable-pinch \
--overscroll-history-navigation=0 --disable-features=Translate,MediaRouter \
--check-for-update-interval=31536000 \
"file://$DIR/index.html"
Executable
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
# Turn the screen on/off. Usage: screen.sh on|off|auto (auto = whatever the schedule says right now)
# Schedule lives in screen.conf next to this script (written by install.sh).
DIR=$(dirname "$(readlink -f "$0")")
OFF_AT=2100 # HHMM
ON_AT=0630
OUTPUT='*' # wlopm output name; '*' = every screen
[ -f "$DIR/screen.conf" ] && . "$DIR/screen.conf"
export XDG_RUNTIME_DIR=/run/user/$(id -u) WAYLAND_DISPLAY=${WAYLAND_DISPLAY:-wayland-0}
state="$1"
if [ "$state" = auto ]; then
now=$(date +%H%M)
if [ "$OFF_AT" -gt "$ON_AT" ]; then # normal overnight window, e.g. 2100 -> 0630
if [ "$now" -ge "$OFF_AT" ] || [ "$now" -lt "$ON_AT" ]; then state=off; else state=on; fi
else # window that doesn't cross midnight, e.g. 0000 -> 0630
if [ "$now" -ge "$OFF_AT" ] && [ "$now" -lt "$ON_AT" ]; then state=off; else state=on; fi
fi
fi
case "$state" in
on|off) exec wlopm --"$state" "$OUTPUT" ;;
*) echo "usage: $0 on|off|auto" >&2; exit 2 ;;
esac
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Tap-to-wake: while the screen is off for the night, a touch turns it on for a minute."""
import os, select, subprocess, sys, time
DIR = os.path.dirname(os.path.realpath(__file__))
SCREEN = os.path.join(DIR, "screen.sh")
ENV = dict(os.environ, XDG_RUNTIME_DIR=f"/run/user/{os.getuid()}",
WAYLAND_DISPLAY=os.environ.get("WAYLAND_DISPLAY", "wayland-0"))
def setting(name, default):
try:
for line in open(os.path.join(DIR, "screen.conf")):
key, _, val = line.split("#")[0].strip().partition("=")
if key == name:
return val.strip().strip("'\"")
except OSError:
pass
return default
AWAKE_SECONDS = int(setting("AWAKE_SECONDS", "60"))
def find_touchscreen():
for block in open("/proc/bus/input/devices").read().split("\n\n"):
if "ft5x06" in block or "touch" in block.lower():
for line in block.splitlines():
if line.startswith("H: Handlers="):
for h in line.split("=", 1)[1].split():
if h.startswith("event"):
return "/dev/input/" + h
return None
def screen_is_off():
out = subprocess.run(["wlopm"], capture_output=True, text=True, env=ENV).stdout
return any(line.endswith(" off") for line in out.splitlines())
dev = find_touchscreen()
if not dev:
print("no touchscreen found; nothing to do", file=sys.stderr)
sys.exit(0)
fd = os.open(dev, os.O_RDONLY)
wake_until = None # set while we have woken the screen ourselves
last_check = 0.0
while True:
timeout = None if wake_until is None else max(0.0, wake_until - time.monotonic())
ready, _, _ = select.select([fd], [], [], timeout)
now = time.monotonic()
if ready:
os.read(fd, 4096)
if wake_until is not None:
wake_until = now + AWAKE_SECONDS # keep it on while being used
elif now - last_check > 2: # a swipe is lots of events; check once
last_check = now
if screen_is_off():
subprocess.run([SCREEN, "on"], env=ENV)
wake_until = now + AWAKE_SECONDS
elif wake_until is not None and now >= wake_until:
subprocess.run([SCREEN, "auto"], env=ENV) # back off if still night, stays on if morning
wake_until = None
+588
View File
@@ -0,0 +1,588 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kids Weather</title>
<style>
:root{
--ink:#1f2a44; --muted:#56627f; --card:rgba(255,255,255,.78);
--sky1:#7cc8ff; --sky2:#e3f4ff; --ground:#7cc66b; --ground2:#69b45a;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{width:100%;height:100%;overflow:hidden;cursor:none;user-select:none;-webkit-user-select:none}
body{
font-family:"Nunito Sans","DejaVu Sans",sans-serif;color:var(--ink);
background:linear-gradient(180deg,var(--sky1) 0%,var(--sky2) 100%);
display:grid;grid-template-columns:330px 1fr;transition:background 1s;
}
#scene{width:100%;height:100vh;display:block}
.ground{fill:var(--ground)} .ground2{fill:var(--ground2)}
#info{display:flex;flex-direction:column;gap:9px;padding:12px 16px 12px 4px;min-width:0;position:relative}
header{display:flex;align-items:baseline;justify-content:space-between}
.day{font-size:34px;font-weight:900;line-height:1}
.date{font-size:18px;font-weight:700;color:var(--muted);margin-left:8px}
.clock{font-size:30px;font-weight:800}
.clock small{font-size:16px;margin-left:2px;color:var(--muted)}
.now{display:flex;align-items:center;gap:10px;height:112px}
.now .big-icon{width:112px;height:112px;flex:none}
.now .big-icon svg{width:100%;height:100%}
.temp{font-size:100px;font-weight:900;line-height:1;letter-spacing:-3px}
.temp sup{font-size:48px;vertical-align:top;position:relative;top:10px;letter-spacing:0}
.side{display:flex;flex-direction:column;gap:6px;margin-left:6px}
.pill{align-self:flex-start;color:#fff;font-size:22px;font-weight:900;padding:2px 14px;border-radius:999px}
.desc{font-size:20px;font-weight:800}
.hilo{font-size:19px;font-weight:800;color:var(--muted)}
.hilo b{color:var(--ink)}
.timeline{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
.slot{background:var(--card);border-radius:16px;padding:5px 4px 6px;text-align:center}
.slot.past{opacity:.45}
.slot .lbl{font-size:14px;font-weight:900;color:var(--muted);white-space:nowrap}
.slot svg{width:46px;height:46px;display:block;margin:0 auto}
.slot .t{font-size:24px;font-weight:900;line-height:1}
.slot .r{font-size:13px;font-weight:800;color:#2f6fd0;height:15px}
.wear{background:var(--card);border-radius:18px;padding:9px 14px 10px;flex:1;display:flex;flex-direction:column;gap:7px;min-height:0}
.headline{font-size:27px;font-weight:900;line-height:1.05}
.chips{display:flex;flex-wrap:wrap;gap:6px}
.chip{display:flex;align-items:center;gap:6px;font-size:18px;font-weight:800;background:#fff;border-radius:999px;padding:3px 12px 3px 6px;box-shadow:0 1px 0 rgba(0,0,0,.08)}
.chip i{width:18px;height:18px;border-radius:50%;display:inline-block;border:2px solid rgba(0,0,0,.12)}
.note{font-size:16px;font-weight:800;color:#b5482f}
#status{position:absolute;right:16px;bottom:2px;font-size:11px;font-weight:700;color:var(--muted);opacity:.7}
#status.bad{color:#c0392b;opacity:1;font-size:13px}
body.cold .cheek{opacity:.95}
</style>
</head>
<body>
<svg id="scene" viewBox="0 0 330 480" preserveAspectRatio="xMidYMax meet" aria-label="What to wear">
<defs>
<polyline id="armL" points="130,188 110,236 102,276"/>
<polyline id="sleeveL" points="130,188 110,236 104,262"/>
<polyline id="sleeveShortL" points="130,188 120,212"/>
<polyline id="legL" points="146,290 144,406"/>
<pattern id="stripes" width="16" height="16" patternUnits="userSpaceOnUse">
<rect width="16" height="16" fill="#E0503F"/><rect width="8" height="16" fill="#fff"/>
</pattern>
<g id="sneakerL">
<path d="M118,424 L118,414 Q118,401 132,401 L148,401 Q160,402 162,413 L162,424 Z" fill="#E2574C"/>
<rect x="116" y="420" width="48" height="7" rx="3.5" fill="#fff"/>
<path d="M134,407 L147,407 M134,413 L150,413" stroke="#fff" stroke-width="2.5" stroke-linecap="round"/>
</g>
<g id="rainbootL">
<path d="M126,366 L160,366 L161,408 Q168,411 168,424 L120,424 L123,408 Z" fill="#3BAF6A"/>
<rect x="118" y="420" width="52" height="7" rx="3.5" fill="#2a8a52"/>
<rect x="124" y="363" width="38" height="9" rx="4.5" fill="#2a8a52"/>
</g>
<g id="snowbootL">
<path d="M124,372 L160,372 L161,406 Q170,410 170,424 L118,424 L121,406 Z" fill="#8A5A3B"/>
<rect x="116" y="419" width="56" height="8" rx="4" fill="#4a3020"/>
<rect x="119" y="361" width="46" height="17" rx="8.5" fill="#F3E9DA"/>
</g>
<g id="mittenL">
<rect x="89" y="268" width="26" height="30" rx="12" fill="#E0503F"/>
<rect x="83" y="274" width="11" height="17" rx="5.5" fill="#E0503F" transform="rotate(-20 89 282)"/>
<rect x="88" y="265" width="28" height="9" rx="4.5" fill="#fff"/>
</g>
<g id="lensL"><path d="M134,112 h28 v12 q-14,10 -28,0 Z"/></g>
</defs>
<!-- sky effects (behind the kid) -->
<g id="fx-sun">
<g stroke="#FFB300" stroke-width="6" stroke-linecap="round">
<line x1="62" y1="22" x2="62" y2="10"/><line x1="62" y1="118" x2="62" y2="130"/>
<line x1="14" y1="70" x2="2" y2="70"/><line x1="110" y1="70" x2="122" y2="70"/>
<line x1="28" y1="36" x2="20" y2="28"/><line x1="96" y1="104" x2="104" y2="112"/>
<line x1="28" y1="104" x2="20" y2="112"/><line x1="96" y1="36" x2="104" y2="28"/>
</g>
<circle cx="62" cy="70" r="34" fill="#FFD23F"/>
</g>
<g id="fx-cloud">
<path transform="translate(-6,8) scale(1.25)" d="M24,74 Q8,74 8,59 Q8,45 24,44 Q26,25 47,25 Q63,25 70,39 Q90,37 92,56 Q93,74 76,74 Z" fill="#fff" opacity=".92"/>
<path transform="translate(236,150) scale(.8)" d="M24,74 Q8,74 8,59 Q8,45 24,44 Q26,25 47,25 Q63,25 70,39 Q90,37 92,56 Q93,74 76,74 Z" fill="#fff" opacity=".8"/>
</g>
<g id="fx-rain" fill="#3D8BFD" opacity=".7"></g>
<g id="fx-snow" fill="#fff" stroke="#b9cbe6" stroke-width="1.5"></g>
<g id="fx-bolt"><polygon points="290,150 268,200 286,200 274,240 310,188 292,188 304,150" fill="#FFC107" stroke="#e0a000" stroke-width="2"/></g>
<path class="ground2" d="M0,440 Q90,412 200,432 Q270,444 330,426 L330,480 L0,480 Z"/>
<path class="ground" d="M0,452 Q165,428 330,452 L330,480 L0,480 Z"/>
<ellipse cx="165" cy="428" rx="66" ry="7" fill="#000" opacity=".13"/>
<g id="kid">
<!-- things that go behind the head/body -->
<circle class="o o-raincoat" cx="165" cy="116" r="60" fill="#F7C82F"/>
<path class="o o-hoodie" d="M118,180 Q108,150 132,144 L198,144 Q222,150 212,180 Z" fill="#7a58c9"/>
<!-- legs + arms -->
<g fill="none" stroke="#F5C9A0" stroke-width="28" stroke-linecap="round">
<use href="#legL"/><use href="#legL" transform="matrix(-1 0 0 1 330 0)"/>
</g>
<g fill="none" stroke="#F5C9A0" stroke-width="22" stroke-linecap="round" stroke-linejoin="round">
<use href="#armL"/><use href="#armL" transform="matrix(-1 0 0 1 330 0)"/>
</g>
<!-- shoes that go under pants -->
<g class="o o-sneakers"><use href="#sneakerL"/><use href="#sneakerL" transform="matrix(-1 0 0 1 330 0)"/></g>
<!-- bottoms -->
<g class="o o-shorts">
<path d="M116,284 L214,284 L217,338 L172,338 L165,316 L158,338 L113,338 Z" fill="#3B6FB6"/>
<path d="M165,292 L165,316" stroke="#2d5590" stroke-width="3"/>
</g>
<g class="o o-pants">
<path d="M118,284 L212,284 L206,410 L172,410 L165,318 L158,410 L124,410 Z" fill="#3B6FB6"/>
<path d="M165,292 L165,318 M141,330 L141,404 M189,330 L189,404" stroke="#2d5590" stroke-width="3" stroke-linecap="round"/>
</g>
<g class="o o-snowpants">
<path d="M113,278 L217,278 L213,412 L170,412 L165,322 L160,412 L117,412 Z" fill="#34407A"/>
<rect x="124" y="346" width="34" height="22" rx="8" fill="#4a58a0"/><rect x="172" y="346" width="34" height="22" rx="8" fill="#4a58a0"/>
<path d="M135,278 L135,250 M195,278 L195,250" stroke="#34407A" stroke-width="9" stroke-linecap="round"/>
</g>
<!-- boots go over pants -->
<g class="o o-rainboots"><use href="#rainbootL"/><use href="#rainbootL" transform="matrix(-1 0 0 1 330 0)"/></g>
<g class="o o-snowboots"><use href="#snowbootL"/><use href="#snowbootL" transform="matrix(-1 0 0 1 330 0)"/></g>
<!-- neck -->
<rect x="154" y="150" width="22" height="28" rx="8" fill="#F5C9A0"/>
<!-- shirts -->
<g class="o o-tshirt">
<g fill="none" stroke="#F28C38" stroke-width="30" stroke-linecap="round"><use href="#sleeveShortL"/><use href="#sleeveShortL" transform="matrix(-1 0 0 1 330 0)"/></g>
<path d="M122,184 Q122,172 138,170 L192,170 Q208,172 208,184 L210,294 L120,294 Z" fill="#F28C38"/>
<path d="M150,170 Q165,186 180,170 Z" fill="#F5C9A0"/>
<polygon points="165,212 171,226 186,226 174,235 179,250 165,241 151,250 156,235 144,226 159,226" fill="#FFE066"/>
</g>
<g class="o o-longsleeve">
<g fill="none" stroke="#2BB3A3" stroke-width="26" stroke-linecap="round" stroke-linejoin="round"><use href="#sleeveL"/><use href="#sleeveL" transform="matrix(-1 0 0 1 330 0)"/></g>
<path d="M122,184 Q122,172 138,170 L192,170 Q208,172 208,184 L210,294 L120,294 Z" fill="#2BB3A3"/>
<rect x="121" y="214" width="88" height="9" fill="#6fd6ca"/><rect x="120" y="244" width="90" height="9" fill="#6fd6ca"/>
<path d="M150,170 Q165,184 180,170 Z" fill="#F5C9A0"/>
</g>
<!-- outer layers -->
<g class="o o-hoodie">
<g fill="none" stroke="#8E6CDB" stroke-width="30" stroke-linecap="round" stroke-linejoin="round"><use href="#sleeveL"/><use href="#sleeveL" transform="matrix(-1 0 0 1 330 0)"/></g>
<path d="M118,184 Q118,170 136,168 L194,168 Q212,170 212,184 L214,300 L116,300 Z" fill="#8E6CDB"/>
<rect x="115" y="290" width="100" height="13" rx="6" fill="#7a58c9"/>
<path d="M136,256 L194,256 L200,286 L130,286 Z" fill="#7a58c9"/>
<path d="M155,172 L153,206 M175,172 L177,206" stroke="#fff" stroke-width="3" stroke-linecap="round"/>
<circle cx="153" cy="208" r="3.5" fill="#fff"/><circle cx="177" cy="208" r="3.5" fill="#fff"/>
</g>
<g class="o o-jacket">
<g fill="none" stroke="#E0503F" stroke-width="32" stroke-linecap="round" stroke-linejoin="round"><use href="#sleeveL"/><use href="#sleeveL" transform="matrix(-1 0 0 1 330 0)"/></g>
<path d="M116,184 Q116,168 136,166 L194,166 Q214,168 214,184 L216,304 L114,304 Z" fill="#E0503F"/>
<path d="M165,170 L165,304" stroke="#9e2f24" stroke-width="3"/>
<path d="M140,164 L165,186 L152,198 L132,172 Z M190,164 L165,186 L178,198 L198,172 Z" fill="#c23e2f"/>
<path d="M130,262 L150,262 M180,262 L200,262" stroke="#9e2f24" stroke-width="3" stroke-linecap="round"/>
</g>
<g class="o o-coat">
<g fill="none" stroke="#2F6FD0" stroke-width="38" stroke-linecap="round" stroke-linejoin="round"><use href="#sleeveL"/><use href="#sleeveL" transform="matrix(-1 0 0 1 330 0)"/></g>
<path d="M112,186 Q112,166 136,164 L194,164 Q218,166 218,186 L222,318 L108,318 Z" fill="#2F6FD0"/>
<path d="M112,208 Q165,216 218,208 M111,244 Q165,252 219,244 M110,280 Q165,288 220,280" stroke="#2558aa" stroke-width="3" fill="none"/>
<path d="M165,176 L165,318" stroke="#1d4a91" stroke-width="3"/>
<ellipse cx="165" cy="168" rx="42" ry="13" fill="#F3E9DA"/>
</g>
<g class="o o-raincoat">
<g fill="none" stroke="#F7C82F" stroke-width="32" stroke-linecap="round" stroke-linejoin="round"><use href="#sleeveL"/><use href="#sleeveL" transform="matrix(-1 0 0 1 330 0)"/></g>
<path d="M116,184 Q116,168 136,166 L194,166 Q214,168 214,184 L224,322 L106,322 Z" fill="#F7C82F"/>
<path d="M165,172 L165,322" stroke="#d9a514" stroke-width="3"/>
<circle cx="174" cy="200" r="4" fill="#d9a514"/><circle cx="174" cy="236" r="4" fill="#d9a514"/>
<circle cx="174" cy="272" r="4" fill="#d9a514"/><circle cx="174" cy="306" r="4" fill="#d9a514"/>
</g>
<!-- hands -->
<g class="o o-hands" fill="#F5C9A0"><circle cx="102" cy="282" r="12"/><circle cx="228" cy="282" r="12"/></g>
<g class="o o-mittens"><use href="#mittenL"/><use href="#mittenL" transform="matrix(-1 0 0 1 330 0)"/></g>
<!-- head -->
<g fill="#F5C9A0"><circle cx="119" cy="122" r="10"/><circle cx="211" cy="122" r="10"/><circle cx="165" cy="116" r="46"/></g>
<path d="M118,114 Q114,64 165,62 Q216,64 212,114 Q204,94 186,88 Q176,100 160,92 Q144,102 130,94 Q122,102 118,114 Z" fill="#6B3E26"/>
<g fill="#2B2B2B"><ellipse cx="148" cy="121" rx="4.5" ry="6"/><ellipse cx="182" cy="121" rx="4.5" ry="6"/></g>
<g fill="#fff"><circle cx="149.5" cy="118.5" r="1.6"/><circle cx="183.5" cy="118.5" r="1.6"/></g>
<g class="cheek" fill="#F29C9C" opacity=".6"><ellipse cx="136" cy="137" rx="8" ry="5"/><ellipse cx="194" cy="137" rx="8" ry="5"/></g>
<path d="M152,141 Q165,154 178,141" stroke="#7A3B2E" stroke-width="3.5" fill="none" stroke-linecap="round"/>
<!-- face/head accessories -->
<g class="o o-sunglasses" fill="#222">
<use href="#lensL"/><use href="#lensL" transform="matrix(-1 0 0 1 330 0)"/>
<path d="M162,115 Q165,111 168,115" stroke="#222" stroke-width="3" fill="none"/>
</g>
<path class="o o-raincoat" fill-rule="evenodd" fill="#F7C82F" d="M105,116 a60,60 0 1,0 120,0 a60,60 0 1,0 -120,0 Z M118,116 a47,47 0 1,0 94,0 a47,47 0 1,0 -94,0 Z"/>
<g class="o o-beanie">
<path d="M116,108 Q116,52 165,52 Q214,52 214,108 Z" fill="#E0503F"/>
<rect x="112" y="94" width="106" height="18" rx="9" fill="#c23e2f"/>
<circle cx="165" cy="50" r="13" fill="#fff"/>
</g>
<g class="o o-cap">
<path d="M120,102 Q120,62 165,62 Q210,62 210,102 Z" fill="#3B6FB6"/>
<path d="M112,103 Q165,88 222,103 Q165,117 112,103 Z" fill="#2d5590"/>
<circle cx="165" cy="63" r="4" fill="#2d5590"/>
</g>
<g class="o o-scarf">
<path d="M134,158 Q165,176 196,158 L199,178 Q165,196 131,178 Z" fill="url(#stripes)"/>
<rect x="176" y="176" width="20" height="58" rx="6" fill="url(#stripes)" transform="rotate(-6 186 176)"/>
</g>
<!-- umbrella -->
<g class="o o-umbrella">
<path d="M229,296 L258,30" stroke="#444" stroke-width="4" stroke-linecap="round"/>
<path d="M229,296 Q227,310 217,307" stroke="#444" stroke-width="4" fill="none" stroke-linecap="round"/>
<path d="M188,62 Q258,-6 328,62 Q310,50 293,62 Q275,50 258,62 Q241,50 223,62 Q206,50 188,62 Z" fill="#E85D9E"/>
<path d="M258,22 L223,62 M258,22 L293,62" stroke="#c94683" stroke-width="2.5" fill="none"/>
<circle cx="258" cy="22" r="4" fill="#444"/>
</g>
<g class="o o-hands-top" fill="#F5C9A0"><circle cx="228" cy="282" r="12"/></g>
</g>
</svg>
<main id="info">
<header>
<div><span class="day" id="day">…</span><span class="date" id="date"></span></div>
<div class="clock" id="clock"></div>
</header>
<section class="now">
<div class="big-icon" id="bigIcon"></div>
<div class="temp" id="temp">--<sup>°</sup></div>
<div class="side">
<span class="pill" id="pill"></span>
<span class="desc" id="desc">Loading weather…</span>
<span class="hilo" id="hilo"></span>
</div>
</section>
<section class="timeline" id="timeline"></section>
<section class="wear">
<div class="headline" id="headline"></div>
<div class="chips" id="chips"></div>
<div class="note" id="note"></div>
</section>
<div id="status"></div>
</main>
<script src="config.js"></script>
<script>
// ---- settings -------------------------------------------------------------
// Per-install values (lat, lon, tz) come from config.js, written by install.sh.
const CFG = Object.assign({
lat: 40.0068, lon: -75.2855, // Ardmore, PA
tz: "America/New_York",
refreshMinutes: 10,
showTomorrowAfterHour: 19, // after 7pm, plan for tomorrow
schoolStart: 7, dayEnd: 18, // hours that matter for the outfit
}, window.KIDS_DASHBOARD || {});
// ---- helpers ---------------------------------------------------------------
const $ = id => document.getElementById(id);
const r = n => Math.round(n);
const sum = a => a.reduce((x, y) => x + y, 0);
const pad = n => String(n).padStart(2, "0");
const ymd = d => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
const DAYS = ["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"];
const MONTHS = ["January","February","March","April","May","June","July","August","September","October","November","December"];
const DESC = {0:"Sunny",1:"Mostly sunny",2:"Partly cloudy",3:"Cloudy",45:"Foggy",48:"Foggy",
51:"Drizzle",53:"Drizzle",55:"Drizzle",56:"Icy drizzle",57:"Icy drizzle",61:"Light rain",63:"Rain",
65:"Heavy rain",66:"Icy rain",67:"Icy rain",71:"Light snow",73:"Snow",75:"Heavy snow",77:"Snow",
80:"Rain showers",81:"Rain showers",82:"Big showers",85:"Snow showers",86:"Snow showers",
95:"Thunderstorms",96:"Thunderstorms",99:"Thunderstorms"};
const SNOW = [71,73,75,77,85,86];
function kindOf(c){
if (c <= 1) return "sun"; if (c === 2) return "partly"; if (c === 3) return "cloud";
if (c === 45 || c === 48) return "fog"; if (SNOW.includes(c)) return "snow";
if (c >= 95) return "storm"; return "rain";
}
function feel(t){
if (t < 32) return ["Freezing","#4f6fd0"]; if (t < 45) return ["Cold","#3d8bd9"];
if (t < 58) return ["Chilly","#2f9e98"]; if (t < 72) return ["Nice","#3fa34d"];
if (t < 82) return ["Warm","#ef8a1f"]; return ["Hot","#e2462f"];
}
// ---- weather icons (100x100) ----------------------------------------------
const CLOUD = "M24,74 Q8,74 8,59 Q8,45 24,44 Q26,25 47,25 Q63,25 70,39 Q90,37 92,56 Q93,74 76,74 Z";
function sunSvg(cx, cy, rad){
let rays = "";
for (let a = 0; a < 8; a++){
const t = a * Math.PI / 4, c = Math.cos(t), s = Math.sin(t);
rays += `<line x1="${cx + c*(rad+6)}" y1="${cy + s*(rad+6)}" x2="${cx + c*(rad+14)}" y2="${cy + s*(rad+14)}"/>`;
}
return `<g stroke="#FFB300" stroke-width="5" stroke-linecap="round">${rays}</g><circle cx="${cx}" cy="${cy}" r="${rad}" fill="#FFD23F"/>`;
}
const moonSvg = (cx, cy, k=1) => `<path transform="translate(${cx},${cy}) scale(${k})" d="M6,-24 A24,24 0 1,0 24,10 A19,19 0 1,1 6,-24 Z" fill="#F4E6A1" stroke="#d9c675" stroke-width="2"/>`;
const cloudSvg = (fill, stroke, tf="") => `<path d="${CLOUD}" transform="${tf}" fill="${fill}" stroke="${stroke}" stroke-width="2.5"/>`;
const drops = `<g fill="#3D8BFD"><path d="M34,80 q-5,9 0,12 q5,-3 0,-12Z"/><path d="M52,84 q-5,9 0,12 q5,-3 0,-12Z"/><path d="M70,80 q-5,9 0,12 q5,-3 0,-12Z"/></g>`;
const flakes = `<g fill="#fff" stroke="#7fa6dd" stroke-width="2"><circle cx="34" cy="86" r="5"/><circle cx="52" cy="92" r="5"/><circle cx="70" cy="86" r="5"/></g>`;
function icon(kind, isDay = true){
const lift = "translate(0,-8)";
let s;
switch (kind){
case "sun": s = isDay ? sunSvg(50, 50, 22) : moonSvg(50, 50, 1.3); break;
case "partly": s = (isDay ? sunSvg(36, 34, 16) : moonSvg(36, 34, .9)) + cloudSvg("#fff", "#c3cfdf", "translate(10,14) scale(.88)"); break;
case "cloud": s = cloudSvg("#e3e9f1", "#b5c0cf", "translate(16,-6) scale(.8)") + cloudSvg("#fff", "#b5c0cf", "translate(0,8) scale(.9)"); break;
case "fog": s = cloudSvg("#fff", "#b5c0cf", lift) + `<g stroke="#9aa6b8" stroke-width="5" stroke-linecap="round"><line x1="18" y1="80" x2="82" y2="80"/><line x1="26" y1="91" x2="74" y2="91"/></g>`; break;
case "snow": s = cloudSvg("#fff", "#b5c0cf", lift) + flakes; break;
case "storm": s = cloudSvg("#a3aec0", "#7d889b", lift) + `<polygon points="52,60 38,82 50,82 42,99 64,74 52,74 60,60" fill="#FFC107" stroke="#e0a000" stroke-width="2"/>`; break;
default: s = cloudSvg("#fff", "#b5c0cf", lift) + drops;
}
return `<svg viewBox="0 0 100 100">${s}</svg>`;
}
// ---- scene setup (rain + snow particles, generated once) -------------------
(function makeParticles(){
let seed = 7; const rnd = () => (seed = (seed * 9301 + 49297) % 233280) / 233280;
let rain = "", snow = "";
for (let i = 0; i < 46; i++){
const x = rnd() * 330, y = rnd() * 420;
rain += `<path d="M${x.toFixed(1)},${y.toFixed(1)} q-4,9 0,11 q4,-2 0,-11Z"/>`;
}
for (let i = 0; i < 40; i++){
const x = rnd() * 330, y = rnd() * 420, rr = 3 + rnd() * 3.5;
snow += `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${rr.toFixed(1)}"/>`;
}
$("fx-rain").innerHTML = rain; $("fx-snow").innerHTML = snow;
})();
const SKY = {
sun: ["#6cc2ff","#e3f4ff","#7cc66b","#69b45a"],
partly: ["#8fcaf3","#eaf5ff","#7cc66b","#69b45a"],
cloud: ["#aebccd","#eef2f7","#86b97a","#74a669"],
fog: ["#c3ccd8","#f1f3f6","#8fb886","#7ea676"],
rain: ["#8aa0b8","#dde5ee","#6fae63","#5f9a55"],
storm: ["#6f7f96","#cfd8e3","#6fae63","#5f9a55"],
snow: ["#c3d2e6","#f4f8fc","#f7f9fc","#e3eaf3"],
};
// ---- the outfit brain -------------------------------------------------------
function summarize(d, dateStr, fromHour){
const H = d.hourly, ids = [];
H.time.forEach((t, i) => { if (t.startsWith(dateStr)) ids.push({ i, h: +t.slice(11, 13) }); });
const pick = (a, b) => ids.filter(x => x.h >= a && x.h <= b).map(x => x.i);
const start = Math.min(Math.max(CFG.schoolStart, fromHour), CFG.dayEnd);
const outTheDoor = pick(start, start + 2), day = pick(start, CFG.dayEnd);
const v = (arr, sel) => sel.map(i => arr[i]).filter(x => x != null);
const codes = v(H.weather_code, day);
const s = {
coldFeel: Math.min(...v(H.apparent_temperature, outTheDoor)),
warmFeel: Math.max(...v(H.apparent_temperature, day)),
rainProb: Math.max(0, ...v(H.precipitation_probability, day)),
precip: sum(v(H.precipitation, day)),
snow: sum(v(H.snowfall, day)),
uv: Math.max(0, ...v(H.uv_index, day)),
wind: Math.max(0, ...v(H.wind_speed_10m, day)),
storm: codes.some(c => c >= 95),
};
s.snowy = s.snow >= 0.2 || codes.filter(c => SNOW.includes(c)).length >= 3;
s.wet = !s.snowy && (s.rainProb >= 50 || s.precip >= 0.05);
s.sunny = codes.filter(c => c <= 2).length >= codes.length / 2;
return s;
}
function outfit(s){
const b = s.coldFeel, w = s.warmFeel;
const o = { top: "longsleeve", bottom: "pants", outer: null, hat: null, shoes: "sneakers",
mittens: false, scarf: false, umbrella: false, sunglasses: false };
if (b >= 68 || (w >= 78 && b >= 60)) o.top = "tshirt";
if (w >= 76 && b >= 62) o.bottom = "shorts";
if (b < 34) o.outer = "coat"; else if (b < 48) o.outer = "jacket"; else if (b < 60) o.outer = "hoodie";
if (b < 40) o.hat = "beanie";
if (b < 34) o.mittens = true;
if (b < 26) o.scarf = true;
if (s.snowy){
o.shoes = "snowboots"; o.hat = "beanie"; o.bottom = o.bottom === "shorts" ? "pants" : o.bottom;
if (b < 36){ o.bottom = "snowpants"; o.mittens = true; }
if (!o.outer || o.outer === "hoodie") o.outer = "jacket";
}
if (s.wet){
o.shoes = "rainboots"; o.umbrella = true;
if (b >= 45 && b < 72){ o.outer = "raincoat"; o.hat = "hood"; }
}
if (!s.wet && !s.snowy && s.sunny && w >= 75 && s.uv >= 5){ o.hat = "cap"; o.sunglasses = w >= 80; }
const chips = [];
const add = (label, color) => chips.push([label, color]);
({ coat: () => add("Winter coat", "#2F6FD0"), jacket: () => add("Jacket", "#E0503F"),
hoodie: () => add("Sweatshirt", "#8E6CDB"), raincoat: () => add("Raincoat", "#F7C82F") }[o.outer] || (() => {}))();
add(o.top === "tshirt" ? "T-shirt" : "Long sleeves", o.top === "tshirt" ? "#F28C38" : "#2BB3A3");
add({ shorts: "Shorts", pants: "Pants", snowpants: "Snow pants" }[o.bottom], o.bottom === "snowpants" ? "#34407A" : "#3B6FB6");
if (o.hat === "beanie") add("Warm hat", "#E0503F");
if (o.hat === "cap") add("Sun hat", "#3B6FB6");
if (o.mittens) add("Gloves", "#E0503F");
if (o.scarf) add("Scarf", "#E0503F");
if (o.shoes === "rainboots") add("Rain boots", "#3BAF6A");
if (o.shoes === "snowboots") add("Snow boots", "#8A5A3B");
if (o.umbrella) add("Umbrella", "#E85D9E");
if (o.sunglasses) add("Sunglasses", "#222");
if (!s.wet && !s.snowy && s.uv >= 6) add("Sunscreen", "#FFD23F");
if (w >= 85) add("Water bottle", "#3D8BFD");
let headline;
if (s.snowy) headline = "Snow day! Bundle up!";
else if (s.wet) headline = "Rainy day. Stay dry!";
else if (o.outer === "coat") headline = "Brrr! Wear your warm coat!";
else if (o.outer === "jacket") headline = "Grab a jacket!";
else if (o.outer === "hoodie") headline = "Bring a sweatshirt.";
else if (o.bottom === "shorts") headline = "Shorts weather!";
else if (o.top === "tshirt") headline = "T-shirt weather!";
else headline = "Long sleeves today.";
const notes = [];
if (s.storm) notes.push("Thunderstorms! Go inside if you hear thunder.");
if (o.outer && w - b >= 15) notes.push("Cold morning, warm afternoon. Wear layers you can take off!");
if (s.wind >= 20) notes.push("Very windy today!");
if (!s.wet && !s.snowy && s.uv >= 7 && !notes.length) notes.push("Strong sun today.");
return { o, chips, headline, note: notes.slice(0, 1).join(" ") };
}
function dressKid(o, kind){
const on = new Set([
"o-" + o.top, "o-" + o.bottom, "o-" + o.shoes,
o.outer && "o-" + o.outer, o.hat && o.hat !== "hood" && "o-" + o.hat,
o.mittens ? "o-mittens" : "o-hands", o.scarf && "o-scarf",
o.umbrella && "o-umbrella", o.sunglasses && "o-sunglasses",
o.umbrella && !o.mittens && "o-hands-top",
].filter(Boolean));
if (o.umbrella && o.mittens) on.add("o-mittens");
document.querySelectorAll("#kid .o").forEach(el => {
const cls = [...el.classList].find(c => c.startsWith("o-"));
el.style.display = on.has(cls) ? "" : "none";
});
$("fx-sun").style.display = (kind === "sun" || kind === "partly") ? "" : "none";
$("fx-cloud").style.display = (kind === "sun") ? "none" : "";
$("fx-rain").style.display = (kind === "rain" || kind === "storm") ? "" : "none";
$("fx-snow").style.display = (kind === "snow") ? "" : "none";
$("fx-bolt").style.display = (kind === "storm") ? "" : "none";
const [s1, s2, g1, g2] = SKY[kind] || SKY.partly;
const st = document.documentElement.style;
st.setProperty("--sky1", s1); st.setProperty("--sky2", s2);
st.setProperty("--ground", g1); st.setProperty("--ground2", g2);
}
// ---- data -------------------------------------------------------------------
let data = null, lastError = null;
const API = "https://api.open-meteo.com/v1/forecast?" + new URLSearchParams({
latitude: CFG.lat, longitude: CFG.lon, timezone: CFG.tz, forecast_days: 2,
temperature_unit: "fahrenheit", wind_speed_unit: "mph", precipitation_unit: "inch",
current: "temperature_2m,apparent_temperature,weather_code,is_day",
hourly: "temperature_2m,apparent_temperature,precipitation_probability,precipitation,snowfall,weather_code,wind_speed_10m,uv_index,is_day",
daily: "weather_code,temperature_2m_max,temperature_2m_min,apparent_temperature_max",
});
async function load(){
if (DEMO) { data = DEMO; render(); return; }
try {
const res = await fetch(API, { cache: "no-store" });
if (!res.ok) throw new Error("HTTP " + res.status);
const j = await res.json();
j._fetched = Date.now(); data = j; lastError = null;
try { localStorage.setItem("wx", JSON.stringify(j)); } catch (e) {}
} catch (e) {
lastError = e;
if (!data) { try { data = JSON.parse(localStorage.getItem("wx")); } catch (_) {} }
}
render();
}
// ---- render -----------------------------------------------------------------
function render(){
const now = new Date();
const h = now.getHours();
$("clock").innerHTML = `${(h % 12) || 12}:${pad(now.getMinutes())}<small>${h < 12 ? "am" : "pm"}</small>`;
const tomorrow = h >= CFG.showTomorrowAfterHour;
const target = new Date(now); if (tomorrow) target.setDate(now.getDate() + 1);
const dateStr = ymd(target);
$("day").textContent = tomorrow ? "Tomorrow" : DAYS[target.getDay()];
$("date").textContent = (tomorrow ? DAYS[target.getDay()] + ", " : "") + MONTHS[target.getMonth()] + " " + target.getDate();
const di = data && data.daily ? data.daily.time.indexOf(dateStr) : -1;
if (di < 0){
$("desc").textContent = "Waiting for the weather…";
$("status").textContent = lastError ? "No internet connection" : "";
$("status").className = lastError ? "bad" : "";
return;
}
const D = data.daily, H = data.hourly;
const s = summarize(data, dateStr, tomorrow ? 0 : h);
const plan = outfit(s);
// the "scene" weather kind for the day
const dayKind = s.storm ? "storm" : s.snowy ? "snow" : s.wet ? "rain" : kindOf(D.weather_code[di]) === "rain" ? "cloud" : kindOf(D.weather_code[di]);
dressKid(plan.o, dayKind);
document.body.classList.toggle("cold", s.coldFeel < 40);
// big number: right now (today) or tomorrow's high
let bigT, bigCode, isDay;
if (!tomorrow && data.current){
bigT = data.current.temperature_2m;
bigCode = data.current.weather_code; isDay = !!data.current.is_day;
} else {
bigT = D.temperature_2m_max[di];
bigCode = D.weather_code[di]; isDay = true;
}
const bigKind = tomorrow ? dayKind : kindOf(bigCode);
$("bigIcon").innerHTML = icon(bigKind, isDay);
$("temp").innerHTML = `${r(bigT)}<sup>°</sup>`;
const [fw, fc] = feel(bigT);
$("pill").textContent = tomorrow ? "High · " + fw : fw; $("pill").style.background = fc;
let desc = DESC[bigCode] || "";
if (!isDay && bigCode <= 1) desc = "Clear night";
if (tomorrow) desc = { storm: "Thunderstorms", snow: "Snowy", rain: "Rainy" }[dayKind] || DESC[D.weather_code[di]] || "";
$("desc").textContent = desc;
$("hilo").innerHTML = `High <b>${r(D.temperature_2m_max[di])}°</b> &nbsp;Low <b>${r(D.temperature_2m_min[di])}°</b>`;
// timeline
const slots = [[8, "Morning"], [12, "Lunch"], [15, "After school"], [18, "Evening"]];
$("timeline").innerHTML = slots.map(([hr, lbl]) => {
const i = H.time.indexOf(`${dateStr}T${pad(hr)}:00`);
if (i < 0) return `<div class="slot"><div class="lbl">${lbl}</div></div>`;
const p = H.precipitation_probability[i];
const past = !tomorrow && hr < h;
return `<div class="slot${past ? " past" : ""}"><div class="lbl">${lbl}</div>${icon(kindOf(H.weather_code[i]), !!H.is_day[i])}
<div class="t">${r(H.temperature_2m[i])}°</div><div class="r">${p >= 20 ? p + (SNOW.includes(H.weather_code[i]) ? "% snow" : "% rain") : ""}</div></div>`;
}).join("");
// what to wear
$("headline").textContent = plan.headline;
$("chips").innerHTML = plan.chips.map(([l, c]) => `<span class="chip"><i style="background:${c}"></i>${l}</span>`).join("");
$("note").textContent = plan.note;
// status line
const t = data._fetched ? new Date(data._fetched) : null;
const stale = t && (Date.now() - t) > 3 * 3600e3;
const tt = t ? `${(t.getHours() % 12) || 12}:${pad(t.getMinutes())}` : "";
$("status").textContent = DEMO ? "demo" : (lastError || stale) ? `No internet. Weather from ${tt}` : `Updated ${tt}`;
$("status").className = (lastError || stale) && !DEMO ? "bad" : "";
}
// ---- demo mode: index.html?demo=lo,hi,code[,rain%][,uv] e.g. ?demo=28,38,73 ----
const DEMO = (() => {
const m = location.search.match(/demo=([\d.,-]+)/); if (!m) return null;
const [lo, hi, code, rain = 0, uv = 3] = m[1].split(",").map(Number);
const today = new Date(), days = [ymd(today), ymd(new Date(today.getTime() + 864e5))];
const H = { time: [], temperature_2m: [], apparent_temperature: [], precipitation_probability: [], precipitation: [], snowfall: [], weather_code: [], wind_speed_10m: [], uv_index: [], is_day: [] };
for (const d of days) for (let hr = 0; hr < 24; hr++){
const t = lo + (hi - lo) * Math.max(0, Math.sin((hr - 6) / 18 * Math.PI) ** 1.3);
H.time.push(`${d}T${pad(hr)}:00`); H.temperature_2m.push(t); H.apparent_temperature.push(t - (t < 50 ? 4 : 0));
H.precipitation_probability.push(rain); H.precipitation.push(rain > 50 ? .05 : 0);
H.snowfall.push(SNOW.includes(code) ? .1 : 0); H.weather_code.push(code); H.wind_speed_10m.push(8);
H.uv_index.push(hr > 9 && hr < 16 ? uv : 1); H.is_day.push(hr >= 7 && hr < 18 ? 1 : 0);
}
return { hourly: H, current: { temperature_2m: (lo + hi) / 2, apparent_temperature: (lo + hi) / 2, weather_code: code, is_day: 1 },
daily: { time: days, weather_code: [code, code], temperature_2m_max: [hi, hi], temperature_2m_min: [lo, lo], apparent_temperature_max: [hi, hi] }, _fetched: Date.now() };
})();
// ---- go ---------------------------------------------------------------------
document.addEventListener("contextmenu", e => e.preventDefault());
load();
setInterval(load, CFG.refreshMinutes * 60e3);
setInterval(render, 20e3);
// fresh page once a day (keeps Chromium tidy on a long-running kiosk)
setInterval(() => { const n = new Date(); if (n.getHours() === 3 && n.getMinutes() < 2) location.reload(); }, 60e3);
</script>
</body>
</html>
Executable
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env bash
# Kids weather dashboard installer for Raspberry Pi OS (Bookworm/Trixie, desktop, labwc).
# Run it ON THE PI as the user the dashboard should run as (not root):
#
# ./install.sh # auto-detect location from IP, screen off 21:00-06:30
# ./install.sh --lat 40.0 --lon -75.3 # set location explicitly
# ./install.sh --off 2030 --on 0700 # change the night-off schedule
# ./install.sh --no-night-off # keep the screen on 24/7
# ./install.sh --uninstall
#
# Safe to re-run: settings you don't pass are kept from the previous install.
set -euo pipefail
REPO=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
DEST="$HOME/kids-dashboard"
AUTOSTART="$HOME/.config/labwc/autostart"
PROFILE="$HOME/.config/kids-dashboard-chromium"
SERVICE=tap-wake.service
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31mxx\033[0m %s\n' "$*" >&2; exit 1; }
usage() { sed -n '2,11p' "$0" | sed 's/^# \{0,1\}//'; exit "${1:-0}"; }
# ---- arguments -------------------------------------------------------------------------
LAT= LON= OFF_AT= ON_AT= AWAKE_SECONDS= NIGHT_OFF= UNINSTALL=0
while [ $# -gt 0 ]; do
case "$1" in
--lat) LAT=${2:?}; shift ;;
--lon) LON=${2:?}; shift ;;
--off) OFF_AT=${2:?}; shift ;;
--on) ON_AT=${2:?}; shift ;;
--awake) AWAKE_SECONDS=${2:?}; shift ;;
--no-night-off) NIGHT_OFF=0 ;;
--night-off) NIGHT_OFF=1 ;;
--uninstall) UNINSTALL=1 ;;
-h|--help) usage ;;
*) warn "unknown option: $1"; usage 1 ;;
esac
shift
done
[ "$(id -u)" -ne 0 ] || die "run this as the desktop user (e.g. 'pi'), not root; it uses sudo where needed."
[ "$REPO" != "$(cd "$DEST" 2>/dev/null && pwd)" ] || die "the repo can't live in $DEST (that's where it installs to); clone it somewhere else."
remove_cron() { (crontab -l 2>/dev/null || true) | { grep -v 'kids-dashboard/screen.sh' || true; } | crontab -; }
restart_kiosk() { pkill -f -- "--user-data-dir=$PROFILE" 2>/dev/null || true; } # lwrespawn relaunches it
# ---- uninstall -------------------------------------------------------------------------
if [ "$UNINSTALL" = 1 ]; then
say "Removing kids dashboard"
systemctl --user disable --now "$SERVICE" 2>/dev/null || true
rm -f "$HOME/.config/systemd/user/$SERVICE"; systemctl --user daemon-reload 2>/dev/null || true
remove_cron
if [ -f "$AUTOSTART" ]; then
sed -i '/kids-dashboard\|Kids weather dashboard/d' "$AUTOSTART"
grep -q '[^[:space:]]' "$AUTOSTART" || rm -f "$AUTOSTART"
fi
[ -x "$DEST/screen.sh" ] && "$DEST/screen.sh" on 2>/dev/null || true
rm -rf "$DEST" # first, so lwrespawn has nothing left to relaunch
restart_kiosk
for _ in $(seq 20); do pgrep -f -- "--user-data-dir=$PROFILE" >/dev/null || break; sleep 0.5; done
rm -rf "$PROFILE"
say "Done. Reboot to get the normal desktop back. (Packages and autologin were left as they are.)"
exit 0
fi
# ---- previous settings become the defaults ------------------------------------------------
if [ -f "$DEST/config.js" ]; then
: "${LAT:=$(sed -n 's/.*lat: *\([-0-9.]*\).*/\1/p' "$DEST/config.js")}"
: "${LON:=$(sed -n 's/.*lon: *\([-0-9.]*\).*/\1/p' "$DEST/config.js")}"
fi
if [ -f "$DEST/screen.conf" ]; then
# shellcheck disable=SC1091
prev() { (. "$DEST/screen.conf"; eval "echo \${$1:-}"); }
: "${OFF_AT:=$(prev OFF_AT)}" "${ON_AT:=$(prev ON_AT)}"
: "${AWAKE_SECONDS:=$(prev AWAKE_SECONDS)}" "${NIGHT_OFF:=$(prev NIGHT_OFF)}"
fi
: "${OFF_AT:=2100}" "${ON_AT:=0630}" "${AWAKE_SECONDS:=60}" "${NIGHT_OFF:=1}"
[[ $OFF_AT =~ ^([01][0-9]|2[0-3])[0-5][0-9]$ ]] || die "--off must be HHMM (24h), got '$OFF_AT'"
[[ $ON_AT =~ ^([01][0-9]|2[0-3])[0-5][0-9]$ ]] || die "--on must be HHMM (24h), got '$ON_AT'"
[[ $AWAKE_SECONDS =~ ^[0-9]+$ ]] || die "--awake must be a number of seconds"
# ---- sanity checks -----------------------------------------------------------------------
command -v raspi-config >/dev/null || warn "raspi-config not found; this is meant for Raspberry Pi OS. Continuing anyway."
command -v labwc >/dev/null || [ -f /usr/share/wayland-sessions/rpd-labwc.desktop ] \
|| die "labwc not found. Install the Raspberry Pi OS *desktop* image (not Lite) and re-run."
# ---- packages ----------------------------------------------------------------------------
need=()
for pkg in chromium wlopm python3 cron curl; do dpkg -s "$pkg" >/dev/null 2>&1 || need+=("$pkg"); done
if [ ${#need[@]} -gt 0 ]; then
say "Installing packages: ${need[*]}"
sudo apt-get update -qq
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "${need[@]}"
fi
# ---- location ----------------------------------------------------------------------------
TZ_NAME=$(timedatectl show -p Timezone --value 2>/dev/null || cat /etc/timezone 2>/dev/null || echo UTC)
if [ -z "$LAT" ] || [ -z "$LON" ]; then
say "Looking up location from this network's public IP"
loc=$(curl -fsS --max-time 10 https://ipinfo.io/json \
| python3 -c 'import json,sys; j=json.load(sys.stdin); print(j["loc"].replace(","," "), j.get("city",""), j.get("region",""), sep="|")' 2>/dev/null) \
|| die "couldn't detect location; pass it with --lat and --lon"
read -r LAT LON <<<"${loc%%|*}"
say "Found: $(cut -d'|' -f2- <<<"$loc" | tr '|' ' ') ($LAT, $LON). Use --lat/--lon if that's wrong."
fi
[[ $LAT =~ ^-?[0-9]+(\.[0-9]+)?$ && $LON =~ ^-?[0-9]+(\.[0-9]+)?$ ]] || die "bad --lat/--lon: '$LAT' '$LON'"
# ---- desktop session: labwc + autologin --------------------------------------------------
if command -v raspi-config >/dev/null; then
if ! grep -qE '^autologin-session=.*labwc' /etc/lightdm/lightdm.conf 2>/dev/null; then
say "Switching the desktop to labwc (Wayland)"
sudo raspi-config nonint do_wayland W2
fi
if ! grep -qE "^autologin-user=$USER\$" /etc/lightdm/lightdm.conf 2>/dev/null; then
say "Turning on desktop autologin for $USER"
sudo raspi-config nonint do_boot_behaviour B4
fi
fi
if ! id -nG | tr ' ' '\n' | grep -qx input; then
say "Adding $USER to the 'input' group (needed for tap-to-wake)"
sudo usermod -aG input "$USER"
NEEDS_REBOOT=1
fi
# ---- files -------------------------------------------------------------------------------
say "Installing to $DEST"
mkdir -p "$DEST"
install -m 644 "$REPO/dashboard/index.html" "$DEST/index.html"
install -m 755 "$REPO/bin/kiosk.sh" "$REPO/bin/screen.sh" "$REPO/bin/tap-wake.py" "$DEST/"
cat > "$DEST/config.js" <<EOF
// Written by install.sh. Re-run it with --lat/--lon to change.
window.KIDS_DASHBOARD = { lat: $LAT, lon: $LON, tz: "$TZ_NAME" };
EOF
cat > "$DEST/screen.conf" <<EOF
# Written by install.sh. Re-run it with --off/--on/--awake/--no-night-off to change
# (the cron schedule is generated from these values).
NIGHT_OFF=$NIGHT_OFF
OFF_AT=$OFF_AT
ON_AT=$ON_AT
AWAKE_SECONDS=$AWAKE_SECONDS
OUTPUT='*'
EOF
# ---- start at login ----------------------------------------------------------------------
mkdir -p "$(dirname "$AUTOSTART")"
touch "$AUTOSTART"
if ! grep -q 'kids-dashboard/kiosk.sh' "$AUTOSTART"; then
cat >> "$AUTOSTART" <<EOF
# kids-dashboard kiosk (labwc also runs /etc/xdg/labwc/autostart; the kiosk covers it).
/usr/bin/lwrespawn $DEST/kiosk.sh &
EOF
fi
# ---- night-off schedule + tap to wake -----------------------------------------------------
remove_cron
mkdir -p "$HOME/.config/systemd/user"
if [ "$NIGHT_OFF" = 1 ]; then
say "Screen off $OFF_AT-$ON_AT, tap to wake for ${AWAKE_SECONDS}s"
{ crontab -l 2>/dev/null || true
echo "$((10#${OFF_AT:2:2})) $((10#${OFF_AT:0:2})) * * * $DEST/screen.sh off"
echo "$((10#${ON_AT:2:2})) $((10#${ON_AT:0:2})) * * * $DEST/screen.sh on"
echo "@reboot sleep 90; $DEST/screen.sh auto"
} | crontab -
install -m 644 "$REPO/systemd/$SERVICE" "$HOME/.config/systemd/user/$SERVICE"
systemctl --user daemon-reload
systemctl --user enable "$SERVICE" >/dev/null 2>&1
systemctl --user restart "$SERVICE" 2>/dev/null || true
else
say "Night-off disabled; screen stays on"
systemctl --user disable --now "$SERVICE" 2>/dev/null || true
"$DEST/screen.sh" on 2>/dev/null || true
fi
# ---- go ----------------------------------------------------------------------------------
if [ "${NEEDS_REBOOT:-0}" = 0 ] && pgrep -u "$USER" -x labwc >/dev/null && pgrep -u "$USER" -f 'kids-dashboard/kiosk.sh' >/dev/null; then
restart_kiosk
say "Done. The dashboard is reloading with the new version."
else
say "Done. Reboot to start the dashboard: sudo reboot"
fi
+10
View File
@@ -0,0 +1,10 @@
[Unit]
Description=Kids dashboard: tap the screen to wake it at night
[Service]
ExecStart=/usr/bin/python3 %h/kids-dashboard/tap-wake.py
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target