mirror of
https://github.com/RunLit/Bambu-Run.git
synced 2026-10-21 23:39:01 +01:00
Compare commits
53 Commits
v0.1.1
...
4cf7d0114d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cf7d0114d | ||
|
|
6c16e66add | ||
|
|
6f681f8083 | ||
|
|
fb1c3fb834 | ||
|
|
888ce452e4 | ||
|
|
86bab78a52 | ||
|
|
e23650eaa4 | ||
|
|
10034a2de4 | ||
|
|
6efa55f946 | ||
|
|
3656286006 | ||
|
|
212de0b06e | ||
|
|
0164a72598 | ||
|
|
1a28197eda | ||
|
|
31d0cb677e | ||
|
|
39e4638f6a | ||
|
|
e8d861f8a9 | ||
|
|
679c620fb2 | ||
|
|
df34ec8e3e | ||
|
|
8a498e933e | ||
|
|
fcd6561cbf | ||
|
|
77d2b31edb | ||
|
|
fcd038c562 | ||
|
|
911905610f | ||
|
|
b2a083fb94 | ||
|
|
1bbeef7a47 | ||
|
|
40ec163134 | ||
|
|
61cfd3f5a9 | ||
|
|
1e35c48be1 | ||
|
|
b16f4b3cac | ||
|
|
58400f16cf | ||
|
|
6f281a0cab | ||
|
|
4db0a7d728 | ||
|
|
c7ea0dd094 | ||
|
|
6f19560842 | ||
|
|
86619a807c | ||
|
|
1231dfafc9 | ||
|
|
2c7d6b8dba | ||
|
|
0a1da46d1f | ||
|
|
4c01b3d909 | ||
|
|
146d5af7aa | ||
|
|
34293ce81a | ||
|
|
af845e3490 | ||
|
|
6aab42b03e | ||
|
|
61d1f630d3 | ||
|
|
c032745eb5 | ||
|
|
2af3509010 | ||
|
|
dd57a963ac | ||
|
|
6fadccb527 | ||
|
|
fa90ef11b6 | ||
|
|
9a91b14593 | ||
|
|
0b07221827 | ||
|
|
46902d7ec0 | ||
|
|
5c56711c57 |
@@ -20,3 +20,4 @@ BAMBU_PASSWORD=your_password
|
||||
# DEBUG=True
|
||||
# DJANGO_SECRET_KEY=change-me-to-a-random-string
|
||||
# ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
# CSRF_TRUSTED_ORIGINS="https://bambu-run.example.com,http://bambu-run.example.com"
|
||||
|
||||
69
.github/workflows/bump-version.yml
vendored
Normal file
69
.github/workflows/bump-version.yml
vendored
Normal 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
27
.gitignore
vendored
Normal 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/
|
||||
12
Dockerfile
12
Dockerfile
@@ -22,23 +22,25 @@ RUN pip install --no-cache-dir bambu-lab-cloud-api --no-deps && \
|
||||
(d / 'INSTALLER').write_text('pip\n'); \
|
||||
(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 .
|
||||
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 . .
|
||||
RUN pip install --no-cache-dir --no-deps .
|
||||
|
||||
# Create data directory for SQLite
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Collect static files
|
||||
# Collect static files from the locally installed package
|
||||
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
|
||||
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||
|
||||
EXPOSE 8000
|
||||
EXPOSE 8808
|
||||
|
||||
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||
|
||||
77
README.md
77
README.md
@@ -20,8 +20,42 @@ It runs quietly in the background 24/7, capturing every print, filament change,
|
||||
|
||||
---
|
||||
|
||||
## What It Looks Like
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<p align="center"><strong>Live Dashboard</strong><br/>Real-time nozzle temps, bed temp, print progress, and chamber light.</p>
|
||||
<img src="https://github.com/user-attachments/assets/169ec3e5-62a9-4e96-afed-dc6ac2647aef" alt="Live Dashboard" width="100%"/>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<p align="center"><strong>Historical Charts</strong><br/>Filament remaining and nozzle temp over time, per printer.</p>
|
||||
<img src="https://github.com/user-attachments/assets/11913c62-254f-4ba6-8d93-d5c27a9406be" alt="Historical Charts" width="100%"/>
|
||||
</td>
|
||||
</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>
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [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)
|
||||
@@ -29,6 +63,7 @@ It runs quietly in the background 24/7, capturing every print, filament change,
|
||||
- [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)
|
||||
|
||||
---
|
||||
|
||||
@@ -262,3 +297,45 @@ Processing: PLA Basic.txt → type='PLA' sub_type='PLA Basic'
|
||||
- **Created** — new color entries added to the database
|
||||
- **Skipped (duplicate)** — already existed, not changed
|
||||
- **Skipped (no type)** — only shown if `--no-auto-create-filament-type` is used and the filament type isn't in the database yet
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from django.contrib import admin
|
||||
from .models import Printer, PrinterMetrics, Filament, FilamentType, FilamentSnapshot, PrintJob, FilamentUsage
|
||||
from .models import Printer, PrinterMetrics, Filament, FilamentType, FilamentSnapshot, PrintJob, FilamentUsage, BambuCloudTask, Hotend, HotendSnapshot
|
||||
|
||||
|
||||
@admin.register(Printer)
|
||||
@@ -105,3 +105,36 @@ class FilamentUsageAdmin(admin.ModelAdmin):
|
||||
list_display = ('print_job', 'filament', 'tray_id', 'consumed_percent', 'consumed_grams', 'is_primary')
|
||||
list_filter = ('is_primary', 'tray_id')
|
||||
readonly_fields = ('consumed_percent', 'consumed_grams')
|
||||
|
||||
|
||||
@admin.register(Hotend)
|
||||
class HotendAdmin(admin.ModelAdmin):
|
||||
list_display = ('printer', 'serial_number', 'nozzle_type', 'is_toolhead', 'slot_number', 'used_time_seconds', 'wear_percent', 'last_seen_at')
|
||||
list_filter = ('printer', 'is_toolhead', 'nozzle_type')
|
||||
search_fields = ('serial_number',)
|
||||
readonly_fields = ('last_seen_at', 'created_at')
|
||||
|
||||
|
||||
@admin.register(HotendSnapshot)
|
||||
class HotendSnapshotAdmin(admin.ModelAdmin):
|
||||
list_display = ('printer_metric', 'hotend', 'raw_id', 'used_time_seconds', 'wear_percent', 'timestamp')
|
||||
list_filter = ('hotend',)
|
||||
readonly_fields = ('printer_metric', 'hotend', 'raw_id', 'used_time_seconds', 'wear_percent', 'stat', 'timestamp')
|
||||
|
||||
|
||||
@admin.register(BambuCloudTask)
|
||||
class BambuCloudTaskAdmin(admin.ModelAdmin):
|
||||
list_display = ('task_id', 'design_title', 'plate_title', 'device_serial', 'cloud_status', 'weight_grams', 'cloud_start_time', 'synced_at')
|
||||
list_filter = ('cloud_status', 'use_ams', 'bed_type')
|
||||
search_fields = ('design_title', 'plate_title', 'device_serial', 'task_id')
|
||||
readonly_fields = ('task_id', 'synced_at', 'raw_data')
|
||||
date_hierarchy = 'cloud_start_time'
|
||||
|
||||
fieldsets = (
|
||||
('Identity', {'fields': ('task_id', 'design_id', 'design_title', 'plate_title', 'model_id', 'profile_id', 'plate_index')}),
|
||||
('Device & Print', {'fields': ('device_serial', 'cloud_status', 'bed_type', 'use_ams', 'print_mode')}),
|
||||
('Filament', {'fields': ('weight_grams', 'length_mm', 'ams_detail_mapping')}),
|
||||
('Times', {'fields': ('cloud_start_time', 'cloud_end_time', 'cost_time_seconds', 'synced_at')}),
|
||||
('Media', {'fields': ('cover_url',)}),
|
||||
('Raw', {'fields': ('raw_data',), 'classes': ('collapse',)}),
|
||||
)
|
||||
|
||||
121
bambu_run/bambu_cloud.py
Normal file
121
bambu_run/bambu_cloud.py
Normal 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}")
|
||||
@@ -51,5 +51,34 @@ class _Settings:
|
||||
def AUTO_CREATE_BRAND(self):
|
||||
return get_setting("BAMBU_RUN_AUTO_CREATE_BRAND", "Bambu Lab")
|
||||
|
||||
# MCP Server settings
|
||||
@property
|
||||
def MCP_API_KEY(self):
|
||||
return get_setting("BAMBU_RUN_MCP_API_KEY", None)
|
||||
|
||||
@property
|
||||
def MCP_HOST(self):
|
||||
return get_setting("BAMBU_RUN_MCP_HOST", "0.0.0.0")
|
||||
|
||||
@property
|
||||
def MCP_PORT(self):
|
||||
return get_setting("BAMBU_RUN_MCP_PORT", 8808)
|
||||
|
||||
# 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()
|
||||
|
||||
78
bambu_run/diagnostics.py
Normal file
78
bambu_run/diagnostics.py
Normal 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,
|
||||
}
|
||||
@@ -52,10 +52,10 @@ class FilamentForm(forms.ModelForm):
|
||||
model = Filament
|
||||
fields = [
|
||||
'tray_uuid', 'tag_uid', 'tag_id', 'created_by',
|
||||
'filament_type', 'type', 'sub_type', 'brand', 'color', 'color_hex',
|
||||
'filament_type', 'type', 'sub_type', 'brand', 'color', 'color_hex', 'is_transparent',
|
||||
'diameter', 'initial_weight_grams',
|
||||
'remaining_percent', 'remaining_weight_grams',
|
||||
'is_loaded_in_ams', 'current_tray_id',
|
||||
'is_loaded_in_ams', 'current_tray_id', 'ams_unit_id', 'ams_type',
|
||||
'purchase_date', 'purchase_price', 'supplier', 'notes'
|
||||
]
|
||||
widgets = {
|
||||
@@ -71,10 +71,10 @@ class FilamentForm(forms.ModelForm):
|
||||
}),
|
||||
'tag_id': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Optional - User-defined ID'}),
|
||||
'created_by': forms.Select(attrs={'class': 'form-select'}),
|
||||
'filament_type': forms.Select(attrs={'class': 'form-select'}),
|
||||
'type': forms.HiddenInput(),
|
||||
'sub_type': forms.HiddenInput(),
|
||||
'brand': forms.HiddenInput(),
|
||||
'filament_type': forms.Select(attrs={'class': 'form-select', 'id': 'id_filament_type'}),
|
||||
'type': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g., PLA, PETG, ABS'}),
|
||||
'sub_type': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g., PLA Basic (optional)'}),
|
||||
'brand': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g., Bambu Lab'}),
|
||||
'color': forms.Select(attrs={'class': 'form-select', 'id': 'id_color'}),
|
||||
'color_hex': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
@@ -85,8 +85,17 @@ class FilamentForm(forms.ModelForm):
|
||||
'initial_weight_grams': forms.NumberInput(attrs={'class': 'form-control', 'placeholder': '1000'}),
|
||||
'remaining_percent': forms.NumberInput(attrs={'class': 'form-control', 'min': '0', 'max': '100'}),
|
||||
'remaining_weight_grams': forms.NumberInput(attrs={'class': 'form-control', 'readonly': 'readonly'}),
|
||||
'is_transparent': forms.CheckboxInput(attrs={'class': 'form-check-input', 'id': 'id_is_transparent'}),
|
||||
'is_loaded_in_ams': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'current_tray_id': forms.NumberInput(attrs={'class': 'form-control', 'min': '0', 'max': '3'}),
|
||||
'current_tray_id': forms.NumberInput(attrs={
|
||||
'class': 'form-control', 'min': '0', 'max': '15',
|
||||
'placeholder': '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_price': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
|
||||
'supplier': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
@@ -105,6 +114,8 @@ class FilamentForm(forms.ModelForm):
|
||||
self.fields['type'].required = False
|
||||
self.fields['sub_type'].required = False
|
||||
self.fields['brand'].required = False
|
||||
self.fields['ams_unit_id'].required = False
|
||||
self.fields['ams_type'].required = False
|
||||
|
||||
self._populate_color_choices()
|
||||
|
||||
|
||||
@@ -13,8 +13,9 @@ import logging
|
||||
import os
|
||||
import ssl
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
@@ -26,6 +27,56 @@ from bambu_run.models import Printer, PrinterMetrics
|
||||
logger = logging.getLogger("bambu_run.collector")
|
||||
|
||||
|
||||
def resolve_printer_device(device_id: str, device_info: Optional[dict] = None) -> Printer:
|
||||
"""Find-or-create the Printer row for a Bambu cloud device, keyed by serial number.
|
||||
|
||||
`device_info` is one entry from BambuClient.get_devices() (keys: name,
|
||||
dev_product_name, dev_id, ...). Falls back to generic defaults when unavailable
|
||||
(e.g. local-only connections that never call get_devices()).
|
||||
"""
|
||||
device_info = device_info or {}
|
||||
name = device_info.get("name") or "Bambu Lab Printer"
|
||||
model = device_info.get("dev_product_name") or "Bambu Lab"
|
||||
|
||||
printer = Printer.objects.filter(serial_number=device_id).first()
|
||||
|
||||
if printer is None:
|
||||
# Upgrade path: a pre-multi-printer deployment has exactly one Printer row
|
||||
# with no serial number yet. Backfill it instead of creating a duplicate.
|
||||
# If there's more than one such row, we can't tell which one this device
|
||||
# used to be, so don't guess — create a fresh row instead.
|
||||
legacy_candidates = list(Printer.objects.filter(serial_number__isnull=True)[:2])
|
||||
if len(legacy_candidates) == 1:
|
||||
printer = legacy_candidates[0]
|
||||
printer.serial_number = device_id
|
||||
|
||||
if printer is None:
|
||||
printer = Printer(serial_number=device_id)
|
||||
|
||||
printer.name = name
|
||||
printer.model = model
|
||||
printer.manufacturer = "Bambu Lab"
|
||||
printer.is_active = True
|
||||
printer.save()
|
||||
return printer
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceSession:
|
||||
"""Per-printer mutable state for one bound device in a multi-printer collector run."""
|
||||
|
||||
device_id: str
|
||||
client: Any # BambuPrinter
|
||||
printer: Printer
|
||||
current_print_job: Optional[Any] = None
|
||||
last_gcode_state: Optional[str] = None
|
||||
last_subtask_name: Optional[str] = None
|
||||
trays_used: set = field(default_factory=set)
|
||||
error_count: int = 0
|
||||
success_count: int = 0
|
||||
mqtt_connect_errors: int = 0
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
MQTT Poll -> PrinterMetrics -> FilamentSnapshot -> Auto-Match -> Update Filament
|
||||
@@ -51,18 +102,11 @@ class Command(BaseCommand):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.printer_client = None
|
||||
self.printer_device = None
|
||||
self.sessions: Dict[str, DeviceSession] = {}
|
||||
self._token: Optional[str] = None
|
||||
self.verbose = False
|
||||
self.disable_ssl_verify = False
|
||||
self.error_count = 0
|
||||
self.success_count = 0
|
||||
self.mqtt_connect_errors = 0
|
||||
self.start_time = None
|
||||
self.current_print_job = None
|
||||
self.last_gcode_state = None
|
||||
self.last_subtask_name = None
|
||||
self.trays_used = set()
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.verbose = options["verbose"]
|
||||
@@ -100,18 +144,22 @@ class Command(BaseCommand):
|
||||
self._configure_logging()
|
||||
|
||||
try:
|
||||
self._initialize_printer()
|
||||
self._initialize_printers()
|
||||
except Exception as e:
|
||||
raise CommandError(f"Initialization failed: {e}")
|
||||
|
||||
self.start_time = timezone.now()
|
||||
logger.info(f"Bambu Run data collector started for printer: {self.printer_device.name}")
|
||||
printer_names = ", ".join(s.printer.name for s in self.sessions.values())
|
||||
logger.info(f"Bambu Run data collector started for {len(self.sessions)} printer(s): {printer_names}")
|
||||
logger.info(f"Collection interval: {interval} seconds")
|
||||
logger.info(f"Mode: {'Single run' if run_once else 'Continuous'}")
|
||||
|
||||
try:
|
||||
if run_once:
|
||||
self._collect_printer_data()
|
||||
import time as _time
|
||||
_time.sleep(5)
|
||||
for session in self.sessions.values():
|
||||
self._collect_printer_data(session)
|
||||
logger.info("Single collection completed successfully")
|
||||
else:
|
||||
self._run_continuous_loop(interval)
|
||||
@@ -122,6 +170,24 @@ class Command(BaseCommand):
|
||||
logger.exception(f"Fatal error in main loop: {e}")
|
||||
raise CommandError(f"Runner failed: {e}")
|
||||
|
||||
def _request_full_status_when_ready(self, client, timeout: float = 20.0) -> None:
|
||||
"""Send pushall once the MQTT broker connection is confirmed.
|
||||
|
||||
BambuPrinter._connected is set True immediately after connect(blocking=False),
|
||||
before the broker handshake. Poll MQTTClient.connected (set in _on_connect)
|
||||
instead, so publish() won't raise "Not connected to broker".
|
||||
"""
|
||||
import time as _time
|
||||
deadline = _time.time() + timeout
|
||||
while _time.time() < deadline:
|
||||
mqtt_client = getattr(client, "_mqtt", None)
|
||||
if mqtt_client is not None and getattr(mqtt_client, "connected", False):
|
||||
client._mqtt.request_full_status()
|
||||
logger.info("Sent MQTT pushall request")
|
||||
return
|
||||
_time.sleep(0.5)
|
||||
logger.warning("MQTT broker connection not confirmed within %.1fs; skipping pushall", timeout)
|
||||
|
||||
def _configure_logging(self):
|
||||
log_level = logging.DEBUG if self.verbose else logging.INFO
|
||||
logger.setLevel(log_level)
|
||||
@@ -135,7 +201,9 @@ class Command(BaseCommand):
|
||||
handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
|
||||
def _initialize_printer(self):
|
||||
def _initialize_printers(self):
|
||||
"""Authenticate once, discover every device bound to the account, and open
|
||||
one BambuPrinter (own MQTT thread) per device — all in this single process."""
|
||||
from bambu_run.mqtt_client import BambuPrinter
|
||||
|
||||
bambu_username = os.environ.get("BAMBU_USERNAME")
|
||||
@@ -149,25 +217,12 @@ class Command(BaseCommand):
|
||||
"environment variables must be set"
|
||||
)
|
||||
|
||||
logger.info("Connecting to Bambu Lab printer...")
|
||||
logger.info("Authenticating with Bambu Lab cloud...")
|
||||
try:
|
||||
if bambu_token:
|
||||
logger.info("Using saved BAMBU_TOKEN for authentication")
|
||||
self.printer_client = BambuPrinter(
|
||||
token=bambu_token, device_id=bambu_device_id
|
||||
auth = BambuPrinter(
|
||||
username=bambu_username, password=bambu_password, token=bambu_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)")
|
||||
|
||||
self._token = auth._ensure_token()
|
||||
except Exception as e:
|
||||
if "CERTIFICATE_VERIFY_FAILED" in str(e) or "SSL" in str(e):
|
||||
error_msg = (
|
||||
@@ -178,56 +233,62 @@ class Command(BaseCommand):
|
||||
"3. pip install --upgrade certifi\n"
|
||||
)
|
||||
raise CommandError(error_msg)
|
||||
raise CommandError(f"Failed to initialize printer client: {e}")
|
||||
raise CommandError(f"Failed to authenticate: {e}")
|
||||
|
||||
self.printer_device = self._ensure_printer_device_exists()
|
||||
logger.info(f"Initialized for printer device: {self.printer_device}")
|
||||
|
||||
def _ensure_printer_device_exists(self) -> Printer:
|
||||
device_infos = self._discover_devices(bambu_device_id)
|
||||
for device_id, device_info in device_infos.items():
|
||||
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
|
||||
|
||||
self._add_session(device_id, device_info)
|
||||
except Exception as e:
|
||||
logger.error(f"Error during device initialization: {e}")
|
||||
logger.error(f"Failed to initialize printer {device_id}: {e}")
|
||||
|
||||
if not self.sessions:
|
||||
raise CommandError("No printer sessions could be initialized")
|
||||
|
||||
def _discover_devices(self, explicit_device_id: Optional[str]) -> Dict[str, dict]:
|
||||
"""Return {device_id: device_info} for every printer to monitor.
|
||||
|
||||
device_info comes from BambuClient.get_devices() (name, dev_product_name,
|
||||
etc.) — empty dict when explicitly pinned to one device via BAMBU_DEVICE_ID
|
||||
and the cloud listing can't be reached.
|
||||
"""
|
||||
from bambu_run.mqtt_client import BambuClient
|
||||
|
||||
try:
|
||||
device = Printer.objects.filter(is_active=True).first()
|
||||
if device:
|
||||
logger.warning(f"Using existing device record from DB: {device}")
|
||||
return device
|
||||
else:
|
||||
raise CommandError(
|
||||
"No printer device found in database and initialization failed."
|
||||
)
|
||||
except Printer.DoesNotExist:
|
||||
raise CommandError("Failed to create or retrieve printer device.")
|
||||
cloud = BambuClient(token=self._token)
|
||||
devices = cloud.get_devices()
|
||||
except Exception as e:
|
||||
if explicit_device_id:
|
||||
logger.warning(f"Could not list account devices ({e}); using BAMBU_DEVICE_ID only")
|
||||
return {explicit_device_id: {}}
|
||||
raise
|
||||
|
||||
device_infos = {d.get("dev_id"): d for d in devices if d.get("dev_id")}
|
||||
|
||||
if explicit_device_id:
|
||||
return {explicit_device_id: device_infos.get(explicit_device_id, {})}
|
||||
|
||||
if not device_infos:
|
||||
raise CommandError("No devices found on this account")
|
||||
|
||||
return device_infos
|
||||
|
||||
def _add_session(self, device_id: str, device_info: dict) -> "DeviceSession":
|
||||
from bambu_run.mqtt_client import BambuPrinter
|
||||
|
||||
logger.info(f"Connecting to printer {device_id} ({device_info.get('name', 'unknown')})...")
|
||||
client = BambuPrinter(token=self._token, device_id=device_id)
|
||||
client.connect(blocking=False)
|
||||
try:
|
||||
self._request_full_status_when_ready(client)
|
||||
except Exception as e:
|
||||
logger.warning("pushall request skipped (non-fatal): %s", e)
|
||||
|
||||
printer = resolve_printer_device(device_id, device_info)
|
||||
session = DeviceSession(device_id=device_id, client=client, printer=printer)
|
||||
self.sessions[device_id] = session
|
||||
logger.info(f"Initialized session for printer: {printer}")
|
||||
return session
|
||||
|
||||
def _run_continuous_loop(self, interval: int):
|
||||
iteration = 0
|
||||
@@ -238,7 +299,8 @@ class Command(BaseCommand):
|
||||
if self.verbose:
|
||||
logger.debug(f"=== Iteration {iteration} ===")
|
||||
|
||||
self._collect_printer_data()
|
||||
for session in list(self.sessions.values()):
|
||||
self._collect_printer_data(session)
|
||||
|
||||
elapsed = time.time() - loop_start
|
||||
sleep_time = max(0, interval - elapsed)
|
||||
@@ -248,9 +310,28 @@ class Command(BaseCommand):
|
||||
|
||||
if iteration % 100 == 0:
|
||||
self._print_statistics()
|
||||
self._refresh_devices()
|
||||
|
||||
time.sleep(sleep_time)
|
||||
|
||||
def _refresh_devices(self):
|
||||
"""Pick up printers added to the account without restarting the process."""
|
||||
if os.environ.get("BAMBU_DEVICE_ID"):
|
||||
return # pinned to a single explicit device — nothing to discover
|
||||
try:
|
||||
device_infos = self._discover_devices(None)
|
||||
except Exception as e:
|
||||
logger.warning(f"Device refresh skipped (non-fatal): {e}")
|
||||
return
|
||||
|
||||
for device_id, device_info in device_infos.items():
|
||||
if device_id not in self.sessions:
|
||||
logger.info(f"New printer detected on account: {device_id}")
|
||||
try:
|
||||
self._add_session(device_id, device_info)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize newly-detected printer {device_id}: {e}")
|
||||
|
||||
def _convert_mqtt_color(self, mqtt_color):
|
||||
if not mqtt_color:
|
||||
return None
|
||||
@@ -316,7 +397,7 @@ class Command(BaseCommand):
|
||||
|
||||
def _auto_create_filament(self, tray_data):
|
||||
from bambu_run.models import Filament, FilamentType
|
||||
from bambu_run.utils import strip_color_padding, match_filament_color
|
||||
from bambu_run.utils import strip_color_padding, match_filament_color, is_mqtt_color_transparent
|
||||
|
||||
tray_uuid = tray_data.get('tray_uuid')
|
||||
tag_uid = tray_data.get('tag_uid')
|
||||
@@ -329,6 +410,7 @@ class Command(BaseCommand):
|
||||
|
||||
default_brand = app_settings.AUTO_CREATE_BRAND
|
||||
|
||||
transparent = is_mqtt_color_transparent(mqtt_color)
|
||||
color_code = strip_color_padding(mqtt_color)
|
||||
color_hex = f"#{color_code}" if color_code else None
|
||||
|
||||
@@ -341,6 +423,7 @@ class Command(BaseCommand):
|
||||
|
||||
if filament_color:
|
||||
color_name = filament_color.color_name
|
||||
transparent = transparent or filament_color.is_transparent
|
||||
if self.verbose:
|
||||
logger.info(f"Matched color from database: {color_name} (#{color_code})")
|
||||
else:
|
||||
@@ -368,12 +451,15 @@ class Command(BaseCommand):
|
||||
brand=default_brand,
|
||||
color=color_name,
|
||||
color_hex=color_hex,
|
||||
is_transparent=transparent,
|
||||
diameter=diameter,
|
||||
initial_weight_grams=initial_weight,
|
||||
remaining_percent=remain_percent,
|
||||
created_by='Auto Detection',
|
||||
is_loaded_in_ams=True,
|
||||
current_tray_id=tray_data.get('tray_id'),
|
||||
ams_unit_id=tray_data.get('ams_unit_id'),
|
||||
ams_type=tray_data.get('ams_type', '') or '',
|
||||
last_loaded_date=timezone.now(),
|
||||
)
|
||||
|
||||
@@ -387,9 +473,13 @@ class Command(BaseCommand):
|
||||
|
||||
return filament
|
||||
|
||||
def _update_filament_status(self, filament, tray_id, remain_percent):
|
||||
def _update_filament_status(self, filament, tray_id, remain_percent, tray_data=None):
|
||||
from bambu_run.models import Filament
|
||||
|
||||
tray_data = tray_data or {}
|
||||
ams_unit_id = tray_data.get('ams_unit_id')
|
||||
ams_type_label = tray_data.get('ams_type', '') or ''
|
||||
|
||||
if filament.remaining_percent != remain_percent:
|
||||
filament.remaining_percent = remain_percent
|
||||
filament.update_remaining_weight()
|
||||
@@ -397,10 +487,19 @@ class Command(BaseCommand):
|
||||
if self.verbose:
|
||||
logger.debug(f"Updated filament {filament}: {remain_percent}%")
|
||||
|
||||
if not filament.is_loaded_in_ams or filament.current_tray_id != tray_id:
|
||||
previous_filament = Filament.objects.filter(
|
||||
location_changed = (
|
||||
not filament.is_loaded_in_ams
|
||||
or filament.current_tray_id != tray_id
|
||||
or (ams_unit_id is not None and filament.ams_unit_id != ams_unit_id)
|
||||
)
|
||||
if location_changed:
|
||||
# Unload anything previously occupying THIS exact (unit, tray) slot.
|
||||
unload_qs = Filament.objects.filter(
|
||||
is_loaded_in_ams=True, current_tray_id=tray_id
|
||||
).exclude(id=filament.id).first()
|
||||
).exclude(id=filament.id)
|
||||
if ams_unit_id is not None:
|
||||
unload_qs = unload_qs.filter(ams_unit_id=ams_unit_id)
|
||||
previous_filament = unload_qs.first()
|
||||
|
||||
if previous_filament:
|
||||
previous_filament.is_loaded_in_ams = False
|
||||
@@ -408,14 +507,21 @@ class Command(BaseCommand):
|
||||
previous_filament.save()
|
||||
logger.info(
|
||||
f"Auto-unloaded {previous_filament} from Tray {tray_id} "
|
||||
f"(replaced by {filament.brand} {filament.type} - {filament.color})"
|
||||
f"(unit {ams_unit_id}; replaced by {filament.brand} {filament.type} - {filament.color})"
|
||||
)
|
||||
|
||||
filament.is_loaded_in_ams = True
|
||||
filament.current_tray_id = tray_id
|
||||
if ams_unit_id is not None:
|
||||
filament.ams_unit_id = ams_unit_id
|
||||
if ams_type_label:
|
||||
filament.ams_type = ams_type_label
|
||||
filament.last_loaded_date = timezone.now()
|
||||
if self.verbose:
|
||||
logger.debug(f"Updated filament location: Tray {tray_id}")
|
||||
logger.debug(f"Updated filament location: unit={ams_unit_id} tray={tray_id}")
|
||||
elif ams_type_label and filament.ams_type != ams_type_label:
|
||||
# Same slot but ams_type was previously unknown — fill it in.
|
||||
filament.ams_type = ams_type_label
|
||||
|
||||
filament.save()
|
||||
|
||||
@@ -436,15 +542,20 @@ class Command(BaseCommand):
|
||||
if filament:
|
||||
remain_percent = tray_data.get('remain_percent')
|
||||
if remain_percent is not None:
|
||||
self._update_filament_status(filament, tray_id, remain_percent)
|
||||
self._update_filament_status(filament, tray_id, remain_percent, tray_data)
|
||||
|
||||
unit_id = str(int(tray_id) // 4) if tray_id.isdigit() else None
|
||||
unit_data = ams_units.get(unit_id, {})
|
||||
# Locate the AMS unit this tray belongs to. Use the unit_id supplied
|
||||
# by the snapshot directly (matches MQTT ams[i].id, including 128 for AMS HT)
|
||||
# — the legacy `tray_id // 4` math breaks for AMS HT.
|
||||
unit_id_int = tray_data.get('ams_unit_id')
|
||||
unit_data = ams_units.get(str(unit_id_int)) if unit_id_int is not None else {}
|
||||
|
||||
FilamentSnapshot.objects.create(
|
||||
printer_metric=printer_metric,
|
||||
filament=filament,
|
||||
tray_id=tray_id,
|
||||
ams_unit_id=unit_id_int,
|
||||
ams_type=tray_data.get('ams_type', '') or '',
|
||||
slot_name=tray_data.get('slot'),
|
||||
type=tray_data.get('type'),
|
||||
sub_type=tray_data.get('sub_type'),
|
||||
@@ -460,122 +571,172 @@ class Command(BaseCommand):
|
||||
match_method=match_method
|
||||
)
|
||||
|
||||
def _track_print_job(self, metric, snapshot):
|
||||
from bambu_run.models import PrintJob, FilamentUsage
|
||||
def _update_hotends(self, printer, printer_metric, hotends_data):
|
||||
from bambu_run.models import Hotend, HotendSnapshot
|
||||
|
||||
for h in hotends_data:
|
||||
if h.get("is_empty"):
|
||||
continue
|
||||
|
||||
hotend, _ = Hotend.objects.update_or_create(
|
||||
printer=printer,
|
||||
serial_number=h.get("serial_number"),
|
||||
defaults={
|
||||
"raw_id": h.get("raw_id", 0),
|
||||
"nozzle_type": h.get("nozzle_type", ""),
|
||||
"diameter": self._to_decimal(h.get("diameter")),
|
||||
"slot_number": h.get("slot_number"),
|
||||
"is_toolhead": bool(h.get("is_toolhead")),
|
||||
"last_filament_profile_id": h.get("fila_id", ""),
|
||||
"last_color": h.get("color") or "",
|
||||
"used_time_seconds": h.get("used_time_seconds", 0),
|
||||
"wear_percent": h.get("wear_percent", 0),
|
||||
},
|
||||
)
|
||||
|
||||
HotendSnapshot.objects.create(
|
||||
printer_metric=printer_metric,
|
||||
hotend=hotend,
|
||||
raw_id=h.get("raw_id", 0),
|
||||
used_time_seconds=h.get("used_time_seconds", 0),
|
||||
wear_percent=h.get("wear_percent", 0),
|
||||
stat=h.get("stat"),
|
||||
)
|
||||
|
||||
def _track_print_job(self, session, metric, snapshot):
|
||||
from bambu_run.models import PrintJob
|
||||
|
||||
gcode_state = snapshot.get('gcode_state')
|
||||
subtask_name = snapshot.get('subtask_name')
|
||||
|
||||
if self._is_print_starting(gcode_state, subtask_name):
|
||||
if self.current_print_job:
|
||||
self._finalize_print_job(metric, snapshot)
|
||||
if self._is_print_starting(session, gcode_state, subtask_name):
|
||||
if session.current_print_job:
|
||||
self._finalize_print_job(session, metric, snapshot)
|
||||
|
||||
self.current_print_job = PrintJob.objects.create(
|
||||
device=self.printer_device,
|
||||
raw_task_id = snapshot.get('task_id')
|
||||
session.current_print_job = PrintJob.objects.create(
|
||||
device=session.printer,
|
||||
project_name=subtask_name,
|
||||
gcode_file=snapshot.get('gcode_file'),
|
||||
start_time=metric.timestamp,
|
||||
start_metric=metric,
|
||||
total_layers=snapshot.get('total_layer_num'),
|
||||
completion_percent=snapshot.get('print_percent', 0)
|
||||
completion_percent=snapshot.get('print_percent', 0),
|
||||
cloud_task_id_raw=int(raw_task_id) if raw_task_id else None,
|
||||
)
|
||||
self.trays_used = set()
|
||||
logger.info(f"Print job started: {subtask_name}")
|
||||
session.trays_used = set()
|
||||
logger.info(f"[{session.device_id}] Print job started: {subtask_name}")
|
||||
|
||||
if self.current_print_job:
|
||||
if session.current_print_job:
|
||||
tray_now = snapshot.get('tray_now', '')
|
||||
if tray_now not in (None, '', '255'):
|
||||
try:
|
||||
tray_id = int(tray_now)
|
||||
if 0 <= tray_id <= 15:
|
||||
self.trays_used.add(tray_id)
|
||||
session.trays_used.add(tray_id)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if self._is_print_ending(gcode_state) and self.current_print_job:
|
||||
self._finalize_print_job(metric, snapshot)
|
||||
if self._is_print_ending(session, gcode_state) and session.current_print_job:
|
||||
self._finalize_print_job(session, metric, snapshot)
|
||||
|
||||
self.last_gcode_state = gcode_state
|
||||
self.last_subtask_name = subtask_name
|
||||
session.last_gcode_state = gcode_state
|
||||
session.last_subtask_name = subtask_name
|
||||
|
||||
def _is_print_starting(self, gcode_state, subtask_name):
|
||||
def _is_print_starting(self, session, gcode_state, subtask_name):
|
||||
is_printing = gcode_state not in ['FINISH', 'IDLE', 'FAILED', None, '']
|
||||
has_new_job = subtask_name and subtask_name != self.last_subtask_name
|
||||
has_new_job = subtask_name and subtask_name != session.last_subtask_name
|
||||
return is_printing and has_new_job
|
||||
|
||||
def _is_print_ending(self, gcode_state):
|
||||
def _is_print_ending(self, session, gcode_state):
|
||||
ending_states = ['FINISH', 'FAILED']
|
||||
return gcode_state in ending_states and self.last_gcode_state not in ending_states
|
||||
return gcode_state in ending_states and session.last_gcode_state not in ending_states
|
||||
|
||||
def _finalize_print_job(self, metric, snapshot):
|
||||
def _finalize_print_job(self, session, metric, snapshot):
|
||||
from bambu_run.models import FilamentUsage
|
||||
|
||||
self.current_print_job.end_time = metric.timestamp
|
||||
self.current_print_job.end_metric = metric
|
||||
self.current_print_job.final_status = snapshot.get('gcode_state')
|
||||
self.current_print_job.completion_percent = snapshot.get('print_percent', 0)
|
||||
self.current_print_job.calculate_duration()
|
||||
self.current_print_job.save()
|
||||
job = session.current_print_job
|
||||
job.end_time = metric.timestamp
|
||||
job.end_metric = metric
|
||||
job.final_status = snapshot.get('gcode_state')
|
||||
job.completion_percent = snapshot.get('print_percent', 0)
|
||||
job.calculate_duration()
|
||||
job.save()
|
||||
|
||||
start_metric = self.current_print_job.start_metric
|
||||
try:
|
||||
from bambu_run.bambu_cloud import fetch_and_upsert_task
|
||||
fetch_and_upsert_task(session.client._client, job)
|
||||
except Exception as e:
|
||||
logger.warning(f"Cloud task sync skipped (non-fatal): {e}")
|
||||
|
||||
start_metric = job.start_metric
|
||||
if not start_metric:
|
||||
logger.warning(f"No start_metric for job {self.current_print_job.id}, skipping filament usage")
|
||||
elif not self.trays_used:
|
||||
logger.warning(f"No trays tracked for job {self.current_print_job.project_name}, skipping filament usage")
|
||||
logger.warning(f"No start_metric for job {job.id}, skipping filament usage")
|
||||
elif not session.trays_used:
|
||||
logger.warning(f"No trays tracked for job {job.project_name}, skipping filament usage")
|
||||
else:
|
||||
for tray_id in self.trays_used:
|
||||
start_snap = start_metric.filament_snapshots.filter(
|
||||
# A bare tray_id (from `tray_now`) doesn't identify which physical AMS
|
||||
# unit was active when multiple units share the same slot numbering —
|
||||
# so create one usage row per (unit, tray) that had a tracked filament
|
||||
# loaded at job start, rather than guessing a single "correct" unit.
|
||||
created_usages = []
|
||||
for tray_id in session.trays_used:
|
||||
start_snaps = start_metric.filament_snapshots.filter(
|
||||
tray_id=tray_id, filament__isnull=False
|
||||
).first()
|
||||
if not start_snap:
|
||||
continue
|
||||
|
||||
)
|
||||
for start_snap in start_snaps:
|
||||
end_snap = metric.filament_snapshots.filter(
|
||||
filament=start_snap.filament, tray_id=tray_id
|
||||
filament=start_snap.filament,
|
||||
tray_id=tray_id,
|
||||
ams_unit_id=start_snap.ams_unit_id,
|
||||
).first()
|
||||
|
||||
usage = FilamentUsage.objects.create(
|
||||
print_job=self.current_print_job,
|
||||
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,
|
||||
is_primary=(len(self.trays_used) == 1),
|
||||
)
|
||||
usage.calculate_consumed()
|
||||
created_usages.append(usage)
|
||||
|
||||
for usage in created_usages:
|
||||
usage.is_primary = len(created_usages) == 1
|
||||
usage.save()
|
||||
|
||||
if self.verbose:
|
||||
logger.debug(
|
||||
f"Filament usage for {start_snap.filament} (tray {tray_id}): "
|
||||
f"Filament usage for {usage.filament} (unit {usage.ams_unit_id}, tray {usage.tray_id}): "
|
||||
f"{usage.starting_percent}% -> {usage.ending_percent}%, consumed {usage.consumed_percent}%"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Print job finished: {self.current_print_job.project_name} "
|
||||
f"({self.current_print_job.final_status}) - Duration: {self.current_print_job.duration_minutes} min, "
|
||||
f"Trays used: {sorted(self.trays_used) if self.trays_used else 'none tracked'}"
|
||||
f"[{session.device_id}] Print job finished: {job.project_name} "
|
||||
f"({job.final_status}) - Duration: {job.duration_minutes} min, "
|
||||
f"Trays used: {sorted(session.trays_used) if session.trays_used else 'none tracked'}"
|
||||
)
|
||||
|
||||
self.current_print_job = None
|
||||
self.trays_used = set()
|
||||
session.current_print_job = None
|
||||
session.trays_used = set()
|
||||
|
||||
def _collect_printer_data(self):
|
||||
def _collect_printer_data(self, session: "DeviceSession"):
|
||||
try:
|
||||
snapshot = self.printer_client.get_snapshot()
|
||||
snapshot = session.client.get_snapshot()
|
||||
|
||||
if snapshot is None:
|
||||
self.mqtt_connect_errors += 1
|
||||
if self.mqtt_connect_errors <= 5 or self.verbose:
|
||||
session.mqtt_connect_errors += 1
|
||||
if session.mqtt_connect_errors <= 5 or self.verbose:
|
||||
logger.warning(
|
||||
f"MQTT not connected yet or no data available "
|
||||
f"(attempt {self.mqtt_connect_errors})"
|
||||
f"[{session.device_id}] MQTT not connected yet or no data available "
|
||||
f"(attempt {session.mqtt_connect_errors})"
|
||||
)
|
||||
return
|
||||
|
||||
with transaction.atomic():
|
||||
metric = PrinterMetrics.objects.create(
|
||||
device=self.printer_device,
|
||||
device=session.printer,
|
||||
timestamp=timezone.now(),
|
||||
nozzle_temp=self._to_decimal(snapshot.get("nozzle_temp")),
|
||||
nozzle_target_temp=self._to_decimal(snapshot.get("nozzle_target_temp")),
|
||||
@@ -584,6 +745,10 @@ class Command(BaseCommand):
|
||||
chamber_temp=self._to_decimal(snapshot.get("chamber_temp")),
|
||||
nozzle_diameter=self._to_decimal(snapshot.get("nozzle_diameter")),
|
||||
nozzle_type=snapshot.get("nozzle_type"),
|
||||
nozzle_temp_left=self._to_decimal(snapshot.get("nozzle_temp_left")),
|
||||
nozzle_target_temp_left=self._to_decimal(snapshot.get("nozzle_target_temp_left")),
|
||||
nozzle_diameter_left=self._to_decimal(snapshot.get("nozzle_diameter_left")),
|
||||
nozzle_type_left=snapshot.get("nozzle_type_left"),
|
||||
gcode_state=snapshot.get("gcode_state"),
|
||||
print_type=snapshot.get("print_type"),
|
||||
print_percent=snapshot.get("print_percent"),
|
||||
@@ -623,27 +788,33 @@ class Command(BaseCommand):
|
||||
ams_units=snapshot.get("ams_units", []),
|
||||
external_spool=snapshot.get("external_spool", {}),
|
||||
lights_report=snapshot.get("lights_report", []),
|
||||
vortek_raw=snapshot.get("vortek_raw", {}),
|
||||
nozzle_info=snapshot.get("hotends", []),
|
||||
)
|
||||
|
||||
filaments_data = snapshot.get('filaments', [])
|
||||
if filaments_data:
|
||||
self._create_filament_snapshots(metric, filaments_data, snapshot)
|
||||
|
||||
self._track_print_job(metric, snapshot)
|
||||
hotends_data = snapshot.get('hotends', [])
|
||||
if hotends_data:
|
||||
self._update_hotends(session.printer, metric, hotends_data)
|
||||
|
||||
self.success_count += 1
|
||||
self._track_print_job(session, metric, snapshot)
|
||||
|
||||
session.success_count += 1
|
||||
|
||||
if self.verbose:
|
||||
logger.debug(
|
||||
f"Printer Metrics: Nozzle={snapshot.get('nozzle_temp')}C, "
|
||||
f"[{session.device_id}] Printer Metrics: Nozzle={snapshot.get('nozzle_temp')}C, "
|
||||
f"Bed={snapshot.get('bed_temp')}C, "
|
||||
f"Progress={snapshot.get('print_percent')}%, "
|
||||
f"State={snapshot.get('gcode_state')}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.error_count += 1
|
||||
logger.error(f"Error collecting printer data (total errors: {self.error_count}): {e}")
|
||||
session.error_count += 1
|
||||
logger.error(f"[{session.device_id}] Error collecting printer data (total errors: {session.error_count}): {e}")
|
||||
if self.verbose:
|
||||
logger.exception("Detailed traceback:")
|
||||
|
||||
@@ -658,16 +829,20 @@ class Command(BaseCommand):
|
||||
def _print_statistics(self):
|
||||
if self.start_time:
|
||||
runtime = timezone.now() - self.start_time
|
||||
total_collections = self.success_count + self.error_count
|
||||
success_count = sum(s.success_count for s in self.sessions.values())
|
||||
error_count = sum(s.error_count for s in self.sessions.values())
|
||||
mqtt_connect_errors = sum(s.mqtt_connect_errors for s in self.sessions.values())
|
||||
total_collections = success_count + error_count
|
||||
success_rate = (
|
||||
(self.success_count / total_collections * 100)
|
||||
(success_count / total_collections * 100)
|
||||
if total_collections > 0
|
||||
else 0
|
||||
)
|
||||
|
||||
logger.info("=== Statistics ===")
|
||||
logger.info(f"Runtime: {runtime}")
|
||||
logger.info(f"Successful collections: {self.success_count}")
|
||||
logger.info(f"Failed collections: {self.error_count}")
|
||||
logger.info(f"MQTT connection warnings: {self.mqtt_connect_errors}")
|
||||
logger.info(f"Printers tracked: {len(self.sessions)}")
|
||||
logger.info(f"Successful collections: {success_count}")
|
||||
logger.info(f"Failed collections: {error_count}")
|
||||
logger.info(f"MQTT connection warnings: {mqtt_connect_errors}")
|
||||
logger.info(f"Success rate: {success_rate:.1f}%")
|
||||
|
||||
128
bambu_run/management/commands/bambu_diagnose.py
Normal file
128
bambu_run/management/commands/bambu_diagnose.py
Normal 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)
|
||||
@@ -371,6 +371,11 @@ class Command(BaseCommand):
|
||||
return "created"
|
||||
return "no_type"
|
||||
|
||||
# ── Transparent detection ────────────────────────────────────────────
|
||||
# "Translucent" (no colour qualifier) + #000000 = clear/transparent filament.
|
||||
# Bambu Lab AMS reports these as 00000000 (alpha=00).
|
||||
is_transparent = color_name.strip().lower() == "translucent" and hex_code == "000000"
|
||||
|
||||
# ── Duplicate check ──────────────────────────────────────────────────
|
||||
# All five fields must match to be considered a duplicate:
|
||||
# color_code (exact), color_name (case-insensitive), brand,
|
||||
@@ -388,9 +393,10 @@ class Command(BaseCommand):
|
||||
return "duplicate"
|
||||
|
||||
if dry_run:
|
||||
transparent_note = " [transparent]" if is_transparent else ""
|
||||
self.stdout.write(
|
||||
f" [dry-run] Would create: {color_name!r} #{hex_code} "
|
||||
f"({filament_type} / {filament_sub_type})"
|
||||
f"({filament_type} / {filament_sub_type}){transparent_note}"
|
||||
)
|
||||
return "created"
|
||||
|
||||
@@ -404,6 +410,7 @@ class Command(BaseCommand):
|
||||
filament_type=filament_type,
|
||||
filament_sub_type=filament_sub_type,
|
||||
brand=BRAND,
|
||||
is_transparent=is_transparent,
|
||||
)
|
||||
self.stdout.write(
|
||||
f" + {color_name!r} #{hex_code} ({filament_type} / {filament_sub_type})"
|
||||
|
||||
110
bambu_run/management/commands/bambu_mcp_server.py
Normal file
110
bambu_run/management/commands/bambu_mcp_server.py
Normal 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)
|
||||
140
bambu_run/management/commands/bambu_sync_cloud.py
Normal file
140
bambu_run/management/commands/bambu_sync_cloud.py
Normal 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"))
|
||||
1
bambu_run/mcp/__init__.py
Normal file
1
bambu_run/mcp/__init__.py
Normal 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
118
bambu_run/mcp/filaments.py
Normal 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
214
bambu_run/mcp/jobs.py
Normal 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
146
bambu_run/mcp/shapes.py
Normal 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
268
bambu_run/mcp/status.py
Normal 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
28
bambu_run/mcp/toolkit.py
Normal 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
|
||||
27
bambu_run/migrations/0002_filament_is_transparent.py
Normal file
27
bambu_run/migrations/0002_filament_is_transparent.py
Normal 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",
|
||||
),
|
||||
),
|
||||
]
|
||||
177
bambu_run/migrations/0003_cloud_task.py
Normal file
177
bambu_run/migrations/0003_cloud_task.py
Normal 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",
|
||||
),
|
||||
),
|
||||
]
|
||||
90
bambu_run/migrations/0004_h2c_dual_nozzle_and_ams_fields.py
Normal file
90
bambu_run/migrations/0004_h2c_dual_nozzle_and_ams_fields.py
Normal 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,
|
||||
),
|
||||
),
|
||||
]
|
||||
22
bambu_run/migrations/0005_printermetrics_vortek_raw.py
Normal file
22
bambu_run/migrations/0005_printermetrics_vortek_raw.py
Normal 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)",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
172
bambu_run/migrations/0007_hotend_hotendsnapshot.py
Normal file
172
bambu_run/migrations/0007_hotend_hotendsnapshot.py
Normal 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",
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
22
bambu_run/migrations/0008_printermetrics_nozzle_info.py
Normal file
22
bambu_run/migrations/0008_printermetrics_nozzle_info.py
Normal 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",
|
||||
),
|
||||
),
|
||||
]
|
||||
77
bambu_run/migrations/0009_printer_category.py
Normal file
77
bambu_run/migrations/0009_printer_category.py
Normal 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()),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -2,11 +2,64 @@ from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
# Bambu AMS model-code → human-readable type label.
|
||||
# Source: live H2C MQTT probe — `print.ams.ams[i].info` field.
|
||||
# Add new codes as they are observed (e.g. AMS Lite, future variants).
|
||||
AMS_INFO_TO_TYPE = {
|
||||
"1001": "AMS",
|
||||
"1003": "AMS 2 Pro",
|
||||
"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):
|
||||
"""Represents a Bambu Lab 3D printer device"""
|
||||
|
||||
CATEGORY_3D_PRINTER = "threed_printer"
|
||||
|
||||
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)")
|
||||
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(
|
||||
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)
|
||||
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:
|
||||
db_table = "infrastructure_device"
|
||||
verbose_name = "Printer"
|
||||
verbose_name_plural = "Printers"
|
||||
ordering = ["name"]
|
||||
base_manager_name = "all_objects"
|
||||
default_manager_name = "objects"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.model})"
|
||||
@@ -58,12 +119,32 @@ class PrinterMetrics(models.Model):
|
||||
max_digits=5, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
|
||||
# Nozzle info
|
||||
# Nozzle info — single-nozzle / right-side back-compat fields. On dual-nozzle
|
||||
# printers (H2C) these mirror the right extruder; the left extruder uses the
|
||||
# `_left` columns below.
|
||||
nozzle_diameter = models.DecimalField(
|
||||
max_digits=3, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
nozzle_type = models.CharField(max_length=50, null=True, blank=True)
|
||||
|
||||
# H2C dual-nozzle: left-side fields (NULL on single-nozzle printers).
|
||||
nozzle_temp_left = models.DecimalField(
|
||||
max_digits=5, decimal_places=2, null=True, blank=True,
|
||||
help_text="Left extruder current temperature (°C). H2C only."
|
||||
)
|
||||
nozzle_target_temp_left = models.DecimalField(
|
||||
max_digits=5, decimal_places=2, null=True, blank=True,
|
||||
help_text="Left extruder target temperature (°C). H2C only."
|
||||
)
|
||||
nozzle_diameter_left = models.DecimalField(
|
||||
max_digits=3, decimal_places=2, null=True, blank=True,
|
||||
help_text="Left nozzle diameter (mm). H2C only."
|
||||
)
|
||||
nozzle_type_left = models.CharField(
|
||||
max_length=50, null=True, blank=True,
|
||||
help_text="Left nozzle type (e.g. HS01-0.4). H2C only."
|
||||
)
|
||||
|
||||
# Print job status
|
||||
gcode_state = models.CharField(
|
||||
max_length=50, null=True, blank=True, help_text="FINISH, RUNNING, IDLE, etc."
|
||||
@@ -184,6 +265,23 @@ class PrinterMetrics(models.Model):
|
||||
default=list, help_text="Light status report [{node, mode}]"
|
||||
)
|
||||
|
||||
# Groundwork for H2C's Vortek nozzle-changer rack (6 swappable hotends + 1 fixed
|
||||
# left nozzle) — the full MQTT schema for per-slot state isn't confirmed yet, so
|
||||
# the raw `print.device` payload is captured here unfiltered to avoid losing data
|
||||
# ahead of proper per-slot modeling.
|
||||
vortek_raw = models.JSONField(
|
||||
default=dict, blank=True, help_text="Raw print.device MQTT payload (Vortek rack groundwork)"
|
||||
)
|
||||
|
||||
# Parsed device.nozzle.info[] from this poll, one dict per entry (mirrors
|
||||
# HotendInfo.to_dict()). Includes induction-chip hotends *and* non-inductive
|
||||
# nozzle positions (e.g. H2C's fixed left nozzle) that have no stable serial
|
||||
# number to key a Hotend registry row on — kept here so the dashboard can show
|
||||
# their readable type/diameter without claiming an identity/history we don't have.
|
||||
nozzle_info = models.JSONField(
|
||||
default=list, blank=True, help_text="Parsed per-poll nozzle/hotend info list"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "infrastructure_printer_metrics"
|
||||
verbose_name = "Printer Metric"
|
||||
@@ -259,6 +357,10 @@ class FilamentColor(models.Model):
|
||||
default='Bambu Lab',
|
||||
help_text="Manufacturer name"
|
||||
)
|
||||
is_transparent = models.BooleanField(
|
||||
default=False,
|
||||
help_text="True for clear/transparent filaments — display as checkerboard, not solid color"
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
@@ -329,6 +431,10 @@ class Filament(models.Model):
|
||||
max_length=7, null=True, blank=True,
|
||||
help_text="Color hex code for display (#RRGGBB)"
|
||||
)
|
||||
is_transparent = models.BooleanField(
|
||||
default=False,
|
||||
help_text="True for clear/transparent filaments — display as checkerboard, not solid color"
|
||||
)
|
||||
|
||||
# Physical properties
|
||||
diameter = models.DecimalField(
|
||||
@@ -357,7 +463,16 @@ class Filament(models.Model):
|
||||
)
|
||||
current_tray_id = models.IntegerField(
|
||||
null=True, blank=True,
|
||||
help_text="Which AMS slot (0-3) if loaded"
|
||||
help_text="Tray slot index within its AMS unit (0-3 for AMS/AMS 2 Pro, 0 for AMS HT)"
|
||||
)
|
||||
ams_unit_id = models.PositiveSmallIntegerField(
|
||||
null=True, blank=True, db_index=True,
|
||||
help_text="Which physical AMS unit this spool is loaded in (matches MQTT ams[i].id; 128 = AMS HT)"
|
||||
)
|
||||
ams_type = models.CharField(
|
||||
max_length=32, blank=True, default="",
|
||||
choices=AMS_TYPE_CHOICES,
|
||||
help_text="Type of the AMS unit this spool is loaded in (AMS / AMS 2 Pro / AMS HT)"
|
||||
)
|
||||
last_loaded_date = models.DateTimeField(
|
||||
null=True, blank=True,
|
||||
@@ -426,6 +541,15 @@ class FilamentSnapshot(models.Model):
|
||||
max_length=20, null=True, blank=True,
|
||||
help_text="Slot identifier like A00-W1"
|
||||
)
|
||||
ams_unit_id = models.PositiveSmallIntegerField(
|
||||
null=True, blank=True, db_index=True,
|
||||
help_text="Which physical AMS unit this tray belongs to (matches MQTT ams[i].id; 128 = AMS HT)"
|
||||
)
|
||||
ams_type = models.CharField(
|
||||
max_length=32, blank=True, default="",
|
||||
choices=AMS_TYPE_CHOICES,
|
||||
help_text="Type of the AMS unit this tray belongs to (AMS / AMS 2 Pro / AMS HT)"
|
||||
)
|
||||
|
||||
type = models.CharField(max_length=50, null=True, blank=True)
|
||||
sub_type = models.CharField(
|
||||
@@ -473,9 +597,10 @@ class FilamentSnapshot(models.Model):
|
||||
db_table = "infrastructure_filament_snapshot"
|
||||
verbose_name = "Filament Snapshot"
|
||||
verbose_name_plural = "Filament Snapshots"
|
||||
ordering = ['printer_metric', 'tray_id']
|
||||
ordering = ['printer_metric', 'ams_unit_id', 'tray_id']
|
||||
indexes = [
|
||||
models.Index(fields=['printer_metric', 'tray_id']),
|
||||
models.Index(fields=['printer_metric', 'ams_unit_id', 'tray_id']),
|
||||
models.Index(fields=['filament']),
|
||||
]
|
||||
|
||||
@@ -484,6 +609,47 @@ class FilamentSnapshot(models.Model):
|
||||
return f"Tray {self.tray_id}: {filament_info}"
|
||||
|
||||
|
||||
class BambuCloudTask(models.Model):
|
||||
"""Cloud task record synced from Bambu Cloud API (v1/user-service/my/tasks)."""
|
||||
|
||||
task_id = models.BigIntegerField(unique=True, db_index=True, help_text="Bambu Cloud task ID (matches MQTT task_id)")
|
||||
design_id = models.IntegerField(null=True, blank=True, help_text="Makerworld design ID")
|
||||
design_title = models.CharField(max_length=500, blank=True, help_text="Human project name from Makerworld (designTitle)")
|
||||
plate_title = models.CharField(max_length=500, blank=True, help_text="Plate/variant name (matches MQTT subtask_name)")
|
||||
model_id = models.CharField(max_length=100, blank=True)
|
||||
profile_id = models.BigIntegerField(null=True, blank=True, help_text="Bambu Cloud profile ID")
|
||||
plate_index = models.SmallIntegerField(null=True, blank=True)
|
||||
device_serial = models.CharField(max_length=100, blank=True, help_text="Printer serial number from cloud")
|
||||
cover_url = models.URLField(max_length=1000, blank=True, help_text="Plate preview image URL from S3")
|
||||
weight_grams = models.DecimalField(max_digits=8, decimal_places=2, null=True, blank=True, help_text="Actual filament weight reported by cloud")
|
||||
length_mm = models.IntegerField(null=True, blank=True, help_text="Filament length in mm")
|
||||
cost_time_seconds = models.IntegerField(null=True, blank=True, help_text="Cloud-measured print duration in seconds")
|
||||
cloud_status = models.SmallIntegerField(null=True, blank=True, help_text="2=finish, 3=failed")
|
||||
bed_type = models.CharField(max_length=50, blank=True)
|
||||
use_ams = models.BooleanField(default=True)
|
||||
print_mode = models.CharField(max_length=50, blank=True, help_text="cloud_file, local, etc.")
|
||||
ams_detail_mapping = models.JSONField(default=list, help_text="Per-slot filament weight breakdown from cloud")
|
||||
cloud_start_time = models.DateTimeField(null=True, blank=True)
|
||||
cloud_end_time = models.DateTimeField(null=True, blank=True)
|
||||
raw_data = models.JSONField(default=dict, help_text="Full task response — preserved for future use")
|
||||
synced_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "infrastructure_cloud_task"
|
||||
verbose_name = "Bambu Cloud Task"
|
||||
verbose_name_plural = "Bambu Cloud Tasks"
|
||||
ordering = ["-cloud_start_time"]
|
||||
indexes = [
|
||||
models.Index(fields=["task_id"]),
|
||||
models.Index(fields=["design_id"]),
|
||||
models.Index(fields=["-cloud_start_time"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
name = self.design_title or self.plate_title or f"task-{self.task_id}"
|
||||
return f"{name} ({self.cloud_start_time.strftime('%Y-%m-%d') if self.cloud_start_time else 'unknown date'})"
|
||||
|
||||
|
||||
class PrintJob(models.Model):
|
||||
"""Represents a single print job from start to finish"""
|
||||
|
||||
@@ -497,6 +663,16 @@ class PrintJob(models.Model):
|
||||
)
|
||||
gcode_file = models.CharField(max_length=200, null=True, blank=True)
|
||||
|
||||
cloud_task = models.ForeignKey(
|
||||
'BambuCloudTask', on_delete=models.SET_NULL,
|
||||
null=True, blank=True, related_name='print_jobs',
|
||||
help_text="Linked Bambu Cloud task record (set by bambu_sync_cloud or collector)"
|
||||
)
|
||||
cloud_task_id_raw = models.BigIntegerField(
|
||||
null=True, blank=True, db_index=True,
|
||||
help_text="MQTT task_id — captured at job start, used to link cloud task"
|
||||
)
|
||||
|
||||
start_time = models.DateTimeField(help_text="When print started")
|
||||
end_time = models.DateTimeField(null=True, blank=True, help_text="When print finished/failed")
|
||||
duration_minutes = models.IntegerField(null=True, blank=True, help_text="Total print duration")
|
||||
@@ -536,6 +712,13 @@ class PrintJob(models.Model):
|
||||
status = self.final_status or 'In Progress'
|
||||
return f"{self.project_name} ({status}) - {self.start_time.strftime('%Y-%m-%d %H:%M')}"
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
"""Human-readable job name: cloud design_title if available, else project_name."""
|
||||
if self.cloud_task_id and self.cloud_task and self.cloud_task.design_title:
|
||||
return self.cloud_task.design_title
|
||||
return self.project_name
|
||||
|
||||
def calculate_duration(self):
|
||||
"""Calculate print duration if end_time is set"""
|
||||
if self.end_time and self.start_time:
|
||||
@@ -556,6 +739,10 @@ class FilamentUsage(models.Model):
|
||||
)
|
||||
|
||||
tray_id = models.IntegerField(help_text="Which AMS slot was used")
|
||||
ams_unit_id = models.PositiveSmallIntegerField(
|
||||
null=True, blank=True, db_index=True,
|
||||
help_text="Which physical AMS unit the slot belongs to (matches MQTT ams[i].id; 128 = AMS HT)"
|
||||
)
|
||||
|
||||
starting_percent = models.IntegerField(help_text="Filament remaining % at job start")
|
||||
ending_percent = models.IntegerField(
|
||||
@@ -593,3 +780,108 @@ class FilamentUsage(models.Model):
|
||||
self.consumed_grams = int(
|
||||
self.filament.initial_weight_grams * (self.consumed_percent / 100.0)
|
||||
)
|
||||
|
||||
|
||||
class Hotend(models.Model):
|
||||
"""Registry of individual Vortek hotends, keyed by serial number.
|
||||
|
||||
A Vortek rack holds up to 6 swappable hotends (bays, MQTT `id` 16-21) plus
|
||||
1 mounted on the toolhead at a time (MQTT `id` 0). `raw_id` reflects whichever
|
||||
address was last seen on the wire for this hotend; `slot_number` is only set
|
||||
when that address falls in the 16-21 rack-bay range — confirmed by watching
|
||||
a "Read All" MQTT capture reassign a toolhead-mounted hotend's id from 0 to
|
||||
its true bay id.
|
||||
"""
|
||||
|
||||
printer = models.ForeignKey(
|
||||
'Printer', on_delete=models.CASCADE, related_name='hotends'
|
||||
)
|
||||
serial_number = models.CharField(max_length=100, db_index=True)
|
||||
|
||||
nozzle_type = models.CharField(max_length=50, blank=True, default="")
|
||||
diameter = models.DecimalField(
|
||||
max_digits=3, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
|
||||
raw_id = models.PositiveSmallIntegerField(
|
||||
help_text="Last-seen MQTT device.nozzle.info[].id"
|
||||
)
|
||||
slot_number = models.PositiveSmallIntegerField(
|
||||
null=True, blank=True,
|
||||
help_text="Rack bay 1-6, derived from raw_id 16-21. Null if currently unknown (e.g. mounted on toolhead and id reports as the 0 sentinel)."
|
||||
)
|
||||
is_toolhead = models.BooleanField(
|
||||
default=False,
|
||||
help_text="True if currently mounted on the toolhead under normal polling (raw_id == 0)."
|
||||
)
|
||||
|
||||
last_filament_profile_id = models.CharField(
|
||||
max_length=20, blank=True, default="",
|
||||
help_text="Bambu material profile id of the filament last loaded (MQTT fila_id, e.g. 'GFA01')"
|
||||
)
|
||||
last_color = models.CharField(
|
||||
max_length=6, blank=True, default="",
|
||||
help_text="6-char hex of the filament last loaded (MQTT color_m, alpha stripped)"
|
||||
)
|
||||
|
||||
used_time_seconds = models.PositiveIntegerField(default=0)
|
||||
wear_percent = models.DecimalField(
|
||||
max_digits=5, decimal_places=2, default=0,
|
||||
help_text="MQTT wear (0-128 scale) converted to a 0-100 percent"
|
||||
)
|
||||
|
||||
last_seen_at = models.DateTimeField(auto_now=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "infrastructure_hotend"
|
||||
verbose_name = "Hotend"
|
||||
verbose_name_plural = "Hotends"
|
||||
ordering = ['printer', '-is_toolhead', 'slot_number', 'serial_number']
|
||||
unique_together = [['printer', 'serial_number']]
|
||||
|
||||
def __str__(self):
|
||||
location = "Toolhead" if self.is_toolhead else (
|
||||
f"Slot {self.slot_number}" if self.slot_number else "Rack"
|
||||
)
|
||||
return f"{self.serial_number} ({location})"
|
||||
|
||||
@property
|
||||
def used_time_display(self) -> str:
|
||||
hours, remainder = divmod(self.used_time_seconds, 3600)
|
||||
minutes = remainder // 60
|
||||
return f"{hours}h {minutes}m" if hours else f"{minutes}m"
|
||||
|
||||
|
||||
class HotendSnapshot(models.Model):
|
||||
"""Point-in-time reading of a Hotend, one row per collector poll."""
|
||||
|
||||
printer_metric = models.ForeignKey(
|
||||
'PrinterMetrics', on_delete=models.CASCADE,
|
||||
related_name='hotend_snapshots'
|
||||
)
|
||||
hotend = models.ForeignKey(
|
||||
'Hotend', on_delete=models.CASCADE,
|
||||
related_name='snapshots'
|
||||
)
|
||||
|
||||
raw_id = models.PositiveSmallIntegerField()
|
||||
used_time_seconds = models.PositiveIntegerField(default=0)
|
||||
wear_percent = models.DecimalField(max_digits=5, decimal_places=2, default=0)
|
||||
stat = models.IntegerField(
|
||||
null=True, blank=True, help_text="Raw MQTT status code for this hotend"
|
||||
)
|
||||
timestamp = models.DateTimeField(default=timezone.now, db_index=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "infrastructure_hotend_snapshot"
|
||||
verbose_name = "Hotend Snapshot"
|
||||
verbose_name_plural = "Hotend Snapshots"
|
||||
ordering = ['printer_metric', 'hotend']
|
||||
indexes = [
|
||||
models.Index(fields=['printer_metric', 'hotend']),
|
||||
models.Index(fields=['hotend', '-timestamp']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.hotend.serial_number} @ {self.timestamp.strftime('%Y-%m-%d %H:%M:%S')}"
|
||||
|
||||
@@ -21,6 +21,7 @@ import os
|
||||
import platform
|
||||
import sys
|
||||
import select
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
@@ -296,6 +297,73 @@ class AMSState:
|
||||
return loaded
|
||||
|
||||
|
||||
@dataclass
|
||||
class HotendInfo:
|
||||
"""A single hotend reported in `device.nozzle.info[]` (Vortek rack).
|
||||
|
||||
`raw_id` semantics (confirmed by watching a live "Read All" MQTT capture):
|
||||
0 = currently mounted on the (swappable) toolhead — the sentinel hides the
|
||||
true bay address until "Read All" resolves it; 1 = the fixed left nozzle on
|
||||
dual-nozzle printers (no RFID chip, always reports sn="N/A"); 16-21 = rack
|
||||
bay address, slot_number = raw_id - 15 (1-6).
|
||||
"""
|
||||
raw_id: int = 0
|
||||
serial_number: str = ""
|
||||
nozzle_type: str = ""
|
||||
diameter: float = 0.4
|
||||
fila_id: str = ""
|
||||
color: Optional[str] = None
|
||||
used_time_seconds: int = 0
|
||||
wear_percent: float = 0.0
|
||||
stat: int = 0
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "HotendInfo":
|
||||
from .utils import strip_color_padding
|
||||
|
||||
return cls(
|
||||
raw_id=int(data.get("id", 0)),
|
||||
serial_number=data.get("sn", ""),
|
||||
nozzle_type=data.get("type", ""),
|
||||
diameter=float(data.get("diameter", 0.4)),
|
||||
fila_id=data.get("fila_id", ""),
|
||||
color=strip_color_padding(data.get("color_m")),
|
||||
used_time_seconds=int(data.get("p_t", 0)),
|
||||
wear_percent=round(float(data.get("wear", 0.0)) / 128.0 * 100, 2),
|
||||
stat=int(data.get("stat", 0)),
|
||||
)
|
||||
|
||||
@property
|
||||
def is_toolhead(self) -> bool:
|
||||
return self.raw_id == 0
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
return self.serial_number in ("", "N/A")
|
||||
|
||||
@property
|
||||
def slot_number(self) -> Optional[int]:
|
||||
if 16 <= self.raw_id <= 21:
|
||||
return self.raw_id - 15
|
||||
return None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"raw_id": self.raw_id,
|
||||
"serial_number": self.serial_number,
|
||||
"nozzle_type": self.nozzle_type,
|
||||
"diameter": self.diameter,
|
||||
"fila_id": self.fila_id,
|
||||
"color": self.color,
|
||||
"used_time_seconds": self.used_time_seconds,
|
||||
"wear_percent": self.wear_percent,
|
||||
"stat": self.stat,
|
||||
"is_toolhead": self.is_toolhead,
|
||||
"is_empty": self.is_empty,
|
||||
"slot_number": self.slot_number,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PrinterState:
|
||||
"""Complete printer state parsed from MQTT data"""
|
||||
@@ -335,10 +403,16 @@ class PrinterState:
|
||||
wifi_signal: str = ""
|
||||
wifi_signal_dbm: int = 0
|
||||
|
||||
# Nozzle info
|
||||
# Nozzle info — single-nozzle / right-side back-compat fields.
|
||||
nozzle_diameter: float = 0.4
|
||||
nozzle_type: str = ""
|
||||
|
||||
# H2C dual-nozzle: left-side fields (None on single-nozzle printers).
|
||||
nozzle_temp_left: Optional[float] = None
|
||||
nozzle_target_temp_left: Optional[float] = None
|
||||
nozzle_diameter_left: Optional[float] = None
|
||||
nozzle_type_left: Optional[str] = None
|
||||
|
||||
# System status
|
||||
home_flag: int = 0
|
||||
hw_switch_state: int = 0
|
||||
@@ -382,6 +456,9 @@ class PrinterState:
|
||||
# External spool (virtual tray)
|
||||
vt_tray: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Vortek hotend rack (device.nozzle.info[])
|
||||
hotends: List[HotendInfo] = field(default_factory=list)
|
||||
|
||||
# Raw data for any additional fields
|
||||
_raw_data: Dict[str, Any] = field(default_factory=dict, repr=False)
|
||||
|
||||
@@ -410,11 +487,52 @@ class PrinterState:
|
||||
|
||||
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(
|
||||
timestamp=timestamp,
|
||||
sequence_id=str(print_data.get("sequence_id", "")),
|
||||
nozzle_temp=float(print_data.get("nozzle_temper", 0.0)),
|
||||
nozzle_target_temp=float(print_data.get("nozzle_target_temper", 0.0)),
|
||||
nozzle_temp=(
|
||||
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_target_temp=float(print_data.get("bed_target_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),
|
||||
nozzle_diameter=float(print_data.get("nozzle_diameter", 0.4)),
|
||||
nozzle_type=print_data.get("nozzle_type", ""),
|
||||
nozzle_temp_left=nozzle_temp_left,
|
||||
nozzle_target_temp_left=nozzle_target_temp_left,
|
||||
# Diameter/type per side: H2C currently uses uniform nozzles, so reuse top-level
|
||||
# values. If a future probe shows per-side diameter/type variance, plumb it from
|
||||
# `device.nozzle.info[]` cross-referenced against `device.extruder.info[i].id`.
|
||||
nozzle_diameter_left=float(print_data.get("nozzle_diameter", 0.4)) if nozzle_temp_left is not None else None,
|
||||
nozzle_type_left=print_data.get("nozzle_type", "") if nozzle_temp_left is not None else None,
|
||||
home_flag=int(print_data.get("home_flag", 0)),
|
||||
hw_switch_state=int(print_data.get("hw_switch_state", 0)),
|
||||
mc_print_stage=str(print_data.get("mc_print_stage", "")),
|
||||
@@ -459,6 +584,7 @@ class PrinterState:
|
||||
gcode_file_prepare_percent=str(print_data.get("gcode_file_prepare_percent", "")),
|
||||
lifecycle=print_data.get("lifecycle", ""),
|
||||
vt_tray=print_data.get("vt_tray"),
|
||||
hotends=hotends,
|
||||
_raw_data=data,
|
||||
)
|
||||
|
||||
@@ -473,6 +599,14 @@ class PrinterState:
|
||||
"chamber_temp": round(self.chamber_temp, 2),
|
||||
"nozzle_diameter": self.nozzle_diameter,
|
||||
"nozzle_type": self.nozzle_type,
|
||||
"nozzle_temp_left": (
|
||||
round(self.nozzle_temp_left, 2) if self.nozzle_temp_left is not None else None
|
||||
),
|
||||
"nozzle_target_temp_left": (
|
||||
round(self.nozzle_target_temp_left, 2) if self.nozzle_target_temp_left is not None else None
|
||||
),
|
||||
"nozzle_diameter_left": self.nozzle_diameter_left,
|
||||
"nozzle_type_left": self.nozzle_type_left,
|
||||
"gcode_state": self.gcode_state,
|
||||
"print_type": self.print_type,
|
||||
"print_percent": self.print_percent,
|
||||
@@ -482,6 +616,8 @@ class PrinterState:
|
||||
"print_line_number": self.print_line_number,
|
||||
"subtask_name": self.subtask_name,
|
||||
"gcode_file": self.gcode_file,
|
||||
"task_id": self.task_id,
|
||||
"project_id": self.project_id,
|
||||
"cooling_fan_speed": self.cooling_fan_speed,
|
||||
"heatbreak_fan_speed": self.heatbreak_fan_speed,
|
||||
"big_fan1_speed": self.big_fan1_speed,
|
||||
@@ -491,6 +627,11 @@ class PrinterState:
|
||||
"wifi_signal_dbm": self.wifi_signal_dbm,
|
||||
"print_error": self.print_error,
|
||||
"has_errors": self.print_error != 0,
|
||||
# Full `print.device` payload, unfiltered. H2C's Vortek rack (6 swappable
|
||||
# hotends + 1 fixed left nozzle) isn't fully modeled yet — stash everything
|
||||
# here so no data is lost once the real Vortek MQTT schema is confirmed.
|
||||
"vortek_raw": self._raw_data.get("print", {}).get("device", {}),
|
||||
"hotends": [h.to_dict() for h in self.hotends],
|
||||
"hms": self.hms,
|
||||
"stg_cur": self.stg_cur,
|
||||
"lights_report": self.lights_report,
|
||||
@@ -513,8 +654,19 @@ class PrinterState:
|
||||
snapshot["tray_now"] = self.ams.tray_now
|
||||
snapshot["ams_version"] = self.ams.version
|
||||
|
||||
from .models import ams_type_from_info
|
||||
|
||||
filaments = []
|
||||
for unit in self.ams.units:
|
||||
# `unit_id` is the AMS unit's own id from the MQTT payload — for the
|
||||
# original AMS / AMS 2 Pro it's a small int (0,1,2,...); for AMS HT
|
||||
# it has the 0x80 bit set (e.g. 128). Don't compute tray_id // 4 —
|
||||
# multi-AMS-type setups are not contiguous.
|
||||
try:
|
||||
unit_id_int = int(unit.unit_id)
|
||||
except (TypeError, ValueError):
|
||||
unit_id_int = None
|
||||
ams_type_label = ams_type_from_info(unit.info)
|
||||
for tray in unit.trays:
|
||||
if tray.tray_type:
|
||||
filaments.append({
|
||||
@@ -540,6 +692,9 @@ class PrinterState:
|
||||
"tray_bed_temp": tray.tray_bed_temp,
|
||||
"bed_temp_type": tray.bed_temp_type,
|
||||
"cols": tray.cols,
|
||||
"ams_unit_id": unit_id_int,
|
||||
"ams_info": unit.info,
|
||||
"ams_type": ams_type_label,
|
||||
})
|
||||
snapshot["filaments"] = filaments
|
||||
|
||||
@@ -550,6 +705,7 @@ class PrinterState:
|
||||
"ams_id": unit.ams_id,
|
||||
"chip_id": unit.chip_id,
|
||||
"info": unit.info,
|
||||
"ams_type": ams_type_from_info(unit.info),
|
||||
"humidity": unit.humidity,
|
||||
"humidity_raw": unit.humidity_raw,
|
||||
"temp": unit.temp,
|
||||
@@ -674,6 +830,7 @@ class BambuPrinter:
|
||||
self._accumulator = PrinterStateAccumulator()
|
||||
self._connected = False
|
||||
self._devices: List[Dict[str, Any]] = []
|
||||
self._last_message_at: Optional[float] = None
|
||||
|
||||
def _get_fresh_token(self, verification_code_timeout: int = 300) -> str:
|
||||
"""Get a fresh token using credentials."""
|
||||
@@ -779,6 +936,30 @@ class BambuPrinter:
|
||||
"""Internal MQTT message handler"""
|
||||
if not data:
|
||||
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)
|
||||
if self._on_update:
|
||||
self._on_update(state)
|
||||
|
||||
@@ -64,3 +64,78 @@
|
||||
opacity: 0.9;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
/* AMS unit type colors — CSS variables so RAE/standalone can override per theme */
|
||||
:root {
|
||||
--ams-badge-ams: #6c757d;
|
||||
--ams-badge-ams-2-pro: #0d6efd;
|
||||
--ams-badge-ams-ht: #fd7e14;
|
||||
--ams-group-border-color: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
[data-coreui-theme="dark"] {
|
||||
--ams-group-border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.ams-badge-ams {
|
||||
background-color: var(--ams-badge-ams);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ams-badge-ams-2-pro {
|
||||
background-color: var(--ams-badge-ams-2-pro);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ams-badge-ams-ht {
|
||||
background-color: var(--ams-badge-ams-ht);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ams-filter-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ams-filter-pill {
|
||||
border-radius: 50rem;
|
||||
padding: 0.25rem 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid var(--ams-group-border-color);
|
||||
background-color: transparent;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.ams-filter-pill.active {
|
||||
opacity: 1;
|
||||
font-weight: 600;
|
||||
border-color: currentColor;
|
||||
}
|
||||
|
||||
/* Grouped AMS unit panels — 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);
|
||||
}
|
||||
|
||||
156
bambu_run/static/bambu_run/js/filament_form.js
Normal file
156
bambu_run/static/bambu_run/js/filament_form.js
Normal 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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
// 3D Printer Charts Initialization and Management
|
||||
// Chart.js implementation for printer metrics visualization
|
||||
|
||||
let nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart;
|
||||
let nozzleTempChart, nozzleTempLeftChart, bedTempChart, printProgressChart, fanSpeedsChart;
|
||||
let wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart;
|
||||
|
||||
function showNoDataMessage(canvasId) {
|
||||
@@ -75,6 +75,50 @@ function initPrinterCharts(printerData, apiUrl) {
|
||||
options: getTemperatureChartOptions(tickColor, gridColor, '°C')
|
||||
});
|
||||
|
||||
// Initialize Left Nozzle Temperature Chart (H2C-class dual-nozzle).
|
||||
// Mounted only when the canvas exists AND the API returned non-null
|
||||
// left-side samples — single-nozzle printers leave the column NULL.
|
||||
const nozzleLeftCanvas = document.getElementById('nozzleTempLeftChart');
|
||||
const hasLeftData = Array.isArray(printerData.nozzle_temp_left)
|
||||
&& printerData.nozzle_temp_left.some(v => v !== null && v !== undefined);
|
||||
if (nozzleLeftCanvas && hasLeftData) {
|
||||
const nozzleLeftCtx = nozzleLeftCanvas.getContext('2d');
|
||||
nozzleTempLeftChart = new Chart(nozzleLeftCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: printerData.timestamps,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Actual Temp (Left)',
|
||||
data: printerData.nozzle_temp_left,
|
||||
borderColor: 'rgb(54, 162, 235)',
|
||||
backgroundColor: 'rgba(54, 162, 235, 0.1)',
|
||||
tension: 0.3,
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 3,
|
||||
spanGaps: true
|
||||
},
|
||||
{
|
||||
label: 'Target Temp (Left)',
|
||||
data: printerData.nozzle_target_temp_left,
|
||||
borderColor: 'rgb(153, 102, 255)',
|
||||
backgroundColor: 'rgba(153, 102, 255, 0.05)',
|
||||
borderDash: [5, 5],
|
||||
tension: 0.3,
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 3,
|
||||
spanGaps: true
|
||||
}
|
||||
]
|
||||
},
|
||||
options: getTemperatureChartOptions(tickColor, gridColor, '°C')
|
||||
});
|
||||
} else if (nozzleLeftCanvas) {
|
||||
showNoDataMessage('nozzleTempLeftChart');
|
||||
}
|
||||
|
||||
// Initialize Bed Temperature Chart
|
||||
const bedCtx = document.getElementById('bedTempChart').getContext('2d');
|
||||
bedTempChart = new Chart(bedCtx, {
|
||||
@@ -581,8 +625,23 @@ function createFilamentDatasets(filamentTimeline, timestamps) {
|
||||
data: filamentTimeline[key]
|
||||
}));
|
||||
|
||||
// Sort by tray_id (numeric first, External last), then by start_idx (chronological)
|
||||
// Distinct (non-null/undefined) AMS units present in this timeline — used to decide
|
||||
// whether labels need an AMS unit prefix (avoid noise for the common single-AMS case).
|
||||
const distinctUnits = new Set(
|
||||
filamentEntries
|
||||
.map(e => e.data.ams_unit_id)
|
||||
.filter(uid => uid !== null && uid !== undefined)
|
||||
);
|
||||
const showUnitPrefix = distinctUnits.size > 1;
|
||||
|
||||
// Sort by AMS unit, then tray_id (numeric first, External last), then by start_idx
|
||||
filamentEntries.sort((a, b) => {
|
||||
const unitA = a.data.ams_unit_id ?? -1;
|
||||
const unitB = b.data.ams_unit_id ?? -1;
|
||||
if (unitA !== unitB) {
|
||||
return unitA - unitB;
|
||||
}
|
||||
|
||||
const trayA = a.data.tray_id;
|
||||
const trayB = b.data.tray_id;
|
||||
|
||||
@@ -615,6 +674,10 @@ function createFilamentDatasets(filamentTimeline, timestamps) {
|
||||
displayLabel = `Tray ${filament.tray_id} (${filament.type})`;
|
||||
}
|
||||
|
||||
if (showUnitPrefix && filament.ams_type) {
|
||||
displayLabel = `${filament.ams_type} · ${displayLabel}`;
|
||||
}
|
||||
|
||||
// Add brand if it's different from type (avoid redundancy)
|
||||
if (filament.brand && filament.brand !== filament.type && filament.brand !== 'External') {
|
||||
displayLabel += ` - ${filament.brand}`;
|
||||
@@ -646,8 +709,20 @@ function hexToRgba(hex, alpha) {
|
||||
function applyFilamentColors() {
|
||||
// Apply colors to filament cards
|
||||
document.querySelectorAll('.filament-card').forEach(card => {
|
||||
const isTransparent = card.getAttribute('data-filament-transparent') === 'true';
|
||||
const colorHex = card.getAttribute('data-filament-color');
|
||||
if (colorHex) {
|
||||
|
||||
if (isTransparent) {
|
||||
// Checkerboard left border and subtle background for clear filaments
|
||||
card.style.borderLeft = '4px solid #aaa';
|
||||
card.style.background = 'repeating-conic-gradient(rgba(180,180,180,0.15) 0% 25%, transparent 0% 50%) 0 0/10px 10px';
|
||||
|
||||
const badge = card.querySelector('.filament-badge');
|
||||
if (badge) {
|
||||
badge.style.backgroundColor = '#aaa';
|
||||
badge.style.color = '#fff';
|
||||
}
|
||||
} else if (colorHex) {
|
||||
const color = '#' + colorHex;
|
||||
|
||||
// Set card background with gradient
|
||||
@@ -690,7 +765,7 @@ function updateChartTheme() {
|
||||
|
||||
// Update all charts
|
||||
const charts = [
|
||||
nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart,
|
||||
nozzleTempChart, nozzleTempLeftChart, bedTempChart, printProgressChart, fanSpeedsChart,
|
||||
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
|
||||
];
|
||||
|
||||
@@ -792,7 +867,7 @@ function applyDateSeparatorsToAllPrinterCharts(timestamps, dates) {
|
||||
const sepAnnotations = buildDateSeparatorAnnotations(timestamps, dates);
|
||||
|
||||
const charts = [
|
||||
nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart,
|
||||
nozzleTempChart, nozzleTempLeftChart, bedTempChart, printProgressChart, fanSpeedsChart,
|
||||
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
|
||||
];
|
||||
|
||||
|
||||
@@ -200,6 +200,13 @@ function updateAllPrinterCharts(data) {
|
||||
{ data: data.nozzle_target_temp, datasetIndex: 1 }
|
||||
]);
|
||||
|
||||
if (typeof nozzleTempLeftChart !== 'undefined' && nozzleTempLeftChart) {
|
||||
updateChartData(nozzleTempLeftChart, data.timestamps, [
|
||||
{ data: data.nozzle_temp_left || [], datasetIndex: 0 },
|
||||
{ data: data.nozzle_target_temp_left || [], datasetIndex: 1 }
|
||||
]);
|
||||
}
|
||||
|
||||
updateChartData(bedTempChart, data.timestamps, [
|
||||
{ data: data.bed_temp, datasetIndex: 0 },
|
||||
{ data: data.bed_target_temp, datasetIndex: 1 }
|
||||
@@ -269,7 +276,7 @@ function addProjectMarkersToCharts(markers, timestamps) {
|
||||
console.log('Adding project markers:', markers);
|
||||
|
||||
const charts = [
|
||||
nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart,
|
||||
nozzleTempChart, nozzleTempLeftChart, bedTempChart, printProgressChart, fanSpeedsChart,
|
||||
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
|
||||
];
|
||||
|
||||
@@ -400,7 +407,7 @@ function resetPrinterControls() {
|
||||
|
||||
// Clear annotations and reload with original data
|
||||
const charts = [
|
||||
nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart,
|
||||
nozzleTempChart, nozzleTempLeftChart, bedTempChart, printProgressChart, fanSpeedsChart,
|
||||
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
|
||||
];
|
||||
|
||||
|
||||
@@ -54,11 +54,19 @@
|
||||
{% for color in colors %}
|
||||
<tr>
|
||||
<td class="align-middle">
|
||||
{% if color.is_transparent %}
|
||||
<div style="width: 50px; height: 50px; border-radius: 4px; border: 2px solid #ddd; background: repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 0 0/10px 10px;" title="Clear / Transparent"></div>
|
||||
{% else %}
|
||||
<div style="width: 50px; height: 50px; background-color: {{ color.get_hex_color }}; border-radius: 4px; border: 2px solid #ddd;"></div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="align-middle"><strong>{{ color.color_name }}</strong></td>
|
||||
<td class="align-middle">
|
||||
{% if color.is_transparent %}
|
||||
<span class="text-muted fst-italic">Clear / Transparent</span>
|
||||
{% else %}
|
||||
<span class="font-monospace">{{ color.get_hex_color }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<span class="badge bg-secondary">{{ color.filament_type }}</span>
|
||||
|
||||
@@ -27,10 +27,14 @@
|
||||
<div class="card-body">
|
||||
<h6>Color</h6>
|
||||
<div class="d-flex align-items-center">
|
||||
{% if filament.is_transparent %}
|
||||
<div style="width: 50px; height: 50px; border-radius: 8px; margin-right: 15px; border: 2px solid #ddd; background: repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 0 0/10px 10px;" title="Clear / Transparent"></div>
|
||||
{% else %}
|
||||
<div style="width: 50px; height: 50px; background-color: {{ filament.color_hex|default:'#999' }}; border-radius: 8px; margin-right: 15px; border: 2px solid #ddd;"></div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<strong>{{ filament.color }}</strong><br>
|
||||
<small class="text-muted">{{ filament.color_hex }}</small>
|
||||
<small class="text-muted">{% if filament.is_transparent %}Clear / Transparent{% else %}{{ filament.color_hex }}{% endif %}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -150,7 +154,7 @@
|
||||
<tbody>
|
||||
{% for usage in print_usages %}
|
||||
<tr>
|
||||
<td>{{ usage.print_job.project_name }}</td>
|
||||
<td>{{ usage.print_job.display_name }}</td>
|
||||
<td>{{ usage.print_job.start_time|date:"Y-m-d H:i" }}</td>
|
||||
<td>Tray {{ usage.tray_id }}</td>
|
||||
<td>{{ usage.consumed_percent|default:"?" }}% ({{ usage.consumed_grams|default:"?" }}g)</td>
|
||||
|
||||
@@ -43,6 +43,14 @@
|
||||
<hr>
|
||||
<h5>Specifications</h5>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Filament Type Preset</label>
|
||||
{{ form.filament_type }}
|
||||
<small class="form-text text-muted">Selecting a preset auto-fills Type, Sub Type, and Brand below.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Type *</label>
|
||||
@@ -62,12 +70,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3">
|
||||
<div class="row mb-3 align-items-end">
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Color Picker</label>
|
||||
<div id="transparent-swatch" style="display:none; width:100%; height:38px; border-radius:4px; border:1px solid #ddd; background: repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 0 0/10px 10px;" title="Clear / Transparent"></div>
|
||||
{{ form.color_hex }}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-2">
|
||||
<div class="form-check mt-4">
|
||||
{{ form.is_transparent }}
|
||||
<label class="form-check-label" for="id_is_transparent">Transparent / Clear</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">{{ form.color_hex_text.label }}</label>
|
||||
{{ form.color_hex_text }}
|
||||
<small class="form-text text-muted">e.g. #0A2CA5</small>
|
||||
@@ -209,95 +224,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Sync color picker and text input
|
||||
const colorPicker = document.getElementById('id_color_hex_picker');
|
||||
const colorText = document.getElementById('id_color_hex_text');
|
||||
|
||||
if (colorPicker && colorText) {
|
||||
colorPicker.addEventListener('input', function() {
|
||||
colorText.value = this.value.toUpperCase();
|
||||
});
|
||||
|
||||
colorText.addEventListener('input', function() {
|
||||
const value = this.value.trim();
|
||||
if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
|
||||
colorPicker.value = value;
|
||||
this.classList.remove('is-invalid');
|
||||
} else if (value.length === 7) {
|
||||
this.classList.add('is-invalid');
|
||||
}
|
||||
});
|
||||
|
||||
if (colorText.value && /^#[0-9A-Fa-f]{6}$/.test(colorText.value)) {
|
||||
colorPicker.value = colorText.value;
|
||||
} else if (colorPicker.value && !colorText.value) {
|
||||
colorText.value = colorPicker.value.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
// Delete confirmation logic
|
||||
const deleteConfirmText = document.getElementById('deleteConfirmText');
|
||||
const confirmDeleteBtn = document.getElementById('confirmDeleteBtn');
|
||||
const deleteForm = document.getElementById('deleteForm');
|
||||
const deleteModal = document.getElementById('deleteModal');
|
||||
|
||||
if (deleteConfirmText && confirmDeleteBtn) {
|
||||
deleteConfirmText.addEventListener('input', function() {
|
||||
const value = this.value.trim();
|
||||
if (value === 'DELETE') {
|
||||
confirmDeleteBtn.disabled = false;
|
||||
this.classList.remove('is-invalid');
|
||||
this.classList.add('is-valid');
|
||||
} else {
|
||||
confirmDeleteBtn.disabled = true;
|
||||
this.classList.remove('is-valid');
|
||||
if (value.length > 0) {
|
||||
this.classList.add('is-invalid');
|
||||
} else {
|
||||
this.classList.remove('is-invalid');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (deleteForm) {
|
||||
deleteForm.addEventListener('submit', function(e) {
|
||||
if (confirmDeleteBtn.disabled) {
|
||||
e.preventDefault();
|
||||
alert('Please type DELETE to confirm deletion');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
if (deleteModal) {
|
||||
deleteModal.addEventListener('hidden.bs.modal', function() {
|
||||
deleteConfirmText.value = '';
|
||||
confirmDeleteBtn.disabled = true;
|
||||
deleteConfirmText.classList.remove('is-valid', 'is-invalid');
|
||||
});
|
||||
|
||||
deleteModal.addEventListener('shown.bs.modal', function() {
|
||||
deleteConfirmText.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Backup modal opener
|
||||
const deleteBtn = document.getElementById('deleteBtn');
|
||||
if (deleteBtn && deleteModal) {
|
||||
deleteBtn.addEventListener('click', function() {
|
||||
if (!deleteModal.classList.contains('show')) {
|
||||
if (typeof bootstrap !== 'undefined') {
|
||||
const modalInstance = bootstrap.Modal.getOrCreateInstance(deleteModal);
|
||||
modalInstance.show();
|
||||
} else if (typeof coreui !== 'undefined' && coreui.Modal) {
|
||||
const modalInstance = coreui.Modal.getOrCreateInstance(deleteModal);
|
||||
modalInstance.show();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{# Server-side data consumed by filament_form.js #}
|
||||
<script type="application/json" id="filament-type-data">{{ filament_type_map|safe }}</script>
|
||||
<script src="{% static 'bambu_run/js/filament_form.js' %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -70,14 +70,22 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-2">
|
||||
<select name="loaded" class="form-select">
|
||||
<option value="">All Spools</option>
|
||||
<option value="yes" {% if request.GET.loaded == 'yes' %}selected{% endif %}>Loaded in AMS</option>
|
||||
<option value="no" {% if request.GET.loaded == 'no' %}selected{% endif %}>Not Loaded</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-2">
|
||||
<select name="ams_type" class="form-select">
|
||||
<option value="">All AMS Types</option>
|
||||
{% for at in ams_type_choices %}
|
||||
<option value="{{ at }}" {% if request.GET.ams_type == at %}selected{% endif %}>{{ at }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button type="submit" class="btn btn-secondary">Filter</button>
|
||||
<a href="{% url 'bambu_run:filament_list' %}" class="btn btn-outline-secondary">Reset</a>
|
||||
</div>
|
||||
@@ -122,7 +130,11 @@
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<div class="d-flex align-items-center">
|
||||
{% if filament.is_transparent %}
|
||||
<div style="width: 30px; height: 30px; border-radius: 4px; margin-right: 10px; border: 1px solid #ddd; background: repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 0 0/10px 10px;" title="Clear / Transparent"></div>
|
||||
{% else %}
|
||||
<div style="width: 30px; height: 30px; background-color: {{ filament.color_hex|default:'#999' }}; border-radius: 4px; margin-right: 10px; border: 1px solid #ddd;"></div>
|
||||
{% endif %}
|
||||
{{ filament.color }}
|
||||
</div>
|
||||
</td>
|
||||
@@ -145,7 +157,11 @@
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
{% if filament.is_loaded_in_ams %}
|
||||
<span class="badge bg-success">AMS Tray {{ filament.current_tray_id }}</span>
|
||||
<span class="badge bg-success">
|
||||
{% if filament.ams_type %}{{ filament.ams_type }}{% else %}AMS{% endif %}
|
||||
{% if filament.ams_unit_id is not None %}#{{ filament.ams_unit_id }}{% endif %}
|
||||
· Tray {{ filament.current_tray_id }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Storage</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -14,6 +14,20 @@
|
||||
Real-time monitoring for {{ device_name }}
|
||||
</p>
|
||||
</div>
|
||||
{% if show_printer_switcher %}
|
||||
<div class="col-auto d-flex align-items-center gap-2">
|
||||
<label for="printerSwitcher" class="form-label mb-0 text-nowrap">Device:</label>
|
||||
<select id="printerSwitcher" class="form-select" aria-label="Select printer"
|
||||
onchange="if (this.value) { window.location.href = this.value; }">
|
||||
{% for p in all_printers %}
|
||||
<option value="{% url 'bambu_run:printer_dashboard' pk=p.pk %}"
|
||||
{% if printer_device.pk == p.pk %}selected{% endif %}>
|
||||
{{ p.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
@@ -22,9 +36,49 @@
|
||||
|
||||
<!-- Summary Cards Row -->
|
||||
<div class="row g-3 mb-4">
|
||||
<!-- Nozzle Temperature Card -->
|
||||
{% if stats.is_dual_nozzle %}
|
||||
<!-- Left Nozzle (dual-nozzle printers, e.g. H2C) -->
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="card infra-card-warning">
|
||||
<div class="card infra-card-warning h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="stat-label d-flex align-items-center gap-1">
|
||||
<svg class="icon" style="width: 1.25rem; height: 1.25rem;"><use href="{% static 'bambu_run/vendors/coreui-icons-free.svg' %}#cil-arrow-thick-left"></use></svg>
|
||||
Left Nozzle
|
||||
</div>
|
||||
<div class="stat-value">{{ stats.nozzle_temp_left|floatformat:1 }}°C</div>
|
||||
<div class="text-muted small">target {{ stats.nozzle_target_temp_left|floatformat:0 }}°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 }}°C</div>
|
||||
<div class="text-muted small">target {{ stats.nozzle_target_temp|floatformat:0 }}°C
|
||||
{% if stats.nozzle_type %}· Nozzle {{ stats.nozzle_type }}{% endif %}</div>
|
||||
</div>
|
||||
<i class="bi bi-thermometer-high" style="font-size: 2rem; opacity: 0.3;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- Nozzle Temperature Card (single-nozzle printers) -->
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="card infra-card-warning h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
@@ -36,10 +90,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Bed Temperature Card -->
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="card infra-card-danger">
|
||||
<div class="card infra-card-danger h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
@@ -54,7 +109,7 @@
|
||||
|
||||
<!-- Print Progress Card -->
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="card infra-card-info">
|
||||
<div class="card infra-card-info h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
@@ -69,7 +124,7 @@
|
||||
|
||||
<!-- Chamber Light Card -->
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="card {% if stats.chamber_light == 'on' %}infra-card-success{% else %}infra-card-secondary{% endif %}">
|
||||
<div class="card h-100 {% if stats.chamber_light == 'on' %}infra-card-success{% else %}infra-card-secondary{% endif %}">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
@@ -94,7 +149,7 @@
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<strong>Job Name:</strong> {{ stats.subtask_name }}
|
||||
<strong>Job Name:</strong> {{ stats.job_display_name }}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<strong>State:</strong> {{ stats.gcode_state }}
|
||||
@@ -109,37 +164,6 @@
|
||||
</div>
|
||||
{% 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 }}°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 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
@@ -149,10 +173,30 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if stats.filaments %}
|
||||
{% if stats.ams_units|length > 1 %}
|
||||
<div class="ams-filter-pills mb-3" id="amsFilterPills">
|
||||
<button type="button" class="btn ams-filter-pill active" data-ams-filter="all">All</button>
|
||||
{% for unit in stats.ams_units %}
|
||||
<button type="button" class="btn ams-filter-pill ams-badge-{{ unit.ams_type|slugify }}" data-ams-filter="{{ unit.ams_unit_id }}">{{ unit.ams_type|default:"AMS" }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row g-3 ams-groups">
|
||||
{% for group in stats.ams_groups %}
|
||||
<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 }}">
|
||||
<div class="ams-group-header d-flex justify-content-between align-items-center mb-2">
|
||||
<strong class="small">{{ group.label }}</strong>
|
||||
{% if group.humidity is not None or group.temp is not None %}
|
||||
<span class="small text-body-secondary">
|
||||
{% if group.humidity is not None %}{{ group.humidity }}%RH{% endif %}
|
||||
{% if group.temp is not None %}· {{ group.temp }}°C{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
{% for filament in stats.filaments %}
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="card filament-card" data-filament-color="{{ filament.color|slice:':6' }}">
|
||||
{% 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>
|
||||
@@ -175,8 +219,13 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if stats.external_spool.type %}
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="card filament-card" data-filament-color="{{ stats.external_spool.color|slice:':6' }}">
|
||||
<div class="card-body">
|
||||
@@ -192,8 +241,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="text-body-secondary">No filament data available</p>
|
||||
{% endif %}
|
||||
@@ -202,6 +251,64 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hotends Section (Vortek rack + any plain/non-inductive nozzles) -->
|
||||
{% if stats.hotends or stats.nozzle_positions %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5>Hotends</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
{% for hotend in stats.hotends %}
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="card filament-card" data-filament-color="{{ hotend.last_color|default:'888888' }}">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="mb-0">
|
||||
{% if hotend.is_toolhead %}Toolhead{% elif hotend.slot_number %}Slot {{ hotend.slot_number }}{% else %}Rack{% endif %}
|
||||
</h6>
|
||||
{% if hotend.is_toolhead %}<span class="badge filament-badge">Toolhead</span>{% endif %}
|
||||
</div>
|
||||
<p class="mb-1 small text-body-secondary">SN {{ hotend.serial_number }}</p>
|
||||
<p class="mb-1 small"><strong>{{ hotend.nozzle_type }}</strong>{% if hotend.diameter %} · {{ 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 %} · {{ nozzle.diameter }}mm{% endif %}</p>
|
||||
<p class="mb-0 small text-body-secondary">No induction chip data</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Date/Time Filter Controls -->
|
||||
{% if not is_basic_user %}
|
||||
<div class="row mb-4">
|
||||
@@ -266,10 +373,10 @@
|
||||
|
||||
<!-- Charts Section -->
|
||||
<div class="row g-3 mb-4">
|
||||
<!-- Nozzle Temperature Chart -->
|
||||
<!-- Nozzle Temperature Chart (right side / single nozzle) -->
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header">Nozzle Temperature</div>
|
||||
<div class="card-header">{% if stats.is_dual_nozzle %}Right Nozzle Temperature{% else %}Nozzle Temperature{% endif %}</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container">
|
||||
<canvas id="nozzleTempChart"></canvas>
|
||||
@@ -278,6 +385,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if stats.is_dual_nozzle %}
|
||||
<!-- Left Nozzle Temperature Chart (H2C-class dual-nozzle) -->
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header">Left Nozzle Temperature</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container">
|
||||
<canvas id="nozzleTempLeftChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Bed Temperature Chart -->
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card">
|
||||
@@ -374,12 +495,12 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3.0.1"></script>
|
||||
<script src="{% static 'bambu_run/js/printer_charts.js' %}"></script>
|
||||
<script src="{% static 'bambu_run/js/printer_charts_control.js' %}"></script>
|
||||
{% if not is_basic_user %}
|
||||
<div id="printerApiUrl" data-url="{% url 'bambu_run:printer_api' %}" style="display: none;"></div>
|
||||
{% if not is_basic_user and printer_device %}
|
||||
<div id="printerApiUrl" data-url="{% url 'bambu_run:printer_api' pk=printer_device.pk %}" style="display: none;"></div>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const printerData = {{ printer_data_json|safe }};
|
||||
const apiUrl = '{% url "bambu_run:printer_api" %}';
|
||||
const apiUrl = '{% url "bambu_run:printer_api" pk=printer_device.pk %}';
|
||||
initPrinterCharts(printerData, apiUrl);
|
||||
|
||||
// Add project markers if they exist
|
||||
@@ -404,4 +525,27 @@
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const pillsContainer = document.getElementById('amsFilterPills');
|
||||
if (!pillsContainer) return;
|
||||
const items = document.querySelectorAll('.ams-groups .ams-group');
|
||||
|
||||
pillsContainer.addEventListener('click', function(e) {
|
||||
const pill = e.target.closest('.ams-filter-pill');
|
||||
if (!pill) return;
|
||||
|
||||
pillsContainer.querySelectorAll('.ams-filter-pill').forEach(function(p) {
|
||||
p.classList.remove('active');
|
||||
});
|
||||
pill.classList.add('active');
|
||||
|
||||
const filter = pill.dataset.amsFilter;
|
||||
items.forEach(function(item) {
|
||||
const show = filter === 'all' || item.dataset.amsUnitId === filter;
|
||||
item.classList.toggle('d-none', !show);
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -5,7 +5,9 @@ app_name = "bambu_run"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.PrinterDashboardView.as_view(), name="printer_dashboard"),
|
||||
path("printer/<int:pk>/", views.PrinterDashboardView.as_view(), name="printer_dashboard"),
|
||||
path("api/printer/", views.PrinterDataAPIView.as_view(), name="printer_api"),
|
||||
path("api/printer/<int:pk>/", views.PrinterDataAPIView.as_view(), name="printer_api"),
|
||||
|
||||
# Filament Inventory routes
|
||||
path("filaments/", views.FilamentListView.as_view(), name="filament_list"),
|
||||
|
||||
@@ -3,14 +3,23 @@ Utility functions for filament color matching
|
||||
"""
|
||||
|
||||
# BambuLab AMS reports colors as 8-char hex with an alpha channel suffix (e.g. '489FDFFF').
|
||||
# The last two chars are always 'FF' (fully opaque). Only the first 6 chars are the RGB value.
|
||||
# Opaque filaments use alpha 'FF'. Clear/transparent filaments use alpha '00' (e.g. '00000000').
|
||||
MQTT_COLOR_HEX_LENGTH = 6
|
||||
|
||||
|
||||
def is_mqtt_color_transparent(mqtt_color):
|
||||
"""
|
||||
Return True if the AMS color represents a clear/transparent filament.
|
||||
Bambu Lab uses alpha=00 for transparent (e.g. '00000000'), not 'FF' like opaque filaments.
|
||||
"""
|
||||
return bool(mqtt_color) and len(mqtt_color) == 8 and mqtt_color[6:8].upper() == '00'
|
||||
|
||||
|
||||
def strip_color_padding(mqtt_color):
|
||||
"""
|
||||
Strip FF padding from MQTT color
|
||||
MQTT: '000000FF' -> '000000'
|
||||
Strip alpha padding from MQTT color, returning the 6-char RGB hex.
|
||||
MQTT: '000000FF' -> '000000' (opaque black)
|
||||
MQTT: '00000000' -> '000000' (transparent — use is_mqtt_color_transparent() to distinguish)
|
||||
MQTT: 'FF6A13FF' -> 'FF6A13'
|
||||
"""
|
||||
if not mqtt_color:
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from datetime import timedelta, datetime
|
||||
from django.views.generic import TemplateView, View, ListView, CreateView, UpdateView, DetailView, DeleteView
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
from django.http import JsonResponse
|
||||
from django.http import Http404, JsonResponse
|
||||
from django.urls import reverse_lazy
|
||||
from django.contrib import messages
|
||||
from django.db.models import Q, Sum
|
||||
@@ -10,12 +11,16 @@ import json
|
||||
import zoneinfo
|
||||
|
||||
from .conf import app_settings
|
||||
from .models import Printer, PrinterMetrics, Filament, FilamentColor, FilamentType, FilamentSnapshot, PrintJob, FilamentUsage
|
||||
from .models import Printer, PrinterMetrics, Filament, FilamentColor, FilamentType, FilamentSnapshot, PrintJob, FilamentUsage, Hotend
|
||||
from .forms import FilamentForm, FilamentColorForm, FilamentTypeForm
|
||||
|
||||
# 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',
|
||||
@@ -23,7 +28,62 @@ _METRICS_API_FIELDS = [
|
||||
'gcode_state', 'print_type', 'subtask_name',
|
||||
'external_spool',
|
||||
]
|
||||
_MAX_CHART_POINTS = 3000
|
||||
# 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):
|
||||
@@ -38,13 +98,20 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||
|
||||
all_printers = Printer.objects.filter(is_active=True)
|
||||
context["all_printers"] = all_printers
|
||||
# Shown even with a single printer — hints that multi-printer support exists.
|
||||
context["show_printer_switcher"] = all_printers.exists()
|
||||
|
||||
try:
|
||||
printer_device = Printer.objects.filter(is_active=True).first()
|
||||
printer_device = resolve_printer_from_request(self.kwargs.get("pk"))
|
||||
if not printer_device:
|
||||
context["error"] = (
|
||||
"No 3D printer device found. Please run bambu_collector first."
|
||||
)
|
||||
return context
|
||||
except Http404:
|
||||
raise
|
||||
except Exception as e:
|
||||
context["error"] = f"Error loading printer device: {str(e)}"
|
||||
return context
|
||||
@@ -53,14 +120,28 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
||||
|
||||
# Get date range (overridable by subclasses)
|
||||
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
|
||||
)
|
||||
if end_dt:
|
||||
metrics = metrics.filter(timestamp__lte=end_dt)
|
||||
metrics = metrics.prefetch_related('filament_snapshots').order_by("timestamp")
|
||||
query = query.filter(timestamp__lte=end_dt)
|
||||
|
||||
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 = {
|
||||
"timestamps": [
|
||||
@@ -76,6 +157,14 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
||||
float(m.nozzle_target_temp) if m.nozzle_target_temp else None
|
||||
for m in metrics
|
||||
],
|
||||
"nozzle_temp_left": [
|
||||
float(m.nozzle_temp_left) if m.nozzle_temp_left is not None else None
|
||||
for m in metrics
|
||||
],
|
||||
"nozzle_target_temp_left": [
|
||||
float(m.nozzle_target_temp_left) if m.nozzle_target_temp_left is not None else None
|
||||
for m in metrics
|
||||
],
|
||||
"bed_temp": [float(m.bed_temp) if m.bed_temp else None for m in metrics],
|
||||
"bed_target_temp": [
|
||||
float(m.bed_target_temp) if m.bed_target_temp else None for m in metrics
|
||||
@@ -106,14 +195,17 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
||||
"total_layer_num": [
|
||||
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 = {}
|
||||
if latest_metric:
|
||||
filaments_list = []
|
||||
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:
|
||||
filament_dict = {
|
||||
'tray_id': snapshot.tray_id,
|
||||
@@ -121,43 +213,150 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
||||
'brand': snapshot.sub_type or 'Unknown',
|
||||
'color': snapshot.color or 'FFFFFFFF',
|
||||
'remain_percent': snapshot.remain_percent or 0,
|
||||
'ams_unit_id': snapshot.ams_unit_id,
|
||||
'ams_type': snapshot.ams_type or '',
|
||||
}
|
||||
if snapshot.filament:
|
||||
filament_dict['color_name'] = snapshot.filament.color
|
||||
filament_dict['filament_pk'] = snapshot.filament.pk
|
||||
filament_dict['is_transparent'] = snapshot.filament.is_transparent
|
||||
filaments_list.append(filament_dict)
|
||||
except Exception:
|
||||
filaments_list = []
|
||||
|
||||
# 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 = {
|
||||
"nozzle_temp": float(latest_metric.nozzle_temp) if latest_metric.nozzle_temp else 0,
|
||||
"nozzle_target_temp": float(latest_metric.nozzle_target_temp) if latest_metric.nozzle_target_temp else 0,
|
||||
"nozzle_diameter": float(latest_metric.nozzle_diameter) if latest_metric.nozzle_diameter else None,
|
||||
"nozzle_type": latest_metric.nozzle_type or "",
|
||||
"nozzle_temp_left": float(latest_metric.nozzle_temp_left) if latest_metric.nozzle_temp_left is not None else None,
|
||||
"nozzle_target_temp_left": float(latest_metric.nozzle_target_temp_left) if latest_metric.nozzle_target_temp_left is not None else None,
|
||||
"nozzle_diameter_left": float(latest_metric.nozzle_diameter_left) if latest_metric.nozzle_diameter_left is not None else None,
|
||||
"nozzle_type_left": latest_metric.nozzle_type_left or "",
|
||||
"is_dual_nozzle": latest_metric.nozzle_temp_left is not None,
|
||||
"bed_temp": float(latest_metric.bed_temp) if latest_metric.bed_temp else 0,
|
||||
"chamber_temp": float(latest_metric.chamber_temp) if latest_metric.chamber_temp else 0,
|
||||
"print_percent": latest_metric.print_percent or 0,
|
||||
"gcode_state": latest_metric.gcode_state or "Unknown",
|
||||
"print_type": latest_metric.print_type or "idle",
|
||||
"subtask_name": latest_metric.subtask_name or "No active print",
|
||||
"subtask_name": subtask_name,
|
||||
"job_display_name": job_display_name,
|
||||
"chamber_light": latest_metric.chamber_light or "unknown",
|
||||
"ams_temp": float(latest_metric.ams_temp) if latest_metric.ams_temp else None,
|
||||
"ams_humidity": latest_metric.ams_humidity,
|
||||
"filaments": filaments_list,
|
||||
"ams_units": ams_units_list,
|
||||
"ams_groups": ams_groups,
|
||||
"hotends": list(
|
||||
Hotend.objects.filter(printer=printer_device)
|
||||
.order_by('-is_toolhead', 'slot_number', 'serial_number')
|
||||
),
|
||||
# Nozzle positions with no induction chip (no stable serial number to
|
||||
# key a Hotend registry row on, e.g. H2C's fixed left nozzle) — shown
|
||||
# read-only from the latest poll, not persisted/historical. Entries with
|
||||
# no readable type/diameter at all (i.e. genuinely nothing there) are
|
||||
# dropped rather than shown as an empty placeholder.
|
||||
"nozzle_positions": [
|
||||
h for h in (latest_metric.nozzle_info or [])
|
||||
if h.get('is_empty') and (h.get('nozzle_type') or h.get('diameter'))
|
||||
],
|
||||
"external_spool": latest_metric.external_spool or {},
|
||||
"timestamp": latest_metric.timestamp.astimezone(tz).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
context["printer_device"] = printer_device
|
||||
context["device_name"] = printer_device.name
|
||||
context["stats"] = stats
|
||||
context["metrics_count"] = metrics.count()
|
||||
context["metrics_count"] = len(metrics)
|
||||
context["printer_data_json"] = json.dumps(printer_data_json)
|
||||
|
||||
return context
|
||||
|
||||
def _calculate_project_markers(self, metrics, timezone_info):
|
||||
"""Calculate where print jobs start and end"""
|
||||
def _calculate_project_markers(self, metrics, timezone_info, device):
|
||||
"""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 = []
|
||||
current_job = None
|
||||
last_state = None
|
||||
@@ -169,21 +368,23 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
||||
is_printing = gcode_state not in ['FINISH', 'IDLE', None, '']
|
||||
|
||||
if subtask and subtask != current_job and is_printing:
|
||||
display = subtask_to_display.get(subtask, subtask)
|
||||
markers.append({
|
||||
'type': 'start',
|
||||
'index': idx,
|
||||
'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(),
|
||||
'project_name': subtask,
|
||||
'project_name': display,
|
||||
})
|
||||
current_job = subtask
|
||||
last_state = gcode_state
|
||||
|
||||
elif current_job and last_state and last_state not in ['FINISH', 'IDLE'] and gcode_state in ['FINISH', 'IDLE']:
|
||||
display = subtask_to_display.get(current_job, current_job)
|
||||
markers.append({
|
||||
'type': 'end',
|
||||
'index': idx,
|
||||
'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(),
|
||||
'project_name': current_job,
|
||||
'project_name': display,
|
||||
})
|
||||
current_job = None
|
||||
|
||||
@@ -191,28 +392,31 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
||||
|
||||
return markers
|
||||
|
||||
def _prepare_filament_timeline(self, metrics):
|
||||
"""Prepare filament data organized by unique filament configurations."""
|
||||
def _prepare_filament_timeline(self, metrics, snapshots_by_metric):
|
||||
"""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 = {}
|
||||
total_points = len(metrics)
|
||||
|
||||
for idx, metric in enumerate(metrics):
|
||||
try:
|
||||
snapshots = metric.filament_snapshots.all()
|
||||
except Exception:
|
||||
snapshots = []
|
||||
|
||||
for snapshot in snapshots:
|
||||
for snapshot in snapshots_by_metric.get(metric.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_sub_type = snapshot.sub_type or 'Unknown'
|
||||
fil_color = snapshot.color or 'FFFFFFFF'
|
||||
|
||||
unique_key = f"{tray_id}_{fil_type}_{fil_sub_type}_{fil_color}"
|
||||
unique_key = f"{ams_unit_id}_{tray_id}_{fil_type}_{fil_sub_type}_{fil_color}"
|
||||
|
||||
if unique_key not in filament_data:
|
||||
filament_data[unique_key] = {
|
||||
'tray_id': tray_id,
|
||||
'ams_unit_id': ams_unit_id,
|
||||
'ams_type': ams_type,
|
||||
'type': fil_type,
|
||||
'brand': fil_sub_type,
|
||||
'color': fil_color,
|
||||
@@ -249,13 +453,18 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
||||
class PrinterDataAPIView(LoginRequiredMixin, View):
|
||||
"""API endpoint for dynamic printer chart updates"""
|
||||
|
||||
def get(self, request):
|
||||
def get(self, request, pk=None):
|
||||
start_date = request.GET.get("start_date")
|
||||
end_date = request.GET.get("end_date")
|
||||
start_time = request.GET.get("start_time", "00:00")
|
||||
end_time = request.GET.get("end_time", "23:59")
|
||||
|
||||
try:
|
||||
if pk is not None:
|
||||
printer_device = Printer.objects.filter(pk=pk, is_active=True).first()
|
||||
if not printer_device:
|
||||
return JsonResponse({"error": "Printer not found"}, status=404)
|
||||
else:
|
||||
printer_device = Printer.objects.filter(is_active=True).first()
|
||||
if not printer_device:
|
||||
return JsonResponse({"error": "No printer device found"}, status=404)
|
||||
@@ -269,38 +478,25 @@ class PrinterDataAPIView(LoginRequiredMixin, View):
|
||||
.only(*_METRICS_API_FIELDS)
|
||||
)
|
||||
|
||||
if start_date and start_time and end_date and end_time:
|
||||
start_dt = datetime.strptime(f"{start_date} {start_time}", "%Y-%m-%d %H:%M").replace(tzinfo=tz)
|
||||
end_dt = datetime.strptime(f"{end_date} {end_time}", "%Y-%m-%d %H:%M").replace(tzinfo=tz)
|
||||
query = query.filter(timestamp__gte=start_dt, timestamp__lte=end_dt)
|
||||
range_seconds = (end_dt - start_dt).total_seconds()
|
||||
expected_count = max(1, int(range_seconds / 30))
|
||||
elif start_date and start_time:
|
||||
start_dt = datetime.strptime(f"{start_date} {start_time}", "%Y-%m-%d %H:%M").replace(tzinfo=tz)
|
||||
query = query.filter(timestamp__gte=start_dt)
|
||||
expected_count = _MAX_CHART_POINTS
|
||||
elif end_date and end_time:
|
||||
end_dt = datetime.strptime(f"{end_date} {end_time}", "%Y-%m-%d %H:%M").replace(tzinfo=tz)
|
||||
query = query.filter(timestamp__lte=end_dt)
|
||||
expected_count = _MAX_CHART_POINTS
|
||||
else:
|
||||
expected_count = _MAX_CHART_POINTS
|
||||
# Both bounds are always applied. A missing bound falls back to a 24h
|
||||
# window rather than being left open — an unbounded range would scan
|
||||
# every metric ever recorded.
|
||||
def _parse(date_str, time_str):
|
||||
return datetime.strptime(
|
||||
f"{date_str} {time_str}", "%Y-%m-%d %H:%M"
|
||||
).replace(tzinfo=tz)
|
||||
|
||||
step = max(1, expected_count // _MAX_CHART_POINTS)
|
||||
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 = list(query.order_by("timestamp"))
|
||||
if step > 1:
|
||||
metrics_list = metrics_list[::step]
|
||||
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: dict = {}
|
||||
if metrics_list:
|
||||
sampled_ids = [m.id for m in metrics_list]
|
||||
for snap in FilamentSnapshot.objects.filter(printer_metric_id__in=sampled_ids):
|
||||
snapshots_by_metric.setdefault(snap.printer_metric_id, []).append(snap)
|
||||
snapshots_by_metric = fetch_snapshots_by_metric(metrics_list)
|
||||
|
||||
# Stage D: single-pass serialization
|
||||
timestamps = []
|
||||
@@ -308,6 +504,8 @@ class PrinterDataAPIView(LoginRequiredMixin, View):
|
||||
dates = []
|
||||
nozzle_temp = []
|
||||
nozzle_target_temp = []
|
||||
nozzle_temp_left = []
|
||||
nozzle_target_temp_left = []
|
||||
bed_temp = []
|
||||
bed_target_temp = []
|
||||
print_percent = []
|
||||
@@ -335,6 +533,8 @@ class PrinterDataAPIView(LoginRequiredMixin, View):
|
||||
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)
|
||||
@@ -412,6 +612,8 @@ class PrinterDataAPIView(LoginRequiredMixin, View):
|
||||
"dates": dates,
|
||||
"nozzle_temp": nozzle_temp,
|
||||
"nozzle_target_temp": nozzle_target_temp,
|
||||
"nozzle_temp_left": nozzle_temp_left,
|
||||
"nozzle_target_temp_left": nozzle_target_temp_left,
|
||||
"bed_temp": bed_temp,
|
||||
"bed_target_temp": bed_target_temp,
|
||||
"print_percent": print_percent,
|
||||
@@ -522,6 +724,10 @@ class FilamentListView(LoginRequiredMixin, ListView):
|
||||
elif loaded == 'no':
|
||||
queryset = queryset.filter(is_loaded_in_ams=False)
|
||||
|
||||
ams_type = self.request.GET.get('ams_type')
|
||||
if ams_type:
|
||||
queryset = queryset.filter(ams_type=ams_type)
|
||||
|
||||
search = self.request.GET.get('search')
|
||||
if search:
|
||||
queryset = queryset.filter(
|
||||
@@ -541,9 +747,22 @@ class FilamentListView(LoginRequiredMixin, ListView):
|
||||
context['filament_types'] = sorted(
|
||||
set(Filament.objects.exclude(type__isnull=True).exclude(type='').values_list('type', flat=True))
|
||||
)
|
||||
context['ams_type_choices'] = sorted(
|
||||
set(
|
||||
Filament.objects.exclude(ams_type='').values_list('ams_type', flat=True)
|
||||
)
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
def _filament_type_map():
|
||||
"""Return a JSON-serialisable dict mapping FilamentType pk → {type, sub_type, brand}."""
|
||||
return {
|
||||
str(ft.pk): {'type': ft.type, 'sub_type': ft.sub_type or '', 'brand': ft.brand}
|
||||
for ft in FilamentType.objects.all()
|
||||
}
|
||||
|
||||
|
||||
class FilamentCreateView(LoginRequiredMixin, CreateView):
|
||||
model = Filament
|
||||
form_class = FilamentForm
|
||||
@@ -553,6 +772,7 @@ class FilamentCreateView(LoginRequiredMixin, CreateView):
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||
context['filament_type_map'] = json.dumps(_filament_type_map())
|
||||
return context
|
||||
|
||||
def form_valid(self, form):
|
||||
@@ -569,6 +789,7 @@ class FilamentUpdateView(LoginRequiredMixin, UpdateView):
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||
context['filament_type_map'] = json.dumps(_filament_type_map())
|
||||
return context
|
||||
|
||||
def form_valid(self, form):
|
||||
@@ -602,7 +823,7 @@ class FilamentDetailView(LoginRequiredMixin, DetailView):
|
||||
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||
filament = self.object
|
||||
|
||||
context['print_usages'] = filament.print_usages.select_related('print_job').order_by('-print_job__start_time')[:20]
|
||||
context['print_usages'] = filament.print_usages.select_related('print_job__cloud_task').order_by('-print_job__start_time')[:20]
|
||||
|
||||
total_consumed = filament.print_usages.aggregate(
|
||||
total=Sum('consumed_percent')
|
||||
|
||||
@@ -3,6 +3,7 @@ services:
|
||||
build: .
|
||||
ports:
|
||||
- "8000:8000"
|
||||
- "8808:8808"
|
||||
env_file: .env
|
||||
volumes:
|
||||
- bambu_data:/app/data
|
||||
|
||||
@@ -25,6 +25,19 @@ autorestart=true
|
||||
startretries=10
|
||||
startsecs=5
|
||||
|
||||
[program:mcp_server]
|
||||
command=python standalone/manage.py bambu_mcp_server --transport sse --host 0.0.0.0 --port 8808
|
||||
directory=/app
|
||||
environment=DJANGO_SETTINGS_MODULE="standalone.settings"
|
||||
stdout_logfile=/dev/fd/1
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/fd/2
|
||||
stderr_logfile_maxbytes=0
|
||||
autorestart=true
|
||||
startretries=10
|
||||
startsecs=5
|
||||
priority=10
|
||||
|
||||
[program:migrate]
|
||||
command=python standalone/manage.py migrate --noinput
|
||||
directory=/app
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
Translucent #000000
|
||||
Translucent Gray #8E8E8E
|
||||
Translucent Light Blue #61B0FF
|
||||
Translucent Olive #748C45
|
||||
|
||||
15
native/bambu-run-mcp.service
Normal file
15
native/bambu-run-mcp.service
Normal 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
|
||||
@@ -8,6 +8,12 @@ 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
|
||||
@@ -25,14 +31,54 @@ case "${1:-help}" in
|
||||
systemctl --user status $SERVICES --no-pager
|
||||
;;
|
||||
logs)
|
||||
journalctl --user -u bambu-run-web -u bambu-run-collector -f --no-hostname
|
||||
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..."
|
||||
"$VENV_DIR/bin/pip" install --quiet ".[standalone]"
|
||||
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
|
||||
@@ -46,7 +92,7 @@ case "${1:-help}" in
|
||||
echo "Update complete."
|
||||
;;
|
||||
help|*)
|
||||
echo "Usage: $0 {start|stop|restart|status|logs|update}"
|
||||
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"
|
||||
@@ -54,5 +100,7 @@ case "${1:-help}" in
|
||||
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
|
||||
|
||||
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "bambu-run"
|
||||
version = "0.1.1"
|
||||
version = "0.1.13"
|
||||
description = "Django reusable app for Bambu Lab 3D printer monitoring and filament inventory management"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
requires-python = ">=3.10"
|
||||
requires-python = ">=3.9"
|
||||
authors = [
|
||||
{name = "Runnan Li"},
|
||||
]
|
||||
@@ -40,10 +40,18 @@ standalone = [
|
||||
"python-dotenv",
|
||||
"whitenoise",
|
||||
]
|
||||
postgres = [
|
||||
"psycopg2-binary",
|
||||
]
|
||||
mcp = [
|
||||
"mcp-gate @ git+https://github.com/RunLit/mcp-gate.git@v0.1.0",
|
||||
"uvicorn",
|
||||
]
|
||||
dev = [
|
||||
"ruff",
|
||||
"pytest",
|
||||
"pytest-django",
|
||||
"anyio",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -59,3 +67,7 @@ bambu_run = [
|
||||
"templates/bambu_run/*.html",
|
||||
"static/bambu_run/**/*",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
DJANGO_SETTINGS_MODULE = "tests.settings"
|
||||
python_files = "test_*.py"
|
||||
|
||||
46
setup.sh
46
setup.sh
@@ -1,8 +1,21 @@
|
||||
#!/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
|
||||
# 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"
|
||||
@@ -98,8 +111,8 @@ else:
|
||||
print(' opencv stub already exists')
|
||||
"
|
||||
|
||||
"$VENV_DIR/bin/pip" install --quiet --upgrade pip
|
||||
"$VENV_DIR/bin/pip" install --quiet ".[standalone]"
|
||||
"$VENV_DIR/bin/pip" install $PIP_QUIET_FLAG --upgrade pip
|
||||
"$VENV_DIR/bin/pip" install $PIP_QUIET_FLAG ".[standalone]"
|
||||
|
||||
# ── 3. Interactive .env ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -201,6 +214,25 @@ sed "s|{{REPO_DIR}}|$REPO_DIR|g; s|{{VENV_DIR}}|$VENV_DIR|g" \
|
||||
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 || \
|
||||
@@ -255,9 +287,17 @@ 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
|
||||
|
||||
@@ -63,16 +63,29 @@ TEMPLATES = [
|
||||
|
||||
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.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
DATABASES = {
|
||||
if os.environ.get("DB_ENGINE") == "postgresql":
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"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
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
@@ -111,6 +124,10 @@ PRINTER_IP = os.environ.get("PRINTER_IP", "")
|
||||
ACCESS_TOKEN = os.environ.get("ACCESS_TOKEN", "")
|
||||
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 = {
|
||||
"version": 1,
|
||||
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
30
tests/conftest.py
Normal file
30
tests/conftest.py
Normal 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
52
tests/settings.py
Normal 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"
|
||||
25
tests/test_ams_type_from_info.py
Normal file
25
tests/test_ams_type_from_info.py
Normal 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
68
tests/test_diagnostics.py
Normal 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."
|
||||
185
tests/test_filament_context.py
Normal file
185
tests/test_filament_context.py
Normal 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()
|
||||
121
tests/test_hotend_collection.py
Normal file
121
tests/test_hotend_collection.py
Normal 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
|
||||
128
tests/test_hotend_dashboard.py
Normal file
128
tests/test_hotend_dashboard.py
Normal 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
|
||||
100
tests/test_hotend_parsing.py
Normal file
100
tests/test_hotend_parsing.py
Normal 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
137
tests/test_mcp_filaments.py
Normal 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
196
tests/test_mcp_jobs.py
Normal 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}
|
||||
24
tests/test_mcp_server_command.py
Normal file
24
tests/test_mcp_server_command.py
Normal 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
140
tests/test_mcp_shapes.py
Normal 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
283
tests/test_mcp_status.py
Normal 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
84
tests/test_mcp_toolkit.py
Normal 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()
|
||||
104
tests/test_multi_ams_collection.py
Normal file
104
tests/test_multi_ams_collection.py
Normal 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
|
||||
90
tests/test_multi_device_collection.py
Normal file
90
tests/test_multi_device_collection.py
Normal 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()
|
||||
71
tests/test_printer_device_scoping.py
Normal file
71
tests/test_printer_device_scoping.py
Normal 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"]) == []
|
||||
233
tests/test_printer_query_performance.py
Normal file
233
tests/test_printer_query_performance.py
Normal 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
|
||||
78
tests/test_printer_routing.py
Normal file
78
tests/test_printer_routing.py
Normal 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]
|
||||
78
tests/test_resolve_printer_device.py
Normal file
78
tests/test_resolve_printer_device.py
Normal 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
|
||||
43
tests/test_vortek_groundwork.py
Normal file
43
tests/test_vortek_groundwork.py
Normal 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
5
tests/urls.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.urls import include, path
|
||||
|
||||
urlpatterns = [
|
||||
path("", include("bambu_run.urls")),
|
||||
]
|
||||
Reference in New Issue
Block a user