19 Commits

Author SHA1 Message Date
RNL
2086c08bb6 Add Vortek hotend rack tracking: per-SN registry with slot mapping confirmed against live MQTT capture, plus a fallback for non-inductive nozzles (e.g. H2C's fixed left nozzle) shown read-only without fabricated identity. New dashboard card hides entirely on printers with no Vortek/nozzle-info data at all. 2026-06-21 00:27:15 +10:00
RNL
c52f084329 Add multi-AMS support: per-unit snapshot/usage tracking, grouped dashboard panels with real type labels, and dual-nozzle card UX fixes. Fixes a real-world AMS info-code parsing bug found by inspecting live H2C data. 2026-06-20 23:42:26 +10:00
RNL
58ebdf518e Always show device dropdown and add bambu_diagnose for multi-printer troubleshooting. 2026-06-20 14:48:45 +10:00
RNL
e7bc3291b6 Initial implementation of multi-printer support. 2026-06-18 22:50:39 +10:00
github-actions[bot]
34293ce81a chore: bump version to 0.1.7 [skip ci] 2026-06-16 00:54:32 +00:00
Ben Roberts
af845e3490 Add CSRF_TRUSTED_ORIGINS env var support (#9) 2026-06-16 10:54:17 +10:00
github-actions[bot]
6aab42b03e chore: bump version to 0.1.6 [skip ci] 2026-06-13 13:26:37 +00:00
RNLgit
61d1f630d3 Merge branch 'main' of github.com:RunLit/Bambu-Run 2026-06-13 23:25:33 +10:00
RNLgit
c032745eb5 python 3.9 shall also work for old os 2026-06-13 23:25:11 +10:00
github-actions[bot]
2af3509010 chore: bump version to 0.1.5 [skip ci] 2026-05-07 05:05:19 +00:00
RNL
dd57a963ac Add H2C dual-nozzle and multi-AMS-type support
Schema (migration 0004):
- PrinterMetrics: nozzle_temp_left, nozzle_target_temp_left,
  nozzle_diameter_left, nozzle_type_left (all nullable)
- Filament: ams_unit_id (nullable int), ams_type (AMS/AMS 2 Pro/AMS HT)
- AMS_INFO_TO_TYPE map and AMS_TYPE_CHOICES on models

Parser (mqtt_client.py):
- Decode bit-packed temps from device.extruder.info[] for left/right nozzle
- Emit per-nozzle fields in get_snapshot(); legacy keys mirror right side
- AMS unit type from info code per unit dict

Collector (bambu_collector.py):
- Write left-nozzle fields to PrinterMetrics
- Set ams_unit_id + ams_type on Filament records
- Fix: poll MQTTClient.connected before pushall (not BambuPrinter._connected)
- Add 5s post-pushall wait in --once mode so response arrives before collect

Views: API and dashboard include left-nozzle series; is_dual_nozzle flag
Templates: dual-nozzle cards + chart; AMS-type badge + filter on filament list
Charts: left nozzle temp chart with conditional render
Forms: fix tray_id max=3 → max=15; add ams_unit_id, ams_type fields
2026-05-07 14:51:31 +10:00
github-actions[bot]
6fadccb527 chore: bump version to 0.1.4 [skip ci] 2026-03-29 12:16:07 +00:00
RunLit
fa90ef11b6 feat: MCP server, Bambu Cloud task sync & display name fix (#7)
* added mcp initial trail files

* timestamp use your local django timezone

* added bambu cloud task sync with correct endpoint other than py cloud api

* back fill and relink print name using cloud if there is

* use correct bump-version
2026-03-29 23:15:59 +11:00
github-actions[bot]
9a91b14593 chore: bump version to 0.1.3 [skip ci] 2026-03-29 05:10:50 +00:00
RNL
0b07221827 bump version in workflows 2026-03-29 16:09:49 +11:00
RNL
46902d7ec0 added bump version ci 2026-03-28 22:53:01 +11:00
RunLit
5c56711c57 Color base add support for transparent color (#5)
* added db model is transparent and fixed PETG translucent showing black

* js and filament form for transparent color

* bumped version to v0.1.2
2026-03-27 23:30:27 +11:00
RunLit
7e39d3e38d Native setup and Downsample data (#4)
* PrinterDataAPIView downsample

* filament usage chart now works without day constraint

* One command native setup

* add setup timezone verification and link

* added wipe off instructions

* setup default to port 80

* user selectable port number with default to 80

* skip superuser creation if exists

* auto install iptables if not available

* wipe out instructions updated
2026-03-07 16:53:33 +11:00
RunLit
217679421f version 0.1.1 2026-03-03 23:22:25 +11:00
62 changed files with 5557 additions and 554 deletions

View File

@@ -20,3 +20,4 @@ BAMBU_PASSWORD=your_password
# DEBUG=True
# DJANGO_SECRET_KEY=change-me-to-a-random-string
# ALLOWED_HOSTS=localhost,127.0.0.1
# CSRF_TRUSTED_ORIGINS="https://bambu-run.example.com,http://bambu-run.example.com"

56
.github/workflows/bump-version.yml vendored Normal file
View File

@@ -0,0 +1,56 @@
name: Bump Patch Version on Merge to Main
on:
push:
branches:
- main
jobs:
bump-version:
runs-on: ubuntu-latest
# Skip if this push was itself the version bump commit (prevents infinite loop)
if: "!contains(github.event.head_commit.message, '[skip ci]')"
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
# Need full git history and ability to push
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Bump patch version in pyproject.toml
id: bump
run: |
# Read current version
CURRENT=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
echo "Current version: $CURRENT"
# Split into parts and increment patch
MAJOR=$(echo $CURRENT | cut -d. -f1)
MINOR=$(echo $CURRENT | cut -d. -f2)
PATCH=$(echo $CURRENT | cut -d. -f3)
NEW_PATCH=$((PATCH + 1))
NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
echo "New version: $NEW_VERSION"
# Write back to pyproject.toml
sed -i "s/^version = \"$CURRENT\"/version = \"$NEW_VERSION\"/" pyproject.toml
# Export for later steps
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Commit and push bumped version
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add pyproject.toml
git commit -m "chore: bump version to ${{ steps.bump.outputs.new_version }} [skip ci]"
git push
- name: Tag the release
run: |
git tag "v${{ steps.bump.outputs.new_version }}"
git push origin "v${{ steps.bump.outputs.new_version }}"

View File

@@ -24,7 +24,7 @@ RUN pip install --no-cache-dir bambu-lab-cloud-api --no-deps && \
# Install project and remaining dependencies (pip sees opencv-python already satisfied)
COPY pyproject.toml .
RUN pip install --no-cache-dir ".[standalone]"
RUN pip install --no-cache-dir ".[standalone,mcp]"
# Copy application code
COPY . .
@@ -40,5 +40,6 @@ RUN python standalone/manage.py collectstatic --noinput 2>/dev/null || true
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
EXPOSE 8000
EXPOSE 8808
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

179
README.md
View File

@@ -22,151 +22,144 @@ It runs quietly in the background 24/7, capturing every print, filament change,
## Table of Contents
- [Quick Start: One-Click Docker Setup — Beginner Friendly](#quick-start-one-click-docker-setup--beginner-friendly)
- [Native Setup (Recommended for Raspberry Pi)](#native-setup-recommended-for-raspberry-pi)
- [What You'll Need](#what-youll-need)
- [Step 1: Connect to Your Raspberry Pi](#step-1-connect-to-your-raspberry-pi)
- [Step 2: Install Docker](#step-2-install-docker)
- [Step 3: Download and Configure](#step-3-download-and-configure)
- [Step 4: Build the Container](#step-4-build-the-container)
- [Step 5: First-Time Authentication](#step-5-first-time-authentication)
- [Step 6: Start Bambu-Run and Create Your Login](#step-6-start-bambu-run-and-create-your-login)
- [Step 7: Open the Dashboard](#step-7-open-the-dashboard)
- [Troubleshooting](#troubleshooting)
- [Clone and run setup.sh](#clone-and-run-setupsh)
- [Managing Bambu-Run](#managing-bambu-run)
- [Troubleshooting (Native)](#troubleshooting-native)
- [Docker Setup](#docker-setup)
- [Batch Importing Filament Colors and Filament Types](#batch-importing-filament-colors-and-filament-types)
---
## Quick Start: One-Click Docker Setup — Beginner Friendly
## Native Setup (Recommended for Raspberry Pi)
Get Bambu-Run running on a **Raspberry Pi** in minutes. No prior server experience needed.
No Docker required. Works on any Raspberry Pi (including 32-bit Pi Model B) running Raspberry Pi OS with Python 3.10+.
### What You'll Need
- A Raspberry Pi (3B+, 4, or 5) running Raspberry Pi OS 64-bit, with a 32 GB+ MicroSD card, connected to your network
- Your Bambu Lab printer on the **same local network**
- Your Bambu Lab account **email and password**
- A computer to SSH into the Pi
- Raspberry Pi on your local network (Python 3.10+)
- Bambu Lab printer
- Bambu Lab account **email and password**
### Step 1: Connect to Your Raspberry Pi
From your computer, open a terminal (Mac/Linux) or PowerShell (Windows):
### Clone and run setup.sh
```bash
ssh pi@raspberrypi.local
git clone https://github.com/RunLit/Bambu-Run.git
cd Bambu-Run
bash setup.sh
```
> Can't connect? Use your Pi's IP address (find it in your router's admin page). Default password: `raspberry`
That's it! The script handles everything interactively, just answer the prompts. When it finishes, open `http://<ip>` from any device on same network.
### Step 2: Install Docker
The script is safe to re-run at any time.
---
**What the script does**:
- **Dependencies**: creates a Python virtual environment, installs all packages
- **Credentials**: prompts for your **BambuLab Cloud account** email, password, and timezone; auto-generates a `DJANGO_SECRET_KEY`; writes `.env`
- **Bambu Cloud auth**: runs `bambu_collector --once`;
- Bambu Lab will send a 6-digit code to your email; check you email box and enter it when prompted;
- the resulting token is saved to `.env` automatically; future restarts skip this step
- **Dashboard login**: runs `createsuperuser`; choose a username and password for Bambu-Run web UI log in
- **Services**: installs and starts two systemd services (`bambu-run-web` and `bambu-run-collector`), enables linger so they auto-start on boot
- **Port 80**: sets an `iptables` redirect (80 to 8000) so you can reach the dashboard at a plain `http://<pi-ip>` with no port number; persisted via `iptables-persistent` across reboots.
---
### Managing Bambu-Run
All commands manage Bambu-Run encapsulated in `./native/bambu-run.sh`. Alternatively, you can do it yourself with systemctl commands.
```bash
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER
./native/bambu-run.sh status # service status
./native/bambu-run.sh logs # tail live logs (Ctrl+C to stop)
./native/bambu-run.sh restart # restart both services
./native/bambu-run.sh stop # stop everything
./native/bambu-run.sh update # git pull + pip install + migrate + restart
```
Log out and back in for the change to take effect, then verify:
### Troubleshooting (Native)
**Services die when SSH disconnects:** `sudo loginctl enable-linger $USER`
**Services not starting:** `./native/bambu-run.sh status` and `./native/bambu-run.sh logs`
**Auth errors / token expired:** Remove `BAMBU_TOKEN` from `.env` and re-run `bash setup.sh`
**Uninstall:**
```bash
exit
systemctl --user disable --now bambu-run-web bambu-run-collector
rm ~/.config/systemd/user/bambu-run-{web,collector}.service
systemctl --user daemon-reload
```
**Wipe everything and start over:**
```bash
ssh pi@raspberrypi.local
docker --version # should show Docker version 27.x.x
# Stop and remove services
systemctl --user stop bambu-run-web bambu-run-collector
systemctl --user disable bambu-run-web bambu-run-collector
rm ~/.config/systemd/user/bambu-run-{web,collector}.service
systemctl --user daemon-reload
# Remove port redirect (replace 80 with whatever port you chose during setup)
sudo iptables -t nat -D PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 8000 2>/dev/null || true
sudo iptables -t nat -D OUTPUT -o lo -p tcp --dport 80 -j REDIRECT --to-port 8000 2>/dev/null || true
sudo netfilter-persistent save 2>/dev/null || true
# Delete repo — wipes venv, database, and .env
cd ~
rm -rf ~/Bambu-Run
# Re-clone and run setup from scratch
git clone https://github.com/RunLit/Bambu-Run.git
cd Bambu-Run
bash setup.sh
```
> Installation issues? See: https://docs.docker.com/engine/install/raspberry-pi-os/
---
### Step 3: Download and Configure
## Docker Setup
Requires Docker and Docker Compose installed. Assumes you already know how to get there.
**Clone and configure:**
```bash
git clone https://github.com/RunLit/Bambu-Run.git
cd Bambu-Run
cp .env.example .env
nano .env
# Edit .env: set BAMBU_USERNAME, BAMBU_PASSWORD, TIMEZONE
```
Fill in your Bambu Lab credentials:
```
BAMBU_USERNAME=your_email@example.com
BAMBU_PASSWORD=your_password
TIMEZONE=Australia/Melbourne # optional — find yours at https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
```
Save: `Ctrl + X`, `Y`, `Enter`
### Step 4: Build the Container
**First-time auth** (Bambu Lab sends a 6-digit verification code to your email):
```bash
docker compose build
```
This takes a few minutes the first time — it downloads all required software.
### Step 5: First-Time Authentication
Bambu Lab requires email verification on first login. Run these two commands:
```bash
docker compose run --rm bambu-run python standalone/manage.py migrate --noinput
docker compose run --rm bambu-run python standalone/manage.py bambu_collector --once
# Paste the printed token into .env as BAMBU_TOKEN=...
```
When prompted, enter the 6-digit code sent to your email. On success you'll see a token printed — copy it and add it to your `.env`:
```bash
nano .env
```
```
BAMBU_TOKEN=eyJhbGciOiJIUzI1N...paste_full_token_here
```
> Saving the token lets future restarts skip re-verification automatically.
### Step 6: Start Bambu-Run and Create Your Login
**Start and create your dashboard login:**
```bash
docker compose up -d
docker compose exec bambu-run python standalone/manage.py createsuperuser
```
Choose a username and password — this is your dashboard login.
Dashboard is at `http://<host-ip>:8000`.
### Step 7: Open the Dashboard
**Common operations:**
On any device on your network, open a browser and go to:
```
http://raspberrypi.local:8000
```
> If that doesn't work, use your Pi's IP: `http://<pi-ip-address>:8000`
Log in with the account you just created. Your printer dashboard should be live.
### Troubleshooting
**No data / cannot connect to printer:** Make sure the printer is on and on the same network. Check logs: `docker compose logs -f`. If you see auth errors, re-run Step 5 to get a fresh token.
**401 Unauthorized / verification loop:** Remove `BAMBU_TOKEN` from `.env` and re-run Step 5.
**Docker daemon error:** Log out and back in after Step 2 — the group change requires a new session.
**Dashboard not loading:** Run `docker compose ps` to confirm the service is `Up`, then try the Pi's IP address directly.
**Update Bambu-Run:**
```bash
cd ~/Bambu-Run && git pull && docker compose up -d --build
docker compose logs -f # live logs
docker compose down # stop (data preserved in volume)
git pull && docker compose up -d --build # update
```
**Stop Bambu-Run:**
```bash
docker compose down
```
Your data is preserved in a Docker volume and will be there when you start it again.
**Troubleshooting:** Auth errors → remove `BAMBU_TOKEN` from `.env` and re-run the auth step. No data → check `docker compose logs -f` for MQTT connection errors.
---

View File

@@ -1,5 +1,5 @@
from django.contrib import admin
from .models import Printer, PrinterMetrics, Filament, FilamentType, FilamentSnapshot, PrintJob, FilamentUsage
from .models import Printer, PrinterMetrics, Filament, FilamentType, FilamentSnapshot, PrintJob, FilamentUsage, BambuCloudTask, Hotend, HotendSnapshot
@admin.register(Printer)
@@ -105,3 +105,36 @@ class FilamentUsageAdmin(admin.ModelAdmin):
list_display = ('print_job', 'filament', 'tray_id', 'consumed_percent', 'consumed_grams', 'is_primary')
list_filter = ('is_primary', 'tray_id')
readonly_fields = ('consumed_percent', 'consumed_grams')
@admin.register(Hotend)
class HotendAdmin(admin.ModelAdmin):
list_display = ('printer', 'serial_number', 'nozzle_type', 'is_toolhead', 'slot_number', 'used_time_seconds', 'wear_percent', 'last_seen_at')
list_filter = ('printer', 'is_toolhead', 'nozzle_type')
search_fields = ('serial_number',)
readonly_fields = ('last_seen_at', 'created_at')
@admin.register(HotendSnapshot)
class HotendSnapshotAdmin(admin.ModelAdmin):
list_display = ('printer_metric', 'hotend', 'raw_id', 'used_time_seconds', 'wear_percent', 'timestamp')
list_filter = ('hotend',)
readonly_fields = ('printer_metric', 'hotend', 'raw_id', 'used_time_seconds', 'wear_percent', 'stat', 'timestamp')
@admin.register(BambuCloudTask)
class BambuCloudTaskAdmin(admin.ModelAdmin):
list_display = ('task_id', 'design_title', 'plate_title', 'device_serial', 'cloud_status', 'weight_grams', 'cloud_start_time', 'synced_at')
list_filter = ('cloud_status', 'use_ams', 'bed_type')
search_fields = ('design_title', 'plate_title', 'device_serial', 'task_id')
readonly_fields = ('task_id', 'synced_at', 'raw_data')
date_hierarchy = 'cloud_start_time'
fieldsets = (
('Identity', {'fields': ('task_id', 'design_id', 'design_title', 'plate_title', 'model_id', 'profile_id', 'plate_index')}),
('Device & Print', {'fields': ('device_serial', 'cloud_status', 'bed_type', 'use_ams', 'print_mode')}),
('Filament', {'fields': ('weight_grams', 'length_mm', 'ams_detail_mapping')}),
('Times', {'fields': ('cloud_start_time', 'cloud_end_time', 'cost_time_seconds', 'synced_at')}),
('Media', {'fields': ('cover_url',)}),
('Raw', {'fields': ('raw_data',), 'classes': ('collapse',)}),
)

121
bambu_run/bambu_cloud.py Normal file
View File

@@ -0,0 +1,121 @@
"""
Thin wrapper around the Bambu Cloud HTTP API using verified endpoints only.
Uses BambuClient as the transport (auth headers, base URL) but bypasses
the package's named methods, which contain guessed/unverified endpoints.
All functions take a BambuClient instance as first argument.
"""
import logging
from datetime import timezone as dt_timezone
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Verified HTTP wrappers
# ---------------------------------------------------------------------------
def get_tasks(client, limit=20, offset=0):
"""Fetch recent cloud tasks. Returns the raw response dict."""
return client.get('v1/user-service/my/tasks', params={'limit': limit, 'offset': offset})
def get_profile(client):
"""Fetch the authenticated user's profile."""
return client.get('v1/user-service/my/profile')
# ---------------------------------------------------------------------------
# Upsert helpers
# ---------------------------------------------------------------------------
def _parse_cloud_dt(value):
"""Parse an ISO-8601 string like '2026-03-28T12:38:29Z' to aware datetime."""
if not value:
return None
from django.utils.dateparse import parse_datetime
from django.utils import timezone
dt = parse_datetime(value)
if dt and dt.tzinfo is None:
dt = dt.replace(tzinfo=dt_timezone.utc)
return dt
def upsert_cloud_task(task_dict):
"""
Parse one task dict from the cloud API and upsert into BambuCloudTask.
Returns the (BambuCloudTask instance, created bool) tuple.
"""
from .models import BambuCloudTask
task_id = task_dict.get('id')
if not task_id:
raise ValueError("task_dict has no 'id' field")
defaults = {
'design_id': task_dict.get('designId') or None,
'design_title': task_dict.get('designTitle') or '',
'plate_title': task_dict.get('title') or '',
'model_id': task_dict.get('modelId') or '',
'profile_id': task_dict.get('profileId') or None,
'plate_index': task_dict.get('plateIndex'),
'device_serial': task_dict.get('deviceId') or '',
'cover_url': task_dict.get('cover') or '',
'weight_grams': task_dict.get('weight'),
'length_mm': task_dict.get('length'),
'cost_time_seconds': task_dict.get('costTime'),
'cloud_status': task_dict.get('status'),
'bed_type': task_dict.get('bedType') or '',
'use_ams': bool(task_dict.get('useAms', True)),
'print_mode': task_dict.get('mode') or '',
'ams_detail_mapping': task_dict.get('amsDetailMapping') or [],
'cloud_start_time': _parse_cloud_dt(task_dict.get('startTime')),
'cloud_end_time': _parse_cloud_dt(task_dict.get('endTime')),
'raw_data': task_dict,
}
return BambuCloudTask.objects.update_or_create(task_id=task_id, defaults=defaults)
def fetch_and_upsert_task(client, print_job):
"""
Called by bambu_collector at print finalization.
Fetches recent tasks from cloud, finds the one matching print_job.cloud_task_id_raw,
upserts BambuCloudTask, and wires up the FK on print_job.
Non-fatal: all errors are logged as warnings only.
"""
if not print_job.cloud_task_id_raw:
logger.debug(f"Job #{print_job.id} has no cloud_task_id_raw — skipping cloud sync")
return
try:
response = get_tasks(client, limit=20)
hits = response.get('hits', response.get('tasks', []))
except Exception as e:
logger.warning(f"Cloud tasks fetch failed for job #{print_job.id}: {e}")
return
target = next((t for t in hits if t.get('id') == print_job.cloud_task_id_raw), None)
if not target:
logger.warning(
f"Job #{print_job.id}: cloud task {print_job.cloud_task_id_raw} "
f"not found in last {len(hits)} tasks from API"
)
return
try:
cloud_task, created = upsert_cloud_task(target)
print_job.cloud_task = cloud_task
print_job.save(update_fields=['cloud_task'])
action = 'created' if created else 'updated'
logger.info(
f"Job #{print_job.id}: cloud task {print_job.cloud_task_id_raw} {action} "
f"— design_title={cloud_task.design_title!r}"
)
except Exception as e:
logger.warning(f"Cloud task upsert failed for job #{print_job.id}: {e}")

View File

@@ -51,5 +51,35 @@ class _Settings:
def AUTO_CREATE_BRAND(self):
return get_setting("BAMBU_RUN_AUTO_CREATE_BRAND", "Bambu Lab")
# MCP Server settings
@property
def MCP_API_KEY(self):
return get_setting("BAMBU_RUN_MCP_API_KEY", None)
@property
def MCP_HOST(self):
return get_setting("BAMBU_RUN_MCP_HOST", "0.0.0.0")
@property
def MCP_PORT(self):
return get_setting("BAMBU_RUN_MCP_PORT", 8808)
@property
def MCP_AUTH_BACKEND(self):
return get_setting("BAMBU_RUN_MCP_AUTH_BACKEND", None)
@property
def MCP_HIDE_SENSITIVE(self):
return get_setting("BAMBU_RUN_MCP_HIDE_SENSITIVE", False)
# Cloud sync settings
@property
def CLOUD_SYNC_ENABLED(self):
return get_setting("BAMBU_RUN_CLOUD_SYNC_ENABLED", True)
@property
def CLOUD_SYNC_DAYS(self):
return get_setting("BAMBU_RUN_CLOUD_SYNC_DAYS", 30)
app_settings = _Settings()

78
bambu_run/diagnostics.py Normal file
View File

@@ -0,0 +1,78 @@
"""
Pure helpers for the `bambu_diagnose` management command.
Kept separate from the command itself (and free of Django/network imports)
so the report-building and redaction logic can be unit-tested without
talking to the real Bambu Lab cloud or MQTT broker.
"""
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
# Keys whose values are always replaced outright, regardless of nesting depth.
_SECRET_KEY_SUBSTRINGS = ("password", "token", "secret", "access_code", "authorization")
# Keys that identify a specific physical device/spool/account — not secret,
# but identifying, so they're partially masked by default before anything
# gets pasted into a public GitHub issue.
_IDENTIFIER_KEYS = {"dev_id", "device_id", "serial_number", "tray_uuid", "tag_uid", "uid"}
def _mask_identifier(value: Any) -> Any:
if not isinstance(value, str) or len(value) <= 8:
return "***"
return f"{value[:4]}...{value[-4:]}"
def redact_diagnostics(data: Any, redact: bool = True) -> Any:
"""Recursively redact secrets and mask identifiers in a diagnostics payload.
`redact=False` returns the data unchanged — only for the reporter's own
local debugging, never for anything posted publicly.
"""
if not redact:
return data
return _redact(data)
def _redact(obj: Any) -> Any:
if isinstance(obj, dict):
result = {}
for key, value in obj.items():
lowered = key.lower()
if any(secret in lowered for secret in _SECRET_KEY_SUBSTRINGS):
result[key] = "***REDACTED***"
elif lowered in _IDENTIFIER_KEYS:
result[key] = _mask_identifier(value)
else:
result[key] = _redact(value)
return result
if isinstance(obj, list):
return [_redact(item) for item in obj]
return obj
def build_diagnostics_report(
devices: List[Dict[str, Any]],
raw_payloads: Dict[str, Optional[Dict[str, Any]]],
) -> Dict[str, Any]:
"""Assemble the (pre-redaction) diagnostics report from discovered devices
and whatever raw MQTT payload was captured for each during the listen window.
"""
device_entries = []
for device in devices:
dev_id = device.get("dev_id")
payload = raw_payloads.get(dev_id)
entry = {
"device_info": device,
"raw_mqtt_payload": payload,
}
if payload is None:
entry["note"] = "No MQTT data received within the listen window."
device_entries.append(entry)
return {
"generated_at": datetime.now(timezone.utc).isoformat(),
"device_count": len(devices),
"devices": device_entries,
}

View File

@@ -52,10 +52,10 @@ class FilamentForm(forms.ModelForm):
model = Filament
fields = [
'tray_uuid', 'tag_uid', 'tag_id', 'created_by',
'filament_type', 'type', 'sub_type', 'brand', 'color', 'color_hex',
'filament_type', 'type', 'sub_type', 'brand', 'color', 'color_hex', 'is_transparent',
'diameter', 'initial_weight_grams',
'remaining_percent', 'remaining_weight_grams',
'is_loaded_in_ams', 'current_tray_id',
'is_loaded_in_ams', 'current_tray_id', 'ams_unit_id', 'ams_type',
'purchase_date', 'purchase_price', 'supplier', 'notes'
]
widgets = {
@@ -71,10 +71,10 @@ class FilamentForm(forms.ModelForm):
}),
'tag_id': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Optional - User-defined ID'}),
'created_by': forms.Select(attrs={'class': 'form-select'}),
'filament_type': forms.Select(attrs={'class': 'form-select'}),
'type': forms.HiddenInput(),
'sub_type': forms.HiddenInput(),
'brand': forms.HiddenInput(),
'filament_type': forms.Select(attrs={'class': 'form-select', 'id': 'id_filament_type'}),
'type': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g., PLA, PETG, ABS'}),
'sub_type': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g., PLA Basic (optional)'}),
'brand': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g., Bambu Lab'}),
'color': forms.Select(attrs={'class': 'form-select', 'id': 'id_color'}),
'color_hex': forms.TextInput(attrs={
'class': 'form-control',
@@ -85,8 +85,17 @@ class FilamentForm(forms.ModelForm):
'initial_weight_grams': forms.NumberInput(attrs={'class': 'form-control', 'placeholder': '1000'}),
'remaining_percent': forms.NumberInput(attrs={'class': 'form-control', 'min': '0', 'max': '100'}),
'remaining_weight_grams': forms.NumberInput(attrs={'class': 'form-control', 'readonly': 'readonly'}),
'is_transparent': forms.CheckboxInput(attrs={'class': 'form-check-input', 'id': 'id_is_transparent'}),
'is_loaded_in_ams': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'current_tray_id': forms.NumberInput(attrs={'class': 'form-control', 'min': '0', 'max': '3'}),
'current_tray_id': forms.NumberInput(attrs={
'class': 'form-control', 'min': '0', 'max': '15',
'placeholder': '03 for AMS / AMS 2 Pro, 0 for AMS HT',
}),
'ams_unit_id': forms.NumberInput(attrs={
'class': 'form-control', 'min': '0', 'max': '255',
'placeholder': 'AMS unit id (0,1,… or 128 for AMS HT)',
}),
'ams_type': forms.Select(attrs={'class': 'form-select'}),
'purchase_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
'purchase_price': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'supplier': forms.TextInput(attrs={'class': 'form-control'}),
@@ -105,6 +114,8 @@ class FilamentForm(forms.ModelForm):
self.fields['type'].required = False
self.fields['sub_type'].required = False
self.fields['brand'].required = False
self.fields['ams_unit_id'].required = False
self.fields['ams_type'].required = False
self._populate_color_choices()

View File

@@ -13,8 +13,9 @@ import logging
import os
import ssl
import time
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Optional
from typing import Any, Dict, Optional
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
@@ -26,6 +27,56 @@ from bambu_run.models import Printer, PrinterMetrics
logger = logging.getLogger("bambu_run.collector")
def resolve_printer_device(device_id: str, device_info: Optional[dict] = None) -> Printer:
"""Find-or-create the Printer row for a Bambu cloud device, keyed by serial number.
`device_info` is one entry from BambuClient.get_devices() (keys: name,
dev_product_name, dev_id, ...). Falls back to generic defaults when unavailable
(e.g. local-only connections that never call get_devices()).
"""
device_info = device_info or {}
name = device_info.get("name") or "Bambu Lab Printer"
model = device_info.get("dev_product_name") or "Bambu Lab"
printer = Printer.objects.filter(serial_number=device_id).first()
if printer is None:
# Upgrade path: a pre-multi-printer deployment has exactly one Printer row
# with no serial number yet. Backfill it instead of creating a duplicate.
# If there's more than one such row, we can't tell which one this device
# used to be, so don't guess — create a fresh row instead.
legacy_candidates = list(Printer.objects.filter(serial_number__isnull=True)[:2])
if len(legacy_candidates) == 1:
printer = legacy_candidates[0]
printer.serial_number = device_id
if printer is None:
printer = Printer(serial_number=device_id)
printer.name = name
printer.model = model
printer.manufacturer = "Bambu Lab"
printer.is_active = True
printer.save()
return printer
@dataclass
class DeviceSession:
"""Per-printer mutable state for one bound device in a multi-printer collector run."""
device_id: str
client: Any # BambuPrinter
printer: Printer
current_print_job: Optional[Any] = None
last_gcode_state: Optional[str] = None
last_subtask_name: Optional[str] = None
trays_used: set = field(default_factory=set)
error_count: int = 0
success_count: int = 0
mqtt_connect_errors: int = 0
class Command(BaseCommand):
"""
MQTT Poll -> PrinterMetrics -> FilamentSnapshot -> Auto-Match -> Update Filament
@@ -51,18 +102,11 @@ class Command(BaseCommand):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.printer_client = None
self.printer_device = None
self.sessions: Dict[str, DeviceSession] = {}
self._token: Optional[str] = None
self.verbose = False
self.disable_ssl_verify = False
self.error_count = 0
self.success_count = 0
self.mqtt_connect_errors = 0
self.start_time = None
self.current_print_job = None
self.last_gcode_state = None
self.last_subtask_name = None
self.trays_used = set()
def handle(self, *args, **options):
self.verbose = options["verbose"]
@@ -100,18 +144,22 @@ class Command(BaseCommand):
self._configure_logging()
try:
self._initialize_printer()
self._initialize_printers()
except Exception as e:
raise CommandError(f"Initialization failed: {e}")
self.start_time = timezone.now()
logger.info(f"Bambu Run data collector started for printer: {self.printer_device.name}")
printer_names = ", ".join(s.printer.name for s in self.sessions.values())
logger.info(f"Bambu Run data collector started for {len(self.sessions)} printer(s): {printer_names}")
logger.info(f"Collection interval: {interval} seconds")
logger.info(f"Mode: {'Single run' if run_once else 'Continuous'}")
try:
if run_once:
self._collect_printer_data()
import time as _time
_time.sleep(5)
for session in self.sessions.values():
self._collect_printer_data(session)
logger.info("Single collection completed successfully")
else:
self._run_continuous_loop(interval)
@@ -122,6 +170,24 @@ class Command(BaseCommand):
logger.exception(f"Fatal error in main loop: {e}")
raise CommandError(f"Runner failed: {e}")
def _request_full_status_when_ready(self, client, timeout: float = 20.0) -> None:
"""Send pushall once the MQTT broker connection is confirmed.
BambuPrinter._connected is set True immediately after connect(blocking=False),
before the broker handshake. Poll MQTTClient.connected (set in _on_connect)
instead, so publish() won't raise "Not connected to broker".
"""
import time as _time
deadline = _time.time() + timeout
while _time.time() < deadline:
mqtt_client = getattr(client, "_mqtt", None)
if mqtt_client is not None and getattr(mqtt_client, "connected", False):
client._mqtt.request_full_status()
logger.info("Sent MQTT pushall request")
return
_time.sleep(0.5)
logger.warning("MQTT broker connection not confirmed within %.1fs; skipping pushall", timeout)
def _configure_logging(self):
log_level = logging.DEBUG if self.verbose else logging.INFO
logger.setLevel(log_level)
@@ -135,7 +201,9 @@ class Command(BaseCommand):
handler.setFormatter(formatter)
logger.addHandler(handler)
def _initialize_printer(self):
def _initialize_printers(self):
"""Authenticate once, discover every device bound to the account, and open
one BambuPrinter (own MQTT thread) per device — all in this single process."""
from bambu_run.mqtt_client import BambuPrinter
bambu_username = os.environ.get("BAMBU_USERNAME")
@@ -149,25 +217,12 @@ class Command(BaseCommand):
"environment variables must be set"
)
logger.info("Connecting to Bambu Lab printer...")
logger.info("Authenticating with Bambu Lab cloud...")
try:
if bambu_token:
logger.info("Using saved BAMBU_TOKEN for authentication")
self.printer_client = BambuPrinter(
token=bambu_token, device_id=bambu_device_id
)
else:
logger.info("Authenticating with username/password")
self.printer_client = BambuPrinter(
username=bambu_username,
password=bambu_password,
device_id=bambu_device_id,
)
logger.info("Initiating MQTT connection...")
self.printer_client.connect(blocking=False)
logger.info("MQTT connection initiated (non-blocking)")
auth = BambuPrinter(
username=bambu_username, password=bambu_password, token=bambu_token,
)
self._token = auth._ensure_token()
except Exception as e:
if "CERTIFICATE_VERIFY_FAILED" in str(e) or "SSL" in str(e):
error_msg = (
@@ -178,56 +233,62 @@ class Command(BaseCommand):
"3. pip install --upgrade certifi\n"
)
raise CommandError(error_msg)
raise CommandError(f"Failed to initialize printer client: {e}")
raise CommandError(f"Failed to authenticate: {e}")
self.printer_device = self._ensure_printer_device_exists()
logger.info(f"Initialized for printer device: {self.printer_device}")
def _ensure_printer_device_exists(self) -> Printer:
try:
snapshot = self.printer_client.get_snapshot()
if snapshot:
device, created = Printer.objects.update_or_create(
model="Bambu Lab",
defaults={
"name": "Bambu Lab Printer",
"manufacturer": "Bambu Lab",
"is_active": True,
},
)
action = "Created" if created else "Updated"
logger.info(f"{action} printer device record: {device}")
return device
else:
logger.warning("Snapshot returned None - MQTT not connected yet")
device = Printer.objects.filter(is_active=True).first()
if device:
logger.info(f"Using existing device record: {device}")
return device
else:
device = Printer.objects.create(
name="Bambu Lab Printer",
model="Bambu Lab",
manufacturer="Bambu Lab",
is_active=True,
)
logger.info(f"Created placeholder device: {device}")
return device
except Exception as e:
logger.error(f"Error during device initialization: {e}")
device_infos = self._discover_devices(bambu_device_id)
for device_id, device_info in device_infos.items():
try:
device = Printer.objects.filter(is_active=True).first()
if device:
logger.warning(f"Using existing device record from DB: {device}")
return device
else:
raise CommandError(
"No printer device found in database and initialization failed."
)
except Printer.DoesNotExist:
raise CommandError("Failed to create or retrieve printer device.")
self._add_session(device_id, device_info)
except Exception as e:
logger.error(f"Failed to initialize printer {device_id}: {e}")
if not self.sessions:
raise CommandError("No printer sessions could be initialized")
def _discover_devices(self, explicit_device_id: Optional[str]) -> Dict[str, dict]:
"""Return {device_id: device_info} for every printer to monitor.
device_info comes from BambuClient.get_devices() (name, dev_product_name,
etc.) — empty dict when explicitly pinned to one device via BAMBU_DEVICE_ID
and the cloud listing can't be reached.
"""
from bambu_run.mqtt_client import BambuClient
try:
cloud = BambuClient(token=self._token)
devices = cloud.get_devices()
except Exception as e:
if explicit_device_id:
logger.warning(f"Could not list account devices ({e}); using BAMBU_DEVICE_ID only")
return {explicit_device_id: {}}
raise
device_infos = {d.get("dev_id"): d for d in devices if d.get("dev_id")}
if explicit_device_id:
return {explicit_device_id: device_infos.get(explicit_device_id, {})}
if not device_infos:
raise CommandError("No devices found on this account")
return device_infos
def _add_session(self, device_id: str, device_info: dict) -> "DeviceSession":
from bambu_run.mqtt_client import BambuPrinter
logger.info(f"Connecting to printer {device_id} ({device_info.get('name', 'unknown')})...")
client = BambuPrinter(token=self._token, device_id=device_id)
client.connect(blocking=False)
try:
self._request_full_status_when_ready(client)
except Exception as e:
logger.warning("pushall request skipped (non-fatal): %s", e)
printer = resolve_printer_device(device_id, device_info)
session = DeviceSession(device_id=device_id, client=client, printer=printer)
self.sessions[device_id] = session
logger.info(f"Initialized session for printer: {printer}")
return session
def _run_continuous_loop(self, interval: int):
iteration = 0
@@ -238,7 +299,8 @@ class Command(BaseCommand):
if self.verbose:
logger.debug(f"=== Iteration {iteration} ===")
self._collect_printer_data()
for session in list(self.sessions.values()):
self._collect_printer_data(session)
elapsed = time.time() - loop_start
sleep_time = max(0, interval - elapsed)
@@ -248,9 +310,28 @@ class Command(BaseCommand):
if iteration % 100 == 0:
self._print_statistics()
self._refresh_devices()
time.sleep(sleep_time)
def _refresh_devices(self):
"""Pick up printers added to the account without restarting the process."""
if os.environ.get("BAMBU_DEVICE_ID"):
return # pinned to a single explicit device — nothing to discover
try:
device_infos = self._discover_devices(None)
except Exception as e:
logger.warning(f"Device refresh skipped (non-fatal): {e}")
return
for device_id, device_info in device_infos.items():
if device_id not in self.sessions:
logger.info(f"New printer detected on account: {device_id}")
try:
self._add_session(device_id, device_info)
except Exception as e:
logger.error(f"Failed to initialize newly-detected printer {device_id}: {e}")
def _convert_mqtt_color(self, mqtt_color):
if not mqtt_color:
return None
@@ -316,7 +397,7 @@ class Command(BaseCommand):
def _auto_create_filament(self, tray_data):
from bambu_run.models import Filament, FilamentType
from bambu_run.utils import strip_color_padding, match_filament_color
from bambu_run.utils import strip_color_padding, match_filament_color, is_mqtt_color_transparent
tray_uuid = tray_data.get('tray_uuid')
tag_uid = tray_data.get('tag_uid')
@@ -329,6 +410,7 @@ class Command(BaseCommand):
default_brand = app_settings.AUTO_CREATE_BRAND
transparent = is_mqtt_color_transparent(mqtt_color)
color_code = strip_color_padding(mqtt_color)
color_hex = f"#{color_code}" if color_code else None
@@ -341,6 +423,7 @@ class Command(BaseCommand):
if filament_color:
color_name = filament_color.color_name
transparent = transparent or filament_color.is_transparent
if self.verbose:
logger.info(f"Matched color from database: {color_name} (#{color_code})")
else:
@@ -368,12 +451,15 @@ class Command(BaseCommand):
brand=default_brand,
color=color_name,
color_hex=color_hex,
is_transparent=transparent,
diameter=diameter,
initial_weight_grams=initial_weight,
remaining_percent=remain_percent,
created_by='Auto Detection',
is_loaded_in_ams=True,
current_tray_id=tray_data.get('tray_id'),
ams_unit_id=tray_data.get('ams_unit_id'),
ams_type=tray_data.get('ams_type', '') or '',
last_loaded_date=timezone.now(),
)
@@ -387,9 +473,13 @@ class Command(BaseCommand):
return filament
def _update_filament_status(self, filament, tray_id, remain_percent):
def _update_filament_status(self, filament, tray_id, remain_percent, tray_data=None):
from bambu_run.models import Filament
tray_data = tray_data or {}
ams_unit_id = tray_data.get('ams_unit_id')
ams_type_label = tray_data.get('ams_type', '') or ''
if filament.remaining_percent != remain_percent:
filament.remaining_percent = remain_percent
filament.update_remaining_weight()
@@ -397,10 +487,19 @@ class Command(BaseCommand):
if self.verbose:
logger.debug(f"Updated filament {filament}: {remain_percent}%")
if not filament.is_loaded_in_ams or filament.current_tray_id != tray_id:
previous_filament = Filament.objects.filter(
location_changed = (
not filament.is_loaded_in_ams
or filament.current_tray_id != tray_id
or (ams_unit_id is not None and filament.ams_unit_id != ams_unit_id)
)
if location_changed:
# Unload anything previously occupying THIS exact (unit, tray) slot.
unload_qs = Filament.objects.filter(
is_loaded_in_ams=True, current_tray_id=tray_id
).exclude(id=filament.id).first()
).exclude(id=filament.id)
if ams_unit_id is not None:
unload_qs = unload_qs.filter(ams_unit_id=ams_unit_id)
previous_filament = unload_qs.first()
if previous_filament:
previous_filament.is_loaded_in_ams = False
@@ -408,14 +507,21 @@ class Command(BaseCommand):
previous_filament.save()
logger.info(
f"Auto-unloaded {previous_filament} from Tray {tray_id} "
f"(replaced by {filament.brand} {filament.type} - {filament.color})"
f"(unit {ams_unit_id}; replaced by {filament.brand} {filament.type} - {filament.color})"
)
filament.is_loaded_in_ams = True
filament.current_tray_id = tray_id
if ams_unit_id is not None:
filament.ams_unit_id = ams_unit_id
if ams_type_label:
filament.ams_type = ams_type_label
filament.last_loaded_date = timezone.now()
if self.verbose:
logger.debug(f"Updated filament location: Tray {tray_id}")
logger.debug(f"Updated filament location: unit={ams_unit_id} tray={tray_id}")
elif ams_type_label and filament.ams_type != ams_type_label:
# Same slot but ams_type was previously unknown — fill it in.
filament.ams_type = ams_type_label
filament.save()
@@ -436,15 +542,20 @@ class Command(BaseCommand):
if filament:
remain_percent = tray_data.get('remain_percent')
if remain_percent is not None:
self._update_filament_status(filament, tray_id, remain_percent)
self._update_filament_status(filament, tray_id, remain_percent, tray_data)
unit_id = str(int(tray_id) // 4) if tray_id.isdigit() else None
unit_data = ams_units.get(unit_id, {})
# Locate the AMS unit this tray belongs to. Use the unit_id supplied
# by the snapshot directly (matches MQTT ams[i].id, including 128 for AMS HT)
# — the legacy `tray_id // 4` math breaks for AMS HT.
unit_id_int = tray_data.get('ams_unit_id')
unit_data = ams_units.get(str(unit_id_int)) if unit_id_int is not None else {}
FilamentSnapshot.objects.create(
printer_metric=printer_metric,
filament=filament,
tray_id=tray_id,
ams_unit_id=unit_id_int,
ams_type=tray_data.get('ams_type', '') or '',
slot_name=tray_data.get('slot'),
type=tray_data.get('type'),
sub_type=tray_data.get('sub_type'),
@@ -460,122 +571,172 @@ class Command(BaseCommand):
match_method=match_method
)
def _track_print_job(self, metric, snapshot):
from bambu_run.models import PrintJob, FilamentUsage
def _update_hotends(self, printer, printer_metric, hotends_data):
from bambu_run.models import Hotend, HotendSnapshot
for h in hotends_data:
if h.get("is_empty"):
continue
hotend, _ = Hotend.objects.update_or_create(
printer=printer,
serial_number=h.get("serial_number"),
defaults={
"raw_id": h.get("raw_id", 0),
"nozzle_type": h.get("nozzle_type", ""),
"diameter": self._to_decimal(h.get("diameter")),
"slot_number": h.get("slot_number"),
"is_toolhead": bool(h.get("is_toolhead")),
"last_filament_profile_id": h.get("fila_id", ""),
"last_color": h.get("color") or "",
"used_time_seconds": h.get("used_time_seconds", 0),
"wear_percent": h.get("wear_percent", 0),
},
)
HotendSnapshot.objects.create(
printer_metric=printer_metric,
hotend=hotend,
raw_id=h.get("raw_id", 0),
used_time_seconds=h.get("used_time_seconds", 0),
wear_percent=h.get("wear_percent", 0),
stat=h.get("stat"),
)
def _track_print_job(self, session, metric, snapshot):
from bambu_run.models import PrintJob
gcode_state = snapshot.get('gcode_state')
subtask_name = snapshot.get('subtask_name')
if self._is_print_starting(gcode_state, subtask_name):
if self.current_print_job:
self._finalize_print_job(metric, snapshot)
if self._is_print_starting(session, gcode_state, subtask_name):
if session.current_print_job:
self._finalize_print_job(session, metric, snapshot)
self.current_print_job = PrintJob.objects.create(
device=self.printer_device,
raw_task_id = snapshot.get('task_id')
session.current_print_job = PrintJob.objects.create(
device=session.printer,
project_name=subtask_name,
gcode_file=snapshot.get('gcode_file'),
start_time=metric.timestamp,
start_metric=metric,
total_layers=snapshot.get('total_layer_num'),
completion_percent=snapshot.get('print_percent', 0)
completion_percent=snapshot.get('print_percent', 0),
cloud_task_id_raw=int(raw_task_id) if raw_task_id else None,
)
self.trays_used = set()
logger.info(f"Print job started: {subtask_name}")
session.trays_used = set()
logger.info(f"[{session.device_id}] Print job started: {subtask_name}")
if self.current_print_job:
if session.current_print_job:
tray_now = snapshot.get('tray_now', '')
if tray_now not in (None, '', '255'):
try:
tray_id = int(tray_now)
if 0 <= tray_id <= 15:
self.trays_used.add(tray_id)
session.trays_used.add(tray_id)
except (ValueError, TypeError):
pass
if self._is_print_ending(gcode_state) and self.current_print_job:
self._finalize_print_job(metric, snapshot)
if self._is_print_ending(session, gcode_state) and session.current_print_job:
self._finalize_print_job(session, metric, snapshot)
self.last_gcode_state = gcode_state
self.last_subtask_name = subtask_name
session.last_gcode_state = gcode_state
session.last_subtask_name = subtask_name
def _is_print_starting(self, gcode_state, subtask_name):
def _is_print_starting(self, session, gcode_state, subtask_name):
is_printing = gcode_state not in ['FINISH', 'IDLE', 'FAILED', None, '']
has_new_job = subtask_name and subtask_name != self.last_subtask_name
has_new_job = subtask_name and subtask_name != session.last_subtask_name
return is_printing and has_new_job
def _is_print_ending(self, gcode_state):
def _is_print_ending(self, session, gcode_state):
ending_states = ['FINISH', 'FAILED']
return gcode_state in ending_states and self.last_gcode_state not in ending_states
return gcode_state in ending_states and session.last_gcode_state not in ending_states
def _finalize_print_job(self, metric, snapshot):
def _finalize_print_job(self, session, metric, snapshot):
from bambu_run.models import FilamentUsage
self.current_print_job.end_time = metric.timestamp
self.current_print_job.end_metric = metric
self.current_print_job.final_status = snapshot.get('gcode_state')
self.current_print_job.completion_percent = snapshot.get('print_percent', 0)
self.current_print_job.calculate_duration()
self.current_print_job.save()
job = session.current_print_job
job.end_time = metric.timestamp
job.end_metric = metric
job.final_status = snapshot.get('gcode_state')
job.completion_percent = snapshot.get('print_percent', 0)
job.calculate_duration()
job.save()
start_metric = self.current_print_job.start_metric
try:
from bambu_run.bambu_cloud import fetch_and_upsert_task
fetch_and_upsert_task(session.client._client, job)
except Exception as e:
logger.warning(f"Cloud task sync skipped (non-fatal): {e}")
start_metric = job.start_metric
if not start_metric:
logger.warning(f"No start_metric for job {self.current_print_job.id}, skipping filament usage")
elif not self.trays_used:
logger.warning(f"No trays tracked for job {self.current_print_job.project_name}, skipping filament usage")
logger.warning(f"No start_metric for job {job.id}, skipping filament usage")
elif not session.trays_used:
logger.warning(f"No trays tracked for job {job.project_name}, skipping filament usage")
else:
for tray_id in self.trays_used:
start_snap = start_metric.filament_snapshots.filter(
# A bare tray_id (from `tray_now`) doesn't identify which physical AMS
# unit was active when multiple units share the same slot numbering —
# so create one usage row per (unit, tray) that had a tracked filament
# loaded at job start, rather than guessing a single "correct" unit.
created_usages = []
for tray_id in session.trays_used:
start_snaps = start_metric.filament_snapshots.filter(
tray_id=tray_id, filament__isnull=False
).first()
if not start_snap:
continue
end_snap = metric.filament_snapshots.filter(
filament=start_snap.filament, tray_id=tray_id
).first()
usage = FilamentUsage.objects.create(
print_job=self.current_print_job,
filament=start_snap.filament,
tray_id=tray_id,
starting_percent=start_snap.remain_percent or 100,
ending_percent=end_snap.remain_percent if end_snap else None,
is_primary=(len(self.trays_used) == 1),
)
usage.calculate_consumed()
for start_snap in start_snaps:
end_snap = metric.filament_snapshots.filter(
filament=start_snap.filament,
tray_id=tray_id,
ams_unit_id=start_snap.ams_unit_id,
).first()
usage = FilamentUsage.objects.create(
print_job=job,
filament=start_snap.filament,
tray_id=tray_id,
ams_unit_id=start_snap.ams_unit_id,
starting_percent=start_snap.remain_percent or 100,
ending_percent=end_snap.remain_percent if end_snap else None,
)
usage.calculate_consumed()
created_usages.append(usage)
for usage in created_usages:
usage.is_primary = len(created_usages) == 1
usage.save()
if self.verbose:
logger.debug(
f"Filament usage for {start_snap.filament} (tray {tray_id}): "
f"Filament usage for {usage.filament} (unit {usage.ams_unit_id}, tray {usage.tray_id}): "
f"{usage.starting_percent}% -> {usage.ending_percent}%, consumed {usage.consumed_percent}%"
)
logger.info(
f"Print job finished: {self.current_print_job.project_name} "
f"({self.current_print_job.final_status}) - Duration: {self.current_print_job.duration_minutes} min, "
f"Trays used: {sorted(self.trays_used) if self.trays_used else 'none tracked'}"
f"[{session.device_id}] Print job finished: {job.project_name} "
f"({job.final_status}) - Duration: {job.duration_minutes} min, "
f"Trays used: {sorted(session.trays_used) if session.trays_used else 'none tracked'}"
)
self.current_print_job = None
self.trays_used = set()
session.current_print_job = None
session.trays_used = set()
def _collect_printer_data(self):
def _collect_printer_data(self, session: "DeviceSession"):
try:
snapshot = self.printer_client.get_snapshot()
snapshot = session.client.get_snapshot()
if snapshot is None:
self.mqtt_connect_errors += 1
if self.mqtt_connect_errors <= 5 or self.verbose:
session.mqtt_connect_errors += 1
if session.mqtt_connect_errors <= 5 or self.verbose:
logger.warning(
f"MQTT not connected yet or no data available "
f"(attempt {self.mqtt_connect_errors})"
f"[{session.device_id}] MQTT not connected yet or no data available "
f"(attempt {session.mqtt_connect_errors})"
)
return
with transaction.atomic():
metric = PrinterMetrics.objects.create(
device=self.printer_device,
device=session.printer,
timestamp=timezone.now(),
nozzle_temp=self._to_decimal(snapshot.get("nozzle_temp")),
nozzle_target_temp=self._to_decimal(snapshot.get("nozzle_target_temp")),
@@ -584,6 +745,10 @@ class Command(BaseCommand):
chamber_temp=self._to_decimal(snapshot.get("chamber_temp")),
nozzle_diameter=self._to_decimal(snapshot.get("nozzle_diameter")),
nozzle_type=snapshot.get("nozzle_type"),
nozzle_temp_left=self._to_decimal(snapshot.get("nozzle_temp_left")),
nozzle_target_temp_left=self._to_decimal(snapshot.get("nozzle_target_temp_left")),
nozzle_diameter_left=self._to_decimal(snapshot.get("nozzle_diameter_left")),
nozzle_type_left=snapshot.get("nozzle_type_left"),
gcode_state=snapshot.get("gcode_state"),
print_type=snapshot.get("print_type"),
print_percent=snapshot.get("print_percent"),
@@ -623,27 +788,33 @@ class Command(BaseCommand):
ams_units=snapshot.get("ams_units", []),
external_spool=snapshot.get("external_spool", {}),
lights_report=snapshot.get("lights_report", []),
vortek_raw=snapshot.get("vortek_raw", {}),
nozzle_info=snapshot.get("hotends", []),
)
filaments_data = snapshot.get('filaments', [])
if filaments_data:
self._create_filament_snapshots(metric, filaments_data, snapshot)
self._track_print_job(metric, snapshot)
hotends_data = snapshot.get('hotends', [])
if hotends_data:
self._update_hotends(session.printer, metric, hotends_data)
self.success_count += 1
self._track_print_job(session, metric, snapshot)
session.success_count += 1
if self.verbose:
logger.debug(
f"Printer Metrics: Nozzle={snapshot.get('nozzle_temp')}C, "
f"[{session.device_id}] Printer Metrics: Nozzle={snapshot.get('nozzle_temp')}C, "
f"Bed={snapshot.get('bed_temp')}C, "
f"Progress={snapshot.get('print_percent')}%, "
f"State={snapshot.get('gcode_state')}"
)
except Exception as e:
self.error_count += 1
logger.error(f"Error collecting printer data (total errors: {self.error_count}): {e}")
session.error_count += 1
logger.error(f"[{session.device_id}] Error collecting printer data (total errors: {session.error_count}): {e}")
if self.verbose:
logger.exception("Detailed traceback:")
@@ -658,16 +829,20 @@ class Command(BaseCommand):
def _print_statistics(self):
if self.start_time:
runtime = timezone.now() - self.start_time
total_collections = self.success_count + self.error_count
success_count = sum(s.success_count for s in self.sessions.values())
error_count = sum(s.error_count for s in self.sessions.values())
mqtt_connect_errors = sum(s.mqtt_connect_errors for s in self.sessions.values())
total_collections = success_count + error_count
success_rate = (
(self.success_count / total_collections * 100)
(success_count / total_collections * 100)
if total_collections > 0
else 0
)
logger.info("=== Statistics ===")
logger.info(f"Runtime: {runtime}")
logger.info(f"Successful collections: {self.success_count}")
logger.info(f"Failed collections: {self.error_count}")
logger.info(f"MQTT connection warnings: {self.mqtt_connect_errors}")
logger.info(f"Printers tracked: {len(self.sessions)}")
logger.info(f"Successful collections: {success_count}")
logger.info(f"Failed collections: {error_count}")
logger.info(f"MQTT connection warnings: {mqtt_connect_errors}")
logger.info(f"Success rate: {success_rate:.1f}%")

View File

@@ -0,0 +1,128 @@
"""
Diagnose multi-printer cloud data for a Bambu Lab account.
Run this if `bambu_collector` doesn't pick up all your printers, or the data
collected for a second/third printer looks wrong. It authenticates with your
Bambu Lab account, lists every device the cloud API reports, listens briefly
for raw MQTT data from each one, and writes a redacted JSON report you can
attach to a GitHub issue.
Usage:
python manage.py bambu_diagnose
python manage.py bambu_diagnose --listen-seconds 15
python manage.py bambu_diagnose --output my_report.json
python manage.py bambu_diagnose --no-redact # local debugging only — do NOT post this output publicly
"""
import json
import logging
import time
from django.core.management.base import BaseCommand, CommandError
from bambu_run.diagnostics import build_diagnostics_report, redact_diagnostics
logger = logging.getLogger("bambu_run.diagnose")
class Command(BaseCommand):
help = "Authenticate, list every printer on the account, and write a redacted diagnostics report."
def add_arguments(self, parser):
parser.add_argument(
"--listen-seconds", type=float, default=8.0,
help="How long to listen for MQTT data per device (default: 8)",
)
parser.add_argument(
"--output", type=str, default=None,
help="Output file path (default: bambu_diagnostics_<timestamp>.json)",
)
parser.add_argument(
"--no-redact", action="store_true",
help="Keep full serials/identifiers unmasked. For your own debugging only — "
"do not paste this output into a public GitHub issue.",
)
def handle(self, *args, **options):
import os
from bambu_run.mqtt_client import BambuPrinter, BambuClient
listen_seconds = options["listen_seconds"]
redact = not options["no_redact"]
bambu_username = os.environ.get("BAMBU_USERNAME")
bambu_password = os.environ.get("BAMBU_PASSWORD")
bambu_token = os.environ.get("BAMBU_TOKEN")
if not bambu_token and not all([bambu_username, bambu_password]):
raise CommandError(
"Either BAMBU_TOKEN or both BAMBU_USERNAME and BAMBU_PASSWORD "
"environment variables must be set"
)
self.stdout.write("Authenticating with Bambu Lab cloud...")
auth = BambuPrinter(username=bambu_username, password=bambu_password, token=bambu_token)
token = auth._ensure_token()
cloud = BambuClient(token=token)
devices = cloud.get_devices()
self.stdout.write(self.style.SUCCESS(f"Found {len(devices)} device(s) on this account:"))
for device in devices:
self.stdout.write(
f" - {device.get('name', 'unknown')} "
f"({device.get('dev_product_name', 'unknown model')}) "
f"online={device.get('online')}"
)
if len(devices) < 2:
self.stdout.write(self.style.WARNING(
"Only one device returned by the cloud API — if you own multiple printers, "
"this is likely the root cause. Note this in the GitHub issue."
))
raw_payloads = {}
for device in devices:
dev_id = device.get("dev_id")
if not dev_id:
continue
self.stdout.write(f"Listening to {device.get('name', dev_id)} for {listen_seconds:.0f}s...")
client = BambuPrinter(token=token, device_id=dev_id)
try:
client.connect(blocking=False)
self._request_full_status_when_ready(client)
time.sleep(listen_seconds)
state = client.get_state()
raw_payloads[dev_id] = state._raw_data.get("print") if state._raw_data else None
except Exception as e:
self.stdout.write(self.style.WARNING(f" Could not collect data for {dev_id}: {e}"))
raw_payloads[dev_id] = None
finally:
client.disconnect()
report = build_diagnostics_report(devices, raw_payloads)
report = redact_diagnostics(report, redact=redact)
output_path = options["output"] or f"bambu_diagnostics_{int(time.time())}.json"
with open(output_path, "w") as f:
json.dump(report, f, indent=2, default=str)
self.stdout.write(self.style.SUCCESS(f"\nDiagnostics written to: {output_path}"))
if not redact:
self.stdout.write(self.style.WARNING(
"--no-redact was used: this file contains unmasked serials/identifiers. "
"Do not attach it to a public GitHub issue as-is."
))
else:
self.stdout.write(
"Serials/identifiers are masked. Please skim the file once before posting — "
"then attach it to https://github.com/RunLit/Bambu-Run/issues/10"
)
def _request_full_status_when_ready(self, client, timeout: float = 20.0) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
mqtt_client = getattr(client, "_mqtt", None)
if mqtt_client is not None and getattr(mqtt_client, "connected", False):
client._mqtt.request_full_status()
return
time.sleep(0.5)

View File

@@ -371,6 +371,11 @@ class Command(BaseCommand):
return "created"
return "no_type"
# ── Transparent detection ────────────────────────────────────────────
# "Translucent" (no colour qualifier) + #000000 = clear/transparent filament.
# Bambu Lab AMS reports these as 00000000 (alpha=00).
is_transparent = color_name.strip().lower() == "translucent" and hex_code == "000000"
# ── Duplicate check ──────────────────────────────────────────────────
# All five fields must match to be considered a duplicate:
# color_code (exact), color_name (case-insensitive), brand,
@@ -388,9 +393,10 @@ class Command(BaseCommand):
return "duplicate"
if dry_run:
transparent_note = " [transparent]" if is_transparent else ""
self.stdout.write(
f" [dry-run] Would create: {color_name!r} #{hex_code} "
f"({filament_type} / {filament_sub_type})"
f"({filament_type} / {filament_sub_type}){transparent_note}"
)
return "created"
@@ -404,6 +410,7 @@ class Command(BaseCommand):
filament_type=filament_type,
filament_sub_type=filament_sub_type,
brand=BRAND,
is_transparent=is_transparent,
)
self.stdout.write(
f" + {color_name!r} #{hex_code} ({filament_type} / {filament_sub_type})"

View File

@@ -0,0 +1,355 @@
"""
Management command to run the Bambu-Run MCP server.
Supports SSE (network) and stdio (local) transports.
Usage:
python manage.py bambu_mcp_server
python manage.py bambu_mcp_server --transport sse --host 0.0.0.0 --port 8808
python manage.py bambu_mcp_server --transport stdio
"""
import logging
from django.core.management.base import BaseCommand, CommandError
logger = logging.getLogger("bambu_run.mcp")
class Command(BaseCommand):
help = "Run the Bambu-Run MCP server for AI agent access"
def add_arguments(self, parser):
from bambu_run.conf import app_settings
parser.add_argument(
"--transport",
choices=["sse", "stdio"],
default="sse",
help="Transport mode (default: sse)",
)
parser.add_argument(
"--host",
default=app_settings.MCP_HOST,
help=f"Host to bind to (default: {app_settings.MCP_HOST})",
)
parser.add_argument(
"--port",
type=int,
default=app_settings.MCP_PORT,
help=f"Port to listen on (default: {app_settings.MCP_PORT})",
)
def handle(self, *args, **options):
try:
from mcp.server.fastmcp import FastMCP
except ImportError:
raise CommandError(
"The 'mcp' package is required. Install it with: pip install 'bambu-run[mcp]'"
)
from asgiref.sync import sync_to_async
from bambu_run.conf import app_settings
from bambu_run import mcp_tools
transport = options["transport"]
host = options["host"]
port = options["port"]
mcp = FastMCP(
"Bambu-Run",
instructions=(
"Bambu-Run MCP server provides read-only access to 3D printer data "
"including live printer status, filament inventory, print history, "
"temperature trends, and diagnostics. All data comes from Bambu Lab "
"printers monitored via MQTT."
),
)
# ── Register Tools ───────────────────────────────────────────────
@mcp.tool()
async def get_printer_status(printer_id: int | None = None) -> str:
"""Get current live status of printer(s) including temperatures, progress, AMS slots, and errors.
Args:
printer_id: Optional printer ID to filter. Omit for all printers.
"""
return await sync_to_async(mcp_tools.get_printer_status)(printer_id=printer_id)
@mcp.tool()
async def list_printers() -> str:
"""List all registered printers with their model, serial, IP, and active status."""
return await sync_to_async(mcp_tools.list_printers)()
@mcp.tool()
async def get_print_history(
status: str | None = None,
days: int | None = None,
project_name: str | None = None,
limit: int = 20,
) -> str:
"""Get print job history with optional filters.
Args:
status: Filter by status (FINISH, FAILED, CANCELLED).
days: Only show jobs from the last N days.
project_name: Filter by project name (partial match).
limit: Maximum number of results (default 20).
"""
return await sync_to_async(mcp_tools.get_print_history)(
status=status, days=days, project_name=project_name, limit=limit
)
@mcp.tool()
async def get_print_job_detail(job_id: int) -> str:
"""Get detailed information about a single print job including filament usage.
Args:
job_id: The print job ID.
"""
return await sync_to_async(mcp_tools.get_print_job_detail)(job_id=job_id)
@mcp.tool()
async def list_filaments(
type: str | None = None,
brand: str | None = None,
color: str | None = None,
loaded_in_ams: bool | None = None,
low_filament: bool | None = None,
) -> str:
"""List filament inventory with optional filters.
Args:
type: Filter by material type (PLA, PETG, ABS, etc.).
brand: Filter by brand name (partial match).
color: Filter by color name (partial match).
loaded_in_ams: Filter by whether spool is currently in AMS.
low_filament: If true, only show spools with <=20% remaining.
"""
return await sync_to_async(mcp_tools.list_filaments)(
type=type, brand=brand, color=color,
loaded_in_ams=loaded_in_ams, low_filament=low_filament,
)
@mcp.tool()
async def get_filament_detail(filament_id: int) -> str:
"""Get detailed information about a single filament spool including usage history.
Args:
filament_id: The filament spool ID.
"""
return await sync_to_async(mcp_tools.get_filament_detail)(filament_id=filament_id)
@mcp.tool()
async def get_temperature_history(
printer_id: int | None = None,
hours: int = 6,
metric: str = "all",
) -> str:
"""Get temperature trends (avg/min/max) over recent hours.
Args:
printer_id: Optional printer ID to filter.
hours: Number of hours to look back (default 6).
metric: Which sensor to show: 'all', 'nozzle', 'bed', or 'chamber'.
"""
return await sync_to_async(mcp_tools.get_temperature_history)(
printer_id=printer_id, hours=hours, metric=metric
)
@mcp.tool()
async def get_filament_usage_stats(days: int = 30, group_by: str = "type") -> str:
"""Get aggregate filament consumption statistics.
Args:
days: Number of days to look back (default 30).
group_by: Group results by 'type', 'color', or 'spool'.
"""
return await sync_to_async(mcp_tools.get_filament_usage_stats)(days=days, group_by=group_by)
@mcp.tool()
async def get_printer_health(printer_id: int | None = None) -> str:
"""Get printer diagnostics including errors, humidity, WiFi signal, and recent failures.
Args:
printer_id: Optional printer ID to filter. Omit for all printers.
"""
return await sync_to_async(mcp_tools.get_printer_health)(printer_id=printer_id)
@mcp.tool()
async def search_print_jobs(query: str) -> str:
"""Search print jobs by project name or gcode filename.
Args:
query: Search text (partial match on project name or gcode file).
"""
return await sync_to_async(mcp_tools.search_print_jobs)(query=query)
@mcp.tool()
async def get_printing_summary(days: int = 7) -> str:
"""Get high-level printing activity summary including job counts, success rate, and top projects.
Args:
days: Number of days to summarize (default 7).
"""
return await sync_to_async(mcp_tools.get_printing_summary)(days=days)
@mcp.tool()
async def find_compatible_filament(
type: str,
min_remaining_percent: int = 10,
color: str | None = None,
) -> str:
"""Find filament spools matching material type and optional criteria.
Args:
type: Material type to search for (PLA, PETG, ABS, etc.).
min_remaining_percent: Minimum remaining percentage (default 10).
color: Optional color filter (partial match).
"""
return await sync_to_async(mcp_tools.find_compatible_filament)(
type=type, min_remaining_percent=min_remaining_percent, color=color
)
# ── Register Resources ───────────────────────────────────────────
@mcp.resource("bambu://printers")
async def res_printers() -> str:
"""List all registered printers."""
return await sync_to_async(mcp_tools.resource_printers)()
@mcp.resource("bambu://printers/{printer_id}/status")
async def res_printer_status(printer_id: int) -> str:
"""Get latest status for a specific printer."""
return await sync_to_async(mcp_tools.resource_printer_status)(printer_id)
@mcp.resource("bambu://filaments")
async def res_filaments() -> str:
"""Full filament inventory."""
return await sync_to_async(mcp_tools.resource_filaments)()
@mcp.resource("bambu://filaments/{filament_id}")
async def res_filament_detail(filament_id: int) -> str:
"""Single filament spool with usage history."""
return await sync_to_async(mcp_tools.resource_filament_detail)(filament_id)
@mcp.resource("bambu://print-jobs/recent")
async def res_recent_jobs() -> str:
"""Last 20 print jobs."""
return await sync_to_async(mcp_tools.resource_recent_print_jobs)()
@mcp.resource("bambu://filament-types")
async def res_filament_types() -> str:
"""Filament type registry."""
return await sync_to_async(mcp_tools.resource_filament_types)()
@mcp.resource("bambu://filament-colors")
async def res_filament_colors() -> str:
"""Filament color database."""
return await sync_to_async(mcp_tools.resource_filament_colors)()
# ── Register Prompts ─────────────────────────────────────────────
@mcp.prompt()
async def printer_check_in(printer_id: int | None = None) -> str:
"""Full printer status briefing with health check and recent prints.
Args:
printer_id: Optional printer ID. Omit for all printers.
"""
return await sync_to_async(mcp_tools.prompt_printer_check_in)(printer_id=printer_id)
@mcp.prompt()
async def filament_inventory_report() -> str:
"""Comprehensive filament inventory report with low-stock warnings."""
return await sync_to_async(mcp_tools.prompt_filament_inventory_report)()
@mcp.prompt()
async def print_job_review(job_id: int) -> str:
"""Detailed review of a completed print job.
Args:
job_id: The print job ID to review.
"""
return await sync_to_async(mcp_tools.prompt_print_job_review)(job_id)
@mcp.prompt()
async def weekly_printing_digest() -> str:
"""Weekly printing activity summary with filament usage breakdown."""
return await sync_to_async(mcp_tools.prompt_weekly_digest)()
@mcp.prompt()
async def troubleshoot_printer(printer_id: int | None = None) -> str:
"""Diagnose printer issues using recent health data, status, and temperatures.
Args:
printer_id: Optional printer ID. Omit for all printers.
"""
return await sync_to_async(mcp_tools.prompt_troubleshoot_printer)(printer_id=printer_id)
# ── Auth middleware for SSE ───────────────────────────────────────
api_key = app_settings.MCP_API_KEY
auth_backend = app_settings.MCP_AUTH_BACKEND
if api_key or auth_backend:
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
class AuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
# Custom auth backend takes priority
if auth_backend:
if not auth_backend(request):
return JSONResponse(
{"error": "Unauthorized"}, status_code=401
)
return await call_next(request)
# API key auth
if api_key:
auth_header = request.headers.get("Authorization", "")
if auth_header == f"Bearer {api_key}":
return await call_next(request)
return JSONResponse(
{"error": "Invalid or missing API key"}, status_code=401
)
return await call_next(request)
# Attach middleware — FastMCP's SSE app is a Starlette app
original_sse_app = mcp.sse_app
def patched_sse_app():
app = original_sse_app()
app.add_middleware(AuthMiddleware)
return app
mcp.sse_app = patched_sse_app
# ── Run ──────────────────────────────────────────────────────────
if transport == "sse":
try:
import uvicorn
except ImportError:
raise CommandError(
"uvicorn is required for SSE transport. Install it with: pip install uvicorn"
)
self.stdout.write(
self.style.SUCCESS(
f"Starting Bambu-Run MCP server (SSE) on {host}:{port}"
)
)
self.stdout.write(
f"Connect with: http://{host}:{port}/sse"
)
app = mcp.sse_app()
uvicorn.run(app, host=host, port=port)
else:
self.stdout.write(
self.style.SUCCESS("Starting Bambu-Run MCP server (stdio)")
)
mcp.run(transport="stdio")

View File

@@ -0,0 +1,140 @@
"""
Management command: bambu_sync_cloud
Backfill BambuCloudTask records from the Bambu Cloud API and link them to
existing PrintJob records. Primarily useful for jobs created before this
feature existed, or for re-syncing if the collector was offline at job end.
Usage:
python manage.py bambu_sync_cloud
python manage.py bambu_sync_cloud --limit 100
python manage.py bambu_sync_cloud --dry-run
"""
import logging
import os
from django.core.management.base import BaseCommand, CommandError
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Backfill BambuCloudTask records from Bambu Cloud API and link to PrintJob"
def add_arguments(self, parser):
parser.add_argument(
'--limit', type=int, default=20,
help='Number of recent cloud tasks to fetch (default: 20)'
)
parser.add_argument(
'--dry-run', action='store_true',
help='Show what would be synced without writing to DB'
)
def handle(self, *args, **options):
limit = options['limit']
dry_run = options['dry_run']
bambu_token = os.environ.get('BAMBU_TOKEN')
bambu_username = os.environ.get('BAMBU_USERNAME')
bambu_password = os.environ.get('BAMBU_PASSWORD')
if not bambu_token and not all([bambu_username, bambu_password]):
raise CommandError(
"Either BAMBU_TOKEN or both BAMBU_USERNAME and BAMBU_PASSWORD must be set"
)
try:
from bambulab import BambuClient
from bambulab.auth import BambuAuthenticator
except ImportError:
raise CommandError("bambu-lab-cloud-api is not installed")
if bambu_token:
client = BambuClient(token=bambu_token)
else:
auth = BambuAuthenticator()
token = auth.login(bambu_username, bambu_password)
client = BambuClient(token=token)
from bambu_run.bambu_cloud import get_tasks, upsert_cloud_task
from bambu_run.models import PrintJob
self.stdout.write(f"Fetching last {limit} tasks from Bambu Cloud...")
try:
response = get_tasks(client, limit=limit)
except Exception as e:
raise CommandError(f"Cloud API request failed: {e}")
hits = response.get('hits', response.get('tasks', []))
self.stdout.write(f"Got {len(hits)} tasks from cloud")
created_count = updated_count = linked_count = 0
for task_dict in hits:
task_id = task_dict.get('id')
design_title = task_dict.get('designTitle') or ''
plate_title = task_dict.get('title') or ''
display_name = design_title or plate_title or f"task-{task_id}"
if dry_run:
self.stdout.write(
f" [dry-run] Would upsert task {task_id}: {display_name!r}"
)
# Check if we'd link to a PrintJob
job = PrintJob.objects.filter(cloud_task_id_raw=task_id).first()
if job:
self.stdout.write(f" → would link to PrintJob #{job.id}")
continue
try:
cloud_task, created = upsert_cloud_task(task_dict)
if created:
created_count += 1
self.stdout.write(f" Created: {display_name!r} (task {task_id})")
else:
updated_count += 1
# Link to any matching PrintJob by cloud_task_id_raw
linked = PrintJob.objects.filter(
cloud_task_id_raw=task_id, cloud_task__isnull=True
).update(cloud_task=cloud_task)
if linked:
linked_count += linked
self.stdout.write(f" Linked {linked} PrintJob(s) for task {task_id}")
# Historical backfill: match by cloud start_time ± 2 min + device serial
if cloud_task.cloud_start_time and cloud_task.device_serial:
from datetime import timedelta
from bambu_run.models import Printer
printer = Printer.objects.filter(
serial_number=cloud_task.device_serial
).first()
if printer:
window_start = cloud_task.cloud_start_time - timedelta(minutes=5)
window_end = cloud_task.cloud_start_time + timedelta(minutes=5)
historical = PrintJob.objects.filter(
device=printer,
start_time__gte=window_start,
start_time__lte=window_end,
cloud_task__isnull=True,
).update(cloud_task=cloud_task)
if historical:
linked_count += historical
self.stdout.write(
f" Historically linked {historical} PrintJob(s) by time for task {task_id}"
)
except Exception as e:
self.stderr.write(f" Error processing task {task_id}: {e}")
if not dry_run:
self.stdout.write(
self.style.SUCCESS(
f"\nDone: {created_count} created, {updated_count} updated, "
f"{linked_count} PrintJob(s) linked"
)
)
else:
self.stdout.write(self.style.WARNING("\nDry run complete — no changes written"))

728
bambu_run/mcp_tools.py Normal file
View File

@@ -0,0 +1,728 @@
"""
Pure Django ORM query functions for MCP tools.
Zero dependency on the `mcp` package — returns markdown strings.
RAE can reuse these directly.
"""
from datetime import timedelta
from decimal import Decimal
from zoneinfo import ZoneInfo
from django.db.models import Avg, Count, Max, Min, Q, Sum
from django.utils import timezone
from .conf import app_settings
def _local_dt(dt, fmt="%Y-%m-%d %H:%M %Z"):
"""Convert a UTC-aware datetime to the configured local timezone for display."""
if dt is None:
return ""
tz = ZoneInfo(app_settings.TIMEZONE)
return dt.astimezone(tz).strftime(fmt)
def _redact(value, label="[redacted]"):
"""Redact sensitive values if MCP_HIDE_SENSITIVE is enabled."""
if app_settings.MCP_HIDE_SENSITIVE:
return label
return value
def _job_name(job):
"""Return the best available display name for a print job.
Prefers cloud design_title (e.g., 'Planetary Gears Finger Fidget Spinners')
over the MQTT subtask_name (e.g., 'All variants at 0.16mm high quality').
Falls back to project_name for local/SD prints with no cloud task.
"""
if job.cloud_task_id and job.cloud_task and job.cloud_task.design_title:
return job.cloud_task.design_title
return job.project_name
def _format_duration(minutes):
"""Format minutes into human-readable duration."""
if minutes is None:
return "Unknown"
hours, mins = divmod(int(minutes), 60)
if hours > 0:
return f"{hours}h {mins}m"
return f"{mins}m"
def _format_temp(temp):
"""Format temperature value."""
if temp is None:
return "N/A"
return f"{temp}°C"
# ─── Tools ───────────────────────────────────────────────────────────────────
def get_printer_status(printer_id=None):
"""Current live status of printer(s) including temps, progress, AMS, errors."""
from .models import Printer, PrinterMetrics
printers = Printer.objects.filter(is_active=True)
if printer_id:
printers = printers.filter(id=printer_id)
if not printers.exists():
return "No printers found."
parts = []
for printer in printers:
metric = PrinterMetrics.objects.filter(device=printer).first()
if not metric:
parts.append(f"## {printer.name}\n**No data available yet.**\n")
continue
state = metric.gcode_state or "Unknown"
lines = [f"## Printer Status: {printer.name}"]
lines.append(f"**Model**: {printer.model} | **Serial**: {_redact(printer.serial_number)}")
lines.append(f"**IP**: {_redact(printer.ip_address)} | **Location**: {printer.location or 'N/A'}")
lines.append(f"**State**: {state}")
if metric.print_percent is not None and state == "RUNNING":
layer_info = ""
if metric.layer_num is not None and metric.total_layer_num:
layer_info = f" (Layer {metric.layer_num}/{metric.total_layer_num})"
lines.append(f"**Progress**: {metric.print_percent}%{layer_info}")
if metric.subtask_name:
lines.append(f"**Project**: {metric.subtask_name}")
if metric.remaining_time_min:
lines.append(f"**ETA**: {_format_duration(metric.remaining_time_min)} remaining")
# Temperatures
lines.append("")
lines.append("### Temperatures")
lines.append("| Component | Current | Target |")
lines.append("|-----------|---------|--------|")
lines.append(f"| Nozzle | {_format_temp(metric.nozzle_temp)} | {_format_temp(metric.nozzle_target_temp)} |")
lines.append(f"| Bed | {_format_temp(metric.bed_temp)} | {_format_temp(metric.bed_target_temp)} |")
lines.append(f"| Chamber | {_format_temp(metric.chamber_temp)} | - |")
# AMS filaments from JSON
if metric.filaments:
lines.append("")
lines.append("### AMS Slots")
lines.append("| Slot | Material | Color | Remaining |")
lines.append("|------|----------|-------|-----------|")
for f in metric.filaments:
slot = f.get("slot", "?")
ftype = f.get("sub_type") or f.get("type", "?")
color = f.get("color", "")
color_display = f"#{color[:6]}" if color and len(color) >= 6 else "?"
remain = f.get("remain_percent", "?")
lines.append(f"| {slot} | {ftype} | {color_display} | {remain}% |")
# Errors
if metric.has_errors or metric.hms:
lines.append("")
lines.append("### Alerts")
if metric.print_error:
lines.append(f"- Print error code: {metric.print_error}")
if metric.hms:
for msg in metric.hms[:5]:
lines.append(f"- HMS: {msg}")
lines.append(f"\n*Last updated: {_local_dt(metric.timestamp, '%Y-%m-%d %H:%M:%S %Z')}*")
parts.append("\n".join(lines))
return "\n\n---\n\n".join(parts)
def list_printers():
"""List all registered printers."""
from .models import Printer
printers = Printer.objects.all()
if not printers.exists():
return "No printers registered."
lines = ["# Printers", ""]
lines.append("| ID | Name | Model | Active | Serial | IP | Location |")
lines.append("|----|------|-------|--------|--------|----|----------|")
for p in printers:
lines.append(
f"| {p.id} | {p.name} | {p.model} | "
f"{'Yes' if p.is_active else 'No'} | "
f"{_redact(p.serial_number)} | {_redact(p.ip_address)} | "
f"{p.location or '-'} |"
)
return "\n".join(lines)
def get_print_history(status=None, days=None, project_name=None, limit=20):
"""Print job history with optional filters."""
from .models import PrintJob
qs = PrintJob.objects.select_related("device", "cloud_task")
if status:
qs = qs.filter(final_status__iexact=status)
if days:
cutoff = timezone.now() - timedelta(days=int(days))
qs = qs.filter(start_time__gte=cutoff)
if project_name:
qs = qs.filter(
Q(project_name__icontains=project_name)
| Q(cloud_task__design_title__icontains=project_name)
)
jobs = qs[:int(limit)]
if not jobs:
return "No print jobs found matching the criteria."
lines = ["# Print History", ""]
lines.append("| ID | Project | Printer | Status | Progress | Duration | Started |")
lines.append("|----|---------|---------|--------|----------|----------|---------|")
for j in jobs:
lines.append(
f"| {j.id} | {_job_name(j)} | {j.device.name} | "
f"{j.final_status or 'In Progress'} | {j.completion_percent}% | "
f"{_format_duration(j.duration_minutes)} | "
f"{_local_dt(j.start_time, '%Y-%m-%d %H:%M')} |"
)
return "\n".join(lines)
def get_print_job_detail(job_id):
"""Single job detail including filament usage."""
from .models import FilamentUsage, PrintJob
try:
job = PrintJob.objects.select_related("device", "cloud_task").get(id=job_id)
except PrintJob.DoesNotExist:
return f"Print job #{job_id} not found."
lines = [f"# Print Job: {_job_name(job)}", ""]
if job.cloud_task and job.cloud_task.design_title and job.cloud_task.design_title != job.project_name:
lines.append(f"**Plate**: {job.project_name}")
lines.append(f"**Printer**: {job.device.name}")
lines.append(f"**Status**: {job.final_status or 'In Progress'}")
lines.append(f"**Progress**: {job.completion_percent}%")
if job.gcode_file:
lines.append(f"**G-code**: {job.gcode_file}")
lines.append(f"**Started**: {_local_dt(job.start_time, '%Y-%m-%d %H:%M:%S %Z')}")
if job.end_time:
lines.append(f"**Ended**: {_local_dt(job.end_time, '%Y-%m-%d %H:%M:%S %Z')}")
lines.append(f"**Duration**: {_format_duration(job.duration_minutes)}")
if job.total_layers:
lines.append(f"**Total Layers**: {job.total_layers}")
# Filament usage
usages = FilamentUsage.objects.select_related("filament").filter(print_job=job)
if usages.exists():
lines.append("")
lines.append("### Filament Usage")
lines.append("| Spool | Material | Color | Consumed | Grams |")
lines.append("|-------|----------|-------|----------|-------|")
for u in usages:
f = u.filament
lines.append(
f"| {f.brand} {f.type} | {f.sub_type or f.type} | "
f"{f.color} | {u.consumed_percent or 0}% | "
f"{u.consumed_grams or '-'}g |"
)
return "\n".join(lines)
def list_filaments(type=None, brand=None, color=None, loaded_in_ams=None, low_filament=None):
"""Filament inventory with optional filters."""
from .models import Filament
qs = Filament.objects.all()
if type:
qs = qs.filter(type__iexact=type)
if brand:
qs = qs.filter(brand__icontains=brand)
if color:
qs = qs.filter(color__icontains=color)
if loaded_in_ams is not None:
qs = qs.filter(is_loaded_in_ams=loaded_in_ams)
if low_filament:
qs = qs.filter(remaining_percent__lte=20)
filaments = qs[:50]
if not filaments:
return "No filaments found matching the criteria."
lines = ["# Filament Inventory", ""]
lines.append(f"*{qs.count()} spools total*\n")
lines.append("| ID | Brand | Type | Color | Remaining | In AMS | Last Used |")
lines.append("|----|-------|------|-------|-----------|--------|-----------|")
for f in filaments:
color_display = f"{f.color}"
if f.color_hex:
color_display += f" ({f.color_hex})"
last_used = _local_dt(f.last_used, "%Y-%m-%d") if f.last_used else "-"
lines.append(
f"| {f.id} | {f.brand} | {f.sub_type or f.type} | "
f"{color_display} | {f.remaining_percent}% | "
f"{'Yes' if f.is_loaded_in_ams else 'No'} | {last_used} |"
)
return "\n".join(lines)
def get_filament_detail(filament_id):
"""Single spool detail with usage history."""
from .models import Filament, FilamentUsage
try:
f = Filament.objects.get(id=filament_id)
except Filament.DoesNotExist:
return f"Filament #{filament_id} not found."
lines = [f"# Filament: {f.brand} {f.type} - {f.color}", ""]
lines.append(f"**Type**: {f.sub_type or f.type}")
lines.append(f"**Brand**: {f.brand}")
lines.append(f"**Color**: {f.color} ({f.color_hex or 'N/A'})")
lines.append(f"**Remaining**: {f.remaining_percent}%")
if f.remaining_weight_grams:
lines.append(f"**Remaining Weight**: {f.remaining_weight_grams}g / {f.initial_weight_grams or '?'}g")
lines.append(f"**In AMS**: {'Yes (slot ' + str(f.current_tray_id) + ')' if f.is_loaded_in_ams else 'No'}")
lines.append(f"**Created By**: {f.created_by}")
if f.tray_uuid:
lines.append(f"**Serial**: {_redact(f.tray_uuid)}")
if f.purchase_date:
lines.append(f"**Purchased**: {f.purchase_date}")
if f.notes:
lines.append(f"**Notes**: {f.notes}")
# Usage history
usages = FilamentUsage.objects.select_related("print_job").filter(filament=f).order_by("-print_job__start_time")[:10]
if usages.exists():
lines.append("")
lines.append("### Recent Print Usage")
lines.append("| Job | Date | Consumed | Grams |")
lines.append("|-----|------|----------|-------|")
for u in usages:
lines.append(
f"| {u.print_job.project_name} | "
f"{_local_dt(u.print_job.start_time, '%Y-%m-%d')} | "
f"{u.consumed_percent or 0}% | {u.consumed_grams or '-'}g |"
)
return "\n".join(lines)
def get_temperature_history(printer_id=None, hours=6, metric="all"):
"""Temperature trends as summary stats (avg/min/max) over recent hours."""
from .models import Printer, PrinterMetrics
cutoff = timezone.now() - timedelta(hours=int(hours))
qs = PrinterMetrics.objects.filter(timestamp__gte=cutoff)
if printer_id:
qs = qs.filter(device_id=printer_id)
if not qs.exists():
return f"No temperature data in the last {hours} hours."
printers = Printer.objects.filter(
id__in=qs.values_list("device_id", flat=True).distinct()
)
parts = [f"# Temperature History (last {hours}h)", ""]
for printer in printers:
pqs = qs.filter(device=printer)
stats = pqs.aggregate(
nozzle_avg=Avg("nozzle_temp"),
nozzle_min=Min("nozzle_temp"),
nozzle_max=Max("nozzle_temp"),
bed_avg=Avg("bed_temp"),
bed_min=Min("bed_temp"),
bed_max=Max("bed_temp"),
chamber_avg=Avg("chamber_temp"),
chamber_min=Min("chamber_temp"),
chamber_max=Max("chamber_temp"),
)
parts.append(f"## {printer.name}")
parts.append(f"*{pqs.count()} data points*\n")
parts.append("| Sensor | Avg | Min | Max |")
parts.append("|--------|-----|-----|-----|")
if metric in ("all", "nozzle"):
parts.append(
f"| Nozzle | {_format_temp(stats['nozzle_avg'])} | "
f"{_format_temp(stats['nozzle_min'])} | {_format_temp(stats['nozzle_max'])} |"
)
if metric in ("all", "bed"):
parts.append(
f"| Bed | {_format_temp(stats['bed_avg'])} | "
f"{_format_temp(stats['bed_min'])} | {_format_temp(stats['bed_max'])} |"
)
if metric in ("all", "chamber"):
parts.append(
f"| Chamber | {_format_temp(stats['chamber_avg'])} | "
f"{_format_temp(stats['chamber_min'])} | {_format_temp(stats['chamber_max'])} |"
)
parts.append("")
return "\n".join(parts)
def get_filament_usage_stats(days=30, group_by="type"):
"""Aggregate filament consumption statistics."""
from .models import FilamentUsage
cutoff = timezone.now() - timedelta(days=int(days))
qs = FilamentUsage.objects.filter(
print_job__start_time__gte=cutoff,
consumed_grams__isnull=False,
).select_related("filament")
if not qs.exists():
return f"No filament usage data in the last {days} days."
lines = [f"# Filament Usage Stats (last {days} days)", ""]
if group_by == "type":
stats = (
qs.values("filament__type")
.annotate(
total_grams=Sum("consumed_grams"),
total_percent=Sum("consumed_percent"),
job_count=Count("print_job", distinct=True),
)
.order_by("-total_grams")
)
lines.append("| Type | Total Grams | Jobs | Avg Grams/Job |")
lines.append("|------|-------------|------|---------------|")
for s in stats:
avg = s["total_grams"] / s["job_count"] if s["job_count"] else 0
lines.append(
f"| {s['filament__type']} | {s['total_grams']}g | "
f"{s['job_count']} | {avg:.0f}g |"
)
elif group_by == "color":
stats = (
qs.values("filament__color", "filament__type")
.annotate(total_grams=Sum("consumed_grams"), job_count=Count("print_job", distinct=True))
.order_by("-total_grams")
)
lines.append("| Color | Type | Total Grams | Jobs |")
lines.append("|-------|------|-------------|------|")
for s in stats:
lines.append(
f"| {s['filament__color']} | {s['filament__type']} | "
f"{s['total_grams']}g | {s['job_count']} |"
)
elif group_by == "spool":
stats = (
qs.values("filament__id", "filament__brand", "filament__type", "filament__color")
.annotate(total_grams=Sum("consumed_grams"), job_count=Count("print_job", distinct=True))
.order_by("-total_grams")[:20]
)
lines.append("| Spool | Total Grams | Jobs |")
lines.append("|-------|-------------|------|")
for s in stats:
lines.append(
f"| {s['filament__brand']} {s['filament__type']} {s['filament__color']} | "
f"{s['total_grams']}g | {s['job_count']} |"
)
return "\n".join(lines)
def get_printer_health(printer_id=None):
"""Diagnostics: errors, humidity, wifi, recent failed prints."""
from .models import Printer, PrinterMetrics, PrintJob
printers = Printer.objects.filter(is_active=True)
if printer_id:
printers = printers.filter(id=printer_id)
if not printers.exists():
return "No printers found."
parts = ["# Printer Health Report", ""]
for printer in printers:
latest = PrinterMetrics.objects.filter(device=printer).first()
if not latest:
parts.append(f"## {printer.name}\n**No data available.**\n")
continue
parts.append(f"## {printer.name}")
# Connectivity
parts.append("### Connectivity")
if latest.wifi_signal_dbm is not None:
signal = latest.wifi_signal_dbm
quality = "Excellent" if signal > -50 else "Good" if signal > -60 else "Fair" if signal > -70 else "Poor"
parts.append(f"- WiFi: {signal} dBm ({quality})")
parts.append(f"- Last seen: {_local_dt(latest.timestamp, '%Y-%m-%d %H:%M:%S %Z')}")
age = (timezone.now() - latest.timestamp).total_seconds()
if age > 300:
parts.append(f"- **Warning**: No data for {_format_duration(age / 60)}")
# AMS environment
if latest.ams_humidity is not None or latest.ams_temp is not None:
parts.append("### AMS Environment")
if latest.ams_humidity is not None:
hum_status = "OK" if latest.ams_humidity < 5 else "High" if latest.ams_humidity < 8 else "Critical"
parts.append(f"- Humidity: {latest.ams_humidity} ({hum_status})")
if latest.ams_temp is not None:
parts.append(f"- Temperature: {latest.ams_temp}°C")
# HMS errors
if latest.hms:
parts.append("### Active HMS Alerts")
for msg in latest.hms:
parts.append(f"- {msg}")
# Recent failures
week_ago = timezone.now() - timedelta(days=7)
failed = PrintJob.objects.filter(
device=printer,
start_time__gte=week_ago,
final_status__in=["FAILED", "CANCELLED"],
)
if failed.exists():
parts.append(f"### Recent Failures (7d): {failed.count()}")
for job in failed.select_related("cloud_task")[:5]:
parts.append(f"- {_job_name(job)} ({job.final_status}) — {_local_dt(job.start_time, '%m-%d %H:%M')}")
# Success rate
week_jobs = PrintJob.objects.filter(device=printer, start_time__gte=week_ago)
total = week_jobs.count()
if total > 0:
success = week_jobs.filter(final_status="FINISH").count()
parts.append(f"\n**7-day success rate**: {success}/{total} ({100 * success // total}%)")
parts.append("")
return "\n".join(parts)
def search_print_jobs(query):
"""Search print jobs by project name or gcode file."""
from .models import PrintJob
if not query:
return "Please provide a search query."
jobs = PrintJob.objects.select_related("device", "cloud_task").filter(
Q(project_name__icontains=query)
| Q(gcode_file__icontains=query)
| Q(cloud_task__design_title__icontains=query)
)[:20]
if not jobs:
return f"No print jobs matching '{query}'."
lines = [f"# Search Results: '{query}'", ""]
lines.append(f"*{len(jobs)} results*\n")
lines.append("| ID | Project | Printer | Status | Date |")
lines.append("|----|---------|---------|--------|------|")
for j in jobs:
lines.append(
f"| {j.id} | {_job_name(j)} | {j.device.name} | "
f"{j.final_status or 'In Progress'} | {_local_dt(j.start_time, '%Y-%m-%d')} |"
)
return "\n".join(lines)
def get_printing_summary(days=7):
"""High-level activity summary."""
from .models import FilamentUsage, Printer, PrintJob
cutoff = timezone.now() - timedelta(days=int(days))
jobs = PrintJob.objects.filter(start_time__gte=cutoff)
total = jobs.count()
finished = jobs.filter(final_status="FINISH").count()
failed = jobs.filter(final_status="FAILED").count()
cancelled = jobs.filter(final_status="CANCELLED").count()
in_progress = jobs.filter(final_status__isnull=True).count()
total_minutes = jobs.filter(duration_minutes__isnull=False).aggregate(
total=Sum("duration_minutes")
)["total"] or 0
total_grams = FilamentUsage.objects.filter(
print_job__start_time__gte=cutoff,
consumed_grams__isnull=False,
).aggregate(total=Sum("consumed_grams"))["total"] or 0
lines = [f"# Printing Summary (last {days} days)", ""]
lines.append(f"**Total Jobs**: {total}")
lines.append(f"- Completed: {finished}")
lines.append(f"- Failed: {failed}")
lines.append(f"- Cancelled: {cancelled}")
lines.append(f"- In Progress: {in_progress}")
if total > 0:
lines.append(f"- Success Rate: {100 * finished // total}%")
lines.append(f"\n**Total Print Time**: {_format_duration(total_minutes)}")
lines.append(f"**Total Filament Used**: {total_grams}g")
# Most printed projects
top_projects = (
jobs.values("project_name")
.annotate(count=Count("id"))
.order_by("-count")[:5]
)
if top_projects:
lines.append("\n### Most Printed")
for p in top_projects:
lines.append(f"- {p['project_name']} ({p['count']}x)")
# Active printers
active_printers = Printer.objects.filter(
print_jobs__start_time__gte=cutoff
).distinct()
if active_printers.exists():
lines.append(f"\n**Active Printers**: {', '.join(p.name for p in active_printers)}")
return "\n".join(lines)
def find_compatible_filament(type, min_remaining_percent=10, color=None):
"""Find spools matching material type criteria."""
from .models import Filament
qs = Filament.objects.filter(
type__iexact=type,
remaining_percent__gte=int(min_remaining_percent),
)
if color:
qs = qs.filter(color__icontains=color)
filaments = qs[:20]
if not filaments:
return f"No {type} filament found with >={min_remaining_percent}% remaining."
lines = [f"# Compatible Filament: {type}", ""]
if color:
lines.append(f"*Color filter: {color}*\n")
lines.append(f"*{qs.count()} spools found*\n")
lines.append("| ID | Brand | Sub-type | Color | Remaining | In AMS |")
lines.append("|----|-------|----------|-------|-----------|--------|")
for f in filaments:
lines.append(
f"| {f.id} | {f.brand} | {f.sub_type or f.type} | "
f"{f.color} | {f.remaining_percent}% | "
f"{'Yes' if f.is_loaded_in_ams else 'No'} |"
)
return "\n".join(lines)
# ─── Resources ───────────────────────────────────────────────────────────────
def resource_printers():
"""List all printers (resource)."""
return list_printers()
def resource_printer_status(printer_id):
"""Latest printer status (resource)."""
return get_printer_status(printer_id=printer_id)
def resource_filaments():
"""Full filament inventory (resource)."""
return list_filaments()
def resource_filament_detail(filament_id):
"""Single spool with usage (resource)."""
return get_filament_detail(filament_id=filament_id)
def resource_recent_print_jobs():
"""Last 20 print jobs (resource)."""
return get_print_history(limit=20)
def resource_filament_types():
"""Filament type registry (resource)."""
from .models import FilamentType
types = FilamentType.objects.all()
if not types.exists():
return "No filament types registered."
lines = ["# Filament Types", ""]
lines.append("| ID | Type | Sub-type | Brand |")
lines.append("|----|------|----------|-------|")
for t in types:
lines.append(f"| {t.id} | {t.type} | {t.sub_type or '-'} | {t.brand} |")
return "\n".join(lines)
def resource_filament_colors():
"""Filament color database (resource)."""
from .models import FilamentColor
colors = FilamentColor.objects.all()[:100]
if not colors:
return "No filament colors in database."
lines = ["# Filament Colors", ""]
lines.append(f"*Showing up to 100 of {FilamentColor.objects.count()}*\n")
lines.append("| Color | Hex | Type | Sub-type | Brand |")
lines.append("|-------|-----|------|----------|-------|")
for c in colors:
lines.append(
f"| {c.color_name} | #{c.color_code} | {c.filament_type} | "
f"{c.filament_sub_type or '-'} | {c.brand} |"
)
return "\n".join(lines)
# ─── Prompts ─────────────────────────────────────────────────────────────────
def prompt_printer_check_in(printer_id=None):
"""Full status briefing: status + health + recent prints."""
parts = [
get_printer_status(printer_id=printer_id),
get_printer_health(printer_id=printer_id),
get_print_history(days=1, limit=5),
]
return "\n\n---\n\n".join(parts)
def prompt_filament_inventory_report():
"""Inventory report with low-stock warnings."""
from .models import Filament
low_stock = Filament.objects.filter(remaining_percent__lte=20)
parts = [list_filaments()]
if low_stock.exists():
lines = ["\n## Low Stock Warnings"]
for f in low_stock:
lines.append(f"- **{f.brand} {f.type} {f.color}**: {f.remaining_percent}% remaining")
parts.append("\n".join(lines))
return "\n\n".join(parts)
def prompt_print_job_review(job_id):
"""Review a completed job."""
return get_print_job_detail(job_id)
def prompt_weekly_digest():
"""Weekly activity summary."""
parts = [
get_printing_summary(days=7),
get_filament_usage_stats(days=7, group_by="type"),
]
return "\n\n---\n\n".join(parts)
def prompt_troubleshoot_printer(printer_id=None):
"""Diagnose issues from recent data."""
parts = [
get_printer_health(printer_id=printer_id),
get_printer_status(printer_id=printer_id),
get_temperature_history(printer_id=printer_id, hours=2),
]
return "\n\n---\n\n".join(parts)

View File

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

View File

@@ -0,0 +1,177 @@
# Generated by Django 6.0.2 on 2026-03-29 11:38
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bambu_run", "0002_filament_is_transparent"),
]
operations = [
migrations.AddField(
model_name="printjob",
name="cloud_task_id_raw",
field=models.BigIntegerField(
blank=True,
db_index=True,
help_text="MQTT task_id — captured at job start, used to link cloud task",
null=True,
),
),
migrations.CreateModel(
name="BambuCloudTask",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"task_id",
models.BigIntegerField(
db_index=True,
help_text="Bambu Cloud task ID (matches MQTT task_id)",
unique=True,
),
),
(
"design_id",
models.IntegerField(
blank=True, help_text="Makerworld design ID", null=True
),
),
(
"design_title",
models.CharField(
blank=True,
help_text="Human project name from Makerworld (designTitle)",
max_length=500,
),
),
(
"plate_title",
models.CharField(
blank=True,
help_text="Plate/variant name (matches MQTT subtask_name)",
max_length=500,
),
),
("model_id", models.CharField(blank=True, max_length=100)),
(
"profile_id",
models.BigIntegerField(
blank=True, help_text="Bambu Cloud profile ID", null=True
),
),
("plate_index", models.SmallIntegerField(blank=True, null=True)),
(
"device_serial",
models.CharField(
blank=True,
help_text="Printer serial number from cloud",
max_length=100,
),
),
(
"cover_url",
models.URLField(
blank=True,
help_text="Plate preview image URL from S3",
max_length=1000,
),
),
(
"weight_grams",
models.DecimalField(
blank=True,
decimal_places=2,
help_text="Actual filament weight reported by cloud",
max_digits=8,
null=True,
),
),
(
"length_mm",
models.IntegerField(
blank=True, help_text="Filament length in mm", null=True
),
),
(
"cost_time_seconds",
models.IntegerField(
blank=True,
help_text="Cloud-measured print duration in seconds",
null=True,
),
),
(
"cloud_status",
models.SmallIntegerField(
blank=True, help_text="2=finish, 3=failed", null=True
),
),
("bed_type", models.CharField(blank=True, max_length=50)),
("use_ams", models.BooleanField(default=True)),
(
"print_mode",
models.CharField(
blank=True, help_text="cloud_file, local, etc.", max_length=50
),
),
(
"ams_detail_mapping",
models.JSONField(
default=list,
help_text="Per-slot filament weight breakdown from cloud",
),
),
("cloud_start_time", models.DateTimeField(blank=True, null=True)),
("cloud_end_time", models.DateTimeField(blank=True, null=True)),
(
"raw_data",
models.JSONField(
default=dict,
help_text="Full task response — preserved for future use",
),
),
("synced_at", models.DateTimeField(auto_now=True)),
],
options={
"verbose_name": "Bambu Cloud Task",
"verbose_name_plural": "Bambu Cloud Tasks",
"db_table": "infrastructure_cloud_task",
"ordering": ["-cloud_start_time"],
"indexes": [
models.Index(
fields=["task_id"], name="infrastruct_task_id_95b5ab_idx"
),
models.Index(
fields=["design_id"], name="infrastruct_design__88bdc0_idx"
),
models.Index(
fields=["-cloud_start_time"],
name="infrastruct_cloud_s_4078b0_idx",
),
],
},
),
migrations.AddField(
model_name="printjob",
name="cloud_task",
field=models.ForeignKey(
blank=True,
help_text="Linked Bambu Cloud task record (set by bambu_sync_cloud or collector)",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="print_jobs",
to="bambu_run.bambucloudtask",
),
),
]

View File

@@ -0,0 +1,90 @@
# Generated by Django 5.2.8 on 2026-05-07 04:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bambu_run", "0003_cloud_task"),
]
operations = [
migrations.AddField(
model_name="filament",
name="ams_type",
field=models.CharField(
blank=True,
choices=[
("AMS", "AMS"),
("AMS 2 Pro", "AMS 2 Pro"),
("AMS HT", "AMS HT"),
],
default="",
help_text="Type of the AMS unit this spool is loaded in (AMS / AMS 2 Pro / AMS HT)",
max_length=32,
),
),
migrations.AddField(
model_name="filament",
name="ams_unit_id",
field=models.PositiveSmallIntegerField(
blank=True,
db_index=True,
help_text="Which physical AMS unit this spool is loaded in (matches MQTT ams[i].id; 128 = AMS HT)",
null=True,
),
),
migrations.AddField(
model_name="printermetrics",
name="nozzle_diameter_left",
field=models.DecimalField(
blank=True,
decimal_places=2,
help_text="Left nozzle diameter (mm). H2C only.",
max_digits=3,
null=True,
),
),
migrations.AddField(
model_name="printermetrics",
name="nozzle_target_temp_left",
field=models.DecimalField(
blank=True,
decimal_places=2,
help_text="Left extruder target temperature (°C). H2C only.",
max_digits=5,
null=True,
),
),
migrations.AddField(
model_name="printermetrics",
name="nozzle_temp_left",
field=models.DecimalField(
blank=True,
decimal_places=2,
help_text="Left extruder current temperature (°C). H2C only.",
max_digits=5,
null=True,
),
),
migrations.AddField(
model_name="printermetrics",
name="nozzle_type_left",
field=models.CharField(
blank=True,
help_text="Left nozzle type (e.g. HS01-0.4). H2C only.",
max_length=50,
null=True,
),
),
migrations.AlterField(
model_name="filament",
name="current_tray_id",
field=models.IntegerField(
blank=True,
help_text="Tray slot index within its AMS unit (0-3 for AMS/AMS 2 Pro, 0 for AMS HT)",
null=True,
),
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 5.2.8 on 2026-06-18 12:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bambu_run", "0004_h2c_dual_nozzle_and_ams_fields"),
]
operations = [
migrations.AddField(
model_name="printermetrics",
name="vortek_raw",
field=models.JSONField(
blank=True,
default=dict,
help_text="Raw print.device MQTT payload (Vortek rack groundwork)",
),
),
]

View File

@@ -0,0 +1,63 @@
# Generated by Django 5.2.8 on 2026-06-20 12:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bambu_run", "0005_printermetrics_vortek_raw"),
]
operations = [
migrations.AlterModelOptions(
name="filamentsnapshot",
options={
"ordering": ["printer_metric", "ams_unit_id", "tray_id"],
"verbose_name": "Filament Snapshot",
"verbose_name_plural": "Filament Snapshots",
},
),
migrations.AddField(
model_name="filamentsnapshot",
name="ams_type",
field=models.CharField(
blank=True,
choices=[
("AMS", "AMS"),
("AMS 2 Pro", "AMS 2 Pro"),
("AMS HT", "AMS HT"),
],
default="",
help_text="Type of the AMS unit this tray belongs to (AMS / AMS 2 Pro / AMS HT)",
max_length=32,
),
),
migrations.AddField(
model_name="filamentsnapshot",
name="ams_unit_id",
field=models.PositiveSmallIntegerField(
blank=True,
db_index=True,
help_text="Which physical AMS unit this tray belongs to (matches MQTT ams[i].id; 128 = AMS HT)",
null=True,
),
),
migrations.AddField(
model_name="filamentusage",
name="ams_unit_id",
field=models.PositiveSmallIntegerField(
blank=True,
db_index=True,
help_text="Which physical AMS unit the slot belongs to (matches MQTT ams[i].id; 128 = AMS HT)",
null=True,
),
),
migrations.AddIndex(
model_name="filamentsnapshot",
index=models.Index(
fields=["printer_metric", "ams_unit_id", "tray_id"],
name="infrastruct_printer_2ad168_idx",
),
),
]

View File

@@ -0,0 +1,172 @@
# Generated by Django 5.2.8 on 2026-06-20 14:07
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bambu_run", "0006_alter_filamentsnapshot_options_and_more"),
]
operations = [
migrations.CreateModel(
name="Hotend",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("serial_number", models.CharField(db_index=True, max_length=100)),
(
"nozzle_type",
models.CharField(blank=True, default="", max_length=50),
),
(
"diameter",
models.DecimalField(
blank=True, decimal_places=2, max_digits=3, null=True
),
),
(
"raw_id",
models.PositiveSmallIntegerField(
help_text="Last-seen MQTT device.nozzle.info[].id"
),
),
(
"slot_number",
models.PositiveSmallIntegerField(
blank=True,
help_text="Rack bay 1-6, derived from raw_id 16-21. Null if currently unknown (e.g. mounted on toolhead and id reports as the 0 sentinel).",
null=True,
),
),
(
"is_toolhead",
models.BooleanField(
default=False,
help_text="True if currently mounted on the toolhead under normal polling (raw_id == 0).",
),
),
(
"last_filament_profile_id",
models.CharField(
blank=True,
default="",
help_text="Bambu material profile id of the filament last loaded (MQTT fila_id, e.g. 'GFA01')",
max_length=20,
),
),
(
"last_color",
models.CharField(
blank=True,
default="",
help_text="6-char hex of the filament last loaded (MQTT color_m, alpha stripped)",
max_length=6,
),
),
("used_time_seconds", models.PositiveIntegerField(default=0)),
(
"wear_percent",
models.DecimalField(
decimal_places=2,
default=0,
help_text="MQTT wear (0-128 scale) converted to a 0-100 percent",
max_digits=5,
),
),
("last_seen_at", models.DateTimeField(auto_now=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"printer",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="hotends",
to="bambu_run.printer",
),
),
],
options={
"verbose_name": "Hotend",
"verbose_name_plural": "Hotends",
"db_table": "infrastructure_hotend",
"ordering": ["printer", "-is_toolhead", "slot_number", "serial_number"],
"unique_together": {("printer", "serial_number")},
},
),
migrations.CreateModel(
name="HotendSnapshot",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("raw_id", models.PositiveSmallIntegerField()),
("used_time_seconds", models.PositiveIntegerField(default=0)),
(
"wear_percent",
models.DecimalField(decimal_places=2, default=0, max_digits=5),
),
(
"stat",
models.IntegerField(
blank=True,
help_text="Raw MQTT status code for this hotend",
null=True,
),
),
(
"timestamp",
models.DateTimeField(
db_index=True, default=django.utils.timezone.now
),
),
(
"hotend",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="snapshots",
to="bambu_run.hotend",
),
),
(
"printer_metric",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="hotend_snapshots",
to="bambu_run.printermetrics",
),
),
],
options={
"verbose_name": "Hotend Snapshot",
"verbose_name_plural": "Hotend Snapshots",
"db_table": "infrastructure_hotend_snapshot",
"ordering": ["printer_metric", "hotend"],
"indexes": [
models.Index(
fields=["printer_metric", "hotend"],
name="infrastruct_printer_b528aa_idx",
),
models.Index(
fields=["hotend", "-timestamp"],
name="infrastruct_hotend__691f7e_idx",
),
],
},
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 5.2.8 on 2026-06-20 14:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bambu_run", "0007_hotend_hotendsnapshot"),
]
operations = [
migrations.AddField(
model_name="printermetrics",
name="nozzle_info",
field=models.JSONField(
blank=True,
default=list,
help_text="Parsed per-poll nozzle/hotend info list",
),
),
]

View File

@@ -2,6 +2,36 @@ from django.db import models
from django.utils import timezone
# Bambu AMS model-code → human-readable type label.
# Source: live H2C MQTT probe — `print.ams.ams[i].info` field.
# Add new codes as they are observed (e.g. AMS Lite, future variants).
AMS_INFO_TO_TYPE = {
"1001": "AMS",
"1003": "AMS 2 Pro",
"2104": "AMS HT",
}
AMS_TYPE_CHOICES = [
("AMS", "AMS"),
("AMS 2 Pro", "AMS 2 Pro"),
("AMS HT", "AMS HT"),
]
def ams_type_from_info(info_code) -> str:
"""Resolve an AMS unit's `info` model code to a human label.
Real MQTT `info` codes are 8 characters (e.g. "10001003") with the type encoded
in the last 4 digits — confirmed against a live H2C with AMS 2 Pro / AMS / AMS HT.
Fall back to an exact match for the bare 4-digit form in case other firmware
reports it short.
"""
if not info_code:
return ""
code = str(info_code)
return AMS_INFO_TO_TYPE.get(code[-4:], "") or AMS_INFO_TO_TYPE.get(code, "")
class Printer(models.Model):
"""Represents a Bambu Lab 3D printer device"""
@@ -58,12 +88,32 @@ class PrinterMetrics(models.Model):
max_digits=5, decimal_places=2, null=True, blank=True
)
# Nozzle info
# Nozzle info — single-nozzle / right-side back-compat fields. On dual-nozzle
# printers (H2C) these mirror the right extruder; the left extruder uses the
# `_left` columns below.
nozzle_diameter = models.DecimalField(
max_digits=3, decimal_places=2, null=True, blank=True
)
nozzle_type = models.CharField(max_length=50, null=True, blank=True)
# H2C dual-nozzle: left-side fields (NULL on single-nozzle printers).
nozzle_temp_left = models.DecimalField(
max_digits=5, decimal_places=2, null=True, blank=True,
help_text="Left extruder current temperature (°C). H2C only."
)
nozzle_target_temp_left = models.DecimalField(
max_digits=5, decimal_places=2, null=True, blank=True,
help_text="Left extruder target temperature (°C). H2C only."
)
nozzle_diameter_left = models.DecimalField(
max_digits=3, decimal_places=2, null=True, blank=True,
help_text="Left nozzle diameter (mm). H2C only."
)
nozzle_type_left = models.CharField(
max_length=50, null=True, blank=True,
help_text="Left nozzle type (e.g. HS01-0.4). H2C only."
)
# Print job status
gcode_state = models.CharField(
max_length=50, null=True, blank=True, help_text="FINISH, RUNNING, IDLE, etc."
@@ -184,6 +234,23 @@ class PrinterMetrics(models.Model):
default=list, help_text="Light status report [{node, mode}]"
)
# Groundwork for H2C's Vortek nozzle-changer rack (6 swappable hotends + 1 fixed
# left nozzle) — the full MQTT schema for per-slot state isn't confirmed yet, so
# the raw `print.device` payload is captured here unfiltered to avoid losing data
# ahead of proper per-slot modeling.
vortek_raw = models.JSONField(
default=dict, blank=True, help_text="Raw print.device MQTT payload (Vortek rack groundwork)"
)
# Parsed device.nozzle.info[] from this poll, one dict per entry (mirrors
# HotendInfo.to_dict()). Includes induction-chip hotends *and* non-inductive
# nozzle positions (e.g. H2C's fixed left nozzle) that have no stable serial
# number to key a Hotend registry row on — kept here so the dashboard can show
# their readable type/diameter without claiming an identity/history we don't have.
nozzle_info = models.JSONField(
default=list, blank=True, help_text="Parsed per-poll nozzle/hotend info list"
)
class Meta:
db_table = "infrastructure_printer_metrics"
verbose_name = "Printer Metric"
@@ -259,6 +326,10 @@ class FilamentColor(models.Model):
default='Bambu Lab',
help_text="Manufacturer name"
)
is_transparent = models.BooleanField(
default=False,
help_text="True for clear/transparent filaments — display as checkerboard, not solid color"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@@ -329,6 +400,10 @@ class Filament(models.Model):
max_length=7, null=True, blank=True,
help_text="Color hex code for display (#RRGGBB)"
)
is_transparent = models.BooleanField(
default=False,
help_text="True for clear/transparent filaments — display as checkerboard, not solid color"
)
# Physical properties
diameter = models.DecimalField(
@@ -357,7 +432,16 @@ class Filament(models.Model):
)
current_tray_id = models.IntegerField(
null=True, blank=True,
help_text="Which AMS slot (0-3) if loaded"
help_text="Tray slot index within its AMS unit (0-3 for AMS/AMS 2 Pro, 0 for AMS HT)"
)
ams_unit_id = models.PositiveSmallIntegerField(
null=True, blank=True, db_index=True,
help_text="Which physical AMS unit this spool is loaded in (matches MQTT ams[i].id; 128 = AMS HT)"
)
ams_type = models.CharField(
max_length=32, blank=True, default="",
choices=AMS_TYPE_CHOICES,
help_text="Type of the AMS unit this spool is loaded in (AMS / AMS 2 Pro / AMS HT)"
)
last_loaded_date = models.DateTimeField(
null=True, blank=True,
@@ -426,6 +510,15 @@ class FilamentSnapshot(models.Model):
max_length=20, null=True, blank=True,
help_text="Slot identifier like A00-W1"
)
ams_unit_id = models.PositiveSmallIntegerField(
null=True, blank=True, db_index=True,
help_text="Which physical AMS unit this tray belongs to (matches MQTT ams[i].id; 128 = AMS HT)"
)
ams_type = models.CharField(
max_length=32, blank=True, default="",
choices=AMS_TYPE_CHOICES,
help_text="Type of the AMS unit this tray belongs to (AMS / AMS 2 Pro / AMS HT)"
)
type = models.CharField(max_length=50, null=True, blank=True)
sub_type = models.CharField(
@@ -473,9 +566,10 @@ class FilamentSnapshot(models.Model):
db_table = "infrastructure_filament_snapshot"
verbose_name = "Filament Snapshot"
verbose_name_plural = "Filament Snapshots"
ordering = ['printer_metric', 'tray_id']
ordering = ['printer_metric', 'ams_unit_id', 'tray_id']
indexes = [
models.Index(fields=['printer_metric', 'tray_id']),
models.Index(fields=['printer_metric', 'ams_unit_id', 'tray_id']),
models.Index(fields=['filament']),
]
@@ -484,6 +578,47 @@ class FilamentSnapshot(models.Model):
return f"Tray {self.tray_id}: {filament_info}"
class BambuCloudTask(models.Model):
"""Cloud task record synced from Bambu Cloud API (v1/user-service/my/tasks)."""
task_id = models.BigIntegerField(unique=True, db_index=True, help_text="Bambu Cloud task ID (matches MQTT task_id)")
design_id = models.IntegerField(null=True, blank=True, help_text="Makerworld design ID")
design_title = models.CharField(max_length=500, blank=True, help_text="Human project name from Makerworld (designTitle)")
plate_title = models.CharField(max_length=500, blank=True, help_text="Plate/variant name (matches MQTT subtask_name)")
model_id = models.CharField(max_length=100, blank=True)
profile_id = models.BigIntegerField(null=True, blank=True, help_text="Bambu Cloud profile ID")
plate_index = models.SmallIntegerField(null=True, blank=True)
device_serial = models.CharField(max_length=100, blank=True, help_text="Printer serial number from cloud")
cover_url = models.URLField(max_length=1000, blank=True, help_text="Plate preview image URL from S3")
weight_grams = models.DecimalField(max_digits=8, decimal_places=2, null=True, blank=True, help_text="Actual filament weight reported by cloud")
length_mm = models.IntegerField(null=True, blank=True, help_text="Filament length in mm")
cost_time_seconds = models.IntegerField(null=True, blank=True, help_text="Cloud-measured print duration in seconds")
cloud_status = models.SmallIntegerField(null=True, blank=True, help_text="2=finish, 3=failed")
bed_type = models.CharField(max_length=50, blank=True)
use_ams = models.BooleanField(default=True)
print_mode = models.CharField(max_length=50, blank=True, help_text="cloud_file, local, etc.")
ams_detail_mapping = models.JSONField(default=list, help_text="Per-slot filament weight breakdown from cloud")
cloud_start_time = models.DateTimeField(null=True, blank=True)
cloud_end_time = models.DateTimeField(null=True, blank=True)
raw_data = models.JSONField(default=dict, help_text="Full task response — preserved for future use")
synced_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "infrastructure_cloud_task"
verbose_name = "Bambu Cloud Task"
verbose_name_plural = "Bambu Cloud Tasks"
ordering = ["-cloud_start_time"]
indexes = [
models.Index(fields=["task_id"]),
models.Index(fields=["design_id"]),
models.Index(fields=["-cloud_start_time"]),
]
def __str__(self):
name = self.design_title or self.plate_title or f"task-{self.task_id}"
return f"{name} ({self.cloud_start_time.strftime('%Y-%m-%d') if self.cloud_start_time else 'unknown date'})"
class PrintJob(models.Model):
"""Represents a single print job from start to finish"""
@@ -497,6 +632,16 @@ class PrintJob(models.Model):
)
gcode_file = models.CharField(max_length=200, null=True, blank=True)
cloud_task = models.ForeignKey(
'BambuCloudTask', on_delete=models.SET_NULL,
null=True, blank=True, related_name='print_jobs',
help_text="Linked Bambu Cloud task record (set by bambu_sync_cloud or collector)"
)
cloud_task_id_raw = models.BigIntegerField(
null=True, blank=True, db_index=True,
help_text="MQTT task_id — captured at job start, used to link cloud task"
)
start_time = models.DateTimeField(help_text="When print started")
end_time = models.DateTimeField(null=True, blank=True, help_text="When print finished/failed")
duration_minutes = models.IntegerField(null=True, blank=True, help_text="Total print duration")
@@ -536,6 +681,13 @@ class PrintJob(models.Model):
status = self.final_status or 'In Progress'
return f"{self.project_name} ({status}) - {self.start_time.strftime('%Y-%m-%d %H:%M')}"
@property
def display_name(self):
"""Human-readable job name: cloud design_title if available, else project_name."""
if self.cloud_task_id and self.cloud_task and self.cloud_task.design_title:
return self.cloud_task.design_title
return self.project_name
def calculate_duration(self):
"""Calculate print duration if end_time is set"""
if self.end_time and self.start_time:
@@ -556,6 +708,10 @@ class FilamentUsage(models.Model):
)
tray_id = models.IntegerField(help_text="Which AMS slot was used")
ams_unit_id = models.PositiveSmallIntegerField(
null=True, blank=True, db_index=True,
help_text="Which physical AMS unit the slot belongs to (matches MQTT ams[i].id; 128 = AMS HT)"
)
starting_percent = models.IntegerField(help_text="Filament remaining % at job start")
ending_percent = models.IntegerField(
@@ -593,3 +749,108 @@ class FilamentUsage(models.Model):
self.consumed_grams = int(
self.filament.initial_weight_grams * (self.consumed_percent / 100.0)
)
class Hotend(models.Model):
"""Registry of individual Vortek hotends, keyed by serial number.
A Vortek rack holds up to 6 swappable hotends (bays, MQTT `id` 16-21) plus
1 mounted on the toolhead at a time (MQTT `id` 0). `raw_id` reflects whichever
address was last seen on the wire for this hotend; `slot_number` is only set
when that address falls in the 16-21 rack-bay range — confirmed by watching
a "Read All" MQTT capture reassign a toolhead-mounted hotend's id from 0 to
its true bay id.
"""
printer = models.ForeignKey(
'Printer', on_delete=models.CASCADE, related_name='hotends'
)
serial_number = models.CharField(max_length=100, db_index=True)
nozzle_type = models.CharField(max_length=50, blank=True, default="")
diameter = models.DecimalField(
max_digits=3, decimal_places=2, null=True, blank=True
)
raw_id = models.PositiveSmallIntegerField(
help_text="Last-seen MQTT device.nozzle.info[].id"
)
slot_number = models.PositiveSmallIntegerField(
null=True, blank=True,
help_text="Rack bay 1-6, derived from raw_id 16-21. Null if currently unknown (e.g. mounted on toolhead and id reports as the 0 sentinel)."
)
is_toolhead = models.BooleanField(
default=False,
help_text="True if currently mounted on the toolhead under normal polling (raw_id == 0)."
)
last_filament_profile_id = models.CharField(
max_length=20, blank=True, default="",
help_text="Bambu material profile id of the filament last loaded (MQTT fila_id, e.g. 'GFA01')"
)
last_color = models.CharField(
max_length=6, blank=True, default="",
help_text="6-char hex of the filament last loaded (MQTT color_m, alpha stripped)"
)
used_time_seconds = models.PositiveIntegerField(default=0)
wear_percent = models.DecimalField(
max_digits=5, decimal_places=2, default=0,
help_text="MQTT wear (0-128 scale) converted to a 0-100 percent"
)
last_seen_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "infrastructure_hotend"
verbose_name = "Hotend"
verbose_name_plural = "Hotends"
ordering = ['printer', '-is_toolhead', 'slot_number', 'serial_number']
unique_together = [['printer', 'serial_number']]
def __str__(self):
location = "Toolhead" if self.is_toolhead else (
f"Slot {self.slot_number}" if self.slot_number else "Rack"
)
return f"{self.serial_number} ({location})"
@property
def used_time_display(self) -> str:
hours, remainder = divmod(self.used_time_seconds, 3600)
minutes = remainder // 60
return f"{hours}h {minutes}m" if hours else f"{minutes}m"
class HotendSnapshot(models.Model):
"""Point-in-time reading of a Hotend, one row per collector poll."""
printer_metric = models.ForeignKey(
'PrinterMetrics', on_delete=models.CASCADE,
related_name='hotend_snapshots'
)
hotend = models.ForeignKey(
'Hotend', on_delete=models.CASCADE,
related_name='snapshots'
)
raw_id = models.PositiveSmallIntegerField()
used_time_seconds = models.PositiveIntegerField(default=0)
wear_percent = models.DecimalField(max_digits=5, decimal_places=2, default=0)
stat = models.IntegerField(
null=True, blank=True, help_text="Raw MQTT status code for this hotend"
)
timestamp = models.DateTimeField(default=timezone.now, db_index=True)
class Meta:
db_table = "infrastructure_hotend_snapshot"
verbose_name = "Hotend Snapshot"
verbose_name_plural = "Hotend Snapshots"
ordering = ['printer_metric', 'hotend']
indexes = [
models.Index(fields=['printer_metric', 'hotend']),
models.Index(fields=['hotend', '-timestamp']),
]
def __str__(self):
return f"{self.hotend.serial_number} @ {self.timestamp.strftime('%Y-%m-%d %H:%M:%S')}"

View File

@@ -296,6 +296,73 @@ class AMSState:
return loaded
@dataclass
class HotendInfo:
"""A single hotend reported in `device.nozzle.info[]` (Vortek rack).
`raw_id` semantics (confirmed by watching a live "Read All" MQTT capture):
0 = currently mounted on the (swappable) toolhead — the sentinel hides the
true bay address until "Read All" resolves it; 1 = the fixed left nozzle on
dual-nozzle printers (no RFID chip, always reports sn="N/A"); 16-21 = rack
bay address, slot_number = raw_id - 15 (1-6).
"""
raw_id: int = 0
serial_number: str = ""
nozzle_type: str = ""
diameter: float = 0.4
fila_id: str = ""
color: Optional[str] = None
used_time_seconds: int = 0
wear_percent: float = 0.0
stat: int = 0
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "HotendInfo":
from .utils import strip_color_padding
return cls(
raw_id=int(data.get("id", 0)),
serial_number=data.get("sn", ""),
nozzle_type=data.get("type", ""),
diameter=float(data.get("diameter", 0.4)),
fila_id=data.get("fila_id", ""),
color=strip_color_padding(data.get("color_m")),
used_time_seconds=int(data.get("p_t", 0)),
wear_percent=round(float(data.get("wear", 0.0)) / 128.0 * 100, 2),
stat=int(data.get("stat", 0)),
)
@property
def is_toolhead(self) -> bool:
return self.raw_id == 0
@property
def is_empty(self) -> bool:
return self.serial_number in ("", "N/A")
@property
def slot_number(self) -> Optional[int]:
if 16 <= self.raw_id <= 21:
return self.raw_id - 15
return None
def to_dict(self) -> Dict[str, Any]:
return {
"raw_id": self.raw_id,
"serial_number": self.serial_number,
"nozzle_type": self.nozzle_type,
"diameter": self.diameter,
"fila_id": self.fila_id,
"color": self.color,
"used_time_seconds": self.used_time_seconds,
"wear_percent": self.wear_percent,
"stat": self.stat,
"is_toolhead": self.is_toolhead,
"is_empty": self.is_empty,
"slot_number": self.slot_number,
}
@dataclass
class PrinterState:
"""Complete printer state parsed from MQTT data"""
@@ -335,10 +402,16 @@ class PrinterState:
wifi_signal: str = ""
wifi_signal_dbm: int = 0
# Nozzle info
# Nozzle info — single-nozzle / right-side back-compat fields.
nozzle_diameter: float = 0.4
nozzle_type: str = ""
# H2C dual-nozzle: left-side fields (None on single-nozzle printers).
nozzle_temp_left: Optional[float] = None
nozzle_target_temp_left: Optional[float] = None
nozzle_diameter_left: Optional[float] = None
nozzle_type_left: Optional[str] = None
# System status
home_flag: int = 0
hw_switch_state: int = 0
@@ -382,6 +455,9 @@ class PrinterState:
# External spool (virtual tray)
vt_tray: Optional[Dict[str, Any]] = None
# Vortek hotend rack (device.nozzle.info[])
hotends: List[HotendInfo] = field(default_factory=list)
# Raw data for any additional fields
_raw_data: Dict[str, Any] = field(default_factory=dict, repr=False)
@@ -410,6 +486,27 @@ class PrinterState:
wifi_signal = print_data.get("wifi_signal", "")
# H2C dual-nozzle decoding. The H2C reports per-extruder temperatures
# under `print.device.extruder.info[]` as a 2-element array (index 0 =
# right, index 1 = left). The `temp` field is bit-packed:
# `temp_raw = (target << 16) | current`, both °C as ints.
nozzle_temp_left = None
nozzle_target_temp_left = None
device = print_data.get("device") or {}
extruders = (device.get("extruder") or {}).get("info") or []
if len(extruders) >= 2:
left = extruders[1]
t = left.get("temp")
if isinstance(t, int):
nozzle_target_temp_left = float((t >> 16) & 0xFFFF)
nozzle_temp_left = float(t & 0xFFFF)
# Vortek hotend rack: device.nozzle.info[] — one entry per hotend.
hotends = [
HotendInfo.from_dict(h)
for h in (device.get("nozzle") or {}).get("info") or []
]
return cls(
timestamp=timestamp,
sequence_id=str(print_data.get("sequence_id", "")),
@@ -438,6 +535,13 @@ class PrinterState:
wifi_signal_dbm=cls._parse_wifi_signal(wifi_signal),
nozzle_diameter=float(print_data.get("nozzle_diameter", 0.4)),
nozzle_type=print_data.get("nozzle_type", ""),
nozzle_temp_left=nozzle_temp_left,
nozzle_target_temp_left=nozzle_target_temp_left,
# Diameter/type per side: H2C currently uses uniform nozzles, so reuse top-level
# values. If a future probe shows per-side diameter/type variance, plumb it from
# `device.nozzle.info[]` cross-referenced against `device.extruder.info[i].id`.
nozzle_diameter_left=float(print_data.get("nozzle_diameter", 0.4)) if nozzle_temp_left is not None else None,
nozzle_type_left=print_data.get("nozzle_type", "") if nozzle_temp_left is not None else None,
home_flag=int(print_data.get("home_flag", 0)),
hw_switch_state=int(print_data.get("hw_switch_state", 0)),
mc_print_stage=str(print_data.get("mc_print_stage", "")),
@@ -459,6 +563,7 @@ class PrinterState:
gcode_file_prepare_percent=str(print_data.get("gcode_file_prepare_percent", "")),
lifecycle=print_data.get("lifecycle", ""),
vt_tray=print_data.get("vt_tray"),
hotends=hotends,
_raw_data=data,
)
@@ -473,6 +578,14 @@ class PrinterState:
"chamber_temp": round(self.chamber_temp, 2),
"nozzle_diameter": self.nozzle_diameter,
"nozzle_type": self.nozzle_type,
"nozzle_temp_left": (
round(self.nozzle_temp_left, 2) if self.nozzle_temp_left is not None else None
),
"nozzle_target_temp_left": (
round(self.nozzle_target_temp_left, 2) if self.nozzle_target_temp_left is not None else None
),
"nozzle_diameter_left": self.nozzle_diameter_left,
"nozzle_type_left": self.nozzle_type_left,
"gcode_state": self.gcode_state,
"print_type": self.print_type,
"print_percent": self.print_percent,
@@ -482,6 +595,8 @@ class PrinterState:
"print_line_number": self.print_line_number,
"subtask_name": self.subtask_name,
"gcode_file": self.gcode_file,
"task_id": self.task_id,
"project_id": self.project_id,
"cooling_fan_speed": self.cooling_fan_speed,
"heatbreak_fan_speed": self.heatbreak_fan_speed,
"big_fan1_speed": self.big_fan1_speed,
@@ -491,6 +606,11 @@ class PrinterState:
"wifi_signal_dbm": self.wifi_signal_dbm,
"print_error": self.print_error,
"has_errors": self.print_error != 0,
# Full `print.device` payload, unfiltered. H2C's Vortek rack (6 swappable
# hotends + 1 fixed left nozzle) isn't fully modeled yet — stash everything
# here so no data is lost once the real Vortek MQTT schema is confirmed.
"vortek_raw": self._raw_data.get("print", {}).get("device", {}),
"hotends": [h.to_dict() for h in self.hotends],
"hms": self.hms,
"stg_cur": self.stg_cur,
"lights_report": self.lights_report,
@@ -513,8 +633,19 @@ class PrinterState:
snapshot["tray_now"] = self.ams.tray_now
snapshot["ams_version"] = self.ams.version
from .models import ams_type_from_info
filaments = []
for unit in self.ams.units:
# `unit_id` is the AMS unit's own id from the MQTT payload — for the
# original AMS / AMS 2 Pro it's a small int (0,1,2,...); for AMS HT
# it has the 0x80 bit set (e.g. 128). Don't compute tray_id // 4 —
# multi-AMS-type setups are not contiguous.
try:
unit_id_int = int(unit.unit_id)
except (TypeError, ValueError):
unit_id_int = None
ams_type_label = ams_type_from_info(unit.info)
for tray in unit.trays:
if tray.tray_type:
filaments.append({
@@ -540,6 +671,9 @@ class PrinterState:
"tray_bed_temp": tray.tray_bed_temp,
"bed_temp_type": tray.bed_temp_type,
"cols": tray.cols,
"ams_unit_id": unit_id_int,
"ams_info": unit.info,
"ams_type": ams_type_label,
})
snapshot["filaments"] = filaments
@@ -550,6 +684,7 @@ class PrinterState:
"ams_id": unit.ams_id,
"chip_id": unit.chip_id,
"info": unit.info,
"ams_type": ams_type_from_info(unit.info),
"humidity": unit.humidity,
"humidity_raw": unit.humidity_raw,
"temp": unit.temp,

View File

@@ -64,3 +64,93 @@
opacity: 0.9;
color: rgba(255, 255, 255, 0.9);
}
/* AMS unit type colors — CSS variables so RAE/standalone can override per theme */
:root {
--ams-badge-ams: #6c757d;
--ams-badge-ams-2-pro: #0d6efd;
--ams-badge-ams-ht: #fd7e14;
--ams-group-border-color: rgba(0, 0, 0, 0.15);
}
[data-coreui-theme="dark"] {
--ams-group-border-color: rgba(255, 255, 255, 0.2);
}
.ams-badge-ams {
background-color: var(--ams-badge-ams);
color: #fff;
}
.ams-badge-ams-2-pro {
background-color: var(--ams-badge-ams-2-pro);
color: #fff;
}
.ams-badge-ams-ht {
background-color: var(--ams-badge-ams-ht);
color: #fff;
}
.ams-filter-pills {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.ams-filter-pill {
border-radius: 50rem;
padding: 0.25rem 0.9rem;
font-size: 0.85rem;
border: 1px solid var(--ams-group-border-color);
background-color: transparent;
opacity: 0.6;
}
.ams-filter-pill.active {
opacity: 1;
font-weight: 600;
border-color: currentColor;
}
/* Grouped AMS unit panels — wide (multi-slot) units stack one per row,
compact (single-slot, e.g. AMS HT) units flow side-by-side and wrap. */
.ams-groups {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.ams-group {
border-radius: 0.5rem;
padding: 0.75rem;
border: 1px solid var(--ams-group-border-color);
}
.ams-group--wide {
flex: 1 1 100%;
}
.ams-group--compact {
flex: 0 1 auto;
min-width: 220px;
}
.ams-badge-bg-ams {
background-color: color-mix(in srgb, var(--ams-badge-ams) 8%, transparent);
border-left: 3px solid var(--ams-badge-ams);
}
.ams-badge-bg-ams-2-pro {
background-color: color-mix(in srgb, var(--ams-badge-ams-2-pro) 8%, transparent);
border-left: 3px solid var(--ams-badge-ams-2-pro);
}
.ams-badge-bg-ams-ht {
background-color: color-mix(in srgb, var(--ams-badge-ams-ht) 8%, transparent);
border-left: 3px solid var(--ams-badge-ams-ht);
}
.ams-badge-bg- {
border-left: 3px solid var(--ams-group-border-color);
}

View File

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

View File

@@ -1,7 +1,7 @@
// 3D Printer Charts Initialization and Management
// Chart.js implementation for printer metrics visualization
let nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart;
let nozzleTempChart, nozzleTempLeftChart, bedTempChart, printProgressChart, fanSpeedsChart;
let wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart;
function showNoDataMessage(canvasId) {
@@ -75,6 +75,50 @@ function initPrinterCharts(printerData, apiUrl) {
options: getTemperatureChartOptions(tickColor, gridColor, '°C')
});
// Initialize Left Nozzle Temperature Chart (H2C-class dual-nozzle).
// Mounted only when the canvas exists AND the API returned non-null
// left-side samples — single-nozzle printers leave the column NULL.
const nozzleLeftCanvas = document.getElementById('nozzleTempLeftChart');
const hasLeftData = Array.isArray(printerData.nozzle_temp_left)
&& printerData.nozzle_temp_left.some(v => v !== null && v !== undefined);
if (nozzleLeftCanvas && hasLeftData) {
const nozzleLeftCtx = nozzleLeftCanvas.getContext('2d');
nozzleTempLeftChart = new Chart(nozzleLeftCtx, {
type: 'line',
data: {
labels: printerData.timestamps,
datasets: [
{
label: 'Actual Temp (Left)',
data: printerData.nozzle_temp_left,
borderColor: 'rgb(54, 162, 235)',
backgroundColor: 'rgba(54, 162, 235, 0.1)',
tension: 0.3,
borderWidth: 2,
pointRadius: 0,
pointHoverRadius: 3,
spanGaps: true
},
{
label: 'Target Temp (Left)',
data: printerData.nozzle_target_temp_left,
borderColor: 'rgb(153, 102, 255)',
backgroundColor: 'rgba(153, 102, 255, 0.05)',
borderDash: [5, 5],
tension: 0.3,
borderWidth: 2,
pointRadius: 0,
pointHoverRadius: 3,
spanGaps: true
}
]
},
options: getTemperatureChartOptions(tickColor, gridColor, '°C')
});
} else if (nozzleLeftCanvas) {
showNoDataMessage('nozzleTempLeftChart');
}
// Initialize Bed Temperature Chart
const bedCtx = document.getElementById('bedTempChart').getContext('2d');
bedTempChart = new Chart(bedCtx, {
@@ -581,8 +625,23 @@ function createFilamentDatasets(filamentTimeline, timestamps) {
data: filamentTimeline[key]
}));
// Sort by tray_id (numeric first, External last), then by start_idx (chronological)
// Distinct (non-null/undefined) AMS units present in this timeline — used to decide
// whether labels need an AMS unit prefix (avoid noise for the common single-AMS case).
const distinctUnits = new Set(
filamentEntries
.map(e => e.data.ams_unit_id)
.filter(uid => uid !== null && uid !== undefined)
);
const showUnitPrefix = distinctUnits.size > 1;
// Sort by AMS unit, then tray_id (numeric first, External last), then by start_idx
filamentEntries.sort((a, b) => {
const unitA = a.data.ams_unit_id ?? -1;
const unitB = b.data.ams_unit_id ?? -1;
if (unitA !== unitB) {
return unitA - unitB;
}
const trayA = a.data.tray_id;
const trayB = b.data.tray_id;
@@ -615,6 +674,10 @@ function createFilamentDatasets(filamentTimeline, timestamps) {
displayLabel = `Tray ${filament.tray_id} (${filament.type})`;
}
if (showUnitPrefix && filament.ams_type) {
displayLabel = `${filament.ams_type} · ${displayLabel}`;
}
// Add brand if it's different from type (avoid redundancy)
if (filament.brand && filament.brand !== filament.type && filament.brand !== 'External') {
displayLabel += ` - ${filament.brand}`;
@@ -646,8 +709,20 @@ function hexToRgba(hex, alpha) {
function applyFilamentColors() {
// Apply colors to filament cards
document.querySelectorAll('.filament-card').forEach(card => {
const isTransparent = card.getAttribute('data-filament-transparent') === 'true';
const colorHex = card.getAttribute('data-filament-color');
if (colorHex) {
if (isTransparent) {
// Checkerboard left border and subtle background for clear filaments
card.style.borderLeft = '4px solid #aaa';
card.style.background = 'repeating-conic-gradient(rgba(180,180,180,0.15) 0% 25%, transparent 0% 50%) 0 0/10px 10px';
const badge = card.querySelector('.filament-badge');
if (badge) {
badge.style.backgroundColor = '#aaa';
badge.style.color = '#fff';
}
} else if (colorHex) {
const color = '#' + colorHex;
// Set card background with gradient
@@ -690,7 +765,7 @@ function updateChartTheme() {
// Update all charts
const charts = [
nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart,
nozzleTempChart, nozzleTempLeftChart, bedTempChart, printProgressChart, fanSpeedsChart,
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
];
@@ -792,7 +867,7 @@ function applyDateSeparatorsToAllPrinterCharts(timestamps, dates) {
const sepAnnotations = buildDateSeparatorAnnotations(timestamps, dates);
const charts = [
nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart,
nozzleTempChart, nozzleTempLeftChart, bedTempChart, printProgressChart, fanSpeedsChart,
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
];

View File

@@ -200,6 +200,13 @@ function updateAllPrinterCharts(data) {
{ data: data.nozzle_target_temp, datasetIndex: 1 }
]);
if (typeof nozzleTempLeftChart !== 'undefined' && nozzleTempLeftChart) {
updateChartData(nozzleTempLeftChart, data.timestamps, [
{ data: data.nozzle_temp_left || [], datasetIndex: 0 },
{ data: data.nozzle_target_temp_left || [], datasetIndex: 1 }
]);
}
updateChartData(bedTempChart, data.timestamps, [
{ data: data.bed_temp, datasetIndex: 0 },
{ data: data.bed_target_temp, datasetIndex: 1 }
@@ -269,7 +276,7 @@ function addProjectMarkersToCharts(markers, timestamps) {
console.log('Adding project markers:', markers);
const charts = [
nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart,
nozzleTempChart, nozzleTempLeftChart, bedTempChart, printProgressChart, fanSpeedsChart,
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
];
@@ -400,7 +407,7 @@ function resetPrinterControls() {
// Clear annotations and reload with original data
const charts = [
nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart,
nozzleTempChart, nozzleTempLeftChart, bedTempChart, printProgressChart, fanSpeedsChart,
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
];

View File

@@ -54,11 +54,19 @@
{% for color in colors %}
<tr>
<td class="align-middle">
{% if color.is_transparent %}
<div style="width: 50px; height: 50px; border-radius: 4px; border: 2px solid #ddd; background: repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 0 0/10px 10px;" title="Clear / Transparent"></div>
{% else %}
<div style="width: 50px; height: 50px; background-color: {{ color.get_hex_color }}; border-radius: 4px; border: 2px solid #ddd;"></div>
{% endif %}
</td>
<td class="align-middle"><strong>{{ color.color_name }}</strong></td>
<td class="align-middle">
{% if color.is_transparent %}
<span class="text-muted fst-italic">Clear / Transparent</span>
{% else %}
<span class="font-monospace">{{ color.get_hex_color }}</span>
{% endif %}
</td>
<td class="align-middle">
<span class="badge bg-secondary">{{ color.filament_type }}</span>

View File

@@ -27,10 +27,14 @@
<div class="card-body">
<h6>Color</h6>
<div class="d-flex align-items-center">
{% if filament.is_transparent %}
<div style="width: 50px; height: 50px; border-radius: 8px; margin-right: 15px; border: 2px solid #ddd; background: repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 0 0/10px 10px;" title="Clear / Transparent"></div>
{% else %}
<div style="width: 50px; height: 50px; background-color: {{ filament.color_hex|default:'#999' }}; border-radius: 8px; margin-right: 15px; border: 2px solid #ddd;"></div>
{% endif %}
<div>
<strong>{{ filament.color }}</strong><br>
<small class="text-muted">{{ filament.color_hex }}</small>
<small class="text-muted">{% if filament.is_transparent %}Clear / Transparent{% else %}{{ filament.color_hex }}{% endif %}</small>
</div>
</div>
</div>
@@ -150,7 +154,7 @@
<tbody>
{% for usage in print_usages %}
<tr>
<td>{{ usage.print_job.project_name }}</td>
<td>{{ usage.print_job.display_name }}</td>
<td>{{ usage.print_job.start_time|date:"Y-m-d H:i" }}</td>
<td>Tray {{ usage.tray_id }}</td>
<td>{{ usage.consumed_percent|default:"?" }}% ({{ usage.consumed_grams|default:"?" }}g)</td>

View File

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

View File

@@ -70,14 +70,22 @@
{% endfor %}
</select>
</div>
<div class="col-md-3">
<div class="col-md-2">
<select name="loaded" class="form-select">
<option value="">All Spools</option>
<option value="yes" {% if request.GET.loaded == 'yes' %}selected{% endif %}>Loaded in AMS</option>
<option value="no" {% if request.GET.loaded == 'no' %}selected{% endif %}>Not Loaded</option>
</select>
</div>
<div class="col-md-3">
<div class="col-md-2">
<select name="ams_type" class="form-select">
<option value="">All AMS Types</option>
{% for at in ams_type_choices %}
<option value="{{ at }}" {% if request.GET.ams_type == at %}selected{% endif %}>{{ at }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-secondary">Filter</button>
<a href="{% url 'bambu_run:filament_list' %}" class="btn btn-outline-secondary">Reset</a>
</div>
@@ -122,7 +130,11 @@
</td>
<td class="align-middle">
<div class="d-flex align-items-center">
{% if filament.is_transparent %}
<div style="width: 30px; height: 30px; border-radius: 4px; margin-right: 10px; border: 1px solid #ddd; background: repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 0 0/10px 10px;" title="Clear / Transparent"></div>
{% else %}
<div style="width: 30px; height: 30px; background-color: {{ filament.color_hex|default:'#999' }}; border-radius: 4px; margin-right: 10px; border: 1px solid #ddd;"></div>
{% endif %}
{{ filament.color }}
</div>
</td>
@@ -145,7 +157,11 @@
</td>
<td class="align-middle">
{% if filament.is_loaded_in_ams %}
<span class="badge bg-success">AMS Tray {{ filament.current_tray_id }}</span>
<span class="badge bg-success">
{% if filament.ams_type %}{{ filament.ams_type }}{% else %}AMS{% endif %}
{% if filament.ams_unit_id is not None %}#{{ filament.ams_unit_id }}{% endif %}
· Tray {{ filament.current_tray_id }}
</span>
{% else %}
<span class="badge bg-secondary">Storage</span>
{% endif %}

View File

@@ -14,6 +14,20 @@
Real-time monitoring for {{ device_name }}
</p>
</div>
{% if show_printer_switcher %}
<div class="col-auto d-flex align-items-center gap-2">
<label for="printerSwitcher" class="form-label mb-0 text-nowrap">Device:</label>
<select id="printerSwitcher" class="form-select" aria-label="Select printer"
onchange="if (this.value) { window.location.href = this.value; }">
{% for p in all_printers %}
<option value="{% url 'bambu_run:printer_dashboard' pk=p.pk %}"
{% if printer_device.pk == p.pk %}selected{% endif %}>
{{ p.name }}
</option>
{% endfor %}
</select>
</div>
{% endif %}
</div>
{% if error %}
@@ -22,9 +36,49 @@
<!-- Summary Cards Row -->
<div class="row g-3 mb-4">
<!-- Nozzle Temperature Card -->
{% if stats.is_dual_nozzle %}
<!-- Left Nozzle (dual-nozzle printers, e.g. H2C) -->
<div class="col-12 col-md-6 col-lg-3">
<div class="card infra-card-warning">
<div class="card infra-card-warning h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stat-label d-flex align-items-center gap-1">
<svg class="icon" style="width: 1.25rem; height: 1.25rem;"><use href="{% static 'bambu_run/vendors/coreui-icons-free.svg' %}#cil-arrow-thick-left"></use></svg>
Left Nozzle
</div>
<div class="stat-value">{{ stats.nozzle_temp_left|floatformat:1 }}&deg;C</div>
<div class="text-muted small">target {{ stats.nozzle_target_temp_left|floatformat:0 }}&deg;C
{% if stats.nozzle_type_left %}· Nozzle {{ stats.nozzle_type_left }}{% endif %}</div>
</div>
<i class="bi bi-thermometer-high" style="font-size: 2rem; opacity: 0.3;"></i>
</div>
</div>
</div>
</div>
<!-- Right Nozzle -->
<div class="col-12 col-md-6 col-lg-3">
<div class="card infra-card-warning h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stat-label d-flex align-items-center gap-1">
Right Nozzle
<svg class="icon" style="width: 1.25rem; height: 1.25rem;"><use href="{% static 'bambu_run/vendors/coreui-icons-free.svg' %}#cil-arrow-thick-right"></use></svg>
</div>
<div class="stat-value">{{ stats.nozzle_temp|floatformat:1 }}&deg;C</div>
<div class="text-muted small">target {{ stats.nozzle_target_temp|floatformat:0 }}&deg;C
{% if stats.nozzle_type %}· Nozzle {{ stats.nozzle_type }}{% endif %}</div>
</div>
<i class="bi bi-thermometer-high" style="font-size: 2rem; opacity: 0.3;"></i>
</div>
</div>
</div>
</div>
{% else %}
<!-- Nozzle Temperature Card (single-nozzle printers) -->
<div class="col-12 col-md-6 col-lg-3">
<div class="card infra-card-warning h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
@@ -36,10 +90,11 @@
</div>
</div>
</div>
{% endif %}
<!-- Bed Temperature Card -->
<div class="col-12 col-md-6 col-lg-3">
<div class="card infra-card-danger">
<div class="card infra-card-danger h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
@@ -54,7 +109,7 @@
<!-- Print Progress Card -->
<div class="col-12 col-md-6 col-lg-3">
<div class="card infra-card-info">
<div class="card infra-card-info h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
@@ -69,7 +124,7 @@
<!-- Chamber Light Card -->
<div class="col-12 col-md-6 col-lg-3">
<div class="card {% if stats.chamber_light == 'on' %}infra-card-success{% else %}infra-card-secondary{% endif %}">
<div class="card h-100 {% if stats.chamber_light == 'on' %}infra-card-success{% else %}infra-card-secondary{% endif %}">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
@@ -94,7 +149,7 @@
<div class="card-body">
<div class="row">
<div class="col-md-6">
<strong>Job Name:</strong> {{ stats.subtask_name }}
<strong>Job Name:</strong> {{ stats.job_display_name }}
</div>
<div class="col-md-3">
<strong>State:</strong> {{ stats.gcode_state }}
@@ -149,34 +204,59 @@
</div>
<div class="card-body">
{% if stats.filaments %}
<div class="row g-3">
{% for filament in stats.filaments %}
<div class="col-12 col-md-6 col-lg-3">
<div class="card filament-card" data-filament-color="{{ filament.color|slice:':6' }}">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Tray {{ filament.tray_id }}</h6>
{% if filament.filament_pk %}
<a href="{% url 'bambu_run:filament_detail' filament.filament_pk %}" class="text-decoration-none" title="View in inventory">
<svg class="icon icon-sm text-body-secondary"><use href="{% static 'bambu_run/vendors/coreui-icons-free.svg' %}#cil-external-link"></use></svg>
</a>
{% endif %}
</div>
<p class="mb-1 small"><strong>{{ filament.type }}</strong> - {{ filament.brand }}</p>
{% if filament.color_name %}<p class="mb-1 small text-body-secondary">{{ filament.color_name }}</p>{% endif %}
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="small">Remaining</span>
<span class="badge filament-badge">{{ filament.remain_percent }}%</span>
</div>
<div class="progress" style="height: 10px; background-color: rgba(0,0,0,0.1);">
<div class="progress-bar filament-progress" role="progressbar" style="width: {{ filament.remain_percent }}%;" aria-valuenow="{{ filament.remain_percent }}" aria-valuemin="0" aria-valuemax="100"></div>
{% if stats.ams_units|length > 1 %}
<div class="ams-filter-pills mb-3" id="amsFilterPills">
<button type="button" class="btn ams-filter-pill active" data-ams-filter="all">All</button>
{% for unit in stats.ams_units %}
<button type="button" class="btn ams-filter-pill ams-badge-{{ unit.ams_type|slugify }}" data-ams-filter="{{ unit.ams_unit_id }}">{{ unit.ams_type|default:"AMS" }}</button>
{% endfor %}
</div>
{% endif %}
<div class="ams-groups">
{% for group in stats.ams_groups %}
<div class="ams-group ams-badge-bg-{{ group.ams_type|slugify }} {% if group.filaments|length > 1 %}ams-group--wide{% else %}ams-group--compact{% endif %}" data-ams-unit-id="{{ group.unit_id }}">
<div class="ams-group-header d-flex justify-content-between align-items-center mb-2">
<strong class="small">{{ group.label }}</strong>
{% if group.humidity is not None or group.temp is not None %}
<span class="small text-body-secondary">
{% if group.humidity is not None %}{{ group.humidity }}%RH{% endif %}
{% if group.temp is not None %}&middot; {{ group.temp }}&deg;C{% endif %}
</span>
{% endif %}
</div>
<div class="row g-3">
{% for filament in group.filaments %}
<div class="col-12 {% if group.filaments|length > 1 %}col-md-6 col-lg-3{% endif %}">
<div class="card filament-card" data-filament-color="{{ filament.color|slice:':6' }}"{% if filament.is_transparent %} data-filament-transparent="true"{% endif %}>
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Tray {{ filament.tray_id }}</h6>
{% if filament.filament_pk %}
<a href="{% url 'bambu_run:filament_detail' filament.filament_pk %}" class="text-decoration-none" title="View in inventory">
<svg class="icon icon-sm text-body-secondary"><use href="{% static 'bambu_run/vendors/coreui-icons-free.svg' %}#cil-external-link"></use></svg>
</a>
{% endif %}
</div>
<p class="mb-1 small"><strong>{{ filament.type }}</strong> - {{ filament.brand }}</p>
{% if filament.color_name %}<p class="mb-1 small text-body-secondary">{{ filament.color_name }}</p>{% endif %}
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="small">Remaining</span>
<span class="badge filament-badge">{{ filament.remain_percent }}%</span>
</div>
<div class="progress" style="height: 10px; background-color: rgba(0,0,0,0.1);">
<div class="progress-bar filament-progress" role="progressbar" style="width: {{ filament.remain_percent }}%;" aria-valuenow="{{ filament.remain_percent }}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% if stats.external_spool.type %}
{% if stats.external_spool.type %}
<div class="row g-3 mt-1">
<div class="col-12 col-md-6 col-lg-3">
<div class="card filament-card" data-filament-color="{{ stats.external_spool.color|slice:':6' }}">
<div class="card-body">
@@ -192,8 +272,8 @@
</div>
</div>
</div>
{% endif %}
</div>
{% endif %}
{% else %}
<p class="text-body-secondary">No filament data available</p>
{% endif %}
@@ -202,6 +282,64 @@
</div>
</div>
<!-- Hotends Section (Vortek rack + any plain/non-inductive nozzles) -->
{% if stats.hotends or stats.nozzle_positions %}
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5>Hotends</h5>
</div>
<div class="card-body">
<div class="row g-3">
{% for hotend in stats.hotends %}
<div class="col-12 col-md-6 col-lg-3">
<div class="card filament-card" data-filament-color="{{ hotend.last_color|default:'888888' }}">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">
{% if hotend.is_toolhead %}Toolhead{% elif hotend.slot_number %}Slot {{ hotend.slot_number }}{% else %}Rack{% endif %}
</h6>
{% if hotend.is_toolhead %}<span class="badge filament-badge">Toolhead</span>{% endif %}
</div>
<p class="mb-1 small text-body-secondary">SN {{ hotend.serial_number }}</p>
<p class="mb-1 small"><strong>{{ hotend.nozzle_type }}</strong>{% if hotend.diameter %} &middot; {{ hotend.diameter }}mm{% endif %}</p>
{% if hotend.last_filament_profile_id %}<p class="mb-1 small text-body-secondary">Last: {{ hotend.last_filament_profile_id }}</p>{% endif %}
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="small">Used time</span>
<span class="small">{{ hotend.used_time_display }}</span>
</div>
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="small">Wear</span>
<span class="badge filament-badge">{{ hotend.wear_percent|floatformat:0 }}%</span>
</div>
<div class="progress" style="height: 10px; background-color: rgba(0,0,0,0.1);">
<div class="progress-bar filament-progress" role="progressbar" style="width: {{ hotend.wear_percent }}%;" aria-valuenow="{{ hotend.wear_percent }}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
</div>
</div>
{% endfor %}
{% for nozzle in stats.nozzle_positions %}
<div class="col-12 col-md-6 col-lg-3">
<div class="card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">{% if nozzle.is_toolhead %}Toolhead{% else %}Fixed Nozzle{% endif %}</h6>
</div>
<p class="mb-1 small"><strong>{{ nozzle.nozzle_type }}</strong>{% if nozzle.diameter %} &middot; {{ nozzle.diameter }}mm{% endif %}</p>
<p class="mb-0 small text-body-secondary">No induction chip data</p>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Date/Time Filter Controls -->
{% if not is_basic_user %}
<div class="row mb-4">
@@ -266,10 +404,10 @@
<!-- Charts Section -->
<div class="row g-3 mb-4">
<!-- Nozzle Temperature Chart -->
<!-- Nozzle Temperature Chart (right side / single nozzle) -->
<div class="col-12 col-lg-6">
<div class="card">
<div class="card-header">Nozzle Temperature</div>
<div class="card-header">{% if stats.is_dual_nozzle %}Right Nozzle Temperature{% else %}Nozzle Temperature{% endif %}</div>
<div class="card-body">
<div class="chart-container">
<canvas id="nozzleTempChart"></canvas>
@@ -278,6 +416,20 @@
</div>
</div>
{% if stats.is_dual_nozzle %}
<!-- Left Nozzle Temperature Chart (H2C-class dual-nozzle) -->
<div class="col-12 col-lg-6">
<div class="card">
<div class="card-header">Left Nozzle Temperature</div>
<div class="card-body">
<div class="chart-container">
<canvas id="nozzleTempLeftChart"></canvas>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Bed Temperature Chart -->
<div class="col-12 col-lg-6">
<div class="card">
@@ -374,12 +526,12 @@
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3.0.1"></script>
<script src="{% static 'bambu_run/js/printer_charts.js' %}"></script>
<script src="{% static 'bambu_run/js/printer_charts_control.js' %}"></script>
{% if not is_basic_user %}
<div id="printerApiUrl" data-url="{% url 'bambu_run:printer_api' %}" style="display: none;"></div>
{% if not is_basic_user and printer_device %}
<div id="printerApiUrl" data-url="{% url 'bambu_run:printer_api' pk=printer_device.pk %}" style="display: none;"></div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const printerData = {{ printer_data_json|safe }};
const apiUrl = '{% url "bambu_run:printer_api" %}';
const apiUrl = '{% url "bambu_run:printer_api" pk=printer_device.pk %}';
initPrinterCharts(printerData, apiUrl);
// Add project markers if they exist
@@ -404,4 +556,27 @@
});
</script>
{% endif %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const pillsContainer = document.getElementById('amsFilterPills');
if (!pillsContainer) return;
const items = document.querySelectorAll('.ams-groups .ams-group');
pillsContainer.addEventListener('click', function(e) {
const pill = e.target.closest('.ams-filter-pill');
if (!pill) return;
pillsContainer.querySelectorAll('.ams-filter-pill').forEach(function(p) {
p.classList.remove('active');
});
pill.classList.add('active');
const filter = pill.dataset.amsFilter;
items.forEach(function(item) {
const show = filter === 'all' || item.dataset.amsUnitId === filter;
item.classList.toggle('d-none', !show);
});
});
});
</script>
{% endblock %}

View File

@@ -5,7 +5,9 @@ app_name = "bambu_run"
urlpatterns = [
path("", views.PrinterDashboardView.as_view(), name="printer_dashboard"),
path("printer/<int:pk>/", views.PrinterDashboardView.as_view(), name="printer_dashboard"),
path("api/printer/", views.PrinterDataAPIView.as_view(), name="printer_api"),
path("api/printer/<int:pk>/", views.PrinterDataAPIView.as_view(), name="printer_api"),
# Filament Inventory routes
path("filaments/", views.FilamentListView.as_view(), name="filament_list"),

View File

@@ -3,14 +3,23 @@ Utility functions for filament color matching
"""
# BambuLab AMS reports colors as 8-char hex with an alpha channel suffix (e.g. '489FDFFF').
# The last two chars are always 'FF' (fully opaque). Only the first 6 chars are the RGB value.
# Opaque filaments use alpha 'FF'. Clear/transparent filaments use alpha '00' (e.g. '00000000').
MQTT_COLOR_HEX_LENGTH = 6
def is_mqtt_color_transparent(mqtt_color):
"""
Return True if the AMS color represents a clear/transparent filament.
Bambu Lab uses alpha=00 for transparent (e.g. '00000000'), not 'FF' like opaque filaments.
"""
return bool(mqtt_color) and len(mqtt_color) == 8 and mqtt_color[6:8].upper() == '00'
def strip_color_padding(mqtt_color):
"""
Strip FF padding from MQTT color
MQTT: '000000FF' -> '000000'
Strip alpha padding from MQTT color, returning the 6-char RGB hex.
MQTT: '000000FF' -> '000000' (opaque black)
MQTT: '00000000' -> '000000' (transparent — use is_mqtt_color_transparent() to distinguish)
MQTT: 'FF6A13FF' -> 'FF6A13'
"""
if not mqtt_color:

View File

@@ -1,8 +1,9 @@
from datetime import timedelta
from datetime import timedelta, datetime
from django.views.generic import TemplateView, View, ListView, CreateView, UpdateView, DetailView, DeleteView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.http import JsonResponse
from django.http import Http404, JsonResponse
from django.urls import reverse_lazy
from django.contrib import messages
from django.db.models import Q, Sum
@@ -10,9 +11,32 @@ import json
import zoneinfo
from .conf import app_settings
from .models import Printer, PrinterMetrics, Filament, FilamentColor, FilamentType, FilamentSnapshot, PrintJob, FilamentUsage
from .models import Printer, PrinterMetrics, Filament, FilamentColor, FilamentType, FilamentSnapshot, PrintJob, FilamentUsage, Hotend
from .forms import FilamentForm, FilamentColorForm, FilamentTypeForm
_METRICS_API_FIELDS = [
'id', 'device_id', 'timestamp',
'nozzle_temp', 'nozzle_target_temp',
'bed_temp', 'bed_target_temp',
'print_percent', 'cooling_fan_speed', 'heatbreak_fan_speed',
'wifi_signal_dbm', 'ams_humidity_raw', 'ams_temp',
'layer_num', 'total_layer_num',
'gcode_state', 'print_type', 'subtask_name',
'external_spool',
]
_MAX_CHART_POINTS = 3000
def resolve_printer_from_request(pk):
"""Resolve which Printer a dashboard/API view should show.
`pk` given (URL kwarg) -> that exact printer, 404 if missing/inactive.
`pk` omitted -> first active printer (today's single-printer default behavior).
"""
if pk is not None:
return get_object_or_404(Printer, pk=pk, is_active=True)
return Printer.objects.filter(is_active=True).first()
class PrinterDashboardView(LoginRequiredMixin, TemplateView):
template_name = "bambu_run/printer_dashboard.html"
@@ -26,13 +50,20 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
context = super().get_context_data(**kwargs)
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
all_printers = Printer.objects.filter(is_active=True)
context["all_printers"] = all_printers
# Shown even with a single printer — hints that multi-printer support exists.
context["show_printer_switcher"] = all_printers.exists()
try:
printer_device = Printer.objects.filter(is_active=True).first()
printer_device = resolve_printer_from_request(self.kwargs.get("pk"))
if not printer_device:
context["error"] = (
"No 3D printer device found. Please run bambu_collector first."
)
return context
except Http404:
raise
except Exception as e:
context["error"] = f"Error loading printer device: {str(e)}"
return context
@@ -64,6 +95,14 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
float(m.nozzle_target_temp) if m.nozzle_target_temp else None
for m in metrics
],
"nozzle_temp_left": [
float(m.nozzle_temp_left) if m.nozzle_temp_left is not None else None
for m in metrics
],
"nozzle_target_temp_left": [
float(m.nozzle_target_temp_left) if m.nozzle_target_temp_left is not None else None
for m in metrics
],
"bed_temp": [float(m.bed_temp) if m.bed_temp else None for m in metrics],
"bed_target_temp": [
float(m.bed_target_temp) if m.bed_target_temp else None for m in metrics
@@ -109,26 +148,102 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
'brand': snapshot.sub_type or 'Unknown',
'color': snapshot.color or 'FFFFFFFF',
'remain_percent': snapshot.remain_percent or 0,
'ams_unit_id': snapshot.ams_unit_id,
'ams_type': snapshot.ams_type or '',
}
if snapshot.filament:
filament_dict['color_name'] = snapshot.filament.color
filament_dict['filament_pk'] = snapshot.filament.pk
filament_dict['is_transparent'] = snapshot.filament.is_transparent
filaments_list.append(filament_dict)
except Exception:
filaments_list = []
# Distinct AMS units represented in this snapshot, for the unit
# filter/badges in the template. Sort numeric unit ids first
# (AMS / AMS 2 Pro), HT (id 128 / bit 0x80 set) last.
seen_units = {}
for f in filaments_list:
uid = f.get('ams_unit_id')
if uid is not None and uid not in seen_units:
seen_units[uid] = f.get('ams_type') or ''
ams_units_list = [
{'ams_unit_id': uid, 'ams_type': label}
for uid, label in sorted(seen_units.items())
]
# Group trays by physical AMS unit for the panel-style dashboard layout —
# one tinted panel per unit, full-width for multi-slot units (AMS/AMS 2 Pro),
# compact for single-slot units (AMS HT) so several can flow side-by-side.
units_meta = {
u.get('unit_id'): u for u in (latest_metric.ams_units or [])
}
ams_groups = []
for uid, label in sorted(seen_units.items()):
unit_meta = units_meta.get(str(uid), {})
ams_groups.append({
'unit_id': uid,
'ams_type': label,
'label': f"{label or 'AMS'} (Unit {uid})",
'humidity': unit_meta.get('humidity'),
'temp': unit_meta.get('temp'),
'filaments': [f for f in filaments_list if f.get('ams_unit_id') == uid],
})
subtask_name = latest_metric.subtask_name or "No active print"
# Look up active PrintJob for a better display name (cloud design_title)
job_display_name = subtask_name
if latest_metric.subtask_name:
active_job = (
PrintJob.objects.filter(
device=printer_device,
project_name=latest_metric.subtask_name,
end_time__isnull=True,
).select_related('cloud_task').first()
or PrintJob.objects.filter(
device=printer_device,
project_name=latest_metric.subtask_name,
).select_related('cloud_task').order_by('-start_time').first()
)
if active_job:
job_display_name = active_job.display_name
stats = {
"nozzle_temp": float(latest_metric.nozzle_temp) if latest_metric.nozzle_temp else 0,
"nozzle_target_temp": float(latest_metric.nozzle_target_temp) if latest_metric.nozzle_target_temp else 0,
"nozzle_diameter": float(latest_metric.nozzle_diameter) if latest_metric.nozzle_diameter else None,
"nozzle_type": latest_metric.nozzle_type or "",
"nozzle_temp_left": float(latest_metric.nozzle_temp_left) if latest_metric.nozzle_temp_left is not None else None,
"nozzle_target_temp_left": float(latest_metric.nozzle_target_temp_left) if latest_metric.nozzle_target_temp_left is not None else None,
"nozzle_diameter_left": float(latest_metric.nozzle_diameter_left) if latest_metric.nozzle_diameter_left is not None else None,
"nozzle_type_left": latest_metric.nozzle_type_left or "",
"is_dual_nozzle": latest_metric.nozzle_temp_left is not None,
"bed_temp": float(latest_metric.bed_temp) if latest_metric.bed_temp else 0,
"chamber_temp": float(latest_metric.chamber_temp) if latest_metric.chamber_temp else 0,
"print_percent": latest_metric.print_percent or 0,
"gcode_state": latest_metric.gcode_state or "Unknown",
"print_type": latest_metric.print_type or "idle",
"subtask_name": latest_metric.subtask_name or "No active print",
"subtask_name": subtask_name,
"job_display_name": job_display_name,
"chamber_light": latest_metric.chamber_light or "unknown",
"ams_temp": float(latest_metric.ams_temp) if latest_metric.ams_temp else None,
"ams_humidity": latest_metric.ams_humidity,
"filaments": filaments_list,
"ams_units": ams_units_list,
"ams_groups": ams_groups,
"hotends": list(
Hotend.objects.filter(printer=printer_device)
.order_by('-is_toolhead', 'slot_number', 'serial_number')
),
# Nozzle positions with no induction chip (no stable serial number to
# key a Hotend registry row on, e.g. H2C's fixed left nozzle) — shown
# read-only from the latest poll, not persisted/historical. Entries with
# no readable type/diameter at all (i.e. genuinely nothing there) are
# dropped rather than shown as an empty placeholder.
"nozzle_positions": [
h for h in (latest_metric.nozzle_info or [])
if h.get('is_empty') and (h.get('nozzle_type') or h.get('diameter'))
],
"external_spool": latest_metric.external_spool or {},
"timestamp": latest_metric.timestamp.astimezone(tz).strftime("%Y-%m-%d %H:%M:%S"),
}
@@ -145,7 +260,24 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
return context
def _calculate_project_markers(self, metrics, timezone_info):
"""Calculate where print jobs start and end"""
"""Calculate where print jobs start and end, using cloud design_title when available."""
if not metrics:
return []
# Build a lookup: subtask_name -> display_name from PrintJobs in this time window
window_start = metrics[0].timestamp
window_end = metrics[-1].timestamp
device = metrics[0].device
jobs_qs = PrintJob.objects.filter(
device=device,
start_time__gte=window_start - timedelta(minutes=5),
start_time__lte=window_end + timedelta(minutes=5),
).select_related('cloud_task')
# Map project_name (= subtask_name) -> best display name
subtask_to_display = {}
for job in jobs_qs:
subtask_to_display[job.project_name] = job.display_name
markers = []
current_job = None
last_state = None
@@ -157,21 +289,23 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
is_printing = gcode_state not in ['FINISH', 'IDLE', None, '']
if subtask and subtask != current_job and is_printing:
display = subtask_to_display.get(subtask, subtask)
markers.append({
'type': 'start',
'index': idx,
'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(),
'project_name': subtask,
'project_name': display,
})
current_job = subtask
last_state = gcode_state
elif current_job and last_state and last_state not in ['FINISH', 'IDLE'] and gcode_state in ['FINISH', 'IDLE']:
display = subtask_to_display.get(current_job, current_job)
markers.append({
'type': 'end',
'index': idx,
'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(),
'project_name': current_job,
'project_name': display,
})
current_job = None
@@ -192,15 +326,19 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
for snapshot in snapshots:
tray_id = snapshot.tray_id
ams_unit_id = snapshot.ams_unit_id
ams_type = snapshot.ams_type or ''
fil_type = snapshot.type or 'Unknown'
fil_sub_type = snapshot.sub_type or 'Unknown'
fil_color = snapshot.color or 'FFFFFFFF'
unique_key = f"{tray_id}_{fil_type}_{fil_sub_type}_{fil_color}"
unique_key = f"{ams_unit_id}_{tray_id}_{fil_type}_{fil_sub_type}_{fil_color}"
if unique_key not in filament_data:
filament_data[unique_key] = {
'tray_id': tray_id,
'ams_unit_id': ams_unit_id,
'ams_type': ams_type,
'type': fil_type,
'brand': fil_sub_type,
'color': fil_color,
@@ -237,62 +375,197 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
class PrinterDataAPIView(LoginRequiredMixin, View):
"""API endpoint for dynamic printer chart updates"""
def get(self, request):
def get(self, request, pk=None):
start_date = request.GET.get("start_date")
end_date = request.GET.get("end_date")
start_time = request.GET.get("start_time", "00:00")
end_time = request.GET.get("end_time", "23:59")
try:
printer_device = Printer.objects.filter(is_active=True).first()
if not printer_device:
return JsonResponse({"error": "No printer device found"}, status=404)
query = PrinterMetrics.objects.filter(device=printer_device).prefetch_related('filament_snapshots')
if pk is not None:
printer_device = Printer.objects.filter(pk=pk, is_active=True).first()
if not printer_device:
return JsonResponse({"error": "Printer not found"}, status=404)
else:
printer_device = Printer.objects.filter(is_active=True).first()
if not printer_device:
return JsonResponse({"error": "No printer device found"}, status=404)
tz = zoneinfo.ZoneInfo(app_settings.TIMEZONE)
if start_date and start_time:
from datetime import datetime
start_dt_naive = datetime.strptime(f"{start_date} {start_time}", "%Y-%m-%d %H:%M")
start_dt = start_dt_naive.replace(tzinfo=tz)
# Stage A: only() + step calculation
query = (
PrinterMetrics.objects
.filter(device=printer_device)
.only(*_METRICS_API_FIELDS)
)
if start_date and start_time and end_date and end_time:
start_dt = datetime.strptime(f"{start_date} {start_time}", "%Y-%m-%d %H:%M").replace(tzinfo=tz)
end_dt = datetime.strptime(f"{end_date} {end_time}", "%Y-%m-%d %H:%M").replace(tzinfo=tz)
query = query.filter(timestamp__gte=start_dt, timestamp__lte=end_dt)
range_seconds = (end_dt - start_dt).total_seconds()
expected_count = max(1, int(range_seconds / 30))
elif start_date and start_time:
start_dt = datetime.strptime(f"{start_date} {start_time}", "%Y-%m-%d %H:%M").replace(tzinfo=tz)
query = query.filter(timestamp__gte=start_dt)
if end_date and end_time:
from datetime import datetime
end_dt_naive = datetime.strptime(f"{end_date} {end_time}", "%Y-%m-%d %H:%M")
end_dt = end_dt_naive.replace(tzinfo=tz)
expected_count = _MAX_CHART_POINTS
elif end_date and end_time:
end_dt = datetime.strptime(f"{end_date} {end_time}", "%Y-%m-%d %H:%M").replace(tzinfo=tz)
query = query.filter(timestamp__lte=end_dt)
expected_count = _MAX_CHART_POINTS
else:
expected_count = _MAX_CHART_POINTS
metrics = query.order_by("timestamp")
step = max(1, expected_count // _MAX_CHART_POINTS)
# Stage B: single DB round-trip, downsample in Python
metrics_list = list(query.order_by("timestamp"))
if step > 1:
metrics_list = metrics_list[::step]
total_points = len(metrics_list)
# Stage C: targeted snapshot fetch (only sampled IDs)
snapshots_by_metric: dict = {}
if metrics_list:
sampled_ids = [m.id for m in metrics_list]
for snap in FilamentSnapshot.objects.filter(printer_metric_id__in=sampled_ids):
snapshots_by_metric.setdefault(snap.printer_metric_id, []).append(snap)
# Stage D: single-pass serialization
timestamps = []
timestamps_iso = []
dates = []
nozzle_temp = []
nozzle_target_temp = []
nozzle_temp_left = []
nozzle_target_temp_left = []
bed_temp = []
bed_target_temp = []
print_percent = []
cooling_fan_speed = []
heatbreak_fan_speed = []
wifi_signal_dbm = []
ams_humidity_raw = []
ams_temp = []
layer_num = []
total_layer_num = []
gcode_state = []
print_type = []
subtask_name = []
project_markers = []
current_job = None
last_state = None
filament_data = {}
for idx, m in enumerate(metrics_list):
ts = m.timestamp.astimezone(tz)
timestamps.append(ts.strftime('%H:%M'))
timestamps_iso.append(ts.isoformat())
dates.append(ts.strftime('%Y-%m-%d'))
nozzle_temp.append(float(m.nozzle_temp) if m.nozzle_temp else None)
nozzle_target_temp.append(float(m.nozzle_target_temp) if m.nozzle_target_temp else None)
nozzle_temp_left.append(float(m.nozzle_temp_left) if m.nozzle_temp_left is not None else None)
nozzle_target_temp_left.append(float(m.nozzle_target_temp_left) if m.nozzle_target_temp_left is not None else None)
bed_temp.append(float(m.bed_temp) if m.bed_temp else None)
bed_target_temp.append(float(m.bed_target_temp) if m.bed_target_temp else None)
print_percent.append(m.print_percent if m.print_percent else 0)
cooling_fan_speed.append(m.cooling_fan_speed if m.cooling_fan_speed else 0)
heatbreak_fan_speed.append(m.heatbreak_fan_speed if m.heatbreak_fan_speed else 0)
wifi_signal_dbm.append(m.wifi_signal_dbm if m.wifi_signal_dbm else None)
ams_humidity_raw.append(m.ams_humidity_raw if m.ams_humidity_raw else None)
ams_temp.append(float(m.ams_temp) if m.ams_temp else None)
layer_num.append(m.layer_num if m.layer_num else 0)
total_layer_num.append(m.total_layer_num if m.total_layer_num else 0)
gcode_state.append(m.gcode_state)
print_type.append(m.print_type)
subtask_name.append(m.subtask_name)
# Project marker detection (inline)
subtask = m.subtask_name
gs = m.gcode_state
is_printing = gs not in ['FINISH', 'IDLE', None, '']
if subtask and subtask != current_job and is_printing:
project_markers.append({
'type': 'start',
'index': idx,
'timestamp': ts.isoformat(),
'project_name': subtask,
})
current_job = subtask
last_state = gs
elif current_job and last_state and last_state not in ['FINISH', 'IDLE'] and gs in ['FINISH', 'IDLE']:
project_markers.append({
'type': 'end',
'index': idx,
'timestamp': ts.isoformat(),
'project_name': current_job,
})
current_job = None
last_state = gs
# Filament timeline (inline)
for snap in snapshots_by_metric.get(m.id, []):
tray_id = snap.tray_id
fil_type = snap.type or 'Unknown'
fil_sub_type = snap.sub_type or 'Unknown'
fil_color = snap.color or 'FFFFFFFF'
unique_key = f"{tray_id}_{fil_type}_{fil_sub_type}_{fil_color}"
if unique_key not in filament_data:
filament_data[unique_key] = {
'tray_id': tray_id,
'type': fil_type,
'brand': fil_sub_type,
'color': fil_color,
'remain_data': [None] * total_points,
'start_idx': idx,
}
filament_data[unique_key]['remain_data'][idx] = snap.remain_percent or 0
external = m.external_spool or {}
if external.get('type'):
fil_type = external.get('type', 'Unknown')
fil_color = external.get('color', '161616FF')
unique_key = f"External_{fil_type}_{fil_color}"
if unique_key not in filament_data:
filament_data[unique_key] = {
'tray_id': 'External',
'type': fil_type,
'brand': 'External',
'color': fil_color,
'remain_data': [None] * total_points,
'start_idx': idx,
}
filament_data[unique_key]['remain_data'][idx] = external.get('remain', 0)
data = {
"timestamps": [m.timestamp.astimezone(tz).strftime('%H:%M') for m in metrics],
"timestamps_iso": [m.timestamp.astimezone(tz).isoformat() for m in metrics],
"dates": [m.timestamp.astimezone(tz).strftime('%Y-%m-%d') for m in metrics],
"nozzle_temp": [float(m.nozzle_temp) if m.nozzle_temp else None for m in metrics],
"nozzle_target_temp": [float(m.nozzle_target_temp) if m.nozzle_target_temp else None for m in metrics],
"bed_temp": [float(m.bed_temp) if m.bed_temp else None for m in metrics],
"bed_target_temp": [float(m.bed_target_temp) if m.bed_target_temp else None for m in metrics],
"print_percent": [m.print_percent if m.print_percent else 0 for m in metrics],
"cooling_fan_speed": [m.cooling_fan_speed if m.cooling_fan_speed else 0 for m in metrics],
"heatbreak_fan_speed": [m.heatbreak_fan_speed if m.heatbreak_fan_speed else 0 for m in metrics],
"wifi_signal_dbm": [m.wifi_signal_dbm if m.wifi_signal_dbm else None for m in metrics],
"ams_humidity_raw": [m.ams_humidity_raw if m.ams_humidity_raw else None for m in metrics],
"ams_temp": [float(m.ams_temp) if m.ams_temp else None for m in metrics],
"layer_num": [m.layer_num if m.layer_num else 0 for m in metrics],
"total_layer_num": [m.total_layer_num if m.total_layer_num else 0 for m in metrics],
"gcode_state": [m.gcode_state for m in metrics],
"print_type": [m.print_type for m in metrics],
"subtask_name": [m.subtask_name for m in metrics],
"timestamps": timestamps,
"timestamps_iso": timestamps_iso,
"dates": dates,
"nozzle_temp": nozzle_temp,
"nozzle_target_temp": nozzle_target_temp,
"nozzle_temp_left": nozzle_temp_left,
"nozzle_target_temp_left": nozzle_target_temp_left,
"bed_temp": bed_temp,
"bed_target_temp": bed_target_temp,
"print_percent": print_percent,
"cooling_fan_speed": cooling_fan_speed,
"heatbreak_fan_speed": heatbreak_fan_speed,
"wifi_signal_dbm": wifi_signal_dbm,
"ams_humidity_raw": ams_humidity_raw,
"ams_temp": ams_temp,
"layer_num": layer_num,
"total_layer_num": total_layer_num,
"gcode_state": gcode_state,
"print_type": print_type,
"subtask_name": subtask_name,
"project_markers": project_markers,
"filament_timeline": filament_data,
}
project_markers = self._calculate_project_markers(metrics, tz)
data["project_markers"] = project_markers
filament_timeline = self._prepare_filament_timeline_for_api(metrics)
data["filament_timeline"] = filament_timeline
return JsonResponse(data)
except Exception as e:
@@ -300,93 +573,6 @@ class PrinterDataAPIView(LoginRequiredMixin, View):
traceback.print_exc()
return JsonResponse({"error": str(e)}, status=500)
def _calculate_project_markers(self, metrics, timezone_info):
markers = []
current_job = None
last_state = None
for idx, metric in enumerate(metrics):
subtask = metric.subtask_name
gcode_state = metric.gcode_state
is_printing = gcode_state not in ['FINISH', 'IDLE', None, '']
if subtask and subtask != current_job and is_printing:
markers.append({
'type': 'start',
'index': idx,
'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(),
'project_name': subtask,
})
current_job = subtask
last_state = gcode_state
elif current_job and last_state and last_state not in ['FINISH', 'IDLE'] and gcode_state in ['FINISH', 'IDLE']:
markers.append({
'type': 'end',
'index': idx,
'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(),
'project_name': current_job,
})
current_job = None
last_state = gcode_state
return markers
def _prepare_filament_timeline_for_api(self, metrics):
filament_data = {}
total_points = len(metrics)
for idx, metric in enumerate(metrics):
try:
snapshots = metric.filament_snapshots.all()
except Exception:
snapshots = []
for snapshot in snapshots:
tray_id = snapshot.tray_id
fil_type = snapshot.type or 'Unknown'
fil_sub_type = snapshot.sub_type or 'Unknown'
fil_color = snapshot.color or 'FFFFFFFF'
unique_key = f"{tray_id}_{fil_type}_{fil_sub_type}_{fil_color}"
if unique_key not in filament_data:
filament_data[unique_key] = {
'tray_id': tray_id,
'type': fil_type,
'brand': fil_sub_type,
'color': fil_color,
'remain_data': [None] * total_points,
'start_idx': idx,
}
remain_percent = snapshot.remain_percent or 0
filament_data[unique_key]['remain_data'][idx] = remain_percent
for idx, metric in enumerate(metrics):
external = metric.external_spool or {}
if external.get('type'):
fil_type = external.get('type', 'Unknown')
fil_color = external.get('color', '161616FF')
unique_key = f"External_{fil_type}_{fil_color}"
if unique_key not in filament_data:
filament_data[unique_key] = {
'tray_id': 'External',
'type': fil_type,
'brand': 'External',
'color': fil_color,
'remain_data': [None] * total_points,
'start_idx': idx,
}
remain_percent = external.get('remain', 0)
filament_data[unique_key]['remain_data'][idx] = remain_percent
return filament_data
class FilamentUsageDataAPIView(LoginRequiredMixin, View):
"""API endpoint for filament usage history with date/time filtering"""
@@ -473,6 +659,10 @@ class FilamentListView(LoginRequiredMixin, ListView):
elif loaded == 'no':
queryset = queryset.filter(is_loaded_in_ams=False)
ams_type = self.request.GET.get('ams_type')
if ams_type:
queryset = queryset.filter(ams_type=ams_type)
search = self.request.GET.get('search')
if search:
queryset = queryset.filter(
@@ -492,9 +682,22 @@ class FilamentListView(LoginRequiredMixin, ListView):
context['filament_types'] = sorted(
set(Filament.objects.exclude(type__isnull=True).exclude(type='').values_list('type', flat=True))
)
context['ams_type_choices'] = sorted(
set(
Filament.objects.exclude(ams_type='').values_list('ams_type', flat=True)
)
)
return context
def _filament_type_map():
"""Return a JSON-serialisable dict mapping FilamentType pk → {type, sub_type, brand}."""
return {
str(ft.pk): {'type': ft.type, 'sub_type': ft.sub_type or '', 'brand': ft.brand}
for ft in FilamentType.objects.all()
}
class FilamentCreateView(LoginRequiredMixin, CreateView):
model = Filament
form_class = FilamentForm
@@ -504,6 +707,7 @@ class FilamentCreateView(LoginRequiredMixin, CreateView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
context['filament_type_map'] = json.dumps(_filament_type_map())
return context
def form_valid(self, form):
@@ -520,6 +724,7 @@ class FilamentUpdateView(LoginRequiredMixin, UpdateView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
context['filament_type_map'] = json.dumps(_filament_type_map())
return context
def form_valid(self, form):
@@ -553,7 +758,7 @@ class FilamentDetailView(LoginRequiredMixin, DetailView):
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
filament = self.object
context['print_usages'] = filament.print_usages.select_related('print_job').order_by('-print_job__start_time')[:20]
context['print_usages'] = filament.print_usages.select_related('print_job__cloud_task').order_by('-print_job__start_time')[:20]
total_consumed = filament.print_usages.aggregate(
total=Sum('consumed_percent')

View File

@@ -3,6 +3,7 @@ services:
build: .
ports:
- "8000:8000"
- "8808:8808"
env_file: .env
volumes:
- bambu_data:/app/data

View File

@@ -25,6 +25,19 @@ autorestart=true
startretries=10
startsecs=5
[program:mcp_server]
command=python standalone/manage.py bambu_mcp_server --transport sse --host 0.0.0.0 --port 8808
directory=/app
environment=DJANGO_SETTINGS_MODULE="standalone.settings"
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
stderr_logfile=/dev/fd/2
stderr_logfile_maxbytes=0
autorestart=true
startretries=10
startsecs=5
priority=10
[program:migrate]
command=python standalone/manage.py migrate --noinput
directory=/app

View File

@@ -21,4 +21,8 @@ Hex:#AF1685
Silver
Hex:#87909A
Black
Hex:#000000
Hex:#000000
Mint
Hex:#7AE1BF
Lavender
Hex:#7248BD

View File

@@ -1,3 +1,4 @@
Translucent #000000
Translucent Gray #8E8E8E
Translucent Light Blue #61B0FF
Translucent Olive #748C45

View File

@@ -0,0 +1,15 @@
[Unit]
Description=Bambu-Run MQTT Collector
After=network.target
[Service]
Type=exec
WorkingDirectory={{REPO_DIR}}
EnvironmentFile={{REPO_DIR}}/.env
Environment=DJANGO_SETTINGS_MODULE=standalone.settings
ExecStart={{VENV_DIR}}/bin/python standalone/manage.py bambu_collector
Restart=on-failure
RestartSec=10
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,15 @@
[Unit]
Description=Bambu-Run MCP Server
After=network.target
[Service]
Type=exec
WorkingDirectory={{REPO_DIR}}
EnvironmentFile={{REPO_DIR}}/.env
Environment=DJANGO_SETTINGS_MODULE=standalone.settings
ExecStart={{VENV_DIR}}/bin/python standalone/manage.py bambu_mcp_server --transport sse --host 0.0.0.0 --port 8808
Restart=on-failure
RestartSec=10
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,15 @@
[Unit]
Description=Bambu-Run Web Dashboard
After=network.target
[Service]
Type=exec
WorkingDirectory={{REPO_DIR}}
EnvironmentFile={{REPO_DIR}}/.env
Environment=DJANGO_SETTINGS_MODULE=standalone.settings
ExecStart={{VENV_DIR}}/bin/gunicorn standalone.wsgi:application --bind 0.0.0.0:8000 --workers {{WORKERS}} --timeout 120
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target

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

@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# Bambu-Run convenience wrapper
# Usage: ./native/bambu-run.sh {start|stop|restart|status|logs|update}
set -euo pipefail
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
VENV_DIR="$REPO_DIR/.venv"
MANAGE="$VENV_DIR/bin/python $REPO_DIR/standalone/manage.py"
SERVICES="bambu-run-web.service bambu-run-collector.service"
# Include MCP service if installed
SERVICE_DIR="$HOME/.config/systemd/user"
if [ -f "$SERVICE_DIR/bambu-run-mcp.service" ]; then
SERVICES="$SERVICES bambu-run-mcp.service"
fi
case "${1:-help}" in
start)
systemctl --user start $SERVICES
echo "Bambu-Run started."
;;
stop)
systemctl --user stop $SERVICES
echo "Bambu-Run stopped."
;;
restart)
systemctl --user restart $SERVICES
echo "Bambu-Run restarted."
;;
status)
systemctl --user status $SERVICES --no-pager
;;
logs)
JOURNAL_UNITS="-u bambu-run-web -u bambu-run-collector"
if [ -f "$SERVICE_DIR/bambu-run-mcp.service" ]; then
JOURNAL_UNITS="$JOURNAL_UNITS -u bambu-run-mcp"
fi
journalctl --user $JOURNAL_UNITS -f --no-hostname
;;
update)
echo "Pulling latest code..."
cd "$REPO_DIR" && git pull
echo "Installing dependencies..."
EXTRAS="standalone"
if [ -f "$SERVICE_DIR/bambu-run-mcp.service" ]; then
EXTRAS="standalone,mcp"
fi
"$VENV_DIR/bin/pip" install --quiet ".[$EXTRAS]"
echo "Running migrations..."
$MANAGE migrate --noinput
echo "Collecting static files..."
$MANAGE collectstatic --noinput --clear 2>/dev/null
echo "Restarting services..."
systemctl --user restart $SERVICES
echo "Update complete."
;;
help|*)
echo "Usage: $0 {start|stop|restart|status|logs|update}"
echo
echo " start Start web + collector services"
echo " stop Stop web + collector services"
echo " restart Restart web + collector services"
echo " status Show service status"
echo " logs Tail live logs (Ctrl+C to stop)"
echo " update Pull latest code, install deps, migrate, restart"
;;
esac

View File

@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
[project]
name = "bambu-run"
version = "0.1.0"
version = "0.1.7"
description = "Django reusable app for Bambu Lab 3D printer monitoring and filament inventory management"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.10"
requires-python = ">=3.9"
authors = [
{name = "Runnan Li"},
]
@@ -40,6 +40,9 @@ standalone = [
"python-dotenv",
"whitenoise",
]
mcp = [
"mcp[cli]>=1.0",
]
dev = [
"ruff",
"pytest",
@@ -59,3 +62,7 @@ bambu_run = [
"templates/bambu_run/*.html",
"static/bambu_run/**/*",
]
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "tests.settings"
python_files = "test_*.py"

290
setup.sh Executable file
View File

@@ -0,0 +1,290 @@
#!/usr/bin/env bash
# Bambu-Run Native Setup — single entry point for Raspberry Pi (or any Linux)
# Usage: git clone ... && cd Bambu-Run && bash setup.sh
set -euo pipefail
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
VENV_DIR="$REPO_DIR/.venv"
ENV_FILE="$REPO_DIR/.env"
MANAGE="$VENV_DIR/bin/python $REPO_DIR/standalone/manage.py"
SERVICE_DIR="$HOME/.config/systemd/user"
green() { printf '\033[1;32m%s\033[0m\n' "$*"; }
yellow() { printf '\033[1;33m%s\033[0m\n' "$*"; }
red() { printf '\033[1;31m%s\033[0m\n' "$*"; }
# ── 1. Pre-flight checks ─────────────────────────────────────────────────────
green "=== Bambu-Run Native Setup ==="
echo
# Acquire sudo upfront and keep it alive for the duration of the script
echo "This script needs sudo for iptables (port redirect) and apt (dependencies)."
sudo -v
while true; do sudo -n true; sleep 50; kill -0 "$$" || exit; done 2>/dev/null &
SUDO_KEEPALIVE_PID=$!
trap 'kill "$SUDO_KEEPALIVE_PID" 2>/dev/null' EXIT
echo
# Python >= 3.10
PYTHON=""
for cmd in python3.12 python3.11 python3.10 python3; do
if command -v "$cmd" &>/dev/null; then
ver=$("$cmd" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
major=${ver%%.*}
minor=${ver##*.}
if [ "$major" -ge 3 ] && [ "$minor" -ge 10 ]; then
PYTHON="$cmd"
break
fi
fi
done
if [ -z "$PYTHON" ]; then
red "Error: Python >= 3.10 is required."
echo "Install it with: sudo apt install python3"
exit 1
fi
green "Found $PYTHON ($ver)"
# Ensure python3-venv is available
if ! "$PYTHON" -m venv --help &>/dev/null; then
yellow "Installing python3-venv..."
sudo apt-get update -qq && sudo apt-get install -y -qq python3-venv
fi
# Detect RAM for gunicorn worker count
TOTAL_RAM_KB=$(grep MemTotal /proc/meminfo 2>/dev/null | awk '{print $2}' || echo 0)
if [ "$TOTAL_RAM_KB" -lt 1048576 ]; then
WORKERS=1
else
WORKERS=2
fi
# Prompt for access port
while true; do
read -rp "Choose Bambu-Run Dashboard access port (Default: 80): " ACCESS_PORT
ACCESS_PORT="${ACCESS_PORT:-80}"
if [[ "$ACCESS_PORT" =~ ^[0-9]+$ ]] && [ "$ACCESS_PORT" -ge 1 ] && [ "$ACCESS_PORT" -le 65535 ]; then
break
else
red "Invalid port '$ACCESS_PORT'. Please enter a number between 1 and 65535."
fi
done
green "Dashboard will be accessible on port $ACCESS_PORT."
# ── 2. Venv + install ────────────────────────────────────────────────────────
if [ ! -d "$VENV_DIR" ]; then
green "Creating virtual environment..."
"$PYTHON" -m venv "$VENV_DIR"
else
yellow "Virtual environment already exists, reusing."
fi
green "Installing dependencies..."
# Stub opencv-python (same trick as Dockerfile — avoids hour-long ARM build)
"$VENV_DIR/bin/python" -c "
import site, pathlib
d = pathlib.Path(site.getsitepackages()[0]) / 'opencv_python-4.99.0.dist-info'
if not d.exists():
d.mkdir()
(d / 'METADATA').write_text('Metadata-Version: 2.1\nName: opencv-python\nVersion: 4.99.0\n')
(d / 'INSTALLER').write_text('pip\n')
(d / 'RECORD').write_text('')
print(' opencv stub created')
else:
print(' opencv stub already exists')
"
"$VENV_DIR/bin/pip" install --quiet --upgrade pip
"$VENV_DIR/bin/pip" install --quiet ".[standalone]"
# ── 3. Interactive .env ───────────────────────────────────────────────────────
if [ ! -f "$ENV_FILE" ]; then
green "Setting up .env configuration..."
echo
read -rp "Bambu Lab email: " BAMBU_USERNAME
read -rsp "Bambu Lab password: " BAMBU_PASSWORD
echo
while true; do
read -rp "Timezone [UTC] (e.g. America/Sydney): " TIMEZONE
TIMEZONE="${TIMEZONE:-UTC}"
if "$VENV_DIR/bin/python" -c "import zoneinfo; zoneinfo.ZoneInfo('$TIMEZONE')" 2>/dev/null; then
break
else
red "Unknown timezone '$TIMEZONE'. Find yours at: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones"
fi
done
# Generate a random Django secret key
DJANGO_SECRET_KEY=$("$VENV_DIR/bin/python" -c "import secrets; print(secrets.token_urlsafe(50))")
cat > "$ENV_FILE" <<EOF
BAMBU_USERNAME=$BAMBU_USERNAME
BAMBU_PASSWORD=$BAMBU_PASSWORD
TIMEZONE=$TIMEZONE
DJANGO_SECRET_KEY=$DJANGO_SECRET_KEY
DEBUG=False
EOF
green ".env created."
else
yellow ".env already exists, skipping."
fi
# ── 4. Migrate ────────────────────────────────────────────────────────────────
green "Running database migrations..."
$MANAGE migrate --noinput
# ── 5. Bambu authentication ──────────────────────────────────────────────────
if ! grep -q '^BAMBU_TOKEN=' "$ENV_FILE" 2>/dev/null; then
green "Authenticating with Bambu Lab (email verification required)..."
echo "A verification code will be sent to your email."
echo
# Run collector in --once mode for interactive auth
$MANAGE bambu_collector --once || true
echo
read -rp "Paste your BAMBU_TOKEN from above (or press Enter to skip): " TOKEN
if [ -n "$TOKEN" ]; then
echo "BAMBU_TOKEN=$TOKEN" >> "$ENV_FILE"
green "Token saved to .env."
else
yellow "Skipped — you can add BAMBU_TOKEN to .env later."
fi
else
yellow "BAMBU_TOKEN already in .env, skipping auth."
fi
# ── 6. Superuser ─────────────────────────────────────────────────────────────
echo
if $MANAGE shell -c "from django.contrib.auth import get_user_model; exit(0 if get_user_model().objects.filter(is_superuser=True).exists() else 1)" 2>/dev/null; then
yellow "Superuser already exists, skipping. (To add another, run: python standalone/manage.py createsuperuser)"
else
green "Create your dashboard login (Django superuser):"
$MANAGE createsuperuser || yellow "Superuser creation skipped."
fi
# ── 7. Collect static files ──────────────────────────────────────────────────
green "Collecting static files..."
$MANAGE collectstatic --noinput --clear 2>/dev/null
# ── 8. Seed filament colors ──────────────────────────────────────────────────
echo
read -rp "Import Bambu Lab filament color catalog? [Y/n] " SEED_COLORS
SEED_COLORS="${SEED_COLORS:-Y}"
if [[ "$SEED_COLORS" =~ ^[Yy] ]]; then
$MANAGE bambu_import_colors "$REPO_DIR/docs/Bambu_Color_Catalog/"
fi
# ── 9. Install systemd services ──────────────────────────────────────────────
green "Installing systemd user services..."
mkdir -p "$SERVICE_DIR"
# Generate unit files with actual paths substituted
sed "s|{{REPO_DIR}}|$REPO_DIR|g; s|{{VENV_DIR}}|$VENV_DIR|g; s|{{WORKERS}}|$WORKERS|g" \
"$REPO_DIR/native/bambu-run-web.service" > "$SERVICE_DIR/bambu-run-web.service"
sed "s|{{REPO_DIR}}|$REPO_DIR|g; s|{{VENV_DIR}}|$VENV_DIR|g" \
"$REPO_DIR/native/bambu-run-collector.service" > "$SERVICE_DIR/bambu-run-collector.service"
systemctl --user daemon-reload
systemctl --user enable bambu-run-web.service bambu-run-collector.service
# ── 9b. Optional MCP server ─────────────────────────────────────────────────
echo
MCP_ENABLED=false
read -rp "Enable MCP server for AI agent access (Claude Desktop, Claude Code, etc.)? [y/N] " ENABLE_MCP
if [[ "$ENABLE_MCP" =~ ^[Yy] ]]; then
green "Installing MCP dependencies..."
"$VENV_DIR/bin/pip" install --quiet ".[mcp]"
sed "s|{{REPO_DIR}}|$REPO_DIR|g; s|{{VENV_DIR}}|$VENV_DIR|g" \
"$REPO_DIR/native/bambu-run-mcp.service" > "$SERVICE_DIR/bambu-run-mcp.service"
systemctl --user daemon-reload
systemctl --user enable bambu-run-mcp.service
systemctl --user start bambu-run-mcp.service
MCP_ENABLED=true
green "MCP server enabled on port 8808."
fi
# Enable linger so services survive SSH logout
loginctl enable-linger "$USER" 2>/dev/null || \
sudo loginctl enable-linger "$USER" 2>/dev/null || \
yellow "Warning: Could not enable linger. Services may stop when you disconnect SSH."
systemctl --user start bambu-run-web.service bambu-run-collector.service
# ── 10. Port redirect (ACCESS_PORT → 8000 via iptables if needed) ────────────
PORT_OK=false
if [ "$ACCESS_PORT" -eq 8000 ]; then
# Gunicorn already on 8000 — no redirect needed
green "Using port 8000 directly (no redirect needed)."
PORT_OK=true
else
if sudo iptables -t nat -C PREROUTING -p tcp --dport "$ACCESS_PORT" -j REDIRECT --to-port 8000 2>/dev/null; then
yellow "Port $ACCESS_PORT → 8000 redirect already set."
PORT_OK=true
else
# Ensure iptables is available
if ! command -v iptables &>/dev/null; then
yellow "Installing iptables..."
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y -qq iptables
fi
if sudo iptables -t nat -A PREROUTING -p tcp --dport "$ACCESS_PORT" -j REDIRECT --to-port 8000 && \
sudo iptables -t nat -A OUTPUT -o lo -p tcp --dport "$ACCESS_PORT" -j REDIRECT --to-port 8000; then
green "Port $ACCESS_PORT → 8000 redirect configured."
PORT_OK=true
# Persist so it survives reboot
if ! command -v netfilter-persistent &>/dev/null; then
yellow "Installing iptables-persistent to survive reboots..."
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y -qq iptables-persistent
fi
sudo netfilter-persistent save 2>/dev/null || sudo sh -c 'iptables-save > /etc/iptables/rules.v4'
else
yellow "Warning: Could not set port $ACCESS_PORT redirect (sudo required). Access via http://<ip>:8000"
fi
fi
fi
# ── 11. Summary ───────────────────────────────────────────────────────────────
PI_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
if [ "$PORT_OK" = true ] && [ "$ACCESS_PORT" -ne 8000 ]; then
DASHBOARD_URL="http://${PI_IP:-localhost}$([ "$ACCESS_PORT" -eq 80 ] && echo '' || echo ":$ACCESS_PORT")"
else
DASHBOARD_URL="http://${PI_IP:-localhost}:8000"
fi
echo
green "============================================"
green " Bambu-Run is running!"
green "============================================"
echo
echo " Dashboard: $DASHBOARD_URL"
if [ "$MCP_ENABLED" = true ]; then
echo " MCP Server: http://${PI_IP:-localhost}:8808/sse"
fi
echo " Status: systemctl --user status bambu-run-web bambu-run-collector"
echo " Logs: journalctl --user -u bambu-run-web -u bambu-run-collector -f"
echo " Helper: ./native/bambu-run.sh {start|stop|restart|status|logs|update}"
echo
if [ "$MCP_ENABLED" = true ]; then
echo " Claude Desktop config:"
echo " {\"mcpServers\":{\"bambu-run\":{\"url\":\"http://${PI_IP:-localhost}:8808/sse\"}}}"
echo
fi
echo " Services auto-start on boot. Safe to close SSH."
echo

View File

@@ -5,7 +5,15 @@ import sys
# Ensure the project root (/app) is on sys.path so that both 'standalone'
# and 'bambu_run' are importable regardless of where this script is invoked from.
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, PROJECT_ROOT)
# Load .env so manage.py commands pick up env vars outside of systemd/Docker
try:
from dotenv import load_dotenv
load_dotenv(os.path.join(PROJECT_ROOT, ".env"))
except ImportError:
pass
def main():

View File

@@ -111,6 +111,8 @@ PRINTER_IP = os.environ.get("PRINTER_IP", "")
ACCESS_TOKEN = os.environ.get("ACCESS_TOKEN", "")
PRINTER_SERIAL = os.environ.get("PRINTER_SERIAL", "")
CSRF_TRUSTED_ORIGINS = os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(",")
# Logging
LOGGING = {
"version": 1,

0
tests/__init__.py Normal file
View File

40
tests/settings.py Normal file
View File

@@ -0,0 +1,40 @@
"""Minimal Django settings for running bambu_run's pytest suite (in-memory SQLite)."""
SECRET_KEY = "test-secret-key"
INSTALLED_APPS = [
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"bambu_run",
]
MIDDLEWARE = [
"django.contrib.sessions.middleware.SessionMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
]
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
],
},
},
]
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": ":memory:",
}
}
USE_TZ = True
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
ROOT_URLCONF = "tests.urls"

View File

@@ -0,0 +1,25 @@
import pytest
from bambu_run.models import ams_type_from_info
@pytest.mark.parametrize(
"info_code,expected",
[
# Real-world 8-char info codes captured from a live H2C with
# AMS 2 Pro (unit 0), AMS (unit 1), AMS HT (unit 128).
("10001003", "AMS 2 Pro"),
("10001001", "AMS"),
("11002104", "AMS HT"),
# Bare 4-digit codes (original assumption) still resolve.
("1001", "AMS"),
("1003", "AMS 2 Pro"),
("2104", "AMS HT"),
# Unknown/missing codes resolve to empty string, not an error.
("99999999", ""),
("", ""),
(None, ""),
],
)
def test_ams_type_from_info(info_code, expected):
assert ams_type_from_info(info_code) == expected

68
tests/test_diagnostics.py Normal file
View File

@@ -0,0 +1,68 @@
import pytest
from bambu_run.diagnostics import redact_diagnostics, build_diagnostics_report
def test_redacts_password_and_token_like_keys():
data = {"BAMBU_PASSWORD": "hunter2", "access_token": "abc123", "ok": "fine"}
redacted = redact_diagnostics(data)
assert redacted["BAMBU_PASSWORD"] == "***REDACTED***"
assert redacted["access_token"] == "***REDACTED***"
assert redacted["ok"] == "fine"
def test_masks_known_identifier_keys_partially():
data = {"dev_id": "31B8BP592601478", "tray_uuid": "EE37828FA8844DE1AB12"}
redacted = redact_diagnostics(data)
assert redacted["dev_id"] == "31B8...1478"
assert redacted["tray_uuid"] == "EE37...AB12"
def test_short_identifier_values_fully_masked():
data = {"dev_id": "short"}
redacted = redact_diagnostics(data)
assert redacted["dev_id"] == "***"
def test_recurses_into_nested_structures():
data = {"devices": [{"dev_id": "31B8BP592601478", "name": "RNL-H2C"}]}
redacted = redact_diagnostics(data)
assert redacted["devices"][0]["dev_id"] == "31B8...1478"
assert redacted["devices"][0]["name"] == "RNL-H2C"
def test_no_redact_passthrough_keeps_original_values():
data = {"dev_id": "31B8BP592601478", "BAMBU_PASSWORD": "hunter2"}
result = redact_diagnostics(data, redact=False)
assert result == data
def test_build_diagnostics_report_structure():
devices = [{"dev_id": "SERIAL-A", "name": "Printer A", "dev_product_name": "H2C"}]
raw_payloads = {"SERIAL-A": {"device": {"extruder": {"info": []}}}}
report = build_diagnostics_report(devices, raw_payloads)
assert report["device_count"] == 1
assert "generated_at" in report
assert report["devices"][0]["device_info"]["dev_id"] == "SERIAL-A"
assert report["devices"][0]["raw_mqtt_payload"] == {"device": {"extruder": {"info": []}}}
def test_build_diagnostics_report_handles_missing_payload():
devices = [{"dev_id": "SERIAL-A", "name": "Printer A"}]
report = build_diagnostics_report(devices, raw_payloads={})
assert report["devices"][0]["raw_mqtt_payload"] is None
assert report["devices"][0]["note"] == "No MQTT data received within the listen window."

View File

@@ -0,0 +1,180 @@
import pytest
from decimal import Decimal
from django.urls import reverse
from django.utils import timezone
from bambu_run.models import Printer, PrinterMetrics, FilamentSnapshot
@pytest.fixture
def logged_in_client(client, django_user_model):
user = django_user_model.objects.create_user(username="tester", password="pw")
client.force_login(user)
return client
@pytest.mark.django_db
def test_dashboard_filaments_carry_ams_unit_info(logged_in_client):
printer = Printer.objects.create(name="Printer A", model="H2C", is_active=True)
metric = PrinterMetrics.objects.create(device=printer, timestamp=timezone.now())
FilamentSnapshot.objects.create(
printer_metric=metric, tray_id=0, ams_unit_id=0, ams_type="AMS",
type="PLA", remain_percent=80,
)
FilamentSnapshot.objects.create(
printer_metric=metric, tray_id=0, ams_unit_id=128, ams_type="AMS HT",
type="PA-CF", remain_percent=50,
)
resp = logged_in_client.get(
reverse("bambu_run:printer_dashboard", kwargs={"pk": printer.pk})
)
filaments = resp.context["stats"]["filaments"]
assert len(filaments) == 2
units = {(f["ams_unit_id"], f["ams_type"]) for f in filaments}
assert units == {(0, "AMS"), (128, "AMS HT")}
ams_units = resp.context["stats"]["ams_units"]
assert ams_units == [
{"ams_unit_id": 0, "ams_type": "AMS"},
{"ams_unit_id": 128, "ams_type": "AMS HT"},
]
@pytest.mark.django_db
def test_filament_timeline_keeps_same_tray_id_units_separate(logged_in_client):
from bambu_run.views import PrinterDashboardView
printer = Printer.objects.create(name="Printer A", model="H2C", is_active=True)
metric = PrinterMetrics.objects.create(device=printer, timestamp=timezone.now())
FilamentSnapshot.objects.create(
printer_metric=metric, tray_id=0, ams_unit_id=0, ams_type="AMS",
type="PLA", sub_type="PLA Basic", color="FF0000", remain_percent=80,
)
FilamentSnapshot.objects.create(
printer_metric=metric, tray_id=0, ams_unit_id=128, ams_type="AMS HT",
type="PLA", sub_type="PLA Basic", color="FF0000", remain_percent=50,
)
view = PrinterDashboardView()
timeline = view._prepare_filament_timeline(PrinterMetrics.objects.filter(pk=metric.pk))
assert len(timeline) == 2
@pytest.mark.django_db
def test_dashboard_renders_unit_pills_and_badges_with_multiple_units(logged_in_client):
printer = Printer.objects.create(name="Printer A", model="H2C", is_active=True)
metric = PrinterMetrics.objects.create(device=printer, timestamp=timezone.now())
FilamentSnapshot.objects.create(
printer_metric=metric, tray_id=0, ams_unit_id=0, ams_type="AMS",
type="PLA", color="FF0000FF", remain_percent=80,
)
FilamentSnapshot.objects.create(
printer_metric=metric, tray_id=0, ams_unit_id=128, ams_type="AMS HT",
type="PA-CF", color="00FF00FF", remain_percent=50,
)
resp = logged_in_client.get(
reverse("bambu_run:printer_dashboard", kwargs={"pk": printer.pk})
)
assert resp.status_code == 200
html = resp.content.decode()
assert "ams-filter-pills" in html
assert "ams-badge-ams" in html
assert "ams-badge-ams-ht" in html
assert 'data-ams-unit-id="0"' in html
assert 'data-ams-unit-id="128"' in html
@pytest.mark.django_db
def test_dashboard_groups_filaments_by_ams_unit(logged_in_client):
printer = Printer.objects.create(name="Printer A", model="H2C", is_active=True)
metric = PrinterMetrics.objects.create(
device=printer, timestamp=timezone.now(),
ams_units=[
{"unit_id": "0", "ams_type": "AMS 2 Pro", "humidity": 5, "temp": 22.5},
{"unit_id": "128", "ams_type": "AMS HT", "humidity": 8, "temp": 60.0},
],
)
FilamentSnapshot.objects.create(
printer_metric=metric, tray_id=0, ams_unit_id=0, ams_type="AMS 2 Pro",
type="ABS", remain_percent=80,
)
FilamentSnapshot.objects.create(
printer_metric=metric, tray_id=1, ams_unit_id=0, ams_type="AMS 2 Pro",
type="ABS", remain_percent=60,
)
FilamentSnapshot.objects.create(
printer_metric=metric, tray_id=0, ams_unit_id=128, ams_type="AMS HT",
type="PA-CF", remain_percent=50,
)
resp = logged_in_client.get(
reverse("bambu_run:printer_dashboard", kwargs={"pk": printer.pk})
)
groups = resp.context["stats"]["ams_groups"]
assert len(groups) == 2
ams2pro_group, ht_group = groups
assert ams2pro_group["unit_id"] == 0
assert ams2pro_group["label"] == "AMS 2 Pro (Unit 0)"
assert ams2pro_group["humidity"] == 5
assert ams2pro_group["temp"] == 22.5
assert len(ams2pro_group["filaments"]) == 2
assert ht_group["unit_id"] == 128
assert ht_group["label"] == "AMS HT (Unit 128)"
assert ht_group["humidity"] == 8
assert len(ht_group["filaments"]) == 1
@pytest.mark.django_db
def test_dashboard_renders_wide_and_compact_panels(logged_in_client):
printer = Printer.objects.create(name="Printer A", model="H2C", is_active=True)
metric = PrinterMetrics.objects.create(
device=printer, timestamp=timezone.now(),
ams_units=[
{"unit_id": "0", "ams_type": "AMS 2 Pro", "humidity": 5, "temp": 22.5},
{"unit_id": "128", "ams_type": "AMS HT", "humidity": 8, "temp": 60.0},
],
)
for tray_id in range(4):
FilamentSnapshot.objects.create(
printer_metric=metric, tray_id=tray_id, ams_unit_id=0, ams_type="AMS 2 Pro",
type="ABS", remain_percent=80,
)
FilamentSnapshot.objects.create(
printer_metric=metric, tray_id=0, ams_unit_id=128, ams_type="AMS HT",
type="PA-CF", remain_percent=50,
)
resp = logged_in_client.get(
reverse("bambu_run:printer_dashboard", kwargs={"pk": printer.pk})
)
html = resp.content.decode()
assert "ams-group--wide" in html
assert "ams-group--compact" in html
assert "AMS 2 Pro (Unit 0)" in html
assert "AMS HT (Unit 128)" in html
@pytest.mark.django_db
def test_dashboard_hides_unit_pills_with_single_unit(logged_in_client):
printer = Printer.objects.create(name="Printer A", model="H2C", is_active=True)
metric = PrinterMetrics.objects.create(device=printer, timestamp=timezone.now())
FilamentSnapshot.objects.create(
printer_metric=metric, tray_id=0, ams_unit_id=0, ams_type="AMS",
type="PLA", color="FF0000FF", remain_percent=80,
)
resp = logged_in_client.get(
reverse("bambu_run:printer_dashboard", kwargs={"pk": printer.pk})
)
assert resp.status_code == 200
assert "ams-filter-pills" not in resp.content.decode()

View File

@@ -0,0 +1,121 @@
import pytest
from bambu_run.management.commands.bambu_collector import Command, DeviceSession, resolve_printer_device
from bambu_run.models import Hotend, HotendSnapshot, PrinterMetrics
class FakeClient:
"""Stub in place of BambuPrinter — returns canned snapshots, no real MQTT."""
def __init__(self, snapshots):
self._snapshots = snapshots
self._index = 0
self._client = None
def get_snapshot(self):
snap = self._snapshots[min(self._index, len(self._snapshots) - 1)]
self._index += 1
return snap
def make_session(device_id, name, snapshots):
printer = resolve_printer_device(device_id, {"name": name, "dev_product_name": "H2C"})
return DeviceSession(device_id=device_id, client=FakeClient(snapshots), printer=printer)
def hotends_snapshot(used_time=11472, wear=100.0):
return {
"gcode_state": "IDLE",
"hotends": [
{
"raw_id": 21, "serial_number": "20D06A5B2918952", "nozzle_type": "HS01",
"diameter": 0.4, "fila_id": "GFA01", "color": "FFFFFF",
"used_time_seconds": used_time, "wear_percent": wear, "stat": 0,
"is_toolhead": False, "is_empty": False, "slot_number": 6,
},
{
"raw_id": 1, "serial_number": "N/A", "nozzle_type": "HS01",
"diameter": 0.4, "fila_id": "", "color": None,
"used_time_seconds": 0, "wear_percent": 0.0, "stat": 0,
"is_toolhead": False, "is_empty": True, "slot_number": None,
},
{
"raw_id": 0, "serial_number": "20D06A5C0426280", "nozzle_type": "HS01",
"diameter": 0.4, "fila_id": "GFA00", "color": "FEC600",
"used_time_seconds": 93490, "wear_percent": 100.0, "stat": 0,
"is_toolhead": True, "is_empty": False, "slot_number": None,
},
],
}
@pytest.mark.django_db
def test_first_poll_creates_one_hotend_per_non_empty_entry():
session = make_session("SERIAL-A", "Printer A", [hotends_snapshot()])
cmd = Command()
cmd.verbose = False
cmd._collect_printer_data(session)
hotends = Hotend.objects.filter(printer=session.printer)
assert hotends.count() == 2 # empty bay (sn="N/A") skipped
rack = hotends.get(serial_number="20D06A5B2918952")
assert rack.raw_id == 21
assert rack.slot_number == 6
assert rack.is_toolhead is False
assert rack.used_time_seconds == 11472
assert rack.wear_percent == 100.0
assert rack.nozzle_type == "HS01"
assert rack.last_filament_profile_id == "GFA01"
assert rack.last_color == "FFFFFF"
toolhead = hotends.get(serial_number="20D06A5C0426280")
assert toolhead.is_toolhead is True
assert toolhead.slot_number is None
@pytest.mark.django_db
def test_first_poll_creates_one_snapshot_per_non_empty_hotend():
session = make_session("SERIAL-A", "Printer A", [hotends_snapshot()])
cmd = Command()
cmd.verbose = False
cmd._collect_printer_data(session)
metric = PrinterMetrics.objects.get(device=session.printer)
assert HotendSnapshot.objects.filter(printer_metric=metric).count() == 2
@pytest.mark.django_db
def test_collector_persists_raw_nozzle_info_including_non_inductive_entries():
session = make_session("SERIAL-A", "Printer A", [hotends_snapshot()])
cmd = Command()
cmd.verbose = False
cmd._collect_printer_data(session)
metric = PrinterMetrics.objects.get(device=session.printer)
assert len(metric.nozzle_info) == 3 # all entries, including the empty/non-inductive one
serials = {h["serial_number"] for h in metric.nozzle_info}
assert serials == {"20D06A5B2918952", "N/A", "20D06A5C0426280"}
@pytest.mark.django_db
def test_second_poll_updates_existing_hotend_instead_of_duplicating():
session = make_session(
"SERIAL-A", "Printer A",
[hotends_snapshot(used_time=11472, wear=100.0), hotends_snapshot(used_time=11500, wear=100.0)],
)
cmd = Command()
cmd.verbose = False
cmd._collect_printer_data(session)
cmd._collect_printer_data(session)
hotends = Hotend.objects.filter(printer=session.printer, serial_number="20D06A5B2918952")
assert hotends.count() == 1
assert hotends.first().used_time_seconds == 11500
snapshots = HotendSnapshot.objects.filter(hotend=hotends.first())
assert snapshots.count() == 2

View File

@@ -0,0 +1,128 @@
import pytest
from django.urls import reverse
from django.utils import timezone
from bambu_run.models import Printer, PrinterMetrics, Hotend
@pytest.fixture
def logged_in_client(client, django_user_model):
user = django_user_model.objects.create_user(username="tester", password="pw")
client.force_login(user)
return client
@pytest.mark.django_db
def test_dashboard_context_includes_hotends_toolhead_first(logged_in_client):
printer = Printer.objects.create(name="Printer A", model="H2C", is_active=True)
PrinterMetrics.objects.create(device=printer, timestamp=timezone.now())
Hotend.objects.create(
printer=printer, serial_number="RACK-SN", raw_id=16, slot_number=1,
is_toolhead=False, nozzle_type="HS01", used_time_seconds=3600, wear_percent=50,
)
Hotend.objects.create(
printer=printer, serial_number="TOOLHEAD-SN", raw_id=0, slot_number=None,
is_toolhead=True, nozzle_type="HS01", used_time_seconds=7200, wear_percent=80,
)
resp = logged_in_client.get(
reverse("bambu_run:printer_dashboard", kwargs={"pk": printer.pk})
)
hotends = resp.context["stats"]["hotends"]
assert len(hotends) == 2
assert hotends[0].serial_number == "TOOLHEAD-SN"
assert hotends[1].serial_number == "RACK-SN"
@pytest.mark.django_db
def test_dashboard_context_includes_non_inductive_nozzle_positions(logged_in_client):
printer = Printer.objects.create(name="Printer A", model="H2C", is_active=True)
PrinterMetrics.objects.create(
device=printer, timestamp=timezone.now(),
nozzle_info=[
{
"raw_id": 1, "serial_number": "N/A", "nozzle_type": "HS01", "diameter": 0.4,
"fila_id": "", "color": None, "used_time_seconds": 0, "wear_percent": 0.0,
"stat": 0, "is_toolhead": False, "is_empty": True, "slot_number": None,
},
],
)
resp = logged_in_client.get(
reverse("bambu_run:printer_dashboard", kwargs={"pk": printer.pk})
)
positions = resp.context["stats"]["nozzle_positions"]
assert len(positions) == 1
assert positions[0]["nozzle_type"] == "HS01"
@pytest.mark.django_db
def test_dashboard_omits_nozzle_positions_with_no_readable_data(logged_in_client):
printer = Printer.objects.create(name="Printer A", model="H2C", is_active=True)
PrinterMetrics.objects.create(
device=printer, timestamp=timezone.now(),
nozzle_info=[
{
"raw_id": 1, "serial_number": "N/A", "nozzle_type": "", "diameter": 0,
"fila_id": "", "color": None, "used_time_seconds": 0, "wear_percent": 0.0,
"stat": 0, "is_toolhead": False, "is_empty": True, "slot_number": None,
},
],
)
resp = logged_in_client.get(
reverse("bambu_run:printer_dashboard", kwargs={"pk": printer.pk})
)
assert resp.context["stats"]["nozzle_positions"] == []
assert "<h5>Hotends</h5>" not in resp.content.decode()
@pytest.mark.django_db
def test_dashboard_renders_nozzle_position_without_serial_or_wear(logged_in_client):
printer = Printer.objects.create(name="Printer A", model="H2C", is_active=True)
PrinterMetrics.objects.create(
device=printer, timestamp=timezone.now(),
nozzle_info=[
{
"raw_id": 1, "serial_number": "N/A", "nozzle_type": "HS01", "diameter": 0.4,
"fila_id": "", "color": None, "used_time_seconds": 0, "wear_percent": 0.0,
"stat": 0, "is_toolhead": False, "is_empty": True, "slot_number": None,
},
],
)
resp = logged_in_client.get(
reverse("bambu_run:printer_dashboard", kwargs={"pk": printer.pk})
)
html = resp.content.decode()
assert "Hotends" in html
assert "HS01" in html
assert "SN: N/A" not in html
assert "SN N/A" not in html
@pytest.mark.django_db
def test_dashboard_renders_hotends_card(logged_in_client):
printer = Printer.objects.create(name="Printer A", model="H2C", is_active=True)
PrinterMetrics.objects.create(device=printer, timestamp=timezone.now())
Hotend.objects.create(
printer=printer, serial_number="RACK-SN", raw_id=18, slot_number=3,
is_toolhead=False, nozzle_type="HS01", diameter=0.4,
used_time_seconds=3661, wear_percent=50, last_filament_profile_id="GFA01",
last_color="DE4343",
)
resp = logged_in_client.get(
reverse("bambu_run:printer_dashboard", kwargs={"pk": printer.pk})
)
html = resp.content.decode()
assert "Hotends" in html
assert "RACK-SN" in html
assert "Slot 3" in html

View File

@@ -0,0 +1,100 @@
from bambu_run.mqtt_client import PrinterState
def real_nozzle_payload():
"""Real captured device.nozzle payload from a live H2C with a Vortek rack
(1x AMS, 1x AMS 2 Pro, 1x AMS HT physically connected — unrelated here).
SN/used-time cross-checked against the user's Bambu Studio Hotends Info table."""
return {
"exist": 3997699,
"src_id": 17,
"tar_id": 17,
"state": 0,
"info": [
{"id": 21, "sn": "20D06A5B2918952", "type": "HS01", "diameter": 0.4,
"fila_id": "GFA01", "color_m": "FFFFFFFF", "p_t": 11472, "wear": 128.0, "stat": 0, "tm": 350},
{"id": 1, "sn": "N/A", "type": "HS01", "diameter": 0.4,
"fila_id": "", "color_m": "00000000", "p_t": 0, "wear": 0.0, "stat": 0, "tm": 0},
{"id": 16, "sn": "20D06A5B2919219", "type": "HS01", "diameter": 0.4,
"fila_id": "GFA01", "color_m": "A3D8E1FF", "p_t": 105386, "wear": 128.0, "stat": 0, "tm": 350},
{"id": 20, "sn": "20D06A590610257", "type": "HS01", "diameter": 0.4,
"fila_id": "GFG01", "color_m": "00000000", "p_t": 81506, "wear": 128.0, "stat": 0, "tm": 350},
{"id": 18, "sn": "20D06A591506263", "type": "HS01", "diameter": 0.4,
"fila_id": "GFA01", "color_m": "DE4343FF", "p_t": 30962, "wear": 128.0, "stat": 0, "tm": 350},
{"id": 0, "sn": "20D06A5C0426280", "type": "HS01", "diameter": 0.4,
"fila_id": "GFA00", "color_m": "FEC600FF", "p_t": 93490, "wear": 128.0, "stat": 0, "tm": 350},
{"id": 19, "sn": "20D06A5C0207881", "type": "HS01", "diameter": 0.4,
"fila_id": "GFA01", "color_m": "DE4343FF", "p_t": 1430, "wear": 128.0, "stat": 0, "tm": 350},
],
}
def make_data(nozzle_payload):
return {"print": {"gcode_state": "IDLE", "device": {"nozzle": nozzle_payload}}}
def test_snapshot_includes_one_hotend_per_nozzle_info_entry():
state = PrinterState.from_mqtt_data(make_data(real_nozzle_payload()))
snapshot = state.get_snapshot()
assert len(snapshot["hotends"]) == 7
def test_hotend_fields_extracted_correctly():
state = PrinterState.from_mqtt_data(make_data(real_nozzle_payload()))
snapshot = state.get_snapshot()
by_sn = {h["serial_number"]: h for h in snapshot["hotends"]}
h = by_sn["20D06A5B2919219"]
assert h["raw_id"] == 16
assert h["nozzle_type"] == "HS01"
assert h["diameter"] == 0.4
assert h["fila_id"] == "GFA01"
assert h["color"] == "A3D8E1" # alpha stripped
assert h["used_time_seconds"] == 105386
assert h["wear_percent"] == 100.0 # 128/128*100
assert h["is_empty"] is False
def test_id_zero_is_toolhead_and_resolves_slot_number():
state = PrinterState.from_mqtt_data(make_data(real_nozzle_payload()))
snapshot = state.get_snapshot()
by_sn = {h["serial_number"]: h for h in snapshot["hotends"]}
toolhead = by_sn["20D06A5C0426280"]
assert toolhead["raw_id"] == 0
assert toolhead["is_toolhead"] is True
assert toolhead["slot_number"] is None # true bay address hidden while id==0 sentinel
def test_rack_bay_ids_resolve_to_slot_numbers_one_through_six():
state = PrinterState.from_mqtt_data(make_data(real_nozzle_payload()))
snapshot = state.get_snapshot()
by_sn = {h["serial_number"]: h for h in snapshot["hotends"]}
assert by_sn["20D06A5B2919219"]["slot_number"] == 1 # raw_id 16
assert by_sn["20D06A591506263"]["slot_number"] == 3 # raw_id 18
assert by_sn["20D06A5C0207881"]["slot_number"] == 4 # raw_id 19
assert by_sn["20D06A590610257"]["slot_number"] == 5 # raw_id 20
assert by_sn["20D06A5B2918952"]["slot_number"] == 6 # raw_id 21
def test_empty_bay_with_na_serial_is_flagged_empty():
state = PrinterState.from_mqtt_data(make_data(real_nozzle_payload()))
snapshot = state.get_snapshot()
by_sn = {h["serial_number"]: h for h in snapshot["hotends"]}
empty = by_sn["N/A"]
assert empty["is_empty"] is True
assert empty["is_toolhead"] is False
def test_snapshot_hotends_empty_list_when_no_nozzle_payload():
state = PrinterState.from_mqtt_data({"print": {"gcode_state": "IDLE"}})
snapshot = state.get_snapshot()
assert snapshot["hotends"] == []

View File

@@ -0,0 +1,104 @@
import pytest
from bambu_run.management.commands.bambu_collector import Command, DeviceSession, resolve_printer_device
from bambu_run.models import Filament, FilamentSnapshot, FilamentUsage, PrinterMetrics
class FakeClient:
"""Stub in place of BambuPrinter — returns canned snapshots, no real MQTT."""
def __init__(self, snapshots):
self._snapshots = snapshots
self._index = 0
self._client = None
def get_snapshot(self):
snap = self._snapshots[min(self._index, len(self._snapshots) - 1)]
self._index += 1
return snap
def make_session(device_id, name, snapshots):
printer = resolve_printer_device(device_id, {"name": name, "dev_product_name": "H2C"})
return DeviceSession(device_id=device_id, client=FakeClient(snapshots), printer=printer)
def two_unit_tray0_snapshot():
"""Two AMS units (AMS unit_id=0, AMS HT unit_id=128) both report tray_id=0,
with different filament types loaded — these must not collide."""
return {
"gcode_state": "IDLE",
"ams_units": [
{"unit_id": "0", "ams_type": "AMS", "humidity": 30, "temp": 25.0},
{"unit_id": "128", "ams_type": "AMS HT", "humidity": 20, "temp": 60.0},
],
"filaments": [
{
"tray_id": 0, "type": "PLA", "sub_type": "PLA Basic", "color": "FF0000FF",
"tray_uuid": "UUID-UNIT0-TRAY0",
"remain_percent": 80, "ams_unit_id": 0, "ams_type": "AMS",
},
{
"tray_id": 0, "type": "PA-CF", "sub_type": "PA6-CF", "color": "00FF00FF",
"tray_uuid": "UUID-UNIT128-TRAY0",
"remain_percent": 50, "ams_unit_id": 128, "ams_type": "AMS HT",
},
],
}
@pytest.mark.django_db
def test_two_ams_units_with_same_tray_id_create_distinct_snapshots():
session = make_session("SERIAL-A", "Printer A", [two_unit_tray0_snapshot()])
cmd = Command()
cmd.verbose = False
cmd._collect_printer_data(session)
metric = PrinterMetrics.objects.get(device=session.printer)
snapshots = FilamentSnapshot.objects.filter(printer_metric=metric).order_by("ams_unit_id")
assert snapshots.count() == 2
ams_snap, ht_snap = snapshots
assert ams_snap.tray_id == 0
assert ams_snap.ams_unit_id == 0
assert ams_snap.ams_type == "AMS"
assert ams_snap.type == "PLA"
assert ht_snap.tray_id == 0
assert ht_snap.ams_unit_id == 128
assert ht_snap.ams_type == "AMS HT"
assert ht_snap.type == "PA-CF"
@pytest.mark.django_db
def test_filament_usage_matches_correct_unit_when_tray_ids_collide():
start_snapshot = two_unit_tray0_snapshot()
start_snapshot.update({"gcode_state": "RUNNING", "subtask_name": "job_1", "print_percent": 1, "tray_now": "0"})
end_snapshot = two_unit_tray0_snapshot()
end_snapshot["filaments"][0]["remain_percent"] = 70 # AMS unit 0 consumed
end_snapshot["filaments"][1]["remain_percent"] = 50 # AMS HT unit 128 untouched
end_snapshot.update({"gcode_state": "FINISH", "subtask_name": "job_1", "print_percent": 100})
session = make_session("SERIAL-A", "Printer A", [start_snapshot, end_snapshot])
cmd = Command()
cmd.verbose = False
cmd._collect_printer_data(session)
cmd._collect_printer_data(session)
usages = FilamentUsage.objects.filter(print_job__device=session.printer).order_by("ams_unit_id")
# Both units reported tray_id=0 with a tracked filament loaded throughout the
# job — usage is recorded per physical unit, not collapsed into one ambiguous row.
assert usages.count() == 2
ams_usage, ht_usage = usages
assert ams_usage.ams_unit_id == 0
assert ams_usage.starting_percent == 80
assert ams_usage.ending_percent == 70
assert ht_usage.ams_unit_id == 128
assert ht_usage.starting_percent == 50
assert ht_usage.ending_percent == 50

View File

@@ -0,0 +1,90 @@
import pytest
from bambu_run.management.commands.bambu_collector import (
Command,
DeviceSession,
resolve_printer_device,
)
from bambu_run.models import PrintJob, PrinterMetrics
class FakeClient:
"""Stub in place of BambuPrinter — returns canned snapshots, no real MQTT."""
def __init__(self, snapshots):
self._snapshots = snapshots
self._index = 0
self._client = None # cloud BambuClient handle used by cloud task sync
def get_snapshot(self):
snap = self._snapshots[min(self._index, len(self._snapshots) - 1)]
self._index += 1
return snap
def make_session(device_id, name, snapshots):
printer = resolve_printer_device(device_id, {"name": name, "dev_product_name": "H2C"})
return DeviceSession(device_id=device_id, client=FakeClient(snapshots), printer=printer)
@pytest.mark.django_db
def test_collects_metrics_against_the_correct_printer_per_session():
session_a = make_session("SERIAL-A", "Printer A", [{"nozzle_temp": 200, "gcode_state": "IDLE"}])
session_b = make_session("SERIAL-B", "Printer B", [{"nozzle_temp": 210, "gcode_state": "IDLE"}])
cmd = Command()
cmd.verbose = False
cmd._collect_printer_data(session_a)
cmd._collect_printer_data(session_b)
metric_a = PrinterMetrics.objects.get(device=session_a.printer)
metric_b = PrinterMetrics.objects.get(device=session_b.printer)
assert metric_a.nozzle_temp == 200
assert metric_b.nozzle_temp == 210
@pytest.mark.django_db
def test_print_job_tracking_is_isolated_per_session():
session_a = make_session(
"SERIAL-A",
"Printer A",
[
{"gcode_state": "RUNNING", "subtask_name": "job_A", "print_percent": 10},
{"gcode_state": "FINISH", "subtask_name": "job_A", "print_percent": 100},
],
)
session_b = make_session("SERIAL-B", "Printer B", [{"gcode_state": "IDLE"}])
cmd = Command()
cmd.verbose = False
cmd._collect_printer_data(session_a)
cmd._collect_printer_data(session_b)
cmd._collect_printer_data(session_a)
assert PrintJob.objects.filter(device=session_a.printer).count() == 1
job = PrintJob.objects.get(device=session_a.printer)
assert job.final_status == "FINISH"
assert session_a.current_print_job is None
assert PrintJob.objects.filter(device=session_b.printer).count() == 0
assert session_b.current_print_job is None
@pytest.mark.django_db
def test_one_session_error_does_not_affect_another_session():
session_a = make_session("SERIAL-A", "Printer A", [{"nozzle_temp": 200, "gcode_state": "IDLE"}])
session_b = make_session("SERIAL-B", "Printer B", [{"nozzle_temp": 210, "gcode_state": "IDLE"}])
class ExplodingClient:
def get_snapshot(self):
raise RuntimeError("MQTT connection lost")
session_a.client = ExplodingClient()
cmd = Command()
cmd.verbose = False
cmd._collect_printer_data(session_a)
cmd._collect_printer_data(session_b)
assert session_a.error_count == 1
assert PrinterMetrics.objects.filter(device=session_b.printer).exists()

View File

@@ -0,0 +1,78 @@
import pytest
from django.urls import reverse
from bambu_run.models import Printer
@pytest.fixture
def logged_in_client(client, django_user_model):
user = django_user_model.objects.create_user(username="tester", password="pw")
client.force_login(user)
return client
@pytest.mark.django_db
def test_dashboard_with_no_printers_shows_error(logged_in_client):
resp = logged_in_client.get(reverse("bambu_run:printer_dashboard"))
assert resp.status_code == 200
assert "error" in resp.context
@pytest.mark.django_db
def test_dashboard_defaults_to_first_active_printer(logged_in_client):
printer = Printer.objects.create(name="Only Printer", model="H2C", is_active=True)
resp = logged_in_client.get(reverse("bambu_run:printer_dashboard"))
assert resp.context["printer_device"].pk == printer.pk
assert list(resp.context["all_printers"]) == [printer]
# Switcher shows even with a single printer, as a hint that multi-printer exists.
assert resp.context["show_printer_switcher"] is True
@pytest.mark.django_db
def test_dashboard_hides_switcher_with_zero_printers(logged_in_client):
resp = logged_in_client.get(reverse("bambu_run:printer_dashboard"))
assert resp.context["show_printer_switcher"] is False
@pytest.mark.django_db
def test_dashboard_pk_route_shows_requested_printer(logged_in_client):
Printer.objects.create(name="Printer A", model="H2C", is_active=True)
printer_b = Printer.objects.create(name="Printer B", model="X1C", is_active=True)
resp = logged_in_client.get(
reverse("bambu_run:printer_dashboard", kwargs={"pk": printer_b.pk})
)
assert resp.context["printer_device"].pk == printer_b.pk
assert resp.context["device_name"] == "Printer B"
@pytest.mark.django_db
def test_dashboard_unknown_pk_returns_404(logged_in_client):
resp = logged_in_client.get(
reverse("bambu_run:printer_dashboard", kwargs={"pk": 99999})
)
assert resp.status_code == 404
@pytest.mark.django_db
def test_api_pk_route_returns_only_requested_printer_data(logged_in_client):
from bambu_run.models import PrinterMetrics
from django.utils import timezone
from decimal import Decimal
printer_a = Printer.objects.create(name="Printer A", model="H2C", is_active=True)
printer_b = Printer.objects.create(name="Printer B", model="X1C", is_active=True)
PrinterMetrics.objects.create(device=printer_a, timestamp=timezone.now(), nozzle_temp=Decimal("200"))
PrinterMetrics.objects.create(device=printer_b, timestamp=timezone.now(), nozzle_temp=Decimal("210"))
resp = logged_in_client.get(
reverse("bambu_run:printer_api", kwargs={"pk": printer_b.pk})
)
assert resp.status_code == 200
data = resp.json()
assert data["nozzle_temp"] == [210.0]

View File

@@ -0,0 +1,78 @@
import pytest
from bambu_run.management.commands.bambu_collector import resolve_printer_device
from bambu_run.models import Printer
@pytest.mark.django_db
def test_creates_new_printer_keyed_by_serial():
printer = resolve_printer_device(
"0309DA123456", {"name": "RNL-H2C", "dev_product_name": "H2C"}
)
assert printer.serial_number == "0309DA123456"
assert printer.name == "RNL-H2C"
assert printer.model == "H2C"
assert printer.is_active is True
@pytest.mark.django_db
def test_second_call_with_same_serial_does_not_create_duplicate():
first = resolve_printer_device("SERIAL-A", {"name": "Printer A", "dev_product_name": "H2C"})
second = resolve_printer_device("SERIAL-A", {"name": "Printer A", "dev_product_name": "H2C"})
assert first.pk == second.pk
assert Printer.objects.filter(serial_number="SERIAL-A").count() == 1
@pytest.mark.django_db
def test_two_different_serials_create_two_printers():
a = resolve_printer_device("SERIAL-A", {"name": "Printer A", "dev_product_name": "H2C"})
b = resolve_printer_device("SERIAL-B", {"name": "Printer B", "dev_product_name": "X1C"})
assert a.pk != b.pk
assert Printer.objects.count() == 2
@pytest.mark.django_db
def test_backfills_single_legacy_printer_with_null_serial():
legacy = Printer.objects.create(
name="Bambu Lab Printer", model="Bambu Lab", manufacturer="Bambu Lab", is_active=True
)
resolved = resolve_printer_device("SERIAL-A", {"name": "RNL-H2C", "dev_product_name": "H2C"})
legacy.refresh_from_db()
assert resolved.pk == legacy.pk
assert legacy.serial_number == "SERIAL-A"
assert Printer.objects.count() == 1
@pytest.mark.django_db
def test_does_not_guess_when_multiple_legacy_printers_exist():
Printer.objects.create(name="Legacy 1", model="Bambu Lab")
Printer.objects.create(name="Legacy 2", model="Bambu Lab")
resolved = resolve_printer_device("SERIAL-A", {"name": "RNL-H2C", "dev_product_name": "H2C"})
assert resolved.serial_number == "SERIAL-A"
assert Printer.objects.count() == 3
@pytest.mark.django_db
def test_falls_back_to_generic_defaults_without_device_info():
printer = resolve_printer_device("SERIAL-A", None)
assert printer.serial_number == "SERIAL-A"
assert printer.name == "Bambu Lab Printer"
assert printer.model == "Bambu Lab"
@pytest.mark.django_db
def test_updates_name_and_model_on_existing_printer_when_changed():
resolve_printer_device("SERIAL-A", {"name": "Old Name", "dev_product_name": "H2C"})
updated = resolve_printer_device("SERIAL-A", {"name": "New Name", "dev_product_name": "H2C"})
assert updated.name == "New Name"
assert Printer.objects.filter(serial_number="SERIAL-A").count() == 1

View File

@@ -0,0 +1,43 @@
import pytest
from bambu_run.mqtt_client import PrinterState
from bambu_run.management.commands.bambu_collector import Command, DeviceSession, resolve_printer_device
from bambu_run.models import PrinterMetrics
def test_snapshot_includes_raw_device_payload_for_future_vortek_modeling():
raw_device = {
"extruder": {"info": [{"id": 0, "temp": 12058720}, {"id": 1, "temp": 11534560}]},
"nozzle": {"info": [{"id": 0, "diameter": 0.4}]},
}
data = {"print": {"device": raw_device, "gcode_state": "IDLE"}}
state = PrinterState.from_mqtt_data(data)
snapshot = state.get_snapshot()
assert snapshot["vortek_raw"] == raw_device
def test_snapshot_vortek_raw_defaults_to_empty_dict_when_no_device_payload():
state = PrinterState.from_mqtt_data({"print": {"gcode_state": "IDLE"}})
snapshot = state.get_snapshot()
assert snapshot["vortek_raw"] == {}
@pytest.mark.django_db
def test_collector_persists_vortek_raw_onto_printer_metrics():
printer = resolve_printer_device("SERIAL-A", {"name": "H2C", "dev_product_name": "H2C"})
class FakeClient:
def get_snapshot(self):
return {"gcode_state": "IDLE", "vortek_raw": {"extruder": {"info": []}}}
session = DeviceSession(device_id="SERIAL-A", client=FakeClient(), printer=printer)
cmd = Command()
cmd.verbose = False
cmd._collect_printer_data(session)
metric = PrinterMetrics.objects.get(device=printer)
assert metric.vortek_raw == {"extruder": {"info": []}}

5
tests/urls.py Normal file
View File

@@ -0,0 +1,5 @@
from django.urls import include, path
urlpatterns = [
path("", include("bambu_run.urls")),
]