mirror of
https://github.com/RunLit/Bambu-Run.git
synced 2026-10-21 23:39:01 +01:00
Compare commits
40 Commits
v0.1.8
...
feature/ba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e4108fb32 | ||
|
|
70731c637f | ||
|
|
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 |
13
.github/workflows/bump-version.yml
vendored
13
.github/workflows/bump-version.yml
vendored
@@ -5,6 +5,10 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: bump-version-main
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
bump-version:
|
bump-version:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -49,8 +53,17 @@ jobs:
|
|||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
git add pyproject.toml
|
git add pyproject.toml
|
||||||
git commit -m "chore: bump version to ${{ steps.bump.outputs.new_version }} [skip ci]"
|
git commit -m "chore: bump version to ${{ steps.bump.outputs.new_version }} [skip ci]"
|
||||||
|
git pull --rebase origin main
|
||||||
git push
|
git push
|
||||||
- name: Tag the release
|
- name: Tag the release
|
||||||
run: |
|
run: |
|
||||||
git tag "v${{ steps.bump.outputs.new_version }}"
|
git tag "v${{ steps.bump.outputs.new_version }}"
|
||||||
git push origin "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/
|
||||||
@@ -22,19 +22,20 @@ RUN pip install --no-cache-dir bambu-lab-cloud-api --no-deps && \
|
|||||||
(d / 'INSTALLER').write_text('pip\n'); \
|
(d / 'INSTALLER').write_text('pip\n'); \
|
||||||
(d / 'RECORD').write_text('')"
|
(d / 'RECORD').write_text('')"
|
||||||
|
|
||||||
# Install project and remaining dependencies (pip sees opencv-python already satisfied)
|
# Install standalone dependencies first — cached unless pyproject.toml changes
|
||||||
COPY pyproject.toml .
|
COPY pyproject.toml .
|
||||||
RUN pip install --no-cache-dir ".[standalone,mcp]"
|
RUN pip install --no-cache-dir ".[standalone,mcp]"
|
||||||
|
|
||||||
# Copy application code
|
# Copy full source and install the local package (no-deps: already installed above)
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN pip install --no-cache-dir --no-deps .
|
||||||
|
|
||||||
# Create data directory for SQLite
|
# Create data directory for SQLite
|
||||||
RUN mkdir -p /app/data
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
# Collect static files
|
# Collect static files from the locally installed package
|
||||||
ENV DJANGO_SETTINGS_MODULE=standalone.settings
|
ENV DJANGO_SETTINGS_MODULE=standalone.settings
|
||||||
RUN python standalone/manage.py collectstatic --noinput 2>/dev/null || true
|
RUN python standalone/manage.py collectstatic --noinput
|
||||||
|
|
||||||
# Supervisor config to run both web and collector
|
# Supervisor config to run both web and collector
|
||||||
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||||
|
|||||||
117
README.md
117
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
|
## Table of Contents
|
||||||
|
|
||||||
|
- [What It Looks Like](#what-it-looks-like)
|
||||||
- [Native Setup (Recommended for Raspberry Pi)](#native-setup-recommended-for-raspberry-pi)
|
- [Native Setup (Recommended for Raspberry Pi)](#native-setup-recommended-for-raspberry-pi)
|
||||||
- [What You'll Need](#what-youll-need)
|
- [What You'll Need](#what-youll-need)
|
||||||
- [Clone and run setup.sh](#clone-and-run-setupsh)
|
- [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)
|
- [Troubleshooting (Native)](#troubleshooting-native)
|
||||||
- [Docker Setup](#docker-setup)
|
- [Docker Setup](#docker-setup)
|
||||||
- [Batch Importing Filament Colors and Filament Types](#batch-importing-filament-colors-and-filament-types)
|
- [Batch Importing Filament Colors and Filament Types](#batch-importing-filament-colors-and-filament-types)
|
||||||
|
- [MCP Server (AI Agent Access)](#mcp-server-ai-agent-access)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -118,6 +153,46 @@ cd Bambu-Run
|
|||||||
bash setup.sh
|
bash setup.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Pointing at an External Postgres Database (Read-Only)
|
||||||
|
|
||||||
|
If you already have printer data in a Postgres database (e.g. shared with
|
||||||
|
another Django project using the same `infrastructure_*` tables) and want this
|
||||||
|
install to act as a read-only viewer instead of collecting its own data:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Bambu-Run
|
||||||
|
|
||||||
|
# 1. Stop and disable the collector — it's the only thing that writes to the
|
||||||
|
# database (PrinterMetrics, FilamentSnapshot, PrintJob). A read-only DB
|
||||||
|
# user will make it crash-loop, so turn it off.
|
||||||
|
systemctl --user stop bambu-run-collector.service
|
||||||
|
systemctl --user disable bambu-run-collector.service
|
||||||
|
|
||||||
|
# 2. Install the Postgres driver
|
||||||
|
.venv/bin/pip install ".[postgres]"
|
||||||
|
|
||||||
|
# 3. Edit .env — point at Postgres with a read-only user, and switch sessions
|
||||||
|
# to signed cookies so login doesn't need to write to django_session
|
||||||
|
cat >> .env <<'EOF'
|
||||||
|
DB_ENGINE=postgresql
|
||||||
|
DB_NAME=your_db_name
|
||||||
|
DB_USER=your_readonly_user
|
||||||
|
DB_PASSWORD=your_readonly_password
|
||||||
|
DB_HOST=your_db_host
|
||||||
|
DB_PORT=5432
|
||||||
|
SESSION_ENGINE=signed_cookies
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 4. Restart the web service (do NOT run `manage.py migrate` — the schema
|
||||||
|
# already exists in that database, and a read-only user can't apply
|
||||||
|
# migrations anyway)
|
||||||
|
systemctl --user restart bambu-run-web.service
|
||||||
|
```
|
||||||
|
|
||||||
|
You'll need a login that already exists in that database's `auth_user`
|
||||||
|
table — this install can't create one itself without write access. If MCP is
|
||||||
|
enabled, it becomes read-only too, since its tools only ever `SELECT`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Docker Setup
|
## Docker Setup
|
||||||
@@ -262,3 +337,45 @@ Processing: PLA Basic.txt → type='PLA' sub_type='PLA Basic'
|
|||||||
- **Created** — new color entries added to the database
|
- **Created** — new color entries added to the database
|
||||||
- **Skipped (duplicate)** — already existed, not changed
|
- **Skipped (duplicate)** — already existed, not changed
|
||||||
- **Skipped (no type)** — only shown if `--no-auto-create-filament-type` is used and the filament type isn't in the database yet
|
- **Skipped (no type)** — only shown if `--no-auto-create-filament-type` is used and the filament type isn't in the database yet
|
||||||
|
|
||||||
|
## MCP Server (AI Agent Access)
|
||||||
|
|
||||||
|
Bambu-Run exposes printer status, print history, and filament inventory as
|
||||||
|
read-only [MCP](https://modelcontextprotocol.io/) tools, so an AI agent (e.g.
|
||||||
|
Claude Desktop, Claude Code) can query your printer directly.
|
||||||
|
|
||||||
|
**Native install (`setup.sh`):** if you declined MCP during setup (or ran
|
||||||
|
`setup.sh` before MCP support existed), enable it any time with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./native/bambu-run.sh enable-mcp # install deps, create + start the systemd service
|
||||||
|
./native/bambu-run.sh disable-mcp # stop and remove it
|
||||||
|
```
|
||||||
|
|
||||||
|
**Manual / library install:** install the extra:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install 'bambu-run[mcp]'
|
||||||
|
```
|
||||||
|
|
||||||
|
**stdio** (for a local agent on the same machine — e.g. Claude Desktop):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py bambu_mcp_server --transport stdio
|
||||||
|
```
|
||||||
|
|
||||||
|
**HTTP** (for a remote agent, or multiple callers over the network):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export BAMBU_RUN_MCP_API_KEY=your-secret-key
|
||||||
|
python manage.py bambu_mcp_server --transport http --host 0.0.0.0 --port 8808
|
||||||
|
```
|
||||||
|
|
||||||
|
`BAMBU_RUN_MCP_API_KEY` is **required** for HTTP — the server refuses to start
|
||||||
|
without it, since an unauthenticated network MCP endpoint would expose your
|
||||||
|
printer and filament data to anyone who can reach the port. stdio needs no key
|
||||||
|
(the OS process boundary is the trust boundary there).
|
||||||
|
|
||||||
|
If serving behind a reverse proxy on a real domain, pass
|
||||||
|
`--allowed-host your.domain.com` (repeatable) — otherwise the SDK's
|
||||||
|
DNS-rebinding guard rejects requests with a real `Host` header.
|
||||||
|
|||||||
@@ -64,14 +64,6 @@ class _Settings:
|
|||||||
def MCP_PORT(self):
|
def MCP_PORT(self):
|
||||||
return get_setting("BAMBU_RUN_MCP_PORT", 8808)
|
return get_setting("BAMBU_RUN_MCP_PORT", 8808)
|
||||||
|
|
||||||
@property
|
|
||||||
def MCP_AUTH_BACKEND(self):
|
|
||||||
return get_setting("BAMBU_RUN_MCP_AUTH_BACKEND", None)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def MCP_HIDE_SENSITIVE(self):
|
|
||||||
return get_setting("BAMBU_RUN_MCP_HIDE_SENSITIVE", False)
|
|
||||||
|
|
||||||
# Cloud sync settings
|
# Cloud sync settings
|
||||||
@property
|
@property
|
||||||
def CLOUD_SYNC_ENABLED(self):
|
def CLOUD_SYNC_ENABLED(self):
|
||||||
@@ -81,5 +73,12 @@ class _Settings:
|
|||||||
def CLOUD_SYNC_DAYS(self):
|
def CLOUD_SYNC_DAYS(self):
|
||||||
return get_setting("BAMBU_RUN_CLOUD_SYNC_DAYS", 30)
|
return get_setting("BAMBU_RUN_CLOUD_SYNC_DAYS", 30)
|
||||||
|
|
||||||
|
# Seconds of silence on the MQTT report topic that, once broken by a new
|
||||||
|
# message, is treated as "the printer was probably offline" and triggers
|
||||||
|
# a pushall re-sync instead of trusting the partial delta to fill in stale
|
||||||
|
# fields left over from before the gap.
|
||||||
|
@property
|
||||||
|
def MQTT_RESYNC_GAP_SECONDS(self):
|
||||||
|
return get_setting("BAMBU_RUN_MQTT_RESYNC_GAP_SECONDS", 90)
|
||||||
|
|
||||||
app_settings = _Settings()
|
app_settings = _Settings()
|
||||||
|
|||||||
@@ -1,355 +1,110 @@
|
|||||||
"""
|
"""Run the Bambu-Run MCP server (standalone, single-owner).
|
||||||
Management command to run the Bambu-Run MCP server.
|
|
||||||
|
|
||||||
Supports SSE (network) and stdio (local) transports.
|
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python manage.py bambu_mcp_server
|
|
||||||
python manage.py bambu_mcp_server --transport sse --host 0.0.0.0 --port 8808
|
|
||||||
python manage.py bambu_mcp_server --transport stdio
|
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
|
import logging
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
|
||||||
logger = logging.getLogger("bambu_run.mcp")
|
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):
|
class Command(BaseCommand):
|
||||||
help = "Run the Bambu-Run MCP server for AI agent access"
|
help = "Run the Bambu-Run MCP server for AI agent access (standalone, single-owner)"
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
from bambu_run.conf import app_settings
|
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(
|
parser.add_argument(
|
||||||
"--transport",
|
"--allowed-host",
|
||||||
choices=["sse", "stdio"],
|
action="append",
|
||||||
default="sse",
|
default=None,
|
||||||
help="Transport mode (default: sse)",
|
help=(
|
||||||
)
|
"Host header this server should accept (repeatable). Required "
|
||||||
parser.add_argument(
|
"when reachable via a real domain or reverse proxy, e.g. "
|
||||||
"--host",
|
"--allowed-host mcp.example.com. Omit for local-only testing "
|
||||||
default=app_settings.MCP_HOST,
|
"on 127.0.0.1/localhost."
|
||||||
help=f"Host to bind to (default: {app_settings.MCP_HOST})",
|
),
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--port",
|
|
||||||
type=int,
|
|
||||||
default=app_settings.MCP_PORT,
|
|
||||||
help=f"Port to listen on (default: {app_settings.MCP_PORT})",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
try:
|
try:
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp_gate import run_stdio, asgi_app
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise CommandError(
|
raise CommandError(
|
||||||
"The 'mcp' package is required. Install it with: pip install 'bambu-run[mcp]'"
|
"The 'mcp-gate' package is required. Install it with: pip install 'bambu-run[mcp]'"
|
||||||
)
|
)
|
||||||
|
|
||||||
from asgiref.sync import sync_to_async
|
|
||||||
from bambu_run.conf import app_settings
|
from bambu_run.conf import app_settings
|
||||||
from bambu_run import mcp_tools
|
from bambu_run.mcp.toolkit import build_toolkit
|
||||||
|
|
||||||
transport = options["transport"]
|
transport = options["transport"]
|
||||||
host = options["host"]
|
kit = build_toolkit()
|
||||||
port = options["port"]
|
|
||||||
|
|
||||||
mcp = FastMCP(
|
if transport == "stdio":
|
||||||
"Bambu-Run",
|
gate = _build_gate(kit, transport="stdio")
|
||||||
instructions=(
|
self.stdout.write(self.style.SUCCESS("Starting Bambu-Run MCP server (stdio)"))
|
||||||
"Bambu-Run MCP server provides read-only access to 3D printer data "
|
run_stdio(gate)
|
||||||
"including live printer status, filament inventory, print history, "
|
return
|
||||||
"temperature trends, and diagnostics. All data comes from Bambu Lab "
|
|
||||||
"printers monitored via MQTT."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Register Tools ───────────────────────────────────────────────
|
try:
|
||||||
|
gate = _build_gate(kit, transport="http", api_key=app_settings.MCP_API_KEY)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise CommandError(str(exc))
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def get_printer_status(printer_id: int | None = None) -> str:
|
|
||||||
"""Get current live status of printer(s) including temperatures, progress, AMS slots, and errors.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
printer_id: Optional printer ID to filter. Omit for all printers.
|
|
||||||
"""
|
|
||||||
return await sync_to_async(mcp_tools.get_printer_status)(printer_id=printer_id)
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def list_printers() -> str:
|
|
||||||
"""List all registered printers with their model, serial, IP, and active status."""
|
|
||||||
return await sync_to_async(mcp_tools.list_printers)()
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def get_print_history(
|
|
||||||
status: str | None = None,
|
|
||||||
days: int | None = None,
|
|
||||||
project_name: str | None = None,
|
|
||||||
limit: int = 20,
|
|
||||||
) -> str:
|
|
||||||
"""Get print job history with optional filters.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
status: Filter by status (FINISH, FAILED, CANCELLED).
|
|
||||||
days: Only show jobs from the last N days.
|
|
||||||
project_name: Filter by project name (partial match).
|
|
||||||
limit: Maximum number of results (default 20).
|
|
||||||
"""
|
|
||||||
return await sync_to_async(mcp_tools.get_print_history)(
|
|
||||||
status=status, days=days, project_name=project_name, limit=limit
|
|
||||||
)
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def get_print_job_detail(job_id: int) -> str:
|
|
||||||
"""Get detailed information about a single print job including filament usage.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
job_id: The print job ID.
|
|
||||||
"""
|
|
||||||
return await sync_to_async(mcp_tools.get_print_job_detail)(job_id=job_id)
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def list_filaments(
|
|
||||||
type: str | None = None,
|
|
||||||
brand: str | None = None,
|
|
||||||
color: str | None = None,
|
|
||||||
loaded_in_ams: bool | None = None,
|
|
||||||
low_filament: bool | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""List filament inventory with optional filters.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
type: Filter by material type (PLA, PETG, ABS, etc.).
|
|
||||||
brand: Filter by brand name (partial match).
|
|
||||||
color: Filter by color name (partial match).
|
|
||||||
loaded_in_ams: Filter by whether spool is currently in AMS.
|
|
||||||
low_filament: If true, only show spools with <=20% remaining.
|
|
||||||
"""
|
|
||||||
return await sync_to_async(mcp_tools.list_filaments)(
|
|
||||||
type=type, brand=brand, color=color,
|
|
||||||
loaded_in_ams=loaded_in_ams, low_filament=low_filament,
|
|
||||||
)
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def get_filament_detail(filament_id: int) -> str:
|
|
||||||
"""Get detailed information about a single filament spool including usage history.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
filament_id: The filament spool ID.
|
|
||||||
"""
|
|
||||||
return await sync_to_async(mcp_tools.get_filament_detail)(filament_id=filament_id)
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def get_temperature_history(
|
|
||||||
printer_id: int | None = None,
|
|
||||||
hours: int = 6,
|
|
||||||
metric: str = "all",
|
|
||||||
) -> str:
|
|
||||||
"""Get temperature trends (avg/min/max) over recent hours.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
printer_id: Optional printer ID to filter.
|
|
||||||
hours: Number of hours to look back (default 6).
|
|
||||||
metric: Which sensor to show: 'all', 'nozzle', 'bed', or 'chamber'.
|
|
||||||
"""
|
|
||||||
return await sync_to_async(mcp_tools.get_temperature_history)(
|
|
||||||
printer_id=printer_id, hours=hours, metric=metric
|
|
||||||
)
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def get_filament_usage_stats(days: int = 30, group_by: str = "type") -> str:
|
|
||||||
"""Get aggregate filament consumption statistics.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
days: Number of days to look back (default 30).
|
|
||||||
group_by: Group results by 'type', 'color', or 'spool'.
|
|
||||||
"""
|
|
||||||
return await sync_to_async(mcp_tools.get_filament_usage_stats)(days=days, group_by=group_by)
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def get_printer_health(printer_id: int | None = None) -> str:
|
|
||||||
"""Get printer diagnostics including errors, humidity, WiFi signal, and recent failures.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
printer_id: Optional printer ID to filter. Omit for all printers.
|
|
||||||
"""
|
|
||||||
return await sync_to_async(mcp_tools.get_printer_health)(printer_id=printer_id)
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def search_print_jobs(query: str) -> str:
|
|
||||||
"""Search print jobs by project name or gcode filename.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Search text (partial match on project name or gcode file).
|
|
||||||
"""
|
|
||||||
return await sync_to_async(mcp_tools.search_print_jobs)(query=query)
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def get_printing_summary(days: int = 7) -> str:
|
|
||||||
"""Get high-level printing activity summary including job counts, success rate, and top projects.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
days: Number of days to summarize (default 7).
|
|
||||||
"""
|
|
||||||
return await sync_to_async(mcp_tools.get_printing_summary)(days=days)
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def find_compatible_filament(
|
|
||||||
type: str,
|
|
||||||
min_remaining_percent: int = 10,
|
|
||||||
color: str | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Find filament spools matching material type and optional criteria.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
type: Material type to search for (PLA, PETG, ABS, etc.).
|
|
||||||
min_remaining_percent: Minimum remaining percentage (default 10).
|
|
||||||
color: Optional color filter (partial match).
|
|
||||||
"""
|
|
||||||
return await sync_to_async(mcp_tools.find_compatible_filament)(
|
|
||||||
type=type, min_remaining_percent=min_remaining_percent, color=color
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Register Resources ───────────────────────────────────────────
|
|
||||||
|
|
||||||
@mcp.resource("bambu://printers")
|
|
||||||
async def res_printers() -> str:
|
|
||||||
"""List all registered printers."""
|
|
||||||
return await sync_to_async(mcp_tools.resource_printers)()
|
|
||||||
|
|
||||||
@mcp.resource("bambu://printers/{printer_id}/status")
|
|
||||||
async def res_printer_status(printer_id: int) -> str:
|
|
||||||
"""Get latest status for a specific printer."""
|
|
||||||
return await sync_to_async(mcp_tools.resource_printer_status)(printer_id)
|
|
||||||
|
|
||||||
@mcp.resource("bambu://filaments")
|
|
||||||
async def res_filaments() -> str:
|
|
||||||
"""Full filament inventory."""
|
|
||||||
return await sync_to_async(mcp_tools.resource_filaments)()
|
|
||||||
|
|
||||||
@mcp.resource("bambu://filaments/{filament_id}")
|
|
||||||
async def res_filament_detail(filament_id: int) -> str:
|
|
||||||
"""Single filament spool with usage history."""
|
|
||||||
return await sync_to_async(mcp_tools.resource_filament_detail)(filament_id)
|
|
||||||
|
|
||||||
@mcp.resource("bambu://print-jobs/recent")
|
|
||||||
async def res_recent_jobs() -> str:
|
|
||||||
"""Last 20 print jobs."""
|
|
||||||
return await sync_to_async(mcp_tools.resource_recent_print_jobs)()
|
|
||||||
|
|
||||||
@mcp.resource("bambu://filament-types")
|
|
||||||
async def res_filament_types() -> str:
|
|
||||||
"""Filament type registry."""
|
|
||||||
return await sync_to_async(mcp_tools.resource_filament_types)()
|
|
||||||
|
|
||||||
@mcp.resource("bambu://filament-colors")
|
|
||||||
async def res_filament_colors() -> str:
|
|
||||||
"""Filament color database."""
|
|
||||||
return await sync_to_async(mcp_tools.resource_filament_colors)()
|
|
||||||
|
|
||||||
# ── Register Prompts ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
@mcp.prompt()
|
|
||||||
async def printer_check_in(printer_id: int | None = None) -> str:
|
|
||||||
"""Full printer status briefing with health check and recent prints.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
printer_id: Optional printer ID. Omit for all printers.
|
|
||||||
"""
|
|
||||||
return await sync_to_async(mcp_tools.prompt_printer_check_in)(printer_id=printer_id)
|
|
||||||
|
|
||||||
@mcp.prompt()
|
|
||||||
async def filament_inventory_report() -> str:
|
|
||||||
"""Comprehensive filament inventory report with low-stock warnings."""
|
|
||||||
return await sync_to_async(mcp_tools.prompt_filament_inventory_report)()
|
|
||||||
|
|
||||||
@mcp.prompt()
|
|
||||||
async def print_job_review(job_id: int) -> str:
|
|
||||||
"""Detailed review of a completed print job.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
job_id: The print job ID to review.
|
|
||||||
"""
|
|
||||||
return await sync_to_async(mcp_tools.prompt_print_job_review)(job_id)
|
|
||||||
|
|
||||||
@mcp.prompt()
|
|
||||||
async def weekly_printing_digest() -> str:
|
|
||||||
"""Weekly printing activity summary with filament usage breakdown."""
|
|
||||||
return await sync_to_async(mcp_tools.prompt_weekly_digest)()
|
|
||||||
|
|
||||||
@mcp.prompt()
|
|
||||||
async def troubleshoot_printer(printer_id: int | None = None) -> str:
|
|
||||||
"""Diagnose printer issues using recent health data, status, and temperatures.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
printer_id: Optional printer ID. Omit for all printers.
|
|
||||||
"""
|
|
||||||
return await sync_to_async(mcp_tools.prompt_troubleshoot_printer)(printer_id=printer_id)
|
|
||||||
|
|
||||||
# ── Auth middleware for SSE ───────────────────────────────────────
|
|
||||||
|
|
||||||
api_key = app_settings.MCP_API_KEY
|
|
||||||
auth_backend = app_settings.MCP_AUTH_BACKEND
|
|
||||||
|
|
||||||
if api_key or auth_backend:
|
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
|
||||||
from starlette.responses import JSONResponse
|
|
||||||
|
|
||||||
class AuthMiddleware(BaseHTTPMiddleware):
|
|
||||||
async def dispatch(self, request, call_next):
|
|
||||||
# Custom auth backend takes priority
|
|
||||||
if auth_backend:
|
|
||||||
if not auth_backend(request):
|
|
||||||
return JSONResponse(
|
|
||||||
{"error": "Unauthorized"}, status_code=401
|
|
||||||
)
|
|
||||||
return await call_next(request)
|
|
||||||
|
|
||||||
# API key auth
|
|
||||||
if api_key:
|
|
||||||
auth_header = request.headers.get("Authorization", "")
|
|
||||||
if auth_header == f"Bearer {api_key}":
|
|
||||||
return await call_next(request)
|
|
||||||
return JSONResponse(
|
|
||||||
{"error": "Invalid or missing API key"}, status_code=401
|
|
||||||
)
|
|
||||||
|
|
||||||
return await call_next(request)
|
|
||||||
|
|
||||||
# Attach middleware — FastMCP's SSE app is a Starlette app
|
|
||||||
original_sse_app = mcp.sse_app
|
|
||||||
|
|
||||||
def patched_sse_app():
|
|
||||||
app = original_sse_app()
|
|
||||||
app.add_middleware(AuthMiddleware)
|
|
||||||
return app
|
|
||||||
|
|
||||||
mcp.sse_app = patched_sse_app
|
|
||||||
|
|
||||||
# ── Run ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
if transport == "sse":
|
|
||||||
try:
|
try:
|
||||||
import uvicorn
|
import uvicorn
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise CommandError(
|
raise CommandError("uvicorn is required for HTTP transport. Install it with: pip install uvicorn")
|
||||||
"uvicorn is required for SSE transport. Install it with: pip install uvicorn"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.stdout.write(
|
asgi_kwargs = {}
|
||||||
self.style.SUCCESS(
|
if options["allowed_host"]:
|
||||||
f"Starting Bambu-Run MCP server (SSE) on {host}:{port}"
|
from mcp.server.transport_security import TransportSecuritySettings
|
||||||
)
|
asgi_kwargs["transport_security"] = TransportSecuritySettings(allowed_hosts=options["allowed_host"])
|
||||||
)
|
|
||||||
self.stdout.write(
|
app = asgi_app(gate, **asgi_kwargs)
|
||||||
f"Connect with: http://{host}:{port}/sse"
|
host, port = options["host"], options["port"]
|
||||||
)
|
self.stdout.write(self.style.SUCCESS(f"Starting Bambu-Run MCP server (HTTP) on {host}:{port}"))
|
||||||
app = mcp.sse_app()
|
self.stdout.write(f"Connect with: http://{host}:{port}/mcp (Authorization: Bearer <key>)")
|
||||||
uvicorn.run(app, host=host, port=port)
|
uvicorn.run(app, host=host, port=port)
|
||||||
else:
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.SUCCESS("Starting Bambu-Run MCP server (stdio)")
|
|
||||||
)
|
|
||||||
mcp.run(transport="stdio")
|
|
||||||
|
|||||||
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
|
||||||
@@ -1,728 +0,0 @@
|
|||||||
"""
|
|
||||||
Pure Django ORM query functions for MCP tools.
|
|
||||||
|
|
||||||
Zero dependency on the `mcp` package — returns markdown strings.
|
|
||||||
RAE can reuse these directly.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import timedelta
|
|
||||||
from decimal import Decimal
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
from django.db.models import Avg, Count, Max, Min, Q, Sum
|
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
from .conf import app_settings
|
|
||||||
|
|
||||||
|
|
||||||
def _local_dt(dt, fmt="%Y-%m-%d %H:%M %Z"):
|
|
||||||
"""Convert a UTC-aware datetime to the configured local timezone for display."""
|
|
||||||
if dt is None:
|
|
||||||
return "—"
|
|
||||||
tz = ZoneInfo(app_settings.TIMEZONE)
|
|
||||||
return dt.astimezone(tz).strftime(fmt)
|
|
||||||
|
|
||||||
|
|
||||||
def _redact(value, label="[redacted]"):
|
|
||||||
"""Redact sensitive values if MCP_HIDE_SENSITIVE is enabled."""
|
|
||||||
if app_settings.MCP_HIDE_SENSITIVE:
|
|
||||||
return label
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _job_name(job):
|
|
||||||
"""Return the best available display name for a print job.
|
|
||||||
|
|
||||||
Prefers cloud design_title (e.g., 'Planetary Gears Finger Fidget Spinners')
|
|
||||||
over the MQTT subtask_name (e.g., 'All variants at 0.16mm high quality').
|
|
||||||
Falls back to project_name for local/SD prints with no cloud task.
|
|
||||||
"""
|
|
||||||
if job.cloud_task_id and job.cloud_task and job.cloud_task.design_title:
|
|
||||||
return job.cloud_task.design_title
|
|
||||||
return job.project_name
|
|
||||||
|
|
||||||
|
|
||||||
def _format_duration(minutes):
|
|
||||||
"""Format minutes into human-readable duration."""
|
|
||||||
if minutes is None:
|
|
||||||
return "Unknown"
|
|
||||||
hours, mins = divmod(int(minutes), 60)
|
|
||||||
if hours > 0:
|
|
||||||
return f"{hours}h {mins}m"
|
|
||||||
return f"{mins}m"
|
|
||||||
|
|
||||||
|
|
||||||
def _format_temp(temp):
|
|
||||||
"""Format temperature value."""
|
|
||||||
if temp is None:
|
|
||||||
return "N/A"
|
|
||||||
return f"{temp}°C"
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Tools ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def get_printer_status(printer_id=None):
|
|
||||||
"""Current live status of printer(s) including temps, progress, AMS, errors."""
|
|
||||||
from .models import Printer, PrinterMetrics
|
|
||||||
|
|
||||||
printers = Printer.objects.filter(is_active=True)
|
|
||||||
if printer_id:
|
|
||||||
printers = printers.filter(id=printer_id)
|
|
||||||
|
|
||||||
if not printers.exists():
|
|
||||||
return "No printers found."
|
|
||||||
|
|
||||||
parts = []
|
|
||||||
for printer in printers:
|
|
||||||
metric = PrinterMetrics.objects.filter(device=printer).first()
|
|
||||||
if not metric:
|
|
||||||
parts.append(f"## {printer.name}\n**No data available yet.**\n")
|
|
||||||
continue
|
|
||||||
|
|
||||||
state = metric.gcode_state or "Unknown"
|
|
||||||
lines = [f"## Printer Status: {printer.name}"]
|
|
||||||
lines.append(f"**Model**: {printer.model} | **Serial**: {_redact(printer.serial_number)}")
|
|
||||||
lines.append(f"**IP**: {_redact(printer.ip_address)} | **Location**: {printer.location or 'N/A'}")
|
|
||||||
lines.append(f"**State**: {state}")
|
|
||||||
|
|
||||||
if metric.print_percent is not None and state == "RUNNING":
|
|
||||||
layer_info = ""
|
|
||||||
if metric.layer_num is not None and metric.total_layer_num:
|
|
||||||
layer_info = f" (Layer {metric.layer_num}/{metric.total_layer_num})"
|
|
||||||
lines.append(f"**Progress**: {metric.print_percent}%{layer_info}")
|
|
||||||
if metric.subtask_name:
|
|
||||||
lines.append(f"**Project**: {metric.subtask_name}")
|
|
||||||
if metric.remaining_time_min:
|
|
||||||
lines.append(f"**ETA**: {_format_duration(metric.remaining_time_min)} remaining")
|
|
||||||
|
|
||||||
# Temperatures
|
|
||||||
lines.append("")
|
|
||||||
lines.append("### Temperatures")
|
|
||||||
lines.append("| Component | Current | Target |")
|
|
||||||
lines.append("|-----------|---------|--------|")
|
|
||||||
lines.append(f"| Nozzle | {_format_temp(metric.nozzle_temp)} | {_format_temp(metric.nozzle_target_temp)} |")
|
|
||||||
lines.append(f"| Bed | {_format_temp(metric.bed_temp)} | {_format_temp(metric.bed_target_temp)} |")
|
|
||||||
lines.append(f"| Chamber | {_format_temp(metric.chamber_temp)} | - |")
|
|
||||||
|
|
||||||
# AMS filaments from JSON
|
|
||||||
if metric.filaments:
|
|
||||||
lines.append("")
|
|
||||||
lines.append("### AMS Slots")
|
|
||||||
lines.append("| Slot | Material | Color | Remaining |")
|
|
||||||
lines.append("|------|----------|-------|-----------|")
|
|
||||||
for f in metric.filaments:
|
|
||||||
slot = f.get("slot", "?")
|
|
||||||
ftype = f.get("sub_type") or f.get("type", "?")
|
|
||||||
color = f.get("color", "")
|
|
||||||
color_display = f"#{color[:6]}" if color and len(color) >= 6 else "?"
|
|
||||||
remain = f.get("remain_percent", "?")
|
|
||||||
lines.append(f"| {slot} | {ftype} | {color_display} | {remain}% |")
|
|
||||||
|
|
||||||
# Errors
|
|
||||||
if metric.has_errors or metric.hms:
|
|
||||||
lines.append("")
|
|
||||||
lines.append("### Alerts")
|
|
||||||
if metric.print_error:
|
|
||||||
lines.append(f"- Print error code: {metric.print_error}")
|
|
||||||
if metric.hms:
|
|
||||||
for msg in metric.hms[:5]:
|
|
||||||
lines.append(f"- HMS: {msg}")
|
|
||||||
|
|
||||||
lines.append(f"\n*Last updated: {_local_dt(metric.timestamp, '%Y-%m-%d %H:%M:%S %Z')}*")
|
|
||||||
parts.append("\n".join(lines))
|
|
||||||
|
|
||||||
return "\n\n---\n\n".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
def list_printers():
|
|
||||||
"""List all registered printers."""
|
|
||||||
from .models import Printer
|
|
||||||
|
|
||||||
printers = Printer.objects.all()
|
|
||||||
if not printers.exists():
|
|
||||||
return "No printers registered."
|
|
||||||
|
|
||||||
lines = ["# Printers", ""]
|
|
||||||
lines.append("| ID | Name | Model | Active | Serial | IP | Location |")
|
|
||||||
lines.append("|----|------|-------|--------|--------|----|----------|")
|
|
||||||
for p in printers:
|
|
||||||
lines.append(
|
|
||||||
f"| {p.id} | {p.name} | {p.model} | "
|
|
||||||
f"{'Yes' if p.is_active else 'No'} | "
|
|
||||||
f"{_redact(p.serial_number)} | {_redact(p.ip_address)} | "
|
|
||||||
f"{p.location or '-'} |"
|
|
||||||
)
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def get_print_history(status=None, days=None, project_name=None, limit=20):
|
|
||||||
"""Print job history with optional filters."""
|
|
||||||
from .models import PrintJob
|
|
||||||
|
|
||||||
qs = PrintJob.objects.select_related("device", "cloud_task")
|
|
||||||
|
|
||||||
if status:
|
|
||||||
qs = qs.filter(final_status__iexact=status)
|
|
||||||
if days:
|
|
||||||
cutoff = timezone.now() - timedelta(days=int(days))
|
|
||||||
qs = qs.filter(start_time__gte=cutoff)
|
|
||||||
if project_name:
|
|
||||||
qs = qs.filter(
|
|
||||||
Q(project_name__icontains=project_name)
|
|
||||||
| Q(cloud_task__design_title__icontains=project_name)
|
|
||||||
)
|
|
||||||
|
|
||||||
jobs = qs[:int(limit)]
|
|
||||||
if not jobs:
|
|
||||||
return "No print jobs found matching the criteria."
|
|
||||||
|
|
||||||
lines = ["# Print History", ""]
|
|
||||||
lines.append("| ID | Project | Printer | Status | Progress | Duration | Started |")
|
|
||||||
lines.append("|----|---------|---------|--------|----------|----------|---------|")
|
|
||||||
for j in jobs:
|
|
||||||
lines.append(
|
|
||||||
f"| {j.id} | {_job_name(j)} | {j.device.name} | "
|
|
||||||
f"{j.final_status or 'In Progress'} | {j.completion_percent}% | "
|
|
||||||
f"{_format_duration(j.duration_minutes)} | "
|
|
||||||
f"{_local_dt(j.start_time, '%Y-%m-%d %H:%M')} |"
|
|
||||||
)
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def get_print_job_detail(job_id):
|
|
||||||
"""Single job detail including filament usage."""
|
|
||||||
from .models import FilamentUsage, PrintJob
|
|
||||||
|
|
||||||
try:
|
|
||||||
job = PrintJob.objects.select_related("device", "cloud_task").get(id=job_id)
|
|
||||||
except PrintJob.DoesNotExist:
|
|
||||||
return f"Print job #{job_id} not found."
|
|
||||||
|
|
||||||
lines = [f"# Print Job: {_job_name(job)}", ""]
|
|
||||||
if job.cloud_task and job.cloud_task.design_title and job.cloud_task.design_title != job.project_name:
|
|
||||||
lines.append(f"**Plate**: {job.project_name}")
|
|
||||||
lines.append(f"**Printer**: {job.device.name}")
|
|
||||||
lines.append(f"**Status**: {job.final_status or 'In Progress'}")
|
|
||||||
lines.append(f"**Progress**: {job.completion_percent}%")
|
|
||||||
if job.gcode_file:
|
|
||||||
lines.append(f"**G-code**: {job.gcode_file}")
|
|
||||||
lines.append(f"**Started**: {_local_dt(job.start_time, '%Y-%m-%d %H:%M:%S %Z')}")
|
|
||||||
if job.end_time:
|
|
||||||
lines.append(f"**Ended**: {_local_dt(job.end_time, '%Y-%m-%d %H:%M:%S %Z')}")
|
|
||||||
lines.append(f"**Duration**: {_format_duration(job.duration_minutes)}")
|
|
||||||
if job.total_layers:
|
|
||||||
lines.append(f"**Total Layers**: {job.total_layers}")
|
|
||||||
|
|
||||||
# Filament usage
|
|
||||||
usages = FilamentUsage.objects.select_related("filament").filter(print_job=job)
|
|
||||||
if usages.exists():
|
|
||||||
lines.append("")
|
|
||||||
lines.append("### Filament Usage")
|
|
||||||
lines.append("| Spool | Material | Color | Consumed | Grams |")
|
|
||||||
lines.append("|-------|----------|-------|----------|-------|")
|
|
||||||
for u in usages:
|
|
||||||
f = u.filament
|
|
||||||
lines.append(
|
|
||||||
f"| {f.brand} {f.type} | {f.sub_type or f.type} | "
|
|
||||||
f"{f.color} | {u.consumed_percent or 0}% | "
|
|
||||||
f"{u.consumed_grams or '-'}g |"
|
|
||||||
)
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def list_filaments(type=None, brand=None, color=None, loaded_in_ams=None, low_filament=None):
|
|
||||||
"""Filament inventory with optional filters."""
|
|
||||||
from .models import Filament
|
|
||||||
|
|
||||||
qs = Filament.objects.all()
|
|
||||||
if type:
|
|
||||||
qs = qs.filter(type__iexact=type)
|
|
||||||
if brand:
|
|
||||||
qs = qs.filter(brand__icontains=brand)
|
|
||||||
if color:
|
|
||||||
qs = qs.filter(color__icontains=color)
|
|
||||||
if loaded_in_ams is not None:
|
|
||||||
qs = qs.filter(is_loaded_in_ams=loaded_in_ams)
|
|
||||||
if low_filament:
|
|
||||||
qs = qs.filter(remaining_percent__lte=20)
|
|
||||||
|
|
||||||
filaments = qs[:50]
|
|
||||||
if not filaments:
|
|
||||||
return "No filaments found matching the criteria."
|
|
||||||
|
|
||||||
lines = ["# Filament Inventory", ""]
|
|
||||||
lines.append(f"*{qs.count()} spools total*\n")
|
|
||||||
lines.append("| ID | Brand | Type | Color | Remaining | In AMS | Last Used |")
|
|
||||||
lines.append("|----|-------|------|-------|-----------|--------|-----------|")
|
|
||||||
for f in filaments:
|
|
||||||
color_display = f"{f.color}"
|
|
||||||
if f.color_hex:
|
|
||||||
color_display += f" ({f.color_hex})"
|
|
||||||
last_used = _local_dt(f.last_used, "%Y-%m-%d") if f.last_used else "-"
|
|
||||||
lines.append(
|
|
||||||
f"| {f.id} | {f.brand} | {f.sub_type or f.type} | "
|
|
||||||
f"{color_display} | {f.remaining_percent}% | "
|
|
||||||
f"{'Yes' if f.is_loaded_in_ams else 'No'} | {last_used} |"
|
|
||||||
)
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def get_filament_detail(filament_id):
|
|
||||||
"""Single spool detail with usage history."""
|
|
||||||
from .models import Filament, FilamentUsage
|
|
||||||
|
|
||||||
try:
|
|
||||||
f = Filament.objects.get(id=filament_id)
|
|
||||||
except Filament.DoesNotExist:
|
|
||||||
return f"Filament #{filament_id} not found."
|
|
||||||
|
|
||||||
lines = [f"# Filament: {f.brand} {f.type} - {f.color}", ""]
|
|
||||||
lines.append(f"**Type**: {f.sub_type or f.type}")
|
|
||||||
lines.append(f"**Brand**: {f.brand}")
|
|
||||||
lines.append(f"**Color**: {f.color} ({f.color_hex or 'N/A'})")
|
|
||||||
lines.append(f"**Remaining**: {f.remaining_percent}%")
|
|
||||||
if f.remaining_weight_grams:
|
|
||||||
lines.append(f"**Remaining Weight**: {f.remaining_weight_grams}g / {f.initial_weight_grams or '?'}g")
|
|
||||||
lines.append(f"**In AMS**: {'Yes (slot ' + str(f.current_tray_id) + ')' if f.is_loaded_in_ams else 'No'}")
|
|
||||||
lines.append(f"**Created By**: {f.created_by}")
|
|
||||||
if f.tray_uuid:
|
|
||||||
lines.append(f"**Serial**: {_redact(f.tray_uuid)}")
|
|
||||||
if f.purchase_date:
|
|
||||||
lines.append(f"**Purchased**: {f.purchase_date}")
|
|
||||||
if f.notes:
|
|
||||||
lines.append(f"**Notes**: {f.notes}")
|
|
||||||
|
|
||||||
# Usage history
|
|
||||||
usages = FilamentUsage.objects.select_related("print_job").filter(filament=f).order_by("-print_job__start_time")[:10]
|
|
||||||
if usages.exists():
|
|
||||||
lines.append("")
|
|
||||||
lines.append("### Recent Print Usage")
|
|
||||||
lines.append("| Job | Date | Consumed | Grams |")
|
|
||||||
lines.append("|-----|------|----------|-------|")
|
|
||||||
for u in usages:
|
|
||||||
lines.append(
|
|
||||||
f"| {u.print_job.project_name} | "
|
|
||||||
f"{_local_dt(u.print_job.start_time, '%Y-%m-%d')} | "
|
|
||||||
f"{u.consumed_percent or 0}% | {u.consumed_grams or '-'}g |"
|
|
||||||
)
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def get_temperature_history(printer_id=None, hours=6, metric="all"):
|
|
||||||
"""Temperature trends as summary stats (avg/min/max) over recent hours."""
|
|
||||||
from .models import Printer, PrinterMetrics
|
|
||||||
|
|
||||||
cutoff = timezone.now() - timedelta(hours=int(hours))
|
|
||||||
|
|
||||||
qs = PrinterMetrics.objects.filter(timestamp__gte=cutoff)
|
|
||||||
if printer_id:
|
|
||||||
qs = qs.filter(device_id=printer_id)
|
|
||||||
|
|
||||||
if not qs.exists():
|
|
||||||
return f"No temperature data in the last {hours} hours."
|
|
||||||
|
|
||||||
printers = Printer.objects.filter(
|
|
||||||
id__in=qs.values_list("device_id", flat=True).distinct()
|
|
||||||
)
|
|
||||||
|
|
||||||
parts = [f"# Temperature History (last {hours}h)", ""]
|
|
||||||
for printer in printers:
|
|
||||||
pqs = qs.filter(device=printer)
|
|
||||||
stats = pqs.aggregate(
|
|
||||||
nozzle_avg=Avg("nozzle_temp"),
|
|
||||||
nozzle_min=Min("nozzle_temp"),
|
|
||||||
nozzle_max=Max("nozzle_temp"),
|
|
||||||
bed_avg=Avg("bed_temp"),
|
|
||||||
bed_min=Min("bed_temp"),
|
|
||||||
bed_max=Max("bed_temp"),
|
|
||||||
chamber_avg=Avg("chamber_temp"),
|
|
||||||
chamber_min=Min("chamber_temp"),
|
|
||||||
chamber_max=Max("chamber_temp"),
|
|
||||||
)
|
|
||||||
|
|
||||||
parts.append(f"## {printer.name}")
|
|
||||||
parts.append(f"*{pqs.count()} data points*\n")
|
|
||||||
parts.append("| Sensor | Avg | Min | Max |")
|
|
||||||
parts.append("|--------|-----|-----|-----|")
|
|
||||||
|
|
||||||
if metric in ("all", "nozzle"):
|
|
||||||
parts.append(
|
|
||||||
f"| Nozzle | {_format_temp(stats['nozzle_avg'])} | "
|
|
||||||
f"{_format_temp(stats['nozzle_min'])} | {_format_temp(stats['nozzle_max'])} |"
|
|
||||||
)
|
|
||||||
if metric in ("all", "bed"):
|
|
||||||
parts.append(
|
|
||||||
f"| Bed | {_format_temp(stats['bed_avg'])} | "
|
|
||||||
f"{_format_temp(stats['bed_min'])} | {_format_temp(stats['bed_max'])} |"
|
|
||||||
)
|
|
||||||
if metric in ("all", "chamber"):
|
|
||||||
parts.append(
|
|
||||||
f"| Chamber | {_format_temp(stats['chamber_avg'])} | "
|
|
||||||
f"{_format_temp(stats['chamber_min'])} | {_format_temp(stats['chamber_max'])} |"
|
|
||||||
)
|
|
||||||
parts.append("")
|
|
||||||
|
|
||||||
return "\n".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
def get_filament_usage_stats(days=30, group_by="type"):
|
|
||||||
"""Aggregate filament consumption statistics."""
|
|
||||||
from .models import FilamentUsage
|
|
||||||
|
|
||||||
cutoff = timezone.now() - timedelta(days=int(days))
|
|
||||||
qs = FilamentUsage.objects.filter(
|
|
||||||
print_job__start_time__gte=cutoff,
|
|
||||||
consumed_grams__isnull=False,
|
|
||||||
).select_related("filament")
|
|
||||||
|
|
||||||
if not qs.exists():
|
|
||||||
return f"No filament usage data in the last {days} days."
|
|
||||||
|
|
||||||
lines = [f"# Filament Usage Stats (last {days} days)", ""]
|
|
||||||
|
|
||||||
if group_by == "type":
|
|
||||||
stats = (
|
|
||||||
qs.values("filament__type")
|
|
||||||
.annotate(
|
|
||||||
total_grams=Sum("consumed_grams"),
|
|
||||||
total_percent=Sum("consumed_percent"),
|
|
||||||
job_count=Count("print_job", distinct=True),
|
|
||||||
)
|
|
||||||
.order_by("-total_grams")
|
|
||||||
)
|
|
||||||
lines.append("| Type | Total Grams | Jobs | Avg Grams/Job |")
|
|
||||||
lines.append("|------|-------------|------|---------------|")
|
|
||||||
for s in stats:
|
|
||||||
avg = s["total_grams"] / s["job_count"] if s["job_count"] else 0
|
|
||||||
lines.append(
|
|
||||||
f"| {s['filament__type']} | {s['total_grams']}g | "
|
|
||||||
f"{s['job_count']} | {avg:.0f}g |"
|
|
||||||
)
|
|
||||||
elif group_by == "color":
|
|
||||||
stats = (
|
|
||||||
qs.values("filament__color", "filament__type")
|
|
||||||
.annotate(total_grams=Sum("consumed_grams"), job_count=Count("print_job", distinct=True))
|
|
||||||
.order_by("-total_grams")
|
|
||||||
)
|
|
||||||
lines.append("| Color | Type | Total Grams | Jobs |")
|
|
||||||
lines.append("|-------|------|-------------|------|")
|
|
||||||
for s in stats:
|
|
||||||
lines.append(
|
|
||||||
f"| {s['filament__color']} | {s['filament__type']} | "
|
|
||||||
f"{s['total_grams']}g | {s['job_count']} |"
|
|
||||||
)
|
|
||||||
elif group_by == "spool":
|
|
||||||
stats = (
|
|
||||||
qs.values("filament__id", "filament__brand", "filament__type", "filament__color")
|
|
||||||
.annotate(total_grams=Sum("consumed_grams"), job_count=Count("print_job", distinct=True))
|
|
||||||
.order_by("-total_grams")[:20]
|
|
||||||
)
|
|
||||||
lines.append("| Spool | Total Grams | Jobs |")
|
|
||||||
lines.append("|-------|-------------|------|")
|
|
||||||
for s in stats:
|
|
||||||
lines.append(
|
|
||||||
f"| {s['filament__brand']} {s['filament__type']} {s['filament__color']} | "
|
|
||||||
f"{s['total_grams']}g | {s['job_count']} |"
|
|
||||||
)
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def get_printer_health(printer_id=None):
|
|
||||||
"""Diagnostics: errors, humidity, wifi, recent failed prints."""
|
|
||||||
from .models import Printer, PrinterMetrics, PrintJob
|
|
||||||
|
|
||||||
printers = Printer.objects.filter(is_active=True)
|
|
||||||
if printer_id:
|
|
||||||
printers = printers.filter(id=printer_id)
|
|
||||||
|
|
||||||
if not printers.exists():
|
|
||||||
return "No printers found."
|
|
||||||
|
|
||||||
parts = ["# Printer Health Report", ""]
|
|
||||||
for printer in printers:
|
|
||||||
latest = PrinterMetrics.objects.filter(device=printer).first()
|
|
||||||
if not latest:
|
|
||||||
parts.append(f"## {printer.name}\n**No data available.**\n")
|
|
||||||
continue
|
|
||||||
|
|
||||||
parts.append(f"## {printer.name}")
|
|
||||||
|
|
||||||
# Connectivity
|
|
||||||
parts.append("### Connectivity")
|
|
||||||
if latest.wifi_signal_dbm is not None:
|
|
||||||
signal = latest.wifi_signal_dbm
|
|
||||||
quality = "Excellent" if signal > -50 else "Good" if signal > -60 else "Fair" if signal > -70 else "Poor"
|
|
||||||
parts.append(f"- WiFi: {signal} dBm ({quality})")
|
|
||||||
parts.append(f"- Last seen: {_local_dt(latest.timestamp, '%Y-%m-%d %H:%M:%S %Z')}")
|
|
||||||
age = (timezone.now() - latest.timestamp).total_seconds()
|
|
||||||
if age > 300:
|
|
||||||
parts.append(f"- **Warning**: No data for {_format_duration(age / 60)}")
|
|
||||||
|
|
||||||
# AMS environment
|
|
||||||
if latest.ams_humidity is not None or latest.ams_temp is not None:
|
|
||||||
parts.append("### AMS Environment")
|
|
||||||
if latest.ams_humidity is not None:
|
|
||||||
hum_status = "OK" if latest.ams_humidity < 5 else "High" if latest.ams_humidity < 8 else "Critical"
|
|
||||||
parts.append(f"- Humidity: {latest.ams_humidity} ({hum_status})")
|
|
||||||
if latest.ams_temp is not None:
|
|
||||||
parts.append(f"- Temperature: {latest.ams_temp}°C")
|
|
||||||
|
|
||||||
# HMS errors
|
|
||||||
if latest.hms:
|
|
||||||
parts.append("### Active HMS Alerts")
|
|
||||||
for msg in latest.hms:
|
|
||||||
parts.append(f"- {msg}")
|
|
||||||
|
|
||||||
# Recent failures
|
|
||||||
week_ago = timezone.now() - timedelta(days=7)
|
|
||||||
failed = PrintJob.objects.filter(
|
|
||||||
device=printer,
|
|
||||||
start_time__gte=week_ago,
|
|
||||||
final_status__in=["FAILED", "CANCELLED"],
|
|
||||||
)
|
|
||||||
if failed.exists():
|
|
||||||
parts.append(f"### Recent Failures (7d): {failed.count()}")
|
|
||||||
for job in failed.select_related("cloud_task")[:5]:
|
|
||||||
parts.append(f"- {_job_name(job)} ({job.final_status}) — {_local_dt(job.start_time, '%m-%d %H:%M')}")
|
|
||||||
|
|
||||||
# Success rate
|
|
||||||
week_jobs = PrintJob.objects.filter(device=printer, start_time__gte=week_ago)
|
|
||||||
total = week_jobs.count()
|
|
||||||
if total > 0:
|
|
||||||
success = week_jobs.filter(final_status="FINISH").count()
|
|
||||||
parts.append(f"\n**7-day success rate**: {success}/{total} ({100 * success // total}%)")
|
|
||||||
|
|
||||||
parts.append("")
|
|
||||||
|
|
||||||
return "\n".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
def search_print_jobs(query):
|
|
||||||
"""Search print jobs by project name or gcode file."""
|
|
||||||
from .models import PrintJob
|
|
||||||
|
|
||||||
if not query:
|
|
||||||
return "Please provide a search query."
|
|
||||||
|
|
||||||
jobs = PrintJob.objects.select_related("device", "cloud_task").filter(
|
|
||||||
Q(project_name__icontains=query)
|
|
||||||
| Q(gcode_file__icontains=query)
|
|
||||||
| Q(cloud_task__design_title__icontains=query)
|
|
||||||
)[:20]
|
|
||||||
|
|
||||||
if not jobs:
|
|
||||||
return f"No print jobs matching '{query}'."
|
|
||||||
|
|
||||||
lines = [f"# Search Results: '{query}'", ""]
|
|
||||||
lines.append(f"*{len(jobs)} results*\n")
|
|
||||||
lines.append("| ID | Project | Printer | Status | Date |")
|
|
||||||
lines.append("|----|---------|---------|--------|------|")
|
|
||||||
for j in jobs:
|
|
||||||
lines.append(
|
|
||||||
f"| {j.id} | {_job_name(j)} | {j.device.name} | "
|
|
||||||
f"{j.final_status or 'In Progress'} | {_local_dt(j.start_time, '%Y-%m-%d')} |"
|
|
||||||
)
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def get_printing_summary(days=7):
|
|
||||||
"""High-level activity summary."""
|
|
||||||
from .models import FilamentUsage, Printer, PrintJob
|
|
||||||
|
|
||||||
cutoff = timezone.now() - timedelta(days=int(days))
|
|
||||||
jobs = PrintJob.objects.filter(start_time__gte=cutoff)
|
|
||||||
|
|
||||||
total = jobs.count()
|
|
||||||
finished = jobs.filter(final_status="FINISH").count()
|
|
||||||
failed = jobs.filter(final_status="FAILED").count()
|
|
||||||
cancelled = jobs.filter(final_status="CANCELLED").count()
|
|
||||||
in_progress = jobs.filter(final_status__isnull=True).count()
|
|
||||||
|
|
||||||
total_minutes = jobs.filter(duration_minutes__isnull=False).aggregate(
|
|
||||||
total=Sum("duration_minutes")
|
|
||||||
)["total"] or 0
|
|
||||||
|
|
||||||
total_grams = FilamentUsage.objects.filter(
|
|
||||||
print_job__start_time__gte=cutoff,
|
|
||||||
consumed_grams__isnull=False,
|
|
||||||
).aggregate(total=Sum("consumed_grams"))["total"] or 0
|
|
||||||
|
|
||||||
lines = [f"# Printing Summary (last {days} days)", ""]
|
|
||||||
lines.append(f"**Total Jobs**: {total}")
|
|
||||||
lines.append(f"- Completed: {finished}")
|
|
||||||
lines.append(f"- Failed: {failed}")
|
|
||||||
lines.append(f"- Cancelled: {cancelled}")
|
|
||||||
lines.append(f"- In Progress: {in_progress}")
|
|
||||||
if total > 0:
|
|
||||||
lines.append(f"- Success Rate: {100 * finished // total}%")
|
|
||||||
lines.append(f"\n**Total Print Time**: {_format_duration(total_minutes)}")
|
|
||||||
lines.append(f"**Total Filament Used**: {total_grams}g")
|
|
||||||
|
|
||||||
# Most printed projects
|
|
||||||
top_projects = (
|
|
||||||
jobs.values("project_name")
|
|
||||||
.annotate(count=Count("id"))
|
|
||||||
.order_by("-count")[:5]
|
|
||||||
)
|
|
||||||
if top_projects:
|
|
||||||
lines.append("\n### Most Printed")
|
|
||||||
for p in top_projects:
|
|
||||||
lines.append(f"- {p['project_name']} ({p['count']}x)")
|
|
||||||
|
|
||||||
# Active printers
|
|
||||||
active_printers = Printer.objects.filter(
|
|
||||||
print_jobs__start_time__gte=cutoff
|
|
||||||
).distinct()
|
|
||||||
if active_printers.exists():
|
|
||||||
lines.append(f"\n**Active Printers**: {', '.join(p.name for p in active_printers)}")
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def find_compatible_filament(type, min_remaining_percent=10, color=None):
|
|
||||||
"""Find spools matching material type criteria."""
|
|
||||||
from .models import Filament
|
|
||||||
|
|
||||||
qs = Filament.objects.filter(
|
|
||||||
type__iexact=type,
|
|
||||||
remaining_percent__gte=int(min_remaining_percent),
|
|
||||||
)
|
|
||||||
if color:
|
|
||||||
qs = qs.filter(color__icontains=color)
|
|
||||||
|
|
||||||
filaments = qs[:20]
|
|
||||||
if not filaments:
|
|
||||||
return f"No {type} filament found with >={min_remaining_percent}% remaining."
|
|
||||||
|
|
||||||
lines = [f"# Compatible Filament: {type}", ""]
|
|
||||||
if color:
|
|
||||||
lines.append(f"*Color filter: {color}*\n")
|
|
||||||
lines.append(f"*{qs.count()} spools found*\n")
|
|
||||||
lines.append("| ID | Brand | Sub-type | Color | Remaining | In AMS |")
|
|
||||||
lines.append("|----|-------|----------|-------|-----------|--------|")
|
|
||||||
for f in filaments:
|
|
||||||
lines.append(
|
|
||||||
f"| {f.id} | {f.brand} | {f.sub_type or f.type} | "
|
|
||||||
f"{f.color} | {f.remaining_percent}% | "
|
|
||||||
f"{'Yes' if f.is_loaded_in_ams else 'No'} |"
|
|
||||||
)
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Resources ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def resource_printers():
|
|
||||||
"""List all printers (resource)."""
|
|
||||||
return list_printers()
|
|
||||||
|
|
||||||
|
|
||||||
def resource_printer_status(printer_id):
|
|
||||||
"""Latest printer status (resource)."""
|
|
||||||
return get_printer_status(printer_id=printer_id)
|
|
||||||
|
|
||||||
|
|
||||||
def resource_filaments():
|
|
||||||
"""Full filament inventory (resource)."""
|
|
||||||
return list_filaments()
|
|
||||||
|
|
||||||
|
|
||||||
def resource_filament_detail(filament_id):
|
|
||||||
"""Single spool with usage (resource)."""
|
|
||||||
return get_filament_detail(filament_id=filament_id)
|
|
||||||
|
|
||||||
|
|
||||||
def resource_recent_print_jobs():
|
|
||||||
"""Last 20 print jobs (resource)."""
|
|
||||||
return get_print_history(limit=20)
|
|
||||||
|
|
||||||
|
|
||||||
def resource_filament_types():
|
|
||||||
"""Filament type registry (resource)."""
|
|
||||||
from .models import FilamentType
|
|
||||||
|
|
||||||
types = FilamentType.objects.all()
|
|
||||||
if not types.exists():
|
|
||||||
return "No filament types registered."
|
|
||||||
|
|
||||||
lines = ["# Filament Types", ""]
|
|
||||||
lines.append("| ID | Type | Sub-type | Brand |")
|
|
||||||
lines.append("|----|------|----------|-------|")
|
|
||||||
for t in types:
|
|
||||||
lines.append(f"| {t.id} | {t.type} | {t.sub_type or '-'} | {t.brand} |")
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def resource_filament_colors():
|
|
||||||
"""Filament color database (resource)."""
|
|
||||||
from .models import FilamentColor
|
|
||||||
|
|
||||||
colors = FilamentColor.objects.all()[:100]
|
|
||||||
if not colors:
|
|
||||||
return "No filament colors in database."
|
|
||||||
|
|
||||||
lines = ["# Filament Colors", ""]
|
|
||||||
lines.append(f"*Showing up to 100 of {FilamentColor.objects.count()}*\n")
|
|
||||||
lines.append("| Color | Hex | Type | Sub-type | Brand |")
|
|
||||||
lines.append("|-------|-----|------|----------|-------|")
|
|
||||||
for c in colors:
|
|
||||||
lines.append(
|
|
||||||
f"| {c.color_name} | #{c.color_code} | {c.filament_type} | "
|
|
||||||
f"{c.filament_sub_type or '-'} | {c.brand} |"
|
|
||||||
)
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Prompts ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def prompt_printer_check_in(printer_id=None):
|
|
||||||
"""Full status briefing: status + health + recent prints."""
|
|
||||||
parts = [
|
|
||||||
get_printer_status(printer_id=printer_id),
|
|
||||||
get_printer_health(printer_id=printer_id),
|
|
||||||
get_print_history(days=1, limit=5),
|
|
||||||
]
|
|
||||||
return "\n\n---\n\n".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
def prompt_filament_inventory_report():
|
|
||||||
"""Inventory report with low-stock warnings."""
|
|
||||||
from .models import Filament
|
|
||||||
|
|
||||||
low_stock = Filament.objects.filter(remaining_percent__lte=20)
|
|
||||||
parts = [list_filaments()]
|
|
||||||
if low_stock.exists():
|
|
||||||
lines = ["\n## Low Stock Warnings"]
|
|
||||||
for f in low_stock:
|
|
||||||
lines.append(f"- **{f.brand} {f.type} {f.color}**: {f.remaining_percent}% remaining")
|
|
||||||
parts.append("\n".join(lines))
|
|
||||||
return "\n\n".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
def prompt_print_job_review(job_id):
|
|
||||||
"""Review a completed job."""
|
|
||||||
return get_print_job_detail(job_id)
|
|
||||||
|
|
||||||
|
|
||||||
def prompt_weekly_digest():
|
|
||||||
"""Weekly activity summary."""
|
|
||||||
parts = [
|
|
||||||
get_printing_summary(days=7),
|
|
||||||
get_filament_usage_stats(days=7, group_by="type"),
|
|
||||||
]
|
|
||||||
return "\n\n---\n\n".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
def prompt_troubleshoot_printer(printer_id=None):
|
|
||||||
"""Diagnose issues from recent data."""
|
|
||||||
parts = [
|
|
||||||
get_printer_health(printer_id=printer_id),
|
|
||||||
get_printer_status(printer_id=printer_id),
|
|
||||||
get_temperature_history(printer_id=printer_id, hours=2),
|
|
||||||
]
|
|
||||||
return "\n\n---\n\n".join(parts)
|
|
||||||
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()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -8,7 +8,8 @@ from django.utils import timezone
|
|||||||
AMS_INFO_TO_TYPE = {
|
AMS_INFO_TO_TYPE = {
|
||||||
"1001": "AMS",
|
"1001": "AMS",
|
||||||
"1003": "AMS 2 Pro",
|
"1003": "AMS 2 Pro",
|
||||||
"2104": "AMS HT",
|
"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_TYPE_CHOICES = [
|
||||||
@@ -32,11 +33,33 @@ def ams_type_from_info(info_code) -> str:
|
|||||||
return AMS_INFO_TO_TYPE.get(code[-4:], "") or AMS_INFO_TO_TYPE.get(code, "")
|
return AMS_INFO_TO_TYPE.get(code[-4:], "") or AMS_INFO_TO_TYPE.get(code, "")
|
||||||
|
|
||||||
|
|
||||||
|
class PrinterManager(models.Manager):
|
||||||
|
"""Default manager — scopes every query to actual 3D printers.
|
||||||
|
|
||||||
|
`Printer` shares the `infrastructure_device` table with a host project's other
|
||||||
|
device rows (RAE stores its NAS, routers and cameras there too). Without this
|
||||||
|
scoping, `Printer.objects.filter(is_active=True).first()` can return a NAS.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return super().get_queryset().filter(category=Printer.CATEGORY_3D_PRINTER)
|
||||||
|
|
||||||
|
|
||||||
class Printer(models.Model):
|
class Printer(models.Model):
|
||||||
"""Represents a Bambu Lab 3D printer device"""
|
"""Represents a Bambu Lab 3D printer device"""
|
||||||
|
|
||||||
|
CATEGORY_3D_PRINTER = "threed_printer"
|
||||||
|
|
||||||
name = models.CharField(max_length=200, help_text="Friendly device name")
|
name = models.CharField(max_length=200, help_text="Friendly device name")
|
||||||
model = models.CharField(max_length=100, help_text="Device model (e.g., X1C, P1S)")
|
model = models.CharField(max_length=100, help_text="Device model (e.g., X1C, P1S)")
|
||||||
|
category = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
default=CATEGORY_3D_PRINTER,
|
||||||
|
help_text=(
|
||||||
|
"Device category. Always 'threed_printer' for printers — present because "
|
||||||
|
"host projects may share this table with other device types."
|
||||||
|
),
|
||||||
|
)
|
||||||
manufacturer = models.CharField(
|
manufacturer = models.CharField(
|
||||||
max_length=100, default="Bambu Lab", help_text="e.g., Bambu Lab"
|
max_length=100, default="Bambu Lab", help_text="e.g., Bambu Lab"
|
||||||
)
|
)
|
||||||
@@ -51,11 +74,19 @@ class Printer(models.Model):
|
|||||||
first_seen = models.DateTimeField(auto_now_add=True)
|
first_seen = models.DateTimeField(auto_now_add=True)
|
||||||
last_updated = models.DateTimeField(auto_now=True)
|
last_updated = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
# `all_objects` is declared first so it serves as the base manager for related
|
||||||
|
# descriptors (PrinterMetrics.device etc.) — those must never filter, or rows
|
||||||
|
# attached to a mis-categorised device become unreachable.
|
||||||
|
all_objects = models.Manager()
|
||||||
|
objects = PrinterManager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "infrastructure_device"
|
db_table = "infrastructure_device"
|
||||||
verbose_name = "Printer"
|
verbose_name = "Printer"
|
||||||
verbose_name_plural = "Printers"
|
verbose_name_plural = "Printers"
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
base_manager_name = "all_objects"
|
||||||
|
default_manager_name = "objects"
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.name} ({self.model})"
|
return f"{self.name} ({self.model})"
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import os
|
|||||||
import platform
|
import platform
|
||||||
import sys
|
import sys
|
||||||
import select
|
import select
|
||||||
|
import time
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -486,15 +487,29 @@ class PrinterState:
|
|||||||
|
|
||||||
wifi_signal = print_data.get("wifi_signal", "")
|
wifi_signal = print_data.get("wifi_signal", "")
|
||||||
|
|
||||||
# H2C dual-nozzle decoding. The H2C reports per-extruder temperatures
|
# Dual-nozzle decoding (H2C, X2D). Dual-nozzle printers report per-extruder
|
||||||
# under `print.device.extruder.info[]` as a 2-element array (index 0 =
|
# temperatures under `print.device.extruder.info[]` as a 2-element array
|
||||||
# right, index 1 = left). The `temp` field is bit-packed:
|
# (index 0 = right, index 1 = left). The `temp` field is bit-packed:
|
||||||
# `temp_raw = (target << 16) | current`, both °C as ints.
|
# `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_temp_left = None
|
||||||
nozzle_target_temp_left = None
|
nozzle_target_temp_left = None
|
||||||
|
nozzle_temp_right = None
|
||||||
|
nozzle_target_temp_right = None
|
||||||
device = print_data.get("device") or {}
|
device = print_data.get("device") or {}
|
||||||
extruders = (device.get("extruder") or {}).get("info") or []
|
extruders = (device.get("extruder") or {}).get("info") or []
|
||||||
if len(extruders) >= 2:
|
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]
|
left = extruders[1]
|
||||||
t = left.get("temp")
|
t = left.get("temp")
|
||||||
if isinstance(t, int):
|
if isinstance(t, int):
|
||||||
@@ -510,8 +525,14 @@ class PrinterState:
|
|||||||
return cls(
|
return cls(
|
||||||
timestamp=timestamp,
|
timestamp=timestamp,
|
||||||
sequence_id=str(print_data.get("sequence_id", "")),
|
sequence_id=str(print_data.get("sequence_id", "")),
|
||||||
nozzle_temp=float(print_data.get("nozzle_temper", 0.0)),
|
nozzle_temp=(
|
||||||
nozzle_target_temp=float(print_data.get("nozzle_target_temper", 0.0)),
|
nozzle_temp_right if nozzle_temp_right is not None
|
||||||
|
else float(print_data.get("nozzle_temper", 0.0))
|
||||||
|
),
|
||||||
|
nozzle_target_temp=(
|
||||||
|
nozzle_target_temp_right if nozzle_target_temp_right is not None
|
||||||
|
else float(print_data.get("nozzle_target_temper", 0.0))
|
||||||
|
),
|
||||||
bed_temp=float(print_data.get("bed_temper", 0.0)),
|
bed_temp=float(print_data.get("bed_temper", 0.0)),
|
||||||
bed_target_temp=float(print_data.get("bed_target_temper", 0.0)),
|
bed_target_temp=float(print_data.get("bed_target_temper", 0.0)),
|
||||||
chamber_temp=float(print_data.get("chamber_temper", 0.0)),
|
chamber_temp=float(print_data.get("chamber_temper", 0.0)),
|
||||||
@@ -809,6 +830,7 @@ class BambuPrinter:
|
|||||||
self._accumulator = PrinterStateAccumulator()
|
self._accumulator = PrinterStateAccumulator()
|
||||||
self._connected = False
|
self._connected = False
|
||||||
self._devices: List[Dict[str, Any]] = []
|
self._devices: List[Dict[str, Any]] = []
|
||||||
|
self._last_message_at: Optional[float] = None
|
||||||
|
|
||||||
def _get_fresh_token(self, verification_code_timeout: int = 300) -> str:
|
def _get_fresh_token(self, verification_code_timeout: int = 300) -> str:
|
||||||
"""Get a fresh token using credentials."""
|
"""Get a fresh token using credentials."""
|
||||||
@@ -914,6 +936,30 @@ class BambuPrinter:
|
|||||||
"""Internal MQTT message handler"""
|
"""Internal MQTT message handler"""
|
||||||
if not data:
|
if not data:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# The printer publishes partial deltas most of the time; the accumulator
|
||||||
|
# merges them onto whatever it already has. If the printer went offline
|
||||||
|
# (power cycle) and just reconnected, the first delta after the gap would
|
||||||
|
# otherwise be merged onto stale pre-outage state, leaving fields like
|
||||||
|
# nozzle_temp frozen at their last value until some unrelated full report
|
||||||
|
# happens to refresh them. Detect the gap and force a full pushall so the
|
||||||
|
# accumulator gets a clean, complete state instead.
|
||||||
|
now = time.time()
|
||||||
|
if (
|
||||||
|
self._last_message_at is not None
|
||||||
|
and now - self._last_message_at > app_settings.MQTT_RESYNC_GAP_SECONDS
|
||||||
|
and self._mqtt is not None
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
self._mqtt.request_full_status()
|
||||||
|
logger.info(
|
||||||
|
"MQTT report gap of %.0fs detected for %s; requested full status re-sync",
|
||||||
|
now - self._last_message_at, device_id,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Full status re-sync request failed (non-fatal): %s", e)
|
||||||
|
self._last_message_at = now
|
||||||
|
|
||||||
state = self._accumulator.update(data)
|
state = self._accumulator.update(data)
|
||||||
if self._on_update:
|
if self._on_update:
|
||||||
self._on_update(state)
|
self._on_update(state)
|
||||||
|
|||||||
@@ -113,29 +113,14 @@
|
|||||||
border-color: currentColor;
|
border-color: currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Grouped AMS unit panels — wide (multi-slot) units stack one per row,
|
/* Grouped AMS unit panels — Bootstrap row/col handles sizing;
|
||||||
compact (single-slot, e.g. AMS HT) units flow side-by-side and wrap. */
|
multi-slot units (AMS/AMS 2 Pro) are col-12, single-slot (AMS HT) are col-lg-3. */
|
||||||
.ams-groups {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ams-group {
|
.ams-group {
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border: 1px solid var(--ams-group-border-color);
|
border: 1px solid var(--ams-group-border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ams-group--wide {
|
|
||||||
flex: 1 1 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ams-group--compact {
|
|
||||||
flex: 0 1 auto;
|
|
||||||
min-width: 220px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ams-badge-bg-ams {
|
.ams-badge-bg-ams {
|
||||||
background-color: color-mix(in srgb, var(--ams-badge-ams) 8%, transparent);
|
background-color: color-mix(in srgb, var(--ams-badge-ams) 8%, transparent);
|
||||||
border-left: 3px solid var(--ams-badge-ams);
|
border-left: 3px solid var(--ams-badge-ams);
|
||||||
|
|||||||
@@ -164,37 +164,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- AMS Status Section -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5>AMS Status</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<strong>Temperature:</strong>
|
|
||||||
{% if stats.ams_temp %}
|
|
||||||
{{ stats.ams_temp|floatformat:1 }}°C
|
|
||||||
{% else %}
|
|
||||||
N/A
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<strong>Humidity:</strong>
|
|
||||||
{% if stats.ams_humidity %}
|
|
||||||
{{ stats.ams_humidity }}%
|
|
||||||
{% else %}
|
|
||||||
N/A
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filaments Section -->
|
<!-- Filaments Section -->
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
@@ -212,9 +181,9 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="ams-groups">
|
<div class="row g-3 ams-groups">
|
||||||
{% for group in stats.ams_groups %}
|
{% for group in stats.ams_groups %}
|
||||||
<div class="ams-group ams-badge-bg-{{ group.ams_type|slugify }} {% if group.filaments|length > 1 %}ams-group--wide{% else %}ams-group--compact{% endif %}" data-ams-unit-id="{{ group.unit_id }}">
|
<div class="{% 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">
|
<div class="ams-group-header d-flex justify-content-between align-items-center mb-2">
|
||||||
<strong class="small">{{ group.label }}</strong>
|
<strong class="small">{{ group.label }}</strong>
|
||||||
{% if group.humidity is not None or group.temp is not None %}
|
{% if group.humidity is not None or group.temp is not None %}
|
||||||
|
|||||||
@@ -14,9 +14,13 @@ from .conf import app_settings
|
|||||||
from .models import Printer, PrinterMetrics, Filament, FilamentColor, FilamentType, FilamentSnapshot, PrintJob, FilamentUsage, Hotend
|
from .models import Printer, PrinterMetrics, Filament, FilamentColor, FilamentType, FilamentSnapshot, PrintJob, FilamentUsage, Hotend
|
||||||
from .forms import FilamentForm, FilamentColorForm, FilamentTypeForm
|
from .forms import FilamentForm, FilamentColorForm, FilamentTypeForm
|
||||||
|
|
||||||
|
# Every field the chart serializers read must be listed here. A field that is
|
||||||
|
# accessed but missing triggers a deferred-field load — one extra SELECT per row,
|
||||||
|
# which turns a single-query page into thousands.
|
||||||
_METRICS_API_FIELDS = [
|
_METRICS_API_FIELDS = [
|
||||||
'id', 'device_id', 'timestamp',
|
'id', 'device_id', 'timestamp',
|
||||||
'nozzle_temp', 'nozzle_target_temp',
|
'nozzle_temp', 'nozzle_target_temp',
|
||||||
|
'nozzle_temp_left', 'nozzle_target_temp_left',
|
||||||
'bed_temp', 'bed_target_temp',
|
'bed_temp', 'bed_target_temp',
|
||||||
'print_percent', 'cooling_fan_speed', 'heatbreak_fan_speed',
|
'print_percent', 'cooling_fan_speed', 'heatbreak_fan_speed',
|
||||||
'wifi_signal_dbm', 'ams_humidity_raw', 'ams_temp',
|
'wifi_signal_dbm', 'ams_humidity_raw', 'ams_temp',
|
||||||
@@ -24,7 +28,16 @@ _METRICS_API_FIELDS = [
|
|||||||
'gcode_state', 'print_type', 'subtask_name',
|
'gcode_state', 'print_type', 'subtask_name',
|
||||||
'external_spool',
|
'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):
|
def resolve_printer_from_request(pk):
|
||||||
@@ -32,12 +45,47 @@ def resolve_printer_from_request(pk):
|
|||||||
|
|
||||||
`pk` given (URL kwarg) -> that exact printer, 404 if missing/inactive.
|
`pk` given (URL kwarg) -> that exact printer, 404 if missing/inactive.
|
||||||
`pk` omitted -> first active printer (today's single-printer default behavior).
|
`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:
|
if pk is not None:
|
||||||
return get_object_or_404(Printer, pk=pk, is_active=True)
|
return get_object_or_404(Printer, pk=pk, is_active=True)
|
||||||
return Printer.objects.filter(is_active=True).first()
|
return Printer.objects.filter(is_active=True).first()
|
||||||
|
|
||||||
|
|
||||||
|
def sample_metrics(metrics_list, max_points=None):
|
||||||
|
"""Evenly thin a metrics list to at most `max_points`, always keeping the last
|
||||||
|
reading — the stat cards are built from it."""
|
||||||
|
max_points = max_points or _MAX_CHART_POINTS
|
||||||
|
total = len(metrics_list)
|
||||||
|
if total <= max_points:
|
||||||
|
return metrics_list
|
||||||
|
step = (total // max_points) + 1
|
||||||
|
sampled = metrics_list[::step]
|
||||||
|
if sampled[-1] is not metrics_list[-1]:
|
||||||
|
sampled.append(metrics_list[-1])
|
||||||
|
return sampled
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_snapshots_by_metric(metrics_list):
|
||||||
|
"""Load filament snapshots for exactly the metrics we're serializing.
|
||||||
|
|
||||||
|
Beats `prefetch_related` on the unsampled queryset, which pulls a snapshot row
|
||||||
|
for every metric in the window (~25k rows for 24h) including the ones sampling
|
||||||
|
just discarded.
|
||||||
|
"""
|
||||||
|
if not metrics_list:
|
||||||
|
return {}
|
||||||
|
snapshots_by_metric = {}
|
||||||
|
for snap in FilamentSnapshot.objects.filter(
|
||||||
|
printer_metric_id__in=[m.id for m in metrics_list]
|
||||||
|
):
|
||||||
|
snapshots_by_metric.setdefault(snap.printer_metric_id, []).append(snap)
|
||||||
|
return snapshots_by_metric
|
||||||
|
|
||||||
|
|
||||||
class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
||||||
template_name = "bambu_run/printer_dashboard.html"
|
template_name = "bambu_run/printer_dashboard.html"
|
||||||
|
|
||||||
@@ -72,14 +120,28 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
|||||||
|
|
||||||
# Get date range (overridable by subclasses)
|
# Get date range (overridable by subclasses)
|
||||||
start_dt, end_dt = self._get_date_range(self.request)
|
start_dt, end_dt = self._get_date_range(self.request)
|
||||||
metrics = PrinterMetrics.objects.filter(
|
query = PrinterMetrics.objects.filter(
|
||||||
device=printer_device, timestamp__gte=start_dt
|
device=printer_device, timestamp__gte=start_dt
|
||||||
)
|
)
|
||||||
if end_dt:
|
if end_dt:
|
||||||
metrics = metrics.filter(timestamp__lte=end_dt)
|
query = query.filter(timestamp__lte=end_dt)
|
||||||
metrics = metrics.prefetch_related('filament_snapshots').order_by("timestamp")
|
|
||||||
|
|
||||||
latest_metric = metrics.last()
|
# Chart series only need the columns the serializer below reads, and only
|
||||||
|
# as many points as a chart can render. Fetching every column (including
|
||||||
|
# the large JSON blobs) for every row is what made this page slow.
|
||||||
|
metrics = sample_metrics(
|
||||||
|
list(query.only(*_METRICS_API_FIELDS).order_by("timestamp"))
|
||||||
|
)
|
||||||
|
snapshots_by_metric = fetch_snapshots_by_metric(metrics)
|
||||||
|
|
||||||
|
# The stat cards read far more fields than the charts do, so the latest
|
||||||
|
# reading is fetched separately as a full instance rather than deferring
|
||||||
|
# (a deferred field on a sampled row costs an extra query per access).
|
||||||
|
latest_metric = (
|
||||||
|
query.prefetch_related('filament_snapshots__filament')
|
||||||
|
.order_by("-timestamp")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
printer_data_json = {
|
printer_data_json = {
|
||||||
"timestamps": [
|
"timestamps": [
|
||||||
@@ -133,14 +195,17 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
|||||||
"total_layer_num": [
|
"total_layer_num": [
|
||||||
m.total_layer_num if m.total_layer_num else 0 for m in metrics
|
m.total_layer_num if m.total_layer_num else 0 for m in metrics
|
||||||
],
|
],
|
||||||
"filament_timeline": self._prepare_filament_timeline(metrics),
|
"filament_timeline": self._prepare_filament_timeline(
|
||||||
|
metrics, snapshots_by_metric
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
stats = {}
|
stats = {}
|
||||||
if latest_metric:
|
if latest_metric:
|
||||||
filaments_list = []
|
filaments_list = []
|
||||||
try:
|
try:
|
||||||
filament_snapshots = latest_metric.filament_snapshots.select_related('filament').all()
|
# `.all()` (not `.select_related()`) so the prefetch cache is used
|
||||||
|
filament_snapshots = latest_metric.filament_snapshots.all()
|
||||||
for snapshot in filament_snapshots:
|
for snapshot in filament_snapshots:
|
||||||
filament_dict = {
|
filament_dict = {
|
||||||
'tray_id': snapshot.tray_id,
|
'tray_id': snapshot.tray_id,
|
||||||
@@ -159,26 +224,41 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
|||||||
except Exception:
|
except Exception:
|
||||||
filaments_list = []
|
filaments_list = []
|
||||||
|
|
||||||
# Distinct AMS units represented in this snapshot, for the unit
|
# Build a lookup from unit_id → AMS unit metadata (humidity, temp, info code)
|
||||||
# filter/badges in the template. Sort numeric unit ids first
|
# first so we can enrich blank ams_type values derived from old snapshots.
|
||||||
# (AMS / AMS 2 Pro), HT (id 128 / bit 0x80 set) last.
|
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 = {}
|
seen_units = {}
|
||||||
for f in filaments_list:
|
for f in filaments_list:
|
||||||
uid = f.get('ams_unit_id')
|
uid = f.get('ams_unit_id')
|
||||||
if uid is not None and uid not in seen_units:
|
if uid is not None and uid not in seen_units:
|
||||||
seen_units[uid] = f.get('ams_type') or ''
|
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_units_list = [
|
||||||
{'ams_unit_id': uid, 'ams_type': label}
|
{'ams_unit_id': uid, 'ams_type': label}
|
||||||
for uid, label in sorted(seen_units.items())
|
for uid, label in sorted(seen_units.items())
|
||||||
]
|
]
|
||||||
|
|
||||||
# Group trays by physical AMS unit for the panel-style dashboard layout —
|
|
||||||
# one tinted panel per unit, full-width for multi-slot units (AMS/AMS 2 Pro),
|
|
||||||
# compact for single-slot units (AMS HT) so several can flow side-by-side.
|
|
||||||
units_meta = {
|
|
||||||
u.get('unit_id'): u for u in (latest_metric.ams_units or [])
|
|
||||||
}
|
|
||||||
ams_groups = []
|
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()):
|
for uid, label in sorted(seen_units.items()):
|
||||||
unit_meta = units_meta.get(str(uid), {})
|
unit_meta = units_meta.get(str(uid), {})
|
||||||
ams_groups.append({
|
ams_groups.append({
|
||||||
@@ -248,18 +328,18 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
|||||||
"timestamp": latest_metric.timestamp.astimezone(tz).strftime("%Y-%m-%d %H:%M:%S"),
|
"timestamp": latest_metric.timestamp.astimezone(tz).strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
}
|
}
|
||||||
|
|
||||||
project_markers = self._calculate_project_markers(list(metrics), tz)
|
project_markers = self._calculate_project_markers(metrics, tz, printer_device)
|
||||||
printer_data_json["project_markers"] = project_markers
|
printer_data_json["project_markers"] = project_markers
|
||||||
|
|
||||||
context["printer_device"] = printer_device
|
context["printer_device"] = printer_device
|
||||||
context["device_name"] = printer_device.name
|
context["device_name"] = printer_device.name
|
||||||
context["stats"] = stats
|
context["stats"] = stats
|
||||||
context["metrics_count"] = metrics.count()
|
context["metrics_count"] = len(metrics)
|
||||||
context["printer_data_json"] = json.dumps(printer_data_json)
|
context["printer_data_json"] = json.dumps(printer_data_json)
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def _calculate_project_markers(self, metrics, timezone_info):
|
def _calculate_project_markers(self, metrics, timezone_info, device):
|
||||||
"""Calculate where print jobs start and end, using cloud design_title when available."""
|
"""Calculate where print jobs start and end, using cloud design_title when available."""
|
||||||
if not metrics:
|
if not metrics:
|
||||||
return []
|
return []
|
||||||
@@ -267,7 +347,6 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
|||||||
# Build a lookup: subtask_name -> display_name from PrintJobs in this time window
|
# Build a lookup: subtask_name -> display_name from PrintJobs in this time window
|
||||||
window_start = metrics[0].timestamp
|
window_start = metrics[0].timestamp
|
||||||
window_end = metrics[-1].timestamp
|
window_end = metrics[-1].timestamp
|
||||||
device = metrics[0].device
|
|
||||||
jobs_qs = PrintJob.objects.filter(
|
jobs_qs = PrintJob.objects.filter(
|
||||||
device=device,
|
device=device,
|
||||||
start_time__gte=window_start - timedelta(minutes=5),
|
start_time__gte=window_start - timedelta(minutes=5),
|
||||||
@@ -313,18 +392,17 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
|||||||
|
|
||||||
return markers
|
return markers
|
||||||
|
|
||||||
def _prepare_filament_timeline(self, metrics):
|
def _prepare_filament_timeline(self, metrics, snapshots_by_metric):
|
||||||
"""Prepare filament data organized by unique filament configurations."""
|
"""Prepare filament data organized by unique filament configurations.
|
||||||
|
|
||||||
|
Snapshots are passed in pre-grouped by metric id; reading them off each
|
||||||
|
metric instance instead would issue one query per point.
|
||||||
|
"""
|
||||||
filament_data = {}
|
filament_data = {}
|
||||||
total_points = len(metrics)
|
total_points = len(metrics)
|
||||||
|
|
||||||
for idx, metric in enumerate(metrics):
|
for idx, metric in enumerate(metrics):
|
||||||
try:
|
for snapshot in snapshots_by_metric.get(metric.id, []):
|
||||||
snapshots = metric.filament_snapshots.all()
|
|
||||||
except Exception:
|
|
||||||
snapshots = []
|
|
||||||
|
|
||||||
for snapshot in snapshots:
|
|
||||||
tray_id = snapshot.tray_id
|
tray_id = snapshot.tray_id
|
||||||
ams_unit_id = snapshot.ams_unit_id
|
ams_unit_id = snapshot.ams_unit_id
|
||||||
ams_type = snapshot.ams_type or ''
|
ams_type = snapshot.ams_type or ''
|
||||||
@@ -400,38 +478,25 @@ class PrinterDataAPIView(LoginRequiredMixin, View):
|
|||||||
.only(*_METRICS_API_FIELDS)
|
.only(*_METRICS_API_FIELDS)
|
||||||
)
|
)
|
||||||
|
|
||||||
if start_date and start_time and end_date and end_time:
|
# Both bounds are always applied. A missing bound falls back to a 24h
|
||||||
start_dt = datetime.strptime(f"{start_date} {start_time}", "%Y-%m-%d %H:%M").replace(tzinfo=tz)
|
# window rather than being left open — an unbounded range would scan
|
||||||
end_dt = datetime.strptime(f"{end_date} {end_time}", "%Y-%m-%d %H:%M").replace(tzinfo=tz)
|
# every metric ever recorded.
|
||||||
query = query.filter(timestamp__gte=start_dt, timestamp__lte=end_dt)
|
def _parse(date_str, time_str):
|
||||||
range_seconds = (end_dt - start_dt).total_seconds()
|
return datetime.strptime(
|
||||||
expected_count = max(1, int(range_seconds / 30))
|
f"{date_str} {time_str}", "%Y-%m-%d %H:%M"
|
||||||
elif start_date and start_time:
|
).replace(tzinfo=tz)
|
||||||
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
|
|
||||||
|
|
||||||
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
|
# Stage B: single DB round-trip, downsample in Python
|
||||||
metrics_list = list(query.order_by("timestamp"))
|
metrics_list = sample_metrics(list(query.order_by("timestamp")))
|
||||||
if step > 1:
|
|
||||||
metrics_list = metrics_list[::step]
|
|
||||||
|
|
||||||
total_points = len(metrics_list)
|
total_points = len(metrics_list)
|
||||||
|
|
||||||
# Stage C: targeted snapshot fetch (only sampled IDs)
|
# Stage C: targeted snapshot fetch (only sampled IDs)
|
||||||
snapshots_by_metric: dict = {}
|
snapshots_by_metric = fetch_snapshots_by_metric(metrics_list)
|
||||||
if metrics_list:
|
|
||||||
sampled_ids = [m.id for m in metrics_list]
|
|
||||||
for snap in FilamentSnapshot.objects.filter(printer_metric_id__in=sampled_ids):
|
|
||||||
snapshots_by_metric.setdefault(snap.printer_metric_id, []).append(snap)
|
|
||||||
|
|
||||||
# Stage D: single-pass serialization
|
# Stage D: single-pass serialization
|
||||||
timestamps = []
|
timestamps = []
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Type=exec
|
|||||||
WorkingDirectory={{REPO_DIR}}
|
WorkingDirectory={{REPO_DIR}}
|
||||||
EnvironmentFile={{REPO_DIR}}/.env
|
EnvironmentFile={{REPO_DIR}}/.env
|
||||||
Environment=DJANGO_SETTINGS_MODULE=standalone.settings
|
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
|
ExecStart={{VENV_DIR}}/bin/python standalone/manage.py bambu_mcp_server --transport http --host 0.0.0.0 --port 8808
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=10
|
RestartSec=10
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ set -euo pipefail
|
|||||||
|
|
||||||
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
VENV_DIR="$REPO_DIR/.venv"
|
VENV_DIR="$REPO_DIR/.venv"
|
||||||
|
ENV_FILE="$REPO_DIR/.env"
|
||||||
MANAGE="$VENV_DIR/bin/python $REPO_DIR/standalone/manage.py"
|
MANAGE="$VENV_DIR/bin/python $REPO_DIR/standalone/manage.py"
|
||||||
SERVICES="bambu-run-web.service bambu-run-collector.service"
|
SERVICES="bambu-run-web.service bambu-run-collector.service"
|
||||||
|
|
||||||
@@ -37,6 +38,52 @@ case "${1:-help}" in
|
|||||||
fi
|
fi
|
||||||
journalctl --user $JOURNAL_UNITS -f --no-hostname
|
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]"
|
||||||
|
|
||||||
|
# Resolution order: a real process/systemd env var always wins and is
|
||||||
|
# never written to .env; otherwise reuse whatever's already in .env;
|
||||||
|
# otherwise generate one (same pattern setup.sh uses for DJANGO_SECRET_KEY).
|
||||||
|
if [ -n "${BAMBU_RUN_MCP_API_KEY:-}" ]; then
|
||||||
|
echo "Using BAMBU_RUN_MCP_API_KEY from the environment (not written to .env)."
|
||||||
|
elif grep -q '^BAMBU_RUN_MCP_API_KEY=' "$ENV_FILE" 2>/dev/null; then
|
||||||
|
echo "BAMBU_RUN_MCP_API_KEY already in .env, reusing it."
|
||||||
|
else
|
||||||
|
MCP_API_KEY=$("$VENV_DIR/bin/python" -c "import secrets; print(secrets.token_urlsafe(32))")
|
||||||
|
echo "BAMBU_RUN_MCP_API_KEY=$MCP_API_KEY" >> "$ENV_FILE"
|
||||||
|
echo "Generated BAMBU_RUN_MCP_API_KEY and saved it to .env."
|
||||||
|
fi
|
||||||
|
|
||||||
|
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}')
|
||||||
|
MCP_KEY_FOR_DISPLAY="${BAMBU_RUN_MCP_API_KEY:-$(grep '^BAMBU_RUN_MCP_API_KEY=' "$ENV_FILE" 2>/dev/null | cut -d= -f2-)}"
|
||||||
|
echo "MCP server enabled on port 8808."
|
||||||
|
echo
|
||||||
|
echo " Claude Desktop config:"
|
||||||
|
echo " {\"mcpServers\":{\"bambu-run\":{\"url\":\"http://${PI_IP:-localhost}:8808/mcp\",\"headers\":{\"Authorization\":\"Bearer ${MCP_KEY_FOR_DISPLAY}\"}}}}"
|
||||||
|
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)
|
update)
|
||||||
echo "Pulling latest code..."
|
echo "Pulling latest code..."
|
||||||
cd "$REPO_DIR" && git pull
|
cd "$REPO_DIR" && git pull
|
||||||
@@ -60,7 +107,7 @@ case "${1:-help}" in
|
|||||||
echo "Update complete."
|
echo "Update complete."
|
||||||
;;
|
;;
|
||||||
help|*)
|
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
|
||||||
echo " start Start web + collector services"
|
echo " start Start web + collector services"
|
||||||
echo " stop Stop web + collector services"
|
echo " stop Stop web + collector services"
|
||||||
@@ -68,5 +115,7 @@ case "${1:-help}" in
|
|||||||
echo " status Show service status"
|
echo " status Show service status"
|
||||||
echo " logs Tail live logs (Ctrl+C to stop)"
|
echo " logs Tail live logs (Ctrl+C to stop)"
|
||||||
echo " update Pull latest code, install deps, migrate, restart"
|
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
|
esac
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "bambu-run"
|
name = "bambu-run"
|
||||||
version = "0.1.8"
|
version = "0.1.13"
|
||||||
description = "Django reusable app for Bambu Lab 3D printer monitoring and filament inventory management"
|
description = "Django reusable app for Bambu Lab 3D printer monitoring and filament inventory management"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = {text = "MIT"}
|
license = {text = "MIT"}
|
||||||
@@ -40,13 +40,18 @@ standalone = [
|
|||||||
"python-dotenv",
|
"python-dotenv",
|
||||||
"whitenoise",
|
"whitenoise",
|
||||||
]
|
]
|
||||||
|
postgres = [
|
||||||
|
"psycopg2-binary",
|
||||||
|
]
|
||||||
mcp = [
|
mcp = [
|
||||||
"mcp[cli]>=1.0",
|
"mcp-gate @ git+https://github.com/RunLit/mcp-gate.git@v0.1.0",
|
||||||
|
"uvicorn",
|
||||||
]
|
]
|
||||||
dev = [
|
dev = [
|
||||||
"ruff",
|
"ruff",
|
||||||
"pytest",
|
"pytest",
|
||||||
"pytest-django",
|
"pytest-django",
|
||||||
|
"anyio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
|
|||||||
49
setup.sh
49
setup.sh
@@ -1,8 +1,21 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Bambu-Run Native Setup — single entry point for Raspberry Pi (or any Linux)
|
# 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
|
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)"
|
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
VENV_DIR="$REPO_DIR/.venv"
|
VENV_DIR="$REPO_DIR/.venv"
|
||||||
ENV_FILE="$REPO_DIR/.env"
|
ENV_FILE="$REPO_DIR/.env"
|
||||||
@@ -98,8 +111,8 @@ else:
|
|||||||
print(' opencv stub already exists')
|
print(' opencv stub already exists')
|
||||||
"
|
"
|
||||||
|
|
||||||
"$VENV_DIR/bin/pip" install --quiet --upgrade pip
|
"$VENV_DIR/bin/pip" install $PIP_QUIET_FLAG --upgrade pip
|
||||||
"$VENV_DIR/bin/pip" install --quiet ".[standalone]"
|
"$VENV_DIR/bin/pip" install $PIP_QUIET_FLAG ".[standalone]"
|
||||||
|
|
||||||
# ── 3. Interactive .env ───────────────────────────────────────────────────────
|
# ── 3. Interactive .env ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -205,10 +218,28 @@ systemctl --user enable bambu-run-web.service bambu-run-collector.service
|
|||||||
|
|
||||||
echo
|
echo
|
||||||
MCP_ENABLED=false
|
MCP_ENABLED=false
|
||||||
read -rp "Enable MCP server for AI agent access (Claude Desktop, Claude Code, etc.)? [y/N] " ENABLE_MCP
|
if [ -f "$SERVICE_DIR/bambu-run-mcp.service" ]; then
|
||||||
if [[ "$ENABLE_MCP" =~ ^[Yy] ]]; then
|
yellow "MCP server already enabled, skipping (use ./native/bambu-run.sh disable-mcp first to reconfigure)."
|
||||||
|
MCP_ENABLED=true
|
||||||
|
else
|
||||||
|
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..."
|
green "Installing MCP dependencies..."
|
||||||
"$VENV_DIR/bin/pip" install --quiet ".[mcp]"
|
"$VENV_DIR/bin/pip" install $PIP_QUIET_FLAG ".[mcp]"
|
||||||
|
|
||||||
|
# Resolution order: a real process/systemd env var always wins and is
|
||||||
|
# never written to .env (lets deployments override without touching
|
||||||
|
# the file); otherwise reuse whatever's already in .env; otherwise
|
||||||
|
# generate one, same pattern as DJANGO_SECRET_KEY above.
|
||||||
|
if [ -n "${BAMBU_RUN_MCP_API_KEY:-}" ]; then
|
||||||
|
yellow "Using BAMBU_RUN_MCP_API_KEY from the environment (not written to .env)."
|
||||||
|
elif grep -q '^BAMBU_RUN_MCP_API_KEY=' "$ENV_FILE" 2>/dev/null; then
|
||||||
|
yellow "BAMBU_RUN_MCP_API_KEY already in .env, reusing it."
|
||||||
|
else
|
||||||
|
MCP_API_KEY=$("$VENV_DIR/bin/python" -c "import secrets; print(secrets.token_urlsafe(32))")
|
||||||
|
echo "BAMBU_RUN_MCP_API_KEY=$MCP_API_KEY" >> "$ENV_FILE"
|
||||||
|
green "Generated BAMBU_RUN_MCP_API_KEY and saved it to .env."
|
||||||
|
fi
|
||||||
|
|
||||||
sed "s|{{REPO_DIR}}|$REPO_DIR|g; s|{{VENV_DIR}}|$VENV_DIR|g" \
|
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"
|
"$REPO_DIR/native/bambu-run-mcp.service" > "$SERVICE_DIR/bambu-run-mcp.service"
|
||||||
@@ -218,6 +249,7 @@ if [[ "$ENABLE_MCP" =~ ^[Yy] ]]; then
|
|||||||
systemctl --user start bambu-run-mcp.service
|
systemctl --user start bambu-run-mcp.service
|
||||||
MCP_ENABLED=true
|
MCP_ENABLED=true
|
||||||
green "MCP server enabled on port 8808."
|
green "MCP server enabled on port 8808."
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Enable linger so services survive SSH logout
|
# Enable linger so services survive SSH logout
|
||||||
@@ -275,15 +307,16 @@ green "============================================"
|
|||||||
echo
|
echo
|
||||||
echo " Dashboard: $DASHBOARD_URL"
|
echo " Dashboard: $DASHBOARD_URL"
|
||||||
if [ "$MCP_ENABLED" = true ]; then
|
if [ "$MCP_ENABLED" = true ]; then
|
||||||
echo " MCP Server: http://${PI_IP:-localhost}:8808/sse"
|
echo " MCP Server: http://${PI_IP:-localhost}:8808/mcp"
|
||||||
fi
|
fi
|
||||||
echo " Status: systemctl --user status bambu-run-web bambu-run-collector"
|
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 " 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 " Helper: ./native/bambu-run.sh {start|stop|restart|status|logs|update}"
|
||||||
echo
|
echo
|
||||||
if [ "$MCP_ENABLED" = true ]; then
|
if [ "$MCP_ENABLED" = true ]; then
|
||||||
|
MCP_KEY_FOR_DISPLAY="${BAMBU_RUN_MCP_API_KEY:-$(grep '^BAMBU_RUN_MCP_API_KEY=' "$ENV_FILE" 2>/dev/null | cut -d= -f2-)}"
|
||||||
echo " Claude Desktop config:"
|
echo " Claude Desktop config:"
|
||||||
echo " {\"mcpServers\":{\"bambu-run\":{\"url\":\"http://${PI_IP:-localhost}:8808/sse\"}}}"
|
echo " {\"mcpServers\":{\"bambu-run\":{\"url\":\"http://${PI_IP:-localhost}:8808/mcp\",\"headers\":{\"Authorization\":\"Bearer ${MCP_KEY_FOR_DISPLAY}\"}}}}"
|
||||||
echo
|
echo
|
||||||
fi
|
fi
|
||||||
echo " Services auto-start on boot. Safe to close SSH."
|
echo " Services auto-start on boot. Safe to close SSH."
|
||||||
|
|||||||
@@ -63,16 +63,38 @@ TEMPLATES = [
|
|||||||
|
|
||||||
WSGI_APPLICATION = "standalone.wsgi.application"
|
WSGI_APPLICATION = "standalone.wsgi.application"
|
||||||
|
|
||||||
# Database — SQLite for zero-setup deployment
|
# Database — SQLite for zero-setup deployment, or Postgres when DB_ENGINE=postgresql
|
||||||
|
# is set (e.g. to point a standalone install at an existing database).
|
||||||
DATA_DIR = Path(os.environ.get("DATA_DIR", BASE_DIR / "data"))
|
DATA_DIR = Path(os.environ.get("DATA_DIR", BASE_DIR / "data"))
|
||||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
DATABASES = {
|
if os.environ.get("DB_ENGINE") == "postgresql":
|
||||||
|
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": {
|
"default": {
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
"ENGINE": "django.db.backends.sqlite3",
|
||||||
"NAME": DATA_DIR / "db.sqlite3",
|
"NAME": DATA_DIR / "db.sqlite3",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Session storage — DB-backed by default. Set SESSION_ENGINE=signed_cookies when
|
||||||
|
# pointing DB_ENGINE at a database whose user has read-only access, since the
|
||||||
|
# default engine writes a row to django_session on every login.
|
||||||
|
SESSION_ENGINE = (
|
||||||
|
"django.contrib.sessions.backends.signed_cookies"
|
||||||
|
if os.environ.get("SESSION_ENGINE") == "signed_cookies"
|
||||||
|
else "django.contrib.sessions.backends.db"
|
||||||
|
)
|
||||||
|
|
||||||
# Password validation
|
# Password validation
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
@@ -111,7 +133,9 @@ PRINTER_IP = os.environ.get("PRINTER_IP", "")
|
|||||||
ACCESS_TOKEN = os.environ.get("ACCESS_TOKEN", "")
|
ACCESS_TOKEN = os.environ.get("ACCESS_TOKEN", "")
|
||||||
PRINTER_SERIAL = os.environ.get("PRINTER_SERIAL", "")
|
PRINTER_SERIAL = os.environ.get("PRINTER_SERIAL", "")
|
||||||
|
|
||||||
CSRF_TRUSTED_ORIGINS = os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(",")
|
CSRF_TRUSTED_ORIGINS = [
|
||||||
|
origin for origin in os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(",") if origin
|
||||||
|
]
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
LOGGING = {
|
LOGGING = {
|
||||||
|
|||||||
@@ -7,3 +7,15 @@ from django.core.wsgi import get_wsgi_application
|
|||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "standalone.settings")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "standalone.settings")
|
||||||
|
|
||||||
application = get_wsgi_application()
|
application = get_wsgi_application()
|
||||||
|
|
||||||
|
# Read-only deployments (SESSION_ENGINE=signed_cookies) point at a database
|
||||||
|
# whose user has no write access. Django writes to auth_user.last_login on
|
||||||
|
# every successful login by default, which would error out against such a
|
||||||
|
# user and break login entirely — so skip that write here.
|
||||||
|
from django.conf import settings # noqa: E402
|
||||||
|
|
||||||
|
if settings.SESSION_ENGINE == "django.contrib.sessions.backends.signed_cookies":
|
||||||
|
from django.contrib.auth.models import update_last_login
|
||||||
|
from django.contrib.auth.signals import user_logged_in
|
||||||
|
|
||||||
|
user_logged_in.disconnect(update_last_login, dispatch_uid="update_last_login")
|
||||||
|
|||||||
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()
|
||||||
@@ -1,4 +1,16 @@
|
|||||||
"""Minimal Django settings for running bambu_run's pytest suite (in-memory SQLite)."""
|
"""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"
|
SECRET_KEY = "test-secret-key"
|
||||||
|
|
||||||
|
|||||||
@@ -57,8 +57,13 @@ def test_filament_timeline_keeps_same_tray_id_units_separate(logged_in_client):
|
|||||||
type="PLA", sub_type="PLA Basic", color="FF0000", remain_percent=50,
|
type="PLA", sub_type="PLA Basic", color="FF0000", remain_percent=50,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from bambu_run.views import fetch_snapshots_by_metric
|
||||||
|
|
||||||
view = PrinterDashboardView()
|
view = PrinterDashboardView()
|
||||||
timeline = view._prepare_filament_timeline(PrinterMetrics.objects.filter(pk=metric.pk))
|
metrics = list(PrinterMetrics.objects.filter(pk=metric.pk))
|
||||||
|
timeline = view._prepare_filament_timeline(
|
||||||
|
metrics, fetch_snapshots_by_metric(metrics)
|
||||||
|
)
|
||||||
|
|
||||||
assert len(timeline) == 2
|
assert len(timeline) == 2
|
||||||
|
|
||||||
@@ -157,8 +162,8 @@ def test_dashboard_renders_wide_and_compact_panels(logged_in_client):
|
|||||||
)
|
)
|
||||||
|
|
||||||
html = resp.content.decode()
|
html = resp.content.decode()
|
||||||
assert "ams-group--wide" in html
|
assert "col-12 ams-group" in html # wide group: col-12 only
|
||||||
assert "ams-group--compact" in html
|
assert "col-lg-3 ams-group" in html # compact group: col-lg-3
|
||||||
assert "AMS 2 Pro (Unit 0)" in html
|
assert "AMS 2 Pro (Unit 0)" in html
|
||||||
assert "AMS HT (Unit 128)" in html
|
assert "AMS HT (Unit 128)" in html
|
||||||
|
|
||||||
|
|||||||
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()
|
||||||
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
|
||||||
Reference in New Issue
Block a user