add shippable kit design and SD card flash script

Ship Kit ($146/unit, $3,262 for 20):
- Pi 4B + Pi Camera Module 3 Wide NoIR in IP65 junction box
- 15ft weatherproof USB-C cable + wall adapter
- Universal L-bracket + zip ties for mounting
- Fits in USPS Medium Flat Rate Box ($17.10 shipping)

flash-kit.sh: one-command SD card provisioning
- Writes Pi OS + fajr-watch + station.yaml
- Pre-configures WiFi, station ID, coordinates
- Volunteer just plugs it in, no setup needed
This commit is contained in:
Aric Camarata 2026-03-23 06:14:46 -04:00
parent b62a361c9a
commit 01085cfcad
2 changed files with 419 additions and 0 deletions

151
.github/docs/hardware/SHIP-KIT.md vendored Normal file
View file

@ -0,0 +1,151 @@
# Shippable Kit Design
Goal: 20 identical kits you can assemble, flash, box, and mail. Volunteer opens the box, mounts it pointing east, plugs in power, done.
## Design Principles
1. **Ships in a USPS Medium Flat Rate Box** ($17.10, 11" x 8.5" x 5.5"). No oversized packages.
2. **No pole included.** Volunteers mount it on whatever they have: fence post, deck railing, wall bracket, window sill, tripod. Include zip ties and a universal L-bracket.
3. **No soldering.** All connections are plug-and-play USB cables.
4. **Two power options:** outdoor AC outlet (included 15ft USB-C cable + wall adapter) or solar panel (optional add-on, ships separately if needed).
5. **WiFi pre-configured.** Ask the volunteer for their SSID and password before shipping. Pre-flash it on the SD card. Zero on-site configuration.
6. **One cable to plug in.** Power. That's it. Camera is internal, WiFi is pre-set, GPS is built in.
## The Kit
### What's Inside the Box
| # | Item | Cost | Notes |
|---|---|---|---|
| 1 | Raspberry Pi 4B 2GB | $45 | Pre-flashed SD card inside |
| 2 | Raspberry Pi Camera Module 3 Wide NoIR | $35 | 120 FOV, no IR filter (better low-light), fixed lens (no alignment needed) |
| 3 | Weatherproof junction box (IP65) | $12 | Hammond 1554K or similar, 6.3" x 3.5" x 2.1" polycarbonate. Camera lens hole pre-drilled. |
| 4 | GPS module (U-blox NEO-6M) | $10 | Glued inside the box, antenna on outside |
| 5 | 64GB SD card (pre-flashed) | $10 | Station config pre-loaded |
| 6 | 15ft outdoor USB-C extension cable | $12 | Weatherproof, runs to nearest outlet |
| 7 | 5V/3A USB-C wall adapter | $8 | Standard phone charger |
| 8 | USB-C right-angle adapter | $3 | For clean cable entry into box |
| 9 | Weatherproof cable gland (PG9) | $2 | Seals the power cable entry hole |
| 10 | Silica gel packets (4x) | $2 | Moisture control inside box |
| 11 | Mounting kit: L-bracket + 4 zip ties + 2 screws | $5 | Universal mount |
| 12 | Quick-start card (laminated) | $2 | 5 steps with pictures |
| **Total per kit** | | **~$146** | |
| **20 kits** | | **~$2,920** | |
| **Shipping (20x USPS Medium Flat Rate)** | | **$342** | |
| **Grand total** | | **~$3,262** | |
### Optional Solar Add-On ($80, ships separately)
For volunteers with no outdoor outlet:
- 20W folding solar panel ($25)
- 10Ah USB power bank with pass-through charging ($35)
- 6ft USB-C cable ($5)
- Velcro strips for panel mounting ($3)
- Ships in a separate flat mailer ($12 shipping)
## Why Pi Camera Module 3 Wide NoIR (Not ZWO)
For a shippable kit, the Pi Camera Module 3 Wide wins over ZWO:
| Factor | Pi Cam 3 Wide NoIR | ZWO ASI224MC |
|---|---|---|
| Cost | $35 | $180 |
| Setup | Ribbon cable, zero config | USB, SDK install, gain tuning |
| Lens | Built-in 120 FOV (no alignment) | Separate lens (must focus + align) |
| Low-light | Good (IMX708, 1.4um pixels) | Excellent (IMX224, 3.75um, 0.8e noise) |
| Size | Tiny (25mm x 24mm) | Larger, needs USB port |
| Power | ~0.25W via ribbon | ~1.75W via USB |
| Fragility | Ribbon cable (delicate but enclosed) | USB cable (robust) |
The ZWO is scientifically better. But for 20 mail-out kits where the volunteer never touches the camera, the Pi Cam 3 Wide is simpler, cheaper, and good enough. The 120 FOV wide-angle captures the full eastern horizon band without a separate fisheye lens.
We can always deploy ZWO units at the highest-priority dark-sky sites (your own stations, Cherry Springs, etc.) where you control the setup.
## Assembly (Your Side, Per Kit)
Time: ~15 minutes per unit once you have the process down. 20 units in an afternoon.
### 1. Prepare the junction box
- Drill a 12mm hole on one short end (camera lens)
- Drill a 16mm hole on the bottom (PG9 cable gland for power)
- Drill a 6mm hole on top (GPS antenna cable)
### 2. Install components
- Mount Pi 4B inside box using brass standoffs (pre-tapped holes in the junction box)
- Connect Pi Camera Module 3 via 150mm ribbon cable
- Position camera lens against the drilled hole, secure with hot glue
- Connect GPS module to Pi UART pins (4 wires: VCC, GND, TX, RX)
- Route GPS antenna wire through top hole, seal with silicone
- Install PG9 cable gland in bottom hole
- Place 4 silica gel packets inside
- Close and seal the box
### 3. Flash the SD card
```bash
# On your Mac, for each station:
./scripts/provision/flash-kit.sh \
--station-id "station-017" \
--lat 41.95 \
--lng -80.55 \
--elevation 175 \
--wifi-ssid "VolunteerWiFi" \
--wifi-pass "password123" \
--host-name "Ahmed in Conneaut" \
--environment suburban \
--horizon lake
```
This writes Pi OS + fajr-watch software + station.yaml to the SD card in one command.
### 4. Test
- Insert SD card, connect power, wait 2 minutes
- LED should go green (connected)
- Check the web dashboard at fajr.watch/stations to see the unit reporting
- Capture a test frame to verify the camera works
- Power off, pack it up
### 5. Pack and ship
Contents of the USPS Medium Flat Rate Box:
- Junction box with electronics (wrapped in bubble wrap)
- USB-C wall adapter in a small bag
- 15ft USB-C cable (coiled)
- Mounting bracket + zip ties in a bag
- Laminated quick-start card on top
## Quick-Start Card (What the Volunteer Sees)
```
FAJR-WATCH STATION — Quick Setup
1. MOUNT the box pointing EAST (toward sunrise)
Use the bracket, zip ties, or set it on a flat surface.
The camera lens (small hole on the side) must face east
with a clear view of the sky above the horizon.
2. PLUG the long USB cable into the box (bottom).
Run the other end to the nearest outdoor outlet.
Plug in the wall adapter.
3. WAIT 2 minutes. The green light means it's working.
4. DONE. The station runs itself. It observes dawn and dusk
each day and uploads the data over your WiFi.
Questions? Text/email: fajr-watch@acamarata.com
Your station ID: _______________
```
## Volunteer Onboarding Flow
1. Volunteer signs up (Google Form or website)
2. They provide: name, email, address, WiFi SSID + password, description of their horizon (photo preferred)
3. You evaluate the site (is the eastern horizon clear enough?)
4. You assign a station ID, flash the SD card with their config
5. Assemble, test, pack, ship
6. They receive it, follow the 3-step card, done
7. You see their station appear on the dashboard within 24 hours

268
scripts/provision/flash-kit.sh Executable file
View file

@ -0,0 +1,268 @@
#!/bin/bash
# flash-kit.sh — Flash a complete fajr-watch SD card for a volunteer kit.
#
# Writes Raspberry Pi OS Lite + fajr-watch software + station config
# to an SD card in one step. Run on your Mac/Linux workstation.
#
# Usage:
# ./scripts/provision/flash-kit.sh \
# --station-id "conneaut-01" \
# --lat 41.95 --lng -80.55 --elevation 175 \
# --wifi-ssid "MyNetwork" --wifi-pass "password" \
# --host-name "Aric Camarata" \
# --environment suburban --horizon lake \
# --device /dev/disk4
#
# Requirements:
# - Raspberry Pi Imager CLI (rpi-imager) or dd
# - A downloaded Raspberry Pi OS Lite image (.img or .img.xz)
# - An SD card (32GB+) inserted
set -euo pipefail
# ── Parse arguments ──
STATION_ID=""
LAT=""
LNG=""
ELEVATION="0"
WIFI_SSID=""
WIFI_PASS=""
HOST_NAME=""
ENVIRONMENT="unknown"
HORIZON="unknown"
CAMERA_TYPE="pi_cam3_wide"
DEVICE=""
PI_OS_IMAGE="${PI_OS_IMAGE:-}"
while [[ $# -gt 0 ]]; do
case "$1" in
--station-id) STATION_ID="$2"; shift 2;;
--lat) LAT="$2"; shift 2;;
--lng) LNG="$2"; shift 2;;
--elevation) ELEVATION="$2"; shift 2;;
--wifi-ssid) WIFI_SSID="$2"; shift 2;;
--wifi-pass) WIFI_PASS="$2"; shift 2;;
--host-name) HOST_NAME="$2"; shift 2;;
--environment) ENVIRONMENT="$2"; shift 2;;
--horizon) HORIZON="$2"; shift 2;;
--camera) CAMERA_TYPE="$2"; shift 2;;
--device) DEVICE="$2"; shift 2;;
--image) PI_OS_IMAGE="$2"; shift 2;;
*) echo "Unknown argument: $1"; exit 1;;
esac
done
# ── Validate ──
if [ -z "$STATION_ID" ] || [ -z "$LAT" ] || [ -z "$LNG" ]; then
echo "Error: --station-id, --lat, and --lng are required."
echo ""
echo "Usage: $0 --station-id ID --lat LAT --lng LNG [options]"
exit 1
fi
if [ -z "$DEVICE" ]; then
echo "Available disks:"
if [ "$(uname)" = "Darwin" ]; then
diskutil list external physical
else
lsblk -d -o NAME,SIZE,MODEL | grep -v "^loop"
fi
echo ""
echo "Specify the SD card device with --device /dev/diskN"
exit 1
fi
echo "=== fajr-watch SD Card Flasher ==="
echo "Station: $STATION_ID"
echo "Location: $LAT, $LNG (elev ${ELEVATION}m)"
echo "WiFi: $WIFI_SSID"
echo "Device: $DEVICE"
echo ""
# ── Confirm ──
read -p "This will ERASE $DEVICE. Continue? [y/N] " confirm
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
echo "Aborted."
exit 0
fi
# ── Flash Pi OS ──
if [ -z "$PI_OS_IMAGE" ]; then
# Try to find a downloaded image
PI_OS_IMAGE=$(ls -t ~/Downloads/*raspios*lite*arm64*.img* 2>/dev/null | head -1 || true)
if [ -z "$PI_OS_IMAGE" ]; then
echo "Error: No Raspberry Pi OS image found."
echo "Download from https://www.raspberrypi.com/software/operating-systems/"
echo "Then pass with --image /path/to/image.img"
exit 1
fi
fi
echo "Flashing Pi OS from: $PI_OS_IMAGE"
if [ "$(uname)" = "Darwin" ]; then
RDISK="${DEVICE/disk/rdisk}"
diskutil unmountDisk "$DEVICE"
if [[ "$PI_OS_IMAGE" == *.xz ]]; then
xz -dc "$PI_OS_IMAGE" | sudo dd of="$RDISK" bs=4m
else
sudo dd if="$PI_OS_IMAGE" of="$RDISK" bs=4m
fi
sync
sleep 2
# Re-mount boot partition
diskutil mountDisk "$DEVICE"
BOOT_MOUNT=$(diskutil info "${DEVICE}s1" 2>/dev/null | grep "Mount Point" | awk '{print $3}')
if [ -z "$BOOT_MOUNT" ]; then
BOOT_MOUNT="/Volumes/bootfs"
fi
else
if [[ "$PI_OS_IMAGE" == *.xz ]]; then
xz -dc "$PI_OS_IMAGE" | sudo dd of="$DEVICE" bs=4M status=progress
else
sudo dd if="$PI_OS_IMAGE" of="$DEVICE" bs=4M status=progress
fi
sync
sleep 2
# Mount boot partition
BOOT_MOUNT="/mnt/fajr-boot"
sudo mkdir -p "$BOOT_MOUNT"
sudo mount "${DEVICE}1" "$BOOT_MOUNT"
fi
echo "Boot partition mounted at: $BOOT_MOUNT"
# ── Enable SSH ──
touch "$BOOT_MOUNT/ssh"
# ── Configure WiFi (for Pi OS Bookworm, use NetworkManager via firstrun) ──
if [ -n "$WIFI_SSID" ] && [ -n "$WIFI_PASS" ]; then
# Create a firstrun script that configures WiFi via nmcli
cat > "$BOOT_MOUNT/firstrun.sh" << FIRSTRUN
#!/bin/bash
set -e
nmcli device wifi connect "$WIFI_SSID" password "$WIFI_PASS" || true
FIRSTRUN
chmod +x "$BOOT_MOUNT/firstrun.sh"
fi
# ── Copy fajr-watch software ──
FAJR_DIR="$BOOT_MOUNT/fajr-watch"
mkdir -p "$FAJR_DIR"
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
cp -r "$REPO_ROOT/src" "$FAJR_DIR/src"
cp -r "$REPO_ROOT/config" "$FAJR_DIR/config"
cp "$REPO_ROOT/requirements.txt" "$FAJR_DIR/"
cp "$REPO_ROOT/scripts/provision/first-boot.sh" "$FAJR_DIR/"
# ── Write station config ──
cat > "$FAJR_DIR/station.yaml" << YAML
station:
id: "$STATION_ID"
lat: $LAT
lng: $LNG
elevation_m: $ELEVATION
horizon: "$HORIZON"
environment: "$ENVIRONMENT"
host: "$HOST_NAME"
contact: ""
camera:
type: "$CAMERA_TYPE"
lens_mm: 4.74
orientation: "east"
azimuth_offset: 0
network:
wifi_ssid: "$WIFI_SSID"
wifi_password: "$WIFI_PASS"
upload_url: "https://api.fajr.watch/v1/upload"
api_key: ""
capture:
interval_s: 10
twilight_margin_deg: 5
raw_format: true
max_exposure_s: 30
target_brightness: 80
processing:
detect_on_device: true
min_confidence: 0.5
keep_raw_frames: false
retention_days: 30
YAML
# ── Create first-boot trigger ──
touch "$FAJR_DIR/first-boot-trigger"
# ── Create first-boot systemd service (runs once) ──
ROOTFS_MOUNT=""
if [ "$(uname)" = "Darwin" ]; then
# On macOS, we can't easily write to the ext4 rootfs partition.
# Instead, we'll use a cmdline.txt hook.
echo "Note: On macOS, first-boot service must be configured after first SSH login."
echo "Run: sudo bash /boot/fajr-watch/first-boot.sh"
else
ROOTFS_MOUNT="/mnt/fajr-rootfs"
sudo mkdir -p "$ROOTFS_MOUNT"
sudo mount "${DEVICE}2" "$ROOTFS_MOUNT"
sudo cp "$FAJR_DIR/first-boot.sh" "$ROOTFS_MOUNT/usr/local/bin/fajr-watch-provision.sh"
sudo chmod +x "$ROOTFS_MOUNT/usr/local/bin/fajr-watch-provision.sh"
sudo tee "$ROOTFS_MOUNT/etc/systemd/system/fajr-watch-provision.service" > /dev/null << SERVICE
[Unit]
Description=fajr-watch first-boot provisioning
After=network-online.target
Wants=network-online.target
ConditionPathExists=/boot/fajr-watch/first-boot-trigger
[Service]
Type=oneshot
ExecStart=/usr/local/bin/fajr-watch-provision.sh
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
SERVICE
sudo ln -sf /etc/systemd/system/fajr-watch-provision.service \
"$ROOTFS_MOUNT/etc/systemd/system/multi-user.target.wants/fajr-watch-provision.service"
sudo umount "$ROOTFS_MOUNT"
fi
# ── Cleanup ──
if [ "$(uname)" = "Darwin" ]; then
diskutil unmountDisk "$DEVICE"
else
sudo umount "$BOOT_MOUNT"
fi
echo ""
echo "=== SD card ready ==="
echo "Station: $STATION_ID"
echo "Location: $LAT, $LNG"
echo ""
echo "Insert into Pi, connect camera, apply power."
if [ "$(uname)" = "Darwin" ]; then
echo ""
echo "NOTE (macOS): After first boot, SSH in and run:"
echo " sudo bash /boot/fajr-watch/first-boot.sh"
fi