56 Commits

Author SHA1 Message Date
RNL
4cf7d0114d added mcp server setup guide 2026-08-31 23:35:02 +10:00
RNL
6c16e66add fixed CSRF_TRUSTED_ORIGINS not set when setup 2026-08-31 22:55:23 +10:00
RNL
6f681f8083 added verbose arg for verbose install 2026-08-31 22:29:27 +10:00
RNL
fb1c3fb834 feat(standalone): support Postgres via DB_ENGINE env var
Lets a standalone install point at an existing Postgres database
(e.g. for testing against real data on a verification host) instead
of always writing to local SQLite. Defaults to SQLite when DB_ENGINE
is unset, so existing self-hosters see no behavior change.
2026-08-28 23:20:29 +10:00
RNL
888ce452e4 chore: ignore .env, local agent notes, and native setup artifacts
setup.sh writes Bambu Lab credentials and DJANGO_SECRET_KEY into .env,
which was previously untracked but not ignored — one 'git add .' away
from being committed. Also ignores .venv/, data/, db.sqlite3, and
CLAUDE.local.md.
2026-08-23 15:51:43 +10:00
RNL
86bab78a52 fix(mcp): address final-review findings (sentinel exclusion, per-unit AMS data, generated_at, gating test, docs, cleanup) 2026-08-19 22:43:11 +10:00
RNL
e23650eaa4 feat(mcp): serve the toolkit via mcp-gate; retire mcp_tools.py and the old FastMCP-based command 2026-08-19 22:30:52 +10:00
RNL
10034a2de4 feat(mcp): assemble the 8-tool toolkit with capability gating, proven via real dispatch
Also fixes async-test DB isolation: test_mcp_toolkit.py drives real MCP
dispatch, which runs tool functions' ORM calls on a sync_to_async worker
thread outside pytest-django's per-test atomic transaction, so writes
committed there weren't rolled back and leaked into later tests. Sets
DJANGO_ALLOW_ASYNC_UNSAFE for the async ORM setup calls and flushes the
Printer table after this file's tests via conftest.py.
2026-08-19 22:26:09 +10:00
RNL
6efa55f946 feat(mcp): add get_filament tool 2026-08-19 22:20:11 +10:00
RNL
3656286006 fix(mcp): exclude -1 sentinel from min_remaining_grams filter in list_filaments 2026-08-19 22:18:07 +10:00
RNL
212de0b06e feat(mcp): add list_filaments tool 2026-08-19 22:14:39 +10:00
RNL
0164a72598 feat(mcp): add get_print_statistics tool 2026-08-19 22:11:51 +10:00
RNL
1a28197eda feat(mcp): add get_print_job tool 2026-08-19 22:09:16 +10:00
RNL
31d0cb677e feat(mcp): add list_print_jobs tool 2026-08-19 22:06:51 +10:00
RNL
39e4638f6a feat(mcp): add get_hardware_health tool 2026-08-19 22:04:14 +10:00
RNL
e8d861f8a9 feat(mcp): add get_metrics_history tool 2026-08-19 22:00:12 +10:00
RNL
679c620fb2 feat(mcp): add get_printer_status tool 2026-08-19 21:55:02 +10:00
RNL
df34ec8e3e fix(mcp): treat ending_percent=-1 as unread sentinel in normalize_consumed 2026-08-19 21:52:48 +10:00
RNL
8a498e933e feat(mcp): add shared output-shaping helpers for the MCP toolkit 2026-08-19 21:49:16 +10:00
RNL
fcd6561cbf build: depend on mcp-gate v0.1.0, drop old mcp[cli] extra 2026-08-19 21:46:26 +10:00
RNL
77d2b31edb chore: ignore .worktrees/ for local worktree isolation 2026-08-19 21:43:38 +10:00
RNL
fcd038c562 ci(readme): auto-generate release notes on tag, add dashboard tour to README
- bump-version.yml now creates a GitHub Release with `--generate-notes`
  alongside each version tag, so the Tags/Releases page is self-describing
  instead of a bare list of vX.Y.Z tags with no context.
- README gets a Dashboard Tour gallery using the wiki's existing
  user-attachments image URLs (no binary images added to the repo).
2026-08-12 22:29:27 +10:00
RNL
911905610f Revert "fix(templates): grey out filament write controls for read-only viewers"
This reverts commit 1bbeef7a47.
2026-08-12 22:29:27 +10:00
github-actions[bot]
b2a083fb94 chore: bump version to 0.1.13 [skip ci] 2026-08-08 13:34:04 +00:00
RNL
1bbeef7a47 fix(templates): grey out filament write controls for read-only viewers
RAE is adding a 'friend' access tier that can view the filament inventory
but not modify it. These buttons already conditionally render on
{% if not is_basic_user %}; this adds a parallel can_write check so a
read-only-but-not-basic viewer sees Add/Edit/Delete/Manage Colors as
visibly disabled (with a tooltip) rather than fully hidden or, worse,
clickable.
2026-08-08 23:33:49 +10:00
github-actions[bot]
40ec163134 chore: bump version to 0.1.12 [skip ci] 2026-08-06 12:45:31 +00:00
RNL
61cfd3f5a9 perf(printer): halve dashboard load by capping chart series at 1440 points
24h at the collector's 30s cadence is ~2800 readings, and each one pulls in
~9 FilamentSnapshot rows. That snapshot fetch — not the metrics query — was
what made the dashboard slow: 25,308 rows and 1.2s of the 2.09s spent
building the context.

Capping at 1440 keeps roughly one point per minute over a day, finer than a
canvas can resolve, and applies to the public demo API too since it shares
sample_metrics().

  printer dashboard  2.09s / 410 KB  ->  0.50s / 207 KB
  public demo API                    ->  0.31s / 116 KB

Claude-Session: https://claude.ai/code/session_01AoYZiGtU3zEuFEGvP6zoZD
2026-08-06 22:45:20 +10:00
github-actions[bot]
1e35c48be1 chore: bump version to 0.1.11 [skip ci] 2026-08-03 12:43:07 +00:00
RunLit
b16f4b3cac Merge pull request #16 from RunLit/fix/x2d-stale-state-after-reconnect
Force MQTT pushall re-sync after a report gap
2026-08-03 22:42:51 +10:00
RunLit
58400f16cf Merge pull request #15 from RunLit/fix/x2d-right-nozzle-temp
Fix X2D right nozzle temp showing active-nozzle value
2026-08-03 22:42:48 +10:00
RNL
6f281a0cab Force MQTT pushall re-sync after a report gap (fixes #14)
The printer publishes partial deltas most of the time; the accumulator
merges each one onto whatever state it already has. When the printer
powers off, our cloud MQTT connection stays up (it's a persistent
session to Bambu's broker, not a socket to the printer), so nothing
signals the outage. When the printer reconnects and resumes its normal
partial updates, those deltas get merged onto the stale pre-outage
state — fields the delta doesn't mention (e.g. nozzle_temp) stay frozen
at their old values. Sending any command happens to trigger a full
report from the printer, which is why toggling the light "fixes" it.

Track the time of the last message per BambuPrinter instance. When a
new message arrives after a gap longer than
BAMBU_RUN_MQTT_RESYNC_GAP_SECONDS (default 90s), request a pushall
before processing it, forcing a complete state refresh instead of
trusting the partial delta to overwrite stale fields.
2026-08-02 23:14:03 +10:00
RNL
4db0a7d728 Fix X2D right nozzle temp showing active-nozzle value (fixes #13)
Dual-nozzle decoding only read extruder.info[1] for the left nozzle,
leaving the right nozzle to fall back on the legacy top-level
nozzle_temper/nozzle_target_temper fields. On the H2C that field
happens to always mirror the right extruder, but on the X2D it tracks
whichever nozzle is currently active — so during a left-only print the
"Right Nozzle" card showed the left nozzle's temperature.

Decode extruder.info[0] the same bit-packed way as the left side and
prefer it over the legacy field when present.
2026-08-02 22:57:03 +10:00
github-actions[bot]
c7ea0dd094 chore: bump version to 0.1.10 [skip ci] 2026-07-27 21:14:31 +00:00
RNL
6f19560842 perf(printer): remove deferred-field N+1 and scope Printer to real printers
The printer chart API was issuing one extra SELECT per row per field that the
serializer read but .only() omitted. nozzle_temp_left/nozzle_target_temp_left
were added to the serialization loop without being added to _METRICS_API_FIELDS,
so a single-day request ran 5,507 queries and took 34s; the UI's default 48h
range took 87s. Against a remote Postgres this is pure round-trip latency.

- Add the two left-nozzle fields to _METRICS_API_FIELDS.
- Always apply both time bounds in PrinterDataAPIView. Missing or partial date
  params previously left the range open, so a bare API call scanned the whole
  metrics table.
- Give PrinterDashboardView the same treatment the API already had: .only(),
  sampling to _MAX_CHART_POINTS, and a targeted snapshot fetch. It also
  evaluated its queryset twice, because .last() on an unevaluated queryset
  issues its own query plus its own prefetch.
- Extract sample_metrics() and fetch_snapshots_by_metric() for reuse.

Printer shares the infrastructure_device table with a host project's other
devices and had no category field, so Printer.objects.filter(is_active=True)
could return a NAS. Add a category field and a category-scoped default manager,
keeping all_objects as the unfiltered base manager so related descriptors still
resolve every row. Migration 0009 creates the column in standalone deployments
and skips the DDL where the host project already owns it.

Measured: API 87s -> 0.67s, dashboard 3.4s -> 0.9s. Query counts are now
independent of row count, asserted by tests.
2026-07-28 07:14:10 +10:00
RNL
86619a807c Remove redundant AMS Status card; fix compact AMS group width by switching to Bootstrap col-lg-3 so single-slot units match one tray card width on all screen sizes. 2026-07-28 07:14:10 +10:00
RNL
1231dfafc9 Fix AMS HT label: add info code 1104 variant and re-derive ams_type from stored info when snapshot value is blank. 2026-07-28 07:14:10 +10:00
github-actions[bot]
2c7d6b8dba chore: bump version to 0.1.9 [skip ci] 2026-06-24 13:54:49 +00:00
RNL
0a1da46d1f Fix filaments card empty for pre-multi-AMS data (ams_unit_id=NULL rows now render in a fallback group). Resolve Dockerfile stash conflict. 2026-06-24 23:54:31 +10:00
github-actions[bot]
4c01b3d909 chore: bump version to 0.1.8 [skip ci] 2026-06-24 13:14:43 +00:00
RunLit
146d5af7aa Feature/multi printer support (#12)
* Initial implementation of multi-printer support.

* Always show device dropdown and add bambu_diagnose for multi-printer troubleshooting.

* 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.

* 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-24 23:14:32 +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
RunLit
5984bd6fa0 Filament tools that help upload bambu colors and filament types easily (#3)
* added cover image

* bambu color import manage tool added

* added AMS hex color trimming

* updated instructions

* touch up readme

* fixed line chart noise x axis and add more date marker to split them up
2026-02-25 23:07:24 +11:00
78 changed files with 6995 additions and 618 deletions

View File

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

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

@@ -0,0 +1,69 @@
name: Bump Patch Version on Merge to Main
on:
push:
branches:
- main
concurrency:
group: bump-version-main
cancel-in-progress: false
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 pull --rebase origin main
git push
- name: Tag the release
run: |
git tag "v${{ steps.bump.outputs.new_version }}"
git push origin "v${{ steps.bump.outputs.new_version }}"
- name: Create GitHub release with auto-generated notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "v${{ steps.bump.outputs.new_version }}" \
--title "v${{ steps.bump.outputs.new_version }}" \
--generate-notes

27
.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
# Secrets — setup.sh writes Bambu credentials + Django secret key here
.env
!.env.example
# Local-only agent notes
CLAUDE.local.md
.claude/
# Native setup artifacts
.venv/
data/
db.sqlite3
staticfiles/
# Python
__pycache__/
*.py[cod]
*.egg-info/
build/
dist/
# Editors / OS
.idea/
.vscode/
.DS_Store
.worktrees/

View File

@@ -22,23 +22,25 @@ RUN pip install --no-cache-dir bambu-lab-cloud-api --no-deps && \
(d / 'INSTALLER').write_text('pip\n'); \ (d / 'INSTALLER').write_text('pip\n'); \
(d / 'RECORD').write_text('')" (d / 'RECORD').write_text('')"
# Install project and remaining dependencies (pip sees opencv-python already satisfied) # Install standalone dependencies first — cached unless pyproject.toml changes
COPY pyproject.toml . COPY pyproject.toml .
RUN pip install --no-cache-dir ".[standalone]" RUN pip install --no-cache-dir ".[standalone,mcp]"
# Copy application code # Copy full source and install the local package (no-deps: already installed above)
COPY . . COPY . .
RUN pip install --no-cache-dir --no-deps .
# Create data directory for SQLite # Create data directory for SQLite
RUN mkdir -p /app/data RUN mkdir -p /app/data
# Collect static files # Collect static files from the locally installed package
ENV DJANGO_SETTINGS_MODULE=standalone.settings ENV DJANGO_SETTINGS_MODULE=standalone.settings
RUN python standalone/manage.py collectstatic --noinput 2>/dev/null || true RUN python standalone/manage.py collectstatic --noinput
# Supervisor config to run both web and collector # Supervisor config to run both web and collector
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
EXPOSE 8000 EXPOSE 8000
EXPOSE 8808
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

262
README.md
View File

@@ -20,153 +20,181 @@ It runs quietly in the background 24/7, capturing every print, filament change,
--- ---
## Table of Contents ## What It Looks Like
- [Quick Start: One-Click Docker Setup — Beginner Friendly](#quick-start-one-click-docker-setup--beginner-friendly) <table>
- [What You'll Need](#what-youll-need) <tr>
- [Step 1: Connect to Your Raspberry Pi](#step-1-connect-to-your-raspberry-pi) <td width="50%">
- [Step 2: Install Docker](#step-2-install-docker) <p align="center"><strong>Live Dashboard</strong><br/>Real-time nozzle temps, bed temp, print progress, and chamber light.</p>
- [Step 3: Download and Configure](#step-3-download-and-configure) <img src="https://github.com/user-attachments/assets/169ec3e5-62a9-4e96-afed-dc6ac2647aef" alt="Live Dashboard" width="100%"/>
- [Step 4: Build the Container](#step-4-build-the-container) </td>
- [Step 5: First-Time Authentication](#step-5-first-time-authentication) <td width="50%">
- [Step 6: Start Bambu-Run and Create Your Login](#step-6-start-bambu-run-and-create-your-login) <p align="center"><strong>Historical Charts</strong><br/>Filament remaining and nozzle temp over time, per printer.</p>
- [Step 7: Open the Dashboard](#step-7-open-the-dashboard) <img src="https://github.com/user-attachments/assets/11913c62-254f-4ba6-8d93-d5c27a9406be" alt="Historical Charts" width="100%"/>
- [Troubleshooting](#troubleshooting) </td>
- [Batch Importing Filament Colors and Filament Types](#batch-importing-filament-colors-and-filament-types) </tr>
<tr>
<td width="50%">
<p align="center"><strong>Filament Inventory</strong><br/>Every spool you own — brand, type, color, remaining %, current tray.</p>
<img src="https://github.com/user-attachments/assets/8e472704-a6d8-45e1-93c4-c6f37d0b55a0" alt="Filament Inventory" width="100%"/>
</td>
<td width="50%">
<p align="center"><strong>AMS / Filament Slots</strong><br/>What's loaded right now, grouped by AMS unit, with humidity/temp.</p>
<img src="https://github.com/user-attachments/assets/200ad65f-a716-4a52-98b2-d5f5ce2445b9" alt="AMS / Filament Slots" width="100%"/>
</td>
</tr>
<tr>
<td colspan="2">
<p align="center"><strong>Hotend Wear Tracking</strong><br/>Used time and wear per hotend slot — handy for multi-nozzle / AMS-fed toolhead setups.</p>
<img src="https://github.com/user-attachments/assets/defee0da-772c-40c2-8dcf-9d3f230cc5ee" alt="Hotend Wear Tracking" width="100%"/>
</td>
</tr>
</table>
--- ---
## Quick Start: One-Click Docker Setup — Beginner Friendly ## Table of Contents
Get Bambu-Run running on a **Raspberry Pi** in minutes. No prior server experience needed. - [What It Looks Like](#what-it-looks-like)
- [Native Setup (Recommended for Raspberry Pi)](#native-setup-recommended-for-raspberry-pi)
- [What You'll Need](#what-youll-need)
- [Clone and run setup.sh](#clone-and-run-setupsh)
- [Managing Bambu-Run](#managing-bambu-run)
- [Troubleshooting (Native)](#troubleshooting-native)
- [Docker Setup](#docker-setup)
- [Batch Importing Filament Colors and Filament Types](#batch-importing-filament-colors-and-filament-types)
- [MCP Server (AI Agent Access)](#mcp-server-ai-agent-access)
---
## Native Setup (Recommended for Raspberry Pi)
No Docker required. Works on any Raspberry Pi (including 32-bit Pi Model B) running Raspberry Pi OS with Python 3.10+.
### What You'll Need ### 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 - Raspberry Pi on your local network (Python 3.10+)
- Your Bambu Lab printer on the **same local network** - Bambu Lab printer
- Your Bambu Lab account **email and password** - Bambu Lab account **email and password**
- A computer to SSH into the Pi
### Step 1: Connect to Your Raspberry Pi ### Clone and run setup.sh
From your computer, open a terminal (Mac/Linux) or PowerShell (Windows):
```bash ```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 ```bash
curl -fsSL https://get.docker.com | sudo sh ./native/bambu-run.sh status # service status
sudo usermod -aG docker $USER ./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 ```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 ```bash
ssh pi@raspberrypi.local # Stop and remove services
docker --version # should show Docker version 27.x.x 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 ```bash
git clone https://github.com/RunLit/Bambu-Run.git git clone https://github.com/RunLit/Bambu-Run.git
cd Bambu-Run cd Bambu-Run
cp .env.example .env cp .env.example .env
nano .env # Edit .env: set BAMBU_USERNAME, BAMBU_PASSWORD, TIMEZONE
``` ```
Fill in your Bambu Lab credentials: **First-time auth** (Bambu Lab sends a 6-digit verification code to your email):
```
BAMBU_USERNAME=your_email@example.com
BAMBU_PASSWORD=your_password
TIMEZONE=Australia/Melbourne # optional — find yours at https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
```
Save: `Ctrl + X`, `Y`, `Enter`
### Step 4: Build the Container
```bash ```bash
docker compose build docker compose build
```
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 migrate --noinput
docker compose run --rm bambu-run python standalone/manage.py bambu_collector --once docker compose run --rm bambu-run python standalone/manage.py bambu_collector --once
# Paste the printed token into .env as BAMBU_TOKEN=...
``` ```
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`: **Start and create your dashboard login:**
```bash
nano .env
```
```
BAMBU_TOKEN=eyJhbGciOiJIUzI1N...paste_full_token_here
```
> Saving the token lets future restarts skip re-verification automatically.
### Step 6: Start Bambu-Run and Create Your Login
```bash ```bash
docker compose up -d docker compose up -d
docker compose exec bambu-run python standalone/manage.py createsuperuser docker compose exec bambu-run python standalone/manage.py createsuperuser
``` ```
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 ```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:** **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.
```bash
docker compose down
```
Your data is preserved in a Docker volume and will be there when you start it again.
--- ---
@@ -269,3 +297,45 @@ Processing: PLA Basic.txt → type='PLA' sub_type='PLA Basic'
- **Created** — new color entries added to the database - **Created** — new color entries added to the database
- **Skipped (duplicate)** — already existed, not changed - **Skipped (duplicate)** — already existed, not changed
- **Skipped (no type)** — only shown if `--no-auto-create-filament-type` is used and the filament type isn't in the database yet - **Skipped (no type)** — only shown if `--no-auto-create-filament-type` is used and the filament type isn't in the database yet
## MCP Server (AI Agent Access)
Bambu-Run exposes printer status, print history, and filament inventory as
read-only [MCP](https://modelcontextprotocol.io/) tools, so an AI agent (e.g.
Claude Desktop, Claude Code) can query your printer directly.
**Native install (`setup.sh`):** if you declined MCP during setup (or ran
`setup.sh` before MCP support existed), enable it any time with:
```bash
./native/bambu-run.sh enable-mcp # install deps, create + start the systemd service
./native/bambu-run.sh disable-mcp # stop and remove it
```
**Manual / library install:** install the extra:
```bash
pip install 'bambu-run[mcp]'
```
**stdio** (for a local agent on the same machine — e.g. Claude Desktop):
```bash
python manage.py bambu_mcp_server --transport stdio
```
**HTTP** (for a remote agent, or multiple callers over the network):
```bash
export BAMBU_RUN_MCP_API_KEY=your-secret-key
python manage.py bambu_mcp_server --transport http --host 0.0.0.0 --port 8808
```
`BAMBU_RUN_MCP_API_KEY` is **required** for HTTP — the server refuses to start
without it, since an unauthenticated network MCP endpoint would expose your
printer and filament data to anyone who can reach the port. stdio needs no key
(the OS process boundary is the trust boundary there).
If serving behind a reverse proxy on a real domain, pass
`--allowed-host your.domain.com` (repeatable) — otherwise the SDK's
DNS-rebinding guard rejects requests with a real `Host` header.

View File

@@ -1,5 +1,5 @@
from django.contrib import admin 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) @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_display = ('print_job', 'filament', 'tray_id', 'consumed_percent', 'consumed_grams', 'is_primary')
list_filter = ('is_primary', 'tray_id') list_filter = ('is_primary', 'tray_id')
readonly_fields = ('consumed_percent', 'consumed_grams') 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,34 @@ class _Settings:
def AUTO_CREATE_BRAND(self): def AUTO_CREATE_BRAND(self):
return get_setting("BAMBU_RUN_AUTO_CREATE_BRAND", "Bambu Lab") 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)
# 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)
# Seconds of silence on the MQTT report topic that, once broken by a new
# message, is treated as "the printer was probably offline" and triggers
# a pushall re-sync instead of trusting the partial delta to fill in stale
# fields left over from before the gap.
@property
def MQTT_RESYNC_GAP_SECONDS(self):
return get_setting("BAMBU_RUN_MQTT_RESYNC_GAP_SECONDS", 90)
app_settings = _Settings() 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 model = Filament
fields = [ fields = [
'tray_uuid', 'tag_uid', 'tag_id', 'created_by', 'tray_uuid', 'tag_uid', 'tag_id', 'created_by',
'filament_type', 'type', 'sub_type', 'brand', 'color', 'color_hex', 'filament_type', 'type', 'sub_type', 'brand', 'color', 'color_hex', 'is_transparent',
'diameter', 'initial_weight_grams', 'diameter', 'initial_weight_grams',
'remaining_percent', 'remaining_weight_grams', 'remaining_percent', 'remaining_weight_grams',
'is_loaded_in_ams', 'current_tray_id', 'is_loaded_in_ams', 'current_tray_id', 'ams_unit_id', 'ams_type',
'purchase_date', 'purchase_price', 'supplier', 'notes' 'purchase_date', 'purchase_price', 'supplier', 'notes'
] ]
widgets = { widgets = {
@@ -71,10 +71,10 @@ class FilamentForm(forms.ModelForm):
}), }),
'tag_id': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Optional - User-defined ID'}), 'tag_id': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Optional - User-defined ID'}),
'created_by': forms.Select(attrs={'class': 'form-select'}), 'created_by': forms.Select(attrs={'class': 'form-select'}),
'filament_type': forms.Select(attrs={'class': 'form-select'}), 'filament_type': forms.Select(attrs={'class': 'form-select', 'id': 'id_filament_type'}),
'type': forms.HiddenInput(), 'type': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g., PLA, PETG, ABS'}),
'sub_type': forms.HiddenInput(), 'sub_type': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g., PLA Basic (optional)'}),
'brand': forms.HiddenInput(), 'brand': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g., Bambu Lab'}),
'color': forms.Select(attrs={'class': 'form-select', 'id': 'id_color'}), 'color': forms.Select(attrs={'class': 'form-select', 'id': 'id_color'}),
'color_hex': forms.TextInput(attrs={ 'color_hex': forms.TextInput(attrs={
'class': 'form-control', 'class': 'form-control',
@@ -85,8 +85,17 @@ class FilamentForm(forms.ModelForm):
'initial_weight_grams': forms.NumberInput(attrs={'class': 'form-control', 'placeholder': '1000'}), 'initial_weight_grams': forms.NumberInput(attrs={'class': 'form-control', 'placeholder': '1000'}),
'remaining_percent': forms.NumberInput(attrs={'class': 'form-control', 'min': '0', 'max': '100'}), 'remaining_percent': forms.NumberInput(attrs={'class': 'form-control', 'min': '0', 'max': '100'}),
'remaining_weight_grams': forms.NumberInput(attrs={'class': 'form-control', 'readonly': 'readonly'}), 'remaining_weight_grams': forms.NumberInput(attrs={'class': 'form-control', 'readonly': 'readonly'}),
'is_transparent': forms.CheckboxInput(attrs={'class': 'form-check-input', 'id': 'id_is_transparent'}),
'is_loaded_in_ams': forms.CheckboxInput(attrs={'class': 'form-check-input'}), 'is_loaded_in_ams': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'current_tray_id': forms.NumberInput(attrs={'class': 'form-control', 'min': '0', 'max': '3'}), 'current_tray_id': forms.NumberInput(attrs={
'class': 'form-control', 'min': '0', 'max': '15',
'placeholder': '0–3 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_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
'purchase_price': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), 'purchase_price': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'supplier': forms.TextInput(attrs={'class': 'form-control'}), 'supplier': forms.TextInput(attrs={'class': 'form-control'}),
@@ -105,6 +114,8 @@ class FilamentForm(forms.ModelForm):
self.fields['type'].required = False self.fields['type'].required = False
self.fields['sub_type'].required = False self.fields['sub_type'].required = False
self.fields['brand'].required = False self.fields['brand'].required = False
self.fields['ams_unit_id'].required = False
self.fields['ams_type'].required = False
self._populate_color_choices() self._populate_color_choices()

View File

@@ -13,8 +13,9 @@ import logging
import os import os
import ssl import ssl
import time import time
from dataclasses import dataclass, field
from decimal import Decimal from decimal import Decimal
from typing import Optional from typing import Any, Dict, Optional
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from django.db import transaction from django.db import transaction
@@ -26,6 +27,56 @@ from bambu_run.models import Printer, PrinterMetrics
logger = logging.getLogger("bambu_run.collector") 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): class Command(BaseCommand):
""" """
MQTT Poll -> PrinterMetrics -> FilamentSnapshot -> Auto-Match -> Update Filament MQTT Poll -> PrinterMetrics -> FilamentSnapshot -> Auto-Match -> Update Filament
@@ -51,18 +102,11 @@ class Command(BaseCommand):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.printer_client = None self.sessions: Dict[str, DeviceSession] = {}
self.printer_device = None self._token: Optional[str] = None
self.verbose = False self.verbose = False
self.disable_ssl_verify = False self.disable_ssl_verify = False
self.error_count = 0
self.success_count = 0
self.mqtt_connect_errors = 0
self.start_time = None 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): def handle(self, *args, **options):
self.verbose = options["verbose"] self.verbose = options["verbose"]
@@ -100,18 +144,22 @@ class Command(BaseCommand):
self._configure_logging() self._configure_logging()
try: try:
self._initialize_printer() self._initialize_printers()
except Exception as e: except Exception as e:
raise CommandError(f"Initialization failed: {e}") raise CommandError(f"Initialization failed: {e}")
self.start_time = timezone.now() 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"Collection interval: {interval} seconds")
logger.info(f"Mode: {'Single run' if run_once else 'Continuous'}") logger.info(f"Mode: {'Single run' if run_once else 'Continuous'}")
try: try:
if run_once: 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") logger.info("Single collection completed successfully")
else: else:
self._run_continuous_loop(interval) self._run_continuous_loop(interval)
@@ -122,6 +170,24 @@ class Command(BaseCommand):
logger.exception(f"Fatal error in main loop: {e}") logger.exception(f"Fatal error in main loop: {e}")
raise CommandError(f"Runner failed: {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): def _configure_logging(self):
log_level = logging.DEBUG if self.verbose else logging.INFO log_level = logging.DEBUG if self.verbose else logging.INFO
logger.setLevel(log_level) logger.setLevel(log_level)
@@ -135,7 +201,9 @@ class Command(BaseCommand):
handler.setFormatter(formatter) handler.setFormatter(formatter)
logger.addHandler(handler) 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 from bambu_run.mqtt_client import BambuPrinter
bambu_username = os.environ.get("BAMBU_USERNAME") bambu_username = os.environ.get("BAMBU_USERNAME")
@@ -149,25 +217,12 @@ class Command(BaseCommand):
"environment variables must be set" "environment variables must be set"
) )
logger.info("Connecting to Bambu Lab printer...") logger.info("Authenticating with Bambu Lab cloud...")
try: try:
if bambu_token: auth = BambuPrinter(
logger.info("Using saved BAMBU_TOKEN for authentication") username=bambu_username, password=bambu_password, token=bambu_token,
self.printer_client = BambuPrinter( )
token=bambu_token, device_id=bambu_device_id self._token = auth._ensure_token()
)
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)")
except Exception as e: except Exception as e:
if "CERTIFICATE_VERIFY_FAILED" in str(e) or "SSL" in str(e): if "CERTIFICATE_VERIFY_FAILED" in str(e) or "SSL" in str(e):
error_msg = ( error_msg = (
@@ -178,56 +233,62 @@ class Command(BaseCommand):
"3. pip install --upgrade certifi\n" "3. pip install --upgrade certifi\n"
) )
raise CommandError(error_msg) 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() device_infos = self._discover_devices(bambu_device_id)
logger.info(f"Initialized for printer device: {self.printer_device}") for device_id, device_info in device_infos.items():
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}")
try: try:
device = Printer.objects.filter(is_active=True).first() self._add_session(device_id, device_info)
if device: except Exception as e:
logger.warning(f"Using existing device record from DB: {device}") logger.error(f"Failed to initialize printer {device_id}: {e}")
return device
else: if not self.sessions:
raise CommandError( raise CommandError("No printer sessions could be initialized")
"No printer device found in database and initialization failed."
) def _discover_devices(self, explicit_device_id: Optional[str]) -> Dict[str, dict]:
except Printer.DoesNotExist: """Return {device_id: device_info} for every printer to monitor.
raise CommandError("Failed to create or retrieve printer device.")
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): def _run_continuous_loop(self, interval: int):
iteration = 0 iteration = 0
@@ -238,7 +299,8 @@ class Command(BaseCommand):
if self.verbose: if self.verbose:
logger.debug(f"=== Iteration {iteration} ===") 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 elapsed = time.time() - loop_start
sleep_time = max(0, interval - elapsed) sleep_time = max(0, interval - elapsed)
@@ -248,9 +310,28 @@ class Command(BaseCommand):
if iteration % 100 == 0: if iteration % 100 == 0:
self._print_statistics() self._print_statistics()
self._refresh_devices()
time.sleep(sleep_time) 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): def _convert_mqtt_color(self, mqtt_color):
if not mqtt_color: if not mqtt_color:
return None return None
@@ -316,7 +397,7 @@ class Command(BaseCommand):
def _auto_create_filament(self, tray_data): def _auto_create_filament(self, tray_data):
from bambu_run.models import Filament, FilamentType from bambu_run.models import Filament, FilamentType
from bambu_run.utils import strip_color_padding, match_filament_color from bambu_run.utils import strip_color_padding, match_filament_color, is_mqtt_color_transparent
tray_uuid = tray_data.get('tray_uuid') tray_uuid = tray_data.get('tray_uuid')
tag_uid = tray_data.get('tag_uid') tag_uid = tray_data.get('tag_uid')
@@ -329,6 +410,7 @@ class Command(BaseCommand):
default_brand = app_settings.AUTO_CREATE_BRAND default_brand = app_settings.AUTO_CREATE_BRAND
transparent = is_mqtt_color_transparent(mqtt_color)
color_code = strip_color_padding(mqtt_color) color_code = strip_color_padding(mqtt_color)
color_hex = f"#{color_code}" if color_code else None color_hex = f"#{color_code}" if color_code else None
@@ -341,6 +423,7 @@ class Command(BaseCommand):
if filament_color: if filament_color:
color_name = filament_color.color_name color_name = filament_color.color_name
transparent = transparent or filament_color.is_transparent
if self.verbose: if self.verbose:
logger.info(f"Matched color from database: {color_name} (#{color_code})") logger.info(f"Matched color from database: {color_name} (#{color_code})")
else: else:
@@ -368,12 +451,15 @@ class Command(BaseCommand):
brand=default_brand, brand=default_brand,
color=color_name, color=color_name,
color_hex=color_hex, color_hex=color_hex,
is_transparent=transparent,
diameter=diameter, diameter=diameter,
initial_weight_grams=initial_weight, initial_weight_grams=initial_weight,
remaining_percent=remain_percent, remaining_percent=remain_percent,
created_by='Auto Detection', created_by='Auto Detection',
is_loaded_in_ams=True, is_loaded_in_ams=True,
current_tray_id=tray_data.get('tray_id'), 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(), last_loaded_date=timezone.now(),
) )
@@ -387,9 +473,13 @@ class Command(BaseCommand):
return filament 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 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: if filament.remaining_percent != remain_percent:
filament.remaining_percent = remain_percent filament.remaining_percent = remain_percent
filament.update_remaining_weight() filament.update_remaining_weight()
@@ -397,10 +487,19 @@ class Command(BaseCommand):
if self.verbose: if self.verbose:
logger.debug(f"Updated filament {filament}: {remain_percent}%") logger.debug(f"Updated filament {filament}: {remain_percent}%")
if not filament.is_loaded_in_ams or filament.current_tray_id != tray_id: location_changed = (
previous_filament = Filament.objects.filter( 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 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: if previous_filament:
previous_filament.is_loaded_in_ams = False previous_filament.is_loaded_in_ams = False
@@ -408,14 +507,21 @@ class Command(BaseCommand):
previous_filament.save() previous_filament.save()
logger.info( logger.info(
f"Auto-unloaded {previous_filament} from Tray {tray_id} " 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.is_loaded_in_ams = True
filament.current_tray_id = tray_id 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() filament.last_loaded_date = timezone.now()
if self.verbose: 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() filament.save()
@@ -436,15 +542,20 @@ class Command(BaseCommand):
if filament: if filament:
remain_percent = tray_data.get('remain_percent') remain_percent = tray_data.get('remain_percent')
if remain_percent is not None: 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 # Locate the AMS unit this tray belongs to. Use the unit_id supplied
unit_data = ams_units.get(unit_id, {}) # 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( FilamentSnapshot.objects.create(
printer_metric=printer_metric, printer_metric=printer_metric,
filament=filament, filament=filament,
tray_id=tray_id, tray_id=tray_id,
ams_unit_id=unit_id_int,
ams_type=tray_data.get('ams_type', '') or '',
slot_name=tray_data.get('slot'), slot_name=tray_data.get('slot'),
type=tray_data.get('type'), type=tray_data.get('type'),
sub_type=tray_data.get('sub_type'), sub_type=tray_data.get('sub_type'),
@@ -460,122 +571,172 @@ class Command(BaseCommand):
match_method=match_method match_method=match_method
) )
def _track_print_job(self, metric, snapshot): def _update_hotends(self, printer, printer_metric, hotends_data):
from bambu_run.models import PrintJob, FilamentUsage 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') gcode_state = snapshot.get('gcode_state')
subtask_name = snapshot.get('subtask_name') subtask_name = snapshot.get('subtask_name')
if self._is_print_starting(gcode_state, subtask_name): if self._is_print_starting(session, gcode_state, subtask_name):
if self.current_print_job: if session.current_print_job:
self._finalize_print_job(metric, snapshot) self._finalize_print_job(session, metric, snapshot)
self.current_print_job = PrintJob.objects.create( raw_task_id = snapshot.get('task_id')
device=self.printer_device, session.current_print_job = PrintJob.objects.create(
device=session.printer,
project_name=subtask_name, project_name=subtask_name,
gcode_file=snapshot.get('gcode_file'), gcode_file=snapshot.get('gcode_file'),
start_time=metric.timestamp, start_time=metric.timestamp,
start_metric=metric, start_metric=metric,
total_layers=snapshot.get('total_layer_num'), 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() session.trays_used = set()
logger.info(f"Print job started: {subtask_name}") 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', '') tray_now = snapshot.get('tray_now', '')
if tray_now not in (None, '', '255'): if tray_now not in (None, '', '255'):
try: try:
tray_id = int(tray_now) tray_id = int(tray_now)
if 0 <= tray_id <= 15: if 0 <= tray_id <= 15:
self.trays_used.add(tray_id) session.trays_used.add(tray_id)
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
if self._is_print_ending(gcode_state) and self.current_print_job: if self._is_print_ending(session, gcode_state) and session.current_print_job:
self._finalize_print_job(metric, snapshot) self._finalize_print_job(session, metric, snapshot)
self.last_gcode_state = gcode_state session.last_gcode_state = gcode_state
self.last_subtask_name = subtask_name 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, ''] 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 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'] 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 from bambu_run.models import FilamentUsage
self.current_print_job.end_time = metric.timestamp job = session.current_print_job
self.current_print_job.end_metric = metric job.end_time = metric.timestamp
self.current_print_job.final_status = snapshot.get('gcode_state') job.end_metric = metric
self.current_print_job.completion_percent = snapshot.get('print_percent', 0) job.final_status = snapshot.get('gcode_state')
self.current_print_job.calculate_duration() job.completion_percent = snapshot.get('print_percent', 0)
self.current_print_job.save() 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: if not start_metric:
logger.warning(f"No start_metric for job {self.current_print_job.id}, skipping filament usage") logger.warning(f"No start_metric for job {job.id}, skipping filament usage")
elif not self.trays_used: elif not session.trays_used:
logger.warning(f"No trays tracked for job {self.current_print_job.project_name}, skipping filament usage") logger.warning(f"No trays tracked for job {job.project_name}, skipping filament usage")
else: else:
for tray_id in self.trays_used: # A bare tray_id (from `tray_now`) doesn't identify which physical AMS
start_snap = start_metric.filament_snapshots.filter( # 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 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() usage.save()
if self.verbose: if self.verbose:
logger.debug( 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}%" f"{usage.starting_percent}% -> {usage.ending_percent}%, consumed {usage.consumed_percent}%"
) )
logger.info( logger.info(
f"Print job finished: {self.current_print_job.project_name} " f"[{session.device_id}] Print job finished: {job.project_name} "
f"({self.current_print_job.final_status}) - Duration: {self.current_print_job.duration_minutes} min, " f"({job.final_status}) - Duration: {job.duration_minutes} min, "
f"Trays used: {sorted(self.trays_used) if self.trays_used else 'none tracked'}" f"Trays used: {sorted(session.trays_used) if session.trays_used else 'none tracked'}"
) )
self.current_print_job = None session.current_print_job = None
self.trays_used = set() session.trays_used = set()
def _collect_printer_data(self): def _collect_printer_data(self, session: "DeviceSession"):
try: try:
snapshot = self.printer_client.get_snapshot() snapshot = session.client.get_snapshot()
if snapshot is None: if snapshot is None:
self.mqtt_connect_errors += 1 session.mqtt_connect_errors += 1
if self.mqtt_connect_errors <= 5 or self.verbose: if session.mqtt_connect_errors <= 5 or self.verbose:
logger.warning( logger.warning(
f"MQTT not connected yet or no data available " f"[{session.device_id}] MQTT not connected yet or no data available "
f"(attempt {self.mqtt_connect_errors})" f"(attempt {session.mqtt_connect_errors})"
) )
return return
with transaction.atomic(): with transaction.atomic():
metric = PrinterMetrics.objects.create( metric = PrinterMetrics.objects.create(
device=self.printer_device, device=session.printer,
timestamp=timezone.now(), timestamp=timezone.now(),
nozzle_temp=self._to_decimal(snapshot.get("nozzle_temp")), nozzle_temp=self._to_decimal(snapshot.get("nozzle_temp")),
nozzle_target_temp=self._to_decimal(snapshot.get("nozzle_target_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")), chamber_temp=self._to_decimal(snapshot.get("chamber_temp")),
nozzle_diameter=self._to_decimal(snapshot.get("nozzle_diameter")), nozzle_diameter=self._to_decimal(snapshot.get("nozzle_diameter")),
nozzle_type=snapshot.get("nozzle_type"), 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"), gcode_state=snapshot.get("gcode_state"),
print_type=snapshot.get("print_type"), print_type=snapshot.get("print_type"),
print_percent=snapshot.get("print_percent"), print_percent=snapshot.get("print_percent"),
@@ -623,27 +788,33 @@ class Command(BaseCommand):
ams_units=snapshot.get("ams_units", []), ams_units=snapshot.get("ams_units", []),
external_spool=snapshot.get("external_spool", {}), external_spool=snapshot.get("external_spool", {}),
lights_report=snapshot.get("lights_report", []), lights_report=snapshot.get("lights_report", []),
vortek_raw=snapshot.get("vortek_raw", {}),
nozzle_info=snapshot.get("hotends", []),
) )
filaments_data = snapshot.get('filaments', []) filaments_data = snapshot.get('filaments', [])
if filaments_data: if filaments_data:
self._create_filament_snapshots(metric, filaments_data, snapshot) 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: if self.verbose:
logger.debug( 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"Bed={snapshot.get('bed_temp')}C, "
f"Progress={snapshot.get('print_percent')}%, " f"Progress={snapshot.get('print_percent')}%, "
f"State={snapshot.get('gcode_state')}" f"State={snapshot.get('gcode_state')}"
) )
except Exception as e: except Exception as e:
self.error_count += 1 session.error_count += 1
logger.error(f"Error collecting printer data (total errors: {self.error_count}): {e}") logger.error(f"[{session.device_id}] Error collecting printer data (total errors: {session.error_count}): {e}")
if self.verbose: if self.verbose:
logger.exception("Detailed traceback:") logger.exception("Detailed traceback:")
@@ -658,16 +829,20 @@ class Command(BaseCommand):
def _print_statistics(self): def _print_statistics(self):
if self.start_time: if self.start_time:
runtime = timezone.now() - 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 = ( success_rate = (
(self.success_count / total_collections * 100) (success_count / total_collections * 100)
if total_collections > 0 if total_collections > 0
else 0 else 0
) )
logger.info("=== Statistics ===") logger.info("=== Statistics ===")
logger.info(f"Runtime: {runtime}") logger.info(f"Runtime: {runtime}")
logger.info(f"Successful collections: {self.success_count}") logger.info(f"Printers tracked: {len(self.sessions)}")
logger.info(f"Failed collections: {self.error_count}") logger.info(f"Successful collections: {success_count}")
logger.info(f"MQTT connection warnings: {self.mqtt_connect_errors}") logger.info(f"Failed collections: {error_count}")
logger.info(f"MQTT connection warnings: {mqtt_connect_errors}")
logger.info(f"Success rate: {success_rate:.1f}%") 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 "created"
return "no_type" 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 ────────────────────────────────────────────────── # ── Duplicate check ──────────────────────────────────────────────────
# All five fields must match to be considered a duplicate: # All five fields must match to be considered a duplicate:
# color_code (exact), color_name (case-insensitive), brand, # color_code (exact), color_name (case-insensitive), brand,
@@ -388,9 +393,10 @@ class Command(BaseCommand):
return "duplicate" return "duplicate"
if dry_run: if dry_run:
transparent_note = " [transparent]" if is_transparent else ""
self.stdout.write( self.stdout.write(
f" [dry-run] Would create: {color_name!r} #{hex_code} " 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" return "created"
@@ -404,6 +410,7 @@ class Command(BaseCommand):
filament_type=filament_type, filament_type=filament_type,
filament_sub_type=filament_sub_type, filament_sub_type=filament_sub_type,
brand=BRAND, brand=BRAND,
is_transparent=is_transparent,
) )
self.stdout.write( self.stdout.write(
f" + {color_name!r} #{hex_code} ({filament_type} / {filament_sub_type})" f" + {color_name!r} #{hex_code} ({filament_type} / {filament_sub_type})"

View File

@@ -0,0 +1,110 @@
"""Run the Bambu-Run MCP server (standalone, single-owner).
Usage:
python manage.py bambu_mcp_server --transport stdio
python manage.py bambu_mcp_server --transport http --host 0.0.0.0 --port 8808
python manage.py bambu_mcp_server --transport http --allowed-host mcp.example.com
"""
import logging
from django.core.management.base import BaseCommand, CommandError
logger = logging.getLogger("bambu_run.mcp")
ALL_CAPABILITIES = ["read_printer", "read_print_history", "read_filament"]
_INSTRUCTIONS = (
"Read-only access to Bambu Lab 3D printer data: live status, print "
"history, and filament inventory, collected via MQTT."
)
def _build_gate(kit, transport, api_key=None):
"""Build the Gate for the requested transport.
Raises ValueError if HTTP is requested without an api_key -- an
unauthenticated network MCP endpoint would expose printer and filament
data to anyone who can reach the port. stdio always uses OpenIdentity:
the OS process boundary is the trust boundary there, matching mcp-gate's
own guidance (never use OpenIdentity over HTTP).
"""
from mcp_gate import Gate, OpenIdentity, StaticTokenIdentity
if transport == "stdio":
return Gate(name="bambu-run", toolkits=[kit], identity=OpenIdentity(ALL_CAPABILITIES), instructions=_INSTRUCTIONS)
if not api_key:
raise ValueError(
"BAMBU_RUN_MCP_API_KEY must be set to serve MCP over HTTP -- an "
"unauthenticated network MCP endpoint would expose printer and "
"filament data to anyone who can reach the port."
)
return Gate(
name="bambu-run",
toolkits=[kit],
identity=StaticTokenIdentity({api_key: ("owner", ALL_CAPABILITIES)}),
instructions=_INSTRUCTIONS,
)
class Command(BaseCommand):
help = "Run the Bambu-Run MCP server for AI agent access (standalone, single-owner)"
def add_arguments(self, parser):
from bambu_run.conf import app_settings
parser.add_argument("--transport", choices=["http", "stdio"], default="stdio")
parser.add_argument("--host", default=app_settings.MCP_HOST)
parser.add_argument("--port", type=int, default=app_settings.MCP_PORT)
parser.add_argument(
"--allowed-host",
action="append",
default=None,
help=(
"Host header this server should accept (repeatable). Required "
"when reachable via a real domain or reverse proxy, e.g. "
"--allowed-host mcp.example.com. Omit for local-only testing "
"on 127.0.0.1/localhost."
),
)
def handle(self, *args, **options):
try:
from mcp_gate import run_stdio, asgi_app
except ImportError:
raise CommandError(
"The 'mcp-gate' package is required. Install it with: pip install 'bambu-run[mcp]'"
)
from bambu_run.conf import app_settings
from bambu_run.mcp.toolkit import build_toolkit
transport = options["transport"]
kit = build_toolkit()
if transport == "stdio":
gate = _build_gate(kit, transport="stdio")
self.stdout.write(self.style.SUCCESS("Starting Bambu-Run MCP server (stdio)"))
run_stdio(gate)
return
try:
gate = _build_gate(kit, transport="http", api_key=app_settings.MCP_API_KEY)
except ValueError as exc:
raise CommandError(str(exc))
try:
import uvicorn
except ImportError:
raise CommandError("uvicorn is required for HTTP transport. Install it with: pip install uvicorn")
asgi_kwargs = {}
if options["allowed_host"]:
from mcp.server.transport_security import TransportSecuritySettings
asgi_kwargs["transport_security"] = TransportSecuritySettings(allowed_hosts=options["allowed_host"])
app = asgi_app(gate, **asgi_kwargs)
host, port = options["host"], options["port"]
self.stdout.write(self.style.SUCCESS(f"Starting Bambu-Run MCP server (HTTP) on {host}:{port}"))
self.stdout.write(f"Connect with: http://{host}:{port}/mcp (Authorization: Bearer <key>)")
uvicorn.run(app, host=host, port=port)

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

View File

@@ -0,0 +1 @@
"""MCP toolkit for Bambu-Run: read-only printer, print-history, and filament tools."""

118
bambu_run/mcp/filaments.py Normal file
View File

@@ -0,0 +1,118 @@
"""list_filaments, get_filament -- the read_filament capability tools."""
from __future__ import annotations
from django.utils import timezone
from bambu_run.mcp.shapes import normalize_consumed, remaining_status
from bambu_run.models import Filament
def list_filaments(
material: str | None = None,
sub_type: str | None = None,
brand: str | None = None,
color: str | None = None,
loaded_only: bool = False,
min_remaining_percent: int | None = None,
min_remaining_grams: int | None = None,
include_history: bool = False,
limit: int = 50,
) -> dict:
"""Filament spool inventory with composable filters.
"PLA Matte Black" is material="PLA", sub_type="PLA Matte", color="black".
"250g of black PLA" is material="PLA", color="black",
min_remaining_grams=250. remaining_percent/remaining_grams are null when
the spool's RFID has never been read (remaining_status is "unread" in
that case, not "low" or "empty"). include_history (default off) adds
each spool's last 3 uses inline; call get_filament for the complete
history of one spool. limit hard ceiling is 100.
"""
limit = min(limit, 100)
qs = Filament.objects.all()
if material:
qs = qs.filter(type__iexact=material)
if sub_type:
qs = qs.filter(sub_type__iexact=sub_type)
if brand:
qs = qs.filter(brand__icontains=brand)
if color:
qs = qs.filter(color__icontains=color)
if loaded_only:
qs = qs.filter(is_loaded_in_ams=True)
if min_remaining_percent is not None:
qs = qs.exclude(remaining_percent=-1).filter(remaining_percent__gte=min_remaining_percent)
if min_remaining_grams is not None:
qs = qs.exclude(remaining_percent=-1).exclude(remaining_weight_grams__isnull=True).filter(remaining_weight_grams__gte=min_remaining_grams)
spools = [_spool_summary(f, include_history) for f in qs.order_by("-remaining_percent")[:limit]]
return {"generated_at": timezone.now().isoformat(), "filaments": spools}
def _spool_summary(f, include_history):
value, status = remaining_status(f.remaining_percent)
grams = f.remaining_weight_grams
entry = {
"id": f.id,
"material": f.type,
"sub_type": f.sub_type,
"brand": f.brand,
"color_name": f.color,
"color_hex": f.color_hex,
"is_transparent": f.is_transparent,
"remaining_percent": value,
"remaining_status": status,
"remaining_grams": grams if (grams is not None and grams >= 0) else None,
"ams_location": (
{"unit_id": f.ams_unit_id, "unit_type": f.ams_type, "slot": f.current_tray_id}
if f.is_loaded_in_ams else None
),
"last_used": f.last_used.isoformat() if f.last_used else None,
}
if include_history:
entry["recent_usage"] = [
{
"job_id": u.print_job_id,
"job_name": u.print_job.project_name,
"started_at": u.print_job.start_time.isoformat(),
"consumed_grams": normalize_consumed(
u.starting_percent, u.ending_percent, f.initial_weight_grams
)[1],
}
for u in f.print_usages.select_related("print_job").order_by("-print_job__start_time")[:3]
]
return entry
def get_filament(filament_id: int) -> dict:
"""One spool in full, plus its complete print-usage history.
Usage rows with corrupt consumption (negative, or derived from the -1
"unread" sentinel) are returned with consumed_grams null and
data_quality "unreliable" rather than dropped -- the print still
happened, and hiding it would misstate the spool's history. Raises
Filament.DoesNotExist if filament_id is unknown.
"""
f = Filament.objects.get(id=filament_id)
summary = _spool_summary(f, include_history=False)
usages = []
for u in f.print_usages.select_related("print_job").order_by("-print_job__start_time"):
consumed_percent, consumed_grams, quality = normalize_consumed(
u.starting_percent, u.ending_percent, f.initial_weight_grams
)
usages.append({
"job_id": u.print_job_id,
"job_name": u.print_job.project_name,
"started_at": u.print_job.start_time.isoformat(),
"consumed_percent": consumed_percent,
"consumed_grams": consumed_grams,
"data_quality": quality,
})
return {
**summary,
"generated_at": timezone.now().isoformat(),
"purchase_date": f.purchase_date.isoformat() if f.purchase_date else None,
"usage_history": usages,
}

214
bambu_run/mcp/jobs.py Normal file
View File

@@ -0,0 +1,214 @@
"""list_print_jobs, get_print_job, get_print_statistics -- the
read_print_history capability tools."""
from __future__ import annotations
import statistics
from datetime import timedelta
from django.db.models import Avg, Count, ExpressionWrapper, F, FloatField, Q, Sum
from django.utils import timezone
from bambu_run.mcp.shapes import display_name, normalize_consumed, resolve_printer
from bambu_run.models import BambuCloudTask, FilamentUsage, Filament, PrintJob
def list_print_jobs(
status: str | None = None,
days: int = 30,
name_contains: str | None = None,
printer: str | int | None = None,
limit: int = 20,
) -> dict:
"""Print job history, most recent first.
status filters on FINISH/FAILED/CANCELLED, or "running" for the
in-flight job (final_status is null while printing). filament_grams
prefers Bambu Cloud's reported weight over the noisier AMS percentage
estimate -- check filament_grams_source ("cloud" vs None, meaning no
cloud data) before trusting precision. cost is always null until
filament purchase prices are recorded. limit has a hard ceiling of 100,
independent of days -- a busy printer's older jobs simply won't be
returned by one call; narrow days or increase limit deliberately.
"""
limit = min(limit, 100)
now = timezone.now()
qs = PrintJob.objects.select_related("cloud_task", "device")
if printer is not None:
qs = qs.filter(device__in=resolve_printer(printer))
since = now - timedelta(days=days)
qs = qs.filter(start_time__gte=since)
if status == "running":
qs = qs.filter(final_status__isnull=True)
elif status:
qs = qs.filter(final_status=status)
if name_contains:
qs = qs.filter(project_name__icontains=name_contains)
jobs = list(qs.order_by("-start_time")[:limit])
return {"generated_at": now.isoformat(), "jobs": [_job_summary(j) for j in jobs]}
def _job_summary(job):
cloud = job.cloud_task
est_min = int(cloud.cost_time_seconds / 60) if cloud and cloud.cost_time_seconds else None
grams = None
grams_source = None
if cloud and cloud.weight_grams is not None:
grams = float(cloud.weight_grams)
grams_source = "cloud"
return {
"id": job.id,
"display_name": display_name(
cloud.design_title if cloud else "", cloud.plate_title if cloud else "", job.project_name
),
"status": job.final_status,
"printer": job.device.name,
"started_at": job.start_time.isoformat(),
"ended_at": job.end_time.isoformat() if job.end_time else None,
"duration_min": job.duration_minutes,
"estimated_duration_min": est_min,
"layers_total": job.total_layers,
"filament_grams": grams,
"filament_grams_source": grams_source,
"cost": None,
"has_cover_image": bool(cloud and cloud.cover_url),
}
def get_print_job(job_id: int) -> dict:
"""Full detail for one print job: the list_print_jobs fields plus
per-slot filament breakdown, estimate-vs-actual delta, and linked cloud
metadata. Raises PrintJob.DoesNotExist if job_id is unknown.
"""
now = timezone.now()
job = PrintJob.objects.select_related("cloud_task", "device").get(id=job_id)
summary = _job_summary(job)
delta_percent = None
if summary["estimated_duration_min"] and summary["duration_min"]:
delta_percent = round(
100 * (summary["duration_min"] - summary["estimated_duration_min"])
/ summary["estimated_duration_min"],
1,
)
usages = []
for u in job.filament_usages.select_related("filament"):
consumed_percent, consumed_grams, quality = normalize_consumed(
u.starting_percent, u.ending_percent,
u.filament.initial_weight_grams if u.filament else None,
)
usages.append({
"tray_id": u.tray_id,
"ams_unit_id": u.ams_unit_id,
"filament_id": u.filament_id,
"consumed_percent": consumed_percent,
"consumed_grams": consumed_grams,
"data_quality": quality,
"is_primary": u.is_primary,
})
cloud = job.cloud_task
return {
**summary,
"generated_at": now.isoformat(),
"estimate_delta_percent": delta_percent,
"filament_usages": usages,
"cloud_design_title": cloud.design_title if cloud else None,
"cover_url": cloud.cover_url if cloud and cloud.cover_url else None,
}
def get_print_statistics(days: int = 30, printer: str | int | None = None) -> dict:
"""Aggregate print analytics over a window: success rate, total time and
filament, estimate accuracy, failure patterns, and material mix.
cost_total is always null in this phase -- cost_available: false tells
the agent to explain the gap rather than report zero spend.
most_used_material answers "what did I print most" directly for the
requested window (days=7 for last week, days=30 for last month).
"""
now = timezone.now()
since = now - timedelta(days=days)
qs = PrintJob.objects.filter(start_time__gte=since)
if printer is not None:
qs = qs.filter(device__in=resolve_printer(printer))
total_jobs = qs.count()
status_counts = {
(row["final_status"] or "running"): row["n"]
for row in qs.values("final_status").annotate(n=Count("id"))
}
finished = status_counts.get("FINISH", 0)
success_rate = round(finished / total_jobs, 3) if total_jobs else None
total_duration = qs.aggregate(s=Sum("duration_minutes"))["s"] or 0
cloud_qs = BambuCloudTask.objects.filter(print_jobs__in=qs).distinct()
total_grams = cloud_qs.aggregate(s=Sum("weight_grams"))["s"]
jobs_without_cloud = qs.filter(cloud_task__isnull=True).count()
# Same exclusion rules as normalize_consumed (starting_percent must be a
# real reading, and consumption must be non-negative), expressed as a DB
# filter -- a Python helper can't run inside an aggregate expression.
valid_usage = (
Q(starting_percent__gt=F("ending_percent"))
& ~Q(starting_percent=-1)
& ~Q(ending_percent=-1)
& Q(filament__initial_weight_grams__isnull=False)
)
material_rows = (
FilamentUsage.objects.filter(print_job__in=qs)
.filter(valid_usage)
.annotate(
grams=ExpressionWrapper(
(F("starting_percent") - F("ending_percent")) * F("filament__initial_weight_grams") / 100.0,
output_field=FloatField(),
)
)
.values("filament__type")
.annotate(total_grams=Sum("grams"))
.order_by("-total_grams")
)
material_mix = {row["filament__type"]: round(row["total_grams"], 2) for row in material_rows if row["filament__type"]}
most_used_material = next(iter(material_mix), None)
failed = qs.filter(final_status="FAILED")
failure_names = list(failed.values("project_name").annotate(n=Count("id")).order_by("-n")[:5])
ratio_qs = qs.filter(
duration_minutes__isnull=False, cloud_task__cost_time_seconds__gt=0
).annotate(
ratio=ExpressionWrapper(
F("duration_minutes") / (F("cloud_task__cost_time_seconds") / 60.0),
output_field=FloatField(),
)
)
mean_ratio = ratio_qs.aggregate(avg=Avg("ratio"))["avg"]
# median has no portable DB-level equivalent across backends; this list
# is already bounded by the same days/printer filters as everything else.
ratios = list(ratio_qs.values_list("ratio", flat=True))
median_ratio = statistics.median(ratios) if ratios else None
return {
"generated_at": now.isoformat(),
"window_days": days,
"total_jobs": total_jobs,
"success_rate": success_rate,
"status_counts": status_counts,
"total_print_minutes": total_duration,
"total_filament_grams": float(total_grams) if total_grams is not None else None,
"jobs_without_cloud_data": jobs_without_cloud,
"estimate_accuracy": {
"mean_ratio": round(mean_ratio, 2) if mean_ratio is not None else None,
"median_ratio": round(median_ratio, 2) if median_ratio is not None else None,
},
"failures": {
"count": failed.count(),
"top_failing_projects": [{"name": f["project_name"], "count": f["n"]} for f in failure_names],
},
"material_mix_grams": material_mix,
"most_used_material": most_used_material,
"cost_total": None,
"cost_available": False,
}

146
bambu_run/mcp/shapes.py Normal file
View File

@@ -0,0 +1,146 @@
"""Shared output-shaping helpers enforcing the spec's data-correction rules.
Every rule here traces to a verified finding in "Bambu-Run MCP Tool Surface"
(the "Data Reality Findings" section) -- not a general defensive-programming
habit. Do not relax these without re-checking against real data.
"""
from __future__ import annotations
import statistics
STALE_THRESHOLD_SECONDS = 120
LOW_REMAINING_THRESHOLD = 20
def freshness(timestamp, now):
"""(data_age_seconds, stale) for a reading timestamp relative to `now`.
`now` is passed in rather than computed here so a caller building a
multi-field response reuses one `timezone.now()` call throughout,
keeping `generated_at` and every `data_age_seconds` mutually consistent.
"""
age_seconds = (now - timestamp).total_seconds()
return {
"data_age_seconds": int(age_seconds),
"stale": age_seconds > STALE_THRESHOLD_SECONDS,
}
def remaining_status(percent):
"""Classify one raw remaining-percent reading, honoring the -1 sentinel.
Returns (value, status): value is percent-or-None; status is one of
"unread" | "empty" | "low" | "ok". -1 and None both mean "no RFID
reading yet" -- the spec rules out -1 meaning "low" (jobs 62/63/70/77
show start=-1% followed by end=90-100%, which a genuinely low reading
could not do).
"""
if percent is None or percent == -1:
return None, "unread"
percent = int(round(percent))
if percent <= 0:
return 0, "empty"
if percent <= LOW_REMAINING_THRESHOLD:
return percent, "low"
return percent, "ok"
def smoothed_remaining_percent(readings):
"""Median of the 5 most recent remain_percent readings for one tray.
`readings` is most-recent-first. -1 sentinel readings are excluded
before the median, not treated as zero (spec: AMS remain_percent swings
22-35% mid-print on real RFID noise; median-of-5 rejects single-sample
spikes while still tracking genuine change).
"""
recent = readings[:5]
valid = [r for r in recent if r is not None and r != -1]
if not valid:
return remaining_status(-1 if recent else None)
return remaining_status(statistics.median(valid))
def classify_alerts(code_counts, total_rows):
"""Turn {(attr, code): occurrences} window counts into the alerts array.
Frequency heuristic (spec: HMS codes are stateful, not event-based --
the two most common codes alone accounted for over 80% of all alert
occurrences in the verified data, in long unbroken runs, not isolated
events): a code present in >20% of window rows is "persistent" (a
likely standing advisory, surfaced once with a count); <5% is
"transient" (possibly actionable). The 5-20% band is omitted rather
than guessed at. severity is always "unknown" -- nothing in Bambu-Run
decodes these codes yet; do not assert "fine" or "faulty" from them.
"""
alerts = []
for (attr, code), count in code_counts.items():
ratio = count / total_rows if total_rows else 0
if ratio > 0.20:
kind = "persistent"
elif ratio < 0.05:
kind = "transient"
else:
continue
alerts.append({
"attr": attr,
"code": code,
"kind": kind,
"occurrences": count,
"severity": "unknown",
})
return alerts
def display_name(design_title, plate_title, project_name):
"""design_title -> plate_title -> project_name fallback.
Spec finding: design_title is empty on 4 of 5 recent BambuCloudTask rows.
"""
return design_title or plate_title or project_name
def normalize_consumed(starting_percent, ending_percent, initial_weight_grams):
"""FilamentUsage consumption, honest about the -1 sentinel and RFID jitter.
Returns (consumed_percent, consumed_grams, data_quality). data_quality is
"ok" or "unreliable". A negative or sentinel-derived result becomes
(None, None, "unreliable") -- never a negative figure, never a
fabricated zero (spec: 24/269 usage rows go negative; a -1 sentinel
subtracted as a real value produces e.g. consumed=-101%).
"""
if (
starting_percent is None or starting_percent == -1
or ending_percent is None or ending_percent == -1
):
return None, None, "unreliable"
consumed_percent = starting_percent - ending_percent
if consumed_percent < 0:
return None, None, "unreliable"
consumed_grams = None
if initial_weight_grams:
consumed_grams = int(initial_weight_grams * (consumed_percent / 100.0))
return consumed_percent, consumed_grams, "ok"
def resolve_printer(printer):
"""Resolve an id, exact name, or None to a Printer queryset.
None returns all active printers. Raises Printer.DoesNotExist if a
specific id/name was given and nothing matched -- MCP tool wrappers let
this propagate as a clear tool error rather than returning empty data
that looks like "no printers exist".
"""
from bambu_run.models import Printer
if printer is None:
return Printer.objects.filter(is_active=True)
if isinstance(printer, int) or (isinstance(printer, str) and printer.isdigit()):
by_id = Printer.objects.filter(pk=int(printer))
if by_id.exists():
return by_id
by_name = Printer.objects.filter(name=printer)
if not by_name.exists():
raise Printer.DoesNotExist(f"No printer matching {printer!r}")
return by_name

268
bambu_run/mcp/status.py Normal file
View File

@@ -0,0 +1,268 @@
"""get_printer_status, get_metrics_history, get_hardware_health -- the
read_printer capability tools."""
from __future__ import annotations
from collections import defaultdict
from datetime import timedelta
from django.db.models import Avg
from django.utils import timezone
from bambu_run.mcp.shapes import classify_alerts, freshness, resolve_printer, smoothed_remaining_percent
from bambu_run.models import Hotend, PrinterMetrics
from bambu_run.utils import strip_color_padding
ALERT_WINDOW_HOURS = 24
def get_printer_status(printer: str | int | None = None) -> dict:
"""Current live status of one or all printers: temps, print progress, AMS slots, and alerts.
Pass a printer id or exact name to scope to one printer; omit to get every
active printer. Numeric percentages under ams_units[].slots[] are smoothed
(median of the 5 most recent readings) and paired with a remain_status
field ("ok"/"low"/"empty"/"unread") -- treat "unread" as "no RFID reading
yet", not as "empty". alerts are frequency-classified, not raw fault
codes; severity is always "unknown" until Bambu's HMS codes are mapped,
so never assert the printer is fine or faulty purely from an alert entry.
"""
now = timezone.now()
printers = resolve_printer(printer)
entries = []
for p in printers:
metric = PrinterMetrics.objects.filter(device=p).order_by("-timestamp").first()
if metric is None:
entries.append({
"id": p.id, "name": p.name, "model": p.model, "location": p.location,
"data_age_seconds": None, "stale": True, "state": "no_data",
"job": None, "temperatures": None, "ams_units": [], "alerts": [],
"fans": None, "chamber_light": None,
})
continue
entries.append({
"id": p.id,
"name": p.name,
"model": p.model,
"location": p.location,
**freshness(metric.timestamp, now),
"state": metric.gcode_state or "UNKNOWN",
"job": _job_info(metric, now),
"temperatures": _temperatures(metric),
"ams_units": _ams_units(p, metric),
"alerts": _alerts_for_printer(p, now),
"fans": {
"cooling_percent": metric.cooling_fan_speed,
"aux_percent": metric.big_fan1_speed,
"chamber_percent": metric.big_fan2_speed,
},
"chamber_light": metric.chamber_light,
})
return {"generated_at": now.isoformat(), "printers": entries}
def _job_info(metric, now):
if metric.gcode_state != "RUNNING":
return None
remaining = metric.remaining_time_min
finish_at = (now + timedelta(minutes=remaining)).isoformat() if remaining is not None else None
return {
"name": metric.subtask_name,
"print_percent": metric.print_percent,
"layer_current": metric.layer_num,
"layer_total": metric.total_layer_num,
"remaining_time_min": remaining,
"estimated_finish_at": finish_at,
}
def _temperatures(metric):
def f(v):
return float(v) if v is not None else None
return {
"nozzle_c": f(metric.nozzle_temp), "nozzle_target_c": f(metric.nozzle_target_temp),
"nozzle_left_c": f(metric.nozzle_temp_left), "nozzle_left_target_c": f(metric.nozzle_target_temp_left),
"bed_c": f(metric.bed_temp), "bed_target_c": f(metric.bed_target_temp),
"chamber_c": f(metric.chamber_temp),
}
def _safe_tray_id(value, default=-1):
"""Coerce a tray_id from the filaments JSON to int, defaulting on anything
that isn't a clean int (missing, None, or malformed) rather than crashing
the whole tool from one bad row."""
try:
return int(value)
except (TypeError, ValueError):
return default
def _ams_units(printer, latest_metric):
"""Build ams_units from the latest metric's filaments, with remain_percent
smoothed against the printer's 5 most recent metric rows.
Per-unit humidity/temp come from PrinterMetrics.ams_units (a JSONField with
one entry per physical AMS unit) -- NOT from PrinterMetrics.ams_humidity_raw/
.ams_temp, which the collector only ever populates from unit 0's reading and
would otherwise stamp one unit's humidity onto every unit.
"""
recent_filament_lists = list(
PrinterMetrics.objects.filter(device=printer)
.order_by("-timestamp")
.values_list("filaments", flat=True)[:5]
)
readings = defaultdict(list)
for filaments in recent_filament_lists:
for f in filaments or []:
key = (f.get("ams_unit_id"), _safe_tray_id(f.get("tray_id")))
readings[key].append(f.get("remain_percent"))
unit_readings = {
u.get("unit_id"): u for u in (latest_metric.ams_units or [])
}
units = {}
for f in latest_metric.filaments or []:
unit_id = f.get("ams_unit_id")
tray_id = _safe_tray_id(f.get("tray_id"))
value, status = smoothed_remaining_percent(readings[(unit_id, tray_id)])
unit_reading = unit_readings.get(unit_id, {})
unit = units.setdefault(unit_id, {
"unit_id": unit_id,
"type": f.get("ams_type", ""),
"humidity_raw": unit_reading.get("humidity_raw"),
"temp_c": float(unit_reading["temp"]) if unit_reading.get("temp") is not None else None,
"slots": [],
})
color = f.get("color")
unit["slots"].append({
"slot": tray_id,
"label": f.get("slot", ""),
"material": f.get("type", ""),
"sub_type": f.get("sub_type", ""),
"color_hex": f"#{strip_color_padding(color)}" if color else None,
"remain_percent": value,
"remain_status": status,
})
return list(units.values())
def _alerts_for_printer(printer, now, window_hours=ALERT_WINDOW_HOURS):
since = now - timedelta(hours=window_hours)
window = PrinterMetrics.objects.filter(device=printer, timestamp__gte=since)
total = window.count()
counts = defaultdict(int)
for hms_list in window.exclude(hms=[]).values_list("hms", flat=True):
for entry in hms_list:
counts[(entry.get("attr"), entry.get("code"))] += 1
return classify_alerts(counts, total)
def get_metrics_history(
printer: str | int | None = None,
hours: int = 6,
metrics: list[str] | None = None,
max_points: int = 300,
) -> dict:
"""Sampled time-series history for one printer: temps, humidity, progress, fans.
metrics selects which series to include: any of "temps", "humidity",
"progress", "fans" (omit for all four). Server-side sampled to at most
max_points (hard ceiling 500) evenly across the window -- do not expect
one point per collector poll on a long window. printer defaults to the
first active printer when omitted; pass an id or name explicitly if more
than one printer is registered.
"""
from bambu_run.views import sample_metrics # reuse: same thinning the dashboard already uses
now = timezone.now()
max_points = min(max_points, 500)
target = resolve_printer(printer).first()
if target is None:
return {"generated_at": now.isoformat(), "printer": None, "timestamps": [], "series": {}}
since = now - timedelta(hours=hours)
rows = list(
PrinterMetrics.objects.filter(device=target, timestamp__gte=since).order_by("timestamp")
)
rows = sample_metrics(rows, max_points=max_points)
wanted = set(metrics) if metrics else {"temps", "humidity", "progress", "fans"}
series = {}
if "temps" in wanted:
series["nozzle_c"] = [float(r.nozzle_temp) if r.nozzle_temp is not None else None for r in rows]
series["bed_c"] = [float(r.bed_temp) if r.bed_temp is not None else None for r in rows]
series["chamber_c"] = [float(r.chamber_temp) if r.chamber_temp is not None else None for r in rows]
if "humidity" in wanted:
series["ams_humidity_raw"] = [r.ams_humidity_raw for r in rows]
if "progress" in wanted:
series["print_percent"] = [r.print_percent for r in rows]
if "fans" in wanted:
series["cooling_fan_percent"] = [r.cooling_fan_speed for r in rows]
return {
"generated_at": now.isoformat(),
"printer": {"id": target.id, "name": target.name},
"timestamps": [r.timestamp.isoformat() for r in rows],
"series": series,
}
def get_hardware_health(printer: str | int | None = None, days: int = 7) -> dict:
"""Maintenance-relevant observations for one printer: wifi, AMS humidity,
alert summary, hotend inventory, and collector coverage.
Returns observations only -- it does not render a verdict; the calling
agent decides what the numbers mean. wear_percent is deliberately absent
from each hotend entry: verified constant at 100.00 across every
hotend's entire history, so it carries no information.
"""
now = timezone.now()
target = resolve_printer(printer).first()
if target is None:
return {"printer": None}
since = now - timedelta(days=days)
window = PrinterMetrics.objects.filter(device=target, timestamp__gte=since)
total = window.count()
latest = window.order_by("-timestamp").first()
wifi_avg = window.exclude(wifi_signal_dbm__isnull=True).aggregate(avg=Avg("wifi_signal_dbm"))["avg"]
humidity_avg = window.exclude(ams_humidity_raw__isnull=True).aggregate(avg=Avg("ams_humidity_raw"))["avg"]
counts = defaultdict(int)
for hms_list in window.exclude(hms=[]).values_list("hms", flat=True):
for entry in hms_list:
counts[(entry.get("attr"), entry.get("code"))] += 1
hotends = [
{
"id": h.id,
"serial_number": h.serial_number,
"position": "toolhead" if h.is_toolhead else (f"slot_{h.slot_number}" if h.slot_number else "rack"),
"used_time_hours": round(h.used_time_seconds / 3600, 1),
"last_filament_profile_id": h.last_filament_profile_id or None,
}
for h in Hotend.objects.filter(printer=target)
]
expected_polls = int(days * 24 * 60 * 60 / 30) # 30s measured collector cadence
return {
"printer": {"id": target.id, "name": target.name},
"generated_at": now.isoformat(),
"wifi_signal_dbm": {
"current": latest.wifi_signal_dbm if latest else None,
"window_avg": round(wifi_avg, 1) if wifi_avg is not None else None,
},
"ams_humidity_raw_window_avg": round(humidity_avg, 1) if humidity_avg is not None else None,
"alerts": classify_alerts(counts, total),
"hotends": hotends,
"collector": {
"polls_in_window": total,
"expected_polls": expected_polls,
"coverage_ratio": round(total / expected_polls, 2) if expected_polls else None,
},
}

28
bambu_run/mcp/toolkit.py Normal file
View File

@@ -0,0 +1,28 @@
"""Assembles the 8-tool bambu_run MCP toolkit, capability-tagged per the spec."""
from __future__ import annotations
from mcp_gate import Toolkit
from bambu_run.mcp.filaments import get_filament, list_filaments
from bambu_run.mcp.jobs import get_print_job, get_print_statistics, list_print_jobs
from bambu_run.mcp.status import get_hardware_health, get_metrics_history, get_printer_status
def build_toolkit() -> Toolkit:
"""Build the bambu_run MCP toolkit: 8 tools across read_printer,
read_print_history, and read_filament.
Called fresh by each entry point rather than shared as a module-level
singleton, so tests (and multiple concurrent Gates, if ever needed) get
an isolated Toolkit instance rather than sharing registration state.
"""
kit = Toolkit("bambu_run")
kit.tool(requires="read_printer")(get_printer_status)
kit.tool(requires="read_printer")(get_metrics_history)
kit.tool(requires="read_printer")(get_hardware_health)
kit.tool(requires="read_print_history")(list_print_jobs)
kit.tool(requires="read_print_history")(get_print_job)
kit.tool(requires="read_print_history")(get_print_statistics)
kit.tool(requires="read_filament")(list_filaments)
kit.tool(requires="read_filament")(get_filament)
return kit

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

@@ -0,0 +1,77 @@
"""Add Printer.category so printer queries can be scoped away from other devices.
`infrastructure_device` is shared with host projects. In a standalone Bambu-Run
deployment bambu_run owns the table and the column must be created here. In a
host project like RAE the table was created by that project's own app and
already carries a `category` column, so creating it again would fail.
`AddFieldIfMissing` introspects the table and only emits DDL when needed; the
model state is updated either way.
"""
import django.db.models.manager
from django.db import migrations, models
class AddFieldIfMissing(migrations.AddField):
"""AddField that is a no-op at the database level if the column exists."""
def database_forwards(self, app_label, schema_editor, from_state, to_state):
model = to_state.apps.get_model(app_label, self.model_name)
with schema_editor.connection.cursor() as cursor:
existing = {
column.name
for column in schema_editor.connection.introspection.get_table_description(
cursor, model._meta.db_table
)
}
if self.name in existing:
return
super().database_forwards(app_label, schema_editor, from_state, to_state)
def database_backwards(self, app_label, schema_editor, from_state, to_state):
"""Reverse the model state only, never the column.
In a host project the column belongs to that project's own app — dropping
it on reverse would break the host's device model. Leaving an unused
column behind in a standalone rollback is the harmless side of this trade.
"""
return
class Migration(migrations.Migration):
dependencies = [
("bambu_run", "0008_printermetrics_nozzle_info"),
]
operations = [
AddFieldIfMissing(
model_name="printer",
name="category",
field=models.CharField(
default="threed_printer",
help_text=(
"Device category. Always 'threed_printer' for printers — present "
"because host projects may share this table with other device types."
),
max_length=50,
),
),
migrations.AlterModelOptions(
name="printer",
options={
"base_manager_name": "all_objects",
"default_manager_name": "objects",
"ordering": ["name"],
"verbose_name": "Printer",
"verbose_name_plural": "Printers",
},
),
migrations.AlterModelManagers(
name="printer",
managers=[
("all_objects", django.db.models.manager.Manager()),
("objects", django.db.models.manager.Manager()),
],
),
]

View File

@@ -2,11 +2,64 @@ from django.db import models
from django.utils import timezone 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",
"1104": "AMS HT", # observed on production H2C (last 4 of info code 11001104)
"2104": "AMS HT", # observed in dev capture (last 4 of info code 11002104)
}
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 PrinterManager(models.Manager):
"""Default manager — scopes every query to actual 3D printers.
`Printer` shares the `infrastructure_device` table with a host project's other
device rows (RAE stores its NAS, routers and cameras there too). Without this
scoping, `Printer.objects.filter(is_active=True).first()` can return a NAS.
"""
def get_queryset(self):
return super().get_queryset().filter(category=Printer.CATEGORY_3D_PRINTER)
class Printer(models.Model): class Printer(models.Model):
"""Represents a Bambu Lab 3D printer device""" """Represents a Bambu Lab 3D printer device"""
CATEGORY_3D_PRINTER = "threed_printer"
name = models.CharField(max_length=200, help_text="Friendly device name") name = models.CharField(max_length=200, help_text="Friendly device name")
model = models.CharField(max_length=100, help_text="Device model (e.g., X1C, P1S)") model = models.CharField(max_length=100, help_text="Device model (e.g., X1C, P1S)")
category = models.CharField(
max_length=50,
default=CATEGORY_3D_PRINTER,
help_text=(
"Device category. Always 'threed_printer' for printers — present because "
"host projects may share this table with other device types."
),
)
manufacturer = models.CharField( manufacturer = models.CharField(
max_length=100, default="Bambu Lab", help_text="e.g., Bambu Lab" max_length=100, default="Bambu Lab", help_text="e.g., Bambu Lab"
) )
@@ -21,11 +74,19 @@ class Printer(models.Model):
first_seen = models.DateTimeField(auto_now_add=True) first_seen = models.DateTimeField(auto_now_add=True)
last_updated = models.DateTimeField(auto_now=True) last_updated = models.DateTimeField(auto_now=True)
# `all_objects` is declared first so it serves as the base manager for related
# descriptors (PrinterMetrics.device etc.) — those must never filter, or rows
# attached to a mis-categorised device become unreachable.
all_objects = models.Manager()
objects = PrinterManager()
class Meta: class Meta:
db_table = "infrastructure_device" db_table = "infrastructure_device"
verbose_name = "Printer" verbose_name = "Printer"
verbose_name_plural = "Printers" verbose_name_plural = "Printers"
ordering = ["name"] ordering = ["name"]
base_manager_name = "all_objects"
default_manager_name = "objects"
def __str__(self): def __str__(self):
return f"{self.name} ({self.model})" return f"{self.name} ({self.model})"
@@ -58,12 +119,32 @@ class PrinterMetrics(models.Model):
max_digits=5, decimal_places=2, null=True, blank=True 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( nozzle_diameter = models.DecimalField(
max_digits=3, decimal_places=2, null=True, blank=True max_digits=3, decimal_places=2, null=True, blank=True
) )
nozzle_type = models.CharField(max_length=50, 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 # Print job status
gcode_state = models.CharField( gcode_state = models.CharField(
max_length=50, null=True, blank=True, help_text="FINISH, RUNNING, IDLE, etc." max_length=50, null=True, blank=True, help_text="FINISH, RUNNING, IDLE, etc."
@@ -184,6 +265,23 @@ class PrinterMetrics(models.Model):
default=list, help_text="Light status report [{node, mode}]" 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: class Meta:
db_table = "infrastructure_printer_metrics" db_table = "infrastructure_printer_metrics"
verbose_name = "Printer Metric" verbose_name = "Printer Metric"
@@ -259,6 +357,10 @@ class FilamentColor(models.Model):
default='Bambu Lab', default='Bambu Lab',
help_text="Manufacturer name" help_text="Manufacturer name"
) )
is_transparent = models.BooleanField(
default=False,
help_text="True for clear/transparent filaments — display as checkerboard, not solid color"
)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@@ -329,6 +431,10 @@ class Filament(models.Model):
max_length=7, null=True, blank=True, max_length=7, null=True, blank=True,
help_text="Color hex code for display (#RRGGBB)" help_text="Color hex code for display (#RRGGBB)"
) )
is_transparent = models.BooleanField(
default=False,
help_text="True for clear/transparent filaments — display as checkerboard, not solid color"
)
# Physical properties # Physical properties
diameter = models.DecimalField( diameter = models.DecimalField(
@@ -357,7 +463,16 @@ class Filament(models.Model):
) )
current_tray_id = models.IntegerField( current_tray_id = models.IntegerField(
null=True, blank=True, 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( last_loaded_date = models.DateTimeField(
null=True, blank=True, null=True, blank=True,
@@ -426,6 +541,15 @@ class FilamentSnapshot(models.Model):
max_length=20, null=True, blank=True, max_length=20, null=True, blank=True,
help_text="Slot identifier like A00-W1" 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) type = models.CharField(max_length=50, null=True, blank=True)
sub_type = models.CharField( sub_type = models.CharField(
@@ -473,9 +597,10 @@ class FilamentSnapshot(models.Model):
db_table = "infrastructure_filament_snapshot" db_table = "infrastructure_filament_snapshot"
verbose_name = "Filament Snapshot" verbose_name = "Filament Snapshot"
verbose_name_plural = "Filament Snapshots" verbose_name_plural = "Filament Snapshots"
ordering = ['printer_metric', 'tray_id'] ordering = ['printer_metric', 'ams_unit_id', 'tray_id']
indexes = [ indexes = [
models.Index(fields=['printer_metric', 'tray_id']), models.Index(fields=['printer_metric', 'tray_id']),
models.Index(fields=['printer_metric', 'ams_unit_id', 'tray_id']),
models.Index(fields=['filament']), models.Index(fields=['filament']),
] ]
@@ -484,6 +609,47 @@ class FilamentSnapshot(models.Model):
return f"Tray {self.tray_id}: {filament_info}" 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): class PrintJob(models.Model):
"""Represents a single print job from start to finish""" """Represents a single print job from start to finish"""
@@ -497,6 +663,16 @@ class PrintJob(models.Model):
) )
gcode_file = models.CharField(max_length=200, null=True, blank=True) 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") start_time = models.DateTimeField(help_text="When print started")
end_time = models.DateTimeField(null=True, blank=True, help_text="When print finished/failed") 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") duration_minutes = models.IntegerField(null=True, blank=True, help_text="Total print duration")
@@ -536,6 +712,13 @@ class PrintJob(models.Model):
status = self.final_status or 'In Progress' status = self.final_status or 'In Progress'
return f"{self.project_name} ({status}) - {self.start_time.strftime('%Y-%m-%d %H:%M')}" 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): def calculate_duration(self):
"""Calculate print duration if end_time is set""" """Calculate print duration if end_time is set"""
if self.end_time and self.start_time: if self.end_time and self.start_time:
@@ -556,6 +739,10 @@ class FilamentUsage(models.Model):
) )
tray_id = models.IntegerField(help_text="Which AMS slot was used") 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") starting_percent = models.IntegerField(help_text="Filament remaining % at job start")
ending_percent = models.IntegerField( ending_percent = models.IntegerField(
@@ -593,3 +780,108 @@ class FilamentUsage(models.Model):
self.consumed_grams = int( self.consumed_grams = int(
self.filament.initial_weight_grams * (self.consumed_percent / 100.0) 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

@@ -21,6 +21,7 @@ import os
import platform import platform
import sys import sys
import select import select
import time
from contextlib import contextmanager from contextlib import contextmanager
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
@@ -296,6 +297,73 @@ class AMSState:
return loaded 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 @dataclass
class PrinterState: class PrinterState:
"""Complete printer state parsed from MQTT data""" """Complete printer state parsed from MQTT data"""
@@ -335,10 +403,16 @@ class PrinterState:
wifi_signal: str = "" wifi_signal: str = ""
wifi_signal_dbm: int = 0 wifi_signal_dbm: int = 0
# Nozzle info # Nozzle info — single-nozzle / right-side back-compat fields.
nozzle_diameter: float = 0.4 nozzle_diameter: float = 0.4
nozzle_type: str = "" 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 # System status
home_flag: int = 0 home_flag: int = 0
hw_switch_state: int = 0 hw_switch_state: int = 0
@@ -382,6 +456,9 @@ class PrinterState:
# External spool (virtual tray) # External spool (virtual tray)
vt_tray: Optional[Dict[str, Any]] = None 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 for any additional fields
_raw_data: Dict[str, Any] = field(default_factory=dict, repr=False) _raw_data: Dict[str, Any] = field(default_factory=dict, repr=False)
@@ -410,11 +487,52 @@ class PrinterState:
wifi_signal = print_data.get("wifi_signal", "") wifi_signal = print_data.get("wifi_signal", "")
# Dual-nozzle decoding (H2C, X2D). Dual-nozzle printers report 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.
#
# The legacy top-level `nozzle_temper`/`nozzle_target_temper` fields track
# whichever nozzle is currently *active*, not specifically the right one —
# on printers like the X2D they report the left nozzle's temp while it's
# printing, even though the dashboard's "Right Nozzle" card reads them.
# Prefer the decoded right-side value from extruder.info[0] when present.
nozzle_temp_left = None
nozzle_target_temp_left = None
nozzle_temp_right = None
nozzle_target_temp_right = None
device = print_data.get("device") or {}
extruders = (device.get("extruder") or {}).get("info") or []
if len(extruders) >= 2:
right = extruders[0]
t = right.get("temp")
if isinstance(t, int):
nozzle_target_temp_right = float((t >> 16) & 0xFFFF)
nozzle_temp_right = float(t & 0xFFFF)
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( return cls(
timestamp=timestamp, timestamp=timestamp,
sequence_id=str(print_data.get("sequence_id", "")), sequence_id=str(print_data.get("sequence_id", "")),
nozzle_temp=float(print_data.get("nozzle_temper", 0.0)), nozzle_temp=(
nozzle_target_temp=float(print_data.get("nozzle_target_temper", 0.0)), nozzle_temp_right if nozzle_temp_right is not None
else float(print_data.get("nozzle_temper", 0.0))
),
nozzle_target_temp=(
nozzle_target_temp_right if nozzle_target_temp_right is not None
else float(print_data.get("nozzle_target_temper", 0.0))
),
bed_temp=float(print_data.get("bed_temper", 0.0)), bed_temp=float(print_data.get("bed_temper", 0.0)),
bed_target_temp=float(print_data.get("bed_target_temper", 0.0)), bed_target_temp=float(print_data.get("bed_target_temper", 0.0)),
chamber_temp=float(print_data.get("chamber_temper", 0.0)), chamber_temp=float(print_data.get("chamber_temper", 0.0)),
@@ -438,6 +556,13 @@ class PrinterState:
wifi_signal_dbm=cls._parse_wifi_signal(wifi_signal), wifi_signal_dbm=cls._parse_wifi_signal(wifi_signal),
nozzle_diameter=float(print_data.get("nozzle_diameter", 0.4)), nozzle_diameter=float(print_data.get("nozzle_diameter", 0.4)),
nozzle_type=print_data.get("nozzle_type", ""), 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)), home_flag=int(print_data.get("home_flag", 0)),
hw_switch_state=int(print_data.get("hw_switch_state", 0)), hw_switch_state=int(print_data.get("hw_switch_state", 0)),
mc_print_stage=str(print_data.get("mc_print_stage", "")), mc_print_stage=str(print_data.get("mc_print_stage", "")),
@@ -459,6 +584,7 @@ class PrinterState:
gcode_file_prepare_percent=str(print_data.get("gcode_file_prepare_percent", "")), gcode_file_prepare_percent=str(print_data.get("gcode_file_prepare_percent", "")),
lifecycle=print_data.get("lifecycle", ""), lifecycle=print_data.get("lifecycle", ""),
vt_tray=print_data.get("vt_tray"), vt_tray=print_data.get("vt_tray"),
hotends=hotends,
_raw_data=data, _raw_data=data,
) )
@@ -473,6 +599,14 @@ class PrinterState:
"chamber_temp": round(self.chamber_temp, 2), "chamber_temp": round(self.chamber_temp, 2),
"nozzle_diameter": self.nozzle_diameter, "nozzle_diameter": self.nozzle_diameter,
"nozzle_type": self.nozzle_type, "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, "gcode_state": self.gcode_state,
"print_type": self.print_type, "print_type": self.print_type,
"print_percent": self.print_percent, "print_percent": self.print_percent,
@@ -482,6 +616,8 @@ class PrinterState:
"print_line_number": self.print_line_number, "print_line_number": self.print_line_number,
"subtask_name": self.subtask_name, "subtask_name": self.subtask_name,
"gcode_file": self.gcode_file, "gcode_file": self.gcode_file,
"task_id": self.task_id,
"project_id": self.project_id,
"cooling_fan_speed": self.cooling_fan_speed, "cooling_fan_speed": self.cooling_fan_speed,
"heatbreak_fan_speed": self.heatbreak_fan_speed, "heatbreak_fan_speed": self.heatbreak_fan_speed,
"big_fan1_speed": self.big_fan1_speed, "big_fan1_speed": self.big_fan1_speed,
@@ -491,6 +627,11 @@ class PrinterState:
"wifi_signal_dbm": self.wifi_signal_dbm, "wifi_signal_dbm": self.wifi_signal_dbm,
"print_error": self.print_error, "print_error": self.print_error,
"has_errors": self.print_error != 0, "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, "hms": self.hms,
"stg_cur": self.stg_cur, "stg_cur": self.stg_cur,
"lights_report": self.lights_report, "lights_report": self.lights_report,
@@ -513,8 +654,19 @@ class PrinterState:
snapshot["tray_now"] = self.ams.tray_now snapshot["tray_now"] = self.ams.tray_now
snapshot["ams_version"] = self.ams.version snapshot["ams_version"] = self.ams.version
from .models import ams_type_from_info
filaments = [] filaments = []
for unit in self.ams.units: 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: for tray in unit.trays:
if tray.tray_type: if tray.tray_type:
filaments.append({ filaments.append({
@@ -540,6 +692,9 @@ class PrinterState:
"tray_bed_temp": tray.tray_bed_temp, "tray_bed_temp": tray.tray_bed_temp,
"bed_temp_type": tray.bed_temp_type, "bed_temp_type": tray.bed_temp_type,
"cols": tray.cols, "cols": tray.cols,
"ams_unit_id": unit_id_int,
"ams_info": unit.info,
"ams_type": ams_type_label,
}) })
snapshot["filaments"] = filaments snapshot["filaments"] = filaments
@@ -550,6 +705,7 @@ class PrinterState:
"ams_id": unit.ams_id, "ams_id": unit.ams_id,
"chip_id": unit.chip_id, "chip_id": unit.chip_id,
"info": unit.info, "info": unit.info,
"ams_type": ams_type_from_info(unit.info),
"humidity": unit.humidity, "humidity": unit.humidity,
"humidity_raw": unit.humidity_raw, "humidity_raw": unit.humidity_raw,
"temp": unit.temp, "temp": unit.temp,
@@ -674,6 +830,7 @@ class BambuPrinter:
self._accumulator = PrinterStateAccumulator() self._accumulator = PrinterStateAccumulator()
self._connected = False self._connected = False
self._devices: List[Dict[str, Any]] = [] self._devices: List[Dict[str, Any]] = []
self._last_message_at: Optional[float] = None
def _get_fresh_token(self, verification_code_timeout: int = 300) -> str: def _get_fresh_token(self, verification_code_timeout: int = 300) -> str:
"""Get a fresh token using credentials.""" """Get a fresh token using credentials."""
@@ -779,6 +936,30 @@ class BambuPrinter:
"""Internal MQTT message handler""" """Internal MQTT message handler"""
if not data: if not data:
return return
# The printer publishes partial deltas most of the time; the accumulator
# merges them onto whatever it already has. If the printer went offline
# (power cycle) and just reconnected, the first delta after the gap would
# otherwise be merged onto stale pre-outage state, leaving fields like
# nozzle_temp frozen at their last value until some unrelated full report
# happens to refresh them. Detect the gap and force a full pushall so the
# accumulator gets a clean, complete state instead.
now = time.time()
if (
self._last_message_at is not None
and now - self._last_message_at > app_settings.MQTT_RESYNC_GAP_SECONDS
and self._mqtt is not None
):
try:
self._mqtt.request_full_status()
logger.info(
"MQTT report gap of %.0fs detected for %s; requested full status re-sync",
now - self._last_message_at, device_id,
)
except Exception as e:
logger.warning("Full status re-sync request failed (non-fatal): %s", e)
self._last_message_at = now
state = self._accumulator.update(data) state = self._accumulator.update(data)
if self._on_update: if self._on_update:
self._on_update(state) self._on_update(state)

View File

@@ -64,3 +64,78 @@
opacity: 0.9; opacity: 0.9;
color: rgba(255, 255, 255, 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 — Bootstrap row/col handles sizing;
multi-slot units (AMS/AMS 2 Pro) are col-12, single-slot (AMS HT) are col-lg-3. */
.ams-group {
border-radius: 0.5rem;
padding: 0.75rem;
border: 1px solid var(--ams-group-border-color);
}
.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 // 3D Printer Charts Initialization and Management
// Chart.js implementation for printer metrics visualization // Chart.js implementation for printer metrics visualization
let nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart; let nozzleTempChart, nozzleTempLeftChart, bedTempChart, printProgressChart, fanSpeedsChart;
let wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart; let wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart;
function showNoDataMessage(canvasId) { function showNoDataMessage(canvasId) {
@@ -75,6 +75,50 @@ function initPrinterCharts(printerData, apiUrl) {
options: getTemperatureChartOptions(tickColor, gridColor, '°C') 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 // Initialize Bed Temperature Chart
const bedCtx = document.getElementById('bedTempChart').getContext('2d'); const bedCtx = document.getElementById('bedTempChart').getContext('2d');
bedTempChart = new Chart(bedCtx, { bedTempChart = new Chart(bedCtx, {
@@ -581,8 +625,23 @@ function createFilamentDatasets(filamentTimeline, timestamps) {
data: filamentTimeline[key] 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) => { 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 trayA = a.data.tray_id;
const trayB = b.data.tray_id; const trayB = b.data.tray_id;
@@ -615,6 +674,10 @@ function createFilamentDatasets(filamentTimeline, timestamps) {
displayLabel = `Tray ${filament.tray_id} (${filament.type})`; 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) // Add brand if it's different from type (avoid redundancy)
if (filament.brand && filament.brand !== filament.type && filament.brand !== 'External') { if (filament.brand && filament.brand !== filament.type && filament.brand !== 'External') {
displayLabel += ` - ${filament.brand}`; displayLabel += ` - ${filament.brand}`;
@@ -646,8 +709,20 @@ function hexToRgba(hex, alpha) {
function applyFilamentColors() { function applyFilamentColors() {
// Apply colors to filament cards // Apply colors to filament cards
document.querySelectorAll('.filament-card').forEach(card => { document.querySelectorAll('.filament-card').forEach(card => {
const isTransparent = card.getAttribute('data-filament-transparent') === 'true';
const colorHex = card.getAttribute('data-filament-color'); const colorHex = card.getAttribute('data-filament-color');
if (colorHex) {
if (isTransparent) {
// Checkerboard left border and subtle background for clear filaments
card.style.borderLeft = '4px solid #aaa';
card.style.background = 'repeating-conic-gradient(rgba(180,180,180,0.15) 0% 25%, transparent 0% 50%) 0 0/10px 10px';
const badge = card.querySelector('.filament-badge');
if (badge) {
badge.style.backgroundColor = '#aaa';
badge.style.color = '#fff';
}
} else if (colorHex) {
const color = '#' + colorHex; const color = '#' + colorHex;
// Set card background with gradient // Set card background with gradient
@@ -690,7 +765,7 @@ function updateChartTheme() {
// Update all charts // Update all charts
const charts = [ const charts = [
nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart, nozzleTempChart, nozzleTempLeftChart, bedTempChart, printProgressChart, fanSpeedsChart,
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
]; ];
@@ -792,7 +867,7 @@ function applyDateSeparatorsToAllPrinterCharts(timestamps, dates) {
const sepAnnotations = buildDateSeparatorAnnotations(timestamps, dates); const sepAnnotations = buildDateSeparatorAnnotations(timestamps, dates);
const charts = [ const charts = [
nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart, nozzleTempChart, nozzleTempLeftChart, bedTempChart, printProgressChart, fanSpeedsChart,
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
]; ];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,20 @@
Real-time monitoring for {{ device_name }} Real-time monitoring for {{ device_name }}
</p> </p>
</div> </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> </div>
{% if error %} {% if error %}
@@ -22,9 +36,49 @@
<!-- Summary Cards Row --> <!-- Summary Cards Row -->
<div class="row g-3 mb-4"> <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="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="card-body">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<div> <div>
@@ -36,10 +90,11 @@
</div> </div>
</div> </div>
</div> </div>
{% endif %}
<!-- Bed Temperature Card --> <!-- Bed Temperature Card -->
<div class="col-12 col-md-6 col-lg-3"> <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="card-body">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<div> <div>
@@ -54,7 +109,7 @@
<!-- Print Progress Card --> <!-- Print Progress Card -->
<div class="col-12 col-md-6 col-lg-3"> <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="card-body">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<div> <div>
@@ -69,7 +124,7 @@
<!-- Chamber Light Card --> <!-- Chamber Light Card -->
<div class="col-12 col-md-6 col-lg-3"> <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="card-body">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<div> <div>
@@ -94,7 +149,7 @@
<div class="card-body"> <div class="card-body">
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<strong>Job Name:</strong> {{ stats.subtask_name }} <strong>Job Name:</strong> {{ stats.job_display_name }}
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<strong>State:</strong> {{ stats.gcode_state }} <strong>State:</strong> {{ stats.gcode_state }}
@@ -109,37 +164,6 @@
</div> </div>
{% endif %} {% endif %}
<!-- AMS Status Section -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5>AMS Status</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4">
<strong>Temperature:</strong>
{% if stats.ams_temp %}
{{ stats.ams_temp|floatformat:1 }}&deg;C
{% else %}
N/A
{% endif %}
</div>
<div class="col-md-4">
<strong>Humidity:</strong>
{% if stats.ams_humidity %}
{{ stats.ams_humidity }}%
{% else %}
N/A
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Filaments Section --> <!-- Filaments Section -->
<div class="row mb-4"> <div class="row mb-4">
<div class="col-12"> <div class="col-12">
@@ -149,34 +173,59 @@
</div> </div>
<div class="card-body"> <div class="card-body">
{% if stats.filaments %} {% if stats.filaments %}
<div class="row g-3"> {% if stats.ams_units|length > 1 %}
{% for filament in stats.filaments %} <div class="ams-filter-pills mb-3" id="amsFilterPills">
<div class="col-12 col-md-6 col-lg-3"> <button type="button" class="btn ams-filter-pill active" data-ams-filter="all">All</button>
<div class="card filament-card" data-filament-color="{{ filament.color|slice:':6' }}"> {% for unit in stats.ams_units %}
<div class="card-body"> <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>
<div class="d-flex justify-content-between align-items-center mb-2"> {% endfor %}
<h6 class="mb-0">Tray {{ filament.tray_id }}</h6> </div>
{% if filament.filament_pk %} {% endif %}
<a href="{% url 'bambu_run:filament_detail' filament.filament_pk %}" class="text-decoration-none" title="View in inventory"> <div class="row g-3 ams-groups">
<svg class="icon icon-sm text-body-secondary"><use href="{% static 'bambu_run/vendors/coreui-icons-free.svg' %}#cil-external-link"></use></svg> {% for group in stats.ams_groups %}
</a> <div class="{% if group.filaments|length > 1 %}col-12{% else %}col-12 col-md-6 col-lg-3{% endif %} ams-group ams-badge-bg-{{ group.ams_type|slugify }}" data-ams-unit-id="{{ group.unit_id }}">
{% endif %} <div class="ams-group-header d-flex justify-content-between align-items-center mb-2">
</div> <strong class="small">{{ group.label }}</strong>
<p class="mb-1 small"><strong>{{ filament.type }}</strong> - {{ filament.brand }}</p> {% if group.humidity is not None or group.temp is not None %}
{% if filament.color_name %}<p class="mb-1 small text-body-secondary">{{ filament.color_name }}</p>{% endif %} <span class="small text-body-secondary">
<div class="d-flex justify-content-between align-items-center mb-2"> {% if group.humidity is not None %}{{ group.humidity }}%RH{% endif %}
<span class="small">Remaining</span> {% if group.temp is not None %}&middot; {{ group.temp }}&deg;C{% endif %}
<span class="badge filament-badge">{{ filament.remain_percent }}%</span> </span>
</div> {% endif %}
<div class="progress" style="height: 10px; background-color: rgba(0,0,0,0.1);"> </div>
<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 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>
</div> </div>
{% endfor %}
</div> </div>
</div> </div>
{% endfor %} {% 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="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 filament-card" data-filament-color="{{ stats.external_spool.color|slice:':6' }}">
<div class="card-body"> <div class="card-body">
@@ -192,8 +241,8 @@
</div> </div>
</div> </div>
</div> </div>
{% endif %}
</div> </div>
{% endif %}
{% else %} {% else %}
<p class="text-body-secondary">No filament data available</p> <p class="text-body-secondary">No filament data available</p>
{% endif %} {% endif %}
@@ -202,6 +251,64 @@
</div> </div>
</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 --> <!-- Date/Time Filter Controls -->
{% if not is_basic_user %} {% if not is_basic_user %}
<div class="row mb-4"> <div class="row mb-4">
@@ -266,10 +373,10 @@
<!-- Charts Section --> <!-- Charts Section -->
<div class="row g-3 mb-4"> <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="col-12 col-lg-6">
<div class="card"> <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="card-body">
<div class="chart-container"> <div class="chart-container">
<canvas id="nozzleTempChart"></canvas> <canvas id="nozzleTempChart"></canvas>
@@ -278,6 +385,20 @@
</div> </div>
</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 --> <!-- Bed Temperature Chart -->
<div class="col-12 col-lg-6"> <div class="col-12 col-lg-6">
<div class="card"> <div class="card">
@@ -374,12 +495,12 @@
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3.0.1"></script> <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3.0.1"></script>
<script src="{% static 'bambu_run/js/printer_charts.js' %}"></script> <script src="{% static 'bambu_run/js/printer_charts.js' %}"></script>
<script src="{% static 'bambu_run/js/printer_charts_control.js' %}"></script> <script src="{% static 'bambu_run/js/printer_charts_control.js' %}"></script>
{% if not is_basic_user %} {% if not is_basic_user and printer_device %}
<div id="printerApiUrl" data-url="{% url 'bambu_run:printer_api' %}" style="display: none;"></div> <div id="printerApiUrl" data-url="{% url 'bambu_run:printer_api' pk=printer_device.pk %}" style="display: none;"></div>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const printerData = {{ printer_data_json|safe }}; 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); initPrinterCharts(printerData, apiUrl);
// Add project markers if they exist // Add project markers if they exist
@@ -404,4 +525,27 @@
}); });
</script> </script>
{% endif %} {% 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 %} {% endblock %}

View File

@@ -5,7 +5,9 @@ app_name = "bambu_run"
urlpatterns = [ urlpatterns = [
path("", views.PrinterDashboardView.as_view(), name="printer_dashboard"), 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/", views.PrinterDataAPIView.as_view(), name="printer_api"),
path("api/printer/<int:pk>/", views.PrinterDataAPIView.as_view(), name="printer_api"),
# Filament Inventory routes # Filament Inventory routes
path("filaments/", views.FilamentListView.as_view(), name="filament_list"), 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'). # 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 MQTT_COLOR_HEX_LENGTH = 6
def is_mqtt_color_transparent(mqtt_color):
"""
Return True if the AMS color represents a clear/transparent filament.
Bambu Lab uses alpha=00 for transparent (e.g. '00000000'), not 'FF' like opaque filaments.
"""
return bool(mqtt_color) and len(mqtt_color) == 8 and mqtt_color[6:8].upper() == '00'
def strip_color_padding(mqtt_color): def strip_color_padding(mqtt_color):
""" """
Strip FF padding from MQTT color Strip alpha padding from MQTT color, returning the 6-char RGB hex.
MQTT: '000000FF' -> '000000' MQTT: '000000FF' -> '000000' (opaque black)
MQTT: '00000000' -> '000000' (transparent — use is_mqtt_color_transparent() to distinguish)
MQTT: 'FF6A13FF' -> 'FF6A13' MQTT: 'FF6A13FF' -> 'FF6A13'
""" """
if not mqtt_color: if not mqtt_color:

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.views.generic import TemplateView, View, ListView, CreateView, UpdateView, DetailView, DeleteView
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import get_object_or_404
from django.utils import timezone from django.utils import timezone
from django.http import JsonResponse from django.http import Http404, JsonResponse
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.contrib import messages from django.contrib import messages
from django.db.models import Q, Sum from django.db.models import Q, Sum
@@ -10,9 +11,80 @@ import json
import zoneinfo import zoneinfo
from .conf import app_settings 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 from .forms import FilamentForm, FilamentColorForm, FilamentTypeForm
# Every field the chart serializers read must be listed here. A field that is
# accessed but missing triggers a deferred-field load — one extra SELECT per row,
# which turns a single-query page into thousands.
_METRICS_API_FIELDS = [
'id', 'device_id', 'timestamp',
'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',
'external_spool',
]
# 24h at the collector's 30s cadence is ~2800 readings, and every one of them
# also drags in ~9 FilamentSnapshot rows — that snapshot fetch, not the metrics
# query, is what dominated the dashboard's load time (measured: 2.09s of context
# building and a 410 KB payload at 3000). 1440 caps the series at roughly one
# point per minute over a day, which is finer than any chart can resolve on
# screen, and cuts both the server time and the payload by ~4x.
_MAX_CHART_POINTS = 1440
# Fallback window for requests that don't specify a full date range. Without it a
# bare API call scans the entire metrics table.
_DEFAULT_WINDOW = timedelta(hours=24)
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).
Both paths go through `Printer.objects`, which is category-scoped, so a
non-printer row sharing `infrastructure_device` (a NAS, a router) can never be
resolved as "the printer" — even when no active printer exists.
"""
if pk is not None:
return get_object_or_404(Printer, pk=pk, is_active=True)
return Printer.objects.filter(is_active=True).first()
def sample_metrics(metrics_list, max_points=None):
"""Evenly thin a metrics list to at most `max_points`, always keeping the last
reading — the stat cards are built from it."""
max_points = max_points or _MAX_CHART_POINTS
total = len(metrics_list)
if total <= max_points:
return metrics_list
step = (total // max_points) + 1
sampled = metrics_list[::step]
if sampled[-1] is not metrics_list[-1]:
sampled.append(metrics_list[-1])
return sampled
def fetch_snapshots_by_metric(metrics_list):
"""Load filament snapshots for exactly the metrics we're serializing.
Beats `prefetch_related` on the unsampled queryset, which pulls a snapshot row
for every metric in the window (~25k rows for 24h) including the ones sampling
just discarded.
"""
if not metrics_list:
return {}
snapshots_by_metric = {}
for snap in FilamentSnapshot.objects.filter(
printer_metric_id__in=[m.id for m in metrics_list]
):
snapshots_by_metric.setdefault(snap.printer_metric_id, []).append(snap)
return snapshots_by_metric
class PrinterDashboardView(LoginRequiredMixin, TemplateView): class PrinterDashboardView(LoginRequiredMixin, TemplateView):
template_name = "bambu_run/printer_dashboard.html" template_name = "bambu_run/printer_dashboard.html"
@@ -26,13 +98,20 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
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: try:
printer_device = Printer.objects.filter(is_active=True).first() printer_device = resolve_printer_from_request(self.kwargs.get("pk"))
if not printer_device: if not printer_device:
context["error"] = ( context["error"] = (
"No 3D printer device found. Please run bambu_collector first." "No 3D printer device found. Please run bambu_collector first."
) )
return context return context
except Http404:
raise
except Exception as e: except Exception as e:
context["error"] = f"Error loading printer device: {str(e)}" context["error"] = f"Error loading printer device: {str(e)}"
return context return context
@@ -41,14 +120,28 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
# Get date range (overridable by subclasses) # Get date range (overridable by subclasses)
start_dt, end_dt = self._get_date_range(self.request) start_dt, end_dt = self._get_date_range(self.request)
metrics = PrinterMetrics.objects.filter( query = PrinterMetrics.objects.filter(
device=printer_device, timestamp__gte=start_dt device=printer_device, timestamp__gte=start_dt
) )
if end_dt: if end_dt:
metrics = metrics.filter(timestamp__lte=end_dt) query = query.filter(timestamp__lte=end_dt)
metrics = metrics.prefetch_related('filament_snapshots').order_by("timestamp")
latest_metric = metrics.last() # Chart series only need the columns the serializer below reads, and only
# as many points as a chart can render. Fetching every column (including
# the large JSON blobs) for every row is what made this page slow.
metrics = sample_metrics(
list(query.only(*_METRICS_API_FIELDS).order_by("timestamp"))
)
snapshots_by_metric = fetch_snapshots_by_metric(metrics)
# The stat cards read far more fields than the charts do, so the latest
# reading is fetched separately as a full instance rather than deferring
# (a deferred field on a sampled row costs an extra query per access).
latest_metric = (
query.prefetch_related('filament_snapshots__filament')
.order_by("-timestamp")
.first()
)
printer_data_json = { printer_data_json = {
"timestamps": [ "timestamps": [
@@ -64,6 +157,14 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
float(m.nozzle_target_temp) if m.nozzle_target_temp else None float(m.nozzle_target_temp) if m.nozzle_target_temp else None
for m in metrics 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_temp": [float(m.bed_temp) if m.bed_temp else None for m in metrics],
"bed_target_temp": [ "bed_target_temp": [
float(m.bed_target_temp) if m.bed_target_temp else None for m in metrics float(m.bed_target_temp) if m.bed_target_temp else None for m in metrics
@@ -94,14 +195,17 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
"total_layer_num": [ "total_layer_num": [
m.total_layer_num if m.total_layer_num else 0 for m in metrics m.total_layer_num if m.total_layer_num else 0 for m in metrics
], ],
"filament_timeline": self._prepare_filament_timeline(metrics), "filament_timeline": self._prepare_filament_timeline(
metrics, snapshots_by_metric
),
} }
stats = {} stats = {}
if latest_metric: if latest_metric:
filaments_list = [] filaments_list = []
try: try:
filament_snapshots = latest_metric.filament_snapshots.select_related('filament').all() # `.all()` (not `.select_related()`) so the prefetch cache is used
filament_snapshots = latest_metric.filament_snapshots.all()
for snapshot in filament_snapshots: for snapshot in filament_snapshots:
filament_dict = { filament_dict = {
'tray_id': snapshot.tray_id, 'tray_id': snapshot.tray_id,
@@ -109,43 +213,150 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
'brand': snapshot.sub_type or 'Unknown', 'brand': snapshot.sub_type or 'Unknown',
'color': snapshot.color or 'FFFFFFFF', 'color': snapshot.color or 'FFFFFFFF',
'remain_percent': snapshot.remain_percent or 0, 'remain_percent': snapshot.remain_percent or 0,
'ams_unit_id': snapshot.ams_unit_id,
'ams_type': snapshot.ams_type or '',
} }
if snapshot.filament: if snapshot.filament:
filament_dict['color_name'] = snapshot.filament.color filament_dict['color_name'] = snapshot.filament.color
filament_dict['filament_pk'] = snapshot.filament.pk filament_dict['filament_pk'] = snapshot.filament.pk
filament_dict['is_transparent'] = snapshot.filament.is_transparent
filaments_list.append(filament_dict) filaments_list.append(filament_dict)
except Exception: except Exception:
filaments_list = [] filaments_list = []
# Build a lookup from unit_id → AMS unit metadata (humidity, temp, info code)
# first so we can enrich blank ams_type values derived from old snapshots.
units_meta = {
u.get('unit_id'): u for u in (latest_metric.ams_units or [])
}
# Distinct AMS units in this snapshot. ams_type stored on FilamentSnapshot
# may be blank for rows written before the multi-AMS deploy — fall back to
# re-deriving from the unit's info code so labels always show correctly.
from .models import ams_type_from_info as _ams_type_from_info
seen_units = {}
for f in filaments_list:
uid = f.get('ams_unit_id')
if uid is not None and uid not in seen_units:
label = f.get('ams_type') or ''
if not label:
unit_meta = units_meta.get(str(uid), {})
label = _ams_type_from_info(unit_meta.get('info', ''))
seen_units[uid] = label
ams_units_list = [
{'ams_unit_id': uid, 'ams_type': label}
for uid, label in sorted(seen_units.items())
]
ams_groups = []
ungrouped = [f for f in filaments_list if f.get('ams_unit_id') is None]
if ungrouped:
ams_groups.append({
'unit_id': None,
'ams_type': '',
'label': 'AMS',
'humidity': None,
'temp': None,
'filaments': ungrouped,
})
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 = { stats = {
"nozzle_temp": float(latest_metric.nozzle_temp) if latest_metric.nozzle_temp else 0, "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, "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, "chamber_temp": float(latest_metric.chamber_temp) if latest_metric.chamber_temp else 0,
"print_percent": latest_metric.print_percent or 0, "print_percent": latest_metric.print_percent or 0,
"gcode_state": latest_metric.gcode_state or "Unknown", "gcode_state": latest_metric.gcode_state or "Unknown",
"print_type": latest_metric.print_type or "idle", "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", "chamber_light": latest_metric.chamber_light or "unknown",
"ams_temp": float(latest_metric.ams_temp) if latest_metric.ams_temp else None, "ams_temp": float(latest_metric.ams_temp) if latest_metric.ams_temp else None,
"ams_humidity": latest_metric.ams_humidity, "ams_humidity": latest_metric.ams_humidity,
"filaments": filaments_list, "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 {}, "external_spool": latest_metric.external_spool or {},
"timestamp": latest_metric.timestamp.astimezone(tz).strftime("%Y-%m-%d %H:%M:%S"), "timestamp": latest_metric.timestamp.astimezone(tz).strftime("%Y-%m-%d %H:%M:%S"),
} }
project_markers = self._calculate_project_markers(list(metrics), tz) project_markers = self._calculate_project_markers(metrics, tz, printer_device)
printer_data_json["project_markers"] = project_markers printer_data_json["project_markers"] = project_markers
context["printer_device"] = printer_device context["printer_device"] = printer_device
context["device_name"] = printer_device.name context["device_name"] = printer_device.name
context["stats"] = stats context["stats"] = stats
context["metrics_count"] = metrics.count() context["metrics_count"] = len(metrics)
context["printer_data_json"] = json.dumps(printer_data_json) context["printer_data_json"] = json.dumps(printer_data_json)
return context return context
def _calculate_project_markers(self, metrics, timezone_info): def _calculate_project_markers(self, metrics, timezone_info, device):
"""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
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 = [] markers = []
current_job = None current_job = None
last_state = None last_state = None
@@ -157,21 +368,23 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
is_printing = gcode_state not in ['FINISH', 'IDLE', None, ''] is_printing = gcode_state not in ['FINISH', 'IDLE', None, '']
if subtask and subtask != current_job and is_printing: if subtask and subtask != current_job and is_printing:
display = subtask_to_display.get(subtask, subtask)
markers.append({ markers.append({
'type': 'start', 'type': 'start',
'index': idx, 'index': idx,
'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(), 'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(),
'project_name': subtask, 'project_name': display,
}) })
current_job = subtask current_job = subtask
last_state = gcode_state last_state = gcode_state
elif current_job and last_state and last_state not in ['FINISH', 'IDLE'] and gcode_state in ['FINISH', 'IDLE']: 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({ markers.append({
'type': 'end', 'type': 'end',
'index': idx, 'index': idx,
'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(), 'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(),
'project_name': current_job, 'project_name': display,
}) })
current_job = None current_job = None
@@ -179,28 +392,31 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
return markers return markers
def _prepare_filament_timeline(self, metrics): def _prepare_filament_timeline(self, metrics, snapshots_by_metric):
"""Prepare filament data organized by unique filament configurations.""" """Prepare filament data organized by unique filament configurations.
Snapshots are passed in pre-grouped by metric id; reading them off each
metric instance instead would issue one query per point.
"""
filament_data = {} filament_data = {}
total_points = len(metrics) total_points = len(metrics)
for idx, metric in enumerate(metrics): for idx, metric in enumerate(metrics):
try: for snapshot in snapshots_by_metric.get(metric.id, []):
snapshots = metric.filament_snapshots.all()
except Exception:
snapshots = []
for snapshot in snapshots:
tray_id = snapshot.tray_id 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_type = snapshot.type or 'Unknown'
fil_sub_type = snapshot.sub_type or 'Unknown' fil_sub_type = snapshot.sub_type or 'Unknown'
fil_color = snapshot.color or 'FFFFFFFF' 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: if unique_key not in filament_data:
filament_data[unique_key] = { filament_data[unique_key] = {
'tray_id': tray_id, 'tray_id': tray_id,
'ams_unit_id': ams_unit_id,
'ams_type': ams_type,
'type': fil_type, 'type': fil_type,
'brand': fil_sub_type, 'brand': fil_sub_type,
'color': fil_color, 'color': fil_color,
@@ -237,62 +453,184 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
class PrinterDataAPIView(LoginRequiredMixin, View): class PrinterDataAPIView(LoginRequiredMixin, View):
"""API endpoint for dynamic printer chart updates""" """API endpoint for dynamic printer chart updates"""
def get(self, request): def get(self, request, pk=None):
start_date = request.GET.get("start_date") start_date = request.GET.get("start_date")
end_date = request.GET.get("end_date") end_date = request.GET.get("end_date")
start_time = request.GET.get("start_time", "00:00") start_time = request.GET.get("start_time", "00:00")
end_time = request.GET.get("end_time", "23:59") end_time = request.GET.get("end_time", "23:59")
try: try:
printer_device = Printer.objects.filter(is_active=True).first() if pk is not None:
if not printer_device: printer_device = Printer.objects.filter(pk=pk, is_active=True).first()
return JsonResponse({"error": "No printer device found"}, status=404) if not printer_device:
return JsonResponse({"error": "Printer not found"}, status=404)
query = PrinterMetrics.objects.filter(device=printer_device).prefetch_related('filament_snapshots') 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) tz = zoneinfo.ZoneInfo(app_settings.TIMEZONE)
if start_date and start_time: # Stage A: only() + step calculation
from datetime import datetime query = (
start_dt_naive = datetime.strptime(f"{start_date} {start_time}", "%Y-%m-%d %H:%M") PrinterMetrics.objects
start_dt = start_dt_naive.replace(tzinfo=tz) .filter(device=printer_device)
query = query.filter(timestamp__gte=start_dt) .only(*_METRICS_API_FIELDS)
)
if end_date and end_time: # Both bounds are always applied. A missing bound falls back to a 24h
from datetime import datetime # window rather than being left open — an unbounded range would scan
end_dt_naive = datetime.strptime(f"{end_date} {end_time}", "%Y-%m-%d %H:%M") # every metric ever recorded.
end_dt = end_dt_naive.replace(tzinfo=tz) def _parse(date_str, time_str):
query = query.filter(timestamp__lte=end_dt) return datetime.strptime(
f"{date_str} {time_str}", "%Y-%m-%d %H:%M"
).replace(tzinfo=tz)
metrics = query.order_by("timestamp") end_dt = _parse(end_date, end_time) if end_date else timezone.now()
start_dt = _parse(start_date, start_time) if start_date else end_dt - _DEFAULT_WINDOW
query = query.filter(timestamp__gte=start_dt, timestamp__lte=end_dt)
# Stage B: single DB round-trip, downsample in Python
metrics_list = sample_metrics(list(query.order_by("timestamp")))
total_points = len(metrics_list)
# Stage C: targeted snapshot fetch (only sampled IDs)
snapshots_by_metric = fetch_snapshots_by_metric(metrics_list)
# 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 = { data = {
"timestamps": [m.timestamp.astimezone(tz).strftime('%H:%M') for m in metrics], "timestamps": timestamps,
"timestamps_iso": [m.timestamp.astimezone(tz).isoformat() for m in metrics], "timestamps_iso": timestamps_iso,
"dates": [m.timestamp.astimezone(tz).strftime('%Y-%m-%d') for m in metrics], "dates": dates,
"nozzle_temp": [float(m.nozzle_temp) if m.nozzle_temp else None for m in metrics], "nozzle_temp": nozzle_temp,
"nozzle_target_temp": [float(m.nozzle_target_temp) if m.nozzle_target_temp else None for m in metrics], "nozzle_target_temp": nozzle_target_temp,
"bed_temp": [float(m.bed_temp) if m.bed_temp else None for m in metrics], "nozzle_temp_left": nozzle_temp_left,
"bed_target_temp": [float(m.bed_target_temp) if m.bed_target_temp else None for m in metrics], "nozzle_target_temp_left": nozzle_target_temp_left,
"print_percent": [m.print_percent if m.print_percent else 0 for m in metrics], "bed_temp": bed_temp,
"cooling_fan_speed": [m.cooling_fan_speed if m.cooling_fan_speed else 0 for m in metrics], "bed_target_temp": bed_target_temp,
"heatbreak_fan_speed": [m.heatbreak_fan_speed if m.heatbreak_fan_speed else 0 for m in metrics], "print_percent": print_percent,
"wifi_signal_dbm": [m.wifi_signal_dbm if m.wifi_signal_dbm else None for m in metrics], "cooling_fan_speed": cooling_fan_speed,
"ams_humidity_raw": [m.ams_humidity_raw if m.ams_humidity_raw else None for m in metrics], "heatbreak_fan_speed": heatbreak_fan_speed,
"ams_temp": [float(m.ams_temp) if m.ams_temp else None for m in metrics], "wifi_signal_dbm": wifi_signal_dbm,
"layer_num": [m.layer_num if m.layer_num else 0 for m in metrics], "ams_humidity_raw": ams_humidity_raw,
"total_layer_num": [m.total_layer_num if m.total_layer_num else 0 for m in metrics], "ams_temp": ams_temp,
"gcode_state": [m.gcode_state for m in metrics], "layer_num": layer_num,
"print_type": [m.print_type for m in metrics], "total_layer_num": total_layer_num,
"subtask_name": [m.subtask_name for m in metrics], "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) return JsonResponse(data)
except Exception as e: except Exception as e:
@@ -300,93 +638,6 @@ class PrinterDataAPIView(LoginRequiredMixin, View):
traceback.print_exc() traceback.print_exc()
return JsonResponse({"error": str(e)}, status=500) return JsonResponse({"error": str(e)}, status=500)
def _calculate_project_markers(self, metrics, timezone_info):
markers = []
current_job = None
last_state = None
for idx, metric in enumerate(metrics):
subtask = metric.subtask_name
gcode_state = metric.gcode_state
is_printing = gcode_state not in ['FINISH', 'IDLE', None, '']
if subtask and subtask != current_job and is_printing:
markers.append({
'type': 'start',
'index': idx,
'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(),
'project_name': subtask,
})
current_job = subtask
last_state = gcode_state
elif current_job and last_state and last_state not in ['FINISH', 'IDLE'] and gcode_state in ['FINISH', 'IDLE']:
markers.append({
'type': 'end',
'index': idx,
'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(),
'project_name': current_job,
})
current_job = None
last_state = gcode_state
return markers
def _prepare_filament_timeline_for_api(self, metrics):
filament_data = {}
total_points = len(metrics)
for idx, metric in enumerate(metrics):
try:
snapshots = metric.filament_snapshots.all()
except Exception:
snapshots = []
for snapshot in snapshots:
tray_id = snapshot.tray_id
fil_type = snapshot.type or 'Unknown'
fil_sub_type = snapshot.sub_type or 'Unknown'
fil_color = snapshot.color or 'FFFFFFFF'
unique_key = f"{tray_id}_{fil_type}_{fil_sub_type}_{fil_color}"
if unique_key not in filament_data:
filament_data[unique_key] = {
'tray_id': tray_id,
'type': fil_type,
'brand': fil_sub_type,
'color': fil_color,
'remain_data': [None] * total_points,
'start_idx': idx,
}
remain_percent = snapshot.remain_percent or 0
filament_data[unique_key]['remain_data'][idx] = remain_percent
for idx, metric in enumerate(metrics):
external = metric.external_spool or {}
if external.get('type'):
fil_type = external.get('type', 'Unknown')
fil_color = external.get('color', '161616FF')
unique_key = f"External_{fil_type}_{fil_color}"
if unique_key not in filament_data:
filament_data[unique_key] = {
'tray_id': 'External',
'type': fil_type,
'brand': 'External',
'color': fil_color,
'remain_data': [None] * total_points,
'start_idx': idx,
}
remain_percent = external.get('remain', 0)
filament_data[unique_key]['remain_data'][idx] = remain_percent
return filament_data
class FilamentUsageDataAPIView(LoginRequiredMixin, View): class FilamentUsageDataAPIView(LoginRequiredMixin, View):
"""API endpoint for filament usage history with date/time filtering""" """API endpoint for filament usage history with date/time filtering"""
@@ -473,6 +724,10 @@ class FilamentListView(LoginRequiredMixin, ListView):
elif loaded == 'no': elif loaded == 'no':
queryset = queryset.filter(is_loaded_in_ams=False) 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') search = self.request.GET.get('search')
if search: if search:
queryset = queryset.filter( queryset = queryset.filter(
@@ -492,9 +747,22 @@ class FilamentListView(LoginRequiredMixin, ListView):
context['filament_types'] = sorted( context['filament_types'] = sorted(
set(Filament.objects.exclude(type__isnull=True).exclude(type='').values_list('type', flat=True)) 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 return context
def _filament_type_map():
"""Return a JSON-serialisable dict mapping FilamentType pk → {type, sub_type, brand}."""
return {
str(ft.pk): {'type': ft.type, 'sub_type': ft.sub_type or '', 'brand': ft.brand}
for ft in FilamentType.objects.all()
}
class FilamentCreateView(LoginRequiredMixin, CreateView): class FilamentCreateView(LoginRequiredMixin, CreateView):
model = Filament model = Filament
form_class = FilamentForm form_class = FilamentForm
@@ -504,6 +772,7 @@ class FilamentCreateView(LoginRequiredMixin, CreateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
context['filament_type_map'] = json.dumps(_filament_type_map())
return context return context
def form_valid(self, form): def form_valid(self, form):
@@ -520,6 +789,7 @@ class FilamentUpdateView(LoginRequiredMixin, UpdateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
context['filament_type_map'] = json.dumps(_filament_type_map())
return context return context
def form_valid(self, form): def form_valid(self, form):
@@ -553,7 +823,7 @@ class FilamentDetailView(LoginRequiredMixin, DetailView):
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
filament = self.object 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_consumed = filament.print_usages.aggregate(
total=Sum('consumed_percent') total=Sum('consumed_percent')

View File

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

View File

@@ -25,6 +25,19 @@ autorestart=true
startretries=10 startretries=10
startsecs=5 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] [program:migrate]
command=python standalone/manage.py migrate --noinput command=python standalone/manage.py migrate --noinput
directory=/app directory=/app

View File

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

View File

@@ -1,3 +1,4 @@
Translucent #000000
Translucent Gray #8E8E8E Translucent Gray #8E8E8E
Translucent Light Blue #61B0FF Translucent Light Blue #61B0FF
Translucent Olive #748C45 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

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

@@ -0,0 +1,106 @@
#!/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
;;
enable-mcp)
if [ -f "$SERVICE_DIR/bambu-run-mcp.service" ]; then
echo "MCP server already enabled."
else
echo "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
PI_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
echo "MCP server enabled on port 8808."
echo
echo " Claude Desktop config:"
echo " {\"mcpServers\":{\"bambu-run\":{\"url\":\"http://${PI_IP:-localhost}:8808/sse\"}}}"
fi
;;
disable-mcp)
if [ ! -f "$SERVICE_DIR/bambu-run-mcp.service" ]; then
echo "MCP server is not enabled."
else
systemctl --user stop bambu-run-mcp.service 2>/dev/null || true
systemctl --user disable bambu-run-mcp.service 2>/dev/null || true
rm -f "$SERVICE_DIR/bambu-run-mcp.service"
systemctl --user daemon-reload
echo "MCP server disabled."
fi
;;
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|enable-mcp|disable-mcp}"
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"
echo " enable-mcp Install and start the MCP server for AI agent access"
echo " disable-mcp Stop and remove the MCP server service"
;;
esac

View File

@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "bambu-run" name = "bambu-run"
version = "0.1.0" version = "0.1.13"
description = "Django reusable app for Bambu Lab 3D printer monitoring and filament inventory management" description = "Django reusable app for Bambu Lab 3D printer monitoring and filament inventory management"
readme = "README.md" readme = "README.md"
license = {text = "MIT"} license = {text = "MIT"}
requires-python = ">=3.10" requires-python = ">=3.9"
authors = [ authors = [
{name = "Runnan Li"}, {name = "Runnan Li"},
] ]
@@ -40,10 +40,18 @@ standalone = [
"python-dotenv", "python-dotenv",
"whitenoise", "whitenoise",
] ]
postgres = [
"psycopg2-binary",
]
mcp = [
"mcp-gate @ git+https://github.com/RunLit/mcp-gate.git@v0.1.0",
"uvicorn",
]
dev = [ dev = [
"ruff", "ruff",
"pytest", "pytest",
"pytest-django", "pytest-django",
"anyio",
] ]
[project.urls] [project.urls]
@@ -59,3 +67,7 @@ bambu_run = [
"templates/bambu_run/*.html", "templates/bambu_run/*.html",
"static/bambu_run/**/*", "static/bambu_run/**/*",
] ]
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "tests.settings"
python_files = "test_*.py"

303
setup.sh Executable file
View File

@@ -0,0 +1,303 @@
#!/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 [-v|--verbose]
# -v, --verbose show full pip install output instead of --quiet
set -euo pipefail
VERBOSE=0
for arg in "$@"; do
case "$arg" in
-v|--verbose) VERBOSE=1 ;;
esac
done
PIP_QUIET_FLAG="--quiet"
if [ "$VERBOSE" -eq 1 ]; then
PIP_QUIET_FLAG=""
fi
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 $PIP_QUIET_FLAG --upgrade pip
"$VENV_DIR/bin/pip" install $PIP_QUIET_FLAG ".[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 $PIP_QUIET_FLAG ".[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' # Ensure the project root (/app) is on sys.path so that both 'standalone'
# and 'bambu_run' are importable regardless of where this script is invoked from. # and 'bambu_run' are importable regardless of where this script is invoked from.
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(): def main():

View File

@@ -63,16 +63,29 @@ TEMPLATES = [
WSGI_APPLICATION = "standalone.wsgi.application" WSGI_APPLICATION = "standalone.wsgi.application"
# Database — SQLite for zero-setup deployment # Database — SQLite for zero-setup deployment, or Postgres when DB_ENGINE=postgresql
# is set (e.g. to point a standalone install at an existing database).
DATA_DIR = Path(os.environ.get("DATA_DIR", BASE_DIR / "data")) DATA_DIR = Path(os.environ.get("DATA_DIR", BASE_DIR / "data"))
DATA_DIR.mkdir(parents=True, exist_ok=True) DATA_DIR.mkdir(parents=True, exist_ok=True)
DATABASES = { if os.environ.get("DB_ENGINE") == "postgresql":
"default": { DATABASES = {
"ENGINE": "django.db.backends.sqlite3", "default": {
"NAME": DATA_DIR / "db.sqlite3", "ENGINE": "django.db.backends.postgresql",
"NAME": os.environ["DB_NAME"],
"USER": os.environ["DB_USER"],
"PASSWORD": os.environ["DB_PASSWORD"],
"HOST": os.environ["DB_HOST"],
"PORT": os.environ.get("DB_PORT", "5432"),
}
}
else:
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": DATA_DIR / "db.sqlite3",
}
} }
}
# Password validation # Password validation
AUTH_PASSWORD_VALIDATORS = [ AUTH_PASSWORD_VALIDATORS = [
@@ -111,6 +124,10 @@ PRINTER_IP = os.environ.get("PRINTER_IP", "")
ACCESS_TOKEN = os.environ.get("ACCESS_TOKEN", "") ACCESS_TOKEN = os.environ.get("ACCESS_TOKEN", "")
PRINTER_SERIAL = os.environ.get("PRINTER_SERIAL", "") PRINTER_SERIAL = os.environ.get("PRINTER_SERIAL", "")
CSRF_TRUSTED_ORIGINS = [
origin for origin in os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(",") if origin
]
# Logging # Logging
LOGGING = { LOGGING = {
"version": 1, "version": 1,

0
tests/__init__.py Normal file
View File

30
tests/conftest.py Normal file
View File

@@ -0,0 +1,30 @@
"""Suite-wide test fixtures.
test_mcp_toolkit.py drives real MCP dispatch through `mcp.client.Client`,
which routes each tool call's synchronous Django ORM work through
`sync_to_async` onto its own dedicated worker thread rather than the test's
own thread. pytest-django's `db` fixture isolates each test by wrapping it in
an outer atomic transaction on *its* thread's connection and rolling that
back afterwards -- but the worker thread opens a separate, thread-local
connection that was never part of that transaction, so writes made through
it commit for real and survive into later tests (e.g. Printer rows from
test_mcp_toolkit.py leaking into test_resolve_printer_device.py's counts).
This fixture flushes bambu_run's Printer table after each test in that file
specifically, restoring isolation without touching the test file itself
(which mirrors mcp-gate's proven end-to-end test pattern verbatim).
"""
from __future__ import annotations
import pytest
@pytest.fixture(autouse=True)
def _isolate_async_mcp_dispatch_writes(request, django_db_blocker):
yield
if request.node.module.__name__ != "tests.test_mcp_toolkit":
return
with django_db_blocker.unblock():
from bambu_run.models import Printer
Printer.objects.all().delete()

52
tests/settings.py Normal file
View File

@@ -0,0 +1,52 @@
"""Minimal Django settings for running bambu_run's pytest suite (in-memory SQLite)."""
import os
# test_mcp_toolkit.py drives real MCP dispatch through `mcp.client.Client`,
# which runs the test body on the asyncio event loop (via pytest-anyio) while
# still using plain synchronous Django ORM calls (Printer.objects.create(),
# and the ORM calls inside the tool functions themselves). Django's asyncio
# safety net treats any sync ORM call made while an event loop is running as
# unsafe by default. It's genuinely safe here: sqlite is in-memory, tests run
# single-threaded, and there's no concurrent request interleaving to corrupt
# a shared connection. Set at settings-import time (before any test body
# runs) rather than per-test, since Django reads this env var at call time.
os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "1")
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,185 @@
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,
)
from bambu_run.views import fetch_snapshots_by_metric
view = PrinterDashboardView()
metrics = list(PrinterMetrics.objects.filter(pk=metric.pk))
timeline = view._prepare_filament_timeline(
metrics, fetch_snapshots_by_metric(metrics)
)
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 "col-12 ams-group" in html # wide group: col-12 only
assert "col-lg-3 ams-group" in html # compact group: col-lg-3
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"] == []

137
tests/test_mcp_filaments.py Normal file
View File

@@ -0,0 +1,137 @@
import pytest
from django.utils import timezone
from bambu_run.models import Filament, Printer, PrintJob, FilamentUsage
from bambu_run.mcp.filaments import list_filaments, get_filament
@pytest.mark.django_db
def test_list_filaments_filters_material_sub_type_color():
Filament.objects.create(type="PLA", sub_type="PLA Matte", brand="Bambu Lab", color="Black", remaining_percent=50)
Filament.objects.create(type="PLA", sub_type="PLA Basic", brand="Bambu Lab", color="Black", remaining_percent=50)
Filament.objects.create(type="ABS", sub_type="ABS", brand="Bambu Lab", color="Black", remaining_percent=50)
result = list_filaments(material="PLA", sub_type="PLA Matte", color="black")
assert len(result["filaments"]) == 1
assert result["filaments"][0]["sub_type"] == "PLA Matte"
assert "generated_at" in result
@pytest.mark.django_db
def test_list_filaments_min_remaining_grams_excludes_unread_sentinel():
Filament.objects.create(type="ABS", brand="Bambu Lab", color="Mint", remaining_percent=-1, remaining_weight_grams=-10)
Filament.objects.create(type="ABS", brand="Bambu Lab", color="Orange", remaining_percent=68, remaining_weight_grams=680)
result = list_filaments(material="ABS", min_remaining_grams=250)
assert len(result["filaments"]) == 1
assert result["filaments"][0]["color_name"] == "Orange"
@pytest.mark.django_db
def test_list_filaments_min_remaining_grams_excludes_unread_even_with_low_threshold():
# The existing test only checks min_remaining_grams=250, which happens to
# exclude a -10g sentinel-derived value anyway. This test uses a threshold
# low enough that a naive filter would let the sentinel spool through.
Filament.objects.create(
type="ABS", brand="Bambu Lab", color="Mint",
remaining_percent=-1, remaining_weight_grams=-10,
)
Filament.objects.create(
type="ABS", brand="Bambu Lab", color="Orange",
remaining_percent=68, remaining_weight_grams=680,
)
result = list_filaments(material="ABS", min_remaining_grams=-20)
assert len(result["filaments"]) == 1
assert result["filaments"][0]["color_name"] == "Orange"
@pytest.mark.django_db
def test_list_filaments_unread_sentinel_status_not_low():
Filament.objects.create(type="ABS", brand="Bambu Lab", color="Mint", remaining_percent=-1)
result = list_filaments(material="ABS")
spool = result["filaments"][0]
assert spool["remaining_percent"] is None
assert spool["remaining_status"] == "unread"
@pytest.mark.django_db
def test_list_filaments_limit_hard_ceiling_100():
Filament.objects.bulk_create([
Filament(type="PLA", brand="Bambu Lab", color=f"Color {i}", remaining_percent=50)
for i in range(150)
])
result = list_filaments(limit=1000)
assert len(result["filaments"]) == 100
@pytest.mark.django_db
def test_list_filaments_include_history_adds_recent_usage():
f = Filament.objects.create(type="PLA", brand="Bambu Lab", color="White", initial_weight_grams=1000, remaining_percent=80)
p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
job = PrintJob.objects.create(device=p, project_name="Vase", start_time=timezone.now(), final_status="FINISH")
FilamentUsage.objects.create(print_job=job, filament=f, tray_id=0, starting_percent=90, ending_percent=80)
result = list_filaments(material="PLA", include_history=True)
history = result["filaments"][0]["recent_usage"]
assert history[0]["job_name"] == "Vase"
assert history[0]["consumed_grams"] == 100
@pytest.mark.django_db
def test_list_filaments_never_exposes_rfid_identifiers():
Filament.objects.create(
type="PLA", brand="Bambu Lab", color="White",
tray_uuid="SECRET-UUID", tag_uid="SECRET-TAG",
)
result = list_filaments(material="PLA")
entry_str = repr(result["filaments"][0])
assert "SECRET-UUID" not in entry_str
assert "SECRET-TAG" not in entry_str
@pytest.mark.django_db
def test_get_filament_returns_full_usage_history_with_data_quality():
p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
f = Filament.objects.create(type="ABS", brand="Bambu Lab", color="Black", initial_weight_grams=1000)
job1 = PrintJob.objects.create(device=p, project_name="Bracket", start_time=timezone.now(), final_status="FINISH")
job2 = PrintJob.objects.create(device=p, project_name="Vase", start_time=timezone.now(), final_status="FINISH")
FilamentUsage.objects.create(print_job=job1, filament=f, tray_id=0, starting_percent=80, ending_percent=70)
FilamentUsage.objects.create(print_job=job2, filament=f, tray_id=0, starting_percent=-1, ending_percent=100)
result = get_filament(f.id)
assert len(result["usage_history"]) == 2
reliable = next(u for u in result["usage_history"] if u["job_name"] == "Bracket")
unreliable = next(u for u in result["usage_history"] if u["job_name"] == "Vase")
assert reliable["data_quality"] == "ok"
assert reliable["consumed_grams"] == 100
assert unreliable["data_quality"] == "unreliable"
assert unreliable["consumed_grams"] is None
assert "generated_at" in result
@pytest.mark.django_db
def test_get_filament_never_exposes_purchase_price_or_cost():
f = Filament.objects.create(type="PLA", brand="Bambu Lab", color="White", purchase_price=25.50)
result = get_filament(f.id)
assert "purchase_price" not in result
assert "cost" not in result
@pytest.mark.django_db
def test_get_filament_unknown_id_raises():
with pytest.raises(Filament.DoesNotExist):
get_filament(999999)

196
tests/test_mcp_jobs.py Normal file
View File

@@ -0,0 +1,196 @@
import pytest
from datetime import timedelta
from django.utils import timezone
from bambu_run.models import Printer, PrintJob, BambuCloudTask, Filament, FilamentUsage
from bambu_run.mcp.jobs import list_print_jobs, get_print_job, get_print_statistics
@pytest.mark.django_db
def test_list_print_jobs_orders_most_recent_first():
p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
now = timezone.now()
older = PrintJob.objects.create(device=p, project_name="Old", start_time=now - timedelta(days=1), final_status="FINISH")
newer = PrintJob.objects.create(device=p, project_name="New", start_time=now, final_status="FINISH")
result = list_print_jobs()
assert [j["id"] for j in result["jobs"]] == [newer.id, older.id]
assert "generated_at" in result
@pytest.mark.django_db
def test_list_print_jobs_filters_by_status_running():
p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
running = PrintJob.objects.create(device=p, project_name="Live", start_time=timezone.now(), final_status=None)
PrintJob.objects.create(device=p, project_name="Done", start_time=timezone.now(), final_status="FINISH")
result = list_print_jobs(status="running")
assert [j["id"] for j in result["jobs"]] == [running.id]
@pytest.mark.django_db
def test_list_print_jobs_limit_hard_ceiling_100():
p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
now = timezone.now()
PrintJob.objects.bulk_create([
PrintJob(device=p, project_name=f"Job {i}", start_time=now - timedelta(hours=i), final_status="FINISH")
for i in range(150)
])
result = list_print_jobs(limit=1000)
assert len(result["jobs"]) == 100
@pytest.mark.django_db
def test_list_print_jobs_prefers_cloud_weight_and_tags_source():
p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
cloud = BambuCloudTask.objects.create(task_id=1, design_title="Cool Bracket", weight_grams=70.10)
PrintJob.objects.create(
device=p, project_name="raw_name", start_time=timezone.now(),
final_status="FINISH", cloud_task=cloud,
)
result = list_print_jobs()
job = result["jobs"][0]
assert job["display_name"] == "Cool Bracket"
assert job["filament_grams"] == 70.10
assert job["filament_grams_source"] == "cloud"
assert job["cost"] is None
@pytest.mark.django_db
def test_list_print_jobs_display_name_falls_back_to_project_name():
p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
PrintJob.objects.create(device=p, project_name="raw_project", start_time=timezone.now(), final_status="FINISH")
result = list_print_jobs()
assert result["jobs"][0]["display_name"] == "raw_project"
@pytest.mark.django_db
def test_get_print_job_marks_negative_consumption_unreliable():
p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
job = PrintJob.objects.create(device=p, project_name="Bracket", start_time=timezone.now(), final_status="FINISH")
filament = Filament.objects.create(type="ABS", brand="Bambu Lab", color="Mint", initial_weight_grams=1000)
FilamentUsage.objects.create(
print_job=job, filament=filament, tray_id=0,
starting_percent=51, ending_percent=52, # ordinary RFID jitter, spec finding
)
result = get_print_job(job.id)
usage = result["filament_usages"][0]
assert usage["consumed_grams"] is None
assert usage["data_quality"] == "unreliable"
assert "generated_at" in result
@pytest.mark.django_db
def test_get_print_job_computes_estimate_delta_percent():
p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
cloud = BambuCloudTask.objects.create(task_id=1, cost_time_seconds=205 * 60)
job = PrintJob.objects.create(
device=p, project_name="Bracket", start_time=timezone.now(),
final_status="FINISH", cloud_task=cloud, duration_minutes=237,
)
result = get_print_job(job.id)
assert result["estimate_delta_percent"] == round(100 * (237 - 205) / 205, 1)
@pytest.mark.django_db
def test_get_print_job_unknown_id_raises():
with pytest.raises(PrintJob.DoesNotExist):
get_print_job(999999)
@pytest.mark.django_db
def test_get_print_statistics_success_rate_and_status_counts():
p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
now = timezone.now()
PrintJob.objects.create(device=p, project_name="A", start_time=now, final_status="FINISH")
PrintJob.objects.create(device=p, project_name="B", start_time=now, final_status="FAILED")
result = get_print_statistics(days=30)
assert result["total_jobs"] == 2
assert result["success_rate"] == 0.5
assert result["status_counts"]["FINISH"] == 1
assert result["status_counts"]["FAILED"] == 1
assert "generated_at" in result
@pytest.mark.django_db
def test_get_print_statistics_excludes_ending_sentinel_from_material_mix():
p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
job = PrintJob.objects.create(device=p, project_name="A", start_time=timezone.now(), final_status="FINISH")
pla = Filament.objects.create(type="PLA", brand="Bambu Lab", color="Black", initial_weight_grams=1000)
# ending_percent=-1 (no RFID read at job end) must not be counted as 610g of consumption
FilamentUsage.objects.create(print_job=job, filament=pla, tray_id=0, starting_percent=60, ending_percent=-1)
result = get_print_statistics(days=30)
assert result["material_mix_grams"] == {}
assert result["most_used_material"] is None
@pytest.mark.django_db
def test_get_print_statistics_material_mix_excludes_negative_consumption():
p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
job = PrintJob.objects.create(device=p, project_name="A", start_time=timezone.now(), final_status="FINISH")
pla = Filament.objects.create(type="PLA", brand="Bambu Lab", color="Black", initial_weight_grams=1000)
abs_f = Filament.objects.create(type="ABS", brand="Bambu Lab", color="White", initial_weight_grams=1000)
FilamentUsage.objects.create(print_job=job, filament=pla, tray_id=0, starting_percent=74, ending_percent=68) # 6% -> 60g, valid
FilamentUsage.objects.create(print_job=job, filament=abs_f, tray_id=1, starting_percent=51, ending_percent=52) # jitter, excluded
result = get_print_statistics(days=30)
assert result["material_mix_grams"] == {"PLA": 60.0}
assert result["most_used_material"] == "PLA"
@pytest.mark.django_db
def test_get_print_statistics_estimate_accuracy_mean_and_median():
p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
cloud1 = BambuCloudTask.objects.create(task_id=1, cost_time_seconds=100 * 60)
cloud2 = BambuCloudTask.objects.create(task_id=2, cost_time_seconds=100 * 60)
PrintJob.objects.create(
device=p, project_name="A", start_time=timezone.now(),
final_status="FINISH", cloud_task=cloud1, duration_minutes=110,
)
PrintJob.objects.create(
device=p, project_name="B", start_time=timezone.now(),
final_status="FINISH", cloud_task=cloud2, duration_minutes=130,
)
result = get_print_statistics(days=30)
assert result["estimate_accuracy"]["mean_ratio"] == 1.2
assert result["estimate_accuracy"]["median_ratio"] == 1.2
@pytest.mark.django_db
def test_get_print_statistics_cost_always_null_and_unavailable():
result = get_print_statistics(days=30)
assert result["cost_total"] is None
assert result["cost_available"] is False
@pytest.mark.django_db
def test_get_print_statistics_failures_lists_top_failing_projects():
p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
now = timezone.now()
PrintJob.objects.create(device=p, project_name="Flaky", start_time=now, final_status="FAILED")
PrintJob.objects.create(device=p, project_name="Flaky", start_time=now, final_status="FAILED")
PrintJob.objects.create(device=p, project_name="Solid", start_time=now, final_status="FINISH")
result = get_print_statistics(days=30)
assert result["failures"]["count"] == 2
assert result["failures"]["top_failing_projects"][0] == {"name": "Flaky", "count": 2}

View File

@@ -0,0 +1,24 @@
import pytest
from mcp_gate import OpenIdentity, StaticTokenIdentity
from bambu_run.management.commands.bambu_mcp_server import ALL_CAPABILITIES, _build_gate
from bambu_run.mcp.toolkit import build_toolkit
def test_build_gate_stdio_uses_open_identity_with_all_capabilities():
gate = _build_gate(build_toolkit(), transport="stdio")
assert isinstance(gate.identity, OpenIdentity)
def test_build_gate_http_requires_api_key():
with pytest.raises(ValueError, match="BAMBU_RUN_MCP_API_KEY"):
_build_gate(build_toolkit(), transport="http", api_key=None)
def test_build_gate_http_with_key_uses_static_token_identity():
gate = _build_gate(build_toolkit(), transport="http", api_key="secret123")
assert isinstance(gate.identity, StaticTokenIdentity)
def test_all_capabilities_matches_the_three_spec_capabilities():
assert set(ALL_CAPABILITIES) == {"read_printer", "read_print_history", "read_filament"}

140
tests/test_mcp_shapes.py Normal file
View File

@@ -0,0 +1,140 @@
import pytest
from datetime import timedelta
from django.utils import timezone
from bambu_run.models import Printer
from bambu_run.mcp.shapes import (
STALE_THRESHOLD_SECONDS,
freshness,
remaining_status,
smoothed_remaining_percent,
classify_alerts,
display_name,
normalize_consumed,
resolve_printer,
)
def test_freshness_fresh_reading():
now = timezone.now()
ts = now - timedelta(seconds=10)
assert freshness(ts, now) == {"data_age_seconds": 10, "stale": False}
def test_freshness_stale_reading():
now = timezone.now()
ts = now - timedelta(seconds=STALE_THRESHOLD_SECONDS + 30)
result = freshness(ts, now)
assert result["stale"] is True
def test_remaining_status_unread_sentinel():
assert remaining_status(-1) == (None, "unread")
def test_remaining_status_none_is_unread():
assert remaining_status(None) == (None, "unread")
def test_remaining_status_empty():
assert remaining_status(0) == (0, "empty")
def test_remaining_status_low_boundary():
assert remaining_status(20) == (20, "low")
assert remaining_status(21) == (21, "ok")
def test_remaining_status_ok():
assert remaining_status(72) == (72, "ok")
def test_smoothed_remaining_percent_rejects_spikes():
# Real data pattern (spec, job 122): genuine values bouncing 22-35%.
readings = [35, 22, 30, 28, 25]
assert smoothed_remaining_percent(readings) == (28, "ok")
def test_smoothed_remaining_percent_excludes_unread_sentinel():
readings = [-1, -1, 90, 88, 91]
assert smoothed_remaining_percent(readings) == (90, "ok")
def test_smoothed_remaining_percent_all_unread():
assert smoothed_remaining_percent([-1, -1, -1]) == (None, "unread")
def test_smoothed_remaining_percent_only_uses_5_most_recent():
readings = [10, 10, 10, 10, 10, 100]
value, _ = smoothed_remaining_percent(readings)
assert value == 10
def test_classify_alerts_persistent_above_20_percent():
counts = {(83887616, 131078): 25}
assert classify_alerts(counts, total_rows=100) == [{
"attr": 83887616, "code": 131078,
"kind": "persistent", "occurrences": 25, "severity": "unknown",
}]
def test_classify_alerts_transient_below_5_percent():
counts = {(50336256, 131073): 3}
alerts = classify_alerts(counts, total_rows=100)
assert alerts[0]["kind"] == "transient"
def test_classify_alerts_omits_ambiguous_band():
counts = {(1, 2): 10} # exactly 10% of 100 rows: neither >20% nor <5%
assert classify_alerts(counts, total_rows=100) == []
def test_display_name_falls_back_through_chain():
assert display_name("", "", "raw_project") == "raw_project"
assert display_name("", "plate_1", "raw_project") == "plate_1"
assert display_name("Cool Design", "plate_1", "raw_project") == "Cool Design"
def test_normalize_consumed_ordinary_reading():
assert normalize_consumed(74, 68, initial_weight_grams=1000) == (6, 60, "ok")
def test_normalize_consumed_rfid_jitter_goes_unreliable_not_negative():
# spec finding: start=51% end=52% -> raw consumed=-1% (ordinary jitter)
assert normalize_consumed(51, 52, initial_weight_grams=1000) == (None, None, "unreliable")
def test_normalize_consumed_sentinel_start_goes_unreliable():
# spec finding: jobs 62/63/70/77, start=-1% end=100%
assert normalize_consumed(-1, 100, initial_weight_grams=1000) == (None, None, "unreliable")
def test_normalize_consumed_sentinel_end_goes_unreliable():
# symmetric case: ending_percent == -1 (no RFID reading at job end)
# must not fabricate a plausible-looking consumption figure
assert normalize_consumed(74, -1, initial_weight_grams=1000) == (None, None, "unreliable")
@pytest.mark.django_db
def test_resolve_printer_by_id():
p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
assert list(resolve_printer(p.id)) == [p]
@pytest.mark.django_db
def test_resolve_printer_by_name():
p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
assert list(resolve_printer("RNL-H2C")) == [p]
@pytest.mark.django_db
def test_resolve_printer_none_returns_all_active():
p1 = Printer.objects.create(name="A", model="H2C", is_active=True)
Printer.objects.create(name="B", model="X1C", is_active=False)
assert list(resolve_printer(None)) == [p1]
@pytest.mark.django_db
def test_resolve_printer_unknown_name_raises():
with pytest.raises(Printer.DoesNotExist):
resolve_printer("nonexistent")

283
tests/test_mcp_status.py Normal file
View File

@@ -0,0 +1,283 @@
import pytest
from datetime import timedelta
from django.utils import timezone
from bambu_run.models import Printer, PrinterMetrics
from bambu_run.mcp.status import get_printer_status
@pytest.mark.django_db
def test_get_printer_status_no_data_yet():
Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
result = get_printer_status()
assert len(result["printers"]) == 1
entry = result["printers"][0]
assert entry["state"] == "no_data"
assert entry["stale"] is True
assert entry["job"] is None
@pytest.mark.django_db
def test_get_printer_status_running_job_computes_finish_time():
p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
PrinterMetrics.objects.create(
device=p, timestamp=timezone.now(), gcode_state="RUNNING",
print_percent=63, layer_num=189, total_layer_num=301,
remaining_time_min=47, subtask_name="SyncGen LightQube Bracket",
)
result = get_printer_status(printer=p.id)
job = result["printers"][0]["job"]
assert job["print_percent"] == 63
assert job["name"] == "SyncGen LightQube Bracket"
assert job["estimated_finish_at"] is not None
@pytest.mark.django_db
def test_get_printer_status_idle_has_no_job():
p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
PrinterMetrics.objects.create(device=p, timestamp=timezone.now(), gcode_state="FINISH")
result = get_printer_status(printer=p.id)
assert result["printers"][0]["job"] is None
@pytest.mark.django_db
def test_get_printer_status_stale_when_collector_gap_exceeds_120s():
p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
PrinterMetrics.objects.create(
device=p, timestamp=timezone.now() - timedelta(seconds=200), gcode_state="RUNNING",
)
result = get_printer_status(printer=p.id)
assert result["printers"][0]["stale"] is True
@pytest.mark.django_db
def test_get_printer_status_ams_slot_carries_smoothed_percent_and_status():
p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
now = timezone.now()
# Same tray, jittering readings across the 3 most recent polls (oldest first).
for i, pct in enumerate([22, 30, 35]):
PrinterMetrics.objects.create(
device=p, timestamp=now - timedelta(seconds=(2 - i) * 30), gcode_state="RUNNING",
filaments=[{
"ams_unit_id": 1, "tray_id": "2", "slot": "A01-P3",
"type": "PLA", "sub_type": "PLA Matte", "color": "E8AFCFFF",
"remain_percent": pct,
}],
)
result = get_printer_status(printer=p.id)
unit = result["printers"][0]["ams_units"][0]
slot = unit["slots"][0]
assert slot["remain_percent"] == 30 # median of [35, 30, 22] most-recent-first
assert slot["remain_status"] == "ok"
assert slot["color_hex"] == "#E8AFCF"
@pytest.mark.django_db
def test_get_printer_status_ams_slot_unread_sentinel():
p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
PrinterMetrics.objects.create(
device=p, timestamp=timezone.now(), gcode_state="RUNNING",
filaments=[{"ams_unit_id": 0, "tray_id": "0", "type": "ABS", "remain_percent": -1}],
)
result = get_printer_status(printer=p.id)
slot = result["printers"][0]["ams_units"][0]["slots"][0]
assert slot["remain_percent"] is None
assert slot["remain_status"] == "unread"
@pytest.mark.django_db
def test_get_printer_status_ams_units_have_independent_humidity():
p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
PrinterMetrics.objects.create(
device=p, timestamp=timezone.now(), gcode_state="RUNNING",
ams_humidity_raw=4, # unit-0-only flat column, must NOT be used for unit 1
ams_units=[
{"unit_id": 0, "ams_type": "AMS 2 Pro", "humidity_raw": 4, "temp": 28.0},
{"unit_id": 1, "ams_type": "AMS", "humidity_raw": 7, "temp": 24.0},
],
filaments=[
{"ams_unit_id": 0, "tray_id": "0", "type": "ABS", "remain_percent": 50},
{"ams_unit_id": 1, "tray_id": "0", "type": "PLA", "remain_percent": 60},
],
)
result = get_printer_status(printer=p.id)
units_by_id = {u["unit_id"]: u for u in result["printers"][0]["ams_units"]}
assert units_by_id[0]["humidity_raw"] == 4
assert units_by_id[1]["humidity_raw"] == 7
assert units_by_id[0]["temp_c"] == 28.0
assert units_by_id[1]["temp_c"] == 24.0
@pytest.mark.django_db
def test_get_printer_status_tolerates_malformed_tray_id():
p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
PrinterMetrics.objects.create(
device=p, timestamp=timezone.now(), gcode_state="RUNNING",
filaments=[{"ams_unit_id": 0, "tray_id": None, "type": "ABS", "remain_percent": 50}],
)
result = get_printer_status(printer=p.id) # must not raise
assert result["printers"][0]["ams_units"][0]["slots"][0]["slot"] == -1
@pytest.mark.django_db
def test_get_printer_status_never_exposes_serial_or_ip():
p = Printer.objects.create(
name="RNL-H2C", model="H2C", is_active=True,
serial_number="SECRET123", ip_address="192.168.1.50",
)
PrinterMetrics.objects.create(device=p, timestamp=timezone.now())
result = get_printer_status(printer=p.id)
entry_str = repr(result["printers"][0])
assert "SECRET123" not in entry_str
assert "192.168.1.50" not in entry_str
@pytest.mark.django_db
def test_get_printer_status_alerts_use_frequency_heuristic_not_hms_emptiness():
p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
now = timezone.now()
# 1 of 5 rows in the alert window carries an HMS code -- below the 5%
# "transient" floor at this row count is not meaningful, but the point
# of this test is that a single non-empty hms row does NOT by itself
# produce an alert with unconditional certainty; it is classified, not
# surfaced verbatim. With 1/5 = 20%, exactly at the persistent boundary
# (not > 20%), so it lands in the omitted band.
for i in range(5):
hms = [{"attr": 1, "code": 2}] if i == 0 else []
PrinterMetrics.objects.create(device=p, timestamp=now - timedelta(minutes=i), hms=hms)
result = get_printer_status(printer=p.id)
assert result["printers"][0]["alerts"] == []
from bambu_run.mcp.status import get_metrics_history
@pytest.mark.django_db
def test_get_metrics_history_returns_series_within_window():
p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
now = timezone.now()
for i in range(3):
PrinterMetrics.objects.create(
device=p, timestamp=now - timedelta(hours=i),
nozzle_temp=200 + i, bed_temp=60, chamber_temp=35,
print_percent=50 + i, ams_humidity_raw=5, cooling_fan_speed=100,
)
PrinterMetrics.objects.create(device=p, timestamp=now - timedelta(hours=10), nozzle_temp=999)
result = get_metrics_history(printer=p.id, hours=6)
assert result["printer"]["id"] == p.id
assert len(result["timestamps"]) == 3
assert 999.0 not in result["series"]["nozzle_c"]
assert "generated_at" in result
@pytest.mark.django_db
def test_get_metrics_history_filters_to_requested_metrics():
p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
PrinterMetrics.objects.create(device=p, timestamp=timezone.now(), nozzle_temp=200, print_percent=10)
result = get_metrics_history(printer=p.id, metrics=["progress"])
assert "print_percent" in result["series"]
assert "nozzle_c" not in result["series"]
@pytest.mark.django_db
def test_get_metrics_history_hard_ceiling_500_points():
p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
now = timezone.now()
PrinterMetrics.objects.bulk_create([
PrinterMetrics(device=p, timestamp=now - timedelta(minutes=i))
for i in range(600)
])
result = get_metrics_history(printer=p.id, hours=24, max_points=10000)
assert len(result["timestamps"]) <= 500
@pytest.mark.django_db
def test_get_metrics_history_no_matching_printer_returns_empty_shape():
result = get_metrics_history(printer=None)
assert result["printer"] is None
assert result["timestamps"] == []
assert result["series"] == {}
assert "generated_at" in result
from bambu_run.models import Hotend
from bambu_run.mcp.status import get_hardware_health
@pytest.mark.django_db
def test_get_hardware_health_reports_wifi_and_humidity_averages():
p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
now = timezone.now()
PrinterMetrics.objects.create(device=p, timestamp=now, wifi_signal_dbm=-50, ams_humidity_raw=4)
PrinterMetrics.objects.create(device=p, timestamp=now - timedelta(hours=1), wifi_signal_dbm=-60, ams_humidity_raw=6)
result = get_hardware_health(printer=p.id, days=7)
assert result["wifi_signal_dbm"]["current"] == -50
assert result["wifi_signal_dbm"]["window_avg"] == -55.0
assert result["ams_humidity_raw_window_avg"] == 5.0
@pytest.mark.django_db
def test_get_hardware_health_excludes_wear_percent_field():
p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
PrinterMetrics.objects.create(device=p, timestamp=timezone.now())
Hotend.objects.create(
printer=p, serial_number="SN1", raw_id=0, is_toolhead=True,
used_time_seconds=3600, wear_percent=100,
)
result = get_hardware_health(printer=p.id)
hotend = result["hotends"][0]
assert hotend["used_time_hours"] == 1.0
assert "wear_percent" not in hotend
@pytest.mark.django_db
def test_get_hardware_health_classifies_persistent_alert():
p = Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
now = timezone.now()
for i in range(30):
PrinterMetrics.objects.create(
device=p, timestamp=now - timedelta(minutes=i),
hms=[{"attr": 83887616, "code": 131078}],
)
for i in range(30, 100):
PrinterMetrics.objects.create(device=p, timestamp=now - timedelta(minutes=i), hms=[])
result = get_hardware_health(printer=p.id, days=1)
assert result["alerts"][0]["kind"] == "persistent"
assert result["alerts"][0]["occurrences"] == 30
@pytest.mark.django_db
def test_get_hardware_health_no_printer_returns_empty_shape():
assert get_hardware_health(printer=None) == {"printer": None}

84
tests/test_mcp_toolkit.py Normal file
View File

@@ -0,0 +1,84 @@
"""Proves this toolkit's capability assignments are wired correctly through
real MCP dispatch -- not by calling tool functions directly. See
~/src/mcp-gate/tests/test_end_to_end.py for the pattern this is modeled on;
mcp-gate's own HTTP-shaped dispatch machinery is already tested there, so
this file only needs the stdio-shaped Client, which is enough to drive
server.middleware and prove which tools this toolkit's capability tags
actually gate.
"""
import pytest
from mcp.client import Client
from mcp.shared.exceptions import MCPError
from mcp_gate import Gate, OpenIdentity
from bambu_run.mcp.toolkit import build_toolkit
from bambu_run.models import Printer
pytestmark = pytest.mark.anyio
@pytest.fixture
def anyio_backend():
return "asyncio"
async def test_full_capability_caller_sees_all_eight_tools(db):
Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
gate = Gate(
name="test",
toolkits=[build_toolkit()],
identity=OpenIdentity(["read_printer", "read_print_history", "read_filament"]),
)
server = gate.build_server()
async with Client(server, mode="legacy") as client:
result = await client.list_tools()
names = {t.name for t in result.tools}
assert names == {
"get_printer_status", "get_metrics_history", "get_hardware_health",
"list_print_jobs", "get_print_job", "get_print_statistics",
"list_filaments", "get_filament",
}
async def test_read_printer_only_caller_cannot_see_or_call_filament_tool(db):
Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
gate = Gate(
name="test",
toolkits=[build_toolkit()],
identity=OpenIdentity(["read_printer"]),
)
server = gate.build_server()
async with Client(server, mode="legacy") as client:
visible = await client.list_tools()
visible_names = {t.name for t in visible.tools}
assert "list_filaments" not in visible_names
assert "list_print_jobs" not in visible_names
assert "get_printer_status" in visible_names
with pytest.raises(MCPError):
await client.call_tool("list_filaments", {})
async def test_read_printer_caller_can_call_get_printer_status(db):
Printer.objects.create(name="RNL-H2C", model="H2C", is_active=True)
gate = Gate(
name="test",
toolkits=[build_toolkit()],
identity=OpenIdentity(["read_printer"]),
)
server = gate.build_server()
async with Client(server, mode="legacy") as client:
result = await client.call_tool("get_printer_status", {})
assert result is not None
def test_build_toolkit_has_no_name_collisions():
# Toolkit.tool() raises ValueError on a name collision at registration
# time -- building it twice without raising is itself the assertion.
build_toolkit()
build_toolkit()

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,71 @@
"""Printer shares the `infrastructure_device` table with non-printer devices
(NAS, routers, ...) in host projects like RAE. Printer queries must never
resolve one of those rows.
"""
import pytest
from django.urls import reverse
from bambu_run.models import Printer
from bambu_run.views import resolve_printer_from_request
@pytest.fixture
def logged_in_client(client, django_user_model):
user = django_user_model.objects.create_user(username="scoping", password="pw")
client.force_login(user)
return client
@pytest.fixture
def nas():
"""A non-printer device row sharing the table, sorting before any printer."""
return Printer.all_objects.create(
name="A NAS", model="DS920+", category="nas", is_active=True
)
@pytest.mark.django_db
def test_default_manager_excludes_non_printers(nas):
printer = Printer.objects.create(name="Z Printer", model="H2C", is_active=True)
assert list(Printer.objects.all()) == [printer]
assert nas in Printer.all_objects.all()
@pytest.mark.django_db
def test_new_printers_default_to_the_printer_category():
printer = Printer.objects.create(name="Fresh", model="H2C")
assert printer.category == Printer.CATEGORY_3D_PRINTER
assert printer in Printer.objects.all()
@pytest.mark.django_db
def test_resolve_printer_skips_an_active_nas(nas):
"""The exact production failure: NAS sorts first and is active, printer is not."""
printer = Printer.objects.create(name="Z Printer", model="H2C", is_active=False)
assert resolve_printer_from_request(None) is None, "inactive printer must not resolve"
printer.is_active = True
printer.save()
assert resolve_printer_from_request(None) == printer
@pytest.mark.django_db
def test_resolve_printer_by_pk_rejects_a_non_printer(nas):
from django.http import Http404
with pytest.raises(Http404):
resolve_printer_from_request(nas.pk)
@pytest.mark.django_db
def test_dashboard_does_not_fall_back_to_a_nas(logged_in_client, nas):
resp = logged_in_client.get(reverse("bambu_run:printer_dashboard"))
assert resp.status_code == 200
assert "error" in resp.context
assert resp.context.get("printer_device") is None
assert list(resp.context["all_printers"]) == []

View File

@@ -0,0 +1,233 @@
"""Guards against the query-count and payload regressions that made the printer
pages slow: deferred-field N+1s, unbounded date ranges, and unsampled chart data.
"""
import json
from datetime import timedelta
import pytest
from django.urls import reverse
from django.utils import timezone
from bambu_run.models import Printer, PrinterMetrics, FilamentSnapshot
from bambu_run.views import _MAX_CHART_POINTS
@pytest.fixture
def logged_in_client(client, django_user_model):
user = django_user_model.objects.create_user(username="perf", password="pw")
client.force_login(user)
return client
@pytest.fixture
def printer():
return Printer.objects.create(name="Perf Printer", model="H2C", is_active=True)
def _make_metrics(printer, count, *, snapshots_per_metric=2, spacing_seconds=30):
"""Create `count` metrics ending now, each with some filament snapshots."""
now = timezone.now()
metrics = PrinterMetrics.objects.bulk_create(
[
PrinterMetrics(
device=printer,
timestamp=now - timedelta(seconds=spacing_seconds * (count - i)),
nozzle_temp=200 + i % 5,
nozzle_target_temp=220,
nozzle_temp_left=180 + i % 3,
nozzle_target_temp_left=190,
bed_temp=60,
bed_target_temp=60,
print_percent=i % 100,
gcode_state="RUNNING",
print_type="local",
subtask_name="job",
)
for i in range(count)
]
)
FilamentSnapshot.objects.bulk_create(
[
FilamentSnapshot(
printer_metric=m,
tray_id=str(tray),
type="PLA",
sub_type="Bambu",
color="FF0000FF",
remain_percent=80,
)
for m in metrics
for tray in range(snapshots_per_metric)
]
)
return metrics
# --- Root cause 1: deferred-field N+1 in the API -----------------------------
def _count_queries(client, url, params=None):
from django.db import connection
from django.test.utils import CaptureQueriesContext
with CaptureQueriesContext(connection) as ctx:
resp = client.get(url, params or {})
assert resp.status_code == 200
return len(ctx)
@pytest.mark.django_db
def test_api_query_count_is_independent_of_row_count(logged_in_client, printer):
"""Every field the serializer reads must be in .only(), or Django emits one
extra SELECT per row per missing field — making query count scale with data."""
today = timezone.localtime().date()
url = reverse("bambu_run:printer_api")
params = {
"start_date": str(today - timedelta(days=1)),
"end_date": str(today),
"start_time": "00:00",
"end_time": "23:59",
}
_make_metrics(printer, 10)
few = _count_queries(logged_in_client, url, params)
_make_metrics(printer, 190)
many = _count_queries(logged_in_client, url, params)
assert few == many, f"query count scales with rows: {few} -> {many}"
@pytest.mark.django_db
def test_api_returns_dual_nozzle_values(logged_in_client, printer):
"""The left-nozzle fields must survive the .only() narrowing."""
_make_metrics(printer, 5)
today = timezone.localtime().date()
resp = logged_in_client.get(
reverse("bambu_run:printer_api"),
{
"start_date": str(today - timedelta(days=1)),
"end_date": str(today),
"start_time": "00:00",
"end_time": "23:59",
},
)
data = resp.json()
assert any(v is not None for v in data["nozzle_temp_left"])
assert any(v is not None for v in data["nozzle_target_temp_left"])
# --- Root cause: unbounded query when date params are missing ----------------
@pytest.mark.django_db
def test_api_without_params_is_time_bounded(logged_in_client, printer):
"""A bare API call must not scan the whole table — it defaults to 24h."""
_make_metrics(printer, 10, spacing_seconds=30) # inside 24h
old = PrinterMetrics.objects.create(
device=printer, timestamp=timezone.now() - timedelta(days=30), nozzle_temp=100
)
resp = logged_in_client.get(reverse("bambu_run:printer_api"))
data = resp.json()
assert len(data["timestamps"]) == 10
assert old.timestamp.isoformat() not in data["timestamps_iso"]
@pytest.mark.django_db
def test_api_with_only_start_date_is_time_bounded(logged_in_client, printer):
"""Partial params must not drop the upper bound and scan forever."""
_make_metrics(printer, 5)
today = timezone.localtime().date()
resp = logged_in_client.get(
reverse("bambu_run:printer_api"), {"start_date": str(today - timedelta(days=1))}
)
assert resp.status_code == 200
assert len(resp.json()["timestamps"]) == 5
@pytest.mark.django_db
def test_api_downsamples_above_max_chart_points(logged_in_client, printer, monkeypatch):
monkeypatch.setattr("bambu_run.views._MAX_CHART_POINTS", 10)
_make_metrics(printer, 40, snapshots_per_metric=1, spacing_seconds=30)
today = timezone.localtime().date()
resp = logged_in_client.get(
reverse("bambu_run:printer_api"),
{
"start_date": str(today - timedelta(days=1)),
"end_date": str(today),
"start_time": "00:00",
"end_time": "23:59",
},
)
assert 0 < len(resp.json()["timestamps"]) <= 10
# --- Root cause 2: the dashboard render -------------------------------------
@pytest.mark.django_db
def test_dashboard_query_count_is_independent_of_row_count(logged_in_client, printer):
url = reverse("bambu_run:printer_dashboard")
_make_metrics(printer, 10)
few = _count_queries(logged_in_client, url)
_make_metrics(printer, 190)
many = _count_queries(logged_in_client, url)
assert few == many, f"query count scales with rows: {few} -> {many}"
@pytest.mark.django_db
def test_dashboard_downsamples_chart_payload(logged_in_client, printer, monkeypatch):
"""The dashboard inlines its JSON into the HTML, so it must sample like the API."""
monkeypatch.setattr("bambu_run.views._MAX_CHART_POINTS", 10)
_make_metrics(printer, 60, snapshots_per_metric=1)
resp = logged_in_client.get(reverse("bambu_run:printer_dashboard"))
payload = json.loads(resp.context["printer_data_json"])
assert 0 < len(payload["timestamps"]) <= 10
@pytest.mark.django_db
def test_dashboard_stats_use_the_newest_metric(logged_in_client, printer):
"""Sampling must never drop the latest reading — the stat cards depend on it."""
import zoneinfo
from bambu_run.conf import app_settings
_make_metrics(printer, 20)
newest = PrinterMetrics.objects.create(
device=printer, timestamp=timezone.now(), nozzle_temp=242, gcode_state="RUNNING"
)
resp = logged_in_client.get(reverse("bambu_run:printer_dashboard"))
assert resp.context["stats"]["nozzle_temp"] == pytest.approx(242)
assert resp.context["stats"]["timestamp"] == newest.timestamp.astimezone(
zoneinfo.ZoneInfo(app_settings.TIMEZONE)
).strftime("%Y-%m-%d %H:%M:%S")
@pytest.mark.django_db
def test_dashboard_filament_timeline_aligns_with_timestamps(logged_in_client, printer):
"""remain_data must stay index-aligned with timestamps after sampling."""
_make_metrics(printer, 30, snapshots_per_metric=2)
resp = logged_in_client.get(reverse("bambu_run:printer_dashboard"))
payload = json.loads(resp.context["printer_data_json"])
n = len(payload["timestamps"])
assert payload["filament_timeline"]
for series in payload["filament_timeline"].values():
assert len(series["remain_data"]) == n

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")),
]