install.sh 54 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442
  1. #!/usr/bin/env bash
  2. #
  3. # SpoolBuddy Installation Script for Raspberry Pi
  4. #
  5. # Supports two scenarios:
  6. # 1) SpoolBuddy only — NFC/scale companion connecting to a remote Bambuddy instance
  7. # 2) SpoolBuddy + Bambuddy — both running natively on this Raspberry Pi
  8. #
  9. # Usage:
  10. # Interactive: curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/spoolbuddy/install.sh -o install.sh && chmod +x install.sh && sudo ./install.sh
  11. # Unattended: sudo ./install.sh --mode spoolbuddy --bambuddy-url http://192.168.1.100:8000 --api-key bb_xxx --yes
  12. #
  13. # Options:
  14. # --mode MODE Installation mode: "spoolbuddy" (companion only) or "full" (both)
  15. # --repo URL Git repository URL to install from (default: upstream repo)
  16. # --ref REF Git ref to install (branch/tag/commit, default: main)
  17. # --bambuddy-url URL Bambuddy server URL (required for spoolbuddy mode)
  18. # --api-key KEY Bambuddy API key (required for spoolbuddy mode)
  19. # --path PATH Installation directory (default: /opt/spoolbuddy or /opt/bambuddy)
  20. # --port PORT Bambuddy port (full mode only, default: 8000)
  21. # --ssh-pubkey KEY Bambuddy SSH public key for remote updates
  22. # --yes, -y Non-interactive mode, accept defaults
  23. # --help, -h Show this help message
  24. #
  25. set -e
  26. # ─────────────────────────────────────────────────────────────────────────────
  27. # Constants
  28. # ─────────────────────────────────────────────────────────────────────────────
  29. RED='\033[0;31m'
  30. GREEN='\033[0;32m'
  31. YELLOW='\033[1;33m'
  32. CYAN='\033[0;36m'
  33. BOLD='\033[1m'
  34. NC='\033[0m'
  35. GITHUB_REPO="https://github.com/maziggy/bambuddy.git"
  36. SPOOLBUDDY_SERVICE_USER="spoolbuddy"
  37. BAMBUDDY_SERVICE_USER="bambuddy"
  38. # Packages needed for SpoolBuddy hardware (NFC reader + scale)
  39. SYSTEM_PACKAGES="python3 python3-pip python3-venv python3-dev python3-spidev python3-libgpiod gpiod libgpiod-dev i2c-tools git"
  40. # Python packages for SpoolBuddy daemon
  41. SPOOLBUDDY_PIP_PACKAGES="spidev gpiod smbus2 httpx"
  42. # ─────────────────────────────────────────────────────────────────────────────
  43. # Variables (set by args or prompts)
  44. # ─────────────────────────────────────────────────────────────────────────────
  45. INSTALL_MODE="" # "spoolbuddy" or "full"
  46. INSTALL_PATH=""
  47. INSTALL_REPO=""
  48. INSTALL_REF=""
  49. DETECTED_INSTALLER_REPO=""
  50. DETECTED_INSTALLER_REF=""
  51. BAMBUDDY_URL=""
  52. API_KEY=""
  53. BAMBUDDY_PORT="8000"
  54. NON_INTERACTIVE="false"
  55. REBOOT_NEEDED="false"
  56. KIOSK_USER="" # auto-detected from $SUDO_USER
  57. KIOSK_URL="" # derived from $BAMBUDDY_URL/spoolbuddy?token=$API_KEY
  58. SSH_PUBKEY="" # Bambuddy's SSH public key for remote updates
  59. # ─────────────────────────────────────────────────────────────────────────────
  60. # Helpers
  61. # ─────────────────────────────────────────────────────────────────────────────
  62. info() { echo -e "${CYAN}[INFO]${NC} $1"; }
  63. success() { echo -e "${GREEN}[OK]${NC} $1"; }
  64. warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
  65. error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
  66. # Run a long-running command with a spinner + live progress output.
  67. # Usage: run_with_progress "description" command [args...]
  68. run_with_progress() {
  69. local desc="$1"
  70. shift
  71. local log_file
  72. log_file=$(mktemp /tmp/spoolbuddy-install.XXXXXX)
  73. local start_time=$SECONDS
  74. # Run command in background, capture stdout+stderr
  75. "$@" > "$log_file" 2>&1 &
  76. local pid=$!
  77. # Spinner frames (braille pattern)
  78. local -a spin=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏")
  79. local i=0
  80. while kill -0 "$pid" 2>/dev/null; do
  81. local elapsed=$(( SECONDS - start_time ))
  82. local time_str
  83. if (( elapsed >= 60 )); then
  84. time_str="$(( elapsed / 60 ))m$(printf '%02d' $(( elapsed % 60 )))s"
  85. else
  86. time_str="${elapsed}s"
  87. fi
  88. # Last chunk of output (handles \r progress lines and regular \n lines)
  89. local last_line=""
  90. last_line=$(tail -c 4096 "$log_file" 2>/dev/null | tr '\r' '\n' | sed 's/\x1b\[[0-9;]*[mGKHJ]//g' | sed '/^[[:space:]]*$/d' | tail -1 | sed 's/^[[:space:]]*//' | cut -c1-50) || true
  91. printf "\r ${spin[$((i % 10))]} %-36s ${CYAN}%6s${NC} %s\033[K" "$desc" "$time_str" "$last_line"
  92. i=$(( i + 1 ))
  93. sleep 0.15
  94. done
  95. local exit_code=0
  96. wait "$pid" || exit_code=$?
  97. # Clear spinner line
  98. printf "\r\033[K"
  99. # Format elapsed time for summary
  100. local elapsed=$(( SECONDS - start_time ))
  101. local time_suffix=""
  102. if (( elapsed >= 60 )); then
  103. time_suffix=" ($(( elapsed / 60 ))m $(( elapsed % 60 ))s)"
  104. elif (( elapsed >= 5 )); then
  105. time_suffix=" (${elapsed}s)"
  106. fi
  107. if [[ $exit_code -eq 0 ]]; then
  108. success "${desc}${time_suffix}"
  109. rm -f "$log_file"
  110. else
  111. echo -e "${RED}[FAIL]${NC} ${desc}${time_suffix}"
  112. echo ""
  113. echo -e " ${YELLOW}Last 20 lines:${NC}"
  114. tail -20 "$log_file" 2>/dev/null | sed 's/^/ /'
  115. echo ""
  116. echo -e " Full log: ${CYAN}$log_file${NC}"
  117. exit 1
  118. fi
  119. }
  120. prompt() {
  121. local prompt_text="$1"
  122. local default_value="$2"
  123. local var_name="$3"
  124. if [[ "$NON_INTERACTIVE" == "true" ]]; then
  125. eval "$var_name=\"$default_value\""
  126. return
  127. fi
  128. if [[ -n "$default_value" ]]; then
  129. echo -en "${BOLD}$prompt_text${NC} [${CYAN}$default_value${NC}]: "
  130. else
  131. echo -en "${BOLD}$prompt_text${NC}: "
  132. fi
  133. read -r input
  134. if [[ -z "$input" ]]; then
  135. eval "$var_name=\"$default_value\""
  136. else
  137. eval "$var_name=\"$input\""
  138. fi
  139. }
  140. prompt_yes_no() {
  141. local prompt_text="$1"
  142. local default="$2"
  143. if [[ "$NON_INTERACTIVE" == "true" ]]; then
  144. [[ "$default" == "y" ]] && return 0 || return 1
  145. fi
  146. local yn_hint="[y/n]"
  147. [[ "$default" == "y" ]] && yn_hint="[Y/n]"
  148. [[ "$default" == "n" ]] && yn_hint="[y/N]"
  149. while true; do
  150. echo -en "${BOLD}$prompt_text${NC} $yn_hint: "
  151. read -r yn
  152. [[ -z "$yn" ]] && yn="$default"
  153. case "$yn" in
  154. [Yy]* ) return 0;;
  155. [Nn]* ) return 1;;
  156. * ) echo "Please answer yes or no.";;
  157. esac
  158. done
  159. }
  160. show_help() {
  161. echo "SpoolBuddy Installation Script for Raspberry Pi"
  162. echo ""
  163. echo "Usage: sudo $0 [OPTIONS]"
  164. echo ""
  165. echo "Options:"
  166. echo " --mode MODE \"spoolbuddy\" (companion only) or \"full\" (Bambuddy + SpoolBuddy)"
  167. echo " --repo URL Git repository URL to install from"
  168. echo " --ref REF Git ref to install (branch/tag/commit)"
  169. echo " --bambuddy-url URL Bambuddy server URL (required for spoolbuddy mode)"
  170. echo " --api-key KEY Bambuddy API key (required for spoolbuddy mode)"
  171. echo " --path PATH Installation directory (default: /opt/spoolbuddy or /opt/bambuddy)"
  172. echo " --port PORT Bambuddy port (full mode only, default: 8000)"
  173. echo " --ssh-pubkey KEY Bambuddy SSH public key for remote updates"
  174. echo " --yes, -y Non-interactive mode, accept defaults"
  175. echo " --help, -h Show this help message"
  176. echo ""
  177. echo "Examples:"
  178. echo " Interactive:"
  179. echo " sudo ./install.sh"
  180. echo ""
  181. echo " SpoolBuddy companion (unattended):"
  182. echo " sudo ./install.sh --mode spoolbuddy --bambuddy-url http://192.168.1.100:8000 --api-key bb_xxx -y"
  183. echo ""
  184. echo " Full install (unattended):"
  185. echo " sudo ./install.sh --mode full --port 8000 -y"
  186. exit 0
  187. }
  188. normalize_github_repo_url() {
  189. local url="$1"
  190. if [[ -z "$url" ]]; then
  191. echo ""
  192. return
  193. fi
  194. # Convert git@github.com:owner/repo(.git) to https://github.com/owner/repo.git
  195. if [[ "$url" =~ ^git@github.com:(.+)$ ]]; then
  196. url="https://github.com/${BASH_REMATCH[1]}"
  197. fi
  198. # Keep remote URL style consistent.
  199. url="${url%.git}"
  200. echo "${url}.git"
  201. }
  202. detect_installer_source_context() {
  203. local script_dir
  204. script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  205. if git -C "$script_dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
  206. DETECTED_INSTALLER_REF="$(git -C "$script_dir" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
  207. local origin_url
  208. origin_url="$(git -C "$script_dir" remote get-url origin 2>/dev/null || true)"
  209. DETECTED_INSTALLER_REPO="$(normalize_github_repo_url "$origin_url")"
  210. fi
  211. # Optional environment overrides for raw-download installs.
  212. if [[ -n "${SPOOLBUDDY_INSTALL_REPO:-}" ]]; then
  213. DETECTED_INSTALLER_REPO="$(normalize_github_repo_url "$SPOOLBUDDY_INSTALL_REPO")"
  214. fi
  215. if [[ -n "${SPOOLBUDDY_INSTALL_REF:-}" ]]; then
  216. DETECTED_INSTALLER_REF="$SPOOLBUDDY_INSTALL_REF"
  217. fi
  218. if [[ -z "$INSTALL_REPO" ]]; then
  219. if [[ -n "$DETECTED_INSTALLER_REPO" ]]; then
  220. INSTALL_REPO="$DETECTED_INSTALLER_REPO"
  221. else
  222. INSTALL_REPO="$GITHUB_REPO"
  223. fi
  224. fi
  225. if [[ -z "$INSTALL_REF" ]]; then
  226. if [[ -n "$DETECTED_INSTALLER_REF" && "$DETECTED_INSTALLER_REF" != "HEAD" ]]; then
  227. INSTALL_REF="$DETECTED_INSTALLER_REF"
  228. else
  229. INSTALL_REF="main"
  230. fi
  231. fi
  232. }
  233. resolve_install_ref() {
  234. local ref="$1"
  235. # If ref exists on origin as a branch, track/reset it. Otherwise treat it as tag/commit.
  236. if git ls-remote --exit-code --heads origin "$ref" >/dev/null 2>&1; then
  237. git checkout -B "$ref" "origin/$ref" > /dev/null 2>&1
  238. git reset --hard "origin/$ref" > /dev/null 2>&1
  239. else
  240. git checkout "$ref" > /dev/null 2>&1
  241. fi
  242. }
  243. # ─────────────────────────────────────────────────────────────────────────────
  244. # Pre-flight Checks
  245. # ─────────────────────────────────────────────────────────────────────────────
  246. check_root() {
  247. if [[ $EUID -ne 0 ]]; then
  248. error "This script must be run as root (use sudo)"
  249. fi
  250. }
  251. check_raspberry_pi() {
  252. if ! grep -q "Raspberry Pi\|BCM2" /proc/cpuinfo 2>/dev/null; then
  253. error "This script is designed for Raspberry Pi only"
  254. fi
  255. # Detect Pi model for hardware recommendations
  256. local model
  257. model=$(tr -d '\0' < /proc/device-tree/model 2>/dev/null) || model="Unknown"
  258. success "Detected: $model"
  259. }
  260. check_raspberry_pi_os() {
  261. if [[ ! -f /etc/os-release ]]; then
  262. error "Cannot detect operating system"
  263. fi
  264. . /etc/os-release
  265. if [[ "$ID" != "raspbian" && "$ID" != "debian" ]]; then
  266. warn "Expected Raspberry Pi OS (Debian-based), found: $ID"
  267. if ! prompt_yes_no "Continue anyway?" "n"; then
  268. exit 0
  269. fi
  270. fi
  271. success "OS: $PRETTY_NAME"
  272. }
  273. detect_python() {
  274. local cmd=""
  275. if command -v python3 &>/dev/null; then
  276. cmd="python3"
  277. elif command -v python &>/dev/null; then
  278. local ver
  279. ver=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1)
  280. if [[ "$ver" -ge 3 ]]; then
  281. cmd="python"
  282. fi
  283. fi
  284. if [[ -z "$cmd" ]]; then
  285. return 1
  286. fi
  287. local version
  288. version=$($cmd -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
  289. local major minor
  290. major=$(echo "$version" | cut -d'.' -f1)
  291. minor=$(echo "$version" | cut -d'.' -f2)
  292. if [[ "$major" -lt 3 ]] || { [[ "$major" -eq 3 ]] && [[ "$minor" -lt 10 ]]; }; then
  293. warn "Python $version found, but 3.10+ is required"
  294. return 1
  295. fi
  296. PYTHON_CMD="$cmd"
  297. success "Found Python $version"
  298. return 0
  299. }
  300. # ─────────────────────────────────────────────────────────────────────────────
  301. # Raspberry Pi Hardware Configuration
  302. # ─────────────────────────────────────────────────────────────────────────────
  303. enable_spi() {
  304. if raspi-config nonint get_spi 2>/dev/null | grep -q "1"; then
  305. info "Enabling SPI..."
  306. raspi-config nonint do_spi 0
  307. REBOOT_NEEDED="true"
  308. success "SPI enabled"
  309. else
  310. success "SPI already enabled"
  311. fi
  312. }
  313. enable_i2c() {
  314. if raspi-config nonint get_i2c 2>/dev/null | grep -q "1"; then
  315. info "Enabling I2C..."
  316. raspi-config nonint do_i2c 0
  317. REBOOT_NEEDED="true"
  318. success "I2C enabled"
  319. else
  320. success "I2C already enabled"
  321. fi
  322. }
  323. configure_boot_config() {
  324. # Find the boot config file (Bookworm+ uses /boot/firmware/config.txt)
  325. local boot_config="/boot/firmware/config.txt"
  326. if [[ ! -f "$boot_config" ]]; then
  327. boot_config="/boot/config.txt"
  328. fi
  329. if [[ ! -f "$boot_config" ]]; then
  330. warn "Boot config not found at /boot/firmware/config.txt or /boot/config.txt"
  331. warn "You may need to manually add: dtparam=i2c_arm=on and dtoverlay=spi0-0cs"
  332. return
  333. fi
  334. info "Configuring $boot_config..."
  335. # Migrate legacy SpoolBuddy setting (bus 0 / i2c_vc) to bus 1 / i2c_arm.
  336. if grep -q "^dtparam=i2c_vc=on" "$boot_config"; then
  337. sed -i "s/^dtparam=i2c_vc=on$/# dtparam=i2c_vc=on (disabled by SpoolBuddy installer; use i2c_arm bus 1)/" "$boot_config"
  338. REBOOT_NEEDED="true"
  339. success "Disabled legacy dtparam=i2c_vc=on"
  340. fi
  341. if grep -q "^# SpoolBuddy: I2C bus 0 for NAU7802 scale (GPIO0/GPIO1)" "$boot_config"; then
  342. sed -i "s/^# SpoolBuddy: I2C bus 0 for NAU7802 scale (GPIO0\/GPIO1)$/# SpoolBuddy: I2C bus 1 for NAU7802 scale (GPIO2\/GPIO3)/" "$boot_config"
  343. fi
  344. # Ensure I2C bus 1 (GPIO2/GPIO3) is enabled for NAU7802 scale
  345. if ! grep -q "^dtparam=i2c_arm=on" "$boot_config"; then
  346. echo "" >> "$boot_config"
  347. echo "# SpoolBuddy: I2C bus 1 for NAU7802 scale (GPIO2/GPIO3)" >> "$boot_config"
  348. echo "dtparam=i2c_arm=on" >> "$boot_config"
  349. REBOOT_NEEDED="true"
  350. success "Added dtparam=i2c_arm=on"
  351. else
  352. success "dtparam=i2c_arm=on already set"
  353. fi
  354. # Disable SPI auto chip-select (manual CS on GPIO23 for PN5180)
  355. if ! grep -q "^dtoverlay=spi0-0cs" "$boot_config"; then
  356. echo "" >> "$boot_config"
  357. echo "# SpoolBuddy: Disable SPI auto CS (manual CS on GPIO23 for PN5180)" >> "$boot_config"
  358. echo "dtoverlay=spi0-0cs" >> "$boot_config"
  359. REBOOT_NEEDED="true"
  360. success "Added dtoverlay=spi0-0cs"
  361. else
  362. success "dtoverlay=spi0-0cs already set"
  363. fi
  364. }
  365. # ─────────────────────────────────────────────────────────────────────────────
  366. # Package Installation
  367. # ─────────────────────────────────────────────────────────────────────────────
  368. install_system_packages() {
  369. run_with_progress "Updating package lists" apt-get update
  370. run_with_progress "Installing system packages" apt-get install -y $SYSTEM_PACKAGES
  371. }
  372. install_wifi_safeguard() {
  373. # Protect WiFi credentials from being wiped by apt upgrades.
  374. # Raspberry Pi OS Bookworm migrated from wpa_supplicant/dhcpcd to
  375. # NetworkManager, but certain package upgrades (raspberrypi-sys-mods,
  376. # raspi-config, NetworkManager itself) can delete saved connections
  377. # from /etc/NetworkManager/system-connections/. This hook backs them
  378. # up before dpkg runs and restores them if they vanish.
  379. local hook_file="/etc/apt/apt.conf.d/80-preserve-wifi"
  380. if [[ -f "$hook_file" ]]; then
  381. success "WiFi safeguard already installed"
  382. return
  383. fi
  384. # Only install if NetworkManager is the active network manager
  385. if ! systemctl is-active --quiet NetworkManager 2>/dev/null; then
  386. return
  387. fi
  388. cat > "$hook_file" << 'APTEOF'
  389. // Preserve NetworkManager WiFi connections across apt upgrades.
  390. // Installed by SpoolBuddy — prevents headless Pis from losing WiFi.
  391. DPkg::Pre-Invoke { "if [ -d /etc/NetworkManager/system-connections ] && [ -n \"$(ls -A /etc/NetworkManager/system-connections/ 2>/dev/null)\" ]; then cp -a /etc/NetworkManager/system-connections/ /etc/NetworkManager/system-connections.bak/; fi"; };
  392. DPkg::Post-Invoke { "if [ -d /etc/NetworkManager/system-connections.bak ] && [ -z \"$(ls -A /etc/NetworkManager/system-connections/ 2>/dev/null)\" ]; then cp -a /etc/NetworkManager/system-connections.bak/* /etc/NetworkManager/system-connections/ && nmcli general reload 2>/dev/null; fi"; };
  393. APTEOF
  394. success "WiFi safeguard installed (${hook_file})"
  395. }
  396. upgrade_system_packages() {
  397. run_with_progress "Upgrading system packages" apt-get upgrade -y
  398. }
  399. # ─────────────────────────────────────────────────────────────────────────────
  400. # SpoolBuddy Installation
  401. # ─────────────────────────────────────────────────────────────────────────────
  402. create_spoolbuddy_user() {
  403. if id "$SPOOLBUDDY_SERVICE_USER" &>/dev/null; then
  404. info "User '$SPOOLBUDDY_SERVICE_USER' already exists"
  405. # Ensure existing installs get a real shell for SSH access
  406. usermod --shell /bin/bash "$SPOOLBUDDY_SERVICE_USER" 2>/dev/null || true
  407. else
  408. info "Creating service user '$SPOOLBUDDY_SERVICE_USER'..."
  409. useradd --system --shell /bin/bash --home-dir "$INSTALL_PATH" "$SPOOLBUDDY_SERVICE_USER"
  410. success "Service user created"
  411. fi
  412. # Add to hardware access groups (gpio, spi, i2c, video for backlight)
  413. for group in gpio spi i2c video; do
  414. if getent group "$group" &>/dev/null; then
  415. usermod -aG "$group" "$SPOOLBUDDY_SERVICE_USER" 2>/dev/null || true
  416. fi
  417. done
  418. success "User added to gpio, spi, i2c, video groups"
  419. # Allow passwordless restart of daemon + kiosk (needed for SSH-based updates from Bambuddy)
  420. cat > /etc/sudoers.d/spoolbuddy << 'SUDOERS'
  421. spoolbuddy ALL=(root) NOPASSWD: /usr/bin/systemctl restart spoolbuddy.service
  422. spoolbuddy ALL=(root) NOPASSWD: /usr/bin/systemctl restart getty@tty1.service
  423. spoolbuddy ALL=(root) NOPASSWD: /usr/bin/find /home -maxdepth 5 *
  424. SUDOERS
  425. chmod 440 /etc/sudoers.d/spoolbuddy
  426. success "Sudoers entries created for service and kiosk restart"
  427. }
  428. download_spoolbuddy() {
  429. if [[ -d "$INSTALL_PATH/.git" ]]; then
  430. info "Existing installation found, updating..."
  431. git config --global --add safe.directory "$INSTALL_PATH" 2>/dev/null || true
  432. cd "$INSTALL_PATH"
  433. git remote set-url origin "$INSTALL_REPO" 2>/dev/null || true
  434. run_with_progress "Fetching updates" git fetch origin
  435. resolve_install_ref "$INSTALL_REF"
  436. else
  437. mkdir -p "$INSTALL_PATH"
  438. run_with_progress "Cloning repository" git clone "$INSTALL_REPO" "$INSTALL_PATH"
  439. cd "$INSTALL_PATH"
  440. resolve_install_ref "$INSTALL_REF"
  441. fi
  442. chown -R "$SPOOLBUDDY_SERVICE_USER:$SPOOLBUDDY_SERVICE_USER" "$INSTALL_PATH"
  443. }
  444. setup_spoolbuddy_venv() {
  445. cd "$INSTALL_PATH/spoolbuddy"
  446. run_with_progress "Creating SpoolBuddy venv" $PYTHON_CMD -m venv --system-site-packages venv
  447. run_with_progress "Upgrading pip" "$INSTALL_PATH/spoolbuddy/venv/bin/pip" install --upgrade pip
  448. run_with_progress "Installing SpoolBuddy packages" "$INSTALL_PATH/spoolbuddy/venv/bin/pip" install $SPOOLBUDDY_PIP_PACKAGES
  449. chown -R "$SPOOLBUDDY_SERVICE_USER:$SPOOLBUDDY_SERVICE_USER" "$INSTALL_PATH/spoolbuddy/venv"
  450. }
  451. create_spoolbuddy_env() {
  452. info "Creating SpoolBuddy configuration..."
  453. local env_file="$INSTALL_PATH/spoolbuddy/.env"
  454. cat > "$env_file" << EOF
  455. # SpoolBuddy Configuration
  456. # Generated by install.sh on $(date)
  457. # Bambuddy backend URL
  458. SPOOLBUDDY_BACKEND_URL=$BAMBUDDY_URL
  459. # API key (create one in Bambuddy Settings -> API Keys)
  460. SPOOLBUDDY_API_KEY=$API_KEY
  461. # NAU7802 scale bus (RPi GPIO2/GPIO3)
  462. SPOOLBUDDY_I2C_BUS=1
  463. EOF
  464. chown "$SPOOLBUDDY_SERVICE_USER:$SPOOLBUDDY_SERVICE_USER" "$env_file"
  465. # Keep secrets owner-writable while allowing kiosk user (in spoolbuddy group)
  466. # to read backend URL/API key for dynamic launcher URL resolution.
  467. chgrp "$SPOOLBUDDY_SERVICE_USER" "$env_file"
  468. chmod 640 "$env_file"
  469. success "Configuration saved to $env_file"
  470. }
  471. ensure_kiosk_env_access() {
  472. local env_file="$INSTALL_PATH/spoolbuddy/.env"
  473. if [[ ! -f "$env_file" ]]; then
  474. warn "SpoolBuddy env file not found at $env_file"
  475. return
  476. fi
  477. # Ensure kiosk user is known even when this function is called outside setup_kiosk.
  478. if [[ -z "$KIOSK_USER" ]]; then
  479. KIOSK_USER="${SUDO_USER:-$(logname 2>/dev/null || echo pi)}"
  480. fi
  481. if id "$KIOSK_USER" &>/dev/null; then
  482. usermod -aG "$SPOOLBUDDY_SERVICE_USER" "$KIOSK_USER" 2>/dev/null || true
  483. fi
  484. chgrp "$SPOOLBUDDY_SERVICE_USER" "$env_file"
  485. chmod 640 "$env_file"
  486. if ! su -s /bin/sh -c "test -r '$env_file'" "$KIOSK_USER"; then
  487. error "Kiosk user '$KIOSK_USER' cannot read $env_file (required for dynamic kiosk URL). Check groups/permissions."
  488. fi
  489. success "Verified kiosk user '$KIOSK_USER' can read SpoolBuddy env"
  490. }
  491. setup_ssh_key() {
  492. info "Setting up SSH access for Bambuddy remote updates..."
  493. local ssh_dir="$INSTALL_PATH/.ssh"
  494. local auth_keys="$ssh_dir/authorized_keys"
  495. mkdir -p "$ssh_dir"
  496. chmod 700 "$ssh_dir"
  497. if [[ -n "$SSH_PUBKEY" ]]; then
  498. # Manual key provided via --ssh-pubkey flag
  499. if [[ -f "$auth_keys" ]] && grep -qF "$SSH_PUBKEY" "$auth_keys" 2>/dev/null; then
  500. info "SSH key already present in authorized_keys"
  501. else
  502. echo "$SSH_PUBKEY" >> "$auth_keys"
  503. success "SSH public key added"
  504. fi
  505. else
  506. # No manual key — the daemon will auto-deploy it on first registration
  507. info "SSH key will be deployed automatically when the daemon connects to Bambuddy"
  508. touch "$auth_keys"
  509. fi
  510. chmod 600 "$auth_keys"
  511. chown -R "$SPOOLBUDDY_SERVICE_USER:$SPOOLBUDDY_SERVICE_USER" "$ssh_dir"
  512. }
  513. create_spoolbuddy_service() {
  514. info "Creating SpoolBuddy systemd service..."
  515. local after_line="After=network-online.target"
  516. if [[ "$INSTALL_MODE" == "full" ]]; then
  517. after_line="After=network-online.target bambuddy.service"
  518. fi
  519. cat > /etc/systemd/system/spoolbuddy.service << EOF
  520. [Unit]
  521. Description=SpoolBuddy - NFC Spool Management Daemon
  522. Documentation=https://github.com/maziggy/bambuddy
  523. $after_line
  524. Wants=network-online.target
  525. [Service]
  526. Type=simple
  527. User=$SPOOLBUDDY_SERVICE_USER
  528. WorkingDirectory=$INSTALL_PATH/spoolbuddy
  529. EnvironmentFile=$INSTALL_PATH/spoolbuddy/.env
  530. ExecStart=$INSTALL_PATH/spoolbuddy/venv/bin/python -m daemon.main
  531. Restart=always
  532. RestartSec=5
  533. StandardOutput=journal
  534. StandardError=journal
  535. [Install]
  536. WantedBy=multi-user.target
  537. EOF
  538. systemctl daemon-reload
  539. systemctl enable spoolbuddy.service
  540. success "SpoolBuddy service created and enabled"
  541. }
  542. # ─────────────────────────────────────────────────────────────────────────────
  543. # Bambuddy Installation (full mode only)
  544. # ─────────────────────────────────────────────────────────────────────────────
  545. create_bambuddy_user() {
  546. if id "$BAMBUDDY_SERVICE_USER" &>/dev/null; then
  547. info "User '$BAMBUDDY_SERVICE_USER' already exists"
  548. return
  549. fi
  550. info "Creating service user '$BAMBUDDY_SERVICE_USER'..."
  551. useradd --system --shell /usr/sbin/nologin --home-dir "$INSTALL_PATH" "$BAMBUDDY_SERVICE_USER"
  552. success "Service user created"
  553. }
  554. setup_bambuddy_venv() {
  555. cd "$INSTALL_PATH"
  556. run_with_progress "Creating Bambuddy venv" $PYTHON_CMD -m venv venv
  557. run_with_progress "Upgrading pip" "$INSTALL_PATH/venv/bin/pip" install --upgrade pip
  558. run_with_progress "Installing Bambuddy dependencies" "$INSTALL_PATH/venv/bin/pip" install -r requirements.txt
  559. chown -R "$BAMBUDDY_SERVICE_USER:$BAMBUDDY_SERVICE_USER" "$INSTALL_PATH/venv"
  560. }
  561. install_nodejs() {
  562. if command -v node &>/dev/null; then
  563. local version
  564. version=$(node --version 2>/dev/null | sed 's/^v//')
  565. local major
  566. major=$(echo "$version" | cut -d'.' -f1)
  567. if [[ "$major" -ge 20 ]]; then
  568. success "Found Node.js v$version"
  569. return
  570. fi
  571. fi
  572. apt-get remove -y nodejs npm > /dev/null 2>&1 || true
  573. run_with_progress "Setting up Node.js repository" bash -c "curl -fsSL https://deb.nodesource.com/setup_22.x | bash -"
  574. run_with_progress "Installing Node.js" apt-get install -y nodejs
  575. hash -r 2>/dev/null || true
  576. success "Node.js installed: $(node --version)"
  577. }
  578. build_frontend() {
  579. cd "$INSTALL_PATH/frontend"
  580. run_with_progress "Installing frontend dependencies" npm ci
  581. run_with_progress "Building frontend" npm run build
  582. }
  583. create_bambuddy_env() {
  584. info "Creating Bambuddy configuration..."
  585. local env_file="$INSTALL_PATH/.env"
  586. cat > "$env_file" << EOF
  587. # Bambuddy Configuration
  588. # Generated by install.sh on $(date)
  589. DEBUG=false
  590. LOG_LEVEL=INFO
  591. LOG_TO_FILE=true
  592. EOF
  593. chown "$BAMBUDDY_SERVICE_USER:$BAMBUDDY_SERVICE_USER" "$env_file"
  594. chmod 600 "$env_file"
  595. success "Configuration saved to $env_file"
  596. }
  597. create_bambuddy_directories() {
  598. mkdir -p "$INSTALL_PATH/data" "$INSTALL_PATH/logs"
  599. chown -R "$BAMBUDDY_SERVICE_USER:$BAMBUDDY_SERVICE_USER" "$INSTALL_PATH/data" "$INSTALL_PATH/logs"
  600. success "Data directories created"
  601. }
  602. create_bambuddy_service() {
  603. info "Creating Bambuddy systemd service..."
  604. cat > /etc/systemd/system/bambuddy.service << EOF
  605. [Unit]
  606. Description=Bambuddy - Bambu Lab Print Management
  607. Documentation=https://github.com/maziggy/bambuddy
  608. After=network.target
  609. [Service]
  610. Type=simple
  611. User=$BAMBUDDY_SERVICE_USER
  612. Group=$BAMBUDDY_SERVICE_USER
  613. WorkingDirectory=$INSTALL_PATH
  614. EnvironmentFile=$INSTALL_PATH/.env
  615. Environment="DATA_DIR=$INSTALL_PATH/data"
  616. Environment="LOG_DIR=$INSTALL_PATH/logs"
  617. ExecStart=$INSTALL_PATH/venv/bin/uvicorn backend.app.main:app --host 0.0.0.0 --port $BAMBUDDY_PORT
  618. Restart=on-failure
  619. RestartSec=5
  620. StandardOutput=journal
  621. StandardError=journal
  622. NoNewPrivileges=true
  623. PrivateTmp=true
  624. ProtectSystem=strict
  625. ProtectHome=true
  626. ReadWritePaths=$INSTALL_PATH/data $INSTALL_PATH/logs $INSTALL_PATH
  627. [Install]
  628. WantedBy=multi-user.target
  629. EOF
  630. systemctl daemon-reload
  631. systemctl enable bambuddy.service
  632. success "Bambuddy service created and enabled"
  633. }
  634. # ─────────────────────────────────────────────────────────────────────────────
  635. # System Strip-Down (dedicated appliance — remove unnecessary services/packages)
  636. # ─────────────────────────────────────────────────────────────────────────────
  637. strip_services() {
  638. info "Disabling unnecessary services..."
  639. local services=(
  640. bluetooth.service
  641. lightdm.service
  642. cloud-init-local.service
  643. cloud-init.service
  644. cloud-init-network.service
  645. cloud-config.service
  646. cloud-final.service
  647. cloud-init-hotplugd.socket
  648. avahi-daemon.service
  649. avahi-daemon.socket
  650. ModemManager.service
  651. udisks2.service
  652. apparmor.service
  653. man-db.timer
  654. e2scrub_all.timer
  655. e2scrub_reap.service
  656. )
  657. local disabled=0
  658. for svc in "${services[@]}"; do
  659. if systemctl is-enabled "$svc" &>/dev/null; then
  660. systemctl disable "$svc" 2>/dev/null || true
  661. (( ++disabled ))
  662. fi
  663. done
  664. if (( disabled > 0 )); then
  665. success "Disabled $disabled unnecessary services"
  666. else
  667. success "No unnecessary services to disable"
  668. fi
  669. }
  670. strip_packages() {
  671. info "Removing unnecessary packages..."
  672. local packages=(
  673. mkvtoolnix
  674. firmware-atheros
  675. firmware-mediatek
  676. cloud-init
  677. rpi-cloud-init-mods
  678. rpi-connect-lite
  679. avahi-daemon
  680. modemmanager
  681. udisks2
  682. )
  683. local to_remove=()
  684. for pkg in "${packages[@]}"; do
  685. if dpkg -l "$pkg" &>/dev/null 2>&1; then
  686. to_remove+=("$pkg")
  687. fi
  688. done
  689. if (( ${#to_remove[@]} > 0 )); then
  690. run_with_progress "Removing ${#to_remove[@]} packages" apt-get remove --purge -y "${to_remove[@]}"
  691. run_with_progress "Cleaning up dependencies" apt-get autoremove --purge -y
  692. else
  693. success "No unnecessary packages to remove"
  694. fi
  695. }
  696. # ─────────────────────────────────────────────────────────────────────────────
  697. # Kiosk Setup (labwc + Chromium + Plymouth splash)
  698. # ─────────────────────────────────────────────────────────────────────────────
  699. setup_kiosk() {
  700. info "Setting up touchscreen kiosk..."
  701. # Detect kiosk user (the human user who ran sudo)
  702. KIOSK_USER="${SUDO_USER:-$(logname 2>/dev/null || echo pi)}"
  703. KIOSK_URL="${BAMBUDDY_URL}/spoolbuddy?token=${API_KEY}"
  704. local KIOSK_HOME
  705. KIOSK_HOME=$(eval echo "~$KIOSK_USER")
  706. info "Kiosk user: $KIOSK_USER (home: $KIOSK_HOME)"
  707. info "Kiosk URL: $KIOSK_URL"
  708. # Allow kiosk user to read SpoolBuddy env so launcher can resolve backend URL
  709. # and API key dynamically instead of using stale install-time fallback values.
  710. local spoolbuddy_env="$INSTALL_PATH/spoolbuddy/.env"
  711. if [[ -f "$spoolbuddy_env" ]]; then
  712. usermod -aG "$SPOOLBUDDY_SERVICE_USER" "$KIOSK_USER" 2>/dev/null || true
  713. chgrp "$SPOOLBUDDY_SERVICE_USER" "$spoolbuddy_env" 2>/dev/null || true
  714. chmod 640 "$spoolbuddy_env" 2>/dev/null || true
  715. fi
  716. # ── Install kiosk packages ────────────────────────────────────────────
  717. run_with_progress "Installing kiosk packages" apt-get install -y labwc chromium plymouth wlr-randr
  718. # ── config.txt tweaks ─────────────────────────────────────────────────
  719. local boot_config="/boot/firmware/config.txt"
  720. if [[ ! -f "$boot_config" ]]; then
  721. boot_config="/boot/config.txt"
  722. fi
  723. if [[ -f "$boot_config" ]]; then
  724. info "Configuring $boot_config for kiosk..."
  725. # Disable audio (change existing on→off)
  726. sed -i 's/^dtparam=audio=on/dtparam=audio=off/' "$boot_config"
  727. # Disable camera auto-detect (change existing 1→0)
  728. sed -i 's/^camera_auto_detect=1/camera_auto_detect=0/' "$boot_config"
  729. # Append if missing: gpu_mem=32
  730. if ! grep -q "^gpu_mem=" "$boot_config"; then
  731. echo "" >> "$boot_config"
  732. echo "# Kiosk: Minimal GPU firmware memory (KMS uses CMA from system RAM)" >> "$boot_config"
  733. echo "gpu_mem=32" >> "$boot_config"
  734. fi
  735. # Append if missing: dtoverlay=disable-bt
  736. if ! grep -q "^dtoverlay=disable-bt" "$boot_config"; then
  737. echo "" >> "$boot_config"
  738. echo "# Kiosk: Disable Bluetooth hardware" >> "$boot_config"
  739. echo "dtoverlay=disable-bt" >> "$boot_config"
  740. fi
  741. # Append if missing: disable_splash=1
  742. if ! grep -q "^disable_splash=" "$boot_config"; then
  743. echo "" >> "$boot_config"
  744. echo "# Kiosk: Disable Raspberry Pi firmware splash, use custom splash.png" >> "$boot_config"
  745. echo "disable_splash=1" >> "$boot_config"
  746. fi
  747. success "Boot config updated"
  748. fi
  749. # ── cmdline.txt tweaks ────────────────────────────────────────────────
  750. local cmdline="/boot/firmware/cmdline.txt"
  751. if [[ ! -f "$cmdline" ]]; then
  752. cmdline="/boot/cmdline.txt"
  753. fi
  754. if [[ -f "$cmdline" ]]; then
  755. info "Configuring $cmdline for kiosk..."
  756. # Remove serial console (Plymouth needs tty-only console)
  757. sed -i 's/console=serial0,[0-9]* //' "$cmdline"
  758. # Add splash quiet loglevel=3 logo.nologo if missing
  759. grep -q "splash" "$cmdline" || sed -i 's/$/ splash quiet loglevel=3 logo.nologo/' "$cmdline"
  760. # Add video mode if missing
  761. grep -q "video=HDMI-A-1" "$cmdline" || sed -i 's/$/ video=HDMI-A-1:1024x600@60/' "$cmdline"
  762. success "Kernel cmdline updated"
  763. fi
  764. # ── Plymouth splash theme ─────────────────────────────────────────────
  765. info "Installing Plymouth boot splash..."
  766. local theme_dir="/usr/share/plymouth/themes/spoolbuddy"
  767. mkdir -p "$theme_dir"
  768. # Copy bundled splash image from the install directory
  769. local script_dir
  770. script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  771. if [[ -f "$script_dir/splash.png" ]]; then
  772. cp "$script_dir/splash.png" "$theme_dir/splash.png"
  773. elif [[ -f "$INSTALL_PATH/spoolbuddy/install/splash.png" ]]; then
  774. cp "$INSTALL_PATH/spoolbuddy/install/splash.png" "$theme_dir/splash.png"
  775. else
  776. warn "splash.png not found — Plymouth splash will not display an image"
  777. fi
  778. # Write .plymouth theme file
  779. cat > "$theme_dir/spoolbuddy.plymouth" << 'EOF'
  780. [Plymouth Theme]
  781. Name=SpoolBuddy
  782. Description=SpoolBuddy boot splash
  783. ModuleName=script
  784. [script]
  785. ImageDir=/usr/share/plymouth/themes/spoolbuddy
  786. ScriptFile=/usr/share/plymouth/themes/spoolbuddy/spoolbuddy.script
  787. EOF
  788. # Write .script theme file
  789. cat > "$theme_dir/spoolbuddy.script" << 'EOF'
  790. wallpaper_image = Image("splash.png");
  791. screen_width = Window.GetWidth();
  792. screen_height = Window.GetHeight();
  793. resized_wallpaper_image = wallpaper_image.Scale(screen_width, screen_height);
  794. wallpaper_sprite = Sprite(resized_wallpaper_image);
  795. wallpaper_sprite.SetZ(-100);
  796. EOF
  797. plymouth-set-default-theme spoolbuddy
  798. run_with_progress "Updating initramfs" update-initramfs -u
  799. success "Plymouth splash installed"
  800. # ── Auto-login on tty1 ────────────────────────────────────────────────
  801. info "Configuring auto-login for $KIOSK_USER..."
  802. mkdir -p /etc/systemd/system/getty@tty1.service.d
  803. cat > /etc/systemd/system/getty@tty1.service.d/autologin.conf << EOF
  804. [Unit]
  805. After=network-online.target
  806. Wants=network-online.target
  807. [Service]
  808. ExecStart=
  809. ExecStart=-/sbin/agetty --autologin $KIOSK_USER --noclear %I \$TERM
  810. EOF
  811. success "Auto-login configured"
  812. # ── labwc rc.xml (no decorations, no keybinds) ────────────────────────
  813. info "Configuring labwc window manager..."
  814. local labwc_dir="$KIOSK_HOME/.config/labwc"
  815. mkdir -p "$labwc_dir"
  816. cat > "$labwc_dir/rc.xml" << 'EOF'
  817. <?xml version="1.0"?>
  818. <labwc_config>
  819. <theme>
  820. <name></name>
  821. <cornerRadius>0</cornerRadius>
  822. </theme>
  823. <!-- Disable all keybindings - kiosk lockdown -->
  824. <keyboard>
  825. </keyboard>
  826. <!-- Disable right-click menu -->
  827. <mouse>
  828. <default />
  829. </mouse>
  830. <!-- Remove window decorations, maximize Chromium, prevent unfullscreen -->
  831. <windowRules>
  832. <windowRule identifier="*">
  833. <serverDecoration>no</serverDecoration>
  834. </windowRule>
  835. <windowRule identifier="chromium">
  836. <skipTaskbar>yes</skipTaskbar>
  837. <fixedPosition>yes</fixedPosition>
  838. </windowRule>
  839. </windowRules>
  840. </labwc_config>
  841. EOF
  842. # ── kiosk launcher (dynamic URL from spoolbuddy/.env) ─────────────────
  843. local kiosk_launcher="/usr/local/bin/spoolbuddy-kiosk-launch"
  844. cat > "$kiosk_launcher" << EOF
  845. #!/usr/bin/env bash
  846. set -euo pipefail
  847. ENV_FILE="$INSTALL_PATH/spoolbuddy/.env"
  848. FALLBACK_URL="$KIOSK_URL"
  849. backend_url=""
  850. api_key=""
  851. if [[ -r "\$ENV_FILE" ]]; then
  852. backend_url="\$(sed -n 's/^SPOOLBUDDY_BACKEND_URL=//p' "\$ENV_FILE" | tail -n1 | tr -d '\r')"
  853. api_key="\$(sed -n 's/^SPOOLBUDDY_API_KEY=//p' "\$ENV_FILE" | tail -n1 | tr -d '\r')"
  854. backend_url="\${backend_url%\"}"
  855. backend_url="\${backend_url#\"}"
  856. api_key="\${api_key%\"}"
  857. api_key="\${api_key#\"}"
  858. elif [[ -f "\$ENV_FILE" ]]; then
  859. echo "spoolbuddy-kiosk-launch: ERROR: \$ENV_FILE exists but is not readable" >&2
  860. echo "spoolbuddy-kiosk-launch: Fix permissions (group-readable by kiosk user) and restart kiosk" >&2
  861. exit 1
  862. fi
  863. if [[ -n "\$backend_url" && -n "\$api_key" ]]; then
  864. backend_url="\${backend_url%/}"
  865. kiosk_url="\${backend_url}/spoolbuddy?token=\${api_key}"
  866. else
  867. kiosk_url="\$FALLBACK_URL"
  868. fi
  869. exec chromium --kiosk --no-first-run --disable-infobars \
  870. --disable-session-crashed-bubble --disable-features=TranslateUI \
  871. --noerrdialogs --disable-component-update \
  872. --overscroll-history-navigation=0 \
  873. --ozone-platform=wayland \
  874. "\$kiosk_url"
  875. EOF
  876. chmod 755 "$kiosk_launcher"
  877. # Tiny self-check: ensure sed command substitutions were not expanded
  878. # while generating the launcher script.
  879. if ! grep -Fq 'backend_url="$(sed -n' "$kiosk_launcher" || ! grep -Fq 'api_key="$(sed -n' "$kiosk_launcher"; then
  880. error "Kiosk launcher generation failed: dynamic env parsing commands were expanded unexpectedly"
  881. fi
  882. # ── labwc autostart ───────────────────────────────────────────────────
  883. cat > "$labwc_dir/autostart" << EOF
  884. # Force 1024x600 (panel doesn't advertise this natively)
  885. wlr-randr --output HDMI-A-1 --custom-mode 1024x600@60 &
  886. # Launch Chromium via helper that resolves URL from spoolbuddy/.env
  887. $kiosk_launcher &
  888. EOF
  889. chown -R "$KIOSK_USER:$KIOSK_USER" "$labwc_dir"
  890. # ── .bash_profile (source .bashrc, exec labwc on tty1) ────────────────
  891. cat > "$KIOSK_HOME/.bash_profile" << 'EOF'
  892. # Source .bashrc if it exists
  893. if [ -f ~/.bashrc ]; then
  894. . ~/.bashrc
  895. fi
  896. # Auto-start kiosk on tty1
  897. if [ "$(tty)" = "/dev/tty1" ]; then
  898. exec labwc
  899. fi
  900. EOF
  901. chown "$KIOSK_USER:$KIOSK_USER" "$KIOSK_HOME/.bash_profile"
  902. REBOOT_NEEDED="true"
  903. success "Kiosk setup complete"
  904. }
  905. # ─────────────────────────────────────────────────────────────────────────────
  906. # User Prompts
  907. # ─────────────────────────────────────────────────────────────────────────────
  908. parse_args() {
  909. while [[ $# -gt 0 ]]; do
  910. case "$1" in
  911. --mode)
  912. INSTALL_MODE="$2"
  913. shift 2
  914. ;;
  915. --repo)
  916. INSTALL_REPO="$(normalize_github_repo_url "$2")"
  917. shift 2
  918. ;;
  919. --ref)
  920. INSTALL_REF="$2"
  921. shift 2
  922. ;;
  923. --bambuddy-url)
  924. BAMBUDDY_URL="$2"
  925. shift 2
  926. ;;
  927. --api-key)
  928. API_KEY="$2"
  929. shift 2
  930. ;;
  931. --path)
  932. INSTALL_PATH="$2"
  933. shift 2
  934. ;;
  935. --port)
  936. BAMBUDDY_PORT="$2"
  937. shift 2
  938. ;;
  939. --ssh-pubkey)
  940. SSH_PUBKEY="$2"
  941. shift 2
  942. ;;
  943. --yes|-y)
  944. NON_INTERACTIVE="true"
  945. shift
  946. ;;
  947. --help|-h)
  948. show_help
  949. ;;
  950. *)
  951. error "Unknown option: $1 (use --help for usage)"
  952. ;;
  953. esac
  954. done
  955. }
  956. ask_install_mode() {
  957. if [[ -n "$INSTALL_MODE" ]]; then
  958. return
  959. fi
  960. echo ""
  961. echo -e "${BOLD}How would you like to set up SpoolBuddy?${NC}"
  962. echo ""
  963. echo -e " ${CYAN}1)${NC} SpoolBuddy only"
  964. echo " NFC reader + scale on this RPi, Bambuddy runs on another device"
  965. echo ""
  966. echo -e " ${CYAN}2)${NC} SpoolBuddy + Bambuddy"
  967. echo " Both running natively on this Raspberry Pi"
  968. echo ""
  969. while true; do
  970. echo -en "${BOLD}Choose${NC} [${CYAN}1${NC}/${CYAN}2${NC}]: "
  971. read -r choice
  972. case "$choice" in
  973. 1) INSTALL_MODE="spoolbuddy"; return;;
  974. 2) INSTALL_MODE="full"; return;;
  975. *) echo "Please enter 1 or 2.";;
  976. esac
  977. done
  978. }
  979. gather_config() {
  980. echo ""
  981. echo -e "${BOLD}Configuration${NC}"
  982. echo -e "${CYAN}─────────────────────────────────────────${NC}"
  983. echo ""
  984. # Set default install path based on mode
  985. if [[ -z "$INSTALL_PATH" ]]; then
  986. if [[ "$INSTALL_MODE" == "full" ]]; then
  987. INSTALL_PATH="/opt/bambuddy"
  988. else
  989. INSTALL_PATH="/opt/bambuddy"
  990. fi
  991. fi
  992. prompt "Installation directory" "$INSTALL_PATH" INSTALL_PATH
  993. if [[ -z "$INSTALL_REPO" ]]; then
  994. INSTALL_REPO="$GITHUB_REPO"
  995. fi
  996. prompt "Git repository URL" "$INSTALL_REPO" INSTALL_REPO
  997. INSTALL_REPO="$(normalize_github_repo_url "$INSTALL_REPO")"
  998. if [[ -z "$INSTALL_REF" ]]; then
  999. INSTALL_REF="main"
  1000. fi
  1001. if [[ "$NON_INTERACTIVE" != "true" && -n "$DETECTED_INSTALLER_REF" && "$DETECTED_INSTALLER_REF" != "HEAD" ]]; then
  1002. echo ""
  1003. echo -e "${BOLD}Install Source Ref${NC}"
  1004. echo "1) main"
  1005. echo "2) $DETECTED_INSTALLER_REF (detected from installer context)"
  1006. echo "3) custom"
  1007. while true; do
  1008. echo -en "${BOLD}Choose${NC} [1/2/3]: "
  1009. read -r ref_choice
  1010. case "$ref_choice" in
  1011. ""|1)
  1012. INSTALL_REF="main"
  1013. break
  1014. ;;
  1015. 2)
  1016. INSTALL_REF="$DETECTED_INSTALLER_REF"
  1017. break
  1018. ;;
  1019. 3)
  1020. prompt "Git ref (branch/tag/commit)" "$INSTALL_REF" INSTALL_REF
  1021. break
  1022. ;;
  1023. *)
  1024. echo "Please enter 1, 2, or 3."
  1025. ;;
  1026. esac
  1027. done
  1028. else
  1029. prompt "Git ref (branch/tag/commit)" "$INSTALL_REF" INSTALL_REF
  1030. fi
  1031. if [[ "$INSTALL_MODE" == "spoolbuddy" ]]; then
  1032. # Need remote Bambuddy URL and API key
  1033. echo ""
  1034. info "SpoolBuddy needs to connect to your Bambuddy server."
  1035. info "You can find/create an API key in Bambuddy under Settings -> API Keys."
  1036. echo ""
  1037. while [[ -z "$BAMBUDDY_URL" ]]; do
  1038. prompt "Bambuddy server URL (e.g. http://192.168.1.100:8000)" "" BAMBUDDY_URL
  1039. if [[ -z "$BAMBUDDY_URL" ]]; then
  1040. warn "Bambuddy URL is required"
  1041. fi
  1042. done
  1043. while [[ -z "$API_KEY" ]]; do
  1044. prompt "Bambuddy API key" "" API_KEY
  1045. if [[ -z "$API_KEY" ]]; then
  1046. warn "API key is required"
  1047. fi
  1048. done
  1049. else
  1050. # Full mode — Bambuddy runs locally
  1051. prompt "Bambuddy port" "$BAMBUDDY_PORT" BAMBUDDY_PORT
  1052. BAMBUDDY_URL="http://localhost:$BAMBUDDY_PORT"
  1053. echo ""
  1054. info "After installation, create an API key in Bambuddy (Settings -> API Keys)"
  1055. info "and update it in: $INSTALL_PATH/spoolbuddy/.env"
  1056. API_KEY="CHANGE_ME_AFTER_SETUP"
  1057. fi
  1058. # Summary
  1059. echo ""
  1060. echo -e "${BOLD}Installation Summary${NC}"
  1061. echo -e "${CYAN}─────────────────────────────────────────${NC}"
  1062. echo -e " Mode: ${GREEN}$([ "$INSTALL_MODE" == "full" ] && echo "Bambuddy + SpoolBuddy" || echo "SpoolBuddy only")${NC}"
  1063. echo -e " Install path: ${GREEN}$INSTALL_PATH${NC}"
  1064. echo -e " Git repo: ${GREEN}$INSTALL_REPO${NC}"
  1065. echo -e " Git ref: ${GREEN}$INSTALL_REF${NC}"
  1066. if [[ "$INSTALL_MODE" == "full" ]]; then
  1067. echo -e " Bambuddy port: ${GREEN}$BAMBUDDY_PORT${NC}"
  1068. echo -e " Bambuddy URL: ${GREEN}$BAMBUDDY_URL${NC}"
  1069. else
  1070. echo -e " Bambuddy URL: ${GREEN}$BAMBUDDY_URL${NC}"
  1071. fi
  1072. echo ""
  1073. if ! prompt_yes_no "Proceed with installation?" "y"; then
  1074. echo "Installation cancelled."
  1075. exit 0
  1076. fi
  1077. }
  1078. # ─────────────────────────────────────────────────────────────────────────────
  1079. # Main
  1080. # ─────────────────────────────────────────────────────────────────────────────
  1081. main() {
  1082. parse_args "$@"
  1083. detect_installer_source_context
  1084. echo ""
  1085. echo -e "${CYAN}╔══════════════════════════════════════════════════════════╗${NC}"
  1086. echo -e "${CYAN}║ ║${NC}"
  1087. echo -e "${CYAN}║ ____ _ ____ _ _ ║${NC}"
  1088. echo -e "${CYAN}║ / ___| _ __ ___ ___ | | __ ) _ _ __| | __| |_ _ ║${NC}"
  1089. echo -e "${CYAN}║ \\___ \\| '_ \\ / _ \\ / _ \\| | _ \\| | | |/ _\` |/ _\` | | | |║${NC}"
  1090. echo -e "${CYAN}║ ___) | |_) | (_) | (_) | | |_) | |_| | (_| | (_| | |_| |║${NC}"
  1091. echo -e "${CYAN}║ |____/| .__/ \\___/ \\___/|_|____/ \\__,_|\\__,_|\\__,_|\\__, |║${NC}"
  1092. echo -e "${CYAN}║ |_| |___/ ║${NC}"
  1093. echo -e "${CYAN}║ ║${NC}"
  1094. echo -e "${CYAN}║ NFC Spool Management for Bambuddy ║${NC}"
  1095. echo -e "${CYAN}║ ║${NC}"
  1096. echo -e "${CYAN}╚══════════════════════════════════════════════════════════╝${NC}"
  1097. echo ""
  1098. # Check if running via pipe without -y
  1099. if [[ ! -t 0 ]] && [[ "$NON_INTERACTIVE" != "true" ]]; then
  1100. error "Interactive mode requires a terminal. Use -y for unattended install, or download and run directly."
  1101. fi
  1102. # Pre-flight checks
  1103. check_root
  1104. check_raspberry_pi
  1105. check_raspberry_pi_os
  1106. if ! detect_python; then
  1107. info "Python 3.10+ not found, will install..."
  1108. fi
  1109. # Gather user preferences
  1110. ask_install_mode
  1111. gather_config
  1112. # Validate mode
  1113. if [[ "$INSTALL_MODE" != "spoolbuddy" && "$INSTALL_MODE" != "full" ]]; then
  1114. error "Invalid mode: $INSTALL_MODE (must be 'spoolbuddy' or 'full')"
  1115. fi
  1116. echo ""
  1117. echo -e "${BOLD}Starting Installation${NC}"
  1118. echo -e "${CYAN}─────────────────────────────────────────${NC}"
  1119. echo ""
  1120. # ── Step 1: Raspberry Pi hardware config ──────────────────────────────
  1121. info "Configuring Raspberry Pi hardware..."
  1122. enable_spi
  1123. enable_i2c
  1124. configure_boot_config
  1125. echo ""
  1126. # ── Step 2: System packages ───────────────────────────────────────────
  1127. install_system_packages
  1128. install_wifi_safeguard
  1129. upgrade_system_packages
  1130. detect_python || error "Failed to install Python 3.10+"
  1131. echo ""
  1132. # ── Step 2b: Strip unnecessary services & packages ────────────────────
  1133. strip_services
  1134. strip_packages
  1135. echo ""
  1136. # ── Step 3: Download source code ──────────────────────────────────────
  1137. create_spoolbuddy_user
  1138. download_spoolbuddy
  1139. echo ""
  1140. # ── Step 3b: Kiosk setup (labwc + Chromium + squeekboard + Plymouth) ──
  1141. setup_kiosk
  1142. echo ""
  1143. # ── Step 4: SpoolBuddy setup ──────────────────────────────────────────
  1144. info "Setting up SpoolBuddy..."
  1145. setup_spoolbuddy_venv
  1146. create_spoolbuddy_env
  1147. # Kiosk env access: only needed if actual kiosk hardware is available
  1148. if [[ -f /boot/firmware/config.txt ]] || [[ -f /boot/config.txt ]]; then
  1149. ensure_kiosk_env_access
  1150. fi
  1151. setup_ssh_key
  1152. create_spoolbuddy_service
  1153. echo ""
  1154. # ── Step 5: Bambuddy setup (full mode only) ───────────────────────────
  1155. if [[ "$INSTALL_MODE" == "full" ]]; then
  1156. info "Setting up Bambuddy..."
  1157. create_bambuddy_user
  1158. setup_bambuddy_venv
  1159. install_nodejs
  1160. build_frontend
  1161. create_bambuddy_directories
  1162. create_bambuddy_env
  1163. create_bambuddy_service
  1164. echo ""
  1165. fi
  1166. # ── Done ──────────────────────────────────────────────────────────────
  1167. echo ""
  1168. echo -e "${GREEN}╔══════════════════════════════════════════════════════════╗${NC}"
  1169. echo -e "${GREEN}║ ║${NC}"
  1170. echo -e "${GREEN}║ Installation Complete! ║${NC}"
  1171. echo -e "${GREEN}║ ║${NC}"
  1172. echo -e "${GREEN}╚══════════════════════════════════════════════════════════╝${NC}"
  1173. echo ""
  1174. local ip_addr
  1175. ip_addr=$(hostname -I 2>/dev/null | awk '{print $1}') || ip_addr="<your-ip>"
  1176. if [[ "$INSTALL_MODE" == "full" ]]; then
  1177. echo -e " ${BOLD}Bambuddy:${NC} ${CYAN}http://$ip_addr:$BAMBUDDY_PORT${NC}"
  1178. else
  1179. echo -e " ${BOLD}SpoolBuddy:${NC} Connecting to ${CYAN}$BAMBUDDY_URL${NC}"
  1180. fi
  1181. echo -e " ${BOLD}Kiosk URL:${NC} ${CYAN}$KIOSK_URL${NC}"
  1182. echo -e " ${BOLD}Kiosk user:${NC} ${CYAN}$KIOSK_USER${NC}"
  1183. echo ""
  1184. if [[ "$INSTALL_MODE" == "full" ]]; then
  1185. echo -e " ${BOLD}Next steps:${NC}"
  1186. echo -e " 1. Reboot (required for kiosk, Plymouth splash, and hardware changes)"
  1187. echo -e " 2. The touchscreen kiosk will start automatically after reboot"
  1188. echo -e " 3. On another device, open ${CYAN}http://$ip_addr:$BAMBUDDY_PORT${NC}"
  1189. echo -e " 4. Go to Settings -> API Keys and create an API key"
  1190. echo -e " 5. Update the API key in: ${CYAN}$INSTALL_PATH/spoolbuddy/.env${NC}"
  1191. echo -e " 6. Restart SpoolBuddy: ${CYAN}sudo systemctl restart spoolbuddy${NC}"
  1192. fi
  1193. echo ""
  1194. echo -e " ${BOLD}Manage services:${NC}"
  1195. echo -e " SpoolBuddy status: ${CYAN}sudo systemctl status spoolbuddy${NC}"
  1196. echo -e " SpoolBuddy logs: ${CYAN}sudo journalctl -u spoolbuddy -f${NC}"
  1197. if [[ "$INSTALL_MODE" == "full" ]]; then
  1198. echo -e " Bambuddy status: ${CYAN}sudo systemctl status bambuddy${NC}"
  1199. echo -e " Bambuddy logs: ${CYAN}sudo journalctl -u bambuddy -f${NC}"
  1200. fi
  1201. echo ""
  1202. echo -e " ${BOLD}Configuration:${NC} ${CYAN}$INSTALL_PATH/spoolbuddy/.env${NC}"
  1203. echo -e " ${BOLD}Hardware wiring:${NC} ${CYAN}$INSTALL_PATH/spoolbuddy/README.md${NC}"
  1204. echo -e " ${BOLD}Diagnostics:${NC} ${CYAN}sudo $INSTALL_PATH/spoolbuddy/venv/bin/python $INSTALL_PATH/spoolbuddy/pn5180_diag.py${NC}"
  1205. echo ""
  1206. echo -e " ${YELLOW}A reboot is required to apply all changes (kiosk, Plymouth splash, hardware).${NC}"
  1207. echo ""
  1208. if prompt_yes_no "Reboot now?" "y"; then
  1209. reboot
  1210. else
  1211. echo -e " Run ${CYAN}sudo reboot${NC} when ready."
  1212. fi
  1213. echo ""
  1214. }
  1215. main "$@"