From 5984bd6fa07398df307212a413f079eedf7740f0 Mon Sep 17 00:00:00 2001
From: RunLit <41996199+RunLit@users.noreply.github.com>
Date: Wed, 25 Feb 2026 23:07:24 +1100
Subject: [PATCH] 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
---
README.md | 314 +++++++------
.../management/commands/bambu_collector.py | 3 +-
.../commands/bambu_import_colors.py | 418 ++++++++++++++++++
.../static/bambu_run/js/filament_detail.js | 302 +++++++++++++
.../static/bambu_run/js/printer_charts.js | 107 ++++-
.../bambu_run/js/printer_charts_control.js | 21 +-
.../templates/bambu_run/filament_detail.html | 141 ++----
bambu_run/utils.py | 8 +-
bambu_run/views.py | 29 +-
docs/Bambu_Color_Catalog/ABS.txt | 24 +
docs/Bambu_Color_Catalog/ASA.txt | 6 +
docs/Bambu_Color_Catalog/PA6-GF.txt | 8 +
docs/Bambu_Color_Catalog/PC FR.txt | 3 +
docs/Bambu_Color_Catalog/PETG HF.txt | 14 +
docs/Bambu_Color_Catalog/PETG Translucent.txt | 8 +
docs/Bambu_Color_Catalog/PLA Basic.txt | 60 +++
docs/Bambu_Color_Catalog/PLA Matte.txt | 50 +++
docs/Bambu_Color_Catalog/PLA Wood.txt | 6 +
18 files changed, 1240 insertions(+), 282 deletions(-)
create mode 100644 bambu_run/management/commands/bambu_import_colors.py
create mode 100644 bambu_run/static/bambu_run/js/filament_detail.js
create mode 100644 docs/Bambu_Color_Catalog/ABS.txt
create mode 100644 docs/Bambu_Color_Catalog/ASA.txt
create mode 100644 docs/Bambu_Color_Catalog/PA6-GF.txt
create mode 100644 docs/Bambu_Color_Catalog/PC FR.txt
create mode 100644 docs/Bambu_Color_Catalog/PETG HF.txt
create mode 100644 docs/Bambu_Color_Catalog/PETG Translucent.txt
create mode 100644 docs/Bambu_Color_Catalog/PLA Basic.txt
create mode 100644 docs/Bambu_Color_Catalog/PLA Matte.txt
create mode 100644 docs/Bambu_Color_Catalog/PLA Wood.txt
diff --git a/README.md b/README.md
index 58ea1eb..4c0cf5a 100644
--- a/README.md
+++ b/README.md
@@ -1,239 +1,271 @@
# Bambu-Run
-Unlock richer data access and powerful customization capabilities for your Bambu Lab 3D printer.
+
+
+
-Bambu-Run is a self-hosted web dashboard that tracks data of your Bambu Lab printer. It gives you:
-- 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.
+Richer data, powerful customization for your Bambu Lab 3D printer.
-### 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).
-
-## Getting Started (Beginner Friendly)
-
-This guide walks you through setting up Bambu-Run on a **Raspberry Pi** from scratch. No prior server experience needed.
+All running on hardware you own.
### What You'll Need
-- A Raspberry Pi (3B+, 4, or 5) with Raspberry Pi OS installed and connected to your network
-- 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
+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.
-### 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:
-- **BAMBU_USERNAME** — Your Bambu Lab account email
-- **BAMBU_PASSWORD** — Your Bambu Lab account password
+## Table of Contents
-> **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.
+- [Quick Start: One-Click Docker Setup — Beginner Friendly](#quick-start-one-click-docker-setup--beginner-friendly)
+ - [What You'll Need](#what-youll-need)
+ - [Step 1: Connect to Your Raspberry Pi](#step-1-connect-to-your-raspberry-pi)
+ - [Step 2: Install Docker](#step-2-install-docker)
+ - [Step 3: Download and Configure](#step-3-download-and-configure)
+ - [Step 4: Build the Container](#step-4-build-the-container)
+ - [Step 5: First-Time Authentication](#step-5-first-time-authentication)
+ - [Step 6: Start Bambu-Run and Create Your Login](#step-6-start-bambu-run-and-create-your-login)
+ - [Step 7: Open the Dashboard](#step-7-open-the-dashboard)
+ - [Troubleshooting](#troubleshooting)
+- [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:
+## Quick Start: One-Click Docker Setup — Beginner Friendly
+
+Get Bambu-Run running on a **Raspberry Pi** in minutes. No prior server experience needed.
+
+### What You'll Need
+
+- A Raspberry Pi (3B+, 4, or 5) running Raspberry Pi OS 64-bit, with a 32 GB+ MicroSD card, connected to your network
+- Your Bambu Lab printer on the **same local network**
+- Your Bambu Lab account **email and password**
+- A computer to SSH into the Pi
+
+### Step 1: Connect to Your Raspberry Pi
+
+From your computer, open a terminal (Mac/Linux) or PowerShell (Windows):
```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).
+> Can't connect? Use your Pi's IP address (find it in your router's admin page). Default password: `raspberry`
-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:
+### Step 2: Install Docker
```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:
+Log out and back in for the change to take effect, then verify:
```bash
exit
```
-Then SSH back in:
-
```bash
ssh pi@raspberrypi.local
+docker --version # should show Docker version 27.x.x
```
-Verify Docker is working:
+> Installation issues? See: https://docs.docker.com/engine/install/raspberry-pi-os/
+
+### Step 3: Download and Configure
```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
cd Bambu-Run
-
-# Create your configuration file
cp .env.example .env
-```
-
-Now edit the `.env` file with your printer details:
-
-```bash
nano .env
```
-Fill in your Bambu Lab account credentials from Step 1:
+Fill in your Bambu Lab credentials:
```
BAMBU_USERNAME=your_email@example.com
BAMBU_PASSWORD=your_password
+TIMEZONE=Australia/Melbourne # optional — find yours at https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
```
-Optionally set your timezone (defaults to UTC):
+Save: `Ctrl + X`, `Y`, `Enter`
-```
-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:
+### Step 4: Build the Container
```bash
docker compose build
```
-This downloads all required software (takes a few minutes the first time).
+This takes a few minutes the first time — it downloads all required software.
-### Step 5a: First-Time Authentication
+### Step 5: 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.
-
-First, set up the database:
+Bambu Lab requires email verification on first login. Run these two commands:
```bash
docker compose run --rm bambu-run python standalone/manage.py migrate --noinput
-```
-
-Then run the collector (this is what triggers Bambu Lab to send the verification email):
-
-```bash
docker compose run --rm bambu-run python standalone/manage.py bambu_collector --once
```
-You'll see output like:
+When prompted, enter the 6-digit code sent to your email. On success you'll see a token printed — copy it and add it to your `.env`:
-```
-BambuLab Authentication
-Authenticating as: your_email@example.com
-...
-EMAIL VERIFICATION REQUIRED
-A verification code has been sent to your email.
-Enter verification code:
+```bash
+nano .env
```
-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
- ```
+```
+BAMBU_TOKEN=eyJhbGciOiJIUzI1N...paste_full_token_here
+```
-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
- ```
+> Saving the token lets future restarts skip re-verification automatically.
-> **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:
+### Step 6: Start Bambu-Run and Create Your Login
```bash
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
```
-You'll be prompted to choose a username, email (optional), and password. This is your login for the dashboard.
+Choose a username and password — this is your dashboard login.
### Step 7: Open the Dashboard
-On any device connected to your network (phone, tablet, computer), open a browser and go to:
+On any device on your network, open a browser and go to:
```
http://raspberrypi.local:8000
```
-> If that doesn't work, use your Pi's IP address: `http://:8000`
+> If that doesn't work, use your Pi's IP: `http://:8000`
-Log in with the account you just created. You should see your printer dashboard with live data flowing in.
+Log in with the account you just created. Your printer dashboard should be live.
### 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
+**No data / cannot connect to printer:** Make sure the printer is on and on the same network. Check logs: `docker compose logs -f`. If you see auth errors, re-run Step 5 to get a fresh token.
-**"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
+**401 Unauthorized / verification loop:** Remove `BAMBU_TOKEN` from `.env` and re-run Step 5.
-**"Cannot connect to Docker daemon":**
-- Did you log out and back in after Step 3? Docker group changes require a new session
+**Docker daemon error:** Log out and back in after Step 2 — the group change requires 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`
+**Dashboard not loading:** Run `docker compose ps` to confirm the service is `Up`, then try the Pi's IP address directly.
-**Updating to a newer version:**
+**Update Bambu-Run:**
```bash
-cd ~/Bambu-Run
-git pull
-docker compose up -d --build
+cd ~/Bambu-Run && git pull && docker compose up -d --build
```
-**Stopping Bambu-Run:**
+**Stop Bambu-Run:**
```bash
docker compose down
```
Your data is preserved in a Docker volume and will be there when you start it again.
+
+---
+
+## 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
+docker compose exec bambu-run python standalone/manage.py bambu_import_colors docs/Bambu_Color_Catalog/
+```
+
+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
diff --git a/bambu_run/management/commands/bambu_collector.py b/bambu_run/management/commands/bambu_collector.py
index 4b18dc0..a94742e 100644
--- a/bambu_run/management/commands/bambu_collector.py
+++ b/bambu_run/management/commands/bambu_collector.py
@@ -332,7 +332,6 @@ class Command(BaseCommand):
color_code = strip_color_padding(mqtt_color)
color_hex = f"#{color_code}" if color_code else None
- color_name = mqtt_color
filament_color = match_filament_color(
filament_type=type_val,
filament_sub_type=sub_type,
@@ -345,7 +344,7 @@ class Command(BaseCommand):
if self.verbose:
logger.info(f"Matched color from database: {color_name} (#{color_code})")
else:
- color_name = mqtt_color
+ color_name = color_hex or mqtt_color
if self.verbose:
logger.warning(
f"No color match in database for {type_val} {sub_type} #{color_code}. "
diff --git a/bambu_run/management/commands/bambu_import_colors.py b/bambu_run/management/commands/bambu_import_colors.py
new file mode 100644
index 0000000..832c6f1
--- /dev/null
+++ b/bambu_run/management/commands/bambu_import_colors.py
@@ -0,0 +1,418 @@
+"""
+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"
+
+ # ── 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:
+ self.stdout.write(
+ f" [dry-run] Would create: {color_name!r} #{hex_code} "
+ f"({filament_type} / {filament_sub_type})"
+ )
+ 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,
+ )
+ 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"
diff --git a/bambu_run/static/bambu_run/js/filament_detail.js b/bambu_run/static/bambu_run/js/filament_detail.js
new file mode 100644
index 0000000..68e8bda
--- /dev/null
+++ b/bambu_run/static/bambu_run/js/filament_detail.js
@@ -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"
+ * 2–7 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);
diff --git a/bambu_run/static/bambu_run/js/printer_charts.js b/bambu_run/static/bambu_run/js/printer_charts.js
index 9b3f7f4..7acb95d 100644
--- a/bambu_run/static/bambu_run/js/printer_charts.js
+++ b/bambu_run/static/bambu_run/js/printer_charts.js
@@ -55,7 +55,7 @@ function initPrinterCharts(printerData, apiUrl) {
tension: 0.3,
borderWidth: 2,
pointRadius: 0,
- pointHoverRadius: 5,
+ pointHoverRadius: 3,
spanGaps: true
},
{
@@ -67,7 +67,7 @@ function initPrinterCharts(printerData, apiUrl) {
tension: 0.3,
borderWidth: 2,
pointRadius: 0,
- pointHoverRadius: 5,
+ pointHoverRadius: 3,
spanGaps: true
}
]
@@ -90,7 +90,7 @@ function initPrinterCharts(printerData, apiUrl) {
tension: 0.3,
borderWidth: 2,
pointRadius: 0,
- pointHoverRadius: 5,
+ pointHoverRadius: 3,
spanGaps: true
},
{
@@ -102,7 +102,7 @@ function initPrinterCharts(printerData, apiUrl) {
tension: 0.3,
borderWidth: 2,
pointRadius: 0,
- pointHoverRadius: 5,
+ pointHoverRadius: 3,
spanGaps: true
}
]
@@ -125,7 +125,7 @@ function initPrinterCharts(printerData, apiUrl) {
tension: 0.3,
borderWidth: 2,
pointRadius: 0,
- pointHoverRadius: 5,
+ pointHoverRadius: 3,
fill: true
}
]
@@ -148,7 +148,7 @@ function initPrinterCharts(printerData, apiUrl) {
tension: 0.3,
borderWidth: 2,
pointRadius: 0,
- pointHoverRadius: 5,
+ pointHoverRadius: 3,
spanGaps: true
},
{
@@ -159,7 +159,7 @@ function initPrinterCharts(printerData, apiUrl) {
tension: 0.3,
borderWidth: 2,
pointRadius: 0,
- pointHoverRadius: 5,
+ pointHoverRadius: 3,
spanGaps: true
}
]
@@ -182,7 +182,7 @@ function initPrinterCharts(printerData, apiUrl) {
tension: 0.3,
borderWidth: 2,
pointRadius: 0,
- pointHoverRadius: 5,
+ pointHoverRadius: 3,
spanGaps: true
}
]
@@ -246,7 +246,7 @@ function initPrinterCharts(printerData, apiUrl) {
tension: 0.3,
borderWidth: 2,
pointRadius: 0,
- pointHoverRadius: 5,
+ pointHoverRadius: 3,
yAxisID: 'y',
spanGaps: true
},
@@ -258,7 +258,7 @@ function initPrinterCharts(printerData, apiUrl) {
tension: 0.3,
borderWidth: 2,
pointRadius: 0,
- pointHoverRadius: 5,
+ pointHoverRadius: 3,
yAxisID: 'y1',
spanGaps: true
}
@@ -342,7 +342,7 @@ function initPrinterCharts(printerData, apiUrl) {
tension: 0.3,
borderWidth: 2,
pointRadius: 0,
- pointHoverRadius: 5,
+ pointHoverRadius: 3,
fill: true
},
{
@@ -354,7 +354,7 @@ function initPrinterCharts(printerData, apiUrl) {
tension: 0.3,
borderWidth: 2,
pointRadius: 0,
- pointHoverRadius: 5,
+ pointHoverRadius: 3,
spanGaps: true
}
]
@@ -452,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
setupThemeObserver();
}
@@ -623,7 +628,7 @@ function createFilamentDatasets(filamentTimeline, timestamps) {
tension: 0.3,
borderWidth: 2,
pointRadius: 0,
- pointHoverRadius: 5,
+ pointHoverRadius: 3,
spanGaps: false // Don't connect across null values (filament changes)
});
});
@@ -731,3 +736,79 @@ function setupThemeObserver() {
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');
+ });
+}
diff --git a/bambu_run/static/bambu_run/js/printer_charts_control.js b/bambu_run/static/bambu_run/js/printer_charts_control.js
index e8bb49c..3739674 100644
--- a/bambu_run/static/bambu_run/js/printer_charts_control.js
+++ b/bambu_run/static/bambu_run/js/printer_charts_control.js
@@ -77,11 +77,12 @@ function populateTimeDropdowns(startSelect, endSelect) {
}
times.forEach(time => {
- const option1 = new Option(time, time);
- const option2 = new Option(time, time);
- startSelect.add(option1);
- endSelect.add(option2);
+ startSelect.add(new Option(time, time));
+ endSelect.add(new Option(time, time));
});
+
+ // 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();
}
+ // 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
if (data.project_markers) {
addProjectMarkersToCharts(data.project_markers, data.timestamps);
@@ -275,8 +281,11 @@ function addProjectMarkersToCharts(markers, timestamps) {
chart.options.plugins.annotation = { annotations: {} };
}
- // Clear existing project markers
- chart.options.plugins.annotation.annotations = {};
+ // Clear existing project markers but preserve date-separator annotations
+ const allAnnotations = chart.options.plugins.annotation.annotations;
+ Object.keys(allAnnotations).forEach(key => {
+ if (!key.startsWith('dateSep_')) delete allAnnotations[key];
+ });
// Track active tooltip
let activeMarkerTooltip = null;
diff --git a/bambu_run/templates/bambu_run/filament_detail.html b/bambu_run/templates/bambu_run/filament_detail.html
index c9c1d53..f6f0e78 100644
--- a/bambu_run/templates/bambu_run/filament_detail.html
+++ b/bambu_run/templates/bambu_run/filament_detail.html
@@ -84,7 +84,7 @@