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:
commit
c98a5e5ed6
8 files changed
+954
No files matched your search
@@ -0,0 +1,3 @@
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
shots/
|
||||
@@ -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
@@ -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
@@ -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
|
||||
Executable
+59
@@ -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
|
||||
@@ -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> 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
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user