6 Commits

Author SHA1 Message Date
RNL
6d284ae79c fixed line chart noise x axis and add more date marker to split them up 2026-02-25 23:05:24 +11:00
RNL
7ca4cd57b5 touch up readme 2026-02-24 23:44:42 +11:00
RNL
a06842be3e updated instructions 2026-02-24 23:39:51 +11:00
RNL
d513f951dd added AMS hex color trimming 2026-02-24 21:33:43 +11:00
RNL
b1858a9129 bambu color import manage tool added 2026-02-23 23:16:56 +11:00
RNL
11cc0e0817 added cover image 2026-02-22 22:59:26 +11:00
24 changed files with 330 additions and 969 deletions

View File

@@ -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 }}"

185
README.md
View File

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

View File

@@ -52,7 +52,7 @@ 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', 'is_loaded_in_ams', 'current_tray_id',
@@ -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,7 +85,6 @@ class FilamentForm(forms.ModelForm):
'initial_weight_grams': forms.NumberInput(attrs={'class': 'form-control', 'placeholder': '1000'}), 'initial_weight_grams': forms.NumberInput(attrs={'class': 'form-control', 'placeholder': '1000'}),
'remaining_percent': forms.NumberInput(attrs={'class': 'form-control', 'min': '0', 'max': '100'}), 'remaining_percent': forms.NumberInput(attrs={'class': 'form-control', 'min': '0', 'max': '100'}),
'remaining_weight_grams': forms.NumberInput(attrs={'class': 'form-control', 'readonly': 'readonly'}), 'remaining_weight_grams': forms.NumberInput(attrs={'class': 'form-control', 'readonly': 'readonly'}),
'is_transparent': forms.CheckboxInput(attrs={'class': 'form-check-input', 'id': 'id_is_transparent'}),
'is_loaded_in_ams': forms.CheckboxInput(attrs={'class': 'form-check-input'}), 'is_loaded_in_ams': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'current_tray_id': forms.NumberInput(attrs={'class': 'form-control', 'min': '0', 'max': '3'}), 'current_tray_id': forms.NumberInput(attrs={'class': 'form-control', 'min': '0', 'max': '3'}),
'purchase_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}), 'purchase_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),

View File

@@ -316,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')
@@ -329,7 +329,6 @@ 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
@@ -342,7 +341,6 @@ 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:
@@ -370,7 +368,6 @@ 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,

View File

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

View File

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

View File

@@ -259,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)
@@ -333,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(

View File

@@ -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();
}
}
});
}
});

View File

@@ -646,20 +646,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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -122,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>

View File

@@ -152,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>

View File

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

View File

@@ -1,4 +1,4 @@
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.utils import timezone from django.utils import timezone
@@ -13,18 +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
class PrinterDashboardView(LoginRequiredMixin, TemplateView): class PrinterDashboardView(LoginRequiredMixin, TemplateView):
template_name = "bambu_run/printer_dashboard.html" template_name = "bambu_run/printer_dashboard.html"
@@ -125,7 +113,6 @@ 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 = []
@@ -261,125 +248,110 @@ class PrinterDataAPIView(LoginRequiredMixin, View):
if not printer_device: if not printer_device:
return JsonResponse({"error": "No printer device found"}, status=404) return JsonResponse({"error": "No printer device found"}, status=404)
query = PrinterMetrics.objects.filter(device=printer_device).prefetch_related('filament_snapshots')
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 data = {
metrics_list = list(query.order_by("timestamp")) "timestamps": [m.timestamp.astimezone(tz).strftime('%H:%M') for m in metrics],
if step > 1: "timestamps_iso": [m.timestamp.astimezone(tz).isoformat() for m in metrics],
metrics_list = metrics_list[::step] "dates": [m.timestamp.astimezone(tz).strftime('%Y-%m-%d') for m in metrics],
"nozzle_temp": [float(m.nozzle_temp) if m.nozzle_temp else None for m in metrics],
"nozzle_target_temp": [float(m.nozzle_target_temp) if m.nozzle_target_temp else None for m in metrics],
"bed_temp": [float(m.bed_temp) if m.bed_temp else None for m in metrics],
"bed_target_temp": [float(m.bed_target_temp) if m.bed_target_temp else None for m in metrics],
"print_percent": [m.print_percent if m.print_percent else 0 for m in metrics],
"cooling_fan_speed": [m.cooling_fan_speed if m.cooling_fan_speed else 0 for m in metrics],
"heatbreak_fan_speed": [m.heatbreak_fan_speed if m.heatbreak_fan_speed else 0 for m in metrics],
"wifi_signal_dbm": [m.wifi_signal_dbm if m.wifi_signal_dbm else None for m in metrics],
"ams_humidity_raw": [m.ams_humidity_raw if m.ams_humidity_raw else None for m in metrics],
"ams_temp": [float(m.ams_temp) if m.ams_temp else None for m in metrics],
"layer_num": [m.layer_num if m.layer_num else 0 for m in metrics],
"total_layer_num": [m.total_layer_num if m.total_layer_num else 0 for m in metrics],
"gcode_state": [m.gcode_state for m in metrics],
"print_type": [m.print_type for m in metrics],
"subtask_name": [m.subtask_name for m in metrics],
}
total_points = len(metrics_list) project_markers = self._calculate_project_markers(metrics, tz)
data["project_markers"] = project_markers
# Stage C: targeted snapshot fetch (only sampled IDs) filament_timeline = self._prepare_filament_timeline_for_api(metrics)
snapshots_by_metric: dict = {} data["filament_timeline"] = filament_timeline
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 return JsonResponse(data)
timestamps = []
timestamps_iso = []
dates = []
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 = []
project_markers = [] except Exception as e:
import traceback
traceback.print_exc()
return JsonResponse({"error": str(e)}, status=500)
def _calculate_project_markers(self, metrics, timezone_info):
markers = []
current_job = None current_job = None
last_state = None last_state = None
filament_data = {} for idx, metric in enumerate(metrics):
subtask = metric.subtask_name
gcode_state = metric.gcode_state
for idx, m in enumerate(metrics_list): is_printing = gcode_state not in ['FINISH', 'IDLE', None, '']
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)
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: if subtask and subtask != current_job and is_printing:
project_markers.append({ markers.append({
'type': 'start', 'type': 'start',
'index': idx, 'index': idx,
'timestamp': ts.isoformat(), 'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(),
'project_name': subtask, 'project_name': subtask,
}) })
current_job = subtask current_job = subtask
last_state = gs last_state = gcode_state
elif current_job and last_state and last_state not in ['FINISH', 'IDLE'] and gs in ['FINISH', 'IDLE']:
project_markers.append({ elif current_job and last_state and last_state not in ['FINISH', 'IDLE'] and gcode_state in ['FINISH', 'IDLE']:
markers.append({
'type': 'end', 'type': 'end',
'index': idx, 'index': idx,
'timestamp': ts.isoformat(), 'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(),
'project_name': current_job, 'project_name': current_job,
}) })
current_job = None current_job = None
last_state = gs
# Filament timeline (inline) last_state = gcode_state
for snap in snapshots_by_metric.get(m.id, []):
tray_id = snap.tray_id return markers
fil_type = snap.type or 'Unknown'
fil_sub_type = snap.sub_type or 'Unknown' def _prepare_filament_timeline_for_api(self, metrics):
fil_color = snap.color or 'FFFFFFFF' 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}" unique_key = f"{tray_id}_{fil_type}_{fil_sub_type}_{fil_color}"
if unique_key not in filament_data: if unique_key not in filament_data:
filament_data[unique_key] = { filament_data[unique_key] = {
'tray_id': tray_id, 'tray_id': tray_id,
@@ -389,13 +361,17 @@ class PrinterDataAPIView(LoginRequiredMixin, View):
'remain_data': [None] * total_points, 'remain_data': [None] * total_points,
'start_idx': idx, 'start_idx': idx,
} }
filament_data[unique_key]['remain_data'][idx] = snap.remain_percent or 0
external = m.external_spool or {} 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'): if external.get('type'):
fil_type = external.get('type', 'Unknown') fil_type = external.get('type', 'Unknown')
fil_color = external.get('color', '161616FF') fil_color = external.get('color', '161616FF')
unique_key = f"External_{fil_type}_{fil_color}" unique_key = f"External_{fil_type}_{fil_color}"
if unique_key not in filament_data: if unique_key not in filament_data:
filament_data[unique_key] = { filament_data[unique_key] = {
'tray_id': 'External', 'tray_id': 'External',
@@ -405,37 +381,11 @@ class PrinterDataAPIView(LoginRequiredMixin, View):
'remain_data': [None] * total_points, 'remain_data': [None] * total_points,
'start_idx': idx, 'start_idx': idx,
} }
filament_data[unique_key]['remain_data'][idx] = external.get('remain', 0)
data = { remain_percent = external.get('remain', 0)
"timestamps": timestamps, filament_data[unique_key]['remain_data'][idx] = remain_percent
"timestamps_iso": timestamps_iso,
"dates": dates,
"nozzle_temp": nozzle_temp,
"nozzle_target_temp": nozzle_target_temp,
"bed_temp": bed_temp,
"bed_target_temp": bed_target_temp,
"print_percent": print_percent,
"cooling_fan_speed": cooling_fan_speed,
"heatbreak_fan_speed": heatbreak_fan_speed,
"wifi_signal_dbm": wifi_signal_dbm,
"ams_humidity_raw": ams_humidity_raw,
"ams_temp": ams_temp,
"layer_num": layer_num,
"total_layer_num": total_layer_num,
"gcode_state": gcode_state,
"print_type": print_type,
"subtask_name": subtask_name,
"project_markers": project_markers,
"filament_timeline": filament_data,
}
return JsonResponse(data) return filament_data
except Exception as e:
import traceback
traceback.print_exc()
return JsonResponse({"error": str(e)}, status=500)
class FilamentUsageDataAPIView(LoginRequiredMixin, View): class FilamentUsageDataAPIView(LoginRequiredMixin, View):
@@ -545,14 +495,6 @@ class FilamentListView(LoginRequiredMixin, ListView):
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
@@ -562,7 +504,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):
@@ -579,7 +520,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):

View File

@@ -22,7 +22,3 @@ Silver
Hex:#87909A Hex:#87909A
Black Black
Hex:#000000 Hex:#000000
Mint
Hex:#7AE1BF
Lavender
Hex:#7248BD

View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -1,58 +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"
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)
journalctl --user -u bambu-run-web -u bambu-run-collector -f --no-hostname
;;
update)
echo "Pulling latest code..."
cd "$REPO_DIR" && git pull
echo "Installing dependencies..."
"$VENV_DIR/bin/pip" install --quiet ".[standalone]"
echo "Running migrations..."
$MANAGE migrate --noinput
echo "Collecting static files..."
$MANAGE collectstatic --noinput --clear 2>/dev/null
echo "Restarting services..."
systemctl --user restart $SERVICES
echo "Update complete."
;;
help|*)
echo "Usage: $0 {start|stop|restart|status|logs|update}"
echo
echo " start Start web + collector services"
echo " stop Stop web + collector services"
echo " restart Restart web + collector services"
echo " status Show service status"
echo " logs Tail live logs (Ctrl+C to stop)"
echo " update Pull latest code, install deps, migrate, restart"
;;
esac

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "bambu-run" name = "bambu-run"
version = "0.1.3" 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"}

263
setup.sh
View File

@@ -1,263 +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
# 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"
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
echo " Services auto-start on boot. Safe to close SSH."
echo

View File

@@ -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():