mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
Merge branch '0.1.6-final' into feature/printer_model_queue
This commit is contained in:
+11
-11
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,4 +18,3 @@ jobs:
|
||||
days-before-stale: 21
|
||||
days-before-close: 7
|
||||
stale-issue-label: 'stale'
|
||||
remove-issue-labels: 'feedback'
|
||||
|
||||
+21
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Generated
+184
-1050
File diff suppressed because it is too large
Load Diff
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ${
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,5 +1,6 @@
|
||||
#!/bin/sh
|
||||
|
||||
cd frontend
|
||||
npm run lint
|
||||
npm test
|
||||
cd ..
|
||||
|
||||
Reference in New Issue
Block a user