mirror of
https://github.com/RunLit/Bambu-Run.git
synced 2026-06-22 22:19:03 +01:00
Compare commits
1 Commits
58ebdf518e
...
as_django_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05d716c09d |
@@ -20,4 +20,3 @@ BAMBU_PASSWORD=your_password
|
|||||||
# DEBUG=True
|
# DEBUG=True
|
||||||
# DJANGO_SECRET_KEY=change-me-to-a-random-string
|
# DJANGO_SECRET_KEY=change-me-to-a-random-string
|
||||||
# ALLOWED_HOSTS=localhost,127.0.0.1
|
# ALLOWED_HOSTS=localhost,127.0.0.1
|
||||||
# CSRF_TRUSTED_ORIGINS="https://bambu-run.example.com,http://bambu-run.example.com"
|
|
||||||
|
|||||||
56
.github/workflows/bump-version.yml
vendored
56
.github/workflows/bump-version.yml
vendored
@@ -1,56 +0,0 @@
|
|||||||
name: Bump Patch Version on Merge to Main
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
bump-version:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
# Skip if this push was itself the version bump commit (prevents infinite loop)
|
|
||||||
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
# Need full git history and ability to push
|
|
||||||
fetch-depth: 0
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Bump patch version in pyproject.toml
|
|
||||||
id: bump
|
|
||||||
run: |
|
|
||||||
# Read current version
|
|
||||||
CURRENT=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
|
|
||||||
echo "Current version: $CURRENT"
|
|
||||||
|
|
||||||
# Split into parts and increment patch
|
|
||||||
MAJOR=$(echo $CURRENT | cut -d. -f1)
|
|
||||||
MINOR=$(echo $CURRENT | cut -d. -f2)
|
|
||||||
PATCH=$(echo $CURRENT | cut -d. -f3)
|
|
||||||
NEW_PATCH=$((PATCH + 1))
|
|
||||||
NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
|
|
||||||
echo "New version: $NEW_VERSION"
|
|
||||||
|
|
||||||
# Write back to pyproject.toml
|
|
||||||
sed -i "s/^version = \"$CURRENT\"/version = \"$NEW_VERSION\"/" pyproject.toml
|
|
||||||
|
|
||||||
# Export for later steps
|
|
||||||
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Commit and push bumped version
|
|
||||||
run: |
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
git add pyproject.toml
|
|
||||||
git commit -m "chore: bump version to ${{ steps.bump.outputs.new_version }} [skip ci]"
|
|
||||||
git push
|
|
||||||
- name: Tag the release
|
|
||||||
run: |
|
|
||||||
git tag "v${{ steps.bump.outputs.new_version }}"
|
|
||||||
git push origin "v${{ steps.bump.outputs.new_version }}"
|
|
||||||
@@ -24,7 +24,7 @@ RUN pip install --no-cache-dir bambu-lab-cloud-api --no-deps && \
|
|||||||
|
|
||||||
# Install project and remaining dependencies (pip sees opencv-python already satisfied)
|
# Install project and remaining dependencies (pip sees opencv-python already satisfied)
|
||||||
COPY pyproject.toml .
|
COPY pyproject.toml .
|
||||||
RUN pip install --no-cache-dir ".[standalone,mcp]"
|
RUN pip install --no-cache-dir ".[standalone]"
|
||||||
|
|
||||||
# Copy application code
|
# Copy application code
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -40,6 +40,5 @@ RUN python standalone/manage.py collectstatic --noinput 2>/dev/null || true
|
|||||||
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
EXPOSE 8808
|
|
||||||
|
|
||||||
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||||
|
|||||||
421
README.md
421
README.md
@@ -1,264 +1,239 @@
|
|||||||
# Bambu-Run
|
# Bambu-Run
|
||||||
|
|
||||||
<p align="center">
|
Unlock richer data access and powerful customization capabilities for your Bambu Lab 3D printer.
|
||||||
<img src="docs/BambuRun.png" alt="Bambu-Run Logo" width="300"/>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
Richer data, powerful customization for your Bambu Lab 3D printer.
|
Bambu-Run is a self-hosted web dashboard that tracks data of your Bambu Lab printer. It gives you:
|
||||||
|
- Real-time monitoring and logging (temperatures, fan speeds, print progress etc)
|
||||||
|
- Automatic filament inventory tracking and usage monitoring system (AMS required)
|
||||||
|
all running on hardware you own.
|
||||||
|
|
||||||
Bambu-Run is a self-hosted web dashboard that gives you:
|
### Hardware Requirement
|
||||||
- Real-time monitoring and logging (temperatures, fan speeds, print progress, and more)
|
|
||||||
- Automatic filament inventory tracking and usage monitoring (AMS required)
|
|
||||||
|
|
||||||
All running on hardware you own.
|
Recommend a raspberry pi, installed with Raspberry Pi OS (low cost running at the background) or an old PC/Laptop you probably never going to use again (install Linux).
|
||||||
|
|
||||||
|
## Getting Started (Beginner Friendly)
|
||||||
|
|
||||||
|
This guide walks you through setting up Bambu-Run on a **Raspberry Pi** from scratch. No prior server experience needed.
|
||||||
|
|
||||||
### What You'll Need
|
### What You'll Need
|
||||||
|
|
||||||
Any always-on device works — a **Raspberry Pi** (3B+, 4, or 5) is ideal: beginner-friendly, runs Raspberry Pi OS out of the box, and quiet enough to tuck behind a desk. An old PC or laptop with Linux works too.
|
- A Raspberry Pi (3B+, 4, or 5) with Raspberry Pi OS installed and connected to your network
|
||||||
|
- Your Bambu Lab printer on the **same local network** as the Pi
|
||||||
|
- Your printer's **IP address**, **access token**, and **serial number** (we'll show you how to find these below)
|
||||||
|
- A computer on the same network to SSH into the Pi
|
||||||
|
|
||||||
It runs quietly in the background 24/7, capturing every print, filament change, and AMS update the moment it happens. And the power bill? A Raspberry Pi 4 under light load draws about **5W**. That's roughly **43.8 kWh per year**, or the cost of **three cups of coffee**. ☕☕☕ Tuck it out of sight and forget it's there.
|
### Step 1: Find Your Bambu Lab Account Credentials
|
||||||
|
|
||||||
---
|
Bambu-Run connects to your printer through the **Bambu Lab Cloud** using your account login — the same email and password you use for Bambu Handy or Bambu Studio.
|
||||||
|
|
||||||
## Table of Contents
|
You'll need:
|
||||||
|
- **BAMBU_USERNAME** — Your Bambu Lab account email
|
||||||
|
- **BAMBU_PASSWORD** — Your Bambu Lab account password
|
||||||
|
|
||||||
- [Native Setup (Recommended for Raspberry Pi)](#native-setup-recommended-for-raspberry-pi)
|
> **First-time login requires email verification.** Bambu Lab will send a 6-digit code to your email. You'll enter this code during Step 5a below. After that, you'll receive a token that skips verification on future startups.
|
||||||
- [What You'll Need](#what-youll-need)
|
|
||||||
- [Clone and run setup.sh](#clone-and-run-setupsh)
|
|
||||||
- [Managing Bambu-Run](#managing-bambu-run)
|
|
||||||
- [Troubleshooting (Native)](#troubleshooting-native)
|
|
||||||
- [Docker Setup](#docker-setup)
|
|
||||||
- [Batch Importing Filament Colors and Filament Types](#batch-importing-filament-colors-and-filament-types)
|
|
||||||
|
|
||||||
---
|
### Step 2: Connect to Your Raspberry Pi
|
||||||
|
|
||||||
## Native Setup (Recommended for Raspberry Pi)
|
From your computer, open a terminal (Mac/Linux) or PowerShell (Windows) and SSH into the Pi:
|
||||||
|
|
||||||
No Docker required. Works on any Raspberry Pi (including 32-bit Pi Model B) running Raspberry Pi OS with Python 3.10+.
|
|
||||||
|
|
||||||
### What You'll Need
|
|
||||||
|
|
||||||
- Raspberry Pi on your local network (Python 3.10+)
|
|
||||||
- Bambu Lab printer
|
|
||||||
- Bambu Lab account **email and password**
|
|
||||||
|
|
||||||
### Clone and run setup.sh
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
ssh pi@raspberrypi.local
|
||||||
|
```
|
||||||
|
|
||||||
|
> If `raspberrypi.local` doesn't work, use your Pi's IP address instead (check your router's admin page to find it).
|
||||||
|
|
||||||
|
The default password is `raspberry` (you should change it after first login with `passwd`).
|
||||||
|
|
||||||
|
### Step 3: Install Docker
|
||||||
|
|
||||||
|
Docker lets you run Bambu-Run in a container — no need to install Python, databases, or anything else manually.
|
||||||
|
|
||||||
|
Run these commands one at a time:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Download and run Docker's install script
|
||||||
|
curl -fsSL https://get.docker.com | sudo sh
|
||||||
|
|
||||||
|
# Let your user run Docker without sudo
|
||||||
|
sudo usermod -aG docker $USER
|
||||||
|
```
|
||||||
|
Installation issue? check installation methods for raspberry pi: https://docs.docker.com/engine/install/raspberry-pi-os/#installation-methods
|
||||||
|
|
||||||
|
**Important:** Log out and log back in for the group change to take effect:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
exit
|
||||||
|
```
|
||||||
|
|
||||||
|
Then SSH back in:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh pi@raspberrypi.local
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify Docker is working:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker --version
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see something like `Docker version 27.x.x` — the exact number doesn't matter.
|
||||||
|
|
||||||
|
### Step 4: Download and Configure Bambu-Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the project
|
||||||
git clone https://github.com/RunLit/Bambu-Run.git
|
git clone https://github.com/RunLit/Bambu-Run.git
|
||||||
cd Bambu-Run
|
cd Bambu-Run
|
||||||
bash setup.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
That's it! The script handles everything interactively, just answer the prompts. When it finishes, open `http://<ip>` from any device on same network.
|
# Create your configuration file
|
||||||
|
|
||||||
The script is safe to re-run at any time.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**What the script does**:
|
|
||||||
|
|
||||||
- **Dependencies**: creates a Python virtual environment, installs all packages
|
|
||||||
- **Credentials**: prompts for your **BambuLab Cloud account** email, password, and timezone; auto-generates a `DJANGO_SECRET_KEY`; writes `.env`
|
|
||||||
- **Bambu Cloud auth**: runs `bambu_collector --once`;
|
|
||||||
- Bambu Lab will send a 6-digit code to your email; check you email box and enter it when prompted;
|
|
||||||
- the resulting token is saved to `.env` automatically; future restarts skip this step
|
|
||||||
- **Dashboard login**: runs `createsuperuser`; choose a username and password for Bambu-Run web UI log in
|
|
||||||
- **Services**: installs and starts two systemd services (`bambu-run-web` and `bambu-run-collector`), enables linger so they auto-start on boot
|
|
||||||
- **Port 80**: sets an `iptables` redirect (80 to 8000) so you can reach the dashboard at a plain `http://<pi-ip>` with no port number; persisted via `iptables-persistent` across reboots.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Managing Bambu-Run
|
|
||||||
|
|
||||||
All commands manage Bambu-Run encapsulated in `./native/bambu-run.sh`. Alternatively, you can do it yourself with systemctl commands.
|
|
||||||
```bash
|
|
||||||
./native/bambu-run.sh status # service status
|
|
||||||
./native/bambu-run.sh logs # tail live logs (Ctrl+C to stop)
|
|
||||||
./native/bambu-run.sh restart # restart both services
|
|
||||||
./native/bambu-run.sh stop # stop everything
|
|
||||||
./native/bambu-run.sh update # git pull + pip install + migrate + restart
|
|
||||||
```
|
|
||||||
|
|
||||||
### Troubleshooting (Native)
|
|
||||||
|
|
||||||
**Services die when SSH disconnects:** `sudo loginctl enable-linger $USER`
|
|
||||||
|
|
||||||
**Services not starting:** `./native/bambu-run.sh status` and `./native/bambu-run.sh logs`
|
|
||||||
|
|
||||||
**Auth errors / token expired:** Remove `BAMBU_TOKEN` from `.env` and re-run `bash setup.sh`
|
|
||||||
|
|
||||||
**Uninstall:**
|
|
||||||
```bash
|
|
||||||
systemctl --user disable --now bambu-run-web bambu-run-collector
|
|
||||||
rm ~/.config/systemd/user/bambu-run-{web,collector}.service
|
|
||||||
systemctl --user daemon-reload
|
|
||||||
```
|
|
||||||
|
|
||||||
**Wipe everything and start over:**
|
|
||||||
```bash
|
|
||||||
# Stop and remove services
|
|
||||||
systemctl --user stop bambu-run-web bambu-run-collector
|
|
||||||
systemctl --user disable bambu-run-web bambu-run-collector
|
|
||||||
rm ~/.config/systemd/user/bambu-run-{web,collector}.service
|
|
||||||
systemctl --user daemon-reload
|
|
||||||
|
|
||||||
# Remove port redirect (replace 80 with whatever port you chose during setup)
|
|
||||||
sudo iptables -t nat -D PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 8000 2>/dev/null || true
|
|
||||||
sudo iptables -t nat -D OUTPUT -o lo -p tcp --dport 80 -j REDIRECT --to-port 8000 2>/dev/null || true
|
|
||||||
sudo netfilter-persistent save 2>/dev/null || true
|
|
||||||
|
|
||||||
# Delete repo — wipes venv, database, and .env
|
|
||||||
cd ~
|
|
||||||
rm -rf ~/Bambu-Run
|
|
||||||
|
|
||||||
# Re-clone and run setup from scratch
|
|
||||||
git clone https://github.com/RunLit/Bambu-Run.git
|
|
||||||
cd Bambu-Run
|
|
||||||
bash setup.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Docker Setup
|
|
||||||
|
|
||||||
Requires Docker and Docker Compose installed. Assumes you already know how to get there.
|
|
||||||
|
|
||||||
**Clone and configure:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/RunLit/Bambu-Run.git
|
|
||||||
cd Bambu-Run
|
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Edit .env: set BAMBU_USERNAME, BAMBU_PASSWORD, TIMEZONE
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**First-time auth** (Bambu Lab sends a 6-digit verification code to your email):
|
Now edit the `.env` file with your printer details:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Fill in your Bambu Lab account credentials from Step 1:
|
||||||
|
|
||||||
|
```
|
||||||
|
BAMBU_USERNAME=your_email@example.com
|
||||||
|
BAMBU_PASSWORD=your_password
|
||||||
|
```
|
||||||
|
|
||||||
|
Optionally set your timezone (defaults to UTC):
|
||||||
|
|
||||||
|
```
|
||||||
|
TIMEZONE=Australia/Melbourne
|
||||||
|
```
|
||||||
|
|
||||||
|
> You can find your timezone name at https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||||
|
|
||||||
|
To save and exit nano: press `Ctrl + X`, then `Y`, then `Enter`.
|
||||||
|
|
||||||
|
### Step 5: Build and Start Bambu-Run
|
||||||
|
|
||||||
|
First, build the container:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose build
|
docker compose build
|
||||||
docker compose run --rm bambu-run python standalone/manage.py migrate --noinput
|
|
||||||
docker compose run --rm bambu-run python standalone/manage.py bambu_collector --once
|
|
||||||
# Paste the printed token into .env as BAMBU_TOKEN=...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Start and create your dashboard login:**
|
This downloads all required software (takes a few minutes the first time).
|
||||||
|
|
||||||
|
### Step 5a: First-Time Authentication
|
||||||
|
|
||||||
|
The first time you connect, Bambu Lab requires email verification. You need to run the collector **interactively** (not in the background) so you can enter the 6-digit code.
|
||||||
|
|
||||||
|
First, set up the database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm bambu-run python standalone/manage.py migrate --noinput
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run the collector (this is what triggers Bambu Lab to send the verification email):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm bambu-run python standalone/manage.py bambu_collector --once
|
||||||
|
```
|
||||||
|
|
||||||
|
You'll see output like:
|
||||||
|
|
||||||
|
```
|
||||||
|
BambuLab Authentication
|
||||||
|
Authenticating as: your_email@example.com
|
||||||
|
...
|
||||||
|
EMAIL VERIFICATION REQUIRED
|
||||||
|
A verification code has been sent to your email.
|
||||||
|
Enter verification code:
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Check your email for the 6-digit code from Bambu Lab
|
||||||
|
2. Type the code and press Enter
|
||||||
|
3. On success, you'll see a token printed:
|
||||||
|
```
|
||||||
|
Authentication successful!
|
||||||
|
Token: eyJhbGciOiJIUzI1N...
|
||||||
|
TIP: Save this token to BAMBU_TOKEN env var to skip login next time
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Copy the full token** and paste it into your `.env` file:
|
||||||
|
```bash
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
Add/uncomment the `BAMBU_TOKEN` line:
|
||||||
|
```
|
||||||
|
BAMBU_TOKEN=eyJhbGciOiJIUzI1N...paste_full_token_here
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Why save the token?** With the token saved, future container restarts authenticate instantly without needing email verification again. Without it, you'd need to repeat this step every time the container restarts.
|
||||||
|
|
||||||
|
### Step 5b: Start Bambu-Run
|
||||||
|
|
||||||
|
Now start everything in the background:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Check that it's running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see the `bambu-run` service with status `Up`.
|
||||||
|
|
||||||
|
### Step 6: Create Your Login Account
|
||||||
|
|
||||||
|
```bash
|
||||||
docker compose exec bambu-run python standalone/manage.py createsuperuser
|
docker compose exec bambu-run python standalone/manage.py createsuperuser
|
||||||
```
|
```
|
||||||
|
|
||||||
Dashboard is at `http://<host-ip>:8000`.
|
You'll be prompted to choose a username, email (optional), and password. This is your login for the dashboard.
|
||||||
|
|
||||||
**Common operations:**
|
### Step 7: Open the Dashboard
|
||||||
|
|
||||||
|
On any device connected to your network (phone, tablet, computer), open a browser and go to:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://raspberrypi.local:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
> If that doesn't work, use your Pi's IP address: `http://<pi-ip-address>:8000`
|
||||||
|
|
||||||
|
Log in with the account you just created. You should see your printer dashboard with live data flowing in.
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
**"Cannot connect to printer" or no data showing:**
|
||||||
|
- Make sure your printer is turned on and connected to the network
|
||||||
|
- Check the logs: `docker compose logs -f`
|
||||||
|
- If you see authentication errors, your token may have expired — re-run Step 5a to get a fresh token
|
||||||
|
|
||||||
|
**"Verification code" or "401 Unauthorized" errors:**
|
||||||
|
- Your `BAMBU_TOKEN` may have expired. Remove it from `.env` and re-run Step 5a
|
||||||
|
- Make sure `BAMBU_USERNAME` and `BAMBU_PASSWORD` are correct in your `.env` file
|
||||||
|
|
||||||
|
**"Cannot connect to Docker daemon":**
|
||||||
|
- Did you log out and back in after Step 3? Docker group changes require a new session
|
||||||
|
|
||||||
|
**Dashboard not loading in browser:**
|
||||||
|
- Verify the container is running: `docker compose ps`
|
||||||
|
- Try using the Pi's IP address instead of `raspberrypi.local`
|
||||||
|
|
||||||
|
**Updating to a newer version:**
|
||||||
```bash
|
```bash
|
||||||
docker compose logs -f # live logs
|
cd ~/Bambu-Run
|
||||||
docker compose down # stop (data preserved in volume)
|
git pull
|
||||||
git pull && docker compose up -d --build # update
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
**Troubleshooting:** Auth errors → remove `BAMBU_TOKEN` from `.env` and re-run the auth step. No data → check `docker compose logs -f` for MQTT connection errors.
|
**Stopping Bambu-Run:**
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Batch Importing Filament Colors and Filament Types
|
|
||||||
|
|
||||||
Bambu-Run ships with a full Bambu Lab color catalog under `docs/Bambu_Color_Catalog/` (one `.txt` file per filament sub-type, e.g. `PLA Basic.txt`, `PETG HF.txt`). Importing these populates the **Filament Colors** database so the dashboard shows proper color names instead of raw hex codes.
|
|
||||||
|
|
||||||
### Adding your own colors
|
|
||||||
|
|
||||||
Need a filament type that isn't in the bundled catalog? Create your own `.txt` file and point the importer at it.
|
|
||||||
|
|
||||||
**File naming** — the filename determines the filament type and sub-type:
|
|
||||||
```
|
|
||||||
PLA Basic.txt → type: PLA, sub-type: PLA Basic
|
|
||||||
PETG HF.txt → type: PETG, sub-type: PETG HF
|
|
||||||
ABS.txt → type: ABS, sub-type: ABS
|
|
||||||
```
|
|
||||||
|
|
||||||
**File format** — list each color on its own line, either as two rows (name then hex) or on the same line:
|
|
||||||
```
|
|
||||||
Jade White
|
|
||||||
Hex:#FFFFFF
|
|
||||||
|
|
||||||
Black Walnut #4F3F24
|
|
||||||
```
|
|
||||||
|
|
||||||
Bambu Lab's website filament pages and their downloadable PDF catalogs are a reliable source — both list color names alongside hex codes you can copy directly.
|
|
||||||
|
|
||||||
### When to run this
|
|
||||||
|
|
||||||
Run the import **once after first setup** to seed the full color catalog in one go, rather than adding colors one by one. Run it again any time you want to add colors for a new filament type. Re-running is always safe — duplicates are detected and skipped automatically.
|
|
||||||
|
|
||||||
### Import all colors (recommended)
|
|
||||||
|
|
||||||
If the container is already running (`docker compose up -d`):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec bambu-run python standalone/manage.py bambu_import_colors docs/Bambu_Color_Catalog/
|
docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
If the container is not running yet:
|
Your data is preserved in a Docker volume and will be there when you start it again.
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose run --rm bambu-run python standalone/manage.py bambu_import_colors docs/Bambu_Color_Catalog/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Import a file from your computer
|
|
||||||
|
|
||||||
If your `.txt` color file lives on your Mac, Pi, or any machine running Docker (i.e. not inside the repo), copy it into the container first, then run the importer:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Step 1 — copy the file from your machine into the container
|
|
||||||
docker compose cp /path/to/your/PLA\ Basic.txt bambu-run:/tmp/
|
|
||||||
|
|
||||||
# Step 2 — run the importer against the copied path
|
|
||||||
docker compose exec bambu-run python standalone/manage.py bambu_import_colors /tmp/PLA\ Basic.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
To import a whole folder of files at once:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Step 1 — copy the folder
|
|
||||||
docker compose cp /path/to/your/color_catalog/ bambu-run:/tmp/color_catalog/
|
|
||||||
|
|
||||||
# Step 2 — import everything in it
|
|
||||||
docker compose exec bambu-run python standalone/manage.py bambu_import_colors /tmp/color_catalog/
|
|
||||||
```
|
|
||||||
|
|
||||||
> **macOS tip:** You can drag a file from Finder into the terminal to paste its full path.
|
|
||||||
|
|
||||||
### Import a single filament type
|
|
||||||
|
|
||||||
To import only one sub-type from the bundled catalog (e.g. just PLA Basic):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose exec bambu-run python standalone/manage.py bambu_import_colors "docs/Bambu_Color_Catalog/PLA Basic.txt"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Preview before importing (dry run)
|
|
||||||
|
|
||||||
Check what would be added without writing anything to the database:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose exec bambu-run python standalone/manage.py bambu_import_colors docs/Bambu_Color_Catalog/ --dry-run
|
|
||||||
```
|
|
||||||
|
|
||||||
### What the output means
|
|
||||||
|
|
||||||
```
|
|
||||||
Processing: PLA Basic.txt → type='PLA' sub_type='PLA Basic'
|
|
||||||
Parsed 40 color(s).
|
|
||||||
+ 'Bambu Green' #009F87 (PLA / PLA Basic)
|
|
||||||
+ 'Jade White' #FFFFFF (PLA / PLA Basic)
|
|
||||||
...
|
|
||||||
──────────────────────────────────────────────────
|
|
||||||
Created: 40
|
|
||||||
Skipped (duplicate): 0
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Created** — new color entries added to the database
|
|
||||||
- **Skipped (duplicate)** — already existed, not changed
|
|
||||||
- **Skipped (no type)** — only shown if `--no-auto-create-filament-type` is used and the filament type isn't in the database yet
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from .models import Printer, PrinterMetrics, Filament, FilamentType, FilamentSnapshot, PrintJob, FilamentUsage, BambuCloudTask
|
from .models import Printer, PrinterMetrics, Filament, FilamentType, FilamentSnapshot, PrintJob, FilamentUsage
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Printer)
|
@admin.register(Printer)
|
||||||
@@ -105,21 +105,3 @@ class FilamentUsageAdmin(admin.ModelAdmin):
|
|||||||
list_display = ('print_job', 'filament', 'tray_id', 'consumed_percent', 'consumed_grams', 'is_primary')
|
list_display = ('print_job', 'filament', 'tray_id', 'consumed_percent', 'consumed_grams', 'is_primary')
|
||||||
list_filter = ('is_primary', 'tray_id')
|
list_filter = ('is_primary', 'tray_id')
|
||||||
readonly_fields = ('consumed_percent', 'consumed_grams')
|
readonly_fields = ('consumed_percent', 'consumed_grams')
|
||||||
|
|
||||||
|
|
||||||
@admin.register(BambuCloudTask)
|
|
||||||
class BambuCloudTaskAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ('task_id', 'design_title', 'plate_title', 'device_serial', 'cloud_status', 'weight_grams', 'cloud_start_time', 'synced_at')
|
|
||||||
list_filter = ('cloud_status', 'use_ams', 'bed_type')
|
|
||||||
search_fields = ('design_title', 'plate_title', 'device_serial', 'task_id')
|
|
||||||
readonly_fields = ('task_id', 'synced_at', 'raw_data')
|
|
||||||
date_hierarchy = 'cloud_start_time'
|
|
||||||
|
|
||||||
fieldsets = (
|
|
||||||
('Identity', {'fields': ('task_id', 'design_id', 'design_title', 'plate_title', 'model_id', 'profile_id', 'plate_index')}),
|
|
||||||
('Device & Print', {'fields': ('device_serial', 'cloud_status', 'bed_type', 'use_ams', 'print_mode')}),
|
|
||||||
('Filament', {'fields': ('weight_grams', 'length_mm', 'ams_detail_mapping')}),
|
|
||||||
('Times', {'fields': ('cloud_start_time', 'cloud_end_time', 'cost_time_seconds', 'synced_at')}),
|
|
||||||
('Media', {'fields': ('cover_url',)}),
|
|
||||||
('Raw', {'fields': ('raw_data',), 'classes': ('collapse',)}),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
"""
|
|
||||||
Thin wrapper around the Bambu Cloud HTTP API using verified endpoints only.
|
|
||||||
|
|
||||||
Uses BambuClient as the transport (auth headers, base URL) but bypasses
|
|
||||||
the package's named methods, which contain guessed/unverified endpoints.
|
|
||||||
|
|
||||||
All functions take a BambuClient instance as first argument.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import timezone as dt_timezone
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Verified HTTP wrappers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def get_tasks(client, limit=20, offset=0):
|
|
||||||
"""Fetch recent cloud tasks. Returns the raw response dict."""
|
|
||||||
return client.get('v1/user-service/my/tasks', params={'limit': limit, 'offset': offset})
|
|
||||||
|
|
||||||
|
|
||||||
def get_profile(client):
|
|
||||||
"""Fetch the authenticated user's profile."""
|
|
||||||
return client.get('v1/user-service/my/profile')
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Upsert helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _parse_cloud_dt(value):
|
|
||||||
"""Parse an ISO-8601 string like '2026-03-28T12:38:29Z' to aware datetime."""
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
from django.utils.dateparse import parse_datetime
|
|
||||||
from django.utils import timezone
|
|
||||||
dt = parse_datetime(value)
|
|
||||||
if dt and dt.tzinfo is None:
|
|
||||||
dt = dt.replace(tzinfo=dt_timezone.utc)
|
|
||||||
return dt
|
|
||||||
|
|
||||||
|
|
||||||
def upsert_cloud_task(task_dict):
|
|
||||||
"""
|
|
||||||
Parse one task dict from the cloud API and upsert into BambuCloudTask.
|
|
||||||
|
|
||||||
Returns the (BambuCloudTask instance, created bool) tuple.
|
|
||||||
"""
|
|
||||||
from .models import BambuCloudTask
|
|
||||||
|
|
||||||
task_id = task_dict.get('id')
|
|
||||||
if not task_id:
|
|
||||||
raise ValueError("task_dict has no 'id' field")
|
|
||||||
|
|
||||||
defaults = {
|
|
||||||
'design_id': task_dict.get('designId') or None,
|
|
||||||
'design_title': task_dict.get('designTitle') or '',
|
|
||||||
'plate_title': task_dict.get('title') or '',
|
|
||||||
'model_id': task_dict.get('modelId') or '',
|
|
||||||
'profile_id': task_dict.get('profileId') or None,
|
|
||||||
'plate_index': task_dict.get('plateIndex'),
|
|
||||||
'device_serial': task_dict.get('deviceId') or '',
|
|
||||||
'cover_url': task_dict.get('cover') or '',
|
|
||||||
'weight_grams': task_dict.get('weight'),
|
|
||||||
'length_mm': task_dict.get('length'),
|
|
||||||
'cost_time_seconds': task_dict.get('costTime'),
|
|
||||||
'cloud_status': task_dict.get('status'),
|
|
||||||
'bed_type': task_dict.get('bedType') or '',
|
|
||||||
'use_ams': bool(task_dict.get('useAms', True)),
|
|
||||||
'print_mode': task_dict.get('mode') or '',
|
|
||||||
'ams_detail_mapping': task_dict.get('amsDetailMapping') or [],
|
|
||||||
'cloud_start_time': _parse_cloud_dt(task_dict.get('startTime')),
|
|
||||||
'cloud_end_time': _parse_cloud_dt(task_dict.get('endTime')),
|
|
||||||
'raw_data': task_dict,
|
|
||||||
}
|
|
||||||
|
|
||||||
return BambuCloudTask.objects.update_or_create(task_id=task_id, defaults=defaults)
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_and_upsert_task(client, print_job):
|
|
||||||
"""
|
|
||||||
Called by bambu_collector at print finalization.
|
|
||||||
|
|
||||||
Fetches recent tasks from cloud, finds the one matching print_job.cloud_task_id_raw,
|
|
||||||
upserts BambuCloudTask, and wires up the FK on print_job.
|
|
||||||
|
|
||||||
Non-fatal: all errors are logged as warnings only.
|
|
||||||
"""
|
|
||||||
if not print_job.cloud_task_id_raw:
|
|
||||||
logger.debug(f"Job #{print_job.id} has no cloud_task_id_raw — skipping cloud sync")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = get_tasks(client, limit=20)
|
|
||||||
hits = response.get('hits', response.get('tasks', []))
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Cloud tasks fetch failed for job #{print_job.id}: {e}")
|
|
||||||
return
|
|
||||||
|
|
||||||
target = next((t for t in hits if t.get('id') == print_job.cloud_task_id_raw), None)
|
|
||||||
if not target:
|
|
||||||
logger.warning(
|
|
||||||
f"Job #{print_job.id}: cloud task {print_job.cloud_task_id_raw} "
|
|
||||||
f"not found in last {len(hits)} tasks from API"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
cloud_task, created = upsert_cloud_task(target)
|
|
||||||
print_job.cloud_task = cloud_task
|
|
||||||
print_job.save(update_fields=['cloud_task'])
|
|
||||||
action = 'created' if created else 'updated'
|
|
||||||
logger.info(
|
|
||||||
f"Job #{print_job.id}: cloud task {print_job.cloud_task_id_raw} {action} "
|
|
||||||
f"— design_title={cloud_task.design_title!r}"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Cloud task upsert failed for job #{print_job.id}: {e}")
|
|
||||||
@@ -51,35 +51,5 @@ class _Settings:
|
|||||||
def AUTO_CREATE_BRAND(self):
|
def AUTO_CREATE_BRAND(self):
|
||||||
return get_setting("BAMBU_RUN_AUTO_CREATE_BRAND", "Bambu Lab")
|
return get_setting("BAMBU_RUN_AUTO_CREATE_BRAND", "Bambu Lab")
|
||||||
|
|
||||||
# MCP Server settings
|
|
||||||
@property
|
|
||||||
def MCP_API_KEY(self):
|
|
||||||
return get_setting("BAMBU_RUN_MCP_API_KEY", None)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def MCP_HOST(self):
|
|
||||||
return get_setting("BAMBU_RUN_MCP_HOST", "0.0.0.0")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def MCP_PORT(self):
|
|
||||||
return get_setting("BAMBU_RUN_MCP_PORT", 8808)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def MCP_AUTH_BACKEND(self):
|
|
||||||
return get_setting("BAMBU_RUN_MCP_AUTH_BACKEND", None)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def MCP_HIDE_SENSITIVE(self):
|
|
||||||
return get_setting("BAMBU_RUN_MCP_HIDE_SENSITIVE", False)
|
|
||||||
|
|
||||||
# Cloud sync settings
|
|
||||||
@property
|
|
||||||
def CLOUD_SYNC_ENABLED(self):
|
|
||||||
return get_setting("BAMBU_RUN_CLOUD_SYNC_ENABLED", True)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def CLOUD_SYNC_DAYS(self):
|
|
||||||
return get_setting("BAMBU_RUN_CLOUD_SYNC_DAYS", 30)
|
|
||||||
|
|
||||||
|
|
||||||
app_settings = _Settings()
|
app_settings = _Settings()
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
"""
|
|
||||||
Pure helpers for the `bambu_diagnose` management command.
|
|
||||||
|
|
||||||
Kept separate from the command itself (and free of Django/network imports)
|
|
||||||
so the report-building and redaction logic can be unit-tested without
|
|
||||||
talking to the real Bambu Lab cloud or MQTT broker.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
# Keys whose values are always replaced outright, regardless of nesting depth.
|
|
||||||
_SECRET_KEY_SUBSTRINGS = ("password", "token", "secret", "access_code", "authorization")
|
|
||||||
|
|
||||||
# Keys that identify a specific physical device/spool/account — not secret,
|
|
||||||
# but identifying, so they're partially masked by default before anything
|
|
||||||
# gets pasted into a public GitHub issue.
|
|
||||||
_IDENTIFIER_KEYS = {"dev_id", "device_id", "serial_number", "tray_uuid", "tag_uid", "uid"}
|
|
||||||
|
|
||||||
|
|
||||||
def _mask_identifier(value: Any) -> Any:
|
|
||||||
if not isinstance(value, str) or len(value) <= 8:
|
|
||||||
return "***"
|
|
||||||
return f"{value[:4]}...{value[-4:]}"
|
|
||||||
|
|
||||||
|
|
||||||
def redact_diagnostics(data: Any, redact: bool = True) -> Any:
|
|
||||||
"""Recursively redact secrets and mask identifiers in a diagnostics payload.
|
|
||||||
|
|
||||||
`redact=False` returns the data unchanged — only for the reporter's own
|
|
||||||
local debugging, never for anything posted publicly.
|
|
||||||
"""
|
|
||||||
if not redact:
|
|
||||||
return data
|
|
||||||
return _redact(data)
|
|
||||||
|
|
||||||
|
|
||||||
def _redact(obj: Any) -> Any:
|
|
||||||
if isinstance(obj, dict):
|
|
||||||
result = {}
|
|
||||||
for key, value in obj.items():
|
|
||||||
lowered = key.lower()
|
|
||||||
if any(secret in lowered for secret in _SECRET_KEY_SUBSTRINGS):
|
|
||||||
result[key] = "***REDACTED***"
|
|
||||||
elif lowered in _IDENTIFIER_KEYS:
|
|
||||||
result[key] = _mask_identifier(value)
|
|
||||||
else:
|
|
||||||
result[key] = _redact(value)
|
|
||||||
return result
|
|
||||||
if isinstance(obj, list):
|
|
||||||
return [_redact(item) for item in obj]
|
|
||||||
return obj
|
|
||||||
|
|
||||||
|
|
||||||
def build_diagnostics_report(
|
|
||||||
devices: List[Dict[str, Any]],
|
|
||||||
raw_payloads: Dict[str, Optional[Dict[str, Any]]],
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Assemble the (pre-redaction) diagnostics report from discovered devices
|
|
||||||
and whatever raw MQTT payload was captured for each during the listen window.
|
|
||||||
"""
|
|
||||||
device_entries = []
|
|
||||||
for device in devices:
|
|
||||||
dev_id = device.get("dev_id")
|
|
||||||
payload = raw_payloads.get(dev_id)
|
|
||||||
entry = {
|
|
||||||
"device_info": device,
|
|
||||||
"raw_mqtt_payload": payload,
|
|
||||||
}
|
|
||||||
if payload is None:
|
|
||||||
entry["note"] = "No MQTT data received within the listen window."
|
|
||||||
device_entries.append(entry)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"device_count": len(devices),
|
|
||||||
"devices": device_entries,
|
|
||||||
}
|
|
||||||
@@ -52,10 +52,10 @@ class FilamentForm(forms.ModelForm):
|
|||||||
model = Filament
|
model = Filament
|
||||||
fields = [
|
fields = [
|
||||||
'tray_uuid', 'tag_uid', 'tag_id', 'created_by',
|
'tray_uuid', 'tag_uid', 'tag_id', 'created_by',
|
||||||
'filament_type', 'type', 'sub_type', 'brand', 'color', 'color_hex', 'is_transparent',
|
'filament_type', 'type', 'sub_type', 'brand', 'color', 'color_hex',
|
||||||
'diameter', 'initial_weight_grams',
|
'diameter', 'initial_weight_grams',
|
||||||
'remaining_percent', 'remaining_weight_grams',
|
'remaining_percent', 'remaining_weight_grams',
|
||||||
'is_loaded_in_ams', 'current_tray_id', 'ams_unit_id', 'ams_type',
|
'is_loaded_in_ams', 'current_tray_id',
|
||||||
'purchase_date', 'purchase_price', 'supplier', 'notes'
|
'purchase_date', 'purchase_price', 'supplier', 'notes'
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
@@ -71,10 +71,10 @@ class FilamentForm(forms.ModelForm):
|
|||||||
}),
|
}),
|
||||||
'tag_id': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Optional - User-defined ID'}),
|
'tag_id': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Optional - User-defined ID'}),
|
||||||
'created_by': forms.Select(attrs={'class': 'form-select'}),
|
'created_by': forms.Select(attrs={'class': 'form-select'}),
|
||||||
'filament_type': forms.Select(attrs={'class': 'form-select', 'id': 'id_filament_type'}),
|
'filament_type': forms.Select(attrs={'class': 'form-select'}),
|
||||||
'type': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g., PLA, PETG, ABS'}),
|
'type': forms.HiddenInput(),
|
||||||
'sub_type': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g., PLA Basic (optional)'}),
|
'sub_type': forms.HiddenInput(),
|
||||||
'brand': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g., Bambu Lab'}),
|
'brand': forms.HiddenInput(),
|
||||||
'color': forms.Select(attrs={'class': 'form-select', 'id': 'id_color'}),
|
'color': forms.Select(attrs={'class': 'form-select', 'id': 'id_color'}),
|
||||||
'color_hex': forms.TextInput(attrs={
|
'color_hex': forms.TextInput(attrs={
|
||||||
'class': 'form-control',
|
'class': 'form-control',
|
||||||
@@ -85,17 +85,8 @@ class FilamentForm(forms.ModelForm):
|
|||||||
'initial_weight_grams': forms.NumberInput(attrs={'class': 'form-control', 'placeholder': '1000'}),
|
'initial_weight_grams': forms.NumberInput(attrs={'class': 'form-control', 'placeholder': '1000'}),
|
||||||
'remaining_percent': forms.NumberInput(attrs={'class': 'form-control', 'min': '0', 'max': '100'}),
|
'remaining_percent': forms.NumberInput(attrs={'class': 'form-control', 'min': '0', 'max': '100'}),
|
||||||
'remaining_weight_grams': forms.NumberInput(attrs={'class': 'form-control', 'readonly': 'readonly'}),
|
'remaining_weight_grams': forms.NumberInput(attrs={'class': 'form-control', 'readonly': 'readonly'}),
|
||||||
'is_transparent': forms.CheckboxInput(attrs={'class': 'form-check-input', 'id': 'id_is_transparent'}),
|
|
||||||
'is_loaded_in_ams': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
'is_loaded_in_ams': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||||
'current_tray_id': forms.NumberInput(attrs={
|
'current_tray_id': forms.NumberInput(attrs={'class': 'form-control', 'min': '0', 'max': '3'}),
|
||||||
'class': 'form-control', 'min': '0', 'max': '15',
|
|
||||||
'placeholder': '0–3 for AMS / AMS 2 Pro, 0 for AMS HT',
|
|
||||||
}),
|
|
||||||
'ams_unit_id': forms.NumberInput(attrs={
|
|
||||||
'class': 'form-control', 'min': '0', 'max': '255',
|
|
||||||
'placeholder': 'AMS unit id (0,1,… or 128 for AMS HT)',
|
|
||||||
}),
|
|
||||||
'ams_type': forms.Select(attrs={'class': 'form-select'}),
|
|
||||||
'purchase_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
|
'purchase_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
|
||||||
'purchase_price': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
|
'purchase_price': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
|
||||||
'supplier': forms.TextInput(attrs={'class': 'form-control'}),
|
'supplier': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
@@ -114,8 +105,6 @@ class FilamentForm(forms.ModelForm):
|
|||||||
self.fields['type'].required = False
|
self.fields['type'].required = False
|
||||||
self.fields['sub_type'].required = False
|
self.fields['sub_type'].required = False
|
||||||
self.fields['brand'].required = False
|
self.fields['brand'].required = False
|
||||||
self.fields['ams_unit_id'].required = False
|
|
||||||
self.fields['ams_type'].required = False
|
|
||||||
|
|
||||||
self._populate_color_choices()
|
self._populate_color_choices()
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,8 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import ssl
|
import ssl
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any, Dict, Optional
|
from typing import Optional
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
@@ -27,56 +26,6 @@ from bambu_run.models import Printer, PrinterMetrics
|
|||||||
logger = logging.getLogger("bambu_run.collector")
|
logger = logging.getLogger("bambu_run.collector")
|
||||||
|
|
||||||
|
|
||||||
def resolve_printer_device(device_id: str, device_info: Optional[dict] = None) -> Printer:
|
|
||||||
"""Find-or-create the Printer row for a Bambu cloud device, keyed by serial number.
|
|
||||||
|
|
||||||
`device_info` is one entry from BambuClient.get_devices() (keys: name,
|
|
||||||
dev_product_name, dev_id, ...). Falls back to generic defaults when unavailable
|
|
||||||
(e.g. local-only connections that never call get_devices()).
|
|
||||||
"""
|
|
||||||
device_info = device_info or {}
|
|
||||||
name = device_info.get("name") or "Bambu Lab Printer"
|
|
||||||
model = device_info.get("dev_product_name") or "Bambu Lab"
|
|
||||||
|
|
||||||
printer = Printer.objects.filter(serial_number=device_id).first()
|
|
||||||
|
|
||||||
if printer is None:
|
|
||||||
# Upgrade path: a pre-multi-printer deployment has exactly one Printer row
|
|
||||||
# with no serial number yet. Backfill it instead of creating a duplicate.
|
|
||||||
# If there's more than one such row, we can't tell which one this device
|
|
||||||
# used to be, so don't guess — create a fresh row instead.
|
|
||||||
legacy_candidates = list(Printer.objects.filter(serial_number__isnull=True)[:2])
|
|
||||||
if len(legacy_candidates) == 1:
|
|
||||||
printer = legacy_candidates[0]
|
|
||||||
printer.serial_number = device_id
|
|
||||||
|
|
||||||
if printer is None:
|
|
||||||
printer = Printer(serial_number=device_id)
|
|
||||||
|
|
||||||
printer.name = name
|
|
||||||
printer.model = model
|
|
||||||
printer.manufacturer = "Bambu Lab"
|
|
||||||
printer.is_active = True
|
|
||||||
printer.save()
|
|
||||||
return printer
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class DeviceSession:
|
|
||||||
"""Per-printer mutable state for one bound device in a multi-printer collector run."""
|
|
||||||
|
|
||||||
device_id: str
|
|
||||||
client: Any # BambuPrinter
|
|
||||||
printer: Printer
|
|
||||||
current_print_job: Optional[Any] = None
|
|
||||||
last_gcode_state: Optional[str] = None
|
|
||||||
last_subtask_name: Optional[str] = None
|
|
||||||
trays_used: set = field(default_factory=set)
|
|
||||||
error_count: int = 0
|
|
||||||
success_count: int = 0
|
|
||||||
mqtt_connect_errors: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
"""
|
"""
|
||||||
MQTT Poll -> PrinterMetrics -> FilamentSnapshot -> Auto-Match -> Update Filament
|
MQTT Poll -> PrinterMetrics -> FilamentSnapshot -> Auto-Match -> Update Filament
|
||||||
@@ -102,11 +51,18 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.sessions: Dict[str, DeviceSession] = {}
|
self.printer_client = None
|
||||||
self._token: Optional[str] = None
|
self.printer_device = None
|
||||||
self.verbose = False
|
self.verbose = False
|
||||||
self.disable_ssl_verify = False
|
self.disable_ssl_verify = False
|
||||||
|
self.error_count = 0
|
||||||
|
self.success_count = 0
|
||||||
|
self.mqtt_connect_errors = 0
|
||||||
self.start_time = None
|
self.start_time = None
|
||||||
|
self.current_print_job = None
|
||||||
|
self.last_gcode_state = None
|
||||||
|
self.last_subtask_name = None
|
||||||
|
self.trays_used = set()
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
self.verbose = options["verbose"]
|
self.verbose = options["verbose"]
|
||||||
@@ -144,22 +100,18 @@ class Command(BaseCommand):
|
|||||||
self._configure_logging()
|
self._configure_logging()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._initialize_printers()
|
self._initialize_printer()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise CommandError(f"Initialization failed: {e}")
|
raise CommandError(f"Initialization failed: {e}")
|
||||||
|
|
||||||
self.start_time = timezone.now()
|
self.start_time = timezone.now()
|
||||||
printer_names = ", ".join(s.printer.name for s in self.sessions.values())
|
logger.info(f"Bambu Run data collector started for printer: {self.printer_device.name}")
|
||||||
logger.info(f"Bambu Run data collector started for {len(self.sessions)} printer(s): {printer_names}")
|
|
||||||
logger.info(f"Collection interval: {interval} seconds")
|
logger.info(f"Collection interval: {interval} seconds")
|
||||||
logger.info(f"Mode: {'Single run' if run_once else 'Continuous'}")
|
logger.info(f"Mode: {'Single run' if run_once else 'Continuous'}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if run_once:
|
if run_once:
|
||||||
import time as _time
|
self._collect_printer_data()
|
||||||
_time.sleep(5)
|
|
||||||
for session in self.sessions.values():
|
|
||||||
self._collect_printer_data(session)
|
|
||||||
logger.info("Single collection completed successfully")
|
logger.info("Single collection completed successfully")
|
||||||
else:
|
else:
|
||||||
self._run_continuous_loop(interval)
|
self._run_continuous_loop(interval)
|
||||||
@@ -170,24 +122,6 @@ class Command(BaseCommand):
|
|||||||
logger.exception(f"Fatal error in main loop: {e}")
|
logger.exception(f"Fatal error in main loop: {e}")
|
||||||
raise CommandError(f"Runner failed: {e}")
|
raise CommandError(f"Runner failed: {e}")
|
||||||
|
|
||||||
def _request_full_status_when_ready(self, client, timeout: float = 20.0) -> None:
|
|
||||||
"""Send pushall once the MQTT broker connection is confirmed.
|
|
||||||
|
|
||||||
BambuPrinter._connected is set True immediately after connect(blocking=False),
|
|
||||||
before the broker handshake. Poll MQTTClient.connected (set in _on_connect)
|
|
||||||
instead, so publish() won't raise "Not connected to broker".
|
|
||||||
"""
|
|
||||||
import time as _time
|
|
||||||
deadline = _time.time() + timeout
|
|
||||||
while _time.time() < deadline:
|
|
||||||
mqtt_client = getattr(client, "_mqtt", None)
|
|
||||||
if mqtt_client is not None and getattr(mqtt_client, "connected", False):
|
|
||||||
client._mqtt.request_full_status()
|
|
||||||
logger.info("Sent MQTT pushall request")
|
|
||||||
return
|
|
||||||
_time.sleep(0.5)
|
|
||||||
logger.warning("MQTT broker connection not confirmed within %.1fs; skipping pushall", timeout)
|
|
||||||
|
|
||||||
def _configure_logging(self):
|
def _configure_logging(self):
|
||||||
log_level = logging.DEBUG if self.verbose else logging.INFO
|
log_level = logging.DEBUG if self.verbose else logging.INFO
|
||||||
logger.setLevel(log_level)
|
logger.setLevel(log_level)
|
||||||
@@ -201,9 +135,7 @@ class Command(BaseCommand):
|
|||||||
handler.setFormatter(formatter)
|
handler.setFormatter(formatter)
|
||||||
logger.addHandler(handler)
|
logger.addHandler(handler)
|
||||||
|
|
||||||
def _initialize_printers(self):
|
def _initialize_printer(self):
|
||||||
"""Authenticate once, discover every device bound to the account, and open
|
|
||||||
one BambuPrinter (own MQTT thread) per device — all in this single process."""
|
|
||||||
from bambu_run.mqtt_client import BambuPrinter
|
from bambu_run.mqtt_client import BambuPrinter
|
||||||
|
|
||||||
bambu_username = os.environ.get("BAMBU_USERNAME")
|
bambu_username = os.environ.get("BAMBU_USERNAME")
|
||||||
@@ -217,12 +149,25 @@ class Command(BaseCommand):
|
|||||||
"environment variables must be set"
|
"environment variables must be set"
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("Authenticating with Bambu Lab cloud...")
|
logger.info("Connecting to Bambu Lab printer...")
|
||||||
try:
|
try:
|
||||||
auth = BambuPrinter(
|
if bambu_token:
|
||||||
username=bambu_username, password=bambu_password, token=bambu_token,
|
logger.info("Using saved BAMBU_TOKEN for authentication")
|
||||||
)
|
self.printer_client = BambuPrinter(
|
||||||
self._token = auth._ensure_token()
|
token=bambu_token, device_id=bambu_device_id
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("Authenticating with username/password")
|
||||||
|
self.printer_client = BambuPrinter(
|
||||||
|
username=bambu_username,
|
||||||
|
password=bambu_password,
|
||||||
|
device_id=bambu_device_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Initiating MQTT connection...")
|
||||||
|
self.printer_client.connect(blocking=False)
|
||||||
|
logger.info("MQTT connection initiated (non-blocking)")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if "CERTIFICATE_VERIFY_FAILED" in str(e) or "SSL" in str(e):
|
if "CERTIFICATE_VERIFY_FAILED" in str(e) or "SSL" in str(e):
|
||||||
error_msg = (
|
error_msg = (
|
||||||
@@ -233,62 +178,56 @@ class Command(BaseCommand):
|
|||||||
"3. pip install --upgrade certifi\n"
|
"3. pip install --upgrade certifi\n"
|
||||||
)
|
)
|
||||||
raise CommandError(error_msg)
|
raise CommandError(error_msg)
|
||||||
raise CommandError(f"Failed to authenticate: {e}")
|
raise CommandError(f"Failed to initialize printer client: {e}")
|
||||||
|
|
||||||
device_infos = self._discover_devices(bambu_device_id)
|
self.printer_device = self._ensure_printer_device_exists()
|
||||||
for device_id, device_info in device_infos.items():
|
logger.info(f"Initialized for printer device: {self.printer_device}")
|
||||||
|
|
||||||
|
def _ensure_printer_device_exists(self) -> Printer:
|
||||||
|
try:
|
||||||
|
snapshot = self.printer_client.get_snapshot()
|
||||||
|
|
||||||
|
if snapshot:
|
||||||
|
device, created = Printer.objects.update_or_create(
|
||||||
|
model="Bambu Lab",
|
||||||
|
defaults={
|
||||||
|
"name": "Bambu Lab Printer",
|
||||||
|
"manufacturer": "Bambu Lab",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
action = "Created" if created else "Updated"
|
||||||
|
logger.info(f"{action} printer device record: {device}")
|
||||||
|
return device
|
||||||
|
else:
|
||||||
|
logger.warning("Snapshot returned None - MQTT not connected yet")
|
||||||
|
device = Printer.objects.filter(is_active=True).first()
|
||||||
|
if device:
|
||||||
|
logger.info(f"Using existing device record: {device}")
|
||||||
|
return device
|
||||||
|
else:
|
||||||
|
device = Printer.objects.create(
|
||||||
|
name="Bambu Lab Printer",
|
||||||
|
model="Bambu Lab",
|
||||||
|
manufacturer="Bambu Lab",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
logger.info(f"Created placeholder device: {device}")
|
||||||
|
return device
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error during device initialization: {e}")
|
||||||
try:
|
try:
|
||||||
self._add_session(device_id, device_info)
|
device = Printer.objects.filter(is_active=True).first()
|
||||||
except Exception as e:
|
if device:
|
||||||
logger.error(f"Failed to initialize printer {device_id}: {e}")
|
logger.warning(f"Using existing device record from DB: {device}")
|
||||||
|
return device
|
||||||
if not self.sessions:
|
else:
|
||||||
raise CommandError("No printer sessions could be initialized")
|
raise CommandError(
|
||||||
|
"No printer device found in database and initialization failed."
|
||||||
def _discover_devices(self, explicit_device_id: Optional[str]) -> Dict[str, dict]:
|
)
|
||||||
"""Return {device_id: device_info} for every printer to monitor.
|
except Printer.DoesNotExist:
|
||||||
|
raise CommandError("Failed to create or retrieve printer device.")
|
||||||
device_info comes from BambuClient.get_devices() (name, dev_product_name,
|
|
||||||
etc.) — empty dict when explicitly pinned to one device via BAMBU_DEVICE_ID
|
|
||||||
and the cloud listing can't be reached.
|
|
||||||
"""
|
|
||||||
from bambu_run.mqtt_client import BambuClient
|
|
||||||
|
|
||||||
try:
|
|
||||||
cloud = BambuClient(token=self._token)
|
|
||||||
devices = cloud.get_devices()
|
|
||||||
except Exception as e:
|
|
||||||
if explicit_device_id:
|
|
||||||
logger.warning(f"Could not list account devices ({e}); using BAMBU_DEVICE_ID only")
|
|
||||||
return {explicit_device_id: {}}
|
|
||||||
raise
|
|
||||||
|
|
||||||
device_infos = {d.get("dev_id"): d for d in devices if d.get("dev_id")}
|
|
||||||
|
|
||||||
if explicit_device_id:
|
|
||||||
return {explicit_device_id: device_infos.get(explicit_device_id, {})}
|
|
||||||
|
|
||||||
if not device_infos:
|
|
||||||
raise CommandError("No devices found on this account")
|
|
||||||
|
|
||||||
return device_infos
|
|
||||||
|
|
||||||
def _add_session(self, device_id: str, device_info: dict) -> "DeviceSession":
|
|
||||||
from bambu_run.mqtt_client import BambuPrinter
|
|
||||||
|
|
||||||
logger.info(f"Connecting to printer {device_id} ({device_info.get('name', 'unknown')})...")
|
|
||||||
client = BambuPrinter(token=self._token, device_id=device_id)
|
|
||||||
client.connect(blocking=False)
|
|
||||||
try:
|
|
||||||
self._request_full_status_when_ready(client)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("pushall request skipped (non-fatal): %s", e)
|
|
||||||
|
|
||||||
printer = resolve_printer_device(device_id, device_info)
|
|
||||||
session = DeviceSession(device_id=device_id, client=client, printer=printer)
|
|
||||||
self.sessions[device_id] = session
|
|
||||||
logger.info(f"Initialized session for printer: {printer}")
|
|
||||||
return session
|
|
||||||
|
|
||||||
def _run_continuous_loop(self, interval: int):
|
def _run_continuous_loop(self, interval: int):
|
||||||
iteration = 0
|
iteration = 0
|
||||||
@@ -299,8 +238,7 @@ class Command(BaseCommand):
|
|||||||
if self.verbose:
|
if self.verbose:
|
||||||
logger.debug(f"=== Iteration {iteration} ===")
|
logger.debug(f"=== Iteration {iteration} ===")
|
||||||
|
|
||||||
for session in list(self.sessions.values()):
|
self._collect_printer_data()
|
||||||
self._collect_printer_data(session)
|
|
||||||
|
|
||||||
elapsed = time.time() - loop_start
|
elapsed = time.time() - loop_start
|
||||||
sleep_time = max(0, interval - elapsed)
|
sleep_time = max(0, interval - elapsed)
|
||||||
@@ -310,28 +248,9 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
if iteration % 100 == 0:
|
if iteration % 100 == 0:
|
||||||
self._print_statistics()
|
self._print_statistics()
|
||||||
self._refresh_devices()
|
|
||||||
|
|
||||||
time.sleep(sleep_time)
|
time.sleep(sleep_time)
|
||||||
|
|
||||||
def _refresh_devices(self):
|
|
||||||
"""Pick up printers added to the account without restarting the process."""
|
|
||||||
if os.environ.get("BAMBU_DEVICE_ID"):
|
|
||||||
return # pinned to a single explicit device — nothing to discover
|
|
||||||
try:
|
|
||||||
device_infos = self._discover_devices(None)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Device refresh skipped (non-fatal): {e}")
|
|
||||||
return
|
|
||||||
|
|
||||||
for device_id, device_info in device_infos.items():
|
|
||||||
if device_id not in self.sessions:
|
|
||||||
logger.info(f"New printer detected on account: {device_id}")
|
|
||||||
try:
|
|
||||||
self._add_session(device_id, device_info)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to initialize newly-detected printer {device_id}: {e}")
|
|
||||||
|
|
||||||
def _convert_mqtt_color(self, mqtt_color):
|
def _convert_mqtt_color(self, mqtt_color):
|
||||||
if not mqtt_color:
|
if not mqtt_color:
|
||||||
return None
|
return None
|
||||||
@@ -397,7 +316,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
def _auto_create_filament(self, tray_data):
|
def _auto_create_filament(self, tray_data):
|
||||||
from bambu_run.models import Filament, FilamentType
|
from bambu_run.models import Filament, FilamentType
|
||||||
from bambu_run.utils import strip_color_padding, match_filament_color, is_mqtt_color_transparent
|
from bambu_run.utils import strip_color_padding, match_filament_color
|
||||||
|
|
||||||
tray_uuid = tray_data.get('tray_uuid')
|
tray_uuid = tray_data.get('tray_uuid')
|
||||||
tag_uid = tray_data.get('tag_uid')
|
tag_uid = tray_data.get('tag_uid')
|
||||||
@@ -410,10 +329,10 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
default_brand = app_settings.AUTO_CREATE_BRAND
|
default_brand = app_settings.AUTO_CREATE_BRAND
|
||||||
|
|
||||||
transparent = is_mqtt_color_transparent(mqtt_color)
|
|
||||||
color_code = strip_color_padding(mqtt_color)
|
color_code = strip_color_padding(mqtt_color)
|
||||||
color_hex = f"#{color_code}" if color_code else None
|
color_hex = f"#{color_code}" if color_code else None
|
||||||
|
|
||||||
|
color_name = mqtt_color
|
||||||
filament_color = match_filament_color(
|
filament_color = match_filament_color(
|
||||||
filament_type=type_val,
|
filament_type=type_val,
|
||||||
filament_sub_type=sub_type,
|
filament_sub_type=sub_type,
|
||||||
@@ -423,11 +342,10 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
if filament_color:
|
if filament_color:
|
||||||
color_name = filament_color.color_name
|
color_name = filament_color.color_name
|
||||||
transparent = transparent or filament_color.is_transparent
|
|
||||||
if self.verbose:
|
if self.verbose:
|
||||||
logger.info(f"Matched color from database: {color_name} (#{color_code})")
|
logger.info(f"Matched color from database: {color_name} (#{color_code})")
|
||||||
else:
|
else:
|
||||||
color_name = color_hex or mqtt_color
|
color_name = mqtt_color
|
||||||
if self.verbose:
|
if self.verbose:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"No color match in database for {type_val} {sub_type} #{color_code}. "
|
f"No color match in database for {type_val} {sub_type} #{color_code}. "
|
||||||
@@ -451,15 +369,12 @@ class Command(BaseCommand):
|
|||||||
brand=default_brand,
|
brand=default_brand,
|
||||||
color=color_name,
|
color=color_name,
|
||||||
color_hex=color_hex,
|
color_hex=color_hex,
|
||||||
is_transparent=transparent,
|
|
||||||
diameter=diameter,
|
diameter=diameter,
|
||||||
initial_weight_grams=initial_weight,
|
initial_weight_grams=initial_weight,
|
||||||
remaining_percent=remain_percent,
|
remaining_percent=remain_percent,
|
||||||
created_by='Auto Detection',
|
created_by='Auto Detection',
|
||||||
is_loaded_in_ams=True,
|
is_loaded_in_ams=True,
|
||||||
current_tray_id=tray_data.get('tray_id'),
|
current_tray_id=tray_data.get('tray_id'),
|
||||||
ams_unit_id=tray_data.get('ams_unit_id'),
|
|
||||||
ams_type=tray_data.get('ams_type', '') or '',
|
|
||||||
last_loaded_date=timezone.now(),
|
last_loaded_date=timezone.now(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -473,13 +388,9 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
return filament
|
return filament
|
||||||
|
|
||||||
def _update_filament_status(self, filament, tray_id, remain_percent, tray_data=None):
|
def _update_filament_status(self, filament, tray_id, remain_percent):
|
||||||
from bambu_run.models import Filament
|
from bambu_run.models import Filament
|
||||||
|
|
||||||
tray_data = tray_data or {}
|
|
||||||
ams_unit_id = tray_data.get('ams_unit_id')
|
|
||||||
ams_type_label = tray_data.get('ams_type', '') or ''
|
|
||||||
|
|
||||||
if filament.remaining_percent != remain_percent:
|
if filament.remaining_percent != remain_percent:
|
||||||
filament.remaining_percent = remain_percent
|
filament.remaining_percent = remain_percent
|
||||||
filament.update_remaining_weight()
|
filament.update_remaining_weight()
|
||||||
@@ -487,19 +398,10 @@ class Command(BaseCommand):
|
|||||||
if self.verbose:
|
if self.verbose:
|
||||||
logger.debug(f"Updated filament {filament}: {remain_percent}%")
|
logger.debug(f"Updated filament {filament}: {remain_percent}%")
|
||||||
|
|
||||||
location_changed = (
|
if not filament.is_loaded_in_ams or filament.current_tray_id != tray_id:
|
||||||
not filament.is_loaded_in_ams
|
previous_filament = Filament.objects.filter(
|
||||||
or filament.current_tray_id != tray_id
|
|
||||||
or (ams_unit_id is not None and filament.ams_unit_id != ams_unit_id)
|
|
||||||
)
|
|
||||||
if location_changed:
|
|
||||||
# Unload anything previously occupying THIS exact (unit, tray) slot.
|
|
||||||
unload_qs = Filament.objects.filter(
|
|
||||||
is_loaded_in_ams=True, current_tray_id=tray_id
|
is_loaded_in_ams=True, current_tray_id=tray_id
|
||||||
).exclude(id=filament.id)
|
).exclude(id=filament.id).first()
|
||||||
if ams_unit_id is not None:
|
|
||||||
unload_qs = unload_qs.filter(ams_unit_id=ams_unit_id)
|
|
||||||
previous_filament = unload_qs.first()
|
|
||||||
|
|
||||||
if previous_filament:
|
if previous_filament:
|
||||||
previous_filament.is_loaded_in_ams = False
|
previous_filament.is_loaded_in_ams = False
|
||||||
@@ -507,21 +409,14 @@ class Command(BaseCommand):
|
|||||||
previous_filament.save()
|
previous_filament.save()
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Auto-unloaded {previous_filament} from Tray {tray_id} "
|
f"Auto-unloaded {previous_filament} from Tray {tray_id} "
|
||||||
f"(unit {ams_unit_id}; replaced by {filament.brand} {filament.type} - {filament.color})"
|
f"(replaced by {filament.brand} {filament.type} - {filament.color})"
|
||||||
)
|
)
|
||||||
|
|
||||||
filament.is_loaded_in_ams = True
|
filament.is_loaded_in_ams = True
|
||||||
filament.current_tray_id = tray_id
|
filament.current_tray_id = tray_id
|
||||||
if ams_unit_id is not None:
|
|
||||||
filament.ams_unit_id = ams_unit_id
|
|
||||||
if ams_type_label:
|
|
||||||
filament.ams_type = ams_type_label
|
|
||||||
filament.last_loaded_date = timezone.now()
|
filament.last_loaded_date = timezone.now()
|
||||||
if self.verbose:
|
if self.verbose:
|
||||||
logger.debug(f"Updated filament location: unit={ams_unit_id} tray={tray_id}")
|
logger.debug(f"Updated filament location: Tray {tray_id}")
|
||||||
elif ams_type_label and filament.ams_type != ams_type_label:
|
|
||||||
# Same slot but ams_type was previously unknown — fill it in.
|
|
||||||
filament.ams_type = ams_type_label
|
|
||||||
|
|
||||||
filament.save()
|
filament.save()
|
||||||
|
|
||||||
@@ -542,13 +437,10 @@ class Command(BaseCommand):
|
|||||||
if filament:
|
if filament:
|
||||||
remain_percent = tray_data.get('remain_percent')
|
remain_percent = tray_data.get('remain_percent')
|
||||||
if remain_percent is not None:
|
if remain_percent is not None:
|
||||||
self._update_filament_status(filament, tray_id, remain_percent, tray_data)
|
self._update_filament_status(filament, tray_id, remain_percent)
|
||||||
|
|
||||||
# Locate the AMS unit this tray belongs to. Use the unit_id supplied
|
unit_id = str(int(tray_id) // 4) if tray_id.isdigit() else None
|
||||||
# by the snapshot directly (matches MQTT ams[i].id, including 128 for AMS HT)
|
unit_data = ams_units.get(unit_id, {})
|
||||||
# — the legacy `tray_id // 4` math breaks for AMS HT.
|
|
||||||
unit_id_int = tray_data.get('ams_unit_id')
|
|
||||||
unit_data = ams_units.get(str(unit_id_int)) if unit_id_int is not None else {}
|
|
||||||
|
|
||||||
FilamentSnapshot.objects.create(
|
FilamentSnapshot.objects.create(
|
||||||
printer_metric=printer_metric,
|
printer_metric=printer_metric,
|
||||||
@@ -569,79 +461,70 @@ class Command(BaseCommand):
|
|||||||
match_method=match_method
|
match_method=match_method
|
||||||
)
|
)
|
||||||
|
|
||||||
def _track_print_job(self, session, metric, snapshot):
|
def _track_print_job(self, metric, snapshot):
|
||||||
from bambu_run.models import PrintJob
|
from bambu_run.models import PrintJob, FilamentUsage
|
||||||
|
|
||||||
gcode_state = snapshot.get('gcode_state')
|
gcode_state = snapshot.get('gcode_state')
|
||||||
subtask_name = snapshot.get('subtask_name')
|
subtask_name = snapshot.get('subtask_name')
|
||||||
|
|
||||||
if self._is_print_starting(session, gcode_state, subtask_name):
|
if self._is_print_starting(gcode_state, subtask_name):
|
||||||
if session.current_print_job:
|
if self.current_print_job:
|
||||||
self._finalize_print_job(session, metric, snapshot)
|
self._finalize_print_job(metric, snapshot)
|
||||||
|
|
||||||
raw_task_id = snapshot.get('task_id')
|
self.current_print_job = PrintJob.objects.create(
|
||||||
session.current_print_job = PrintJob.objects.create(
|
device=self.printer_device,
|
||||||
device=session.printer,
|
|
||||||
project_name=subtask_name,
|
project_name=subtask_name,
|
||||||
gcode_file=snapshot.get('gcode_file'),
|
gcode_file=snapshot.get('gcode_file'),
|
||||||
start_time=metric.timestamp,
|
start_time=metric.timestamp,
|
||||||
start_metric=metric,
|
start_metric=metric,
|
||||||
total_layers=snapshot.get('total_layer_num'),
|
total_layers=snapshot.get('total_layer_num'),
|
||||||
completion_percent=snapshot.get('print_percent', 0),
|
completion_percent=snapshot.get('print_percent', 0)
|
||||||
cloud_task_id_raw=int(raw_task_id) if raw_task_id else None,
|
|
||||||
)
|
)
|
||||||
session.trays_used = set()
|
self.trays_used = set()
|
||||||
logger.info(f"[{session.device_id}] Print job started: {subtask_name}")
|
logger.info(f"Print job started: {subtask_name}")
|
||||||
|
|
||||||
if session.current_print_job:
|
if self.current_print_job:
|
||||||
tray_now = snapshot.get('tray_now', '')
|
tray_now = snapshot.get('tray_now', '')
|
||||||
if tray_now not in (None, '', '255'):
|
if tray_now not in (None, '', '255'):
|
||||||
try:
|
try:
|
||||||
tray_id = int(tray_now)
|
tray_id = int(tray_now)
|
||||||
if 0 <= tray_id <= 15:
|
if 0 <= tray_id <= 15:
|
||||||
session.trays_used.add(tray_id)
|
self.trays_used.add(tray_id)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if self._is_print_ending(session, gcode_state) and session.current_print_job:
|
if self._is_print_ending(gcode_state) and self.current_print_job:
|
||||||
self._finalize_print_job(session, metric, snapshot)
|
self._finalize_print_job(metric, snapshot)
|
||||||
|
|
||||||
session.last_gcode_state = gcode_state
|
self.last_gcode_state = gcode_state
|
||||||
session.last_subtask_name = subtask_name
|
self.last_subtask_name = subtask_name
|
||||||
|
|
||||||
def _is_print_starting(self, session, gcode_state, subtask_name):
|
def _is_print_starting(self, gcode_state, subtask_name):
|
||||||
is_printing = gcode_state not in ['FINISH', 'IDLE', 'FAILED', None, '']
|
is_printing = gcode_state not in ['FINISH', 'IDLE', 'FAILED', None, '']
|
||||||
has_new_job = subtask_name and subtask_name != session.last_subtask_name
|
has_new_job = subtask_name and subtask_name != self.last_subtask_name
|
||||||
return is_printing and has_new_job
|
return is_printing and has_new_job
|
||||||
|
|
||||||
def _is_print_ending(self, session, gcode_state):
|
def _is_print_ending(self, gcode_state):
|
||||||
ending_states = ['FINISH', 'FAILED']
|
ending_states = ['FINISH', 'FAILED']
|
||||||
return gcode_state in ending_states and session.last_gcode_state not in ending_states
|
return gcode_state in ending_states and self.last_gcode_state not in ending_states
|
||||||
|
|
||||||
def _finalize_print_job(self, session, metric, snapshot):
|
def _finalize_print_job(self, metric, snapshot):
|
||||||
from bambu_run.models import FilamentUsage
|
from bambu_run.models import FilamentUsage
|
||||||
|
|
||||||
job = session.current_print_job
|
self.current_print_job.end_time = metric.timestamp
|
||||||
job.end_time = metric.timestamp
|
self.current_print_job.end_metric = metric
|
||||||
job.end_metric = metric
|
self.current_print_job.final_status = snapshot.get('gcode_state')
|
||||||
job.final_status = snapshot.get('gcode_state')
|
self.current_print_job.completion_percent = snapshot.get('print_percent', 0)
|
||||||
job.completion_percent = snapshot.get('print_percent', 0)
|
self.current_print_job.calculate_duration()
|
||||||
job.calculate_duration()
|
self.current_print_job.save()
|
||||||
job.save()
|
|
||||||
|
|
||||||
try:
|
start_metric = self.current_print_job.start_metric
|
||||||
from bambu_run.bambu_cloud import fetch_and_upsert_task
|
|
||||||
fetch_and_upsert_task(session.client._client, job)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Cloud task sync skipped (non-fatal): {e}")
|
|
||||||
|
|
||||||
start_metric = job.start_metric
|
|
||||||
if not start_metric:
|
if not start_metric:
|
||||||
logger.warning(f"No start_metric for job {job.id}, skipping filament usage")
|
logger.warning(f"No start_metric for job {self.current_print_job.id}, skipping filament usage")
|
||||||
elif not session.trays_used:
|
elif not self.trays_used:
|
||||||
logger.warning(f"No trays tracked for job {job.project_name}, skipping filament usage")
|
logger.warning(f"No trays tracked for job {self.current_print_job.project_name}, skipping filament usage")
|
||||||
else:
|
else:
|
||||||
for tray_id in session.trays_used:
|
for tray_id in self.trays_used:
|
||||||
start_snap = start_metric.filament_snapshots.filter(
|
start_snap = start_metric.filament_snapshots.filter(
|
||||||
tray_id=tray_id, filament__isnull=False
|
tray_id=tray_id, filament__isnull=False
|
||||||
).first()
|
).first()
|
||||||
@@ -653,12 +536,12 @@ class Command(BaseCommand):
|
|||||||
).first()
|
).first()
|
||||||
|
|
||||||
usage = FilamentUsage.objects.create(
|
usage = FilamentUsage.objects.create(
|
||||||
print_job=job,
|
print_job=self.current_print_job,
|
||||||
filament=start_snap.filament,
|
filament=start_snap.filament,
|
||||||
tray_id=tray_id,
|
tray_id=tray_id,
|
||||||
starting_percent=start_snap.remain_percent or 100,
|
starting_percent=start_snap.remain_percent or 100,
|
||||||
ending_percent=end_snap.remain_percent if end_snap else None,
|
ending_percent=end_snap.remain_percent if end_snap else None,
|
||||||
is_primary=(len(session.trays_used) == 1),
|
is_primary=(len(self.trays_used) == 1),
|
||||||
)
|
)
|
||||||
usage.calculate_consumed()
|
usage.calculate_consumed()
|
||||||
usage.save()
|
usage.save()
|
||||||
@@ -670,30 +553,30 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[{session.device_id}] Print job finished: {job.project_name} "
|
f"Print job finished: {self.current_print_job.project_name} "
|
||||||
f"({job.final_status}) - Duration: {job.duration_minutes} min, "
|
f"({self.current_print_job.final_status}) - Duration: {self.current_print_job.duration_minutes} min, "
|
||||||
f"Trays used: {sorted(session.trays_used) if session.trays_used else 'none tracked'}"
|
f"Trays used: {sorted(self.trays_used) if self.trays_used else 'none tracked'}"
|
||||||
)
|
)
|
||||||
|
|
||||||
session.current_print_job = None
|
self.current_print_job = None
|
||||||
session.trays_used = set()
|
self.trays_used = set()
|
||||||
|
|
||||||
def _collect_printer_data(self, session: "DeviceSession"):
|
def _collect_printer_data(self):
|
||||||
try:
|
try:
|
||||||
snapshot = session.client.get_snapshot()
|
snapshot = self.printer_client.get_snapshot()
|
||||||
|
|
||||||
if snapshot is None:
|
if snapshot is None:
|
||||||
session.mqtt_connect_errors += 1
|
self.mqtt_connect_errors += 1
|
||||||
if session.mqtt_connect_errors <= 5 or self.verbose:
|
if self.mqtt_connect_errors <= 5 or self.verbose:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[{session.device_id}] MQTT not connected yet or no data available "
|
f"MQTT not connected yet or no data available "
|
||||||
f"(attempt {session.mqtt_connect_errors})"
|
f"(attempt {self.mqtt_connect_errors})"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
metric = PrinterMetrics.objects.create(
|
metric = PrinterMetrics.objects.create(
|
||||||
device=session.printer,
|
device=self.printer_device,
|
||||||
timestamp=timezone.now(),
|
timestamp=timezone.now(),
|
||||||
nozzle_temp=self._to_decimal(snapshot.get("nozzle_temp")),
|
nozzle_temp=self._to_decimal(snapshot.get("nozzle_temp")),
|
||||||
nozzle_target_temp=self._to_decimal(snapshot.get("nozzle_target_temp")),
|
nozzle_target_temp=self._to_decimal(snapshot.get("nozzle_target_temp")),
|
||||||
@@ -702,10 +585,6 @@ class Command(BaseCommand):
|
|||||||
chamber_temp=self._to_decimal(snapshot.get("chamber_temp")),
|
chamber_temp=self._to_decimal(snapshot.get("chamber_temp")),
|
||||||
nozzle_diameter=self._to_decimal(snapshot.get("nozzle_diameter")),
|
nozzle_diameter=self._to_decimal(snapshot.get("nozzle_diameter")),
|
||||||
nozzle_type=snapshot.get("nozzle_type"),
|
nozzle_type=snapshot.get("nozzle_type"),
|
||||||
nozzle_temp_left=self._to_decimal(snapshot.get("nozzle_temp_left")),
|
|
||||||
nozzle_target_temp_left=self._to_decimal(snapshot.get("nozzle_target_temp_left")),
|
|
||||||
nozzle_diameter_left=self._to_decimal(snapshot.get("nozzle_diameter_left")),
|
|
||||||
nozzle_type_left=snapshot.get("nozzle_type_left"),
|
|
||||||
gcode_state=snapshot.get("gcode_state"),
|
gcode_state=snapshot.get("gcode_state"),
|
||||||
print_type=snapshot.get("print_type"),
|
print_type=snapshot.get("print_type"),
|
||||||
print_percent=snapshot.get("print_percent"),
|
print_percent=snapshot.get("print_percent"),
|
||||||
@@ -745,28 +624,27 @@ class Command(BaseCommand):
|
|||||||
ams_units=snapshot.get("ams_units", []),
|
ams_units=snapshot.get("ams_units", []),
|
||||||
external_spool=snapshot.get("external_spool", {}),
|
external_spool=snapshot.get("external_spool", {}),
|
||||||
lights_report=snapshot.get("lights_report", []),
|
lights_report=snapshot.get("lights_report", []),
|
||||||
vortek_raw=snapshot.get("vortek_raw", {}),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
filaments_data = snapshot.get('filaments', [])
|
filaments_data = snapshot.get('filaments', [])
|
||||||
if filaments_data:
|
if filaments_data:
|
||||||
self._create_filament_snapshots(metric, filaments_data, snapshot)
|
self._create_filament_snapshots(metric, filaments_data, snapshot)
|
||||||
|
|
||||||
self._track_print_job(session, metric, snapshot)
|
self._track_print_job(metric, snapshot)
|
||||||
|
|
||||||
session.success_count += 1
|
self.success_count += 1
|
||||||
|
|
||||||
if self.verbose:
|
if self.verbose:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"[{session.device_id}] Printer Metrics: Nozzle={snapshot.get('nozzle_temp')}C, "
|
f"Printer Metrics: Nozzle={snapshot.get('nozzle_temp')}C, "
|
||||||
f"Bed={snapshot.get('bed_temp')}C, "
|
f"Bed={snapshot.get('bed_temp')}C, "
|
||||||
f"Progress={snapshot.get('print_percent')}%, "
|
f"Progress={snapshot.get('print_percent')}%, "
|
||||||
f"State={snapshot.get('gcode_state')}"
|
f"State={snapshot.get('gcode_state')}"
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
session.error_count += 1
|
self.error_count += 1
|
||||||
logger.error(f"[{session.device_id}] Error collecting printer data (total errors: {session.error_count}): {e}")
|
logger.error(f"Error collecting printer data (total errors: {self.error_count}): {e}")
|
||||||
if self.verbose:
|
if self.verbose:
|
||||||
logger.exception("Detailed traceback:")
|
logger.exception("Detailed traceback:")
|
||||||
|
|
||||||
@@ -781,20 +659,16 @@ class Command(BaseCommand):
|
|||||||
def _print_statistics(self):
|
def _print_statistics(self):
|
||||||
if self.start_time:
|
if self.start_time:
|
||||||
runtime = timezone.now() - self.start_time
|
runtime = timezone.now() - self.start_time
|
||||||
success_count = sum(s.success_count for s in self.sessions.values())
|
total_collections = self.success_count + self.error_count
|
||||||
error_count = sum(s.error_count for s in self.sessions.values())
|
|
||||||
mqtt_connect_errors = sum(s.mqtt_connect_errors for s in self.sessions.values())
|
|
||||||
total_collections = success_count + error_count
|
|
||||||
success_rate = (
|
success_rate = (
|
||||||
(success_count / total_collections * 100)
|
(self.success_count / total_collections * 100)
|
||||||
if total_collections > 0
|
if total_collections > 0
|
||||||
else 0
|
else 0
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("=== Statistics ===")
|
logger.info("=== Statistics ===")
|
||||||
logger.info(f"Runtime: {runtime}")
|
logger.info(f"Runtime: {runtime}")
|
||||||
logger.info(f"Printers tracked: {len(self.sessions)}")
|
logger.info(f"Successful collections: {self.success_count}")
|
||||||
logger.info(f"Successful collections: {success_count}")
|
logger.info(f"Failed collections: {self.error_count}")
|
||||||
logger.info(f"Failed collections: {error_count}")
|
logger.info(f"MQTT connection warnings: {self.mqtt_connect_errors}")
|
||||||
logger.info(f"MQTT connection warnings: {mqtt_connect_errors}")
|
|
||||||
logger.info(f"Success rate: {success_rate:.1f}%")
|
logger.info(f"Success rate: {success_rate:.1f}%")
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
"""
|
|
||||||
Diagnose multi-printer cloud data for a Bambu Lab account.
|
|
||||||
|
|
||||||
Run this if `bambu_collector` doesn't pick up all your printers, or the data
|
|
||||||
collected for a second/third printer looks wrong. It authenticates with your
|
|
||||||
Bambu Lab account, lists every device the cloud API reports, listens briefly
|
|
||||||
for raw MQTT data from each one, and writes a redacted JSON report you can
|
|
||||||
attach to a GitHub issue.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python manage.py bambu_diagnose
|
|
||||||
python manage.py bambu_diagnose --listen-seconds 15
|
|
||||||
python manage.py bambu_diagnose --output my_report.json
|
|
||||||
python manage.py bambu_diagnose --no-redact # local debugging only — do NOT post this output publicly
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
|
||||||
|
|
||||||
from bambu_run.diagnostics import build_diagnostics_report, redact_diagnostics
|
|
||||||
|
|
||||||
logger = logging.getLogger("bambu_run.diagnose")
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = "Authenticate, list every printer on the account, and write a redacted diagnostics report."
|
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
|
||||||
parser.add_argument(
|
|
||||||
"--listen-seconds", type=float, default=8.0,
|
|
||||||
help="How long to listen for MQTT data per device (default: 8)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--output", type=str, default=None,
|
|
||||||
help="Output file path (default: bambu_diagnostics_<timestamp>.json)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--no-redact", action="store_true",
|
|
||||||
help="Keep full serials/identifiers unmasked. For your own debugging only — "
|
|
||||||
"do not paste this output into a public GitHub issue.",
|
|
||||||
)
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
import os
|
|
||||||
from bambu_run.mqtt_client import BambuPrinter, BambuClient
|
|
||||||
|
|
||||||
listen_seconds = options["listen_seconds"]
|
|
||||||
redact = not options["no_redact"]
|
|
||||||
|
|
||||||
bambu_username = os.environ.get("BAMBU_USERNAME")
|
|
||||||
bambu_password = os.environ.get("BAMBU_PASSWORD")
|
|
||||||
bambu_token = os.environ.get("BAMBU_TOKEN")
|
|
||||||
|
|
||||||
if not bambu_token and not all([bambu_username, bambu_password]):
|
|
||||||
raise CommandError(
|
|
||||||
"Either BAMBU_TOKEN or both BAMBU_USERNAME and BAMBU_PASSWORD "
|
|
||||||
"environment variables must be set"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.stdout.write("Authenticating with Bambu Lab cloud...")
|
|
||||||
auth = BambuPrinter(username=bambu_username, password=bambu_password, token=bambu_token)
|
|
||||||
token = auth._ensure_token()
|
|
||||||
|
|
||||||
cloud = BambuClient(token=token)
|
|
||||||
devices = cloud.get_devices()
|
|
||||||
|
|
||||||
self.stdout.write(self.style.SUCCESS(f"Found {len(devices)} device(s) on this account:"))
|
|
||||||
for device in devices:
|
|
||||||
self.stdout.write(
|
|
||||||
f" - {device.get('name', 'unknown')} "
|
|
||||||
f"({device.get('dev_product_name', 'unknown model')}) "
|
|
||||||
f"online={device.get('online')}"
|
|
||||||
)
|
|
||||||
if len(devices) < 2:
|
|
||||||
self.stdout.write(self.style.WARNING(
|
|
||||||
"Only one device returned by the cloud API — if you own multiple printers, "
|
|
||||||
"this is likely the root cause. Note this in the GitHub issue."
|
|
||||||
))
|
|
||||||
|
|
||||||
raw_payloads = {}
|
|
||||||
for device in devices:
|
|
||||||
dev_id = device.get("dev_id")
|
|
||||||
if not dev_id:
|
|
||||||
continue
|
|
||||||
self.stdout.write(f"Listening to {device.get('name', dev_id)} for {listen_seconds:.0f}s...")
|
|
||||||
client = BambuPrinter(token=token, device_id=dev_id)
|
|
||||||
try:
|
|
||||||
client.connect(blocking=False)
|
|
||||||
self._request_full_status_when_ready(client)
|
|
||||||
time.sleep(listen_seconds)
|
|
||||||
state = client.get_state()
|
|
||||||
raw_payloads[dev_id] = state._raw_data.get("print") if state._raw_data else None
|
|
||||||
except Exception as e:
|
|
||||||
self.stdout.write(self.style.WARNING(f" Could not collect data for {dev_id}: {e}"))
|
|
||||||
raw_payloads[dev_id] = None
|
|
||||||
finally:
|
|
||||||
client.disconnect()
|
|
||||||
|
|
||||||
report = build_diagnostics_report(devices, raw_payloads)
|
|
||||||
report = redact_diagnostics(report, redact=redact)
|
|
||||||
|
|
||||||
output_path = options["output"] or f"bambu_diagnostics_{int(time.time())}.json"
|
|
||||||
with open(output_path, "w") as f:
|
|
||||||
json.dump(report, f, indent=2, default=str)
|
|
||||||
|
|
||||||
self.stdout.write(self.style.SUCCESS(f"\nDiagnostics written to: {output_path}"))
|
|
||||||
if not redact:
|
|
||||||
self.stdout.write(self.style.WARNING(
|
|
||||||
"--no-redact was used: this file contains unmasked serials/identifiers. "
|
|
||||||
"Do not attach it to a public GitHub issue as-is."
|
|
||||||
))
|
|
||||||
else:
|
|
||||||
self.stdout.write(
|
|
||||||
"Serials/identifiers are masked. Please skim the file once before posting — "
|
|
||||||
"then attach it to https://github.com/RunLit/Bambu-Run/issues/10"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _request_full_status_when_ready(self, client, timeout: float = 20.0) -> None:
|
|
||||||
deadline = time.time() + timeout
|
|
||||||
while time.time() < deadline:
|
|
||||||
mqtt_client = getattr(client, "_mqtt", None)
|
|
||||||
if mqtt_client is not None and getattr(mqtt_client, "connected", False):
|
|
||||||
client._mqtt.request_full_status()
|
|
||||||
return
|
|
||||||
time.sleep(0.5)
|
|
||||||
@@ -1,425 +0,0 @@
|
|||||||
"""
|
|
||||||
Management command to import Bambu Lab filament color catalogs into the FilamentColor database.
|
|
||||||
|
|
||||||
Parses .txt color catalog files (one file per filament sub-type) and creates or skips
|
|
||||||
FilamentColor records. FilamentType records are auto-created as needed.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
# Import a single file
|
|
||||||
python manage.py bambu_import_colors docs/Bambu_Color_Catalog/PLA\ Basic.txt
|
|
||||||
|
|
||||||
# Import all .txt files in a directory
|
|
||||||
python manage.py bambu_import_colors docs/Bambu_Color_Catalog/
|
|
||||||
|
|
||||||
# Dry-run (preview without writing)
|
|
||||||
python manage.py bambu_import_colors docs/Bambu_Color_Catalog/ --dry-run
|
|
||||||
|
|
||||||
# Fail instead of auto-creating missing FilamentType entries
|
|
||||||
python manage.py bambu_import_colors docs/Bambu_Color_Catalog/ --no-auto-create-filament-type
|
|
||||||
|
|
||||||
File naming convention:
|
|
||||||
The stem determines filament type and sub-type:
|
|
||||||
PLA Basic.txt → type=PLA, sub_type=PLA Basic
|
|
||||||
PA6-GF.txt → type=PA6, sub_type=PA6-GF
|
|
||||||
ABS.txt → type=ABS, sub_type=ABS
|
|
||||||
|
|
||||||
Supported file formats:
|
|
||||||
Format 1 (multi-line): Format 2 (same-line / tab-separated):
|
|
||||||
Jade White Black Walnut #4F3F24
|
|
||||||
Hex:#FFFFFF Rosewood #4C241C
|
|
||||||
|
|
||||||
Hex values may appear as: Hex:#RRGGBB Hex: #RRGGBB #RRGGBB RRGGBB
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
|
||||||
from django.db import transaction
|
|
||||||
|
|
||||||
from bambu_run.models import FilamentColor, FilamentType
|
|
||||||
|
|
||||||
logger = logging.getLogger("bambu_run.import_colors")
|
|
||||||
|
|
||||||
BRAND = "Bambu Lab"
|
|
||||||
|
|
||||||
# ─── Parsing helpers ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_SAME_LINE_RE = re.compile(
|
|
||||||
r'^(.+?)\s+(?:Hex\s*:\s*)?#?([0-9A-Fa-f]{6})\s*$', re.IGNORECASE
|
|
||||||
)
|
|
||||||
_HEX_ONLY_RE = re.compile(
|
|
||||||
r'^\s*(?:Hex\s*:\s*)?#?([0-9A-Fa-f]{6})\s*$', re.IGNORECASE
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _stem_to_type_and_subtype(stem):
|
|
||||||
"""
|
|
||||||
Derive (filament_type, filament_sub_type) from a file stem.
|
|
||||||
|
|
||||||
The sub-type is the full stem. The type is everything before the first
|
|
||||||
space or hyphen.
|
|
||||||
|
|
||||||
"PLA Basic" → ("PLA", "PLA Basic")
|
|
||||||
"PA6-GF" → ("PA6", "PA6-GF")
|
|
||||||
"ABS" → ("ABS", "ABS")
|
|
||||||
"PETG HF" → ("PETG", "PETG HF")
|
|
||||||
"""
|
|
||||||
sub_type = stem
|
|
||||||
m = re.search(r'[ -]', stem)
|
|
||||||
filament_type = stem[: m.start()] if m else stem
|
|
||||||
return filament_type, sub_type
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_file(path):
|
|
||||||
"""
|
|
||||||
Parse a color catalog file and return a list of (color_name, hex_code) tuples.
|
|
||||||
|
|
||||||
hex_code is always 6-char uppercase without '#'.
|
|
||||||
|
|
||||||
Raises ValueError if the file cannot be read.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
text = path.read_text(encoding="utf-8", errors="replace")
|
|
||||||
except OSError as exc:
|
|
||||||
raise ValueError(f"Cannot read file: {exc}") from exc
|
|
||||||
|
|
||||||
lines = text.splitlines()
|
|
||||||
colors = []
|
|
||||||
i = 0
|
|
||||||
|
|
||||||
while i < len(lines):
|
|
||||||
stripped = lines[i].strip()
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
if not stripped:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# ── Format 2: color name + hex on the same line ─────────────────────
|
|
||||||
m = _SAME_LINE_RE.match(stripped)
|
|
||||||
if m:
|
|
||||||
colors.append((m.group(1).strip(), m.group(2).upper()))
|
|
||||||
continue
|
|
||||||
|
|
||||||
# ── Orphaned hex line with no preceding name — skip ──────────────────
|
|
||||||
if _HEX_ONLY_RE.match(stripped):
|
|
||||||
logger.warning(" [parse] Orphaned hex line (no preceding name): '%s'", stripped)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# ── Format 1: color name on this line, hex on the next ──────────────
|
|
||||||
color_name = stripped
|
|
||||||
found_hex = False
|
|
||||||
|
|
||||||
while i < len(lines):
|
|
||||||
next_stripped = lines[i].strip()
|
|
||||||
i += 1 # tentatively consume
|
|
||||||
|
|
||||||
if not next_stripped:
|
|
||||||
continue # skip blank lines between name and hex
|
|
||||||
|
|
||||||
m_hex = _HEX_ONLY_RE.match(next_stripped)
|
|
||||||
if m_hex:
|
|
||||||
colors.append((color_name, m_hex.group(1).upper()))
|
|
||||||
found_hex = True
|
|
||||||
else:
|
|
||||||
# Not a hex line — put it back for the outer loop
|
|
||||||
i -= 1
|
|
||||||
logger.warning(
|
|
||||||
" [parse] Expected hex after '%s', got '%s' — skipping name",
|
|
||||||
color_name,
|
|
||||||
next_stripped,
|
|
||||||
)
|
|
||||||
break # look-ahead done (one non-empty line checked)
|
|
||||||
|
|
||||||
if not found_hex:
|
|
||||||
logger.warning(
|
|
||||||
" [parse] Color '%s' has no hex line following it — skipping", color_name
|
|
||||||
)
|
|
||||||
|
|
||||||
return colors
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Command ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = (
|
|
||||||
"Import Bambu Lab filament color catalog .txt files into the FilamentColor database. "
|
|
||||||
"Accepts a single .txt file or a directory of .txt files."
|
|
||||||
)
|
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
|
||||||
parser.add_argument(
|
|
||||||
"path",
|
|
||||||
help="Path to a single .txt catalog file or a directory containing .txt files.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--auto-create-filament-type",
|
|
||||||
default=True,
|
|
||||||
action="store_true",
|
|
||||||
dest="auto_create",
|
|
||||||
help="Auto-create FilamentType entries when missing (default: enabled).",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--no-auto-create-filament-type",
|
|
||||||
action="store_false",
|
|
||||||
dest="auto_create",
|
|
||||||
help="Skip colors whose FilamentType entry does not exist instead of creating it.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--dry-run",
|
|
||||||
action="store_true",
|
|
||||||
help="Preview what would be imported without writing to the database.",
|
|
||||||
)
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
input_path = Path(options["path"]).expanduser().resolve()
|
|
||||||
auto_create = options["auto_create"]
|
|
||||||
dry_run = options["dry_run"]
|
|
||||||
|
|
||||||
if dry_run:
|
|
||||||
self.stdout.write(self.style.WARNING("DRY RUN — no changes will be written.\n"))
|
|
||||||
|
|
||||||
# ── Collect files to process ─────────────────────────────────────────
|
|
||||||
if input_path.is_dir():
|
|
||||||
files = sorted(input_path.glob("*.txt"))
|
|
||||||
if not files:
|
|
||||||
raise CommandError(f"No .txt files found in: {input_path}")
|
|
||||||
self.stdout.write(f"Found {len(files)} .txt file(s) in {input_path}\n")
|
|
||||||
elif input_path.is_file():
|
|
||||||
if input_path.suffix.lower() != ".txt":
|
|
||||||
raise CommandError(f"Expected a .txt file, got: {input_path.name}")
|
|
||||||
files = [input_path]
|
|
||||||
else:
|
|
||||||
raise CommandError(f"Path does not exist: {input_path}")
|
|
||||||
|
|
||||||
# ── Counters ─────────────────────────────────────────────────────────
|
|
||||||
total_created = 0
|
|
||||||
total_skipped_dup = 0
|
|
||||||
total_skipped_no_type = 0
|
|
||||||
total_errors = 0
|
|
||||||
|
|
||||||
for file_path in files:
|
|
||||||
created, skipped_dup, skipped_no_type, errors = self._process_file(
|
|
||||||
file_path, auto_create=auto_create, dry_run=dry_run
|
|
||||||
)
|
|
||||||
total_created += created
|
|
||||||
total_skipped_dup += skipped_dup
|
|
||||||
total_skipped_no_type += skipped_no_type
|
|
||||||
total_errors += errors
|
|
||||||
|
|
||||||
# ── Summary ──────────────────────────────────────────────────────────
|
|
||||||
self.stdout.write("\n" + "─" * 50)
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.SUCCESS(f" Created: {total_created}")
|
|
||||||
)
|
|
||||||
self.stdout.write(f" Skipped (duplicate): {total_skipped_dup}")
|
|
||||||
if total_skipped_no_type:
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.WARNING(f" Skipped (no type): {total_skipped_no_type}")
|
|
||||||
)
|
|
||||||
if total_errors:
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.ERROR(f" Errors: {total_errors}")
|
|
||||||
)
|
|
||||||
if dry_run:
|
|
||||||
self.stdout.write(self.style.WARNING("\nDRY RUN complete — nothing was written."))
|
|
||||||
|
|
||||||
# ── Per-file processing ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _process_file(self, file_path, *, auto_create, dry_run):
|
|
||||||
"""Process one catalog file. Returns (created, skipped_dup, skipped_no_type, errors)."""
|
|
||||||
stem = file_path.stem
|
|
||||||
filament_type, filament_sub_type = _stem_to_type_and_subtype(stem)
|
|
||||||
|
|
||||||
self.stdout.write(
|
|
||||||
f"\nProcessing: {file_path.name} "
|
|
||||||
f"→ type={filament_type!r} sub_type={filament_sub_type!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Parse file ───────────────────────────────────────────────────────
|
|
||||||
try:
|
|
||||||
colors = _parse_file(file_path)
|
|
||||||
except ValueError as exc:
|
|
||||||
self.stderr.write(self.style.ERROR(f" ERROR reading file: {exc}"))
|
|
||||||
return 0, 0, 0, 1
|
|
||||||
|
|
||||||
if not colors:
|
|
||||||
self.stdout.write(self.style.WARNING(" No colors parsed — skipping file."))
|
|
||||||
return 0, 0, 0, 0
|
|
||||||
|
|
||||||
self.stdout.write(f" Parsed {len(colors)} color(s).")
|
|
||||||
|
|
||||||
# ── Resolve FilamentType ─────────────────────────────────────────────
|
|
||||||
filament_type_obj = self._resolve_filament_type(
|
|
||||||
filament_type, filament_sub_type, auto_create=auto_create, dry_run=dry_run
|
|
||||||
)
|
|
||||||
if filament_type_obj is None and not auto_create:
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.WARNING(
|
|
||||||
f" No FilamentType for type={filament_type!r} "
|
|
||||||
f"sub_type={filament_sub_type!r} brand={BRAND!r} — "
|
|
||||||
f"skipping all {len(colors)} color(s) in this file."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return 0, 0, len(colors), 0
|
|
||||||
|
|
||||||
# ── Import colors ────────────────────────────────────────────────────
|
|
||||||
created = skipped_dup = skipped_no_type = errors = 0
|
|
||||||
|
|
||||||
for color_name, hex_code in colors:
|
|
||||||
result = self._import_color(
|
|
||||||
color_name=color_name,
|
|
||||||
hex_code=hex_code,
|
|
||||||
filament_type=filament_type,
|
|
||||||
filament_sub_type=filament_sub_type,
|
|
||||||
filament_type_obj=filament_type_obj,
|
|
||||||
dry_run=dry_run,
|
|
||||||
)
|
|
||||||
if result == "created":
|
|
||||||
created += 1
|
|
||||||
elif result == "duplicate":
|
|
||||||
skipped_dup += 1
|
|
||||||
elif result == "no_type":
|
|
||||||
skipped_no_type += 1
|
|
||||||
elif result == "error":
|
|
||||||
errors += 1
|
|
||||||
|
|
||||||
self.stdout.write(
|
|
||||||
f" → created={created} duplicate={skipped_dup} "
|
|
||||||
f"no_type={skipped_no_type} errors={errors}"
|
|
||||||
)
|
|
||||||
return created, skipped_dup, skipped_no_type, errors
|
|
||||||
|
|
||||||
def _resolve_filament_type(self, filament_type, filament_sub_type, *, auto_create, dry_run):
|
|
||||||
"""
|
|
||||||
Return the matching FilamentType instance.
|
|
||||||
|
|
||||||
If none exists:
|
|
||||||
- auto_create=True → create it (or simulate in dry-run) and return it
|
|
||||||
- auto_create=False → return None
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
obj = FilamentType.objects.get(
|
|
||||||
type=filament_type,
|
|
||||||
sub_type=filament_sub_type,
|
|
||||||
brand=BRAND,
|
|
||||||
)
|
|
||||||
return obj
|
|
||||||
except FilamentType.DoesNotExist:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if not auto_create:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if dry_run:
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.NOTICE(
|
|
||||||
f" [dry-run] Would create FilamentType: "
|
|
||||||
f"type={filament_type!r} sub_type={filament_sub_type!r} brand={BRAND!r}"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return None # can't return a real object in dry-run
|
|
||||||
|
|
||||||
try:
|
|
||||||
with transaction.atomic():
|
|
||||||
obj, created = FilamentType.objects.get_or_create(
|
|
||||||
type=filament_type,
|
|
||||||
sub_type=filament_sub_type,
|
|
||||||
brand=BRAND,
|
|
||||||
)
|
|
||||||
if created:
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.SUCCESS(
|
|
||||||
f" Created FilamentType: "
|
|
||||||
f"type={filament_type!r} sub_type={filament_sub_type!r} brand={BRAND!r}"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return obj
|
|
||||||
except Exception as exc:
|
|
||||||
self.stderr.write(
|
|
||||||
self.style.ERROR(
|
|
||||||
f" ERROR creating FilamentType "
|
|
||||||
f"(type={filament_type!r} sub_type={filament_sub_type!r}): {exc}"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _import_color(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
color_name,
|
|
||||||
hex_code,
|
|
||||||
filament_type,
|
|
||||||
filament_sub_type,
|
|
||||||
filament_type_obj,
|
|
||||||
dry_run,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Import a single (color_name, hex_code) entry.
|
|
||||||
|
|
||||||
Returns one of: "created", "duplicate", "no_type", "error"
|
|
||||||
"""
|
|
||||||
if filament_type_obj is None:
|
|
||||||
# dry-run path: FilamentType would have been created but isn't real yet
|
|
||||||
if dry_run:
|
|
||||||
self.stdout.write(
|
|
||||||
f" [dry-run] Would create: {color_name!r} #{hex_code} "
|
|
||||||
f"({filament_type} / {filament_sub_type})"
|
|
||||||
)
|
|
||||||
return "created"
|
|
||||||
return "no_type"
|
|
||||||
|
|
||||||
# ── Transparent detection ────────────────────────────────────────────
|
|
||||||
# "Translucent" (no colour qualifier) + #000000 = clear/transparent filament.
|
|
||||||
# Bambu Lab AMS reports these as 00000000 (alpha=00).
|
|
||||||
is_transparent = color_name.strip().lower() == "translucent" and hex_code == "000000"
|
|
||||||
|
|
||||||
# ── Duplicate check ──────────────────────────────────────────────────
|
|
||||||
# All five fields must match to be considered a duplicate:
|
|
||||||
# color_code (exact), color_name (case-insensitive), brand,
|
|
||||||
# denormalised filament_type + filament_sub_type
|
|
||||||
duplicate = FilamentColor.objects.filter(
|
|
||||||
color_code=hex_code,
|
|
||||||
color_name__iexact=color_name,
|
|
||||||
brand=BRAND,
|
|
||||||
filament_type=filament_type,
|
|
||||||
filament_sub_type=filament_sub_type,
|
|
||||||
).exists()
|
|
||||||
|
|
||||||
if duplicate:
|
|
||||||
logger.debug(" Duplicate — skipping: %s #%s", color_name, hex_code)
|
|
||||||
return "duplicate"
|
|
||||||
|
|
||||||
if dry_run:
|
|
||||||
transparent_note = " [transparent]" if is_transparent else ""
|
|
||||||
self.stdout.write(
|
|
||||||
f" [dry-run] Would create: {color_name!r} #{hex_code} "
|
|
||||||
f"({filament_type} / {filament_sub_type}){transparent_note}"
|
|
||||||
)
|
|
||||||
return "created"
|
|
||||||
|
|
||||||
# ── Write to database ────────────────────────────────────────────────
|
|
||||||
try:
|
|
||||||
with transaction.atomic():
|
|
||||||
FilamentColor.objects.create(
|
|
||||||
color_code=hex_code,
|
|
||||||
color_name=color_name,
|
|
||||||
filament_type_fk=filament_type_obj,
|
|
||||||
filament_type=filament_type,
|
|
||||||
filament_sub_type=filament_sub_type,
|
|
||||||
brand=BRAND,
|
|
||||||
is_transparent=is_transparent,
|
|
||||||
)
|
|
||||||
self.stdout.write(
|
|
||||||
f" + {color_name!r} #{hex_code} ({filament_type} / {filament_sub_type})"
|
|
||||||
)
|
|
||||||
return "created"
|
|
||||||
except Exception as exc:
|
|
||||||
self.stderr.write(
|
|
||||||
self.style.ERROR(
|
|
||||||
f" ERROR saving {color_name!r} #{hex_code}: {exc}"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return "error"
|
|
||||||
@@ -1,355 +0,0 @@
|
|||||||
"""
|
|
||||||
Management command to run the Bambu-Run MCP server.
|
|
||||||
|
|
||||||
Supports SSE (network) and stdio (local) transports.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python manage.py bambu_mcp_server
|
|
||||||
python manage.py bambu_mcp_server --transport sse --host 0.0.0.0 --port 8808
|
|
||||||
python manage.py bambu_mcp_server --transport stdio
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
|
||||||
|
|
||||||
logger = logging.getLogger("bambu_run.mcp")
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = "Run the Bambu-Run MCP server for AI agent access"
|
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
|
||||||
from bambu_run.conf import app_settings
|
|
||||||
|
|
||||||
parser.add_argument(
|
|
||||||
"--transport",
|
|
||||||
choices=["sse", "stdio"],
|
|
||||||
default="sse",
|
|
||||||
help="Transport mode (default: sse)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--host",
|
|
||||||
default=app_settings.MCP_HOST,
|
|
||||||
help=f"Host to bind to (default: {app_settings.MCP_HOST})",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--port",
|
|
||||||
type=int,
|
|
||||||
default=app_settings.MCP_PORT,
|
|
||||||
help=f"Port to listen on (default: {app_settings.MCP_PORT})",
|
|
||||||
)
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
try:
|
|
||||||
from mcp.server.fastmcp import FastMCP
|
|
||||||
except ImportError:
|
|
||||||
raise CommandError(
|
|
||||||
"The 'mcp' package is required. Install it with: pip install 'bambu-run[mcp]'"
|
|
||||||
)
|
|
||||||
|
|
||||||
from asgiref.sync import sync_to_async
|
|
||||||
from bambu_run.conf import app_settings
|
|
||||||
from bambu_run import mcp_tools
|
|
||||||
|
|
||||||
transport = options["transport"]
|
|
||||||
host = options["host"]
|
|
||||||
port = options["port"]
|
|
||||||
|
|
||||||
mcp = FastMCP(
|
|
||||||
"Bambu-Run",
|
|
||||||
instructions=(
|
|
||||||
"Bambu-Run MCP server provides read-only access to 3D printer data "
|
|
||||||
"including live printer status, filament inventory, print history, "
|
|
||||||
"temperature trends, and diagnostics. All data comes from Bambu Lab "
|
|
||||||
"printers monitored via MQTT."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Register Tools ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def get_printer_status(printer_id: int | None = None) -> str:
|
|
||||||
"""Get current live status of printer(s) including temperatures, progress, AMS slots, and errors.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
printer_id: Optional printer ID to filter. Omit for all printers.
|
|
||||||
"""
|
|
||||||
return await sync_to_async(mcp_tools.get_printer_status)(printer_id=printer_id)
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def list_printers() -> str:
|
|
||||||
"""List all registered printers with their model, serial, IP, and active status."""
|
|
||||||
return await sync_to_async(mcp_tools.list_printers)()
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def get_print_history(
|
|
||||||
status: str | None = None,
|
|
||||||
days: int | None = None,
|
|
||||||
project_name: str | None = None,
|
|
||||||
limit: int = 20,
|
|
||||||
) -> str:
|
|
||||||
"""Get print job history with optional filters.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
status: Filter by status (FINISH, FAILED, CANCELLED).
|
|
||||||
days: Only show jobs from the last N days.
|
|
||||||
project_name: Filter by project name (partial match).
|
|
||||||
limit: Maximum number of results (default 20).
|
|
||||||
"""
|
|
||||||
return await sync_to_async(mcp_tools.get_print_history)(
|
|
||||||
status=status, days=days, project_name=project_name, limit=limit
|
|
||||||
)
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def get_print_job_detail(job_id: int) -> str:
|
|
||||||
"""Get detailed information about a single print job including filament usage.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
job_id: The print job ID.
|
|
||||||
"""
|
|
||||||
return await sync_to_async(mcp_tools.get_print_job_detail)(job_id=job_id)
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def list_filaments(
|
|
||||||
type: str | None = None,
|
|
||||||
brand: str | None = None,
|
|
||||||
color: str | None = None,
|
|
||||||
loaded_in_ams: bool | None = None,
|
|
||||||
low_filament: bool | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""List filament inventory with optional filters.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
type: Filter by material type (PLA, PETG, ABS, etc.).
|
|
||||||
brand: Filter by brand name (partial match).
|
|
||||||
color: Filter by color name (partial match).
|
|
||||||
loaded_in_ams: Filter by whether spool is currently in AMS.
|
|
||||||
low_filament: If true, only show spools with <=20% remaining.
|
|
||||||
"""
|
|
||||||
return await sync_to_async(mcp_tools.list_filaments)(
|
|
||||||
type=type, brand=brand, color=color,
|
|
||||||
loaded_in_ams=loaded_in_ams, low_filament=low_filament,
|
|
||||||
)
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def get_filament_detail(filament_id: int) -> str:
|
|
||||||
"""Get detailed information about a single filament spool including usage history.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
filament_id: The filament spool ID.
|
|
||||||
"""
|
|
||||||
return await sync_to_async(mcp_tools.get_filament_detail)(filament_id=filament_id)
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def get_temperature_history(
|
|
||||||
printer_id: int | None = None,
|
|
||||||
hours: int = 6,
|
|
||||||
metric: str = "all",
|
|
||||||
) -> str:
|
|
||||||
"""Get temperature trends (avg/min/max) over recent hours.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
printer_id: Optional printer ID to filter.
|
|
||||||
hours: Number of hours to look back (default 6).
|
|
||||||
metric: Which sensor to show: 'all', 'nozzle', 'bed', or 'chamber'.
|
|
||||||
"""
|
|
||||||
return await sync_to_async(mcp_tools.get_temperature_history)(
|
|
||||||
printer_id=printer_id, hours=hours, metric=metric
|
|
||||||
)
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def get_filament_usage_stats(days: int = 30, group_by: str = "type") -> str:
|
|
||||||
"""Get aggregate filament consumption statistics.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
days: Number of days to look back (default 30).
|
|
||||||
group_by: Group results by 'type', 'color', or 'spool'.
|
|
||||||
"""
|
|
||||||
return await sync_to_async(mcp_tools.get_filament_usage_stats)(days=days, group_by=group_by)
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def get_printer_health(printer_id: int | None = None) -> str:
|
|
||||||
"""Get printer diagnostics including errors, humidity, WiFi signal, and recent failures.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
printer_id: Optional printer ID to filter. Omit for all printers.
|
|
||||||
"""
|
|
||||||
return await sync_to_async(mcp_tools.get_printer_health)(printer_id=printer_id)
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def search_print_jobs(query: str) -> str:
|
|
||||||
"""Search print jobs by project name or gcode filename.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Search text (partial match on project name or gcode file).
|
|
||||||
"""
|
|
||||||
return await sync_to_async(mcp_tools.search_print_jobs)(query=query)
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def get_printing_summary(days: int = 7) -> str:
|
|
||||||
"""Get high-level printing activity summary including job counts, success rate, and top projects.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
days: Number of days to summarize (default 7).
|
|
||||||
"""
|
|
||||||
return await sync_to_async(mcp_tools.get_printing_summary)(days=days)
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def find_compatible_filament(
|
|
||||||
type: str,
|
|
||||||
min_remaining_percent: int = 10,
|
|
||||||
color: str | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Find filament spools matching material type and optional criteria.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
type: Material type to search for (PLA, PETG, ABS, etc.).
|
|
||||||
min_remaining_percent: Minimum remaining percentage (default 10).
|
|
||||||
color: Optional color filter (partial match).
|
|
||||||
"""
|
|
||||||
return await sync_to_async(mcp_tools.find_compatible_filament)(
|
|
||||||
type=type, min_remaining_percent=min_remaining_percent, color=color
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Register Resources ───────────────────────────────────────────
|
|
||||||
|
|
||||||
@mcp.resource("bambu://printers")
|
|
||||||
async def res_printers() -> str:
|
|
||||||
"""List all registered printers."""
|
|
||||||
return await sync_to_async(mcp_tools.resource_printers)()
|
|
||||||
|
|
||||||
@mcp.resource("bambu://printers/{printer_id}/status")
|
|
||||||
async def res_printer_status(printer_id: int) -> str:
|
|
||||||
"""Get latest status for a specific printer."""
|
|
||||||
return await sync_to_async(mcp_tools.resource_printer_status)(printer_id)
|
|
||||||
|
|
||||||
@mcp.resource("bambu://filaments")
|
|
||||||
async def res_filaments() -> str:
|
|
||||||
"""Full filament inventory."""
|
|
||||||
return await sync_to_async(mcp_tools.resource_filaments)()
|
|
||||||
|
|
||||||
@mcp.resource("bambu://filaments/{filament_id}")
|
|
||||||
async def res_filament_detail(filament_id: int) -> str:
|
|
||||||
"""Single filament spool with usage history."""
|
|
||||||
return await sync_to_async(mcp_tools.resource_filament_detail)(filament_id)
|
|
||||||
|
|
||||||
@mcp.resource("bambu://print-jobs/recent")
|
|
||||||
async def res_recent_jobs() -> str:
|
|
||||||
"""Last 20 print jobs."""
|
|
||||||
return await sync_to_async(mcp_tools.resource_recent_print_jobs)()
|
|
||||||
|
|
||||||
@mcp.resource("bambu://filament-types")
|
|
||||||
async def res_filament_types() -> str:
|
|
||||||
"""Filament type registry."""
|
|
||||||
return await sync_to_async(mcp_tools.resource_filament_types)()
|
|
||||||
|
|
||||||
@mcp.resource("bambu://filament-colors")
|
|
||||||
async def res_filament_colors() -> str:
|
|
||||||
"""Filament color database."""
|
|
||||||
return await sync_to_async(mcp_tools.resource_filament_colors)()
|
|
||||||
|
|
||||||
# ── Register Prompts ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
@mcp.prompt()
|
|
||||||
async def printer_check_in(printer_id: int | None = None) -> str:
|
|
||||||
"""Full printer status briefing with health check and recent prints.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
printer_id: Optional printer ID. Omit for all printers.
|
|
||||||
"""
|
|
||||||
return await sync_to_async(mcp_tools.prompt_printer_check_in)(printer_id=printer_id)
|
|
||||||
|
|
||||||
@mcp.prompt()
|
|
||||||
async def filament_inventory_report() -> str:
|
|
||||||
"""Comprehensive filament inventory report with low-stock warnings."""
|
|
||||||
return await sync_to_async(mcp_tools.prompt_filament_inventory_report)()
|
|
||||||
|
|
||||||
@mcp.prompt()
|
|
||||||
async def print_job_review(job_id: int) -> str:
|
|
||||||
"""Detailed review of a completed print job.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
job_id: The print job ID to review.
|
|
||||||
"""
|
|
||||||
return await sync_to_async(mcp_tools.prompt_print_job_review)(job_id)
|
|
||||||
|
|
||||||
@mcp.prompt()
|
|
||||||
async def weekly_printing_digest() -> str:
|
|
||||||
"""Weekly printing activity summary with filament usage breakdown."""
|
|
||||||
return await sync_to_async(mcp_tools.prompt_weekly_digest)()
|
|
||||||
|
|
||||||
@mcp.prompt()
|
|
||||||
async def troubleshoot_printer(printer_id: int | None = None) -> str:
|
|
||||||
"""Diagnose printer issues using recent health data, status, and temperatures.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
printer_id: Optional printer ID. Omit for all printers.
|
|
||||||
"""
|
|
||||||
return await sync_to_async(mcp_tools.prompt_troubleshoot_printer)(printer_id=printer_id)
|
|
||||||
|
|
||||||
# ── Auth middleware for SSE ───────────────────────────────────────
|
|
||||||
|
|
||||||
api_key = app_settings.MCP_API_KEY
|
|
||||||
auth_backend = app_settings.MCP_AUTH_BACKEND
|
|
||||||
|
|
||||||
if api_key or auth_backend:
|
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
|
||||||
from starlette.responses import JSONResponse
|
|
||||||
|
|
||||||
class AuthMiddleware(BaseHTTPMiddleware):
|
|
||||||
async def dispatch(self, request, call_next):
|
|
||||||
# Custom auth backend takes priority
|
|
||||||
if auth_backend:
|
|
||||||
if not auth_backend(request):
|
|
||||||
return JSONResponse(
|
|
||||||
{"error": "Unauthorized"}, status_code=401
|
|
||||||
)
|
|
||||||
return await call_next(request)
|
|
||||||
|
|
||||||
# API key auth
|
|
||||||
if api_key:
|
|
||||||
auth_header = request.headers.get("Authorization", "")
|
|
||||||
if auth_header == f"Bearer {api_key}":
|
|
||||||
return await call_next(request)
|
|
||||||
return JSONResponse(
|
|
||||||
{"error": "Invalid or missing API key"}, status_code=401
|
|
||||||
)
|
|
||||||
|
|
||||||
return await call_next(request)
|
|
||||||
|
|
||||||
# Attach middleware — FastMCP's SSE app is a Starlette app
|
|
||||||
original_sse_app = mcp.sse_app
|
|
||||||
|
|
||||||
def patched_sse_app():
|
|
||||||
app = original_sse_app()
|
|
||||||
app.add_middleware(AuthMiddleware)
|
|
||||||
return app
|
|
||||||
|
|
||||||
mcp.sse_app = patched_sse_app
|
|
||||||
|
|
||||||
# ── Run ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
if transport == "sse":
|
|
||||||
try:
|
|
||||||
import uvicorn
|
|
||||||
except ImportError:
|
|
||||||
raise CommandError(
|
|
||||||
"uvicorn is required for SSE transport. Install it with: pip install uvicorn"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.SUCCESS(
|
|
||||||
f"Starting Bambu-Run MCP server (SSE) on {host}:{port}"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.stdout.write(
|
|
||||||
f"Connect with: http://{host}:{port}/sse"
|
|
||||||
)
|
|
||||||
app = mcp.sse_app()
|
|
||||||
uvicorn.run(app, host=host, port=port)
|
|
||||||
else:
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.SUCCESS("Starting Bambu-Run MCP server (stdio)")
|
|
||||||
)
|
|
||||||
mcp.run(transport="stdio")
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
"""
|
|
||||||
Management command: bambu_sync_cloud
|
|
||||||
|
|
||||||
Backfill BambuCloudTask records from the Bambu Cloud API and link them to
|
|
||||||
existing PrintJob records. Primarily useful for jobs created before this
|
|
||||||
feature existed, or for re-syncing if the collector was offline at job end.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python manage.py bambu_sync_cloud
|
|
||||||
python manage.py bambu_sync_cloud --limit 100
|
|
||||||
python manage.py bambu_sync_cloud --dry-run
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = "Backfill BambuCloudTask records from Bambu Cloud API and link to PrintJob"
|
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
|
||||||
parser.add_argument(
|
|
||||||
'--limit', type=int, default=20,
|
|
||||||
help='Number of recent cloud tasks to fetch (default: 20)'
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--dry-run', action='store_true',
|
|
||||||
help='Show what would be synced without writing to DB'
|
|
||||||
)
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
limit = options['limit']
|
|
||||||
dry_run = options['dry_run']
|
|
||||||
|
|
||||||
bambu_token = os.environ.get('BAMBU_TOKEN')
|
|
||||||
bambu_username = os.environ.get('BAMBU_USERNAME')
|
|
||||||
bambu_password = os.environ.get('BAMBU_PASSWORD')
|
|
||||||
|
|
||||||
if not bambu_token and not all([bambu_username, bambu_password]):
|
|
||||||
raise CommandError(
|
|
||||||
"Either BAMBU_TOKEN or both BAMBU_USERNAME and BAMBU_PASSWORD must be set"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
from bambulab import BambuClient
|
|
||||||
from bambulab.auth import BambuAuthenticator
|
|
||||||
except ImportError:
|
|
||||||
raise CommandError("bambu-lab-cloud-api is not installed")
|
|
||||||
|
|
||||||
if bambu_token:
|
|
||||||
client = BambuClient(token=bambu_token)
|
|
||||||
else:
|
|
||||||
auth = BambuAuthenticator()
|
|
||||||
token = auth.login(bambu_username, bambu_password)
|
|
||||||
client = BambuClient(token=token)
|
|
||||||
|
|
||||||
from bambu_run.bambu_cloud import get_tasks, upsert_cloud_task
|
|
||||||
from bambu_run.models import PrintJob
|
|
||||||
|
|
||||||
self.stdout.write(f"Fetching last {limit} tasks from Bambu Cloud...")
|
|
||||||
try:
|
|
||||||
response = get_tasks(client, limit=limit)
|
|
||||||
except Exception as e:
|
|
||||||
raise CommandError(f"Cloud API request failed: {e}")
|
|
||||||
|
|
||||||
hits = response.get('hits', response.get('tasks', []))
|
|
||||||
self.stdout.write(f"Got {len(hits)} tasks from cloud")
|
|
||||||
|
|
||||||
created_count = updated_count = linked_count = 0
|
|
||||||
|
|
||||||
for task_dict in hits:
|
|
||||||
task_id = task_dict.get('id')
|
|
||||||
design_title = task_dict.get('designTitle') or ''
|
|
||||||
plate_title = task_dict.get('title') or ''
|
|
||||||
display_name = design_title or plate_title or f"task-{task_id}"
|
|
||||||
|
|
||||||
if dry_run:
|
|
||||||
self.stdout.write(
|
|
||||||
f" [dry-run] Would upsert task {task_id}: {display_name!r}"
|
|
||||||
)
|
|
||||||
# Check if we'd link to a PrintJob
|
|
||||||
job = PrintJob.objects.filter(cloud_task_id_raw=task_id).first()
|
|
||||||
if job:
|
|
||||||
self.stdout.write(f" → would link to PrintJob #{job.id}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
cloud_task, created = upsert_cloud_task(task_dict)
|
|
||||||
if created:
|
|
||||||
created_count += 1
|
|
||||||
self.stdout.write(f" Created: {display_name!r} (task {task_id})")
|
|
||||||
else:
|
|
||||||
updated_count += 1
|
|
||||||
|
|
||||||
# Link to any matching PrintJob by cloud_task_id_raw
|
|
||||||
linked = PrintJob.objects.filter(
|
|
||||||
cloud_task_id_raw=task_id, cloud_task__isnull=True
|
|
||||||
).update(cloud_task=cloud_task)
|
|
||||||
if linked:
|
|
||||||
linked_count += linked
|
|
||||||
self.stdout.write(f" Linked {linked} PrintJob(s) for task {task_id}")
|
|
||||||
|
|
||||||
# Historical backfill: match by cloud start_time ± 2 min + device serial
|
|
||||||
if cloud_task.cloud_start_time and cloud_task.device_serial:
|
|
||||||
from datetime import timedelta
|
|
||||||
from bambu_run.models import Printer
|
|
||||||
printer = Printer.objects.filter(
|
|
||||||
serial_number=cloud_task.device_serial
|
|
||||||
).first()
|
|
||||||
if printer:
|
|
||||||
window_start = cloud_task.cloud_start_time - timedelta(minutes=5)
|
|
||||||
window_end = cloud_task.cloud_start_time + timedelta(minutes=5)
|
|
||||||
historical = PrintJob.objects.filter(
|
|
||||||
device=printer,
|
|
||||||
start_time__gte=window_start,
|
|
||||||
start_time__lte=window_end,
|
|
||||||
cloud_task__isnull=True,
|
|
||||||
).update(cloud_task=cloud_task)
|
|
||||||
if historical:
|
|
||||||
linked_count += historical
|
|
||||||
self.stdout.write(
|
|
||||||
f" Historically linked {historical} PrintJob(s) by time for task {task_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.stderr.write(f" Error processing task {task_id}: {e}")
|
|
||||||
|
|
||||||
if not dry_run:
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.SUCCESS(
|
|
||||||
f"\nDone: {created_count} created, {updated_count} updated, "
|
|
||||||
f"{linked_count} PrintJob(s) linked"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.stdout.write(self.style.WARNING("\nDry run complete — no changes written"))
|
|
||||||
@@ -1,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)
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("bambu_run", "0001_initial"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="filamentcolor",
|
|
||||||
name="is_transparent",
|
|
||||||
field=models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
help_text="True for clear/transparent filaments — display as checkerboard, not solid color",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="filament",
|
|
||||||
name="is_transparent",
|
|
||||||
field=models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
help_text="True for clear/transparent filaments — display as checkerboard, not solid color",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
# Generated by Django 6.0.2 on 2026-03-29 11:38
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("bambu_run", "0002_filament_is_transparent"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="printjob",
|
|
||||||
name="cloud_task_id_raw",
|
|
||||||
field=models.BigIntegerField(
|
|
||||||
blank=True,
|
|
||||||
db_index=True,
|
|
||||||
help_text="MQTT task_id — captured at job start, used to link cloud task",
|
|
||||||
null=True,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="BambuCloudTask",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"id",
|
|
||||||
models.BigAutoField(
|
|
||||||
auto_created=True,
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
verbose_name="ID",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"task_id",
|
|
||||||
models.BigIntegerField(
|
|
||||||
db_index=True,
|
|
||||||
help_text="Bambu Cloud task ID (matches MQTT task_id)",
|
|
||||||
unique=True,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"design_id",
|
|
||||||
models.IntegerField(
|
|
||||||
blank=True, help_text="Makerworld design ID", null=True
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"design_title",
|
|
||||||
models.CharField(
|
|
||||||
blank=True,
|
|
||||||
help_text="Human project name from Makerworld (designTitle)",
|
|
||||||
max_length=500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"plate_title",
|
|
||||||
models.CharField(
|
|
||||||
blank=True,
|
|
||||||
help_text="Plate/variant name (matches MQTT subtask_name)",
|
|
||||||
max_length=500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("model_id", models.CharField(blank=True, max_length=100)),
|
|
||||||
(
|
|
||||||
"profile_id",
|
|
||||||
models.BigIntegerField(
|
|
||||||
blank=True, help_text="Bambu Cloud profile ID", null=True
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("plate_index", models.SmallIntegerField(blank=True, null=True)),
|
|
||||||
(
|
|
||||||
"device_serial",
|
|
||||||
models.CharField(
|
|
||||||
blank=True,
|
|
||||||
help_text="Printer serial number from cloud",
|
|
||||||
max_length=100,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"cover_url",
|
|
||||||
models.URLField(
|
|
||||||
blank=True,
|
|
||||||
help_text="Plate preview image URL from S3",
|
|
||||||
max_length=1000,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"weight_grams",
|
|
||||||
models.DecimalField(
|
|
||||||
blank=True,
|
|
||||||
decimal_places=2,
|
|
||||||
help_text="Actual filament weight reported by cloud",
|
|
||||||
max_digits=8,
|
|
||||||
null=True,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"length_mm",
|
|
||||||
models.IntegerField(
|
|
||||||
blank=True, help_text="Filament length in mm", null=True
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"cost_time_seconds",
|
|
||||||
models.IntegerField(
|
|
||||||
blank=True,
|
|
||||||
help_text="Cloud-measured print duration in seconds",
|
|
||||||
null=True,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"cloud_status",
|
|
||||||
models.SmallIntegerField(
|
|
||||||
blank=True, help_text="2=finish, 3=failed", null=True
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("bed_type", models.CharField(blank=True, max_length=50)),
|
|
||||||
("use_ams", models.BooleanField(default=True)),
|
|
||||||
(
|
|
||||||
"print_mode",
|
|
||||||
models.CharField(
|
|
||||||
blank=True, help_text="cloud_file, local, etc.", max_length=50
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"ams_detail_mapping",
|
|
||||||
models.JSONField(
|
|
||||||
default=list,
|
|
||||||
help_text="Per-slot filament weight breakdown from cloud",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("cloud_start_time", models.DateTimeField(blank=True, null=True)),
|
|
||||||
("cloud_end_time", models.DateTimeField(blank=True, null=True)),
|
|
||||||
(
|
|
||||||
"raw_data",
|
|
||||||
models.JSONField(
|
|
||||||
default=dict,
|
|
||||||
help_text="Full task response — preserved for future use",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("synced_at", models.DateTimeField(auto_now=True)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"verbose_name": "Bambu Cloud Task",
|
|
||||||
"verbose_name_plural": "Bambu Cloud Tasks",
|
|
||||||
"db_table": "infrastructure_cloud_task",
|
|
||||||
"ordering": ["-cloud_start_time"],
|
|
||||||
"indexes": [
|
|
||||||
models.Index(
|
|
||||||
fields=["task_id"], name="infrastruct_task_id_95b5ab_idx"
|
|
||||||
),
|
|
||||||
models.Index(
|
|
||||||
fields=["design_id"], name="infrastruct_design__88bdc0_idx"
|
|
||||||
),
|
|
||||||
models.Index(
|
|
||||||
fields=["-cloud_start_time"],
|
|
||||||
name="infrastruct_cloud_s_4078b0_idx",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="printjob",
|
|
||||||
name="cloud_task",
|
|
||||||
field=models.ForeignKey(
|
|
||||||
blank=True,
|
|
||||||
help_text="Linked Bambu Cloud task record (set by bambu_sync_cloud or collector)",
|
|
||||||
null=True,
|
|
||||||
on_delete=django.db.models.deletion.SET_NULL,
|
|
||||||
related_name="print_jobs",
|
|
||||||
to="bambu_run.bambucloudtask",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
# Generated by Django 5.2.8 on 2026-05-07 04:16
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("bambu_run", "0003_cloud_task"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="filament",
|
|
||||||
name="ams_type",
|
|
||||||
field=models.CharField(
|
|
||||||
blank=True,
|
|
||||||
choices=[
|
|
||||||
("AMS", "AMS"),
|
|
||||||
("AMS 2 Pro", "AMS 2 Pro"),
|
|
||||||
("AMS HT", "AMS HT"),
|
|
||||||
],
|
|
||||||
default="",
|
|
||||||
help_text="Type of the AMS unit this spool is loaded in (AMS / AMS 2 Pro / AMS HT)",
|
|
||||||
max_length=32,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="filament",
|
|
||||||
name="ams_unit_id",
|
|
||||||
field=models.PositiveSmallIntegerField(
|
|
||||||
blank=True,
|
|
||||||
db_index=True,
|
|
||||||
help_text="Which physical AMS unit this spool is loaded in (matches MQTT ams[i].id; 128 = AMS HT)",
|
|
||||||
null=True,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="printermetrics",
|
|
||||||
name="nozzle_diameter_left",
|
|
||||||
field=models.DecimalField(
|
|
||||||
blank=True,
|
|
||||||
decimal_places=2,
|
|
||||||
help_text="Left nozzle diameter (mm). H2C only.",
|
|
||||||
max_digits=3,
|
|
||||||
null=True,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="printermetrics",
|
|
||||||
name="nozzle_target_temp_left",
|
|
||||||
field=models.DecimalField(
|
|
||||||
blank=True,
|
|
||||||
decimal_places=2,
|
|
||||||
help_text="Left extruder target temperature (°C). H2C only.",
|
|
||||||
max_digits=5,
|
|
||||||
null=True,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="printermetrics",
|
|
||||||
name="nozzle_temp_left",
|
|
||||||
field=models.DecimalField(
|
|
||||||
blank=True,
|
|
||||||
decimal_places=2,
|
|
||||||
help_text="Left extruder current temperature (°C). H2C only.",
|
|
||||||
max_digits=5,
|
|
||||||
null=True,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="printermetrics",
|
|
||||||
name="nozzle_type_left",
|
|
||||||
field=models.CharField(
|
|
||||||
blank=True,
|
|
||||||
help_text="Left nozzle type (e.g. HS01-0.4). H2C only.",
|
|
||||||
max_length=50,
|
|
||||||
null=True,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="filament",
|
|
||||||
name="current_tray_id",
|
|
||||||
field=models.IntegerField(
|
|
||||||
blank=True,
|
|
||||||
help_text="Tray slot index within its AMS unit (0-3 for AMS/AMS 2 Pro, 0 for AMS HT)",
|
|
||||||
null=True,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# Generated by Django 5.2.8 on 2026-06-18 12:20
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("bambu_run", "0004_h2c_dual_nozzle_and_ams_fields"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="printermetrics",
|
|
||||||
name="vortek_raw",
|
|
||||||
field=models.JSONField(
|
|
||||||
blank=True,
|
|
||||||
default=dict,
|
|
||||||
help_text="Raw print.device MQTT payload (Vortek rack groundwork)",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -2,33 +2,6 @@ from django.db import models
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
# Bambu AMS model-code → human-readable type label.
|
|
||||||
# Source: live H2C MQTT probe — `print.ams.ams[i].info` field.
|
|
||||||
# Add new codes as they are observed (e.g. AMS Lite, future variants).
|
|
||||||
AMS_INFO_TO_TYPE = {
|
|
||||||
"1001": "AMS",
|
|
||||||
"1003": "AMS 2 Pro",
|
|
||||||
"2104": "AMS HT",
|
|
||||||
}
|
|
||||||
|
|
||||||
AMS_TYPE_CHOICES = [
|
|
||||||
("AMS", "AMS"),
|
|
||||||
("AMS 2 Pro", "AMS 2 Pro"),
|
|
||||||
("AMS HT", "AMS HT"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def ams_type_from_info(info_code) -> str:
|
|
||||||
"""Resolve an AMS unit's `info` model code to a human label.
|
|
||||||
|
|
||||||
The HT unit reports its `id` with the 0x80 bit set (e.g. 128) — when the info
|
|
||||||
code is unknown, that bit is a reasonable secondary hint for HT identification.
|
|
||||||
"""
|
|
||||||
if info_code is None:
|
|
||||||
return ""
|
|
||||||
return AMS_INFO_TO_TYPE.get(str(info_code), "")
|
|
||||||
|
|
||||||
|
|
||||||
class Printer(models.Model):
|
class Printer(models.Model):
|
||||||
"""Represents a Bambu Lab 3D printer device"""
|
"""Represents a Bambu Lab 3D printer device"""
|
||||||
|
|
||||||
@@ -85,32 +58,12 @@ class PrinterMetrics(models.Model):
|
|||||||
max_digits=5, decimal_places=2, null=True, blank=True
|
max_digits=5, decimal_places=2, null=True, blank=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Nozzle info — single-nozzle / right-side back-compat fields. On dual-nozzle
|
# Nozzle info
|
||||||
# printers (H2C) these mirror the right extruder; the left extruder uses the
|
|
||||||
# `_left` columns below.
|
|
||||||
nozzle_diameter = models.DecimalField(
|
nozzle_diameter = models.DecimalField(
|
||||||
max_digits=3, decimal_places=2, null=True, blank=True
|
max_digits=3, decimal_places=2, null=True, blank=True
|
||||||
)
|
)
|
||||||
nozzle_type = models.CharField(max_length=50, null=True, blank=True)
|
nozzle_type = models.CharField(max_length=50, null=True, blank=True)
|
||||||
|
|
||||||
# H2C dual-nozzle: left-side fields (NULL on single-nozzle printers).
|
|
||||||
nozzle_temp_left = models.DecimalField(
|
|
||||||
max_digits=5, decimal_places=2, null=True, blank=True,
|
|
||||||
help_text="Left extruder current temperature (°C). H2C only."
|
|
||||||
)
|
|
||||||
nozzle_target_temp_left = models.DecimalField(
|
|
||||||
max_digits=5, decimal_places=2, null=True, blank=True,
|
|
||||||
help_text="Left extruder target temperature (°C). H2C only."
|
|
||||||
)
|
|
||||||
nozzle_diameter_left = models.DecimalField(
|
|
||||||
max_digits=3, decimal_places=2, null=True, blank=True,
|
|
||||||
help_text="Left nozzle diameter (mm). H2C only."
|
|
||||||
)
|
|
||||||
nozzle_type_left = models.CharField(
|
|
||||||
max_length=50, null=True, blank=True,
|
|
||||||
help_text="Left nozzle type (e.g. HS01-0.4). H2C only."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Print job status
|
# Print job status
|
||||||
gcode_state = models.CharField(
|
gcode_state = models.CharField(
|
||||||
max_length=50, null=True, blank=True, help_text="FINISH, RUNNING, IDLE, etc."
|
max_length=50, null=True, blank=True, help_text="FINISH, RUNNING, IDLE, etc."
|
||||||
@@ -231,14 +184,6 @@ class PrinterMetrics(models.Model):
|
|||||||
default=list, help_text="Light status report [{node, mode}]"
|
default=list, help_text="Light status report [{node, mode}]"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Groundwork for H2C's Vortek nozzle-changer rack (6 swappable hotends + 1 fixed
|
|
||||||
# left nozzle) — the full MQTT schema for per-slot state isn't confirmed yet, so
|
|
||||||
# the raw `print.device` payload is captured here unfiltered to avoid losing data
|
|
||||||
# ahead of proper per-slot modeling.
|
|
||||||
vortek_raw = models.JSONField(
|
|
||||||
default=dict, blank=True, help_text="Raw print.device MQTT payload (Vortek rack groundwork)"
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "infrastructure_printer_metrics"
|
db_table = "infrastructure_printer_metrics"
|
||||||
verbose_name = "Printer Metric"
|
verbose_name = "Printer Metric"
|
||||||
@@ -314,10 +259,6 @@ class FilamentColor(models.Model):
|
|||||||
default='Bambu Lab',
|
default='Bambu Lab',
|
||||||
help_text="Manufacturer name"
|
help_text="Manufacturer name"
|
||||||
)
|
)
|
||||||
is_transparent = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
help_text="True for clear/transparent filaments — display as checkerboard, not solid color"
|
|
||||||
)
|
|
||||||
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
@@ -388,10 +329,6 @@ class Filament(models.Model):
|
|||||||
max_length=7, null=True, blank=True,
|
max_length=7, null=True, blank=True,
|
||||||
help_text="Color hex code for display (#RRGGBB)"
|
help_text="Color hex code for display (#RRGGBB)"
|
||||||
)
|
)
|
||||||
is_transparent = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
help_text="True for clear/transparent filaments — display as checkerboard, not solid color"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Physical properties
|
# Physical properties
|
||||||
diameter = models.DecimalField(
|
diameter = models.DecimalField(
|
||||||
@@ -420,16 +357,7 @@ class Filament(models.Model):
|
|||||||
)
|
)
|
||||||
current_tray_id = models.IntegerField(
|
current_tray_id = models.IntegerField(
|
||||||
null=True, blank=True,
|
null=True, blank=True,
|
||||||
help_text="Tray slot index within its AMS unit (0-3 for AMS/AMS 2 Pro, 0 for AMS HT)"
|
help_text="Which AMS slot (0-3) if loaded"
|
||||||
)
|
|
||||||
ams_unit_id = models.PositiveSmallIntegerField(
|
|
||||||
null=True, blank=True, db_index=True,
|
|
||||||
help_text="Which physical AMS unit this spool is loaded in (matches MQTT ams[i].id; 128 = AMS HT)"
|
|
||||||
)
|
|
||||||
ams_type = models.CharField(
|
|
||||||
max_length=32, blank=True, default="",
|
|
||||||
choices=AMS_TYPE_CHOICES,
|
|
||||||
help_text="Type of the AMS unit this spool is loaded in (AMS / AMS 2 Pro / AMS HT)"
|
|
||||||
)
|
)
|
||||||
last_loaded_date = models.DateTimeField(
|
last_loaded_date = models.DateTimeField(
|
||||||
null=True, blank=True,
|
null=True, blank=True,
|
||||||
@@ -556,47 +484,6 @@ class FilamentSnapshot(models.Model):
|
|||||||
return f"Tray {self.tray_id}: {filament_info}"
|
return f"Tray {self.tray_id}: {filament_info}"
|
||||||
|
|
||||||
|
|
||||||
class BambuCloudTask(models.Model):
|
|
||||||
"""Cloud task record synced from Bambu Cloud API (v1/user-service/my/tasks)."""
|
|
||||||
|
|
||||||
task_id = models.BigIntegerField(unique=True, db_index=True, help_text="Bambu Cloud task ID (matches MQTT task_id)")
|
|
||||||
design_id = models.IntegerField(null=True, blank=True, help_text="Makerworld design ID")
|
|
||||||
design_title = models.CharField(max_length=500, blank=True, help_text="Human project name from Makerworld (designTitle)")
|
|
||||||
plate_title = models.CharField(max_length=500, blank=True, help_text="Plate/variant name (matches MQTT subtask_name)")
|
|
||||||
model_id = models.CharField(max_length=100, blank=True)
|
|
||||||
profile_id = models.BigIntegerField(null=True, blank=True, help_text="Bambu Cloud profile ID")
|
|
||||||
plate_index = models.SmallIntegerField(null=True, blank=True)
|
|
||||||
device_serial = models.CharField(max_length=100, blank=True, help_text="Printer serial number from cloud")
|
|
||||||
cover_url = models.URLField(max_length=1000, blank=True, help_text="Plate preview image URL from S3")
|
|
||||||
weight_grams = models.DecimalField(max_digits=8, decimal_places=2, null=True, blank=True, help_text="Actual filament weight reported by cloud")
|
|
||||||
length_mm = models.IntegerField(null=True, blank=True, help_text="Filament length in mm")
|
|
||||||
cost_time_seconds = models.IntegerField(null=True, blank=True, help_text="Cloud-measured print duration in seconds")
|
|
||||||
cloud_status = models.SmallIntegerField(null=True, blank=True, help_text="2=finish, 3=failed")
|
|
||||||
bed_type = models.CharField(max_length=50, blank=True)
|
|
||||||
use_ams = models.BooleanField(default=True)
|
|
||||||
print_mode = models.CharField(max_length=50, blank=True, help_text="cloud_file, local, etc.")
|
|
||||||
ams_detail_mapping = models.JSONField(default=list, help_text="Per-slot filament weight breakdown from cloud")
|
|
||||||
cloud_start_time = models.DateTimeField(null=True, blank=True)
|
|
||||||
cloud_end_time = models.DateTimeField(null=True, blank=True)
|
|
||||||
raw_data = models.JSONField(default=dict, help_text="Full task response — preserved for future use")
|
|
||||||
synced_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
db_table = "infrastructure_cloud_task"
|
|
||||||
verbose_name = "Bambu Cloud Task"
|
|
||||||
verbose_name_plural = "Bambu Cloud Tasks"
|
|
||||||
ordering = ["-cloud_start_time"]
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=["task_id"]),
|
|
||||||
models.Index(fields=["design_id"]),
|
|
||||||
models.Index(fields=["-cloud_start_time"]),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
name = self.design_title or self.plate_title or f"task-{self.task_id}"
|
|
||||||
return f"{name} ({self.cloud_start_time.strftime('%Y-%m-%d') if self.cloud_start_time else 'unknown date'})"
|
|
||||||
|
|
||||||
|
|
||||||
class PrintJob(models.Model):
|
class PrintJob(models.Model):
|
||||||
"""Represents a single print job from start to finish"""
|
"""Represents a single print job from start to finish"""
|
||||||
|
|
||||||
@@ -610,16 +497,6 @@ class PrintJob(models.Model):
|
|||||||
)
|
)
|
||||||
gcode_file = models.CharField(max_length=200, null=True, blank=True)
|
gcode_file = models.CharField(max_length=200, null=True, blank=True)
|
||||||
|
|
||||||
cloud_task = models.ForeignKey(
|
|
||||||
'BambuCloudTask', on_delete=models.SET_NULL,
|
|
||||||
null=True, blank=True, related_name='print_jobs',
|
|
||||||
help_text="Linked Bambu Cloud task record (set by bambu_sync_cloud or collector)"
|
|
||||||
)
|
|
||||||
cloud_task_id_raw = models.BigIntegerField(
|
|
||||||
null=True, blank=True, db_index=True,
|
|
||||||
help_text="MQTT task_id — captured at job start, used to link cloud task"
|
|
||||||
)
|
|
||||||
|
|
||||||
start_time = models.DateTimeField(help_text="When print started")
|
start_time = models.DateTimeField(help_text="When print started")
|
||||||
end_time = models.DateTimeField(null=True, blank=True, help_text="When print finished/failed")
|
end_time = models.DateTimeField(null=True, blank=True, help_text="When print finished/failed")
|
||||||
duration_minutes = models.IntegerField(null=True, blank=True, help_text="Total print duration")
|
duration_minutes = models.IntegerField(null=True, blank=True, help_text="Total print duration")
|
||||||
@@ -659,13 +536,6 @@ class PrintJob(models.Model):
|
|||||||
status = self.final_status or 'In Progress'
|
status = self.final_status or 'In Progress'
|
||||||
return f"{self.project_name} ({status}) - {self.start_time.strftime('%Y-%m-%d %H:%M')}"
|
return f"{self.project_name} ({status}) - {self.start_time.strftime('%Y-%m-%d %H:%M')}"
|
||||||
|
|
||||||
@property
|
|
||||||
def display_name(self):
|
|
||||||
"""Human-readable job name: cloud design_title if available, else project_name."""
|
|
||||||
if self.cloud_task_id and self.cloud_task and self.cloud_task.design_title:
|
|
||||||
return self.cloud_task.design_title
|
|
||||||
return self.project_name
|
|
||||||
|
|
||||||
def calculate_duration(self):
|
def calculate_duration(self):
|
||||||
"""Calculate print duration if end_time is set"""
|
"""Calculate print duration if end_time is set"""
|
||||||
if self.end_time and self.start_time:
|
if self.end_time and self.start_time:
|
||||||
|
|||||||
@@ -335,16 +335,10 @@ class PrinterState:
|
|||||||
wifi_signal: str = ""
|
wifi_signal: str = ""
|
||||||
wifi_signal_dbm: int = 0
|
wifi_signal_dbm: int = 0
|
||||||
|
|
||||||
# Nozzle info — single-nozzle / right-side back-compat fields.
|
# Nozzle info
|
||||||
nozzle_diameter: float = 0.4
|
nozzle_diameter: float = 0.4
|
||||||
nozzle_type: str = ""
|
nozzle_type: str = ""
|
||||||
|
|
||||||
# H2C dual-nozzle: left-side fields (None on single-nozzle printers).
|
|
||||||
nozzle_temp_left: Optional[float] = None
|
|
||||||
nozzle_target_temp_left: Optional[float] = None
|
|
||||||
nozzle_diameter_left: Optional[float] = None
|
|
||||||
nozzle_type_left: Optional[str] = None
|
|
||||||
|
|
||||||
# System status
|
# System status
|
||||||
home_flag: int = 0
|
home_flag: int = 0
|
||||||
hw_switch_state: int = 0
|
hw_switch_state: int = 0
|
||||||
@@ -416,21 +410,6 @@ 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
|
|
||||||
# under `print.device.extruder.info[]` as a 2-element array (index 0 =
|
|
||||||
# right, index 1 = left). The `temp` field is bit-packed:
|
|
||||||
# `temp_raw = (target << 16) | current`, both °C as ints.
|
|
||||||
nozzle_temp_left = None
|
|
||||||
nozzle_target_temp_left = None
|
|
||||||
device = print_data.get("device") or {}
|
|
||||||
extruders = (device.get("extruder") or {}).get("info") or []
|
|
||||||
if len(extruders) >= 2:
|
|
||||||
left = extruders[1]
|
|
||||||
t = left.get("temp")
|
|
||||||
if isinstance(t, int):
|
|
||||||
nozzle_target_temp_left = float((t >> 16) & 0xFFFF)
|
|
||||||
nozzle_temp_left = float(t & 0xFFFF)
|
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
timestamp=timestamp,
|
timestamp=timestamp,
|
||||||
sequence_id=str(print_data.get("sequence_id", "")),
|
sequence_id=str(print_data.get("sequence_id", "")),
|
||||||
@@ -459,13 +438,6 @@ class PrinterState:
|
|||||||
wifi_signal_dbm=cls._parse_wifi_signal(wifi_signal),
|
wifi_signal_dbm=cls._parse_wifi_signal(wifi_signal),
|
||||||
nozzle_diameter=float(print_data.get("nozzle_diameter", 0.4)),
|
nozzle_diameter=float(print_data.get("nozzle_diameter", 0.4)),
|
||||||
nozzle_type=print_data.get("nozzle_type", ""),
|
nozzle_type=print_data.get("nozzle_type", ""),
|
||||||
nozzle_temp_left=nozzle_temp_left,
|
|
||||||
nozzle_target_temp_left=nozzle_target_temp_left,
|
|
||||||
# Diameter/type per side: H2C currently uses uniform nozzles, so reuse top-level
|
|
||||||
# values. If a future probe shows per-side diameter/type variance, plumb it from
|
|
||||||
# `device.nozzle.info[]` cross-referenced against `device.extruder.info[i].id`.
|
|
||||||
nozzle_diameter_left=float(print_data.get("nozzle_diameter", 0.4)) if nozzle_temp_left is not None else None,
|
|
||||||
nozzle_type_left=print_data.get("nozzle_type", "") if nozzle_temp_left is not None else None,
|
|
||||||
home_flag=int(print_data.get("home_flag", 0)),
|
home_flag=int(print_data.get("home_flag", 0)),
|
||||||
hw_switch_state=int(print_data.get("hw_switch_state", 0)),
|
hw_switch_state=int(print_data.get("hw_switch_state", 0)),
|
||||||
mc_print_stage=str(print_data.get("mc_print_stage", "")),
|
mc_print_stage=str(print_data.get("mc_print_stage", "")),
|
||||||
@@ -501,14 +473,6 @@ class PrinterState:
|
|||||||
"chamber_temp": round(self.chamber_temp, 2),
|
"chamber_temp": round(self.chamber_temp, 2),
|
||||||
"nozzle_diameter": self.nozzle_diameter,
|
"nozzle_diameter": self.nozzle_diameter,
|
||||||
"nozzle_type": self.nozzle_type,
|
"nozzle_type": self.nozzle_type,
|
||||||
"nozzle_temp_left": (
|
|
||||||
round(self.nozzle_temp_left, 2) if self.nozzle_temp_left is not None else None
|
|
||||||
),
|
|
||||||
"nozzle_target_temp_left": (
|
|
||||||
round(self.nozzle_target_temp_left, 2) if self.nozzle_target_temp_left is not None else None
|
|
||||||
),
|
|
||||||
"nozzle_diameter_left": self.nozzle_diameter_left,
|
|
||||||
"nozzle_type_left": self.nozzle_type_left,
|
|
||||||
"gcode_state": self.gcode_state,
|
"gcode_state": self.gcode_state,
|
||||||
"print_type": self.print_type,
|
"print_type": self.print_type,
|
||||||
"print_percent": self.print_percent,
|
"print_percent": self.print_percent,
|
||||||
@@ -518,8 +482,6 @@ class PrinterState:
|
|||||||
"print_line_number": self.print_line_number,
|
"print_line_number": self.print_line_number,
|
||||||
"subtask_name": self.subtask_name,
|
"subtask_name": self.subtask_name,
|
||||||
"gcode_file": self.gcode_file,
|
"gcode_file": self.gcode_file,
|
||||||
"task_id": self.task_id,
|
|
||||||
"project_id": self.project_id,
|
|
||||||
"cooling_fan_speed": self.cooling_fan_speed,
|
"cooling_fan_speed": self.cooling_fan_speed,
|
||||||
"heatbreak_fan_speed": self.heatbreak_fan_speed,
|
"heatbreak_fan_speed": self.heatbreak_fan_speed,
|
||||||
"big_fan1_speed": self.big_fan1_speed,
|
"big_fan1_speed": self.big_fan1_speed,
|
||||||
@@ -529,10 +491,6 @@ class PrinterState:
|
|||||||
"wifi_signal_dbm": self.wifi_signal_dbm,
|
"wifi_signal_dbm": self.wifi_signal_dbm,
|
||||||
"print_error": self.print_error,
|
"print_error": self.print_error,
|
||||||
"has_errors": self.print_error != 0,
|
"has_errors": self.print_error != 0,
|
||||||
# Full `print.device` payload, unfiltered. H2C's Vortek rack (6 swappable
|
|
||||||
# hotends + 1 fixed left nozzle) isn't fully modeled yet — stash everything
|
|
||||||
# here so no data is lost once the real Vortek MQTT schema is confirmed.
|
|
||||||
"vortek_raw": self._raw_data.get("print", {}).get("device", {}),
|
|
||||||
"hms": self.hms,
|
"hms": self.hms,
|
||||||
"stg_cur": self.stg_cur,
|
"stg_cur": self.stg_cur,
|
||||||
"lights_report": self.lights_report,
|
"lights_report": self.lights_report,
|
||||||
@@ -555,19 +513,8 @@ class PrinterState:
|
|||||||
snapshot["tray_now"] = self.ams.tray_now
|
snapshot["tray_now"] = self.ams.tray_now
|
||||||
snapshot["ams_version"] = self.ams.version
|
snapshot["ams_version"] = self.ams.version
|
||||||
|
|
||||||
from .models import ams_type_from_info
|
|
||||||
|
|
||||||
filaments = []
|
filaments = []
|
||||||
for unit in self.ams.units:
|
for unit in self.ams.units:
|
||||||
# `unit_id` is the AMS unit's own id from the MQTT payload — for the
|
|
||||||
# original AMS / AMS 2 Pro it's a small int (0,1,2,...); for AMS HT
|
|
||||||
# it has the 0x80 bit set (e.g. 128). Don't compute tray_id // 4 —
|
|
||||||
# multi-AMS-type setups are not contiguous.
|
|
||||||
try:
|
|
||||||
unit_id_int = int(unit.unit_id)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
unit_id_int = None
|
|
||||||
ams_type_label = ams_type_from_info(unit.info)
|
|
||||||
for tray in unit.trays:
|
for tray in unit.trays:
|
||||||
if tray.tray_type:
|
if tray.tray_type:
|
||||||
filaments.append({
|
filaments.append({
|
||||||
@@ -593,9 +540,6 @@ class PrinterState:
|
|||||||
"tray_bed_temp": tray.tray_bed_temp,
|
"tray_bed_temp": tray.tray_bed_temp,
|
||||||
"bed_temp_type": tray.bed_temp_type,
|
"bed_temp_type": tray.bed_temp_type,
|
||||||
"cols": tray.cols,
|
"cols": tray.cols,
|
||||||
"ams_unit_id": unit_id_int,
|
|
||||||
"ams_info": unit.info,
|
|
||||||
"ams_type": ams_type_label,
|
|
||||||
})
|
})
|
||||||
snapshot["filaments"] = filaments
|
snapshot["filaments"] = filaments
|
||||||
|
|
||||||
@@ -606,7 +550,6 @@ class PrinterState:
|
|||||||
"ams_id": unit.ams_id,
|
"ams_id": unit.ams_id,
|
||||||
"chip_id": unit.chip_id,
|
"chip_id": unit.chip_id,
|
||||||
"info": unit.info,
|
"info": unit.info,
|
||||||
"ams_type": ams_type_from_info(unit.info),
|
|
||||||
"humidity": unit.humidity,
|
"humidity": unit.humidity,
|
||||||
"humidity_raw": unit.humidity_raw,
|
"humidity_raw": unit.humidity_raw,
|
||||||
"temp": unit.temp,
|
"temp": unit.temp,
|
||||||
|
|||||||
@@ -1,302 +0,0 @@
|
|||||||
// Filament Detail Chart — Usage History
|
|
||||||
// Depends on: chart.js, chartjs-plugin-annotation
|
|
||||||
// Config injected by template: FILAMENT_USAGE_API_URL
|
|
||||||
|
|
||||||
let usageChart = null;
|
|
||||||
|
|
||||||
// Register annotation plugin once it's available
|
|
||||||
if (typeof ChartAnnotation !== 'undefined') {
|
|
||||||
Chart.register(ChartAnnotation);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Time-select population ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const startTimeSelect = document.getElementById('filamentStartTime');
|
|
||||||
const endTimeSelect = document.getElementById('filamentEndTime');
|
|
||||||
if (startTimeSelect && endTimeSelect) {
|
|
||||||
for (let h = 0; h < 24; h++) {
|
|
||||||
for (let m = 0; m < 60; m += 30) {
|
|
||||||
const t = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
|
||||||
startTimeSelect.add(new Option(t, t));
|
|
||||||
endTimeSelect.add(new Option(t, t));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// End-time gets one extra option so the last minute of the day is reachable
|
|
||||||
endTimeSelect.add(new Option('23:59', '23:59'));
|
|
||||||
startTimeSelect.value = '00:00';
|
|
||||||
endTimeSelect.value = '23:59';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Default date inputs (last 24 h) ────────────────────────────────────────
|
|
||||||
|
|
||||||
(function setDefaultDates() {
|
|
||||||
const now = new Date();
|
|
||||||
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
|
||||||
const sd = document.getElementById('filamentStartDate');
|
|
||||||
const ed = document.getElementById('filamentEndDate');
|
|
||||||
if (sd) sd.value = yesterday.toISOString().split('T')[0];
|
|
||||||
if (ed) ed.value = now.toISOString().split('T')[0];
|
|
||||||
}());
|
|
||||||
|
|
||||||
// ── Full-day checkbox ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const fullDayCheckbox = document.getElementById('filamentFullDayCheckbox');
|
|
||||||
if (fullDayCheckbox) {
|
|
||||||
fullDayCheckbox.addEventListener('change', function () {
|
|
||||||
const isFullDay = this.checked;
|
|
||||||
if (startTimeSelect) startTimeSelect.disabled = isFullDay;
|
|
||||||
if (endTimeSelect) endTimeSelect.disabled = isFullDay;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build date-separator annotations from "YYYY-MM-DD HH:MM" timestamp strings.
|
|
||||||
* Places a vertical dotted line at each day boundary, label at the bottom.
|
|
||||||
*/
|
|
||||||
function buildFilamentDateSeparators(timestamps) {
|
|
||||||
const annotations = {};
|
|
||||||
if (!timestamps || timestamps.length < 2) return annotations;
|
|
||||||
let count = 0;
|
|
||||||
for (let i = 1; i < timestamps.length; i++) {
|
|
||||||
const prevDate = timestamps[i - 1].split(' ')[0];
|
|
||||||
const currDate = timestamps[i].split(' ')[0];
|
|
||||||
if (currDate !== prevDate) {
|
|
||||||
const d = new Date(currDate + 'T00:00:00');
|
|
||||||
const label = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
||||||
annotations['dateSep_' + count] = {
|
|
||||||
type: 'line',
|
|
||||||
scaleID: 'x',
|
|
||||||
value: i,
|
|
||||||
borderColor: 'rgba(128, 128, 128, 0.45)',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderDash: [4, 4],
|
|
||||||
drawTime: 'beforeDatasetsDraw',
|
|
||||||
label: {
|
|
||||||
display: true,
|
|
||||||
content: label,
|
|
||||||
position: 'end',
|
|
||||||
backgroundColor: 'rgba(100, 100, 100, 0.65)',
|
|
||||||
color: '#fff',
|
|
||||||
font: { size: 9 },
|
|
||||||
padding: { x: 4, y: 2 }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return annotations;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build x-axis tick options that adapt to the date span.
|
|
||||||
*
|
|
||||||
* autoSkip: true — Chart.js selects evenly-spaced tick positions.
|
|
||||||
* maxTicksLimit — caps how many ticks are drawn.
|
|
||||||
* callback — formats the label at each chosen tick position.
|
|
||||||
*
|
|
||||||
* ≤1 day : up to 12 ticks, show "HH:MM"
|
|
||||||
* 2–7 days: up to dayCount×4 ticks (≤28), show "Feb 22 06:00"
|
|
||||||
* >7 days : up to min(dayCount, 20) ticks, show "Feb 22"
|
|
||||||
*/
|
|
||||||
function filamentXAxisTicks(isDarkMode, timestamps) {
|
|
||||||
const tickColor = isDarkMode ? 'rgba(255,255,255,0.8)' : 'rgba(0,0,0,0.8)';
|
|
||||||
|
|
||||||
const dayCount = (timestamps && timestamps.length > 0)
|
|
||||||
? new Set(timestamps.map(t => t.split(' ')[0])).size
|
|
||||||
: 1;
|
|
||||||
|
|
||||||
let maxTicksLimit, formatCb;
|
|
||||||
|
|
||||||
if (dayCount <= 1) {
|
|
||||||
maxTicksLimit = 12;
|
|
||||||
formatCb = function (val) {
|
|
||||||
const label = this.getLabelForValue(val);
|
|
||||||
return label ? label.slice(11, 16) : ''; // "HH:MM"
|
|
||||||
};
|
|
||||||
} else if (dayCount <= 7) {
|
|
||||||
maxTicksLimit = Math.min(dayCount * 4, 28);
|
|
||||||
formatCb = function (val) {
|
|
||||||
const label = this.getLabelForValue(val);
|
|
||||||
if (!label) return '';
|
|
||||||
const datePart = label.split(' ')[0];
|
|
||||||
const timePart = label.length >= 16 ? label.slice(11, 16) : '';
|
|
||||||
const d = new Date(datePart + 'T00:00:00');
|
|
||||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ' ' + timePart;
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
maxTicksLimit = Math.min(dayCount, 20);
|
|
||||||
formatCb = function (val) {
|
|
||||||
const label = this.getLabelForValue(val);
|
|
||||||
if (!label) return '';
|
|
||||||
const datePart = label.split(' ')[0];
|
|
||||||
const d = new Date(datePart + 'T00:00:00');
|
|
||||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
color: tickColor,
|
|
||||||
autoSkip: true,
|
|
||||||
maxTicksLimit: maxTicksLimit,
|
|
||||||
maxRotation: 45,
|
|
||||||
minRotation: 0,
|
|
||||||
callback: formatCb
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Chart fetch / render ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch and render the usage chart.
|
|
||||||
*
|
|
||||||
* @param {boolean} sendDates When false (initial load / reset), no date params
|
|
||||||
* are sent so the backend can apply its default
|
|
||||||
* "last 24h or fallback to last available" logic.
|
|
||||||
* When true (explicit Refresh), the current input
|
|
||||||
* values are sent as-is.
|
|
||||||
*/
|
|
||||||
async function fetchFilamentUsageData(sendDates = true) {
|
|
||||||
const startDate = document.getElementById('filamentStartDate').value;
|
|
||||||
const endDate = document.getElementById('filamentEndDate').value;
|
|
||||||
const isFullDay = fullDayCheckbox ? fullDayCheckbox.checked : true;
|
|
||||||
const startTime = isFullDay ? '00:00' : (startTimeSelect ? startTimeSelect.value : '00:00');
|
|
||||||
const endTime = isFullDay ? '23:59' : (endTimeSelect ? endTimeSelect.value : '23:59');
|
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (sendDates) {
|
|
||||||
if (startDate) params.append('start_date', startDate);
|
|
||||||
if (endDate) params.append('end_date', endDate);
|
|
||||||
if (startTime) params.append('start_time', startTime);
|
|
||||||
if (endTime) params.append('end_time', endTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(FILAMENT_USAGE_API_URL + '?' + params.toString());
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// If the backend used the fallback window, sync the date inputs so the
|
|
||||||
// user can see and extend the range from that starting point.
|
|
||||||
if (data.fallback_used && data.timestamps && data.timestamps.length > 0) {
|
|
||||||
const firstDate = data.timestamps[0].split(' ')[0];
|
|
||||||
const lastDate = data.timestamps[data.timestamps.length - 1].split(' ')[0];
|
|
||||||
const sd = document.getElementById('filamentStartDate');
|
|
||||||
const ed = document.getElementById('filamentEndDate');
|
|
||||||
if (sd) sd.value = firstDate;
|
|
||||||
if (ed) ed.value = lastDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update date-range label
|
|
||||||
const dateRangeSpan = document.getElementById('filamentDateRange');
|
|
||||||
if (dateRangeSpan) {
|
|
||||||
if (data.fallback_used) {
|
|
||||||
dateRangeSpan.textContent = '(Last available data — 24h window)';
|
|
||||||
} else if (startDate && endDate && sendDates) {
|
|
||||||
dateRangeSpan.textContent = `(${startDate} to ${endDate})`;
|
|
||||||
} else {
|
|
||||||
dateRangeSpan.textContent = '(Last 24 Hours)';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDarkMode = document.documentElement.getAttribute('data-coreui-theme') === 'dark';
|
|
||||||
const tickColor = isDarkMode ? 'rgba(255,255,255,0.8)' : 'rgba(0,0,0,0.8)';
|
|
||||||
const gridColor = isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)';
|
|
||||||
const sepAnnotations = buildFilamentDateSeparators(data.timestamps);
|
|
||||||
const xTicks = filamentXAxisTicks(isDarkMode, data.timestamps);
|
|
||||||
|
|
||||||
if (usageChart) {
|
|
||||||
usageChart.data.labels = data.timestamps;
|
|
||||||
usageChart.data.datasets[0].data = data.remaining;
|
|
||||||
usageChart.options.plugins.annotation.annotations = sepAnnotations;
|
|
||||||
usageChart.options.scales.x.ticks = xTicks;
|
|
||||||
usageChart.update();
|
|
||||||
} else {
|
|
||||||
const ctx = document.getElementById('usageChart').getContext('2d');
|
|
||||||
usageChart = new Chart(ctx, {
|
|
||||||
type: 'line',
|
|
||||||
data: {
|
|
||||||
labels: data.timestamps,
|
|
||||||
datasets: [{
|
|
||||||
label: 'Remaining %',
|
|
||||||
data: data.remaining,
|
|
||||||
borderColor: 'rgb(75, 192, 192)',
|
|
||||||
backgroundColor: 'rgba(75, 192, 192, 0.1)',
|
|
||||||
tension: 0.3,
|
|
||||||
fill: true,
|
|
||||||
pointRadius: 0,
|
|
||||||
pointHoverRadius: 3,
|
|
||||||
borderWidth: 2
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
interaction: { mode: 'index', intersect: false },
|
|
||||||
plugins: {
|
|
||||||
annotation: { annotations: sepAnnotations },
|
|
||||||
legend: {
|
|
||||||
position: 'top',
|
|
||||||
labels: { color: tickColor }
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
callbacks: {
|
|
||||||
label: function (ctx) {
|
|
||||||
return 'Remaining: ' + ctx.parsed.y + '%';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
x: {
|
|
||||||
ticks: xTicks,
|
|
||||||
grid: { color: gridColor }
|
|
||||||
},
|
|
||||||
y: {
|
|
||||||
beginAtZero: true,
|
|
||||||
max: 100,
|
|
||||||
ticks: {
|
|
||||||
color: tickColor,
|
|
||||||
callback: function (v) { return v + '%'; }
|
|
||||||
},
|
|
||||||
grid: { color: gridColor }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching filament usage data:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Event listeners ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const refreshBtn = document.getElementById('refreshFilamentChart');
|
|
||||||
const resetBtn = document.getElementById('resetFilamentChart');
|
|
||||||
|
|
||||||
if (refreshBtn) {
|
|
||||||
// Refresh: honour whatever the user has typed in the date inputs
|
|
||||||
refreshBtn.addEventListener('click', function () { fetchFilamentUsageData(true); });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resetBtn) {
|
|
||||||
resetBtn.addEventListener('click', function () {
|
|
||||||
// Reset inputs to "last 24 hours" defaults, then let the backend
|
|
||||||
// decide (fallback if no recent data).
|
|
||||||
const now = new Date();
|
|
||||||
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
|
||||||
const sd = document.getElementById('filamentStartDate');
|
|
||||||
const ed = document.getElementById('filamentEndDate');
|
|
||||||
if (sd) sd.value = yesterday.toISOString().split('T')[0];
|
|
||||||
if (ed) ed.value = now.toISOString().split('T')[0];
|
|
||||||
if (fullDayCheckbox) fullDayCheckbox.checked = true;
|
|
||||||
if (startTimeSelect) startTimeSelect.disabled = true;
|
|
||||||
if (endTimeSelect) endTimeSelect.disabled = true;
|
|
||||||
fetchFilamentUsageData(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Initial load — no dates so backend fallback can fire ───────────────────
|
|
||||||
|
|
||||||
fetchFilamentUsageData(false);
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
/**
|
|
||||||
* filament_form.js — Filament add/edit form interactions.
|
|
||||||
*
|
|
||||||
* Handles:
|
|
||||||
* - Filament type preset → auto-fill Type / Sub Type / Brand
|
|
||||||
* - Transparent checkbox → toggle color picker vs. checkerboard swatch
|
|
||||||
* - Color picker ↔ hex text sync
|
|
||||||
* - Delete confirmation modal
|
|
||||||
*/
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
|
|
||||||
// ── Filament type preset auto-fill ────────────────────────────────────────
|
|
||||||
|
|
||||||
const dataEl = document.getElementById('filament-type-data');
|
|
||||||
const filamentTypeMap = dataEl ? JSON.parse(dataEl.textContent) : {};
|
|
||||||
|
|
||||||
const filamentTypeSelect = document.getElementById('id_filament_type');
|
|
||||||
const typeField = document.getElementById('id_type');
|
|
||||||
const subTypeField = document.getElementById('id_sub_type');
|
|
||||||
const brandField = document.getElementById('id_brand');
|
|
||||||
|
|
||||||
if (filamentTypeSelect) {
|
|
||||||
filamentTypeSelect.addEventListener('change', function () {
|
|
||||||
const mapping = filamentTypeMap[this.value];
|
|
||||||
if (mapping && typeField && subTypeField && brandField) {
|
|
||||||
typeField.value = mapping.type;
|
|
||||||
subTypeField.value = mapping.sub_type;
|
|
||||||
brandField.value = mapping.brand;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Transparent toggle ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const transparentCheckbox = document.getElementById('id_is_transparent');
|
|
||||||
const transparentSwatch = document.getElementById('transparent-swatch');
|
|
||||||
const colorPicker = document.getElementById('id_color_hex_picker');
|
|
||||||
const colorText = document.getElementById('id_color_hex_text');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show checkerboard swatch and disable color inputs when transparent,
|
|
||||||
* restore normal color picker when not transparent.
|
|
||||||
* @param {boolean} isTransparent
|
|
||||||
*/
|
|
||||||
function applyTransparentState(isTransparent) {
|
|
||||||
if (!colorPicker) return;
|
|
||||||
if (isTransparent) {
|
|
||||||
transparentSwatch.style.display = 'block';
|
|
||||||
colorPicker.style.display = 'none';
|
|
||||||
colorPicker.disabled = true;
|
|
||||||
if (colorText) { colorText.disabled = true; colorText.value = ''; }
|
|
||||||
} else {
|
|
||||||
transparentSwatch.style.display = 'none';
|
|
||||||
colorPicker.style.display = '';
|
|
||||||
colorPicker.disabled = false;
|
|
||||||
if (colorText) { colorText.disabled = false; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (transparentCheckbox) {
|
|
||||||
applyTransparentState(transparentCheckbox.checked);
|
|
||||||
transparentCheckbox.addEventListener('change', function () {
|
|
||||||
applyTransparentState(this.checked);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Color picker ↔ hex text sync ──────────────────────────────────────────
|
|
||||||
|
|
||||||
if (colorPicker && colorText) {
|
|
||||||
colorPicker.addEventListener('input', function () {
|
|
||||||
colorText.value = this.value.toUpperCase();
|
|
||||||
});
|
|
||||||
|
|
||||||
colorText.addEventListener('input', function () {
|
|
||||||
const value = this.value.trim();
|
|
||||||
if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
|
|
||||||
colorPicker.value = value;
|
|
||||||
this.classList.remove('is-invalid');
|
|
||||||
} else if (value.length === 7) {
|
|
||||||
this.classList.add('is-invalid');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (colorText.value && /^#[0-9A-Fa-f]{6}$/.test(colorText.value)) {
|
|
||||||
colorPicker.value = colorText.value;
|
|
||||||
} else if (colorPicker.value && !colorText.value) {
|
|
||||||
colorText.value = colorPicker.value.toUpperCase();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Delete confirmation modal ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
const deleteConfirmText = document.getElementById('deleteConfirmText');
|
|
||||||
const confirmDeleteBtn = document.getElementById('confirmDeleteBtn');
|
|
||||||
const deleteForm = document.getElementById('deleteForm');
|
|
||||||
const deleteModal = document.getElementById('deleteModal');
|
|
||||||
|
|
||||||
if (deleteConfirmText && confirmDeleteBtn) {
|
|
||||||
deleteConfirmText.addEventListener('input', function () {
|
|
||||||
const value = this.value.trim();
|
|
||||||
if (value === 'DELETE') {
|
|
||||||
confirmDeleteBtn.disabled = false;
|
|
||||||
this.classList.remove('is-invalid');
|
|
||||||
this.classList.add('is-valid');
|
|
||||||
} else {
|
|
||||||
confirmDeleteBtn.disabled = true;
|
|
||||||
this.classList.remove('is-valid');
|
|
||||||
if (value.length > 0) {
|
|
||||||
this.classList.add('is-invalid');
|
|
||||||
} else {
|
|
||||||
this.classList.remove('is-invalid');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (deleteForm) {
|
|
||||||
deleteForm.addEventListener('submit', function (e) {
|
|
||||||
if (confirmDeleteBtn.disabled) {
|
|
||||||
e.preventDefault();
|
|
||||||
alert('Please type DELETE to confirm deletion');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deleteModal) {
|
|
||||||
deleteModal.addEventListener('hidden.bs.modal', function () {
|
|
||||||
deleteConfirmText.value = '';
|
|
||||||
confirmDeleteBtn.disabled = true;
|
|
||||||
deleteConfirmText.classList.remove('is-valid', 'is-invalid');
|
|
||||||
});
|
|
||||||
|
|
||||||
deleteModal.addEventListener('shown.bs.modal', function () {
|
|
||||||
deleteConfirmText.focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Delete button modal opener (backup) ───────────────────────────────────
|
|
||||||
|
|
||||||
const deleteBtn = document.getElementById('deleteBtn');
|
|
||||||
if (deleteBtn && deleteModal) {
|
|
||||||
deleteBtn.addEventListener('click', function () {
|
|
||||||
if (!deleteModal.classList.contains('show')) {
|
|
||||||
if (typeof bootstrap !== 'undefined') {
|
|
||||||
bootstrap.Modal.getOrCreateInstance(deleteModal).show();
|
|
||||||
} else if (typeof coreui !== 'undefined' && coreui.Modal) {
|
|
||||||
coreui.Modal.getOrCreateInstance(deleteModal).show();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// 3D Printer Charts Initialization and Management
|
// 3D Printer Charts Initialization and Management
|
||||||
// Chart.js implementation for printer metrics visualization
|
// Chart.js implementation for printer metrics visualization
|
||||||
|
|
||||||
let nozzleTempChart, nozzleTempLeftChart, bedTempChart, printProgressChart, fanSpeedsChart;
|
let nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart;
|
||||||
let wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart;
|
let wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart;
|
||||||
|
|
||||||
function showNoDataMessage(canvasId) {
|
function showNoDataMessage(canvasId) {
|
||||||
@@ -55,7 +55,7 @@ function initPrinterCharts(printerData, apiUrl) {
|
|||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
pointRadius: 0,
|
pointRadius: 0,
|
||||||
pointHoverRadius: 3,
|
pointHoverRadius: 5,
|
||||||
spanGaps: true
|
spanGaps: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -67,7 +67,7 @@ function initPrinterCharts(printerData, apiUrl) {
|
|||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
pointRadius: 0,
|
pointRadius: 0,
|
||||||
pointHoverRadius: 3,
|
pointHoverRadius: 5,
|
||||||
spanGaps: true
|
spanGaps: true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -75,50 +75,6 @@ function initPrinterCharts(printerData, apiUrl) {
|
|||||||
options: getTemperatureChartOptions(tickColor, gridColor, '°C')
|
options: getTemperatureChartOptions(tickColor, gridColor, '°C')
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize Left Nozzle Temperature Chart (H2C-class dual-nozzle).
|
|
||||||
// Mounted only when the canvas exists AND the API returned non-null
|
|
||||||
// left-side samples — single-nozzle printers leave the column NULL.
|
|
||||||
const nozzleLeftCanvas = document.getElementById('nozzleTempLeftChart');
|
|
||||||
const hasLeftData = Array.isArray(printerData.nozzle_temp_left)
|
|
||||||
&& printerData.nozzle_temp_left.some(v => v !== null && v !== undefined);
|
|
||||||
if (nozzleLeftCanvas && hasLeftData) {
|
|
||||||
const nozzleLeftCtx = nozzleLeftCanvas.getContext('2d');
|
|
||||||
nozzleTempLeftChart = new Chart(nozzleLeftCtx, {
|
|
||||||
type: 'line',
|
|
||||||
data: {
|
|
||||||
labels: printerData.timestamps,
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
label: 'Actual Temp (Left)',
|
|
||||||
data: printerData.nozzle_temp_left,
|
|
||||||
borderColor: 'rgb(54, 162, 235)',
|
|
||||||
backgroundColor: 'rgba(54, 162, 235, 0.1)',
|
|
||||||
tension: 0.3,
|
|
||||||
borderWidth: 2,
|
|
||||||
pointRadius: 0,
|
|
||||||
pointHoverRadius: 3,
|
|
||||||
spanGaps: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Target Temp (Left)',
|
|
||||||
data: printerData.nozzle_target_temp_left,
|
|
||||||
borderColor: 'rgb(153, 102, 255)',
|
|
||||||
backgroundColor: 'rgba(153, 102, 255, 0.05)',
|
|
||||||
borderDash: [5, 5],
|
|
||||||
tension: 0.3,
|
|
||||||
borderWidth: 2,
|
|
||||||
pointRadius: 0,
|
|
||||||
pointHoverRadius: 3,
|
|
||||||
spanGaps: true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
options: getTemperatureChartOptions(tickColor, gridColor, '°C')
|
|
||||||
});
|
|
||||||
} else if (nozzleLeftCanvas) {
|
|
||||||
showNoDataMessage('nozzleTempLeftChart');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize Bed Temperature Chart
|
// Initialize Bed Temperature Chart
|
||||||
const bedCtx = document.getElementById('bedTempChart').getContext('2d');
|
const bedCtx = document.getElementById('bedTempChart').getContext('2d');
|
||||||
bedTempChart = new Chart(bedCtx, {
|
bedTempChart = new Chart(bedCtx, {
|
||||||
@@ -134,7 +90,7 @@ function initPrinterCharts(printerData, apiUrl) {
|
|||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
pointRadius: 0,
|
pointRadius: 0,
|
||||||
pointHoverRadius: 3,
|
pointHoverRadius: 5,
|
||||||
spanGaps: true
|
spanGaps: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -146,7 +102,7 @@ function initPrinterCharts(printerData, apiUrl) {
|
|||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
pointRadius: 0,
|
pointRadius: 0,
|
||||||
pointHoverRadius: 3,
|
pointHoverRadius: 5,
|
||||||
spanGaps: true
|
spanGaps: true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -169,7 +125,7 @@ function initPrinterCharts(printerData, apiUrl) {
|
|||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
pointRadius: 0,
|
pointRadius: 0,
|
||||||
pointHoverRadius: 3,
|
pointHoverRadius: 5,
|
||||||
fill: true
|
fill: true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -192,7 +148,7 @@ function initPrinterCharts(printerData, apiUrl) {
|
|||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
pointRadius: 0,
|
pointRadius: 0,
|
||||||
pointHoverRadius: 3,
|
pointHoverRadius: 5,
|
||||||
spanGaps: true
|
spanGaps: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -203,7 +159,7 @@ function initPrinterCharts(printerData, apiUrl) {
|
|||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
pointRadius: 0,
|
pointRadius: 0,
|
||||||
pointHoverRadius: 3,
|
pointHoverRadius: 5,
|
||||||
spanGaps: true
|
spanGaps: true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -226,7 +182,7 @@ function initPrinterCharts(printerData, apiUrl) {
|
|||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
pointRadius: 0,
|
pointRadius: 0,
|
||||||
pointHoverRadius: 3,
|
pointHoverRadius: 5,
|
||||||
spanGaps: true
|
spanGaps: true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -290,7 +246,7 @@ function initPrinterCharts(printerData, apiUrl) {
|
|||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
pointRadius: 0,
|
pointRadius: 0,
|
||||||
pointHoverRadius: 3,
|
pointHoverRadius: 5,
|
||||||
yAxisID: 'y',
|
yAxisID: 'y',
|
||||||
spanGaps: true
|
spanGaps: true
|
||||||
},
|
},
|
||||||
@@ -302,7 +258,7 @@ function initPrinterCharts(printerData, apiUrl) {
|
|||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
pointRadius: 0,
|
pointRadius: 0,
|
||||||
pointHoverRadius: 3,
|
pointHoverRadius: 5,
|
||||||
yAxisID: 'y1',
|
yAxisID: 'y1',
|
||||||
spanGaps: true
|
spanGaps: true
|
||||||
}
|
}
|
||||||
@@ -386,7 +342,7 @@ function initPrinterCharts(printerData, apiUrl) {
|
|||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
pointRadius: 0,
|
pointRadius: 0,
|
||||||
pointHoverRadius: 3,
|
pointHoverRadius: 5,
|
||||||
fill: true
|
fill: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -398,7 +354,7 @@ function initPrinterCharts(printerData, apiUrl) {
|
|||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
pointRadius: 0,
|
pointRadius: 0,
|
||||||
pointHoverRadius: 3,
|
pointHoverRadius: 5,
|
||||||
spanGaps: true
|
spanGaps: true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -496,11 +452,6 @@ function initPrinterCharts(printerData, apiUrl) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add date separator markers when data spans multiple days
|
|
||||||
if (printerData.dates && printerData.dates.length > 0) {
|
|
||||||
applyDateSeparatorsToAllPrinterCharts(printerData.timestamps, printerData.dates);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up theme observer for dynamic theme switching
|
// Set up theme observer for dynamic theme switching
|
||||||
setupThemeObserver();
|
setupThemeObserver();
|
||||||
}
|
}
|
||||||
@@ -672,7 +623,7 @@ function createFilamentDatasets(filamentTimeline, timestamps) {
|
|||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
pointRadius: 0,
|
pointRadius: 0,
|
||||||
pointHoverRadius: 3,
|
pointHoverRadius: 5,
|
||||||
spanGaps: false // Don't connect across null values (filament changes)
|
spanGaps: false // Don't connect across null values (filament changes)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -690,20 +641,8 @@ function hexToRgba(hex, alpha) {
|
|||||||
function applyFilamentColors() {
|
function applyFilamentColors() {
|
||||||
// Apply colors to filament cards
|
// Apply colors to filament cards
|
||||||
document.querySelectorAll('.filament-card').forEach(card => {
|
document.querySelectorAll('.filament-card').forEach(card => {
|
||||||
const isTransparent = card.getAttribute('data-filament-transparent') === 'true';
|
|
||||||
const colorHex = card.getAttribute('data-filament-color');
|
const colorHex = card.getAttribute('data-filament-color');
|
||||||
|
if (colorHex) {
|
||||||
if (isTransparent) {
|
|
||||||
// Checkerboard left border and subtle background for clear filaments
|
|
||||||
card.style.borderLeft = '4px solid #aaa';
|
|
||||||
card.style.background = 'repeating-conic-gradient(rgba(180,180,180,0.15) 0% 25%, transparent 0% 50%) 0 0/10px 10px';
|
|
||||||
|
|
||||||
const badge = card.querySelector('.filament-badge');
|
|
||||||
if (badge) {
|
|
||||||
badge.style.backgroundColor = '#aaa';
|
|
||||||
badge.style.color = '#fff';
|
|
||||||
}
|
|
||||||
} else if (colorHex) {
|
|
||||||
const color = '#' + colorHex;
|
const color = '#' + colorHex;
|
||||||
|
|
||||||
// Set card background with gradient
|
// Set card background with gradient
|
||||||
@@ -746,7 +685,7 @@ function updateChartTheme() {
|
|||||||
|
|
||||||
// Update all charts
|
// Update all charts
|
||||||
const charts = [
|
const charts = [
|
||||||
nozzleTempChart, nozzleTempLeftChart, bedTempChart, printProgressChart, fanSpeedsChart,
|
nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart,
|
||||||
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
|
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -792,79 +731,3 @@ function setupThemeObserver() {
|
|||||||
attributeFilter: ['data-coreui-theme']
|
attributeFilter: ['data-coreui-theme']
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build date-separator annotations for multi-day charts.
|
|
||||||
* Detects where consecutive dates differ and returns a vertical dotted line
|
|
||||||
* annotation at each boundary index, labelled with the new date.
|
|
||||||
*
|
|
||||||
* @param {string[]} timestamps - HH:MM display labels (one per data point)
|
|
||||||
* @param {string[]} dates - YYYY-MM-DD dates (same length as timestamps)
|
|
||||||
* @returns {Object} chartjs-plugin-annotation annotations keyed as "dateSep_N"
|
|
||||||
*/
|
|
||||||
function buildDateSeparatorAnnotations(timestamps, dates) {
|
|
||||||
const annotations = {};
|
|
||||||
if (!dates || dates.length < 2) return annotations;
|
|
||||||
|
|
||||||
let count = 0;
|
|
||||||
for (let i = 1; i < dates.length; i++) {
|
|
||||||
if (dates[i] !== dates[i - 1]) {
|
|
||||||
// Format date as "Feb 25" for a compact label
|
|
||||||
const d = new Date(dates[i] + 'T00:00:00');
|
|
||||||
const label = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
||||||
|
|
||||||
annotations['dateSep_' + count] = {
|
|
||||||
type: 'line',
|
|
||||||
scaleID: 'x',
|
|
||||||
value: i,
|
|
||||||
borderColor: 'rgba(128, 128, 128, 0.45)',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderDash: [4, 4],
|
|
||||||
drawTime: 'beforeDatasetsDraw',
|
|
||||||
label: {
|
|
||||||
display: true,
|
|
||||||
content: label,
|
|
||||||
position: 'end',
|
|
||||||
backgroundColor: 'rgba(100, 100, 100, 0.65)',
|
|
||||||
color: '#fff',
|
|
||||||
font: { size: 9 },
|
|
||||||
padding: { x: 4, y: 2 }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return annotations;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply date-separator annotations to all printer charts.
|
|
||||||
* Preserves any existing "marker_*" (project marker) annotations.
|
|
||||||
*
|
|
||||||
* @param {string[]} timestamps
|
|
||||||
* @param {string[]} dates
|
|
||||||
*/
|
|
||||||
function applyDateSeparatorsToAllPrinterCharts(timestamps, dates) {
|
|
||||||
const sepAnnotations = buildDateSeparatorAnnotations(timestamps, dates);
|
|
||||||
|
|
||||||
const charts = [
|
|
||||||
nozzleTempChart, nozzleTempLeftChart, bedTempChart, printProgressChart, fanSpeedsChart,
|
|
||||||
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
|
|
||||||
];
|
|
||||||
|
|
||||||
charts.forEach(chart => {
|
|
||||||
if (!chart) return;
|
|
||||||
if (!chart.options.plugins.annotation) {
|
|
||||||
chart.options.plugins.annotation = { annotations: {} };
|
|
||||||
}
|
|
||||||
const existing = chart.options.plugins.annotation.annotations;
|
|
||||||
|
|
||||||
// Remove any old dateSep_* entries then re-add updated ones
|
|
||||||
Object.keys(existing).forEach(key => {
|
|
||||||
if (key.startsWith('dateSep_')) delete existing[key];
|
|
||||||
});
|
|
||||||
Object.assign(existing, sepAnnotations);
|
|
||||||
|
|
||||||
chart.update('none');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -77,12 +77,11 @@ function populateTimeDropdowns(startSelect, endSelect) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
times.forEach(time => {
|
times.forEach(time => {
|
||||||
startSelect.add(new Option(time, time));
|
const option1 = new Option(time, time);
|
||||||
endSelect.add(new Option(time, time));
|
const option2 = new Option(time, time);
|
||||||
|
startSelect.add(option1);
|
||||||
|
endSelect.add(option2);
|
||||||
});
|
});
|
||||||
|
|
||||||
// End-time gets one extra option so the last minute of the day is reachable
|
|
||||||
endSelect.add(new Option('23:59', '23:59'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -200,13 +199,6 @@ function updateAllPrinterCharts(data) {
|
|||||||
{ data: data.nozzle_target_temp, datasetIndex: 1 }
|
{ data: data.nozzle_target_temp, datasetIndex: 1 }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (typeof nozzleTempLeftChart !== 'undefined' && nozzleTempLeftChart) {
|
|
||||||
updateChartData(nozzleTempLeftChart, data.timestamps, [
|
|
||||||
{ data: data.nozzle_temp_left || [], datasetIndex: 0 },
|
|
||||||
{ data: data.nozzle_target_temp_left || [], datasetIndex: 1 }
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateChartData(bedTempChart, data.timestamps, [
|
updateChartData(bedTempChart, data.timestamps, [
|
||||||
{ data: data.bed_temp, datasetIndex: 0 },
|
{ data: data.bed_temp, datasetIndex: 0 },
|
||||||
{ data: data.bed_target_temp, datasetIndex: 1 }
|
{ data: data.bed_target_temp, datasetIndex: 1 }
|
||||||
@@ -243,11 +235,6 @@ function updateAllPrinterCharts(data) {
|
|||||||
filamentTimelineChart.update();
|
filamentTimelineChart.update();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply date separator markers (multi-day views)
|
|
||||||
if (data.dates && data.dates.length > 0) {
|
|
||||||
applyDateSeparatorsToAllPrinterCharts(data.timestamps, data.dates);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add project markers to all charts
|
// Add project markers to all charts
|
||||||
if (data.project_markers) {
|
if (data.project_markers) {
|
||||||
addProjectMarkersToCharts(data.project_markers, data.timestamps);
|
addProjectMarkersToCharts(data.project_markers, data.timestamps);
|
||||||
@@ -276,7 +263,7 @@ function addProjectMarkersToCharts(markers, timestamps) {
|
|||||||
console.log('Adding project markers:', markers);
|
console.log('Adding project markers:', markers);
|
||||||
|
|
||||||
const charts = [
|
const charts = [
|
||||||
nozzleTempChart, nozzleTempLeftChart, bedTempChart, printProgressChart, fanSpeedsChart,
|
nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart,
|
||||||
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
|
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -288,11 +275,8 @@ function addProjectMarkersToCharts(markers, timestamps) {
|
|||||||
chart.options.plugins.annotation = { annotations: {} };
|
chart.options.plugins.annotation = { annotations: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear existing project markers but preserve date-separator annotations
|
// Clear existing project markers
|
||||||
const allAnnotations = chart.options.plugins.annotation.annotations;
|
chart.options.plugins.annotation.annotations = {};
|
||||||
Object.keys(allAnnotations).forEach(key => {
|
|
||||||
if (!key.startsWith('dateSep_')) delete allAnnotations[key];
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track active tooltip
|
// Track active tooltip
|
||||||
let activeMarkerTooltip = null;
|
let activeMarkerTooltip = null;
|
||||||
@@ -407,7 +391,7 @@ function resetPrinterControls() {
|
|||||||
|
|
||||||
// Clear annotations and reload with original data
|
// Clear annotations and reload with original data
|
||||||
const charts = [
|
const charts = [
|
||||||
nozzleTempChart, nozzleTempLeftChart, bedTempChart, printProgressChart, fanSpeedsChart,
|
nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart,
|
||||||
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
|
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -54,19 +54,11 @@
|
|||||||
{% for color in colors %}
|
{% for color in colors %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="align-middle">
|
<td class="align-middle">
|
||||||
{% if color.is_transparent %}
|
|
||||||
<div style="width: 50px; height: 50px; border-radius: 4px; border: 2px solid #ddd; background: repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 0 0/10px 10px;" title="Clear / Transparent"></div>
|
|
||||||
{% else %}
|
|
||||||
<div style="width: 50px; height: 50px; background-color: {{ color.get_hex_color }}; border-radius: 4px; border: 2px solid #ddd;"></div>
|
<div style="width: 50px; height: 50px; background-color: {{ color.get_hex_color }}; border-radius: 4px; border: 2px solid #ddd;"></div>
|
||||||
{% endif %}
|
|
||||||
</td>
|
</td>
|
||||||
<td class="align-middle"><strong>{{ color.color_name }}</strong></td>
|
<td class="align-middle"><strong>{{ color.color_name }}</strong></td>
|
||||||
<td class="align-middle">
|
<td class="align-middle">
|
||||||
{% if color.is_transparent %}
|
|
||||||
<span class="text-muted fst-italic">Clear / Transparent</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="font-monospace">{{ color.get_hex_color }}</span>
|
<span class="font-monospace">{{ color.get_hex_color }}</span>
|
||||||
{% endif %}
|
|
||||||
</td>
|
</td>
|
||||||
<td class="align-middle">
|
<td class="align-middle">
|
||||||
<span class="badge bg-secondary">{{ color.filament_type }}</span>
|
<span class="badge bg-secondary">{{ color.filament_type }}</span>
|
||||||
|
|||||||
@@ -27,14 +27,10 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h6>Color</h6>
|
<h6>Color</h6>
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
{% if filament.is_transparent %}
|
|
||||||
<div style="width: 50px; height: 50px; border-radius: 8px; margin-right: 15px; border: 2px solid #ddd; background: repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 0 0/10px 10px;" title="Clear / Transparent"></div>
|
|
||||||
{% else %}
|
|
||||||
<div style="width: 50px; height: 50px; background-color: {{ filament.color_hex|default:'#999' }}; border-radius: 8px; margin-right: 15px; border: 2px solid #ddd;"></div>
|
<div style="width: 50px; height: 50px; background-color: {{ filament.color_hex|default:'#999' }}; border-radius: 8px; margin-right: 15px; border: 2px solid #ddd;"></div>
|
||||||
{% endif %}
|
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ filament.color }}</strong><br>
|
<strong>{{ filament.color }}</strong><br>
|
||||||
<small class="text-muted">{% if filament.is_transparent %}Clear / Transparent{% else %}{{ filament.color_hex }}{% endif %}</small>
|
<small class="text-muted">{{ filament.color_hex }}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,7 +84,7 @@
|
|||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
|
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
<div>
|
<div>
|
||||||
<strong>Filament Usage History</strong>
|
<strong>Chart Filters</strong>
|
||||||
<span class="text-muted" id="filamentDateRange">(Last 24 Hours)</span>
|
<span class="text-muted" id="filamentDateRange">(Last 24 Hours)</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
@@ -115,11 +111,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Buttons -->
|
<!-- Buttons -->
|
||||||
<button type="button" class="btn btn-primary btn-sm" id="refreshFilamentChart">
|
<button type="button" class="btn btn-primary btn-sm" id="refreshFilamentChart">
|
||||||
<svg class="icon"><use href="{% static 'bambu_run/vendors/coreui-icons-free.svg' %}#cil-reload"></use></svg>
|
<svg class="icon"><use xlink:href="https://cdn.jsdelivr.net/npm/@coreui/icons@3.0.1/sprites/free.svg#cil-reload"></use></svg>
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-secondary btn-sm" id="resetFilamentChart">
|
<button type="button" class="btn btn-secondary btn-sm" id="resetFilamentChart">
|
||||||
<svg class="icon"><use href="{% static 'bambu_run/vendors/coreui-icons-free.svg' %}#cil-action-undo"></use></svg>
|
<svg class="icon"><use xlink:href="https://cdn.jsdelivr.net/npm/@coreui/icons@3.0.1/sprites/free.svg#cil-action-undo"></use></svg>
|
||||||
Reset
|
Reset
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -154,7 +150,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for usage in print_usages %}
|
{% for usage in print_usages %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ usage.print_job.display_name }}</td>
|
<td>{{ usage.print_job.project_name }}</td>
|
||||||
<td>{{ usage.print_job.start_time|date:"Y-m-d H:i" }}</td>
|
<td>{{ usage.print_job.start_time|date:"Y-m-d H:i" }}</td>
|
||||||
<td>Tray {{ usage.tray_id }}</td>
|
<td>Tray {{ usage.tray_id }}</td>
|
||||||
<td>{{ usage.consumed_percent|default:"?" }}% ({{ usage.consumed_grams|default:"?" }}g)</td>
|
<td>{{ usage.consumed_percent|default:"?" }}% ({{ usage.consumed_grams|default:"?" }}g)</td>
|
||||||
@@ -207,39 +203,126 @@
|
|||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3.0.1"></script>
|
|
||||||
{% if not is_basic_user %}
|
{% if not is_basic_user %}
|
||||||
{# Inject Django-specific values that the static JS file cannot know #}
|
|
||||||
<script>
|
<script>
|
||||||
const FILAMENT_USAGE_API_URL = "{% url 'bambu_run:filament_usage_api' filament.pk %}";
|
const filamentId = {{ filament.pk }};
|
||||||
|
let usageChart = null;
|
||||||
|
|
||||||
|
// Populate time selects
|
||||||
|
const startTimeSelect = document.getElementById('filamentStartTime');
|
||||||
|
const endTimeSelect = document.getElementById('filamentEndTime');
|
||||||
|
for (let h = 0; h < 24; h++) {
|
||||||
|
for (let m = 0; m < 60; m += 30) {
|
||||||
|
const timeStr = `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`;
|
||||||
|
startTimeSelect.add(new Option(timeStr, timeStr));
|
||||||
|
endTimeSelect.add(new Option(timeStr, timeStr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
startTimeSelect.value = '00:00';
|
||||||
|
endTimeSelect.value = '23:30';
|
||||||
|
|
||||||
|
// Initialize date inputs to last 24 hours
|
||||||
|
const now = new Date();
|
||||||
|
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||||
|
document.getElementById('filamentStartDate').value = yesterday.toISOString().split('T')[0];
|
||||||
|
document.getElementById('filamentEndDate').value = now.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Full day checkbox handler
|
||||||
|
document.getElementById('filamentFullDayCheckbox').addEventListener('change', function() {
|
||||||
|
const isFullDay = this.checked;
|
||||||
|
startTimeSelect.disabled = isFullDay;
|
||||||
|
endTimeSelect.disabled = isFullDay;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch and render chart
|
||||||
|
async function fetchFilamentUsageData() {
|
||||||
|
const startDate = document.getElementById('filamentStartDate').value;
|
||||||
|
const endDate = document.getElementById('filamentEndDate').value;
|
||||||
|
const isFullDay = document.getElementById('filamentFullDayCheckbox').checked;
|
||||||
|
const startTime = isFullDay ? '00:00' : startTimeSelect.value;
|
||||||
|
const endTime = isFullDay ? '23:59' : endTimeSelect.value;
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (startDate) params.append('start_date', startDate);
|
||||||
|
if (endDate) params.append('end_date', endDate);
|
||||||
|
if (startTime) params.append('start_time', startTime);
|
||||||
|
if (endTime) params.append('end_time', endTime);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`{% url 'bambu_run:filament_usage_api' filament.pk %}?${params.toString()}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Update date range display
|
||||||
|
const dateRangeSpan = document.getElementById('filamentDateRange');
|
||||||
|
if (startDate && endDate) {
|
||||||
|
dateRangeSpan.textContent = `(${startDate} to ${endDate})`;
|
||||||
|
} else {
|
||||||
|
dateRangeSpan.textContent = '(Last 24 Hours)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update chart
|
||||||
|
if (usageChart) {
|
||||||
|
usageChart.data.labels = data.timestamps;
|
||||||
|
usageChart.data.datasets[0].data = data.remaining;
|
||||||
|
usageChart.update();
|
||||||
|
} else {
|
||||||
|
const ctx = document.getElementById('usageChart').getContext('2d');
|
||||||
|
usageChart = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: data.timestamps,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Remaining %',
|
||||||
|
data: data.remaining,
|
||||||
|
borderColor: 'rgb(75, 192, 192)',
|
||||||
|
backgroundColor: 'rgba(75, 192, 192, 0.1)',
|
||||||
|
tension: 0.3,
|
||||||
|
fill: true
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
max: 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching filament usage data:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
document.getElementById('refreshFilamentChart').addEventListener('click', fetchFilamentUsageData);
|
||||||
|
document.getElementById('resetFilamentChart').addEventListener('click', function() {
|
||||||
|
const now = new Date();
|
||||||
|
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||||
|
document.getElementById('filamentStartDate').value = yesterday.toISOString().split('T')[0];
|
||||||
|
document.getElementById('filamentEndDate').value = now.toISOString().split('T')[0];
|
||||||
|
document.getElementById('filamentFullDayCheckbox').checked = true;
|
||||||
|
startTimeSelect.disabled = true;
|
||||||
|
endTimeSelect.disabled = true;
|
||||||
|
fetchFilamentUsageData();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
fetchFilamentUsageData();
|
||||||
</script>
|
</script>
|
||||||
<script src="{% static 'bambu_run/js/filament_detail.js' %}"></script>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
// Basic user: render static chart from server-provided data if available
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const ctx = document.getElementById('usageChart');
|
const ctx = document.getElementById('usageChart');
|
||||||
if (ctx) {
|
if (ctx) {
|
||||||
new Chart(ctx.getContext('2d'), {
|
new Chart(ctx.getContext('2d'), {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: {
|
data: { labels: [], datasets: [{ label: 'Remaining %', data: [], borderColor: 'rgb(75, 192, 192)', backgroundColor: 'rgba(75, 192, 192, 0.1)', tension: 0.3, fill: true }] },
|
||||||
labels: [],
|
options: { responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true, max: 100 } } }
|
||||||
datasets: [{
|
|
||||||
label: 'Remaining %',
|
|
||||||
data: [],
|
|
||||||
borderColor: 'rgb(75, 192, 192)',
|
|
||||||
backgroundColor: 'rgba(75, 192, 192, 0.1)',
|
|
||||||
tension: 0.3,
|
|
||||||
fill: true,
|
|
||||||
pointRadius: 0,
|
|
||||||
pointHoverRadius: 3,
|
|
||||||
borderWidth: 2
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
scales: { y: { beginAtZero: true, max: 100 } }
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,14 +43,6 @@
|
|||||||
<hr>
|
<hr>
|
||||||
<h5>Specifications</h5>
|
<h5>Specifications</h5>
|
||||||
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-md-12">
|
|
||||||
<label class="form-label">Filament Type Preset</label>
|
|
||||||
{{ form.filament_type }}
|
|
||||||
<small class="form-text text-muted">Selecting a preset auto-fills Type, Sub Type, and Brand below.</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<label class="form-label">Type *</label>
|
<label class="form-label">Type *</label>
|
||||||
@@ -70,19 +62,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row mb-3 align-items-end">
|
<div class="row mb-3">
|
||||||
<div class="col-md-2">
|
<div class="col-md-3">
|
||||||
<label class="form-label">Color Picker</label>
|
<label class="form-label">Color Picker</label>
|
||||||
<div id="transparent-swatch" style="display:none; width:100%; height:38px; border-radius:4px; border:1px solid #ddd; background: repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 0 0/10px 10px;" title="Clear / Transparent"></div>
|
|
||||||
{{ form.color_hex }}
|
{{ form.color_hex }}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-3">
|
||||||
<div class="form-check mt-4">
|
|
||||||
{{ form.is_transparent }}
|
|
||||||
<label class="form-check-label" for="id_is_transparent">Transparent / Clear</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
<label class="form-label">{{ form.color_hex_text.label }}</label>
|
<label class="form-label">{{ form.color_hex_text.label }}</label>
|
||||||
{{ form.color_hex_text }}
|
{{ form.color_hex_text }}
|
||||||
<small class="form-text text-muted">e.g. #0A2CA5</small>
|
<small class="form-text text-muted">e.g. #0A2CA5</small>
|
||||||
@@ -224,7 +209,95 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
{# Server-side data consumed by filament_form.js #}
|
<script>
|
||||||
<script type="application/json" id="filament-type-data">{{ filament_type_map|safe }}</script>
|
// Sync color picker and text input
|
||||||
<script src="{% static 'bambu_run/js/filament_form.js' %}"></script>
|
const colorPicker = document.getElementById('id_color_hex_picker');
|
||||||
|
const colorText = document.getElementById('id_color_hex_text');
|
||||||
|
|
||||||
|
if (colorPicker && colorText) {
|
||||||
|
colorPicker.addEventListener('input', function() {
|
||||||
|
colorText.value = this.value.toUpperCase();
|
||||||
|
});
|
||||||
|
|
||||||
|
colorText.addEventListener('input', function() {
|
||||||
|
const value = this.value.trim();
|
||||||
|
if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
|
||||||
|
colorPicker.value = value;
|
||||||
|
this.classList.remove('is-invalid');
|
||||||
|
} else if (value.length === 7) {
|
||||||
|
this.classList.add('is-invalid');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (colorText.value && /^#[0-9A-Fa-f]{6}$/.test(colorText.value)) {
|
||||||
|
colorPicker.value = colorText.value;
|
||||||
|
} else if (colorPicker.value && !colorText.value) {
|
||||||
|
colorText.value = colorPicker.value.toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete confirmation logic
|
||||||
|
const deleteConfirmText = document.getElementById('deleteConfirmText');
|
||||||
|
const confirmDeleteBtn = document.getElementById('confirmDeleteBtn');
|
||||||
|
const deleteForm = document.getElementById('deleteForm');
|
||||||
|
const deleteModal = document.getElementById('deleteModal');
|
||||||
|
|
||||||
|
if (deleteConfirmText && confirmDeleteBtn) {
|
||||||
|
deleteConfirmText.addEventListener('input', function() {
|
||||||
|
const value = this.value.trim();
|
||||||
|
if (value === 'DELETE') {
|
||||||
|
confirmDeleteBtn.disabled = false;
|
||||||
|
this.classList.remove('is-invalid');
|
||||||
|
this.classList.add('is-valid');
|
||||||
|
} else {
|
||||||
|
confirmDeleteBtn.disabled = true;
|
||||||
|
this.classList.remove('is-valid');
|
||||||
|
if (value.length > 0) {
|
||||||
|
this.classList.add('is-invalid');
|
||||||
|
} else {
|
||||||
|
this.classList.remove('is-invalid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (deleteForm) {
|
||||||
|
deleteForm.addEventListener('submit', function(e) {
|
||||||
|
if (confirmDeleteBtn.disabled) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert('Please type DELETE to confirm deletion');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteModal) {
|
||||||
|
deleteModal.addEventListener('hidden.bs.modal', function() {
|
||||||
|
deleteConfirmText.value = '';
|
||||||
|
confirmDeleteBtn.disabled = true;
|
||||||
|
deleteConfirmText.classList.remove('is-valid', 'is-invalid');
|
||||||
|
});
|
||||||
|
|
||||||
|
deleteModal.addEventListener('shown.bs.modal', function() {
|
||||||
|
deleteConfirmText.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup modal opener
|
||||||
|
const deleteBtn = document.getElementById('deleteBtn');
|
||||||
|
if (deleteBtn && deleteModal) {
|
||||||
|
deleteBtn.addEventListener('click', function() {
|
||||||
|
if (!deleteModal.classList.contains('show')) {
|
||||||
|
if (typeof bootstrap !== 'undefined') {
|
||||||
|
const modalInstance = bootstrap.Modal.getOrCreateInstance(deleteModal);
|
||||||
|
modalInstance.show();
|
||||||
|
} else if (typeof coreui !== 'undefined' && coreui.Modal) {
|
||||||
|
const modalInstance = coreui.Modal.getOrCreateInstance(deleteModal);
|
||||||
|
modalInstance.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -70,22 +70,14 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-3">
|
||||||
<select name="loaded" class="form-select">
|
<select name="loaded" class="form-select">
|
||||||
<option value="">All Spools</option>
|
<option value="">All Spools</option>
|
||||||
<option value="yes" {% if request.GET.loaded == 'yes' %}selected{% endif %}>Loaded in AMS</option>
|
<option value="yes" {% if request.GET.loaded == 'yes' %}selected{% endif %}>Loaded in AMS</option>
|
||||||
<option value="no" {% if request.GET.loaded == 'no' %}selected{% endif %}>Not Loaded</option>
|
<option value="no" {% if request.GET.loaded == 'no' %}selected{% endif %}>Not Loaded</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-3">
|
||||||
<select name="ams_type" class="form-select">
|
|
||||||
<option value="">All AMS Types</option>
|
|
||||||
{% for at in ams_type_choices %}
|
|
||||||
<option value="{{ at }}" {% if request.GET.ams_type == at %}selected{% endif %}>{{ at }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
<button type="submit" class="btn btn-secondary">Filter</button>
|
<button type="submit" class="btn btn-secondary">Filter</button>
|
||||||
<a href="{% url 'bambu_run:filament_list' %}" class="btn btn-outline-secondary">Reset</a>
|
<a href="{% url 'bambu_run:filament_list' %}" class="btn btn-outline-secondary">Reset</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,11 +122,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="align-middle">
|
<td class="align-middle">
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
{% if filament.is_transparent %}
|
|
||||||
<div style="width: 30px; height: 30px; border-radius: 4px; margin-right: 10px; border: 1px solid #ddd; background: repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 0 0/10px 10px;" title="Clear / Transparent"></div>
|
|
||||||
{% else %}
|
|
||||||
<div style="width: 30px; height: 30px; background-color: {{ filament.color_hex|default:'#999' }}; border-radius: 4px; margin-right: 10px; border: 1px solid #ddd;"></div>
|
<div style="width: 30px; height: 30px; background-color: {{ filament.color_hex|default:'#999' }}; border-radius: 4px; margin-right: 10px; border: 1px solid #ddd;"></div>
|
||||||
{% endif %}
|
|
||||||
{{ filament.color }}
|
{{ filament.color }}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -157,11 +145,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="align-middle">
|
<td class="align-middle">
|
||||||
{% if filament.is_loaded_in_ams %}
|
{% if filament.is_loaded_in_ams %}
|
||||||
<span class="badge bg-success">
|
<span class="badge bg-success">AMS Tray {{ filament.current_tray_id }}</span>
|
||||||
{% if filament.ams_type %}{{ filament.ams_type }}{% else %}AMS{% endif %}
|
|
||||||
{% if filament.ams_unit_id is not None %}#{{ filament.ams_unit_id }}{% endif %}
|
|
||||||
· Tray {{ filament.current_tray_id }}
|
|
||||||
</span>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-secondary">Storage</span>
|
<span class="badge bg-secondary">Storage</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -14,20 +14,6 @@
|
|||||||
Real-time monitoring for {{ device_name }}
|
Real-time monitoring for {{ device_name }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% if show_printer_switcher %}
|
|
||||||
<div class="col-auto d-flex align-items-center gap-2">
|
|
||||||
<label for="printerSwitcher" class="form-label mb-0 text-nowrap">Device:</label>
|
|
||||||
<select id="printerSwitcher" class="form-select" aria-label="Select printer"
|
|
||||||
onchange="if (this.value) { window.location.href = this.value; }">
|
|
||||||
{% for p in all_printers %}
|
|
||||||
<option value="{% url 'bambu_run:printer_dashboard' pk=p.pk %}"
|
|
||||||
{% if printer_device.pk == p.pk %}selected{% endif %}>
|
|
||||||
{{ p.name }}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
@@ -36,41 +22,7 @@
|
|||||||
|
|
||||||
<!-- Summary Cards Row -->
|
<!-- Summary Cards Row -->
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
{% if stats.is_dual_nozzle %}
|
<!-- Nozzle Temperature Card -->
|
||||||
<!-- Right Nozzle (dual-nozzle printers, e.g. H2C) -->
|
|
||||||
<div class="col-12 col-md-6 col-lg-3">
|
|
||||||
<div class="card infra-card-warning">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
|
||||||
<div>
|
|
||||||
<div class="stat-label">Right Nozzle</div>
|
|
||||||
<div class="stat-value">{{ stats.nozzle_temp|floatformat:1 }}°C</div>
|
|
||||||
<div class="text-muted small">target {{ stats.nozzle_target_temp|floatformat:0 }}°C
|
|
||||||
{% if stats.nozzle_type %}· {{ stats.nozzle_type }}{% endif %}</div>
|
|
||||||
</div>
|
|
||||||
<i class="bi bi-thermometer-high" style="font-size: 2rem; opacity: 0.3;"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Left Nozzle -->
|
|
||||||
<div class="col-12 col-md-6 col-lg-3">
|
|
||||||
<div class="card infra-card-warning">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
|
||||||
<div>
|
|
||||||
<div class="stat-label">Left Nozzle</div>
|
|
||||||
<div class="stat-value">{{ stats.nozzle_temp_left|floatformat:1 }}°C</div>
|
|
||||||
<div class="text-muted small">target {{ stats.nozzle_target_temp_left|floatformat:0 }}°C
|
|
||||||
{% if stats.nozzle_type_left %}· {{ stats.nozzle_type_left }}{% endif %}</div>
|
|
||||||
</div>
|
|
||||||
<i class="bi bi-thermometer-high" style="font-size: 2rem; opacity: 0.3;"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<!-- Nozzle Temperature Card (single-nozzle printers) -->
|
|
||||||
<div class="col-12 col-md-6 col-lg-3">
|
<div class="col-12 col-md-6 col-lg-3">
|
||||||
<div class="card infra-card-warning">
|
<div class="card infra-card-warning">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@@ -84,7 +36,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Bed Temperature Card -->
|
<!-- Bed Temperature Card -->
|
||||||
<div class="col-12 col-md-6 col-lg-3">
|
<div class="col-12 col-md-6 col-lg-3">
|
||||||
@@ -143,7 +94,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<strong>Job Name:</strong> {{ stats.job_display_name }}
|
<strong>Job Name:</strong> {{ stats.subtask_name }}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<strong>State:</strong> {{ stats.gcode_state }}
|
<strong>State:</strong> {{ stats.gcode_state }}
|
||||||
@@ -201,7 +152,7 @@
|
|||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
{% for filament in stats.filaments %}
|
{% for filament in stats.filaments %}
|
||||||
<div class="col-12 col-md-6 col-lg-3">
|
<div class="col-12 col-md-6 col-lg-3">
|
||||||
<div class="card filament-card" data-filament-color="{{ filament.color|slice:':6' }}"{% if filament.is_transparent %} data-filament-transparent="true"{% endif %}>
|
<div class="card filament-card" data-filament-color="{{ filament.color|slice:':6' }}">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
<h6 class="mb-0">Tray {{ filament.tray_id }}</h6>
|
<h6 class="mb-0">Tray {{ filament.tray_id }}</h6>
|
||||||
@@ -315,10 +266,10 @@
|
|||||||
|
|
||||||
<!-- Charts Section -->
|
<!-- Charts Section -->
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
<!-- Nozzle Temperature Chart (right side / single nozzle) -->
|
<!-- Nozzle Temperature Chart -->
|
||||||
<div class="col-12 col-lg-6">
|
<div class="col-12 col-lg-6">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">{% if stats.is_dual_nozzle %}Right Nozzle Temperature{% else %}Nozzle Temperature{% endif %}</div>
|
<div class="card-header">Nozzle Temperature</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="chart-container">
|
<div class="chart-container">
|
||||||
<canvas id="nozzleTempChart"></canvas>
|
<canvas id="nozzleTempChart"></canvas>
|
||||||
@@ -327,20 +278,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if stats.is_dual_nozzle %}
|
|
||||||
<!-- Left Nozzle Temperature Chart (H2C-class dual-nozzle) -->
|
|
||||||
<div class="col-12 col-lg-6">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">Left Nozzle Temperature</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="chart-container">
|
|
||||||
<canvas id="nozzleTempLeftChart"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Bed Temperature Chart -->
|
<!-- Bed Temperature Chart -->
|
||||||
<div class="col-12 col-lg-6">
|
<div class="col-12 col-lg-6">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -437,12 +374,12 @@
|
|||||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3.0.1"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3.0.1"></script>
|
||||||
<script src="{% static 'bambu_run/js/printer_charts.js' %}"></script>
|
<script src="{% static 'bambu_run/js/printer_charts.js' %}"></script>
|
||||||
<script src="{% static 'bambu_run/js/printer_charts_control.js' %}"></script>
|
<script src="{% static 'bambu_run/js/printer_charts_control.js' %}"></script>
|
||||||
{% if not is_basic_user and printer_device %}
|
{% if not is_basic_user %}
|
||||||
<div id="printerApiUrl" data-url="{% url 'bambu_run:printer_api' pk=printer_device.pk %}" style="display: none;"></div>
|
<div id="printerApiUrl" data-url="{% url 'bambu_run:printer_api' %}" style="display: none;"></div>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const printerData = {{ printer_data_json|safe }};
|
const printerData = {{ printer_data_json|safe }};
|
||||||
const apiUrl = '{% url "bambu_run:printer_api" pk=printer_device.pk %}';
|
const apiUrl = '{% url "bambu_run:printer_api" %}';
|
||||||
initPrinterCharts(printerData, apiUrl);
|
initPrinterCharts(printerData, apiUrl);
|
||||||
|
|
||||||
// Add project markers if they exist
|
// Add project markers if they exist
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ app_name = "bambu_run"
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", views.PrinterDashboardView.as_view(), name="printer_dashboard"),
|
path("", views.PrinterDashboardView.as_view(), name="printer_dashboard"),
|
||||||
path("printer/<int:pk>/", views.PrinterDashboardView.as_view(), name="printer_dashboard"),
|
|
||||||
path("api/printer/", views.PrinterDataAPIView.as_view(), name="printer_api"),
|
path("api/printer/", views.PrinterDataAPIView.as_view(), name="printer_api"),
|
||||||
path("api/printer/<int:pk>/", views.PrinterDataAPIView.as_view(), name="printer_api"),
|
|
||||||
|
|
||||||
# Filament Inventory routes
|
# Filament Inventory routes
|
||||||
path("filaments/", views.FilamentListView.as_view(), name="filament_list"),
|
path("filaments/", views.FilamentListView.as_view(), name="filament_list"),
|
||||||
|
|||||||
@@ -2,31 +2,18 @@
|
|||||||
Utility functions for filament color matching
|
Utility functions for filament color matching
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# BambuLab AMS reports colors as 8-char hex with an alpha channel suffix (e.g. '489FDFFF').
|
|
||||||
# Opaque filaments use alpha 'FF'. Clear/transparent filaments use alpha '00' (e.g. '00000000').
|
|
||||||
MQTT_COLOR_HEX_LENGTH = 6
|
|
||||||
|
|
||||||
|
|
||||||
def is_mqtt_color_transparent(mqtt_color):
|
|
||||||
"""
|
|
||||||
Return True if the AMS color represents a clear/transparent filament.
|
|
||||||
Bambu Lab uses alpha=00 for transparent (e.g. '00000000'), not 'FF' like opaque filaments.
|
|
||||||
"""
|
|
||||||
return bool(mqtt_color) and len(mqtt_color) == 8 and mqtt_color[6:8].upper() == '00'
|
|
||||||
|
|
||||||
|
|
||||||
def strip_color_padding(mqtt_color):
|
def strip_color_padding(mqtt_color):
|
||||||
"""
|
"""
|
||||||
Strip alpha padding from MQTT color, returning the 6-char RGB hex.
|
Strip FF padding from MQTT color
|
||||||
MQTT: '000000FF' -> '000000' (opaque black)
|
MQTT: '000000FF' -> '000000'
|
||||||
MQTT: '00000000' -> '000000' (transparent — use is_mqtt_color_transparent() to distinguish)
|
|
||||||
MQTT: 'FF6A13FF' -> 'FF6A13'
|
MQTT: 'FF6A13FF' -> 'FF6A13'
|
||||||
"""
|
"""
|
||||||
if not mqtt_color:
|
if not mqtt_color:
|
||||||
return None
|
return None
|
||||||
if len(mqtt_color) == 8:
|
if len(mqtt_color) == 8:
|
||||||
return mqtt_color[:MQTT_COLOR_HEX_LENGTH].upper()
|
return mqtt_color[:6].upper()
|
||||||
return mqtt_color[:MQTT_COLOR_HEX_LENGTH].upper() if len(mqtt_color) >= MQTT_COLOR_HEX_LENGTH else mqtt_color.upper()
|
return mqtt_color[:6].upper() if len(mqtt_color) >= 6 else mqtt_color.upper()
|
||||||
|
|
||||||
|
|
||||||
def match_filament_color(filament_type, filament_sub_type, color_code, brand='Bambu Lab'):
|
def match_filament_color(filament_type, filament_sub_type, color_code, brand='Bambu Lab'):
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
from datetime import timedelta, datetime
|
from datetime import timedelta
|
||||||
from django.views.generic import TemplateView, View, ListView, CreateView, UpdateView, DetailView, DeleteView
|
from django.views.generic import TemplateView, View, ListView, CreateView, UpdateView, DetailView, DeleteView
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.shortcuts import get_object_or_404
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.http import Http404, JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.db.models import Q, Sum
|
from django.db.models import Q, Sum
|
||||||
@@ -14,29 +13,6 @@ from .conf import app_settings
|
|||||||
from .models import Printer, PrinterMetrics, Filament, FilamentColor, FilamentType, FilamentSnapshot, PrintJob, FilamentUsage
|
from .models import Printer, PrinterMetrics, Filament, FilamentColor, FilamentType, FilamentSnapshot, PrintJob, FilamentUsage
|
||||||
from .forms import FilamentForm, FilamentColorForm, FilamentTypeForm
|
from .forms import FilamentForm, FilamentColorForm, FilamentTypeForm
|
||||||
|
|
||||||
_METRICS_API_FIELDS = [
|
|
||||||
'id', 'device_id', 'timestamp',
|
|
||||||
'nozzle_temp', 'nozzle_target_temp',
|
|
||||||
'bed_temp', 'bed_target_temp',
|
|
||||||
'print_percent', 'cooling_fan_speed', 'heatbreak_fan_speed',
|
|
||||||
'wifi_signal_dbm', 'ams_humidity_raw', 'ams_temp',
|
|
||||||
'layer_num', 'total_layer_num',
|
|
||||||
'gcode_state', 'print_type', 'subtask_name',
|
|
||||||
'external_spool',
|
|
||||||
]
|
|
||||||
_MAX_CHART_POINTS = 3000
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_printer_from_request(pk):
|
|
||||||
"""Resolve which Printer a dashboard/API view should show.
|
|
||||||
|
|
||||||
`pk` given (URL kwarg) -> that exact printer, 404 if missing/inactive.
|
|
||||||
`pk` omitted -> first active printer (today's single-printer default behavior).
|
|
||||||
"""
|
|
||||||
if pk is not None:
|
|
||||||
return get_object_or_404(Printer, pk=pk, is_active=True)
|
|
||||||
return Printer.objects.filter(is_active=True).first()
|
|
||||||
|
|
||||||
|
|
||||||
class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
||||||
template_name = "bambu_run/printer_dashboard.html"
|
template_name = "bambu_run/printer_dashboard.html"
|
||||||
@@ -50,20 +26,13 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
|||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||||
|
|
||||||
all_printers = Printer.objects.filter(is_active=True)
|
|
||||||
context["all_printers"] = all_printers
|
|
||||||
# Shown even with a single printer — hints that multi-printer support exists.
|
|
||||||
context["show_printer_switcher"] = all_printers.exists()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
printer_device = resolve_printer_from_request(self.kwargs.get("pk"))
|
printer_device = Printer.objects.filter(is_active=True).first()
|
||||||
if not printer_device:
|
if not printer_device:
|
||||||
context["error"] = (
|
context["error"] = (
|
||||||
"No 3D printer device found. Please run bambu_collector first."
|
"No 3D printer device found. Please run bambu_collector first."
|
||||||
)
|
)
|
||||||
return context
|
return context
|
||||||
except Http404:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
context["error"] = f"Error loading printer device: {str(e)}"
|
context["error"] = f"Error loading printer device: {str(e)}"
|
||||||
return context
|
return context
|
||||||
@@ -85,9 +54,6 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
|||||||
"timestamps": [
|
"timestamps": [
|
||||||
m.timestamp.astimezone(tz).strftime("%H:%M") for m in metrics
|
m.timestamp.astimezone(tz).strftime("%H:%M") for m in metrics
|
||||||
],
|
],
|
||||||
"dates": [
|
|
||||||
m.timestamp.astimezone(tz).strftime("%Y-%m-%d") for m in metrics
|
|
||||||
],
|
|
||||||
"nozzle_temp": [
|
"nozzle_temp": [
|
||||||
float(m.nozzle_temp) if m.nozzle_temp else None for m in metrics
|
float(m.nozzle_temp) if m.nozzle_temp else None for m in metrics
|
||||||
],
|
],
|
||||||
@@ -95,14 +61,6 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
|||||||
float(m.nozzle_target_temp) if m.nozzle_target_temp else None
|
float(m.nozzle_target_temp) if m.nozzle_target_temp else None
|
||||||
for m in metrics
|
for m in metrics
|
||||||
],
|
],
|
||||||
"nozzle_temp_left": [
|
|
||||||
float(m.nozzle_temp_left) if m.nozzle_temp_left is not None else None
|
|
||||||
for m in metrics
|
|
||||||
],
|
|
||||||
"nozzle_target_temp_left": [
|
|
||||||
float(m.nozzle_target_temp_left) if m.nozzle_target_temp_left is not None else None
|
|
||||||
for m in metrics
|
|
||||||
],
|
|
||||||
"bed_temp": [float(m.bed_temp) if m.bed_temp else None for m in metrics],
|
"bed_temp": [float(m.bed_temp) if m.bed_temp else None for m in metrics],
|
||||||
"bed_target_temp": [
|
"bed_target_temp": [
|
||||||
float(m.bed_target_temp) if m.bed_target_temp else None for m in metrics
|
float(m.bed_target_temp) if m.bed_target_temp else None for m in metrics
|
||||||
@@ -152,46 +110,18 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
|||||||
if snapshot.filament:
|
if snapshot.filament:
|
||||||
filament_dict['color_name'] = snapshot.filament.color
|
filament_dict['color_name'] = snapshot.filament.color
|
||||||
filament_dict['filament_pk'] = snapshot.filament.pk
|
filament_dict['filament_pk'] = snapshot.filament.pk
|
||||||
filament_dict['is_transparent'] = snapshot.filament.is_transparent
|
|
||||||
filaments_list.append(filament_dict)
|
filaments_list.append(filament_dict)
|
||||||
except Exception:
|
except Exception:
|
||||||
filaments_list = []
|
filaments_list = []
|
||||||
|
|
||||||
subtask_name = latest_metric.subtask_name or "No active print"
|
|
||||||
# Look up active PrintJob for a better display name (cloud design_title)
|
|
||||||
job_display_name = subtask_name
|
|
||||||
if latest_metric.subtask_name:
|
|
||||||
active_job = (
|
|
||||||
PrintJob.objects.filter(
|
|
||||||
device=printer_device,
|
|
||||||
project_name=latest_metric.subtask_name,
|
|
||||||
end_time__isnull=True,
|
|
||||||
).select_related('cloud_task').first()
|
|
||||||
or PrintJob.objects.filter(
|
|
||||||
device=printer_device,
|
|
||||||
project_name=latest_metric.subtask_name,
|
|
||||||
).select_related('cloud_task').order_by('-start_time').first()
|
|
||||||
)
|
|
||||||
if active_job:
|
|
||||||
job_display_name = active_job.display_name
|
|
||||||
|
|
||||||
stats = {
|
stats = {
|
||||||
"nozzle_temp": float(latest_metric.nozzle_temp) if latest_metric.nozzle_temp else 0,
|
"nozzle_temp": float(latest_metric.nozzle_temp) if latest_metric.nozzle_temp else 0,
|
||||||
"nozzle_target_temp": float(latest_metric.nozzle_target_temp) if latest_metric.nozzle_target_temp else 0,
|
|
||||||
"nozzle_diameter": float(latest_metric.nozzle_diameter) if latest_metric.nozzle_diameter else None,
|
|
||||||
"nozzle_type": latest_metric.nozzle_type or "",
|
|
||||||
"nozzle_temp_left": float(latest_metric.nozzle_temp_left) if latest_metric.nozzle_temp_left is not None else None,
|
|
||||||
"nozzle_target_temp_left": float(latest_metric.nozzle_target_temp_left) if latest_metric.nozzle_target_temp_left is not None else None,
|
|
||||||
"nozzle_diameter_left": float(latest_metric.nozzle_diameter_left) if latest_metric.nozzle_diameter_left is not None else None,
|
|
||||||
"nozzle_type_left": latest_metric.nozzle_type_left or "",
|
|
||||||
"is_dual_nozzle": latest_metric.nozzle_temp_left is not None,
|
|
||||||
"bed_temp": float(latest_metric.bed_temp) if latest_metric.bed_temp else 0,
|
"bed_temp": float(latest_metric.bed_temp) if latest_metric.bed_temp else 0,
|
||||||
"chamber_temp": float(latest_metric.chamber_temp) if latest_metric.chamber_temp else 0,
|
"chamber_temp": float(latest_metric.chamber_temp) if latest_metric.chamber_temp else 0,
|
||||||
"print_percent": latest_metric.print_percent or 0,
|
"print_percent": latest_metric.print_percent or 0,
|
||||||
"gcode_state": latest_metric.gcode_state or "Unknown",
|
"gcode_state": latest_metric.gcode_state or "Unknown",
|
||||||
"print_type": latest_metric.print_type or "idle",
|
"print_type": latest_metric.print_type or "idle",
|
||||||
"subtask_name": subtask_name,
|
"subtask_name": latest_metric.subtask_name or "No active print",
|
||||||
"job_display_name": job_display_name,
|
|
||||||
"chamber_light": latest_metric.chamber_light or "unknown",
|
"chamber_light": latest_metric.chamber_light or "unknown",
|
||||||
"ams_temp": float(latest_metric.ams_temp) if latest_metric.ams_temp else None,
|
"ams_temp": float(latest_metric.ams_temp) if latest_metric.ams_temp else None,
|
||||||
"ams_humidity": latest_metric.ams_humidity,
|
"ams_humidity": latest_metric.ams_humidity,
|
||||||
@@ -212,24 +142,7 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
def _calculate_project_markers(self, metrics, timezone_info):
|
def _calculate_project_markers(self, metrics, timezone_info):
|
||||||
"""Calculate where print jobs start and end, using cloud design_title when available."""
|
"""Calculate where print jobs start and end"""
|
||||||
if not metrics:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Build a lookup: subtask_name -> display_name from PrintJobs in this time window
|
|
||||||
window_start = metrics[0].timestamp
|
|
||||||
window_end = metrics[-1].timestamp
|
|
||||||
device = metrics[0].device
|
|
||||||
jobs_qs = PrintJob.objects.filter(
|
|
||||||
device=device,
|
|
||||||
start_time__gte=window_start - timedelta(minutes=5),
|
|
||||||
start_time__lte=window_end + timedelta(minutes=5),
|
|
||||||
).select_related('cloud_task')
|
|
||||||
# Map project_name (= subtask_name) -> best display name
|
|
||||||
subtask_to_display = {}
|
|
||||||
for job in jobs_qs:
|
|
||||||
subtask_to_display[job.project_name] = job.display_name
|
|
||||||
|
|
||||||
markers = []
|
markers = []
|
||||||
current_job = None
|
current_job = None
|
||||||
last_state = None
|
last_state = None
|
||||||
@@ -241,23 +154,21 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
|||||||
is_printing = gcode_state not in ['FINISH', 'IDLE', None, '']
|
is_printing = gcode_state not in ['FINISH', 'IDLE', None, '']
|
||||||
|
|
||||||
if subtask and subtask != current_job and is_printing:
|
if subtask and subtask != current_job and is_printing:
|
||||||
display = subtask_to_display.get(subtask, subtask)
|
|
||||||
markers.append({
|
markers.append({
|
||||||
'type': 'start',
|
'type': 'start',
|
||||||
'index': idx,
|
'index': idx,
|
||||||
'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(),
|
'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(),
|
||||||
'project_name': display,
|
'project_name': subtask,
|
||||||
})
|
})
|
||||||
current_job = subtask
|
current_job = subtask
|
||||||
last_state = gcode_state
|
last_state = gcode_state
|
||||||
|
|
||||||
elif current_job and last_state and last_state not in ['FINISH', 'IDLE'] and gcode_state in ['FINISH', 'IDLE']:
|
elif current_job and last_state and last_state not in ['FINISH', 'IDLE'] and gcode_state in ['FINISH', 'IDLE']:
|
||||||
display = subtask_to_display.get(current_job, current_job)
|
|
||||||
markers.append({
|
markers.append({
|
||||||
'type': 'end',
|
'type': 'end',
|
||||||
'index': idx,
|
'index': idx,
|
||||||
'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(),
|
'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(),
|
||||||
'project_name': display,
|
'project_name': current_job,
|
||||||
})
|
})
|
||||||
current_job = None
|
current_job = None
|
||||||
|
|
||||||
@@ -323,197 +234,61 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
|||||||
class PrinterDataAPIView(LoginRequiredMixin, View):
|
class PrinterDataAPIView(LoginRequiredMixin, View):
|
||||||
"""API endpoint for dynamic printer chart updates"""
|
"""API endpoint for dynamic printer chart updates"""
|
||||||
|
|
||||||
def get(self, request, pk=None):
|
def get(self, request):
|
||||||
start_date = request.GET.get("start_date")
|
start_date = request.GET.get("start_date")
|
||||||
end_date = request.GET.get("end_date")
|
end_date = request.GET.get("end_date")
|
||||||
start_time = request.GET.get("start_time", "00:00")
|
start_time = request.GET.get("start_time", "00:00")
|
||||||
end_time = request.GET.get("end_time", "23:59")
|
end_time = request.GET.get("end_time", "23:59")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if pk is not None:
|
printer_device = Printer.objects.filter(is_active=True).first()
|
||||||
printer_device = Printer.objects.filter(pk=pk, is_active=True).first()
|
if not printer_device:
|
||||||
if not printer_device:
|
return JsonResponse({"error": "No printer device found"}, status=404)
|
||||||
return JsonResponse({"error": "Printer not found"}, status=404)
|
|
||||||
else:
|
query = PrinterMetrics.objects.filter(device=printer_device).prefetch_related('filament_snapshots')
|
||||||
printer_device = Printer.objects.filter(is_active=True).first()
|
|
||||||
if not printer_device:
|
|
||||||
return JsonResponse({"error": "No printer device found"}, status=404)
|
|
||||||
|
|
||||||
tz = zoneinfo.ZoneInfo(app_settings.TIMEZONE)
|
tz = zoneinfo.ZoneInfo(app_settings.TIMEZONE)
|
||||||
|
|
||||||
# Stage A: only() + step calculation
|
if start_date and start_time:
|
||||||
query = (
|
from datetime import datetime
|
||||||
PrinterMetrics.objects
|
start_dt_naive = datetime.strptime(f"{start_date} {start_time}", "%Y-%m-%d %H:%M")
|
||||||
.filter(device=printer_device)
|
start_dt = start_dt_naive.replace(tzinfo=tz)
|
||||||
.only(*_METRICS_API_FIELDS)
|
|
||||||
)
|
|
||||||
|
|
||||||
if start_date and start_time and end_date and end_time:
|
|
||||||
start_dt = datetime.strptime(f"{start_date} {start_time}", "%Y-%m-%d %H:%M").replace(tzinfo=tz)
|
|
||||||
end_dt = datetime.strptime(f"{end_date} {end_time}", "%Y-%m-%d %H:%M").replace(tzinfo=tz)
|
|
||||||
query = query.filter(timestamp__gte=start_dt, timestamp__lte=end_dt)
|
|
||||||
range_seconds = (end_dt - start_dt).total_seconds()
|
|
||||||
expected_count = max(1, int(range_seconds / 30))
|
|
||||||
elif start_date and start_time:
|
|
||||||
start_dt = datetime.strptime(f"{start_date} {start_time}", "%Y-%m-%d %H:%M").replace(tzinfo=tz)
|
|
||||||
query = query.filter(timestamp__gte=start_dt)
|
query = query.filter(timestamp__gte=start_dt)
|
||||||
expected_count = _MAX_CHART_POINTS
|
|
||||||
elif end_date and end_time:
|
if end_date and end_time:
|
||||||
end_dt = datetime.strptime(f"{end_date} {end_time}", "%Y-%m-%d %H:%M").replace(tzinfo=tz)
|
from datetime import datetime
|
||||||
|
end_dt_naive = datetime.strptime(f"{end_date} {end_time}", "%Y-%m-%d %H:%M")
|
||||||
|
end_dt = end_dt_naive.replace(tzinfo=tz)
|
||||||
query = query.filter(timestamp__lte=end_dt)
|
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)
|
metrics = query.order_by("timestamp")
|
||||||
|
|
||||||
# Stage B: single DB round-trip, downsample in Python
|
|
||||||
metrics_list = list(query.order_by("timestamp"))
|
|
||||||
if step > 1:
|
|
||||||
metrics_list = metrics_list[::step]
|
|
||||||
|
|
||||||
total_points = len(metrics_list)
|
|
||||||
|
|
||||||
# Stage C: targeted snapshot fetch (only sampled IDs)
|
|
||||||
snapshots_by_metric: dict = {}
|
|
||||||
if metrics_list:
|
|
||||||
sampled_ids = [m.id for m in metrics_list]
|
|
||||||
for snap in FilamentSnapshot.objects.filter(printer_metric_id__in=sampled_ids):
|
|
||||||
snapshots_by_metric.setdefault(snap.printer_metric_id, []).append(snap)
|
|
||||||
|
|
||||||
# Stage D: single-pass serialization
|
|
||||||
timestamps = []
|
|
||||||
timestamps_iso = []
|
|
||||||
dates = []
|
|
||||||
nozzle_temp = []
|
|
||||||
nozzle_target_temp = []
|
|
||||||
nozzle_temp_left = []
|
|
||||||
nozzle_target_temp_left = []
|
|
||||||
bed_temp = []
|
|
||||||
bed_target_temp = []
|
|
||||||
print_percent = []
|
|
||||||
cooling_fan_speed = []
|
|
||||||
heatbreak_fan_speed = []
|
|
||||||
wifi_signal_dbm = []
|
|
||||||
ams_humidity_raw = []
|
|
||||||
ams_temp = []
|
|
||||||
layer_num = []
|
|
||||||
total_layer_num = []
|
|
||||||
gcode_state = []
|
|
||||||
print_type = []
|
|
||||||
subtask_name = []
|
|
||||||
|
|
||||||
project_markers = []
|
|
||||||
current_job = None
|
|
||||||
last_state = None
|
|
||||||
|
|
||||||
filament_data = {}
|
|
||||||
|
|
||||||
for idx, m in enumerate(metrics_list):
|
|
||||||
ts = m.timestamp.astimezone(tz)
|
|
||||||
timestamps.append(ts.strftime('%H:%M'))
|
|
||||||
timestamps_iso.append(ts.isoformat())
|
|
||||||
dates.append(ts.strftime('%Y-%m-%d'))
|
|
||||||
nozzle_temp.append(float(m.nozzle_temp) if m.nozzle_temp else None)
|
|
||||||
nozzle_target_temp.append(float(m.nozzle_target_temp) if m.nozzle_target_temp else None)
|
|
||||||
nozzle_temp_left.append(float(m.nozzle_temp_left) if m.nozzle_temp_left is not None else None)
|
|
||||||
nozzle_target_temp_left.append(float(m.nozzle_target_temp_left) if m.nozzle_target_temp_left is not None else None)
|
|
||||||
bed_temp.append(float(m.bed_temp) if m.bed_temp else None)
|
|
||||||
bed_target_temp.append(float(m.bed_target_temp) if m.bed_target_temp else None)
|
|
||||||
print_percent.append(m.print_percent if m.print_percent else 0)
|
|
||||||
cooling_fan_speed.append(m.cooling_fan_speed if m.cooling_fan_speed else 0)
|
|
||||||
heatbreak_fan_speed.append(m.heatbreak_fan_speed if m.heatbreak_fan_speed else 0)
|
|
||||||
wifi_signal_dbm.append(m.wifi_signal_dbm if m.wifi_signal_dbm else None)
|
|
||||||
ams_humidity_raw.append(m.ams_humidity_raw if m.ams_humidity_raw else None)
|
|
||||||
ams_temp.append(float(m.ams_temp) if m.ams_temp else None)
|
|
||||||
layer_num.append(m.layer_num if m.layer_num else 0)
|
|
||||||
total_layer_num.append(m.total_layer_num if m.total_layer_num else 0)
|
|
||||||
gcode_state.append(m.gcode_state)
|
|
||||||
print_type.append(m.print_type)
|
|
||||||
subtask_name.append(m.subtask_name)
|
|
||||||
|
|
||||||
# Project marker detection (inline)
|
|
||||||
subtask = m.subtask_name
|
|
||||||
gs = m.gcode_state
|
|
||||||
is_printing = gs not in ['FINISH', 'IDLE', None, '']
|
|
||||||
if subtask and subtask != current_job and is_printing:
|
|
||||||
project_markers.append({
|
|
||||||
'type': 'start',
|
|
||||||
'index': idx,
|
|
||||||
'timestamp': ts.isoformat(),
|
|
||||||
'project_name': subtask,
|
|
||||||
})
|
|
||||||
current_job = subtask
|
|
||||||
last_state = gs
|
|
||||||
elif current_job and last_state and last_state not in ['FINISH', 'IDLE'] and gs in ['FINISH', 'IDLE']:
|
|
||||||
project_markers.append({
|
|
||||||
'type': 'end',
|
|
||||||
'index': idx,
|
|
||||||
'timestamp': ts.isoformat(),
|
|
||||||
'project_name': current_job,
|
|
||||||
})
|
|
||||||
current_job = None
|
|
||||||
last_state = gs
|
|
||||||
|
|
||||||
# Filament timeline (inline)
|
|
||||||
for snap in snapshots_by_metric.get(m.id, []):
|
|
||||||
tray_id = snap.tray_id
|
|
||||||
fil_type = snap.type or 'Unknown'
|
|
||||||
fil_sub_type = snap.sub_type or 'Unknown'
|
|
||||||
fil_color = snap.color or 'FFFFFFFF'
|
|
||||||
unique_key = f"{tray_id}_{fil_type}_{fil_sub_type}_{fil_color}"
|
|
||||||
if unique_key not in filament_data:
|
|
||||||
filament_data[unique_key] = {
|
|
||||||
'tray_id': tray_id,
|
|
||||||
'type': fil_type,
|
|
||||||
'brand': fil_sub_type,
|
|
||||||
'color': fil_color,
|
|
||||||
'remain_data': [None] * total_points,
|
|
||||||
'start_idx': idx,
|
|
||||||
}
|
|
||||||
filament_data[unique_key]['remain_data'][idx] = snap.remain_percent or 0
|
|
||||||
|
|
||||||
external = m.external_spool or {}
|
|
||||||
if external.get('type'):
|
|
||||||
fil_type = external.get('type', 'Unknown')
|
|
||||||
fil_color = external.get('color', '161616FF')
|
|
||||||
unique_key = f"External_{fil_type}_{fil_color}"
|
|
||||||
if unique_key not in filament_data:
|
|
||||||
filament_data[unique_key] = {
|
|
||||||
'tray_id': 'External',
|
|
||||||
'type': fil_type,
|
|
||||||
'brand': 'External',
|
|
||||||
'color': fil_color,
|
|
||||||
'remain_data': [None] * total_points,
|
|
||||||
'start_idx': idx,
|
|
||||||
}
|
|
||||||
filament_data[unique_key]['remain_data'][idx] = external.get('remain', 0)
|
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"timestamps": timestamps,
|
"timestamps": [m.timestamp.astimezone(tz).strftime('%H:%M') for m in metrics],
|
||||||
"timestamps_iso": timestamps_iso,
|
"timestamps_iso": [m.timestamp.astimezone(tz).isoformat() for m in metrics],
|
||||||
"dates": dates,
|
"nozzle_temp": [float(m.nozzle_temp) if m.nozzle_temp else None for m in metrics],
|
||||||
"nozzle_temp": nozzle_temp,
|
"nozzle_target_temp": [float(m.nozzle_target_temp) if m.nozzle_target_temp else None for m in metrics],
|
||||||
"nozzle_target_temp": nozzle_target_temp,
|
"bed_temp": [float(m.bed_temp) if m.bed_temp else None for m in metrics],
|
||||||
"nozzle_temp_left": nozzle_temp_left,
|
"bed_target_temp": [float(m.bed_target_temp) if m.bed_target_temp else None for m in metrics],
|
||||||
"nozzle_target_temp_left": nozzle_target_temp_left,
|
"print_percent": [m.print_percent if m.print_percent else 0 for m in metrics],
|
||||||
"bed_temp": bed_temp,
|
"cooling_fan_speed": [m.cooling_fan_speed if m.cooling_fan_speed else 0 for m in metrics],
|
||||||
"bed_target_temp": bed_target_temp,
|
"heatbreak_fan_speed": [m.heatbreak_fan_speed if m.heatbreak_fan_speed else 0 for m in metrics],
|
||||||
"print_percent": print_percent,
|
"wifi_signal_dbm": [m.wifi_signal_dbm if m.wifi_signal_dbm else None for m in metrics],
|
||||||
"cooling_fan_speed": cooling_fan_speed,
|
"ams_humidity_raw": [m.ams_humidity_raw if m.ams_humidity_raw else None for m in metrics],
|
||||||
"heatbreak_fan_speed": heatbreak_fan_speed,
|
"ams_temp": [float(m.ams_temp) if m.ams_temp else None for m in metrics],
|
||||||
"wifi_signal_dbm": wifi_signal_dbm,
|
"layer_num": [m.layer_num if m.layer_num else 0 for m in metrics],
|
||||||
"ams_humidity_raw": ams_humidity_raw,
|
"total_layer_num": [m.total_layer_num if m.total_layer_num else 0 for m in metrics],
|
||||||
"ams_temp": ams_temp,
|
"gcode_state": [m.gcode_state for m in metrics],
|
||||||
"layer_num": layer_num,
|
"print_type": [m.print_type for m in metrics],
|
||||||
"total_layer_num": total_layer_num,
|
"subtask_name": [m.subtask_name for m in metrics],
|
||||||
"gcode_state": gcode_state,
|
|
||||||
"print_type": print_type,
|
|
||||||
"subtask_name": subtask_name,
|
|
||||||
"project_markers": project_markers,
|
|
||||||
"filament_timeline": filament_data,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
project_markers = self._calculate_project_markers(metrics, tz)
|
||||||
|
data["project_markers"] = project_markers
|
||||||
|
|
||||||
|
filament_timeline = self._prepare_filament_timeline_for_api(metrics)
|
||||||
|
data["filament_timeline"] = filament_timeline
|
||||||
|
|
||||||
return JsonResponse(data)
|
return JsonResponse(data)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -521,6 +296,93 @@ class PrinterDataAPIView(LoginRequiredMixin, View):
|
|||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return JsonResponse({"error": str(e)}, status=500)
|
return JsonResponse({"error": str(e)}, status=500)
|
||||||
|
|
||||||
|
def _calculate_project_markers(self, metrics, timezone_info):
|
||||||
|
markers = []
|
||||||
|
current_job = None
|
||||||
|
last_state = None
|
||||||
|
|
||||||
|
for idx, metric in enumerate(metrics):
|
||||||
|
subtask = metric.subtask_name
|
||||||
|
gcode_state = metric.gcode_state
|
||||||
|
|
||||||
|
is_printing = gcode_state not in ['FINISH', 'IDLE', None, '']
|
||||||
|
|
||||||
|
if subtask and subtask != current_job and is_printing:
|
||||||
|
markers.append({
|
||||||
|
'type': 'start',
|
||||||
|
'index': idx,
|
||||||
|
'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(),
|
||||||
|
'project_name': subtask,
|
||||||
|
})
|
||||||
|
current_job = subtask
|
||||||
|
last_state = gcode_state
|
||||||
|
|
||||||
|
elif current_job and last_state and last_state not in ['FINISH', 'IDLE'] and gcode_state in ['FINISH', 'IDLE']:
|
||||||
|
markers.append({
|
||||||
|
'type': 'end',
|
||||||
|
'index': idx,
|
||||||
|
'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(),
|
||||||
|
'project_name': current_job,
|
||||||
|
})
|
||||||
|
current_job = None
|
||||||
|
|
||||||
|
last_state = gcode_state
|
||||||
|
|
||||||
|
return markers
|
||||||
|
|
||||||
|
def _prepare_filament_timeline_for_api(self, metrics):
|
||||||
|
filament_data = {}
|
||||||
|
total_points = len(metrics)
|
||||||
|
|
||||||
|
for idx, metric in enumerate(metrics):
|
||||||
|
try:
|
||||||
|
snapshots = metric.filament_snapshots.all()
|
||||||
|
except Exception:
|
||||||
|
snapshots = []
|
||||||
|
|
||||||
|
for snapshot in snapshots:
|
||||||
|
tray_id = snapshot.tray_id
|
||||||
|
fil_type = snapshot.type or 'Unknown'
|
||||||
|
fil_sub_type = snapshot.sub_type or 'Unknown'
|
||||||
|
fil_color = snapshot.color or 'FFFFFFFF'
|
||||||
|
|
||||||
|
unique_key = f"{tray_id}_{fil_type}_{fil_sub_type}_{fil_color}"
|
||||||
|
|
||||||
|
if unique_key not in filament_data:
|
||||||
|
filament_data[unique_key] = {
|
||||||
|
'tray_id': tray_id,
|
||||||
|
'type': fil_type,
|
||||||
|
'brand': fil_sub_type,
|
||||||
|
'color': fil_color,
|
||||||
|
'remain_data': [None] * total_points,
|
||||||
|
'start_idx': idx,
|
||||||
|
}
|
||||||
|
|
||||||
|
remain_percent = snapshot.remain_percent or 0
|
||||||
|
filament_data[unique_key]['remain_data'][idx] = remain_percent
|
||||||
|
|
||||||
|
for idx, metric in enumerate(metrics):
|
||||||
|
external = metric.external_spool or {}
|
||||||
|
if external.get('type'):
|
||||||
|
fil_type = external.get('type', 'Unknown')
|
||||||
|
fil_color = external.get('color', '161616FF')
|
||||||
|
unique_key = f"External_{fil_type}_{fil_color}"
|
||||||
|
|
||||||
|
if unique_key not in filament_data:
|
||||||
|
filament_data[unique_key] = {
|
||||||
|
'tray_id': 'External',
|
||||||
|
'type': fil_type,
|
||||||
|
'brand': 'External',
|
||||||
|
'color': fil_color,
|
||||||
|
'remain_data': [None] * total_points,
|
||||||
|
'start_idx': idx,
|
||||||
|
}
|
||||||
|
|
||||||
|
remain_percent = external.get('remain', 0)
|
||||||
|
filament_data[unique_key]['remain_data'][idx] = remain_percent
|
||||||
|
|
||||||
|
return filament_data
|
||||||
|
|
||||||
|
|
||||||
class FilamentUsageDataAPIView(LoginRequiredMixin, View):
|
class FilamentUsageDataAPIView(LoginRequiredMixin, View):
|
||||||
"""API endpoint for filament usage history with date/time filtering"""
|
"""API endpoint for filament usage history with date/time filtering"""
|
||||||
@@ -548,32 +410,15 @@ class FilamentUsageDataAPIView(LoginRequiredMixin, View):
|
|||||||
end_dt = end_dt_naive.replace(tzinfo=tz)
|
end_dt = end_dt_naive.replace(tzinfo=tz)
|
||||||
query = query.filter(printer_metric__timestamp__lte=end_dt)
|
query = query.filter(printer_metric__timestamp__lte=end_dt)
|
||||||
|
|
||||||
fallback_used = False
|
|
||||||
if not start_date and not end_date:
|
if not start_date and not end_date:
|
||||||
time_24h_ago = timezone.now() - timedelta(hours=24)
|
time_24h_ago = timezone.now() - timedelta(hours=24)
|
||||||
default_query = query.filter(printer_metric__timestamp__gte=time_24h_ago)
|
query = query.filter(printer_metric__timestamp__gte=time_24h_ago)
|
||||||
if default_query.exists():
|
|
||||||
snapshots = default_query.order_by('printer_metric__timestamp')
|
snapshots = query.order_by('printer_metric__timestamp')
|
||||||
else:
|
|
||||||
# Fallback: show 24h window ending at the most recent available snapshot
|
|
||||||
last_snapshot = query.order_by('-printer_metric__timestamp').first()
|
|
||||||
if last_snapshot:
|
|
||||||
last_ts = last_snapshot.printer_metric.timestamp
|
|
||||||
fallback_start = last_ts - timedelta(hours=24)
|
|
||||||
snapshots = query.filter(
|
|
||||||
printer_metric__timestamp__gte=fallback_start,
|
|
||||||
printer_metric__timestamp__lte=last_ts
|
|
||||||
).order_by('printer_metric__timestamp')
|
|
||||||
fallback_used = True
|
|
||||||
else:
|
|
||||||
snapshots = query.none()
|
|
||||||
else:
|
|
||||||
snapshots = query.order_by('printer_metric__timestamp')
|
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"timestamps": [s.printer_metric.timestamp.astimezone(tz).strftime('%Y-%m-%d %H:%M') for s in snapshots],
|
"timestamps": [s.printer_metric.timestamp.astimezone(tz).strftime('%Y-%m-%d %H:%M') for s in snapshots],
|
||||||
"remaining": [s.remain_percent for s in snapshots],
|
"remaining": [s.remain_percent for s in snapshots]
|
||||||
"fallback_used": fallback_used,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return JsonResponse(data)
|
return JsonResponse(data)
|
||||||
@@ -607,10 +452,6 @@ class FilamentListView(LoginRequiredMixin, ListView):
|
|||||||
elif loaded == 'no':
|
elif loaded == 'no':
|
||||||
queryset = queryset.filter(is_loaded_in_ams=False)
|
queryset = queryset.filter(is_loaded_in_ams=False)
|
||||||
|
|
||||||
ams_type = self.request.GET.get('ams_type')
|
|
||||||
if ams_type:
|
|
||||||
queryset = queryset.filter(ams_type=ams_type)
|
|
||||||
|
|
||||||
search = self.request.GET.get('search')
|
search = self.request.GET.get('search')
|
||||||
if search:
|
if search:
|
||||||
queryset = queryset.filter(
|
queryset = queryset.filter(
|
||||||
@@ -630,22 +471,9 @@ class FilamentListView(LoginRequiredMixin, ListView):
|
|||||||
context['filament_types'] = sorted(
|
context['filament_types'] = sorted(
|
||||||
set(Filament.objects.exclude(type__isnull=True).exclude(type='').values_list('type', flat=True))
|
set(Filament.objects.exclude(type__isnull=True).exclude(type='').values_list('type', flat=True))
|
||||||
)
|
)
|
||||||
context['ams_type_choices'] = sorted(
|
|
||||||
set(
|
|
||||||
Filament.objects.exclude(ams_type='').values_list('ams_type', flat=True)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
def _filament_type_map():
|
|
||||||
"""Return a JSON-serialisable dict mapping FilamentType pk → {type, sub_type, brand}."""
|
|
||||||
return {
|
|
||||||
str(ft.pk): {'type': ft.type, 'sub_type': ft.sub_type or '', 'brand': ft.brand}
|
|
||||||
for ft in FilamentType.objects.all()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class FilamentCreateView(LoginRequiredMixin, CreateView):
|
class FilamentCreateView(LoginRequiredMixin, CreateView):
|
||||||
model = Filament
|
model = Filament
|
||||||
form_class = FilamentForm
|
form_class = FilamentForm
|
||||||
@@ -655,7 +483,6 @@ class FilamentCreateView(LoginRequiredMixin, CreateView):
|
|||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||||
context['filament_type_map'] = json.dumps(_filament_type_map())
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
@@ -672,7 +499,6 @@ class FilamentUpdateView(LoginRequiredMixin, UpdateView):
|
|||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||||
context['filament_type_map'] = json.dumps(_filament_type_map())
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
@@ -706,7 +532,7 @@ class FilamentDetailView(LoginRequiredMixin, DetailView):
|
|||||||
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||||
filament = self.object
|
filament = self.object
|
||||||
|
|
||||||
context['print_usages'] = filament.print_usages.select_related('print_job__cloud_task').order_by('-print_job__start_time')[:20]
|
context['print_usages'] = filament.print_usages.select_related('print_job').order_by('-print_job__start_time')[:20]
|
||||||
|
|
||||||
total_consumed = filament.print_usages.aggregate(
|
total_consumed = filament.print_usages.aggregate(
|
||||||
total=Sum('consumed_percent')
|
total=Sum('consumed_percent')
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
- "8808:8808"
|
|
||||||
env_file: .env
|
env_file: .env
|
||||||
volumes:
|
volumes:
|
||||||
- bambu_data:/app/data
|
- bambu_data:/app/data
|
||||||
|
|||||||
@@ -25,19 +25,6 @@ autorestart=true
|
|||||||
startretries=10
|
startretries=10
|
||||||
startsecs=5
|
startsecs=5
|
||||||
|
|
||||||
[program:mcp_server]
|
|
||||||
command=python standalone/manage.py bambu_mcp_server --transport sse --host 0.0.0.0 --port 8808
|
|
||||||
directory=/app
|
|
||||||
environment=DJANGO_SETTINGS_MODULE="standalone.settings"
|
|
||||||
stdout_logfile=/dev/fd/1
|
|
||||||
stdout_logfile_maxbytes=0
|
|
||||||
stderr_logfile=/dev/fd/2
|
|
||||||
stderr_logfile_maxbytes=0
|
|
||||||
autorestart=true
|
|
||||||
startretries=10
|
|
||||||
startsecs=5
|
|
||||||
priority=10
|
|
||||||
|
|
||||||
[program:migrate]
|
[program:migrate]
|
||||||
command=python standalone/manage.py migrate --noinput
|
command=python standalone/manage.py migrate --noinput
|
||||||
directory=/app
|
directory=/app
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
White
|
|
||||||
Hex:#FFFFFF
|
|
||||||
Bambu Green
|
|
||||||
Hex:#00AE42
|
|
||||||
Olive
|
|
||||||
Hex:#789D4A
|
|
||||||
Azure
|
|
||||||
Hex:#489FDF
|
|
||||||
Navy Blue
|
|
||||||
Hex:#0C2340
|
|
||||||
Blue
|
|
||||||
Hex:#0A2CA5
|
|
||||||
Tangerine Yellow
|
|
||||||
Hex:#FFC72C
|
|
||||||
Orange
|
|
||||||
Hex:#FF6A13
|
|
||||||
Red
|
|
||||||
Hex:#D32941
|
|
||||||
Purple
|
|
||||||
Hex:#AF1685
|
|
||||||
Silver
|
|
||||||
Hex:#87909A
|
|
||||||
Black
|
|
||||||
Hex:#000000
|
|
||||||
Mint
|
|
||||||
Hex:#7AE1BF
|
|
||||||
Lavender
|
|
||||||
Hex:#7248BD
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
White #FFFAF2
|
|
||||||
Gray #8A949E
|
|
||||||
Red #E02928
|
|
||||||
Green #00A6A0
|
|
||||||
Blue #2140B4
|
|
||||||
Black #000000
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
White #EAEAE4
|
|
||||||
Yellow #FFCE00
|
|
||||||
Lime #C5ED48
|
|
||||||
Blue #75AED8
|
|
||||||
Orange #FF4800
|
|
||||||
Brown #5B492F
|
|
||||||
Gray #353533
|
|
||||||
Black #000000
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
White #FFFFFF
|
|
||||||
Gray #A8A8AA
|
|
||||||
Black #000000
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
Yellow #FFD00B
|
|
||||||
Orange #F75403
|
|
||||||
Green #00AE42
|
|
||||||
Red #EB3A3A
|
|
||||||
Blue #002E96
|
|
||||||
Black #000000
|
|
||||||
White #FFFFFF
|
|
||||||
Cream #F9DFB9
|
|
||||||
Lime Green #6EE53C
|
|
||||||
Forest Green #39541A
|
|
||||||
Lake Blue #1F79E5
|
|
||||||
Peanut Brown #875718
|
|
||||||
Gray #ADB1B2
|
|
||||||
Dark Gray #515151
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
Translucent #000000
|
|
||||||
Translucent Gray #8E8E8E
|
|
||||||
Translucent Light Blue #61B0FF
|
|
||||||
Translucent Olive #748C45
|
|
||||||
Translucent Brown #C9A381
|
|
||||||
Translucent Teal #77EDD7
|
|
||||||
Translucent Orange #FF911A
|
|
||||||
Translucent Purple #D6ABFF
|
|
||||||
Translucent Pink #F9C1BD
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
Jade White
|
|
||||||
Hex:#FFFFFF
|
|
||||||
Magenta
|
|
||||||
Hex:#EC008C
|
|
||||||
Gold
|
|
||||||
Hex:#E4BD68
|
|
||||||
Mistletoe Green
|
|
||||||
Hex:#3F8E43
|
|
||||||
Red
|
|
||||||
Hex:#C12E1F
|
|
||||||
Purple
|
|
||||||
Hex:#5E43B7
|
|
||||||
Beige
|
|
||||||
Hex:#F7E6DE
|
|
||||||
Pink
|
|
||||||
Hex:#F55A74
|
|
||||||
Sunflower Yellow
|
|
||||||
Hex:#FEC600
|
|
||||||
Bronze
|
|
||||||
Hex:#847D48
|
|
||||||
Turquoise
|
|
||||||
Hex:#00B1B7
|
|
||||||
Indigo Purple
|
|
||||||
Hex:#482960
|
|
||||||
Light Gray
|
|
||||||
Hex:#D1D3D5
|
|
||||||
Hot Pink
|
|
||||||
Hex:#F5547C
|
|
||||||
Yellow
|
|
||||||
Hex:#F4EE2A
|
|
||||||
Cocoa Brown
|
|
||||||
Hex:#6F5034
|
|
||||||
Cyan
|
|
||||||
Hex:#0086D6
|
|
||||||
Blue Grey
|
|
||||||
Hex:#5B6579
|
|
||||||
Silver
|
|
||||||
Hex:#A6A9AA
|
|
||||||
Orange
|
|
||||||
Hex:#FF6A13
|
|
||||||
Bright Green
|
|
||||||
Hex:#BECF00
|
|
||||||
Brown
|
|
||||||
Hex:#9D432C
|
|
||||||
Blue
|
|
||||||
Hex:#0A2989
|
|
||||||
Dark Gray
|
|
||||||
Hex:#545454
|
|
||||||
Gray
|
|
||||||
Hex:#8E9089
|
|
||||||
Pumpkin Orange
|
|
||||||
Hex:#FF9016
|
|
||||||
Bambu Green
|
|
||||||
Hex:#00AE42
|
|
||||||
Maroon Red
|
|
||||||
Hex:#9D2235
|
|
||||||
Cobalt Blue
|
|
||||||
Hex:#0056B8
|
|
||||||
Black
|
|
||||||
Hex:#000000
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
Ivory White
|
|
||||||
Hex:#FFFFFF
|
|
||||||
Bone White
|
|
||||||
Hex:#CBC6B8
|
|
||||||
Desert Tan
|
|
||||||
Hex:#E8DBB7
|
|
||||||
Latte Brown
|
|
||||||
Hex:#D3B7A7
|
|
||||||
Caramel
|
|
||||||
Hex:#AE835B
|
|
||||||
Terracotta
|
|
||||||
Hex:#B15533
|
|
||||||
Dark Brown
|
|
||||||
Hex:#7D6556
|
|
||||||
Dark Chocolate
|
|
||||||
Hex:#4D3324
|
|
||||||
Lilac Purple
|
|
||||||
Hex:#AE96D4
|
|
||||||
Sakura Pink
|
|
||||||
Hex:#E8AFCF
|
|
||||||
Mandarin Orange
|
|
||||||
Hex:#F99963
|
|
||||||
Lemon Yellow
|
|
||||||
Hex:#F7D959
|
|
||||||
Plum
|
|
||||||
Hex:#950051
|
|
||||||
Scarlet Red
|
|
||||||
Hex:#DE4343
|
|
||||||
Dark Red
|
|
||||||
Hex:#BB3D43
|
|
||||||
Dark Green
|
|
||||||
Hex:#68724D
|
|
||||||
Grass Green
|
|
||||||
Hex:#61C680
|
|
||||||
Apple Green
|
|
||||||
Hex:#C2E189
|
|
||||||
Ice Blue
|
|
||||||
Hex:#A3D8E1
|
|
||||||
Sky Blue
|
|
||||||
Hex:#56B7E6
|
|
||||||
Marine Blue
|
|
||||||
Hex:#0078BF
|
|
||||||
Dark Blue
|
|
||||||
Hex:#042F56
|
|
||||||
Ash Gray
|
|
||||||
Hex:#9B9EA0
|
|
||||||
Nardo Gray
|
|
||||||
Hex:#757575
|
|
||||||
Charcoal
|
|
||||||
Hex:#000000
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
Black Walnut #4F3F24
|
|
||||||
Rosewood #4C241C
|
|
||||||
Clay Brown #995F11
|
|
||||||
Classic Birch #918669
|
|
||||||
White Oak #D6CCA3
|
|
||||||
Ochre Yellow #C98935
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Bambu-Run MQTT Collector
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=exec
|
|
||||||
WorkingDirectory={{REPO_DIR}}
|
|
||||||
EnvironmentFile={{REPO_DIR}}/.env
|
|
||||||
Environment=DJANGO_SETTINGS_MODULE=standalone.settings
|
|
||||||
ExecStart={{VENV_DIR}}/bin/python standalone/manage.py bambu_collector
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=10
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Bambu-Run MCP Server
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=exec
|
|
||||||
WorkingDirectory={{REPO_DIR}}
|
|
||||||
EnvironmentFile={{REPO_DIR}}/.env
|
|
||||||
Environment=DJANGO_SETTINGS_MODULE=standalone.settings
|
|
||||||
ExecStart={{VENV_DIR}}/bin/python standalone/manage.py bambu_mcp_server --transport sse --host 0.0.0.0 --port 8808
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=10
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Bambu-Run Web Dashboard
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=exec
|
|
||||||
WorkingDirectory={{REPO_DIR}}
|
|
||||||
EnvironmentFile={{REPO_DIR}}/.env
|
|
||||||
Environment=DJANGO_SETTINGS_MODULE=standalone.settings
|
|
||||||
ExecStart={{VENV_DIR}}/bin/gunicorn standalone.wsgi:application --bind 0.0.0.0:8000 --workers {{WORKERS}} --timeout 120
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=5
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Bambu-Run convenience wrapper
|
|
||||||
# Usage: ./native/bambu-run.sh {start|stop|restart|status|logs|update}
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
|
||||||
VENV_DIR="$REPO_DIR/.venv"
|
|
||||||
MANAGE="$VENV_DIR/bin/python $REPO_DIR/standalone/manage.py"
|
|
||||||
SERVICES="bambu-run-web.service bambu-run-collector.service"
|
|
||||||
|
|
||||||
# Include MCP service if installed
|
|
||||||
SERVICE_DIR="$HOME/.config/systemd/user"
|
|
||||||
if [ -f "$SERVICE_DIR/bambu-run-mcp.service" ]; then
|
|
||||||
SERVICES="$SERVICES bambu-run-mcp.service"
|
|
||||||
fi
|
|
||||||
|
|
||||||
case "${1:-help}" in
|
|
||||||
start)
|
|
||||||
systemctl --user start $SERVICES
|
|
||||||
echo "Bambu-Run started."
|
|
||||||
;;
|
|
||||||
stop)
|
|
||||||
systemctl --user stop $SERVICES
|
|
||||||
echo "Bambu-Run stopped."
|
|
||||||
;;
|
|
||||||
restart)
|
|
||||||
systemctl --user restart $SERVICES
|
|
||||||
echo "Bambu-Run restarted."
|
|
||||||
;;
|
|
||||||
status)
|
|
||||||
systemctl --user status $SERVICES --no-pager
|
|
||||||
;;
|
|
||||||
logs)
|
|
||||||
JOURNAL_UNITS="-u bambu-run-web -u bambu-run-collector"
|
|
||||||
if [ -f "$SERVICE_DIR/bambu-run-mcp.service" ]; then
|
|
||||||
JOURNAL_UNITS="$JOURNAL_UNITS -u bambu-run-mcp"
|
|
||||||
fi
|
|
||||||
journalctl --user $JOURNAL_UNITS -f --no-hostname
|
|
||||||
;;
|
|
||||||
update)
|
|
||||||
echo "Pulling latest code..."
|
|
||||||
cd "$REPO_DIR" && git pull
|
|
||||||
|
|
||||||
echo "Installing dependencies..."
|
|
||||||
EXTRAS="standalone"
|
|
||||||
if [ -f "$SERVICE_DIR/bambu-run-mcp.service" ]; then
|
|
||||||
EXTRAS="standalone,mcp"
|
|
||||||
fi
|
|
||||||
"$VENV_DIR/bin/pip" install --quiet ".[$EXTRAS]"
|
|
||||||
|
|
||||||
echo "Running migrations..."
|
|
||||||
$MANAGE migrate --noinput
|
|
||||||
|
|
||||||
echo "Collecting static files..."
|
|
||||||
$MANAGE collectstatic --noinput --clear 2>/dev/null
|
|
||||||
|
|
||||||
echo "Restarting services..."
|
|
||||||
systemctl --user restart $SERVICES
|
|
||||||
|
|
||||||
echo "Update complete."
|
|
||||||
;;
|
|
||||||
help|*)
|
|
||||||
echo "Usage: $0 {start|stop|restart|status|logs|update}"
|
|
||||||
echo
|
|
||||||
echo " start Start web + collector services"
|
|
||||||
echo " stop Stop web + collector services"
|
|
||||||
echo " restart Restart web + collector services"
|
|
||||||
echo " status Show service status"
|
|
||||||
echo " logs Tail live logs (Ctrl+C to stop)"
|
|
||||||
echo " update Pull latest code, install deps, migrate, restart"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "bambu-run"
|
name = "bambu-run"
|
||||||
version = "0.1.7"
|
version = "0.1.0"
|
||||||
description = "Django reusable app for Bambu Lab 3D printer monitoring and filament inventory management"
|
description = "Django reusable app for Bambu Lab 3D printer monitoring and filament inventory management"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = {text = "MIT"}
|
license = {text = "MIT"}
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.10"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Runnan Li"},
|
{name = "Runnan Li"},
|
||||||
]
|
]
|
||||||
@@ -40,9 +40,6 @@ standalone = [
|
|||||||
"python-dotenv",
|
"python-dotenv",
|
||||||
"whitenoise",
|
"whitenoise",
|
||||||
]
|
]
|
||||||
mcp = [
|
|
||||||
"mcp[cli]>=1.0",
|
|
||||||
]
|
|
||||||
dev = [
|
dev = [
|
||||||
"ruff",
|
"ruff",
|
||||||
"pytest",
|
"pytest",
|
||||||
@@ -62,7 +59,3 @@ bambu_run = [
|
|||||||
"templates/bambu_run/*.html",
|
"templates/bambu_run/*.html",
|
||||||
"static/bambu_run/**/*",
|
"static/bambu_run/**/*",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
|
||||||
DJANGO_SETTINGS_MODULE = "tests.settings"
|
|
||||||
python_files = "test_*.py"
|
|
||||||
|
|||||||
290
setup.sh
290
setup.sh
@@ -1,290 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Bambu-Run Native Setup — single entry point for Raspberry Pi (or any Linux)
|
|
||||||
# Usage: git clone ... && cd Bambu-Run && bash setup.sh
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
VENV_DIR="$REPO_DIR/.venv"
|
|
||||||
ENV_FILE="$REPO_DIR/.env"
|
|
||||||
MANAGE="$VENV_DIR/bin/python $REPO_DIR/standalone/manage.py"
|
|
||||||
SERVICE_DIR="$HOME/.config/systemd/user"
|
|
||||||
|
|
||||||
green() { printf '\033[1;32m%s\033[0m\n' "$*"; }
|
|
||||||
yellow() { printf '\033[1;33m%s\033[0m\n' "$*"; }
|
|
||||||
red() { printf '\033[1;31m%s\033[0m\n' "$*"; }
|
|
||||||
|
|
||||||
# ── 1. Pre-flight checks ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
green "=== Bambu-Run Native Setup ==="
|
|
||||||
echo
|
|
||||||
|
|
||||||
# Acquire sudo upfront and keep it alive for the duration of the script
|
|
||||||
echo "This script needs sudo for iptables (port redirect) and apt (dependencies)."
|
|
||||||
sudo -v
|
|
||||||
while true; do sudo -n true; sleep 50; kill -0 "$$" || exit; done 2>/dev/null &
|
|
||||||
SUDO_KEEPALIVE_PID=$!
|
|
||||||
trap 'kill "$SUDO_KEEPALIVE_PID" 2>/dev/null' EXIT
|
|
||||||
echo
|
|
||||||
|
|
||||||
# Python >= 3.10
|
|
||||||
PYTHON=""
|
|
||||||
for cmd in python3.12 python3.11 python3.10 python3; do
|
|
||||||
if command -v "$cmd" &>/dev/null; then
|
|
||||||
ver=$("$cmd" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
|
|
||||||
major=${ver%%.*}
|
|
||||||
minor=${ver##*.}
|
|
||||||
if [ "$major" -ge 3 ] && [ "$minor" -ge 10 ]; then
|
|
||||||
PYTHON="$cmd"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ -z "$PYTHON" ]; then
|
|
||||||
red "Error: Python >= 3.10 is required."
|
|
||||||
echo "Install it with: sudo apt install python3"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
green "Found $PYTHON ($ver)"
|
|
||||||
|
|
||||||
# Ensure python3-venv is available
|
|
||||||
if ! "$PYTHON" -m venv --help &>/dev/null; then
|
|
||||||
yellow "Installing python3-venv..."
|
|
||||||
sudo apt-get update -qq && sudo apt-get install -y -qq python3-venv
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Detect RAM for gunicorn worker count
|
|
||||||
TOTAL_RAM_KB=$(grep MemTotal /proc/meminfo 2>/dev/null | awk '{print $2}' || echo 0)
|
|
||||||
if [ "$TOTAL_RAM_KB" -lt 1048576 ]; then
|
|
||||||
WORKERS=1
|
|
||||||
else
|
|
||||||
WORKERS=2
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Prompt for access port
|
|
||||||
while true; do
|
|
||||||
read -rp "Choose Bambu-Run Dashboard access port (Default: 80): " ACCESS_PORT
|
|
||||||
ACCESS_PORT="${ACCESS_PORT:-80}"
|
|
||||||
if [[ "$ACCESS_PORT" =~ ^[0-9]+$ ]] && [ "$ACCESS_PORT" -ge 1 ] && [ "$ACCESS_PORT" -le 65535 ]; then
|
|
||||||
break
|
|
||||||
else
|
|
||||||
red "Invalid port '$ACCESS_PORT'. Please enter a number between 1 and 65535."
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
green "Dashboard will be accessible on port $ACCESS_PORT."
|
|
||||||
|
|
||||||
# ── 2. Venv + install ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
if [ ! -d "$VENV_DIR" ]; then
|
|
||||||
green "Creating virtual environment..."
|
|
||||||
"$PYTHON" -m venv "$VENV_DIR"
|
|
||||||
else
|
|
||||||
yellow "Virtual environment already exists, reusing."
|
|
||||||
fi
|
|
||||||
|
|
||||||
green "Installing dependencies..."
|
|
||||||
|
|
||||||
# Stub opencv-python (same trick as Dockerfile — avoids hour-long ARM build)
|
|
||||||
"$VENV_DIR/bin/python" -c "
|
|
||||||
import site, pathlib
|
|
||||||
d = pathlib.Path(site.getsitepackages()[0]) / 'opencv_python-4.99.0.dist-info'
|
|
||||||
if not d.exists():
|
|
||||||
d.mkdir()
|
|
||||||
(d / 'METADATA').write_text('Metadata-Version: 2.1\nName: opencv-python\nVersion: 4.99.0\n')
|
|
||||||
(d / 'INSTALLER').write_text('pip\n')
|
|
||||||
(d / 'RECORD').write_text('')
|
|
||||||
print(' opencv stub created')
|
|
||||||
else:
|
|
||||||
print(' opencv stub already exists')
|
|
||||||
"
|
|
||||||
|
|
||||||
"$VENV_DIR/bin/pip" install --quiet --upgrade pip
|
|
||||||
"$VENV_DIR/bin/pip" install --quiet ".[standalone]"
|
|
||||||
|
|
||||||
# ── 3. Interactive .env ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
if [ ! -f "$ENV_FILE" ]; then
|
|
||||||
green "Setting up .env configuration..."
|
|
||||||
echo
|
|
||||||
|
|
||||||
read -rp "Bambu Lab email: " BAMBU_USERNAME
|
|
||||||
read -rsp "Bambu Lab password: " BAMBU_PASSWORD
|
|
||||||
echo
|
|
||||||
while true; do
|
|
||||||
read -rp "Timezone [UTC] (e.g. America/Sydney): " TIMEZONE
|
|
||||||
TIMEZONE="${TIMEZONE:-UTC}"
|
|
||||||
if "$VENV_DIR/bin/python" -c "import zoneinfo; zoneinfo.ZoneInfo('$TIMEZONE')" 2>/dev/null; then
|
|
||||||
break
|
|
||||||
else
|
|
||||||
red "Unknown timezone '$TIMEZONE'. Find yours at: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# Generate a random Django secret key
|
|
||||||
DJANGO_SECRET_KEY=$("$VENV_DIR/bin/python" -c "import secrets; print(secrets.token_urlsafe(50))")
|
|
||||||
|
|
||||||
cat > "$ENV_FILE" <<EOF
|
|
||||||
BAMBU_USERNAME=$BAMBU_USERNAME
|
|
||||||
BAMBU_PASSWORD=$BAMBU_PASSWORD
|
|
||||||
TIMEZONE=$TIMEZONE
|
|
||||||
DJANGO_SECRET_KEY=$DJANGO_SECRET_KEY
|
|
||||||
DEBUG=False
|
|
||||||
EOF
|
|
||||||
green ".env created."
|
|
||||||
else
|
|
||||||
yellow ".env already exists, skipping."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 4. Migrate ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
green "Running database migrations..."
|
|
||||||
$MANAGE migrate --noinput
|
|
||||||
|
|
||||||
# ── 5. Bambu authentication ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
if ! grep -q '^BAMBU_TOKEN=' "$ENV_FILE" 2>/dev/null; then
|
|
||||||
green "Authenticating with Bambu Lab (email verification required)..."
|
|
||||||
echo "A verification code will be sent to your email."
|
|
||||||
echo
|
|
||||||
|
|
||||||
# Run collector in --once mode for interactive auth
|
|
||||||
$MANAGE bambu_collector --once || true
|
|
||||||
|
|
||||||
echo
|
|
||||||
read -rp "Paste your BAMBU_TOKEN from above (or press Enter to skip): " TOKEN
|
|
||||||
if [ -n "$TOKEN" ]; then
|
|
||||||
echo "BAMBU_TOKEN=$TOKEN" >> "$ENV_FILE"
|
|
||||||
green "Token saved to .env."
|
|
||||||
else
|
|
||||||
yellow "Skipped — you can add BAMBU_TOKEN to .env later."
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
yellow "BAMBU_TOKEN already in .env, skipping auth."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 6. Superuser ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
echo
|
|
||||||
if $MANAGE shell -c "from django.contrib.auth import get_user_model; exit(0 if get_user_model().objects.filter(is_superuser=True).exists() else 1)" 2>/dev/null; then
|
|
||||||
yellow "Superuser already exists, skipping. (To add another, run: python standalone/manage.py createsuperuser)"
|
|
||||||
else
|
|
||||||
green "Create your dashboard login (Django superuser):"
|
|
||||||
$MANAGE createsuperuser || yellow "Superuser creation skipped."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 7. Collect static files ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
green "Collecting static files..."
|
|
||||||
$MANAGE collectstatic --noinput --clear 2>/dev/null
|
|
||||||
|
|
||||||
# ── 8. Seed filament colors ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
echo
|
|
||||||
read -rp "Import Bambu Lab filament color catalog? [Y/n] " SEED_COLORS
|
|
||||||
SEED_COLORS="${SEED_COLORS:-Y}"
|
|
||||||
if [[ "$SEED_COLORS" =~ ^[Yy] ]]; then
|
|
||||||
$MANAGE bambu_import_colors "$REPO_DIR/docs/Bambu_Color_Catalog/"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 9. Install systemd services ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
green "Installing systemd user services..."
|
|
||||||
mkdir -p "$SERVICE_DIR"
|
|
||||||
|
|
||||||
# Generate unit files with actual paths substituted
|
|
||||||
sed "s|{{REPO_DIR}}|$REPO_DIR|g; s|{{VENV_DIR}}|$VENV_DIR|g; s|{{WORKERS}}|$WORKERS|g" \
|
|
||||||
"$REPO_DIR/native/bambu-run-web.service" > "$SERVICE_DIR/bambu-run-web.service"
|
|
||||||
|
|
||||||
sed "s|{{REPO_DIR}}|$REPO_DIR|g; s|{{VENV_DIR}}|$VENV_DIR|g" \
|
|
||||||
"$REPO_DIR/native/bambu-run-collector.service" > "$SERVICE_DIR/bambu-run-collector.service"
|
|
||||||
|
|
||||||
systemctl --user daemon-reload
|
|
||||||
systemctl --user enable bambu-run-web.service bambu-run-collector.service
|
|
||||||
|
|
||||||
# ── 9b. Optional MCP server ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
echo
|
|
||||||
MCP_ENABLED=false
|
|
||||||
read -rp "Enable MCP server for AI agent access (Claude Desktop, Claude Code, etc.)? [y/N] " ENABLE_MCP
|
|
||||||
if [[ "$ENABLE_MCP" =~ ^[Yy] ]]; then
|
|
||||||
green "Installing MCP dependencies..."
|
|
||||||
"$VENV_DIR/bin/pip" install --quiet ".[mcp]"
|
|
||||||
|
|
||||||
sed "s|{{REPO_DIR}}|$REPO_DIR|g; s|{{VENV_DIR}}|$VENV_DIR|g" \
|
|
||||||
"$REPO_DIR/native/bambu-run-mcp.service" > "$SERVICE_DIR/bambu-run-mcp.service"
|
|
||||||
|
|
||||||
systemctl --user daemon-reload
|
|
||||||
systemctl --user enable bambu-run-mcp.service
|
|
||||||
systemctl --user start bambu-run-mcp.service
|
|
||||||
MCP_ENABLED=true
|
|
||||||
green "MCP server enabled on port 8808."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Enable linger so services survive SSH logout
|
|
||||||
loginctl enable-linger "$USER" 2>/dev/null || \
|
|
||||||
sudo loginctl enable-linger "$USER" 2>/dev/null || \
|
|
||||||
yellow "Warning: Could not enable linger. Services may stop when you disconnect SSH."
|
|
||||||
|
|
||||||
systemctl --user start bambu-run-web.service bambu-run-collector.service
|
|
||||||
|
|
||||||
# ── 10. Port redirect (ACCESS_PORT → 8000 via iptables if needed) ────────────
|
|
||||||
|
|
||||||
PORT_OK=false
|
|
||||||
if [ "$ACCESS_PORT" -eq 8000 ]; then
|
|
||||||
# Gunicorn already on 8000 — no redirect needed
|
|
||||||
green "Using port 8000 directly (no redirect needed)."
|
|
||||||
PORT_OK=true
|
|
||||||
else
|
|
||||||
if sudo iptables -t nat -C PREROUTING -p tcp --dport "$ACCESS_PORT" -j REDIRECT --to-port 8000 2>/dev/null; then
|
|
||||||
yellow "Port $ACCESS_PORT → 8000 redirect already set."
|
|
||||||
PORT_OK=true
|
|
||||||
else
|
|
||||||
# Ensure iptables is available
|
|
||||||
if ! command -v iptables &>/dev/null; then
|
|
||||||
yellow "Installing iptables..."
|
|
||||||
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y -qq iptables
|
|
||||||
fi
|
|
||||||
if sudo iptables -t nat -A PREROUTING -p tcp --dport "$ACCESS_PORT" -j REDIRECT --to-port 8000 && \
|
|
||||||
sudo iptables -t nat -A OUTPUT -o lo -p tcp --dport "$ACCESS_PORT" -j REDIRECT --to-port 8000; then
|
|
||||||
green "Port $ACCESS_PORT → 8000 redirect configured."
|
|
||||||
PORT_OK=true
|
|
||||||
# Persist so it survives reboot
|
|
||||||
if ! command -v netfilter-persistent &>/dev/null; then
|
|
||||||
yellow "Installing iptables-persistent to survive reboots..."
|
|
||||||
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y -qq iptables-persistent
|
|
||||||
fi
|
|
||||||
sudo netfilter-persistent save 2>/dev/null || sudo sh -c 'iptables-save > /etc/iptables/rules.v4'
|
|
||||||
else
|
|
||||||
yellow "Warning: Could not set port $ACCESS_PORT redirect (sudo required). Access via http://<ip>:8000"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 11. Summary ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
PI_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
|
|
||||||
if [ "$PORT_OK" = true ] && [ "$ACCESS_PORT" -ne 8000 ]; then
|
|
||||||
DASHBOARD_URL="http://${PI_IP:-localhost}$([ "$ACCESS_PORT" -eq 80 ] && echo '' || echo ":$ACCESS_PORT")"
|
|
||||||
else
|
|
||||||
DASHBOARD_URL="http://${PI_IP:-localhost}:8000"
|
|
||||||
fi
|
|
||||||
echo
|
|
||||||
green "============================================"
|
|
||||||
green " Bambu-Run is running!"
|
|
||||||
green "============================================"
|
|
||||||
echo
|
|
||||||
echo " Dashboard: $DASHBOARD_URL"
|
|
||||||
if [ "$MCP_ENABLED" = true ]; then
|
|
||||||
echo " MCP Server: http://${PI_IP:-localhost}:8808/sse"
|
|
||||||
fi
|
|
||||||
echo " Status: systemctl --user status bambu-run-web bambu-run-collector"
|
|
||||||
echo " Logs: journalctl --user -u bambu-run-web -u bambu-run-collector -f"
|
|
||||||
echo " Helper: ./native/bambu-run.sh {start|stop|restart|status|logs|update}"
|
|
||||||
echo
|
|
||||||
if [ "$MCP_ENABLED" = true ]; then
|
|
||||||
echo " Claude Desktop config:"
|
|
||||||
echo " {\"mcpServers\":{\"bambu-run\":{\"url\":\"http://${PI_IP:-localhost}:8808/sse\"}}}"
|
|
||||||
echo
|
|
||||||
fi
|
|
||||||
echo " Services auto-start on boot. Safe to close SSH."
|
|
||||||
echo
|
|
||||||
@@ -5,15 +5,7 @@ import sys
|
|||||||
|
|
||||||
# Ensure the project root (/app) is on sys.path so that both 'standalone'
|
# Ensure the project root (/app) is on sys.path so that both 'standalone'
|
||||||
# and 'bambu_run' are importable regardless of where this script is invoked from.
|
# and 'bambu_run' are importable regardless of where this script is invoked from.
|
||||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
sys.path.insert(0, PROJECT_ROOT)
|
|
||||||
|
|
||||||
# Load .env so manage.py commands pick up env vars outside of systemd/Docker
|
|
||||||
try:
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
load_dotenv(os.path.join(PROJECT_ROOT, ".env"))
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@@ -111,8 +111,6 @@ 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(",")
|
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
LOGGING = {
|
LOGGING = {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
"""Minimal Django settings for running bambu_run's pytest suite (in-memory SQLite)."""
|
|
||||||
|
|
||||||
SECRET_KEY = "test-secret-key"
|
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
|
||||||
"django.contrib.auth",
|
|
||||||
"django.contrib.contenttypes",
|
|
||||||
"django.contrib.sessions",
|
|
||||||
"bambu_run",
|
|
||||||
]
|
|
||||||
|
|
||||||
MIDDLEWARE = [
|
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
|
||||||
]
|
|
||||||
|
|
||||||
TEMPLATES = [
|
|
||||||
{
|
|
||||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
|
||||||
"APP_DIRS": True,
|
|
||||||
"OPTIONS": {
|
|
||||||
"context_processors": [
|
|
||||||
"django.template.context_processors.request",
|
|
||||||
"django.contrib.auth.context_processors.auth",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
DATABASES = {
|
|
||||||
"default": {
|
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
|
||||||
"NAME": ":memory:",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
USE_TZ = True
|
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
|
||||||
|
|
||||||
ROOT_URLCONF = "tests.urls"
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from bambu_run.diagnostics import redact_diagnostics, build_diagnostics_report
|
|
||||||
|
|
||||||
|
|
||||||
def test_redacts_password_and_token_like_keys():
|
|
||||||
data = {"BAMBU_PASSWORD": "hunter2", "access_token": "abc123", "ok": "fine"}
|
|
||||||
|
|
||||||
redacted = redact_diagnostics(data)
|
|
||||||
|
|
||||||
assert redacted["BAMBU_PASSWORD"] == "***REDACTED***"
|
|
||||||
assert redacted["access_token"] == "***REDACTED***"
|
|
||||||
assert redacted["ok"] == "fine"
|
|
||||||
|
|
||||||
|
|
||||||
def test_masks_known_identifier_keys_partially():
|
|
||||||
data = {"dev_id": "31B8BP592601478", "tray_uuid": "EE37828FA8844DE1AB12"}
|
|
||||||
|
|
||||||
redacted = redact_diagnostics(data)
|
|
||||||
|
|
||||||
assert redacted["dev_id"] == "31B8...1478"
|
|
||||||
assert redacted["tray_uuid"] == "EE37...AB12"
|
|
||||||
|
|
||||||
|
|
||||||
def test_short_identifier_values_fully_masked():
|
|
||||||
data = {"dev_id": "short"}
|
|
||||||
|
|
||||||
redacted = redact_diagnostics(data)
|
|
||||||
|
|
||||||
assert redacted["dev_id"] == "***"
|
|
||||||
|
|
||||||
|
|
||||||
def test_recurses_into_nested_structures():
|
|
||||||
data = {"devices": [{"dev_id": "31B8BP592601478", "name": "RNL-H2C"}]}
|
|
||||||
|
|
||||||
redacted = redact_diagnostics(data)
|
|
||||||
|
|
||||||
assert redacted["devices"][0]["dev_id"] == "31B8...1478"
|
|
||||||
assert redacted["devices"][0]["name"] == "RNL-H2C"
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_redact_passthrough_keeps_original_values():
|
|
||||||
data = {"dev_id": "31B8BP592601478", "BAMBU_PASSWORD": "hunter2"}
|
|
||||||
|
|
||||||
result = redact_diagnostics(data, redact=False)
|
|
||||||
|
|
||||||
assert result == data
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_diagnostics_report_structure():
|
|
||||||
devices = [{"dev_id": "SERIAL-A", "name": "Printer A", "dev_product_name": "H2C"}]
|
|
||||||
raw_payloads = {"SERIAL-A": {"device": {"extruder": {"info": []}}}}
|
|
||||||
|
|
||||||
report = build_diagnostics_report(devices, raw_payloads)
|
|
||||||
|
|
||||||
assert report["device_count"] == 1
|
|
||||||
assert "generated_at" in report
|
|
||||||
assert report["devices"][0]["device_info"]["dev_id"] == "SERIAL-A"
|
|
||||||
assert report["devices"][0]["raw_mqtt_payload"] == {"device": {"extruder": {"info": []}}}
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_diagnostics_report_handles_missing_payload():
|
|
||||||
devices = [{"dev_id": "SERIAL-A", "name": "Printer A"}]
|
|
||||||
|
|
||||||
report = build_diagnostics_report(devices, raw_payloads={})
|
|
||||||
|
|
||||||
assert report["devices"][0]["raw_mqtt_payload"] is None
|
|
||||||
assert report["devices"][0]["note"] == "No MQTT data received within the listen window."
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from bambu_run.management.commands.bambu_collector import (
|
|
||||||
Command,
|
|
||||||
DeviceSession,
|
|
||||||
resolve_printer_device,
|
|
||||||
)
|
|
||||||
from bambu_run.models import PrintJob, PrinterMetrics
|
|
||||||
|
|
||||||
|
|
||||||
class FakeClient:
|
|
||||||
"""Stub in place of BambuPrinter — returns canned snapshots, no real MQTT."""
|
|
||||||
|
|
||||||
def __init__(self, snapshots):
|
|
||||||
self._snapshots = snapshots
|
|
||||||
self._index = 0
|
|
||||||
self._client = None # cloud BambuClient handle used by cloud task sync
|
|
||||||
|
|
||||||
def get_snapshot(self):
|
|
||||||
snap = self._snapshots[min(self._index, len(self._snapshots) - 1)]
|
|
||||||
self._index += 1
|
|
||||||
return snap
|
|
||||||
|
|
||||||
|
|
||||||
def make_session(device_id, name, snapshots):
|
|
||||||
printer = resolve_printer_device(device_id, {"name": name, "dev_product_name": "H2C"})
|
|
||||||
return DeviceSession(device_id=device_id, client=FakeClient(snapshots), printer=printer)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_collects_metrics_against_the_correct_printer_per_session():
|
|
||||||
session_a = make_session("SERIAL-A", "Printer A", [{"nozzle_temp": 200, "gcode_state": "IDLE"}])
|
|
||||||
session_b = make_session("SERIAL-B", "Printer B", [{"nozzle_temp": 210, "gcode_state": "IDLE"}])
|
|
||||||
|
|
||||||
cmd = Command()
|
|
||||||
cmd.verbose = False
|
|
||||||
cmd._collect_printer_data(session_a)
|
|
||||||
cmd._collect_printer_data(session_b)
|
|
||||||
|
|
||||||
metric_a = PrinterMetrics.objects.get(device=session_a.printer)
|
|
||||||
metric_b = PrinterMetrics.objects.get(device=session_b.printer)
|
|
||||||
assert metric_a.nozzle_temp == 200
|
|
||||||
assert metric_b.nozzle_temp == 210
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_print_job_tracking_is_isolated_per_session():
|
|
||||||
session_a = make_session(
|
|
||||||
"SERIAL-A",
|
|
||||||
"Printer A",
|
|
||||||
[
|
|
||||||
{"gcode_state": "RUNNING", "subtask_name": "job_A", "print_percent": 10},
|
|
||||||
{"gcode_state": "FINISH", "subtask_name": "job_A", "print_percent": 100},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
session_b = make_session("SERIAL-B", "Printer B", [{"gcode_state": "IDLE"}])
|
|
||||||
|
|
||||||
cmd = Command()
|
|
||||||
cmd.verbose = False
|
|
||||||
cmd._collect_printer_data(session_a)
|
|
||||||
cmd._collect_printer_data(session_b)
|
|
||||||
cmd._collect_printer_data(session_a)
|
|
||||||
|
|
||||||
assert PrintJob.objects.filter(device=session_a.printer).count() == 1
|
|
||||||
job = PrintJob.objects.get(device=session_a.printer)
|
|
||||||
assert job.final_status == "FINISH"
|
|
||||||
assert session_a.current_print_job is None
|
|
||||||
|
|
||||||
assert PrintJob.objects.filter(device=session_b.printer).count() == 0
|
|
||||||
assert session_b.current_print_job is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_one_session_error_does_not_affect_another_session():
|
|
||||||
session_a = make_session("SERIAL-A", "Printer A", [{"nozzle_temp": 200, "gcode_state": "IDLE"}])
|
|
||||||
session_b = make_session("SERIAL-B", "Printer B", [{"nozzle_temp": 210, "gcode_state": "IDLE"}])
|
|
||||||
|
|
||||||
class ExplodingClient:
|
|
||||||
def get_snapshot(self):
|
|
||||||
raise RuntimeError("MQTT connection lost")
|
|
||||||
|
|
||||||
session_a.client = ExplodingClient()
|
|
||||||
|
|
||||||
cmd = Command()
|
|
||||||
cmd.verbose = False
|
|
||||||
cmd._collect_printer_data(session_a)
|
|
||||||
cmd._collect_printer_data(session_b)
|
|
||||||
|
|
||||||
assert session_a.error_count == 1
|
|
||||||
assert PrinterMetrics.objects.filter(device=session_b.printer).exists()
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from django.urls import reverse
|
|
||||||
|
|
||||||
from bambu_run.models import Printer
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def logged_in_client(client, django_user_model):
|
|
||||||
user = django_user_model.objects.create_user(username="tester", password="pw")
|
|
||||||
client.force_login(user)
|
|
||||||
return client
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_dashboard_with_no_printers_shows_error(logged_in_client):
|
|
||||||
resp = logged_in_client.get(reverse("bambu_run:printer_dashboard"))
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert "error" in resp.context
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_dashboard_defaults_to_first_active_printer(logged_in_client):
|
|
||||||
printer = Printer.objects.create(name="Only Printer", model="H2C", is_active=True)
|
|
||||||
|
|
||||||
resp = logged_in_client.get(reverse("bambu_run:printer_dashboard"))
|
|
||||||
|
|
||||||
assert resp.context["printer_device"].pk == printer.pk
|
|
||||||
assert list(resp.context["all_printers"]) == [printer]
|
|
||||||
# Switcher shows even with a single printer, as a hint that multi-printer exists.
|
|
||||||
assert resp.context["show_printer_switcher"] is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_dashboard_hides_switcher_with_zero_printers(logged_in_client):
|
|
||||||
resp = logged_in_client.get(reverse("bambu_run:printer_dashboard"))
|
|
||||||
|
|
||||||
assert resp.context["show_printer_switcher"] is False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_dashboard_pk_route_shows_requested_printer(logged_in_client):
|
|
||||||
Printer.objects.create(name="Printer A", model="H2C", is_active=True)
|
|
||||||
printer_b = Printer.objects.create(name="Printer B", model="X1C", is_active=True)
|
|
||||||
|
|
||||||
resp = logged_in_client.get(
|
|
||||||
reverse("bambu_run:printer_dashboard", kwargs={"pk": printer_b.pk})
|
|
||||||
)
|
|
||||||
|
|
||||||
assert resp.context["printer_device"].pk == printer_b.pk
|
|
||||||
assert resp.context["device_name"] == "Printer B"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_dashboard_unknown_pk_returns_404(logged_in_client):
|
|
||||||
resp = logged_in_client.get(
|
|
||||||
reverse("bambu_run:printer_dashboard", kwargs={"pk": 99999})
|
|
||||||
)
|
|
||||||
assert resp.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_api_pk_route_returns_only_requested_printer_data(logged_in_client):
|
|
||||||
from bambu_run.models import PrinterMetrics
|
|
||||||
from django.utils import timezone
|
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
printer_a = Printer.objects.create(name="Printer A", model="H2C", is_active=True)
|
|
||||||
printer_b = Printer.objects.create(name="Printer B", model="X1C", is_active=True)
|
|
||||||
PrinterMetrics.objects.create(device=printer_a, timestamp=timezone.now(), nozzle_temp=Decimal("200"))
|
|
||||||
PrinterMetrics.objects.create(device=printer_b, timestamp=timezone.now(), nozzle_temp=Decimal("210"))
|
|
||||||
|
|
||||||
resp = logged_in_client.get(
|
|
||||||
reverse("bambu_run:printer_api", kwargs={"pk": printer_b.pk})
|
|
||||||
)
|
|
||||||
|
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.json()
|
|
||||||
assert data["nozzle_temp"] == [210.0]
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from bambu_run.management.commands.bambu_collector import resolve_printer_device
|
|
||||||
from bambu_run.models import Printer
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_creates_new_printer_keyed_by_serial():
|
|
||||||
printer = resolve_printer_device(
|
|
||||||
"0309DA123456", {"name": "RNL-H2C", "dev_product_name": "H2C"}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert printer.serial_number == "0309DA123456"
|
|
||||||
assert printer.name == "RNL-H2C"
|
|
||||||
assert printer.model == "H2C"
|
|
||||||
assert printer.is_active is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_second_call_with_same_serial_does_not_create_duplicate():
|
|
||||||
first = resolve_printer_device("SERIAL-A", {"name": "Printer A", "dev_product_name": "H2C"})
|
|
||||||
second = resolve_printer_device("SERIAL-A", {"name": "Printer A", "dev_product_name": "H2C"})
|
|
||||||
|
|
||||||
assert first.pk == second.pk
|
|
||||||
assert Printer.objects.filter(serial_number="SERIAL-A").count() == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_two_different_serials_create_two_printers():
|
|
||||||
a = resolve_printer_device("SERIAL-A", {"name": "Printer A", "dev_product_name": "H2C"})
|
|
||||||
b = resolve_printer_device("SERIAL-B", {"name": "Printer B", "dev_product_name": "X1C"})
|
|
||||||
|
|
||||||
assert a.pk != b.pk
|
|
||||||
assert Printer.objects.count() == 2
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_backfills_single_legacy_printer_with_null_serial():
|
|
||||||
legacy = Printer.objects.create(
|
|
||||||
name="Bambu Lab Printer", model="Bambu Lab", manufacturer="Bambu Lab", is_active=True
|
|
||||||
)
|
|
||||||
|
|
||||||
resolved = resolve_printer_device("SERIAL-A", {"name": "RNL-H2C", "dev_product_name": "H2C"})
|
|
||||||
|
|
||||||
legacy.refresh_from_db()
|
|
||||||
assert resolved.pk == legacy.pk
|
|
||||||
assert legacy.serial_number == "SERIAL-A"
|
|
||||||
assert Printer.objects.count() == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_does_not_guess_when_multiple_legacy_printers_exist():
|
|
||||||
Printer.objects.create(name="Legacy 1", model="Bambu Lab")
|
|
||||||
Printer.objects.create(name="Legacy 2", model="Bambu Lab")
|
|
||||||
|
|
||||||
resolved = resolve_printer_device("SERIAL-A", {"name": "RNL-H2C", "dev_product_name": "H2C"})
|
|
||||||
|
|
||||||
assert resolved.serial_number == "SERIAL-A"
|
|
||||||
assert Printer.objects.count() == 3
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_falls_back_to_generic_defaults_without_device_info():
|
|
||||||
printer = resolve_printer_device("SERIAL-A", None)
|
|
||||||
|
|
||||||
assert printer.serial_number == "SERIAL-A"
|
|
||||||
assert printer.name == "Bambu Lab Printer"
|
|
||||||
assert printer.model == "Bambu Lab"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_updates_name_and_model_on_existing_printer_when_changed():
|
|
||||||
resolve_printer_device("SERIAL-A", {"name": "Old Name", "dev_product_name": "H2C"})
|
|
||||||
|
|
||||||
updated = resolve_printer_device("SERIAL-A", {"name": "New Name", "dev_product_name": "H2C"})
|
|
||||||
|
|
||||||
assert updated.name == "New Name"
|
|
||||||
assert Printer.objects.filter(serial_number="SERIAL-A").count() == 1
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from bambu_run.mqtt_client import PrinterState
|
|
||||||
from bambu_run.management.commands.bambu_collector import Command, DeviceSession, resolve_printer_device
|
|
||||||
from bambu_run.models import PrinterMetrics
|
|
||||||
|
|
||||||
|
|
||||||
def test_snapshot_includes_raw_device_payload_for_future_vortek_modeling():
|
|
||||||
raw_device = {
|
|
||||||
"extruder": {"info": [{"id": 0, "temp": 12058720}, {"id": 1, "temp": 11534560}]},
|
|
||||||
"nozzle": {"info": [{"id": 0, "diameter": 0.4}]},
|
|
||||||
}
|
|
||||||
data = {"print": {"device": raw_device, "gcode_state": "IDLE"}}
|
|
||||||
|
|
||||||
state = PrinterState.from_mqtt_data(data)
|
|
||||||
snapshot = state.get_snapshot()
|
|
||||||
|
|
||||||
assert snapshot["vortek_raw"] == raw_device
|
|
||||||
|
|
||||||
|
|
||||||
def test_snapshot_vortek_raw_defaults_to_empty_dict_when_no_device_payload():
|
|
||||||
state = PrinterState.from_mqtt_data({"print": {"gcode_state": "IDLE"}})
|
|
||||||
snapshot = state.get_snapshot()
|
|
||||||
|
|
||||||
assert snapshot["vortek_raw"] == {}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_collector_persists_vortek_raw_onto_printer_metrics():
|
|
||||||
printer = resolve_printer_device("SERIAL-A", {"name": "H2C", "dev_product_name": "H2C"})
|
|
||||||
|
|
||||||
class FakeClient:
|
|
||||||
def get_snapshot(self):
|
|
||||||
return {"gcode_state": "IDLE", "vortek_raw": {"extruder": {"info": []}}}
|
|
||||||
|
|
||||||
session = DeviceSession(device_id="SERIAL-A", client=FakeClient(), printer=printer)
|
|
||||||
|
|
||||||
cmd = Command()
|
|
||||||
cmd.verbose = False
|
|
||||||
cmd._collect_printer_data(session)
|
|
||||||
|
|
||||||
metric = PrinterMetrics.objects.get(device=printer)
|
|
||||||
assert metric.vortek_raw == {"extruder": {"info": []}}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
from django.urls import include, path
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
path("", include("bambu_run.urls")),
|
|
||||||
]
|
|
||||||
Reference in New Issue
Block a user