7 Commits

Author SHA1 Message Date
github-actions[bot]
9a91b14593 chore: bump version to 0.1.3 [skip ci] 2026-03-29 05:10:50 +00:00
RNL
0b07221827 bump version in workflows 2026-03-29 16:09:49 +11:00
RNL
46902d7ec0 added bump version ci 2026-03-28 22:53:01 +11:00
RunLit
5c56711c57 Color base add support for transparent color (#5)
* added db model is transparent and fixed PETG translucent showing black

* js and filament form for transparent color

* bumped version to v0.1.2
2026-03-27 23:30:27 +11:00
RunLit
7e39d3e38d Native setup and Downsample data (#4)
* PrinterDataAPIView downsample

* filament usage chart now works without day constraint

* One command native setup

* add setup timezone verification and link

* added wipe off instructions

* setup default to port 80

* user selectable port number with default to 80

* skip superuser creation if exists

* auto install iptables if not available

* wipe out instructions updated
2026-03-07 16:53:33 +11:00
RunLit
217679421f version 0.1.1 2026-03-03 23:22:25 +11:00
RunLit
5984bd6fa0 Filament tools that help upload bambu colors and filament types easily (#3)
* added cover image

* bambu color import manage tool added

* added AMS hex color trimming

* updated instructions

* touch up readme

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

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

@@ -0,0 +1,56 @@
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 }}"

179
README.md
View File

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

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', 'filament_type', 'type', 'sub_type', 'brand', 'color', 'color_hex', 'is_transparent',
'diameter', 'initial_weight_grams', 'diameter', 'initial_weight_grams',
'remaining_percent', 'remaining_weight_grams', 'remaining_percent', 'remaining_weight_grams',
'is_loaded_in_ams', 'current_tray_id', 'is_loaded_in_ams', 'current_tray_id',
@@ -71,10 +71,10 @@ class FilamentForm(forms.ModelForm):
}), }),
'tag_id': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Optional - User-defined ID'}), 'tag_id': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Optional - User-defined ID'}),
'created_by': forms.Select(attrs={'class': 'form-select'}), 'created_by': forms.Select(attrs={'class': 'form-select'}),
'filament_type': forms.Select(attrs={'class': 'form-select'}), 'filament_type': forms.Select(attrs={'class': 'form-select', 'id': 'id_filament_type'}),
'type': forms.HiddenInput(), 'type': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g., PLA, PETG, ABS'}),
'sub_type': forms.HiddenInput(), 'sub_type': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g., PLA Basic (optional)'}),
'brand': forms.HiddenInput(), 'brand': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g., Bambu Lab'}),
'color': forms.Select(attrs={'class': 'form-select', 'id': 'id_color'}), 'color': forms.Select(attrs={'class': 'form-select', 'id': 'id_color'}),
'color_hex': forms.TextInput(attrs={ 'color_hex': forms.TextInput(attrs={
'class': 'form-control', 'class': 'form-control',
@@ -85,6 +85,7 @@ 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 from bambu_run.utils import strip_color_padding, match_filament_color, is_mqtt_color_transparent
tray_uuid = tray_data.get('tray_uuid') tray_uuid = tray_data.get('tray_uuid')
tag_uid = tray_data.get('tag_uid') tag_uid = tray_data.get('tag_uid')
@@ -329,6 +329,7 @@ class Command(BaseCommand):
default_brand = app_settings.AUTO_CREATE_BRAND default_brand = app_settings.AUTO_CREATE_BRAND
transparent = is_mqtt_color_transparent(mqtt_color)
color_code = strip_color_padding(mqtt_color) color_code = strip_color_padding(mqtt_color)
color_hex = f"#{color_code}" if color_code else None color_hex = f"#{color_code}" if color_code else None
@@ -341,6 +342,7 @@ class Command(BaseCommand):
if filament_color: if filament_color:
color_name = filament_color.color_name color_name = filament_color.color_name
transparent = transparent or filament_color.is_transparent
if self.verbose: if self.verbose:
logger.info(f"Matched color from database: {color_name} (#{color_code})") logger.info(f"Matched color from database: {color_name} (#{color_code})")
else: else:
@@ -368,6 +370,7 @@ 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,6 +371,11 @@ class Command(BaseCommand):
return "created" return "created"
return "no_type" return "no_type"
# ── Transparent detection ────────────────────────────────────────────
# "Translucent" (no colour qualifier) + #000000 = clear/transparent filament.
# Bambu Lab AMS reports these as 00000000 (alpha=00).
is_transparent = color_name.strip().lower() == "translucent" and hex_code == "000000"
# ── Duplicate check ────────────────────────────────────────────────── # ── Duplicate check ──────────────────────────────────────────────────
# All five fields must match to be considered a duplicate: # All five fields must match to be considered a duplicate:
# color_code (exact), color_name (case-insensitive), brand, # color_code (exact), color_name (case-insensitive), brand,
@@ -388,9 +393,10 @@ class Command(BaseCommand):
return "duplicate" return "duplicate"
if dry_run: if dry_run:
transparent_note = " [transparent]" if is_transparent else ""
self.stdout.write( self.stdout.write(
f" [dry-run] Would create: {color_name!r} #{hex_code} " f" [dry-run] Would create: {color_name!r} #{hex_code} "
f"({filament_type} / {filament_sub_type})" f"({filament_type} / {filament_sub_type}){transparent_note}"
) )
return "created" return "created"
@@ -404,6 +410,7 @@ class Command(BaseCommand):
filament_type=filament_type, filament_type=filament_type,
filament_sub_type=filament_sub_type, filament_sub_type=filament_sub_type,
brand=BRAND, brand=BRAND,
is_transparent=is_transparent,
) )
self.stdout.write( self.stdout.write(
f" + {color_name!r} #{hex_code} ({filament_type} / {filament_sub_type})" f" + {color_name!r} #{hex_code} ({filament_type} / {filament_sub_type})"

View File

@@ -0,0 +1,27 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bambu_run", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="filamentcolor",
name="is_transparent",
field=models.BooleanField(
default=False,
help_text="True for clear/transparent filaments — display as checkerboard, not solid color",
),
),
migrations.AddField(
model_name="filament",
name="is_transparent",
field=models.BooleanField(
default=False,
help_text="True for clear/transparent filaments — display as checkerboard, not solid color",
),
),
]

View File

@@ -259,6 +259,10 @@ class FilamentColor(models.Model):
default='Bambu Lab', default='Bambu Lab',
help_text="Manufacturer name" help_text="Manufacturer name"
) )
is_transparent = models.BooleanField(
default=False,
help_text="True for clear/transparent filaments — display as checkerboard, not solid color"
)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@@ -329,6 +333,10 @@ class Filament(models.Model):
max_length=7, null=True, blank=True, max_length=7, null=True, blank=True,
help_text="Color hex code for display (#RRGGBB)" help_text="Color hex code for display (#RRGGBB)"
) )
is_transparent = models.BooleanField(
default=False,
help_text="True for clear/transparent filaments — display as checkerboard, not solid color"
)
# Physical properties # Physical properties
diameter = models.DecimalField( diameter = models.DecimalField(

View File

@@ -0,0 +1,156 @@
/**
* filament_form.js — Filament add/edit form interactions.
*
* Handles:
* - Filament type preset → auto-fill Type / Sub Type / Brand
* - Transparent checkbox → toggle color picker vs. checkerboard swatch
* - Color picker ↔ hex text sync
* - Delete confirmation modal
*/
document.addEventListener('DOMContentLoaded', function () {
// ── Filament type preset auto-fill ────────────────────────────────────────
const dataEl = document.getElementById('filament-type-data');
const filamentTypeMap = dataEl ? JSON.parse(dataEl.textContent) : {};
const filamentTypeSelect = document.getElementById('id_filament_type');
const typeField = document.getElementById('id_type');
const subTypeField = document.getElementById('id_sub_type');
const brandField = document.getElementById('id_brand');
if (filamentTypeSelect) {
filamentTypeSelect.addEventListener('change', function () {
const mapping = filamentTypeMap[this.value];
if (mapping && typeField && subTypeField && brandField) {
typeField.value = mapping.type;
subTypeField.value = mapping.sub_type;
brandField.value = mapping.brand;
}
});
}
// ── Transparent toggle ────────────────────────────────────────────────────
const transparentCheckbox = document.getElementById('id_is_transparent');
const transparentSwatch = document.getElementById('transparent-swatch');
const colorPicker = document.getElementById('id_color_hex_picker');
const colorText = document.getElementById('id_color_hex_text');
/**
* Show checkerboard swatch and disable color inputs when transparent,
* restore normal color picker when not transparent.
* @param {boolean} isTransparent
*/
function applyTransparentState(isTransparent) {
if (!colorPicker) return;
if (isTransparent) {
transparentSwatch.style.display = 'block';
colorPicker.style.display = 'none';
colorPicker.disabled = true;
if (colorText) { colorText.disabled = true; colorText.value = ''; }
} else {
transparentSwatch.style.display = 'none';
colorPicker.style.display = '';
colorPicker.disabled = false;
if (colorText) { colorText.disabled = false; }
}
}
if (transparentCheckbox) {
applyTransparentState(transparentCheckbox.checked);
transparentCheckbox.addEventListener('change', function () {
applyTransparentState(this.checked);
});
}
// ── Color picker ↔ hex text sync ──────────────────────────────────────────
if (colorPicker && colorText) {
colorPicker.addEventListener('input', function () {
colorText.value = this.value.toUpperCase();
});
colorText.addEventListener('input', function () {
const value = this.value.trim();
if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
colorPicker.value = value;
this.classList.remove('is-invalid');
} else if (value.length === 7) {
this.classList.add('is-invalid');
}
});
if (colorText.value && /^#[0-9A-Fa-f]{6}$/.test(colorText.value)) {
colorPicker.value = colorText.value;
} else if (colorPicker.value && !colorText.value) {
colorText.value = colorPicker.value.toUpperCase();
}
}
// ── Delete confirmation modal ─────────────────────────────────────────────
const deleteConfirmText = document.getElementById('deleteConfirmText');
const confirmDeleteBtn = document.getElementById('confirmDeleteBtn');
const deleteForm = document.getElementById('deleteForm');
const deleteModal = document.getElementById('deleteModal');
if (deleteConfirmText && confirmDeleteBtn) {
deleteConfirmText.addEventListener('input', function () {
const value = this.value.trim();
if (value === 'DELETE') {
confirmDeleteBtn.disabled = false;
this.classList.remove('is-invalid');
this.classList.add('is-valid');
} else {
confirmDeleteBtn.disabled = true;
this.classList.remove('is-valid');
if (value.length > 0) {
this.classList.add('is-invalid');
} else {
this.classList.remove('is-invalid');
}
}
});
if (deleteForm) {
deleteForm.addEventListener('submit', function (e) {
if (confirmDeleteBtn.disabled) {
e.preventDefault();
alert('Please type DELETE to confirm deletion');
return false;
}
return true;
});
}
if (deleteModal) {
deleteModal.addEventListener('hidden.bs.modal', function () {
deleteConfirmText.value = '';
confirmDeleteBtn.disabled = true;
deleteConfirmText.classList.remove('is-valid', 'is-invalid');
});
deleteModal.addEventListener('shown.bs.modal', function () {
deleteConfirmText.focus();
});
}
}
// ── Delete button modal opener (backup) ───────────────────────────────────
const deleteBtn = document.getElementById('deleteBtn');
if (deleteBtn && deleteModal) {
deleteBtn.addEventListener('click', function () {
if (!deleteModal.classList.contains('show')) {
if (typeof bootstrap !== 'undefined') {
bootstrap.Modal.getOrCreateInstance(deleteModal).show();
} else if (typeof coreui !== 'undefined' && coreui.Modal) {
coreui.Modal.getOrCreateInstance(deleteModal).show();
}
}
});
}
});

View File

@@ -646,8 +646,20 @@ function hexToRgba(hex, alpha) {
function applyFilamentColors() { function applyFilamentColors() {
// Apply colors to filament cards // Apply colors to filament cards
document.querySelectorAll('.filament-card').forEach(card => { document.querySelectorAll('.filament-card').forEach(card => {
const isTransparent = card.getAttribute('data-filament-transparent') === 'true';
const colorHex = card.getAttribute('data-filament-color'); const colorHex = card.getAttribute('data-filament-color');
if (colorHex) {
if (isTransparent) {
// Checkerboard left border and subtle background for clear filaments
card.style.borderLeft = '4px solid #aaa';
card.style.background = 'repeating-conic-gradient(rgba(180,180,180,0.15) 0% 25%, transparent 0% 50%) 0 0/10px 10px';
const badge = card.querySelector('.filament-badge');
if (badge) {
badge.style.backgroundColor = '#aaa';
badge.style.color = '#fff';
}
} else if (colorHex) {
const color = '#' + colorHex; const color = '#' + colorHex;
// Set card background with gradient // Set card background with gradient

View File

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

View File

@@ -27,10 +27,14 @@
<div class="card-body"> <div class="card-body">
<h6>Color</h6> <h6>Color</h6>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
{% if filament.is_transparent %}
<div style="width: 50px; height: 50px; border-radius: 8px; margin-right: 15px; border: 2px solid #ddd; background: repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 0 0/10px 10px;" title="Clear / Transparent"></div>
{% else %}
<div style="width: 50px; height: 50px; background-color: {{ filament.color_hex|default:'#999' }}; border-radius: 8px; margin-right: 15px; border: 2px solid #ddd;"></div> <div style="width: 50px; height: 50px; background-color: {{ filament.color_hex|default:'#999' }}; border-radius: 8px; margin-right: 15px; border: 2px solid #ddd;"></div>
{% endif %}
<div> <div>
<strong>{{ filament.color }}</strong><br> <strong>{{ filament.color }}</strong><br>
<small class="text-muted">{{ filament.color_hex }}</small> <small class="text-muted">{% if filament.is_transparent %}Clear / Transparent{% else %}{{ filament.color_hex }}{% endif %}</small>
</div> </div>
</div> </div>
</div> </div>

View File

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

View File

@@ -122,7 +122,11 @@
</td> </td>
<td class="align-middle"> <td class="align-middle">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
{% if filament.is_transparent %}
<div style="width: 30px; height: 30px; border-radius: 4px; margin-right: 10px; border: 1px solid #ddd; background: repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 0 0/10px 10px;" title="Clear / Transparent"></div>
{% else %}
<div style="width: 30px; height: 30px; background-color: {{ filament.color_hex|default:'#999' }}; border-radius: 4px; margin-right: 10px; border: 1px solid #ddd;"></div> <div style="width: 30px; height: 30px; background-color: {{ filament.color_hex|default:'#999' }}; border-radius: 4px; margin-right: 10px; border: 1px solid #ddd;"></div>
{% endif %}
{{ filament.color }} {{ filament.color }}
</div> </div>
</td> </td>

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

View File

@@ -1,4 +1,4 @@
from datetime import timedelta from datetime import timedelta, datetime
from django.views.generic import TemplateView, View, ListView, CreateView, UpdateView, DetailView, DeleteView from django.views.generic import TemplateView, View, ListView, CreateView, UpdateView, DetailView, DeleteView
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.utils import timezone from django.utils import timezone
@@ -13,6 +13,18 @@ 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"
@@ -113,6 +125,7 @@ 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 = []
@@ -248,51 +261,175 @@ 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)
if start_date and start_time: # Stage A: only() + step calculation
from datetime import datetime query = (
start_dt_naive = datetime.strptime(f"{start_date} {start_time}", "%Y-%m-%d %H:%M") PrinterMetrics.objects
start_dt = start_dt_naive.replace(tzinfo=tz) .filter(device=printer_device)
.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
if end_date and end_time: elif end_date and end_time:
from datetime import datetime end_dt = datetime.strptime(f"{end_date} {end_time}", "%Y-%m-%d %H:%M").replace(tzinfo=tz)
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
metrics = query.order_by("timestamp") step = max(1, expected_count // _MAX_CHART_POINTS)
# 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 = []
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)
bed_temp.append(float(m.bed_temp) if m.bed_temp else None)
bed_target_temp.append(float(m.bed_target_temp) if m.bed_target_temp else None)
print_percent.append(m.print_percent if m.print_percent else 0)
cooling_fan_speed.append(m.cooling_fan_speed if m.cooling_fan_speed else 0)
heatbreak_fan_speed.append(m.heatbreak_fan_speed if m.heatbreak_fan_speed else 0)
wifi_signal_dbm.append(m.wifi_signal_dbm if m.wifi_signal_dbm else None)
ams_humidity_raw.append(m.ams_humidity_raw if m.ams_humidity_raw else None)
ams_temp.append(float(m.ams_temp) if m.ams_temp else None)
layer_num.append(m.layer_num if m.layer_num else 0)
total_layer_num.append(m.total_layer_num if m.total_layer_num else 0)
gcode_state.append(m.gcode_state)
print_type.append(m.print_type)
subtask_name.append(m.subtask_name)
# Project marker detection (inline)
subtask = m.subtask_name
gs = m.gcode_state
is_printing = gs not in ['FINISH', 'IDLE', None, '']
if subtask and subtask != current_job and is_printing:
project_markers.append({
'type': 'start',
'index': idx,
'timestamp': ts.isoformat(),
'project_name': subtask,
})
current_job = subtask
last_state = gs
elif current_job and last_state and last_state not in ['FINISH', 'IDLE'] and gs in ['FINISH', 'IDLE']:
project_markers.append({
'type': 'end',
'index': idx,
'timestamp': ts.isoformat(),
'project_name': current_job,
})
current_job = None
last_state = gs
# Filament timeline (inline)
for snap in snapshots_by_metric.get(m.id, []):
tray_id = snap.tray_id
fil_type = snap.type or 'Unknown'
fil_sub_type = snap.sub_type or 'Unknown'
fil_color = snap.color or 'FFFFFFFF'
unique_key = f"{tray_id}_{fil_type}_{fil_sub_type}_{fil_color}"
if unique_key not in filament_data:
filament_data[unique_key] = {
'tray_id': tray_id,
'type': fil_type,
'brand': fil_sub_type,
'color': fil_color,
'remain_data': [None] * total_points,
'start_idx': idx,
}
filament_data[unique_key]['remain_data'][idx] = snap.remain_percent or 0
external = m.external_spool or {}
if external.get('type'):
fil_type = external.get('type', 'Unknown')
fil_color = external.get('color', '161616FF')
unique_key = f"External_{fil_type}_{fil_color}"
if unique_key not in filament_data:
filament_data[unique_key] = {
'tray_id': 'External',
'type': fil_type,
'brand': 'External',
'color': fil_color,
'remain_data': [None] * total_points,
'start_idx': idx,
}
filament_data[unique_key]['remain_data'][idx] = external.get('remain', 0)
data = { data = {
"timestamps": [m.timestamp.astimezone(tz).strftime('%H:%M') for m in metrics], "timestamps": timestamps,
"timestamps_iso": [m.timestamp.astimezone(tz).isoformat() for m in metrics], "timestamps_iso": timestamps_iso,
"dates": [m.timestamp.astimezone(tz).strftime('%Y-%m-%d') for m in metrics], "dates": dates,
"nozzle_temp": [float(m.nozzle_temp) if m.nozzle_temp else None for m in metrics], "nozzle_temp": nozzle_temp,
"nozzle_target_temp": [float(m.nozzle_target_temp) if m.nozzle_target_temp else None for m in metrics], "nozzle_target_temp": nozzle_target_temp,
"bed_temp": [float(m.bed_temp) if m.bed_temp else None for m in metrics], "bed_temp": bed_temp,
"bed_target_temp": [float(m.bed_target_temp) if m.bed_target_temp else None for m in metrics], "bed_target_temp": bed_target_temp,
"print_percent": [m.print_percent if m.print_percent else 0 for m in metrics], "print_percent": print_percent,
"cooling_fan_speed": [m.cooling_fan_speed if m.cooling_fan_speed else 0 for m in metrics], "cooling_fan_speed": cooling_fan_speed,
"heatbreak_fan_speed": [m.heatbreak_fan_speed if m.heatbreak_fan_speed else 0 for m in metrics], "heatbreak_fan_speed": heatbreak_fan_speed,
"wifi_signal_dbm": [m.wifi_signal_dbm if m.wifi_signal_dbm else None for m in metrics], "wifi_signal_dbm": wifi_signal_dbm,
"ams_humidity_raw": [m.ams_humidity_raw if m.ams_humidity_raw else None for m in metrics], "ams_humidity_raw": ams_humidity_raw,
"ams_temp": [float(m.ams_temp) if m.ams_temp else None for m in metrics], "ams_temp": ams_temp,
"layer_num": [m.layer_num if m.layer_num else 0 for m in metrics], "layer_num": layer_num,
"total_layer_num": [m.total_layer_num if m.total_layer_num else 0 for m in metrics], "total_layer_num": total_layer_num,
"gcode_state": [m.gcode_state for m in metrics], "gcode_state": gcode_state,
"print_type": [m.print_type for m in metrics], "print_type": print_type,
"subtask_name": [m.subtask_name for m in metrics], "subtask_name": subtask_name,
"project_markers": project_markers,
"filament_timeline": filament_data,
} }
project_markers = self._calculate_project_markers(metrics, tz)
data["project_markers"] = project_markers
filament_timeline = self._prepare_filament_timeline_for_api(metrics)
data["filament_timeline"] = filament_timeline
return JsonResponse(data) return JsonResponse(data)
except Exception as e: except Exception as e:
@@ -300,93 +437,6 @@ class PrinterDataAPIView(LoginRequiredMixin, View):
traceback.print_exc() traceback.print_exc()
return JsonResponse({"error": str(e)}, status=500) return JsonResponse({"error": str(e)}, status=500)
def _calculate_project_markers(self, metrics, timezone_info):
markers = []
current_job = None
last_state = None
for idx, metric in enumerate(metrics):
subtask = metric.subtask_name
gcode_state = metric.gcode_state
is_printing = gcode_state not in ['FINISH', 'IDLE', None, '']
if subtask and subtask != current_job and is_printing:
markers.append({
'type': 'start',
'index': idx,
'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(),
'project_name': subtask,
})
current_job = subtask
last_state = gcode_state
elif current_job and last_state and last_state not in ['FINISH', 'IDLE'] and gcode_state in ['FINISH', 'IDLE']:
markers.append({
'type': 'end',
'index': idx,
'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(),
'project_name': current_job,
})
current_job = None
last_state = gcode_state
return markers
def _prepare_filament_timeline_for_api(self, metrics):
filament_data = {}
total_points = len(metrics)
for idx, metric in enumerate(metrics):
try:
snapshots = metric.filament_snapshots.all()
except Exception:
snapshots = []
for snapshot in snapshots:
tray_id = snapshot.tray_id
fil_type = snapshot.type or 'Unknown'
fil_sub_type = snapshot.sub_type or 'Unknown'
fil_color = snapshot.color or 'FFFFFFFF'
unique_key = f"{tray_id}_{fil_type}_{fil_sub_type}_{fil_color}"
if unique_key not in filament_data:
filament_data[unique_key] = {
'tray_id': tray_id,
'type': fil_type,
'brand': fil_sub_type,
'color': fil_color,
'remain_data': [None] * total_points,
'start_idx': idx,
}
remain_percent = snapshot.remain_percent or 0
filament_data[unique_key]['remain_data'][idx] = remain_percent
for idx, metric in enumerate(metrics):
external = metric.external_spool or {}
if external.get('type'):
fil_type = external.get('type', 'Unknown')
fil_color = external.get('color', '161616FF')
unique_key = f"External_{fil_type}_{fil_color}"
if unique_key not in filament_data:
filament_data[unique_key] = {
'tray_id': 'External',
'type': fil_type,
'brand': 'External',
'color': fil_color,
'remain_data': [None] * total_points,
'start_idx': idx,
}
remain_percent = external.get('remain', 0)
filament_data[unique_key]['remain_data'][idx] = remain_percent
return filament_data
class FilamentUsageDataAPIView(LoginRequiredMixin, View): class FilamentUsageDataAPIView(LoginRequiredMixin, View):
"""API endpoint for filament usage history with date/time filtering""" """API endpoint for filament usage history with date/time filtering"""
@@ -495,6 +545,14 @@ 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
@@ -504,6 +562,7 @@ class FilamentCreateView(LoginRequiredMixin, CreateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
context['filament_type_map'] = json.dumps(_filament_type_map())
return context return context
def form_valid(self, form): def form_valid(self, form):
@@ -520,6 +579,7 @@ class FilamentUpdateView(LoginRequiredMixin, UpdateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
context['filament_type_map'] = json.dumps(_filament_type_map())
return context return context
def form_valid(self, form): def form_valid(self, form):

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
[Unit]
Description=Bambu-Run MQTT Collector
After=network.target
[Service]
Type=exec
WorkingDirectory={{REPO_DIR}}
EnvironmentFile={{REPO_DIR}}/.env
Environment=DJANGO_SETTINGS_MODULE=standalone.settings
ExecStart={{VENV_DIR}}/bin/python standalone/manage.py bambu_collector
Restart=on-failure
RestartSec=10
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,15 @@
[Unit]
Description=Bambu-Run 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

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

@@ -0,0 +1,58 @@
#!/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.0" version = "0.1.3"
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 Executable file
View File

@@ -0,0 +1,263 @@
#!/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,7 +5,15 @@ import sys
# Ensure the project root (/app) is on sys.path so that both 'standalone' # Ensure the project root (/app) is on sys.path so that both 'standalone'
# and 'bambu_run' are importable regardless of where this script is invoked from. # and 'bambu_run' are importable regardless of where this script is invoked from.
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, PROJECT_ROOT)
# Load .env so manage.py commands pick up env vars outside of systemd/Docker
try:
from dotenv import load_dotenv
load_dotenv(os.path.join(PROJECT_ROOT, ".env"))
except ImportError:
pass
def main(): def main():