From 7e39d3e38d3f72af863c0ffc205704d823e7a1e3 Mon Sep 17 00:00:00 2001 From: RunLit <41996199+RunLit@users.noreply.github.com> Date: Sat, 7 Mar 2026 16:53:33 +1100 Subject: [PATCH] Native setup and Downsample data (#4) * PrinterDataAPIView downsample * filament usage chart now works without day constraint * One command native setup * add setup timezone verification and link * added wipe off instructions * setup default to port 80 * user selectable port number with default to 80 * skip superuser creation if exists * auto install iptables if not available * wipe out instructions updated --- README.md | 179 +++++++++-------- bambu_run/views.py | 297 +++++++++++++++++------------ docs/Bambu_Color_Catalog/ABS.txt | 6 +- native/bambu-run-collector.service | 15 ++ native/bambu-run-web.service | 15 ++ native/bambu-run.sh | 58 ++++++ setup.sh | 263 +++++++++++++++++++++++++ standalone/manage.py | 10 +- 8 files changed, 624 insertions(+), 219 deletions(-) create mode 100644 native/bambu-run-collector.service create mode 100644 native/bambu-run-web.service create mode 100755 native/bambu-run.sh create mode 100755 setup.sh diff --git a/README.md b/README.md index 4c0cf5a..b3bb905 100644 --- a/README.md +++ b/README.md @@ -22,151 +22,144 @@ It runs quietly in the background 24/7, capturing every print, filament change, ## Table of Contents -- [Quick Start: One-Click Docker Setup — Beginner Friendly](#quick-start-one-click-docker-setup--beginner-friendly) +- [Native Setup (Recommended for Raspberry Pi)](#native-setup-recommended-for-raspberry-pi) - [What You'll Need](#what-youll-need) - - [Step 1: Connect to Your Raspberry Pi](#step-1-connect-to-your-raspberry-pi) - - [Step 2: Install Docker](#step-2-install-docker) - - [Step 3: Download and Configure](#step-3-download-and-configure) - - [Step 4: Build the Container](#step-4-build-the-container) - - [Step 5: First-Time Authentication](#step-5-first-time-authentication) - - [Step 6: Start Bambu-Run and Create Your Login](#step-6-start-bambu-run-and-create-your-login) - - [Step 7: Open the Dashboard](#step-7-open-the-dashboard) - - [Troubleshooting](#troubleshooting) + - [Clone and run setup.sh](#clone-and-run-setupsh) + - [Managing Bambu-Run](#managing-bambu-run) + - [Troubleshooting (Native)](#troubleshooting-native) +- [Docker Setup](#docker-setup) - [Batch Importing Filament Colors and Filament Types](#batch-importing-filament-colors-and-filament-types) --- -## Quick Start: One-Click Docker Setup — Beginner Friendly +## Native Setup (Recommended for Raspberry Pi) -Get Bambu-Run running on a **Raspberry Pi** in minutes. No prior server experience needed. +No Docker required. Works on any Raspberry Pi (including 32-bit Pi Model B) running Raspberry Pi OS with Python 3.10+. ### What You'll Need -- A Raspberry Pi (3B+, 4, or 5) running Raspberry Pi OS 64-bit, with a 32 GB+ MicroSD card, connected to your network -- Your Bambu Lab printer on the **same local network** -- Your Bambu Lab account **email and password** -- A computer to SSH into the Pi +- Raspberry Pi on your local network (Python 3.10+) +- Bambu Lab printer +- Bambu Lab account **email and password** -### Step 1: Connect to Your Raspberry Pi - -From your computer, open a terminal (Mac/Linux) or PowerShell (Windows): +### Clone and run setup.sh ```bash -ssh pi@raspberrypi.local +git clone https://github.com/RunLit/Bambu-Run.git +cd Bambu-Run +bash setup.sh ``` -> Can't connect? Use your Pi's IP address (find it in your router's admin page). Default password: `raspberry` +That's it! The script handles everything interactively, just answer the prompts. When it finishes, open `http://` from any device on same network. -### Step 2: Install Docker +The script is safe to re-run at any time. +--- + +**What the script does**: + +- **Dependencies**: creates a Python virtual environment, installs all packages +- **Credentials**: prompts for your **BambuLab Cloud account** email, password, and timezone; auto-generates a `DJANGO_SECRET_KEY`; writes `.env` +- **Bambu Cloud auth**: runs `bambu_collector --once`; + - Bambu Lab will send a 6-digit code to your email; check you email box and enter it when prompted; + - the resulting token is saved to `.env` automatically; future restarts skip this step +- **Dashboard login**: runs `createsuperuser`; choose a username and password for Bambu-Run web UI log in +- **Services**: installs and starts two systemd services (`bambu-run-web` and `bambu-run-collector`), enables linger so they auto-start on boot +- **Port 80**: sets an `iptables` redirect (80 to 8000) so you can reach the dashboard at a plain `http://` with no port number; persisted via `iptables-persistent` across reboots. + +--- + +### Managing Bambu-Run + +All commands manage Bambu-Run encapsulated in `./native/bambu-run.sh`. Alternatively, you can do it yourself with systemctl commands. ```bash -curl -fsSL https://get.docker.com | sudo sh -sudo usermod -aG docker $USER +./native/bambu-run.sh status # service status +./native/bambu-run.sh logs # tail live logs (Ctrl+C to stop) +./native/bambu-run.sh restart # restart both services +./native/bambu-run.sh stop # stop everything +./native/bambu-run.sh update # git pull + pip install + migrate + restart ``` -Log out and back in for the change to take effect, then verify: +### Troubleshooting (Native) +**Services die when SSH disconnects:** `sudo loginctl enable-linger $USER` + +**Services not starting:** `./native/bambu-run.sh status` and `./native/bambu-run.sh logs` + +**Auth errors / token expired:** Remove `BAMBU_TOKEN` from `.env` and re-run `bash setup.sh` + +**Uninstall:** ```bash -exit +systemctl --user disable --now bambu-run-web bambu-run-collector +rm ~/.config/systemd/user/bambu-run-{web,collector}.service +systemctl --user daemon-reload ``` +**Wipe everything and start over:** ```bash -ssh pi@raspberrypi.local -docker --version # should show Docker version 27.x.x +# Stop and remove services +systemctl --user stop bambu-run-web bambu-run-collector +systemctl --user disable bambu-run-web bambu-run-collector +rm ~/.config/systemd/user/bambu-run-{web,collector}.service +systemctl --user daemon-reload + +# Remove port redirect (replace 80 with whatever port you chose during setup) +sudo iptables -t nat -D PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 8000 2>/dev/null || true +sudo iptables -t nat -D OUTPUT -o lo -p tcp --dport 80 -j REDIRECT --to-port 8000 2>/dev/null || true +sudo netfilter-persistent save 2>/dev/null || true + +# Delete repo — wipes venv, database, and .env +cd ~ +rm -rf ~/Bambu-Run + +# Re-clone and run setup from scratch +git clone https://github.com/RunLit/Bambu-Run.git +cd Bambu-Run +bash setup.sh ``` -> Installation issues? See: https://docs.docker.com/engine/install/raspberry-pi-os/ +--- -### Step 3: Download and Configure +## Docker Setup + +Requires Docker and Docker Compose installed. Assumes you already know how to get there. + +**Clone and configure:** ```bash git clone https://github.com/RunLit/Bambu-Run.git cd Bambu-Run cp .env.example .env -nano .env +# Edit .env: set BAMBU_USERNAME, BAMBU_PASSWORD, TIMEZONE ``` -Fill in your Bambu Lab credentials: - -``` -BAMBU_USERNAME=your_email@example.com -BAMBU_PASSWORD=your_password -TIMEZONE=Australia/Melbourne # optional — find yours at https://en.wikipedia.org/wiki/List_of_tz_database_time_zones -``` - -Save: `Ctrl + X`, `Y`, `Enter` - -### Step 4: Build the Container +**First-time auth** (Bambu Lab sends a 6-digit verification code to your email): ```bash docker compose build -``` - -This takes a few minutes the first time — it downloads all required software. - -### Step 5: First-Time Authentication - -Bambu Lab requires email verification on first login. Run these two commands: - -```bash docker compose run --rm bambu-run python standalone/manage.py migrate --noinput docker compose run --rm bambu-run python standalone/manage.py bambu_collector --once +# Paste the printed token into .env as BAMBU_TOKEN=... ``` -When prompted, enter the 6-digit code sent to your email. On success you'll see a token printed — copy it and add it to your `.env`: - -```bash -nano .env -``` - -``` -BAMBU_TOKEN=eyJhbGciOiJIUzI1N...paste_full_token_here -``` - -> Saving the token lets future restarts skip re-verification automatically. - -### Step 6: Start Bambu-Run and Create Your Login +**Start and create your dashboard login:** ```bash docker compose up -d docker compose exec bambu-run python standalone/manage.py createsuperuser ``` -Choose a username and password — this is your dashboard login. +Dashboard is at `http://:8000`. -### Step 7: Open the Dashboard +**Common operations:** -On any device on your network, open a browser and go to: - -``` -http://raspberrypi.local:8000 -``` - -> If that doesn't work, use your Pi's IP: `http://:8000` - -Log in with the account you just created. Your printer dashboard should be live. - -### Troubleshooting - -**No data / cannot connect to printer:** Make sure the printer is on and on the same network. Check logs: `docker compose logs -f`. If you see auth errors, re-run Step 5 to get a fresh token. - -**401 Unauthorized / verification loop:** Remove `BAMBU_TOKEN` from `.env` and re-run Step 5. - -**Docker daemon error:** Log out and back in after Step 2 — the group change requires a new session. - -**Dashboard not loading:** Run `docker compose ps` to confirm the service is `Up`, then try the Pi's IP address directly. - -**Update Bambu-Run:** ```bash -cd ~/Bambu-Run && git pull && docker compose up -d --build +docker compose logs -f # live logs +docker compose down # stop (data preserved in volume) +git pull && docker compose up -d --build # update ``` -**Stop Bambu-Run:** -```bash -docker compose down -``` - -Your data is preserved in a Docker volume and will be there when you start it again. +**Troubleshooting:** Auth errors → remove `BAMBU_TOKEN` from `.env` and re-run the auth step. No data → check `docker compose logs -f` for MQTT connection errors. --- diff --git a/bambu_run/views.py b/bambu_run/views.py index ab67776..e4e1c6e 100644 --- a/bambu_run/views.py +++ b/bambu_run/views.py @@ -1,4 +1,4 @@ -from datetime import timedelta +from datetime import timedelta, datetime from django.views.generic import TemplateView, View, ListView, CreateView, UpdateView, DetailView, DeleteView from django.contrib.auth.mixins import LoginRequiredMixin from django.utils import timezone @@ -13,6 +13,18 @@ from .conf import app_settings from .models import Printer, PrinterMetrics, Filament, FilamentColor, FilamentType, FilamentSnapshot, PrintJob, FilamentUsage from .forms import FilamentForm, FilamentColorForm, FilamentTypeForm +_METRICS_API_FIELDS = [ + 'id', 'device_id', 'timestamp', + 'nozzle_temp', 'nozzle_target_temp', + 'bed_temp', 'bed_target_temp', + 'print_percent', 'cooling_fan_speed', 'heatbreak_fan_speed', + 'wifi_signal_dbm', 'ams_humidity_raw', 'ams_temp', + 'layer_num', 'total_layer_num', + 'gcode_state', 'print_type', 'subtask_name', + 'external_spool', +] +_MAX_CHART_POINTS = 3000 + class PrinterDashboardView(LoginRequiredMixin, TemplateView): template_name = "bambu_run/printer_dashboard.html" @@ -248,51 +260,175 @@ class PrinterDataAPIView(LoginRequiredMixin, View): if not printer_device: return JsonResponse({"error": "No printer device found"}, status=404) - query = PrinterMetrics.objects.filter(device=printer_device).prefetch_related('filament_snapshots') - tz = zoneinfo.ZoneInfo(app_settings.TIMEZONE) - if start_date and start_time: - from datetime import datetime - start_dt_naive = datetime.strptime(f"{start_date} {start_time}", "%Y-%m-%d %H:%M") - start_dt = start_dt_naive.replace(tzinfo=tz) + # Stage A: only() + step calculation + query = ( + PrinterMetrics.objects + .filter(device=printer_device) + .only(*_METRICS_API_FIELDS) + ) + + if start_date and start_time and end_date and end_time: + start_dt = datetime.strptime(f"{start_date} {start_time}", "%Y-%m-%d %H:%M").replace(tzinfo=tz) + end_dt = datetime.strptime(f"{end_date} {end_time}", "%Y-%m-%d %H:%M").replace(tzinfo=tz) + query = query.filter(timestamp__gte=start_dt, timestamp__lte=end_dt) + range_seconds = (end_dt - start_dt).total_seconds() + expected_count = max(1, int(range_seconds / 30)) + elif start_date and start_time: + start_dt = datetime.strptime(f"{start_date} {start_time}", "%Y-%m-%d %H:%M").replace(tzinfo=tz) query = query.filter(timestamp__gte=start_dt) - - if end_date and end_time: - from datetime import datetime - end_dt_naive = datetime.strptime(f"{end_date} {end_time}", "%Y-%m-%d %H:%M") - end_dt = end_dt_naive.replace(tzinfo=tz) + expected_count = _MAX_CHART_POINTS + elif end_date and end_time: + end_dt = datetime.strptime(f"{end_date} {end_time}", "%Y-%m-%d %H:%M").replace(tzinfo=tz) query = query.filter(timestamp__lte=end_dt) + expected_count = _MAX_CHART_POINTS + else: + expected_count = _MAX_CHART_POINTS - metrics = query.order_by("timestamp") + step = max(1, expected_count // _MAX_CHART_POINTS) + + # Stage B: single DB round-trip, downsample in Python + metrics_list = list(query.order_by("timestamp")) + if step > 1: + metrics_list = metrics_list[::step] + + total_points = len(metrics_list) + + # Stage C: targeted snapshot fetch (only sampled IDs) + snapshots_by_metric: dict = {} + if metrics_list: + sampled_ids = [m.id for m in metrics_list] + for snap in FilamentSnapshot.objects.filter(printer_metric_id__in=sampled_ids): + snapshots_by_metric.setdefault(snap.printer_metric_id, []).append(snap) + + # Stage D: single-pass serialization + timestamps = [] + timestamps_iso = [] + dates = [] + nozzle_temp = [] + nozzle_target_temp = [] + bed_temp = [] + bed_target_temp = [] + print_percent = [] + cooling_fan_speed = [] + heatbreak_fan_speed = [] + wifi_signal_dbm = [] + ams_humidity_raw = [] + ams_temp = [] + layer_num = [] + total_layer_num = [] + gcode_state = [] + print_type = [] + subtask_name = [] + + project_markers = [] + current_job = None + last_state = None + + filament_data = {} + + for idx, m in enumerate(metrics_list): + ts = m.timestamp.astimezone(tz) + timestamps.append(ts.strftime('%H:%M')) + timestamps_iso.append(ts.isoformat()) + dates.append(ts.strftime('%Y-%m-%d')) + nozzle_temp.append(float(m.nozzle_temp) if m.nozzle_temp else None) + nozzle_target_temp.append(float(m.nozzle_target_temp) if m.nozzle_target_temp else None) + bed_temp.append(float(m.bed_temp) if m.bed_temp else None) + bed_target_temp.append(float(m.bed_target_temp) if m.bed_target_temp else None) + print_percent.append(m.print_percent if m.print_percent else 0) + cooling_fan_speed.append(m.cooling_fan_speed if m.cooling_fan_speed else 0) + heatbreak_fan_speed.append(m.heatbreak_fan_speed if m.heatbreak_fan_speed else 0) + wifi_signal_dbm.append(m.wifi_signal_dbm if m.wifi_signal_dbm else None) + ams_humidity_raw.append(m.ams_humidity_raw if m.ams_humidity_raw else None) + ams_temp.append(float(m.ams_temp) if m.ams_temp else None) + layer_num.append(m.layer_num if m.layer_num else 0) + total_layer_num.append(m.total_layer_num if m.total_layer_num else 0) + gcode_state.append(m.gcode_state) + print_type.append(m.print_type) + subtask_name.append(m.subtask_name) + + # Project marker detection (inline) + subtask = m.subtask_name + gs = m.gcode_state + is_printing = gs not in ['FINISH', 'IDLE', None, ''] + if subtask and subtask != current_job and is_printing: + project_markers.append({ + 'type': 'start', + 'index': idx, + 'timestamp': ts.isoformat(), + 'project_name': subtask, + }) + current_job = subtask + last_state = gs + elif current_job and last_state and last_state not in ['FINISH', 'IDLE'] and gs in ['FINISH', 'IDLE']: + project_markers.append({ + 'type': 'end', + 'index': idx, + 'timestamp': ts.isoformat(), + 'project_name': current_job, + }) + current_job = None + last_state = gs + + # Filament timeline (inline) + for snap in snapshots_by_metric.get(m.id, []): + tray_id = snap.tray_id + fil_type = snap.type or 'Unknown' + fil_sub_type = snap.sub_type or 'Unknown' + fil_color = snap.color or 'FFFFFFFF' + unique_key = f"{tray_id}_{fil_type}_{fil_sub_type}_{fil_color}" + if unique_key not in filament_data: + filament_data[unique_key] = { + 'tray_id': tray_id, + 'type': fil_type, + 'brand': fil_sub_type, + 'color': fil_color, + 'remain_data': [None] * total_points, + 'start_idx': idx, + } + filament_data[unique_key]['remain_data'][idx] = snap.remain_percent or 0 + + external = m.external_spool or {} + if external.get('type'): + fil_type = external.get('type', 'Unknown') + fil_color = external.get('color', '161616FF') + unique_key = f"External_{fil_type}_{fil_color}" + if unique_key not in filament_data: + filament_data[unique_key] = { + 'tray_id': 'External', + 'type': fil_type, + 'brand': 'External', + 'color': fil_color, + 'remain_data': [None] * total_points, + 'start_idx': idx, + } + filament_data[unique_key]['remain_data'][idx] = external.get('remain', 0) data = { - "timestamps": [m.timestamp.astimezone(tz).strftime('%H:%M') for m in metrics], - "timestamps_iso": [m.timestamp.astimezone(tz).isoformat() for m in metrics], - "dates": [m.timestamp.astimezone(tz).strftime('%Y-%m-%d') for m in metrics], - "nozzle_temp": [float(m.nozzle_temp) if m.nozzle_temp else None for m in metrics], - "nozzle_target_temp": [float(m.nozzle_target_temp) if m.nozzle_target_temp else None for m in metrics], - "bed_temp": [float(m.bed_temp) if m.bed_temp else None for m in metrics], - "bed_target_temp": [float(m.bed_target_temp) if m.bed_target_temp else None for m in metrics], - "print_percent": [m.print_percent if m.print_percent else 0 for m in metrics], - "cooling_fan_speed": [m.cooling_fan_speed if m.cooling_fan_speed else 0 for m in metrics], - "heatbreak_fan_speed": [m.heatbreak_fan_speed if m.heatbreak_fan_speed else 0 for m in metrics], - "wifi_signal_dbm": [m.wifi_signal_dbm if m.wifi_signal_dbm else None for m in metrics], - "ams_humidity_raw": [m.ams_humidity_raw if m.ams_humidity_raw else None for m in metrics], - "ams_temp": [float(m.ams_temp) if m.ams_temp else None for m in metrics], - "layer_num": [m.layer_num if m.layer_num else 0 for m in metrics], - "total_layer_num": [m.total_layer_num if m.total_layer_num else 0 for m in metrics], - "gcode_state": [m.gcode_state for m in metrics], - "print_type": [m.print_type for m in metrics], - "subtask_name": [m.subtask_name for m in metrics], + "timestamps": timestamps, + "timestamps_iso": timestamps_iso, + "dates": dates, + "nozzle_temp": nozzle_temp, + "nozzle_target_temp": nozzle_target_temp, + "bed_temp": bed_temp, + "bed_target_temp": bed_target_temp, + "print_percent": print_percent, + "cooling_fan_speed": cooling_fan_speed, + "heatbreak_fan_speed": heatbreak_fan_speed, + "wifi_signal_dbm": wifi_signal_dbm, + "ams_humidity_raw": ams_humidity_raw, + "ams_temp": ams_temp, + "layer_num": layer_num, + "total_layer_num": total_layer_num, + "gcode_state": gcode_state, + "print_type": print_type, + "subtask_name": subtask_name, + "project_markers": project_markers, + "filament_timeline": filament_data, } - project_markers = self._calculate_project_markers(metrics, tz) - data["project_markers"] = project_markers - - filament_timeline = self._prepare_filament_timeline_for_api(metrics) - data["filament_timeline"] = filament_timeline - return JsonResponse(data) except Exception as e: @@ -300,93 +436,6 @@ class PrinterDataAPIView(LoginRequiredMixin, View): traceback.print_exc() return JsonResponse({"error": str(e)}, status=500) - def _calculate_project_markers(self, metrics, timezone_info): - markers = [] - current_job = None - last_state = None - - for idx, metric in enumerate(metrics): - subtask = metric.subtask_name - gcode_state = metric.gcode_state - - is_printing = gcode_state not in ['FINISH', 'IDLE', None, ''] - - if subtask and subtask != current_job and is_printing: - markers.append({ - 'type': 'start', - 'index': idx, - 'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(), - 'project_name': subtask, - }) - current_job = subtask - last_state = gcode_state - - elif current_job and last_state and last_state not in ['FINISH', 'IDLE'] and gcode_state in ['FINISH', 'IDLE']: - markers.append({ - 'type': 'end', - 'index': idx, - 'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(), - 'project_name': current_job, - }) - current_job = None - - last_state = gcode_state - - return markers - - def _prepare_filament_timeline_for_api(self, metrics): - filament_data = {} - total_points = len(metrics) - - for idx, metric in enumerate(metrics): - try: - snapshots = metric.filament_snapshots.all() - except Exception: - snapshots = [] - - for snapshot in snapshots: - tray_id = snapshot.tray_id - fil_type = snapshot.type or 'Unknown' - fil_sub_type = snapshot.sub_type or 'Unknown' - fil_color = snapshot.color or 'FFFFFFFF' - - unique_key = f"{tray_id}_{fil_type}_{fil_sub_type}_{fil_color}" - - if unique_key not in filament_data: - filament_data[unique_key] = { - 'tray_id': tray_id, - 'type': fil_type, - 'brand': fil_sub_type, - 'color': fil_color, - 'remain_data': [None] * total_points, - 'start_idx': idx, - } - - remain_percent = snapshot.remain_percent or 0 - filament_data[unique_key]['remain_data'][idx] = remain_percent - - for idx, metric in enumerate(metrics): - external = metric.external_spool or {} - if external.get('type'): - fil_type = external.get('type', 'Unknown') - fil_color = external.get('color', '161616FF') - unique_key = f"External_{fil_type}_{fil_color}" - - if unique_key not in filament_data: - filament_data[unique_key] = { - 'tray_id': 'External', - 'type': fil_type, - 'brand': 'External', - 'color': fil_color, - 'remain_data': [None] * total_points, - 'start_idx': idx, - } - - remain_percent = external.get('remain', 0) - filament_data[unique_key]['remain_data'][idx] = remain_percent - - return filament_data - class FilamentUsageDataAPIView(LoginRequiredMixin, View): """API endpoint for filament usage history with date/time filtering""" diff --git a/docs/Bambu_Color_Catalog/ABS.txt b/docs/Bambu_Color_Catalog/ABS.txt index 767030c..cfaa36a 100644 --- a/docs/Bambu_Color_Catalog/ABS.txt +++ b/docs/Bambu_Color_Catalog/ABS.txt @@ -21,4 +21,8 @@ Hex:#AF1685 Silver Hex:#87909A Black -Hex:#000000 \ No newline at end of file +Hex:#000000 +Mint +Hex:#7AE1BF +Lavender +Hex:#7248BD diff --git a/native/bambu-run-collector.service b/native/bambu-run-collector.service new file mode 100644 index 0000000..2049b8d --- /dev/null +++ b/native/bambu-run-collector.service @@ -0,0 +1,15 @@ +[Unit] +Description=Bambu-Run MQTT Collector +After=network.target + +[Service] +Type=exec +WorkingDirectory={{REPO_DIR}} +EnvironmentFile={{REPO_DIR}}/.env +Environment=DJANGO_SETTINGS_MODULE=standalone.settings +ExecStart={{VENV_DIR}}/bin/python standalone/manage.py bambu_collector +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=default.target diff --git a/native/bambu-run-web.service b/native/bambu-run-web.service new file mode 100644 index 0000000..ef46637 --- /dev/null +++ b/native/bambu-run-web.service @@ -0,0 +1,15 @@ +[Unit] +Description=Bambu-Run Web Dashboard +After=network.target + +[Service] +Type=exec +WorkingDirectory={{REPO_DIR}} +EnvironmentFile={{REPO_DIR}}/.env +Environment=DJANGO_SETTINGS_MODULE=standalone.settings +ExecStart={{VENV_DIR}}/bin/gunicorn standalone.wsgi:application --bind 0.0.0.0:8000 --workers {{WORKERS}} --timeout 120 +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=default.target diff --git a/native/bambu-run.sh b/native/bambu-run.sh new file mode 100755 index 0000000..9541c00 --- /dev/null +++ b/native/bambu-run.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Bambu-Run convenience wrapper +# Usage: ./native/bambu-run.sh {start|stop|restart|status|logs|update} +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" +VENV_DIR="$REPO_DIR/.venv" +MANAGE="$VENV_DIR/bin/python $REPO_DIR/standalone/manage.py" +SERVICES="bambu-run-web.service bambu-run-collector.service" + +case "${1:-help}" in + start) + systemctl --user start $SERVICES + echo "Bambu-Run started." + ;; + stop) + systemctl --user stop $SERVICES + echo "Bambu-Run stopped." + ;; + restart) + systemctl --user restart $SERVICES + echo "Bambu-Run restarted." + ;; + status) + systemctl --user status $SERVICES --no-pager + ;; + logs) + journalctl --user -u bambu-run-web -u bambu-run-collector -f --no-hostname + ;; + update) + echo "Pulling latest code..." + cd "$REPO_DIR" && git pull + + echo "Installing dependencies..." + "$VENV_DIR/bin/pip" install --quiet ".[standalone]" + + echo "Running migrations..." + $MANAGE migrate --noinput + + echo "Collecting static files..." + $MANAGE collectstatic --noinput --clear 2>/dev/null + + echo "Restarting services..." + systemctl --user restart $SERVICES + + echo "Update complete." + ;; + help|*) + echo "Usage: $0 {start|stop|restart|status|logs|update}" + echo + echo " start Start web + collector services" + echo " stop Stop web + collector services" + echo " restart Restart web + collector services" + echo " status Show service status" + echo " logs Tail live logs (Ctrl+C to stop)" + echo " update Pull latest code, install deps, migrate, restart" + ;; +esac diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..e6a1563 --- /dev/null +++ b/setup.sh @@ -0,0 +1,263 @@ +#!/usr/bin/env bash +# Bambu-Run Native Setup — single entry point for Raspberry Pi (or any Linux) +# Usage: git clone ... && cd Bambu-Run && bash setup.sh +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "$0")" && pwd)" +VENV_DIR="$REPO_DIR/.venv" +ENV_FILE="$REPO_DIR/.env" +MANAGE="$VENV_DIR/bin/python $REPO_DIR/standalone/manage.py" +SERVICE_DIR="$HOME/.config/systemd/user" + +green() { printf '\033[1;32m%s\033[0m\n' "$*"; } +yellow() { printf '\033[1;33m%s\033[0m\n' "$*"; } +red() { printf '\033[1;31m%s\033[0m\n' "$*"; } + +# ── 1. Pre-flight checks ───────────────────────────────────────────────────── + +green "=== Bambu-Run Native Setup ===" +echo + +# Acquire sudo upfront and keep it alive for the duration of the script +echo "This script needs sudo for iptables (port redirect) and apt (dependencies)." +sudo -v +while true; do sudo -n true; sleep 50; kill -0 "$$" || exit; done 2>/dev/null & +SUDO_KEEPALIVE_PID=$! +trap 'kill "$SUDO_KEEPALIVE_PID" 2>/dev/null' EXIT +echo + +# Python >= 3.10 +PYTHON="" +for cmd in python3.12 python3.11 python3.10 python3; do + if command -v "$cmd" &>/dev/null; then + ver=$("$cmd" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")') + major=${ver%%.*} + minor=${ver##*.} + if [ "$major" -ge 3 ] && [ "$minor" -ge 10 ]; then + PYTHON="$cmd" + break + fi + fi +done + +if [ -z "$PYTHON" ]; then + red "Error: Python >= 3.10 is required." + echo "Install it with: sudo apt install python3" + exit 1 +fi +green "Found $PYTHON ($ver)" + +# Ensure python3-venv is available +if ! "$PYTHON" -m venv --help &>/dev/null; then + yellow "Installing python3-venv..." + sudo apt-get update -qq && sudo apt-get install -y -qq python3-venv +fi + +# Detect RAM for gunicorn worker count +TOTAL_RAM_KB=$(grep MemTotal /proc/meminfo 2>/dev/null | awk '{print $2}' || echo 0) +if [ "$TOTAL_RAM_KB" -lt 1048576 ]; then + WORKERS=1 +else + WORKERS=2 +fi + +# Prompt for access port +while true; do + read -rp "Choose Bambu-Run Dashboard access port (Default: 80): " ACCESS_PORT + ACCESS_PORT="${ACCESS_PORT:-80}" + if [[ "$ACCESS_PORT" =~ ^[0-9]+$ ]] && [ "$ACCESS_PORT" -ge 1 ] && [ "$ACCESS_PORT" -le 65535 ]; then + break + else + red "Invalid port '$ACCESS_PORT'. Please enter a number between 1 and 65535." + fi +done +green "Dashboard will be accessible on port $ACCESS_PORT." + +# ── 2. Venv + install ──────────────────────────────────────────────────────── + +if [ ! -d "$VENV_DIR" ]; then + green "Creating virtual environment..." + "$PYTHON" -m venv "$VENV_DIR" +else + yellow "Virtual environment already exists, reusing." +fi + +green "Installing dependencies..." + +# Stub opencv-python (same trick as Dockerfile — avoids hour-long ARM build) +"$VENV_DIR/bin/python" -c " +import site, pathlib +d = pathlib.Path(site.getsitepackages()[0]) / 'opencv_python-4.99.0.dist-info' +if not d.exists(): + d.mkdir() + (d / 'METADATA').write_text('Metadata-Version: 2.1\nName: opencv-python\nVersion: 4.99.0\n') + (d / 'INSTALLER').write_text('pip\n') + (d / 'RECORD').write_text('') + print(' opencv stub created') +else: + print(' opencv stub already exists') +" + +"$VENV_DIR/bin/pip" install --quiet --upgrade pip +"$VENV_DIR/bin/pip" install --quiet ".[standalone]" + +# ── 3. Interactive .env ─────────────────────────────────────────────────────── + +if [ ! -f "$ENV_FILE" ]; then + green "Setting up .env configuration..." + echo + + read -rp "Bambu Lab email: " BAMBU_USERNAME + read -rsp "Bambu Lab password: " BAMBU_PASSWORD + echo + while true; do + read -rp "Timezone [UTC] (e.g. America/Sydney): " TIMEZONE + TIMEZONE="${TIMEZONE:-UTC}" + if "$VENV_DIR/bin/python" -c "import zoneinfo; zoneinfo.ZoneInfo('$TIMEZONE')" 2>/dev/null; then + break + else + red "Unknown timezone '$TIMEZONE'. Find yours at: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones" + fi + done + + # Generate a random Django secret key + DJANGO_SECRET_KEY=$("$VENV_DIR/bin/python" -c "import secrets; print(secrets.token_urlsafe(50))") + + cat > "$ENV_FILE" < /etc/iptables/rules.v4' + else + yellow "Warning: Could not set port $ACCESS_PORT redirect (sudo required). Access via http://:8000" + fi + fi +fi + +# ── 11. Summary ─────────────────────────────────────────────────────────────── + +PI_IP=$(hostname -I 2>/dev/null | awk '{print $1}') +if [ "$PORT_OK" = true ] && [ "$ACCESS_PORT" -ne 8000 ]; then + DASHBOARD_URL="http://${PI_IP:-localhost}$([ "$ACCESS_PORT" -eq 80 ] && echo '' || echo ":$ACCESS_PORT")" +else + DASHBOARD_URL="http://${PI_IP:-localhost}:8000" +fi +echo +green "============================================" +green " Bambu-Run is running!" +green "============================================" +echo +echo " Dashboard: $DASHBOARD_URL" +echo " Status: systemctl --user status bambu-run-web bambu-run-collector" +echo " Logs: journalctl --user -u bambu-run-web -u bambu-run-collector -f" +echo " Helper: ./native/bambu-run.sh {start|stop|restart|status|logs|update}" +echo +echo " Services auto-start on boot. Safe to close SSH." +echo diff --git a/standalone/manage.py b/standalone/manage.py index 468c371..19c8237 100644 --- a/standalone/manage.py +++ b/standalone/manage.py @@ -5,7 +5,15 @@ import sys # Ensure the project root (/app) is on sys.path so that both 'standalone' # and 'bambu_run' are importable regardless of where this script is invoked from. -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, PROJECT_ROOT) + +# Load .env so manage.py commands pick up env vars outside of systemd/Docker +try: + from dotenv import load_dotenv + load_dotenv(os.path.join(PROJECT_ROOT, ".env")) +except ImportError: + pass def main():