6 Commits

Author SHA1 Message Date
RunLit
5c56711c57 Color base add support for transparent color (#5)
* added db model is transparent and fixed PETG translucent showing black

* js and filament form for transparent color

* bumped version to v0.1.2
2026-03-27 23:30:27 +11:00
RunLit
7e39d3e38d 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
2026-03-07 16:53:33 +11:00
RunLit
217679421f version 0.1.1 2026-03-03 23:22:25 +11:00
RunLit
5984bd6fa0 Filament tools that help upload bambu colors and filament types easily (#3)
* added cover image

* bambu color import manage tool added

* added AMS hex color trimming

* updated instructions

* touch up readme

* fixed line chart noise x axis and add more date marker to split them up
2026-02-25 23:07:24 +11:00
RunLit
ab6a7c0bcc support bammbu run as external django app (#2) 2026-02-22 21:32:58 +11:00
RunLit
6376b4cc94 docker deployment patch with verification and broken UI fixes (#1)
* bypass bambu cloud api opencb requirement

* project root add to managepy

* update instruction to do migration; mqtt login more verbose

* migrations up to date model

* use migrations from django migrate

* print full token to copy paste

* allow local network hosts

* added side bar toggle

* removed standalone css from dashboard css

* added icon and fixed text trunction issue

* fixed chart missing whitenoise and not rendering

* aded favicon and fixed ui issues
2026-02-21 15:03:16 +11:00
49 changed files with 5021 additions and 723 deletions

View File

@@ -10,11 +10,19 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
supervisor \ supervisor \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install bambu-lab-cloud-api without deps (opencv-python is declared but unused at runtime) # Install bambu-lab-cloud-api without deps (opencv-python is declared but unused at runtime).
# Then stub out opencv-python so pip's resolver considers it satisfied and won't try to
# build it from source (no C compiler, no armv7l wheel available).
RUN pip install --no-cache-dir bambu-lab-cloud-api --no-deps && \ RUN pip install --no-cache-dir bambu-lab-cloud-api --no-deps && \
pip install --no-cache-dir paho-mqtt requests flask flask-cors flask-limiter pip install --no-cache-dir paho-mqtt requests flask flask-cors flask-limiter && \
python3 -c "import site, pathlib; \
d = pathlib.Path(site.getsitepackages()[0]) / 'opencv_python-4.99.0.dist-info'; \
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('')"
# Install project and remaining dependencies # Install project and remaining dependencies (pip sees opencv-python already satisfied)
COPY pyproject.toml . COPY pyproject.toml .
RUN pip install --no-cache-dir ".[standalone]" RUN pip install --no-cache-dir ".[standalone]"

407
README.md
View File

@@ -1,231 +1,264 @@
# Bambu-Run # Bambu-Run
Unlock richer data access and powerful customization capabilities for your Bambu Lab 3D printer. <p align="center">
<img src="docs/BambuRun.png" alt="Bambu-Run Logo" width="300"/>
</p>
Bambu-Run is a self-hosted web dashboard that tracks data of your Bambu Lab printer. It gives you: Richer data, powerful customization for your Bambu Lab 3D printer.
- Real-time monitoring and logging (temperatures, fan speeds, print progress etc)
- Automatic filament inventory tracking and usage monitoring system (AMS required)
all running on hardware you own.
### Hardware Requirement Bambu-Run is a self-hosted web dashboard that gives you:
- Real-time monitoring and logging (temperatures, fan speeds, print progress, and more)
- Automatic filament inventory tracking and usage monitoring (AMS required)
Recommend a raspberry pi, installed with Raspberry Pi OS (low cost running at the background) or an old PC/Laptop you probably never going to use again (install Linux). All running on hardware you own.
## Getting Started (Beginner Friendly)
This guide walks you through setting up Bambu-Run on a **Raspberry Pi** from scratch. No prior server experience needed.
### What You'll Need ### What You'll Need
- A Raspberry Pi (3B+, 4, or 5) with Raspberry Pi OS installed and connected to your network Any always-on device works — a **Raspberry Pi** (3B+, 4, or 5) is ideal: beginner-friendly, runs Raspberry Pi OS out of the box, and quiet enough to tuck behind a desk. An old PC or laptop with Linux works too.
- Your Bambu Lab printer on the **same local network** as the Pi
- Your printer's **IP address**, **access token**, and **serial number** (we'll show you how to find these below)
- A computer on the same network to SSH into the Pi
### Step 1: Find Your Bambu Lab Account Credentials It runs quietly in the background 24/7, capturing every print, filament change, and AMS update the moment it happens. And the power bill? A Raspberry Pi 4 under light load draws about **5W**. That's roughly **43.8 kWh per year**, or the cost of **three cups of coffee**. ☕☕☕ Tuck it out of sight and forget it's there.
Bambu-Run connects to your printer through the **Bambu Lab Cloud** using your account login — the same email and password you use for Bambu Handy or Bambu Studio. ---
You'll need: ## Table of Contents
- **BAMBU_USERNAME** — Your Bambu Lab account email
- **BAMBU_PASSWORD** — Your Bambu Lab account password
> **First-time login requires email verification.** Bambu Lab will send a 6-digit code to your email. You'll enter this code during Step 5a below. After that, you'll receive a token that skips verification on future startups. - [Native Setup (Recommended for Raspberry Pi)](#native-setup-recommended-for-raspberry-pi)
- [What You'll Need](#what-youll-need)
- [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)
### Step 2: Connect to Your Raspberry Pi ---
From your computer, open a terminal (Mac/Linux) or PowerShell (Windows) and SSH into the Pi: ## Native Setup (Recommended for Raspberry Pi)
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
- Raspberry Pi on your local network (Python 3.10+)
- Bambu Lab printer
- Bambu Lab account **email and password**
### Clone and run setup.sh
```bash ```bash
ssh pi@raspberrypi.local
```
> If `raspberrypi.local` doesn't work, use your Pi's IP address instead (check your router's admin page to find it).
The default password is `raspberry` (you should change it after first login with `passwd`).
### Step 3: Install Docker
Docker lets you run Bambu-Run in a container — no need to install Python, databases, or anything else manually.
Run these commands one at a time:
```bash
# Download and run Docker's install script
curl -fsSL https://get.docker.com | sudo sh
# Let your user run Docker without sudo
sudo usermod -aG docker $USER
```
Installation issue? check installation methods for raspberry pi: https://docs.docker.com/engine/install/raspberry-pi-os/#installation-methods
**Important:** Log out and log back in for the group change to take effect:
```bash
exit
```
Then SSH back in:
```bash
ssh pi@raspberrypi.local
```
Verify Docker is working:
```bash
docker --version
```
You should see something like `Docker version 27.x.x` — the exact number doesn't matter.
### Step 4: Download and Configure Bambu-Run
```bash
# Clone the project
git clone https://github.com/RunLit/Bambu-Run.git git clone https://github.com/RunLit/Bambu-Run.git
cd Bambu-Run cd Bambu-Run
bash setup.sh
# Create your configuration file
cp .env.example .env
``` ```
Now edit the `.env` file with your printer details: That's it! The script handles everything interactively, just answer the prompts. When it finishes, open `http://<ip>` from any device on same network.
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://<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
./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
```
### 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
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
# 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
```
---
## Docker Setup
Requires Docker and Docker Compose installed. Assumes you already know how to get there.
**Clone and configure:**
```bash ```bash
nano .env git clone https://github.com/RunLit/Bambu-Run.git
cd Bambu-Run
cp .env.example .env
# Edit .env: set BAMBU_USERNAME, BAMBU_PASSWORD, TIMEZONE
``` ```
Fill in your Bambu Lab account credentials from Step 1: **First-time auth** (Bambu Lab sends a 6-digit verification code to your email):
```
BAMBU_USERNAME=your_email@example.com
BAMBU_PASSWORD=your_password
```
Optionally set your timezone (defaults to UTC):
```
TIMEZONE=Australia/Melbourne
```
> You can find your timezone name at https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
To save and exit nano: press `Ctrl + X`, then `Y`, then `Enter`.
### Step 5: Build and Start Bambu-Run
First, build the container:
```bash ```bash
docker compose build docker compose build
``` docker compose run --rm bambu-run python standalone/manage.py migrate --noinput
This downloads all required software (takes a few minutes the first time).
### Step 5a: First-Time Authentication
The first time you connect, Bambu Lab requires email verification. You need to run the collector **interactively** (not in the background) so you can enter the 6-digit code:
```bash
docker compose run --rm bambu-run python standalone/manage.py bambu_collector --once docker compose run --rm bambu-run python standalone/manage.py bambu_collector --once
# Paste the printed token into .env as BAMBU_TOKEN=...
``` ```
You'll see output like: **Start and create your dashboard login:**
```
BambuLab Authentication
Authenticating as: your_email@example.com
...
EMAIL VERIFICATION REQUIRED
A verification code has been sent to your email.
Enter verification code:
```
1. Check your email for the 6-digit code from Bambu Lab
2. Type the code and press Enter
3. On success, you'll see a token printed:
```
Authentication successful!
Token: eyJhbGciOiJIUzI1N...
TIP: Save this token to BAMBU_TOKEN env var to skip login next time
```
4. **Copy the full token** and paste it into your `.env` file:
```bash
nano .env
```
Add/uncomment the `BAMBU_TOKEN` line:
```
BAMBU_TOKEN=eyJhbGciOiJIUzI1N...paste_full_token_here
```
> **Why save the token?** With the token saved, future container restarts authenticate instantly without needing email verification again. Without it, you'd need to repeat this step every time the container restarts.
### Step 5b: Start Bambu-Run
Now start everything in the background:
```bash ```bash
docker compose up -d docker compose up -d
```
Check that it's running:
```bash
docker compose ps
```
You should see the `bambu-run` service with status `Up`.
### Step 6: Create Your Login Account
```bash
docker compose exec bambu-run python standalone/manage.py createsuperuser docker compose exec bambu-run python standalone/manage.py createsuperuser
``` ```
You'll be prompted to choose a username, email (optional), and password. This is your login for the dashboard. Dashboard is at `http://<host-ip>:8000`.
### Step 7: Open the Dashboard **Common operations:**
On any device connected to your network (phone, tablet, computer), open a browser and go to:
```
http://raspberrypi.local:8000
```
> If that doesn't work, use your Pi's IP address: `http://<pi-ip-address>:8000`
Log in with the account you just created. You should see your printer dashboard with live data flowing in.
### Troubleshooting
**"Cannot connect to printer" or no data showing:**
- Make sure your printer is turned on and connected to the network
- Check the logs: `docker compose logs -f`
- If you see authentication errors, your token may have expired — re-run Step 5a to get a fresh token
**"Verification code" or "401 Unauthorized" errors:**
- Your `BAMBU_TOKEN` may have expired. Remove it from `.env` and re-run Step 5a
- Make sure `BAMBU_USERNAME` and `BAMBU_PASSWORD` are correct in your `.env` file
**"Cannot connect to Docker daemon":**
- Did you log out and back in after Step 3? Docker group changes require a new session
**Dashboard not loading in browser:**
- Verify the container is running: `docker compose ps`
- Try using the Pi's IP address instead of `raspberrypi.local`
**Updating to a newer version:**
```bash ```bash
cd ~/Bambu-Run docker compose logs -f # live logs
git pull docker compose down # stop (data preserved in volume)
docker compose up -d --build git pull && docker compose up -d --build # update
``` ```
**Stopping Bambu-Run:** **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.
---
## Batch Importing Filament Colors and Filament Types
Bambu-Run ships with a full Bambu Lab color catalog under `docs/Bambu_Color_Catalog/` (one `.txt` file per filament sub-type, e.g. `PLA Basic.txt`, `PETG HF.txt`). Importing these populates the **Filament Colors** database so the dashboard shows proper color names instead of raw hex codes.
### Adding your own colors
Need a filament type that isn't in the bundled catalog? Create your own `.txt` file and point the importer at it.
**File naming** — the filename determines the filament type and sub-type:
```
PLA Basic.txt → type: PLA, sub-type: PLA Basic
PETG HF.txt → type: PETG, sub-type: PETG HF
ABS.txt → type: ABS, sub-type: ABS
```
**File format** — list each color on its own line, either as two rows (name then hex) or on the same line:
```
Jade White
Hex:#FFFFFF
Black Walnut #4F3F24
```
Bambu Lab's website filament pages and their downloadable PDF catalogs are a reliable source — both list color names alongside hex codes you can copy directly.
### When to run this
Run the import **once after first setup** to seed the full color catalog in one go, rather than adding colors one by one. Run it again any time you want to add colors for a new filament type. Re-running is always safe — duplicates are detected and skipped automatically.
### Import all colors (recommended)
If the container is already running (`docker compose up -d`):
```bash ```bash
docker compose down docker compose exec bambu-run python standalone/manage.py bambu_import_colors docs/Bambu_Color_Catalog/
``` ```
Your data is preserved in a Docker volume and will be there when you start it again. If the container is not running yet:
```bash
docker compose run --rm bambu-run python standalone/manage.py bambu_import_colors docs/Bambu_Color_Catalog/
```
### Import a file from your computer
If your `.txt` color file lives on your Mac, Pi, or any machine running Docker (i.e. not inside the repo), copy it into the container first, then run the importer:
```bash
# Step 1 — copy the file from your machine into the container
docker compose cp /path/to/your/PLA\ Basic.txt bambu-run:/tmp/
# Step 2 — run the importer against the copied path
docker compose exec bambu-run python standalone/manage.py bambu_import_colors /tmp/PLA\ Basic.txt
```
To import a whole folder of files at once:
```bash
# Step 1 — copy the folder
docker compose cp /path/to/your/color_catalog/ bambu-run:/tmp/color_catalog/
# Step 2 — import everything in it
docker compose exec bambu-run python standalone/manage.py bambu_import_colors /tmp/color_catalog/
```
> **macOS tip:** You can drag a file from Finder into the terminal to paste its full path.
### Import a single filament type
To import only one sub-type from the bundled catalog (e.g. just PLA Basic):
```bash
docker compose exec bambu-run python standalone/manage.py bambu_import_colors "docs/Bambu_Color_Catalog/PLA Basic.txt"
```
### Preview before importing (dry run)
Check what would be added without writing anything to the database:
```bash
docker compose exec bambu-run python standalone/manage.py bambu_import_colors docs/Bambu_Color_Catalog/ --dry-run
```
### What the output means
```
Processing: PLA Basic.txt → type='PLA' sub_type='PLA Basic'
Parsed 40 color(s).
+ 'Bambu Green' #009F87 (PLA / PLA Basic)
+ 'Jade White' #FFFFFF (PLA / PLA Basic)
...
──────────────────────────────────────────────────
Created: 40
Skipped (duplicate): 0
```
- **Created** — new color entries added to the database
- **Skipped (duplicate)** — already existed, not changed
- **Skipped (no type)** — only shown if `--no-auto-create-filament-type` is used and the filament type isn't in the database yet

View File

@@ -52,7 +52,7 @@ class FilamentForm(forms.ModelForm):
model = Filament model = Filament
fields = [ fields = [
'tray_uuid', 'tag_uid', 'tag_id', 'created_by', 'tray_uuid', 'tag_uid', 'tag_id', 'created_by',
'filament_type', 'type', 'sub_type', 'brand', 'color', 'color_hex', 'filament_type', 'type', 'sub_type', 'brand', 'color', 'color_hex', 'is_transparent',
'diameter', 'initial_weight_grams', 'diameter', 'initial_weight_grams',
'remaining_percent', 'remaining_weight_grams', 'remaining_percent', 'remaining_weight_grams',
'is_loaded_in_ams', 'current_tray_id', 'is_loaded_in_ams', 'current_tray_id',
@@ -71,10 +71,10 @@ class FilamentForm(forms.ModelForm):
}), }),
'tag_id': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Optional - User-defined ID'}), 'tag_id': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Optional - User-defined ID'}),
'created_by': forms.Select(attrs={'class': 'form-select'}), 'created_by': forms.Select(attrs={'class': 'form-select'}),
'filament_type': forms.Select(attrs={'class': 'form-select'}), 'filament_type': forms.Select(attrs={'class': 'form-select', 'id': 'id_filament_type'}),
'type': forms.HiddenInput(), 'type': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g., PLA, PETG, ABS'}),
'sub_type': forms.HiddenInput(), 'sub_type': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g., PLA Basic (optional)'}),
'brand': forms.HiddenInput(), 'brand': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g., Bambu Lab'}),
'color': forms.Select(attrs={'class': 'form-select', 'id': 'id_color'}), 'color': forms.Select(attrs={'class': 'form-select', 'id': 'id_color'}),
'color_hex': forms.TextInput(attrs={ 'color_hex': forms.TextInput(attrs={
'class': 'form-control', 'class': 'form-control',
@@ -85,6 +85,7 @@ class FilamentForm(forms.ModelForm):
'initial_weight_grams': forms.NumberInput(attrs={'class': 'form-control', 'placeholder': '1000'}), 'initial_weight_grams': forms.NumberInput(attrs={'class': 'form-control', 'placeholder': '1000'}),
'remaining_percent': forms.NumberInput(attrs={'class': 'form-control', 'min': '0', 'max': '100'}), 'remaining_percent': forms.NumberInput(attrs={'class': 'form-control', 'min': '0', 'max': '100'}),
'remaining_weight_grams': forms.NumberInput(attrs={'class': 'form-control', 'readonly': 'readonly'}), 'remaining_weight_grams': forms.NumberInput(attrs={'class': 'form-control', 'readonly': 'readonly'}),
'is_transparent': forms.CheckboxInput(attrs={'class': 'form-check-input', 'id': 'id_is_transparent'}),
'is_loaded_in_ams': forms.CheckboxInput(attrs={'class': 'form-check-input'}), 'is_loaded_in_ams': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'current_tray_id': forms.NumberInput(attrs={'class': 'form-control', 'min': '0', 'max': '3'}), 'current_tray_id': forms.NumberInput(attrs={'class': 'form-control', 'min': '0', 'max': '3'}),
'purchase_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}), 'purchase_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),

View File

@@ -316,7 +316,7 @@ class Command(BaseCommand):
def _auto_create_filament(self, tray_data): def _auto_create_filament(self, tray_data):
from bambu_run.models import Filament, FilamentType from bambu_run.models import Filament, FilamentType
from bambu_run.utils import strip_color_padding, match_filament_color from bambu_run.utils import strip_color_padding, match_filament_color, is_mqtt_color_transparent
tray_uuid = tray_data.get('tray_uuid') tray_uuid = tray_data.get('tray_uuid')
tag_uid = tray_data.get('tag_uid') tag_uid = tray_data.get('tag_uid')
@@ -329,10 +329,10 @@ class Command(BaseCommand):
default_brand = app_settings.AUTO_CREATE_BRAND default_brand = app_settings.AUTO_CREATE_BRAND
transparent = is_mqtt_color_transparent(mqtt_color)
color_code = strip_color_padding(mqtt_color) color_code = strip_color_padding(mqtt_color)
color_hex = f"#{color_code}" if color_code else None color_hex = f"#{color_code}" if color_code else None
color_name = mqtt_color
filament_color = match_filament_color( filament_color = match_filament_color(
filament_type=type_val, filament_type=type_val,
filament_sub_type=sub_type, filament_sub_type=sub_type,
@@ -342,10 +342,11 @@ class Command(BaseCommand):
if filament_color: if filament_color:
color_name = filament_color.color_name color_name = filament_color.color_name
transparent = transparent or filament_color.is_transparent
if self.verbose: if self.verbose:
logger.info(f"Matched color from database: {color_name} (#{color_code})") logger.info(f"Matched color from database: {color_name} (#{color_code})")
else: else:
color_name = mqtt_color color_name = color_hex or mqtt_color
if self.verbose: if self.verbose:
logger.warning( logger.warning(
f"No color match in database for {type_val} {sub_type} #{color_code}. " f"No color match in database for {type_val} {sub_type} #{color_code}. "
@@ -369,6 +370,7 @@ class Command(BaseCommand):
brand=default_brand, brand=default_brand,
color=color_name, color=color_name,
color_hex=color_hex, color_hex=color_hex,
is_transparent=transparent,
diameter=diameter, diameter=diameter,
initial_weight_grams=initial_weight, initial_weight_grams=initial_weight,
remaining_percent=remain_percent, remaining_percent=remain_percent,

View File

@@ -0,0 +1,425 @@
"""
Management command to import Bambu Lab filament color catalogs into the FilamentColor database.
Parses .txt color catalog files (one file per filament sub-type) and creates or skips
FilamentColor records. FilamentType records are auto-created as needed.
Usage:
# Import a single file
python manage.py bambu_import_colors docs/Bambu_Color_Catalog/PLA\ Basic.txt
# Import all .txt files in a directory
python manage.py bambu_import_colors docs/Bambu_Color_Catalog/
# Dry-run (preview without writing)
python manage.py bambu_import_colors docs/Bambu_Color_Catalog/ --dry-run
# Fail instead of auto-creating missing FilamentType entries
python manage.py bambu_import_colors docs/Bambu_Color_Catalog/ --no-auto-create-filament-type
File naming convention:
The stem determines filament type and sub-type:
PLA Basic.txt → type=PLA, sub_type=PLA Basic
PA6-GF.txt → type=PA6, sub_type=PA6-GF
ABS.txt → type=ABS, sub_type=ABS
Supported file formats:
Format 1 (multi-line): Format 2 (same-line / tab-separated):
Jade White Black Walnut #4F3F24
Hex:#FFFFFF Rosewood #4C241C
Hex values may appear as: Hex:#RRGGBB Hex: #RRGGBB #RRGGBB RRGGBB
"""
import logging
import re
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from bambu_run.models import FilamentColor, FilamentType
logger = logging.getLogger("bambu_run.import_colors")
BRAND = "Bambu Lab"
# ─── Parsing helpers ──────────────────────────────────────────────────────────
_SAME_LINE_RE = re.compile(
r'^(.+?)\s+(?:Hex\s*:\s*)?#?([0-9A-Fa-f]{6})\s*$', re.IGNORECASE
)
_HEX_ONLY_RE = re.compile(
r'^\s*(?:Hex\s*:\s*)?#?([0-9A-Fa-f]{6})\s*$', re.IGNORECASE
)
def _stem_to_type_and_subtype(stem):
"""
Derive (filament_type, filament_sub_type) from a file stem.
The sub-type is the full stem. The type is everything before the first
space or hyphen.
"PLA Basic" → ("PLA", "PLA Basic")
"PA6-GF" → ("PA6", "PA6-GF")
"ABS" → ("ABS", "ABS")
"PETG HF" → ("PETG", "PETG HF")
"""
sub_type = stem
m = re.search(r'[ -]', stem)
filament_type = stem[: m.start()] if m else stem
return filament_type, sub_type
def _parse_file(path):
"""
Parse a color catalog file and return a list of (color_name, hex_code) tuples.
hex_code is always 6-char uppercase without '#'.
Raises ValueError if the file cannot be read.
"""
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError as exc:
raise ValueError(f"Cannot read file: {exc}") from exc
lines = text.splitlines()
colors = []
i = 0
while i < len(lines):
stripped = lines[i].strip()
i += 1
if not stripped:
continue
# ── Format 2: color name + hex on the same line ─────────────────────
m = _SAME_LINE_RE.match(stripped)
if m:
colors.append((m.group(1).strip(), m.group(2).upper()))
continue
# ── Orphaned hex line with no preceding name — skip ──────────────────
if _HEX_ONLY_RE.match(stripped):
logger.warning(" [parse] Orphaned hex line (no preceding name): '%s'", stripped)
continue
# ── Format 1: color name on this line, hex on the next ──────────────
color_name = stripped
found_hex = False
while i < len(lines):
next_stripped = lines[i].strip()
i += 1 # tentatively consume
if not next_stripped:
continue # skip blank lines between name and hex
m_hex = _HEX_ONLY_RE.match(next_stripped)
if m_hex:
colors.append((color_name, m_hex.group(1).upper()))
found_hex = True
else:
# Not a hex line — put it back for the outer loop
i -= 1
logger.warning(
" [parse] Expected hex after '%s', got '%s' — skipping name",
color_name,
next_stripped,
)
break # look-ahead done (one non-empty line checked)
if not found_hex:
logger.warning(
" [parse] Color '%s' has no hex line following it — skipping", color_name
)
return colors
# ─── Command ──────────────────────────────────────────────────────────────────
class Command(BaseCommand):
help = (
"Import Bambu Lab filament color catalog .txt files into the FilamentColor database. "
"Accepts a single .txt file or a directory of .txt files."
)
def add_arguments(self, parser):
parser.add_argument(
"path",
help="Path to a single .txt catalog file or a directory containing .txt files.",
)
parser.add_argument(
"--auto-create-filament-type",
default=True,
action="store_true",
dest="auto_create",
help="Auto-create FilamentType entries when missing (default: enabled).",
)
parser.add_argument(
"--no-auto-create-filament-type",
action="store_false",
dest="auto_create",
help="Skip colors whose FilamentType entry does not exist instead of creating it.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Preview what would be imported without writing to the database.",
)
def handle(self, *args, **options):
input_path = Path(options["path"]).expanduser().resolve()
auto_create = options["auto_create"]
dry_run = options["dry_run"]
if dry_run:
self.stdout.write(self.style.WARNING("DRY RUN — no changes will be written.\n"))
# ── Collect files to process ─────────────────────────────────────────
if input_path.is_dir():
files = sorted(input_path.glob("*.txt"))
if not files:
raise CommandError(f"No .txt files found in: {input_path}")
self.stdout.write(f"Found {len(files)} .txt file(s) in {input_path}\n")
elif input_path.is_file():
if input_path.suffix.lower() != ".txt":
raise CommandError(f"Expected a .txt file, got: {input_path.name}")
files = [input_path]
else:
raise CommandError(f"Path does not exist: {input_path}")
# ── Counters ─────────────────────────────────────────────────────────
total_created = 0
total_skipped_dup = 0
total_skipped_no_type = 0
total_errors = 0
for file_path in files:
created, skipped_dup, skipped_no_type, errors = self._process_file(
file_path, auto_create=auto_create, dry_run=dry_run
)
total_created += created
total_skipped_dup += skipped_dup
total_skipped_no_type += skipped_no_type
total_errors += errors
# ── Summary ──────────────────────────────────────────────────────────
self.stdout.write("\n" + "" * 50)
self.stdout.write(
self.style.SUCCESS(f" Created: {total_created}")
)
self.stdout.write(f" Skipped (duplicate): {total_skipped_dup}")
if total_skipped_no_type:
self.stdout.write(
self.style.WARNING(f" Skipped (no type): {total_skipped_no_type}")
)
if total_errors:
self.stdout.write(
self.style.ERROR(f" Errors: {total_errors}")
)
if dry_run:
self.stdout.write(self.style.WARNING("\nDRY RUN complete — nothing was written."))
# ── Per-file processing ───────────────────────────────────────────────────
def _process_file(self, file_path, *, auto_create, dry_run):
"""Process one catalog file. Returns (created, skipped_dup, skipped_no_type, errors)."""
stem = file_path.stem
filament_type, filament_sub_type = _stem_to_type_and_subtype(stem)
self.stdout.write(
f"\nProcessing: {file_path.name} "
f"→ type={filament_type!r} sub_type={filament_sub_type!r}"
)
# ── Parse file ───────────────────────────────────────────────────────
try:
colors = _parse_file(file_path)
except ValueError as exc:
self.stderr.write(self.style.ERROR(f" ERROR reading file: {exc}"))
return 0, 0, 0, 1
if not colors:
self.stdout.write(self.style.WARNING(" No colors parsed — skipping file."))
return 0, 0, 0, 0
self.stdout.write(f" Parsed {len(colors)} color(s).")
# ── Resolve FilamentType ─────────────────────────────────────────────
filament_type_obj = self._resolve_filament_type(
filament_type, filament_sub_type, auto_create=auto_create, dry_run=dry_run
)
if filament_type_obj is None and not auto_create:
self.stdout.write(
self.style.WARNING(
f" No FilamentType for type={filament_type!r} "
f"sub_type={filament_sub_type!r} brand={BRAND!r}"
f"skipping all {len(colors)} color(s) in this file."
)
)
return 0, 0, len(colors), 0
# ── Import colors ────────────────────────────────────────────────────
created = skipped_dup = skipped_no_type = errors = 0
for color_name, hex_code in colors:
result = self._import_color(
color_name=color_name,
hex_code=hex_code,
filament_type=filament_type,
filament_sub_type=filament_sub_type,
filament_type_obj=filament_type_obj,
dry_run=dry_run,
)
if result == "created":
created += 1
elif result == "duplicate":
skipped_dup += 1
elif result == "no_type":
skipped_no_type += 1
elif result == "error":
errors += 1
self.stdout.write(
f" → created={created} duplicate={skipped_dup} "
f"no_type={skipped_no_type} errors={errors}"
)
return created, skipped_dup, skipped_no_type, errors
def _resolve_filament_type(self, filament_type, filament_sub_type, *, auto_create, dry_run):
"""
Return the matching FilamentType instance.
If none exists:
- auto_create=True → create it (or simulate in dry-run) and return it
- auto_create=False → return None
"""
try:
obj = FilamentType.objects.get(
type=filament_type,
sub_type=filament_sub_type,
brand=BRAND,
)
return obj
except FilamentType.DoesNotExist:
pass
if not auto_create:
return None
if dry_run:
self.stdout.write(
self.style.NOTICE(
f" [dry-run] Would create FilamentType: "
f"type={filament_type!r} sub_type={filament_sub_type!r} brand={BRAND!r}"
)
)
return None # can't return a real object in dry-run
try:
with transaction.atomic():
obj, created = FilamentType.objects.get_or_create(
type=filament_type,
sub_type=filament_sub_type,
brand=BRAND,
)
if created:
self.stdout.write(
self.style.SUCCESS(
f" Created FilamentType: "
f"type={filament_type!r} sub_type={filament_sub_type!r} brand={BRAND!r}"
)
)
return obj
except Exception as exc:
self.stderr.write(
self.style.ERROR(
f" ERROR creating FilamentType "
f"(type={filament_type!r} sub_type={filament_sub_type!r}): {exc}"
)
)
return None
def _import_color(
self,
*,
color_name,
hex_code,
filament_type,
filament_sub_type,
filament_type_obj,
dry_run,
):
"""
Import a single (color_name, hex_code) entry.
Returns one of: "created", "duplicate", "no_type", "error"
"""
if filament_type_obj is None:
# dry-run path: FilamentType would have been created but isn't real yet
if dry_run:
self.stdout.write(
f" [dry-run] Would create: {color_name!r} #{hex_code} "
f"({filament_type} / {filament_sub_type})"
)
return "created"
return "no_type"
# ── Transparent detection ────────────────────────────────────────────
# "Translucent" (no colour qualifier) + #000000 = clear/transparent filament.
# Bambu Lab AMS reports these as 00000000 (alpha=00).
is_transparent = color_name.strip().lower() == "translucent" and hex_code == "000000"
# ── Duplicate check ──────────────────────────────────────────────────
# All five fields must match to be considered a duplicate:
# color_code (exact), color_name (case-insensitive), brand,
# denormalised filament_type + filament_sub_type
duplicate = FilamentColor.objects.filter(
color_code=hex_code,
color_name__iexact=color_name,
brand=BRAND,
filament_type=filament_type,
filament_sub_type=filament_sub_type,
).exists()
if duplicate:
logger.debug(" Duplicate — skipping: %s #%s", color_name, hex_code)
return "duplicate"
if dry_run:
transparent_note = " [transparent]" if is_transparent else ""
self.stdout.write(
f" [dry-run] Would create: {color_name!r} #{hex_code} "
f"({filament_type} / {filament_sub_type}){transparent_note}"
)
return "created"
# ── Write to database ────────────────────────────────────────────────
try:
with transaction.atomic():
FilamentColor.objects.create(
color_code=hex_code,
color_name=color_name,
filament_type_fk=filament_type_obj,
filament_type=filament_type,
filament_sub_type=filament_sub_type,
brand=BRAND,
is_transparent=is_transparent,
)
self.stdout.write(
f" + {color_name!r} #{hex_code} ({filament_type} / {filament_sub_type})"
)
return "created"
except Exception as exc:
self.stderr.write(
self.style.ERROR(
f" ERROR saving {color_name!r} #{hex_code}: {exc}"
)
)
return "error"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bambu_run", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="filamentcolor",
name="is_transparent",
field=models.BooleanField(
default=False,
help_text="True for clear/transparent filaments — display as checkerboard, not solid color",
),
),
migrations.AddField(
model_name="filament",
name="is_transparent",
field=models.BooleanField(
default=False,
help_text="True for clear/transparent filaments — display as checkerboard, not solid color",
),
),
]

View File

@@ -259,6 +259,10 @@ class FilamentColor(models.Model):
default='Bambu Lab', default='Bambu Lab',
help_text="Manufacturer name" help_text="Manufacturer name"
) )
is_transparent = models.BooleanField(
default=False,
help_text="True for clear/transparent filaments — display as checkerboard, not solid color"
)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@@ -329,6 +333,10 @@ class Filament(models.Model):
max_length=7, null=True, blank=True, max_length=7, null=True, blank=True,
help_text="Color hex code for display (#RRGGBB)" help_text="Color hex code for display (#RRGGBB)"
) )
is_transparent = models.BooleanField(
default=False,
help_text="True for clear/transparent filaments — display as checkerboard, not solid color"
)
# Physical properties # Physical properties
diameter = models.DecimalField( diameter = models.DecimalField(

View File

@@ -687,19 +687,20 @@ class BambuPrinter:
print("BambuLab Authentication") print("BambuLab Authentication")
print("=" * 60) print("=" * 60)
print(f"Authenticating as: {self.username}") print(f"Authenticating as: {self.username}")
print("This may require email verification (2FA)...") print()
print(">>> ACTION MAY BE REQUIRED <<<")
print("Bambu Lab will send a 6-digit verification code to your")
print("registered email. Watch this terminal — if a prompt")
print(f"appears below, enter the code and press Enter.")
print(f"(You have {verification_code_timeout} seconds to respond.)")
print("=" * 60)
print() print()
auth = BambuAuthenticator() auth = BambuAuthenticator()
try: try:
if self._silent: # Always show stdout during auth — suppress_stdout would hide
with suppress_stdout(): # interactive prompts from the library (e.g. verification code input).
token = auth.get_or_create_token(
username=self.username,
password=self.password
)
else:
token = auth.get_or_create_token( token = auth.get_or_create_token(
username=self.username, username=self.username,
password=self.password password=self.password
@@ -707,7 +708,7 @@ class BambuPrinter:
self._token = token self._token = token
print("Authentication successful!") print("Authentication successful!")
print(f"Token: {token[:20]}...{token[-10:]}") print(f"Token: {token}")
print("=" * 60 + "\n") print("=" * 60 + "\n")
logger.info("BambuLab token obtained successfully") logger.info("BambuLab token obtained successfully")
return token return token

View File

@@ -5,6 +5,11 @@
height: 300px; height: 300px;
} }
.no-data-message {
font-size: 0.9rem;
font-style: italic;
}
/* Card styling */ /* Card styling */
.infra-card-warning { .infra-card-warning {
background: linear-gradient(135deg, #ffc107 0%, #ffb300 100%); background: linear-gradient(135deg, #ffc107 0%, #ffb300 100%);

View File

@@ -0,0 +1,302 @@
// Filament Detail Chart — Usage History
// Depends on: chart.js, chartjs-plugin-annotation
// Config injected by template: FILAMENT_USAGE_API_URL
let usageChart = null;
// Register annotation plugin once it's available
if (typeof ChartAnnotation !== 'undefined') {
Chart.register(ChartAnnotation);
}
// ── Time-select population ──────────────────────────────────────────────────
const startTimeSelect = document.getElementById('filamentStartTime');
const endTimeSelect = document.getElementById('filamentEndTime');
if (startTimeSelect && endTimeSelect) {
for (let h = 0; h < 24; h++) {
for (let m = 0; m < 60; m += 30) {
const t = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
startTimeSelect.add(new Option(t, t));
endTimeSelect.add(new Option(t, t));
}
}
// End-time gets one extra option so the last minute of the day is reachable
endTimeSelect.add(new Option('23:59', '23:59'));
startTimeSelect.value = '00:00';
endTimeSelect.value = '23:59';
}
// ── Default date inputs (last 24 h) ────────────────────────────────────────
(function setDefaultDates() {
const now = new Date();
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const sd = document.getElementById('filamentStartDate');
const ed = document.getElementById('filamentEndDate');
if (sd) sd.value = yesterday.toISOString().split('T')[0];
if (ed) ed.value = now.toISOString().split('T')[0];
}());
// ── Full-day checkbox ───────────────────────────────────────────────────────
const fullDayCheckbox = document.getElementById('filamentFullDayCheckbox');
if (fullDayCheckbox) {
fullDayCheckbox.addEventListener('change', function () {
const isFullDay = this.checked;
if (startTimeSelect) startTimeSelect.disabled = isFullDay;
if (endTimeSelect) endTimeSelect.disabled = isFullDay;
});
}
// ── Helpers ─────────────────────────────────────────────────────────────────
/**
* Build date-separator annotations from "YYYY-MM-DD HH:MM" timestamp strings.
* Places a vertical dotted line at each day boundary, label at the bottom.
*/
function buildFilamentDateSeparators(timestamps) {
const annotations = {};
if (!timestamps || timestamps.length < 2) return annotations;
let count = 0;
for (let i = 1; i < timestamps.length; i++) {
const prevDate = timestamps[i - 1].split(' ')[0];
const currDate = timestamps[i].split(' ')[0];
if (currDate !== prevDate) {
const d = new Date(currDate + 'T00:00:00');
const label = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
annotations['dateSep_' + count] = {
type: 'line',
scaleID: 'x',
value: i,
borderColor: 'rgba(128, 128, 128, 0.45)',
borderWidth: 1,
borderDash: [4, 4],
drawTime: 'beforeDatasetsDraw',
label: {
display: true,
content: label,
position: 'end',
backgroundColor: 'rgba(100, 100, 100, 0.65)',
color: '#fff',
font: { size: 9 },
padding: { x: 4, y: 2 }
}
};
count++;
}
}
return annotations;
}
/**
* Build x-axis tick options that adapt to the date span.
*
* autoSkip: true — Chart.js selects evenly-spaced tick positions.
* maxTicksLimit — caps how many ticks are drawn.
* callback — formats the label at each chosen tick position.
*
* ≤1 day : up to 12 ticks, show "HH:MM"
* 27 days: up to dayCount×4 ticks (≤28), show "Feb 22 06:00"
* >7 days : up to min(dayCount, 20) ticks, show "Feb 22"
*/
function filamentXAxisTicks(isDarkMode, timestamps) {
const tickColor = isDarkMode ? 'rgba(255,255,255,0.8)' : 'rgba(0,0,0,0.8)';
const dayCount = (timestamps && timestamps.length > 0)
? new Set(timestamps.map(t => t.split(' ')[0])).size
: 1;
let maxTicksLimit, formatCb;
if (dayCount <= 1) {
maxTicksLimit = 12;
formatCb = function (val) {
const label = this.getLabelForValue(val);
return label ? label.slice(11, 16) : ''; // "HH:MM"
};
} else if (dayCount <= 7) {
maxTicksLimit = Math.min(dayCount * 4, 28);
formatCb = function (val) {
const label = this.getLabelForValue(val);
if (!label) return '';
const datePart = label.split(' ')[0];
const timePart = label.length >= 16 ? label.slice(11, 16) : '';
const d = new Date(datePart + 'T00:00:00');
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ' ' + timePart;
};
} else {
maxTicksLimit = Math.min(dayCount, 20);
formatCb = function (val) {
const label = this.getLabelForValue(val);
if (!label) return '';
const datePart = label.split(' ')[0];
const d = new Date(datePart + 'T00:00:00');
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
};
}
return {
color: tickColor,
autoSkip: true,
maxTicksLimit: maxTicksLimit,
maxRotation: 45,
minRotation: 0,
callback: formatCb
};
}
// ── Chart fetch / render ────────────────────────────────────────────────────
/**
* Fetch and render the usage chart.
*
* @param {boolean} sendDates When false (initial load / reset), no date params
* are sent so the backend can apply its default
* "last 24h or fallback to last available" logic.
* When true (explicit Refresh), the current input
* values are sent as-is.
*/
async function fetchFilamentUsageData(sendDates = true) {
const startDate = document.getElementById('filamentStartDate').value;
const endDate = document.getElementById('filamentEndDate').value;
const isFullDay = fullDayCheckbox ? fullDayCheckbox.checked : true;
const startTime = isFullDay ? '00:00' : (startTimeSelect ? startTimeSelect.value : '00:00');
const endTime = isFullDay ? '23:59' : (endTimeSelect ? endTimeSelect.value : '23:59');
const params = new URLSearchParams();
if (sendDates) {
if (startDate) params.append('start_date', startDate);
if (endDate) params.append('end_date', endDate);
if (startTime) params.append('start_time', startTime);
if (endTime) params.append('end_time', endTime);
}
try {
const response = await fetch(FILAMENT_USAGE_API_URL + '?' + params.toString());
const data = await response.json();
// If the backend used the fallback window, sync the date inputs so the
// user can see and extend the range from that starting point.
if (data.fallback_used && data.timestamps && data.timestamps.length > 0) {
const firstDate = data.timestamps[0].split(' ')[0];
const lastDate = data.timestamps[data.timestamps.length - 1].split(' ')[0];
const sd = document.getElementById('filamentStartDate');
const ed = document.getElementById('filamentEndDate');
if (sd) sd.value = firstDate;
if (ed) ed.value = lastDate;
}
// Update date-range label
const dateRangeSpan = document.getElementById('filamentDateRange');
if (dateRangeSpan) {
if (data.fallback_used) {
dateRangeSpan.textContent = '(Last available data — 24h window)';
} else if (startDate && endDate && sendDates) {
dateRangeSpan.textContent = `(${startDate} to ${endDate})`;
} else {
dateRangeSpan.textContent = '(Last 24 Hours)';
}
}
const isDarkMode = document.documentElement.getAttribute('data-coreui-theme') === 'dark';
const tickColor = isDarkMode ? 'rgba(255,255,255,0.8)' : 'rgba(0,0,0,0.8)';
const gridColor = isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)';
const sepAnnotations = buildFilamentDateSeparators(data.timestamps);
const xTicks = filamentXAxisTicks(isDarkMode, data.timestamps);
if (usageChart) {
usageChart.data.labels = data.timestamps;
usageChart.data.datasets[0].data = data.remaining;
usageChart.options.plugins.annotation.annotations = sepAnnotations;
usageChart.options.scales.x.ticks = xTicks;
usageChart.update();
} else {
const ctx = document.getElementById('usageChart').getContext('2d');
usageChart = new Chart(ctx, {
type: 'line',
data: {
labels: data.timestamps,
datasets: [{
label: 'Remaining %',
data: data.remaining,
borderColor: 'rgb(75, 192, 192)',
backgroundColor: 'rgba(75, 192, 192, 0.1)',
tension: 0.3,
fill: true,
pointRadius: 0,
pointHoverRadius: 3,
borderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
annotation: { annotations: sepAnnotations },
legend: {
position: 'top',
labels: { color: tickColor }
},
tooltip: {
callbacks: {
label: function (ctx) {
return 'Remaining: ' + ctx.parsed.y + '%';
}
}
}
},
scales: {
x: {
ticks: xTicks,
grid: { color: gridColor }
},
y: {
beginAtZero: true,
max: 100,
ticks: {
color: tickColor,
callback: function (v) { return v + '%'; }
},
grid: { color: gridColor }
}
}
}
});
}
} catch (error) {
console.error('Error fetching filament usage data:', error);
}
}
// ── Event listeners ─────────────────────────────────────────────────────────
const refreshBtn = document.getElementById('refreshFilamentChart');
const resetBtn = document.getElementById('resetFilamentChart');
if (refreshBtn) {
// Refresh: honour whatever the user has typed in the date inputs
refreshBtn.addEventListener('click', function () { fetchFilamentUsageData(true); });
}
if (resetBtn) {
resetBtn.addEventListener('click', function () {
// Reset inputs to "last 24 hours" defaults, then let the backend
// decide (fallback if no recent data).
const now = new Date();
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const sd = document.getElementById('filamentStartDate');
const ed = document.getElementById('filamentEndDate');
if (sd) sd.value = yesterday.toISOString().split('T')[0];
if (ed) ed.value = now.toISOString().split('T')[0];
if (fullDayCheckbox) fullDayCheckbox.checked = true;
if (startTimeSelect) startTimeSelect.disabled = true;
if (endTimeSelect) endTimeSelect.disabled = true;
fetchFilamentUsageData(false);
});
}
// ── Initial load — no dates so backend fallback can fire ───────────────────
fetchFilamentUsageData(false);

View File

@@ -0,0 +1,156 @@
/**
* filament_form.js — Filament add/edit form interactions.
*
* Handles:
* - Filament type preset → auto-fill Type / Sub Type / Brand
* - Transparent checkbox → toggle color picker vs. checkerboard swatch
* - Color picker ↔ hex text sync
* - Delete confirmation modal
*/
document.addEventListener('DOMContentLoaded', function () {
// ── Filament type preset auto-fill ────────────────────────────────────────
const dataEl = document.getElementById('filament-type-data');
const filamentTypeMap = dataEl ? JSON.parse(dataEl.textContent) : {};
const filamentTypeSelect = document.getElementById('id_filament_type');
const typeField = document.getElementById('id_type');
const subTypeField = document.getElementById('id_sub_type');
const brandField = document.getElementById('id_brand');
if (filamentTypeSelect) {
filamentTypeSelect.addEventListener('change', function () {
const mapping = filamentTypeMap[this.value];
if (mapping && typeField && subTypeField && brandField) {
typeField.value = mapping.type;
subTypeField.value = mapping.sub_type;
brandField.value = mapping.brand;
}
});
}
// ── Transparent toggle ────────────────────────────────────────────────────
const transparentCheckbox = document.getElementById('id_is_transparent');
const transparentSwatch = document.getElementById('transparent-swatch');
const colorPicker = document.getElementById('id_color_hex_picker');
const colorText = document.getElementById('id_color_hex_text');
/**
* Show checkerboard swatch and disable color inputs when transparent,
* restore normal color picker when not transparent.
* @param {boolean} isTransparent
*/
function applyTransparentState(isTransparent) {
if (!colorPicker) return;
if (isTransparent) {
transparentSwatch.style.display = 'block';
colorPicker.style.display = 'none';
colorPicker.disabled = true;
if (colorText) { colorText.disabled = true; colorText.value = ''; }
} else {
transparentSwatch.style.display = 'none';
colorPicker.style.display = '';
colorPicker.disabled = false;
if (colorText) { colorText.disabled = false; }
}
}
if (transparentCheckbox) {
applyTransparentState(transparentCheckbox.checked);
transparentCheckbox.addEventListener('change', function () {
applyTransparentState(this.checked);
});
}
// ── Color picker ↔ hex text sync ──────────────────────────────────────────
if (colorPicker && colorText) {
colorPicker.addEventListener('input', function () {
colorText.value = this.value.toUpperCase();
});
colorText.addEventListener('input', function () {
const value = this.value.trim();
if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
colorPicker.value = value;
this.classList.remove('is-invalid');
} else if (value.length === 7) {
this.classList.add('is-invalid');
}
});
if (colorText.value && /^#[0-9A-Fa-f]{6}$/.test(colorText.value)) {
colorPicker.value = colorText.value;
} else if (colorPicker.value && !colorText.value) {
colorText.value = colorPicker.value.toUpperCase();
}
}
// ── Delete confirmation modal ─────────────────────────────────────────────
const deleteConfirmText = document.getElementById('deleteConfirmText');
const confirmDeleteBtn = document.getElementById('confirmDeleteBtn');
const deleteForm = document.getElementById('deleteForm');
const deleteModal = document.getElementById('deleteModal');
if (deleteConfirmText && confirmDeleteBtn) {
deleteConfirmText.addEventListener('input', function () {
const value = this.value.trim();
if (value === 'DELETE') {
confirmDeleteBtn.disabled = false;
this.classList.remove('is-invalid');
this.classList.add('is-valid');
} else {
confirmDeleteBtn.disabled = true;
this.classList.remove('is-valid');
if (value.length > 0) {
this.classList.add('is-invalid');
} else {
this.classList.remove('is-invalid');
}
}
});
if (deleteForm) {
deleteForm.addEventListener('submit', function (e) {
if (confirmDeleteBtn.disabled) {
e.preventDefault();
alert('Please type DELETE to confirm deletion');
return false;
}
return true;
});
}
if (deleteModal) {
deleteModal.addEventListener('hidden.bs.modal', function () {
deleteConfirmText.value = '';
confirmDeleteBtn.disabled = true;
deleteConfirmText.classList.remove('is-valid', 'is-invalid');
});
deleteModal.addEventListener('shown.bs.modal', function () {
deleteConfirmText.focus();
});
}
}
// ── Delete button modal opener (backup) ───────────────────────────────────
const deleteBtn = document.getElementById('deleteBtn');
if (deleteBtn && deleteModal) {
deleteBtn.addEventListener('click', function () {
if (!deleteModal.classList.contains('show')) {
if (typeof bootstrap !== 'undefined') {
bootstrap.Modal.getOrCreateInstance(deleteModal).show();
} else if (typeof coreui !== 'undefined' && coreui.Modal) {
coreui.Modal.getOrCreateInstance(deleteModal).show();
}
}
});
}
});

View File

@@ -4,10 +4,30 @@
let nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart; let nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart;
let wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart; let wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart;
function showNoDataMessage(canvasId) {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const container = canvas.closest('.chart-container');
if (!container) return;
canvas.style.display = 'none';
const msg = document.createElement('div');
msg.className = 'no-data-message d-flex align-items-center justify-content-center h-100 text-body-secondary';
msg.textContent = 'No data available for this period';
container.appendChild(msg);
}
function initPrinterCharts(printerData, apiUrl) { function initPrinterCharts(printerData, apiUrl) {
// Apply filament card colors // Apply filament card colors
applyFilamentColors(); applyFilamentColors();
// If no data, show placeholder messages and exit early
if (!printerData.timestamps || printerData.timestamps.length === 0) {
['nozzleTempChart', 'bedTempChart', 'printProgressChart', 'fanSpeedsChart',
'wifiSignalChart', 'amsConditionsChart', 'layerProgressChart', 'filamentTimelineChart'
].forEach(showNoDataMessage);
return;
}
// Register the annotation plugin // Register the annotation plugin
if (typeof Chart !== 'undefined' && typeof ChartAnnotation !== 'undefined') { if (typeof Chart !== 'undefined' && typeof ChartAnnotation !== 'undefined') {
Chart.register(ChartAnnotation); Chart.register(ChartAnnotation);
@@ -35,7 +55,7 @@ function initPrinterCharts(printerData, apiUrl) {
tension: 0.3, tension: 0.3,
borderWidth: 2, borderWidth: 2,
pointRadius: 0, pointRadius: 0,
pointHoverRadius: 5, pointHoverRadius: 3,
spanGaps: true spanGaps: true
}, },
{ {
@@ -47,7 +67,7 @@ function initPrinterCharts(printerData, apiUrl) {
tension: 0.3, tension: 0.3,
borderWidth: 2, borderWidth: 2,
pointRadius: 0, pointRadius: 0,
pointHoverRadius: 5, pointHoverRadius: 3,
spanGaps: true spanGaps: true
} }
] ]
@@ -70,7 +90,7 @@ function initPrinterCharts(printerData, apiUrl) {
tension: 0.3, tension: 0.3,
borderWidth: 2, borderWidth: 2,
pointRadius: 0, pointRadius: 0,
pointHoverRadius: 5, pointHoverRadius: 3,
spanGaps: true spanGaps: true
}, },
{ {
@@ -82,7 +102,7 @@ function initPrinterCharts(printerData, apiUrl) {
tension: 0.3, tension: 0.3,
borderWidth: 2, borderWidth: 2,
pointRadius: 0, pointRadius: 0,
pointHoverRadius: 5, pointHoverRadius: 3,
spanGaps: true spanGaps: true
} }
] ]
@@ -105,7 +125,7 @@ function initPrinterCharts(printerData, apiUrl) {
tension: 0.3, tension: 0.3,
borderWidth: 2, borderWidth: 2,
pointRadius: 0, pointRadius: 0,
pointHoverRadius: 5, pointHoverRadius: 3,
fill: true fill: true
} }
] ]
@@ -128,7 +148,7 @@ function initPrinterCharts(printerData, apiUrl) {
tension: 0.3, tension: 0.3,
borderWidth: 2, borderWidth: 2,
pointRadius: 0, pointRadius: 0,
pointHoverRadius: 5, pointHoverRadius: 3,
spanGaps: true spanGaps: true
}, },
{ {
@@ -139,7 +159,7 @@ function initPrinterCharts(printerData, apiUrl) {
tension: 0.3, tension: 0.3,
borderWidth: 2, borderWidth: 2,
pointRadius: 0, pointRadius: 0,
pointHoverRadius: 5, pointHoverRadius: 3,
spanGaps: true spanGaps: true
} }
] ]
@@ -162,7 +182,7 @@ function initPrinterCharts(printerData, apiUrl) {
tension: 0.3, tension: 0.3,
borderWidth: 2, borderWidth: 2,
pointRadius: 0, pointRadius: 0,
pointHoverRadius: 5, pointHoverRadius: 3,
spanGaps: true spanGaps: true
} }
] ]
@@ -226,7 +246,7 @@ function initPrinterCharts(printerData, apiUrl) {
tension: 0.3, tension: 0.3,
borderWidth: 2, borderWidth: 2,
pointRadius: 0, pointRadius: 0,
pointHoverRadius: 5, pointHoverRadius: 3,
yAxisID: 'y', yAxisID: 'y',
spanGaps: true spanGaps: true
}, },
@@ -238,7 +258,7 @@ function initPrinterCharts(printerData, apiUrl) {
tension: 0.3, tension: 0.3,
borderWidth: 2, borderWidth: 2,
pointRadius: 0, pointRadius: 0,
pointHoverRadius: 5, pointHoverRadius: 3,
yAxisID: 'y1', yAxisID: 'y1',
spanGaps: true spanGaps: true
} }
@@ -322,7 +342,7 @@ function initPrinterCharts(printerData, apiUrl) {
tension: 0.3, tension: 0.3,
borderWidth: 2, borderWidth: 2,
pointRadius: 0, pointRadius: 0,
pointHoverRadius: 5, pointHoverRadius: 3,
fill: true fill: true
}, },
{ {
@@ -334,7 +354,7 @@ function initPrinterCharts(printerData, apiUrl) {
tension: 0.3, tension: 0.3,
borderWidth: 2, borderWidth: 2,
pointRadius: 0, pointRadius: 0,
pointHoverRadius: 5, pointHoverRadius: 3,
spanGaps: true spanGaps: true
} }
] ]
@@ -432,6 +452,11 @@ function initPrinterCharts(printerData, apiUrl) {
} }
}); });
// Add date separator markers when data spans multiple days
if (printerData.dates && printerData.dates.length > 0) {
applyDateSeparatorsToAllPrinterCharts(printerData.timestamps, printerData.dates);
}
// Set up theme observer for dynamic theme switching // Set up theme observer for dynamic theme switching
setupThemeObserver(); setupThemeObserver();
} }
@@ -603,7 +628,7 @@ function createFilamentDatasets(filamentTimeline, timestamps) {
tension: 0.3, tension: 0.3,
borderWidth: 2, borderWidth: 2,
pointRadius: 0, pointRadius: 0,
pointHoverRadius: 5, pointHoverRadius: 3,
spanGaps: false // Don't connect across null values (filament changes) spanGaps: false // Don't connect across null values (filament changes)
}); });
}); });
@@ -621,8 +646,20 @@ function hexToRgba(hex, alpha) {
function applyFilamentColors() { function applyFilamentColors() {
// Apply colors to filament cards // Apply colors to filament cards
document.querySelectorAll('.filament-card').forEach(card => { document.querySelectorAll('.filament-card').forEach(card => {
const isTransparent = card.getAttribute('data-filament-transparent') === 'true';
const colorHex = card.getAttribute('data-filament-color'); const colorHex = card.getAttribute('data-filament-color');
if (colorHex) {
if (isTransparent) {
// Checkerboard left border and subtle background for clear filaments
card.style.borderLeft = '4px solid #aaa';
card.style.background = 'repeating-conic-gradient(rgba(180,180,180,0.15) 0% 25%, transparent 0% 50%) 0 0/10px 10px';
const badge = card.querySelector('.filament-badge');
if (badge) {
badge.style.backgroundColor = '#aaa';
badge.style.color = '#fff';
}
} else if (colorHex) {
const color = '#' + colorHex; const color = '#' + colorHex;
// Set card background with gradient // Set card background with gradient
@@ -711,3 +748,79 @@ function setupThemeObserver() {
attributeFilter: ['data-coreui-theme'] attributeFilter: ['data-coreui-theme']
}); });
} }
/**
* Build date-separator annotations for multi-day charts.
* Detects where consecutive dates differ and returns a vertical dotted line
* annotation at each boundary index, labelled with the new date.
*
* @param {string[]} timestamps - HH:MM display labels (one per data point)
* @param {string[]} dates - YYYY-MM-DD dates (same length as timestamps)
* @returns {Object} chartjs-plugin-annotation annotations keyed as "dateSep_N"
*/
function buildDateSeparatorAnnotations(timestamps, dates) {
const annotations = {};
if (!dates || dates.length < 2) return annotations;
let count = 0;
for (let i = 1; i < dates.length; i++) {
if (dates[i] !== dates[i - 1]) {
// Format date as "Feb 25" for a compact label
const d = new Date(dates[i] + 'T00:00:00');
const label = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
annotations['dateSep_' + count] = {
type: 'line',
scaleID: 'x',
value: i,
borderColor: 'rgba(128, 128, 128, 0.45)',
borderWidth: 1,
borderDash: [4, 4],
drawTime: 'beforeDatasetsDraw',
label: {
display: true,
content: label,
position: 'end',
backgroundColor: 'rgba(100, 100, 100, 0.65)',
color: '#fff',
font: { size: 9 },
padding: { x: 4, y: 2 }
}
};
count++;
}
}
return annotations;
}
/**
* Apply date-separator annotations to all printer charts.
* Preserves any existing "marker_*" (project marker) annotations.
*
* @param {string[]} timestamps
* @param {string[]} dates
*/
function applyDateSeparatorsToAllPrinterCharts(timestamps, dates) {
const sepAnnotations = buildDateSeparatorAnnotations(timestamps, dates);
const charts = [
nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart,
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
];
charts.forEach(chart => {
if (!chart) return;
if (!chart.options.plugins.annotation) {
chart.options.plugins.annotation = { annotations: {} };
}
const existing = chart.options.plugins.annotation.annotations;
// Remove any old dateSep_* entries then re-add updated ones
Object.keys(existing).forEach(key => {
if (key.startsWith('dateSep_')) delete existing[key];
});
Object.assign(existing, sepAnnotations);
chart.update('none');
});
}

View File

@@ -77,11 +77,12 @@ function populateTimeDropdowns(startSelect, endSelect) {
} }
times.forEach(time => { times.forEach(time => {
const option1 = new Option(time, time); startSelect.add(new Option(time, time));
const option2 = new Option(time, time); endSelect.add(new Option(time, time));
startSelect.add(option1);
endSelect.add(option2);
}); });
// End-time gets one extra option so the last minute of the day is reachable
endSelect.add(new Option('23:59', '23:59'));
} }
/** /**
@@ -235,6 +236,11 @@ function updateAllPrinterCharts(data) {
filamentTimelineChart.update(); filamentTimelineChart.update();
} }
// Apply date separator markers (multi-day views)
if (data.dates && data.dates.length > 0) {
applyDateSeparatorsToAllPrinterCharts(data.timestamps, data.dates);
}
// Add project markers to all charts // Add project markers to all charts
if (data.project_markers) { if (data.project_markers) {
addProjectMarkersToCharts(data.project_markers, data.timestamps); addProjectMarkersToCharts(data.project_markers, data.timestamps);
@@ -275,8 +281,11 @@ function addProjectMarkersToCharts(markers, timestamps) {
chart.options.plugins.annotation = { annotations: {} }; chart.options.plugins.annotation = { annotations: {} };
} }
// Clear existing project markers // Clear existing project markers but preserve date-separator annotations
chart.options.plugins.annotation.annotations = {}; const allAnnotations = chart.options.plugins.annotation.annotations;
Object.keys(allAnnotations).forEach(key => {
if (!key.startsWith('dateSep_')) delete allAnnotations[key];
});
// Track active tooltip // Track active tooltip
let activeMarkerTooltip = null; let activeMarkerTooltip = null;

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 410 KiB

View File

@@ -1,55 +1,101 @@
{% load static %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-coreui-theme="dark"> <html lang="en" data-coreui-theme="dark">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Apply saved theme immediately to prevent flash -->
<script>
(function(){
var t = localStorage.getItem('bambu-run-theme') || 'dark';
if (t === 'auto') t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-coreui-theme', t);
})();
</script>
<title>{% block title %}Bambu Run{% endblock %}</title> <title>{% block title %}Bambu Run{% endblock %}</title>
<!-- CoreUI 5.3 CSS CDN --> <!-- CoreUI 5.3 CSS CDN -->
<link href="https://cdn.jsdelivr.net/npm/@coreui/coreui@5.3.0/dist/css/coreui.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/@coreui/coreui@5.3.0/dist/css/coreui.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/@coreui/icons@3.0.1/css/all.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/@coreui/icons@3.0.1/css/all.min.css" rel="stylesheet">
<!-- Bootstrap Icons (for bi-vinyl filament icon) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
{% block extra_css %}{% endblock %} {% block extra_css %}{% endblock %}
{% block extra_head %}{% endblock %}
<style> <style>
.sidebar-brand { padding: 1rem; font-size: 1.25rem; font-weight: 700; } /* Sidebar brand sizing and padding */
.sidebar-brand {
padding: 1rem 1rem 1.25rem;
font-size: 1.25rem;
font-weight: 700;
min-height: 56px;
align-items: center;
overflow: hidden;
white-space: nowrap;
}
/* Hide brand text when sidebar is narrow */
.sidebar-narrow-unfoldable:not(:hover) .sidebar-brand-text {
display: none;
}
/* Gap between brand icon and text */
.sidebar-brand img + .sidebar-brand-text {
margin-left: 0.5rem;
}
/* Sidebar collapse layout — standalone only */
.wrapper { transition: margin-left 0.15s ease-out; }
@media (min-width: 992px) {
.sidebar ~ .wrapper { margin-left: 256px; }
.sidebar.sidebar-narrow ~ .wrapper,
.sidebar.sidebar-narrow-unfoldable ~ .wrapper { margin-left: 56px; }
}
@media (max-width: 991.98px) {
.sidebar ~ .wrapper { margin-left: 0; }
}
/* Theme toggle icon visibility — driven by data-coreui-theme on <html> */
[data-coreui-theme="dark"] .theme-icon-light { display: none; }
[data-coreui-theme="light"] .theme-icon-dark { display: none; }
</style> </style>
</head> </head>
<body> <body>
<div class="sidebar sidebar-dark sidebar-fixed" id="sidebar"> <div class="sidebar sidebar-dark sidebar-fixed" id="sidebar">
<div class="sidebar-brand d-none d-md-flex"> <div class="sidebar-brand d-none d-md-flex">
Bambu Run {% block sidebar_brand_icon %}{% endblock %}
<span class="sidebar-brand-text">Bambu Run</span>
</div> </div>
<ul class="sidebar-nav" data-coreui="navigation"> <ul class="sidebar-nav" data-coreui="navigation">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{% url 'bambu_run:printer_dashboard' %}"> <a class="nav-link" href="{% url 'bambu_run:printer_dashboard' %}">
<svg class="nav-icon"><use xlink:href="https://cdn.jsdelivr.net/npm/@coreui/icons@3.0.1/sprites/free.svg#cil-print"></use></svg> <svg class="nav-icon"><use href="{% static 'bambu_run/vendors/coreui-icons-free.svg' %}#cil-expand-down"></use></svg>
3D Printer 3D Printer
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{% url 'bambu_run:filament_list' %}"> <a class="nav-link" href="{% url 'bambu_run:filament_list' %}">
<svg class="nav-icon"><use xlink:href="https://cdn.jsdelivr.net/npm/@coreui/icons@3.0.1/sprites/free.svg#cil-layers"></use></svg> <i class="nav-icon bi bi-vinyl"></i>
Filament Inventory Filament Inventory
</a> </a>
</li> </li>
</ul> </ul>
<div class="sidebar-footer border-top d-flex">
<button class="sidebar-toggler" type="button"></button>
</div>
</div> </div>
<div class="wrapper d-flex flex-column min-vh-100"> <div class="wrapper d-flex flex-column min-vh-100">
<header class="header header-sticky p-0 mb-4"> <header class="header header-sticky p-0 mb-4">
<div class="container-fluid px-4"> <div class="container-fluid px-4">
<button class="header-toggler" type="button" onclick="document.getElementById('sidebar').classList.toggle('show')"> <button class="header-toggler d-lg-none" type="button"
<svg class="icon icon-lg"><use xlink:href="https://cdn.jsdelivr.net/npm/@coreui/icons@3.0.1/sprites/free.svg#cil-menu"></use></svg> onclick="coreui.Sidebar.getInstance(document.querySelector('#sidebar')).toggle()">
<svg class="icon icon-lg"><use href="{% static 'bambu_run/vendors/coreui-icons-free.svg' %}#cil-menu"></use></svg>
</button> </button>
<ul class="header-nav ms-auto"> <ul class="header-nav ms-auto">
{% block theme_toggle %}
<li class="nav-item"> <li class="nav-item">
<button class="nav-link" id="themeToggle" type="button"> <button class="nav-link" id="themeToggle" type="button">
<svg class="icon icon-lg"><use xlink:href="https://cdn.jsdelivr.net/npm/@coreui/icons@3.0.1/sprites/free.svg#cil-moon"></use></svg> <svg class="icon icon-lg theme-icon-dark"><use href="{% static 'bambu_run/vendors/coreui-icons-free.svg' %}#cil-moon"></use></svg>
<svg class="icon icon-lg theme-icon-light"><use href="{% static 'bambu_run/vendors/coreui-icons-free.svg' %}#cil-sun"></use></svg>
</button> </button>
</li> </li>
{% if user.is_authenticated %} {% endblock %}
<li class="nav-item"> {% block logout_nav %}{% endblock %}
<a class="nav-link" href="{% url 'logout' %}">Logout</a>
</li>
{% endif %}
</ul> </ul>
</div> </div>
</header> </header>
@@ -62,18 +108,31 @@
<footer class="footer px-4"> <footer class="footer px-4">
<div>Bambu Run</div> <div>Bambu Run</div>
<div class="ms-auto">Powered by <a href="https://github.com/runnanli/Bambu-Run">Bambu Run</a></div> <div class="ms-auto">Powered by <a href="https://github.com/RunLit/Bambu-Run.git">Bambu Run</a></div>
</footer> </footer>
</div> </div>
<!-- CoreUI 5.3 JS CDN --> <!-- CoreUI 5.3 JS CDN -->
<script src="https://cdn.jsdelivr.net/npm/@coreui/coreui@5.3.0/dist/js/coreui.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/@coreui/coreui@5.3.0/dist/js/coreui.bundle.min.js"></script>
<script> <script>
// Theme toggle // Sidebar narrow-toggle with state persistence
const sidebarToggler = document.querySelector('.sidebar-toggler');
const sidebar = document.querySelector('#sidebar');
if (sidebarToggler && sidebar) {
if (localStorage.getItem('bambu-run-sidebar-narrow') === 'true') {
sidebar.classList.add('sidebar-narrow-unfoldable');
}
sidebarToggler.addEventListener('click', (e) => {
e.preventDefault(); e.stopPropagation();
const isNarrow = sidebar.classList.contains('sidebar-narrow-unfoldable');
sidebar.classList.toggle('sidebar-narrow-unfoldable', !isNarrow);
localStorage.setItem('bambu-run-sidebar-narrow', String(!isNarrow));
});
}
</script>
<script>
// Simple 2-state theme toggle (standalone default)
const themeToggle = document.getElementById('themeToggle'); const themeToggle = document.getElementById('themeToggle');
const savedTheme = localStorage.getItem('bambu-run-theme') || 'dark';
document.documentElement.setAttribute('data-coreui-theme', savedTheme);
if (themeToggle) { if (themeToggle) {
themeToggle.addEventListener('click', function() { themeToggle.addEventListener('click', function() {
const current = document.documentElement.getAttribute('data-coreui-theme'); const current = document.documentElement.getAttribute('data-coreui-theme');

View File

@@ -9,9 +9,11 @@
<p class="text-muted">Manage filament colors for auto-matching</p> <p class="text-muted">Manage filament colors for auto-matching</p>
</div> </div>
<div class="col-md-4 text-end"> <div class="col-md-4 text-end">
{% if not is_basic_user %}
<a href="{% url 'bambu_run:filament_color_create' %}" class="btn btn-primary"> <a href="{% url 'bambu_run:filament_color_create' %}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add New Color <i class="bi bi-plus-circle"></i> Add New Color
</a> </a>
{% endif %}
<a href="{% url 'bambu_run:filament_list' %}" class="btn btn-outline-secondary"> <a href="{% url 'bambu_run:filament_list' %}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to Inventory <i class="bi bi-arrow-left"></i> Back to Inventory
</a> </a>
@@ -52,11 +54,19 @@
{% for color in colors %} {% for color in colors %}
<tr> <tr>
<td class="align-middle"> <td class="align-middle">
{% if color.is_transparent %}
<div style="width: 50px; height: 50px; border-radius: 4px; border: 2px solid #ddd; background: repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 0 0/10px 10px;" title="Clear / Transparent"></div>
{% else %}
<div style="width: 50px; height: 50px; background-color: {{ color.get_hex_color }}; border-radius: 4px; border: 2px solid #ddd;"></div> <div style="width: 50px; height: 50px; background-color: {{ color.get_hex_color }}; border-radius: 4px; border: 2px solid #ddd;"></div>
{% endif %}
</td> </td>
<td class="align-middle"><strong>{{ color.color_name }}</strong></td> <td class="align-middle"><strong>{{ color.color_name }}</strong></td>
<td class="align-middle"> <td class="align-middle">
{% if color.is_transparent %}
<span class="text-muted fst-italic">Clear / Transparent</span>
{% else %}
<span class="font-monospace">{{ color.get_hex_color }}</span> <span class="font-monospace">{{ color.get_hex_color }}</span>
{% endif %}
</td> </td>
<td class="align-middle"> <td class="align-middle">
<span class="badge bg-secondary">{{ color.filament_type }}</span> <span class="badge bg-secondary">{{ color.filament_type }}</span>
@@ -70,8 +80,10 @@
</td> </td>
<td class="align-middle">{{ color.brand }}</td> <td class="align-middle">{{ color.brand }}</td>
<td class="align-middle"> <td class="align-middle">
{% if not is_basic_user %}
<a href="{% url 'bambu_run:filament_color_update' color.pk %}" class="btn btn-sm btn-warning">Edit</a> <a href="{% url 'bambu_run:filament_color_update' color.pk %}" class="btn btn-sm btn-warning">Edit</a>
<a href="{% url 'bambu_run:filament_color_delete' color.pk %}" class="btn btn-sm btn-danger">Delete</a> <a href="{% url 'bambu_run:filament_color_delete' color.pk %}" class="btn btn-sm btn-danger">Delete</a>
{% endif %}
</td> </td>
</tr> </tr>
{% empty %} {% empty %}

View File

@@ -13,7 +13,9 @@
<p class="text-body-secondary">Filament Spool Details</p> <p class="text-body-secondary">Filament Spool Details</p>
</div> </div>
<div class="col-auto"> <div class="col-auto">
{% if not is_basic_user %}
<a href="{% url 'bambu_run:filament_update' filament.pk %}" class="btn btn-warning">Edit</a> <a href="{% url 'bambu_run:filament_update' filament.pk %}" class="btn btn-warning">Edit</a>
{% endif %}
<a href="{% url 'bambu_run:filament_list' %}" class="btn btn-secondary">Back to List</a> <a href="{% url 'bambu_run:filament_list' %}" class="btn btn-secondary">Back to List</a>
</div> </div>
</div> </div>
@@ -25,10 +27,14 @@
<div class="card-body"> <div class="card-body">
<h6>Color</h6> <h6>Color</h6>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
{% if filament.is_transparent %}
<div style="width: 50px; height: 50px; border-radius: 8px; margin-right: 15px; border: 2px solid #ddd; background: repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 0 0/10px 10px;" title="Clear / Transparent"></div>
{% else %}
<div style="width: 50px; height: 50px; background-color: {{ filament.color_hex|default:'#999' }}; border-radius: 8px; margin-right: 15px; border: 2px solid #ddd;"></div> <div style="width: 50px; height: 50px; background-color: {{ filament.color_hex|default:'#999' }}; border-radius: 8px; margin-right: 15px; border: 2px solid #ddd;"></div>
{% endif %}
<div> <div>
<strong>{{ filament.color }}</strong><br> <strong>{{ filament.color }}</strong><br>
<small class="text-muted">{{ filament.color_hex }}</small> <small class="text-muted">{% if filament.is_transparent %}Clear / Transparent{% else %}{{ filament.color_hex }}{% endif %}</small>
</div> </div>
</div> </div>
</div> </div>
@@ -78,10 +84,11 @@
<!-- Usage Chart --> <!-- Usage Chart -->
<div class="card mb-4"> <div class="card mb-4">
{% if not is_basic_user %}
<div class="card-header"> <div class="card-header">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2"> <div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
<div> <div>
<strong>Chart Filters</strong> <strong>Filament Usage History</strong>
<span class="text-muted" id="filamentDateRange">(Last 24 Hours)</span> <span class="text-muted" id="filamentDateRange">(Last 24 Hours)</span>
</div> </div>
<div class="d-flex align-items-center gap-2 flex-wrap"> <div class="d-flex align-items-center gap-2 flex-wrap">
@@ -108,16 +115,17 @@
</div> </div>
<!-- Buttons --> <!-- Buttons -->
<button type="button" class="btn btn-primary btn-sm" id="refreshFilamentChart"> <button type="button" class="btn btn-primary btn-sm" id="refreshFilamentChart">
<svg class="icon"><use xlink:href="https://cdn.jsdelivr.net/npm/@coreui/icons@3.0.1/sprites/free.svg#cil-reload"></use></svg> <svg class="icon"><use href="{% static 'bambu_run/vendors/coreui-icons-free.svg' %}#cil-reload"></use></svg>
Refresh Refresh
</button> </button>
<button type="button" class="btn btn-secondary btn-sm" id="resetFilamentChart"> <button type="button" class="btn btn-secondary btn-sm" id="resetFilamentChart">
<svg class="icon"><use xlink:href="https://cdn.jsdelivr.net/npm/@coreui/icons@3.0.1/sprites/free.svg#cil-action-undo"></use></svg> <svg class="icon"><use href="{% static 'bambu_run/vendors/coreui-icons-free.svg' %}#cil-action-undo"></use></svg>
Reset Reset
</button> </button>
</div> </div>
</div> </div>
</div> </div>
{% endif %}
<div class="card-body"> <div class="card-body">
<div class="chart-container" style="height: 300px;"> <div class="chart-container" style="height: 300px;">
<canvas id="usageChart"></canvas> <canvas id="usageChart"></canvas>
@@ -199,113 +207,42 @@
{% block extra_js %} {% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3.0.1"></script>
{% if not is_basic_user %}
{# Inject Django-specific values that the static JS file cannot know #}
<script> <script>
const filamentId = {{ filament.pk }}; const FILAMENT_USAGE_API_URL = "{% url 'bambu_run:filament_usage_api' filament.pk %}";
let usageChart = null; </script>
<script src="{% static 'bambu_run/js/filament_detail.js' %}"></script>
// Populate time selects {% else %}
const startTimeSelect = document.getElementById('filamentStartTime'); <script>
const endTimeSelect = document.getElementById('filamentEndTime'); document.addEventListener('DOMContentLoaded', function () {
for (let h = 0; h < 24; h++) { const ctx = document.getElementById('usageChart');
for (let m = 0; m < 60; m += 30) { if (ctx) {
const timeStr = `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`; new Chart(ctx.getContext('2d'), {
startTimeSelect.add(new Option(timeStr, timeStr));
endTimeSelect.add(new Option(timeStr, timeStr));
}
}
startTimeSelect.value = '00:00';
endTimeSelect.value = '23:30';
// Initialize date inputs to last 24 hours
const now = new Date();
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
document.getElementById('filamentStartDate').value = yesterday.toISOString().split('T')[0];
document.getElementById('filamentEndDate').value = now.toISOString().split('T')[0];
// Full day checkbox handler
document.getElementById('filamentFullDayCheckbox').addEventListener('change', function() {
const isFullDay = this.checked;
startTimeSelect.disabled = isFullDay;
endTimeSelect.disabled = isFullDay;
});
// Fetch and render chart
async function fetchFilamentUsageData() {
const startDate = document.getElementById('filamentStartDate').value;
const endDate = document.getElementById('filamentEndDate').value;
const isFullDay = document.getElementById('filamentFullDayCheckbox').checked;
const startTime = isFullDay ? '00:00' : startTimeSelect.value;
const endTime = isFullDay ? '23:59' : endTimeSelect.value;
const params = new URLSearchParams();
if (startDate) params.append('start_date', startDate);
if (endDate) params.append('end_date', endDate);
if (startTime) params.append('start_time', startTime);
if (endTime) params.append('end_time', endTime);
try {
const response = await fetch(`{% url 'bambu_run:filament_usage_api' filament.pk %}?${params.toString()}`);
const data = await response.json();
// Update date range display
const dateRangeSpan = document.getElementById('filamentDateRange');
if (startDate && endDate) {
dateRangeSpan.textContent = `(${startDate} to ${endDate})`;
} else {
dateRangeSpan.textContent = '(Last 24 Hours)';
}
// Update chart
if (usageChart) {
usageChart.data.labels = data.timestamps;
usageChart.data.datasets[0].data = data.remaining;
usageChart.update();
} else {
const ctx = document.getElementById('usageChart').getContext('2d');
usageChart = new Chart(ctx, {
type: 'line', type: 'line',
data: { data: {
labels: data.timestamps, labels: [],
datasets: [{ datasets: [{
label: 'Remaining %', label: 'Remaining %',
data: data.remaining, data: [],
borderColor: 'rgb(75, 192, 192)', borderColor: 'rgb(75, 192, 192)',
backgroundColor: 'rgba(75, 192, 192, 0.1)', backgroundColor: 'rgba(75, 192, 192, 0.1)',
tension: 0.3, tension: 0.3,
fill: true fill: true,
pointRadius: 0,
pointHoverRadius: 3,
borderWidth: 2
}] }]
}, },
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
scales: { scales: { y: { beginAtZero: true, max: 100 } }
y: {
beginAtZero: true,
max: 100
}
}
} }
}); });
} }
} catch (error) {
console.error('Error fetching filament usage data:', error);
}
}
// Event listeners
document.getElementById('refreshFilamentChart').addEventListener('click', fetchFilamentUsageData);
document.getElementById('resetFilamentChart').addEventListener('click', function() {
const now = new Date();
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
document.getElementById('filamentStartDate').value = yesterday.toISOString().split('T')[0];
document.getElementById('filamentEndDate').value = now.toISOString().split('T')[0];
document.getElementById('filamentFullDayCheckbox').checked = true;
startTimeSelect.disabled = true;
endTimeSelect.disabled = true;
fetchFilamentUsageData();
}); });
// Initial load
fetchFilamentUsageData();
</script> </script>
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -43,6 +43,14 @@
<hr> <hr>
<h5>Specifications</h5> <h5>Specifications</h5>
<div class="row mb-3">
<div class="col-md-12">
<label class="form-label">Filament Type Preset</label>
{{ form.filament_type }}
<small class="form-text text-muted">Selecting a preset auto-fills Type, Sub Type, and Brand below.</small>
</div>
</div>
<div class="row mb-3"> <div class="row mb-3">
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label">Type *</label> <label class="form-label">Type *</label>
@@ -62,12 +70,19 @@
</div> </div>
</div> </div>
<div class="row mb-3"> <div class="row mb-3 align-items-end">
<div class="col-md-3"> <div class="col-md-2">
<label class="form-label">Color Picker</label> <label class="form-label">Color Picker</label>
<div id="transparent-swatch" style="display:none; width:100%; height:38px; border-radius:4px; border:1px solid #ddd; background: repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 0 0/10px 10px;" title="Clear / Transparent"></div>
{{ form.color_hex }} {{ form.color_hex }}
</div> </div>
<div class="col-md-3"> <div class="col-md-2">
<div class="form-check mt-4">
{{ form.is_transparent }}
<label class="form-check-label" for="id_is_transparent">Transparent / Clear</label>
</div>
</div>
<div class="col-md-2">
<label class="form-label">{{ form.color_hex_text.label }}</label> <label class="form-label">{{ form.color_hex_text.label }}</label>
{{ form.color_hex_text }} {{ form.color_hex_text }}
<small class="form-text text-muted">e.g. #0A2CA5</small> <small class="form-text text-muted">e.g. #0A2CA5</small>
@@ -145,7 +160,7 @@
<button type="submit" class="btn btn-primary">Save</button> <button type="submit" class="btn btn-primary">Save</button>
<a href="{% url 'bambu_run:filament_list' %}" class="btn btn-secondary">Cancel</a> <a href="{% url 'bambu_run:filament_list' %}" class="btn btn-secondary">Cancel</a>
</div> </div>
{% if form.instance.pk %} {% if form.instance.pk and not is_basic_user %}
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#deleteModal" id="deleteBtn"> <button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#deleteModal" id="deleteBtn">
<i class="bi bi-trash-fill me-1"></i>Delete <i class="bi bi-trash-fill me-1"></i>Delete
</button> </button>
@@ -209,95 +224,7 @@
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script> {# Server-side data consumed by filament_form.js #}
// Sync color picker and text input <script type="application/json" id="filament-type-data">{{ filament_type_map|safe }}</script>
const colorPicker = document.getElementById('id_color_hex_picker'); <script src="{% static 'bambu_run/js/filament_form.js' %}"></script>
const colorText = document.getElementById('id_color_hex_text');
if (colorPicker && colorText) {
colorPicker.addEventListener('input', function() {
colorText.value = this.value.toUpperCase();
});
colorText.addEventListener('input', function() {
const value = this.value.trim();
if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
colorPicker.value = value;
this.classList.remove('is-invalid');
} else if (value.length === 7) {
this.classList.add('is-invalid');
}
});
if (colorText.value && /^#[0-9A-Fa-f]{6}$/.test(colorText.value)) {
colorPicker.value = colorText.value;
} else if (colorPicker.value && !colorText.value) {
colorText.value = colorPicker.value.toUpperCase();
}
}
// Delete confirmation logic
const deleteConfirmText = document.getElementById('deleteConfirmText');
const confirmDeleteBtn = document.getElementById('confirmDeleteBtn');
const deleteForm = document.getElementById('deleteForm');
const deleteModal = document.getElementById('deleteModal');
if (deleteConfirmText && confirmDeleteBtn) {
deleteConfirmText.addEventListener('input', function() {
const value = this.value.trim();
if (value === 'DELETE') {
confirmDeleteBtn.disabled = false;
this.classList.remove('is-invalid');
this.classList.add('is-valid');
} else {
confirmDeleteBtn.disabled = true;
this.classList.remove('is-valid');
if (value.length > 0) {
this.classList.add('is-invalid');
} else {
this.classList.remove('is-invalid');
}
}
});
if (deleteForm) {
deleteForm.addEventListener('submit', function(e) {
if (confirmDeleteBtn.disabled) {
e.preventDefault();
alert('Please type DELETE to confirm deletion');
return false;
}
return true;
});
}
if (deleteModal) {
deleteModal.addEventListener('hidden.bs.modal', function() {
deleteConfirmText.value = '';
confirmDeleteBtn.disabled = true;
deleteConfirmText.classList.remove('is-valid', 'is-invalid');
});
deleteModal.addEventListener('shown.bs.modal', function() {
deleteConfirmText.focus();
});
}
}
// Backup modal opener
const deleteBtn = document.getElementById('deleteBtn');
if (deleteBtn && deleteModal) {
deleteBtn.addEventListener('click', function() {
if (!deleteModal.classList.contains('show')) {
if (typeof bootstrap !== 'undefined') {
const modalInstance = bootstrap.Modal.getOrCreateInstance(deleteModal);
modalInstance.show();
} else if (typeof coreui !== 'undefined' && coreui.Modal) {
const modalInstance = coreui.Modal.getOrCreateInstance(deleteModal);
modalInstance.show();
}
}
});
}
</script>
{% endblock %} {% endblock %}

View File

@@ -12,6 +12,7 @@
<h1>Filament Inventory</h1> <h1>Filament Inventory</h1>
<p class="text-body-secondary">Manage your 3D printer filament spools</p> <p class="text-body-secondary">Manage your 3D printer filament spools</p>
</div> </div>
{% if not is_basic_user %}
<div class="col-auto"> <div class="col-auto">
<a href="{% url 'bambu_run:filament_type_list' %}" class="btn btn-outline-info me-2"> <a href="{% url 'bambu_run:filament_type_list' %}" class="btn btn-outline-info me-2">
<i class="bi bi-list-ul"></i> Manage Types <i class="bi bi-list-ul"></i> Manage Types
@@ -23,6 +24,7 @@
<i class="bi bi-plus-circle"></i> Add Filament <i class="bi bi-plus-circle"></i> Add Filament
</a> </a>
</div> </div>
{% endif %}
</div> </div>
<!-- Summary Cards --> <!-- Summary Cards -->
@@ -120,7 +122,11 @@
</td> </td>
<td class="align-middle"> <td class="align-middle">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
{% if filament.is_transparent %}
<div style="width: 30px; height: 30px; border-radius: 4px; margin-right: 10px; border: 1px solid #ddd; background: repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 0 0/10px 10px;" title="Clear / Transparent"></div>
{% else %}
<div style="width: 30px; height: 30px; background-color: {{ filament.color_hex|default:'#999' }}; border-radius: 4px; margin-right: 10px; border: 1px solid #ddd;"></div> <div style="width: 30px; height: 30px; background-color: {{ filament.color_hex|default:'#999' }}; border-radius: 4px; margin-right: 10px; border: 1px solid #ddd;"></div>
{% endif %}
{{ filament.color }} {{ filament.color }}
</div> </div>
</td> </td>
@@ -158,7 +164,9 @@
<td class="align-middle">{{ filament.last_used|date:"Y-m-d H:i"|default:"Never" }}</td> <td class="align-middle">{{ filament.last_used|date:"Y-m-d H:i"|default:"Never" }}</td>
<td class="align-middle"> <td class="align-middle">
<a href="{% url 'bambu_run:filament_detail' filament.pk %}" class="btn btn-sm btn-info">View</a> <a href="{% url 'bambu_run:filament_detail' filament.pk %}" class="btn btn-sm btn-info">View</a>
{% if not is_basic_user %}
<a href="{% url 'bambu_run:filament_update' filament.pk %}" class="btn btn-sm btn-warning">Edit</a> <a href="{% url 'bambu_run:filament_update' filament.pk %}" class="btn btn-sm btn-warning">Edit</a>
{% endif %}
</td> </td>
</tr> </tr>
{% empty %} {% empty %}

View File

@@ -9,9 +9,11 @@
<p class="text-muted">Manage filament types (material, sub-type, brand)</p> <p class="text-muted">Manage filament types (material, sub-type, brand)</p>
</div> </div>
<div class="col-md-4 text-end"> <div class="col-md-4 text-end">
{% if not is_basic_user %}
<a href="{% url 'bambu_run:filament_type_create' %}" class="btn btn-primary"> <a href="{% url 'bambu_run:filament_type_create' %}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add New Type <i class="bi bi-plus-circle"></i> Add New Type
</a> </a>
{% endif %}
<a href="{% url 'bambu_run:filament_list' %}" class="btn btn-outline-secondary"> <a href="{% url 'bambu_run:filament_list' %}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to Inventory <i class="bi bi-arrow-left"></i> Back to Inventory
</a> </a>
@@ -60,8 +62,10 @@
</td> </td>
<td class="align-middle">{{ ft.brand }}</td> <td class="align-middle">{{ ft.brand }}</td>
<td class="align-middle"> <td class="align-middle">
{% if not is_basic_user %}
<a href="{% url 'bambu_run:filament_type_update' ft.pk %}" class="btn btn-sm btn-warning">Edit</a> <a href="{% url 'bambu_run:filament_type_update' ft.pk %}" class="btn btn-sm btn-warning">Edit</a>
<a href="{% url 'bambu_run:filament_type_delete' ft.pk %}" class="btn btn-sm btn-danger">Delete</a> <a href="{% url 'bambu_run:filament_type_delete' ft.pk %}" class="btn btn-sm btn-danger">Delete</a>
{% endif %}
</td> </td>
</tr> </tr>
{% empty %} {% empty %}

View File

@@ -152,13 +152,13 @@
<div class="row g-3"> <div class="row g-3">
{% for filament in stats.filaments %} {% for filament in stats.filaments %}
<div class="col-12 col-md-6 col-lg-3"> <div class="col-12 col-md-6 col-lg-3">
<div class="card filament-card" data-filament-color="{{ filament.color|slice:':6' }}"> <div class="card filament-card" data-filament-color="{{ filament.color|slice:':6' }}"{% if filament.is_transparent %} data-filament-transparent="true"{% endif %}>
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-2"> <div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Tray {{ filament.tray_id }}</h6> <h6 class="mb-0">Tray {{ filament.tray_id }}</h6>
{% if filament.filament_pk %} {% if filament.filament_pk %}
<a href="{% url 'bambu_run:filament_detail' filament.filament_pk %}" class="text-decoration-none" title="View in inventory"> <a href="{% url 'bambu_run:filament_detail' filament.filament_pk %}" class="text-decoration-none" title="View in inventory">
<svg class="icon icon-sm text-body-secondary"><use xlink:href="https://cdn.jsdelivr.net/npm/@coreui/icons@3.0.1/sprites/free.svg#cil-external-link"></use></svg> <svg class="icon icon-sm text-body-secondary"><use href="{% static 'bambu_run/vendors/coreui-icons-free.svg' %}#cil-external-link"></use></svg>
</a> </a>
{% endif %} {% endif %}
</div> </div>
@@ -203,6 +203,7 @@
</div> </div>
<!-- Date/Time Filter Controls --> <!-- Date/Time Filter Controls -->
{% if not is_basic_user %}
<div class="row mb-4"> <div class="row mb-4">
<div class="col-12"> <div class="col-12">
<div class="card"> <div class="card">
@@ -235,11 +236,11 @@
</div> </div>
<!-- Buttons --> <!-- Buttons -->
<button type="button" class="btn btn-primary btn-sm" id="refreshPrinterCharts"> <button type="button" class="btn btn-primary btn-sm" id="refreshPrinterCharts">
<svg class="icon"><use xlink:href="https://cdn.jsdelivr.net/npm/@coreui/icons@3.0.1/sprites/free.svg#cil-reload"></use></svg> <svg class="icon"><use href="{% static 'bambu_run/vendors/coreui-icons-free.svg' %}#cil-reload"></use></svg>
Refresh Refresh
</button> </button>
<button type="button" class="btn btn-secondary btn-sm" id="resetPrinterCharts"> <button type="button" class="btn btn-secondary btn-sm" id="resetPrinterCharts">
<svg class="icon"><use xlink:href="https://cdn.jsdelivr.net/npm/@coreui/icons@3.0.1/sprites/free.svg#cil-action-undo"></use></svg> <svg class="icon"><use href="{% static 'bambu_run/vendors/coreui-icons-free.svg' %}#cil-action-undo"></use></svg>
Reset Reset
</button> </button>
</div> </div>
@@ -247,6 +248,7 @@
</div> </div>
</div> </div>
</div> </div>
{% endif %}
<!-- Filament Timeline Chart - Full Width --> <!-- Filament Timeline Chart - Full Width -->
<div class="row g-3 mb-4"> <div class="row g-3 mb-4">
@@ -372,6 +374,7 @@
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3.0.1"></script> <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3.0.1"></script>
<script src="{% static 'bambu_run/js/printer_charts.js' %}"></script> <script src="{% static 'bambu_run/js/printer_charts.js' %}"></script>
<script src="{% static 'bambu_run/js/printer_charts_control.js' %}"></script> <script src="{% static 'bambu_run/js/printer_charts_control.js' %}"></script>
{% if not is_basic_user %}
<div id="printerApiUrl" data-url="{% url 'bambu_run:printer_api' %}" style="display: none;"></div> <div id="printerApiUrl" data-url="{% url 'bambu_run:printer_api' %}" style="display: none;"></div>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
@@ -387,4 +390,18 @@
} }
}); });
</script> </script>
{% else %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const printerData = {{ printer_data_json|safe }};
initPrinterCharts(printerData, null);
if (printerData.project_markers && printerData.project_markers.length > 0) {
setTimeout(function() {
addProjectMarkersToCharts(printerData.project_markers, printerData.timestamps);
}, 500);
}
});
</script>
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -2,18 +2,31 @@
Utility functions for filament color matching Utility functions for filament color matching
""" """
# BambuLab AMS reports colors as 8-char hex with an alpha channel suffix (e.g. '489FDFFF').
# Opaque filaments use alpha 'FF'. Clear/transparent filaments use alpha '00' (e.g. '00000000').
MQTT_COLOR_HEX_LENGTH = 6
def is_mqtt_color_transparent(mqtt_color):
"""
Return True if the AMS color represents a clear/transparent filament.
Bambu Lab uses alpha=00 for transparent (e.g. '00000000'), not 'FF' like opaque filaments.
"""
return bool(mqtt_color) and len(mqtt_color) == 8 and mqtt_color[6:8].upper() == '00'
def strip_color_padding(mqtt_color): def strip_color_padding(mqtt_color):
""" """
Strip FF padding from MQTT color Strip alpha padding from MQTT color, returning the 6-char RGB hex.
MQTT: '000000FF' -> '000000' MQTT: '000000FF' -> '000000' (opaque black)
MQTT: '00000000' -> '000000' (transparent — use is_mqtt_color_transparent() to distinguish)
MQTT: 'FF6A13FF' -> 'FF6A13' MQTT: 'FF6A13FF' -> 'FF6A13'
""" """
if not mqtt_color: if not mqtt_color:
return None return None
if len(mqtt_color) == 8: if len(mqtt_color) == 8:
return mqtt_color[:6].upper() return mqtt_color[:MQTT_COLOR_HEX_LENGTH].upper()
return mqtt_color[:6].upper() if len(mqtt_color) >= 6 else mqtt_color.upper() return mqtt_color[:MQTT_COLOR_HEX_LENGTH].upper() if len(mqtt_color) >= MQTT_COLOR_HEX_LENGTH else mqtt_color.upper()
def match_filament_color(filament_type, filament_sub_type, color_code, brand='Bambu Lab'): def match_filament_color(filament_type, filament_sub_type, color_code, brand='Bambu Lab'):

View File

@@ -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.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,10 +13,27 @@ 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"
def _get_date_range(self, request):
"""Return (start_dt, end_dt) for the dashboard query. Override for custom date logic."""
time_24h_ago = timezone.now() - timedelta(hours=24)
return time_24h_ago, None # None means "now"
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
@@ -34,11 +51,14 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
tz = zoneinfo.ZoneInfo(app_settings.TIMEZONE) tz = zoneinfo.ZoneInfo(app_settings.TIMEZONE)
# Last 24 hours of live data # Get date range (overridable by subclasses)
time_24h_ago = timezone.now() - timedelta(hours=24) start_dt, end_dt = self._get_date_range(self.request)
metrics = PrinterMetrics.objects.filter( metrics = PrinterMetrics.objects.filter(
device=printer_device, timestamp__gte=time_24h_ago device=printer_device, timestamp__gte=start_dt
).prefetch_related('filament_snapshots').order_by("timestamp") )
if end_dt:
metrics = metrics.filter(timestamp__lte=end_dt)
metrics = metrics.prefetch_related('filament_snapshots').order_by("timestamp")
latest_metric = metrics.last() latest_metric = metrics.last()
@@ -46,6 +66,9 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
"timestamps": [ "timestamps": [
m.timestamp.astimezone(tz).strftime("%H:%M") for m in metrics m.timestamp.astimezone(tz).strftime("%H:%M") for m in metrics
], ],
"dates": [
m.timestamp.astimezone(tz).strftime("%Y-%m-%d") for m in metrics
],
"nozzle_temp": [ "nozzle_temp": [
float(m.nozzle_temp) if m.nozzle_temp else None for m in metrics float(m.nozzle_temp) if m.nozzle_temp else None for m in metrics
], ],
@@ -102,6 +125,7 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
if snapshot.filament: if snapshot.filament:
filament_dict['color_name'] = snapshot.filament.color filament_dict['color_name'] = snapshot.filament.color
filament_dict['filament_pk'] = snapshot.filament.pk filament_dict['filament_pk'] = snapshot.filament.pk
filament_dict['is_transparent'] = snapshot.filament.is_transparent
filaments_list.append(filament_dict) filaments_list.append(filament_dict)
except Exception: except Exception:
filaments_list = [] filaments_list = []
@@ -237,109 +261,125 @@ 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)
if start_date and start_time: # Stage A: only() + step calculation
from datetime import datetime query = (
start_dt_naive = datetime.strptime(f"{start_date} {start_time}", "%Y-%m-%d %H:%M") PrinterMetrics.objects
start_dt = start_dt_naive.replace(tzinfo=tz) .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) query = query.filter(timestamp__gte=start_dt)
expected_count = _MAX_CHART_POINTS
if end_date and end_time: elif end_date and end_time:
from datetime import datetime end_dt = datetime.strptime(f"{end_date} {end_time}", "%Y-%m-%d %H:%M").replace(tzinfo=tz)
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
metrics = query.order_by("timestamp") step = max(1, expected_count // _MAX_CHART_POINTS)
data = { # Stage B: single DB round-trip, downsample in Python
"timestamps": [m.timestamp.astimezone(tz).strftime('%H:%M') for m in metrics], metrics_list = list(query.order_by("timestamp"))
"timestamps_iso": [m.timestamp.astimezone(tz).isoformat() for m in metrics], if step > 1:
"nozzle_temp": [float(m.nozzle_temp) if m.nozzle_temp else None for m in metrics], metrics_list = metrics_list[::step]
"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],
}
project_markers = self._calculate_project_markers(metrics, tz) total_points = len(metrics_list)
data["project_markers"] = project_markers
filament_timeline = self._prepare_filament_timeline_for_api(metrics) # Stage C: targeted snapshot fetch (only sampled IDs)
data["filament_timeline"] = filament_timeline 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)
return JsonResponse(data) # 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 = []
except Exception as e: project_markers = []
import traceback
traceback.print_exc()
return JsonResponse({"error": str(e)}, status=500)
def _calculate_project_markers(self, metrics, timezone_info):
markers = []
current_job = None current_job = None
last_state = None last_state = None
for idx, metric in enumerate(metrics): filament_data = {}
subtask = metric.subtask_name
gcode_state = metric.gcode_state
is_printing = gcode_state not in ['FINISH', 'IDLE', None, ''] 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: if subtask and subtask != current_job and is_printing:
markers.append({ project_markers.append({
'type': 'start', 'type': 'start',
'index': idx, 'index': idx,
'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(), 'timestamp': ts.isoformat(),
'project_name': subtask, 'project_name': subtask,
}) })
current_job = subtask current_job = subtask
last_state = gcode_state last_state = gs
elif current_job and last_state and last_state not in ['FINISH', 'IDLE'] and gs in ['FINISH', 'IDLE']:
elif current_job and last_state and last_state not in ['FINISH', 'IDLE'] and gcode_state in ['FINISH', 'IDLE']: project_markers.append({
markers.append({
'type': 'end', 'type': 'end',
'index': idx, 'index': idx,
'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(), 'timestamp': ts.isoformat(),
'project_name': current_job, 'project_name': current_job,
}) })
current_job = None current_job = None
last_state = gs
last_state = gcode_state # Filament timeline (inline)
for snap in snapshots_by_metric.get(m.id, []):
return markers tray_id = snap.tray_id
fil_type = snap.type or 'Unknown'
def _prepare_filament_timeline_for_api(self, metrics): fil_sub_type = snap.sub_type or 'Unknown'
filament_data = {} fil_color = snap.color or 'FFFFFFFF'
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}" unique_key = f"{tray_id}_{fil_type}_{fil_sub_type}_{fil_color}"
if unique_key not in filament_data: if unique_key not in filament_data:
filament_data[unique_key] = { filament_data[unique_key] = {
'tray_id': tray_id, 'tray_id': tray_id,
@@ -349,17 +389,13 @@ class PrinterDataAPIView(LoginRequiredMixin, View):
'remain_data': [None] * total_points, 'remain_data': [None] * total_points,
'start_idx': idx, 'start_idx': idx,
} }
filament_data[unique_key]['remain_data'][idx] = snap.remain_percent or 0
remain_percent = snapshot.remain_percent or 0 external = m.external_spool or {}
filament_data[unique_key]['remain_data'][idx] = remain_percent
for idx, metric in enumerate(metrics):
external = metric.external_spool or {}
if external.get('type'): if external.get('type'):
fil_type = external.get('type', 'Unknown') fil_type = external.get('type', 'Unknown')
fil_color = external.get('color', '161616FF') fil_color = external.get('color', '161616FF')
unique_key = f"External_{fil_type}_{fil_color}" unique_key = f"External_{fil_type}_{fil_color}"
if unique_key not in filament_data: if unique_key not in filament_data:
filament_data[unique_key] = { filament_data[unique_key] = {
'tray_id': 'External', 'tray_id': 'External',
@@ -369,11 +405,37 @@ class PrinterDataAPIView(LoginRequiredMixin, View):
'remain_data': [None] * total_points, 'remain_data': [None] * total_points,
'start_idx': idx, 'start_idx': idx,
} }
filament_data[unique_key]['remain_data'][idx] = external.get('remain', 0)
remain_percent = external.get('remain', 0) data = {
filament_data[unique_key]['remain_data'][idx] = remain_percent "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,
}
return filament_data return JsonResponse(data)
except Exception as e:
import traceback
traceback.print_exc()
return JsonResponse({"error": str(e)}, status=500)
class FilamentUsageDataAPIView(LoginRequiredMixin, View): class FilamentUsageDataAPIView(LoginRequiredMixin, View):
@@ -402,15 +464,32 @@ class FilamentUsageDataAPIView(LoginRequiredMixin, View):
end_dt = end_dt_naive.replace(tzinfo=tz) end_dt = end_dt_naive.replace(tzinfo=tz)
query = query.filter(printer_metric__timestamp__lte=end_dt) query = query.filter(printer_metric__timestamp__lte=end_dt)
fallback_used = False
if not start_date and not end_date: if not start_date and not end_date:
time_24h_ago = timezone.now() - timedelta(hours=24) time_24h_ago = timezone.now() - timedelta(hours=24)
query = query.filter(printer_metric__timestamp__gte=time_24h_ago) default_query = query.filter(printer_metric__timestamp__gte=time_24h_ago)
if default_query.exists():
snapshots = default_query.order_by('printer_metric__timestamp')
else:
# Fallback: show 24h window ending at the most recent available snapshot
last_snapshot = query.order_by('-printer_metric__timestamp').first()
if last_snapshot:
last_ts = last_snapshot.printer_metric.timestamp
fallback_start = last_ts - timedelta(hours=24)
snapshots = query.filter(
printer_metric__timestamp__gte=fallback_start,
printer_metric__timestamp__lte=last_ts
).order_by('printer_metric__timestamp')
fallback_used = True
else:
snapshots = query.none()
else:
snapshots = query.order_by('printer_metric__timestamp') snapshots = query.order_by('printer_metric__timestamp')
data = { data = {
"timestamps": [s.printer_metric.timestamp.astimezone(tz).strftime('%Y-%m-%d %H:%M') for s in snapshots], "timestamps": [s.printer_metric.timestamp.astimezone(tz).strftime('%Y-%m-%d %H:%M') for s in snapshots],
"remaining": [s.remain_percent for s in snapshots] "remaining": [s.remain_percent for s in snapshots],
"fallback_used": fallback_used,
} }
return JsonResponse(data) return JsonResponse(data)
@@ -466,6 +545,14 @@ class FilamentListView(LoginRequiredMixin, ListView):
return context return context
def _filament_type_map():
"""Return a JSON-serialisable dict mapping FilamentType pk → {type, sub_type, brand}."""
return {
str(ft.pk): {'type': ft.type, 'sub_type': ft.sub_type or '', 'brand': ft.brand}
for ft in FilamentType.objects.all()
}
class FilamentCreateView(LoginRequiredMixin, CreateView): class FilamentCreateView(LoginRequiredMixin, CreateView):
model = Filament model = Filament
form_class = FilamentForm form_class = FilamentForm
@@ -475,6 +562,7 @@ class FilamentCreateView(LoginRequiredMixin, CreateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
context['filament_type_map'] = json.dumps(_filament_type_map())
return context return context
def form_valid(self, form): def form_valid(self, form):
@@ -491,6 +579,7 @@ class FilamentUpdateView(LoginRequiredMixin, UpdateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
context['filament_type_map'] = json.dumps(_filament_type_map())
return context return context
def form_valid(self, form): def form_valid(self, form):

BIN
docs/BambuRun.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@@ -0,0 +1,28 @@
White
Hex:#FFFFFF
Bambu Green
Hex:#00AE42
Olive
Hex:#789D4A
Azure
Hex:#489FDF
Navy Blue
Hex:#0C2340
Blue
Hex:#0A2CA5
Tangerine Yellow
Hex:#FFC72C
Orange
Hex:#FF6A13
Red
Hex:#D32941
Purple
Hex:#AF1685
Silver
Hex:#87909A
Black
Hex:#000000
Mint
Hex:#7AE1BF
Lavender
Hex:#7248BD

View File

@@ -0,0 +1,6 @@
White #FFFAF2
Gray #8A949E
Red #E02928
Green #00A6A0
Blue #2140B4
Black #000000

View File

@@ -0,0 +1,8 @@
White #EAEAE4
Yellow #FFCE00
Lime #C5ED48
Blue #75AED8
Orange #FF4800
Brown #5B492F
Gray #353533
Black #000000

View File

@@ -0,0 +1,3 @@
White #FFFFFF
Gray #A8A8AA
Black #000000

View File

@@ -0,0 +1,14 @@
Yellow #FFD00B
Orange #F75403
Green #00AE42
Red #EB3A3A
Blue #002E96
Black #000000
White #FFFFFF
Cream #F9DFB9
Lime Green #6EE53C
Forest Green #39541A
Lake Blue #1F79E5
Peanut Brown #875718
Gray #ADB1B2
Dark Gray #515151

View File

@@ -0,0 +1,9 @@
Translucent #000000
Translucent Gray #8E8E8E
Translucent Light Blue #61B0FF
Translucent Olive #748C45
Translucent Brown #C9A381
Translucent Teal #77EDD7
Translucent Orange #FF911A
Translucent Purple #D6ABFF
Translucent Pink #F9C1BD

View File

@@ -0,0 +1,60 @@
Jade White
Hex:#FFFFFF
Magenta
Hex:#EC008C
Gold
Hex:#E4BD68
Mistletoe Green
Hex:#3F8E43
Red
Hex:#C12E1F
Purple
Hex:#5E43B7
Beige
Hex:#F7E6DE
Pink
Hex:#F55A74
Sunflower Yellow
Hex:#FEC600
Bronze
Hex:#847D48
Turquoise
Hex:#00B1B7
Indigo Purple
Hex:#482960
Light Gray
Hex:#D1D3D5
Hot Pink
Hex:#F5547C
Yellow
Hex:#F4EE2A
Cocoa Brown
Hex:#6F5034
Cyan
Hex:#0086D6
Blue Grey
Hex:#5B6579
Silver
Hex:#A6A9AA
Orange
Hex:#FF6A13
Bright Green
Hex:#BECF00
Brown
Hex:#9D432C
Blue
Hex:#0A2989
Dark Gray
Hex:#545454
Gray
Hex:#8E9089
Pumpkin Orange
Hex:#FF9016
Bambu Green
Hex:#00AE42
Maroon Red
Hex:#9D2235
Cobalt Blue
Hex:#0056B8
Black
Hex:#000000

View File

@@ -0,0 +1,50 @@
Ivory White
Hex:#FFFFFF
Bone White
Hex:#CBC6B8
Desert Tan
Hex:#E8DBB7
Latte Brown
Hex:#D3B7A7
Caramel
Hex:#AE835B
Terracotta
Hex:#B15533
Dark Brown
Hex:#7D6556
Dark Chocolate
Hex:#4D3324
Lilac Purple
Hex:#AE96D4
Sakura Pink
Hex:#E8AFCF
Mandarin Orange
Hex:#F99963
Lemon Yellow
Hex:#F7D959
Plum
Hex:#950051
Scarlet Red
Hex:#DE4343
Dark Red
Hex:#BB3D43
Dark Green
Hex:#68724D
Grass Green
Hex:#61C680
Apple Green
Hex:#C2E189
Ice Blue
Hex:#A3D8E1
Sky Blue
Hex:#56B7E6
Marine Blue
Hex:#0078BF
Dark Blue
Hex:#042F56
Ash Gray
Hex:#9B9EA0
Nardo Gray
Hex:#757575
Charcoal
Hex:#000000

View File

@@ -0,0 +1,6 @@
Black Walnut #4F3F24
Rosewood #4C241C
Clay Brown #995F11
Classic Birch #918669
White Oak #D6CCA3
Ochre Yellow #C98935

View File

@@ -0,0 +1,75 @@
# Setup Local Environment for Debug
## Prerequisites
- Docker Desktop running on macOS
- Your Bambu Lab account email + password
- Bambu-Run source at /Users/runnanli/src/Bambu-Run
---
### Step 1 — Create .env
Create /Users/runnanli/src/Bambu-Run/.env:
BAMBU_USERNAME=your_bambulab_email@example.com
BAMBU_PASSWORD=your_bambulab_password
TIMEZONE=Australia/Melbourne
No DB vars needed — SQLite is the default when DB_NAME is absent.
---
### Step 2 — Build the image
cd /Users/runnanli/src/Bambu-Run
docker compose build
Takes a few minutes first time.
---
### Step 3 — Run database migrations
docker compose run --rm bambu-run python standalone/manage.py migrate --noinput
---
### Step 4 — First-time Bambu Lab authentication (email verification)
docker compose run --rm bambu-run python standalone/manage.py bambu_collector --once
You'll be prompted for a 6-digit code sent to your email. Enter it.
On success the token is printed:
Token: eyJhbGci...
Add it to .env:
BAMBU_TOKEN=eyJhbGci...paste_full_token_here
Future restarts will skip email verification.
---
### Step 5 — Start everything
docker compose up -d
Supervisord starts three processes: migrate (idempotent), web (gunicorn on :8000), collector (polls printer continuously).
---
### Step 6 — Create a login account
docker compose exec bambu-run python standalone/manage.py createsuperuser
---
### Step 7 — Open the dashboard
http://localhost:8000
---
### Useful commands
#### Watch live logs
docker compose logs -f
#### Stop
docker compose down
#### Rebuild after code changes
docker compose up -d --build
### Notes
- SQLite lives inside Docker volume bambu_data — persists across restarts
- If charts are blank: printer must be on; give collector ~1 minute to start polling

View File

@@ -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

View File

@@ -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

58
native/bambu-run.sh Executable file
View File

@@ -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

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "bambu-run" name = "bambu-run"
version = "0.1.0" version = "0.1.2"
description = "Django reusable app for Bambu Lab 3D printer monitoring and filament inventory management" description = "Django reusable app for Bambu Lab 3D printer monitoring and filament inventory management"
readme = "README.md" readme = "README.md"
license = {text = "MIT"} license = {text = "MIT"}
@@ -38,6 +38,7 @@ dependencies = [
standalone = [ standalone = [
"gunicorn", "gunicorn",
"python-dotenv", "python-dotenv",
"whitenoise",
] ]
dev = [ dev = [
"ruff", "ruff",

263
setup.sh Executable file
View File

@@ -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" <<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

@@ -3,6 +3,18 @@
import os import os
import sys 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.
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(): def main():
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "standalone.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "standalone.settings")

View File

@@ -19,7 +19,7 @@ SECRET_KEY = os.environ.get(
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = os.environ.get("DEBUG", "True").lower() in ("true", "1", "yes") DEBUG = os.environ.get("DEBUG", "True").lower() in ("true", "1", "yes")
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") ALLOWED_HOSTS = ["*"]
# Application definition # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
@@ -34,6 +34,7 @@ INSTALLED_APPS = [
MIDDLEWARE = [ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
@@ -90,6 +91,8 @@ USE_TZ = True
# Static files # Static files
STATIC_URL = "static/" STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles" STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [BASE_DIR / "standalone" / "static"]
STATICFILES_STORAGE = "whitenoise.storage.CompressedStaticFilesStorage"
# Default primary key field type # Default primary key field type
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
@@ -101,7 +104,7 @@ LOGOUT_REDIRECT_URL = "/accounts/login/"
# Bambu Run settings # Bambu Run settings
BAMBU_RUN_TIMEZONE = os.environ.get("TIMEZONE", "UTC") BAMBU_RUN_TIMEZONE = os.environ.get("TIMEZONE", "UTC")
BAMBU_RUN_BASE_TEMPLATE = "bambu_run/base.html" BAMBU_RUN_BASE_TEMPLATE = "standalone_base.html"
# Printer connection — read from environment # Printer connection — read from environment
PRINTER_IP = os.environ.get("PRINTER_IP", "") PRINTER_IP = os.environ.get("PRINTER_IP", "")

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

View File

@@ -0,0 +1,21 @@
{% extends "bambu_run/base.html" %}
{% load static %}
{% block extra_head %}
<link rel="icon" type="image/png" href="{% static 'favicon-32.png' %}">
{% endblock %}
{% block sidebar_brand_icon %}
<img src="{% static 'favicon-64.png' %}" alt="Bambu Run" width="32" height="32" style="flex-shrink:0;">
{% endblock %}
{% block logout_nav %}
{% if user.is_authenticated %}
<li class="nav-item">
<form method="post" action="{% url 'logout' %}" style="margin:0;">
{% csrf_token %}
<button type="submit" class="nav-link">Logout</button>
</form>
</li>
{% endif %}
{% endblock %}