mirror of
https://github.com/lynchaos/ashvale-station.git
synced 2026-09-12 12:47:49 +00:00
Prebuilt Pi image and a one-line installer
Two routes onto a Pi. The image for a blank card, the script for a board that already works, which is most of them. deploy/install.sh installs apt dependencies, clones to /opt/ashvale, builds a venv with --system-site-packages, enables I2C and installs a systemd unit. Tested end to end on the real Zero 2 W by installing a second instance on port 8099 alongside the live station: both served, and the live station was untouched throughout. That test earned its keep twice. The installer first declared success while the service was crash-looping on a port clash, because systemd reports active for the instant between exec and the first failed bind; it now polls the HTTP endpoint instead, which is the only check that means anything. And my first attempt to verify that fix was itself worthless, because curl on 127.0.0.1:8000 was answered by the live station rather than the instance under test. deploy/pi-image is a pi-gen stage on Raspberry Pi OS Lite, Trixie, arm64, which is exactly what the board runs. Built by .github/workflows/image.yml against a pinned pi-gen commit, so the artifact does not move when an upstream branch does, and published to Releases where the 2 GB asset limit comfortably fits a Lite image. The image ships no password, no WiFi and no SSH host keys. Baked host keys would give every person who flashed it the same identity and make them trivially impersonable on their own network. Coordinates default to Greenwich at 0 m, wrong for everybody on purpose, because a plausible wrong altitude quietly biases the sea-level reduction on every row. The source is copied through a .gitignore filter rather than a hand-written exclude list, and that is a security property rather than tidiness: the hand-written list I wrote first missed HANDOVER.md, which is gitignored precisely because it contains LAN addresses and SSH details. Verified: 69 files, no state, no local notes, all essentials present.
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
# pi-gen configuration for the Ashvale Station image.
|
||||
#
|
||||
# Built on Raspberry Pi OS Lite (Trixie, arm64), which is what a Zero 2 W
|
||||
# actually runs: aarch64, Debian 13. Lite because the station is headless and a
|
||||
# desktop would eat the 512 MB budget the whole project is built around.
|
||||
#
|
||||
# Nothing secret belongs in this file. No password, no WiFi, no SSH host keys.
|
||||
# The first two are supplied by the user in Raspberry Pi Imager, and the third
|
||||
# is generated on first boot: an image with baked host keys gives every user
|
||||
# the same key and makes them trivially impersonable on their own network.
|
||||
|
||||
IMG_NAME=ashvale-station
|
||||
RELEASE=trixie
|
||||
# ARCH is not set here: the arm64 branch of pi-gen exports it unconditionally,
|
||||
# so a value here would be silently ignored and imply a choice that is not ours.
|
||||
|
||||
# Lite only. stage3 and up add the desktop.
|
||||
STAGE_LIST="stage0 stage1 stage2 stage-ashvale"
|
||||
|
||||
# No default user. Raspberry Pi OS refuses to boot to a login without one, which
|
||||
# is deliberate: it forces the person flashing the card to choose credentials in
|
||||
# Imager rather than inheriting ours.
|
||||
DISABLE_FIRST_BOOT_USER_RENAME=0
|
||||
|
||||
DEPLOY_COMPRESSION=xz
|
||||
COMPRESSION_LEVEL=6
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash -e
|
||||
# Runs inside the target filesystem, so pip resolves against the image's Python.
|
||||
|
||||
# --system-site-packages so numpy and the Sense HAT stack come from apt. Building
|
||||
# them in a clean venv on ARM means compiling RTIMULib and numpy from source,
|
||||
# which is an ordeal on a Zero 2 W and pointless when Debian ships both.
|
||||
python3 -m venv --system-site-packages /opt/ashvale/.venv
|
||||
/opt/ashvale/.venv/bin/pip install --no-cache-dir --upgrade pip
|
||||
# Plain uvicorn, never uvicorn[standard]: the extra pulls watchfiles and uvloop,
|
||||
# both of which compile Rust and C from source on ARM for features unused here.
|
||||
/opt/ashvale/.venv/bin/pip install --no-cache-dir -r /opt/ashvale/requirements.txt
|
||||
|
||||
install -d -o 1000 -g 1000 /opt/ashvale/data
|
||||
|
||||
systemctl enable ashvale-firstboot.service
|
||||
systemctl enable ashvale.service
|
||||
|
||||
# Belt and braces. Raspberry Pi OS regenerates these on first boot, but an image
|
||||
# that shipped real host keys would give every flasher the same identity, so
|
||||
# make certain none are present in the artifact.
|
||||
rm -f /etc/ssh/ssh_host_*
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
#!/bin/bash -e
|
||||
# Runs on the build host with ${ROOTFS_DIR} pointing at the target filesystem.
|
||||
|
||||
install -d "${ROOTFS_DIR}/opt/ashvale"
|
||||
|
||||
# The application source, straight from the repository being built.
|
||||
#
|
||||
# The filter honours .gitignore rather than listing excludes by hand, and that
|
||||
# is a security property, not tidiness. A hand-written list missed HANDOVER.md,
|
||||
# which is gitignored precisely because it contains LAN addresses and SSH
|
||||
# details: a local build would have baked one person's network into an image
|
||||
# other people flash. Anything not fit to commit is not fit to ship.
|
||||
rsync -a --delete \
|
||||
--filter=':- .gitignore' \
|
||||
--exclude '.git/' --exclude '.gitignore' --exclude 'deploy/' \
|
||||
--exclude 'tests/' --exclude '.github/' --exclude '.DS_Store' \
|
||||
"${ASHVALE_SRC}/" "${ROOTFS_DIR}/opt/ashvale/"
|
||||
|
||||
install -m 644 files/ashvale.service "${ROOTFS_DIR}/etc/systemd/system/ashvale.service"
|
||||
install -m 755 files/ashvale-firstboot "${ROOTFS_DIR}/usr/local/sbin/ashvale-firstboot"
|
||||
install -m 644 files/ashvale-firstboot.service "${ROOTFS_DIR}/etc/systemd/system/ashvale-firstboot.service"
|
||||
install -m 755 files/motd.sh "${ROOTFS_DIR}/etc/update-motd.d/20-ashvale"
|
||||
install -m 644 files/README.first-boot "${ROOTFS_DIR}/opt/ashvale/README.first-boot"
|
||||
|
||||
# I2C is not optional: without it the Sense HAT is invisible and the station
|
||||
# silently falls back to its simulator, which looks like it works and is not
|
||||
# measuring anything.
|
||||
CONFIG_TXT="${ROOTFS_DIR}/boot/firmware/config.txt"
|
||||
[ -f "$CONFIG_TXT" ] || CONFIG_TXT="${ROOTFS_DIR}/boot/config.txt"
|
||||
if ! grep -q '^dtparam=i2c_arm=on' "$CONFIG_TXT"; then
|
||||
cat >> "$CONFIG_TXT" <<'CFG'
|
||||
|
||||
# --- Ashvale Station ---
|
||||
# Sense HAT sits on I2C. Without this the board is not detected at all.
|
||||
dtparam=i2c_arm=on
|
||||
# Uncomment for a DS18B20 outdoor probe on GPIO4. Left off by default because
|
||||
# it claims that pin whether or not a sensor is attached.
|
||||
#dtoverlay=w1-gpio
|
||||
CFG
|
||||
fi
|
||||
@@ -0,0 +1,41 @@
|
||||
Ashvale Station
|
||||
===============
|
||||
|
||||
The service is already running. Open http://<this-pi>:8000 from the same network.
|
||||
|
||||
Three things to do, in order of how much they matter.
|
||||
|
||||
1. Set your location. Settings tab -> Site and model. Altitude feeds the
|
||||
sea-level pressure reduction on every stored row, and pressure tendency is
|
||||
what drives the precipitation forecast, so a wrong altitude quietly biases
|
||||
the thing this station is best at. The image ships Greenwich at 0 m, which
|
||||
is wrong for you on purpose.
|
||||
|
||||
2. Calibrate the temperature. Models and Calibration tab. Put any thermometer
|
||||
next to the board, wait ten minutes, type the number in. The Sense HAT sits
|
||||
millimetres above a SoC running 20 C hotter than the room, and one reading
|
||||
fixes the bias on every forecast that follows. This is the highest value per
|
||||
minute of anything you can do.
|
||||
|
||||
3. Tell it where it lives. Settings tab -> Surroundings. Indoors the building
|
||||
governs temperature and humidity rather than the sky. Say so, and say when
|
||||
you open a window or turn the heating on, because those are changes of
|
||||
regime and the models carry about 55 hours of memory.
|
||||
|
||||
Forecasts appear after about 10 hours of history and the scorecard fills in over
|
||||
the following day as each horizon matures. Until a head has been scored, its
|
||||
forecast is an opinion.
|
||||
|
||||
Security
|
||||
--------
|
||||
No authentication, no TLS, binds 0.0.0.0. It is built for a trusted home
|
||||
network. Do not port-forward it: /api/train, /api/calibrate, /api/label and
|
||||
/api/settings all mutate model state.
|
||||
|
||||
Where things are
|
||||
----------------
|
||||
/opt/ashvale application
|
||||
/opt/ashvale/config.yaml site configuration
|
||||
/opt/ashvale/data database and trained state (never in the image)
|
||||
systemctl status ashvale service
|
||||
journalctl -u ashvale -f logs
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
# Runs once, before the station starts. Everything here is deliberately absent
|
||||
# from the image itself, because baking it in would mean every person who
|
||||
# flashed this card shared the same secrets or the same location.
|
||||
set -e
|
||||
STATE=/opt/ashvale/data
|
||||
CONF=/opt/ashvale/config.yaml
|
||||
UID_MAIN=$(getent passwd 1000 | cut -d: -f1)
|
||||
|
||||
install -d -o 1000 -g 1000 "$STATE" "$STATE/state"
|
||||
|
||||
# Site coordinates default to Greenwich, not to the author's house. They are
|
||||
# wrong for everyone, which is the point: the Methods tab and the sea-level
|
||||
# reduction both depend on them, so they should be conspicuously wrong until set.
|
||||
if [ -f "$CONF" ] && ! grep -q 'ASHVALE_FIRSTBOOT_DONE' "$CONF"; then
|
||||
sed -i 's/^\( *latitude:\).*/\1 51.4779 # CHANGE ME: Settings tab or this file/' "$CONF"
|
||||
sed -i 's/^\( *longitude:\).*/\1 0.0015 # CHANGE ME/' "$CONF"
|
||||
sed -i 's/^\( *altitude_m:\).*/\1 0.0 # CHANGE ME: wrong altitude skews sea-level pressure/' "$CONF"
|
||||
echo "# ASHVALE_FIRSTBOOT_DONE" >> "$CONF"
|
||||
chown 1000:1000 "$CONF"
|
||||
fi
|
||||
|
||||
logger -t ashvale-firstboot "prepared state for user ${UID_MAIN:-uid1000}"
|
||||
systemctl disable ashvale-firstboot.service || true
|
||||
@@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=Ashvale Station first-boot preparation
|
||||
After=local-fs.target
|
||||
Before=ashvale.service
|
||||
ConditionPathExists=!/opt/ashvale/data/state
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/ashvale-firstboot
|
||||
RemainAfterExit=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,27 @@
|
||||
[Unit]
|
||||
Description=Ashvale Station forecast service
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=1000
|
||||
Group=1000
|
||||
WorkingDirectory=/opt/ashvale
|
||||
ExecStart=/opt/ashvale/.venv/bin/python run.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
# A Zero 2 W has 512 MB. Cap the service so a runaway allocation takes the
|
||||
# service down instead of the whole board.
|
||||
MemoryMax=280M
|
||||
CPUWeight=70
|
||||
Nice=5
|
||||
|
||||
# The SD card is a consumable: keep journald from writing every heartbeat.
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=ashvale
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
IP=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||
printf '\n Ashvale Station -> http://%s:8000\n' "${IP:-<this-pi>}"
|
||||
printf ' status: %s\n' "$(systemctl is-active ashvale 2>/dev/null || echo unknown)"
|
||||
printf '\n No authentication and no TLS. Trusted LAN only: do not port-forward it.\n'
|
||||
printf ' Set your coordinates and altitude on the Settings tab before trusting\n'
|
||||
printf ' the pressure readings. See /opt/ashvale/README.first-boot\n\n'
|
||||
@@ -0,0 +1,5 @@
|
||||
python3-venv
|
||||
python3-numpy
|
||||
python3-smbus2
|
||||
sense-hat
|
||||
sqlite3
|
||||
@@ -0,0 +1 @@
|
||||
IMG_SUFFIX=""
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash -e
|
||||
if [ ! -d "${ROOTFS_DIR}" ]; then
|
||||
copy_previous
|
||||
fi
|
||||
Reference in New Issue
Block a user