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 Logo +

-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 @@
- Chart Filters + Filament Usage History (Last 24 Hours)
@@ -111,11 +111,11 @@
@@ -203,126 +203,39 @@ {% block extra_js %} + {% if not is_basic_user %} +{# Inject Django-specific values that the static JS file cannot know #} + {% else %}