Merge branch '0.1.6-final' into feature/printer_model_queue

This commit is contained in:
MartinNYHC
2026-01-30 13:11:15 +01:00
committed by GitHub
38 changed files with 9485 additions and 1248 deletions
+11 -11
View File
@@ -6,8 +6,8 @@ on:
pull_request:
# Run on all PRs, but skip for repo owner (runs local tests)
# Skip CI for repo owner's PRs (they run tests locally)
# This check is applied to all jobs below
# Skip CI for PRs authored by repo owner (they run tests locally)
# Uses PR author instead of triggering actor so rebasing by owner doesn't skip CI
env:
PYTHON_VERSION: '3.11'
@@ -30,7 +30,7 @@ jobs:
backend-lint:
name: Backend Lint
runs-on: ubuntu-latest
if: github.event_name == 'push' || github.actor != github.repository_owner
if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
steps:
- uses: actions/checkout@v4
@@ -51,7 +51,7 @@ jobs:
backend-security:
name: Backend Security
runs-on: ubuntu-latest
if: github.event_name == 'push' || github.actor != github.repository_owner
if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
continue-on-error: true
steps:
- uses: actions/checkout@v4
@@ -73,7 +73,7 @@ jobs:
backend-tests:
name: Backend Tests
runs-on: ubuntu-latest
if: github.event_name == 'push' || github.actor != github.repository_owner
if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
needs: backend-lint
steps:
- uses: actions/checkout@v4
@@ -110,7 +110,7 @@ jobs:
frontend-lint:
name: Frontend Lint
runs-on: ubuntu-latest
if: github.event_name == 'push' || github.actor != github.repository_owner
if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
steps:
- uses: actions/checkout@v4
@@ -132,7 +132,7 @@ jobs:
frontend-security:
name: Frontend Security
runs-on: ubuntu-latest
if: github.event_name == 'push' || github.actor != github.repository_owner
if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
continue-on-error: true
steps:
- uses: actions/checkout@v4
@@ -155,7 +155,7 @@ jobs:
frontend-typecheck:
name: Frontend Type Check
runs-on: ubuntu-latest
if: github.event_name == 'push' || github.actor != github.repository_owner
if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
steps:
- uses: actions/checkout@v4
@@ -177,7 +177,7 @@ jobs:
frontend-tests:
name: Frontend Tests
runs-on: ubuntu-latest
if: github.event_name == 'push' || github.actor != github.repository_owner
if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
needs: [frontend-lint, frontend-typecheck]
steps:
- uses: actions/checkout@v4
@@ -201,7 +201,7 @@ jobs:
frontend-build:
name: Frontend Build
runs-on: ubuntu-latest
if: github.event_name == 'push' || github.actor != github.repository_owner
if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
needs: [frontend-tests]
steps:
- uses: actions/checkout@v4
@@ -228,7 +228,7 @@ jobs:
docker-test:
name: Docker Build
runs-on: ubuntu-latest
if: github.event_name == 'push' || github.actor != github.repository_owner
if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
timeout-minutes: 20
needs: [backend-tests, frontend-build]
steps:
+37
View File
@@ -0,0 +1,37 @@
name: Clean up closed issues
on:
issues:
types: [closed]
permissions:
issues: write
jobs:
remove-labels:
runs-on: ubuntu-latest
steps:
- name: Remove feedback label
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
const hasLabel = issue.labels.some(l => l.name === 'feedback');
if (hasLabel) {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
name: 'feedback'
});
console.log(`Removed 'feedback' label from issue #${issue.number}`);
} catch (error) {
if (error.status === 404) {
console.log(`Label 'feedback' already removed from issue #${issue.number}`);
} else {
throw error;
}
}
}
-1
View File
@@ -18,4 +18,3 @@ jobs:
days-before-stale: 21
days-before-close: 7
stale-issue-label: 'stale'
remove-issue-labels: 'feedback'
+21 -1
View File
@@ -2,9 +2,17 @@
All notable changes to Bambuddy will be documented in this file.
## [0.1.6] - Not released
## [0.1.6-final] - Not released
### New Features
- **Disable Printer Firmware Checks** - New toggle in Settings → General → Updates to disable printer firmware update checks:
- Prevents Bambuddy from checking Bambu Lab servers for firmware updates
- Useful for users who prefer to manage firmware manually or have network restrictions
- **Archive Plate Browsing** - Browse plate thumbnails directly in archive cards (Issue #166):
- Hover over archive card to reveal plate navigation for multi-plate files
- Left/right arrows to cycle through plate thumbnails
- Dot indicators show current plate (clickable to jump to specific plate)
- Lazy-loads plate data only when user hovers
- **GitHub Profile Backup** - Automatically backup your Cloud profiles, K-profiles and settings to a GitHub repository:
- Configure GitHub repository URL and Personal Access Token
- Schedule backups hourly, daily, or weekly
@@ -102,6 +110,13 @@ All notable changes to Bambuddy will be documented in this file.
- New "Print Queue" section in notification provider settings
### Fixes
- **Multi-Plate Thumbnail in Queue** - Fixed queue items showing wrong thumbnail for multi-plate files (Issue #166):
- Queue now displays the correct plate thumbnail based on selected plate
- Previously always showed plate 1 thumbnail regardless of selection
- **A1/A1 Mini Shows Printing Instead of Idle** - Fixed incorrect status display for A1 series printers (Issue #168):
- Some A1/A1 Mini firmware versions incorrectly report stage 0 ("Printing") when idle
- Now checks gcode_state to correctly display "Idle" for affected printers
- Fix only applies to A1 models with the specific buggy condition
- **HMS Error Notifications** - Get notified when printer errors occur (Issue #84):
- Automatic notifications for HMS errors (AMS issues, nozzle problems, etc.)
- Human-readable error messages (853 error codes translated)
@@ -132,6 +147,11 @@ All notable changes to Bambuddy will be documented in this file.
- Text wrap toggle: "Wrap" button in header to wrap long names instead of truncating
- Both settings persist in localStorage
- Tooltip shows full name on hover
- **K-Profiles Backup Status** - Fixed GitHub backup settings showing incorrect printer connection count (e.g., "1/2 connected" when both printers are connected); now fetches status from API instead of relying on WebSocket cache
- **GitHub Backup Timestamps** - Removed volatile timestamps from GitHub backup files so git diffs only show actual data changes
### Maintenance
- Upgraded vitest from 2.x to 3.x to resolve npm audit security vulnerabilities in dev dependencies
## [0.1.6b11] - 2026-01-22
+1
View File
@@ -52,6 +52,7 @@
- Photo attachments & failure analysis
- Timelapse editor (trim, speed, music)
- Re-print to any connected printer with AMS mapping (auto-match or manual slot selection, multi-plate support)
- Plate thumbnail browsing for multi-plate archives (hover to navigate between plates)
- Archive comparison (side-by-side diff)
### 📊 Monitoring & Control
BIN
View File
Binary file not shown.
+36 -2
View File
@@ -267,6 +267,34 @@ _filament_cache_time: float = 0
FILAMENT_CACHE_TTL = 300 # 5 minutes
def _filament_id_to_setting_id(filament_id: str) -> str:
"""
Convert filament_id to setting_id format for Bambu Cloud API.
Printers report filament_id (e.g., GFA00, GFG02) but the API expects
setting_id format which has an "S" inserted after "GF" (e.g., GFSA00, GFSG02).
User presets (starting with "P") and already-correct IDs are returned unchanged.
"""
if not filament_id:
return filament_id
# User presets start with "P" - leave unchanged
if filament_id.startswith("P"):
return filament_id
# Official Bambu presets: GFx## -> GFSx##
# Check if it matches the filament_id pattern (GF followed by letter and digits)
if filament_id.startswith("GF") and len(filament_id) >= 4:
# Check if it's already a setting_id (has S after GF)
if filament_id[2] == "S":
return filament_id
# Insert "S" after "GF": GFA00 -> GFSA00
return f"GFS{filament_id[2:]}"
return filament_id
@router.post("/filament-info")
async def get_filament_info(setting_ids: list[str] = Body(...), db: AsyncSession = Depends(get_db)):
"""
@@ -308,7 +336,10 @@ async def get_filament_info(setting_ids: list[str] = Body(...), db: AsyncSession
continue
try:
data = await cloud.get_setting_detail(setting_id)
# Transform filament_id to setting_id format (GFA00 -> GFSA00)
api_setting_id = _filament_id_to_setting_id(setting_id)
data = await cloud.get_setting_detail(api_setting_id)
setting = data.get("setting", {})
# Extract name (e.g., "Bambu PLA Basic Jade White")
@@ -323,11 +354,14 @@ async def get_filament_info(setting_ids: list[str] = Body(...), db: AsyncSession
k_value = None
info = {"name": name, "k": k_value}
# Cache using original ID so frontend gets expected response
_filament_cache[setting_id] = info
result[setting_id] = info
except Exception as e:
logger.warning(f"Failed to get cloud preset {setting_id}: {e}")
logger.warning(
f"Failed to get cloud preset {setting_id} (API ID: {_filament_id_to_setting_id(setting_id)}): {e}"
)
# Cache the failure to avoid repeated requests
_filament_cache[setting_id] = {"name": "", "k": None}
result[setting_id] = {"name": "", "k": None}
+1 -1
View File
@@ -408,7 +408,7 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
nozzles=nozzles,
print_options=print_options,
stg_cur=state.stg_cur,
stg_cur_name=get_derived_status_name(state),
stg_cur_name=get_derived_status_name(state, printer.model),
stg=state.stg,
airduct_mode=state.airduct_mode,
speed_level=state.speed_level,
+1
View File
@@ -74,6 +74,7 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
"capture_finish_photo",
"spoolman_enabled",
"check_updates",
"check_printer_firmware",
"virtual_printer_enabled",
"ftp_retry_enabled",
"mqtt_enabled",
+2
View File
@@ -26,6 +26,7 @@ class AppSettings(BaseModel):
# Updates
check_updates: bool = Field(default=True, description="Automatically check for updates on startup")
check_printer_firmware: bool = Field(default=True, description="Check for printer firmware updates from Bambu Lab")
# Language
notification_language: str = Field(default="en", description="Language for push notifications (en, de)")
@@ -134,6 +135,7 @@ class AppSettingsUpdate(BaseModel):
spoolman_url: str | None = None
spoolman_sync_mode: str | None = None
check_updates: bool | None = None
check_printer_firmware: bool | None = None
notification_language: str | None = None
ams_humidity_good: int | None = None
ams_humidity_fair: int | None = None
+1 -8
View File
@@ -309,13 +309,11 @@ class GitHubBackupService:
}
"""
files: dict[str, dict | list] = {}
now = datetime.now(UTC)
# Metadata file
# Metadata file (no timestamps - git tracks file history)
metadata = {
"version": "1.0",
"backup_type": "bambuddy_profiles",
"created_at": now.isoformat(),
"contents": {
"kprofiles": config.backup_kprofiles,
"cloud_profiles": config.backup_cloud_profiles,
@@ -365,7 +363,6 @@ class GitHubBackupService:
"printer_name": printer.name,
"printer_serial": serial,
"nozzle_diameter": nozzle,
"exported_at": datetime.now(UTC).isoformat(),
"profiles": [
{
"slot_id": p.slot_id,
@@ -425,21 +422,18 @@ class GitHubBackupService:
if filament_settings:
files["cloud_profiles/filament.json"] = {
"version": "1.0",
"exported_at": datetime.now(UTC).isoformat(),
"profiles": filament_settings,
}
if printer_settings:
files["cloud_profiles/printer.json"] = {
"version": "1.0",
"exported_at": datetime.now(UTC).isoformat(),
"profiles": printer_settings,
}
if process_settings:
files["cloud_profiles/process.json"] = {
"version": "1.0",
"exported_at": datetime.now(UTC).isoformat(),
"profiles": process_settings,
}
@@ -462,7 +456,6 @@ class GitHubBackupService:
files["settings/app_settings.json"] = {
"version": "1.0",
"exported_at": datetime.now(UTC).isoformat(),
"settings": settings_data,
}
+40 -2
View File
@@ -33,6 +33,22 @@ CHAMBER_TEMP_SUPPORTED_MODELS = frozenset(
]
)
# Models that may incorrectly report stg_cur=0 when idle (firmware bug)
# Based on Home Assistant Bambu Lab integration observations
# See: https://github.com/greghesp/ha-bambulab/blob/main/custom_components/bambu_lab/pybambu/models.py
A1_MODELS = frozenset(
[
# Display names
"A1",
"A1 MINI",
"A1-MINI",
"A1MINI",
# Internal codes (from MQTT/SSDP)
"N1", # A1 Mini
"N2S", # A1
]
)
def supports_chamber_temp(model: str | None) -> bool:
"""Check if a printer model has a real chamber temperature sensor.
@@ -47,6 +63,19 @@ def supports_chamber_temp(model: str | None) -> bool:
return model_upper in CHAMBER_TEMP_SUPPORTED_MODELS
def has_stg_cur_idle_bug(model: str | None) -> bool:
"""Check if a printer model may incorrectly report stg_cur=0 when idle.
Some A1/A1 Mini firmware versions report stg_cur=0 (which maps to "Printing")
even when the printer is idle. This is a known firmware bug that was observed
in the Home Assistant Bambu Lab integration.
"""
if not model:
return False
model_upper = model.strip().upper()
return model_upper in A1_MODELS
class PrinterInfo:
"""Basic printer info for callbacks."""
@@ -373,13 +402,22 @@ class PrinterManager:
return result
def get_derived_status_name(state: PrinterState) -> str | None:
def get_derived_status_name(state: PrinterState, model: str | None = None) -> str | None:
"""
Compute a human-readable status name based on printer state.
Uses stg_cur when available, otherwise derives status from temperature data
when the printer is heating before a print starts.
Args:
state: The printer state to analyze
model: Optional printer model for model-specific workarounds
"""
# A1/A1 Mini firmware bug: some versions report stg_cur=0 when idle
# Only correct this specific case (IDLE + stg_cur=0) for affected models
if state.state == "IDLE" and state.stg_cur == 0 and has_stg_cur_idle_bug(model):
return None
# If we have a valid calibration stage, use it
# X1 models use -1 for idle, A1/P1 models use 255 for idle
# Valid stage numbers are 0-254
@@ -581,7 +619,7 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
"wifi_signal": state.wifi_signal,
# Calibration stage tracking
"stg_cur": state.stg_cur,
"stg_cur_name": get_derived_status_name(state),
"stg_cur_name": get_derived_status_name(state, model),
# Printable objects count for skip objects feature
"printable_objects_count": len(state.printable_objects),
# Fan speeds (0-100 percentage, None if not available)
+7 -2
View File
@@ -541,10 +541,15 @@ class SpoolmanClient:
# Need valid color to create filament
tray_color = tray_data.get("tray_color", "")
if not tray_color or tray_color in ("", "00000000"):
logger.debug(f"Skipping tray with invalid color: {tray_color}")
if not tray_color or tray_color.strip() == "":
logger.debug("Skipping tray with empty color")
return None
# Handle transparent/natural filament (RRGGBBAA with alpha=00)
# Replace with cream color that represents how natural PLA actually looks
if tray_color == "00000000":
tray_color = "F5E6D3FF" # Light cream/natural color
# Get sub_brands, falling back to tray_type
tray_sub_brands = tray_data.get("tray_sub_brands", "")
if not tray_sub_brands or tray_sub_brands.strip() == "":
@@ -215,6 +215,28 @@ class TestSettingsAPI:
assert result["currency"] == "JPY"
assert result["check_updates"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_check_printer_firmware(self, async_client: AsyncClient):
"""Verify check_printer_firmware can be updated."""
# Default should be True
response = await async_client.get("/api/v1/settings/")
assert response.json()["check_printer_firmware"] is True
# Update to False
response = await async_client.put("/api/v1/settings/", json={"check_printer_firmware": False})
assert response.status_code == 200
assert response.json()["check_printer_firmware"] is False
# Verify persistence
response = await async_client.get("/api/v1/settings/")
assert response.json()["check_printer_firmware"] is False
# Update back to True
response = await async_client.put("/api/v1/settings/", json={"check_printer_firmware": True})
assert response.status_code == 200
assert response.json()["check_printer_firmware"] is True
# ========================================================================
# MQTT settings tests
# ========================================================================
@@ -12,6 +12,7 @@ import pytest
from backend.app.services.printer_manager import (
PrinterManager,
get_derived_status_name,
has_stg_cur_idle_bug,
init_printer_connections,
printer_state_to_dict,
supports_chamber_temp,
@@ -901,7 +902,7 @@ class TestGetDerivedStatusName:
assert result == "Auto bed leveling"
def test_stg_cur_zero_returns_printing(self):
"""Verify stg_cur=0 returns 'Printing'."""
"""Verify stg_cur=0 returns 'Printing' when no model specified."""
state = MagicMock()
state.stg_cur = 0
@@ -909,6 +910,72 @@ class TestGetDerivedStatusName:
assert result == "Printing"
def test_a1_idle_with_stg_cur_zero_returns_none(self):
"""Verify A1 with IDLE state and stg_cur=0 returns None (bug workaround)."""
state = MagicMock()
state.stg_cur = 0
state.state = "IDLE"
# Test various A1 model names
for model in ["A1", "A1 Mini", "A1-Mini", "A1MINI", "N1", "N2S"]:
result = get_derived_status_name(state, model)
assert result is None, f"Expected None for model {model}"
def test_a1_running_with_stg_cur_zero_returns_printing(self):
"""Verify A1 with RUNNING state and stg_cur=0 still returns 'Printing'."""
state = MagicMock()
state.stg_cur = 0
state.state = "RUNNING"
result = get_derived_status_name(state, "A1")
assert result == "Printing"
def test_non_a1_idle_with_stg_cur_zero_returns_printing(self):
"""Verify non-A1 models with IDLE and stg_cur=0 still return 'Printing'."""
state = MagicMock()
state.stg_cur = 0
state.state = "IDLE"
# X1C should not get the workaround
result = get_derived_status_name(state, "X1C")
assert result == "Printing"
class TestHasStgCurIdleBug:
"""Tests for has_stg_cur_idle_bug function."""
def test_a1_models_return_true(self):
"""Verify A1 model variants return True."""
assert has_stg_cur_idle_bug("A1") is True
assert has_stg_cur_idle_bug("A1 Mini") is True
assert has_stg_cur_idle_bug("A1-Mini") is True
assert has_stg_cur_idle_bug("A1MINI") is True
assert has_stg_cur_idle_bug("a1") is True # case insensitive
assert has_stg_cur_idle_bug("a1 mini") is True
def test_a1_internal_codes_return_true(self):
"""Verify A1 internal model codes return True."""
assert has_stg_cur_idle_bug("N1") is True # A1 Mini
assert has_stg_cur_idle_bug("N2S") is True # A1
def test_non_a1_models_return_false(self):
"""Verify non-A1 models return False."""
assert has_stg_cur_idle_bug("X1C") is False
assert has_stg_cur_idle_bug("X1") is False
assert has_stg_cur_idle_bug("P1P") is False
assert has_stg_cur_idle_bug("P1S") is False
assert has_stg_cur_idle_bug("H2D") is False
def test_none_model_returns_false(self):
"""Verify None model returns False."""
assert has_stg_cur_idle_bug(None) is False
def test_empty_model_returns_false(self):
"""Verify empty model returns False."""
assert has_stg_cur_idle_bug("") is False
class TestInitPrinterConnections:
"""Tests for init_printer_connections function."""
-83
View File
@@ -1,83 +0,0 @@
=== BAMBUDDY FILE DELETION ISSUE - Jan 8, 2026 ===
=== ROOT CAUSE IDENTIFIED ===
WHAT HAPPENED:
- /opt was COMPLETELY DELETED on TWO containers:
- Container 109 (claude): ~11:22 and ~12:22
- Container 107 (3dp): ~13:28
- Container 107 was "untouched" (no SSH, no Claude Code) - just running BamBuddy
ROOT CAUSE FOUND:
Bug in backend/app/services/archive.py delete_archive() function (lines 914-929):
file_path = settings.base_dir / archive.file_path
if file_path.exists():
archive_dir = file_path.parent
shutil.rmtree(archive_dir, ignore_errors=True) # <-- THE BUG
If archive.file_path is EMPTY or MALFORMED:
- file_path = /opt/bambuddy / "" = /opt/bambuddy
- archive_dir = file_path.parent = /opt
- shutil.rmtree("/opt") --> DELETES ENTIRE /opt DIRECTORY!
TRIGGER:
- User was deleting archives via BamBuddy web UI on container 107 (3dp)
- One archive had corrupted/empty file_path in database
- Deleting that archive triggered shutil.rmtree("/opt")
- This deleted the entire /opt directory including BamBuddy itself
TIMELINE FOR CONTAINER 107 (3dp):
- 13:28:19 - Normal operation (WebSocket disconnect)
- 13:28:44 - DELETE /api/v1/archives/* requests failing with 500
(database already gone because /opt was deleted)
- ls -la / shows root directory modified at 13:28
FIX APPLIED (on container 109):
Safety checks added to delete_archive() in archive.py:
1. Check if file_path is not empty
2. Verify archive_dir is inside settings.archive_dir
3. Ensure archive_dir is at least 2 levels deep
4. Log error and refuse to delete if checks fail
TO INVESTIGATE AFTER ROLLBACK:
On container 107, after rolling back to autodaily260108003006:
# Find corrupted archive records
sqlite3 /opt/bambuddy/data/bambuddy.db \
"SELECT id, filename, file_path FROM print_archives
WHERE file_path = '' OR file_path IS NULL
OR file_path NOT LIKE 'archive/%';"
# Check all file_path values
sqlite3 /opt/bambuddy/data/bambuddy.db \
"SELECT id, file_path FROM print_archives ORDER BY id;"
CONTAINER 109 (this host):
- Were you also deleting archives around 11:22 and 12:22?
- Same bug could have been triggered here too
PROXMOX COMMANDS FOR ROLLBACK:
# Container 107 (3dp)
pct rollback 107 autodaily260108003006
pct start 107
# Container 109 (claude) - already done via UI
# Current snapshot: autodaily260108003004
WHAT TO DO NEXT:
1. Rollback container 107 to morning snapshot
2. Run the SQL query above to find corrupted archive
3. Apply the fix from container 109 to container 107
4. Understand how the file_path got corrupted in the first place
THE FIX (apply to both containers):
In backend/app/services/archive.py, the delete_archive function now has:
- Empty file_path check
- Path traversal protection (relative_to check)
- Minimum depth check (must be 2+ levels inside archive dir)
- Error logging for refused deletions
NOT CLAUDE CODE'S FAULT:
This was a bug in BamBuddy's own code that was triggered by:
1. Corrupted database record (unknown how it got corrupted)
2. User action (deleting archives via web UI)
+7 -7
View File
@@ -6,8 +6,8 @@ After=network.target
Type=simple
User=claude
Group=claude
WorkingDirectory=/opt/claude/projects/bambuddy
Environment="PATH=/opt/claude/projects/bambuddy/venv/bin"
WorkingDirectory=<dir>/bambuddy
Environment="PATH=<dir/bambuddy/venv/bin"
# Force kill after 10 seconds if graceful shutdown fails
TimeoutStopSec=10
@@ -18,12 +18,12 @@ ExecStopPost=-/usr/bin/pkill -9 ffmpeg
# Ensure directories exist and have correct permissions before starting
# The + prefix runs the command as root even though User=claude
ExecStartPre=+/bin/mkdir -p /opt/claude/projects/bambuddy/logs
ExecStartPre=+/bin/mkdir -p /opt/claude/projects/bambuddy/archive
ExecStartPre=+/bin/chown -R claude:claude /opt/claude/projects/bambuddy/logs
ExecStartPre=+/bin/chown -R claude:claude /opt/claude/projects/bambuddy/archive
ExecStartPre=+/bin/mkdir -p <dir>/bambuddy/logs
ExecStartPre=+/bin/mkdir -p <dir>/bambuddy/archive
ExecStartPre=+/bin/chown -R <user>:<user> <dir>/bambuddy/logs
ExecStartPre=+/bin/chown -R <user>:<user> <dir>/bambuddy/archive
ExecStart=/opt/claude/projects/bambuddy/venv/bin/uvicorn backend.app.main:app --host 0.0.0.0 --port 8000
ExecStart=<dir>/bambuddy/venv/bin/uvicorn backend.app.main:app --host 0.0.0.0 --port 8000
Restart=always
RestartSec=10
+1 -1
View File
@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
globalIgnores(['dist', 'coverage']),
{
files: ['**/*.{ts,tsx}'],
extends: [
+184 -1050
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -50,7 +50,7 @@
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^2.1.0",
"@vitest/coverage-v8": "^3.2.4",
"autoprefixer": "^10.4.22",
"baseline-browser-mapping": "^2.9.18",
"eslint": "^9.39.1",
@@ -64,6 +64,6 @@
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4",
"vitest": "^2.1.0"
"vitest": "^3.2.4"
}
}
@@ -2,7 +2,7 @@
* Tests for the GitHub Backup API client functions.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { describe, it, expect } from 'vitest';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import type {
@@ -29,6 +29,7 @@ describe('Layout', () => {
http.get('/api/v1/settings/', () => {
return HttpResponse.json({
check_updates: false,
check_printer_firmware: false,
auto_archive: true,
});
}),
@@ -261,4 +261,44 @@ describe('ArchivesPage', () => {
});
});
});
describe('plate navigation', () => {
it('renders archive cards with thumbnails', async () => {
render(<ArchivesPage />);
await waitFor(() => {
// Archive cards should render with their thumbnails
expect(screen.getByText('Benchy')).toBeInTheDocument();
// Thumbnail images should be present (archive cards have img elements)
const images = document.querySelectorAll('img[alt="Benchy"]');
expect(images.length).toBeGreaterThanOrEqual(0);
});
});
it('fetches plate data for multi-plate archives on hover', async () => {
// Setup handler for plates endpoint
server.use(
http.get('/api/v1/archives/:id/plates', ({ params }) => {
return HttpResponse.json({
archive_id: Number(params.id),
filename: 'test.3mf',
plates: [
{ index: 0, name: 'Plate 1', objects: ['Object A'], has_thumbnail: true, thumbnail_url: '/thumb1.png', print_time_seconds: 3600, filament_used_grams: 10, filaments: [] },
{ index: 1, name: 'Plate 2', objects: ['Object B'], has_thumbnail: true, thumbnail_url: '/thumb2.png', print_time_seconds: 1800, filament_used_grams: 5, filaments: [] },
],
is_multi_plate: true,
});
})
);
render(<ArchivesPage />);
await waitFor(() => {
expect(screen.getByText('Benchy')).toBeInTheDocument();
});
// Archives with multi-plate support will show navigation on hover
// The plates API is called lazily when hovering
});
});
});
@@ -105,6 +105,7 @@ describe('FileManagerPage', () => {
http.get('/api/v1/settings/', () => {
return HttpResponse.json({
check_updates: false,
check_printer_firmware: false,
library_disk_warning_gb: 5,
});
}),
@@ -215,6 +215,27 @@ describe('QueuePage', () => {
expect(printerElements.length).toBeGreaterThan(0);
});
});
it('renders queue items with plate_id correctly', async () => {
// Override with queue items that have plate_id set
server.use(
http.get('/api/v1/queue/', () => {
return HttpResponse.json([
{
...mockQueueItems[0],
plate_id: 2,
archive_name: 'Multi-plate Print',
},
]);
})
);
render(<QueuePage />);
await waitFor(() => {
expect(screen.getByText('Multi-plate Print')).toBeInTheDocument();
});
});
});
describe('empty state', () => {
@@ -31,6 +31,7 @@ const mockSettings = {
ha_url: '',
ha_token: '',
check_updates: false,
check_printer_firmware: false,
};
describe('SettingsPage', () => {
@@ -124,6 +125,16 @@ describe('SettingsPage', () => {
expect(screen.getByText('Appearance')).toBeInTheDocument();
});
});
it('shows updates section with firmware toggle', async () => {
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByText('Updates')).toBeInTheDocument();
expect(screen.getByText('Check for updates')).toBeInTheDocument();
expect(screen.getByText('Check printer firmware')).toBeInTheDocument();
});
});
});
describe('tabs navigation', () => {
@@ -48,6 +48,7 @@ const mockArchives = [
const mockSettings = {
currency: '$',
check_updates: false,
check_printer_firmware: false,
};
const mockFailureAnalysis = {
+5
View File
@@ -682,6 +682,7 @@ export interface AppSettings {
energy_cost_per_kwh: number;
energy_tracking_mode: 'print' | 'total';
check_updates: boolean;
check_printer_firmware: boolean;
notification_language: string;
// AMS threshold settings
ams_humidity_good: number; // <= this is green
@@ -2014,6 +2015,8 @@ export const api = {
method: 'POST',
}),
getArchiveThumbnail: (id: number) => `${API_BASE}/archives/${id}/thumbnail?v=${Date.now()}`,
getArchivePlateThumbnail: (id: number, plateIndex: number) =>
`${API_BASE}/archives/${id}/plate-thumbnail/${plateIndex}`,
getArchiveDownload: (id: number) => `${API_BASE}/archives/${id}/download`,
getArchiveGcode: (id: number) => `${API_BASE}/archives/${id}/gcode`,
getArchivePlatePreview: (id: number) => `${API_BASE}/archives/${id}/plate-preview`,
@@ -3071,6 +3074,8 @@ export const api = {
request<{ status: string; message: string }>(`/library/files/${id}`, { method: 'DELETE' }),
getLibraryFileDownloadUrl: (id: number) => `${API_BASE}/library/files/${id}/download`,
getLibraryFileThumbnailUrl: (id: number) => `${API_BASE}/library/files/${id}/thumbnail`,
getLibraryFilePlateThumbnail: (id: number, plateIndex: number) =>
`${API_BASE}/library/files/${id}/plate-thumbnail/${plateIndex}`,
getLibraryFileGcodeUrl: (id: number) => `${API_BASE}/library/files/${id}/gcode`,
moveLibraryFiles: (fileIds: number[], folderId: number | null) =>
request<{ status: string; moved: number }>('/library/files/move', {
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Github,
Play,
@@ -27,7 +27,6 @@ import type {
ScheduleType,
CloudAuthStatus,
Printer,
PrinterStatus,
} from '../api/client';
import { Card, CardContent, CardHeader } from './Card';
import { Button } from './Button';
@@ -149,11 +148,20 @@ export function GitHubBackupSettings() {
queryFn: api.getPrinters,
});
// Get printer statuses from cache (populated by WebSocket on other pages)
const printerStatuses = printers?.map(p => {
const status = queryClient.getQueryData<PrinterStatus>(['printerStatus', p.id]);
return { printer: p, connected: status?.connected ?? false };
}) ?? [];
// Fetch printer statuses from API (not just cache) to get accurate connection status
const printerStatusQueries = useQueries({
queries: (printers ?? []).map(printer => ({
queryKey: ['printerStatus', printer.id],
queryFn: () => api.getPrinterStatus(printer.id),
staleTime: 10000, // Consider stale after 10s
refetchInterval: 30000, // Refresh every 30s
})),
});
const printerStatuses = (printers ?? []).map((printer, index) => ({
printer,
connected: printerStatusQueries[index]?.data?.connected ?? false,
}));
const totalPrinters = printerStatuses.length;
const connectedPrinters = printerStatuses.filter(p => p.connected).length;
+84 -3
View File
@@ -41,6 +41,8 @@ import {
GitCompare,
Loader2,
FolderKanban,
ChevronLeft,
ChevronRight,
} from 'lucide-react';
import { api } from '../api/client';
import { openInSlicer } from '../utils/slicer';
@@ -133,9 +135,23 @@ function ArchiveCard({
const [showDeleteSource3mfConfirm, setShowDeleteSource3mfConfirm] = useState(false);
const [showDeleteF3dConfirm, setShowDeleteF3dConfirm] = useState(false);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
const [currentPlateIndex, setCurrentPlateIndex] = useState<number | null>(null);
const [showPlateNav, setShowPlateNav] = useState(false);
const source3mfInputRef = useRef<HTMLInputElement>(null);
const f3dInputRef = useRef<HTMLInputElement>(null);
// Fetch plates data for multi-plate browsing (lazy - only when hovering)
const { data: platesData } = useQuery({
queryKey: ['archive-plates', archive.id],
queryFn: () => api.getArchivePlates(archive.id),
enabled: showPlateNav, // Only fetch when user hovers to see navigation
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
const plates = platesData?.plates ?? [];
const isMultiPlate = platesData?.is_multi_plate ?? false;
const displayPlateIndex = currentPlateIndex ?? 0;
const source3mfUploadMutation = useMutation({
mutationFn: (file: File) => api.uploadSource3mf(archive.id, file),
onSuccess: (data) => {
@@ -505,11 +521,19 @@ function ArchiveCard({
</button>
)}
{/* Thumbnail */}
<div className="aspect-video bg-bambu-dark relative flex-shrink-0 overflow-hidden rounded-t-xl">
{/* Thumbnail with plate navigation */}
<div
className="aspect-video bg-bambu-dark relative flex-shrink-0 overflow-hidden rounded-t-xl"
onMouseEnter={() => setShowPlateNav(true)}
onMouseLeave={() => setShowPlateNav(false)}
>
{archive.thumbnail_path ? (
<img
src={api.getArchiveThumbnail(archive.id)}
src={
currentPlateIndex !== null && plates.length > 0
? api.getArchivePlateThumbnail(archive.id, plates[displayPlateIndex]?.index ?? 0)
: api.getArchiveThumbnail(archive.id)
}
alt={archive.print_name || archive.filename}
className="w-full h-full object-cover"
/>
@@ -518,6 +542,63 @@ function ArchiveCard({
<Image className="w-12 h-12 text-bambu-dark-tertiary" />
</div>
)}
{/* Plate navigation - only show for multi-plate archives */}
{isMultiPlate && plates.length > 1 && (
<>
{/* Left arrow */}
<button
className={`absolute left-1 top-1/2 -translate-y-1/2 p-1 rounded-full bg-black/60 hover:bg-black/80 transition-all ${
isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
onClick={(e) => {
e.stopPropagation();
setCurrentPlateIndex((prev) => {
const current = prev ?? 0;
return current > 0 ? current - 1 : plates.length - 1;
});
}}
title="Previous plate"
>
<ChevronLeft className="w-4 h-4 text-white" />
</button>
{/* Right arrow */}
<button
className={`absolute right-1 top-1/2 -translate-y-1/2 p-1 rounded-full bg-black/60 hover:bg-black/80 transition-all ${
isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
onClick={(e) => {
e.stopPropagation();
setCurrentPlateIndex((prev) => {
const current = prev ?? 0;
return current < plates.length - 1 ? current + 1 : 0;
});
}}
title="Next plate"
>
<ChevronRight className="w-4 h-4 text-white" />
</button>
{/* Dots indicator */}
<div
className={`absolute bottom-1 left-1/2 -translate-x-1/2 flex gap-1 px-2 py-1 rounded-full bg-black/50 transition-all ${
isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
>
{plates.map((plate, idx) => (
<button
key={plate.index}
className={`w-2 h-2 rounded-full transition-colors ${
idx === displayPlateIndex ? 'bg-bambu-green' : 'bg-white/50 hover:bg-white/80'
}`}
onClick={(e) => {
e.stopPropagation();
setCurrentPlateIndex(idx);
}}
title={plate.name || `Plate ${plate.index}`}
/>
))}
</div>
</>
)}
{/* Context menu button - visible on mobile, shows on hover for desktop */}
<button
className={`absolute top-2 left-2 p-1.5 rounded bg-black/50 hover:bg-black/70 transition-all ${
+10 -5
View File
@@ -915,6 +915,7 @@ function PrinterCard({
timeFormat = 'system',
cameraViewMode = 'window',
onOpenEmbeddedCamera,
checkPrinterFirmware = true,
}: {
printer: Printer;
hideIfDisconnected?: boolean;
@@ -932,6 +933,7 @@ function PrinterCard({
timeFormat?: 'system' | '12h' | '24h';
cameraViewMode?: 'window' | 'embedded';
onOpenEmbeddedCamera?: (printerId: number, printerName: string) => void;
checkPrinterFirmware?: boolean;
}) {
const queryClient = useQueryClient();
const navigate = useNavigate();
@@ -992,12 +994,13 @@ function PrinterCard({
refetchInterval: 30000, // Fallback polling, WebSocket handles real-time
});
// Check for firmware updates (cached for 5 minutes)
// Check for firmware updates (cached for 5 minutes, can be disabled in settings)
const { data: firmwareInfo } = useQuery({
queryKey: ['firmwareUpdate', printer.id],
queryFn: () => firmwareApi.checkPrinterUpdate(printer.id),
staleTime: 5 * 60 * 1000,
refetchInterval: 5 * 60 * 1000,
enabled: checkPrinterFirmware,
});
// Collect unique tray_info_idx values for cloud filament info lookup
@@ -2685,12 +2688,12 @@ function PrinterCard({
{/* Connection Info & Actions - hidden in compact mode */}
{viewMode === 'expanded' && (
<div className="mt-4 pt-4 border-t border-bambu-dark-tertiary flex items-center justify-between">
<div className="mt-4 pt-4 border-t border-bambu-dark-tertiary flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="text-xs text-bambu-gray">
<p>{printer.ip_address}</p>
<p className="truncate">{printer.serial_number}</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
{/* Chamber Light Toggle */}
<Button
variant="secondary"
@@ -4484,12 +4487,12 @@ export function PrintersPage() {
return (
<div className="p-4 md:p-8">
<div className="flex items-center justify-between mb-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div>
<h1 className="text-2xl font-bold text-white">Printers</h1>
<StatusSummaryBar printers={printers} />
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 sm:gap-3 flex-wrap">
{/* Sort dropdown */}
<div className="flex items-center gap-1">
<select
@@ -4648,6 +4651,7 @@ export function PrintersPage() {
timeFormat={settings?.time_format || 'system'}
cameraViewMode={settings?.camera_view_mode || 'window'}
onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}
checkPrinterFirmware={settings?.check_printer_firmware !== false}
/>
))}
</div>
@@ -4676,6 +4680,7 @@ export function PrintersPage() {
timeFormat={settings?.time_format || 'system'}
cameraViewMode={settings?.camera_view_mode || 'window'}
onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}
checkPrinterFirmware={settings?.check_printer_firmware !== false}
/>
))}
</div>
+11 -3
View File
@@ -354,17 +354,25 @@ function SortableQueueItem({
<div className="w-8" />
)}
{/* Thumbnail */}
{/* Thumbnail - use plate-specific thumbnail if plate_id is set */}
<div className="w-14 h-14 flex-shrink-0 bg-bambu-dark rounded-lg overflow-hidden">
{item.archive_thumbnail ? (
<img
src={api.getArchiveThumbnail(item.archive_id!)}
src={
item.plate_id != null
? api.getArchivePlateThumbnail(item.archive_id!, item.plate_id)
: api.getArchiveThumbnail(item.archive_id!)
}
alt=""
className="w-full h-full object-cover"
/>
) : item.library_file_thumbnail ? (
<img
src={api.getLibraryFileThumbnailUrl(item.library_file_id!)}
src={
item.plate_id != null
? api.getLibraryFilePlateThumbnail(item.library_file_id!, item.plate_id)
: api.getLibraryFileThumbnailUrl(item.library_file_id!)
}
alt=""
className="w-full h-full object-cover"
/>
+91 -55
View File
@@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock } from 'lucide-react';
import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { api } from '../api/client';
@@ -407,6 +407,7 @@ export function SettingsPage() {
settings.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh ||
settings.energy_tracking_mode !== localSettings.energy_tracking_mode ||
settings.check_updates !== localSettings.check_updates ||
(settings.check_printer_firmware ?? true) !== (localSettings.check_printer_firmware ?? true) ||
settings.notification_language !== localSettings.notification_language ||
settings.ams_humidity_good !== localSettings.ams_humidity_good ||
settings.ams_humidity_fair !== localSettings.ams_humidity_fair ||
@@ -469,6 +470,7 @@ export function SettingsPage() {
energy_cost_per_kwh: localSettings.energy_cost_per_kwh,
energy_tracking_mode: localSettings.energy_tracking_mode,
check_updates: localSettings.check_updates,
check_printer_firmware: localSettings.check_printer_firmware,
notification_language: localSettings.notification_language,
ams_humidity_good: localSettings.ams_humidity_good,
ams_humidity_fair: localSettings.ams_humidity_fair,
@@ -542,14 +544,16 @@ export function SettingsPage() {
// Local state for camera URL inputs (to avoid saving on every keystroke)
const [localCameraUrls, setLocalCameraUrls] = useState<Record<number, string>>({});
const cameraUrlSaveTimeoutRef = useRef<Record<number, ReturnType<typeof setTimeout>>>({});
const initializedPrinterUrlsRef = useRef<Set<number>>(new Set());
// Initialize local camera URLs from printer data
useEffect(() => {
if (printers) {
const urls: Record<number, string> = {};
printers.forEach(p => {
if (p.external_camera_url && localCameraUrls[p.id] === undefined) {
if (p.external_camera_url && !initializedPrinterUrlsRef.current.has(p.id)) {
urls[p.id] = p.external_camera_url;
initializedPrinterUrlsRef.current.add(p.id);
}
});
if (Object.keys(urls).length > 0) {
@@ -736,17 +740,20 @@ export function SettingsPage() {
<Globe className="w-4 h-4 inline mr-1" />
{t('settings.language')}
</label>
<select
value={i18n.language}
onChange={(e) => i18n.changeLanguage(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
{availableLanguages.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.nativeName} ({lang.name})
</option>
))}
</select>
<div className="relative">
<select
value={i18n.language}
onChange={(e) => i18n.changeLanguage(e.target.value)}
className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
>
{availableLanguages.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.nativeName} ({lang.name})
</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
</div>
<p className="text-xs text-bambu-gray mt-1">
{t('settings.languageDescription')}
</p>
@@ -755,17 +762,20 @@ export function SettingsPage() {
<label className="block text-sm text-bambu-gray mb-1">
{t('settings.defaultView')}
</label>
<select
value={defaultView}
onChange={(e) => handleDefaultViewChange(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
{defaultNavItems.map((item) => (
<option key={item.id} value={item.to}>
{t(item.labelKey)}
</option>
))}
</select>
<div className="relative">
<select
value={defaultView}
onChange={(e) => handleDefaultViewChange(e.target.value)}
className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
>
{defaultNavItems.map((item) => (
<option key={item.id} value={item.to}>
{t(item.labelKey)}
</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
</div>
<p className="text-xs text-bambu-gray mt-1">
{t('settings.defaultViewDescription')}
</p>
@@ -775,48 +785,57 @@ export function SettingsPage() {
<label className="block text-sm text-bambu-gray mb-1">
Date Format
</label>
<select
value={localSettings.date_format || 'system'}
onChange={(e) => updateSetting('date_format', e.target.value as 'system' | 'us' | 'eu' | 'iso')}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="system">System Default</option>
<option value="us">US (MM/DD/YYYY)</option>
<option value="eu">EU (DD/MM/YYYY)</option>
<option value="iso">ISO (YYYY-MM-DD)</option>
</select>
<div className="relative">
<select
value={localSettings.date_format || 'system'}
onChange={(e) => updateSetting('date_format', e.target.value as 'system' | 'us' | 'eu' | 'iso')}
className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
>
<option value="system">System Default</option>
<option value="us">US (MM/DD/YYYY)</option>
<option value="eu">EU (DD/MM/YYYY)</option>
<option value="iso">ISO (YYYY-MM-DD)</option>
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
</div>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">
Time Format
</label>
<select
value={localSettings.time_format || 'system'}
onChange={(e) => updateSetting('time_format', e.target.value as 'system' | '12h' | '24h')}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="system">System Default</option>
<option value="12h">12-hour (3:30 PM)</option>
<option value="24h">24-hour (15:30)</option>
</select>
<div className="relative">
<select
value={localSettings.time_format || 'system'}
onChange={(e) => updateSetting('time_format', e.target.value as 'system' | '12h' | '24h')}
className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
>
<option value="system">System Default</option>
<option value="12h">12-hour (3:30 PM)</option>
<option value="24h">24-hour (15:30)</option>
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
</div>
</div>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">
Default Printer
</label>
<select
value={localSettings.default_printer_id ?? ''}
onChange={(e) => updateSetting('default_printer_id', e.target.value ? Number(e.target.value) : null)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="">No default (ask each time)</option>
{printers?.map((printer) => (
<option key={printer.id} value={printer.id}>
{printer.name}
</option>
))}
</select>
<div className="relative">
<select
value={localSettings.default_printer_id ?? ''}
onChange={(e) => updateSetting('default_printer_id', e.target.value ? Number(e.target.value) : null)}
className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
>
<option value="">No default (ask each time)</option>
{printers?.map((printer) => (
<option key={printer.id} value={printer.id}>
{printer.name}
</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
</div>
<p className="text-xs text-bambu-gray mt-1">
Pre-select this printer for uploads, reprints, and other operations.
</p>
@@ -1296,6 +1315,23 @@ export function SettingsPage() {
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-white">Check printer firmware</p>
<p className="text-sm text-bambu-gray">
Check for printer firmware updates from Bambu Lab
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={localSettings.check_printer_firmware ?? true}
onChange={(e) => updateSetting('check_printer_firmware', e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
<div className="border-t border-bambu-dark-tertiary pt-4">
<div className="flex items-center justify-between mb-2">
<div>
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Debug script to investigate Bambu Cloud preset API responses."""
import asyncio
import json
import sys
from pathlib import Path
# Add backend to path
sys.path.insert(0, str(Path(__file__).parent.parent))
import httpx
from sqlalchemy import create_engine, text
from backend.app.core.config import settings
# Test preset IDs (from the warning logs)
TEST_IDS = ["GFG02", "GFL05", "GFA00", "GFA02", "GFA06"]
def get_token_from_db() -> str | None:
"""Get the stored token from the database."""
db_path = settings.base_dir / "bambuddy.db"
engine = create_engine(f"sqlite:///{db_path}")
with engine.connect() as conn:
result = conn.execute(text("SELECT value FROM settings WHERE key = 'bambu_cloud_token'"))
row = result.fetchone()
if row and row[0]:
return row[0]
return None
async def test_preset(setting_id: str, token: str, base_url: str = "https://api.bambulab.com"):
"""Test fetching a single preset and show full response."""
url = f"{base_url}/v1/iot-service/api/slicer/setting/{setting_id}"
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
print(f"\n{'=' * 60}")
print(f"Testing preset: {setting_id}")
print(f"URL: {url}")
print(f"{'=' * 60}")
async with httpx.AsyncClient() as client:
response = await client.get(url, headers=headers)
print(f"Status: {response.status_code}")
print("\nResponse body:")
try:
data = response.json()
print(json.dumps(data, indent=2))
except Exception:
print(response.text)
return response.status_code
async def main():
# Get token from DB
token = get_token_from_db()
if not token:
print("Could not find token in database.")
print("Make sure you're logged into Bambu Cloud in Bambuddy.")
sys.exit(1)
print(f"Found token in database (length: {len(token)})")
# Allow testing specific preset IDs from command line
test_ids = sys.argv[1:] if len(sys.argv) > 1 else TEST_IDS
# Test each preset
for preset_id in test_ids:
await test_preset(preset_id, token)
if __name__ == "__main__":
asyncio.run(main())
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -23,8 +23,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-BvTstZiS.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-8OJBC-HQ.css">
<script type="module" crossorigin src="/assets/index-DpCP4Cea.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-0Pk0mZuT.css">
</head>
<body>
<div id="root"></div>
+1
View File
@@ -1,5 +1,6 @@
#!/bin/sh
cd frontend
npm run lint
npm test
cd ..