mirror of
https://github.com/RunLit/Bambu-Run.git
synced 2026-06-22 14:09:04 +01:00
Initial spin-off of bambu-run from my private project separation
This commit is contained in:
19
.env.example
Normal file
19
.env.example
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Bambu Run Configuration
|
||||||
|
# Copy this file to .env and fill in your values
|
||||||
|
|
||||||
|
# Required: Your Bambu Lab printer's local IP address
|
||||||
|
PRINTER_IP=192.168.1.xxx
|
||||||
|
|
||||||
|
# Required: Your printer's access token (found in printer settings)
|
||||||
|
ACCESS_TOKEN=your_access_token_here
|
||||||
|
|
||||||
|
# Required: Your printer's serial number (found in Settings > Device Info)
|
||||||
|
PRINTER_SERIAL=your_serial_number
|
||||||
|
|
||||||
|
# Optional: Timezone (default: UTC)
|
||||||
|
# TIMEZONE=Australia/Melbourne
|
||||||
|
|
||||||
|
# Optional: Django settings
|
||||||
|
# DEBUG=True
|
||||||
|
# DJANGO_SECRET_KEY=change-me-to-a-random-string
|
||||||
|
# ALLOWED_HOSTS=localhost,127.0.0.1
|
||||||
32
Dockerfile
Normal file
32
Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
supervisor \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
COPY pyproject.toml .
|
||||||
|
RUN pip install --no-cache-dir ".[standalone]"
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Create data directory for SQLite
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
# Collect static files
|
||||||
|
ENV DJANGO_SETTINGS_MODULE=standalone.settings
|
||||||
|
RUN python standalone/manage.py collectstatic --noinput 2>/dev/null || true
|
||||||
|
|
||||||
|
# Supervisor config to run both web and collector
|
||||||
|
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||||
182
README.md
182
README.md
@@ -1,2 +1,182 @@
|
|||||||
# Bambu-Run
|
# Bambu-Run
|
||||||
Unlock deeper control, richer data access, and powerful customization capabilities for your Bambu Lab 3D printer
|
|
||||||
|
Unlock deeper control, richer data access, and powerful customization capabilities for your Bambu Lab 3D printer.
|
||||||
|
|
||||||
|
Bambu-Run is a self-hosted web dashboard that connects to your Bambu Lab printer over your local network via MQTT. It gives you real-time monitoring (temperatures, fan speeds, print progress) and a full filament inventory system — all running on hardware you own.
|
||||||
|
|
||||||
|
## Getting Started (Beginner Friendly)
|
||||||
|
|
||||||
|
This guide walks you through setting up Bambu-Run on a **Raspberry Pi** from scratch. No prior server experience needed.
|
||||||
|
|
||||||
|
### What You'll Need
|
||||||
|
|
||||||
|
- A Raspberry Pi (3B+, 4, or 5) with Raspberry Pi OS installed and connected to your network
|
||||||
|
- Your Bambu Lab printer on the **same local network** as the Pi
|
||||||
|
- Your printer's **IP address**, **access token**, and **serial number** (we'll show you how to find these below)
|
||||||
|
- A computer on the same network to SSH into the Pi
|
||||||
|
|
||||||
|
### Step 1: Find Your Printer's Connection Details
|
||||||
|
|
||||||
|
You'll need three pieces of information from your printer. Here's how to find them:
|
||||||
|
|
||||||
|
**IP Address:**
|
||||||
|
1. On your printer's touchscreen, go to **Settings** (gear icon)
|
||||||
|
2. Tap **Network** — your IP address is shown (e.g. `192.168.1.42`)
|
||||||
|
|
||||||
|
**Access Token:**
|
||||||
|
1. On the touchscreen, go to **Settings**
|
||||||
|
2. Tap **General** > **Access Code** — note down the 8-character code
|
||||||
|
|
||||||
|
**Serial Number:**
|
||||||
|
1. On the touchscreen, go to **Settings**
|
||||||
|
2. Tap **Device Info** — the serial number is listed at the top
|
||||||
|
|
||||||
|
Write all three down. You'll need them in Step 4.
|
||||||
|
|
||||||
|
### Step 2: Connect to Your Raspberry Pi
|
||||||
|
|
||||||
|
From your computer, open a terminal (Mac/Linux) or PowerShell (Windows) and SSH into the Pi:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh pi@raspberrypi.local
|
||||||
|
```
|
||||||
|
|
||||||
|
> If `raspberrypi.local` doesn't work, use your Pi's IP address instead (check your router's admin page to find it).
|
||||||
|
|
||||||
|
The default password is `raspberry` (you should change it after first login with `passwd`).
|
||||||
|
|
||||||
|
### Step 3: Install Docker
|
||||||
|
|
||||||
|
Docker lets you run Bambu-Run in a container — no need to install Python, databases, or anything else manually.
|
||||||
|
|
||||||
|
Run these commands one at a time:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Download and run Docker's install script
|
||||||
|
curl -fsSL https://get.docker.com | sudo sh
|
||||||
|
|
||||||
|
# Let your user run Docker without sudo
|
||||||
|
sudo usermod -aG docker $USER
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:** Log out and log back in for the group change to take effect:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
exit
|
||||||
|
```
|
||||||
|
|
||||||
|
Then SSH back in:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh pi@raspberrypi.local
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify Docker is working:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker --version
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see something like `Docker version 27.x.x` — the exact number doesn't matter.
|
||||||
|
|
||||||
|
### Step 4: Download and Configure Bambu-Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the project
|
||||||
|
git clone https://github.com/RunLit/Bambu-Run.git
|
||||||
|
cd Bambu-Run
|
||||||
|
|
||||||
|
# Create your configuration file
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Now edit the `.env` file with your printer details:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Fill in the three values you noted in Step 1:
|
||||||
|
|
||||||
|
```
|
||||||
|
PRINTER_IP=192.168.1.42
|
||||||
|
ACCESS_TOKEN=your8char
|
||||||
|
PRINTER_SERIAL=01P00A000000000
|
||||||
|
```
|
||||||
|
|
||||||
|
Optionally set your timezone (defaults to UTC):
|
||||||
|
|
||||||
|
```
|
||||||
|
TIMEZONE=Australia/Melbourne
|
||||||
|
```
|
||||||
|
|
||||||
|
> You can find your timezone name at https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||||
|
|
||||||
|
To save and exit nano: press `Ctrl + X`, then `Y`, then `Enter`.
|
||||||
|
|
||||||
|
### Step 5: Start Bambu-Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
- Download all required software automatically (takes a few minutes the first time)
|
||||||
|
- Set up the database
|
||||||
|
- Start the web dashboard and printer data collector in the background
|
||||||
|
|
||||||
|
Check that it's running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see the `bambu-run` service with status `Up`.
|
||||||
|
|
||||||
|
### Step 6: Create Your Login Account
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec bambu-run python standalone/manage.py createsuperuser
|
||||||
|
```
|
||||||
|
|
||||||
|
You'll be prompted to choose a username, email (optional), and password. This is your login for the dashboard.
|
||||||
|
|
||||||
|
### Step 7: Open the Dashboard
|
||||||
|
|
||||||
|
On any device connected to your network (phone, tablet, computer), open a browser and go to:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://raspberrypi.local:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
> If that doesn't work, use your Pi's IP address: `http://<pi-ip-address>:8000`
|
||||||
|
|
||||||
|
Log in with the account you just created. You should see your printer dashboard with live data flowing in.
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
**"Cannot connect to printer" or no data showing:**
|
||||||
|
- Make sure your printer is turned on and connected to the same network
|
||||||
|
- Double-check the IP address, access token, and serial number in your `.env` file
|
||||||
|
- Check the logs: `docker compose logs -f`
|
||||||
|
|
||||||
|
**"Cannot connect to Docker daemon":**
|
||||||
|
- Did you log out and back in after Step 3? Docker group changes require a new session
|
||||||
|
|
||||||
|
**Dashboard not loading in browser:**
|
||||||
|
- Verify the container is running: `docker compose ps`
|
||||||
|
- Try using the Pi's IP address instead of `raspberrypi.local`
|
||||||
|
|
||||||
|
**Updating to a newer version:**
|
||||||
|
```bash
|
||||||
|
cd ~/Bambu-Run
|
||||||
|
git pull
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
**Stopping Bambu-Run:**
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
Your data is preserved in a Docker volume and will be there when you start it again.
|
||||||
|
|||||||
1
bambu_run/__init__.py
Normal file
1
bambu_run/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
default_app_config = "bambu_run.apps.BambuRunConfig"
|
||||||
107
bambu_run/admin.py
Normal file
107
bambu_run/admin.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from .models import Printer, PrinterMetrics, Filament, FilamentType, FilamentSnapshot, PrintJob, FilamentUsage
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Printer)
|
||||||
|
class PrinterAdmin(admin.ModelAdmin):
|
||||||
|
list_display = [
|
||||||
|
"name", "model", "manufacturer", "ip_address", "is_active", "first_seen",
|
||||||
|
]
|
||||||
|
list_filter = ["manufacturer", "is_active"]
|
||||||
|
search_fields = ["name", "model", "serial_number", "ip_address"]
|
||||||
|
readonly_fields = ["first_seen", "last_updated"]
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
("Basic Information", {"fields": ("name", "model", "manufacturer", "description")}),
|
||||||
|
("Identification", {"fields": ("serial_number",)}),
|
||||||
|
("Network", {"fields": ("ip_address",)}),
|
||||||
|
("Status", {"fields": ("is_active", "location")}),
|
||||||
|
("Metadata", {"fields": ("first_seen", "last_updated"), "classes": ("collapse",)}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PrinterMetrics)
|
||||||
|
class PrinterMetricsAdmin(admin.ModelAdmin):
|
||||||
|
list_display = [
|
||||||
|
"device", "timestamp", "nozzle_temp", "bed_temp",
|
||||||
|
"print_percent", "gcode_state", "chamber_light",
|
||||||
|
]
|
||||||
|
list_filter = ["device", "gcode_state", "print_type", "chamber_light"]
|
||||||
|
search_fields = ["device__name", "subtask_name", "gcode_file"]
|
||||||
|
readonly_fields = ["timestamp"]
|
||||||
|
date_hierarchy = "timestamp"
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
("Device & Timestamp", {"fields": ("device", "timestamp")}),
|
||||||
|
("Temperatures", {
|
||||||
|
"fields": ("nozzle_temp", "nozzle_target_temp", "bed_temp", "bed_target_temp", "chamber_temp")
|
||||||
|
}),
|
||||||
|
("Print Status", {
|
||||||
|
"fields": ("gcode_state", "print_type", "print_percent", "remaining_time_min",
|
||||||
|
"layer_num", "total_layer_num", "subtask_name", "gcode_file")
|
||||||
|
}),
|
||||||
|
("AMS & Filaments", {
|
||||||
|
"fields": ("ams_unit_count", "ams_status", "ams_temp", "ams_humidity",
|
||||||
|
"ams_humidity_raw", "filaments", "external_spool")
|
||||||
|
}),
|
||||||
|
("System", {
|
||||||
|
"fields": ("chamber_light", "wifi_signal_dbm", "cooling_fan_speed",
|
||||||
|
"heatbreak_fan_speed", "has_errors", "print_error")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(FilamentType)
|
||||||
|
class FilamentTypeAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('type', 'sub_type', 'brand', 'created_at')
|
||||||
|
search_fields = ('type', 'sub_type', 'brand')
|
||||||
|
list_filter = ('type', 'brand')
|
||||||
|
readonly_fields = ('created_at', 'updated_at')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Filament)
|
||||||
|
class FilamentAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
'brand', 'type', 'sub_type', 'color', 'remaining_percent',
|
||||||
|
'is_loaded_in_ams', 'current_tray_id', 'last_used'
|
||||||
|
)
|
||||||
|
list_filter = ('type', 'brand', 'is_loaded_in_ams')
|
||||||
|
search_fields = ('brand', 'color', 'type', 'tag_id')
|
||||||
|
readonly_fields = ('created_at', 'updated_at', 'last_used')
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Identification', {'fields': ('tag_id',)}),
|
||||||
|
('Specifications', {
|
||||||
|
'fields': ('type', 'sub_type', 'brand', 'color', 'color_hex', 'diameter', 'initial_weight_grams')
|
||||||
|
}),
|
||||||
|
('Current Status', {
|
||||||
|
'fields': ('remaining_percent', 'remaining_weight_grams',
|
||||||
|
'is_loaded_in_ams', 'current_tray_id', 'last_loaded_date')
|
||||||
|
}),
|
||||||
|
('Purchase Info', {'fields': ('purchase_date', 'purchase_price', 'supplier', 'notes')}),
|
||||||
|
('Timestamps', {'fields': ('created_at', 'updated_at', 'last_used')}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(FilamentSnapshot)
|
||||||
|
class FilamentSnapshotAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('printer_metric', 'tray_id', 'filament', 'type', 'sub_type', 'tag_uid', 'remain_percent', 'match_method')
|
||||||
|
list_filter = ('match_method', 'auto_matched', 'tray_id', 'type')
|
||||||
|
search_fields = ('type', 'sub_type', 'brand', 'color', 'tag_uid')
|
||||||
|
readonly_fields = ('printer_metric', 'filament', 'auto_matched', 'match_method', 'tag_uid', 'tray_uuid', 'state')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PrintJob)
|
||||||
|
class PrintJobAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('project_name', 'device', 'start_time', 'end_time', 'duration_minutes', 'final_status', 'completion_percent')
|
||||||
|
list_filter = ('device', 'final_status')
|
||||||
|
search_fields = ('project_name', 'gcode_file')
|
||||||
|
readonly_fields = ('created_at', 'updated_at', 'duration_minutes')
|
||||||
|
date_hierarchy = 'start_time'
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(FilamentUsage)
|
||||||
|
class FilamentUsageAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('print_job', 'filament', 'tray_id', 'consumed_percent', 'consumed_grams', 'is_primary')
|
||||||
|
list_filter = ('is_primary', 'tray_id')
|
||||||
|
readonly_fields = ('consumed_percent', 'consumed_grams')
|
||||||
7
bambu_run/apps.py
Normal file
7
bambu_run/apps.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class BambuRunConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "bambu_run"
|
||||||
|
verbose_name = "Bambu Run"
|
||||||
55
bambu_run/conf.py
Normal file
55
bambu_run/conf.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""
|
||||||
|
App-level settings with sensible defaults.
|
||||||
|
|
||||||
|
Override in your Django settings.py:
|
||||||
|
BAMBU_RUN_TIMEZONE = 'Australia/Melbourne'
|
||||||
|
BAMBU_RUN_BASE_TEMPLATE = 'base/base.html'
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
def get_setting(name, default):
|
||||||
|
return getattr(settings, name, default)
|
||||||
|
|
||||||
|
|
||||||
|
# Timezone for all timestamp display and queries
|
||||||
|
BAMBU_RUN_TIMEZONE = property(lambda self: get_setting("BAMBU_RUN_TIMEZONE", "UTC"))
|
||||||
|
|
||||||
|
# Base template that all bambu_run templates extend
|
||||||
|
BAMBU_RUN_BASE_TEMPLATE = property(
|
||||||
|
lambda self: get_setting("BAMBU_RUN_BASE_TEMPLATE", "bambu_run/base.html")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Login URL for @login_required redirects
|
||||||
|
BAMBU_RUN_LOGIN_URL = property(
|
||||||
|
lambda self: get_setting("BAMBU_RUN_LOGIN_URL", "/accounts/login/")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Default brand for auto-created filaments from MQTT
|
||||||
|
BAMBU_RUN_AUTO_CREATE_BRAND = property(
|
||||||
|
lambda self: get_setting("BAMBU_RUN_AUTO_CREATE_BRAND", "Bambu Lab")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Settings:
|
||||||
|
"""Lazy settings object that reads from Django settings with defaults."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def TIMEZONE(self):
|
||||||
|
return get_setting("BAMBU_RUN_TIMEZONE", "UTC")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def BASE_TEMPLATE(self):
|
||||||
|
return get_setting("BAMBU_RUN_BASE_TEMPLATE", "bambu_run/base.html")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def LOGIN_URL(self):
|
||||||
|
return get_setting("BAMBU_RUN_LOGIN_URL", "/accounts/login/")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def AUTO_CREATE_BRAND(self):
|
||||||
|
return get_setting("BAMBU_RUN_AUTO_CREATE_BRAND", "Bambu Lab")
|
||||||
|
|
||||||
|
|
||||||
|
app_settings = _Settings()
|
||||||
232
bambu_run/forms.py
Normal file
232
bambu_run/forms.py
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
from django import forms
|
||||||
|
from .models import Filament, FilamentColor, FilamentType
|
||||||
|
|
||||||
|
|
||||||
|
class FilamentTypeForm(forms.ModelForm):
|
||||||
|
"""Form for managing FilamentType registry"""
|
||||||
|
|
||||||
|
PRESET_TYPES = ['PLA', 'PETG', 'PET', 'ABS', 'ASA', 'TPU', 'PA', 'PC', 'PPS']
|
||||||
|
PRESET_SUB_TYPES = [
|
||||||
|
'PLA Basic', 'PLA Matte', 'PLA Silk', 'PLA Metal', 'PLA Marble', 'PLA Glow', 'PLA-CF',
|
||||||
|
'PETG Basic', 'PETG-CF', 'PETG-HF', 'ABS', 'TPU 95A', 'PA6-CF', 'ASA', 'PC', 'PPS-CF',
|
||||||
|
'Support W', 'Support G',
|
||||||
|
]
|
||||||
|
PRESET_BRANDS = [
|
||||||
|
'Bambu Lab', 'eSUN', 'Polymaker', 'Hatchbox', 'Prusament',
|
||||||
|
'MatterHackers', 'Overture', '3DXTech', 'ColorFabb',
|
||||||
|
]
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = FilamentType
|
||||||
|
fields = ['type', 'sub_type', 'brand']
|
||||||
|
widgets = {
|
||||||
|
'type': forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': 'e.g., PLA, PETG, ABS'
|
||||||
|
}),
|
||||||
|
'sub_type': forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': 'e.g., PLA Basic, PLA Matte (optional)'
|
||||||
|
}),
|
||||||
|
'brand': forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': 'e.g., Bambu Lab'
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FilamentForm(forms.ModelForm):
|
||||||
|
color_hex_text = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
max_length=7,
|
||||||
|
widget=forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': '#000000',
|
||||||
|
'pattern': '#[0-9A-Fa-f]{6}',
|
||||||
|
'id': 'id_color_hex_text'
|
||||||
|
}),
|
||||||
|
label='Color Hex Code'
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Filament
|
||||||
|
fields = [
|
||||||
|
'tray_uuid', 'tag_uid', 'tag_id', 'created_by',
|
||||||
|
'filament_type', 'type', 'sub_type', 'brand', 'color', 'color_hex',
|
||||||
|
'diameter', 'initial_weight_grams',
|
||||||
|
'remaining_percent', 'remaining_weight_grams',
|
||||||
|
'is_loaded_in_ams', 'current_tray_id',
|
||||||
|
'purchase_date', 'purchase_price', 'supplier', 'notes'
|
||||||
|
]
|
||||||
|
widgets = {
|
||||||
|
'tray_uuid': forms.TextInput(attrs={
|
||||||
|
'class': 'form-control font-monospace',
|
||||||
|
'placeholder': 'Optional - Auto-filled by MQTT',
|
||||||
|
'style': 'font-size: 0.9em;'
|
||||||
|
}),
|
||||||
|
'tag_uid': forms.TextInput(attrs={
|
||||||
|
'class': 'form-control font-monospace',
|
||||||
|
'placeholder': 'Optional - RFID chip ID',
|
||||||
|
'style': 'font-size: 0.9em;'
|
||||||
|
}),
|
||||||
|
'tag_id': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Optional - User-defined ID'}),
|
||||||
|
'created_by': forms.Select(attrs={'class': 'form-select'}),
|
||||||
|
'filament_type': forms.Select(attrs={'class': 'form-select'}),
|
||||||
|
'type': forms.HiddenInput(),
|
||||||
|
'sub_type': forms.HiddenInput(),
|
||||||
|
'brand': forms.HiddenInput(),
|
||||||
|
'color': forms.Select(attrs={'class': 'form-select', 'id': 'id_color'}),
|
||||||
|
'color_hex': forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'type': 'color',
|
||||||
|
'id': 'id_color_hex_picker'
|
||||||
|
}),
|
||||||
|
'diameter': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
|
||||||
|
'initial_weight_grams': forms.NumberInput(attrs={'class': 'form-control', 'placeholder': '1000'}),
|
||||||
|
'remaining_percent': forms.NumberInput(attrs={'class': 'form-control', 'min': '0', 'max': '100'}),
|
||||||
|
'remaining_weight_grams': forms.NumberInput(attrs={'class': 'form-control', 'readonly': 'readonly'}),
|
||||||
|
'is_loaded_in_ams': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||||
|
'current_tray_id': forms.NumberInput(attrs={'class': 'form-control', 'min': '0', 'max': '3'}),
|
||||||
|
'purchase_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
|
||||||
|
'purchase_price': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
|
||||||
|
'supplier': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
|
'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
if self.instance and self.instance.color_hex:
|
||||||
|
self.fields['color_hex_text'].initial = self.instance.color_hex
|
||||||
|
|
||||||
|
self.fields['filament_type'].queryset = FilamentType.objects.all()
|
||||||
|
self.fields['filament_type'].empty_label = '--- Select Filament Type ---'
|
||||||
|
self.fields['filament_type'].required = False
|
||||||
|
|
||||||
|
self.fields['type'].required = False
|
||||||
|
self.fields['sub_type'].required = False
|
||||||
|
self.fields['brand'].required = False
|
||||||
|
|
||||||
|
self._populate_color_choices()
|
||||||
|
|
||||||
|
def _populate_color_choices(self):
|
||||||
|
"""Populate color field choices from FilamentColor database with suggested colors"""
|
||||||
|
from .utils import strip_color_padding, match_filament_color
|
||||||
|
|
||||||
|
color_choices = [('', '--- Select Color ---')]
|
||||||
|
suggested_color = None
|
||||||
|
|
||||||
|
all_colors = FilamentColor.objects.all().order_by('filament_type', 'filament_sub_type', 'color_name')
|
||||||
|
|
||||||
|
if self.instance and self.instance.type and self.instance.color_hex:
|
||||||
|
color_code = strip_color_padding(self.instance.color_hex.lstrip('#'))
|
||||||
|
suggested = match_filament_color(
|
||||||
|
filament_type=self.instance.type,
|
||||||
|
filament_sub_type=self.instance.sub_type,
|
||||||
|
color_code=color_code,
|
||||||
|
brand=self.instance.brand or 'Bambu Lab'
|
||||||
|
)
|
||||||
|
if suggested:
|
||||||
|
suggested_color = suggested
|
||||||
|
|
||||||
|
if suggested_color:
|
||||||
|
color_choices.append((
|
||||||
|
suggested_color.color_name,
|
||||||
|
f"SUGGESTED: {suggested_color.filament_sub_type or suggested_color.filament_type}: {suggested_color.color_name}"
|
||||||
|
))
|
||||||
|
color_choices.append(('---separator---', '---' * 20))
|
||||||
|
|
||||||
|
for color in all_colors:
|
||||||
|
if suggested_color and color.pk == suggested_color.pk:
|
||||||
|
continue
|
||||||
|
|
||||||
|
display_name = f"{color.filament_sub_type or color.filament_type}: {color.color_name}"
|
||||||
|
color_choices.append((color.color_name, display_name))
|
||||||
|
|
||||||
|
color_choices.append(('---separator2---', '---' * 20))
|
||||||
|
color_choices.append(('custom', 'Custom (type in manually)'))
|
||||||
|
|
||||||
|
self.fields['color'].widget.choices = color_choices
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
is_loaded = cleaned_data.get('is_loaded_in_ams')
|
||||||
|
tray_id = cleaned_data.get('current_tray_id')
|
||||||
|
|
||||||
|
color_hex_text = cleaned_data.get('color_hex_text')
|
||||||
|
if color_hex_text:
|
||||||
|
cleaned_data['color_hex'] = color_hex_text
|
||||||
|
|
||||||
|
color = cleaned_data.get('color')
|
||||||
|
if color and 'separator' in color:
|
||||||
|
cleaned_data['color'] = ''
|
||||||
|
|
||||||
|
ft = cleaned_data.get('filament_type')
|
||||||
|
if ft:
|
||||||
|
cleaned_data['type'] = ft.type
|
||||||
|
cleaned_data['sub_type'] = ft.sub_type or ''
|
||||||
|
cleaned_data['brand'] = ft.brand
|
||||||
|
|
||||||
|
if is_loaded and tray_id is None:
|
||||||
|
raise forms.ValidationError('Tray ID required when filament is loaded in AMS')
|
||||||
|
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
|
||||||
|
class FilamentColorForm(forms.ModelForm):
|
||||||
|
"""Form for managing FilamentColor database"""
|
||||||
|
|
||||||
|
color_code = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.HiddenInput()
|
||||||
|
)
|
||||||
|
|
||||||
|
color_hex_input = forms.CharField(
|
||||||
|
required=True,
|
||||||
|
max_length=7,
|
||||||
|
widget=forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': '#000000',
|
||||||
|
'pattern': '#[0-9A-Fa-f]{6}',
|
||||||
|
}),
|
||||||
|
label='Color Hex Code'
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = FilamentColor
|
||||||
|
fields = ['color_code', 'color_name', 'filament_type_fk', 'filament_type', 'filament_sub_type', 'brand']
|
||||||
|
widgets = {
|
||||||
|
'color_name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g., Black, Orange'}),
|
||||||
|
'filament_type_fk': forms.Select(attrs={'class': 'form-select'}),
|
||||||
|
'filament_type': forms.HiddenInput(),
|
||||||
|
'filament_sub_type': forms.HiddenInput(),
|
||||||
|
'brand': forms.HiddenInput(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
if self.instance and self.instance.color_code:
|
||||||
|
self.fields['color_hex_input'].initial = f"#{self.instance.color_code}"
|
||||||
|
|
||||||
|
self.fields['filament_type_fk'].queryset = FilamentType.objects.all()
|
||||||
|
self.fields['filament_type_fk'].empty_label = '--- Select Filament Type ---'
|
||||||
|
self.fields['filament_type_fk'].required = False
|
||||||
|
|
||||||
|
self.fields['filament_type'].required = False
|
||||||
|
self.fields['filament_sub_type'].required = False
|
||||||
|
self.fields['brand'].required = False
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
|
||||||
|
color_hex = cleaned_data.get('color_hex_input', '')
|
||||||
|
if color_hex:
|
||||||
|
color_code = color_hex.lstrip('#').upper()[:6]
|
||||||
|
cleaned_data['color_code'] = color_code
|
||||||
|
|
||||||
|
ft_fk = cleaned_data.get('filament_type_fk')
|
||||||
|
if ft_fk:
|
||||||
|
cleaned_data['filament_type'] = ft_fk.type
|
||||||
|
cleaned_data['filament_sub_type'] = ft_fk.sub_type or ''
|
||||||
|
cleaned_data['brand'] = ft_fk.brand
|
||||||
|
|
||||||
|
return cleaned_data
|
||||||
0
bambu_run/management/__init__.py
Normal file
0
bambu_run/management/__init__.py
Normal file
0
bambu_run/management/commands/__init__.py
Normal file
0
bambu_run/management/commands/__init__.py
Normal file
142
bambu_run/management/commands/bambu_cleanup.py
Normal file
142
bambu_run/management/commands/bambu_cleanup.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
"""
|
||||||
|
Management command to clean up old FilamentSnapshot records.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python manage.py bambu_cleanup --days 90 --dry-run
|
||||||
|
python manage.py bambu_cleanup --days 180
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from bambu_run.models import FilamentSnapshot, PrinterMetrics
|
||||||
|
|
||||||
|
logger = logging.getLogger("bambu_run.cleanup")
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Clean up old FilamentSnapshot records to save database space"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--days", type=int, default=90,
|
||||||
|
help="Delete snapshots older than X days (default: 90)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run", action="store_true",
|
||||||
|
help="Show what would be deleted without actually deleting",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--keep-print-jobs", action="store_true",
|
||||||
|
help="Keep snapshots linked to print jobs even if old",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
days = options["days"]
|
||||||
|
dry_run = options["dry_run"]
|
||||||
|
keep_print_jobs = options["keep_print_jobs"]
|
||||||
|
|
||||||
|
cutoff_date = timezone.now() - timedelta(days=days)
|
||||||
|
|
||||||
|
self.stdout.write(f"Cleaning up FilamentSnapshots older than {days} days")
|
||||||
|
self.stdout.write(f"Cutoff date: {cutoff_date.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
|
|
||||||
|
old_snapshots = FilamentSnapshot.objects.filter(
|
||||||
|
printer_metric__timestamp__lt=cutoff_date
|
||||||
|
)
|
||||||
|
|
||||||
|
if keep_print_jobs:
|
||||||
|
old_snapshots = old_snapshots.exclude(
|
||||||
|
printer_metric__started_jobs__isnull=False
|
||||||
|
).exclude(
|
||||||
|
printer_metric__ended_jobs__isnull=False
|
||||||
|
)
|
||||||
|
|
||||||
|
count = old_snapshots.count()
|
||||||
|
|
||||||
|
if count == 0:
|
||||||
|
self.stdout.write(self.style.SUCCESS("No snapshots to delete."))
|
||||||
|
return
|
||||||
|
|
||||||
|
space_mb = (count * 391) / (1024 * 1024)
|
||||||
|
|
||||||
|
self.stdout.write(f"\nSnapshots to delete: {count:,}")
|
||||||
|
self.stdout.write(f"Estimated space saved: {space_mb:.2f} MB")
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(self.style.WARNING("\nDRY RUN - Nothing deleted"))
|
||||||
|
|
||||||
|
sample = old_snapshots[:10]
|
||||||
|
self.stdout.write("\nSample of snapshots to delete:")
|
||||||
|
for snap in sample:
|
||||||
|
self.stdout.write(
|
||||||
|
f" - {snap.printer_metric.timestamp} | "
|
||||||
|
f"Tray {snap.tray_id} | {snap.type or 'Empty'} | "
|
||||||
|
f"{snap.remain_percent}%"
|
||||||
|
)
|
||||||
|
if count > 10:
|
||||||
|
self.stdout.write(f" ... and {count - 10:,} more")
|
||||||
|
else:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(
|
||||||
|
f"\nThis will permanently delete {count:,} snapshot records!"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
confirm = input("Type 'DELETE' to confirm: ")
|
||||||
|
|
||||||
|
if confirm != "DELETE":
|
||||||
|
self.stdout.write(self.style.ERROR("Cancelled."))
|
||||||
|
return
|
||||||
|
|
||||||
|
batch_size = 1000
|
||||||
|
deleted_total = 0
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
while True:
|
||||||
|
batch_ids = list(
|
||||||
|
old_snapshots.values_list('id', flat=True)[:batch_size]
|
||||||
|
)
|
||||||
|
if not batch_ids:
|
||||||
|
break
|
||||||
|
|
||||||
|
deleted = FilamentSnapshot.objects.filter(id__in=batch_ids).delete()
|
||||||
|
deleted_count = deleted[0]
|
||||||
|
deleted_total += deleted_count
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
f"Deleted {deleted_total:,} / {count:,} snapshots...",
|
||||||
|
ending='\r'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"\nSuccessfully deleted {deleted_total:,} snapshots "
|
||||||
|
f"({space_mb:.2f} MB freed)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write("\nChecking for orphaned PrinterMetrics...")
|
||||||
|
orphaned_metrics = PrinterMetrics.objects.filter(
|
||||||
|
timestamp__lt=cutoff_date,
|
||||||
|
filament_snapshots__isnull=True
|
||||||
|
)
|
||||||
|
|
||||||
|
metrics_count = orphaned_metrics.count()
|
||||||
|
if metrics_count > 0:
|
||||||
|
metrics_space_mb = (metrics_count * 1500) / (1024 * 1024)
|
||||||
|
self.stdout.write(
|
||||||
|
f"Found {metrics_count:,} orphaned PrinterMetrics "
|
||||||
|
f"({metrics_space_mb:.2f} MB)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if input("Delete these too? (y/N): ").lower() == 'y':
|
||||||
|
orphaned_metrics.delete()
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"Deleted {metrics_count:,} orphaned metrics"
|
||||||
|
)
|
||||||
|
)
|
||||||
674
bambu_run/management/commands/bambu_collector.py
Normal file
674
bambu_run/management/commands/bambu_collector.py
Normal file
@@ -0,0 +1,674 @@
|
|||||||
|
"""
|
||||||
|
Management command to continuously collect 3D printer MQTT data.
|
||||||
|
Collects printer metrics from Bambu Lab 3D printers.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python manage.py bambu_collector
|
||||||
|
python manage.py bambu_collector --interval 60
|
||||||
|
python manage.py bambu_collector --once
|
||||||
|
python manage.py bambu_collector --verbose
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import ssl
|
||||||
|
import time
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from bambu_run.conf import app_settings
|
||||||
|
from bambu_run.models import Printer, PrinterMetrics
|
||||||
|
|
||||||
|
logger = logging.getLogger("bambu_run.collector")
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
"""
|
||||||
|
MQTT Poll -> PrinterMetrics -> FilamentSnapshot -> Auto-Match -> Update Filament
|
||||||
|
"""
|
||||||
|
help = "Continuously collect 3D printer MQTT data from Bambu Lab printer"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--interval", type=int, default=30,
|
||||||
|
help="Data collection interval in seconds (default: 30)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--once", action="store_true",
|
||||||
|
help="Run once and exit (useful for testing/cron)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--verbose", action="store_true", help="Enable verbose logging"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--disable-ssl-verify", action="store_true",
|
||||||
|
help="Disable SSL certificate verification (use with caution)",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.printer_client = None
|
||||||
|
self.printer_device = None
|
||||||
|
self.verbose = False
|
||||||
|
self.disable_ssl_verify = False
|
||||||
|
self.error_count = 0
|
||||||
|
self.success_count = 0
|
||||||
|
self.mqtt_connect_errors = 0
|
||||||
|
self.start_time = None
|
||||||
|
self.current_print_job = None
|
||||||
|
self.last_gcode_state = None
|
||||||
|
self.last_subtask_name = None
|
||||||
|
self.trays_used = set()
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
self.verbose = options["verbose"]
|
||||||
|
self.disable_ssl_verify = options["disable_ssl_verify"]
|
||||||
|
interval = options["interval"]
|
||||||
|
run_once = options["once"]
|
||||||
|
|
||||||
|
if self.disable_ssl_verify:
|
||||||
|
logger.warning("SSL verification disabled - use with caution!")
|
||||||
|
ssl._create_default_https_context = ssl._create_unverified_context
|
||||||
|
os.environ["PYTHONHTTPSVERIFY"] = "0"
|
||||||
|
os.environ["CURL_CA_BUNDLE"] = ""
|
||||||
|
os.environ["REQUESTS_CA_BUNDLE"] = ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
import paho.mqtt.client as mqtt_client
|
||||||
|
|
||||||
|
original_tls_set = mqtt_client.Client.tls_set
|
||||||
|
|
||||||
|
def patched_tls_set(
|
||||||
|
self, ca_certs=None, certfile=None, keyfile=None,
|
||||||
|
cert_reqs=ssl.CERT_NONE, tls_version=ssl.PROTOCOL_TLS, ciphers=None,
|
||||||
|
):
|
||||||
|
return original_tls_set(
|
||||||
|
self, ca_certs, certfile, keyfile, ssl.CERT_NONE, tls_version, ciphers,
|
||||||
|
)
|
||||||
|
|
||||||
|
mqtt_client.Client.tls_set = patched_tls_set
|
||||||
|
logger.debug("Successfully patched paho-mqtt SSL verification")
|
||||||
|
except ImportError:
|
||||||
|
logger.debug("paho-mqtt not yet imported, will rely on SSL context")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not patch paho-mqtt: {e}")
|
||||||
|
|
||||||
|
self._configure_logging()
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._initialize_printer()
|
||||||
|
except Exception as e:
|
||||||
|
raise CommandError(f"Initialization failed: {e}")
|
||||||
|
|
||||||
|
self.start_time = timezone.now()
|
||||||
|
logger.info(f"Bambu Run data collector started for printer: {self.printer_device.name}")
|
||||||
|
logger.info(f"Collection interval: {interval} seconds")
|
||||||
|
logger.info(f"Mode: {'Single run' if run_once else 'Continuous'}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if run_once:
|
||||||
|
self._collect_printer_data()
|
||||||
|
logger.info("Single collection completed successfully")
|
||||||
|
else:
|
||||||
|
self._run_continuous_loop(interval)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
self._print_statistics()
|
||||||
|
logger.info("Bambu Run data collector stopped by user")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Fatal error in main loop: {e}")
|
||||||
|
raise CommandError(f"Runner failed: {e}")
|
||||||
|
|
||||||
|
def _configure_logging(self):
|
||||||
|
log_level = logging.DEBUG if self.verbose else logging.INFO
|
||||||
|
logger.setLevel(log_level)
|
||||||
|
|
||||||
|
if not logger.handlers:
|
||||||
|
handler = logging.StreamHandler()
|
||||||
|
handler.setLevel(log_level)
|
||||||
|
formatter = logging.Formatter(
|
||||||
|
"%(asctime)s [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
|
||||||
|
)
|
||||||
|
handler.setFormatter(formatter)
|
||||||
|
logger.addHandler(handler)
|
||||||
|
|
||||||
|
def _initialize_printer(self):
|
||||||
|
from bambu_run.mqtt_client import BambuPrinter
|
||||||
|
|
||||||
|
bambu_username = os.environ.get("BAMBU_USERNAME")
|
||||||
|
bambu_password = os.environ.get("BAMBU_PASSWORD")
|
||||||
|
bambu_token = os.environ.get("BAMBU_TOKEN")
|
||||||
|
bambu_device_id = os.environ.get("BAMBU_DEVICE_ID")
|
||||||
|
|
||||||
|
if not bambu_token and not all([bambu_username, bambu_password]):
|
||||||
|
raise CommandError(
|
||||||
|
"Either BAMBU_TOKEN or both BAMBU_USERNAME and BAMBU_PASSWORD "
|
||||||
|
"environment variables must be set"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Connecting to Bambu Lab printer...")
|
||||||
|
try:
|
||||||
|
if bambu_token:
|
||||||
|
logger.info("Using saved BAMBU_TOKEN for authentication")
|
||||||
|
self.printer_client = BambuPrinter(
|
||||||
|
token=bambu_token, device_id=bambu_device_id
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("Authenticating with username/password")
|
||||||
|
self.printer_client = BambuPrinter(
|
||||||
|
username=bambu_username,
|
||||||
|
password=bambu_password,
|
||||||
|
device_id=bambu_device_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Initiating MQTT connection...")
|
||||||
|
self.printer_client.connect(blocking=False)
|
||||||
|
logger.info("MQTT connection initiated (non-blocking)")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if "CERTIFICATE_VERIFY_FAILED" in str(e) or "SSL" in str(e):
|
||||||
|
error_msg = (
|
||||||
|
f"SSL certificate verification failed: {e}\n\n"
|
||||||
|
"Solutions:\n"
|
||||||
|
"1. Run with --disable-ssl-verify flag\n"
|
||||||
|
"2. Install Python SSL certificates\n"
|
||||||
|
"3. pip install --upgrade certifi\n"
|
||||||
|
)
|
||||||
|
raise CommandError(error_msg)
|
||||||
|
raise CommandError(f"Failed to initialize printer client: {e}")
|
||||||
|
|
||||||
|
self.printer_device = self._ensure_printer_device_exists()
|
||||||
|
logger.info(f"Initialized for printer device: {self.printer_device}")
|
||||||
|
|
||||||
|
def _ensure_printer_device_exists(self) -> Printer:
|
||||||
|
try:
|
||||||
|
snapshot = self.printer_client.get_snapshot()
|
||||||
|
|
||||||
|
if snapshot:
|
||||||
|
device, created = Printer.objects.update_or_create(
|
||||||
|
model="Bambu Lab",
|
||||||
|
defaults={
|
||||||
|
"name": "Bambu Lab Printer",
|
||||||
|
"manufacturer": "Bambu Lab",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
action = "Created" if created else "Updated"
|
||||||
|
logger.info(f"{action} printer device record: {device}")
|
||||||
|
return device
|
||||||
|
else:
|
||||||
|
logger.warning("Snapshot returned None - MQTT not connected yet")
|
||||||
|
device = Printer.objects.filter(is_active=True).first()
|
||||||
|
if device:
|
||||||
|
logger.info(f"Using existing device record: {device}")
|
||||||
|
return device
|
||||||
|
else:
|
||||||
|
device = Printer.objects.create(
|
||||||
|
name="Bambu Lab Printer",
|
||||||
|
model="Bambu Lab",
|
||||||
|
manufacturer="Bambu Lab",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
logger.info(f"Created placeholder device: {device}")
|
||||||
|
return device
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error during device initialization: {e}")
|
||||||
|
try:
|
||||||
|
device = Printer.objects.filter(is_active=True).first()
|
||||||
|
if device:
|
||||||
|
logger.warning(f"Using existing device record from DB: {device}")
|
||||||
|
return device
|
||||||
|
else:
|
||||||
|
raise CommandError(
|
||||||
|
"No printer device found in database and initialization failed."
|
||||||
|
)
|
||||||
|
except Printer.DoesNotExist:
|
||||||
|
raise CommandError("Failed to create or retrieve printer device.")
|
||||||
|
|
||||||
|
def _run_continuous_loop(self, interval: int):
|
||||||
|
iteration = 0
|
||||||
|
while True:
|
||||||
|
iteration += 1
|
||||||
|
loop_start = time.time()
|
||||||
|
|
||||||
|
if self.verbose:
|
||||||
|
logger.debug(f"=== Iteration {iteration} ===")
|
||||||
|
|
||||||
|
self._collect_printer_data()
|
||||||
|
|
||||||
|
elapsed = time.time() - loop_start
|
||||||
|
sleep_time = max(0, interval - elapsed)
|
||||||
|
|
||||||
|
if self.verbose:
|
||||||
|
logger.debug(f"Collection took {elapsed:.2f}s, sleeping for {sleep_time:.2f}s")
|
||||||
|
|
||||||
|
if iteration % 100 == 0:
|
||||||
|
self._print_statistics()
|
||||||
|
|
||||||
|
time.sleep(sleep_time)
|
||||||
|
|
||||||
|
def _convert_mqtt_color(self, mqtt_color):
|
||||||
|
if not mqtt_color:
|
||||||
|
return None
|
||||||
|
color_hex = mqtt_color[:6] if len(mqtt_color) >= 6 else mqtt_color
|
||||||
|
return f"#{color_hex.upper()}"
|
||||||
|
|
||||||
|
def _match_filament_to_inventory(self, tray_data):
|
||||||
|
from bambu_run.models import Filament
|
||||||
|
|
||||||
|
tray_id = tray_data.get('tray_id')
|
||||||
|
tray_uuid = tray_data.get('tray_uuid')
|
||||||
|
tag_uid = tray_data.get('tag_uid')
|
||||||
|
tag_id = tray_data.get('tag_id')
|
||||||
|
type_val = tray_data.get('type')
|
||||||
|
sub_type = tray_data.get('sub_type')
|
||||||
|
color = tray_data.get('color')
|
||||||
|
|
||||||
|
if tray_uuid:
|
||||||
|
filament = Filament.objects.filter(tray_uuid=tray_uuid).first()
|
||||||
|
if filament:
|
||||||
|
if self.verbose:
|
||||||
|
logger.debug(f"Matched filament via tray_uuid: {tray_uuid[:16]}...")
|
||||||
|
return filament, 'tray_uuid'
|
||||||
|
|
||||||
|
if tag_uid:
|
||||||
|
filament = Filament.objects.filter(tag_uid=tag_uid).first()
|
||||||
|
if filament:
|
||||||
|
if self.verbose:
|
||||||
|
logger.debug(f"Matched filament via tag_uid: {tag_uid}")
|
||||||
|
return filament, 'tag_uid'
|
||||||
|
|
||||||
|
if tag_id:
|
||||||
|
filament = Filament.objects.filter(tag_id=tag_id).first()
|
||||||
|
if filament:
|
||||||
|
if self.verbose:
|
||||||
|
logger.debug(f"Matched filament via tag_id: {tag_id}")
|
||||||
|
return filament, 'tag_id'
|
||||||
|
|
||||||
|
if type_val and color:
|
||||||
|
query_filters = {'type': type_val, 'color': color}
|
||||||
|
if sub_type:
|
||||||
|
query_filters['sub_type'] = sub_type
|
||||||
|
|
||||||
|
filament = Filament.objects.filter(
|
||||||
|
**query_filters, is_loaded_in_ams=False
|
||||||
|
).order_by('remaining_percent', 'last_used').first()
|
||||||
|
|
||||||
|
if not filament:
|
||||||
|
filament = Filament.objects.filter(
|
||||||
|
**query_filters
|
||||||
|
).order_by('remaining_percent', 'last_used').first()
|
||||||
|
|
||||||
|
if filament:
|
||||||
|
if self.verbose:
|
||||||
|
logger.debug(f"Matched filament via type+sub_type+color: {filament}")
|
||||||
|
return filament, 'type_sub_type_color'
|
||||||
|
|
||||||
|
if self.verbose:
|
||||||
|
logger.info(f"No match found for tray {tray_id}. Auto-creating new filament...")
|
||||||
|
|
||||||
|
filament = self._auto_create_filament(tray_data)
|
||||||
|
return filament, 'auto_created'
|
||||||
|
|
||||||
|
def _auto_create_filament(self, tray_data):
|
||||||
|
from bambu_run.models import Filament, FilamentType
|
||||||
|
from bambu_run.utils import strip_color_padding, match_filament_color
|
||||||
|
|
||||||
|
tray_uuid = tray_data.get('tray_uuid')
|
||||||
|
tag_uid = tray_data.get('tag_uid')
|
||||||
|
type_val = tray_data.get('type', 'Unknown')
|
||||||
|
sub_type = tray_data.get('sub_type', '')
|
||||||
|
mqtt_color = tray_data.get('color')
|
||||||
|
remain_percent = tray_data.get('remain_percent', 100)
|
||||||
|
diameter = tray_data.get('tray_diameter', 1.75)
|
||||||
|
initial_weight = tray_data.get('tray_weight', 1000)
|
||||||
|
|
||||||
|
default_brand = app_settings.AUTO_CREATE_BRAND
|
||||||
|
|
||||||
|
color_code = strip_color_padding(mqtt_color)
|
||||||
|
color_hex = f"#{color_code}" if color_code else None
|
||||||
|
|
||||||
|
color_name = mqtt_color
|
||||||
|
filament_color = match_filament_color(
|
||||||
|
filament_type=type_val,
|
||||||
|
filament_sub_type=sub_type,
|
||||||
|
color_code=color_code,
|
||||||
|
brand=default_brand
|
||||||
|
)
|
||||||
|
|
||||||
|
if filament_color:
|
||||||
|
color_name = filament_color.color_name
|
||||||
|
if self.verbose:
|
||||||
|
logger.info(f"Matched color from database: {color_name} (#{color_code})")
|
||||||
|
else:
|
||||||
|
color_name = mqtt_color
|
||||||
|
if self.verbose:
|
||||||
|
logger.warning(
|
||||||
|
f"No color match in database for {type_val} {sub_type} #{color_code}. "
|
||||||
|
f"Using hex code as color name."
|
||||||
|
)
|
||||||
|
|
||||||
|
filament_type_obj, ft_created = FilamentType.objects.get_or_create(
|
||||||
|
type=type_val,
|
||||||
|
sub_type=sub_type or None,
|
||||||
|
brand=default_brand,
|
||||||
|
)
|
||||||
|
if ft_created and self.verbose:
|
||||||
|
logger.info(f"Auto-created FilamentType: {filament_type_obj}")
|
||||||
|
|
||||||
|
filament = Filament.objects.create(
|
||||||
|
filament_type=filament_type_obj,
|
||||||
|
tray_uuid=tray_uuid,
|
||||||
|
tag_uid=tag_uid,
|
||||||
|
type=type_val,
|
||||||
|
sub_type=sub_type,
|
||||||
|
brand=default_brand,
|
||||||
|
color=color_name,
|
||||||
|
color_hex=color_hex,
|
||||||
|
diameter=diameter,
|
||||||
|
initial_weight_grams=initial_weight,
|
||||||
|
remaining_percent=remain_percent,
|
||||||
|
created_by='Auto Detection',
|
||||||
|
is_loaded_in_ams=True,
|
||||||
|
current_tray_id=tray_data.get('tray_id'),
|
||||||
|
last_loaded_date=timezone.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
filament.update_remaining_weight()
|
||||||
|
filament.save()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Auto-created filament: {filament.brand} {filament.type} "
|
||||||
|
f"{filament.sub_type} - {filament.color} (SN: {tray_uuid[:16] if tray_uuid else 'N/A'}...)"
|
||||||
|
)
|
||||||
|
|
||||||
|
return filament
|
||||||
|
|
||||||
|
def _update_filament_status(self, filament, tray_id, remain_percent):
|
||||||
|
from bambu_run.models import Filament
|
||||||
|
|
||||||
|
if filament.remaining_percent != remain_percent:
|
||||||
|
filament.remaining_percent = remain_percent
|
||||||
|
filament.update_remaining_weight()
|
||||||
|
filament.last_used = timezone.now()
|
||||||
|
if self.verbose:
|
||||||
|
logger.debug(f"Updated filament {filament}: {remain_percent}%")
|
||||||
|
|
||||||
|
if not filament.is_loaded_in_ams or filament.current_tray_id != tray_id:
|
||||||
|
previous_filament = Filament.objects.filter(
|
||||||
|
is_loaded_in_ams=True, current_tray_id=tray_id
|
||||||
|
).exclude(id=filament.id).first()
|
||||||
|
|
||||||
|
if previous_filament:
|
||||||
|
previous_filament.is_loaded_in_ams = False
|
||||||
|
previous_filament.current_tray_id = None
|
||||||
|
previous_filament.save()
|
||||||
|
logger.info(
|
||||||
|
f"Auto-unloaded {previous_filament} from Tray {tray_id} "
|
||||||
|
f"(replaced by {filament.brand} {filament.type} - {filament.color})"
|
||||||
|
)
|
||||||
|
|
||||||
|
filament.is_loaded_in_ams = True
|
||||||
|
filament.current_tray_id = tray_id
|
||||||
|
filament.last_loaded_date = timezone.now()
|
||||||
|
if self.verbose:
|
||||||
|
logger.debug(f"Updated filament location: Tray {tray_id}")
|
||||||
|
|
||||||
|
filament.save()
|
||||||
|
|
||||||
|
def _create_filament_snapshots(self, printer_metric, filaments_data, snapshot):
|
||||||
|
from bambu_run.models import FilamentSnapshot
|
||||||
|
|
||||||
|
ams_units = {
|
||||||
|
u.get('unit_id'): u for u in snapshot.get('ams_units', [])
|
||||||
|
}
|
||||||
|
|
||||||
|
for tray_data in filaments_data:
|
||||||
|
tray_id = tray_data.get('tray_id')
|
||||||
|
if tray_id is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
filament, match_method = self._match_filament_to_inventory(tray_data)
|
||||||
|
|
||||||
|
if filament:
|
||||||
|
remain_percent = tray_data.get('remain_percent')
|
||||||
|
if remain_percent is not None:
|
||||||
|
self._update_filament_status(filament, tray_id, remain_percent)
|
||||||
|
|
||||||
|
unit_id = str(int(tray_id) // 4) if tray_id.isdigit() else None
|
||||||
|
unit_data = ams_units.get(unit_id, {})
|
||||||
|
|
||||||
|
FilamentSnapshot.objects.create(
|
||||||
|
printer_metric=printer_metric,
|
||||||
|
filament=filament,
|
||||||
|
tray_id=tray_id,
|
||||||
|
slot_name=tray_data.get('slot'),
|
||||||
|
type=tray_data.get('type'),
|
||||||
|
sub_type=tray_data.get('sub_type'),
|
||||||
|
color=tray_data.get('color'),
|
||||||
|
remain_percent=tray_data.get('remain_percent'),
|
||||||
|
k_value=tray_data.get('k'),
|
||||||
|
temp=self._to_decimal(unit_data.get('temp')),
|
||||||
|
humidity=unit_data.get('humidity'),
|
||||||
|
tag_uid=tray_data.get('tag_uid'),
|
||||||
|
tray_uuid=tray_data.get('tray_uuid'),
|
||||||
|
state=tray_data.get('state'),
|
||||||
|
auto_matched=bool(filament),
|
||||||
|
match_method=match_method
|
||||||
|
)
|
||||||
|
|
||||||
|
def _track_print_job(self, metric, snapshot):
|
||||||
|
from bambu_run.models import PrintJob, FilamentUsage
|
||||||
|
|
||||||
|
gcode_state = snapshot.get('gcode_state')
|
||||||
|
subtask_name = snapshot.get('subtask_name')
|
||||||
|
|
||||||
|
if self._is_print_starting(gcode_state, subtask_name):
|
||||||
|
if self.current_print_job:
|
||||||
|
self._finalize_print_job(metric, snapshot)
|
||||||
|
|
||||||
|
self.current_print_job = PrintJob.objects.create(
|
||||||
|
device=self.printer_device,
|
||||||
|
project_name=subtask_name,
|
||||||
|
gcode_file=snapshot.get('gcode_file'),
|
||||||
|
start_time=metric.timestamp,
|
||||||
|
start_metric=metric,
|
||||||
|
total_layers=snapshot.get('total_layer_num'),
|
||||||
|
completion_percent=snapshot.get('print_percent', 0)
|
||||||
|
)
|
||||||
|
self.trays_used = set()
|
||||||
|
logger.info(f"Print job started: {subtask_name}")
|
||||||
|
|
||||||
|
if self.current_print_job:
|
||||||
|
tray_now = snapshot.get('tray_now', '')
|
||||||
|
if tray_now not in (None, '', '255'):
|
||||||
|
try:
|
||||||
|
tray_id = int(tray_now)
|
||||||
|
if 0 <= tray_id <= 15:
|
||||||
|
self.trays_used.add(tray_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if self._is_print_ending(gcode_state) and self.current_print_job:
|
||||||
|
self._finalize_print_job(metric, snapshot)
|
||||||
|
|
||||||
|
self.last_gcode_state = gcode_state
|
||||||
|
self.last_subtask_name = subtask_name
|
||||||
|
|
||||||
|
def _is_print_starting(self, gcode_state, subtask_name):
|
||||||
|
is_printing = gcode_state not in ['FINISH', 'IDLE', 'FAILED', None, '']
|
||||||
|
has_new_job = subtask_name and subtask_name != self.last_subtask_name
|
||||||
|
return is_printing and has_new_job
|
||||||
|
|
||||||
|
def _is_print_ending(self, gcode_state):
|
||||||
|
ending_states = ['FINISH', 'FAILED']
|
||||||
|
return gcode_state in ending_states and self.last_gcode_state not in ending_states
|
||||||
|
|
||||||
|
def _finalize_print_job(self, metric, snapshot):
|
||||||
|
from bambu_run.models import FilamentUsage
|
||||||
|
|
||||||
|
self.current_print_job.end_time = metric.timestamp
|
||||||
|
self.current_print_job.end_metric = metric
|
||||||
|
self.current_print_job.final_status = snapshot.get('gcode_state')
|
||||||
|
self.current_print_job.completion_percent = snapshot.get('print_percent', 0)
|
||||||
|
self.current_print_job.calculate_duration()
|
||||||
|
self.current_print_job.save()
|
||||||
|
|
||||||
|
start_metric = self.current_print_job.start_metric
|
||||||
|
if not start_metric:
|
||||||
|
logger.warning(f"No start_metric for job {self.current_print_job.id}, skipping filament usage")
|
||||||
|
elif not self.trays_used:
|
||||||
|
logger.warning(f"No trays tracked for job {self.current_print_job.project_name}, skipping filament usage")
|
||||||
|
else:
|
||||||
|
for tray_id in self.trays_used:
|
||||||
|
start_snap = start_metric.filament_snapshots.filter(
|
||||||
|
tray_id=tray_id, filament__isnull=False
|
||||||
|
).first()
|
||||||
|
if not start_snap:
|
||||||
|
continue
|
||||||
|
|
||||||
|
end_snap = metric.filament_snapshots.filter(
|
||||||
|
filament=start_snap.filament, tray_id=tray_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
usage = FilamentUsage.objects.create(
|
||||||
|
print_job=self.current_print_job,
|
||||||
|
filament=start_snap.filament,
|
||||||
|
tray_id=tray_id,
|
||||||
|
starting_percent=start_snap.remain_percent or 100,
|
||||||
|
ending_percent=end_snap.remain_percent if end_snap else None,
|
||||||
|
is_primary=(len(self.trays_used) == 1),
|
||||||
|
)
|
||||||
|
usage.calculate_consumed()
|
||||||
|
usage.save()
|
||||||
|
|
||||||
|
if self.verbose:
|
||||||
|
logger.debug(
|
||||||
|
f"Filament usage for {start_snap.filament} (tray {tray_id}): "
|
||||||
|
f"{usage.starting_percent}% -> {usage.ending_percent}%, consumed {usage.consumed_percent}%"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Print job finished: {self.current_print_job.project_name} "
|
||||||
|
f"({self.current_print_job.final_status}) - Duration: {self.current_print_job.duration_minutes} min, "
|
||||||
|
f"Trays used: {sorted(self.trays_used) if self.trays_used else 'none tracked'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.current_print_job = None
|
||||||
|
self.trays_used = set()
|
||||||
|
|
||||||
|
def _collect_printer_data(self):
|
||||||
|
try:
|
||||||
|
snapshot = self.printer_client.get_snapshot()
|
||||||
|
|
||||||
|
if snapshot is None:
|
||||||
|
self.mqtt_connect_errors += 1
|
||||||
|
if self.mqtt_connect_errors <= 5 or self.verbose:
|
||||||
|
logger.warning(
|
||||||
|
f"MQTT not connected yet or no data available "
|
||||||
|
f"(attempt {self.mqtt_connect_errors})"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
metric = PrinterMetrics.objects.create(
|
||||||
|
device=self.printer_device,
|
||||||
|
timestamp=timezone.now(),
|
||||||
|
nozzle_temp=self._to_decimal(snapshot.get("nozzle_temp")),
|
||||||
|
nozzle_target_temp=self._to_decimal(snapshot.get("nozzle_target_temp")),
|
||||||
|
bed_temp=self._to_decimal(snapshot.get("bed_temp")),
|
||||||
|
bed_target_temp=self._to_decimal(snapshot.get("bed_target_temp")),
|
||||||
|
chamber_temp=self._to_decimal(snapshot.get("chamber_temp")),
|
||||||
|
nozzle_diameter=self._to_decimal(snapshot.get("nozzle_diameter")),
|
||||||
|
nozzle_type=snapshot.get("nozzle_type"),
|
||||||
|
gcode_state=snapshot.get("gcode_state"),
|
||||||
|
print_type=snapshot.get("print_type"),
|
||||||
|
print_percent=snapshot.get("print_percent"),
|
||||||
|
remaining_time_min=snapshot.get("remaining_time_min"),
|
||||||
|
layer_num=snapshot.get("layer_num"),
|
||||||
|
total_layer_num=snapshot.get("total_layer_num"),
|
||||||
|
print_line_number=snapshot.get("print_line_number"),
|
||||||
|
subtask_name=snapshot.get("subtask_name"),
|
||||||
|
gcode_file=snapshot.get("gcode_file"),
|
||||||
|
cooling_fan_speed=snapshot.get("cooling_fan_speed"),
|
||||||
|
heatbreak_fan_speed=snapshot.get("heatbreak_fan_speed"),
|
||||||
|
big_fan1_speed=snapshot.get("big_fan1_speed"),
|
||||||
|
big_fan2_speed=snapshot.get("big_fan2_speed"),
|
||||||
|
spd_lvl=snapshot.get("spd_lvl"),
|
||||||
|
spd_mag=snapshot.get("spd_mag"),
|
||||||
|
wifi_signal_dbm=snapshot.get("wifi_signal_dbm"),
|
||||||
|
print_error=snapshot.get("print_error", 0),
|
||||||
|
has_errors=snapshot.get("has_errors", False),
|
||||||
|
chamber_light=snapshot.get("chamber_light"),
|
||||||
|
ipcam_record=snapshot.get("ipcam_record"),
|
||||||
|
timelapse=snapshot.get("timelapse"),
|
||||||
|
stg_cur=snapshot.get("stg_cur"),
|
||||||
|
sdcard=snapshot.get("sdcard"),
|
||||||
|
gcode_file_prepare_percent=snapshot.get("gcode_file_prepare_percent"),
|
||||||
|
lifecycle=snapshot.get("lifecycle"),
|
||||||
|
hms=snapshot.get("hms", []),
|
||||||
|
ams_unit_count=snapshot.get("ams_unit_count"),
|
||||||
|
ams_status=snapshot.get("ams_status"),
|
||||||
|
ams_rfid_status=snapshot.get("ams_rfid_status"),
|
||||||
|
ams_humidity=snapshot.get("ams_humidity"),
|
||||||
|
ams_humidity_raw=snapshot.get("ams_humidity_raw"),
|
||||||
|
ams_temp=self._to_decimal(snapshot.get("ams_temp")),
|
||||||
|
ams_version=snapshot.get("ams_version"),
|
||||||
|
tray_is_bbl_bits=snapshot.get("tray_is_bbl_bits"),
|
||||||
|
tray_read_done_bits=snapshot.get("tray_read_done_bits"),
|
||||||
|
filaments=snapshot.get("filaments", []),
|
||||||
|
ams_units=snapshot.get("ams_units", []),
|
||||||
|
external_spool=snapshot.get("external_spool", {}),
|
||||||
|
lights_report=snapshot.get("lights_report", []),
|
||||||
|
)
|
||||||
|
|
||||||
|
filaments_data = snapshot.get('filaments', [])
|
||||||
|
if filaments_data:
|
||||||
|
self._create_filament_snapshots(metric, filaments_data, snapshot)
|
||||||
|
|
||||||
|
self._track_print_job(metric, snapshot)
|
||||||
|
|
||||||
|
self.success_count += 1
|
||||||
|
|
||||||
|
if self.verbose:
|
||||||
|
logger.debug(
|
||||||
|
f"Printer Metrics: Nozzle={snapshot.get('nozzle_temp')}C, "
|
||||||
|
f"Bed={snapshot.get('bed_temp')}C, "
|
||||||
|
f"Progress={snapshot.get('print_percent')}%, "
|
||||||
|
f"State={snapshot.get('gcode_state')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.error_count += 1
|
||||||
|
logger.error(f"Error collecting printer data (total errors: {self.error_count}): {e}")
|
||||||
|
if self.verbose:
|
||||||
|
logger.exception("Detailed traceback:")
|
||||||
|
|
||||||
|
def _to_decimal(self, value) -> Optional[Decimal]:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return Decimal(str(value))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _print_statistics(self):
|
||||||
|
if self.start_time:
|
||||||
|
runtime = timezone.now() - self.start_time
|
||||||
|
total_collections = self.success_count + self.error_count
|
||||||
|
success_rate = (
|
||||||
|
(self.success_count / total_collections * 100)
|
||||||
|
if total_collections > 0
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("=== Statistics ===")
|
||||||
|
logger.info(f"Runtime: {runtime}")
|
||||||
|
logger.info(f"Successful collections: {self.success_count}")
|
||||||
|
logger.info(f"Failed collections: {self.error_count}")
|
||||||
|
logger.info(f"MQTT connection warnings: {self.mqtt_connect_errors}")
|
||||||
|
logger.info(f"Success rate: {success_rate:.1f}%")
|
||||||
208
bambu_run/migrations/0001_initial.py
Normal file
208
bambu_run/migrations/0001_initial.py
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
"""
|
||||||
|
Initial migration for bambu_run.
|
||||||
|
|
||||||
|
For STANDALONE deployments (fresh SQLite), this creates all tables from scratch.
|
||||||
|
|
||||||
|
For RAE integration, this migration should NOT be run directly — instead,
|
||||||
|
use the SeparateDatabaseAndState migration in the infrastructure app
|
||||||
|
to transfer model ownership without touching existing tables.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Printer",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("name", models.CharField(help_text="Printer display name", max_length=200)),
|
||||||
|
("ip_address", models.GenericIPAddressField(blank=True, help_text="Local IP address", null=True)),
|
||||||
|
("serial_number", models.CharField(blank=True, help_text="Printer serial number", max_length=100)),
|
||||||
|
("model", models.CharField(blank=True, help_text="Printer model (e.g., X1C, P1S)", max_length=100)),
|
||||||
|
("is_active", models.BooleanField(default=True, help_text="Whether the printer is actively monitored")),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Printer",
|
||||||
|
"verbose_name_plural": "Printers",
|
||||||
|
"db_table": "infrastructure_device",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="PrinterMetrics",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("timestamp", models.DateTimeField(db_index=True, help_text="When this metric was recorded")),
|
||||||
|
("nozzle_temp", models.FloatField(blank=True, help_text="Nozzle temperature in Celsius", null=True)),
|
||||||
|
("nozzle_target_temp", models.FloatField(blank=True, help_text="Nozzle target temperature", null=True)),
|
||||||
|
("bed_temp", models.FloatField(blank=True, help_text="Bed temperature in Celsius", null=True)),
|
||||||
|
("bed_target_temp", models.FloatField(blank=True, help_text="Bed target temperature", null=True)),
|
||||||
|
("chamber_temp", models.FloatField(blank=True, help_text="Chamber temperature", null=True)),
|
||||||
|
("print_percent", models.IntegerField(blank=True, help_text="Print progress percentage", null=True)),
|
||||||
|
("wifi_signal_dbm", models.IntegerField(blank=True, help_text="WiFi signal strength in dBm", null=True)),
|
||||||
|
("cooling_fan_speed", models.IntegerField(blank=True, help_text="Cooling fan speed (0-15)", null=True)),
|
||||||
|
("heatbreak_fan_speed", models.IntegerField(blank=True, help_text="Heatbreak fan speed (0-15)", null=True)),
|
||||||
|
("gcode_state", models.CharField(blank=True, help_text="Current GCode execution state", max_length=50, null=True)),
|
||||||
|
("subtask_name", models.CharField(blank=True, help_text="Current print subtask name", max_length=255, null=True)),
|
||||||
|
("layer_num", models.IntegerField(blank=True, help_text="Current layer number", null=True)),
|
||||||
|
("total_layer_num", models.IntegerField(blank=True, help_text="Total layer count for current print", null=True)),
|
||||||
|
("chamber_light", models.CharField(blank=True, help_text="Chamber light status (on/off)", max_length=10, null=True)),
|
||||||
|
("ams_humidity_raw", models.IntegerField(blank=True, help_text="AMS raw humidity value", null=True)),
|
||||||
|
("ams_temp", models.FloatField(blank=True, help_text="AMS temperature in Celsius", null=True)),
|
||||||
|
("tray_now", models.CharField(blank=True, help_text="Currently active AMS tray", max_length=10, null=True)),
|
||||||
|
("device", models.ForeignKey(help_text="The printer this metric belongs to", on_delete=django.db.models.deletion.CASCADE, related_name="printer_metrics", to="bambu_run.printer")),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Printer Metrics",
|
||||||
|
"verbose_name_plural": "Printer Metrics",
|
||||||
|
"db_table": "infrastructure_printer_metrics",
|
||||||
|
"ordering": ["-timestamp"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="FilamentType",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("type", models.CharField(help_text="Base material type (PLA, PETG, ABS, etc.)", max_length=50)),
|
||||||
|
("sub_type", models.CharField(blank=True, default="", help_text="Material variant (Basic, Matte, Silk, etc.)", max_length=50)),
|
||||||
|
("brand", models.CharField(help_text="Filament manufacturer", max_length=100)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Filament Type",
|
||||||
|
"verbose_name_plural": "Filament Types",
|
||||||
|
"db_table": "infrastructure_filament_type",
|
||||||
|
"ordering": ["type", "sub_type", "brand"],
|
||||||
|
"unique_together": {("type", "sub_type", "brand")},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="FilamentColor",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("color_name", models.CharField(help_text="Human-readable color name", max_length=100)),
|
||||||
|
("color_code", models.CharField(help_text="8-char hex color code from printer (RRGGBBFF)", max_length=8)),
|
||||||
|
("filament_type", models.CharField(blank=True, default="", help_text="Material type (legacy field)", max_length=50)),
|
||||||
|
("filament_sub_type", models.CharField(blank=True, default="", help_text="Sub type (legacy field)", max_length=50)),
|
||||||
|
("brand", models.CharField(blank=True, default="", help_text="Brand (legacy field)", max_length=100)),
|
||||||
|
("filament_type_fk", models.ForeignKey(blank=True, help_text="Link to filament type registry", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="colors", to="bambu_run.filamenttype")),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Filament Color",
|
||||||
|
"verbose_name_plural": "Filament Colors",
|
||||||
|
"db_table": "infrastructure_filament_color",
|
||||||
|
"ordering": ["filament_type", "color_name"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Filament",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("tray_uuid", models.CharField(blank=True, db_index=True, help_text="Spool serial number from AMS (unique per spool)", max_length=100, null=True)),
|
||||||
|
("tag_uid", models.CharField(blank=True, db_index=True, help_text="RFID chip UID from AMS tray", max_length=100, null=True)),
|
||||||
|
("tag_id", models.CharField(blank=True, help_text="User-defined tag/barcode ID", max_length=100, null=True)),
|
||||||
|
("type", models.CharField(help_text="Material type (PLA, PETG, ABS, etc.)", max_length=50)),
|
||||||
|
("sub_type", models.CharField(blank=True, default="", help_text="Material sub-type (Basic, Matte, Silk, etc.)", max_length=50)),
|
||||||
|
("brand", models.CharField(default="Unknown", help_text="Filament manufacturer/brand", max_length=100)),
|
||||||
|
("color", models.CharField(help_text="Color name (e.g., Black, White, Red)", max_length=50)),
|
||||||
|
("color_hex", models.CharField(blank=True, help_text="Hex color code (#RRGGBB format)", max_length=9, null=True)),
|
||||||
|
("diameter", models.FloatField(default=1.75, help_text="Filament diameter in mm")),
|
||||||
|
("initial_weight_grams", models.FloatField(blank=True, help_text="Initial spool weight in grams", null=True)),
|
||||||
|
("remaining_percent", models.FloatField(default=100, help_text="Remaining filament percentage (0-100)")),
|
||||||
|
("remaining_weight_grams", models.FloatField(blank=True, help_text="Remaining filament weight in grams", null=True)),
|
||||||
|
("is_loaded_in_ams", models.BooleanField(default=False, help_text="Whether this filament is currently in an AMS tray")),
|
||||||
|
("current_tray_id", models.IntegerField(blank=True, help_text="AMS tray slot (0-3) if loaded", null=True)),
|
||||||
|
("last_loaded_date", models.DateTimeField(blank=True, help_text="When filament was last loaded into AMS", null=True)),
|
||||||
|
("last_used", models.DateTimeField(blank=True, help_text="Last time this filament was used in a print", null=True)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("created_by", models.CharField(default="Manual", help_text="How this filament was added (Manual or Auto Detection)", max_length=50)),
|
||||||
|
("purchase_date", models.DateField(blank=True, help_text="When the filament was purchased", null=True)),
|
||||||
|
("purchase_price", models.DecimalField(blank=True, decimal_places=2, help_text="Purchase price", max_digits=8, null=True)),
|
||||||
|
("supplier", models.CharField(blank=True, help_text="Where the filament was purchased", max_length=200, null=True)),
|
||||||
|
("notes", models.TextField(blank=True, help_text="Additional notes about this filament", null=True)),
|
||||||
|
("filament_color", models.ForeignKey(blank=True, help_text="Matched color from database", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="filaments", to="bambu_run.filamentcolor")),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Filament",
|
||||||
|
"verbose_name_plural": "Filaments",
|
||||||
|
"db_table": "infrastructure_filament",
|
||||||
|
"ordering": ["-updated_at"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="FilamentSnapshot",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("tray_id", models.IntegerField(help_text="AMS tray slot (0-3)")),
|
||||||
|
("type", models.CharField(blank=True, help_text="Filament type at snapshot time", max_length=50, null=True)),
|
||||||
|
("sub_type", models.CharField(blank=True, help_text="Filament sub-type at snapshot time", max_length=50, null=True)),
|
||||||
|
("color", models.CharField(blank=True, help_text="Hex color code at snapshot time", max_length=20, null=True)),
|
||||||
|
("remain_percent", models.IntegerField(blank=True, help_text="Remaining percentage at snapshot time", null=True)),
|
||||||
|
("tray_uuid", models.CharField(blank=True, help_text="Spool serial number at snapshot time", max_length=100, null=True)),
|
||||||
|
("tag_uid", models.CharField(blank=True, help_text="RFID tag UID at snapshot time", max_length=100, null=True)),
|
||||||
|
("filament", models.ForeignKey(blank=True, help_text="Matched filament from inventory", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="snapshots", to="bambu_run.filament")),
|
||||||
|
("printer_metric", models.ForeignKey(help_text="The printer metric this snapshot belongs to", on_delete=django.db.models.deletion.CASCADE, related_name="filament_snapshots", to="bambu_run.printermetrics")),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Filament Snapshot",
|
||||||
|
"verbose_name_plural": "Filament Snapshots",
|
||||||
|
"db_table": "infrastructure_filament_snapshot",
|
||||||
|
"ordering": ["-printer_metric__timestamp"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="PrintJob",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("project_name", models.CharField(help_text="Name of the print project", max_length=255)),
|
||||||
|
("gcode_file", models.CharField(blank=True, help_text="GCode filename", max_length=255, null=True)),
|
||||||
|
("start_time", models.DateTimeField(db_index=True, help_text="When the print started")),
|
||||||
|
("end_time", models.DateTimeField(blank=True, help_text="When the print ended", null=True)),
|
||||||
|
("final_status", models.CharField(blank=True, help_text="Final status (FINISH, FAILED, etc.)", max_length=50, null=True)),
|
||||||
|
("total_layers", models.IntegerField(blank=True, help_text="Total layers in the print", null=True)),
|
||||||
|
("device", models.ForeignKey(help_text="Printer used for this job", on_delete=django.db.models.deletion.CASCADE, related_name="print_jobs", to="bambu_run.printer")),
|
||||||
|
("start_metric", models.ForeignKey(blank=True, help_text="Metric snapshot at print start", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="started_jobs", to="bambu_run.printermetrics")),
|
||||||
|
("end_metric", models.ForeignKey(blank=True, help_text="Metric snapshot at print end", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="ended_jobs", to="bambu_run.printermetrics")),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Print Job",
|
||||||
|
"verbose_name_plural": "Print Jobs",
|
||||||
|
"db_table": "infrastructure_print_job",
|
||||||
|
"ordering": ["-start_time"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="FilamentUsage",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("tray_id", models.IntegerField(help_text="AMS tray slot used (0-3)")),
|
||||||
|
("starting_percent", models.FloatField(blank=True, help_text="Filament remaining % at print start", null=True)),
|
||||||
|
("ending_percent", models.FloatField(blank=True, help_text="Filament remaining % at print end", null=True)),
|
||||||
|
("consumed_percent", models.FloatField(blank=True, help_text="Percentage of filament consumed", null=True)),
|
||||||
|
("consumed_grams", models.FloatField(blank=True, help_text="Weight of filament consumed in grams", null=True)),
|
||||||
|
("filament", models.ForeignKey(blank=True, help_text="Which filament spool was used", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="usage_records", to="bambu_run.filament")),
|
||||||
|
("print_job", models.ForeignKey(help_text="The print job that used this filament", on_delete=django.db.models.deletion.CASCADE, related_name="filament_usages", to="bambu_run.printjob")),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Filament Usage",
|
||||||
|
"verbose_name_plural": "Filament Usages",
|
||||||
|
"db_table": "infrastructure_filament_usage",
|
||||||
|
"ordering": ["-print_job__start_time"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
# Add indexes
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="printermetrics",
|
||||||
|
index=models.Index(fields=["device", "-timestamp"], name="infra_pm_device_ts_idx"),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
bambu_run/migrations/__init__.py
Normal file
0
bambu_run/migrations/__init__.py
Normal file
595
bambu_run/models.py
Normal file
595
bambu_run/models.py
Normal file
@@ -0,0 +1,595 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
class Printer(models.Model):
|
||||||
|
"""Represents a Bambu Lab 3D printer device"""
|
||||||
|
|
||||||
|
name = models.CharField(max_length=200, help_text="Friendly device name")
|
||||||
|
model = models.CharField(max_length=100, help_text="Device model (e.g., X1C, P1S)")
|
||||||
|
manufacturer = models.CharField(
|
||||||
|
max_length=100, default="Bambu Lab", help_text="e.g., Bambu Lab"
|
||||||
|
)
|
||||||
|
description = models.TextField(blank=True, null=True)
|
||||||
|
serial_number = models.CharField(max_length=100, blank=True, null=True, unique=True)
|
||||||
|
ip_address = models.GenericIPAddressField(blank=True, null=True)
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
location = models.CharField(
|
||||||
|
max_length=200, blank=True, help_text="Physical location"
|
||||||
|
)
|
||||||
|
|
||||||
|
first_seen = models.DateTimeField(auto_now_add=True)
|
||||||
|
last_updated = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "infrastructure_device"
|
||||||
|
verbose_name = "Printer"
|
||||||
|
verbose_name_plural = "Printers"
|
||||||
|
ordering = ["name"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} ({self.model})"
|
||||||
|
|
||||||
|
|
||||||
|
class PrinterMetrics(models.Model):
|
||||||
|
"""Time-series metrics for 3D Printer devices (Bambu Lab)"""
|
||||||
|
|
||||||
|
device = models.ForeignKey(
|
||||||
|
Printer, on_delete=models.CASCADE, related_name="printer_metrics", db_index=True
|
||||||
|
)
|
||||||
|
timestamp = models.DateTimeField(
|
||||||
|
default=timezone.now, db_index=True, help_text="When this reading was taken"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Temperature metrics
|
||||||
|
nozzle_temp = models.DecimalField(
|
||||||
|
max_digits=5, decimal_places=2, null=True, blank=True
|
||||||
|
)
|
||||||
|
nozzle_target_temp = models.DecimalField(
|
||||||
|
max_digits=5, decimal_places=2, null=True, blank=True
|
||||||
|
)
|
||||||
|
bed_temp = models.DecimalField(
|
||||||
|
max_digits=5, decimal_places=2, null=True, blank=True
|
||||||
|
)
|
||||||
|
bed_target_temp = models.DecimalField(
|
||||||
|
max_digits=5, decimal_places=2, null=True, blank=True
|
||||||
|
)
|
||||||
|
chamber_temp = models.DecimalField(
|
||||||
|
max_digits=5, decimal_places=2, null=True, blank=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Nozzle info
|
||||||
|
nozzle_diameter = models.DecimalField(
|
||||||
|
max_digits=3, decimal_places=2, null=True, blank=True
|
||||||
|
)
|
||||||
|
nozzle_type = models.CharField(max_length=50, null=True, blank=True)
|
||||||
|
|
||||||
|
# Print job status
|
||||||
|
gcode_state = models.CharField(
|
||||||
|
max_length=50, null=True, blank=True, help_text="FINISH, RUNNING, IDLE, etc."
|
||||||
|
)
|
||||||
|
print_type = models.CharField(
|
||||||
|
max_length=50, null=True, blank=True, help_text="idle, printing, etc."
|
||||||
|
)
|
||||||
|
print_percent = models.IntegerField(
|
||||||
|
null=True, blank=True, help_text="Print progress percentage"
|
||||||
|
)
|
||||||
|
remaining_time_min = models.IntegerField(
|
||||||
|
null=True, blank=True, help_text="Estimated remaining time in minutes"
|
||||||
|
)
|
||||||
|
layer_num = models.IntegerField(
|
||||||
|
null=True, blank=True, help_text="Current layer number"
|
||||||
|
)
|
||||||
|
total_layer_num = models.IntegerField(
|
||||||
|
null=True, blank=True, help_text="Total layers in print"
|
||||||
|
)
|
||||||
|
print_line_number = models.IntegerField(null=True, blank=True)
|
||||||
|
subtask_name = models.CharField(max_length=200, null=True, blank=True)
|
||||||
|
gcode_file = models.CharField(max_length=200, null=True, blank=True)
|
||||||
|
|
||||||
|
# Fan speeds (0-100%)
|
||||||
|
cooling_fan_speed = models.IntegerField(null=True, blank=True)
|
||||||
|
heatbreak_fan_speed = models.IntegerField(null=True, blank=True)
|
||||||
|
big_fan1_speed = models.IntegerField(
|
||||||
|
null=True, blank=True, help_text="Auxiliary/chamber fan 1 speed"
|
||||||
|
)
|
||||||
|
big_fan2_speed = models.IntegerField(
|
||||||
|
null=True, blank=True, help_text="Auxiliary/chamber fan 2 speed"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Speed settings
|
||||||
|
spd_lvl = models.IntegerField(
|
||||||
|
null=True, blank=True,
|
||||||
|
help_text="Speed level (1=silent, 2=standard, 3=sport, 4=ludicrous)",
|
||||||
|
)
|
||||||
|
spd_mag = models.IntegerField(
|
||||||
|
null=True, blank=True, help_text="Speed magnitude percentage"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Network & connectivity
|
||||||
|
wifi_signal_dbm = models.IntegerField(null=True, blank=True)
|
||||||
|
|
||||||
|
# Error tracking
|
||||||
|
print_error = models.IntegerField(default=0)
|
||||||
|
has_errors = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
# Chamber light & camera
|
||||||
|
chamber_light = models.CharField(
|
||||||
|
max_length=20, null=True, blank=True, help_text="on/off"
|
||||||
|
)
|
||||||
|
ipcam_record = models.CharField(
|
||||||
|
max_length=20, null=True, blank=True, help_text="enable/disable"
|
||||||
|
)
|
||||||
|
timelapse = models.CharField(
|
||||||
|
max_length=20, null=True, blank=True, help_text="enable/disable"
|
||||||
|
)
|
||||||
|
|
||||||
|
# System info
|
||||||
|
stg_cur = models.IntegerField(
|
||||||
|
null=True, blank=True, help_text="Current print stage"
|
||||||
|
)
|
||||||
|
sdcard = models.BooleanField(
|
||||||
|
null=True, blank=True, help_text="SD card present"
|
||||||
|
)
|
||||||
|
gcode_file_prepare_percent = models.CharField(
|
||||||
|
max_length=10, null=True, blank=True, help_text="File preparation progress"
|
||||||
|
)
|
||||||
|
lifecycle = models.CharField(
|
||||||
|
max_length=50, null=True, blank=True, help_text="Product lifecycle state"
|
||||||
|
)
|
||||||
|
|
||||||
|
# HMS (Health Management System)
|
||||||
|
hms = models.JSONField(
|
||||||
|
default=list, help_text="Health management system messages (errors/warnings)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# AMS (Automatic Material System) status
|
||||||
|
ams_unit_count = models.IntegerField(null=True, blank=True)
|
||||||
|
ams_status = models.IntegerField(null=True, blank=True)
|
||||||
|
ams_rfid_status = models.IntegerField(null=True, blank=True)
|
||||||
|
ams_humidity = models.IntegerField(
|
||||||
|
null=True, blank=True, help_text="AMS humidity level (processed)"
|
||||||
|
)
|
||||||
|
ams_humidity_raw = models.IntegerField(
|
||||||
|
null=True, blank=True, help_text="AMS raw humidity reading"
|
||||||
|
)
|
||||||
|
ams_temp = models.DecimalField(
|
||||||
|
max_digits=5, decimal_places=2, null=True, blank=True
|
||||||
|
)
|
||||||
|
ams_version = models.IntegerField(
|
||||||
|
null=True, blank=True, help_text="AMS firmware version"
|
||||||
|
)
|
||||||
|
tray_is_bbl_bits = models.CharField(
|
||||||
|
max_length=20, null=True, blank=True,
|
||||||
|
help_text="Which trays have Bambu Lab (OEM) filament",
|
||||||
|
)
|
||||||
|
tray_read_done_bits = models.CharField(
|
||||||
|
max_length=20, null=True, blank=True,
|
||||||
|
help_text="RFID read completion status bits",
|
||||||
|
)
|
||||||
|
|
||||||
|
# JSON fields for complex nested data
|
||||||
|
filaments = models.JSONField(
|
||||||
|
default=list,
|
||||||
|
help_text="List of filament info [{tray_id, slot, type, sub_type, color, remain_percent, k, ...}]",
|
||||||
|
)
|
||||||
|
ams_units = models.JSONField(
|
||||||
|
default=list,
|
||||||
|
help_text="AMS unit info [{unit_id, ams_id, chip_id, humidity, temp, ...}]",
|
||||||
|
)
|
||||||
|
external_spool = models.JSONField(
|
||||||
|
default=dict, help_text="External spool info {type, color, remain}"
|
||||||
|
)
|
||||||
|
lights_report = models.JSONField(
|
||||||
|
default=list, help_text="Light status report [{node, mode}]"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "infrastructure_printer_metrics"
|
||||||
|
verbose_name = "Printer Metric"
|
||||||
|
verbose_name_plural = "Printer Metrics"
|
||||||
|
ordering = ["-timestamp"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["device", "-timestamp"], name="printer_dev_time_idx"),
|
||||||
|
models.Index(fields=["-timestamp"], name="printer_time_idx"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.device.name} @ {self.timestamp.strftime('%Y-%m-%d %H:%M:%S')}"
|
||||||
|
|
||||||
|
|
||||||
|
class FilamentType(models.Model):
|
||||||
|
"""Central registry of filament types (material + sub-type + brand)"""
|
||||||
|
|
||||||
|
type = models.CharField(max_length=50, help_text="Base material: PLA, PETG, ABS, etc.")
|
||||||
|
sub_type = models.CharField(
|
||||||
|
max_length=100, null=True, blank=True,
|
||||||
|
help_text="Sub-type: PLA Basic, PLA Matte, etc."
|
||||||
|
)
|
||||||
|
brand = models.CharField(
|
||||||
|
max_length=100, default='Bambu Lab',
|
||||||
|
help_text="Manufacturer name"
|
||||||
|
)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "infrastructure_filament_type"
|
||||||
|
verbose_name = "Filament Type"
|
||||||
|
verbose_name_plural = "Filament Types"
|
||||||
|
ordering = ['type', 'sub_type', 'brand']
|
||||||
|
unique_together = [['type', 'sub_type', 'brand']]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
sub = f" {self.sub_type}" if self.sub_type else ""
|
||||||
|
return f"{self.type}{sub} ({self.brand})"
|
||||||
|
|
||||||
|
|
||||||
|
class FilamentColor(models.Model):
|
||||||
|
"""Master database of Bambu Lab filament colors for auto-matching"""
|
||||||
|
|
||||||
|
color_code = models.CharField(
|
||||||
|
max_length=6,
|
||||||
|
help_text="Hex color code without padding (e.g., '000000' not '000000FF')"
|
||||||
|
)
|
||||||
|
color_name = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
help_text="Human-readable color name (e.g., 'Black', 'Orange')"
|
||||||
|
)
|
||||||
|
|
||||||
|
filament_type_fk = models.ForeignKey(
|
||||||
|
'FilamentType', null=True, blank=True, on_delete=models.SET_NULL,
|
||||||
|
related_name='colors',
|
||||||
|
help_text="Link to FilamentType registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
filament_type = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
help_text="Base material type: PLA, PETG, ABS, TPU, etc."
|
||||||
|
)
|
||||||
|
filament_sub_type = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Material sub-type: 'PLA Basic', 'PLA Matte', 'ABS GF', etc."
|
||||||
|
)
|
||||||
|
brand = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
default='Bambu Lab',
|
||||||
|
help_text="Manufacturer name"
|
||||||
|
)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "infrastructure_filament_color"
|
||||||
|
verbose_name = "Filament Color"
|
||||||
|
verbose_name_plural = "Filament Colors"
|
||||||
|
ordering = ['filament_type', 'filament_sub_type', 'color_name']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['color_code', 'filament_type', 'filament_sub_type', 'brand']),
|
||||||
|
models.Index(fields=['filament_type']),
|
||||||
|
]
|
||||||
|
unique_together = [['color_code', 'filament_type', 'filament_sub_type', 'brand']]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
sub_type_info = f" {self.filament_sub_type}" if self.filament_sub_type else ""
|
||||||
|
return f"{self.filament_type}{sub_type_info}: {self.color_name} (#{self.color_code})"
|
||||||
|
|
||||||
|
def get_hex_color(self):
|
||||||
|
"""Return color code with # prefix for display"""
|
||||||
|
return f"#{self.color_code}"
|
||||||
|
|
||||||
|
|
||||||
|
class Filament(models.Model):
|
||||||
|
"""Master inventory of filament spools owned by user"""
|
||||||
|
|
||||||
|
# Unique identification
|
||||||
|
tray_uuid = models.CharField(
|
||||||
|
max_length=100, unique=True, null=True, blank=True, db_index=True,
|
||||||
|
help_text="Spool serial number from MQTT"
|
||||||
|
)
|
||||||
|
tag_uid = models.CharField(
|
||||||
|
max_length=100, null=True, blank=True, db_index=True,
|
||||||
|
help_text="RFID chip unique identifier"
|
||||||
|
)
|
||||||
|
tag_id = models.CharField(
|
||||||
|
max_length=100, null=True, blank=True,
|
||||||
|
help_text="User-defined unique identifier (barcode, label, etc.)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Creation tracking
|
||||||
|
created_by = models.CharField(
|
||||||
|
max_length=20, default='Manual',
|
||||||
|
choices=[
|
||||||
|
('Auto Detection', 'Auto Detection'),
|
||||||
|
('Manual', 'Manual'),
|
||||||
|
],
|
||||||
|
help_text="How this filament was added to inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
# FK to FilamentType registry
|
||||||
|
filament_type = models.ForeignKey(
|
||||||
|
'FilamentType', null=True, blank=True, on_delete=models.SET_NULL,
|
||||||
|
related_name='filaments',
|
||||||
|
help_text="Link to FilamentType registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filament specifications
|
||||||
|
type = models.CharField(max_length=50, help_text="PLA, PETG, ABS, TPU, etc.")
|
||||||
|
sub_type = models.CharField(
|
||||||
|
max_length=100, null=True, blank=True,
|
||||||
|
help_text="Material sub-type from MQTT: 'PLA Matte', 'PLA Basic', etc."
|
||||||
|
)
|
||||||
|
brand = models.CharField(max_length=100, help_text="Manufacturer name")
|
||||||
|
color = models.CharField(max_length=50, help_text="Color name")
|
||||||
|
color_hex = models.CharField(
|
||||||
|
max_length=7, null=True, blank=True,
|
||||||
|
help_text="Color hex code for display (#RRGGBB)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Physical properties
|
||||||
|
diameter = models.DecimalField(
|
||||||
|
max_digits=4, decimal_places=2, default=1.75,
|
||||||
|
help_text="Filament diameter in mm (1.75 or 2.85)"
|
||||||
|
)
|
||||||
|
initial_weight_grams = models.IntegerField(
|
||||||
|
null=True, blank=True,
|
||||||
|
help_text="Spool weight when new (typically 1000g)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Current status
|
||||||
|
remaining_percent = models.IntegerField(
|
||||||
|
default=100,
|
||||||
|
help_text="Estimated remaining filament (0-100%)"
|
||||||
|
)
|
||||||
|
remaining_weight_grams = models.IntegerField(
|
||||||
|
null=True, blank=True,
|
||||||
|
help_text="Calculated remaining weight"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Current location in AMS
|
||||||
|
is_loaded_in_ams = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Is this spool currently loaded in AMS?"
|
||||||
|
)
|
||||||
|
current_tray_id = models.IntegerField(
|
||||||
|
null=True, blank=True,
|
||||||
|
help_text="Which AMS slot (0-3) if loaded"
|
||||||
|
)
|
||||||
|
last_loaded_date = models.DateTimeField(
|
||||||
|
null=True, blank=True,
|
||||||
|
help_text="When was this spool loaded into AMS"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Purchase/inventory tracking
|
||||||
|
purchase_date = models.DateField(null=True, blank=True)
|
||||||
|
purchase_price = models.DecimalField(
|
||||||
|
max_digits=8, decimal_places=2, null=True, blank=True
|
||||||
|
)
|
||||||
|
supplier = models.CharField(max_length=100, null=True, blank=True)
|
||||||
|
notes = models.TextField(blank=True, help_text="Custom notes about this spool")
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
last_used = models.DateTimeField(
|
||||||
|
null=True, blank=True,
|
||||||
|
help_text="Last time this spool was used in a print"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "infrastructure_filament"
|
||||||
|
verbose_name = "Filament Spool"
|
||||||
|
verbose_name_plural = "Filament Spools"
|
||||||
|
ordering = ['type', 'brand', 'color', '-remaining_percent']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['type', 'brand', 'color']),
|
||||||
|
models.Index(fields=['tray_uuid']),
|
||||||
|
models.Index(fields=['tag_uid']),
|
||||||
|
models.Index(fields=['tag_id']),
|
||||||
|
models.Index(fields=['is_loaded_in_ams', 'current_tray_id']),
|
||||||
|
models.Index(fields=['remaining_percent']),
|
||||||
|
models.Index(fields=['created_by']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
sn_info = f"[SN:{self.tray_uuid[:8]}...] " if self.tray_uuid else ""
|
||||||
|
return f"{sn_info}{self.brand} {self.type} - {self.color} ({self.remaining_percent}%)"
|
||||||
|
|
||||||
|
def update_remaining_weight(self):
|
||||||
|
"""Calculate remaining weight based on percentage"""
|
||||||
|
if self.initial_weight_grams:
|
||||||
|
self.remaining_weight_grams = int(
|
||||||
|
self.initial_weight_grams * (self.remaining_percent / 100.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FilamentSnapshot(models.Model):
|
||||||
|
"""Links PrinterMetrics to Filament inventory with point-in-time AMS data"""
|
||||||
|
|
||||||
|
printer_metric = models.ForeignKey(
|
||||||
|
'PrinterMetrics', on_delete=models.CASCADE,
|
||||||
|
related_name='filament_snapshots'
|
||||||
|
)
|
||||||
|
filament = models.ForeignKey(
|
||||||
|
'Filament', on_delete=models.SET_NULL,
|
||||||
|
null=True, blank=True,
|
||||||
|
related_name='usage_snapshots',
|
||||||
|
help_text="Matched filament from inventory (null if no match)"
|
||||||
|
)
|
||||||
|
|
||||||
|
tray_id = models.IntegerField(help_text="AMS slot number (0-3)")
|
||||||
|
slot_name = models.CharField(
|
||||||
|
max_length=20, null=True, blank=True,
|
||||||
|
help_text="Slot identifier like A00-W1"
|
||||||
|
)
|
||||||
|
|
||||||
|
type = models.CharField(max_length=50, null=True, blank=True)
|
||||||
|
sub_type = models.CharField(
|
||||||
|
max_length=100, null=True, blank=True,
|
||||||
|
help_text="Material sub-type from MQTT (PLA Basic, PLA Matte, etc.)"
|
||||||
|
)
|
||||||
|
brand = models.CharField(
|
||||||
|
max_length=100, null=True, blank=True,
|
||||||
|
help_text="Deprecated: MQTT doesn't provide brand. Use Filament.brand instead."
|
||||||
|
)
|
||||||
|
color = models.CharField(max_length=50, null=True, blank=True)
|
||||||
|
remain_percent = models.IntegerField(null=True, blank=True)
|
||||||
|
k_value = models.DecimalField(
|
||||||
|
max_digits=6, decimal_places=4, null=True, blank=True
|
||||||
|
)
|
||||||
|
|
||||||
|
tag_uid = models.CharField(
|
||||||
|
max_length=100, null=True, blank=True, db_index=True,
|
||||||
|
help_text="RFID chip unique identifier"
|
||||||
|
)
|
||||||
|
tray_uuid = models.CharField(
|
||||||
|
max_length=100, null=True, blank=True,
|
||||||
|
help_text="Tray UUID from MQTT"
|
||||||
|
)
|
||||||
|
state = models.IntegerField(
|
||||||
|
null=True, blank=True,
|
||||||
|
help_text="Tray state from MQTT"
|
||||||
|
)
|
||||||
|
|
||||||
|
temp = models.DecimalField(
|
||||||
|
max_digits=5, decimal_places=2, null=True, blank=True
|
||||||
|
)
|
||||||
|
humidity = models.IntegerField(null=True, blank=True)
|
||||||
|
|
||||||
|
auto_matched = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="Was this auto-matched to inventory or manually set?"
|
||||||
|
)
|
||||||
|
match_method = models.CharField(
|
||||||
|
max_length=20, default='none',
|
||||||
|
help_text="tag_id, lowest_remaining, manual, or none"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "infrastructure_filament_snapshot"
|
||||||
|
verbose_name = "Filament Snapshot"
|
||||||
|
verbose_name_plural = "Filament Snapshots"
|
||||||
|
ordering = ['printer_metric', 'tray_id']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['printer_metric', 'tray_id']),
|
||||||
|
models.Index(fields=['filament']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
filament_info = str(self.filament) if self.filament else f"{self.brand} {self.type}"
|
||||||
|
return f"Tray {self.tray_id}: {filament_info}"
|
||||||
|
|
||||||
|
|
||||||
|
class PrintJob(models.Model):
|
||||||
|
"""Represents a single print job from start to finish"""
|
||||||
|
|
||||||
|
device = models.ForeignKey(
|
||||||
|
'Printer', on_delete=models.CASCADE,
|
||||||
|
related_name='print_jobs'
|
||||||
|
)
|
||||||
|
|
||||||
|
project_name = models.CharField(
|
||||||
|
max_length=200, help_text="From subtask_name field"
|
||||||
|
)
|
||||||
|
gcode_file = models.CharField(max_length=200, null=True, blank=True)
|
||||||
|
|
||||||
|
start_time = models.DateTimeField(help_text="When print started")
|
||||||
|
end_time = models.DateTimeField(null=True, blank=True, help_text="When print finished/failed")
|
||||||
|
duration_minutes = models.IntegerField(null=True, blank=True, help_text="Total print duration")
|
||||||
|
|
||||||
|
total_layers = models.IntegerField(null=True, blank=True)
|
||||||
|
final_status = models.CharField(
|
||||||
|
max_length=50, null=True, blank=True, help_text="FINISH, FAILED, CANCELLED"
|
||||||
|
)
|
||||||
|
completion_percent = models.IntegerField(
|
||||||
|
default=0, help_text="Final completion percentage"
|
||||||
|
)
|
||||||
|
|
||||||
|
start_metric = models.ForeignKey(
|
||||||
|
'PrinterMetrics', on_delete=models.SET_NULL,
|
||||||
|
null=True, related_name='started_jobs'
|
||||||
|
)
|
||||||
|
end_metric = models.ForeignKey(
|
||||||
|
'PrinterMetrics', on_delete=models.SET_NULL,
|
||||||
|
null=True, related_name='ended_jobs'
|
||||||
|
)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "infrastructure_print_job"
|
||||||
|
verbose_name = "Print Job"
|
||||||
|
verbose_name_plural = "Print Jobs"
|
||||||
|
ordering = ['-start_time']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['device', '-start_time']),
|
||||||
|
models.Index(fields=['project_name']),
|
||||||
|
models.Index(fields=['-start_time']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
status = self.final_status or 'In Progress'
|
||||||
|
return f"{self.project_name} ({status}) - {self.start_time.strftime('%Y-%m-%d %H:%M')}"
|
||||||
|
|
||||||
|
def calculate_duration(self):
|
||||||
|
"""Calculate print duration if end_time is set"""
|
||||||
|
if self.end_time and self.start_time:
|
||||||
|
delta = self.end_time - self.start_time
|
||||||
|
self.duration_minutes = int(delta.total_seconds() / 60)
|
||||||
|
|
||||||
|
|
||||||
|
class FilamentUsage(models.Model):
|
||||||
|
"""Tracks filament consumption during print jobs"""
|
||||||
|
|
||||||
|
print_job = models.ForeignKey(
|
||||||
|
'PrintJob', on_delete=models.CASCADE,
|
||||||
|
related_name='filament_usages'
|
||||||
|
)
|
||||||
|
filament = models.ForeignKey(
|
||||||
|
'Filament', on_delete=models.CASCADE,
|
||||||
|
related_name='print_usages'
|
||||||
|
)
|
||||||
|
|
||||||
|
tray_id = models.IntegerField(help_text="Which AMS slot was used")
|
||||||
|
|
||||||
|
starting_percent = models.IntegerField(help_text="Filament remaining % at job start")
|
||||||
|
ending_percent = models.IntegerField(
|
||||||
|
null=True, blank=True, help_text="Filament remaining % at job end"
|
||||||
|
)
|
||||||
|
consumed_percent = models.IntegerField(
|
||||||
|
null=True, blank=True, help_text="Amount consumed during print"
|
||||||
|
)
|
||||||
|
consumed_grams = models.IntegerField(
|
||||||
|
null=True, blank=True, help_text="Estimated grams consumed"
|
||||||
|
)
|
||||||
|
|
||||||
|
is_primary = models.BooleanField(
|
||||||
|
default=True, help_text="Primary filament vs multi-color"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "infrastructure_filament_usage"
|
||||||
|
verbose_name = "Filament Usage"
|
||||||
|
verbose_name_plural = "Filament Usages"
|
||||||
|
ordering = ['print_job', 'tray_id']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['print_job']),
|
||||||
|
models.Index(fields=['filament']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.filament} - {self.print_job.project_name} ({self.consumed_percent}%)"
|
||||||
|
|
||||||
|
def calculate_consumed(self):
|
||||||
|
"""Calculate consumed amount"""
|
||||||
|
if self.ending_percent is not None:
|
||||||
|
self.consumed_percent = self.starting_percent - self.ending_percent
|
||||||
|
if self.filament.initial_weight_grams:
|
||||||
|
self.consumed_grams = int(
|
||||||
|
self.filament.initial_weight_grams * (self.consumed_percent / 100.0)
|
||||||
|
)
|
||||||
876
bambu_run/mqtt_client.py
Normal file
876
bambu_run/mqtt_client.py
Normal file
@@ -0,0 +1,876 @@
|
|||||||
|
"""
|
||||||
|
BambuLab Cloud API Client
|
||||||
|
Provides authentication, device management, and real-time MQTT monitoring
|
||||||
|
for BambuLab 3D printers via the Cloud API.
|
||||||
|
|
||||||
|
Requires: pip install bambu-lab-cloud-api
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from bambu_run.mqtt_client import BambuPrinter, PrinterState
|
||||||
|
|
||||||
|
printer = BambuPrinter(token="your_token", device_id="your_device_id")
|
||||||
|
printer.connect()
|
||||||
|
state = printer.get_state()
|
||||||
|
snapshot = printer.get_snapshot()
|
||||||
|
printer.disconnect()
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import sys
|
||||||
|
import select
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from .conf import app_settings
|
||||||
|
|
||||||
|
# Re-export from bambu-lab-cloud-api package
|
||||||
|
try:
|
||||||
|
from bambulab import BambuAuthenticator, BambuClient, MQTTClient
|
||||||
|
except ImportError as e:
|
||||||
|
raise ImportError(
|
||||||
|
"bambu-lab-cloud-api package is required. Install with: pip install bambu-lab-cloud-api"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def suppress_stdout():
|
||||||
|
"""Context manager to suppress stdout (for silencing library print statements)"""
|
||||||
|
old_stdout = sys.stdout
|
||||||
|
sys.stdout = io.StringIO()
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
sys.stdout = old_stdout
|
||||||
|
|
||||||
|
|
||||||
|
def timed_input(prompt: str, timeout_sec: int = 300) -> str:
|
||||||
|
"""
|
||||||
|
Get user input with a timeout.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: The prompt to display
|
||||||
|
timeout_sec: Timeout in seconds (default 300 = 5 minutes)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User input string
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TimeoutError: If no input received within timeout
|
||||||
|
"""
|
||||||
|
print(prompt, end='', flush=True)
|
||||||
|
|
||||||
|
if platform.system() == 'Windows':
|
||||||
|
import threading
|
||||||
|
result = {'value': None, 'done': False}
|
||||||
|
|
||||||
|
def get_input():
|
||||||
|
try:
|
||||||
|
result['value'] = input()
|
||||||
|
except EOFError:
|
||||||
|
result['value'] = None
|
||||||
|
result['done'] = True
|
||||||
|
|
||||||
|
thread = threading.Thread(target=get_input, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
thread.join(timeout=timeout_sec)
|
||||||
|
|
||||||
|
if not result['done']:
|
||||||
|
print()
|
||||||
|
raise TimeoutError(f"No input received within {timeout_sec} seconds")
|
||||||
|
return result['value'] or ""
|
||||||
|
else:
|
||||||
|
ready, _, _ = select.select([sys.stdin], [], [], timeout_sec)
|
||||||
|
if ready:
|
||||||
|
return sys.stdin.readline().strip()
|
||||||
|
else:
|
||||||
|
print()
|
||||||
|
raise TimeoutError(f"No input received within {timeout_sec} seconds")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FilamentTray:
|
||||||
|
"""Represents a single filament tray in an AMS unit"""
|
||||||
|
tray_id: str = ""
|
||||||
|
tray_id_name: str = ""
|
||||||
|
tray_type: str = ""
|
||||||
|
tray_sub_brands: str = ""
|
||||||
|
tray_color: str = ""
|
||||||
|
remain_percent: int = -1
|
||||||
|
tray_weight: int = 0
|
||||||
|
tray_diameter: float = 1.75
|
||||||
|
tray_temp: int = 0
|
||||||
|
nozzle_temp_min: int = 0
|
||||||
|
nozzle_temp_max: int = 0
|
||||||
|
state: int = 0
|
||||||
|
tag_uid: str = ""
|
||||||
|
tray_uuid: str = ""
|
||||||
|
k: float = 0.0
|
||||||
|
n: float = 0.0
|
||||||
|
cali_idx: int = -1
|
||||||
|
total_len: int = 0
|
||||||
|
tray_info_idx: str = ""
|
||||||
|
tray_time: int = 0
|
||||||
|
tray_bed_temp: int = 0
|
||||||
|
bed_temp_type: int = 0
|
||||||
|
cols: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "FilamentTray":
|
||||||
|
"""Create FilamentTray from MQTT tray data"""
|
||||||
|
return cls(
|
||||||
|
tray_id=str(data.get("id", "")),
|
||||||
|
tray_id_name=data.get("tray_id_name", ""),
|
||||||
|
tray_type=data.get("tray_type", ""),
|
||||||
|
tray_sub_brands=data.get("tray_sub_brands", ""),
|
||||||
|
tray_color=data.get("tray_color", ""),
|
||||||
|
remain_percent=data.get("remain", -1),
|
||||||
|
tray_weight=int(data.get("tray_weight", 0)),
|
||||||
|
tray_diameter=float(data.get("tray_diameter", 1.75)),
|
||||||
|
tray_temp=int(data.get("tray_temp", 0)),
|
||||||
|
nozzle_temp_min=int(data.get("nozzle_temp_min", 0)),
|
||||||
|
nozzle_temp_max=int(data.get("nozzle_temp_max", 0)),
|
||||||
|
state=data.get("state", 0),
|
||||||
|
tag_uid=data.get("tag_uid", ""),
|
||||||
|
tray_uuid=data.get("tray_uuid", ""),
|
||||||
|
k=float(data.get("k", 0.0)),
|
||||||
|
n=float(data.get("n", 0.0)),
|
||||||
|
cali_idx=int(data.get("cali_idx", -1)),
|
||||||
|
total_len=int(data.get("total_len", 0)),
|
||||||
|
tray_info_idx=data.get("tray_info_idx", ""),
|
||||||
|
tray_time=int(data.get("tray_time", 0)),
|
||||||
|
tray_bed_temp=int(data.get("bed_temp", 0)),
|
||||||
|
bed_temp_type=int(data.get("bed_temp_type", 0)),
|
||||||
|
cols=data.get("cols", []),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Convert to dictionary for database storage"""
|
||||||
|
return {
|
||||||
|
"tray_id": self.tray_id,
|
||||||
|
"tray_id_name": self.tray_id_name,
|
||||||
|
"tray_type": self.tray_type,
|
||||||
|
"tray_sub_brands": self.tray_sub_brands,
|
||||||
|
"tray_color": self.tray_color,
|
||||||
|
"remain_percent": self.remain_percent,
|
||||||
|
"tray_weight": self.tray_weight,
|
||||||
|
"tray_diameter": self.tray_diameter,
|
||||||
|
"tray_temp": self.tray_temp,
|
||||||
|
"nozzle_temp_min": self.nozzle_temp_min,
|
||||||
|
"nozzle_temp_max": self.nozzle_temp_max,
|
||||||
|
"state": self.state,
|
||||||
|
"tag_uid": self.tag_uid,
|
||||||
|
"tray_uuid": self.tray_uuid,
|
||||||
|
"k": self.k,
|
||||||
|
"n": self.n,
|
||||||
|
"cali_idx": self.cali_idx,
|
||||||
|
"total_len": self.total_len,
|
||||||
|
"tray_info_idx": self.tray_info_idx,
|
||||||
|
"tray_time": self.tray_time,
|
||||||
|
"tray_bed_temp": self.tray_bed_temp,
|
||||||
|
"bed_temp_type": self.bed_temp_type,
|
||||||
|
"cols": self.cols,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AMSUnit:
|
||||||
|
"""Represents a single AMS (Automatic Material System) unit"""
|
||||||
|
ams_id: str = ""
|
||||||
|
unit_id: str = ""
|
||||||
|
humidity: int = -1
|
||||||
|
humidity_raw: int = -1
|
||||||
|
temp: float = 0.0
|
||||||
|
dry_time: int = 0
|
||||||
|
chip_id: str = ""
|
||||||
|
info: str = ""
|
||||||
|
trays: List[FilamentTray] = field(default_factory=list)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "AMSUnit":
|
||||||
|
"""Create AMSUnit from MQTT ams data"""
|
||||||
|
trays = [FilamentTray.from_dict(t) for t in data.get("tray", [])]
|
||||||
|
return cls(
|
||||||
|
ams_id=data.get("ams_id", ""),
|
||||||
|
unit_id=str(data.get("id", "")),
|
||||||
|
humidity=int(data.get("humidity", -1)),
|
||||||
|
humidity_raw=int(data.get("humidity_raw", -1)),
|
||||||
|
temp=float(data.get("temp", 0.0)),
|
||||||
|
dry_time=data.get("dry_time", 0),
|
||||||
|
chip_id=data.get("chip_id", ""),
|
||||||
|
info=data.get("info", ""),
|
||||||
|
trays=trays,
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Convert to dictionary for database storage"""
|
||||||
|
return {
|
||||||
|
"ams_id": self.ams_id,
|
||||||
|
"unit_id": self.unit_id,
|
||||||
|
"humidity": self.humidity,
|
||||||
|
"humidity_raw": self.humidity_raw,
|
||||||
|
"temp": self.temp,
|
||||||
|
"dry_time": self.dry_time,
|
||||||
|
"chip_id": self.chip_id,
|
||||||
|
"info": self.info,
|
||||||
|
"trays": [t.to_dict() for t in self.trays],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AMSState:
|
||||||
|
"""Complete AMS system state including all units"""
|
||||||
|
ams_exist_bits: str = ""
|
||||||
|
tray_exist_bits: str = ""
|
||||||
|
tray_now: str = ""
|
||||||
|
tray_pre: str = ""
|
||||||
|
tray_tar: str = ""
|
||||||
|
ams_status: int = 0
|
||||||
|
ams_rfid_status: int = 0
|
||||||
|
tray_is_bbl_bits: str = ""
|
||||||
|
tray_read_done_bits: str = ""
|
||||||
|
version: int = 0
|
||||||
|
insert_flag: bool = False
|
||||||
|
power_on_flag: bool = False
|
||||||
|
units: List[AMSUnit] = field(default_factory=list)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "AMSState":
|
||||||
|
"""Create AMSState from MQTT ams data"""
|
||||||
|
units = [AMSUnit.from_dict(u) for u in data.get("ams", [])]
|
||||||
|
return cls(
|
||||||
|
ams_exist_bits=data.get("ams_exist_bits", ""),
|
||||||
|
tray_exist_bits=data.get("tray_exist_bits", ""),
|
||||||
|
tray_now=data.get("tray_now", ""),
|
||||||
|
tray_pre=data.get("tray_pre", ""),
|
||||||
|
tray_tar=data.get("tray_tar", ""),
|
||||||
|
ams_status=data.get("ams_status", 0),
|
||||||
|
ams_rfid_status=data.get("ams_rfid_status", 0),
|
||||||
|
tray_is_bbl_bits=data.get("tray_is_bbl_bits", ""),
|
||||||
|
tray_read_done_bits=data.get("tray_read_done_bits", ""),
|
||||||
|
version=int(data.get("version", 0)),
|
||||||
|
insert_flag=bool(data.get("insert_flag", False)),
|
||||||
|
power_on_flag=bool(data.get("power_on_flag", False)),
|
||||||
|
units=units,
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Convert to dictionary for database storage"""
|
||||||
|
return {
|
||||||
|
"ams_exist_bits": self.ams_exist_bits,
|
||||||
|
"tray_exist_bits": self.tray_exist_bits,
|
||||||
|
"tray_now": self.tray_now,
|
||||||
|
"tray_pre": self.tray_pre,
|
||||||
|
"tray_tar": self.tray_tar,
|
||||||
|
"ams_status": self.ams_status,
|
||||||
|
"ams_rfid_status": self.ams_rfid_status,
|
||||||
|
"tray_is_bbl_bits": self.tray_is_bbl_bits,
|
||||||
|
"tray_read_done_bits": self.tray_read_done_bits,
|
||||||
|
"version": self.version,
|
||||||
|
"insert_flag": self.insert_flag,
|
||||||
|
"power_on_flag": self.power_on_flag,
|
||||||
|
"units": [u.to_dict() for u in self.units],
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_trays(self) -> int:
|
||||||
|
"""Total number of trays across all units"""
|
||||||
|
return sum(len(u.trays) for u in self.units)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def loaded_trays(self) -> List[FilamentTray]:
|
||||||
|
"""Get all trays that have filament loaded"""
|
||||||
|
loaded = []
|
||||||
|
for unit in self.units:
|
||||||
|
for tray in unit.trays:
|
||||||
|
if tray.tray_type:
|
||||||
|
loaded.append(tray)
|
||||||
|
return loaded
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PrinterState:
|
||||||
|
"""Complete printer state parsed from MQTT data"""
|
||||||
|
timestamp: str = ""
|
||||||
|
sequence_id: str = ""
|
||||||
|
|
||||||
|
# Temperature info
|
||||||
|
nozzle_temp: float = 0.0
|
||||||
|
nozzle_target_temp: float = 0.0
|
||||||
|
bed_temp: float = 0.0
|
||||||
|
bed_target_temp: float = 0.0
|
||||||
|
chamber_temp: float = 0.0
|
||||||
|
|
||||||
|
# Print progress
|
||||||
|
gcode_state: str = ""
|
||||||
|
print_percent: int = 0
|
||||||
|
remaining_time_min: int = 0
|
||||||
|
layer_num: int = 0
|
||||||
|
total_layer_num: int = 0
|
||||||
|
print_line_number: int = 0
|
||||||
|
|
||||||
|
# Current job info
|
||||||
|
gcode_file: str = ""
|
||||||
|
subtask_name: str = ""
|
||||||
|
subtask_id: str = ""
|
||||||
|
task_id: str = ""
|
||||||
|
project_id: str = ""
|
||||||
|
profile_id: str = ""
|
||||||
|
print_type: str = ""
|
||||||
|
|
||||||
|
# Fan speeds
|
||||||
|
fan_gear: int = 0
|
||||||
|
cooling_fan_speed: int = 0
|
||||||
|
heatbreak_fan_speed: int = 0
|
||||||
|
|
||||||
|
# WiFi / Network
|
||||||
|
wifi_signal: str = ""
|
||||||
|
wifi_signal_dbm: int = 0
|
||||||
|
|
||||||
|
# Nozzle info
|
||||||
|
nozzle_diameter: float = 0.4
|
||||||
|
nozzle_type: str = ""
|
||||||
|
|
||||||
|
# System status
|
||||||
|
home_flag: int = 0
|
||||||
|
hw_switch_state: int = 0
|
||||||
|
mc_print_stage: str = ""
|
||||||
|
mc_print_sub_stage: int = 0
|
||||||
|
print_error: int = 0
|
||||||
|
stg_cur: int = 0
|
||||||
|
|
||||||
|
# AMS state
|
||||||
|
ams: Optional[AMSState] = None
|
||||||
|
|
||||||
|
# Upgrade state
|
||||||
|
upgrade_state: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
# Version info
|
||||||
|
version: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
# Camera / Timelapse
|
||||||
|
ipcam: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
timelapse: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
# Lights
|
||||||
|
lights_report: List[Dict[str, Any]] = field(default_factory=list)
|
||||||
|
|
||||||
|
# HMS (Health Management System) messages
|
||||||
|
hms: List[Dict[str, Any]] = field(default_factory=list)
|
||||||
|
|
||||||
|
# Speed settings
|
||||||
|
spd_lvl: int = 0
|
||||||
|
spd_mag: int = 0
|
||||||
|
|
||||||
|
# Auxiliary fans
|
||||||
|
big_fan1_speed: int = 0
|
||||||
|
big_fan2_speed: int = 0
|
||||||
|
|
||||||
|
# System info
|
||||||
|
sdcard: bool = False
|
||||||
|
gcode_file_prepare_percent: str = ""
|
||||||
|
lifecycle: str = ""
|
||||||
|
|
||||||
|
# External spool (virtual tray)
|
||||||
|
vt_tray: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
# Raw data for any additional fields
|
||||||
|
_raw_data: Dict[str, Any] = field(default_factory=dict, repr=False)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_wifi_signal(signal_str: str) -> int:
|
||||||
|
"""Parse WiFi signal string (e.g., '-34dBm') to integer dBm"""
|
||||||
|
if not signal_str:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
return int(signal_str.replace("dBm", ""))
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_mqtt_data(cls, data: Dict[str, Any], timestamp: Optional[str] = None) -> "PrinterState":
|
||||||
|
"""Create PrinterState from MQTT push_status data."""
|
||||||
|
if timestamp is None:
|
||||||
|
timestamp = datetime.now(ZoneInfo(app_settings.TIMEZONE)).isoformat()
|
||||||
|
|
||||||
|
print_data = data.get("print", {})
|
||||||
|
|
||||||
|
# Parse AMS data if present
|
||||||
|
ams = None
|
||||||
|
if "ams" in print_data:
|
||||||
|
ams = AMSState.from_dict(print_data["ams"])
|
||||||
|
|
||||||
|
wifi_signal = print_data.get("wifi_signal", "")
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
timestamp=timestamp,
|
||||||
|
sequence_id=str(print_data.get("sequence_id", "")),
|
||||||
|
nozzle_temp=float(print_data.get("nozzle_temper", 0.0)),
|
||||||
|
nozzle_target_temp=float(print_data.get("nozzle_target_temper", 0.0)),
|
||||||
|
bed_temp=float(print_data.get("bed_temper", 0.0)),
|
||||||
|
bed_target_temp=float(print_data.get("bed_target_temper", 0.0)),
|
||||||
|
chamber_temp=float(print_data.get("chamber_temper", 0.0)),
|
||||||
|
gcode_state=print_data.get("gcode_state", ""),
|
||||||
|
print_percent=int(print_data.get("mc_percent", 0)),
|
||||||
|
remaining_time_min=int(print_data.get("mc_remaining_time", 0)),
|
||||||
|
layer_num=int(print_data.get("layer_num", 0)),
|
||||||
|
total_layer_num=int(print_data.get("total_layer_num", 0)),
|
||||||
|
print_line_number=int(print_data.get("mc_print_line_number", 0)),
|
||||||
|
gcode_file=print_data.get("gcode_file", ""),
|
||||||
|
subtask_name=print_data.get("subtask_name", ""),
|
||||||
|
subtask_id=print_data.get("subtask_id", ""),
|
||||||
|
task_id=print_data.get("task_id", ""),
|
||||||
|
project_id=print_data.get("project_id", ""),
|
||||||
|
profile_id=print_data.get("profile_id", ""),
|
||||||
|
print_type=print_data.get("print_type", ""),
|
||||||
|
fan_gear=int(print_data.get("fan_gear", 0)),
|
||||||
|
cooling_fan_speed=int(print_data.get("cooling_fan_speed", 0)),
|
||||||
|
heatbreak_fan_speed=int(print_data.get("heatbreak_fan_speed", 0)),
|
||||||
|
wifi_signal=wifi_signal,
|
||||||
|
wifi_signal_dbm=cls._parse_wifi_signal(wifi_signal),
|
||||||
|
nozzle_diameter=float(print_data.get("nozzle_diameter", 0.4)),
|
||||||
|
nozzle_type=print_data.get("nozzle_type", ""),
|
||||||
|
home_flag=int(print_data.get("home_flag", 0)),
|
||||||
|
hw_switch_state=int(print_data.get("hw_switch_state", 0)),
|
||||||
|
mc_print_stage=str(print_data.get("mc_print_stage", "")),
|
||||||
|
mc_print_sub_stage=int(print_data.get("mc_print_sub_stage", 0)),
|
||||||
|
print_error=int(print_data.get("print_error", 0)),
|
||||||
|
stg_cur=int(print_data.get("stg_cur", 0)),
|
||||||
|
ams=ams,
|
||||||
|
upgrade_state=print_data.get("upgrade_state", {}),
|
||||||
|
version=print_data.get("version", {}),
|
||||||
|
ipcam=print_data.get("ipcam", {}),
|
||||||
|
timelapse=print_data.get("timelapse", {}),
|
||||||
|
lights_report=print_data.get("lights_report", []),
|
||||||
|
hms=print_data.get("hms", []),
|
||||||
|
spd_lvl=int(print_data.get("spd_lvl", 0)),
|
||||||
|
spd_mag=int(print_data.get("spd_mag", 0)),
|
||||||
|
big_fan1_speed=int(print_data.get("big_fan1_speed", 0)),
|
||||||
|
big_fan2_speed=int(print_data.get("big_fan2_speed", 0)),
|
||||||
|
sdcard=bool(print_data.get("sdcard", False)),
|
||||||
|
gcode_file_prepare_percent=str(print_data.get("gcode_file_prepare_percent", "")),
|
||||||
|
lifecycle=print_data.get("lifecycle", ""),
|
||||||
|
vt_tray=print_data.get("vt_tray"),
|
||||||
|
_raw_data=data,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_snapshot(self) -> Dict[str, Any]:
|
||||||
|
"""Get a simplified snapshot for database logging."""
|
||||||
|
snapshot = {
|
||||||
|
"timestamp": self.timestamp,
|
||||||
|
"nozzle_temp": round(self.nozzle_temp, 2),
|
||||||
|
"nozzle_target_temp": round(self.nozzle_target_temp, 2),
|
||||||
|
"bed_temp": round(self.bed_temp, 2),
|
||||||
|
"bed_target_temp": round(self.bed_target_temp, 2),
|
||||||
|
"chamber_temp": round(self.chamber_temp, 2),
|
||||||
|
"nozzle_diameter": self.nozzle_diameter,
|
||||||
|
"nozzle_type": self.nozzle_type,
|
||||||
|
"gcode_state": self.gcode_state,
|
||||||
|
"print_type": self.print_type,
|
||||||
|
"print_percent": self.print_percent,
|
||||||
|
"remaining_time_min": self.remaining_time_min,
|
||||||
|
"layer_num": self.layer_num,
|
||||||
|
"total_layer_num": self.total_layer_num,
|
||||||
|
"print_line_number": self.print_line_number,
|
||||||
|
"subtask_name": self.subtask_name,
|
||||||
|
"gcode_file": self.gcode_file,
|
||||||
|
"cooling_fan_speed": self.cooling_fan_speed,
|
||||||
|
"heatbreak_fan_speed": self.heatbreak_fan_speed,
|
||||||
|
"big_fan1_speed": self.big_fan1_speed,
|
||||||
|
"big_fan2_speed": self.big_fan2_speed,
|
||||||
|
"spd_lvl": self.spd_lvl,
|
||||||
|
"spd_mag": self.spd_mag,
|
||||||
|
"wifi_signal_dbm": self.wifi_signal_dbm,
|
||||||
|
"print_error": self.print_error,
|
||||||
|
"has_errors": self.print_error != 0,
|
||||||
|
"hms": self.hms,
|
||||||
|
"stg_cur": self.stg_cur,
|
||||||
|
"lights_report": self.lights_report,
|
||||||
|
"chamber_light": self._get_chamber_light_status(),
|
||||||
|
"ipcam_record": self.ipcam.get("ipcam_record", ""),
|
||||||
|
"timelapse": self.ipcam.get("timelapse", ""),
|
||||||
|
"sdcard": self.sdcard,
|
||||||
|
"gcode_file_prepare_percent": self.gcode_file_prepare_percent,
|
||||||
|
"lifecycle": self.lifecycle,
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.ams:
|
||||||
|
snapshot["ams_unit_count"] = len(self.ams.units)
|
||||||
|
snapshot["ams_status"] = self.ams.ams_status
|
||||||
|
snapshot["ams_rfid_status"] = self.ams.ams_rfid_status
|
||||||
|
snapshot["ams_exist_bits"] = self.ams.ams_exist_bits
|
||||||
|
snapshot["tray_exist_bits"] = self.ams.tray_exist_bits
|
||||||
|
snapshot["tray_is_bbl_bits"] = self.ams.tray_is_bbl_bits
|
||||||
|
snapshot["tray_read_done_bits"] = self.ams.tray_read_done_bits
|
||||||
|
snapshot["tray_now"] = self.ams.tray_now
|
||||||
|
snapshot["ams_version"] = self.ams.version
|
||||||
|
|
||||||
|
filaments = []
|
||||||
|
for unit in self.ams.units:
|
||||||
|
for tray in unit.trays:
|
||||||
|
if tray.tray_type:
|
||||||
|
filaments.append({
|
||||||
|
"tray_id": tray.tray_id,
|
||||||
|
"slot": tray.tray_id_name,
|
||||||
|
"type": tray.tray_type,
|
||||||
|
"sub_type": tray.tray_sub_brands,
|
||||||
|
"color": tray.tray_color,
|
||||||
|
"remain_percent": tray.remain_percent,
|
||||||
|
"tray_weight": tray.tray_weight,
|
||||||
|
"tray_diameter": tray.tray_diameter,
|
||||||
|
"nozzle_temp_min": tray.nozzle_temp_min,
|
||||||
|
"nozzle_temp_max": tray.nozzle_temp_max,
|
||||||
|
"tag_uid": tray.tag_uid,
|
||||||
|
"state": tray.state,
|
||||||
|
"tray_uuid": tray.tray_uuid,
|
||||||
|
"k": tray.k,
|
||||||
|
"n": tray.n,
|
||||||
|
"cali_idx": tray.cali_idx,
|
||||||
|
"total_len": tray.total_len,
|
||||||
|
"tray_info_idx": tray.tray_info_idx,
|
||||||
|
"tray_time": tray.tray_time,
|
||||||
|
"tray_bed_temp": tray.tray_bed_temp,
|
||||||
|
"bed_temp_type": tray.bed_temp_type,
|
||||||
|
"cols": tray.cols,
|
||||||
|
})
|
||||||
|
snapshot["filaments"] = filaments
|
||||||
|
|
||||||
|
ams_units = []
|
||||||
|
for unit in self.ams.units:
|
||||||
|
ams_units.append({
|
||||||
|
"unit_id": unit.unit_id,
|
||||||
|
"ams_id": unit.ams_id,
|
||||||
|
"chip_id": unit.chip_id,
|
||||||
|
"info": unit.info,
|
||||||
|
"humidity": unit.humidity,
|
||||||
|
"humidity_raw": unit.humidity_raw,
|
||||||
|
"temp": unit.temp,
|
||||||
|
"dry_time": unit.dry_time,
|
||||||
|
})
|
||||||
|
snapshot["ams_units"] = ams_units
|
||||||
|
|
||||||
|
if self.ams.units:
|
||||||
|
snapshot["ams_humidity"] = self.ams.units[0].humidity
|
||||||
|
snapshot["ams_humidity_raw"] = self.ams.units[0].humidity_raw
|
||||||
|
snapshot["ams_temp"] = self.ams.units[0].temp
|
||||||
|
|
||||||
|
if self.vt_tray:
|
||||||
|
snapshot["external_spool"] = {
|
||||||
|
"type": self.vt_tray.get("tray_type", ""),
|
||||||
|
"color": self.vt_tray.get("tray_color", ""),
|
||||||
|
"remain": self.vt_tray.get("remain", 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
def _get_chamber_light_status(self) -> str:
|
||||||
|
"""Extract chamber light status from lights_report"""
|
||||||
|
for light in self.lights_report:
|
||||||
|
if light.get("node") == "chamber_light":
|
||||||
|
return light.get("mode", "unknown")
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_printing(self) -> bool:
|
||||||
|
return self.gcode_state.upper() in ("RUNNING", "PRINTING")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_idle(self) -> bool:
|
||||||
|
return self.gcode_state.upper() in ("IDLE", "FINISH", "")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_paused(self) -> bool:
|
||||||
|
return self.gcode_state.upper() == "PAUSE"
|
||||||
|
|
||||||
|
|
||||||
|
class PrinterStateAccumulator:
|
||||||
|
"""
|
||||||
|
Accumulates MQTT updates into a complete printer state.
|
||||||
|
|
||||||
|
BambuLab MQTT sends incremental updates - each message may only contain
|
||||||
|
a subset of fields that have changed. This class maintains the complete
|
||||||
|
state by merging updates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._state_data: Dict[str, Any] = {"print": {}}
|
||||||
|
self._last_update: Optional[str] = None
|
||||||
|
self._update_count: int = 0
|
||||||
|
|
||||||
|
def update(self, data: Dict[str, Any]) -> PrinterState:
|
||||||
|
"""Merge new MQTT data into accumulated state and return complete PrinterState."""
|
||||||
|
timestamp = datetime.now(ZoneInfo(app_settings.TIMEZONE)).isoformat()
|
||||||
|
self._last_update = timestamp
|
||||||
|
self._update_count += 1
|
||||||
|
|
||||||
|
if "print" in data:
|
||||||
|
self._deep_merge(self._state_data["print"], data["print"])
|
||||||
|
|
||||||
|
return PrinterState.from_mqtt_data(self._state_data, timestamp)
|
||||||
|
|
||||||
|
def _deep_merge(self, base: Dict, update: Dict) -> None:
|
||||||
|
"""Recursively merge update into base dict"""
|
||||||
|
for key, value in update.items():
|
||||||
|
if key in base and isinstance(base[key], dict) and isinstance(value, dict):
|
||||||
|
self._deep_merge(base[key], value)
|
||||||
|
else:
|
||||||
|
base[key] = value
|
||||||
|
|
||||||
|
def get_state(self) -> PrinterState:
|
||||||
|
"""Get current accumulated state without updating"""
|
||||||
|
timestamp = self._last_update or datetime.now(ZoneInfo(app_settings.TIMEZONE)).isoformat()
|
||||||
|
return PrinterState.from_mqtt_data(self._state_data, timestamp)
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Reset accumulated state"""
|
||||||
|
self._state_data = {"print": {}}
|
||||||
|
self._last_update = None
|
||||||
|
self._update_count = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def update_count(self) -> int:
|
||||||
|
return self._update_count
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_update(self) -> Optional[str]:
|
||||||
|
return self._last_update
|
||||||
|
|
||||||
|
|
||||||
|
class BambuPrinter:
|
||||||
|
"""
|
||||||
|
High-level interface for BambuLab printer monitoring.
|
||||||
|
Combines authentication, client, and MQTT into a single interface.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
username: Optional[str] = None,
|
||||||
|
password: Optional[str] = None,
|
||||||
|
token: Optional[str] = None,
|
||||||
|
device_id: Optional[str] = None,
|
||||||
|
on_update: Optional[Callable[[PrinterState], None]] = None,
|
||||||
|
silent: bool = True,
|
||||||
|
verification_timeout: int = 300,
|
||||||
|
):
|
||||||
|
self.username = username or os.getenv("BAMBU_USERNAME")
|
||||||
|
self.password = password or os.getenv("BAMBU_PASSWORD")
|
||||||
|
self._token = token or os.getenv("BAMBU_TOKEN")
|
||||||
|
self._device_id = device_id or os.getenv("BAMBU_DEVICE_ID")
|
||||||
|
self._uid: Optional[str] = None
|
||||||
|
self._on_update = on_update
|
||||||
|
self._silent = silent
|
||||||
|
self._verification_timeout = verification_timeout
|
||||||
|
|
||||||
|
self._client: Optional[BambuClient] = None
|
||||||
|
self._mqtt: Optional[MQTTClient] = None
|
||||||
|
self._accumulator = PrinterStateAccumulator()
|
||||||
|
self._connected = False
|
||||||
|
self._devices: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
def _get_fresh_token(self, verification_code_timeout: int = 300) -> str:
|
||||||
|
"""Get a fresh token using credentials."""
|
||||||
|
if not self.username or not self.password:
|
||||||
|
raise ValueError(
|
||||||
|
"Username and password required for token refresh. Provide as arguments "
|
||||||
|
"or set BAMBU_USERNAME and BAMBU_PASSWORD environment variables."
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("BambuLab Authentication")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f"Authenticating as: {self.username}")
|
||||||
|
print("This may require email verification (2FA)...")
|
||||||
|
print()
|
||||||
|
|
||||||
|
auth = BambuAuthenticator()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self._silent:
|
||||||
|
with suppress_stdout():
|
||||||
|
token = auth.get_or_create_token(
|
||||||
|
username=self.username,
|
||||||
|
password=self.password
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
token = auth.get_or_create_token(
|
||||||
|
username=self.username,
|
||||||
|
password=self.password
|
||||||
|
)
|
||||||
|
|
||||||
|
self._token = token
|
||||||
|
print("Authentication successful!")
|
||||||
|
print(f"Token: {token[:20]}...{token[-10:]}")
|
||||||
|
print("=" * 60 + "\n")
|
||||||
|
logger.info("BambuLab token obtained successfully")
|
||||||
|
return token
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = str(e).lower()
|
||||||
|
|
||||||
|
if "verification" in error_msg or "code" in error_msg or "2fa" in error_msg:
|
||||||
|
print("\n" + "-" * 60)
|
||||||
|
print("EMAIL VERIFICATION REQUIRED")
|
||||||
|
print("-" * 60)
|
||||||
|
print("A verification code has been sent to your email.")
|
||||||
|
print(f"You have {verification_code_timeout} seconds to enter it.")
|
||||||
|
print()
|
||||||
|
|
||||||
|
try:
|
||||||
|
code = timed_input(
|
||||||
|
"Enter verification code: ",
|
||||||
|
timeout_sec=verification_code_timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
if not code:
|
||||||
|
raise ValueError("No verification code entered")
|
||||||
|
|
||||||
|
print("Verifying code...")
|
||||||
|
token = auth.login(
|
||||||
|
self.username,
|
||||||
|
self.password,
|
||||||
|
verification_code=code
|
||||||
|
)
|
||||||
|
|
||||||
|
self._token = token
|
||||||
|
print("\nAuthentication successful!")
|
||||||
|
print(f"Token: {token[:20]}...{token[-10:]}")
|
||||||
|
print("=" * 60 + "\n")
|
||||||
|
print("TIP: Save this token to BAMBU_TOKEN env var to skip login next time")
|
||||||
|
logger.info("BambuLab token obtained with 2FA verification")
|
||||||
|
return token
|
||||||
|
|
||||||
|
except TimeoutError:
|
||||||
|
print("\nVerification timed out!")
|
||||||
|
raise TimeoutError(
|
||||||
|
f"Verification code not entered within {verification_code_timeout} seconds"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(f"\nAuthentication failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _ensure_token(self) -> str:
|
||||||
|
"""Ensure we have a valid token, refreshing if needed"""
|
||||||
|
if self._token:
|
||||||
|
logger.debug("Using existing token")
|
||||||
|
return self._token
|
||||||
|
|
||||||
|
print("\n" + "!" * 60)
|
||||||
|
print("NO TOKEN FOUND")
|
||||||
|
print("!" * 60)
|
||||||
|
print("Checked:")
|
||||||
|
print(" - Constructor 'token' parameter: Not provided")
|
||||||
|
print(" - Environment variable 'BAMBU_TOKEN': Not set")
|
||||||
|
print()
|
||||||
|
print("Will attempt to authenticate with username/password...")
|
||||||
|
print("!" * 60 + "\n")
|
||||||
|
|
||||||
|
return self._get_fresh_token(verification_code_timeout=self._verification_timeout)
|
||||||
|
|
||||||
|
def _on_mqtt_message(self, device_id: str, data: Dict[str, Any]) -> None:
|
||||||
|
"""Internal MQTT message handler"""
|
||||||
|
if not data:
|
||||||
|
return
|
||||||
|
state = self._accumulator.update(data)
|
||||||
|
if self._on_update:
|
||||||
|
self._on_update(state)
|
||||||
|
|
||||||
|
def connect(self, blocking: bool = False, retry_on_auth_error: bool = True) -> None:
|
||||||
|
"""Connect to printer via MQTT."""
|
||||||
|
token = self._ensure_token()
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._client = BambuClient(token=token)
|
||||||
|
user_info = self._client.get_user_info()
|
||||||
|
self._uid = str(user_info.get("uid", ""))
|
||||||
|
|
||||||
|
if not self._device_id:
|
||||||
|
self._devices = self._client.get_devices()
|
||||||
|
if not self._devices:
|
||||||
|
raise RuntimeError("No devices found on this account")
|
||||||
|
self._device_id = self._devices[0].get("dev_id")
|
||||||
|
|
||||||
|
self._mqtt = MQTTClient(
|
||||||
|
self._uid,
|
||||||
|
token,
|
||||||
|
self._device_id,
|
||||||
|
on_message=self._on_mqtt_message
|
||||||
|
)
|
||||||
|
self._mqtt.connect(blocking=blocking)
|
||||||
|
self._connected = True
|
||||||
|
logger.info(f"Connected to BambuLab printer: {self._device_id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = str(e).lower()
|
||||||
|
is_auth_error = any(x in error_msg for x in ["401", "unauthorized", "token", "auth", "expired"])
|
||||||
|
|
||||||
|
if is_auth_error and retry_on_auth_error and self.username and self.password:
|
||||||
|
logger.warning("Auth error detected, refreshing token and retrying...")
|
||||||
|
self._token = None
|
||||||
|
self._get_fresh_token()
|
||||||
|
self.connect(blocking=blocking, retry_on_auth_error=False)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
def reconnect(self, blocking: bool = False) -> None:
|
||||||
|
"""Disconnect and reconnect."""
|
||||||
|
self.disconnect()
|
||||||
|
self._accumulator.reset()
|
||||||
|
self.connect(blocking=blocking)
|
||||||
|
|
||||||
|
def disconnect(self) -> None:
|
||||||
|
"""Disconnect from MQTT"""
|
||||||
|
if self._mqtt:
|
||||||
|
try:
|
||||||
|
self._mqtt.disconnect()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._connected = False
|
||||||
|
logger.debug("Disconnected from BambuLab printer")
|
||||||
|
|
||||||
|
def get_state(self) -> PrinterState:
|
||||||
|
"""Get current accumulated printer state"""
|
||||||
|
return self._accumulator.get_state()
|
||||||
|
|
||||||
|
def get_snapshot(self) -> Dict[str, Any]:
|
||||||
|
"""Get simplified snapshot for database logging"""
|
||||||
|
return self._accumulator.get_state().get_snapshot()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_id(self) -> Optional[str]:
|
||||||
|
return self._device_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def devices(self) -> List[Dict[str, Any]]:
|
||||||
|
return self._devices
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
return self._connected
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self.connect(blocking=False)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
self.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BambuAuthenticator",
|
||||||
|
"BambuClient",
|
||||||
|
"MQTTClient",
|
||||||
|
"FilamentTray",
|
||||||
|
"AMSUnit",
|
||||||
|
"AMSState",
|
||||||
|
"PrinterState",
|
||||||
|
"PrinterStateAccumulator",
|
||||||
|
"BambuPrinter",
|
||||||
|
]
|
||||||
61
bambu_run/static/bambu_run/css/dashboard.css
Normal file
61
bambu_run/static/bambu_run/css/dashboard.css
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/* Bambu Run Dashboard Styles */
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
position: relative;
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card styling */
|
||||||
|
.infra-card-warning {
|
||||||
|
background: linear-gradient(135deg, #ffc107 0%, #ffb300 100%);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infra-card-info {
|
||||||
|
background: linear-gradient(135deg, #0dcaf0 0%, #0bb5d6 100%);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infra-card-danger {
|
||||||
|
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infra-card-success {
|
||||||
|
background: linear-gradient(135deg, #198754 0%, #157347 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode adjustments */
|
||||||
|
[data-coreui-theme="dark"] .infra-card-warning {
|
||||||
|
background: linear-gradient(135deg, #ffb300 0%, #ff8f00 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-coreui-theme="dark"] .infra-card-info {
|
||||||
|
background: linear-gradient(135deg, #0bb5d6 0%, #099cbd 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stat display styling */
|
||||||
|
.stat-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infra-card-warning .card-text,
|
||||||
|
.infra-card-info .card-text {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-coreui-theme="dark"] .infra-card-warning .card-text,
|
||||||
|
[data-coreui-theme="dark"] .infra-card-info .card-text {
|
||||||
|
opacity: 0.9;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
61
bambu_run/static/bambu_run/js/filament_type_form.js
Normal file
61
bambu_run/static/bambu_run/js/filament_type_form.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Dropdown-assisted text inputs for FilamentType add/edit form.
|
||||||
|
* Reads existing DB values and preset suggestions from json_script tags,
|
||||||
|
* then populates dropdown menus that fill the adjacent text input on click.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a dropdown menu with existing DB values and preset suggestions.
|
||||||
|
* @param {string} dropdownId - ID of the <ul> dropdown menu element
|
||||||
|
* @param {string} inputId - ID of the text input to fill on click
|
||||||
|
* @param {Array<string>} existingValues - Values already in the database
|
||||||
|
* @param {Array<string>} presetValues - Pre-coded suggestion values
|
||||||
|
*/
|
||||||
|
function buildDropdown(dropdownId, inputId, existingValues, presetValues) {
|
||||||
|
const menu = document.getElementById(dropdownId);
|
||||||
|
|
||||||
|
// Add existing DB values
|
||||||
|
existingValues.forEach(val => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.innerHTML = `<a class="dropdown-item" href="#">${val}</a>`;
|
||||||
|
li.querySelector('a').addEventListener('click', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
document.getElementById(inputId).value = val;
|
||||||
|
});
|
||||||
|
menu.appendChild(li);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add dotted separator if there were DB values
|
||||||
|
if (existingValues.length > 0) {
|
||||||
|
const sep = document.createElement('li');
|
||||||
|
sep.innerHTML = '<hr class="dropdown-divider" style="border-style: dotted;">';
|
||||||
|
menu.appendChild(sep);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add preset values (skip duplicates already in DB)
|
||||||
|
const existingSet = new Set(existingValues);
|
||||||
|
presetValues.forEach(val => {
|
||||||
|
if (existingSet.has(val)) return;
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.innerHTML = `<a class="dropdown-item text-muted" href="#">${val}</a>`;
|
||||||
|
li.querySelector('a').addEventListener('click', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
document.getElementById(inputId).value = val;
|
||||||
|
});
|
||||||
|
menu.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse data from json_script tags and build all three dropdowns
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const existingTypes = JSON.parse(document.getElementById('existing-types').textContent);
|
||||||
|
const existingSubTypes = JSON.parse(document.getElementById('existing-sub-types').textContent);
|
||||||
|
const existingBrands = JSON.parse(document.getElementById('existing-brands').textContent);
|
||||||
|
const presetTypes = JSON.parse(document.getElementById('preset-types').textContent);
|
||||||
|
const presetSubTypes = JSON.parse(document.getElementById('preset-sub-types').textContent);
|
||||||
|
const presetBrands = JSON.parse(document.getElementById('preset-brands').textContent);
|
||||||
|
|
||||||
|
buildDropdown('type-dropdown', 'id_type', existingTypes, presetTypes);
|
||||||
|
buildDropdown('sub-type-dropdown', 'id_sub_type', existingSubTypes, presetSubTypes);
|
||||||
|
buildDropdown('brand-dropdown', 'id_brand', existingBrands, presetBrands);
|
||||||
|
});
|
||||||
713
bambu_run/static/bambu_run/js/printer_charts.js
Normal file
713
bambu_run/static/bambu_run/js/printer_charts.js
Normal file
@@ -0,0 +1,713 @@
|
|||||||
|
// 3D Printer Charts Initialization and Management
|
||||||
|
// Chart.js implementation for printer metrics visualization
|
||||||
|
|
||||||
|
let nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart;
|
||||||
|
let wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart;
|
||||||
|
|
||||||
|
function initPrinterCharts(printerData, apiUrl) {
|
||||||
|
// Apply filament card colors
|
||||||
|
applyFilamentColors();
|
||||||
|
|
||||||
|
// Register the annotation plugin
|
||||||
|
if (typeof Chart !== 'undefined' && typeof ChartAnnotation !== 'undefined') {
|
||||||
|
Chart.register(ChartAnnotation);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect current theme
|
||||||
|
const isDarkMode = document.documentElement.getAttribute('data-coreui-theme') === 'dark';
|
||||||
|
|
||||||
|
// Set colors based on theme
|
||||||
|
const tickColor = isDarkMode ? 'rgba(255, 255, 255, 0.8)' : 'rgba(0, 0, 0, 0.8)';
|
||||||
|
const gridColor = isDarkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
|
||||||
|
|
||||||
|
// Initialize Nozzle Temperature Chart
|
||||||
|
const nozzleCtx = document.getElementById('nozzleTempChart').getContext('2d');
|
||||||
|
nozzleTempChart = new Chart(nozzleCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: printerData.timestamps,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Actual Temp',
|
||||||
|
data: printerData.nozzle_temp,
|
||||||
|
borderColor: 'rgb(255, 159, 64)',
|
||||||
|
backgroundColor: 'rgba(255, 159, 64, 0.1)',
|
||||||
|
tension: 0.3,
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
spanGaps: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Target Temp',
|
||||||
|
data: printerData.nozzle_target_temp,
|
||||||
|
borderColor: 'rgb(255, 99, 132)',
|
||||||
|
backgroundColor: 'rgba(255, 99, 132, 0.05)',
|
||||||
|
borderDash: [5, 5],
|
||||||
|
tension: 0.3,
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
spanGaps: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: getTemperatureChartOptions(tickColor, gridColor, '°C')
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize Bed Temperature Chart
|
||||||
|
const bedCtx = document.getElementById('bedTempChart').getContext('2d');
|
||||||
|
bedTempChart = new Chart(bedCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: printerData.timestamps,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Actual Temp',
|
||||||
|
data: printerData.bed_temp,
|
||||||
|
borderColor: 'rgb(255, 99, 132)',
|
||||||
|
backgroundColor: 'rgba(255, 99, 132, 0.1)',
|
||||||
|
tension: 0.3,
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
spanGaps: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Target Temp',
|
||||||
|
data: printerData.bed_target_temp,
|
||||||
|
borderColor: 'rgb(255, 159, 64)',
|
||||||
|
backgroundColor: 'rgba(255, 159, 64, 0.05)',
|
||||||
|
borderDash: [5, 5],
|
||||||
|
tension: 0.3,
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
spanGaps: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: getTemperatureChartOptions(tickColor, gridColor, '°C')
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize Print Progress Chart
|
||||||
|
const progressCtx = document.getElementById('printProgressChart').getContext('2d');
|
||||||
|
printProgressChart = new Chart(progressCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: printerData.timestamps,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Print Progress',
|
||||||
|
data: printerData.print_percent,
|
||||||
|
borderColor: 'rgb(54, 162, 235)',
|
||||||
|
backgroundColor: 'rgba(54, 162, 235, 0.2)',
|
||||||
|
tension: 0.3,
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
fill: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: getPercentageChartOptions(tickColor, gridColor, 'Print Progress')
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize Fan Speeds Chart
|
||||||
|
const fanCtx = document.getElementById('fanSpeedsChart').getContext('2d');
|
||||||
|
fanSpeedsChart = new Chart(fanCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: printerData.timestamps,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Cooling Fan',
|
||||||
|
data: printerData.cooling_fan_speed,
|
||||||
|
borderColor: 'rgb(75, 192, 192)',
|
||||||
|
backgroundColor: 'rgba(75, 192, 192, 0.1)',
|
||||||
|
tension: 0.3,
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
spanGaps: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Heatbreak Fan',
|
||||||
|
data: printerData.heatbreak_fan_speed,
|
||||||
|
borderColor: 'rgb(153, 102, 255)',
|
||||||
|
backgroundColor: 'rgba(153, 102, 255, 0.1)',
|
||||||
|
tension: 0.3,
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
spanGaps: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: getPercentageChartOptions(tickColor, gridColor, 'Fan Speed')
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize WiFi Signal Chart
|
||||||
|
const wifiCtx = document.getElementById('wifiSignalChart').getContext('2d');
|
||||||
|
wifiSignalChart = new Chart(wifiCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: printerData.timestamps,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'WiFi Signal',
|
||||||
|
data: printerData.wifi_signal_dbm,
|
||||||
|
borderColor: 'rgb(255, 205, 86)',
|
||||||
|
backgroundColor: 'rgba(255, 205, 86, 0.1)',
|
||||||
|
tension: 0.3,
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
spanGaps: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
annotation: {
|
||||||
|
annotations: {}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
labels: {
|
||||||
|
color: tickColor
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
return 'Signal: ' + context.parsed.y + ' dBm';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: { color: tickColor },
|
||||||
|
grid: { color: gridColor }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
reverse: false, // -30 dBm (better) should be higher than -40 dBm (worse)
|
||||||
|
ticks: {
|
||||||
|
color: tickColor,
|
||||||
|
callback: function(value) {
|
||||||
|
return value + ' dBm';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: { color: gridColor }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize AMS Conditions Chart
|
||||||
|
const amsCtx = document.getElementById('amsConditionsChart').getContext('2d');
|
||||||
|
amsConditionsChart = new Chart(amsCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: printerData.timestamps,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Humidity (Raw)',
|
||||||
|
data: printerData.ams_humidity_raw,
|
||||||
|
borderColor: 'rgb(54, 162, 235)',
|
||||||
|
backgroundColor: 'rgba(54, 162, 235, 0.1)',
|
||||||
|
tension: 0.3,
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
yAxisID: 'y',
|
||||||
|
spanGaps: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Temperature',
|
||||||
|
data: printerData.ams_temp,
|
||||||
|
borderColor: 'rgb(255, 99, 132)',
|
||||||
|
backgroundColor: 'rgba(255, 99, 132, 0.1)',
|
||||||
|
tension: 0.3,
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
yAxisID: 'y1',
|
||||||
|
spanGaps: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
annotation: {
|
||||||
|
annotations: {}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
labels: {
|
||||||
|
color: tickColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: { color: tickColor },
|
||||||
|
grid: { color: gridColor }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
type: 'linear',
|
||||||
|
display: true,
|
||||||
|
position: 'left',
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Humidity',
|
||||||
|
color: tickColor
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: 'rgb(54, 162, 235)',
|
||||||
|
callback: function(value) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: { color: gridColor }
|
||||||
|
},
|
||||||
|
y1: {
|
||||||
|
type: 'linear',
|
||||||
|
display: true,
|
||||||
|
position: 'right',
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Temperature (°C)',
|
||||||
|
color: tickColor
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: 'rgb(255, 99, 132)',
|
||||||
|
callback: function(value) {
|
||||||
|
return value + '°C';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
drawOnChartArea: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize Layer Progress Chart
|
||||||
|
const layerCtx = document.getElementById('layerProgressChart').getContext('2d');
|
||||||
|
layerProgressChart = new Chart(layerCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: printerData.timestamps,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Current Layer',
|
||||||
|
data: printerData.layer_num,
|
||||||
|
borderColor: 'rgb(75, 192, 192)',
|
||||||
|
backgroundColor: 'rgba(75, 192, 192, 0.1)',
|
||||||
|
tension: 0.3,
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
fill: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Total Layers',
|
||||||
|
data: printerData.total_layer_num,
|
||||||
|
borderColor: 'rgb(201, 203, 207)',
|
||||||
|
backgroundColor: 'rgba(201, 203, 207, 0.05)',
|
||||||
|
borderDash: [5, 5],
|
||||||
|
tension: 0.3,
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
spanGaps: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
annotation: {
|
||||||
|
annotations: {}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
labels: {
|
||||||
|
color: tickColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: { color: tickColor },
|
||||||
|
grid: { color: gridColor }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
color: tickColor,
|
||||||
|
stepSize: 1
|
||||||
|
},
|
||||||
|
grid: { color: gridColor }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize Filament Timeline Chart
|
||||||
|
const filamentCtx = document.getElementById('filamentTimelineChart').getContext('2d');
|
||||||
|
const filamentDatasets = createFilamentDatasets(printerData.filament_timeline, printerData.timestamps);
|
||||||
|
filamentTimelineChart = new Chart(filamentCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: printerData.timestamps,
|
||||||
|
datasets: filamentDatasets
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
annotation: {
|
||||||
|
annotations: {}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
labels: {
|
||||||
|
color: tickColor,
|
||||||
|
boxWidth: 12,
|
||||||
|
padding: 8
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
const datasetLabel = context.dataset.label || '';
|
||||||
|
const value = context.parsed.y;
|
||||||
|
return datasetLabel + ': ' + value + '% remaining';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: { color: tickColor },
|
||||||
|
grid: { color: gridColor }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
min: -10, // Allow for negative filament readings (e.g., -4%)
|
||||||
|
max: 110, // 10% higher than 100% to make 100% line more visible
|
||||||
|
ticks: {
|
||||||
|
color: tickColor,
|
||||||
|
callback: function(value) {
|
||||||
|
return value + '%';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: { color: gridColor }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up theme observer for dynamic theme switching
|
||||||
|
setupThemeObserver();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTemperatureChartOptions(tickColor, gridColor, unit) {
|
||||||
|
return {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
annotation: {
|
||||||
|
annotations: {}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
labels: {
|
||||||
|
color: tickColor
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
let label = context.dataset.label || '';
|
||||||
|
if (label) {
|
||||||
|
label += ': ';
|
||||||
|
}
|
||||||
|
if (context.parsed.y !== null) {
|
||||||
|
label += context.parsed.y.toFixed(1) + unit;
|
||||||
|
}
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: {
|
||||||
|
color: tickColor
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: gridColor
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
color: tickColor,
|
||||||
|
callback: function(value) {
|
||||||
|
return value + unit;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: gridColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPercentageChartOptions(tickColor, gridColor, label) {
|
||||||
|
return {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
annotation: {
|
||||||
|
annotations: {}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
labels: {
|
||||||
|
color: tickColor
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
return label + ': ' + context.parsed.y + '%';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: {
|
||||||
|
color: tickColor
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: gridColor
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
max: 100,
|
||||||
|
ticks: {
|
||||||
|
color: tickColor,
|
||||||
|
callback: function(value) {
|
||||||
|
return value + '%';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: gridColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFilamentDatasets(filamentTimeline, timestamps) {
|
||||||
|
const datasets = [];
|
||||||
|
const filamentKeys = Object.keys(filamentTimeline);
|
||||||
|
|
||||||
|
// Convert to array for sorting
|
||||||
|
const filamentEntries = filamentKeys.map(key => ({
|
||||||
|
key: key,
|
||||||
|
data: filamentTimeline[key]
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Sort by tray_id (numeric first, External last), then by start_idx (chronological)
|
||||||
|
filamentEntries.sort((a, b) => {
|
||||||
|
const trayA = a.data.tray_id;
|
||||||
|
const trayB = b.data.tray_id;
|
||||||
|
|
||||||
|
// Handle External vs numeric
|
||||||
|
if (trayA === 'External' && trayB !== 'External') return 1;
|
||||||
|
if (trayB === 'External' && trayA !== 'External') return -1;
|
||||||
|
if (trayA === 'External' && trayB === 'External') {
|
||||||
|
return a.data.start_idx - b.data.start_idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both numeric - sort by tray_id first, then by start_idx
|
||||||
|
const trayNumA = parseInt(trayA);
|
||||||
|
const trayNumB = parseInt(trayB);
|
||||||
|
if (trayNumA !== trayNumB) {
|
||||||
|
return trayNumA - trayNumB;
|
||||||
|
}
|
||||||
|
return a.data.start_idx - b.data.start_idx;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create datasets
|
||||||
|
filamentEntries.forEach(entry => {
|
||||||
|
const filament = entry.data;
|
||||||
|
const color = '#' + filament.color.substring(0, 6);
|
||||||
|
|
||||||
|
// Build descriptive label
|
||||||
|
let displayLabel;
|
||||||
|
if (filament.tray_id === 'External') {
|
||||||
|
displayLabel = `External (${filament.type})`;
|
||||||
|
} else {
|
||||||
|
displayLabel = `Tray ${filament.tray_id} (${filament.type})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add brand if it's different from type (avoid redundancy)
|
||||||
|
if (filament.brand && filament.brand !== filament.type && filament.brand !== 'External') {
|
||||||
|
displayLabel += ` - ${filament.brand}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
datasets.push({
|
||||||
|
label: displayLabel,
|
||||||
|
data: filament.remain_data,
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: hexToRgba(color, 0.1),
|
||||||
|
tension: 0.3,
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
spanGaps: false // Don't connect across null values (filament changes)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return datasets;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgba(hex, alpha) {
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16);
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16);
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16);
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilamentColors() {
|
||||||
|
// Apply colors to filament cards
|
||||||
|
document.querySelectorAll('.filament-card').forEach(card => {
|
||||||
|
const colorHex = card.getAttribute('data-filament-color');
|
||||||
|
if (colorHex) {
|
||||||
|
const color = '#' + colorHex;
|
||||||
|
|
||||||
|
// Set card background with gradient
|
||||||
|
card.style.background = `linear-gradient(135deg, ${hexToRgba(color, 0.12)} 0%, ${hexToRgba(color, 0.03)} 100%)`;
|
||||||
|
card.style.borderLeft = `4px solid ${color}`;
|
||||||
|
|
||||||
|
// Set badge color
|
||||||
|
const badge = card.querySelector('.filament-badge');
|
||||||
|
if (badge) {
|
||||||
|
badge.style.backgroundColor = color;
|
||||||
|
badge.style.color = getContrastColor(color);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set progress bar color
|
||||||
|
const progressBar = card.querySelector('.filament-progress');
|
||||||
|
if (progressBar) {
|
||||||
|
progressBar.style.backgroundColor = color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContrastColor(hexColor) {
|
||||||
|
// Convert hex to RGB
|
||||||
|
const r = parseInt(hexColor.slice(1, 3), 16);
|
||||||
|
const g = parseInt(hexColor.slice(3, 5), 16);
|
||||||
|
const b = parseInt(hexColor.slice(5, 7), 16);
|
||||||
|
|
||||||
|
// Calculate luminance
|
||||||
|
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||||
|
|
||||||
|
// Return black or white based on luminance
|
||||||
|
return luminance > 0.5 ? '#000000' : '#ffffff';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChartTheme() {
|
||||||
|
const isDarkMode = document.documentElement.getAttribute('data-coreui-theme') === 'dark';
|
||||||
|
const tickColor = isDarkMode ? 'rgba(255, 255, 255, 0.8)' : 'rgba(0, 0, 0, 0.8)';
|
||||||
|
const gridColor = isDarkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
|
||||||
|
|
||||||
|
// Update all charts
|
||||||
|
const charts = [
|
||||||
|
nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart,
|
||||||
|
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
|
||||||
|
];
|
||||||
|
|
||||||
|
charts.forEach(chart => {
|
||||||
|
if (chart) {
|
||||||
|
// Update legend colors
|
||||||
|
chart.options.plugins.legend.labels.color = tickColor;
|
||||||
|
|
||||||
|
// Update x-axis colors
|
||||||
|
chart.options.scales.x.ticks.color = tickColor;
|
||||||
|
chart.options.scales.x.grid.color = gridColor;
|
||||||
|
|
||||||
|
// Update y-axis colors
|
||||||
|
if (chart.options.scales.y) {
|
||||||
|
chart.options.scales.y.ticks.color = tickColor;
|
||||||
|
chart.options.scales.y.grid.color = gridColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update y1-axis if exists (for dual-axis charts)
|
||||||
|
if (chart.options.scales.y1) {
|
||||||
|
if (chart.options.scales.y1.title) {
|
||||||
|
chart.options.scales.y1.title.color = tickColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chart.update();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupThemeObserver() {
|
||||||
|
// Watch for theme changes
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
mutations.forEach((mutation) => {
|
||||||
|
if (mutation.type === 'attributes' && mutation.attributeName === 'data-coreui-theme') {
|
||||||
|
updateChartTheme();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['data-coreui-theme']
|
||||||
|
});
|
||||||
|
}
|
||||||
418
bambu_run/static/bambu_run/js/printer_charts_control.js
Normal file
418
bambu_run/static/bambu_run/js/printer_charts_control.js
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
// 3D Printer Charts Control - Date/Time Filtering and Project Markers
|
||||||
|
// Handles date range picker, time selection, and chart updates with annotations
|
||||||
|
|
||||||
|
// Global state
|
||||||
|
const printerChartControls = {
|
||||||
|
isFullDay: true,
|
||||||
|
isCustomRange: false,
|
||||||
|
apiUrl: null
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize on page load
|
||||||
|
*/
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const apiUrlElement = document.getElementById('printerApiUrl');
|
||||||
|
if (apiUrlElement) {
|
||||||
|
printerChartControls.apiUrl = apiUrlElement.dataset.url;
|
||||||
|
initializePrinterControls();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize printer chart date/time controls
|
||||||
|
*/
|
||||||
|
function initializePrinterControls() {
|
||||||
|
const startDateInput = document.getElementById('printerStartDate');
|
||||||
|
const endDateInput = document.getElementById('printerEndDate');
|
||||||
|
const startTimeSelect = document.getElementById('printerStartTime');
|
||||||
|
const endTimeSelect = document.getElementById('printerEndTime');
|
||||||
|
const fullDayCheckbox = document.getElementById('printerFullDayCheckbox');
|
||||||
|
const refreshBtn = document.getElementById('refreshPrinterCharts');
|
||||||
|
const resetBtn = document.getElementById('resetPrinterCharts');
|
||||||
|
|
||||||
|
// Set max date to today
|
||||||
|
const today = formatDate(new Date());
|
||||||
|
startDateInput.max = today;
|
||||||
|
endDateInput.max = today;
|
||||||
|
|
||||||
|
// Populate time dropdowns with 30-minute intervals
|
||||||
|
populateTimeDropdowns(startTimeSelect, endTimeSelect);
|
||||||
|
|
||||||
|
// Set default values
|
||||||
|
setDefaultPrinterDateTimeValues();
|
||||||
|
|
||||||
|
// Date input change handling
|
||||||
|
startDateInput.addEventListener('change', handlePrinterDateChange);
|
||||||
|
endDateInput.addEventListener('change', handlePrinterDateChange);
|
||||||
|
|
||||||
|
// Full Day checkbox toggle
|
||||||
|
fullDayCheckbox.addEventListener('change', function() {
|
||||||
|
printerChartControls.isFullDay = this.checked;
|
||||||
|
togglePrinterTimeControls(!this.checked);
|
||||||
|
updatePrinterDateRangeLabel();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh button
|
||||||
|
refreshBtn.addEventListener('click', function() {
|
||||||
|
refreshPrinterChartsData();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset button
|
||||||
|
resetBtn.addEventListener('click', function() {
|
||||||
|
resetPrinterControls();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Populate time dropdowns with 30-minute intervals
|
||||||
|
*/
|
||||||
|
function populateTimeDropdowns(startSelect, endSelect) {
|
||||||
|
const times = [];
|
||||||
|
for (let hour = 0; hour < 24; hour++) {
|
||||||
|
for (let minute = 0; minute < 60; minute += 30) {
|
||||||
|
const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||||
|
times.push(timeStr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
times.forEach(time => {
|
||||||
|
const option1 = new Option(time, time);
|
||||||
|
const option2 = new Option(time, time);
|
||||||
|
startSelect.add(option1);
|
||||||
|
endSelect.add(option2);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle time picker controls
|
||||||
|
*/
|
||||||
|
function togglePrinterTimeControls(enabled) {
|
||||||
|
document.getElementById('printerStartTime').disabled = !enabled;
|
||||||
|
document.getElementById('printerEndTime').disabled = !enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set default date/time values (last 24 hours)
|
||||||
|
*/
|
||||||
|
function setDefaultPrinterDateTimeValues() {
|
||||||
|
const now = new Date();
|
||||||
|
const yesterday = new Date(now);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
|
||||||
|
document.getElementById('printerStartDate').value = formatDate(yesterday);
|
||||||
|
document.getElementById('printerEndDate').value = formatDate(now);
|
||||||
|
document.getElementById('printerStartTime').value = '00:00';
|
||||||
|
document.getElementById('printerEndTime').value = '23:59';
|
||||||
|
|
||||||
|
const fullDayCheckbox = document.getElementById('printerFullDayCheckbox');
|
||||||
|
fullDayCheckbox.checked = true;
|
||||||
|
printerChartControls.isFullDay = true;
|
||||||
|
togglePrinterTimeControls(false);
|
||||||
|
|
||||||
|
document.getElementById('printerDateRange').textContent = '(Last 24 Hours)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle date input changes
|
||||||
|
*/
|
||||||
|
function handlePrinterDateChange() {
|
||||||
|
const startDate = document.getElementById('printerStartDate').value;
|
||||||
|
const endDate = document.getElementById('printerEndDate').value;
|
||||||
|
|
||||||
|
// Ensure end date is not before start date
|
||||||
|
if (startDate && endDate && startDate > endDate) {
|
||||||
|
document.getElementById('printerEndDate').value = startDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
printerChartControls.isCustomRange = true;
|
||||||
|
updatePrinterDateRangeLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the date range label
|
||||||
|
*/
|
||||||
|
function updatePrinterDateRangeLabel() {
|
||||||
|
const startDate = document.getElementById('printerStartDate').value;
|
||||||
|
const endDate = document.getElementById('printerEndDate').value;
|
||||||
|
|
||||||
|
let label = '';
|
||||||
|
if (startDate === endDate) {
|
||||||
|
label = '(' + startDate + ')';
|
||||||
|
} else {
|
||||||
|
label = '(' + startDate + ' to ' + endDate + ')';
|
||||||
|
}
|
||||||
|
document.getElementById('printerDateRange').textContent = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh printer charts data from API
|
||||||
|
*/
|
||||||
|
async function refreshPrinterChartsData() {
|
||||||
|
const startDate = document.getElementById('printerStartDate').value;
|
||||||
|
const endDate = document.getElementById('printerEndDate').value;
|
||||||
|
const isFullDay = printerChartControls.isFullDay;
|
||||||
|
|
||||||
|
const startTime = isFullDay ? '00:00' : document.getElementById('printerStartTime').value;
|
||||||
|
const endTime = isFullDay ? '23:59' : document.getElementById('printerEndTime').value;
|
||||||
|
|
||||||
|
// Show loading state (you can add a spinner here if needed)
|
||||||
|
console.log('Refreshing printer charts...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
start_date: startDate,
|
||||||
|
end_date: endDate,
|
||||||
|
start_time: startTime,
|
||||||
|
end_time: endTime
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(printerChartControls.apiUrl + '?' + params.toString());
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
throw new Error(data.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update all charts with new data and project markers
|
||||||
|
updateAllPrinterCharts(data);
|
||||||
|
updatePrinterDateRangeLabel();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error refreshing printer charts:', error);
|
||||||
|
alert('Error loading chart data: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update all printer charts with new data
|
||||||
|
*/
|
||||||
|
function updateAllPrinterCharts(data) {
|
||||||
|
// Update chart data
|
||||||
|
updateChartData(nozzleTempChart, data.timestamps, [
|
||||||
|
{ data: data.nozzle_temp, datasetIndex: 0 },
|
||||||
|
{ data: data.nozzle_target_temp, datasetIndex: 1 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
updateChartData(bedTempChart, data.timestamps, [
|
||||||
|
{ data: data.bed_temp, datasetIndex: 0 },
|
||||||
|
{ data: data.bed_target_temp, datasetIndex: 1 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
updateChartData(printProgressChart, data.timestamps, [
|
||||||
|
{ data: data.print_percent, datasetIndex: 0 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
updateChartData(fanSpeedsChart, data.timestamps, [
|
||||||
|
{ data: data.cooling_fan_speed, datasetIndex: 0 },
|
||||||
|
{ data: data.heatbreak_fan_speed, datasetIndex: 1 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
updateChartData(wifiSignalChart, data.timestamps, [
|
||||||
|
{ data: data.wifi_signal_dbm, datasetIndex: 0 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
updateChartData(amsConditionsChart, data.timestamps, [
|
||||||
|
{ data: data.ams_humidity_raw, datasetIndex: 0 },
|
||||||
|
{ data: data.ams_temp, datasetIndex: 1 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
updateChartData(layerProgressChart, data.timestamps, [
|
||||||
|
{ data: data.layer_num, datasetIndex: 0 },
|
||||||
|
{ data: data.total_layer_num, datasetIndex: 1 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update filament timeline chart
|
||||||
|
if (data.filament_timeline) {
|
||||||
|
const filamentDatasets = createFilamentDatasets(data.filament_timeline, data.timestamps);
|
||||||
|
filamentTimelineChart.data.labels = data.timestamps;
|
||||||
|
filamentTimelineChart.data.datasets = filamentDatasets;
|
||||||
|
filamentTimelineChart.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add project markers to all charts
|
||||||
|
if (data.project_markers) {
|
||||||
|
addProjectMarkersToCharts(data.project_markers, data.timestamps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to update chart data
|
||||||
|
*/
|
||||||
|
function updateChartData(chart, labels, datasets) {
|
||||||
|
if (!chart) return;
|
||||||
|
|
||||||
|
chart.data.labels = labels;
|
||||||
|
datasets.forEach(({ data, datasetIndex }) => {
|
||||||
|
if (chart.data.datasets[datasetIndex]) {
|
||||||
|
chart.data.datasets[datasetIndex].data = data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
chart.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add project markers (start/end lines) to all charts
|
||||||
|
*/
|
||||||
|
function addProjectMarkersToCharts(markers, timestamps) {
|
||||||
|
console.log('Adding project markers:', markers);
|
||||||
|
|
||||||
|
const charts = [
|
||||||
|
nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart,
|
||||||
|
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
|
||||||
|
];
|
||||||
|
|
||||||
|
charts.forEach(chart => {
|
||||||
|
if (!chart) return;
|
||||||
|
|
||||||
|
// Initialize annotations plugin if not already
|
||||||
|
if (!chart.options.plugins.annotation) {
|
||||||
|
chart.options.plugins.annotation = { annotations: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear existing project markers
|
||||||
|
chart.options.plugins.annotation.annotations = {};
|
||||||
|
|
||||||
|
// Track active tooltip
|
||||||
|
let activeMarkerTooltip = null;
|
||||||
|
|
||||||
|
// Add markers
|
||||||
|
markers.forEach((marker, idx) => {
|
||||||
|
const isStart = marker.type === 'start';
|
||||||
|
const xValue = marker.index; // Use the index directly, not the timestamp string
|
||||||
|
|
||||||
|
const projectName = marker.project_name || 'Unknown';
|
||||||
|
const markerId = `marker_${idx}`;
|
||||||
|
|
||||||
|
chart.options.plugins.annotation.annotations[markerId] = {
|
||||||
|
type: 'line',
|
||||||
|
scaleID: 'x',
|
||||||
|
value: xValue,
|
||||||
|
borderColor: isStart ? 'rgba(34, 197, 94, 0.7)' : 'rgba(239, 68, 68, 0.7)',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderDash: [5, 5],
|
||||||
|
drawTime: 'beforeDatasetsDraw',
|
||||||
|
// Tighter hit detection - only trigger when very close to the line
|
||||||
|
borderDashOffset: 0,
|
||||||
|
display: true,
|
||||||
|
enter: (ctx, event) => {
|
||||||
|
// Verify we're actually hovering over THIS specific annotation line
|
||||||
|
// Check if mouse X position is close to the line's X position
|
||||||
|
if (event && event.native) {
|
||||||
|
const chartArea = chart.chartArea;
|
||||||
|
const xScale = chart.scales.x;
|
||||||
|
const lineXPixel = xScale.getPixelForValue(xValue);
|
||||||
|
const mouseX = event.native.offsetX;
|
||||||
|
|
||||||
|
// Only show tooltip if mouse is within 10 pixels of the line
|
||||||
|
const distance = Math.abs(mouseX - lineXPixel);
|
||||||
|
if (distance > 10) {
|
||||||
|
return; // Too far from this line, don't show tooltip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show tooltip if not already showing from another marker
|
||||||
|
if (activeMarkerTooltip && activeMarkerTooltip !== markerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeMarkerTooltip = markerId;
|
||||||
|
|
||||||
|
const tooltipText = isStart
|
||||||
|
? `Print Start: ${projectName}`
|
||||||
|
: `Print End: ${projectName}`;
|
||||||
|
|
||||||
|
// Change line appearance on hover
|
||||||
|
ctx.element.options.borderWidth = 3;
|
||||||
|
ctx.element.options.borderColor = isStart ? 'rgba(34, 197, 94, 1)' : 'rgba(239, 68, 68, 1)';
|
||||||
|
chart.update('none');
|
||||||
|
|
||||||
|
// Create or update tooltip element
|
||||||
|
let tooltip = document.getElementById('annotation-tooltip');
|
||||||
|
if (!tooltip) {
|
||||||
|
tooltip = document.createElement('div');
|
||||||
|
tooltip.id = 'annotation-tooltip';
|
||||||
|
tooltip.style.position = 'fixed';
|
||||||
|
tooltip.style.backgroundColor = 'rgba(0, 0, 0, 0.85)';
|
||||||
|
tooltip.style.color = 'white';
|
||||||
|
tooltip.style.padding = '6px 10px';
|
||||||
|
tooltip.style.borderRadius = '4px';
|
||||||
|
tooltip.style.fontSize = '13px';
|
||||||
|
tooltip.style.pointerEvents = 'none';
|
||||||
|
tooltip.style.zIndex = '9999';
|
||||||
|
tooltip.style.display = 'none';
|
||||||
|
tooltip.style.whiteSpace = 'nowrap';
|
||||||
|
document.body.appendChild(tooltip);
|
||||||
|
}
|
||||||
|
tooltip.textContent = tooltipText;
|
||||||
|
tooltip.style.display = 'block';
|
||||||
|
tooltip.dataset.markerId = markerId;
|
||||||
|
|
||||||
|
// Position at mouse location
|
||||||
|
if (event && event.native) {
|
||||||
|
tooltip.style.left = (event.native.clientX + 12) + 'px';
|
||||||
|
tooltip.style.top = (event.native.clientY - 10) + 'px';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
leave: (ctx) => {
|
||||||
|
// Only hide if this is the active marker
|
||||||
|
if (activeMarkerTooltip === markerId) {
|
||||||
|
activeMarkerTooltip = null;
|
||||||
|
|
||||||
|
// Restore line appearance
|
||||||
|
ctx.element.options.borderWidth = 2;
|
||||||
|
ctx.element.options.borderColor = isStart ? 'rgba(34, 197, 94, 0.7)' : 'rgba(239, 68, 68, 0.7)';
|
||||||
|
chart.update('none');
|
||||||
|
|
||||||
|
const tooltip = document.getElementById('annotation-tooltip');
|
||||||
|
if (tooltip && tooltip.dataset.markerId === markerId) {
|
||||||
|
tooltip.style.display = 'none';
|
||||||
|
tooltip.dataset.markerId = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
chart.update();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset printer controls to default
|
||||||
|
*/
|
||||||
|
function resetPrinterControls() {
|
||||||
|
setDefaultPrinterDateTimeValues();
|
||||||
|
|
||||||
|
// Clear annotations and reload with original data
|
||||||
|
const charts = [
|
||||||
|
nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart,
|
||||||
|
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
|
||||||
|
];
|
||||||
|
|
||||||
|
charts.forEach(chart => {
|
||||||
|
if (chart && chart.options.plugins.annotation) {
|
||||||
|
chart.options.plugins.annotation.annotations = {};
|
||||||
|
chart.update();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reload page to get default data
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date as YYYY-MM-DD
|
||||||
|
*/
|
||||||
|
function formatDate(date) {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
88
bambu_run/templates/bambu_run/base.html
Normal file
88
bambu_run/templates/bambu_run/base.html
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-coreui-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block title %}Bambu Run{% endblock %}</title>
|
||||||
|
<!-- CoreUI 5.3 CSS CDN -->
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/@coreui/coreui@5.3.0/dist/css/coreui.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/@coreui/icons@3.0.1/css/all.min.css" rel="stylesheet">
|
||||||
|
{% block extra_css %}{% endblock %}
|
||||||
|
<style>
|
||||||
|
.sidebar-brand { padding: 1rem; font-size: 1.25rem; font-weight: 700; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="sidebar sidebar-dark sidebar-fixed" id="sidebar">
|
||||||
|
<div class="sidebar-brand d-none d-md-flex">
|
||||||
|
Bambu Run
|
||||||
|
</div>
|
||||||
|
<ul class="sidebar-nav" data-coreui="navigation">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{% url 'bambu_run:printer_dashboard' %}">
|
||||||
|
<svg class="nav-icon"><use xlink:href="https://cdn.jsdelivr.net/npm/@coreui/icons@3.0.1/sprites/free.svg#cil-print"></use></svg>
|
||||||
|
3D Printer
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{% url 'bambu_run:filament_list' %}">
|
||||||
|
<svg class="nav-icon"><use xlink:href="https://cdn.jsdelivr.net/npm/@coreui/icons@3.0.1/sprites/free.svg#cil-layers"></use></svg>
|
||||||
|
Filament Inventory
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wrapper d-flex flex-column min-vh-100">
|
||||||
|
<header class="header header-sticky p-0 mb-4">
|
||||||
|
<div class="container-fluid px-4">
|
||||||
|
<button class="header-toggler" type="button" onclick="document.getElementById('sidebar').classList.toggle('show')">
|
||||||
|
<svg class="icon icon-lg"><use xlink:href="https://cdn.jsdelivr.net/npm/@coreui/icons@3.0.1/sprites/free.svg#cil-menu"></use></svg>
|
||||||
|
</button>
|
||||||
|
<ul class="header-nav ms-auto">
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link" id="themeToggle" type="button">
|
||||||
|
<svg class="icon icon-lg"><use xlink:href="https://cdn.jsdelivr.net/npm/@coreui/icons@3.0.1/sprites/free.svg#cil-moon"></use></svg>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{% if user.is_authenticated %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{% url 'logout' %}">Logout</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="body flex-grow-1">
|
||||||
|
<div class="container-lg px-4">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="footer px-4">
|
||||||
|
<div>Bambu Run</div>
|
||||||
|
<div class="ms-auto">Powered by <a href="https://github.com/runnanli/Bambu-Run">Bambu Run</a></div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CoreUI 5.3 JS CDN -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@coreui/coreui@5.3.0/dist/js/coreui.bundle.min.js"></script>
|
||||||
|
<script>
|
||||||
|
// Theme toggle
|
||||||
|
const themeToggle = document.getElementById('themeToggle');
|
||||||
|
const savedTheme = localStorage.getItem('bambu-run-theme') || 'dark';
|
||||||
|
document.documentElement.setAttribute('data-coreui-theme', savedTheme);
|
||||||
|
|
||||||
|
if (themeToggle) {
|
||||||
|
themeToggle.addEventListener('click', function() {
|
||||||
|
const current = document.documentElement.getAttribute('data-coreui-theme');
|
||||||
|
const next = current === 'dark' ? 'light' : 'dark';
|
||||||
|
document.documentElement.setAttribute('data-coreui-theme', next);
|
||||||
|
localStorage.setItem('bambu-run-theme', next);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% block extra_js %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{% extends bambu_run_base_template %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col">
|
||||||
|
<h1>Delete Filament Color</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<h5><i class="bi bi-exclamation-triangle"></i> Warning</h5>
|
||||||
|
<p>Are you sure you want to delete this filament color?</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<h5>Color Details:</h5>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-2">
|
||||||
|
<div style="width: 100px; height: 100px; background-color: {{ object.get_hex_color }}; border-radius: 8px; border: 2px solid #ddd;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-10">
|
||||||
|
<p><strong>Color Name:</strong> {{ object.color_name }}</p>
|
||||||
|
<p><strong>Hex Code:</strong> <span class="font-monospace">{{ object.get_hex_color }}</span></p>
|
||||||
|
<p><strong>Type:</strong> {{ object.filament_type }}</p>
|
||||||
|
<p><strong>Sub Type:</strong> {{ object.filament_sub_type|default:"-" }}</p>
|
||||||
|
<p><strong>Brand:</strong> {{ object.brand }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<a href="{% url 'bambu_run:filament_color_list' %}" class="btn btn-secondary">Cancel</a>
|
||||||
|
<button type="submit" class="btn btn-danger">
|
||||||
|
<i class="bi bi-trash"></i> Yes, Delete Color
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
80
bambu_run/templates/bambu_run/filament_color_form.html
Normal file
80
bambu_run/templates/bambu_run/filament_color_form.html
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
{% extends bambu_run_base_template %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col">
|
||||||
|
<h1>{% if form.instance.pk %}Edit{% else %}Add{% endif %} Filament Color</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<h5>Color Information</h5>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Color Name *</label>
|
||||||
|
{{ form.color_name }}
|
||||||
|
<small class="form-text text-muted">e.g., Black, Orange, Signal White</small>
|
||||||
|
{% if form.color_name.errors %}
|
||||||
|
<div class="text-danger">{{ form.color_name.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Color Hex Code *</label>
|
||||||
|
{{ form.color_hex_input }}
|
||||||
|
<small class="form-text text-muted">Format: #RRGGBB (without FF padding)</small>
|
||||||
|
{% if form.color_hex_input.errors %}
|
||||||
|
<div class="text-danger">{{ form.color_hex_input.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<h5>Filament Type (for matching)</h5>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Filament Type</label>
|
||||||
|
{{ form.filament_type_fk }}
|
||||||
|
<small class="form-text text-muted">Select from the filament type registry</small>
|
||||||
|
{% if form.filament_type_fk.errors %}
|
||||||
|
<div class="text-danger">{{ form.filament_type_fk.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden fields for backward compatibility -->
|
||||||
|
{{ form.color_code }}
|
||||||
|
{{ form.filament_type }}
|
||||||
|
{{ form.filament_sub_type }}
|
||||||
|
{{ form.brand }}
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<a href="{% url 'bambu_run:filament_color_list' %}" class="btn btn-secondary">Cancel</a>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
{% if form.instance.pk %}Update Color{% else %}Add Color{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if form.errors %}
|
||||||
|
<div class="alert alert-danger mt-3">
|
||||||
|
<strong>Please correct the following errors:</strong>
|
||||||
|
<ul>
|
||||||
|
{% for field, errors in form.errors.items %}
|
||||||
|
{% for error in errors %}
|
||||||
|
<li>{{ field }}: {{ error }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
109
bambu_run/templates/bambu_run/filament_color_list.html
Normal file
109
bambu_run/templates/bambu_run/filament_color_list.html
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
{% extends bambu_run_base_template %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<h1>Filament Color Database</h1>
|
||||||
|
<p class="text-muted">Manage filament colors for auto-matching</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 text-end">
|
||||||
|
<a href="{% url 'bambu_run:filament_color_create' %}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus-circle"></i> Add New Color
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'bambu_run:filament_list' %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back to Inventory
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary Card -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Summary</h5>
|
||||||
|
<p class="card-text">
|
||||||
|
<strong>Total Colors:</strong> {{ total_colors }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Color List -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="align-middle">Color Preview</th>
|
||||||
|
<th class="align-middle">Color Name</th>
|
||||||
|
<th class="align-middle">Hex Code</th>
|
||||||
|
<th class="align-middle">Type</th>
|
||||||
|
<th class="align-middle">Sub Type</th>
|
||||||
|
<th class="align-middle">Brand</th>
|
||||||
|
<th class="align-middle">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for color in colors %}
|
||||||
|
<tr>
|
||||||
|
<td class="align-middle">
|
||||||
|
<div style="width: 50px; height: 50px; background-color: {{ color.get_hex_color }}; border-radius: 4px; border: 2px solid #ddd;"></div>
|
||||||
|
</td>
|
||||||
|
<td class="align-middle"><strong>{{ color.color_name }}</strong></td>
|
||||||
|
<td class="align-middle">
|
||||||
|
<span class="font-monospace">{{ color.get_hex_color }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="align-middle">
|
||||||
|
<span class="badge bg-secondary">{{ color.filament_type }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="align-middle">
|
||||||
|
{% if color.filament_sub_type %}
|
||||||
|
<span class="badge bg-info">{{ color.filament_sub_type }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="align-middle">{{ color.brand }}</td>
|
||||||
|
<td class="align-middle">
|
||||||
|
<a href="{% url 'bambu_run:filament_color_update' color.pk %}" class="btn btn-sm btn-warning">Edit</a>
|
||||||
|
<a href="{% url 'bambu_run:filament_color_delete' color.pk %}" class="btn btn-sm btn-danger">Delete</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="text-center text-muted">
|
||||||
|
No colors found. <a href="{% url 'bambu_run:filament_color_create' %}">Add your first color!</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if is_paginated %}
|
||||||
|
<nav>
|
||||||
|
<ul class="pagination justify-content-center">
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
<li class="page-item"><a class="page-link" href="?page=1">First</a></li>
|
||||||
|
<li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}">Previous</a></li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<li class="page-item active"><span class="page-link">{{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span></li>
|
||||||
|
|
||||||
|
{% if page_obj.has_next %}
|
||||||
|
<li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}">Next</a></li>
|
||||||
|
<li class="page-item"><a class="page-link" href="?page={{ page_obj.paginator.num_pages }}">Last</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
311
bambu_run/templates/bambu_run/filament_detail.html
Normal file
311
bambu_run/templates/bambu_run/filament_detail.html
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
{% extends bambu_run_base_template %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<link rel="stylesheet" href="{% static 'bambu_run/css/dashboard.css' %}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col">
|
||||||
|
<h1>{{ filament }}</h1>
|
||||||
|
<p class="text-body-secondary">Filament Spool Details</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<a href="{% url 'bambu_run:filament_update' filament.pk %}" class="btn btn-warning">Edit</a>
|
||||||
|
<a href="{% url 'bambu_run:filament_list' %}" class="btn btn-secondary">Back to List</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filament Info Cards -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6>Color</h6>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<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>
|
||||||
|
<strong>{{ filament.color }}</strong><br>
|
||||||
|
<small class="text-muted">{{ filament.color_hex }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6>Specifications</h6>
|
||||||
|
<p class="mb-1"><strong>Type:</strong> {{ filament.type }}</p>
|
||||||
|
{% if filament.sub_type %}
|
||||||
|
<p class="mb-1"><strong>Sub Type:</strong> {{ filament.sub_type }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<p class="mb-1"><strong>Brand:</strong> {{ filament.brand }}</p>
|
||||||
|
<p class="mb-0"><strong>Diameter:</strong> {{ filament.diameter }}mm</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6>Remaining</h6>
|
||||||
|
<div class="progress mb-2" style="height: 25px;">
|
||||||
|
<div class="progress-bar {% if filament.remaining_percent < 20 %}bg-danger{% elif filament.remaining_percent < 50 %}bg-warning{% else %}bg-success{% endif %}"
|
||||||
|
style="width: {{ filament.remaining_percent }}%;">
|
||||||
|
{{ filament.remaining_percent }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small>{{ filament.remaining_weight_grams|default:"?" }}g of {{ filament.initial_weight_grams|default:"?" }}g</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6>Location</h6>
|
||||||
|
{% if filament.is_loaded_in_ams %}
|
||||||
|
<span class="badge bg-success fs-6">AMS Tray {{ filament.current_tray_id }}</span>
|
||||||
|
<p class="mb-0 mt-2"><small>Loaded: {{ filament.last_loaded_date|date:"Y-m-d H:i" }}</small></p>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary fs-6">Storage</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Usage Chart -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<strong>Chart Filters</strong>
|
||||||
|
<span class="text-muted" id="filamentDateRange">(Last 24 Hours)</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<!-- Date Range -->
|
||||||
|
<div class="d-flex align-items-center gap-1">
|
||||||
|
<label class="form-label mb-0 small text-body-secondary">From:</label>
|
||||||
|
<input type="date" class="form-control form-control-sm" id="filamentStartDate" style="width: auto;">
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-1">
|
||||||
|
<label class="form-label mb-0 small text-body-secondary">To:</label>
|
||||||
|
<input type="date" class="form-control form-control-sm" id="filamentEndDate" style="width: auto;">
|
||||||
|
</div>
|
||||||
|
<!-- Full Day Checkbox -->
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="filamentFullDayCheckbox" checked>
|
||||||
|
<label class="form-check-label small" for="filamentFullDayCheckbox">Full Day</label>
|
||||||
|
</div>
|
||||||
|
<!-- Time Range -->
|
||||||
|
<div class="d-flex align-items-center gap-1" id="filamentTimeRangeControls">
|
||||||
|
<label class="form-label mb-0 small text-body-secondary">Time:</label>
|
||||||
|
<select class="form-select form-select-sm" id="filamentStartTime" style="width: auto;" disabled></select>
|
||||||
|
<span class="text-body-secondary">-</span>
|
||||||
|
<select class="form-select form-select-sm" id="filamentEndTime" style="width: auto;" disabled></select>
|
||||||
|
</div>
|
||||||
|
<!-- Buttons -->
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" id="refreshFilamentChart">
|
||||||
|
<svg class="icon"><use xlink:href="https://cdn.jsdelivr.net/npm/@coreui/icons@3.0.1/sprites/free.svg#cil-reload"></use></svg>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" id="resetFilamentChart">
|
||||||
|
<svg class="icon"><use xlink:href="https://cdn.jsdelivr.net/npm/@coreui/icons@3.0.1/sprites/free.svg#cil-action-undo"></use></svg>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="chart-container" style="height: 300px;">
|
||||||
|
<canvas id="usageChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Print Jobs -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>Print Jobs Using This Filament</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if print_usages %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Project</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Tray</th>
|
||||||
|
<th>Consumed</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for usage in print_usages %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ usage.print_job.project_name }}</td>
|
||||||
|
<td>{{ usage.print_job.start_time|date:"Y-m-d H:i" }}</td>
|
||||||
|
<td>Tray {{ usage.tray_id }}</td>
|
||||||
|
<td>{{ usage.consumed_percent|default:"?" }}% ({{ usage.consumed_grams|default:"?" }}g)</td>
|
||||||
|
<td><span class="badge bg-{% if usage.print_job.final_status == 'FINISH' %}success{% else %}danger{% endif %}">{{ usage.print_job.final_status }}</span></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted">No print jobs recorded yet</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Purchase Info -->
|
||||||
|
{% if filament.purchase_date or filament.purchase_price or filament.supplier %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>Purchase Information</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
{% if filament.purchase_date %}
|
||||||
|
<div class="col-md-4">
|
||||||
|
<strong>Purchase Date:</strong> {{ filament.purchase_date|date:"Y-m-d" }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if filament.purchase_price %}
|
||||||
|
<div class="col-md-4">
|
||||||
|
<strong>Price:</strong> ${{ filament.purchase_price }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if filament.supplier %}
|
||||||
|
<div class="col-md-4">
|
||||||
|
<strong>Supplier:</strong> {{ filament.supplier }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if filament.notes %}
|
||||||
|
<hr>
|
||||||
|
<strong>Notes:</strong>
|
||||||
|
<p>{{ filament.notes }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0"></script>
|
||||||
|
<script>
|
||||||
|
const filamentId = {{ filament.pk }};
|
||||||
|
let usageChart = null;
|
||||||
|
|
||||||
|
// Populate time selects
|
||||||
|
const startTimeSelect = document.getElementById('filamentStartTime');
|
||||||
|
const endTimeSelect = document.getElementById('filamentEndTime');
|
||||||
|
for (let h = 0; h < 24; h++) {
|
||||||
|
for (let m = 0; m < 60; m += 30) {
|
||||||
|
const timeStr = `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`;
|
||||||
|
startTimeSelect.add(new Option(timeStr, timeStr));
|
||||||
|
endTimeSelect.add(new Option(timeStr, timeStr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
startTimeSelect.value = '00:00';
|
||||||
|
endTimeSelect.value = '23:30';
|
||||||
|
|
||||||
|
// Initialize date inputs to last 24 hours
|
||||||
|
const now = new Date();
|
||||||
|
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||||
|
document.getElementById('filamentStartDate').value = yesterday.toISOString().split('T')[0];
|
||||||
|
document.getElementById('filamentEndDate').value = now.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Full day checkbox handler
|
||||||
|
document.getElementById('filamentFullDayCheckbox').addEventListener('change', function() {
|
||||||
|
const isFullDay = this.checked;
|
||||||
|
startTimeSelect.disabled = isFullDay;
|
||||||
|
endTimeSelect.disabled = isFullDay;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch and render chart
|
||||||
|
async function fetchFilamentUsageData() {
|
||||||
|
const startDate = document.getElementById('filamentStartDate').value;
|
||||||
|
const endDate = document.getElementById('filamentEndDate').value;
|
||||||
|
const isFullDay = document.getElementById('filamentFullDayCheckbox').checked;
|
||||||
|
const startTime = isFullDay ? '00:00' : startTimeSelect.value;
|
||||||
|
const endTime = isFullDay ? '23:59' : endTimeSelect.value;
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (startDate) params.append('start_date', startDate);
|
||||||
|
if (endDate) params.append('end_date', endDate);
|
||||||
|
if (startTime) params.append('start_time', startTime);
|
||||||
|
if (endTime) params.append('end_time', endTime);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`{% url 'bambu_run:filament_usage_api' filament.pk %}?${params.toString()}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Update date range display
|
||||||
|
const dateRangeSpan = document.getElementById('filamentDateRange');
|
||||||
|
if (startDate && endDate) {
|
||||||
|
dateRangeSpan.textContent = `(${startDate} to ${endDate})`;
|
||||||
|
} else {
|
||||||
|
dateRangeSpan.textContent = '(Last 24 Hours)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update chart
|
||||||
|
if (usageChart) {
|
||||||
|
usageChart.data.labels = data.timestamps;
|
||||||
|
usageChart.data.datasets[0].data = data.remaining;
|
||||||
|
usageChart.update();
|
||||||
|
} else {
|
||||||
|
const ctx = document.getElementById('usageChart').getContext('2d');
|
||||||
|
usageChart = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: data.timestamps,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Remaining %',
|
||||||
|
data: data.remaining,
|
||||||
|
borderColor: 'rgb(75, 192, 192)',
|
||||||
|
backgroundColor: 'rgba(75, 192, 192, 0.1)',
|
||||||
|
tension: 0.3,
|
||||||
|
fill: true
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
max: 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching filament usage data:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
document.getElementById('refreshFilamentChart').addEventListener('click', fetchFilamentUsageData);
|
||||||
|
document.getElementById('resetFilamentChart').addEventListener('click', function() {
|
||||||
|
const now = new Date();
|
||||||
|
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||||
|
document.getElementById('filamentStartDate').value = yesterday.toISOString().split('T')[0];
|
||||||
|
document.getElementById('filamentEndDate').value = now.toISOString().split('T')[0];
|
||||||
|
document.getElementById('filamentFullDayCheckbox').checked = true;
|
||||||
|
startTimeSelect.disabled = true;
|
||||||
|
endTimeSelect.disabled = true;
|
||||||
|
fetchFilamentUsageData();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
fetchFilamentUsageData();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
303
bambu_run/templates/bambu_run/filament_form.html
Normal file
303
bambu_run/templates/bambu_run/filament_form.html
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
{% extends bambu_run_base_template %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col">
|
||||||
|
<h1>{% if form.instance.pk %}Edit{% else %}Add{% endif %} Filament Spool</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<h5>Identification</h5>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Spool Serial Number (SN)</label>
|
||||||
|
{{ form.tray_uuid }}
|
||||||
|
<small class="form-text text-muted">Auto-filled from MQTT tray_uuid</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">RFID Chip ID (tag_uid)</label>
|
||||||
|
{{ form.tag_uid }}
|
||||||
|
<small class="form-text text-muted">Auto-filled from MQTT RFID</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Custom Tag ID (Optional)</label>
|
||||||
|
{{ form.tag_id }}
|
||||||
|
<small class="form-text text-muted">User-defined barcode/label</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Created By</label>
|
||||||
|
{{ form.created_by }}
|
||||||
|
<small class="form-text text-muted">How this filament was added</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<h5>Specifications</h5>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Type *</label>
|
||||||
|
{{ form.type }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Sub Type</label>
|
||||||
|
{{ form.sub_type }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Brand *</label>
|
||||||
|
{{ form.brand }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Color *</label>
|
||||||
|
{{ form.color }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Color Picker</label>
|
||||||
|
{{ form.color_hex }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">{{ form.color_hex_text.label }}</label>
|
||||||
|
{{ form.color_hex_text }}
|
||||||
|
<small class="form-text text-muted">e.g. #0A2CA5</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Diameter (mm)</label>
|
||||||
|
{{ form.diameter }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Initial Weight (g)</label>
|
||||||
|
{{ form.initial_weight_grams }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<h5>Current Status</h5>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Remaining %</label>
|
||||||
|
{{ form.remaining_percent }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Remaining Weight (g)</label>
|
||||||
|
{{ form.remaining_weight_grams }}
|
||||||
|
<small class="form-text text-muted">Auto-calculated</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-check">
|
||||||
|
{{ form.is_loaded_in_ams }}
|
||||||
|
<label class="form-check-label">Loaded in AMS</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">AMS Tray ID (0-3)</label>
|
||||||
|
{{ form.current_tray_id }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<h5>Purchase Info (Optional)</h5>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Purchase Date</label>
|
||||||
|
{{ form.purchase_date }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Price</label>
|
||||||
|
{{ form.purchase_price }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Supplier</label>
|
||||||
|
{{ form.supplier }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Notes</label>
|
||||||
|
{{ form.notes }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if form.errors %}
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<strong>Please correct the following errors:</strong>
|
||||||
|
{{ form.errors }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<a href="{% url 'bambu_run:filament_list' %}" class="btn btn-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
{% if form.instance.pk %}
|
||||||
|
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#deleteModal" id="deleteBtn">
|
||||||
|
<i class="bi bi-trash-fill me-1"></i>Delete
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if form.instance.pk %}
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-danger text-white">
|
||||||
|
<h5 class="modal-title" id="deleteModalLabel">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>Delete Filament Spool
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{% url 'bambu_run:filament_delete' form.instance.pk %}" id="deleteForm">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="alert alert-danger mb-3" role="alert">
|
||||||
|
<strong>Warning: This action cannot be undone!</strong>
|
||||||
|
</div>
|
||||||
|
<p>You are about to permanently delete:</p>
|
||||||
|
<div class="card bg-light mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<strong>{{ form.instance }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p>This will remove:</p>
|
||||||
|
<ul>
|
||||||
|
<li>This filament spool record</li>
|
||||||
|
<li>All associated usage history</li>
|
||||||
|
<li>All filament snapshots</li>
|
||||||
|
</ul>
|
||||||
|
<hr>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="deleteConfirmText" class="form-label">
|
||||||
|
To confirm deletion, type <strong class="text-danger">DELETE</strong> in the box below:
|
||||||
|
</label>
|
||||||
|
<input type="text" id="deleteConfirmText" class="form-control form-control-lg" placeholder="Type DELETE to confirm" autocomplete="off">
|
||||||
|
<div class="form-text">Must be in capital letters</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" id="confirmDeleteBtn" class="btn btn-danger" disabled>
|
||||||
|
<i class="bi bi-trash-fill me-1"></i>Confirm Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
// Sync color picker and text input
|
||||||
|
const colorPicker = document.getElementById('id_color_hex_picker');
|
||||||
|
const colorText = document.getElementById('id_color_hex_text');
|
||||||
|
|
||||||
|
if (colorPicker && colorText) {
|
||||||
|
colorPicker.addEventListener('input', function() {
|
||||||
|
colorText.value = this.value.toUpperCase();
|
||||||
|
});
|
||||||
|
|
||||||
|
colorText.addEventListener('input', function() {
|
||||||
|
const value = this.value.trim();
|
||||||
|
if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
|
||||||
|
colorPicker.value = value;
|
||||||
|
this.classList.remove('is-invalid');
|
||||||
|
} else if (value.length === 7) {
|
||||||
|
this.classList.add('is-invalid');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (colorText.value && /^#[0-9A-Fa-f]{6}$/.test(colorText.value)) {
|
||||||
|
colorPicker.value = colorText.value;
|
||||||
|
} else if (colorPicker.value && !colorText.value) {
|
||||||
|
colorText.value = colorPicker.value.toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete confirmation logic
|
||||||
|
const deleteConfirmText = document.getElementById('deleteConfirmText');
|
||||||
|
const confirmDeleteBtn = document.getElementById('confirmDeleteBtn');
|
||||||
|
const deleteForm = document.getElementById('deleteForm');
|
||||||
|
const deleteModal = document.getElementById('deleteModal');
|
||||||
|
|
||||||
|
if (deleteConfirmText && confirmDeleteBtn) {
|
||||||
|
deleteConfirmText.addEventListener('input', function() {
|
||||||
|
const value = this.value.trim();
|
||||||
|
if (value === 'DELETE') {
|
||||||
|
confirmDeleteBtn.disabled = false;
|
||||||
|
this.classList.remove('is-invalid');
|
||||||
|
this.classList.add('is-valid');
|
||||||
|
} else {
|
||||||
|
confirmDeleteBtn.disabled = true;
|
||||||
|
this.classList.remove('is-valid');
|
||||||
|
if (value.length > 0) {
|
||||||
|
this.classList.add('is-invalid');
|
||||||
|
} else {
|
||||||
|
this.classList.remove('is-invalid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (deleteForm) {
|
||||||
|
deleteForm.addEventListener('submit', function(e) {
|
||||||
|
if (confirmDeleteBtn.disabled) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert('Please type DELETE to confirm deletion');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteModal) {
|
||||||
|
deleteModal.addEventListener('hidden.bs.modal', function() {
|
||||||
|
deleteConfirmText.value = '';
|
||||||
|
confirmDeleteBtn.disabled = true;
|
||||||
|
deleteConfirmText.classList.remove('is-valid', 'is-invalid');
|
||||||
|
});
|
||||||
|
|
||||||
|
deleteModal.addEventListener('shown.bs.modal', function() {
|
||||||
|
deleteConfirmText.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup modal opener
|
||||||
|
const deleteBtn = document.getElementById('deleteBtn');
|
||||||
|
if (deleteBtn && deleteModal) {
|
||||||
|
deleteBtn.addEventListener('click', function() {
|
||||||
|
if (!deleteModal.classList.contains('show')) {
|
||||||
|
if (typeof bootstrap !== 'undefined') {
|
||||||
|
const modalInstance = bootstrap.Modal.getOrCreateInstance(deleteModal);
|
||||||
|
modalInstance.show();
|
||||||
|
} else if (typeof coreui !== 'undefined' && coreui.Modal) {
|
||||||
|
const modalInstance = coreui.Modal.getOrCreateInstance(deleteModal);
|
||||||
|
modalInstance.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
206
bambu_run/templates/bambu_run/filament_list.html
Normal file
206
bambu_run/templates/bambu_run/filament_list.html
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
{% extends bambu_run_base_template %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<link rel="stylesheet" href="{% static 'bambu_run/css/dashboard.css' %}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col">
|
||||||
|
<h1>Filament Inventory</h1>
|
||||||
|
<p class="text-body-secondary">Manage your 3D printer filament spools</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<a href="{% url 'bambu_run:filament_type_list' %}" class="btn btn-outline-info me-2">
|
||||||
|
<i class="bi bi-list-ul"></i> Manage Types
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'bambu_run:filament_color_list' %}" class="btn btn-outline-info me-2">
|
||||||
|
<i class="bi bi-palette"></i> Manage Colors
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'bambu_run:filament_create' %}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus-circle"></i> Add Filament
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary Cards -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card infra-card-info">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="stat-label">Total Spools</div>
|
||||||
|
<div class="stat-value">{{ total_spools }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card infra-card-success">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="stat-label">Loaded in AMS</div>
|
||||||
|
<div class="stat-value">{{ loaded_spools }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card infra-card-warning">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="stat-label">Low Filament (<20%)</div>
|
||||||
|
<div class="stat-value">{{ low_filaments }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="get" class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<input type="text" name="search" class="form-control" placeholder="Search..." value="{{ request.GET.search }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<select name="type" class="form-select">
|
||||||
|
<option value="">All Types</option>
|
||||||
|
{% for type in filament_types %}
|
||||||
|
<option value="{{ type }}" {% if request.GET.type == type %}selected{% endif %}>{{ type }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<select name="loaded" class="form-select">
|
||||||
|
<option value="">All Spools</option>
|
||||||
|
<option value="yes" {% if request.GET.loaded == 'yes' %}selected{% endif %}>Loaded in AMS</option>
|
||||||
|
<option value="no" {% if request.GET.loaded == 'no' %}selected{% endif %}>Not Loaded</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<button type="submit" class="btn btn-secondary">Filter</button>
|
||||||
|
<a href="{% url 'bambu_run:filament_list' %}" class="btn btn-outline-secondary">Reset</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filament List -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="align-middle">SN</th>
|
||||||
|
<th class="align-middle">Color</th>
|
||||||
|
<th class="align-middle">Brand</th>
|
||||||
|
<th class="align-middle">Type</th>
|
||||||
|
<th class="align-middle">Sub Type</th>
|
||||||
|
<th class="align-middle">Remaining</th>
|
||||||
|
<th class="align-middle">Location</th>
|
||||||
|
<th class="align-middle">Created By</th>
|
||||||
|
<th class="align-middle">Last Used</th>
|
||||||
|
<th class="align-middle">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for filament in filaments %}
|
||||||
|
<tr>
|
||||||
|
<td class="align-middle">
|
||||||
|
{% if filament.tray_uuid %}
|
||||||
|
<span class="font-monospace small"
|
||||||
|
data-bs-toggle="tooltip"
|
||||||
|
data-bs-placement="top"
|
||||||
|
title="{{ filament.tray_uuid }}"
|
||||||
|
style="cursor: help;">
|
||||||
|
{{ filament.tray_uuid|slice:":8" }}...
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="align-middle">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div style="width: 30px; height: 30px; background-color: {{ filament.color_hex|default:'#999' }}; border-radius: 4px; margin-right: 10px; border: 1px solid #ddd;"></div>
|
||||||
|
{{ filament.color }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="align-middle">{{ filament.brand }}</td>
|
||||||
|
<td class="align-middle"><span class="badge bg-secondary">{{ filament.type }}</span></td>
|
||||||
|
<td class="align-middle">
|
||||||
|
{% if filament.sub_type %}
|
||||||
|
<span class="badge bg-info">{{ filament.sub_type }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="align-middle">
|
||||||
|
<div class="progress" style="height: 20px;">
|
||||||
|
<div class="progress-bar {% if filament.remaining_percent < 20 %}bg-danger{% elif filament.remaining_percent < 50 %}bg-warning{% else %}bg-success{% endif %}"
|
||||||
|
style="width: {{ filament.remaining_percent }}%;">
|
||||||
|
{{ filament.remaining_percent }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="align-middle">
|
||||||
|
{% if filament.is_loaded_in_ams %}
|
||||||
|
<span class="badge bg-success">AMS Tray {{ filament.current_tray_id }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">Storage</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="align-middle">
|
||||||
|
{% if filament.created_by == 'Auto Detection' %}
|
||||||
|
<span class="badge bg-primary">Auto</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">Manual</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="align-middle">{{ filament.last_used|date:"Y-m-d H:i"|default:"Never" }}</td>
|
||||||
|
<td class="align-middle">
|
||||||
|
<a href="{% url 'bambu_run:filament_detail' filament.pk %}" class="btn btn-sm btn-info">View</a>
|
||||||
|
<a href="{% url 'bambu_run:filament_update' filament.pk %}" class="btn btn-sm btn-warning">Edit</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="10" class="text-center text-muted">No filaments found. <a href="{% url 'bambu_run:filament_create' %}">Add your first spool!</a></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if is_paginated %}
|
||||||
|
<nav>
|
||||||
|
<ul class="pagination justify-content-center">
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
<li class="page-item"><a class="page-link" href="?page=1">First</a></li>
|
||||||
|
<li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}">Previous</a></li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<li class="page-item active"><span class="page-link">{{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span></li>
|
||||||
|
|
||||||
|
{% if page_obj.has_next %}
|
||||||
|
<li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}">Next</a></li>
|
||||||
|
<li class="page-item"><a class="page-link" href="?page={{ page_obj.paginator.num_pages }}">Last</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
// Enable Bootstrap tooltips for SN hover
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
||||||
|
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||||
|
return new bootstrap.Tooltip(tooltipTriggerEl);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{% extends bambu_run_base_template %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col">
|
||||||
|
<h1>Delete Filament Type</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<h5><i class="bi bi-exclamation-triangle"></i> Warning</h5>
|
||||||
|
<p>Are you sure you want to delete this filament type?</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<h5>Type Details:</h5>
|
||||||
|
<p><strong>Type:</strong> {{ object.type }}</p>
|
||||||
|
<p><strong>Sub Type:</strong> {{ object.sub_type|default:"-" }}</p>
|
||||||
|
<p><strong>Brand:</strong> {{ object.brand }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<a href="{% url 'bambu_run:filament_type_list' %}" class="btn btn-secondary">Cancel</a>
|
||||||
|
<button type="submit" class="btn btn-danger">
|
||||||
|
<i class="bi bi-trash"></i> Yes, Delete Type
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
92
bambu_run/templates/bambu_run/filament_type_form.html
Normal file
92
bambu_run/templates/bambu_run/filament_type_form.html
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
{% extends bambu_run_base_template %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col">
|
||||||
|
<h1>{% if form.instance.pk %}Edit{% else %}Add{% endif %} Filament Type</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Type *</label>
|
||||||
|
<div class="input-group">
|
||||||
|
{{ form.type }}
|
||||||
|
<button class="btn btn-outline-secondary dropdown-toggle" type="button"
|
||||||
|
data-coreui-toggle="dropdown" aria-expanded="false"></button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end" id="type-dropdown"></ul>
|
||||||
|
</div>
|
||||||
|
<small class="form-text text-muted">Base material: PLA, PETG, ABS, etc.</small>
|
||||||
|
{% if form.type.errors %}
|
||||||
|
<div class="text-danger">{{ form.type.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Sub Type</label>
|
||||||
|
<div class="input-group">
|
||||||
|
{{ form.sub_type }}
|
||||||
|
<button class="btn btn-outline-secondary dropdown-toggle" type="button"
|
||||||
|
data-coreui-toggle="dropdown" aria-expanded="false"></button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end" id="sub-type-dropdown"></ul>
|
||||||
|
</div>
|
||||||
|
<small class="form-text text-muted">Optional: PLA Basic, PLA Matte, etc.</small>
|
||||||
|
{% if form.sub_type.errors %}
|
||||||
|
<div class="text-danger">{{ form.sub_type.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Brand *</label>
|
||||||
|
<div class="input-group">
|
||||||
|
{{ form.brand }}
|
||||||
|
<button class="btn btn-outline-secondary dropdown-toggle" type="button"
|
||||||
|
data-coreui-toggle="dropdown" aria-expanded="false"></button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end" id="brand-dropdown"></ul>
|
||||||
|
</div>
|
||||||
|
{% if form.brand.errors %}
|
||||||
|
<div class="text-danger">{{ form.brand.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<a href="{% url 'bambu_run:filament_type_list' %}" class="btn btn-secondary">Cancel</a>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
{% if form.instance.pk %}Update Type{% else %}Add Type{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if form.errors %}
|
||||||
|
<div class="alert alert-danger mt-3">
|
||||||
|
<strong>Please correct the following errors:</strong>
|
||||||
|
<ul>
|
||||||
|
{% for field, errors in form.errors.items %}
|
||||||
|
{% for error in errors %}
|
||||||
|
<li>{{ field }}: {{ error }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
{{ existing_types|json_script:"existing-types" }}
|
||||||
|
{{ existing_sub_types|json_script:"existing-sub-types" }}
|
||||||
|
{{ existing_brands|json_script:"existing-brands" }}
|
||||||
|
{{ preset_types|json_script:"preset-types" }}
|
||||||
|
{{ preset_sub_types|json_script:"preset-sub-types" }}
|
||||||
|
{{ preset_brands|json_script:"preset-brands" }}
|
||||||
|
<script src="{% static 'bambu_run/js/filament_type_form.js' %}"></script>
|
||||||
|
{% endblock %}
|
||||||
99
bambu_run/templates/bambu_run/filament_type_list.html
Normal file
99
bambu_run/templates/bambu_run/filament_type_list.html
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
{% extends bambu_run_base_template %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<h1>Filament Type Registry</h1>
|
||||||
|
<p class="text-muted">Manage filament types (material, sub-type, brand)</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 text-end">
|
||||||
|
<a href="{% url 'bambu_run:filament_type_create' %}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus-circle"></i> Add New Type
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'bambu_run:filament_list' %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back to Inventory
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary Card -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Summary</h5>
|
||||||
|
<p class="card-text">
|
||||||
|
<strong>Total Types:</strong> {{ total_types }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Type List -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="align-middle">Type</th>
|
||||||
|
<th class="align-middle">Sub Type</th>
|
||||||
|
<th class="align-middle">Brand</th>
|
||||||
|
<th class="align-middle">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for ft in types %}
|
||||||
|
<tr>
|
||||||
|
<td class="align-middle">
|
||||||
|
<span class="badge bg-secondary">{{ ft.type }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="align-middle">
|
||||||
|
{% if ft.sub_type %}
|
||||||
|
<span class="badge bg-info">{{ ft.sub_type }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="align-middle">{{ ft.brand }}</td>
|
||||||
|
<td class="align-middle">
|
||||||
|
<a href="{% url 'bambu_run:filament_type_update' ft.pk %}" class="btn btn-sm btn-warning">Edit</a>
|
||||||
|
<a href="{% url 'bambu_run:filament_type_delete' ft.pk %}" class="btn btn-sm btn-danger">Delete</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="text-center text-muted">
|
||||||
|
No filament types found. <a href="{% url 'bambu_run:filament_type_create' %}">Add your first type!</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if is_paginated %}
|
||||||
|
<nav>
|
||||||
|
<ul class="pagination justify-content-center">
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
<li class="page-item"><a class="page-link" href="?page=1">First</a></li>
|
||||||
|
<li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}">Previous</a></li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<li class="page-item active"><span class="page-link">{{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span></li>
|
||||||
|
|
||||||
|
{% if page_obj.has_next %}
|
||||||
|
<li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}">Next</a></li>
|
||||||
|
<li class="page-item"><a class="page-link" href="?page={{ page_obj.paginator.num_pages }}">Last</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
390
bambu_run/templates/bambu_run/printer_dashboard.html
Normal file
390
bambu_run/templates/bambu_run/printer_dashboard.html
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
{% extends bambu_run_base_template %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<link rel="stylesheet" href="{% static 'bambu_run/css/dashboard.css' %}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col">
|
||||||
|
<h1>3D Printer Dashboard</h1>
|
||||||
|
<p class="text-body-secondary">
|
||||||
|
Real-time monitoring for {{ device_name }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger">{{ error }}</div>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<!-- Summary Cards Row -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<!-- Nozzle Temperature Card -->
|
||||||
|
<div class="col-12 col-md-6 col-lg-3">
|
||||||
|
<div class="card infra-card-warning">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<div class="stat-label">Nozzle Temp</div>
|
||||||
|
<div class="stat-value">{{ stats.nozzle_temp|floatformat:1 }}°C</div>
|
||||||
|
</div>
|
||||||
|
<i class="bi bi-thermometer-high" style="font-size: 2rem; opacity: 0.3;"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bed Temperature Card -->
|
||||||
|
<div class="col-12 col-md-6 col-lg-3">
|
||||||
|
<div class="card infra-card-danger">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<div class="stat-label">Bed Temp</div>
|
||||||
|
<div class="stat-value">{{ stats.bed_temp|floatformat:1 }}°C</div>
|
||||||
|
</div>
|
||||||
|
<i class="bi bi-thermometer-half" style="font-size: 2rem; opacity: 0.3;"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Print Progress Card -->
|
||||||
|
<div class="col-12 col-md-6 col-lg-3">
|
||||||
|
<div class="card infra-card-info">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<div class="stat-label">Print Progress</div>
|
||||||
|
<div class="stat-value">{{ stats.print_percent }}%</div>
|
||||||
|
</div>
|
||||||
|
<i class="bi bi-pie-chart-fill" style="font-size: 2rem; opacity: 0.3;"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chamber Light Card -->
|
||||||
|
<div class="col-12 col-md-6 col-lg-3">
|
||||||
|
<div class="card {% if stats.chamber_light == 'on' %}infra-card-success{% else %}infra-card-secondary{% endif %}">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<div class="stat-label">Chamber Light</div>
|
||||||
|
<div class="stat-value">{{ stats.chamber_light|upper }}</div>
|
||||||
|
</div>
|
||||||
|
<i class="bi bi-lightbulb-fill" style="font-size: 2rem; opacity: 0.3;"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Current Print Job Info -->
|
||||||
|
{% if stats.subtask_name and stats.subtask_name != 'No active print' %}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>Current Print Job</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<strong>Job Name:</strong> {{ stats.subtask_name }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<strong>State:</strong> {{ stats.gcode_state }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<strong>Progress:</strong> {{ stats.print_percent }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- AMS Status Section -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>AMS Status</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<strong>Temperature:</strong>
|
||||||
|
{% if stats.ams_temp %}
|
||||||
|
{{ stats.ams_temp|floatformat:1 }}°C
|
||||||
|
{% else %}
|
||||||
|
N/A
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<strong>Humidity:</strong>
|
||||||
|
{% if stats.ams_humidity %}
|
||||||
|
{{ stats.ams_humidity }}%
|
||||||
|
{% else %}
|
||||||
|
N/A
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filaments Section -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>Filaments</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if stats.filaments %}
|
||||||
|
<div class="row g-3">
|
||||||
|
{% for filament in stats.filaments %}
|
||||||
|
<div class="col-12 col-md-6 col-lg-3">
|
||||||
|
<div class="card filament-card" data-filament-color="{{ filament.color|slice:':6' }}">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<h6 class="mb-0">Tray {{ filament.tray_id }}</h6>
|
||||||
|
{% if filament.filament_pk %}
|
||||||
|
<a href="{% url 'bambu_run:filament_detail' filament.filament_pk %}" class="text-decoration-none" title="View in inventory">
|
||||||
|
<svg class="icon icon-sm text-body-secondary"><use xlink:href="https://cdn.jsdelivr.net/npm/@coreui/icons@3.0.1/sprites/free.svg#cil-external-link"></use></svg>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<p class="mb-1 small"><strong>{{ filament.type }}</strong> - {{ filament.brand }}</p>
|
||||||
|
{% if filament.color_name %}<p class="mb-1 small text-body-secondary">{{ filament.color_name }}</p>{% endif %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<span class="small">Remaining</span>
|
||||||
|
<span class="badge filament-badge">{{ filament.remain_percent }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress" style="height: 10px; background-color: rgba(0,0,0,0.1);">
|
||||||
|
<div class="progress-bar filament-progress" role="progressbar" style="width: {{ filament.remain_percent }}%;" aria-valuenow="{{ filament.remain_percent }}" aria-valuemin="0" aria-valuemax="100"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if stats.external_spool.type %}
|
||||||
|
<div class="col-12 col-md-6 col-lg-3">
|
||||||
|
<div class="card filament-card" data-filament-color="{{ stats.external_spool.color|slice:':6' }}">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="mb-2">External Spool</h6>
|
||||||
|
<p class="mb-1 small"><strong>{{ stats.external_spool.type }}</strong> - External</p>
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<span class="small">Remaining</span>
|
||||||
|
<span class="badge filament-badge">{{ stats.external_spool.remain }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress" style="height: 10px; background-color: rgba(0,0,0,0.1);">
|
||||||
|
<div class="progress-bar filament-progress" role="progressbar" style="width: {{ stats.external_spool.remain }}%;" aria-valuenow="{{ stats.external_spool.remain }}" aria-valuemin="0" aria-valuemax="100"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-body-secondary">No filament data available</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date/Time Filter Controls -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<strong>Chart Filters</strong>
|
||||||
|
<span class="text-muted" id="printerDateRange">(Last 24 Hours)</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<!-- Date Range -->
|
||||||
|
<div class="d-flex align-items-center gap-1">
|
||||||
|
<label class="form-label mb-0 small text-body-secondary">From:</label>
|
||||||
|
<input type="date" class="form-control form-control-sm" id="printerStartDate" style="width: auto;">
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-1">
|
||||||
|
<label class="form-label mb-0 small text-body-secondary">To:</label>
|
||||||
|
<input type="date" class="form-control form-control-sm" id="printerEndDate" style="width: auto;">
|
||||||
|
</div>
|
||||||
|
<!-- Full Day Checkbox -->
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="printerFullDayCheckbox" checked>
|
||||||
|
<label class="form-check-label small" for="printerFullDayCheckbox">Full Day</label>
|
||||||
|
</div>
|
||||||
|
<!-- Time Range -->
|
||||||
|
<div class="d-flex align-items-center gap-1" id="printerTimeRangeControls">
|
||||||
|
<label class="form-label mb-0 small text-body-secondary">Time:</label>
|
||||||
|
<select class="form-select form-select-sm" id="printerStartTime" style="width: auto;" disabled></select>
|
||||||
|
<span class="text-body-secondary">-</span>
|
||||||
|
<select class="form-select form-select-sm" id="printerEndTime" style="width: auto;" disabled></select>
|
||||||
|
</div>
|
||||||
|
<!-- Buttons -->
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" id="refreshPrinterCharts">
|
||||||
|
<svg class="icon"><use xlink:href="https://cdn.jsdelivr.net/npm/@coreui/icons@3.0.1/sprites/free.svg#cil-reload"></use></svg>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" id="resetPrinterCharts">
|
||||||
|
<svg class="icon"><use xlink:href="https://cdn.jsdelivr.net/npm/@coreui/icons@3.0.1/sprites/free.svg#cil-action-undo"></use></svg>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filament Timeline Chart - Full Width -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">Filament Remaining Timeline</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="filamentTimelineChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts Section -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<!-- Nozzle Temperature Chart -->
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">Nozzle Temperature</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="nozzleTempChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bed Temperature Chart -->
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">Bed Temperature</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="bedTempChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<!-- Print Progress Chart -->
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">Print Progress</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="printProgressChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fan Speeds Chart -->
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">Fan Speeds</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="fanSpeedsChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<!-- WiFi Signal Chart -->
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">WiFi Signal Strength</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="wifiSignalChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AMS Conditions Chart -->
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">AMS Conditions</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="amsConditionsChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<!-- Layer Progress Chart -->
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">Layer Progress</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="layerProgressChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<p class="text-body-secondary text-end">
|
||||||
|
Last updated: {{ stats.timestamp }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3.0.1"></script>
|
||||||
|
<script src="{% static 'bambu_run/js/printer_charts.js' %}"></script>
|
||||||
|
<script src="{% static 'bambu_run/js/printer_charts_control.js' %}"></script>
|
||||||
|
<div id="printerApiUrl" data-url="{% url 'bambu_run:printer_api' %}" style="display: none;"></div>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const printerData = {{ printer_data_json|safe }};
|
||||||
|
const apiUrl = '{% url "bambu_run:printer_api" %}';
|
||||||
|
initPrinterCharts(printerData, apiUrl);
|
||||||
|
|
||||||
|
// Add project markers if they exist
|
||||||
|
if (printerData.project_markers && printerData.project_markers.length > 0) {
|
||||||
|
setTimeout(function() {
|
||||||
|
addProjectMarkersToCharts(printerData.project_markers, printerData.timestamps);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
29
bambu_run/urls.py
Normal file
29
bambu_run/urls.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
app_name = "bambu_run"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", views.PrinterDashboardView.as_view(), name="printer_dashboard"),
|
||||||
|
path("api/printer/", views.PrinterDataAPIView.as_view(), name="printer_api"),
|
||||||
|
|
||||||
|
# Filament Inventory routes
|
||||||
|
path("filaments/", views.FilamentListView.as_view(), name="filament_list"),
|
||||||
|
path("api/filaments/<int:pk>/usage/", views.FilamentUsageDataAPIView.as_view(), name="filament_usage_api"),
|
||||||
|
path("filaments/add/", views.FilamentCreateView.as_view(), name="filament_create"),
|
||||||
|
path("filaments/<int:pk>/", views.FilamentDetailView.as_view(), name="filament_detail"),
|
||||||
|
path("filaments/<int:pk>/edit/", views.FilamentUpdateView.as_view(), name="filament_update"),
|
||||||
|
path("filaments/<int:pk>/delete/", views.FilamentDeleteView.as_view(), name="filament_delete"),
|
||||||
|
|
||||||
|
# FilamentColor management routes
|
||||||
|
path("filament-colors/", views.FilamentColorListView.as_view(), name="filament_color_list"),
|
||||||
|
path("filament-colors/add/", views.FilamentColorCreateView.as_view(), name="filament_color_create"),
|
||||||
|
path("filament-colors/<int:pk>/edit/", views.FilamentColorUpdateView.as_view(), name="filament_color_update"),
|
||||||
|
path("filament-colors/<int:pk>/delete/", views.FilamentColorDeleteView.as_view(), name="filament_color_delete"),
|
||||||
|
|
||||||
|
# FilamentType management routes
|
||||||
|
path("filament-types/", views.FilamentTypeListView.as_view(), name="filament_type_list"),
|
||||||
|
path("filament-types/add/", views.FilamentTypeCreateView.as_view(), name="filament_type_create"),
|
||||||
|
path("filament-types/<int:pk>/edit/", views.FilamentTypeUpdateView.as_view(), name="filament_type_update"),
|
||||||
|
path("filament-types/<int:pk>/delete/", views.FilamentTypeDeleteView.as_view(), name="filament_type_delete"),
|
||||||
|
]
|
||||||
76
bambu_run/utils.py
Normal file
76
bambu_run/utils.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""
|
||||||
|
Utility functions for filament color matching
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def strip_color_padding(mqtt_color):
|
||||||
|
"""
|
||||||
|
Strip FF padding from MQTT color
|
||||||
|
MQTT: '000000FF' -> '000000'
|
||||||
|
MQTT: 'FF6A13FF' -> 'FF6A13'
|
||||||
|
"""
|
||||||
|
if not mqtt_color:
|
||||||
|
return None
|
||||||
|
if len(mqtt_color) == 8:
|
||||||
|
return mqtt_color[:6].upper()
|
||||||
|
return mqtt_color[:6].upper() if len(mqtt_color) >= 6 else mqtt_color.upper()
|
||||||
|
|
||||||
|
|
||||||
|
def match_filament_color(filament_type, filament_sub_type, color_code, brand='Bambu Lab'):
|
||||||
|
"""
|
||||||
|
Match a FilamentColor from database based on type, sub_type, color_code, and brand
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FilamentColor instance or None
|
||||||
|
"""
|
||||||
|
from .models import FilamentColor
|
||||||
|
|
||||||
|
if not all([filament_type, color_code]):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Try exact match first (with sub_type)
|
||||||
|
if filament_sub_type:
|
||||||
|
color_match = FilamentColor.objects.filter(
|
||||||
|
filament_type=filament_type,
|
||||||
|
filament_sub_type=filament_sub_type,
|
||||||
|
color_code=color_code,
|
||||||
|
brand=brand
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if color_match:
|
||||||
|
return color_match
|
||||||
|
|
||||||
|
# Try match without sub_type (more flexible)
|
||||||
|
color_match = FilamentColor.objects.filter(
|
||||||
|
filament_type=filament_type,
|
||||||
|
color_code=color_code,
|
||||||
|
brand=brand
|
||||||
|
).first()
|
||||||
|
|
||||||
|
return color_match
|
||||||
|
|
||||||
|
|
||||||
|
def match_and_update_filament_color(filament_color):
|
||||||
|
"""
|
||||||
|
Retroactively update all Filament spools that match this FilamentColor
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of Filament records updated
|
||||||
|
"""
|
||||||
|
from .models import Filament
|
||||||
|
|
||||||
|
query_filters = {
|
||||||
|
'type': filament_color.filament_type,
|
||||||
|
'brand': filament_color.brand,
|
||||||
|
}
|
||||||
|
|
||||||
|
color_hex = f"#{filament_color.color_code}"
|
||||||
|
query_filters['color_hex'] = color_hex
|
||||||
|
|
||||||
|
if filament_color.filament_sub_type:
|
||||||
|
query_filters['sub_type'] = filament_color.filament_sub_type
|
||||||
|
|
||||||
|
matching_filaments = Filament.objects.filter(**query_filters)
|
||||||
|
updated_count = matching_filaments.update(color=filament_color.color_name)
|
||||||
|
|
||||||
|
return updated_count
|
||||||
709
bambu_run/views.py
Normal file
709
bambu_run/views.py
Normal file
@@ -0,0 +1,709 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
from django.views.generic import TemplateView, View, ListView, CreateView, UpdateView, DetailView, DeleteView
|
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.urls import reverse_lazy
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.db.models import Q, Sum
|
||||||
|
import json
|
||||||
|
import zoneinfo
|
||||||
|
|
||||||
|
from .conf import app_settings
|
||||||
|
from .models import Printer, PrinterMetrics, Filament, FilamentColor, FilamentType, FilamentSnapshot, PrintJob, FilamentUsage
|
||||||
|
from .forms import FilamentForm, FilamentColorForm, FilamentTypeForm
|
||||||
|
|
||||||
|
|
||||||
|
class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
||||||
|
template_name = "bambu_run/printer_dashboard.html"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||||
|
|
||||||
|
try:
|
||||||
|
printer_device = Printer.objects.filter(is_active=True).first()
|
||||||
|
if not printer_device:
|
||||||
|
context["error"] = (
|
||||||
|
"No 3D printer device found. Please run bambu_collector first."
|
||||||
|
)
|
||||||
|
return context
|
||||||
|
except Exception as e:
|
||||||
|
context["error"] = f"Error loading printer device: {str(e)}"
|
||||||
|
return context
|
||||||
|
|
||||||
|
tz = zoneinfo.ZoneInfo(app_settings.TIMEZONE)
|
||||||
|
|
||||||
|
# Last 24 hours of live data
|
||||||
|
time_24h_ago = timezone.now() - timedelta(hours=24)
|
||||||
|
metrics = PrinterMetrics.objects.filter(
|
||||||
|
device=printer_device, timestamp__gte=time_24h_ago
|
||||||
|
).prefetch_related('filament_snapshots').order_by("timestamp")
|
||||||
|
|
||||||
|
latest_metric = metrics.last()
|
||||||
|
|
||||||
|
printer_data_json = {
|
||||||
|
"timestamps": [
|
||||||
|
m.timestamp.astimezone(tz).strftime("%H:%M") 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
|
||||||
|
],
|
||||||
|
"print_type": [m.print_type for m in metrics],
|
||||||
|
"gcode_state": [m.gcode_state 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
|
||||||
|
],
|
||||||
|
"filament_timeline": self._prepare_filament_timeline(metrics),
|
||||||
|
}
|
||||||
|
|
||||||
|
stats = {}
|
||||||
|
if latest_metric:
|
||||||
|
filaments_list = []
|
||||||
|
try:
|
||||||
|
filament_snapshots = latest_metric.filament_snapshots.select_related('filament').all()
|
||||||
|
for snapshot in filament_snapshots:
|
||||||
|
filament_dict = {
|
||||||
|
'tray_id': snapshot.tray_id,
|
||||||
|
'type': snapshot.type or 'Unknown',
|
||||||
|
'brand': snapshot.sub_type or 'Unknown',
|
||||||
|
'color': snapshot.color or 'FFFFFFFF',
|
||||||
|
'remain_percent': snapshot.remain_percent or 0,
|
||||||
|
}
|
||||||
|
if snapshot.filament:
|
||||||
|
filament_dict['color_name'] = snapshot.filament.color
|
||||||
|
filament_dict['filament_pk'] = snapshot.filament.pk
|
||||||
|
filaments_list.append(filament_dict)
|
||||||
|
except Exception:
|
||||||
|
filaments_list = []
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"nozzle_temp": float(latest_metric.nozzle_temp) if latest_metric.nozzle_temp else 0,
|
||||||
|
"bed_temp": float(latest_metric.bed_temp) if latest_metric.bed_temp else 0,
|
||||||
|
"chamber_temp": float(latest_metric.chamber_temp) if latest_metric.chamber_temp else 0,
|
||||||
|
"print_percent": latest_metric.print_percent or 0,
|
||||||
|
"gcode_state": latest_metric.gcode_state or "Unknown",
|
||||||
|
"print_type": latest_metric.print_type or "idle",
|
||||||
|
"subtask_name": latest_metric.subtask_name or "No active print",
|
||||||
|
"chamber_light": latest_metric.chamber_light or "unknown",
|
||||||
|
"ams_temp": float(latest_metric.ams_temp) if latest_metric.ams_temp else None,
|
||||||
|
"ams_humidity": latest_metric.ams_humidity,
|
||||||
|
"filaments": filaments_list,
|
||||||
|
"external_spool": latest_metric.external_spool or {},
|
||||||
|
"timestamp": latest_metric.timestamp.astimezone(tz).strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
}
|
||||||
|
|
||||||
|
project_markers = self._calculate_project_markers(list(metrics), tz)
|
||||||
|
printer_data_json["project_markers"] = project_markers
|
||||||
|
|
||||||
|
context["printer_device"] = printer_device
|
||||||
|
context["device_name"] = printer_device.name
|
||||||
|
context["stats"] = stats
|
||||||
|
context["metrics_count"] = metrics.count()
|
||||||
|
context["printer_data_json"] = json.dumps(printer_data_json)
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
def _calculate_project_markers(self, metrics, timezone_info):
|
||||||
|
"""Calculate where print jobs start and end"""
|
||||||
|
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(self, metrics):
|
||||||
|
"""Prepare filament data organized by unique filament configurations."""
|
||||||
|
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 PrinterDataAPIView(LoginRequiredMixin, View):
|
||||||
|
"""API endpoint for dynamic printer chart updates"""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
start_date = request.GET.get("start_date")
|
||||||
|
end_date = request.GET.get("end_date")
|
||||||
|
start_time = request.GET.get("start_time", "00:00")
|
||||||
|
end_time = request.GET.get("end_time", "23:59")
|
||||||
|
|
||||||
|
try:
|
||||||
|
printer_device = Printer.objects.filter(is_active=True).first()
|
||||||
|
if not printer_device:
|
||||||
|
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)
|
||||||
|
|
||||||
|
if start_date and start_time:
|
||||||
|
from datetime import datetime
|
||||||
|
start_dt_naive = datetime.strptime(f"{start_date} {start_time}", "%Y-%m-%d %H:%M")
|
||||||
|
start_dt = start_dt_naive.replace(tzinfo=tz)
|
||||||
|
query = query.filter(timestamp__gte=start_dt)
|
||||||
|
|
||||||
|
if end_date and end_time:
|
||||||
|
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)
|
||||||
|
|
||||||
|
metrics = query.order_by("timestamp")
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"timestamps": [m.timestamp.astimezone(tz).strftime('%H:%M') for m in metrics],
|
||||||
|
"timestamps_iso": [m.timestamp.astimezone(tz).isoformat() 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],
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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
|
||||||
|
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):
|
||||||
|
"""API endpoint for filament usage history with date/time filtering"""
|
||||||
|
|
||||||
|
def get(self, request, pk):
|
||||||
|
start_date = request.GET.get("start_date")
|
||||||
|
end_date = request.GET.get("end_date")
|
||||||
|
start_time = request.GET.get("start_time", "00:00")
|
||||||
|
end_time = request.GET.get("end_time", "23:59")
|
||||||
|
|
||||||
|
try:
|
||||||
|
filament = Filament.objects.get(pk=pk)
|
||||||
|
tz = zoneinfo.ZoneInfo(app_settings.TIMEZONE)
|
||||||
|
query = filament.usage_snapshots.select_related('printer_metric')
|
||||||
|
|
||||||
|
if start_date and start_time:
|
||||||
|
from datetime import datetime
|
||||||
|
start_dt_naive = datetime.strptime(f"{start_date} {start_time}", "%Y-%m-%d %H:%M")
|
||||||
|
start_dt = start_dt_naive.replace(tzinfo=tz)
|
||||||
|
query = query.filter(printer_metric__timestamp__gte=start_dt)
|
||||||
|
|
||||||
|
if end_date and end_time:
|
||||||
|
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(printer_metric__timestamp__lte=end_dt)
|
||||||
|
|
||||||
|
if not start_date and not end_date:
|
||||||
|
time_24h_ago = timezone.now() - timedelta(hours=24)
|
||||||
|
query = query.filter(printer_metric__timestamp__gte=time_24h_ago)
|
||||||
|
|
||||||
|
snapshots = query.order_by('printer_metric__timestamp')
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"timestamps": [s.printer_metric.timestamp.astimezone(tz).strftime('%Y-%m-%d %H:%M') for s in snapshots],
|
||||||
|
"remaining": [s.remain_percent for s in snapshots]
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonResponse(data)
|
||||||
|
|
||||||
|
except Filament.DoesNotExist:
|
||||||
|
return JsonResponse({"error": "Filament not found"}, status=404)
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return JsonResponse({"error": str(e)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Filament CRUD Views ====================
|
||||||
|
|
||||||
|
class FilamentListView(LoginRequiredMixin, ListView):
|
||||||
|
model = Filament
|
||||||
|
template_name = 'bambu_run/filament_list.html'
|
||||||
|
context_object_name = 'filaments'
|
||||||
|
paginate_by = 20
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = Filament.objects.all()
|
||||||
|
|
||||||
|
filament_type = self.request.GET.get('type')
|
||||||
|
if filament_type:
|
||||||
|
queryset = queryset.filter(type=filament_type)
|
||||||
|
|
||||||
|
loaded = self.request.GET.get('loaded')
|
||||||
|
if loaded == 'yes':
|
||||||
|
queryset = queryset.filter(is_loaded_in_ams=True)
|
||||||
|
elif loaded == 'no':
|
||||||
|
queryset = queryset.filter(is_loaded_in_ams=False)
|
||||||
|
|
||||||
|
search = self.request.GET.get('search')
|
||||||
|
if search:
|
||||||
|
queryset = queryset.filter(
|
||||||
|
Q(brand__icontains=search) |
|
||||||
|
Q(color__icontains=search) |
|
||||||
|
Q(type__icontains=search)
|
||||||
|
)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||||
|
context['total_spools'] = Filament.objects.count()
|
||||||
|
context['loaded_spools'] = Filament.objects.filter(is_loaded_in_ams=True).count()
|
||||||
|
context['low_filaments'] = Filament.objects.filter(remaining_percent__lt=20).count()
|
||||||
|
context['filament_types'] = sorted(
|
||||||
|
set(Filament.objects.exclude(type__isnull=True).exclude(type='').values_list('type', flat=True))
|
||||||
|
)
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class FilamentCreateView(LoginRequiredMixin, CreateView):
|
||||||
|
model = Filament
|
||||||
|
form_class = FilamentForm
|
||||||
|
template_name = 'bambu_run/filament_form.html'
|
||||||
|
success_url = reverse_lazy('bambu_run:filament_list')
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||||
|
return context
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
messages.success(self.request, f'Filament spool "{form.instance}" added successfully!')
|
||||||
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
|
||||||
|
class FilamentUpdateView(LoginRequiredMixin, UpdateView):
|
||||||
|
model = Filament
|
||||||
|
form_class = FilamentForm
|
||||||
|
template_name = 'bambu_run/filament_form.html'
|
||||||
|
success_url = reverse_lazy('bambu_run:filament_list')
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||||
|
return context
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
messages.success(self.request, f'Filament spool "{form.instance}" updated successfully!')
|
||||||
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
|
||||||
|
class FilamentDeleteView(LoginRequiredMixin, DeleteView):
|
||||||
|
model = Filament
|
||||||
|
template_name = 'bambu_run/filament_confirm_delete.html'
|
||||||
|
success_url = reverse_lazy('bambu_run:filament_list')
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||||
|
return context
|
||||||
|
|
||||||
|
def delete(self, request, *args, **kwargs):
|
||||||
|
filament = self.get_object()
|
||||||
|
messages.success(self.request, f'Filament spool "{filament}" has been deleted.')
|
||||||
|
return super().delete(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class FilamentDetailView(LoginRequiredMixin, DetailView):
|
||||||
|
model = Filament
|
||||||
|
template_name = 'bambu_run/filament_detail.html'
|
||||||
|
context_object_name = 'filament'
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||||
|
filament = self.object
|
||||||
|
|
||||||
|
context['print_usages'] = filament.print_usages.select_related('print_job').order_by('-print_job__start_time')[:20]
|
||||||
|
|
||||||
|
total_consumed = filament.print_usages.aggregate(
|
||||||
|
total=Sum('consumed_percent')
|
||||||
|
)['total'] or 0
|
||||||
|
context['total_consumed_percent'] = total_consumed
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== FilamentColor Views ====================
|
||||||
|
|
||||||
|
class FilamentColorListView(LoginRequiredMixin, ListView):
|
||||||
|
model = FilamentColor
|
||||||
|
template_name = 'bambu_run/filament_color_list.html'
|
||||||
|
context_object_name = 'colors'
|
||||||
|
paginate_by = 50
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return FilamentColor.objects.all().order_by('filament_type', 'filament_sub_type', 'color_name')
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||||
|
context['total_colors'] = FilamentColor.objects.count()
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class FilamentColorCreateView(LoginRequiredMixin, CreateView):
|
||||||
|
model = FilamentColor
|
||||||
|
form_class = FilamentColorForm
|
||||||
|
template_name = 'bambu_run/filament_color_form.html'
|
||||||
|
success_url = reverse_lazy('bambu_run:filament_color_list')
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||||
|
return context
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
response = super().form_valid(form)
|
||||||
|
self._update_matching_filaments(self.object)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def _update_matching_filaments(self, filament_color):
|
||||||
|
from .utils import match_and_update_filament_color
|
||||||
|
updated_count = match_and_update_filament_color(filament_color)
|
||||||
|
if updated_count > 0:
|
||||||
|
messages.success(
|
||||||
|
self.request,
|
||||||
|
f"Color '{filament_color.color_name}' created! "
|
||||||
|
f"Updated {updated_count} matching filament spool(s)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FilamentColorUpdateView(LoginRequiredMixin, UpdateView):
|
||||||
|
model = FilamentColor
|
||||||
|
form_class = FilamentColorForm
|
||||||
|
template_name = 'bambu_run/filament_color_form.html'
|
||||||
|
success_url = reverse_lazy('bambu_run:filament_color_list')
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||||
|
return context
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
response = super().form_valid(form)
|
||||||
|
self._update_matching_filaments(self.object)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def _update_matching_filaments(self, filament_color):
|
||||||
|
from .utils import match_and_update_filament_color
|
||||||
|
updated_count = match_and_update_filament_color(filament_color)
|
||||||
|
if updated_count > 0:
|
||||||
|
messages.success(
|
||||||
|
self.request,
|
||||||
|
f"Color '{filament_color.color_name}' updated! "
|
||||||
|
f"Updated {updated_count} matching filament spool(s)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FilamentColorDeleteView(LoginRequiredMixin, DeleteView):
|
||||||
|
model = FilamentColor
|
||||||
|
template_name = 'bambu_run/filament_color_confirm_delete.html'
|
||||||
|
success_url = reverse_lazy('bambu_run:filament_color_list')
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||||
|
return context
|
||||||
|
|
||||||
|
def delete(self, request, *args, **kwargs):
|
||||||
|
messages.success(request, f"Color '{self.get_object().color_name}' deleted successfully!")
|
||||||
|
return super().delete(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== FilamentType Views ====================
|
||||||
|
|
||||||
|
class FilamentTypeListView(LoginRequiredMixin, ListView):
|
||||||
|
model = FilamentType
|
||||||
|
template_name = 'bambu_run/filament_type_list.html'
|
||||||
|
context_object_name = 'types'
|
||||||
|
paginate_by = 50
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||||
|
context['total_types'] = FilamentType.objects.count()
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class FilamentTypeCreateView(LoginRequiredMixin, CreateView):
|
||||||
|
model = FilamentType
|
||||||
|
form_class = FilamentTypeForm
|
||||||
|
template_name = 'bambu_run/filament_type_form.html'
|
||||||
|
success_url = reverse_lazy('bambu_run:filament_type_list')
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||||
|
context['existing_types'] = list(
|
||||||
|
FilamentType.objects.values_list('type', flat=True).distinct().order_by('type')
|
||||||
|
)
|
||||||
|
context['existing_sub_types'] = list(
|
||||||
|
FilamentType.objects.exclude(sub_type__isnull=True).exclude(sub_type='')
|
||||||
|
.values_list('sub_type', flat=True).distinct().order_by('sub_type')
|
||||||
|
)
|
||||||
|
context['existing_brands'] = list(
|
||||||
|
FilamentType.objects.values_list('brand', flat=True).distinct().order_by('brand')
|
||||||
|
)
|
||||||
|
context['preset_types'] = FilamentTypeForm.PRESET_TYPES
|
||||||
|
context['preset_sub_types'] = FilamentTypeForm.PRESET_SUB_TYPES
|
||||||
|
context['preset_brands'] = FilamentTypeForm.PRESET_BRANDS
|
||||||
|
return context
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
messages.success(self.request, f'Filament type "{form.instance}" added successfully!')
|
||||||
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
|
||||||
|
class FilamentTypeUpdateView(LoginRequiredMixin, UpdateView):
|
||||||
|
model = FilamentType
|
||||||
|
form_class = FilamentTypeForm
|
||||||
|
template_name = 'bambu_run/filament_type_form.html'
|
||||||
|
success_url = reverse_lazy('bambu_run:filament_type_list')
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||||
|
context['existing_types'] = list(
|
||||||
|
FilamentType.objects.values_list('type', flat=True).distinct().order_by('type')
|
||||||
|
)
|
||||||
|
context['existing_sub_types'] = list(
|
||||||
|
FilamentType.objects.exclude(sub_type__isnull=True).exclude(sub_type='')
|
||||||
|
.values_list('sub_type', flat=True).distinct().order_by('sub_type')
|
||||||
|
)
|
||||||
|
context['existing_brands'] = list(
|
||||||
|
FilamentType.objects.values_list('brand', flat=True).distinct().order_by('brand')
|
||||||
|
)
|
||||||
|
context['preset_types'] = FilamentTypeForm.PRESET_TYPES
|
||||||
|
context['preset_sub_types'] = FilamentTypeForm.PRESET_SUB_TYPES
|
||||||
|
context['preset_brands'] = FilamentTypeForm.PRESET_BRANDS
|
||||||
|
return context
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
messages.success(self.request, f'Filament type "{form.instance}" updated successfully!')
|
||||||
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
|
||||||
|
class FilamentTypeDeleteView(LoginRequiredMixin, DeleteView):
|
||||||
|
model = FilamentType
|
||||||
|
template_name = 'bambu_run/filament_type_confirm_delete.html'
|
||||||
|
success_url = reverse_lazy('bambu_run:filament_type_list')
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
||||||
|
return context
|
||||||
|
|
||||||
|
def delete(self, request, *args, **kwargs):
|
||||||
|
messages.success(request, f"Filament type '{self.get_object()}' deleted successfully!")
|
||||||
|
return super().delete(request, *args, **kwargs)
|
||||||
12
docker-compose.yml
Normal file
12
docker-compose.yml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
services:
|
||||||
|
bambu-run:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
env_file: .env
|
||||||
|
volumes:
|
||||||
|
- bambu_data:/app/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
bambu_data:
|
||||||
38
docker/supervisord.conf
Normal file
38
docker/supervisord.conf
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
[supervisord]
|
||||||
|
nodaemon=true
|
||||||
|
logfile=/dev/null
|
||||||
|
logfile_maxbytes=0
|
||||||
|
|
||||||
|
[program:web]
|
||||||
|
command=gunicorn standalone.wsgi:application --bind 0.0.0.0:8000 --workers 2
|
||||||
|
directory=/app
|
||||||
|
environment=DJANGO_SETTINGS_MODULE="standalone.settings"
|
||||||
|
stdout_logfile=/dev/fd/1
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/fd/2
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
autorestart=true
|
||||||
|
|
||||||
|
[program:collector]
|
||||||
|
command=python standalone/manage.py bambu_collector
|
||||||
|
directory=/app
|
||||||
|
environment=DJANGO_SETTINGS_MODULE="standalone.settings"
|
||||||
|
stdout_logfile=/dev/fd/1
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/fd/2
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
autorestart=true
|
||||||
|
startretries=10
|
||||||
|
startsecs=5
|
||||||
|
|
||||||
|
[program:migrate]
|
||||||
|
command=python standalone/manage.py migrate --noinput
|
||||||
|
directory=/app
|
||||||
|
environment=DJANGO_SETTINGS_MODULE="standalone.settings"
|
||||||
|
stdout_logfile=/dev/fd/1
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/fd/2
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
autorestart=false
|
||||||
|
startsecs=0
|
||||||
|
priority=1
|
||||||
60
pyproject.toml
Normal file
60
pyproject.toml
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68.0", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "bambu-run"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Django reusable app for Bambu Lab 3D printer monitoring and filament inventory management"
|
||||||
|
readme = "README.md"
|
||||||
|
license = {text = "MIT"}
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
authors = [
|
||||||
|
{name = "Runnan Li"},
|
||||||
|
]
|
||||||
|
keywords = ["django", "bambu-lab", "3d-printer", "filament", "mqtt", "monitoring"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 4 - Beta",
|
||||||
|
"Environment :: Web Environment",
|
||||||
|
"Framework :: Django",
|
||||||
|
"Framework :: Django :: 4.2",
|
||||||
|
"Framework :: Django :: 5.0",
|
||||||
|
"Framework :: Django :: 5.1",
|
||||||
|
"Intended Audience :: End Users/Desktop",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Operating System :: OS Independent",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Topic :: Home Automation",
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"django>=4.2",
|
||||||
|
"bambu-lab-cloud-api",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
standalone = [
|
||||||
|
"gunicorn",
|
||||||
|
"python-dotenv",
|
||||||
|
]
|
||||||
|
dev = [
|
||||||
|
"ruff",
|
||||||
|
"pytest",
|
||||||
|
"pytest-django",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://github.com/RunLit/Bambu-Run"
|
||||||
|
Repository = "https://github.com/RunLit/Bambu-Run"
|
||||||
|
Issues = "https://github.com/RunLit/Bambu-Run/issues"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["bambu_run*"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
bambu_run = [
|
||||||
|
"templates/bambu_run/*.html",
|
||||||
|
"static/bambu_run/**/*",
|
||||||
|
]
|
||||||
0
standalone/__init__.py
Normal file
0
standalone/__init__.py
Normal file
21
standalone/manage.py
Normal file
21
standalone/manage.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Django's command-line utility for Bambu Run standalone deployment."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "standalone.settings")
|
||||||
|
try:
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"Couldn't import Django. Are you sure it's installed and "
|
||||||
|
"available on your PYTHONPATH environment variable? Did you "
|
||||||
|
"forget to activate a virtual environment?"
|
||||||
|
) from exc
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
138
standalone/settings.py
Normal file
138
standalone/settings.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"""
|
||||||
|
Minimal Django settings for Bambu Run standalone deployment.
|
||||||
|
|
||||||
|
Reads configuration from environment variables or .env file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
|
SECRET_KEY = os.environ.get(
|
||||||
|
"DJANGO_SECRET_KEY",
|
||||||
|
"bambu-run-insecure-default-change-me-in-production",
|
||||||
|
)
|
||||||
|
|
||||||
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
|
DEBUG = os.environ.get("DEBUG", "True").lower() in ("true", "1", "yes")
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
|
||||||
|
|
||||||
|
# Application definition
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
"django.contrib.admin",
|
||||||
|
"django.contrib.auth",
|
||||||
|
"django.contrib.contenttypes",
|
||||||
|
"django.contrib.sessions",
|
||||||
|
"django.contrib.messages",
|
||||||
|
"django.contrib.staticfiles",
|
||||||
|
"bambu_run",
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
"django.middleware.security.SecurityMiddleware",
|
||||||
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
|
"django.middleware.common.CommonMiddleware",
|
||||||
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = "standalone.urls"
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
|
"DIRS": [BASE_DIR / "standalone" / "templates"],
|
||||||
|
"APP_DIRS": True,
|
||||||
|
"OPTIONS": {
|
||||||
|
"context_processors": [
|
||||||
|
"django.template.context_processors.debug",
|
||||||
|
"django.template.context_processors.request",
|
||||||
|
"django.contrib.auth.context_processors.auth",
|
||||||
|
"django.contrib.messages.context_processors.messages",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = "standalone.wsgi.application"
|
||||||
|
|
||||||
|
# Database — SQLite for zero-setup deployment
|
||||||
|
DATA_DIR = Path(os.environ.get("DATA_DIR", BASE_DIR / "data"))
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
"default": {
|
||||||
|
"ENGINE": "django.db.backends.sqlite3",
|
||||||
|
"NAME": DATA_DIR / "db.sqlite3",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Password validation
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
|
||||||
|
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
||||||
|
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
|
||||||
|
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Internationalization
|
||||||
|
LANGUAGE_CODE = "en-us"
|
||||||
|
TIME_ZONE = os.environ.get("TIMEZONE", "UTC")
|
||||||
|
USE_I18N = True
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
# Static files
|
||||||
|
STATIC_URL = "static/"
|
||||||
|
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||||
|
|
||||||
|
# Default primary key field type
|
||||||
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
|
# Login / Logout
|
||||||
|
LOGIN_URL = "/accounts/login/"
|
||||||
|
LOGIN_REDIRECT_URL = "/"
|
||||||
|
LOGOUT_REDIRECT_URL = "/accounts/login/"
|
||||||
|
|
||||||
|
# Bambu Run settings
|
||||||
|
BAMBU_RUN_TIMEZONE = os.environ.get("TIMEZONE", "UTC")
|
||||||
|
BAMBU_RUN_BASE_TEMPLATE = "bambu_run/base.html"
|
||||||
|
|
||||||
|
# Printer connection — read from environment
|
||||||
|
PRINTER_IP = os.environ.get("PRINTER_IP", "")
|
||||||
|
ACCESS_TOKEN = os.environ.get("ACCESS_TOKEN", "")
|
||||||
|
PRINTER_SERIAL = os.environ.get("PRINTER_SERIAL", "")
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOGGING = {
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"formatters": {
|
||||||
|
"verbose": {
|
||||||
|
"format": "{asctime} {levelname} {name} {message}",
|
||||||
|
"style": "{",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"handlers": {
|
||||||
|
"console": {
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"formatter": "verbose",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"handlers": ["console"],
|
||||||
|
"level": "INFO",
|
||||||
|
},
|
||||||
|
"loggers": {
|
||||||
|
"bambu_run": {
|
||||||
|
"handlers": ["console"],
|
||||||
|
"level": "DEBUG" if DEBUG else "INFO",
|
||||||
|
"propagate": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
47
standalone/templates/registration/login.html
Normal file
47
standalone/templates/registration/login.html
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-coreui-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Login - Bambu Run</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/@coreui/coreui@5.3.0/dist/css/coreui.min.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="bg-body-tertiary min-vh-100 d-flex flex-row align-items-center">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card-group d-block d-md-flex row">
|
||||||
|
<div class="card p-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h1>Bambu Run</h1>
|
||||||
|
<p class="text-body-secondary">Sign in to your account</p>
|
||||||
|
{% if form.errors %}
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
Invalid username or password.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<span class="input-group-text">@</span>
|
||||||
|
<input type="text" name="username" class="form-control" placeholder="Username" autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="input-group mb-4">
|
||||||
|
<span class="input-group-text">*</span>
|
||||||
|
<input type="password" name="password" class="form-control" placeholder="Password">
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-6">
|
||||||
|
<button class="btn btn-primary px-4" type="submit">Login</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@coreui/coreui@5.3.0/dist/js/coreui.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
13
standalone/urls.py
Normal file
13
standalone/urls.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"""URL configuration for Bambu Run standalone deployment."""
|
||||||
|
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib.auth import views as auth_views
|
||||||
|
from django.urls import include, path
|
||||||
|
from django.views.generic import RedirectView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("admin/", admin.site.urls),
|
||||||
|
path("accounts/login/", auth_views.LoginView.as_view(template_name="registration/login.html"), name="login"),
|
||||||
|
path("accounts/logout/", auth_views.LogoutView.as_view(), name="logout"),
|
||||||
|
path("", include("bambu_run.urls")),
|
||||||
|
]
|
||||||
9
standalone/wsgi.py
Normal file
9
standalone/wsgi.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
"""WSGI config for Bambu Run standalone deployment."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "standalone.settings")
|
||||||
|
|
||||||
|
application = get_wsgi_application()
|
||||||
Reference in New Issue
Block a user