diff --git a/deploy-hardware.sh b/deploy-hardware.sh new file mode 100755 index 0000000..aa5996b --- /dev/null +++ b/deploy-hardware.sh @@ -0,0 +1,4 @@ +#!/bin/bash +# Shorthand for ./deploy.sh hardware +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec "$SCRIPT_DIR/deploy.sh" hardware "$@" diff --git a/deploy-local.sh b/deploy-local.sh new file mode 100755 index 0000000..e14f0a2 --- /dev/null +++ b/deploy-local.sh @@ -0,0 +1,4 @@ +#!/bin/bash +# Shorthand for ./deploy.sh local +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec "$SCRIPT_DIR/deploy.sh" local "$@" diff --git a/deploy-prod.sh b/deploy-prod.sh new file mode 100755 index 0000000..54780f0 --- /dev/null +++ b/deploy-prod.sh @@ -0,0 +1,4 @@ +#!/bin/bash +# Shorthand for ./deploy.sh prod +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec "$SCRIPT_DIR/deploy.sh" prod "$@" diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..c67ba88 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# Onefinity firmware deploy script. +# +# ./deploy.sh local — build & static-serve the UI on macOS +# (chrome only; no controller, shows +# DISCONNECTED overlay) +# ./deploy.sh hardware — fast iteration: rsync build/http/ +# contents to the running Pi at +# onefinity.local, then restart bbctrl +# ./deploy.sh prod — full firmware update via the Pi's +# /api/firmware/update endpoint +# (equivalent to `make update`) +# +# Notes: +# * On macOS we cannot run the Python `bbctrl` controller directly +# because it imports the ARM-only camotics gplan.so. For full UI +# testing with live data, deploy to the Pi (hardware or prod). +# * `prod` requires a clean working tree. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +CMD="${1:-}" + +case "$CMD" in + local) exec "$SCRIPT_DIR/scripts/deploy/local.sh" "$@" ;; + hardware) exec "$SCRIPT_DIR/scripts/deploy/hardware.sh" "$@" ;; + prod) exec "$SCRIPT_DIR/scripts/deploy/prod.sh" "$@" ;; + *) + cat </dev/null +# Copy src/resources/* into build/http/. The Makefile's "all" target +# also does this, but pulls in cross-compiled subprojects (avr/boot/ +# pwr/jig) we don't have toolchains for on macOS. This rsync mirrors +# only the resource tree. +rsync -a src/resources/ build/http/ + +echo "Locating bbctrl http/ directory on $HOST..." +REMOTE_HTTP_DIR="$(ssh -o ConnectTimeout=5 "${REMOTE_USER}@${HOST}" \ + "ls -d /usr/local/lib/python*/dist-packages/bbctrl-*-py*.egg/bbctrl/http 2>/dev/null | head -1")" +if [[ -z "$REMOTE_HTTP_DIR" ]]; then + echo "ERROR: could not find bbctrl http/ directory on $HOST" + exit 1 +fi +echo " $REMOTE_HTTP_DIR" + +echo "Rsyncing build/http/ -> $HOST:$REMOTE_HTTP_DIR/" +# Stage to a tmp dir owned by $REMOTE_USER, then sudo-rsync into +# place. This avoids needing root over rsync. We do NOT use --delete +# anywhere -- the Pi's egg ships extra runtime files (config-template +# .json, default machine JSON, buildbotics.nc, etc.) that come with +# the bbctrl package and are not in this repo's src/resources. If +# they were deleted the controller's API would 500 because Python +# imports fail. +REMOTE_TMP="/tmp/onefin_ui_$$" +ssh -o ConnectTimeout=5 "${REMOTE_USER}@${HOST}" "mkdir -p '${REMOTE_TMP}'" +rsync -avz \ + --exclude='hostinfo.txt' \ + -e "ssh -o ConnectTimeout=5" \ + build/http/ "${REMOTE_USER}@${HOST}:${REMOTE_TMP}/" + +echo "Installing into ${REMOTE_HTTP_DIR}/ (sudo)..." +ssh -o ConnectTimeout=5 "${REMOTE_USER}@${HOST}" \ + "echo '${PASSWORD}' | sudo -S bash -c ' + rsync -a --exclude=hostinfo.txt \"${REMOTE_TMP}/\" \"${REMOTE_HTTP_DIR}/\" \ + && rm -rf \"${REMOTE_TMP}\" + '" 2>&1 | tail -3 + +# Patch bbctrl Web.py so font files get the correct MIME type. The +# Pi ships Python 3.5, whose `mimetypes` module doesn't know about +# woff/woff2/ttf, so Tornado serves them as application/octet-stream +# which Chromium 72 (the Pi's onboard browser) refuses to use as a +# web font, leading to all FontAwesome icons rendering as empty +# boxes in the kiosk UI. The patch is idempotent. +echo "Patching bbctrl font MIME types (idempotent)..." +scp -o ConnectTimeout=5 "$SCRIPT_DIR/scripts/deploy/patch_font_mime.py" \ + "${REMOTE_USER}@${HOST}:/tmp/patch_font_mime.py" >/dev/null +ssh -o ConnectTimeout=5 "${REMOTE_USER}@${HOST}" \ + "echo '${PASSWORD}' | sudo -S python3 /tmp/patch_font_mime.py" 2>&1 | tail -3 + +echo "Restarting bbctrl service..." +ssh -o ConnectTimeout=5 "${REMOTE_USER}@${HOST}" \ + "echo '${PASSWORD}' | sudo -S systemctl restart bbctrl" 2>&1 | tail -3 + +echo "" +echo "Deployed to http://${HOST}/" +echo " Logs: ssh ${REMOTE_USER}@${HOST} 'journalctl -u bbctrl -f'" +echo " Open: open -a 'Google Chrome' http://${HOST}/" diff --git a/scripts/deploy/local.sh b/scripts/deploy/local.sh new file mode 100755 index 0000000..3045ab5 --- /dev/null +++ b/scripts/deploy/local.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# --- Local development (macOS) --- +# +# Builds the UI bundle and static-serves it on http://localhost:8770/. +# Runs in a named tmux session so we can iterate (re-running this script +# rebuilds and restarts the server in-place, you keep your browser tab). +# +# What you'll see: +# * The full V09 chrome (header tabs, settings rail, jog grid, DRO +# skeleton, status strip). +# * A "DISCONNECTED" overlay because there's no controller backend. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$SCRIPT_DIR" + +echo "🛠 Building UI bundle..." +make build/http/index.html >/dev/null + +PORT="${PORT:-8770}" +SESSION="onefin-local" + +ensure_tmux_window() { + local session="$1" + local window="${2:-}" + local target="${session}${window:+:$window}" + if tmux has-session -t "$session" 2>/dev/null; then + if tmux send-keys -t "$target" "" 2>/dev/null; then + echo "🔁 Reusing tmux session '$session'..." + tmux send-keys -t "$target" C-c + sleep 1 + return + fi + echo "âš ī¸ Dead pane in '$session', recreating..." + tmux kill-session -t "$session" 2>/dev/null + fi + echo "🆕 Creating tmux session '$session'..." + tmux new-session -d -s "$session" +} + +ensure_tmux_window "$SESSION" + +# Free the port if a previous run is still listening. +if lsof -iTCP:"$PORT" -sTCP:LISTEN >/dev/null 2>&1; then + echo "âš ī¸ Port $PORT is busy; killing previous server..." + lsof -tiTCP:"$PORT" -sTCP:LISTEN | xargs -r kill 2>/dev/null || true + sleep 1 +fi + +tmux send-keys -t "$SESSION" \ + "cd '$SCRIPT_DIR' && python3 -m http.server --directory build/http $PORT" \ + C-m + +echo "" +echo "✅ Static UI server started on http://localhost:$PORT/" +echo "" +echo " Routes to try:" +echo " http://localhost:$PORT/#control" +echo " http://localhost:$PORT/#program" +echo " http://localhost:$PORT/#console" +echo " http://localhost:$PORT/#settings (Display & Units)" +echo " http://localhost:$PORT/#admin-network (WiFi / IP)" +echo " http://localhost:$PORT/#motor:0 (Motor 0 settings)" +echo "" +echo " tmux: tmux attach -t $SESSION" +echo " stop: tmux kill-session -t $SESSION" +echo "" +echo "â„šī¸ No controller is running, so the page shows DISCONNECTED and" +echo " axis values stay empty. For live data + W axis, run:" +echo " ./deploy.sh hardware (fast: rsync build/http -> Pi)" +echo " ./deploy.sh prod (full firmware update)" diff --git a/scripts/deploy/patch_font_mime.py b/scripts/deploy/patch_font_mime.py new file mode 100644 index 0000000..145a6d6 --- /dev/null +++ b/scripts/deploy/patch_font_mime.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +""" +Patch bbctrl Web.py so font files get the correct MIME type. + +Background +---------- +The Onefinity controller (Pi 3B running Raspbian stretch) ships Python +3.5, whose ``mimetypes`` module does not recognize ``.woff``, ``.woff2`` +or ``.ttf``. Tornado's ``StaticFileHandler`` therefore falls back to +``application/octet-stream`` for those, and Chromium 72 (the Pi's +onboard kiosk browser) refuses to use such payloads as web fonts. The +result is that every FontAwesome icon renders as an empty box on the +kiosk display. + +This patch monkey-patches ``StaticFileHandler.get_content_type`` to +emit the right MIME types. It is idempotent: running it twice is a +no-op. Run with ``sudo`` so it can rewrite the egg's Web.py. + +Used by: + scripts/deploy/hardware.sh +""" + +from __future__ import print_function +import os +import sys + + +def find_web_py(): + """Return the absolute path to the bbctrl Web.py shipped in the egg.""" + base = "/usr/local/lib" + for entry in os.listdir(base): + if not entry.startswith("python"): + continue + candidate_dir = os.path.join(base, entry, "dist-packages") + if not os.path.isdir(candidate_dir): + continue + for sub in os.listdir(candidate_dir): + if sub.startswith("bbctrl-") and sub.endswith(".egg"): + p = os.path.join(candidate_dir, sub, "bbctrl", "Web.py") + if os.path.isfile(p): + return p + return None + + +OLD_BLOCK = ( + "class StaticFileHandler(tornado.web.StaticFileHandler):\n" + " def set_extra_headers(self, path):\n" + " self.set_header('Cache-Control',\n" + " 'no-store, no-cache, must-revalidate, max-age=0')" +) + +NEW_BLOCK = ( + "class StaticFileHandler(tornado.web.StaticFileHandler):\n" + " # FONT_MIME_FIX: Python 3.5's mimetypes module does not know\n" + " # woff/woff2/ttf, so Tornado serves them as application/octet-\n" + " # stream which Chromium 72 (the Pi's onboard kiosk browser)\n" + " # refuses to use as web fonts. Set explicit types so the FA6\n" + " # icon set actually renders on the kiosk display.\n" + " def get_content_type(self):\n" + " path = self.absolute_path or ''\n" + " if path.endswith('.woff2'): return 'font/woff2'\n" + " if path.endswith('.woff'): return 'font/woff'\n" + " if path.endswith('.ttf'): return 'font/ttf'\n" + " if path.endswith('.otf'): return 'font/otf'\n" + " if path.endswith('.eot'): return 'application/vnd.ms-fontobject'\n" + " return super().get_content_type()\n" + "\n" + " def set_extra_headers(self, path):\n" + " self.set_header('Cache-Control',\n" + " 'no-store, no-cache, must-revalidate, max-age=0')" +) + + +def main(): + target = find_web_py() + if target is None: + print("ERROR: could not locate bbctrl Web.py under /usr/local/lib", + file=sys.stderr) + return 1 + + with open(target) as f: + src = f.read() + + if "FONT_MIME_FIX" in src: + print("font mime: already patched ({})".format(target)) + return 0 + + if OLD_BLOCK not in src: + print("font mime: expected block not found in {} -- skipping".format(target), + file=sys.stderr) + # Don't fail the deploy; just log and continue. + return 0 + + new_src = src.replace(OLD_BLOCK, NEW_BLOCK, 1) + with open(target, "w") as f: + f.write(new_src) + print("font mime: patched {}".format(target)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/deploy/prod.sh b/scripts/deploy/prod.sh new file mode 100755 index 0000000..6118d41 --- /dev/null +++ b/scripts/deploy/prod.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# --- Production firmware update (Pi at onefinity.local) --- +# +# Builds a full firmware package (.tar.bz2) and PUTs it through the Pi's +# /api/firmware/update endpoint. This is the canonical OTA flow and goes +# through the bbctrl Tornado server's update handler. +# +# Defaults: +# HOST=onefinity.local +# PASSWORD=onefinity +# +# Override: +# HOST=10.1.10.55 PASSWORD=secret ./deploy.sh prod + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$SCRIPT_DIR" + +HOST="${HOST:-onefinity.local}" +PASSWORD="${PASSWORD:-onefinity}" + +# Require a clean working tree. +echo "🔍 Checking git state..." +if ! git diff --quiet || ! git diff --cached --quiet \ + || [[ -n "$(git ls-files --others --exclude-standard)" ]]; then + echo "❌ Refusing to deploy: working tree has uncommitted changes." + git status --short + exit 1 +fi +echo "✅ Working tree is clean." + +echo "🛠 Building firmware package..." +make pkg + +echo "🚚 Uploading to http://${HOST}/api/firmware/update..." +make update HOST="$HOST" PASSWORD="$PASSWORD" + +echo "" +echo "✅ Firmware update PUT to ${HOST}." +echo " The Pi will reboot itself after applying the update." +echo " Once it comes back, open: http://${HOST}/"