6 Commits

Author SHA1 Message Date
RNL
6d284ae79c fixed line chart noise x axis and add more date marker to split them up 2026-02-25 23:05:24 +11:00
RNL
7ca4cd57b5 touch up readme 2026-02-24 23:44:42 +11:00
RNL
a06842be3e updated instructions 2026-02-24 23:39:51 +11:00
RNL
d513f951dd added AMS hex color trimming 2026-02-24 21:33:43 +11:00
RNL
b1858a9129 bambu color import manage tool added 2026-02-23 23:16:56 +11:00
RNL
11cc0e0817 added cover image 2026-02-22 22:59:26 +11:00
8 changed files with 222 additions and 627 deletions

185
README.md
View File

@@ -22,144 +22,151 @@ It runs quietly in the background 24/7, capturing every print, filament change,
## Table of Contents ## Table of Contents
- [Native Setup (Recommended for Raspberry Pi)](#native-setup-recommended-for-raspberry-pi) - [Quick Start: One-Click Docker Setup — Beginner Friendly](#quick-start-one-click-docker-setup--beginner-friendly)
- [What You'll Need](#what-youll-need) - [What You'll Need](#what-youll-need)
- [Clone and run setup.sh](#clone-and-run-setupsh) - [Step 1: Connect to Your Raspberry Pi](#step-1-connect-to-your-raspberry-pi)
- [Managing Bambu-Run](#managing-bambu-run) - [Step 2: Install Docker](#step-2-install-docker)
- [Troubleshooting (Native)](#troubleshooting-native) - [Step 3: Download and Configure](#step-3-download-and-configure)
- [Docker Setup](#docker-setup) - [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)
- [Batch Importing Filament Colors and Filament Types](#batch-importing-filament-colors-and-filament-types) - [Batch Importing Filament Colors and Filament Types](#batch-importing-filament-colors-and-filament-types)
--- ---
## Native Setup (Recommended for Raspberry Pi) ## Quick Start: One-Click Docker Setup — Beginner Friendly
No Docker required. Works on any Raspberry Pi (including 32-bit Pi Model B) running Raspberry Pi OS with Python 3.10+. Get Bambu-Run running on a **Raspberry Pi** in minutes. No prior server experience needed.
### What You'll Need ### What You'll Need
- Raspberry Pi on your local network (Python 3.10+) - A Raspberry Pi (3B+, 4, or 5) running Raspberry Pi OS 64-bit, with a 32 GB+ MicroSD card, connected to your network
- Bambu Lab printer - Your Bambu Lab printer on the **same local network**
- Bambu Lab account **email and password** - Your Bambu Lab account **email and password**
- A computer to SSH into the Pi
### Clone and run setup.sh ### Step 1: Connect to Your Raspberry Pi
From your computer, open a terminal (Mac/Linux) or PowerShell (Windows):
```bash ```bash
git clone https://github.com/RunLit/Bambu-Run.git ssh pi@raspberrypi.local
cd Bambu-Run
bash setup.sh
``` ```
That's it! The script handles everything interactively, just answer the prompts. When it finishes, open `http://<ip>` from any device on same network. > Can't connect? Use your Pi's IP address (find it in your router's admin page). Default password: `raspberry`
The script is safe to re-run at any time. ### Step 2: Install Docker
---
**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://<pi-ip>` 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 ```bash
./native/bambu-run.sh status # service status curl -fsSL https://get.docker.com | sudo sh
./native/bambu-run.sh logs # tail live logs (Ctrl+C to stop) sudo usermod -aG docker $USER
./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
``` ```
### Troubleshooting (Native) Log out and back in for the change to take effect, then verify:
**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 ```bash
systemctl --user disable --now bambu-run-web bambu-run-collector exit
rm ~/.config/systemd/user/bambu-run-{web,collector}.service
systemctl --user daemon-reload
``` ```
**Wipe everything and start over:**
```bash ```bash
# Stop and remove services ssh pi@raspberrypi.local
systemctl --user stop bambu-run-web bambu-run-collector docker --version # should show Docker version 27.x.x
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/
## Docker Setup ### Step 3: Download and Configure
Requires Docker and Docker Compose installed. Assumes you already know how to get there.
**Clone and configure:**
```bash ```bash
git clone https://github.com/RunLit/Bambu-Run.git git clone https://github.com/RunLit/Bambu-Run.git
cd Bambu-Run cd Bambu-Run
cp .env.example .env cp .env.example .env
# Edit .env: set BAMBU_USERNAME, BAMBU_PASSWORD, TIMEZONE nano .env
``` ```
**First-time auth** (Bambu Lab sends a 6-digit verification code to your email): 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
```bash ```bash
docker compose build docker compose build
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=...
``` ```
**Start and create your dashboard login:** 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
```
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
```bash ```bash
docker compose up -d docker compose up -d
docker compose exec bambu-run python standalone/manage.py createsuperuser docker compose exec bambu-run python standalone/manage.py createsuperuser
``` ```
Dashboard is at `http://<host-ip>:8000`. Choose a username and password — this is your dashboard login.
**Common operations:** ### Step 7: Open the Dashboard
```bash On any device on your network, open a browser and go to:
docker compose logs -f # live logs
docker compose down # stop (data preserved in volume) ```
git pull && docker compose up -d --build # update http://raspberrypi.local:8000
``` ```
**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. > If that doesn't work, use your Pi's IP: `http://<pi-ip-address>: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
```
**Stop Bambu-Run:**
```bash
docker compose down
```
Your data is preserved in a Docker volume and will be there when you start it again.
--- ---

View File

@@ -1,4 +1,4 @@
from datetime import timedelta, datetime from datetime import timedelta
from django.views.generic import TemplateView, View, ListView, CreateView, UpdateView, DetailView, DeleteView from django.views.generic import TemplateView, View, ListView, CreateView, UpdateView, DetailView, DeleteView
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.utils import timezone from django.utils import timezone
@@ -13,18 +13,6 @@ from .conf import app_settings
from .models import Printer, PrinterMetrics, Filament, FilamentColor, FilamentType, FilamentSnapshot, PrintJob, FilamentUsage from .models import Printer, PrinterMetrics, Filament, FilamentColor, FilamentType, FilamentSnapshot, PrintJob, FilamentUsage
from .forms import FilamentForm, FilamentColorForm, FilamentTypeForm 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): class PrinterDashboardView(LoginRequiredMixin, TemplateView):
template_name = "bambu_run/printer_dashboard.html" template_name = "bambu_run/printer_dashboard.html"
@@ -260,175 +248,51 @@ class PrinterDataAPIView(LoginRequiredMixin, View):
if not printer_device: if not printer_device:
return JsonResponse({"error": "No printer device found"}, status=404) 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) tz = zoneinfo.ZoneInfo(app_settings.TIMEZONE)
# Stage A: only() + step calculation if start_date and start_time:
query = ( from datetime import datetime
PrinterMetrics.objects start_dt_naive = datetime.strptime(f"{start_date} {start_time}", "%Y-%m-%d %H:%M")
.filter(device=printer_device) start_dt = start_dt_naive.replace(tzinfo=tz)
.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) query = query.filter(timestamp__gte=start_dt)
expected_count = _MAX_CHART_POINTS
elif end_date and end_time: if end_date and end_time:
end_dt = datetime.strptime(f"{end_date} {end_time}", "%Y-%m-%d %H:%M").replace(tzinfo=tz) 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)
query = query.filter(timestamp__lte=end_dt) query = query.filter(timestamp__lte=end_dt)
expected_count = _MAX_CHART_POINTS
else:
expected_count = _MAX_CHART_POINTS
step = max(1, expected_count // _MAX_CHART_POINTS) metrics = query.order_by("timestamp")
# 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 = { data = {
"timestamps": timestamps, "timestamps": [m.timestamp.astimezone(tz).strftime('%H:%M') for m in metrics],
"timestamps_iso": timestamps_iso, "timestamps_iso": [m.timestamp.astimezone(tz).isoformat() for m in metrics],
"dates": dates, "dates": [m.timestamp.astimezone(tz).strftime('%Y-%m-%d') for m in metrics],
"nozzle_temp": nozzle_temp, "nozzle_temp": [float(m.nozzle_temp) if m.nozzle_temp else None for m in metrics],
"nozzle_target_temp": nozzle_target_temp, "nozzle_target_temp": [float(m.nozzle_target_temp) if m.nozzle_target_temp else None for m in metrics],
"bed_temp": bed_temp, "bed_temp": [float(m.bed_temp) if m.bed_temp else None for m in metrics],
"bed_target_temp": bed_target_temp, "bed_target_temp": [float(m.bed_target_temp) if m.bed_target_temp else None for m in metrics],
"print_percent": print_percent, "print_percent": [m.print_percent if m.print_percent else 0 for m in metrics],
"cooling_fan_speed": cooling_fan_speed, "cooling_fan_speed": [m.cooling_fan_speed if m.cooling_fan_speed else 0 for m in metrics],
"heatbreak_fan_speed": heatbreak_fan_speed, "heatbreak_fan_speed": [m.heatbreak_fan_speed if m.heatbreak_fan_speed else 0 for m in metrics],
"wifi_signal_dbm": wifi_signal_dbm, "wifi_signal_dbm": [m.wifi_signal_dbm if m.wifi_signal_dbm else None for m in metrics],
"ams_humidity_raw": ams_humidity_raw, "ams_humidity_raw": [m.ams_humidity_raw if m.ams_humidity_raw else None for m in metrics],
"ams_temp": ams_temp, "ams_temp": [float(m.ams_temp) if m.ams_temp else None for m in metrics],
"layer_num": layer_num, "layer_num": [m.layer_num if m.layer_num else 0 for m in metrics],
"total_layer_num": total_layer_num, "total_layer_num": [m.total_layer_num if m.total_layer_num else 0 for m in metrics],
"gcode_state": gcode_state, "gcode_state": [m.gcode_state for m in metrics],
"print_type": print_type, "print_type": [m.print_type for m in metrics],
"subtask_name": subtask_name, "subtask_name": [m.subtask_name for m in metrics],
"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) return JsonResponse(data)
except Exception as e: except Exception as e:
@@ -436,6 +300,93 @@ class PrinterDataAPIView(LoginRequiredMixin, View):
traceback.print_exc() traceback.print_exc()
return JsonResponse({"error": str(e)}, status=500) 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): class FilamentUsageDataAPIView(LoginRequiredMixin, View):
"""API endpoint for filament usage history with date/time filtering""" """API endpoint for filament usage history with date/time filtering"""

View File

@@ -22,7 +22,3 @@ Silver
Hex:#87909A Hex:#87909A
Black Black
Hex:#000000 Hex:#000000
Mint
Hex:#7AE1BF
Lavender
Hex:#7248BD

View File

@@ -1,15 +0,0 @@
[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

View File

@@ -1,15 +0,0 @@
[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

View File

@@ -1,58 +0,0 @@
#!/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

263
setup.sh
View File

@@ -1,263 +0,0 @@
#!/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" <<EOF
BAMBU_USERNAME=$BAMBU_USERNAME
BAMBU_PASSWORD=$BAMBU_PASSWORD
TIMEZONE=$TIMEZONE
DJANGO_SECRET_KEY=$DJANGO_SECRET_KEY
DEBUG=False
EOF
green ".env created."
else
yellow ".env already exists, skipping."
fi
# ── 4. Migrate ────────────────────────────────────────────────────────────────
green "Running database migrations..."
$MANAGE migrate --noinput
# ── 5. Bambu authentication ──────────────────────────────────────────────────
if ! grep -q '^BAMBU_TOKEN=' "$ENV_FILE" 2>/dev/null; then
green "Authenticating with Bambu Lab (email verification required)..."
echo "A verification code will be sent to your email."
echo
# Run collector in --once mode for interactive auth
$MANAGE bambu_collector --once || true
echo
read -rp "Paste your BAMBU_TOKEN from above (or press Enter to skip): " TOKEN
if [ -n "$TOKEN" ]; then
echo "BAMBU_TOKEN=$TOKEN" >> "$ENV_FILE"
green "Token saved to .env."
else
yellow "Skipped — you can add BAMBU_TOKEN to .env later."
fi
else
yellow "BAMBU_TOKEN already in .env, skipping auth."
fi
# ── 6. Superuser ─────────────────────────────────────────────────────────────
echo
if $MANAGE shell -c "from django.contrib.auth import get_user_model; exit(0 if get_user_model().objects.filter(is_superuser=True).exists() else 1)" 2>/dev/null; then
yellow "Superuser already exists, skipping. (To add another, run: python standalone/manage.py createsuperuser)"
else
green "Create your dashboard login (Django superuser):"
$MANAGE createsuperuser || yellow "Superuser creation skipped."
fi
# ── 7. Collect static files ──────────────────────────────────────────────────
green "Collecting static files..."
$MANAGE collectstatic --noinput --clear 2>/dev/null
# ── 8. Seed filament colors ──────────────────────────────────────────────────
echo
read -rp "Import Bambu Lab filament color catalog? [Y/n] " SEED_COLORS
SEED_COLORS="${SEED_COLORS:-Y}"
if [[ "$SEED_COLORS" =~ ^[Yy] ]]; then
$MANAGE bambu_import_colors "$REPO_DIR/docs/Bambu_Color_Catalog/"
fi
# ── 9. Install systemd services ──────────────────────────────────────────────
green "Installing systemd user services..."
mkdir -p "$SERVICE_DIR"
# Generate unit files with actual paths substituted
sed "s|{{REPO_DIR}}|$REPO_DIR|g; s|{{VENV_DIR}}|$VENV_DIR|g; s|{{WORKERS}}|$WORKERS|g" \
"$REPO_DIR/native/bambu-run-web.service" > "$SERVICE_DIR/bambu-run-web.service"
sed "s|{{REPO_DIR}}|$REPO_DIR|g; s|{{VENV_DIR}}|$VENV_DIR|g" \
"$REPO_DIR/native/bambu-run-collector.service" > "$SERVICE_DIR/bambu-run-collector.service"
systemctl --user daemon-reload
systemctl --user enable bambu-run-web.service bambu-run-collector.service
# Enable linger so services survive SSH logout
loginctl enable-linger "$USER" 2>/dev/null || \
sudo loginctl enable-linger "$USER" 2>/dev/null || \
yellow "Warning: Could not enable linger. Services may stop when you disconnect SSH."
systemctl --user start bambu-run-web.service bambu-run-collector.service
# ── 10. Port redirect (ACCESS_PORT → 8000 via iptables if needed) ────────────
PORT_OK=false
if [ "$ACCESS_PORT" -eq 8000 ]; then
# Gunicorn already on 8000 — no redirect needed
green "Using port 8000 directly (no redirect needed)."
PORT_OK=true
else
if sudo iptables -t nat -C PREROUTING -p tcp --dport "$ACCESS_PORT" -j REDIRECT --to-port 8000 2>/dev/null; then
yellow "Port $ACCESS_PORT → 8000 redirect already set."
PORT_OK=true
else
# Ensure iptables is available
if ! command -v iptables &>/dev/null; then
yellow "Installing iptables..."
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y -qq iptables
fi
if sudo iptables -t nat -A PREROUTING -p tcp --dport "$ACCESS_PORT" -j REDIRECT --to-port 8000 && \
sudo iptables -t nat -A OUTPUT -o lo -p tcp --dport "$ACCESS_PORT" -j REDIRECT --to-port 8000; then
green "Port $ACCESS_PORT → 8000 redirect configured."
PORT_OK=true
# Persist so it survives reboot
if ! command -v netfilter-persistent &>/dev/null; then
yellow "Installing iptables-persistent to survive reboots..."
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y -qq iptables-persistent
fi
sudo netfilter-persistent save 2>/dev/null || sudo sh -c 'iptables-save > /etc/iptables/rules.v4'
else
yellow "Warning: Could not set port $ACCESS_PORT redirect (sudo required). Access via http://<ip>: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

View File

@@ -5,15 +5,7 @@ import sys
# Ensure the project root (/app) is on sys.path so that both 'standalone' # 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. # and 'bambu_run' are importable regardless of where this script is invoked from.
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, 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(): def main():