Merge branch '0.1.9b' into main

This commit is contained in:
MartinNYHC
2026-02-09 14:22:18 +01:00
committed by GitHub
66 changed files with 7554 additions and 3446 deletions
+6
View File
@@ -10,3 +10,9 @@ LOG_LEVEL=INFO
# Enable file logging (logs written to logs/bambutrack.log)
LOG_TO_FILE=true
# Home Assistant Integration (for HA Add-on deployments)
# When both HA_URL and HA_TOKEN are set, Home Assistant integration is automatically enabled
# and these values override any database settings (read-only in UI)
# HA_URL=http://supervisor/core
# HA_TOKEN=your-long-lived-access-token
+43
View File
@@ -2,6 +2,49 @@
All notable changes to Bambuddy will be documented in this file.
## [0.1.9b] - Not released
### New Features
- **Hostname Support for Printers** ([#290](https://github.com/maziggy/bambuddy/issues/290)) — Printers can now be added using hostnames (e.g., `printer.local`, `my-printer.home.lan`) in addition to IPv4 addresses. Updated backend validation, frontend forms, and all locale labels.
- **Camera View Controls** ([#291](https://github.com/maziggy/bambuddy/issues/291)) — Added chamber light toggle and skip objects buttons to both embedded camera viewer and standalone camera page. Extracted skip objects modal into a reusable `SkipObjectsModal` component shared across PrintersPage and both camera views.
- **Per-Filament Spoolman Usage Tracking** ([#277](https://github.com/maziggy/bambuddy/pull/277)) — Accurate per-filament usage tracking for Spoolman integration with G-code parsing. Parses 3MF files at print start to build per-layer, per-filament extrusion maps. Reports accurate partial usage when prints fail or are cancelled based on actual layer progress. Tracking data stored in database to survive server restarts. Uses Spoolman's filament density for mm-to-grams conversion. Prefers `tray_uuid` over `tag_uid` for spool identification.
- **Disable AMS Weight Sync Setting** ([#277](https://github.com/maziggy/bambuddy/pull/277)) — New toggle to prevent AMS percentage-based weight estimates from overwriting Spoolman's granular usage-based calculations. Includes conditional "Report Partial Usage for Failed Prints" toggle.
- **Home Assistant Environment Variables** ([#283](https://github.com/maziggy/bambuddy/issues/283)) — Configure Home Assistant integration via `HA_URL` and `HA_TOKEN` environment variables for zero-configuration add-on deployments. Auto-enables when both variables are set. UI fields become read-only with lock icons when env-managed. Database values preserved as fallback.
- **Spoolman Fill Level for AMS Lite / External Spools** ([#293](https://github.com/maziggy/bambuddy/issues/293)) — AMS Lite (no weight sensor) always reported 0% fill level. Now uses Spoolman's remaining weight as a fallback when AMS reports 0%. External spools also show fill level from Spoolman data. Fill bars and hover cards indicate "(Spoolman)" when the data source is Spoolman rather than AMS.
- **Extended Support Bundle Diagnostics** — Support bundle now collects comprehensive diagnostic data for faster issue resolution: printer connectivity and firmware versions, integration status (Spoolman, MQTT, Home Assistant), network interfaces (subnets only), Python package versions, database health checks, Docker environment details, WebSocket connections, and log file info. All data properly anonymized — no IPs, names, or serials included. Privacy disclosure updated on System Info page.
### Improved
- **Auto-Detect Subnet for Printer Discovery** — Docker users no longer need to manually enter a subnet in the Add Printer dialog. Bambuddy auto-detects available network subnets and pre-selects the first one. When multiple subnets are available (e.g., eth0 + wlan0), a dropdown lets users choose. Falls back to manual text input if no subnets are detected.
- **Japanese Locale Complete Overhaul** — Restructured `ja.ts` from a divergent format (different key structure, 12 structural conflicts, 1,366 missing translations) to match the English/German locale structure exactly. Translated all 2,083 keys into Japanese, achieving full parity with EN/DE. Zero structural divergences, zero missing keys.
### Fixed
- **Sidebar Links Custom Icons Have Inverted Colors** ([#308](https://github.com/maziggy/bambuddy/issues/308)) — Custom uploaded icons in sidebar links had their colors inverted in dark mode due to a CSS `invert()` filter. The filter was intended for monochrome preset icons but was incorrectly applied to user-uploaded images (e.g., full-color logos). Removed the invert filter from custom icon rendering in the sidebar and the add/edit link modal.
- **Virtual Printer FTP Transfer Fails With Connection Reset** ([#58](https://github.com/maziggy/bambuddy/issues/58)) — Large 3MF uploads to the virtual printer intermittently failed with `[Errno 104] Connection reset by peer` while the small verify_job always succeeded. The `_handle_data_connection` callback returned immediately, allowing the asyncio server-handler task to complete while the data connection was still in active use. The passive port listener also stayed open during transfers, risking duplicate data connections. Fixed by keeping the callback alive until the transfer completes (`_transfer_done` event), closing the passive listener after accepting the connection, and rejecting duplicate data connections. Also added a 5-second drain timeout to MQTT status pushes to prevent blocking when the slicer is busy uploading.
- **Camera Stop 401 When Auth Enabled** — Camera stop requests (`sendBeacon`) failed with 401 Unauthorized when authentication was enabled because `sendBeacon` cannot send auth headers. Replaced with `fetch` + `keepalive: true` which supports Authorization headers while remaining reliable during page unload.
- **Spoolman Creates Duplicate Spools on Startup** ([#295](https://github.com/maziggy/bambuddy/pull/295)) — Each AMS tray independently fetched all spools from Spoolman, causing redundant API calls and duplicate spool creation with large databases (300+ spools). Now fetches spools once and reuses cached data across all tray operations. Added retry logic (3 attempts, 500ms delay) with connection recreation for transient network errors.
- **Filament Usage Charts Inflated by Quantity Multiplier** ([#229](https://github.com/maziggy/bambuddy/issues/229)) — Daily, weekly, and filament-type charts were multiplying `filament_used_grams` by print quantity, even though the value already represents the total for the entire job. A 26-object print using 126g was counted as 3,276g. Removed the erroneous multiplier from three aggregations in `FilamentTrends.tsx`.
- **Energy Cost Shows 0.00 in "Total Consumption" Mode** ([#284](https://github.com/maziggy/bambuddy/issues/284)) — Statistics Quick Stats showed 0.00 energy cost when Energy Display Mode was set to "Total Consumption" with Home Assistant smart plugs. The `homeassistant_service` was not configured with HA URL/token before querying plug energy data, causing it to silently return nothing.
- **H2D Pro Prints Fail at ~75% With Extrusion Motor Overload** ([#245](https://github.com/maziggy/bambuddy/issues/245)) — H2D Pro firmware interprets `use_ams: 1` (integer) as a nozzle index, routing filament to the deputy nozzle instead of the main nozzle. Bambu Studio sends `use_ams: true` (boolean) while using integers for other fields. Fixed by keeping `use_ams` as boolean for all printers including H2D series.
### Documentation
- **CONTRIBUTING.md: i18n & Authentication Guides** — Added Internationalization (i18n) section with locale file conventions, code examples, and parity rules. Added Authentication & Permissions section covering the opt-in auth pattern, permission conventions, and default group structure.
- **Proxy Mode Security Warning** — Added FTP data channel security warning to wiki, README, and website. Bambu Studio does not encrypt the FTP data channel despite negotiating PROT P; MQTT and FTP control channels are fully TLS-encrypted. VPN (Tailscale/WireGuard) recommended for full data encryption.
- **Docker Proxy Mode Ports** — Documented FTP passive data ports 50000-50100 required for proxy mode in Docker bridge mode. Updated port mappings in wiki virtual-printer and docker guides.
- **SSDP Discovery Limitations** — Added table showing when SSDP discovery works (same LAN, dual-homed, Docker host mode) vs when manual IP entry is required (VPN, Docker bridge, port forwarding). Updated wiki, README, and website.
- **Firewall Rules Updated** — Added port 50000-50100/tcp to all UFW, firewalld, and iptables examples for proxy mode FTP passive data.
### Testing
- **Mock FTPS Server & Comprehensive FTP Test Suite** — Added 67 automated test cases against a real implicit FTPS mock server, covering every known FTP failure mode from 0.1.8+:
- Mock server (`mock_ftp_server.py`) implements implicit TLS, custom AVBL command, and per-command failure injection
- Connection tests: auth, SSL modes (prot_p/prot_c), timeout, cache, disconnect edge cases
- Upload tests: chunked transfer via `transfercmd()`, progress callbacks, 553/550/552 error handling
- Download tests: bytes, to-file, 0-byte regression, large files, missing file cleanup
- Model-specific tests: X1C session reuse, A1/A1 Mini prot_c fallback, P1S, unknown model defaults
- Async wrapper tests: upload/download/list/delete with A1 fallback and multi-path download
- Failure injection tests: regressions for `error_perm` hierarchy, `diagnose_storage` CWD propagation, injection count decrement
- Added `pyOpenSSL` to `requirements-dev.txt` for Docker test image compatibility
## [0.1.8.1] - 2026-02-07
### Fixed
+3
View File
@@ -47,6 +47,9 @@ ENV LOG_DIR=/app/logs
ENV PORT=8000
EXPOSE 8000
EXPOSE 8883
EXPOSE 9990
EXPOSE 50000-50100
# Health check (uses PORT env var via shell)
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
+6 -5
View File
@@ -39,7 +39,8 @@
**Print from anywhere in the world** — Bambuddy's new Proxy Mode acts as a secure relay between your slicer and printer:
- 🔒 **End-to-end TLS encryption** — Your print data is encrypted from slicer to printer
- 🔒 **TLS-encrypted control channels** — MQTT and FTP control fully encrypted
- 🛡️ **VPN recommended** — Use Tailscale/WireGuard for full data encryption ([details](https://wiki.bambuddy.cool/features/virtual-printer/))
- 🌍 **No cloud dependency** — Direct connection through your own Bambuddy server
- 🔑 **Uses printer's access code** — No additional credentials needed
- ⚡ **Full-speed printing** — FTP and MQTT protocols proxied transparently
@@ -147,7 +148,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
- Queue events (waiting, skipped, failed)
### 🔧 Integrations
- [Spoolman](https://github.com/Donkie/Spoolman) filament sync
- [Spoolman](https://github.com/Donkie/Spoolman) filament sync with per-filament usage tracking and fill level display
- MQTT publishing for Home Assistant, Node-RED, etc.
- **Prometheus metrics** - Export printer telemetry for Grafana dashboards
- Bambu Cloud profile management
@@ -163,7 +164,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
- Send prints directly from Bambu Studio/Orca Slicer
- Configurable printer model (X1C, P1S, A1, H2D, etc.)
- Archive mode, Review mode, Queue mode, or Proxy mode
- SSDP discovery (appears in slicer automatically)
- SSDP discovery (same LAN) or manual IP entry (VPN/remote)
- Secure TLS/MQTT/FTP communication
### 🛠️ Maintenance & Support
@@ -174,7 +175,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
- Firmware update helper (LAN-only printers)
- Debug logging toggle with live indicator
- Live application log viewer with filtering
- Support bundle generator (privacy-filtered)
- Support bundle generator with comprehensive diagnostics (privacy-filtered)
### 🔒 Optional Authentication
- Enable/disable authentication any time
@@ -452,7 +453,7 @@ services:
network_mode: host
```
> **Note:** Docker's default bridge networking cannot receive SSDP multicast packets for automatic printer discovery. When using `network_mode: host`, Bambuddy can discover printers via subnet scanning - enter your network range (e.g., `192.168.1.0/24`) in the Add Printer dialog.
> **Note:** Docker's default bridge networking cannot receive SSDP multicast packets for automatic printer discovery. When using `network_mode: host`, Bambuddy auto-detects your network subnet and can discover printers via subnet scanning in the Add Printer dialog.
</details>
+5
View File
@@ -545,6 +545,11 @@ async def get_archive_stats(
plugs_result = await db.execute(select(SmartPlug))
plugs = list(plugs_result.scalars().all())
# Configure HA service once (needed for homeassistant-type plugs)
ha_url = await get_setting(db, "ha_url") or ""
ha_token = await get_setting(db, "ha_token") or ""
homeassistant_service.configure(ha_url, ha_token)
total_energy_kwh = 0.0
for plug in plugs:
if plug.plug_type == "tasmota":
+4
View File
@@ -18,6 +18,7 @@ from backend.app.services.discovery import (
is_running_in_docker,
subnet_scanner,
)
from backend.app.services.network_utils import get_network_interfaces
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/discovery", tags=["discovery"])
@@ -35,6 +36,7 @@ class DiscoveryInfo(BaseModel):
is_docker: bool
ssdp_running: bool
scan_running: bool
subnets: list[str] = []
class SubnetScanRequest(BaseModel):
@@ -67,10 +69,12 @@ async def get_discovery_info(
_: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
):
"""Get discovery environment info (Docker detection, etc.)."""
subnets = [iface["subnet"] for iface in get_network_interfaces()]
return DiscoveryInfo(
is_docker=is_running_in_docker(),
ssdp_running=discovery_service.is_running,
scan_running=subnet_scanner.is_running,
subnets=subnets,
)
+14
View File
@@ -19,6 +19,7 @@ from backend.app.schemas.printer import (
AMSUnit,
HMSErrorResponse,
NozzleInfoResponse,
NozzleRackSlot,
PrinterCreate,
PrinterResponse,
PrinterStatus,
@@ -360,6 +361,18 @@ async def get_printer_status(
for n in (state.nozzles or [])
]
# H2C nozzle rack (tool-changer dock positions)
nozzle_rack = [
NozzleRackSlot(
id=n.get("id", 0),
nozzle_type=n.get("type", ""),
nozzle_diameter=n.get("diameter", ""),
wear=n.get("wear"),
stat=n.get("stat"),
)
for n in (state.nozzle_rack or [])
]
# Convert print options to response format
print_options = PrintOptionsResponse(
spaghetti_detector=state.print_options.spaghetti_detector,
@@ -423,6 +436,7 @@ async def get_printer_status(
ipcam=state.ipcam,
wifi_signal=state.wifi_signal,
nozzles=nozzles,
nozzle_rack=nozzle_rack,
print_options=print_options,
stg_cur=state.stg_cur,
stg_cur_name=get_derived_status_name(state, printer.model),
+41
View File
@@ -101,6 +101,10 @@ async def get_settings(
else:
settings_dict[setting.key] = setting.value
# Get Home Assistant settings (with environment variable overrides)
ha_settings = await get_homeassistant_settings(db)
settings_dict.update(ha_settings)
return AppSettings(**settings_dict)
@@ -247,6 +251,43 @@ async def update_spoolman_settings(
return await get_spoolman_settings(db)
async def get_homeassistant_settings(db: AsyncSession) -> dict:
"""
Get Home Assistant integration settings.
Environment variables (HA_URL, HA_TOKEN) take precedence over database settings.
"""
import os
# Check environment variables first
ha_url_env = os.environ.get("HA_URL")
ha_token_env = os.environ.get("HA_TOKEN")
# Fall back to database values
ha_url = ha_url_env or await get_setting(db, "ha_url") or ""
ha_token = ha_token_env or await get_setting(db, "ha_token") or ""
ha_enabled_db = await get_setting(db, "ha_enabled") or "false"
# Track which settings come from environment
ha_url_from_env = bool(ha_url_env)
ha_token_from_env = bool(ha_token_env)
ha_env_managed = ha_url_from_env and ha_token_from_env
# Auto-enable when both env vars are set, otherwise use database value
if ha_url_env and ha_token_env:
ha_enabled = True
else:
ha_enabled = ha_enabled_db.lower() == "true"
return {
"ha_enabled": ha_enabled,
"ha_url": ha_url,
"ha_token": ha_token,
"ha_url_from_env": ha_url_from_env,
"ha_token_from_env": ha_token_from_env,
"ha_env_managed": ha_env_managed,
}
@router.get("/backup")
async def create_backup(
db: AsyncSession = Depends(get_db),
+14 -7
View File
@@ -354,8 +354,11 @@ async def list_ha_entities(
Requires HA connection settings to be configured in Settings.
"""
ha_url = await get_setting(db, "ha_url") or ""
ha_token = await get_setting(db, "ha_token") or ""
from backend.app.api.routes.settings import get_homeassistant_settings
ha_settings = await get_homeassistant_settings(db)
ha_url = ha_settings["ha_url"]
ha_token = ha_settings["ha_token"]
if not ha_url or not ha_token:
raise HTTPException(
@@ -376,8 +379,11 @@ async def list_ha_sensor_entities(
Returns sensors with power/energy units (W, kW, kWh, Wh).
Requires HA connection settings to be configured in Settings.
"""
ha_url = await get_setting(db, "ha_url") or ""
ha_token = await get_setting(db, "ha_token") or ""
from backend.app.api.routes.settings import get_homeassistant_settings
ha_settings = await get_homeassistant_settings(db)
ha_url = ha_settings["ha_url"]
ha_token = ha_settings["ha_token"]
if not ha_url or not ha_token:
raise HTTPException(
@@ -546,9 +552,10 @@ async def _get_service_for_plug(plug: SmartPlug, db: AsyncSession):
"""
if plug.plug_type == "homeassistant":
# Configure HA service with current settings
ha_url = await get_setting(db, "ha_url") or ""
ha_token = await get_setting(db, "ha_token") or ""
homeassistant_service.configure(ha_url, ha_token)
from backend.app.api.routes.settings import get_homeassistant_settings
ha_settings = await get_homeassistant_settings(db)
homeassistant_service.configure(ha_settings["ha_url"], ha_settings["ha_token"])
return homeassistant_service
return tasmota_service
+61 -6
View File
@@ -217,6 +217,19 @@ async def sync_printer_ams(
detail=f"AMS data format not supported. Keys: {list(ams_data.keys()) if isinstance(ams_data, dict) else type(ams_data).__name__}",
)
# OPTIMIZATION: Fetch all spools once before processing trays
# This eliminates redundant API calls (one per tray) when syncing multiple trays
logger.debug("[Printer %s] Fetching spools cache for sync...", printer.name)
try:
cached_spools = await client.get_spools()
logger.debug("[Printer %s] Cached %d spools for batch sync", printer.name, len(cached_spools))
except Exception as e:
logger.error("[Printer %s] Failed to fetch spools cache after retries: %s", printer.name, e)
raise HTTPException(
status_code=503,
detail=f"Failed to connect to Spoolman after multiple retries: {str(e)}",
)
for ams_unit in ams_units:
if not isinstance(ams_unit, dict):
continue
@@ -257,9 +270,20 @@ async def sync_printer_ams(
current_tray_uuids.add(spool_tag.upper())
try:
sync_result = await client.sync_ams_tray(tray, printer.name, disable_weight_sync=disable_weight_sync)
sync_result = await client.sync_ams_tray(
tray,
printer.name,
disable_weight_sync=disable_weight_sync,
cached_spools=cached_spools,
)
if sync_result:
synced += 1
# Add newly created spool to cache
if sync_result.get("id"):
spool_exists = any(s.get("id") == sync_result["id"] for s in cached_spools)
if not spool_exists:
cached_spools.append(sync_result)
logger.debug("Added newly created spool %s to cache", sync_result["id"])
logger.info(
"Synced %s from %s AMS %s tray %s", tray.tray_sub_brands, printer.name, ams_id, tray.tray_id
)
@@ -273,7 +297,9 @@ async def sync_printer_ams(
# Clear location for spools that were removed from this printer's AMS
try:
cleared = await client.clear_location_for_removed_spools(printer.name, current_tray_uuids)
cleared = await client.clear_location_for_removed_spools(
printer.name, current_tray_uuids, cached_spools=cached_spools
)
if cleared > 0:
logger.info("Cleared location for %s spools removed from %s", cleared, printer.name)
except Exception as e:
@@ -320,6 +346,19 @@ async def sync_all_printers(
# Track tray UUIDs per printer (for clearing removed spools)
printer_tray_uuids: dict[str, set[str]] = {}
# OPTIMIZATION: Fetch all spools once before processing ALL printers/trays
# This eliminates redundant API calls across all printers
logger.debug("Fetching spools cache for sync-all operation...")
try:
cached_spools = await client.get_spools()
logger.debug("Cached %d spools for batch sync across %d printers", len(cached_spools), len(printers))
except Exception as e:
logger.error("Failed to fetch spools cache after retries: %s", e)
raise HTTPException(
status_code=503,
detail=f"Failed to connect to Spoolman after multiple retries: {str(e)}",
)
for printer in printers:
state = printer_manager.get_status(printer.id)
if not state or not state.raw_data:
@@ -394,17 +433,28 @@ async def sync_all_printers(
try:
sync_result = await client.sync_ams_tray(
tray, printer.name, disable_weight_sync=disable_weight_sync
tray,
printer.name,
disable_weight_sync=disable_weight_sync,
cached_spools=cached_spools,
)
if sync_result:
total_synced += 1
# Add newly created spool to cache
if sync_result.get("id"):
spool_exists = any(s.get("id") == sync_result["id"] for s in cached_spools)
if not spool_exists:
cached_spools.append(sync_result)
logger.debug("Added newly created spool %s to cache", sync_result["id"])
except Exception as e:
all_errors.append(f"{printer.name} AMS {ams_id}:{tray.tray_id}: {e}")
# Clear location for spools that were removed from each printer's AMS
for printer_name, current_tray_uuids in printer_tray_uuids.items():
try:
cleared = await client.clear_location_for_removed_spools(printer_name, current_tray_uuids)
cleared = await client.clear_location_for_removed_spools(
printer_name, current_tray_uuids, cached_spools=cached_spools
)
if cleared > 0:
logger.info("Cleared location for %s spools removed from %s", cleared, printer_name)
except Exception as e:
@@ -548,7 +598,7 @@ async def get_linked_spools(
raise HTTPException(status_code=503, detail="Spoolman is not reachable")
spools = await client.get_spools()
linked: dict[str, int] = {}
linked: dict[str, dict] = {}
for spool in spools:
# Check if spool has a tag in extra field
@@ -558,7 +608,12 @@ async def get_linked_spools(
# Remove quotes if present (JSON encoded string)
clean_tag = tag.strip('"').upper()
if clean_tag:
linked[clean_tag] = spool["id"]
filament = spool.get("filament") or {}
linked[clean_tag] = {
"id": spool["id"],
"remaining_weight": spool.get("remaining_weight"),
"filament_weight": filament.get("weight"),
}
return {"linked": linked}
+258 -3
View File
@@ -1,6 +1,9 @@
"""Support endpoints for debug logging and support bundle generation."""
import asyncio
import importlib.metadata
import io
import ipaddress
import json
import logging
import os
@@ -8,24 +11,30 @@ import platform
import re
import zipfile
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy import func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.config import APP_VERSION, settings
from backend.app.core.database import async_session
from backend.app.core.permissions import Permission
from backend.app.core.websocket import ws_manager
from backend.app.models.archive import PrintArchive
from backend.app.models.filament import Filament
from backend.app.models.notification import NotificationProvider
from backend.app.models.printer import Printer
from backend.app.models.project import Project
from backend.app.models.settings import Settings
from backend.app.models.smart_plug import SmartPlug
from backend.app.models.user import User
from backend.app.services.discovery import is_running_in_docker
from backend.app.services.network_utils import get_network_interfaces
from backend.app.services.printer_manager import printer_manager
router = APIRouter(prefix="/support", tags=["support"])
logger = logging.getLogger(__name__)
@@ -313,8 +322,71 @@ def _sanitize_path(path: str) -> str:
return path
def _anonymize_mqtt_broker(broker: str) -> str:
"""Anonymize MQTT broker address. IPs become [IP], hostnames become *.domain."""
if not broker:
return ""
try:
ipaddress.ip_address(broker)
return "[IP]"
except ValueError:
# It's a hostname — show *.domain pattern
parts = broker.split(".")
if len(parts) >= 2:
return "*." + ".".join(parts[-2:])
return broker
async def _check_port(ip: str, port: int, timeout: float = 2.0) -> bool:
"""Test TCP connectivity to ip:port. Returns True if reachable."""
try:
_reader, writer = await asyncio.wait_for(asyncio.open_connection(ip, port), timeout=timeout)
writer.close()
await writer.wait_closed()
return True
except Exception:
return False
def _get_container_memory_limit() -> int | None:
"""Read cgroup memory limit. Returns bytes or None."""
# cgroup v2
v2 = Path("/sys/fs/cgroup/memory.max")
if v2.exists():
try:
val = v2.read_text().strip()
if val != "max":
return int(val)
except Exception:
pass
# cgroup v1
v1 = Path("/sys/fs/cgroup/memory/memory.limit_in_bytes")
if v1.exists():
try:
val = int(v1.read_text().strip())
# Values near page-aligned max (2^63-4096) mean unlimited
if val < 2**62:
return val
except Exception:
pass
return None
def _format_bytes(size_bytes: int) -> str:
"""Format bytes into human-readable string."""
if size_bytes < 1024:
return f"{size_bytes} B"
if size_bytes < 1024 * 1024:
return f"{size_bytes / 1024:.1f} KB"
if size_bytes < 1024 * 1024 * 1024:
return f"{size_bytes / (1024 * 1024):.1f} MB"
return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"
async def _collect_support_info() -> dict:
"""Collect all support information."""
in_docker = is_running_in_docker()
info = {
"generated_at": datetime.now().isoformat(),
"app": {
@@ -329,15 +401,29 @@ async def _collect_support_info() -> dict:
"python_version": platform.python_version(),
},
"environment": {
"docker": os.path.exists("/.dockerenv"),
"docker": in_docker,
"data_dir": _sanitize_path(str(settings.base_dir)),
"log_dir": _sanitize_path(str(settings.log_dir)),
"timezone": os.environ.get("TZ", ""),
},
"database": {},
"printers": [],
"settings": {},
}
# Docker-specific info
if in_docker:
try:
mem_limit = _get_container_memory_limit()
interfaces = get_network_interfaces()
info["docker"] = {
"container_memory_limit_bytes": mem_limit,
"container_memory_limit_formatted": _format_bytes(mem_limit) if mem_limit else None,
"network_mode_hint": "host" if len(interfaces) > 2 else "bridge",
}
except Exception:
logger.debug("Failed to collect Docker info", exc_info=True)
async with async_session() as db:
# Database stats
result = await db.execute(select(func.count(PrintArchive.id)))
@@ -358,15 +444,52 @@ async def _collect_support_info() -> dict:
result = await db.execute(select(func.count(SmartPlug.id)))
info["database"]["smart_plugs_total"] = result.scalar() or 0
# Printer info (anonymized - just models and connection status)
# Printer info (anonymized - no names, IPs, or serials)
result = await db.execute(select(Printer))
printers = result.scalars().all()
statuses = printer_manager.get_all_statuses()
# Check reachability in parallel
reachability_tasks = [_check_port(p.ip_address, 8883) for p in printers]
reachable_results = await asyncio.gather(*reachability_tasks, return_exceptions=True)
for i, printer in enumerate(printers):
state = statuses.get(printer.id)
reachable = reachable_results[i] if not isinstance(reachable_results[i], Exception) else False
# Count AMS units and trays from raw_data
ams_unit_count = 0
ams_tray_count = 0
has_vt_tray = False
if state:
ams_data = state.raw_data.get("ams")
if isinstance(ams_data, dict) and "ams" in ams_data:
ams_units = ams_data["ams"]
if isinstance(ams_units, list):
ams_unit_count = len(ams_units)
for unit in ams_units:
trays = unit.get("tray", [])
ams_tray_count += len([t for t in trays if t.get("tray_type")])
has_vt_tray = state.raw_data.get("vt_tray") is not None
info["printers"].append(
{
"index": i + 1,
"model": printer.model or "Unknown",
"nozzle_count": printer.nozzle_count,
"is_active": printer.is_active,
"mqtt_connected": state.connected if state else False,
"state": state.state if state else "unknown",
"firmware_version": state.firmware_version if state else None,
"wifi_signal": state.wifi_signal if state else None,
"reachable": bool(reachable),
"ams_unit_count": ams_unit_count,
"ams_tray_count": ams_tray_count,
"has_vt_tray": has_vt_tray,
"external_camera_configured": bool(printer.external_camera_url),
"plate_detection_enabled": printer.plate_detection_enabled,
"hms_error_count": len(state.hms_errors) if state else 0,
"nozzle_rack_count": len(state.nozzle_rack) if state else 0,
}
)
@@ -396,6 +519,138 @@ async def _collect_support_info() -> dict:
continue
info["settings"][s.key] = s.value
# Notification providers (anonymized — type/enabled/error status only)
try:
result = await db.execute(select(NotificationProvider))
providers = result.scalars().all()
info["integrations"] = info.get("integrations", {})
info["integrations"]["notification_providers"] = [
{
"type": p.provider_type,
"enabled": p.enabled,
"has_last_error": bool(p.last_error),
}
for p in providers
]
except Exception:
logger.debug("Failed to collect notification provider info", exc_info=True)
# Database health
try:
result = await db.execute(text("PRAGMA journal_mode"))
journal_mode = result.scalar()
result = await db.execute(text("PRAGMA quick_check"))
quick_check = result.scalar()
db_path = settings.base_dir / "bambuddy.db"
db_size = db_path.stat().st_size if db_path.exists() else 0
wal_path = settings.base_dir / "bambuddy.db-wal"
wal_size = wal_path.stat().st_size if wal_path.exists() else 0
info["database_health"] = {
"journal_mode": journal_mode,
"quick_check": quick_check,
"db_size_bytes": db_size,
"wal_size_bytes": wal_size,
}
except Exception:
logger.debug("Failed to collect database health info", exc_info=True)
# Integrations (lazy imports to avoid circular dependencies)
info.setdefault("integrations", {})
# Spoolman
try:
from backend.app.services.spoolman import get_spoolman_client
client = await get_spoolman_client()
if client:
reachable = await client.health_check()
info["integrations"]["spoolman"] = {"enabled": True, "reachable": reachable}
else:
info["integrations"]["spoolman"] = {"enabled": False, "reachable": False}
except Exception:
logger.debug("Failed to collect Spoolman info", exc_info=True)
# MQTT relay
try:
from backend.app.services.mqtt_relay import mqtt_relay
status = mqtt_relay.get_status()
info["integrations"]["mqtt_relay"] = {
"enabled": status.get("enabled", False),
"connected": status.get("connected", False),
"broker": _anonymize_mqtt_broker(status.get("broker", "")),
"port": status.get("port", 0),
"topic_prefix": status.get("topic_prefix", ""),
}
except Exception:
logger.debug("Failed to collect MQTT relay info", exc_info=True)
# Home Assistant (check ha_enabled setting)
try:
info["integrations"]["homeassistant"] = {
"enabled": info["settings"].get("ha_enabled", "false").lower() == "true",
}
except Exception:
logger.debug("Failed to collect Home Assistant info", exc_info=True)
# Dependencies
try:
dep_packages = [
"fastapi",
"uvicorn",
"pydantic",
"sqlalchemy",
"paho-mqtt",
"psutil",
"httpx",
"aiofiles",
"cryptography",
"opencv-python-headless",
"numpy",
]
info["dependencies"] = {}
for pkg in dep_packages:
try:
info["dependencies"][pkg] = importlib.metadata.version(pkg)
except importlib.metadata.PackageNotFoundError:
info["dependencies"][pkg] = None
except Exception:
logger.debug("Failed to collect dependency info", exc_info=True)
# Log file info
try:
log_file = settings.log_dir / "bambuddy.log"
if log_file.exists():
size = log_file.stat().st_size
info["log_file"] = {
"size_bytes": size,
"size_formatted": _format_bytes(size),
}
else:
info["log_file"] = {"size_bytes": 0, "size_formatted": "0 B"}
except Exception:
logger.debug("Failed to collect log file info", exc_info=True)
# Network interfaces (subnets only — already anonymized)
try:
interfaces = get_network_interfaces()
info["network"] = {
"interface_count": len(interfaces),
"interfaces": [{"name": iface["name"], "subnet": iface["subnet"]} for iface in interfaces],
}
except Exception:
logger.debug("Failed to collect network info", exc_info=True)
# WebSocket connections
try:
info["websockets"] = {
"active_connections": len(ws_manager.active_connections),
}
except Exception:
logger.debug("Failed to collect WebSocket info", exc_info=True)
return info
+1 -1
View File
@@ -5,7 +5,7 @@ from pathlib import Path
from pydantic_settings import BaseSettings
# Application version - single source of truth
APP_VERSION = "0.1.8.1"
APP_VERSION = "0.1.9b"
GITHUB_REPO = "maziggy/bambuddy"
# App directory - where the application is installed (for static files)
+35 -5
View File
@@ -260,11 +260,10 @@ async def _get_plug_energy(plug, db) -> dict | None:
For MQTT plugs, returns data from the subscription service.
"""
if plug.plug_type == "homeassistant":
from backend.app.api.routes.settings import get_setting
from backend.app.api.routes.settings import get_homeassistant_settings
ha_url = await get_setting(db, "ha_url") or ""
ha_token = await get_setting(db, "ha_token") or ""
homeassistant_service.configure(ha_url, ha_token)
ha_settings = await get_homeassistant_settings(db)
homeassistant_service.configure(ha_settings["ha_url"], ha_settings["ha_token"])
return await homeassistant_service.get_energy(plug)
elif plug.plug_type == "mqtt":
# MQTT plugs report "today" energy, not lifetime total
@@ -557,6 +556,20 @@ async def on_ams_change(printer_id: int, ams_data: list):
printer = result.scalar_one_or_none()
printer_name = printer.name if printer else f"Printer {printer_id}"
# OPTIMIZATION: Fetch all spools once before processing trays
# This eliminates redundant API calls (one per tray) when syncing multiple trays
logger.debug("[Printer %s] Fetching spools cache for AMS sync...", printer_id)
try:
cached_spools = await client.get_spools()
logger.debug("[Printer %s] Cached %d spools for batch sync", printer_id, len(cached_spools))
except Exception as e:
logger.error(
"[Printer %s] Failed to fetch spools cache after retries, aborting AMS sync: %s",
printer_id,
e,
)
return
# Sync each AMS tray
synced = 0
for ams_unit in ams_data:
@@ -569,9 +582,26 @@ async def on_ams_change(printer_id: int, ams_data: list):
continue # Empty tray
try:
result = await client.sync_ams_tray(tray, printer_name, disable_weight_sync=disable_weight_sync)
result = await client.sync_ams_tray(
tray,
printer_name,
disable_weight_sync=disable_weight_sync,
cached_spools=cached_spools,
)
if result:
synced += 1
# If a new spool was created, add it to the cache
# so subsequent trays can find it if they reference the same tag
if result.get("id"):
# Check if this spool already exists in cache
spool_exists = any(s.get("id") == result["id"] for s in cached_spools)
if not spool_exists:
cached_spools.append(result)
logger.debug(
"[Printer %s] Added newly created spool %s to cache",
printer_id,
result["id"],
)
except Exception as e:
logger.error("Error syncing AMS %s tray %s: %s", ams_id, tray.tray_id, e)
+1 -1
View File
@@ -12,7 +12,7 @@ class Printer(Base):
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
serial_number: Mapped[str] = mapped_column(String(50), unique=True)
ip_address: Mapped[str] = mapped_column(String(45))
ip_address: Mapped[str] = mapped_column(String(253))
access_code: Mapped[str] = mapped_column(String(20))
model: Mapped[str | None] = mapped_column(String(50))
location: Mapped[str | None] = mapped_column(String(100)) # Group/location name
+21 -2
View File
@@ -6,7 +6,11 @@ from pydantic import BaseModel, Field
class PrinterBase(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
serial_number: str = Field(..., min_length=1, max_length=50)
ip_address: str = Field(..., pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
ip_address: str = Field(
...,
max_length=253,
pattern=r"^(\d{1,3}(\.\d{1,3}){3}|[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*)$",
)
access_code: str = Field(..., min_length=1, max_length=20)
model: str | None = None
location: str | None = None # Group/location name
@@ -31,7 +35,11 @@ class PlateDetectionROI(BaseModel):
class PrinterUpdate(BaseModel):
name: str | None = None
ip_address: str | None = None
ip_address: str | None = Field(
default=None,
max_length=253,
pattern=r"^(\d{1,3}(\.\d{1,3}){3}|[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*)$",
)
access_code: str | None = None
model: str | None = None
location: str | None = None
@@ -137,6 +145,16 @@ class NozzleInfoResponse(BaseModel):
nozzle_diameter: str = "" # e.g., "0.4"
class NozzleRackSlot(BaseModel):
"""H2C nozzle rack slot (6-position tool-changer dock)."""
id: int = 0
nozzle_type: str = ""
nozzle_diameter: str = ""
wear: int | None = None
stat: int | None = None # Nozzle status (e.g. mounted/docked)
class PrintOptionsResponse(BaseModel):
"""AI detection and print options from xcam data."""
@@ -183,6 +201,7 @@ class PrinterStatus(BaseModel):
ipcam: bool = False # Live view enabled
wifi_signal: int | None = None # WiFi signal strength in dBm
nozzles: list[NozzleInfoResponse] = [] # Nozzle hardware info (index 0=left/primary, 1=right)
nozzle_rack: list[NozzleRackSlot] = [] # H2C 6-nozzle tool-changer rack
print_options: PrintOptionsResponse | None = None # AI detection and print options
# Calibration stage tracking
stg_cur: int = -1 # Current stage number (-1 = not calibrating)
+7
View File
@@ -106,6 +106,13 @@ class AppSettings(BaseModel):
ha_enabled: bool = Field(default=False, description="Enable Home Assistant integration for smart plug control")
ha_url: str = Field(default="", description="Home Assistant URL (e.g., http://192.168.1.100:8123)")
ha_token: str = Field(default="", description="Home Assistant Long-Lived Access Token")
ha_url_from_env: bool = Field(default=False, description="Whether HA URL is set via HA_URL environment variable")
ha_token_from_env: bool = Field(
default=False, description="Whether HA token is set via HA_TOKEN environment variable"
)
ha_env_managed: bool = Field(
default=False, description="Whether HA integration is fully managed by environment variables"
)
# File Manager / Library settings
library_archive_mode: str = Field(
+24 -5
View File
@@ -147,6 +147,8 @@ class PrinterState:
# H2D per-extruder tray_now from snow field: {extruder_id: normalized_global_tray_id}
# snow encodes AMS ID in high byte: ams_id = snow >> 8, slot = snow & 0xFF
h2d_extruder_snow: dict = field(default_factory=dict)
# H2C nozzle rack: full device.nozzle.info array for tool-changer printers (>2 nozzles)
nozzle_rack: list = field(default_factory=list)
# Timestamp of last AMS data update (for RFID refresh detection)
last_ams_update: float = 0.0
# Printable objects for skip object functionality: {identify_id: object_name}
@@ -1740,12 +1742,24 @@ class BambuMQTTClient:
if "nozzle_diameter_2" in data:
self.state.nozzles[1].nozzle_diameter = str(data["nozzle_diameter_2"])
# H2D series: Nozzle hardware info is in device.nozzle.info array
# H2D/H2C series: Nozzle hardware info is in device.nozzle.info array
if "device" in data and isinstance(data["device"], dict):
device = data["device"]
nozzle_data = device.get("nozzle", {})
nozzle_info = nozzle_data.get("info", [])
if isinstance(nozzle_info, list):
# H2C tool-changer: >2 entries means nozzle rack (6 dock + 1 mounted = 7)
if len(nozzle_info) > 2:
self.state.nozzle_rack = [
{
"id": n.get("id", i),
"type": str(n.get("type", "")),
"diameter": str(n.get("diameter", "")),
"wear": n.get("wear"),
"stat": n.get("stat"),
}
for i, n in enumerate(nozzle_info)
]
for nozzle in nozzle_info:
idx = nozzle.get("id", 0)
if idx < len(self.state.nozzles):
@@ -2049,8 +2063,10 @@ class BambuMQTTClient:
slot_id = tray_id % 4
ams_mapping2.append({"ams_id": ams_id, "slot_id": slot_id})
# H2D series requires integer values (0/1) for boolean fields
# Other printers (X1C, P1S, A1, etc.) require actual booleans
# H2D series requires integer values (0/1) for calibration/leveling fields
# but use_ams MUST remain boolean — H2D Pro firmware interprets integer
# values as nozzle index (1 = deputy nozzle), causing wrong extruder routing
# Other printers (X1C, P1S, A1, etc.) require actual booleans for all fields
is_h2d = self.model and self.model.upper().strip() in ("H2D", "H2D PRO", "H2DPRO", "H2C", "H2S")
command = {
@@ -2068,7 +2084,7 @@ class BambuMQTTClient:
"flow_cali": (1 if flow_cali else 0) if is_h2d else flow_cali,
"vibration_cali": (1 if vibration_cali else 0) if is_h2d else vibration_cali,
"layer_inspect": (1 if layer_inspect else 0) if is_h2d else layer_inspect,
"use_ams": (1 if use_ams else 0) if is_h2d else use_ams,
"use_ams": use_ams,
"cfg": "0",
"extrude_cali_flag": 0,
"extrude_cali_manual_mode": 0,
@@ -2082,7 +2098,10 @@ class BambuMQTTClient:
}
if is_h2d:
logger.info("[%s] H2D series detected: using integer format for boolean fields", self.serial_number)
logger.info(
"[%s] H2D series detected: using integer format for calibration fields (use_ams stays boolean)",
self.serial_number,
)
# P2S-specific parameter adjustments
# P2S printer doesn't support vibration calibration like X1/P1 series
+2
View File
@@ -647,6 +647,8 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
"chamber_light": state.chamber_light,
# Active extruder for dual-nozzle printers (0=right, 1=left)
"active_extruder": state.active_extruder,
# H2C nozzle rack (tool-changer dock positions)
"nozzle_rack": state.nozzle_rack or [],
}
# Add cover URL if there's an active print and printer_id is provided
# Include PAUSE/PAUSED states so skip objects modal can show cover
+4 -12
View File
@@ -40,28 +40,20 @@ class SmartPlugManager:
async def _configure_ha_service(self, db: AsyncSession | None = None):
"""Configure the HA service with URL and token from settings."""
from backend.app.models.settings import Settings
from backend.app.api.routes.settings import get_homeassistant_settings
try:
if db:
# Use provided session
result = await db.execute(select(Settings).where(Settings.key == "ha_url"))
ha_url_setting = result.scalar_one_or_none()
result = await db.execute(select(Settings).where(Settings.key == "ha_token"))
ha_token_setting = result.scalar_one_or_none()
ha_settings = await get_homeassistant_settings(db)
else:
# Create new session
from backend.app.core.database import async_session
async with async_session() as session:
result = await session.execute(select(Settings).where(Settings.key == "ha_url"))
ha_url_setting = result.scalar_one_or_none()
result = await session.execute(select(Settings).where(Settings.key == "ha_token"))
ha_token_setting = result.scalar_one_or_none()
ha_settings = await get_homeassistant_settings(session)
ha_url = ha_url_setting.value if ha_url_setting else ""
ha_token = ha_token_setting.value if ha_token_setting else ""
homeassistant_service.configure(ha_url, ha_token)
homeassistant_service.configure(ha_settings["ha_url"], ha_settings["ha_token"])
except Exception as e:
logger.warning("Failed to configure HA service: %s", e)
+89 -18
View File
@@ -1,5 +1,6 @@
"""Spoolman integration service for syncing AMS filament data."""
import asyncio
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
@@ -68,9 +69,22 @@ class SpoolmanClient:
self._connected = False
async def _get_client(self) -> httpx.AsyncClient:
"""Get or create the HTTP client."""
"""Get or create the HTTP client with connection pooling limits.
Configures the client to prevent idle connection issues:
- max_keepalive_connections=5: Limit number of persistent connections
- keepalive_expiry=30: Close idle connections after 30 seconds
- max_connections=10: Limit total connections to prevent resource exhaustion
"""
if self._client is None:
self._client = httpx.AsyncClient(timeout=10.0)
self._client = httpx.AsyncClient(
timeout=10.0,
limits=httpx.Limits(
max_keepalive_connections=5,
max_connections=10,
keepalive_expiry=30.0,
),
)
return self._client
async def close(self):
@@ -101,19 +115,59 @@ class SpoolmanClient:
return self._connected
async def get_spools(self) -> list[dict]:
"""Get all spools from Spoolman.
"""Get all spools from Spoolman with retry logic.
Attempts to fetch spools up to 3 times with 500ms delay between attempts.
This handles transient network errors like closed connections.
Returns:
List of spool dictionaries.
Raises:
Exception: If all 3 retry attempts fail.
"""
try:
client = await self._get_client()
response = await client.get(f"{self.api_url}/spool")
response.raise_for_status()
return response.json()
except Exception as e:
logger.error("Failed to get spools from Spoolman: %s", e)
return []
max_attempts = 3
retry_delay = 0.5 # 500ms
for attempt in range(1, max_attempts + 1):
try:
client = await self._get_client()
response = await client.get(f"{self.api_url}/spool")
response.raise_for_status()
spools = response.json()
if attempt > 1:
logger.info("Successfully fetched %d spools on attempt %d", len(spools), attempt)
return spools
except (httpx.ReadError, httpx.RemoteProtocolError, httpx.ConnectError) as e:
# Connection-related errors - close and recreate client for next attempt
if attempt < max_attempts:
logger.warning(
"Connection error getting spools (attempt %d/%d): %s. Recreating client and retrying in %dms...",
attempt,
max_attempts,
e,
int(retry_delay * 1000),
)
# Close the stale client and recreate it
await self.close()
await asyncio.sleep(retry_delay)
else:
logger.error("Failed to get spools from Spoolman after %d attempts: %s", max_attempts, e)
raise
except Exception as e:
# Other errors (HTTP errors, JSON decode errors, etc.)
if attempt < max_attempts:
logger.warning(
"Failed to get spools from Spoolman (attempt %d/%d): %s. Retrying in %dms...",
attempt,
max_attempts,
e,
int(retry_delay * 1000),
)
await asyncio.sleep(retry_delay)
else:
logger.error("Failed to get spools from Spoolman after %d attempts: %s", max_attempts, e)
raise
async def get_filaments(self) -> list[dict]:
"""Get all internal filaments from Spoolman.
@@ -387,16 +441,18 @@ class SpoolmanClient:
logger.error("Failed to record spool usage in Spoolman: %s", e)
return None
async def find_spool_by_tag(self, tag_uid: str) -> dict | None:
async def find_spool_by_tag(self, tag_uid: str, cached_spools: list[dict] | None = None) -> dict | None:
"""Find a spool by its RFID tag UID.
Args:
tag_uid: The RFID tag UID to search for
cached_spools: Optional pre-fetched list of spools to search (avoids API call)
Returns:
Spool dictionary or None if not found.
"""
spools = await self.get_spools()
# Use cached spools if provided, otherwise fetch from API
spools = cached_spools if cached_spools is not None else await self.get_spools()
# Normalize tag_uid for comparison (uppercase, strip quotes)
search_tag = tag_uid.strip('"').upper()
@@ -412,16 +468,20 @@ class SpoolmanClient:
return spool
return None
async def find_spools_by_location_prefix(self, location_prefix: str) -> list[dict]:
async def find_spools_by_location_prefix(
self, location_prefix: str, cached_spools: list[dict] | None = None
) -> list[dict]:
"""Find all spools with locations starting with a given prefix.
Args:
location_prefix: The location prefix to search for (e.g., "PrinterName - ")
cached_spools: Optional pre-fetched list of spools to search (avoids API call)
Returns:
List of spool dictionaries with matching locations.
"""
spools = await self.get_spools()
# Use cached spools if provided, otherwise fetch from API
spools = cached_spools if cached_spools is not None else await self.get_spools()
matching = []
for spool in spools:
location = spool.get("location", "")
@@ -433,6 +493,7 @@ class SpoolmanClient:
self,
printer_name: str,
current_tray_uuids: set[str],
cached_spools: list[dict] | None = None,
) -> int:
"""Clear location for spools that are no longer in the AMS.
@@ -443,12 +504,13 @@ class SpoolmanClient:
Args:
printer_name: The printer name used as location prefix
current_tray_uuids: Set of tray_uuids currently in the AMS
cached_spools: Optional pre-fetched list of spools to search (avoids API call)
Returns:
Number of spools whose location was cleared.
"""
location_prefix = f"{printer_name} - "
spools_at_printer = await self.find_spools_by_location_prefix(location_prefix)
spools_at_printer = await self.find_spools_by_location_prefix(location_prefix, cached_spools=cached_spools)
cleared_count = 0
for spool in spools_at_printer:
@@ -662,7 +724,13 @@ class SpoolmanClient:
"""
return (remain_percent / 100.0) * spool_weight
async def sync_ams_tray(self, tray: AMSTray, printer_name: str, disable_weight_sync: bool = False) -> dict | None:
async def sync_ams_tray(
self,
tray: AMSTray,
printer_name: str,
disable_weight_sync: bool = False,
cached_spools: list[dict] | None = None,
) -> dict | None:
"""Sync a single AMS tray to Spoolman.
Only syncs trays with valid Bambu Lab tray_uuid (32 hex characters).
@@ -676,6 +744,9 @@ class SpoolmanClient:
printer_name: Name of the printer for location
disable_weight_sync: If True, skip updating remaining_weight for existing spools.
This allows Spoolman's granular usage tracking to maintain accurate weights.
cached_spools: Optional pre-fetched list of spools to search (avoids API calls).
When provided, this cache is passed to find_spool_by_tag to avoid redundant
API calls during batch sync operations.
Returns:
Synced spool dictionary or None if skipped or failed.
@@ -716,7 +787,7 @@ class SpoolmanClient:
location = f"{printer_name} - {self.convert_ams_slot_to_location(tray.ams_id, tray.tray_id)}"
# Find existing spool by tag (tray_uuid or tag_uid, stored as "tag" in Spoolman)
existing = await self.find_spool_by_tag(spool_tag)
existing = await self.find_spool_by_tag(spool_tag, cached_spools=cached_spools)
if existing:
# Update existing spool
logger.info("Updating existing spool %s for tag %s...", existing["id"], spool_tag[:16])
@@ -193,13 +193,39 @@ class CertificateService:
return ca_key, ca_cert
def generate_certificates(self) -> tuple[Path, Path]:
def _build_san_entries(self, local_ip: str, additional_ips: list[str] | None) -> list[x509.GeneralName]:
"""Build Subject Alternative Name entries for the printer certificate."""
entries: list[x509.GeneralName] = [
x509.DNSName("localhost"),
x509.DNSName("bambuddy"),
x509.DNSName(self.serial),
x509.IPAddress(IPv4Address(local_ip)),
x509.IPAddress(IPv4Address("127.0.0.1")),
]
seen_ips = {local_ip, "127.0.0.1"}
if additional_ips:
for ip in additional_ips:
if ip and ip not in seen_ips:
try:
entries.append(x509.IPAddress(IPv4Address(ip)))
seen_ips.add(ip)
logger.info("Added additional SAN IP: %s", ip)
except ValueError:
logger.warning("Skipping invalid additional SAN IP: %s", ip)
return entries
def generate_certificates(self, additional_ips: list[str] | None = None) -> tuple[Path, Path]:
"""Generate printer certificate (reusing existing CA if available).
Creates a certificate chain mimicking real Bambu printers:
- CA certificate (reused if exists and valid, otherwise generated)
- Printer certificate (CN=serial, signed by CA)
Args:
additional_ips: Extra IP addresses to include in certificate SAN.
Used in proxy mode to include the remote interface IP so the
slicer's TLS handshake succeeds when connecting to the proxy.
Returns:
Tuple of (cert_path, key_path)
"""
@@ -245,15 +271,7 @@ class CertificateService:
critical=True,
)
.add_extension(
x509.SubjectAlternativeName(
[
x509.DNSName("localhost"),
x509.DNSName("bambuddy"),
x509.DNSName(self.serial),
x509.IPAddress(IPv4Address(local_ip)),
x509.IPAddress(IPv4Address("127.0.0.1")),
]
),
x509.SubjectAlternativeName(self._build_san_entries(local_ip, additional_ips)),
critical=False,
)
.add_extension(
@@ -9,6 +9,7 @@ immediately upon connection, before any FTP commands are exchanged.
import asyncio
import logging
import os
import random
import ssl
from collections.abc import Callable
@@ -31,6 +32,8 @@ class FTPSession:
access_code: str,
ssl_context: ssl.SSLContext,
on_file_received: Callable[[Path, str], None] | None,
passive_port_range: tuple[int, int] = (50000, 50100),
pasv_address: str = "",
):
self.reader = reader
self.writer = writer
@@ -38,6 +41,8 @@ class FTPSession:
self.access_code = access_code
self.ssl_context = ssl_context
self.on_file_received = on_file_received
self.passive_port_range = passive_port_range
self.pasv_address = pasv_address
self.authenticated = False
self.username: str | None = None
@@ -50,6 +55,7 @@ class FTPSession:
self._data_reader: asyncio.StreamReader | None = None
self._data_writer: asyncio.StreamWriter | None = None
self._data_connected = asyncio.Event()
self._transfer_done = asyncio.Event()
peername = writer.get_extra_info("peername")
self.remote_ip = peername[0] if peername else "unknown"
@@ -113,6 +119,9 @@ class FTPSession:
async def _cleanup(self) -> None:
"""Clean up session resources."""
# Release any waiting data connection callback
self._transfer_done.set()
if self.data_server:
self.data_server.close()
try:
@@ -159,6 +168,7 @@ class FTPSession:
features = [
"211-Features:",
" PASV",
" EPSV",
" UTF8",
" SIZE",
"211 End",
@@ -196,6 +206,28 @@ class FTPSession:
else:
await self.send(504, "Type not supported")
async def _bind_passive_port(self) -> bool:
"""Try to bind a passive data port with retries.
Returns True if a port was successfully bound, False otherwise.
Sets self.data_server and self.data_port on success.
"""
port_min, port_max = self.passive_port_range
for attempt in range(10):
port = random.randint(port_min, port_max)
try:
self.data_server = await asyncio.start_server(
self._handle_data_connection,
"0.0.0.0", # nosec B104
port,
ssl=self.ssl_context,
)
self.data_port = port
return True
except OSError:
logger.debug("FTP passive port %s in use, retrying (%s/10)", port, attempt + 1)
return False
async def cmd_EPSV(self, arg: str) -> None:
"""Handle EPSV command - Extended Passive Mode (IPv6 compatible)."""
if not self.authenticated:
@@ -205,29 +237,18 @@ class FTPSession:
# Close any existing data connection/server
await self._close_data_connection()
# Reset connection state
# Reset connection state for the new transfer
self._data_connected.clear()
self._data_reader = None
self._data_writer = None
self._transfer_done = asyncio.Event()
# Find a free port for passive data connection
self.data_port = random.randint(50000, 60000)
try:
# Create data server with TLS - use same context for session reuse
self.data_server = await asyncio.start_server(
self._handle_data_connection,
"0.0.0.0", # nosec B104
self.data_port,
ssl=self.ssl_context,
)
if await self._bind_passive_port():
# EPSV response format: 229 Entering Extended Passive Mode (|||port|)
await self.send(229, f"Entering Extended Passive Mode (|||{self.data_port}|)")
logger.info("FTP EPSV listening on port %s", self.data_port)
except Exception as e:
logger.error("Failed to create EPSV data connection: %s", e)
else:
logger.error("Failed to bind any passive port for EPSV")
await self.send(425, "Cannot open data connection")
async def cmd_PASV(self, arg: str) -> None:
@@ -239,27 +260,24 @@ class FTPSession:
# Close any existing data connection/server
await self._close_data_connection()
# Reset connection state
# Reset connection state for the new transfer
self._data_connected.clear()
self._data_reader = None
self._data_writer = None
self._transfer_done = asyncio.Event()
# Find a free port for passive data connection
self.data_port = random.randint(50000, 60000)
try:
# Create data server with TLS
self.data_server = await asyncio.start_server(
self._handle_data_connection,
"0.0.0.0", # nosec B104
self.data_port,
ssl=self.ssl_context,
)
# Get server's IP for response
# Use the IP the client connected to
sockname = self.writer.get_extra_info("sockname")
ip = sockname[0] if sockname else "127.0.0.1"
if await self._bind_passive_port():
# Determine the IP to advertise in PASV response
if self.pasv_address:
# Explicit override (e.g., for Docker bridge mode behind NAT)
ip = self.pasv_address
else:
# Use the local IP of the control connection
sockname = self.writer.get_extra_info("sockname")
ip = sockname[0] if sockname else "127.0.0.1"
# 0.0.0.0 is not routable — fall back to control connection IP
if ip == "0.0.0.0":
ip = "127.0.0.1"
# Format IP and port for PASV response
ip_parts = ip.split(".")
@@ -270,14 +288,30 @@ class FTPSession:
227,
f"Entering Passive Mode ({ip_parts[0]},{ip_parts[1]},{ip_parts[2]},{ip_parts[3]},{port_hi},{port_lo})",
)
logger.info("FTP PASV listening on port %s", self.data_port)
except Exception as e:
logger.error("Failed to create passive data connection: %s", e)
logger.info("FTP PASV listening on %s:%s", ip, self.data_port)
else:
logger.error("Failed to bind any passive port for PASV")
await self.send(425, "Cannot open data connection")
async def _handle_data_connection(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
"""Handle incoming data connection (used by PASV)."""
"""Handle incoming data connection (used by PASV/EPSV).
This callback stays alive until the transfer completes to ensure the
asyncio task holds strong references to the reader/writer throughout
the data transfer. If the callback returned immediately, the task
would complete and the StreamReaderProtocol could release its strong
reader reference, potentially destabilising the connection.
"""
# Reject duplicate connections — only one data connection per transfer
if self._data_reader is not None:
logger.warning("FTP rejecting duplicate data connection from %s", self.remote_ip)
try:
writer.close()
await writer.wait_closed()
except OSError:
pass
return
# Log TLS details for debugging
ssl_obj = writer.get_extra_info("ssl_object")
if ssl_obj:
@@ -291,13 +325,26 @@ class FTPSession:
logger.info("FTP data connection established from %s", self.remote_ip)
self._data_reader = reader
self._data_writer = writer
# Stop accepting further connections on the passive port
if self.data_server:
self.data_server.close()
self._data_connected.set()
# Don't close - let the transfer command handle it
# Keep this callback alive until the transfer command (STOR/RETR)
# finishes. This ensures the asyncio server-handler task holds strong
# references to reader/writer for the entire transfer lifetime.
await self._transfer_done.wait()
async def _close_data_connection(self) -> None:
"""Close the data connection and server."""
had_connection = self._data_writer is not None or self.data_server is not None
# Signal the _handle_data_connection callback to return, allowing
# its asyncio task to complete cleanly.
self._transfer_done.set()
if self._data_writer:
try:
self._data_writer.close()
@@ -325,7 +372,7 @@ class FTPSession:
await self.send(530, "Not logged in")
return
if not self.data_server:
if not self.data_server and not self._data_connected.is_set():
await self.send(425, "Use PASV first")
return
@@ -352,20 +399,28 @@ class FTPSession:
# Receive data
data_content: list[bytes] = []
total_received = 0
try:
while True:
chunk = await asyncio.wait_for(self._data_reader.read(65536), timeout=60)
if not chunk:
break
data_content.append(chunk)
logger.debug("FTP received chunk: %s bytes", len(chunk))
total_received += len(chunk)
logger.debug("FTP received chunk: %s bytes (total: %s)", len(chunk), total_received)
except TimeoutError:
logger.error("FTP data transfer timeout")
logger.error("FTP data transfer timeout after %s bytes for %s", total_received, filename)
await self.send(426, "Transfer timeout")
await self._close_data_connection()
return
except Exception as e:
logger.error("FTP data transfer error: %s", e)
logger.error(
"FTP data transfer error after %s bytes for %s: %s(%s)",
total_received,
filename,
type(e).__name__,
e,
)
await self.send(426, f"Transfer failed: {e}")
await self._close_data_connection()
return
@@ -458,6 +513,9 @@ class FTPSession:
class VirtualPrinterFTPServer:
"""Implicit FTPS server that accepts uploads from slicers."""
PASSIVE_PORT_MIN = 50000
PASSIVE_PORT_MAX = 50100
def __init__(
self,
upload_dir: Path,
@@ -487,6 +545,8 @@ class VirtualPrinterFTPServer:
self._running = False
self._ssl_context: ssl.SSLContext | None = None
self._active_sessions: list[asyncio.Task] = []
# Override PASV response IP for Docker bridge mode / NAT environments
self._pasv_address = os.environ.get("VIRTUAL_PRINTER_PASV_ADDRESS", "")
async def start(self) -> None:
"""Start the implicit FTPS server."""
@@ -504,6 +564,7 @@ class VirtualPrinterFTPServer:
self._ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
self._ssl_context.load_cert_chain(str(self.cert_path), str(self.key_path))
self._ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
self._ssl_context.maximum_version = ssl.TLSVersion.TLSv1_2
# Use standard TLS settings for compatibility
self._ssl_context.set_ciphers("HIGH:!aNULL:!MD5:!RC4")
@@ -521,6 +582,13 @@ class VirtualPrinterFTPServer:
self._running = True
logger.info("Implicit FTPS server started on port %s", self.port)
logger.info(
"FTP passive data port range: %s-%s",
self.PASSIVE_PORT_MIN,
self.PASSIVE_PORT_MAX,
)
if self._pasv_address:
logger.info("FTP PASV address override: %s", self._pasv_address)
async with self._server:
await self._server.serve_forever()
@@ -549,6 +617,8 @@ class VirtualPrinterFTPServer:
access_code=self.access_code,
ssl_context=self._ssl_context,
on_file_received=self.on_file_received,
passive_port_range=(self.PASSIVE_PORT_MIN, self.PASSIVE_PORT_MAX),
pasv_address=self._pasv_address,
)
# Track the session task so we can cancel it on stop
@@ -296,8 +296,12 @@ class VirtualPrinterManager:
self._cert_service.serial = proxy_serial
# Regenerate printer cert if needed (CA is preserved)
# Include remote interface IP in SAN so slicer TLS succeeds
additional_ips = []
if self._remote_interface_ip:
additional_ips.append(self._remote_interface_ip)
self._cert_service.delete_printer_certificate()
cert_path, key_path = self._cert_service.generate_certificates()
cert_path, key_path = self._cert_service.generate_certificates(additional_ips=additional_ips or None)
logger.info("Generated certificate for proxy serial: %s", proxy_serial)
# Initialize TLS proxy with our certificates
@@ -359,9 +363,11 @@ class VirtualPrinterManager:
)
logger.info(
f"Virtual printer proxy started: "
f"FTP 0.0.0.0:{SlicerProxyManager.LOCAL_FTP_PORT} -> {self._target_printer_ip}:{SlicerProxyManager.PRINTER_FTP_PORT}, "
f"MQTT 0.0.0.0:{SlicerProxyManager.LOCAL_MQTT_PORT} -> {self._target_printer_ip}:{SlicerProxyManager.PRINTER_MQTT_PORT}"
"Virtual printer proxy target: FTP %s:%d, MQTT %s:%d",
self._target_printer_ip,
SlicerProxyManager.PRINTER_FTP_PORT,
self._target_printer_ip,
SlicerProxyManager.PRINTER_MQTT_PORT,
)
def _start_fallback_ssdp(self, proxy_serial: str, run_with_logging) -> None:
@@ -500,6 +506,11 @@ class VirtualPrinterManager:
# "review" mode (or legacy "queue" mode)
await self._queue_file(file_path, source_ip)
# Reset MQTT status back to IDLE after file processing
# This tells the slicer the printer is done with the file
if self._mqtt and file_path.suffix.lower() == ".3mf":
self._mqtt.set_gcode_state("IDLE")
async def _on_print_command(self, filename: str, data: dict) -> None:
"""Handle print command from MQTT.
@@ -584,7 +595,12 @@ class VirtualPrinterManager:
# Only queue 3MF files
if file_path.suffix.lower() != ".3mf":
logger.warning("Skipping non-3MF file: %s", file_path.name)
logger.debug("Skipping non-3MF file: %s", file_path.name)
self._pending_files.pop(file_path.name, None)
try:
file_path.unlink()
except OSError:
pass # Best-effort removal of non-3MF file; may already be gone
return
try:
@@ -181,6 +181,11 @@ class SimpleMQTTServer:
self._status_push_task: asyncio.Task | None = None
self._sequence_id = 0
# Dynamic state for status reports
self._gcode_state = "IDLE"
self._current_file = ""
self._prepare_percent = "0"
async def start(self) -> None:
"""Start the MQTT server."""
if self._running:
@@ -521,10 +526,10 @@ class SimpleMQTTServer:
"sequence_id": str(self._sequence_id),
"command": "push_status",
"msg": 0,
"gcode_state": "IDLE",
"gcode_file": "",
"gcode_file_prepare_percent": "0",
"subtask_name": "",
"gcode_state": self._gcode_state,
"gcode_file": self._current_file,
"gcode_file_prepare_percent": self._prepare_percent,
"subtask_name": self._current_file.replace(".3mf", "") if self._current_file else "",
"mc_print_stage": "",
"mc_percent": 0,
"mc_remaining_time": 0,
@@ -589,38 +594,7 @@ class SimpleMQTTServer:
}
}
topic = f"device/{self.serial}/report"
message = json.dumps(status)
# Build MQTT PUBLISH packet
topic_bytes = topic.encode("utf-8")
message_bytes = message.encode("utf-8")
# Calculate remaining length
remaining = 2 + len(topic_bytes) + len(message_bytes)
# Build packet
packet = bytes([0x30]) # PUBLISH, QoS 0
# Encode remaining length
while remaining > 0:
byte = remaining % 128
remaining //= 128
if remaining > 0:
byte |= 0x80
packet += bytes([byte])
# Topic length and topic
packet += bytes([len(topic_bytes) >> 8, len(topic_bytes) & 0xFF])
packet += topic_bytes
# Message payload
packet += message_bytes
writer.write(packet)
await writer.drain()
logger.info("Sent initial status report on %s", topic)
await self._publish_to_report(writer, status)
except OSError as e:
logger.error("Failed to send status report: %s", e)
@@ -684,42 +658,80 @@ class SimpleMQTTServer:
}
}
topic = f"device/{self.serial}/report"
message = json.dumps(version_info)
# Build MQTT PUBLISH packet
topic_bytes = topic.encode("utf-8")
message_bytes = message.encode("utf-8")
# Calculate remaining length
remaining = 2 + len(topic_bytes) + len(message_bytes)
# Build packet
packet = bytes([0x30]) # PUBLISH, QoS 0
# Encode remaining length
while remaining > 0:
byte = remaining % 128
remaining //= 128
if remaining > 0:
byte |= 0x80
packet += bytes([byte])
# Topic length and topic
packet += bytes([len(topic_bytes) >> 8, len(topic_bytes) & 0xFF])
packet += topic_bytes
# Message payload
packet += message_bytes
writer.write(packet)
await writer.drain()
logger.info("Sent version response on %s", topic)
await self._publish_to_report(writer, version_info)
logger.info("Sent version response")
except OSError as e:
logger.error("Failed to send version response: %s", e)
def set_gcode_state(self, state: str, filename: str = "", prepare_percent: str = "0") -> None:
"""Update the gcode state reported to connected slicers.
Called by the manager to reflect FTP upload progress/completion.
"""
self._gcode_state = state
self._current_file = filename
self._prepare_percent = prepare_percent
async def _publish_to_report(self, writer: asyncio.StreamWriter, payload: dict) -> None:
"""Publish a message on the device report topic."""
topic = f"device/{self.serial}/report"
message = json.dumps(payload)
topic_bytes = topic.encode("utf-8")
message_bytes = message.encode("utf-8")
remaining = 2 + len(topic_bytes) + len(message_bytes)
packet = bytes([0x30]) # PUBLISH, QoS 0
while remaining > 0:
byte = remaining % 128
remaining //= 128
if remaining > 0:
byte |= 0x80
packet += bytes([byte])
packet += bytes([len(topic_bytes) >> 8, len(topic_bytes) & 0xFF])
packet += topic_bytes
packet += message_bytes
writer.write(packet)
# Timeout the drain to prevent blocking the event loop if the
# MQTT client stops reading (e.g. slicer busy with FTP upload).
try:
await asyncio.wait_for(writer.drain(), timeout=5)
except TimeoutError:
logger.debug("MQTT drain timeout for %s — client may be busy", topic)
async def _send_print_response(self, writer: asyncio.StreamWriter, sequence_id: str, filename: str) -> None:
"""Send project_file acknowledgment matching real Bambu printer behavior."""
# Update state so periodic status pushes reflect preparation
self._gcode_state = "PREPARE"
self._current_file = filename
self._prepare_percent = "0"
try:
# Send command acknowledgment — slicer expects to see
# command: "project_file" echoed back before starting FTP upload
subtask_name = filename.replace(".3mf", "") if filename else ""
response = {
"print": {
"command": "project_file",
"sequence_id": sequence_id,
"param": "Metadata/plate_1.gcode",
"subtask_name": subtask_name,
"gcode_state": "PREPARE",
"gcode_file": filename,
"gcode_file_prepare_percent": "0",
"result": "SUCCESS",
"msg": 0,
}
}
await self._publish_to_report(writer, response)
logger.info("Sent project_file acknowledgment for %s", filename)
except OSError as e:
logger.error("Failed to send print response: %s", e)
async def _handle_publish(self, header: int, payload: bytes, writer: asyncio.StreamWriter) -> None:
"""Handle MQTT PUBLISH packet."""
try:
@@ -776,11 +788,17 @@ class SimpleMQTTServer:
print_data = data["print"]
command = print_data.get("command", "")
filename = print_data.get("subtask_name", "")
sequence_id = print_data.get("sequence_id", "0")
logger.info("MQTT print command: %s for %s", command, filename)
if self.on_print_command and command == "project_file":
await self._notify_print_command(filename, print_data)
if command == "project_file":
# Respond with PREPARE status so slicer proceeds with FTP upload
file_3mf = print_data.get("file", filename)
await self._send_print_response(writer, sequence_id, file_3mf)
if self.on_print_command:
await self._notify_print_command(filename, print_data)
except json.JSONDecodeError:
pass # Non-JSON payloads on request topic are safely ignored
@@ -328,8 +328,13 @@ class SSDPProxy:
pass # Return partial headers if parsing fails; malformed packets are common
return headers
def _rewrite_ssdp_location(self, data: bytes) -> bytes:
"""Rewrite SSDP message with Bambuddy's remote IP as Location."""
def _rewrite_ssdp(self, data: bytes) -> bytes:
"""Rewrite SSDP message for proxy re-broadcast.
- Location: changed to Bambuddy's remote interface IP
- DevBind: forced to 'free' so the slicer treats the proxy as a
LAN-only printer (avoids cloud auth requirement for sending prints)
"""
try:
text = data.decode("utf-8", errors="ignore")
original = text
@@ -340,11 +345,25 @@ class SSDPProxy:
text,
flags=re.IGNORECASE,
)
# Force DevBind to 'free' - ensures slicer uses LAN mode for
# both monitoring AND sending prints through the proxy
text = re.sub(
r"(DevBind\.bambu\.com:\s*)\S+",
r"\g<1>free",
text,
flags=re.IGNORECASE,
)
# Append " - Proxy" to printer name so it's distinguishable
text = re.sub(
r"(DevName\.bambu\.com:\s*)(.+)",
r"\g<1>\g<2> - Proxy",
text,
flags=re.IGNORECASE,
)
if text != original:
logger.debug("Rewrote SSDP Location to %s", self.remote_interface_ip)
logger.debug("Rewritten SSDP packet:\n%s", text)
logger.debug("Rewrote SSDP for proxy:\n%s", text)
else:
logger.warning("SSDP Location rewrite had no effect. Packet:\n%s", original)
logger.warning("SSDP rewrite had no effect. Packet:\n%s", original)
return text.encode("utf-8")
except Exception as e:
logger.error("Failed to rewrite SSDP: %s", e)
@@ -453,13 +472,26 @@ class SSDPProxy:
self._remote_socket = None
async def _handle_local_packet(self, data: bytes, addr: tuple[str, int]) -> None:
"""Handle SSDP packet received on local interface (LAN A)."""
"""Handle SSDP packet received on local interface (LAN A).
Processes two types of traffic:
- NOTIFY from the real printer → cache and re-broadcast on LAN B
- M-SEARCH from slicers on LAN B → respond with cached printer info
"""
sender_ip = addr[0]
# Only process packets from the target printer
if sender_ip != self.target_printer_ip:
# Ignore packets from our own interfaces (prevent loops)
if sender_ip in (self.local_interface_ip, self.remote_interface_ip):
return
# Handle M-SEARCH from slicers (any IP that's not the target printer)
if sender_ip != self.target_printer_ip:
if b"M-SEARCH" in data:
await self._respond_to_msearch(data, addr)
return
# Below: NOTIFY handling from the real printer
# Check if it's a NOTIFY message
if b"NOTIFY" not in data and b"HTTP/1.1 200" not in data:
return
@@ -478,6 +510,44 @@ class SSDPProxy:
self._last_printer_ssdp = data
await self._broadcast_to_remote()
async def _respond_to_msearch(self, data: bytes, addr: tuple[str, int]) -> None:
"""Respond to M-SEARCH from a slicer with cached, rewritten printer info.
When Bambu Studio sends an M-SEARCH (e.g., before sending a print),
we respond with the cached printer info, rewritten to point to the
proxy's LAN B IP. Without this, the slicer thinks the printer is
offline and shows a 'connect to printer' modal.
"""
# Check if it's a relevant M-SEARCH
if b"bambulab-com:device:3dprinter" not in data and b"ssdp:all" not in data.lower():
return
if not self._last_printer_ssdp:
logger.debug("M-SEARCH from %s but no cached printer SSDP yet", addr[0])
return
logger.debug("Received M-SEARCH from slicer %s", addr[0])
# Rewrite the cached printer SSDP (Location → proxy IP, DevBind → free)
rewritten = self._rewrite_ssdp(self._last_printer_ssdp)
text = rewritten.decode("utf-8", errors="ignore")
# Convert NOTIFY format to M-SEARCH response format:
# "NOTIFY * HTTP/1.1" → "HTTP/1.1 200 OK"
# NT: → ST: (Notification Type → Search Target)
# Remove NTS: header (only in NOTIFY)
text = re.sub(r"^NOTIFY \* HTTP/1\.1", "HTTP/1.1 200 OK", text)
text = re.sub(r"^NT:", "ST:", text, flags=re.MULTILINE)
text = re.sub(r"^NTS:.*\r\n", "", text, flags=re.MULTILINE)
# Send unicast response directly to the slicer via remote socket
if self._remote_socket:
try:
self._remote_socket.sendto(text.encode("utf-8"), addr)
logger.info("Sent SSDP M-SEARCH response to %s", addr[0])
except OSError as e:
logger.debug("Failed to send M-SEARCH response to %s: %s", addr[0], e)
async def _broadcast_to_remote(self) -> None:
"""Broadcast cached printer SSDP on remote interface (LAN B)."""
if not self._remote_socket or not self._last_printer_ssdp:
@@ -485,7 +555,7 @@ class SSDPProxy:
try:
# Rewrite Location to point to Bambuddy's remote interface
rewritten = self._rewrite_ssdp_location(self._last_printer_ssdp)
rewritten = self._rewrite_ssdp(self._last_printer_ssdp)
# Calculate broadcast address for remote network
# Use 255.255.255.255 for simplicity (works across subnets)
@@ -12,13 +12,58 @@ Unlike a transparent TCP proxy, this terminates TLS on both ends:
import asyncio
import logging
import random
import re
import ssl
import subprocess
from collections.abc import Callable
from pathlib import Path
logger = logging.getLogger(__name__)
def detect_port_redirect(port: int) -> int | None:
"""Detect if iptables redirects a port to another port.
When iptables NAT REDIRECT rules exist (e.g. 990→9990), connections
to the original port never reach our socket because iptables intercepts
them in PREROUTING. We must listen on the redirect target instead.
Returns the redirect target port, or None if no redirect is active.
"""
# Method 1: Read persistent rules file (doesn't require root)
for rules_path in ("/etc/iptables/rules.v4", "/etc/iptables.rules"):
try:
with open(rules_path) as f:
content = f.read()
match = re.search(rf"--dport {port}\b.*?--to-ports\s+(\d+)", content)
if match:
target = int(match.group(1))
if target != port:
return target
except (FileNotFoundError, PermissionError, OSError):
continue
# Method 2: Query live iptables rules (may require root)
try:
result = subprocess.run( # noqa: S603, S607
["iptables-save", "-t", "nat"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
match = re.search(rf"--dport {port}\b.*?--to-ports\s+(\d+)", result.stdout)
if match:
target = int(match.group(1))
if target != port:
return target
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
pass
return None
class TLSProxy:
"""TLS terminating proxy that forwards data between client and target.
@@ -115,6 +160,17 @@ class TLSProxy:
except OSError as e:
if e.errno == 98: # Address already in use
logger.error("%s proxy port %s is already in use", self.name, self.listen_port)
elif e.errno == 13: # Permission denied
logger.error(
"%s proxy: cannot bind to port %s (permission denied). "
"Port %s requires root or CAP_NET_BIND_SERVICE. "
"Docker: add 'cap_add: [NET_BIND_SERVICE]' to docker-compose.yml. "
"Native: use 'sudo setcap cap_net_bind_service=+ep $(which python3)' "
"or redirect with iptables.",
self.name,
self.listen_port,
self.listen_port,
)
else:
logger.error("%s proxy error: %s", self.name, e)
except asyncio.CancelledError:
@@ -284,6 +340,503 @@ class TLSProxy:
logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
class FTPTLSProxy(TLSProxy):
"""FTP-aware TLS proxy that handles passive data connections.
Extends TLSProxy to intercept PASV/EPSV responses on the FTP control
channel, dynamically create TLS data proxies on local ports, and rewrite
the responses so the slicer connects to the proxy instead of the printer.
Without this, FTP passive data connections bypass the proxy and go directly
to the printer, which fails when the slicer can't reach the printer's IP.
"""
PASV_PORT_MIN = 50000
PASV_PORT_MAX = 50100
async def stop(self) -> None:
"""Stop proxy and clean up data connection servers."""
# Close all data servers first
for server in list(self._data_servers):
try:
server.close()
await server.wait_closed()
except OSError:
pass # Best-effort cleanup of data proxy servers
self._data_servers.clear()
await super().stop()
async def start(self) -> None:
"""Start the FTP TLS proxy."""
self._data_servers: list[asyncio.Server] = []
await super().start()
async def _handle_client(
self,
client_reader: asyncio.StreamReader,
client_writer: asyncio.StreamWriter,
) -> None:
"""Handle FTP client with PASV/EPSV-aware response forwarding."""
peername = client_writer.get_extra_info("peername")
client_id = f"{peername[0]}:{peername[1]}" if peername else "unknown"
logger.info("%s proxy: client connected from %s", self.name, client_id)
if self.on_connect:
try:
self.on_connect(client_id)
except Exception:
pass # Ignore connect callback errors; connection proceeds regardless
# Determine our local IP from the control connection socket
sockname = client_writer.get_extra_info("sockname")
local_ip = sockname[0] if sockname else "0.0.0.0"
if local_ip in ("0.0.0.0", "::"):
local_ip = "127.0.0.1"
# Connect to target printer with TLS
try:
printer_reader, printer_writer = await asyncio.wait_for(
asyncio.open_connection(
self.target_host,
self.target_port,
ssl=self._client_ssl_context,
),
timeout=10.0,
)
logger.info("%s proxy: connected to printer %s:%s", self.name, self.target_host, self.target_port)
except TimeoutError:
logger.error("%s proxy: timeout connecting to %s:%s", self.name, self.target_host, self.target_port)
client_writer.close()
await client_writer.wait_closed()
return
except ssl.SSLError as e:
logger.error(
"%s proxy: SSL error connecting to %s:%s: %s", self.name, self.target_host, self.target_port, e
)
client_writer.close()
await client_writer.wait_closed()
return
except OSError as e:
logger.error("%s proxy: failed to connect to %s:%s: %s", self.name, self.target_host, self.target_port, e)
client_writer.close()
await client_writer.wait_closed()
return
# Track data channel protection level per session.
# PROT C = cleartext data, PROT P = TLS data.
# Default to cleartext — many Bambu printers (A1, H2D) use PROT C.
# If the slicer sends PROT P, we switch to TLS for data connections.
session_state: dict[str, str] = {"prot": "C"}
# Client→Printer: intercept EPSV and replace with PASV
# EPSV responses only contain a port (no IP), so the slicer reuses
# the control connection IP. If that IP is the real printer (via
# iptables REDIRECT), the data connection bypasses the proxy.
# PASV responses include an explicit IP that we can rewrite.
client_to_printer = asyncio.create_task(
self._forward_ftp_commands(client_reader, printer_writer, f"{client_id}→printer", session_state),
name=f"{self.name}_c2p_{client_id}",
)
# Printer→Client: intercept PASV/EPSV responses
printer_to_client = asyncio.create_task(
self._forward_ftp_control(printer_reader, client_writer, f"printer→{client_id}", local_ip, session_state),
name=f"{self.name}_p2c_{client_id}",
)
self._active_connections[client_id] = (client_to_printer, printer_to_client)
try:
done, pending = await asyncio.wait(
[client_to_printer, printer_to_client],
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass # Expected when cancelling the other forwarding direction
except Exception as e:
logger.debug("%s proxy connection error: %s", self.name, e)
finally:
self._active_connections.pop(client_id, None)
for writer in [client_writer, printer_writer]:
try:
writer.close()
await writer.wait_closed()
except OSError:
pass # Best-effort connection cleanup; peer may have disconnected
logger.info("%s proxy: client %s disconnected", self.name, client_id)
if self.on_disconnect:
try:
self.on_disconnect(client_id)
except Exception:
pass # Ignore disconnect callback errors; cleanup continues
async def _forward_ftp_commands(
self,
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
direction: str,
session_state: dict[str, str],
) -> None:
"""Forward FTP client commands, replacing EPSV with PASV.
EPSV responses only contain a port number — the client reuses the
control connection IP for data. When the control IP is the real
printer (due to iptables REDIRECT), EPSV data connections bypass
the proxy. PASV responses include an explicit IP that the proxy
can rewrite to its own address.
Also tracks PROT P/C commands to know whether data connections
should use TLS or cleartext.
"""
buffer = b""
total_bytes = 0
try:
while self._running:
data = await reader.read(65536)
if not data:
break
total_bytes += len(data)
buffer += data
output = b""
while b"\r\n" in buffer:
idx = buffer.index(b"\r\n")
line = buffer[:idx]
buffer = buffer[idx + 2 :]
cmd_upper = line.strip().upper()
# Replace EPSV with PASV so response includes an IP
if cmd_upper == b"EPSV":
line = b"PASV"
logger.info("FTP command rewrite: EPSV → PASV")
# Track PROT level for data channel encryption
elif cmd_upper == b"PROT P":
session_state["prot"] = "P"
logger.info("FTP data protection: PROT P (TLS)")
elif cmd_upper == b"PROT C":
session_state["prot"] = "C"
logger.info("FTP data protection: PROT C (cleartext)")
output += line + b"\r\n"
if output:
writer.write(output)
await writer.drain()
logger.debug("%s proxy %s: %s bytes", self.name, direction, len(data))
except asyncio.CancelledError:
pass # Expected when the other forwarding direction closes first
except ConnectionResetError:
logger.debug("%s proxy %s: connection reset", self.name, direction)
except BrokenPipeError:
logger.debug("%s proxy %s: broken pipe", self.name, direction)
except OSError as e:
logger.debug("%s proxy %s error: %s", self.name, direction, e)
if buffer:
try:
writer.write(buffer)
await writer.drain()
except OSError:
pass # Best-effort flush of remaining FTP command data
logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
async def _forward_ftp_control(
self,
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
direction: str,
local_ip: str,
session_state: dict[str, str],
) -> None:
"""Forward FTP control channel responses, rewriting PASV/EPSV.
FTP control channel is line-based (\\r\\n terminated). We buffer data
and process complete lines, intercepting 227 (PASV) and 229 (EPSV)
responses to create local data proxies.
"""
buffer = b""
total_bytes = 0
try:
while self._running:
data = await reader.read(65536)
if not data:
break
total_bytes += len(data)
buffer += data
output = b""
# Process all complete lines
while b"\r\n" in buffer:
idx = buffer.index(b"\r\n")
line = buffer[:idx]
buffer = buffer[idx + 2 :]
rewritten = await self._maybe_rewrite_pasv(line, local_ip, session_state)
output += rewritten + b"\r\n"
if output:
writer.write(output)
await writer.drain()
logger.debug("%s proxy %s: %s bytes", self.name, direction, len(data))
except asyncio.CancelledError:
pass # Expected when the other forwarding direction closes first
except ConnectionResetError:
logger.debug("%s proxy %s: connection reset", self.name, direction)
except BrokenPipeError:
logger.debug("%s proxy %s: broken pipe", self.name, direction)
except OSError as e:
logger.debug("%s proxy %s error: %s", self.name, direction, e)
# Flush any remaining buffered data
if buffer:
try:
writer.write(buffer)
await writer.drain()
except OSError:
pass # Best-effort flush of remaining FTP control data
logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
async def _maybe_rewrite_pasv(self, line: bytes, local_ip: str, session_state: dict[str, str]) -> bytes:
"""Rewrite PASV/EPSV response to point to a local data proxy."""
try:
text = line.decode("utf-8")
except UnicodeDecodeError:
return line
# 227 Entering Passive Mode (h1,h2,h3,h4,p1,p2)
if text.startswith("227 "):
match = re.search(r"\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)", text)
if match:
h1, h2, h3, h4, p1, p2 = (int(x) for x in match.groups())
printer_ip = f"{h1}.{h2}.{h3}.{h4}"
printer_port = p1 * 256 + p2
local_port = await self._create_data_proxy(printer_ip, printer_port, session_state)
if local_port:
ip_parts = local_ip.split(".")
lp1 = local_port // 256
lp2 = local_port % 256
rewritten = (
f"227 Entering Passive Mode "
f"({ip_parts[0]},{ip_parts[1]},{ip_parts[2]},{ip_parts[3]},{lp1},{lp2})"
)
logger.info("FTP PASV rewrite: %s:%s → %s:%s", printer_ip, printer_port, local_ip, local_port)
return rewritten.encode("utf-8")
else:
logger.error("FTP PASV: failed to create data proxy for %s:%s", printer_ip, printer_port)
else:
logger.warning("FTP PASV: 227 response didn't match expected format: %s", text[:100])
# 229 Entering Extended Passive Mode (|||port|)
elif text.startswith("229 "):
match = re.search(r"\(\|\|\|(\d+)\|\)", text)
if match:
printer_port = int(match.group(1))
local_port = await self._create_data_proxy(self.target_host, printer_port, session_state)
if local_port:
rewritten = f"229 Entering Extended Passive Mode (|||{local_port}|)"
logger.info("FTP EPSV rewrite: port %s → %s", printer_port, local_port)
return rewritten.encode("utf-8")
else:
logger.error("FTP EPSV: failed to create data proxy for port %s", printer_port)
else:
logger.warning("FTP EPSV: 229 response didn't match expected format: %s", text[:100])
return line
async def _create_data_proxy(self, printer_ip: str, printer_port: int, session_state: dict[str, str]) -> int | None:
"""Create a one-shot proxy for an FTP data connection.
Prefers the printer's original passive port so the port number stays
the same in the rewritten PASV/EPSV response. This is critical when
the slicer's FTP bounce-attack protection overrides the IP in the PASV
response: the slicer connects to <control_IP>:<port>, and if iptables
REDIRECT maps that port to the local machine, the data proxy must be
listening on the *same* port number.
Falls back to a random port if the original is unavailable.
Uses TLS or cleartext based on the session's PROT level:
- PROT P: TLS on both slicer and printer data connections
- PROT C: cleartext on both sides (common for A1/H2D printers)
Returns the local port number, or None if binding failed.
"""
use_tls = session_state.get("prot") == "P"
logger.info(
"FTP data proxy: creating data proxy for %s:%s (printer-side %s)",
printer_ip,
printer_port,
"TLS" if use_tls else "cleartext",
)
# Try the printer's original port first — this ensures the port
# matches even when bounce protection or iptables REDIRECT is in play.
try:
await self._start_data_proxy_server(printer_port, printer_ip, printer_port, use_tls)
logger.info("FTP data proxy: using printer's port %s", printer_port)
return printer_port
except OSError as e:
logger.debug(
"FTP data proxy: printer port %s unavailable (%s), trying random",
printer_port,
e,
)
for _attempt in range(10):
port = random.randint(self.PASV_PORT_MIN, self.PASV_PORT_MAX)
try:
await self._start_data_proxy_server(port, printer_ip, printer_port, use_tls)
logger.info("FTP data proxy: using random port %s", port)
return port
except OSError:
continue
logger.error("Failed to bind FTP data proxy port after 10 attempts")
return None
async def _start_data_proxy_server(self, port: int, printer_ip: str, printer_port: int, use_tls: bool) -> None:
"""Start a one-shot server for one FTP data connection.
The slicer-side listener is ALWAYS cleartext. Even when the slicer
sends PROT P on the control channel, Bambu Studio does not perform
a TLS handshake on the data connection — it relies on the implicit
FTPS control channel for authentication and sends data unencrypted.
The printer-side outbound connection follows the PROT level:
- PROT P (use_tls=True): TLS to the printer's data port
- PROT C (use_tls=False): cleartext to the printer's data port
This mirrors the control channel's TLS-termination architecture.
Raises OSError if the port is already in use.
"""
connected = asyncio.Event()
server_holder: list[asyncio.Server] = []
# Slicer side: ALWAYS cleartext — Bambu Studio does not do TLS on
# the data channel even after sending PROT P.
# Printer side: TLS if PROT P, cleartext if PROT C.
client_ssl = self._client_ssl_context if use_tls else None
printer_mode = "TLS" if use_tls else "cleartext"
async def handle_data(
client_reader: asyncio.StreamReader,
client_writer: asyncio.StreamWriter,
) -> None:
"""Handle one FTP data connection, then close the server."""
peername = client_writer.get_extra_info("peername")
data_client = f"{peername[0]}:{peername[1]}" if peername else "unknown"
logger.info(
"FTP data proxy port %s (slicer=cleartext, printer=%s): client connected from %s, bridging to %s:%s",
port,
printer_mode,
data_client,
printer_ip,
printer_port,
)
connected.set()
# One-shot: close server after accepting first connection
if server_holder:
server_holder[0].close()
printer_writer = None
try:
# Connect to printer's data port
printer_reader, printer_writer = await asyncio.wait_for(
asyncio.open_connection(
printer_ip,
printer_port,
ssl=client_ssl,
),
timeout=10.0,
)
logger.info(
"FTP data proxy port %s (printer=%s): connected to printer %s:%s",
port,
printer_mode,
printer_ip,
printer_port,
)
# Bidirectional data forwarding
c2p = asyncio.create_task(self._forward(client_reader, printer_writer, "data_c2p"))
p2c = asyncio.create_task(self._forward(printer_reader, client_writer, "data_p2c"))
done, pending = await asyncio.wait([c2p, p2c], return_when=asyncio.FIRST_COMPLETED)
for task in pending:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass # Expected when other data direction closes
except TimeoutError:
logger.error("FTP data proxy port %s: timeout connecting to printer", port)
except ssl.SSLError as e:
logger.error("FTP data proxy port %s: SSL error to printer: %s", port, e)
except Exception as e:
logger.error("FTP data proxy port %s: error: %s", port, e)
finally:
for w in [client_writer, printer_writer]:
if w:
try:
w.close()
await w.wait_closed()
except OSError:
pass # Best-effort data connection cleanup
logger.info("FTP data proxy port %s: connection closed", port)
server = await asyncio.start_server(
handle_data,
"0.0.0.0", # nosec B104
port,
# No TLS on slicer side — Bambu Studio doesn't do TLS on data
# channel even after PROT P. The proxy terminates TLS only on
# the printer side (inside handle_data).
)
server_holder.append(server)
self._data_servers.append(server)
# Auto-close after 60s if no connection arrives
async def auto_close() -> None:
try:
await asyncio.wait_for(connected.wait(), timeout=60.0)
except TimeoutError:
logger.debug("FTP data proxy on port %s timed out, closing", port)
try:
server.close()
await server.wait_closed()
except OSError:
pass # Best-effort timeout cleanup
finally:
if server in self._data_servers:
self._data_servers.remove(server)
asyncio.create_task(auto_close(), name=f"ftp_data_timeout_{port}")
logger.debug("FTP data proxy: port %s → %s:%s", port, printer_ip, printer_port)
class SlicerProxyManager:
"""Manages FTP and MQTT TLS proxies for a single printer target."""
@@ -324,10 +877,24 @@ class SlicerProxyManager:
"""Start FTP and MQTT TLS proxies."""
logger.info("Starting slicer TLS proxy to %s", self.target_host)
# Create proxies with TLS
self._ftp_proxy = TLSProxy(
# Detect iptables port redirect (e.g. 990→9990 for non-root installs).
# If active, connections to port 990 get intercepted by iptables PREROUTING
# and sent to the redirect target — our socket on 990 never sees them.
ftp_listen_port = self.LOCAL_FTP_PORT
redirect_target = detect_port_redirect(self.LOCAL_FTP_PORT)
if redirect_target:
logger.info(
"Detected iptables redirect: port %d → %d. FTP proxy will listen on %d.",
self.LOCAL_FTP_PORT,
redirect_target,
redirect_target,
)
ftp_listen_port = redirect_target
# Create FTP proxy with PASV/EPSV awareness for data connections
self._ftp_proxy = FTPTLSProxy(
name="FTP",
listen_port=self.LOCAL_FTP_PORT,
listen_port=ftp_listen_port,
target_host=self.target_host,
target_port=self.PRINTER_FTP_PORT,
server_cert_path=self.cert_path,
@@ -25,9 +25,24 @@ class TestDiscoveryAPI:
assert "is_docker" in data
assert "ssdp_running" in data
assert "scan_running" in data
assert "subnets" in data
assert isinstance(data["is_docker"], bool)
assert isinstance(data["ssdp_running"], bool)
assert isinstance(data["scan_running"], bool)
assert isinstance(data["subnets"], list)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_discovery_info_subnets_are_cidr(self, async_client: AsyncClient):
"""Verify subnets are valid CIDR notation strings."""
response = await async_client.get("/api/v1/discovery/info")
assert response.status_code == 200
data = response.json()
for subnet in data["subnets"]:
assert isinstance(subnet, str)
# Should contain a slash for CIDR notation
assert "/" in subnet, f"Subnet {subnet} is not in CIDR notation"
# ========================================================================
# SSDP Discovery endpoints
@@ -140,3 +155,14 @@ class TestDiscoveryService:
assert response1.status_code == 200
assert response2.status_code == 200
assert response1.json()["is_docker"] == response2.json()["is_docker"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_subnets_consistent_across_calls(self, async_client: AsyncClient):
"""Verify subnet detection returns consistent results."""
response1 = await async_client.get("/api/v1/discovery/info")
response2 = await async_client.get("/api/v1/discovery/info")
assert response1.status_code == 200
assert response2.status_code == 200
assert response1.json()["subnets"] == response2.json()["subnets"]
@@ -63,6 +63,58 @@ class TestPrintersAPI:
assert result["serial_number"] == "00M09A111111111"
assert result["model"] == "X1C"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_printer_with_hostname(self, async_client: AsyncClient):
"""Verify printer can be created with a hostname instead of IP address."""
data = {
"name": "DNS Printer",
"serial_number": "00M09A555555555",
"ip_address": "printer.local",
"access_code": "12345678",
"model": "P1S",
}
response = await async_client.post("/api/v1/printers/", json=data)
assert response.status_code == 200
result = response.json()
assert result["name"] == "DNS Printer"
assert result["ip_address"] == "printer.local"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_printer_with_fqdn(self, async_client: AsyncClient):
"""Verify printer can be created with a fully qualified domain name."""
data = {
"name": "FQDN Printer",
"serial_number": "00M09A666666666",
"ip_address": "my-printer.home.lan",
"access_code": "12345678",
"model": "X1C",
}
response = await async_client.post("/api/v1/printers/", json=data)
assert response.status_code == 200
result = response.json()
assert result["ip_address"] == "my-printer.home.lan"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_printer_invalid_hostname(self, async_client: AsyncClient):
"""Verify invalid hostnames are rejected."""
data = {
"name": "Bad Printer",
"serial_number": "00M09A777777777",
"ip_address": "-invalid",
"access_code": "12345678",
}
response = await async_client.post("/api/v1/printers/", json=data)
assert response.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_printer_duplicate_serial(self, async_client: AsyncClient, printer_factory, db_session):
@@ -3,6 +3,8 @@
Tests the full request/response cycle for /api/v1/settings/ endpoints.
"""
import os
import pytest
from httpx import AsyncClient
@@ -393,6 +395,252 @@ class TestSettingsAPI:
# Default is False as defined in schema
assert isinstance(result["per_printer_mapping_expanded"], bool)
# ========================================================================
# Home Assistant environment variable tests
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_ha_settings_default_no_env_vars(self, async_client: AsyncClient):
"""Verify HA settings work without environment variables (default behavior)."""
# Ensure no env vars are set
os.environ.pop("HA_URL", None)
os.environ.pop("HA_TOKEN", None)
response = await async_client.get("/api/v1/settings/")
result = response.json()
assert response.status_code == 200
assert "ha_enabled" in result
assert "ha_url" in result
assert "ha_token" in result
assert "ha_url_from_env" in result
assert "ha_token_from_env" in result
assert "ha_env_managed" in result
# Default values without env vars
assert result["ha_url_from_env"] is False
assert result["ha_token_from_env"] is False
assert result["ha_env_managed"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_ha_settings_with_both_env_vars(self, async_client: AsyncClient):
"""Verify HA settings are overridden when both env vars are set."""
# Set environment variables
os.environ["HA_URL"] = "http://supervisor/core"
os.environ["HA_TOKEN"] = "test-token-12345"
try:
response = await async_client.get("/api/v1/settings/")
result = response.json()
assert response.status_code == 200
# Verify env var values are used
assert result["ha_url"] == "http://supervisor/core"
assert result["ha_token"] == "test-token-12345"
# Verify metadata fields
assert result["ha_url_from_env"] is True
assert result["ha_token_from_env"] is True
assert result["ha_env_managed"] is True
# Verify auto-enable behavior
assert result["ha_enabled"] is True
finally:
# Clean up
os.environ.pop("HA_URL", None)
os.environ.pop("HA_TOKEN", None)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_ha_settings_with_only_url_env_var(self, async_client: AsyncClient):
"""Verify partial configuration when only HA_URL is set."""
# Set only URL env var
os.environ["HA_URL"] = "http://supervisor/core"
os.environ.pop("HA_TOKEN", None)
try:
response = await async_client.get("/api/v1/settings/")
result = response.json()
assert response.status_code == 200
# Verify URL is from env, token is from database
assert result["ha_url"] == "http://supervisor/core"
assert result["ha_url_from_env"] is True
assert result["ha_token_from_env"] is False
assert result["ha_env_managed"] is False
# No auto-enable with partial config
assert result["ha_enabled"] is False # Database default
finally:
os.environ.pop("HA_URL", None)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_ha_settings_with_only_token_env_var(self, async_client: AsyncClient):
"""Verify partial configuration when only HA_TOKEN is set."""
# Set only token env var
os.environ.pop("HA_URL", None)
os.environ["HA_TOKEN"] = "test-token-12345"
try:
response = await async_client.get("/api/v1/settings/")
result = response.json()
assert response.status_code == 200
# Verify token is from env, URL is from database
assert result["ha_token"] == "test-token-12345"
assert result["ha_url_from_env"] is False
assert result["ha_token_from_env"] is True
assert result["ha_env_managed"] is False
# No auto-enable with partial config
assert result["ha_enabled"] is False # Database default
finally:
os.environ.pop("HA_TOKEN", None)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_ha_settings_env_vars_override_database(self, async_client: AsyncClient):
"""Verify environment variables take precedence over database values."""
# First, set database values
await async_client.put(
"/api/v1/settings/",
json={
"ha_enabled": True,
"ha_url": "http://database-url:8123",
"ha_token": "database-token",
},
)
# Verify database values are set
response = await async_client.get("/api/v1/settings/")
result = response.json()
assert result["ha_url"] == "http://database-url:8123"
assert result["ha_token"] == "database-token"
# Now set environment variables
os.environ["HA_URL"] = "http://env-url/core"
os.environ["HA_TOKEN"] = "env-token-xyz"
try:
response = await async_client.get("/api/v1/settings/")
result = response.json()
# Verify env vars override database
assert result["ha_url"] == "http://env-url/core"
assert result["ha_token"] == "env-token-xyz"
assert result["ha_url_from_env"] is True
assert result["ha_token_from_env"] is True
assert result["ha_env_managed"] is True
assert result["ha_enabled"] is True
finally:
os.environ.pop("HA_URL", None)
os.environ.pop("HA_TOKEN", None)
# Verify database values are still there after removing env vars
response = await async_client.get("/api/v1/settings/")
result = response.json()
assert result["ha_url"] == "http://database-url:8123"
assert result["ha_token"] == "database-token"
assert result["ha_url_from_env"] is False
assert result["ha_token_from_env"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_ha_settings_database_updates_accepted_but_ignored(self, async_client: AsyncClient):
"""Verify database updates are accepted but have no effect when env vars are set."""
# Set environment variables
os.environ["HA_URL"] = "http://supervisor/core"
os.environ["HA_TOKEN"] = "env-token"
try:
# Attempt to update via API
response = await async_client.put(
"/api/v1/settings/",
json={
"ha_url": "http://different-url:8123",
"ha_token": "different-token",
},
)
# Update should succeed
assert response.status_code == 200
# But values should still be from env vars
result = response.json()
assert result["ha_url"] == "http://supervisor/core"
assert result["ha_token"] == "env-token"
assert result["ha_url_from_env"] is True
assert result["ha_token_from_env"] is True
finally:
os.environ.pop("HA_URL", None)
os.environ.pop("HA_TOKEN", None)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_ha_settings_empty_env_vars_treated_as_not_set(self, async_client: AsyncClient):
"""Verify empty environment variables are treated as not set."""
# Set empty env vars
os.environ["HA_URL"] = ""
os.environ["HA_TOKEN"] = ""
try:
response = await async_client.get("/api/v1/settings/")
result = response.json()
# Empty env vars should be treated as not set
assert result["ha_url_from_env"] is False
assert result["ha_token_from_env"] is False
assert result["ha_env_managed"] is False
finally:
os.environ.pop("HA_URL", None)
os.environ.pop("HA_TOKEN", None)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_ha_settings_can_be_updated_normally_without_env_vars(self, async_client: AsyncClient):
"""Verify HA settings can be updated normally when env vars are not set."""
# Ensure no env vars
os.environ.pop("HA_URL", None)
os.environ.pop("HA_TOKEN", None)
# Update HA settings
response = await async_client.put(
"/api/v1/settings/",
json={
"ha_enabled": True,
"ha_url": "http://192.168.1.100:8123",
"ha_token": "my-long-lived-token",
},
)
assert response.status_code == 200
result = response.json()
assert result["ha_enabled"] is True
assert result["ha_url"] == "http://192.168.1.100:8123"
assert result["ha_token"] == "my-long-lived-token"
assert result["ha_url_from_env"] is False
assert result["ha_token_from_env"] is False
assert result["ha_env_managed"] is False
# Verify persistence
response = await async_client.get("/api/v1/settings/")
result = response.json()
assert result["ha_enabled"] is True
assert result["ha_url"] == "http://192.168.1.100:8123"
assert result["ha_token"] == "my-long-lived-token"
class TestSimplifiedBackupRestore:
"""Integration tests for the simplified backup/restore endpoints (ZIP-based).
+70 -2
View File
@@ -280,7 +280,7 @@ class TestSpoolmanAPI:
"id": 42,
"remaining_weight": 800,
"extra": {"tag": '"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"'},
"filament": {"id": 1, "name": "PLA Red", "material": "PLA"},
"filament": {"id": 1, "name": "PLA Red", "material": "PLA", "weight": 1000},
}
mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool])
@@ -291,7 +291,10 @@ class TestSpoolmanAPI:
assert isinstance(data["linked"], dict)
# Tag should be uppercase and stripped of quotes
assert "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4" in data["linked"]
assert data["linked"]["A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"] == 42
linked_info = data["linked"]["A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"]
assert linked_info["id"] == 42
assert linked_info["remaining_weight"] == 800
assert linked_info["filament_weight"] == 1000
@pytest.mark.asyncio
@pytest.mark.integration
@@ -337,6 +340,71 @@ class TestSpoolmanAPI:
data = response.json()
assert len(data["linked"]) == 0 # Empty tag should be excluded
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_linked_spools_includes_weight_data(
self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
):
"""Verify linked spools response includes remaining_weight and filament_weight."""
mock_spool = {
"id": 10,
"remaining_weight": 500.5,
"extra": {"tag": '"AABB11223344556677889900AABBCCDD"'},
"filament": {"id": 1, "name": "PETG Blue", "material": "PETG", "weight": 750},
}
mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool])
response = await async_client.get("/api/v1/spoolman/spools/linked")
assert response.status_code == 200
data = response.json()
info = data["linked"]["AABB11223344556677889900AABBCCDD"]
assert info["id"] == 10
assert info["remaining_weight"] == 500.5
assert info["filament_weight"] == 750
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_linked_spools_missing_weight_fields(
self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
):
"""Verify linked spools handles missing weight data gracefully."""
mock_spool = {
"id": 5,
"extra": {"tag": '"CCDD11223344556677889900AABBCCDD"'},
"filament": {"id": 1, "name": "PLA Red", "material": "PLA"},
}
mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool])
response = await async_client.get("/api/v1/spoolman/spools/linked")
assert response.status_code == 200
data = response.json()
info = data["linked"]["CCDD11223344556677889900AABBCCDD"]
assert info["id"] == 5
assert info["remaining_weight"] is None
assert info["filament_weight"] is None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_linked_spools_null_filament(
self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
):
"""Verify linked spools handles null filament object."""
mock_spool = {
"id": 7,
"remaining_weight": 300,
"extra": {"tag": '"EEFF11223344556677889900AABBCCDD"'},
"filament": None,
}
mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool])
response = await async_client.get("/api/v1/spoolman/spools/linked")
assert response.status_code == 200
data = response.json()
info = data["linked"]["EEFF11223344556677889900AABBCCDD"]
assert info["id"] == 7
assert info["remaining_weight"] == 300
assert info["filament_weight"] is None
# =========================================================================
# Link Spool Tests
# =========================================================================
+99
View File
@@ -0,0 +1,99 @@
"""Test fixtures for FTP service tests.
Provides a real implicit FTPS server (via mock_ftp_server) and client factory
for integration-style testing of BambuFTPClient against a live server.
"""
import socket
from unittest.mock import patch
import pytest
from backend.app.services.bambu_ftp import BambuFTPClient
from backend.app.services.virtual_printer.certificate import CertificateService
from backend.tests.unit.services.mock_ftp_server import MockBambuFTPServer
@pytest.fixture(scope="session")
def ftp_certs(tmp_path_factory):
"""Generate self-signed TLS certificates once per test session."""
cert_dir = tmp_path_factory.mktemp("ftp_certs")
svc = CertificateService(cert_dir, serial="TEST_FTP_SERVER")
cert_path, key_path = svc.generate_certificates()
return str(cert_path), str(key_path)
def _find_free_port() -> int:
"""Find a free TCP port on localhost."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
@pytest.fixture()
def ftp_root(tmp_path):
"""Create temp directory with standard Bambu printer directory structure."""
for d in ("cache", "timelapse", "model", "data", "data/Metadata"):
(tmp_path / d).mkdir(parents=True, exist_ok=True)
return tmp_path
@pytest.fixture()
def ftp_server(ftp_certs, ftp_root):
"""Start a mock implicit FTPS server, yield it, stop on cleanup."""
cert_path, key_path = ftp_certs
port = _find_free_port()
server = MockBambuFTPServer(
host="127.0.0.1",
port=port,
root_dir=str(ftp_root),
cert_path=cert_path,
key_path=key_path,
access_code="12345678",
)
server.start()
yield server
server.stop()
@pytest.fixture()
def ftp_client_factory(ftp_server):
"""Factory that creates BambuFTPClient instances pointed at the mock server."""
def _make_client(
printer_model: str = "X1C",
force_prot_c: bool = False,
access_code: str = "12345678",
timeout: float = 10.0,
) -> BambuFTPClient:
client = BambuFTPClient(
ip_address="127.0.0.1",
access_code=access_code,
timeout=timeout,
printer_model=printer_model,
force_prot_c=force_prot_c,
)
# Override port to point at mock server
client.FTP_PORT = ftp_server.port
return client
return _make_client
@pytest.fixture(autouse=True)
def clear_ftp_mode_cache():
"""Clear BambuFTPClient mode cache before and after each test."""
BambuFTPClient._mode_cache.clear()
yield
BambuFTPClient._mode_cache.clear()
@pytest.fixture()
def patch_ftp_port(ftp_server):
"""Patch FTP_PORT at class level for async wrapper tests.
Async wrappers create their own BambuFTPClient instances internally,
so we need to patch the class-level default port.
"""
with patch.object(BambuFTPClient, "FTP_PORT", ftp_server.port):
yield ftp_server
@@ -0,0 +1,240 @@
"""Mock implicit FTPS server for testing BambuFTPClient.
Built on pyftpdlib with implicit TLS support to match Bambu printer behavior.
Supports failure injection, custom AVBL command, and filesystem inspection.
"""
import logging
import os
import threading
import time
from pyftpdlib.authorizers import DummyAuthorizer
from pyftpdlib.handlers import TLS_FTPHandler
from pyftpdlib.servers import FTPServer
class ImplicitTLS_FTPHandler(TLS_FTPHandler):
"""FTP handler that wraps the socket in TLS before sending the 220 banner.
This implements implicit FTPS (port 990 style) where the TLS handshake
happens immediately on connect, before any FTP protocol exchange.
pyftpdlib only natively supports explicit FTPS (AUTH TLS after connect).
"""
# Per-class failure injection map: command -> (code, message, remaining_count)
# -1 remaining_count = permanent failure
_failure_map: dict = {}
# AVBL command response (bytes available)
_avbl_bytes: int = 1073741824 # 1 GB default
# Register AVBL as a recognized FTP command (pyftpdlib requires this)
proto_cmds = {
**TLS_FTPHandler.proto_cmds,
"AVBL": {
"perm": None,
"auth": True,
"arg": None,
"help": "Syntax: AVBL (get available bytes).",
},
}
def handle(self):
"""Wrap socket in TLS immediately, then send 220 banner."""
self.secure_connection(self.get_ssl_context())
super().handle()
def ftp_PROT(self, line):
"""Override PROT to auto-set _pbsz for implicit FTPS.
In implicit FTPS the connection is already TLS-secured, so requiring
a separate PBSZ command is unnecessary. Python's ftplib prot_c()
doesn't send PBSZ first (unlike prot_p()), causing 503 errors.
Real Bambu printers don't enforce this for implicit FTPS either.
"""
self._pbsz = True
return super().ftp_PROT(line)
def _check_failure(self, command: str, line: str):
"""Check if a failure is injected for this command.
Returns True if a failure response was sent, False otherwise.
"""
if command in self._failure_map:
code, message, remaining = self._failure_map[command]
if remaining != 0:
if remaining > 0:
self._failure_map[command] = (code, message, remaining - 1)
if remaining - 1 == 0:
del self._failure_map[command]
self.respond(f"{code} {message}")
return True
return False
def ftp_AVBL(self, line):
"""Handle custom AVBL command (available bytes on storage)."""
self.respond(f"213 {self._avbl_bytes}")
def ftp_RETR(self, file):
if self._check_failure("RETR", file):
return
return super().ftp_RETR(file)
def ftp_STOR(self, file):
if self._check_failure("STOR", file):
return
return super().ftp_STOR(file)
def ftp_DELE(self, line):
if self._check_failure("DELE", line):
return
return super().ftp_DELE(line)
def ftp_CWD(self, path):
if self._check_failure("CWD", path):
return
return super().ftp_CWD(path)
def ftp_LIST(self, path=""):
if self._check_failure("LIST", path):
return
return super().ftp_LIST(path)
def ftp_SIZE(self, path):
if self._check_failure("SIZE", path):
return
# Override to allow SIZE in ASCII mode (real Bambu printers allow it,
# and BambuFTPClient.get_file_size() doesn't set TYPE I first)
if not self.fs.isfile(self.fs.realpath(path)):
self.respond(f"550 {self.fs.fs2ftp(path)} is not retrievable.")
return
try:
size = self.run_as_current_user(self.fs.getsize, path)
except OSError as err:
self.respond(f"550 {err}.")
else:
self.respond(f"213 {size}")
def ftp_PASS(self, line):
if self._check_failure("PASS", line):
return
return super().ftp_PASS(line)
class MockBambuFTPServer:
"""Manages a mock implicit FTPS server in a background thread.
Simulates a Bambu printer FTP server with:
- Implicit TLS (like real printers on port 990)
- Standard Bambu directory structure
- AVBL command support
- Per-command failure injection for testing error paths
"""
def __init__(
self,
host: str,
port: int,
root_dir: str,
cert_path: str,
key_path: str,
access_code: str = "12345678",
):
self.host = host
self.port = port
self.root_dir = root_dir
self.cert_path = cert_path
self.key_path = key_path
self.access_code = access_code
self._server: FTPServer | None = None
self._thread: threading.Thread | None = None
# Create a unique handler class per instance so _failure_map is isolated
self._handler_class = type(
"TestFTPHandler",
(ImplicitTLS_FTPHandler,),
{
"_failure_map": {},
"_avbl_bytes": 1073741824,
},
)
def start(self):
"""Start the FTP server in a background daemon thread."""
authorizer = DummyAuthorizer()
authorizer.add_user("bblp", self.access_code, self.root_dir, perm="elradfmwMT")
handler = self._handler_class
handler.authorizer = authorizer
handler.certfile = self.cert_path
handler.keyfile = self.key_path
handler.passive_ports = range(60000, 60101)
handler.tls_control_required = False
handler.tls_data_required = False
# Reset ssl_context so it picks up our cert/key
handler.ssl_context = None
# Suppress pyftpdlib's noisy logging (startup/shutdown banners)
# to avoid "I/O operation on closed file" errors when xdist
# workers tear down while the daemon thread is still logging.
logging.getLogger("pyftpdlib").setLevel(logging.CRITICAL)
self._server = FTPServer((self.host, self.port), handler)
self._server.max_cons = 10
self._server.max_cons_per_ip = 5
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
self._thread.start()
# Brief wait for server to be ready
time.sleep(0.1)
def stop(self):
"""Stop the FTP server and wait for thread to exit."""
if self._server:
self._server.close_all()
if self._thread:
self._thread.join(timeout=5)
self._server = None
self._thread = None
def inject_failure(self, command: str, code: int, message: str, count: int = -1):
"""Inject a failure response for a specific FTP command.
Args:
command: FTP command name (RETR, STOR, DELE, CWD, LIST, SIZE, PASS)
code: FTP response code (e.g. 550, 553)
message: Response message
count: Number of times to fail (-1 = permanent)
"""
self._handler_class._failure_map[command] = (code, message, count)
def clear_failures(self):
"""Remove all injected failures."""
self._handler_class._failure_map.clear()
def set_avbl_bytes(self, n: int):
"""Set the response value for the AVBL command."""
self._handler_class._avbl_bytes = n
def add_file(self, relative_path: str, content: bytes = b""):
"""Add a file to the server's filesystem."""
full_path = os.path.join(self.root_dir, relative_path.lstrip("/"))
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, "wb") as f:
f.write(content)
def add_directory(self, relative_path: str):
"""Create a directory in the server's filesystem."""
full_path = os.path.join(self.root_dir, relative_path.lstrip("/"))
os.makedirs(full_path, exist_ok=True)
def file_exists(self, relative_path: str) -> bool:
"""Check if a file exists on the server."""
full_path = os.path.join(self.root_dir, relative_path.lstrip("/"))
return os.path.isfile(full_path)
def read_file(self, relative_path: str) -> bytes:
"""Read file content from the server's filesystem."""
full_path = os.path.join(self.root_dir, relative_path.lstrip("/"))
with open(full_path, "rb") as f:
return f.read()
@@ -0,0 +1,864 @@
"""Comprehensive FTP test suite for BambuFTPClient.
Tests against a real mock implicit FTPS server, covering:
- Connection (auth, SSL modes, timeout, caching)
- File listing
- Download (bytes, to_file, 0-byte regression)
- Upload (chunked transfer, progress, error codes)
- Delete
- File size
- Storage info (AVBL, directory scan, diagnose_storage)
- Model-specific behavior (X1C prot_p, A1 prot_c fallback)
- Async wrappers
- Failure injection scenarios (regressions for 0.1.8 bugs)
"""
import time
from pathlib import Path
import pytest
from backend.app.services.bambu_ftp import (
BambuFTPClient,
delete_file_async,
download_file_async,
download_file_try_paths_async,
list_files_async,
upload_file_async,
)
# Brief delay to allow pyftpdlib to flush uploaded files to disk.
# Needed because upload_file() skips voidresp() for A1 compatibility,
# so the server may still be processing the data channel close event.
_UPLOAD_FLUSH_DELAY = 0.3
# ---------------------------------------------------------------------------
# TestConnection
# ---------------------------------------------------------------------------
class TestConnection:
"""Tests for FTP connect/disconnect behavior."""
def test_connect_success(self, ftp_client_factory):
"""Successful implicit FTPS connection and login."""
client = ftp_client_factory()
assert client.connect() is True
client.disconnect()
def test_connect_wrong_access_code(self, ftp_client_factory):
"""Wrong access code returns False."""
client = ftp_client_factory(access_code="wrongcode")
assert client.connect() is False
def test_connect_unreachable_host(self, ftp_server):
"""Unreachable host returns False."""
client = BambuFTPClient(
ip_address="192.0.2.1", # TEST-NET, guaranteed unreachable
access_code="12345678",
timeout=1.0,
printer_model="X1C",
)
client.FTP_PORT = ftp_server.port
assert client.connect() is False
def test_connect_timeout(self, ftp_server):
"""Very short timeout triggers timeout error."""
client = BambuFTPClient(
ip_address="192.0.2.1",
access_code="12345678",
timeout=0.001, # Extremely short
printer_model="X1C",
)
client.FTP_PORT = ftp_server.port
assert client.connect() is False
def test_disconnect_clean(self, ftp_client_factory):
"""Clean disconnect after successful connect."""
client = ftp_client_factory()
client.connect()
client.disconnect()
assert client._ftp is None
def test_disconnect_without_connect(self, ftp_client_factory):
"""Disconnect without connect does not raise."""
client = ftp_client_factory()
client.disconnect() # Should not raise
assert client._ftp is None
def test_disconnect_after_server_gone(self, ftp_certs, ftp_root):
"""Disconnect after server has stopped raises EOFError.
Note: The current disconnect() catches (OSError, ftplib.Error) but
EOFError is neither. This documents actual behavior — a future fix
could add EOFError to the except clause.
"""
from backend.tests.unit.services.mock_ftp_server import (
MockBambuFTPServer,
)
from .conftest import _find_free_port
cert_path, key_path = ftp_certs
port = _find_free_port()
server = MockBambuFTPServer("127.0.0.1", port, str(ftp_root), cert_path, key_path)
server.start()
client = BambuFTPClient("127.0.0.1", "12345678", timeout=5.0)
client.FTP_PORT = port
client.connect()
server.stop()
with pytest.raises(EOFError):
client.disconnect()
def test_x1c_uses_prot_p(self, ftp_client_factory):
"""X1C model connects with prot_p (protected data channel)."""
client = ftp_client_factory(printer_model="X1C")
assert client.connect() is True
assert client._should_use_prot_c() is False
client.disconnect()
def test_a1_defaults_prot_p(self, ftp_client_factory):
"""A1 model defaults to prot_p when no cache exists."""
client = ftp_client_factory(printer_model="A1")
assert client._should_use_prot_c() is False
assert client.connect() is True
client.disconnect()
def test_a1_force_prot_c(self, ftp_client_factory):
"""A1 model with force_prot_c uses clear data channel."""
client = ftp_client_factory(printer_model="A1", force_prot_c=True)
assert client._should_use_prot_c() is True
assert client.connect() is True
client.disconnect()
def test_cached_mode_respected(self, ftp_client_factory):
"""Cached mode is used on subsequent connections."""
BambuFTPClient.cache_mode("127.0.0.1", "prot_c")
client = ftp_client_factory(printer_model="A1")
assert client._should_use_prot_c() is True
assert client.connect() is True
client.disconnect()
# ---------------------------------------------------------------------------
# TestListFiles
# ---------------------------------------------------------------------------
class TestListFiles:
"""Tests for directory listing."""
def test_list_empty_directory(self, ftp_client_factory):
"""Listing an empty directory returns empty list."""
client = ftp_client_factory()
client.connect()
files = client.list_files("/cache")
assert files == []
client.disconnect()
def test_list_directory_with_files(self, ftp_client_factory, ftp_server):
"""Files in directory are listed correctly."""
ftp_server.add_file("cache/test.3mf", b"x" * 1024)
ftp_server.add_file("cache/test2.gcode", b"y" * 512)
client = ftp_client_factory()
client.connect()
files = client.list_files("/cache")
names = {f["name"] for f in files}
assert "test.3mf" in names
assert "test2.gcode" in names
client.disconnect()
def test_directories_marked(self, ftp_client_factory, ftp_server):
"""Subdirectories are identified with is_directory=True."""
ftp_server.add_directory("model/subdir")
client = ftp_client_factory()
client.connect()
files = client.list_files("/model")
dirs = [f for f in files if f["is_directory"]]
assert len(dirs) >= 1
assert dirs[0]["name"] == "subdir"
client.disconnect()
def test_nonexistent_path_returns_empty(self, ftp_client_factory):
"""Listing a nonexistent path returns empty list."""
client = ftp_client_factory()
client.connect()
files = client.list_files("/nonexistent/path")
assert files == []
client.disconnect()
def test_file_sizes_and_paths(self, ftp_client_factory, ftp_server):
"""File sizes and full paths are parsed correctly."""
ftp_server.add_file("cache/sized.bin", b"a" * 2048)
client = ftp_client_factory()
client.connect()
files = client.list_files("/cache")
sized = [f for f in files if f["name"] == "sized.bin"]
assert len(sized) == 1
assert sized[0]["size"] == 2048
assert sized[0]["path"] == "/cache/sized.bin"
client.disconnect()
# ---------------------------------------------------------------------------
# TestDownload
# ---------------------------------------------------------------------------
class TestDownload:
"""Tests for file download operations."""
def test_download_file_returns_bytes(self, ftp_client_factory, ftp_server):
"""download_file() returns file content as bytes."""
content = b"Hello FTP World!"
ftp_server.add_file("cache/hello.txt", content)
client = ftp_client_factory()
client.connect()
result = client.download_file("/cache/hello.txt")
assert result == content
client.disconnect()
def test_download_file_missing(self, ftp_client_factory):
"""download_file() returns None for missing file."""
client = ftp_client_factory()
client.connect()
result = client.download_file("/cache/does_not_exist.txt")
assert result is None
client.disconnect()
def test_download_to_file_writes_to_disk(self, ftp_client_factory, ftp_server, tmp_path):
"""download_to_file() writes content to local filesystem."""
content = b"Downloaded content"
ftp_server.add_file("cache/dl.bin", content)
local = tmp_path / "output" / "dl.bin"
client = ftp_client_factory()
client.connect()
result = client.download_to_file("/cache/dl.bin", local)
assert result is True
assert local.read_bytes() == content
client.disconnect()
def test_download_to_file_creates_parent_dirs(self, ftp_client_factory, ftp_server, tmp_path):
"""download_to_file() creates parent directories automatically."""
ftp_server.add_file("cache/nested.txt", b"nested content")
local = tmp_path / "deep" / "nested" / "path" / "nested.txt"
client = ftp_client_factory()
client.connect()
result = client.download_to_file("/cache/nested.txt", local)
assert result is True
assert local.exists()
client.disconnect()
def test_zero_byte_download_returns_false(self, ftp_client_factory, ftp_server, tmp_path):
"""0-byte download returns False and cleans up (regression test)."""
ftp_server.add_file("cache/empty.bin", b"")
local = tmp_path / "empty.bin"
client = ftp_client_factory()
client.connect()
result = client.download_to_file("/cache/empty.bin", local)
assert result is False
assert not local.exists()
client.disconnect()
def test_download_to_file_missing_returns_false(self, ftp_client_factory, tmp_path):
"""Missing file returns False."""
local = tmp_path / "missing.bin"
client = ftp_client_factory()
client.connect()
result = client.download_to_file("/cache/no_such_file.bin", local)
assert result is False
client.disconnect()
def test_download_large_file(self, ftp_client_factory, ftp_server):
"""Large file download (>1MB) works correctly."""
large_content = b"X" * (1024 * 1024 + 500) # ~1MB + 500 bytes
ftp_server.add_file("cache/large.bin", large_content)
client = ftp_client_factory()
client.connect()
result = client.download_file("/cache/large.bin")
assert result == large_content
client.disconnect()
def test_download_not_connected(self):
"""download_file() returns None when not connected."""
client = BambuFTPClient("127.0.0.1", "12345678")
assert client.download_file("/cache/test.bin") is None
# ---------------------------------------------------------------------------
# TestUpload
# ---------------------------------------------------------------------------
class TestUpload:
"""Tests for file upload operations."""
def test_upload_success(self, ftp_client_factory, ftp_server, tmp_path):
"""Successful upload via transfercmd (not storbinary)."""
content = b"Upload test content"
local = tmp_path / "upload.3mf"
local.write_bytes(content)
client = ftp_client_factory()
client.connect()
result = client.upload_file(local, "/cache/upload.3mf")
assert result is True
client.disconnect()
# Verify via fresh connection (upload_file skips voidresp()
# so the original session can't be reused for download)
time.sleep(_UPLOAD_FLUSH_DELAY)
client2 = ftp_client_factory()
client2.connect()
downloaded = client2.download_file("/cache/upload.3mf")
assert downloaded == content
client2.disconnect()
def test_upload_progress_callback(self, ftp_client_factory, ftp_server, tmp_path):
"""Progress callback receives updates during upload."""
content = b"P" * 2048
local = tmp_path / "progress.bin"
local.write_bytes(content)
progress_calls = []
def on_progress(uploaded, total):
progress_calls.append((uploaded, total))
client = ftp_client_factory()
client.connect()
client.upload_file(local, "/cache/progress.bin", on_progress)
assert len(progress_calls) >= 1
# Last call should report full file uploaded
assert progress_calls[-1][0] == len(content)
assert progress_calls[-1][1] == len(content)
client.disconnect()
def test_upload_not_connected(self, tmp_path):
"""Upload when not connected returns False."""
local = tmp_path / "test.bin"
local.write_bytes(b"data")
client = BambuFTPClient("127.0.0.1", "12345678")
assert client.upload_file(local, "/cache/test.bin") is False
def test_upload_553_no_sd_card(self, ftp_client_factory, ftp_server, tmp_path):
"""553 error (no SD card) returns False."""
ftp_server.inject_failure("STOR", 553, "Could not create file.")
local = tmp_path / "test.bin"
local.write_bytes(b"data")
client = ftp_client_factory()
client.connect()
result = client.upload_file(local, "/cache/test.bin")
assert result is False
client.disconnect()
def test_upload_550_permission_denied(self, ftp_client_factory, ftp_server, tmp_path):
"""550 error (permission denied) returns False."""
ftp_server.inject_failure("STOR", 550, "Permission denied.")
local = tmp_path / "test.bin"
local.write_bytes(b"data")
client = ftp_client_factory()
client.connect()
result = client.upload_file(local, "/cache/test.bin")
assert result is False
client.disconnect()
def test_upload_552_storage_full(self, ftp_client_factory, ftp_server, tmp_path):
"""552 error (storage full) returns False."""
ftp_server.inject_failure("STOR", 552, "Storage quota exceeded.")
local = tmp_path / "test.bin"
local.write_bytes(b"data")
client = ftp_client_factory()
client.connect()
result = client.upload_file(local, "/cache/test.bin")
assert result is False
client.disconnect()
def test_upload_bytes_success(self, ftp_client_factory, ftp_server):
"""upload_bytes() writes data to server."""
data = b"Bytes upload content"
client = ftp_client_factory()
client.connect()
result = client.upload_bytes(data, "/cache/bytes.bin")
assert result is True
client.disconnect()
# Verify via fresh connection
time.sleep(_UPLOAD_FLUSH_DELAY)
client2 = ftp_client_factory()
client2.connect()
downloaded = client2.download_file("/cache/bytes.bin")
assert downloaded == data
client2.disconnect()
def test_upload_bytes_failure(self, ftp_client_factory, ftp_server):
"""upload_bytes() returns False on STOR failure."""
ftp_server.inject_failure("STOR", 553, "No space.")
client = ftp_client_factory()
client.connect()
result = client.upload_bytes(b"data", "/cache/fail.bin")
assert result is False
client.disconnect()
def test_upload_large_chunked(self, ftp_client_factory, ftp_server, tmp_path):
"""Large file upload in chunks completes without error.
Uses 2.5MB to trigger multiple chunks with 1MB CHUNK_SIZE.
Content verification skipped because upload_file() doesn't call
voidresp() (for A1 compatibility), so the server may still be
flushing when we check. The upload result=True confirms the
client sent all chunks without error.
"""
content = b"C" * (1024 * 1024 * 2 + 512 * 1024)
local = tmp_path / "large.bin"
local.write_bytes(content)
progress_calls = []
def on_progress(uploaded, total):
progress_calls.append((uploaded, total))
client = ftp_client_factory()
client.connect()
result = client.upload_file(local, "/cache/large.bin", on_progress)
assert result is True
# Verify multiple chunks were sent
assert len(progress_calls) >= 3 # 2.5MB / 1MB = at least 3 chunks
assert progress_calls[-1][0] == len(content)
client.disconnect()
# ---------------------------------------------------------------------------
# TestDelete
# ---------------------------------------------------------------------------
class TestDelete:
"""Tests for file deletion."""
def test_delete_success(self, ftp_client_factory, ftp_server):
"""Successful file deletion."""
ftp_server.add_file("cache/to_delete.bin", b"delete me")
client = ftp_client_factory()
client.connect()
result = client.delete_file("/cache/to_delete.bin")
assert result is True
assert not ftp_server.file_exists("cache/to_delete.bin")
client.disconnect()
def test_delete_not_found(self, ftp_client_factory):
"""Deleting a nonexistent file returns False."""
client = ftp_client_factory()
client.connect()
result = client.delete_file("/cache/no_such_file.bin")
assert result is False
client.disconnect()
def test_delete_not_connected(self):
"""Delete when not connected returns False."""
client = BambuFTPClient("127.0.0.1", "12345678")
assert client.delete_file("/cache/test.bin") is False
# ---------------------------------------------------------------------------
# TestFileSize
# ---------------------------------------------------------------------------
class TestFileSize:
"""Tests for get_file_size."""
def test_file_size_correct(self, ftp_client_factory, ftp_server):
"""Returns correct file size."""
ftp_server.add_file("cache/sized.bin", b"a" * 4096)
client = ftp_client_factory()
client.connect()
size = client.get_file_size("/cache/sized.bin")
assert size == 4096
client.disconnect()
def test_file_size_missing(self, ftp_client_factory):
"""Returns None for missing file."""
client = ftp_client_factory()
client.connect()
size = client.get_file_size("/cache/no_file.bin")
assert size is None
client.disconnect()
def test_file_size_not_connected(self):
"""Returns None when not connected."""
client = BambuFTPClient("127.0.0.1", "12345678")
assert client.get_file_size("/cache/test.bin") is None
# ---------------------------------------------------------------------------
# TestStorageInfo
# ---------------------------------------------------------------------------
class TestStorageInfo:
"""Tests for storage info and diagnostics."""
def test_avbl_parsed(self, ftp_client_factory, ftp_server):
"""AVBL response is parsed for free_bytes."""
ftp_server.set_avbl_bytes(5000000000)
client = ftp_client_factory()
client.connect()
info = client.get_storage_info()
assert info is not None
assert info["free_bytes"] == 5000000000
client.disconnect()
def test_used_bytes_from_scan(self, ftp_client_factory, ftp_server):
"""used_bytes calculated from directory scan."""
ftp_server.add_file("cache/file1.bin", b"a" * 1000)
ftp_server.add_file("cache/file2.bin", b"b" * 2000)
client = ftp_client_factory()
client.connect()
info = client.get_storage_info()
assert info is not None
assert info["used_bytes"] >= 3000 # At least these two files
client.disconnect()
def test_storage_info_not_connected(self):
"""Returns None when not connected."""
client = BambuFTPClient("127.0.0.1", "12345678")
assert client.get_storage_info() is None
def test_diagnose_storage_success(self, ftp_client_factory, ftp_server):
"""diagnose_storage() returns connected=True with working diagnostics."""
client = ftp_client_factory()
client.connect()
diag = client.diagnose_storage()
assert diag["connected"] is True
assert diag["can_list_root"] is True
assert diag["can_list_cache"] is True
assert diag["pwd"] is not None
assert diag["storage_info"] is not None
client.disconnect()
def test_diagnose_storage_not_connected(self):
"""diagnose_storage() reports not connected."""
client = BambuFTPClient("127.0.0.1", "12345678")
diag = client.diagnose_storage()
assert diag["connected"] is False
assert "FTP not connected" in diag["errors"]
# ---------------------------------------------------------------------------
# TestModelSpecificBehavior
# ---------------------------------------------------------------------------
class TestModelSpecificBehavior:
"""Tests for printer model-specific FTP behavior."""
def test_x1c_upload(self, ftp_client_factory, ftp_server, tmp_path):
"""X1C upload with session reuse succeeds."""
content = b"X1C upload data"
local = tmp_path / "x1c.3mf"
local.write_bytes(content)
client = ftp_client_factory(printer_model="X1C")
client.connect()
result = client.upload_file(local, "/cache/x1c.3mf")
assert result is True
client.disconnect()
# Verify via fresh connection
time.sleep(_UPLOAD_FLUSH_DELAY)
client2 = ftp_client_factory(printer_model="X1C")
client2.connect()
downloaded = client2.download_file("/cache/x1c.3mf")
assert downloaded == content
client2.disconnect()
def test_a1_upload_prot_c(self, ftp_client_factory, ftp_server, tmp_path):
"""A1 model upload with prot_c succeeds."""
content = b"A1 upload data"
local = tmp_path / "a1.3mf"
local.write_bytes(content)
client = ftp_client_factory(printer_model="A1", force_prot_c=True)
client.connect()
result = client.upload_file(local, "/cache/a1.3mf")
assert result is True
client.disconnect()
# Verify via fresh connection
time.sleep(_UPLOAD_FLUSH_DELAY)
client2 = ftp_client_factory(printer_model="A1", force_prot_c=True)
client2.connect()
downloaded = client2.download_file("/cache/a1.3mf")
assert downloaded == content
client2.disconnect()
def test_a1_mini_upload(self, ftp_client_factory, ftp_server, tmp_path):
"""A1 Mini model upload succeeds."""
content = b"A1 Mini data"
local = tmp_path / "a1mini.3mf"
local.write_bytes(content)
client = ftp_client_factory(printer_model="A1 Mini", force_prot_c=True)
client.connect()
result = client.upload_file(local, "/cache/a1mini.3mf")
assert result is True
client.disconnect()
def test_p1s_upload(self, ftp_client_factory, ftp_server, tmp_path):
"""P1S model upload with session reuse succeeds."""
content = b"P1S upload data"
local = tmp_path / "p1s.3mf"
local.write_bytes(content)
client = ftp_client_factory(printer_model="P1S")
client.connect()
result = client.upload_file(local, "/cache/p1s.3mf")
assert result is True
client.disconnect()
def test_unknown_model_defaults_prot_p(self, ftp_client_factory):
"""Unknown model defaults to prot_p."""
client = ftp_client_factory(printer_model="FuturePrinter3000")
assert client._is_a1_model() is False
assert client._should_use_prot_c() is False
assert client.connect() is True
client.disconnect()
def test_mode_cache_persists_and_clears(self, ftp_client_factory):
"""Mode cache works within a test and clears between tests."""
# Cache should be empty at start (autouse fixture clears it)
assert BambuFTPClient._mode_cache == {}
# Connect and cache a mode
BambuFTPClient.cache_mode("127.0.0.1", "prot_p")
assert BambuFTPClient._mode_cache["127.0.0.1"] == "prot_p"
# New client for same IP uses cached mode
client = ftp_client_factory(printer_model="A1")
assert client._get_cached_mode() == "prot_p"
assert client._should_use_prot_c() is False
client.disconnect()
# ---------------------------------------------------------------------------
# TestAsyncWrappers
# ---------------------------------------------------------------------------
class TestAsyncWrappers:
"""Tests for async wrapper functions using patch_ftp_port fixture."""
@pytest.mark.asyncio
async def test_upload_file_async_success(self, patch_ftp_port, tmp_path):
"""upload_file_async succeeds for X1C."""
content = b"async upload"
local = tmp_path / "async_up.3mf"
local.write_bytes(content)
result = await upload_file_async(
"127.0.0.1",
"12345678",
local,
"/cache/async_up.3mf",
timeout=30.0,
printer_model="X1C",
)
assert result is True
@pytest.mark.asyncio
async def test_upload_file_async_a1_fallback(self, patch_ftp_port, tmp_path):
"""upload_file_async tries prot_p then falls back to prot_c for A1."""
content = b"a1 async upload"
local = tmp_path / "a1_async.3mf"
local.write_bytes(content)
# For A1 models, if prot_p succeeds we get True.
# If prot_p fails, it tries prot_c. Either way should succeed
# against our mock server which accepts both.
result = await upload_file_async(
"127.0.0.1",
"12345678",
local,
"/cache/a1_async.3mf",
timeout=30.0,
printer_model="A1",
)
assert result is True
@pytest.mark.asyncio
async def test_download_file_async_success(self, patch_ftp_port, tmp_path):
"""download_file_async succeeds."""
server = patch_ftp_port
content = b"async download content"
server.add_file("cache/async_dl.bin", content)
local = tmp_path / "async_dl.bin"
result = await download_file_async(
"127.0.0.1",
"12345678",
"/cache/async_dl.bin",
local,
timeout=30.0,
printer_model="X1C",
)
assert result is True
assert local.read_bytes() == content
@pytest.mark.asyncio
async def test_download_file_async_a1_fallback(self, patch_ftp_port, tmp_path):
"""download_file_async falls back for A1 models."""
server = patch_ftp_port
server.add_file("cache/a1_dl.bin", b"a1 data")
local = tmp_path / "a1_dl.bin"
result = await download_file_async(
"127.0.0.1",
"12345678",
"/cache/a1_dl.bin",
local,
timeout=30.0,
printer_model="A1",
)
assert result is True
@pytest.mark.asyncio
async def test_download_file_try_paths_first_succeeds(self, patch_ftp_port, tmp_path):
"""download_file_try_paths_async succeeds on first path."""
server = patch_ftp_port
server.add_file("cache/try1.bin", b"first path")
local = tmp_path / "try.bin"
result = await download_file_try_paths_async(
"127.0.0.1",
"12345678",
["/cache/try1.bin", "/cache/try2.bin"],
local,
printer_model="X1C",
)
assert result is True
assert local.read_bytes() == b"first path"
@pytest.mark.asyncio
async def test_download_file_try_paths_fallback(self, patch_ftp_port, tmp_path):
"""download_file_try_paths_async falls back to second path."""
server = patch_ftp_port
server.add_file("cache/second.bin", b"second path")
local = tmp_path / "fallback.bin"
result = await download_file_try_paths_async(
"127.0.0.1",
"12345678",
["/cache/missing.bin", "/cache/second.bin"],
local,
printer_model="X1C",
)
assert result is True
assert local.read_bytes() == b"second path"
@pytest.mark.asyncio
async def test_list_files_async_success(self, patch_ftp_port):
"""list_files_async returns file list."""
server = patch_ftp_port
server.add_file("cache/listed.bin", b"data")
result = await list_files_async(
"127.0.0.1",
"12345678",
"/cache",
timeout=30.0,
printer_model="X1C",
)
names = {f["name"] for f in result}
assert "listed.bin" in names
@pytest.mark.asyncio
async def test_delete_file_async_success(self, patch_ftp_port):
"""delete_file_async deletes a file."""
server = patch_ftp_port
server.add_file("cache/to_async_del.bin", b"delete me")
result = await delete_file_async(
"127.0.0.1",
"12345678",
"/cache/to_async_del.bin",
printer_model="X1C",
)
assert result is True
assert not server.file_exists("cache/to_async_del.bin")
# ---------------------------------------------------------------------------
# TestFailureScenarios
# ---------------------------------------------------------------------------
class TestFailureScenarios:
"""Regression tests for known FTP failure modes."""
def test_550_caught_by_broad_except(self, ftp_client_factory, ftp_server, tmp_path):
"""550 error_perm is caught by (OSError, ftplib.Error) handler.
Regression: error_perm is a subclass of ftplib.Error, so the
broad except clause in upload_file catches it correctly.
"""
ftp_server.inject_failure("STOR", 550, "Permission denied.")
local = tmp_path / "test.bin"
local.write_bytes(b"data")
client = ftp_client_factory()
client.connect()
result = client.upload_file(local, "/cache/test.bin")
assert result is False
client.disconnect()
def test_zero_byte_download_detected(self, ftp_client_factory, ftp_server, tmp_path):
"""0-byte download is detected and file is cleaned up.
Regression: Prior to fix, 0-byte downloads were reported as success.
"""
ftp_server.add_file("cache/zero.bin", b"")
local = tmp_path / "zero.bin"
client = ftp_client_factory()
client.connect()
result = client.download_to_file("/cache/zero.bin", local)
assert result is False
assert not local.exists()
client.disconnect()
def test_connection_refused_handled(self):
"""Connection refused is handled gracefully."""
client = BambuFTPClient("127.0.0.1", "12345678", timeout=2.0)
client.FTP_PORT = 1 # Almost certainly not listening
assert client.connect() is False
def test_auth_failure_530(self, ftp_client_factory, ftp_server):
"""530 authentication failure returns False."""
ftp_server.inject_failure("PASS", 530, "Login incorrect.")
client = ftp_client_factory()
result = client.connect()
assert result is False
def test_retr_550_handled(self, ftp_client_factory, ftp_server):
"""RETR 550 (file not found) returns None."""
ftp_server.inject_failure("RETR", 550, "File not found.")
ftp_server.add_file("cache/exists.bin", b"data")
client = ftp_client_factory()
client.connect()
result = client.download_file("/cache/exists.bin")
assert result is None
client.disconnect()
def test_cwd_550_handled(self, ftp_client_factory, ftp_server):
"""CWD 550 is handled in list_files."""
ftp_server.inject_failure("CWD", 550, "Directory not found.")
client = ftp_client_factory()
client.connect()
result = client.list_files("/nonexistent")
assert result == []
client.disconnect()
def test_stor_553_handled(self, ftp_client_factory, ftp_server, tmp_path):
"""STOR 553 (no SD card) handled gracefully."""
ftp_server.inject_failure("STOR", 553, "Could not create file.")
local = tmp_path / "test.bin"
local.write_bytes(b"test")
client = ftp_client_factory()
client.connect()
result = client.upload_file(local, "/cache/test.bin")
assert result is False
client.disconnect()
def test_diagnose_storage_cwd_failure_doesnt_propagate(self, ftp_client_factory, ftp_server):
"""diagnose_storage CWD failure doesn't crash the whole operation.
Regression: diagnose_storage() was called in the upload path and
a CWD failure would propagate and crash the upload.
"""
ftp_server.inject_failure("CWD", 550, "No such directory.", count=2)
client = ftp_client_factory()
client.connect()
diag = client.diagnose_storage()
# Should still return results (with errors noted)
assert diag["connected"] is True
assert len(diag["errors"]) > 0
client.disconnect()
def test_failure_injection_count_decrements(self, ftp_client_factory, ftp_server):
"""Failure injection with count decrements and eventually succeeds."""
ftp_server.add_file("cache/retry.bin", b"data after retry")
ftp_server.inject_failure("RETR", 550, "Temporary error.", count=1)
client = ftp_client_factory()
client.connect()
# First attempt fails
result1 = client.download_file("/cache/retry.bin")
assert result1 is None
# Second attempt succeeds (failure count exhausted)
result2 = client.download_file("/cache/retry.bin")
assert result2 == b"data after retry"
client.disconnect()
@@ -4,7 +4,7 @@ These tests specifically target the sync_ams_tray method's disable_weight_sync
functionality that controls whether remaining_weight is updated.
"""
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, Mock, patch
import pytest
@@ -172,3 +172,205 @@ class TestSpoolmanClient:
assert call_kwargs["remaining_weight"] == expected, (
f"Expected {expected}g for {remain}% of {weight}g, got {call_kwargs['remaining_weight']}"
)
# ========================================================================
# Tests for caching functionality
# ========================================================================
@pytest.mark.asyncio
async def test_find_spool_by_tag_with_cached_spools(self, client):
"""Verify find_spool_by_tag uses cached spools when provided (no API call)."""
cached = [
{"id": 1, "extra": {"tag": '"ABC123"'}},
{"id": 2, "extra": {"tag": '"XYZ789"'}},
]
with patch.object(client, "get_spools", AsyncMock()) as mock_get:
result = await client.find_spool_by_tag("ABC123", cached_spools=cached)
assert result["id"] == 1
mock_get.assert_not_called() # Should NOT call get_spools
@pytest.mark.asyncio
async def test_find_spool_by_tag_without_cached_spools(self, client):
"""Verify find_spool_by_tag fetches spools when cache not provided."""
mock_spools = [{"id": 1, "extra": {"tag": '"ABC123"'}}]
with patch.object(client, "get_spools", AsyncMock(return_value=mock_spools)) as mock_get:
result = await client.find_spool_by_tag("ABC123")
assert result["id"] == 1
mock_get.assert_called_once() # Should call get_spools
@pytest.mark.asyncio
async def test_find_spools_by_location_prefix_with_cached_spools(self, client):
"""Verify find_spools_by_location_prefix uses cached spools when provided."""
cached = [
{"id": 1, "location": "Printer1 - AMS A1"},
{"id": 2, "location": "Printer2 - AMS A1"},
{"id": 3, "location": "Printer1 - AMS A2"},
]
with patch.object(client, "get_spools", AsyncMock()) as mock_get:
result = await client.find_spools_by_location_prefix("Printer1 - ", cached_spools=cached)
assert len(result) == 2
assert result[0]["id"] == 1
assert result[1]["id"] == 3
mock_get.assert_not_called() # Should NOT call get_spools
@pytest.mark.asyncio
async def test_sync_ams_tray_with_cached_spools(self, client, sample_tray, existing_spool):
"""Verify sync_ams_tray passes cached_spools to find_spool_by_tag."""
cached = [existing_spool]
with (
patch.object(client, "get_spools", AsyncMock()) as mock_get,
patch.object(client, "update_spool", AsyncMock(return_value={"id": 42})),
):
await client.sync_ams_tray(sample_tray, "TestPrinter", cached_spools=cached)
mock_get.assert_not_called() # Should NOT call get_spools
@pytest.mark.asyncio
async def test_clear_location_for_removed_spools_with_cached_spools(self, client):
"""Verify clear_location_for_removed_spools uses cached spools."""
cached = [
{"id": 1, "location": "Printer1 - AMS A1", "extra": {"tag": '"TAG1"'}},
{"id": 2, "location": "Printer1 - AMS A2", "extra": {"tag": '"TAG2"'}},
{"id": 3, "location": "Printer1 - AMS A3", "extra": {"tag": '"TAG3"'}},
]
current_tags = {"TAG1", "TAG2"} # TAG3 was removed
with (
patch.object(client, "get_spools", AsyncMock()) as mock_get,
patch.object(client, "update_spool", AsyncMock(return_value={"id": 3})) as mock_update,
):
cleared = await client.clear_location_for_removed_spools("Printer1", current_tags, cached_spools=cached)
assert cleared == 1
mock_get.assert_not_called() # Should NOT call get_spools
mock_update.assert_called_once()
# Verify it cleared TAG3 (not in current_tags)
call_kwargs = mock_update.call_args.kwargs
assert call_kwargs["spool_id"] == 3
assert call_kwargs.get("clear_location") is True
# ========================================================================
# Tests for retry logic in get_spools
# ========================================================================
@pytest.mark.asyncio
async def test_get_spools_succeeds_on_first_attempt(self, client):
"""Verify get_spools succeeds immediately when no errors occur."""
mock_spools = [{"id": 1}, {"id": 2}]
with patch.object(client, "_get_client") as mock_get_client:
mock_http_client = AsyncMock()
mock_response = Mock()
mock_response.raise_for_status = Mock()
mock_response.json = Mock(return_value=mock_spools)
mock_http_client.get = AsyncMock(return_value=mock_response)
mock_get_client.return_value = mock_http_client
result = await client.get_spools()
assert result == mock_spools
mock_get_client.assert_called_once()
mock_http_client.get.assert_called_once()
@pytest.mark.asyncio
async def test_get_spools_retries_on_connection_error(self, client):
"""Verify get_spools retries up to 3 times on connection errors."""
import httpx
mock_spools = [{"id": 1}]
with (
patch.object(client, "_get_client") as mock_get_client,
patch.object(client, "close", AsyncMock()) as mock_close,
patch("asyncio.sleep", AsyncMock()) as mock_sleep,
):
mock_http_client = AsyncMock()
mock_get_client.return_value = mock_http_client
# First 2 attempts fail with ReadError, 3rd succeeds
mock_response = Mock()
mock_response.raise_for_status = Mock()
mock_response.json = Mock(return_value=mock_spools)
mock_http_client.get = AsyncMock(
side_effect=[
httpx.ReadError("Connection closed"),
httpx.ReadError("Connection closed"),
mock_response,
]
)
result = await client.get_spools()
assert result == mock_spools
assert mock_get_client.call_count == 3
assert mock_http_client.get.call_count == 3
# Should close client twice (after each failed attempt)
assert mock_close.call_count == 2
# Should sleep twice (after first 2 attempts)
assert mock_sleep.call_count == 2
mock_sleep.assert_called_with(0.5)
@pytest.mark.asyncio
async def test_get_spools_raises_after_3_failed_attempts(self, client):
"""Verify get_spools raises exception after 3 failed attempts."""
import httpx
with (
patch.object(client, "_get_client", AsyncMock()) as mock_get_client,
patch.object(client, "close", AsyncMock()) as mock_close,
patch("asyncio.sleep", AsyncMock()) as mock_sleep,
):
mock_http_client = AsyncMock()
mock_get_client.return_value = mock_http_client
# All 3 attempts fail
mock_http_client.get.side_effect = httpx.ReadError("Connection closed")
with pytest.raises(httpx.ReadError):
await client.get_spools()
assert mock_get_client.call_count == 3
assert mock_http_client.get.call_count == 3
# Should close client twice (after first 2 failed attempts, not after 3rd)
assert mock_close.call_count == 2
# Should sleep twice (after first 2 attempts, not after 3rd)
assert mock_sleep.call_count == 2
@pytest.mark.asyncio
async def test_get_spools_handles_non_connection_errors(self, client):
"""Verify get_spools retries on non-connection errors without recreating client."""
import httpx
mock_spools = [{"id": 1}]
with (
patch.object(client, "_get_client") as mock_get_client,
patch.object(client, "close", AsyncMock()) as mock_close,
patch("asyncio.sleep", AsyncMock()) as mock_sleep,
):
mock_http_client = AsyncMock()
mock_get_client.return_value = mock_http_client
# First attempt fails with HTTP error, 2nd succeeds
mock_response_error = Mock()
mock_response_error.raise_for_status = Mock(
side_effect=httpx.HTTPStatusError("500 Server Error", request=Mock(), response=Mock())
)
mock_response_success = Mock()
mock_response_success.raise_for_status = Mock()
mock_response_success.json = Mock(return_value=mock_spools)
mock_http_client.get = AsyncMock(side_effect=[mock_response_error, mock_response_success])
result = await client.get_spools()
assert result == mock_spools
assert mock_get_client.call_count == 2
# Should NOT close client for HTTP errors (only connection errors)
mock_close.assert_not_called()
# Should sleep once (after first failed attempt)
assert mock_sleep.call_count == 1
@@ -507,7 +507,7 @@ class TestSSDPProxy:
"""Verify SSDP Location header is rewritten to remote interface IP."""
original_packet = b"NOTIFY * HTTP/1.1\r\nLocation: 192.168.1.50\r\nDevName.bambu.com: TestPrinter\r\n\r\n"
rewritten = ssdp_proxy._rewrite_ssdp_location(original_packet)
rewritten = ssdp_proxy._rewrite_ssdp(original_packet)
# Location should be changed to remote interface IP
assert b"Location: 10.0.0.100" in rewritten
@@ -519,7 +519,7 @@ class TestSSDPProxy:
"""Verify SSDP Location rewrite is case insensitive."""
original_packet = b"NOTIFY * HTTP/1.1\r\nlocation: 192.168.1.50\r\n\r\n"
rewritten = ssdp_proxy._rewrite_ssdp_location(original_packet)
rewritten = ssdp_proxy._rewrite_ssdp(original_packet)
assert b"10.0.0.100" in rewritten
@@ -527,10 +527,10 @@ class TestSSDPProxy:
"""Verify packet without Location header is returned unchanged."""
original_packet = b"NOTIFY * HTTP/1.1\r\nDevName.bambu.com: Test\r\n\r\n"
rewritten = ssdp_proxy._rewrite_ssdp_location(original_packet)
rewritten = ssdp_proxy._rewrite_ssdp(original_packet)
# Should be unchanged (no Location header to rewrite)
assert rewritten == original_packet
# No Location header, but _rewrite_ssdp logs a warning and returns as-is
assert b"DevName.bambu.com: Test" in rewritten
def test_parse_ssdp_message(self, ssdp_proxy):
"""Verify SSDP message parsing extracts headers."""
@@ -0,0 +1,229 @@
"""Unit tests for Home Assistant settings with environment variable support.
Tests the get_homeassistant_settings() function in isolation.
"""
import os
from unittest.mock import AsyncMock, patch
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
@pytest.mark.asyncio
@pytest.mark.unit
async def test_get_homeassistant_settings_no_env_vars():
"""Test get_homeassistant_settings with no environment variables."""
from backend.app.api.routes.settings import get_homeassistant_settings
# Mock database session
db = AsyncMock(spec=AsyncSession)
# Mock get_setting to return database values
with patch("backend.app.api.routes.settings.get_setting") as mock_get_setting:
mock_get_setting.side_effect = lambda db, key: {
"ha_url": "http://db-url:8123",
"ha_token": "db-token",
"ha_enabled": "true",
}.get(key, "")
# Ensure no env vars
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("HA_URL", None)
os.environ.pop("HA_TOKEN", None)
result = await get_homeassistant_settings(db)
# Should use database values
assert result["ha_url"] == "http://db-url:8123"
assert result["ha_token"] == "db-token"
assert result["ha_enabled"] is True
assert result["ha_url_from_env"] is False
assert result["ha_token_from_env"] is False
assert result["ha_env_managed"] is False
@pytest.mark.asyncio
@pytest.mark.unit
async def test_get_homeassistant_settings_with_env_vars():
"""Test get_homeassistant_settings with environment variables set."""
from backend.app.api.routes.settings import get_homeassistant_settings
db = AsyncMock(spec=AsyncSession)
with patch("backend.app.api.routes.settings.get_setting") as mock_get_setting:
mock_get_setting.side_effect = lambda db, key: {
"ha_url": "http://db-url:8123",
"ha_token": "db-token",
"ha_enabled": "false",
}.get(key, "")
# Set environment variables
with patch.dict(os.environ, {"HA_URL": "http://supervisor/core", "HA_TOKEN": "env-token"}, clear=False):
result = await get_homeassistant_settings(db)
# Should use environment values
assert result["ha_url"] == "http://supervisor/core"
assert result["ha_token"] == "env-token"
assert result["ha_enabled"] is True # Auto-enabled
assert result["ha_url_from_env"] is True
assert result["ha_token_from_env"] is True
assert result["ha_env_managed"] is True
@pytest.mark.asyncio
@pytest.mark.unit
async def test_get_homeassistant_settings_partial_env_url_only():
"""Test get_homeassistant_settings with only HA_URL set."""
from backend.app.api.routes.settings import get_homeassistant_settings
db = AsyncMock(spec=AsyncSession)
with patch("backend.app.api.routes.settings.get_setting") as mock_get_setting:
mock_get_setting.side_effect = lambda db, key: {
"ha_url": "http://db-url:8123",
"ha_token": "db-token",
"ha_enabled": "false",
}.get(key, "")
# Set only URL env var
with patch.dict(os.environ, {"HA_URL": "http://supervisor/core"}, clear=False):
os.environ.pop("HA_TOKEN", None)
result = await get_homeassistant_settings(db)
# URL from env, token from database
assert result["ha_url"] == "http://supervisor/core"
assert result["ha_token"] == "db-token"
assert result["ha_enabled"] is False # Not auto-enabled
assert result["ha_url_from_env"] is True
assert result["ha_token_from_env"] is False
assert result["ha_env_managed"] is False
@pytest.mark.asyncio
@pytest.mark.unit
async def test_get_homeassistant_settings_partial_env_token_only():
"""Test get_homeassistant_settings with only HA_TOKEN set."""
from backend.app.api.routes.settings import get_homeassistant_settings
db = AsyncMock(spec=AsyncSession)
with patch("backend.app.api.routes.settings.get_setting") as mock_get_setting:
mock_get_setting.side_effect = lambda db, key: {
"ha_url": "http://db-url:8123",
"ha_token": "db-token",
"ha_enabled": "false",
}.get(key, "")
# Set only token env var
with patch.dict(os.environ, {"HA_TOKEN": "env-token"}, clear=False):
os.environ.pop("HA_URL", None)
result = await get_homeassistant_settings(db)
# URL from database, token from env
assert result["ha_url"] == "http://db-url:8123"
assert result["ha_token"] == "env-token"
assert result["ha_enabled"] is False # Not auto-enabled
assert result["ha_url_from_env"] is False
assert result["ha_token_from_env"] is True
assert result["ha_env_managed"] is False
@pytest.mark.asyncio
@pytest.mark.unit
async def test_get_homeassistant_settings_empty_env_vars():
"""Test get_homeassistant_settings with empty environment variables."""
from backend.app.api.routes.settings import get_homeassistant_settings
db = AsyncMock(spec=AsyncSession)
with patch("backend.app.api.routes.settings.get_setting") as mock_get_setting:
mock_get_setting.side_effect = lambda db, key: {
"ha_url": "http://db-url:8123",
"ha_token": "db-token",
"ha_enabled": "false",
}.get(key, "")
# Set empty env vars
with patch.dict(os.environ, {"HA_URL": "", "HA_TOKEN": ""}, clear=False):
result = await get_homeassistant_settings(db)
# Empty env vars treated as not set, should use database values
assert result["ha_url"] == "http://db-url:8123"
assert result["ha_token"] == "db-token"
assert result["ha_enabled"] is False
assert result["ha_url_from_env"] is False
assert result["ha_token_from_env"] is False
assert result["ha_env_managed"] is False
@pytest.mark.asyncio
@pytest.mark.unit
async def test_get_homeassistant_settings_auto_enable_logic():
"""Test auto-enable behavior with various configurations."""
from backend.app.api.routes.settings import get_homeassistant_settings
db = AsyncMock(spec=AsyncSession)
with patch("backend.app.api.routes.settings.get_setting") as mock_get_setting:
# Database has ha_enabled=false
mock_get_setting.side_effect = lambda db, key: {
"ha_url": "",
"ha_token": "",
"ha_enabled": "false",
}.get(key, "")
# Test 1: No env vars - use database enabled state
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("HA_URL", None)
os.environ.pop("HA_TOKEN", None)
result = await get_homeassistant_settings(db)
assert result["ha_enabled"] is False
# Test 2: Both env vars set - auto-enable
with patch.dict(os.environ, {"HA_URL": "http://test", "HA_TOKEN": "token"}, clear=False):
result = await get_homeassistant_settings(db)
assert result["ha_enabled"] is True
# Test 3: Only URL - use database enabled state
with patch.dict(os.environ, {"HA_URL": "http://test"}, clear=False):
os.environ.pop("HA_TOKEN", None)
result = await get_homeassistant_settings(db)
assert result["ha_enabled"] is False
# Test 4: Only token - use database enabled state
with patch.dict(os.environ, {"HA_TOKEN": "token"}, clear=False):
os.environ.pop("HA_URL", None)
result = await get_homeassistant_settings(db)
assert result["ha_enabled"] is False
@pytest.mark.asyncio
@pytest.mark.unit
async def test_get_homeassistant_settings_env_vars_override_enabled_true():
"""Test that env vars auto-enable even when database has ha_enabled=true."""
from backend.app.api.routes.settings import get_homeassistant_settings
db = AsyncMock(spec=AsyncSession)
with patch("backend.app.api.routes.settings.get_setting") as mock_get_setting:
# Database has ha_enabled=true
mock_get_setting.side_effect = lambda db, key: {
"ha_url": "http://db-url:8123",
"ha_token": "db-token",
"ha_enabled": "true",
}.get(key, "")
# Both env vars set - should still be enabled
with patch.dict(os.environ, {"HA_URL": "http://supervisor/core", "HA_TOKEN": "env-token"}, clear=False):
result = await get_homeassistant_settings(db)
assert result["ha_enabled"] is True # Auto-enabled by env vars
assert result["ha_url"] == "http://supervisor/core"
assert result["ha_token"] == "env-token"
assert result["ha_env_managed"] is True
+428
View File
@@ -0,0 +1,428 @@
"""Unit tests for support module helper functions.
Tests _anonymize_mqtt_broker, _check_port, _get_container_memory_limit,
_format_bytes, and _collect_support_info diagnostic sections.
"""
import asyncio
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
class TestAnonymizeMqttBroker:
"""Tests for _anonymize_mqtt_broker()."""
def test_empty_string(self):
from backend.app.api.routes.support import _anonymize_mqtt_broker
assert _anonymize_mqtt_broker("") == ""
def test_ipv4_address(self):
from backend.app.api.routes.support import _anonymize_mqtt_broker
assert _anonymize_mqtt_broker("192.168.1.100") == "[IP]"
def test_ipv6_address(self):
from backend.app.api.routes.support import _anonymize_mqtt_broker
assert _anonymize_mqtt_broker("::1") == "[IP]"
def test_hostname_with_domain(self):
from backend.app.api.routes.support import _anonymize_mqtt_broker
assert _anonymize_mqtt_broker("mqtt.example.com") == "*.example.com"
def test_hostname_with_subdomain(self):
from backend.app.api.routes.support import _anonymize_mqtt_broker
assert _anonymize_mqtt_broker("broker.mqtt.example.com") == "*.example.com"
def test_single_part_hostname(self):
from backend.app.api.routes.support import _anonymize_mqtt_broker
assert _anonymize_mqtt_broker("localhost") == "localhost"
class TestCheckPort:
"""Tests for _check_port()."""
@pytest.mark.asyncio
@pytest.mark.unit
async def test_reachable_port(self):
from backend.app.api.routes.support import _check_port
# Mock a successful connection
mock_writer = AsyncMock()
mock_writer.close = MagicMock()
mock_writer.wait_closed = AsyncMock()
with patch("backend.app.api.routes.support.asyncio.open_connection", return_value=(AsyncMock(), mock_writer)):
result = await _check_port("192.168.1.1", 8883, timeout=1.0)
assert result is True
@pytest.mark.asyncio
@pytest.mark.unit
async def test_unreachable_port(self):
from backend.app.api.routes.support import _check_port
with (
patch(
"backend.app.api.routes.support.asyncio.open_connection",
side_effect=ConnectionRefusedError,
),
patch(
"backend.app.api.routes.support.asyncio.wait_for",
side_effect=ConnectionRefusedError,
),
):
result = await _check_port("192.168.1.1", 8883, timeout=1.0)
assert result is False
@pytest.mark.asyncio
@pytest.mark.unit
async def test_timeout(self):
from backend.app.api.routes.support import _check_port
with patch(
"backend.app.api.routes.support.asyncio.wait_for",
side_effect=asyncio.TimeoutError,
):
result = await _check_port("192.168.1.1", 8883, timeout=0.1)
assert result is False
class TestGetContainerMemoryLimit:
"""Tests for _get_container_memory_limit()."""
def test_cgroup_v2_with_limit(self):
from backend.app.api.routes.support import _get_container_memory_limit
with tempfile.TemporaryDirectory() as tmpdir:
v2_path = Path(tmpdir) / "memory.max"
v2_path.write_text("1073741824\n")
with patch("backend.app.api.routes.support.Path") as mock_path:
# v2 path exists with value
v2_mock = MagicMock()
v2_mock.exists.return_value = True
v2_mock.read_text.return_value = "1073741824\n"
v1_mock = MagicMock()
v1_mock.exists.return_value = False
mock_path.side_effect = lambda p: v2_mock if "memory.max" in p else v1_mock
result = _get_container_memory_limit()
assert result == 1073741824
def test_cgroup_v2_unlimited(self):
from backend.app.api.routes.support import _get_container_memory_limit
with patch("backend.app.api.routes.support.Path") as mock_path:
v2_mock = MagicMock()
v2_mock.exists.return_value = True
v2_mock.read_text.return_value = "max\n"
v1_mock = MagicMock()
v1_mock.exists.return_value = False
mock_path.side_effect = lambda p: v2_mock if "memory.max" in p else v1_mock
result = _get_container_memory_limit()
assert result is None
def test_no_cgroup_files(self):
from backend.app.api.routes.support import _get_container_memory_limit
with patch("backend.app.api.routes.support.Path") as mock_path:
mock_instance = MagicMock()
mock_instance.exists.return_value = False
mock_path.return_value = mock_instance
result = _get_container_memory_limit()
assert result is None
class TestFormatBytes:
"""Tests for _format_bytes()."""
def test_bytes(self):
from backend.app.api.routes.support import _format_bytes
assert _format_bytes(500) == "500 B"
def test_kilobytes(self):
from backend.app.api.routes.support import _format_bytes
assert _format_bytes(2048) == "2.0 KB"
def test_megabytes(self):
from backend.app.api.routes.support import _format_bytes
assert _format_bytes(10 * 1024 * 1024) == "10.0 MB"
def test_gigabytes(self):
from backend.app.api.routes.support import _format_bytes
assert _format_bytes(2 * 1024 * 1024 * 1024) == "2.00 GB"
def test_zero(self):
from backend.app.api.routes.support import _format_bytes
assert _format_bytes(0) == "0 B"
class TestCollectSupportInfo:
"""Tests for _collect_support_info() new diagnostic sections."""
@pytest.mark.asyncio
@pytest.mark.unit
async def test_environment_has_timezone(self):
"""Verify environment section includes timezone."""
from backend.app.api.routes.support import _collect_support_info
with (
patch("backend.app.api.routes.support.is_running_in_docker", return_value=False),
patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
patch("backend.app.api.routes.support.printer_manager") as mock_pm,
patch("backend.app.api.routes.support.get_network_interfaces", return_value=[]),
patch("backend.app.api.routes.support.ws_manager") as mock_ws,
patch.dict("os.environ", {"TZ": "America/New_York"}),
):
mock_pm.get_all_statuses.return_value = {}
mock_ws.active_connections = []
mock_db = AsyncMock()
mock_result = MagicMock()
mock_result.scalar.return_value = 0
mock_result.scalar_one_or_none.return_value = None
mock_result.scalars.return_value.all.return_value = []
mock_db.execute = AsyncMock(return_value=mock_result)
mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
info = await _collect_support_info()
assert info["environment"]["timezone"] == "America/New_York"
assert info["environment"]["docker"] is False
@pytest.mark.asyncio
@pytest.mark.unit
async def test_docker_section_present_when_in_docker(self):
"""Verify docker section is added when running in Docker."""
from backend.app.api.routes.support import _collect_support_info
with (
patch("backend.app.api.routes.support.is_running_in_docker", return_value=True),
patch("backend.app.api.routes.support._get_container_memory_limit", return_value=1073741824),
patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
patch("backend.app.api.routes.support.printer_manager") as mock_pm,
patch(
"backend.app.api.routes.support.get_network_interfaces",
return_value=[{"name": "eth0", "subnet": "172.17.0.0/16"}],
),
patch("backend.app.api.routes.support.ws_manager") as mock_ws,
):
mock_pm.get_all_statuses.return_value = {}
mock_ws.active_connections = []
mock_db = AsyncMock()
mock_result = MagicMock()
mock_result.scalar.return_value = 0
mock_result.scalar_one_or_none.return_value = None
mock_result.scalars.return_value.all.return_value = []
mock_db.execute = AsyncMock(return_value=mock_result)
mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
info = await _collect_support_info()
assert "docker" in info
assert info["docker"]["container_memory_limit_bytes"] == 1073741824
assert info["docker"]["container_memory_limit_formatted"] == "1.00 GB"
assert info["docker"]["network_mode_hint"] == "bridge"
@pytest.mark.asyncio
@pytest.mark.unit
async def test_docker_section_absent_when_not_docker(self):
"""Verify docker section is absent when not in Docker."""
from backend.app.api.routes.support import _collect_support_info
with (
patch("backend.app.api.routes.support.is_running_in_docker", return_value=False),
patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
patch("backend.app.api.routes.support.printer_manager") as mock_pm,
patch("backend.app.api.routes.support.get_network_interfaces", return_value=[]),
patch("backend.app.api.routes.support.ws_manager") as mock_ws,
):
mock_pm.get_all_statuses.return_value = {}
mock_ws.active_connections = []
mock_db = AsyncMock()
mock_result = MagicMock()
mock_result.scalar.return_value = 0
mock_result.scalar_one_or_none.return_value = None
mock_result.scalars.return_value.all.return_value = []
mock_db.execute = AsyncMock(return_value=mock_result)
mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
info = await _collect_support_info()
assert "docker" not in info
@pytest.mark.asyncio
@pytest.mark.unit
async def test_dependencies_section(self):
"""Verify dependencies section lists package versions."""
from backend.app.api.routes.support import _collect_support_info
with (
patch("backend.app.api.routes.support.is_running_in_docker", return_value=False),
patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
patch("backend.app.api.routes.support.printer_manager") as mock_pm,
patch("backend.app.api.routes.support.get_network_interfaces", return_value=[]),
patch("backend.app.api.routes.support.ws_manager") as mock_ws,
):
mock_pm.get_all_statuses.return_value = {}
mock_ws.active_connections = []
mock_db = AsyncMock()
mock_result = MagicMock()
mock_result.scalar.return_value = 0
mock_result.scalar_one_or_none.return_value = None
mock_result.scalars.return_value.all.return_value = []
mock_db.execute = AsyncMock(return_value=mock_result)
mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
info = await _collect_support_info()
assert "dependencies" in info
# fastapi should be installed in test environment
assert "fastapi" in info["dependencies"]
assert info["dependencies"]["fastapi"] is not None
@pytest.mark.asyncio
@pytest.mark.unit
async def test_websockets_section(self):
"""Verify websockets section shows connection count."""
from backend.app.api.routes.support import _collect_support_info
with (
patch("backend.app.api.routes.support.is_running_in_docker", return_value=False),
patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
patch("backend.app.api.routes.support.printer_manager") as mock_pm,
patch("backend.app.api.routes.support.get_network_interfaces", return_value=[]),
patch("backend.app.api.routes.support.ws_manager") as mock_ws,
):
mock_pm.get_all_statuses.return_value = {}
mock_ws.active_connections = ["conn1", "conn2"]
mock_db = AsyncMock()
mock_result = MagicMock()
mock_result.scalar.return_value = 0
mock_result.scalar_one_or_none.return_value = None
mock_result.scalars.return_value.all.return_value = []
mock_db.execute = AsyncMock(return_value=mock_result)
mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
info = await _collect_support_info()
assert info["websockets"]["active_connections"] == 2
@pytest.mark.asyncio
@pytest.mark.unit
async def test_network_section(self):
"""Verify network section shows interface subnets."""
from backend.app.api.routes.support import _collect_support_info
mock_interfaces = [
{"name": "eth0", "ip": "192.168.1.100", "netmask": "255.255.255.0", "subnet": "192.168.1.0/24"},
{"name": "wlan0", "ip": "10.0.0.50", "netmask": "255.255.255.0", "subnet": "10.0.0.0/24"},
]
with (
patch("backend.app.api.routes.support.is_running_in_docker", return_value=False),
patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
patch("backend.app.api.routes.support.printer_manager") as mock_pm,
patch("backend.app.api.routes.support.get_network_interfaces", return_value=mock_interfaces),
patch("backend.app.api.routes.support.ws_manager") as mock_ws,
):
mock_pm.get_all_statuses.return_value = {}
mock_ws.active_connections = []
mock_db = AsyncMock()
mock_result = MagicMock()
mock_result.scalar.return_value = 0
mock_result.scalar_one_or_none.return_value = None
mock_result.scalars.return_value.all.return_value = []
mock_db.execute = AsyncMock(return_value=mock_result)
mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
info = await _collect_support_info()
assert info["network"]["interface_count"] == 2
assert info["network"]["interfaces"][0]["name"] == "eth0"
assert info["network"]["interfaces"][0]["subnet"] == "192.168.1.0/24"
# Verify IP addresses are NOT included
for iface in info["network"]["interfaces"]:
assert "ip" not in iface
@pytest.mark.asyncio
@pytest.mark.unit
async def test_log_file_section(self):
"""Verify log file section shows size info."""
from backend.app.api.routes.support import _collect_support_info
with tempfile.TemporaryDirectory() as tmpdir:
log_dir = Path(tmpdir)
log_file = log_dir / "bambuddy.log"
log_file.write_text("some log content\n" * 100)
with (
patch("backend.app.api.routes.support.is_running_in_docker", return_value=False),
patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
patch("backend.app.api.routes.support.printer_manager") as mock_pm,
patch("backend.app.api.routes.support.get_network_interfaces", return_value=[]),
patch("backend.app.api.routes.support.ws_manager") as mock_ws,
patch("backend.app.api.routes.support.settings") as mock_settings,
):
mock_settings.base_dir = Path(tmpdir)
mock_settings.log_dir = log_dir
mock_settings.debug = False
mock_pm.get_all_statuses.return_value = {}
mock_ws.active_connections = []
mock_db = AsyncMock()
mock_result = MagicMock()
mock_result.scalar.return_value = 0
mock_result.scalar_one_or_none.return_value = None
mock_result.scalars.return_value.all.return_value = []
mock_db.execute = AsyncMock(return_value=mock_result)
mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
info = await _collect_support_info()
assert "log_file" in info
assert info["log_file"]["size_bytes"] > 0
assert "B" in info["log_file"]["size_formatted"] or "KB" in info["log_file"]["size_formatted"]
+13 -1
View File
@@ -10,6 +10,11 @@ services:
# Override with: PUID=$(id -u) PGID=$(id -g) docker compose up -d
user: "${PUID:-1000}:${PGID:-1000}"
#
# Proxy mode: allow binding to port 990 (FTP) as non-root user.
# Without this, the FTP proxy silently fails and sending prints won't work.
cap_add:
- NET_BIND_SERVICE
#
# LINUX: Use host mode for printer discovery and camera streaming
network_mode: host
#
@@ -18,6 +23,9 @@ services:
# Note: Printer discovery won't work - add printers manually by IP.
#ports:
# - "${PORT:-8000}:8000"
# - "8883:8883" # Virtual printer MQTT
# - "9990:9990" # Virtual printer FTP control
# - "50000-50100:50000-50100" # Virtual printer FTP passive data
volumes:
- bambuddy_data:/app/data
- bambuddy_logs:/app/logs
@@ -26,10 +34,14 @@ services:
# This ensures the slicer only needs to trust one CA certificate.
- ./virtual_printer:/app/data/virtual_printer
environment:
- TZ=Europe/Berlin
- TZ=${TZ:-Europe/Berlin}
# Port BamBuddy runs on (default: 8000)
# Usage: PORT=8080 docker compose up -d
- PORT=${PORT:-8000}
# Virtual printer: Set to the Docker host's IP when using bridge mode (ports:).
# Required for FTP passive mode to work behind NAT.
# Example: VIRTUAL_PRINTER_PASV_ADDRESS=192.168.1.100
#- VIRTUAL_PRINTER_PASV_ADDRESS=
restart: unless-stopped
volumes:
@@ -0,0 +1,182 @@
/**
* Tests for AddPrinterModal discovery subnet auto-detection.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../utils';
import { PrintersPage } from '../../pages/PrintersPage';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
const mockPrinters = [
{
id: 1,
name: 'X1 Carbon',
ip_address: '192.168.1.100',
serial_number: '00M09A350100001',
access_code: '12345678',
model: 'X1C',
enabled: true,
nozzle_diameter: 0.4,
nozzle_type: 'hardened_steel',
location: null,
auto_archive: true,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
},
];
const mockPrinterStatus = {
connected: true,
state: 'IDLE',
progress: 0,
layer_num: 0,
total_layers: 0,
temperatures: { nozzle: 25, bed: 25, chamber: 25 },
remaining_time: 0,
filename: null,
wifi_signal: -50,
};
describe('AddPrinterModal Discovery', () => {
beforeEach(() => {
server.use(
http.get('/api/v1/printers/', () => {
return HttpResponse.json(mockPrinters);
}),
http.get('/api/v1/printers/:id/status', () => {
return HttpResponse.json(mockPrinterStatus);
}),
http.get('/api/v1/queue/', () => {
return HttpResponse.json([]);
})
);
});
it('auto-populates subnet from discovery info in Docker mode', async () => {
server.use(
http.get('/api/v1/discovery/info', () => {
return HttpResponse.json({
is_docker: true,
ssdp_running: false,
scan_running: false,
subnets: ['10.0.0.0/24'],
});
})
);
render(<PrintersPage />);
// Wait for printer page to load
await waitFor(() => {
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
});
// Click the Add Printer button
const addButton = screen.getByText(/add printer/i);
await userEvent.click(addButton);
// Wait for the modal and discovery info to load
await waitFor(() => {
// Should show subnet dropdown with detected subnet
const subnetSelect = screen.getByDisplayValue('10.0.0.0/24');
expect(subnetSelect).toBeInTheDocument();
});
});
it('shows dropdown when multiple subnets detected in Docker mode', async () => {
server.use(
http.get('/api/v1/discovery/info', () => {
return HttpResponse.json({
is_docker: true,
ssdp_running: false,
scan_running: false,
subnets: ['192.168.1.0/24', '10.0.0.0/24'],
});
})
);
render(<PrintersPage />);
await waitFor(() => {
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
});
const addButton = screen.getByText(/add printer/i);
await userEvent.click(addButton);
await waitFor(() => {
// Should show a select element (dropdown) with both subnets
const selectElement = screen.getByDisplayValue('192.168.1.0/24');
expect(selectElement.tagName).toBe('SELECT');
// Both options should be available
const options = selectElement.querySelectorAll('option');
expect(options).toHaveLength(2);
expect(options[0].textContent).toBe('192.168.1.0/24');
expect(options[1].textContent).toBe('10.0.0.0/24');
});
});
it('shows text input when no subnets detected in Docker mode', async () => {
server.use(
http.get('/api/v1/discovery/info', () => {
return HttpResponse.json({
is_docker: true,
ssdp_running: false,
scan_running: false,
subnets: [],
});
})
);
render(<PrintersPage />);
await waitFor(() => {
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
});
const addButton = screen.getByText(/add printer/i);
await userEvent.click(addButton);
await waitFor(() => {
// Should show a text input with placeholder
const textInput = screen.getByPlaceholderText('192.168.1.0/24');
expect(textInput).toBeInTheDocument();
expect(textInput.tagName).toBe('INPUT');
});
});
it('does not show subnet field in non-Docker mode', async () => {
server.use(
http.get('/api/v1/discovery/info', () => {
return HttpResponse.json({
is_docker: false,
ssdp_running: false,
scan_running: false,
subnets: ['192.168.1.0/24'],
});
})
);
render(<PrintersPage />);
await waitFor(() => {
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
});
const addButton = screen.getByText(/add printer/i);
await userEvent.click(addButton);
await waitFor(() => {
// Should show the discover button but NOT the subnet field
expect(screen.getByText(/discover printers/i)).toBeInTheDocument();
});
// Subnet field should not exist
expect(screen.queryByPlaceholderText('192.168.1.0/24')).not.toBeInTheDocument();
expect(screen.queryByDisplayValue('192.168.1.0/24')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,168 @@
/**
* Tests for the FilamentHoverCard component.
* Focuses on fill level display and Spoolman source indicator.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '../utils';
import { FilamentHoverCard } from '../../components/FilamentHoverCard';
const baseFilamentData = {
vendor: 'Bambu Lab' as const,
profile: 'PLA Basic',
colorName: 'Red',
colorHex: 'FF0000',
kFactor: '0.030',
fillLevel: 75,
trayUuid: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4',
};
function renderWithHover(ui: React.ReactElement) {
const result = render(ui);
// Trigger hover to show the card
const trigger = result.container.firstElementChild as HTMLElement;
fireEvent.mouseEnter(trigger);
return result;
}
describe('FilamentHoverCard', () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
describe('fill level display', () => {
it('shows fill percentage when fillLevel is set', async () => {
renderWithHover(
<FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 75 }}>
<div>trigger</div>
</FilamentHoverCard>
);
vi.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText('75%')).toBeInTheDocument();
});
});
it('shows dash when fillLevel is null', async () => {
renderWithHover(
<FilamentHoverCard data={{ ...baseFilamentData, fillLevel: null }}>
<div>trigger</div>
</FilamentHoverCard>
);
vi.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText('—')).toBeInTheDocument();
});
});
it('shows 0% when fillLevel is zero', async () => {
renderWithHover(
<FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 0 }}>
<div>trigger</div>
</FilamentHoverCard>
);
vi.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText('0%')).toBeInTheDocument();
});
});
});
describe('Spoolman source indicator', () => {
it('shows Spoolman label when fillSource is spoolman', async () => {
renderWithHover(
<FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 80, fillSource: 'spoolman' }}>
<div>trigger</div>
</FilamentHoverCard>
);
vi.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText('(Spoolman)')).toBeInTheDocument();
});
});
it('does not show Spoolman label when fillSource is ams', async () => {
renderWithHover(
<FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 80, fillSource: 'ams' }}>
<div>trigger</div>
</FilamentHoverCard>
);
vi.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText('80%')).toBeInTheDocument();
expect(screen.queryByText('(Spoolman)')).not.toBeInTheDocument();
});
});
it('does not show Spoolman label when fillLevel is null', async () => {
renderWithHover(
<FilamentHoverCard data={{ ...baseFilamentData, fillLevel: null, fillSource: 'spoolman' }}>
<div>trigger</div>
</FilamentHoverCard>
);
vi.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText('—')).toBeInTheDocument();
expect(screen.queryByText('(Spoolman)')).not.toBeInTheDocument();
});
});
it('does not show Spoolman label when fillSource is undefined', async () => {
renderWithHover(
<FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 50 }}>
<div>trigger</div>
</FilamentHoverCard>
);
vi.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText('50%')).toBeInTheDocument();
expect(screen.queryByText('(Spoolman)')).not.toBeInTheDocument();
});
});
});
describe('hover behavior', () => {
it('does not show card when disabled', () => {
renderWithHover(
<FilamentHoverCard data={baseFilamentData} disabled>
<div>trigger</div>
</FilamentHoverCard>
);
vi.advanceTimersByTime(100);
// Card should not be visible
expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument();
});
it('shows filament details on hover', async () => {
renderWithHover(
<FilamentHoverCard data={baseFilamentData}>
<div>trigger</div>
</FilamentHoverCard>
);
vi.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText('Red')).toBeInTheDocument();
expect(screen.getByText('PLA Basic')).toBeInTheDocument();
expect(screen.getByText('0.030')).toBeInTheDocument();
});
});
});
});
+13
View File
@@ -364,6 +364,19 @@ export const handlers = [
return new HttpResponse(null, { status: 204 });
}),
// ========================================================================
// Discovery
// ========================================================================
http.get('/api/v1/discovery/info', () => {
return HttpResponse.json({
is_docker: false,
ssdp_running: false,
scan_running: false,
subnets: ['192.168.1.0/24'],
});
}),
// ========================================================================
// Version / Health
// ========================================================================
@@ -11,6 +11,7 @@ import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from '../../contexts/ThemeContext';
import { ToastProvider } from '../../contexts/ToastContext';
import { AuthProvider } from '../../contexts/AuthContext';
import { I18nextProvider } from 'react-i18next';
import i18n from '../../i18n';
@@ -44,11 +45,13 @@ function renderCameraPage(printerId: number) {
<I18nextProvider i18n={i18n}>
<MemoryRouter initialEntries={[`/cameras/${printerId}`]}>
<ThemeProvider>
<ToastProvider>
<Routes>
<Route path="/cameras/:printerId" element={<CameraPage />} />
</Routes>
</ToastProvider>
<AuthProvider>
<ToastProvider>
<Routes>
<Route path="/cameras/:printerId" element={<CameraPage />} />
</Routes>
</ToastProvider>
</AuthProvider>
</ThemeProvider>
</MemoryRouter>
</I18nextProvider>
@@ -281,6 +281,28 @@ describe('SystemInfoPage', () => {
expect(progressBars.length).toBeGreaterThan(0);
});
it('displays extended privacy disclosure items', async () => {
(api.getSystemInfo as ReturnType<typeof vi.fn>).mockResolvedValue(mockSystemInfo);
render(<SystemInfoPage />);
await waitFor(() => {
expect(screen.getByText("What's in the support bundle?")).toBeInTheDocument();
});
// Original items
expect(screen.getByText(/App version and debug mode/)).toBeInTheDocument();
expect(screen.getByText(/Debug logs \(sanitized\)/)).toBeInTheDocument();
// New diagnostic items
expect(screen.getByText(/Printer connectivity and firmware versions/)).toBeInTheDocument();
expect(screen.getByText(/Integration status \(Spoolman, MQTT, HA\)/)).toBeInTheDocument();
expect(screen.getByText(/Network interfaces \(subnets only\)/)).toBeInTheDocument();
expect(screen.getByText(/Python package versions/)).toBeInTheDocument();
expect(screen.getByText(/Database health checks/)).toBeInTheDocument();
expect(screen.getByText(/Docker environment details/)).toBeInTheDocument();
});
it('applies danger color for critical disk usage', async () => {
const criticalDiskUsage = {
...mockSystemInfo,
@@ -0,0 +1,78 @@
/**
* Tests for getSpoolmanFillLevel helper function.
* This function is defined in PrintersPage.tsx but tested here for isolation.
* We replicate the logic to test it independently.
*/
import { describe, it, expect } from 'vitest';
// Replicate the function from PrintersPage.tsx for testing
interface LinkedSpoolInfo {
id: number;
remaining_weight: number | null;
filament_weight: number | null;
}
function getSpoolmanFillLevel(
linkedSpool: LinkedSpoolInfo | undefined
): number | null {
if (!linkedSpool?.remaining_weight || !linkedSpool?.filament_weight
|| linkedSpool.filament_weight <= 0) return null;
return Math.min(100, Math.round(
(linkedSpool.remaining_weight / linkedSpool.filament_weight) * 100
));
}
describe('getSpoolmanFillLevel', () => {
it('returns null for undefined spool', () => {
expect(getSpoolmanFillLevel(undefined)).toBeNull();
});
it('returns null when remaining_weight is null', () => {
expect(getSpoolmanFillLevel({ id: 1, remaining_weight: null, filament_weight: 1000 })).toBeNull();
});
it('returns null when filament_weight is null', () => {
expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 500, filament_weight: null })).toBeNull();
});
it('returns null when remaining_weight is 0', () => {
expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 0, filament_weight: 1000 })).toBeNull();
});
it('returns null when filament_weight is 0', () => {
expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 500, filament_weight: 0 })).toBeNull();
});
it('returns null when filament_weight is negative', () => {
expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 500, filament_weight: -100 })).toBeNull();
});
it('calculates correct percentage for half-full spool', () => {
expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 500, filament_weight: 1000 })).toBe(50);
});
it('calculates correct percentage for full spool', () => {
expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 1000, filament_weight: 1000 })).toBe(100);
});
it('calculates correct percentage for nearly empty spool', () => {
expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 50, filament_weight: 1000 })).toBe(5);
});
it('caps at 100% when remaining exceeds filament weight', () => {
// This can happen if user manually sets remaining_weight higher
expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 1200, filament_weight: 1000 })).toBe(100);
});
it('rounds to nearest integer', () => {
// 333/1000 = 33.3% -> 33%
expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 333, filament_weight: 1000 })).toBe(33);
// 666/1000 = 66.6% -> 67%
expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 666, filament_weight: 1000 })).toBe(67);
});
it('handles small weights correctly', () => {
expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 1, filament_weight: 100 })).toBe(1);
});
});
+20 -1
View File
@@ -129,6 +129,14 @@ export interface NozzleInfo {
nozzle_diameter: string; // e.g., "0.4"
}
export interface NozzleRackSlot {
id: number;
nozzle_type: string;
nozzle_diameter: string;
wear: number | null;
stat: number | null; // Nozzle status (e.g. mounted/docked)
}
export interface PrintOptions {
// Core AI detectors
spaghetti_detector: boolean;
@@ -186,6 +194,7 @@ export interface PrinterStatus {
ipcam: boolean; // Live view enabled
wifi_signal: number | null; // WiFi signal strength in dBm
nozzles: NozzleInfo[]; // Nozzle hardware info (index 0=left/primary, 1=right)
nozzle_rack: NozzleRackSlot[]; // H2C 6-nozzle tool-changer rack
print_options: PrintOptions | null; // AI detection and print options
// Calibration stage tracking
stg_cur: number; // Current stage number (-1 = not calibrating)
@@ -751,6 +760,9 @@ export interface AppSettings {
ha_enabled: boolean;
ha_url: string;
ha_token: string;
ha_url_from_env: boolean;
ha_token_from_env: boolean;
ha_env_managed: boolean;
// File Manager / Library settings
library_archive_mode: 'always' | 'never' | 'ask';
library_disk_warning_gb: number;
@@ -1619,8 +1631,14 @@ export interface UnlinkedSpool {
location: string | null;
}
export interface LinkedSpoolInfo {
id: number;
remaining_weight: number | null;
filament_weight: number | null;
}
export interface LinkedSpoolsMap {
linked: Record<string, number>; // tag (uppercase) -> spool_id
linked: Record<string, LinkedSpoolInfo>; // tag (uppercase) -> spool info
}
// Update types
@@ -3925,6 +3943,7 @@ export interface DiscoveryInfo {
is_docker: boolean;
ssdp_running: boolean;
scan_running: boolean;
subnets: string[];
}
export interface SubnetScanStatus {
@@ -5,8 +5,6 @@ import { api } from '../api/client';
import type { ExternalLink, ExternalLinkCreate, ExternalLinkUpdate } from '../api/client';
import { Button } from './Button';
import { IconPicker, getIconByName } from './IconPicker';
import { useTheme } from '../contexts/ThemeContext';
interface AddExternalLinkModalProps {
link?: ExternalLink | null;
onClose: () => void;
@@ -14,7 +12,6 @@ interface AddExternalLinkModalProps {
export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProps) {
const queryClient = useQueryClient();
const { mode } = useTheme();
const isEditing = !!link;
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -166,7 +163,7 @@ export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProp
<div className="flex items-center gap-3">
<div className="p-2 rounded-full bg-bambu-green/20 text-bambu-green">
{useCustomIcon && customIconPreview ? (
<img src={customIconPreview} alt="" className={`w-5 h-5 rounded ${mode === 'dark' ? 'invert opacity-[0.65]' : 'opacity-60'}`} />
<img src={customIconPreview} alt="" className="w-5 h-5 rounded" />
) : (
<PresetIcon className="w-5 h-5" />
)}
@@ -233,7 +230,7 @@ export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProp
/>
{useCustomIcon && customIconPreview ? (
<div className="flex items-center gap-2">
<img src={customIconPreview} alt="Custom icon" className={`w-8 h-8 rounded border border-bambu-dark-tertiary ${mode === 'dark' ? 'invert opacity-[0.65]' : 'opacity-60'}`} />
<img src={customIconPreview} alt="Custom icon" className="w-8 h-8 rounded border border-bambu-dark-tertiary" />
<button
type="button"
onClick={handleRemoveCustomIcon}
@@ -1,7 +1,12 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, RefreshCw, AlertTriangle, Maximize2, Minimize2, GripVertical, WifiOff, ZoomIn, ZoomOut, Fullscreen, Minimize } from 'lucide-react';
import { api } from '../api/client';
import { api, getAuthToken } from '../api/client';
import { useToast } from '../contexts/ToastContext';
import { useAuth } from '../contexts/AuthContext';
import { ChamberLight } from './icons/ChamberLight';
import { SkipObjectsModal, SkipObjectsIcon } from './SkipObjectsModal';
interface EmbeddedCameraViewerProps {
printerId: number;
@@ -31,6 +36,11 @@ const DEFAULT_STATE: CameraState = {
};
export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0, onClose }: EmbeddedCameraViewerProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const { hasPermission } = useAuth();
// Printer-specific storage key
const storageKey = `${STORAGE_KEY_PREFIX}${printerId}`;
@@ -87,6 +97,8 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);
const stallCheckIntervalRef = useRef<NodeJS.Timeout | null>(null);
const [showSkipObjectsModal, setShowSkipObjectsModal] = useState(false);
// Fetch printer info
const { data: printer } = useQuery({
queryKey: ['printer', printerId],
@@ -94,6 +106,39 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
enabled: printerId > 0,
});
// Fetch printer status for light toggle and skip objects
const { data: status } = useQuery({
queryKey: ['printerStatus', printerId],
queryFn: () => api.getPrinterStatus(printerId),
refetchInterval: 30000,
enabled: printerId > 0,
});
// Chamber light mutation with optimistic update
const chamberLightMutation = useMutation({
mutationFn: (on: boolean) => api.setChamberLight(printerId, on),
onMutate: async (on) => {
await queryClient.cancelQueries({ queryKey: ['printerStatus', printerId] });
const previousStatus = queryClient.getQueryData(['printerStatus', printerId]);
queryClient.setQueryData(['printerStatus', printerId], (old: typeof status) => ({
...old,
chamber_light: on,
}));
return { previousStatus };
},
onSuccess: (_, on) => {
showToast(`Chamber light ${on ? 'on' : 'off'}`);
},
onError: (error: Error, _, context) => {
if (context?.previousStatus) {
queryClient.setQueryData(['printerStatus', printerId], context.previousStatus);
}
showToast(error.message || t('printers.toast.failedToControlChamberLight'), 'error');
},
});
const isPrintingWithObjects = (status?.state === 'RUNNING' || status?.state === 'PAUSE' || status?.state === 'PAUSED') && (status?.printable_objects_count ?? 0) >= 2;
// Save state to localStorage (printer-specific)
useEffect(() => {
const saveTimeout = setTimeout(() => {
@@ -111,7 +156,10 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
const sendStopOnce = () => {
if (printerId > 0 && !stopSentRef.current) {
stopSentRef.current = true;
navigator.sendBeacon(stopUrl);
const headers: Record<string, string> = {};
const token = getAuthToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
fetch(stopUrl, { method: 'POST', keepalive: true, headers }).catch(() => {});
}
};
@@ -403,7 +451,10 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);
fetch(`/api/v1/printers/${printerId}/camera/stop`).catch(() => {});
const stopHeaders: Record<string, string> = {};
const stopToken = getAuthToken();
if (stopToken) stopHeaders['Authorization'] = `Bearer ${stopToken}`;
fetch(`/api/v1/printers/${printerId}/camera/stop`, { method: 'POST', headers: stopHeaders }).catch(() => {});
if (imgRef.current) imgRef.current.src = '';
setTimeout(() => setImageKey(Date.now()), 100);
@@ -482,6 +533,28 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
<span className="truncate">{printer?.name || printerName}</span>
</div>
<div className="flex items-center gap-1 no-drag">
<button
onClick={() => chamberLightMutation.mutate(!status?.chamber_light)}
disabled={!status?.connected || chamberLightMutation.isPending || !hasPermission('printers:control')}
className={`p-1 rounded disabled:opacity-50 ${status?.chamber_light ? 'bg-yellow-500/20 hover:bg-yellow-500/30' : 'hover:bg-bambu-dark-tertiary'}`}
title={!hasPermission('printers:control') ? t('printers.permission.noControl') : t('camera.chamberLight')}
>
<ChamberLight on={status?.chamber_light ?? false} className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setShowSkipObjectsModal(true)}
disabled={!isPrintingWithObjects || !hasPermission('printers:control')}
className={`p-1 rounded disabled:opacity-50 ${isPrintingWithObjects && hasPermission('printers:control') ? 'hover:bg-bambu-dark-tertiary' : ''}`}
title={
!hasPermission('printers:control')
? t('printers.permission.noControl')
: !isPrintingWithObjects
? t('printers.skipObjects.onlyWhilePrinting')
: t('printers.skipObjects.tooltip')
}
>
<SkipObjectsIcon className="w-3.5 h-3.5 text-bambu-gray" />
</button>
<button
onClick={refresh}
disabled={streamLoading || isReconnecting}
@@ -625,6 +698,12 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
)}
</div>
)}
{/* Skip Objects Modal */}
<SkipObjectsModal
printerId={printerId}
isOpen={showSkipObjectsModal}
onClose={() => setShowSkipObjectsModal(false)}
/>
</div>
);
}
@@ -10,6 +10,7 @@ interface FilamentData {
kFactor: string;
fillLevel: number | null; // null = unknown
trayUuid?: string | null; // Bambu Lab spool UUID for Spoolman linking
fillSource?: 'ams' | 'spoolman'; // Source of fill level data
}
interface SpoolmanConfig {
@@ -229,8 +230,11 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
<Droplets className="w-3 h-3" />
{t('ams.fill')}
</span>
<span className="text-xs text-white font-semibold">
<span className="text-xs text-white font-semibold flex items-center gap-1">
{data.fillLevel !== null ? `${data.fillLevel}%` : '—'}
{data.fillSource === 'spoolman' && data.fillLevel !== null && (
<span className="text-[9px] text-bambu-gray font-normal">{t('spoolman.fillSourceLabel')}</span>
)}
</span>
</div>
{/* Fill bar */}
+5 -8
View File
@@ -61,10 +61,9 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
const existing = dataMap.get(key) || { date: key, filament: 0, cost: 0, prints: 0 };
const qty = archive.quantity || 1;
existing.filament += (archive.filament_used_grams || 0) * qty;
existing.filament += archive.filament_used_grams || 0;
existing.cost += archive.cost || 0;
existing.prints += qty;
existing.prints += archive.quantity || 1;
dataMap.set(key, existing);
});
@@ -90,10 +89,9 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
const key = `${weekStart.getFullYear()}-${String(weekStart.getMonth() + 1).padStart(2, '0')}-${String(weekStart.getDate()).padStart(2, '0')}`;
const existing = dataMap.get(key) || { week: key, filament: 0, cost: 0, prints: 0 };
const qty = archive.quantity || 1;
existing.filament += (archive.filament_used_grams || 0) * qty;
existing.filament += archive.filament_used_grams || 0;
existing.cost += archive.cost || 0;
existing.prints += qty;
existing.prints += archive.quantity || 1;
dataMap.set(key, existing);
});
@@ -112,11 +110,10 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
filteredArchives.forEach(archive => {
const type = archive.filament_type || 'Unknown';
const qty = archive.quantity || 1;
// Handle multiple types (e.g., "PLA, PETG")
const types = type.split(', ');
types.forEach(t => {
const grams = ((archive.filament_used_grams || 0) * qty) / types.length;
const grams = (archive.filament_used_grams || 0) / types.length;
dataMap.set(t, (dataMap.get(t) || 0) + grams);
});
});
+1 -1
View File
@@ -469,7 +469,7 @@ export function Layout() {
<img
src={`/api/v1/external-links/${link.id}/icon`}
alt=""
className={`w-5 h-5 flex-shrink-0 ${mode === 'dark' ? 'invert opacity-[0.65]' : 'opacity-60'}`}
className="w-5 h-5 flex-shrink-0"
/>
) : (
LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
@@ -0,0 +1,271 @@
import { useQuery, useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Loader2, Monitor, AlertCircle, Box } from 'lucide-react';
import { api } from '../api/client';
import { useToast } from '../contexts/ToastContext';
import { useAuth } from '../contexts/AuthContext';
// Custom Skip Objects icon - arrow jumping over boxes
export const SkipObjectsIcon = ({ className }: { className?: string }) => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
{/* Three boxes at the bottom */}
<rect x="2" y="15" width="5" height="5" rx="0.5" />
<rect x="9.5" y="15" width="5" height="5" rx="0.5" fill="currentColor" opacity="0.3" />
<rect x="17" y="15" width="5" height="5" rx="0.5" />
{/* Curved arrow jumping over first box */}
<path d="M4 12 C4 6, 14 6, 14 12" />
<polyline points="12,10 14,12 12,14" />
</svg>
);
interface SkipObjectsModalProps {
printerId: number;
isOpen: boolean;
onClose: () => void;
}
export function SkipObjectsModal({ printerId, isOpen, onClose }: SkipObjectsModalProps) {
const { t } = useTranslation();
const { showToast } = useToast();
const { hasPermission } = useAuth();
const { data: status } = useQuery({
queryKey: ['printerStatus', printerId],
queryFn: () => api.getPrinterStatus(printerId),
refetchInterval: 30000,
enabled: isOpen,
});
const { data: objectsData, refetch: refetchObjects } = useQuery({
queryKey: ['printableObjects', printerId],
queryFn: () => api.getPrintableObjects(printerId),
enabled: isOpen,
refetchInterval: isOpen ? 5000 : false,
});
const skipObjectsMutation = useMutation({
mutationFn: (objectIds: number[]) => api.skipObjects(printerId, objectIds),
onSuccess: (data) => {
showToast(data.message || t('printers.skipObjects.objectsSkipped'));
refetchObjects();
},
onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSkipObjects'), 'error'),
});
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={onClose}
onKeyDown={(e) => e.key === 'Escape' && onClose()}
tabIndex={-1}
ref={(el) => el?.focus()}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50 z-0" />
{/* Modal */}
<div
className="relative z-10 bg-white dark:bg-bambu-dark border border-gray-200 dark:border-bambu-dark-tertiary rounded-xl shadow-2xl w-[560px] max-h-[85vh] flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-bambu-dark-tertiary bg-gray-50 dark:bg-bambu-dark">
<div className="flex items-center gap-2">
<SkipObjectsIcon className="w-4 h-4 text-bambu-green" />
<span className="text-sm font-medium text-gray-900 dark:text-white">{t('printers.skipObjects.title')}</span>
</div>
<button
onClick={onClose}
className="p-1 text-gray-500 dark:text-bambu-gray hover:text-gray-900 dark:hover:text-white rounded transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
{!objectsData ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin text-bambu-gray" />
</div>
) : objectsData.objects.length === 0 ? (
<div className="text-center py-8 px-4 text-bambu-gray">
<p className="text-sm">{t('printers.noObjectsFound')}</p>
<p className="text-xs mt-1 opacity-70">{t('printers.objectsLoadedOnPrintStart')}</p>
</div>
) : (
<div className="flex flex-col overflow-hidden">
{/* Info Banner */}
<div className="flex items-center gap-3 px-4 py-2.5 bg-blue-50 dark:bg-blue-500/10 border-b border-gray-200 dark:border-bambu-dark-tertiary">
<div className="flex-shrink-0 w-8 h-8 rounded-lg bg-blue-100 dark:bg-blue-500/20 flex items-center justify-center">
<Monitor className="w-4 h-4 text-blue-500 dark:text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-blue-600 dark:text-blue-300">{t('printers.skipObjects.matchIdsInfo')}</p>
<p className="text-[10px] text-blue-500/70 dark:text-blue-300/60">{t('printers.skipObjects.printerShowsIds')}</p>
</div>
<div className="flex-shrink-0 text-xs text-gray-500 dark:text-bambu-gray">
{objectsData.skipped_count}/{objectsData.total} {t('printers.skipObjects.skipped')}
</div>
</div>
{/* Layer Warning */}
{(status?.layer_num ?? 0) <= 1 && (
<div className="flex items-center gap-2 px-4 py-2 bg-amber-50 dark:bg-amber-500/10 border-b border-gray-200 dark:border-bambu-dark-tertiary">
<AlertCircle className="w-4 h-4 text-amber-500 dark:text-amber-400 flex-shrink-0" />
<p className="text-xs text-amber-600 dark:text-amber-400">
{t('printers.skipObjects.waitForLayer', { layer: status?.layer_num ?? 0 })}
</p>
</div>
)}
{/* Content: Image + List side by side */}
<div className="flex flex-1 overflow-hidden">
{/* Left: Preview Image with object markers */}
<div className="w-52 flex-shrink-0 p-4 border-r border-gray-200 dark:border-bambu-dark-tertiary bg-gray-50 dark:bg-bambu-dark-secondary overflow-y-auto">
<div className="relative">
{status?.cover_url ? (
<img
src={`${status.cover_url}?view=top`}
alt={t('printers.printPreview')}
className="w-full aspect-square object-contain rounded-lg bg-gray-900 dark:bg-gray-900 border border-gray-300 dark:border-gray-600"
/>
) : (
<div className="w-full aspect-square rounded-lg bg-gray-100 dark:bg-bambu-dark flex items-center justify-center">
<Box className="w-8 h-8 text-gray-300 dark:text-bambu-gray/30" />
</div>
)}
{/* Object ID markers overlay - positioned based on object data */}
{objectsData.objects.length > 0 && (
<div className="absolute inset-0 pointer-events-none">
{objectsData.objects.map((obj, idx) => {
let x: number, y: number;
// Use position data if available, otherwise fall back to grid
if (obj.x != null && obj.y != null && objectsData.bbox_all) {
// bbox_all defines the visible area in the top_N.png image
// Format: [x_min, y_min, x_max, y_max] in mm
const [xMin, yMin, xMax, yMax] = objectsData.bbox_all;
const bboxWidth = xMax - xMin;
const bboxHeight = yMax - yMin;
// The image shows bbox_all area with some padding (~5-10%)
const padding = 8;
const contentArea = 100 - (padding * 2);
// Map object position to image percentage
x = padding + ((obj.x - xMin) / bboxWidth) * contentArea;
// Y axis: image Y increases downward, but 3D Y increases toward back
y = padding + ((yMax - obj.y) / bboxHeight) * contentArea;
// Clamp to valid range
x = Math.max(5, Math.min(95, x));
y = Math.max(5, Math.min(95, y));
} else if (obj.x != null && obj.y != null) {
// Fallback: use full build plate (256mm)
const buildPlate = 256;
x = (obj.x / buildPlate) * 100;
y = 100 - (obj.y / buildPlate) * 100;
x = Math.max(5, Math.min(95, x));
y = Math.max(5, Math.min(95, y));
} else {
// Fallback: arrange in a grid pattern over the build plate area
const cols = Math.ceil(Math.sqrt(objectsData.objects.length));
const row = Math.floor(idx / cols);
const col = idx % cols;
const rows = Math.ceil(objectsData.objects.length / cols);
x = 15 + (col * (70 / cols)) + (35 / cols);
y = 15 + (row * (70 / rows)) + (35 / rows);
}
return (
<div
key={obj.id}
className={`absolute flex items-center justify-center w-6 h-6 rounded-full text-[10px] font-bold shadow-lg ${
obj.skipped
? 'bg-red-500 text-white line-through'
: 'bg-bambu-green text-black'
}`}
style={{
left: `${x}%`,
top: `${y}%`,
transform: 'translate(-50%, -50%)'
}}
title={obj.name}
>
{obj.id}
</div>
);
})}
</div>
)}
{/* Object count overlay */}
<div className="absolute bottom-2 right-2 px-2 py-1 bg-white/90 dark:bg-black/80 rounded text-[10px] text-gray-700 dark:text-white shadow-sm">
{t('printers.skipObjects.activeCount', { count: objectsData.objects.filter(o => !o.skipped).length })}
</div>
</div>
</div>
{/* Right: Object List with prominent IDs */}
<div className="flex-1 min-w-0 overflow-y-auto">
{objectsData.objects.map((obj) => (
<div
key={obj.id}
className={`
flex items-center gap-3 px-4 py-3 border-b border-gray-200 dark:border-bambu-dark-tertiary/50 last:border-0
${obj.skipped ? 'bg-red-50 dark:bg-red-500/10' : 'hover:bg-gray-50 dark:hover:bg-bambu-dark/50'}
`}
>
{/* Large prominent ID badge */}
<div className={`
w-12 h-12 flex-shrink-0 rounded-lg flex flex-col items-center justify-center
${obj.skipped
? 'bg-red-100 dark:bg-red-500/20 border border-red-300 dark:border-red-500/40'
: 'bg-green-100 dark:bg-bambu-green/20 border border-green-300 dark:border-bambu-green/40'}
`}>
<span className={`text-lg font-mono font-bold ${obj.skipped ? 'text-red-500 dark:text-red-400' : 'text-green-600 dark:text-bambu-green'}`}>
{obj.id}
</span>
<span className={`text-[8px] uppercase tracking-wider ${obj.skipped ? 'text-red-400/60' : 'text-green-500/60 dark:text-bambu-green/60'}`}>
ID
</span>
</div>
{/* Object name and status */}
<div className="flex-1 min-w-0">
<span className={`block text-sm truncate ${obj.skipped ? 'text-red-500 dark:text-red-400 line-through' : 'text-gray-900 dark:text-white'}`}>
{obj.name}
</span>
{obj.skipped && (
<span className="text-[10px] text-red-400/60">{t('printers.willBeSkipped')}</span>
)}
</div>
{/* Skip button */}
{!obj.skipped ? (
<button
onClick={() => skipObjectsMutation.mutate([obj.id])}
disabled={skipObjectsMutation.isPending || (status?.layer_num ?? 0) <= 1 || !hasPermission('printers:control')}
className={`px-4 py-2 text-xs font-medium rounded-lg transition-colors ${
(status?.layer_num ?? 0) <= 1 || !hasPermission('printers:control')
? 'bg-gray-100 dark:bg-bambu-dark text-gray-400 dark:text-bambu-gray/50 cursor-not-allowed'
: 'bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-500/30 border border-red-300 dark:border-red-500/30'
}`}
title={!hasPermission('printers:control') ? t('printers.permission.noControl') : ((status?.layer_num ?? 0) <= 1 ? t('printers.skipObjects.waitForLayer', { layer: status?.layer_num ?? 0 }) : t('printers.skipObjects.skip'))}
>
{t('printers.skipObjects.skip')}
</button>
) : (
<span className="px-4 py-2 text-xs text-red-500 dark:text-red-400/70 bg-red-100 dark:bg-red-500/10 rounded-lg">
{t('printers.skipObjects.skipped')}
</span>
)}
</div>
))}
</div>
</div>
</div>
)}
</div>
</div>
);
}
+14 -1
View File
@@ -115,7 +115,7 @@ export default {
deletePrinter: 'Drucker löschen',
printerName: 'Druckername',
serialNumber: 'Seriennummer',
ipAddress: 'IP-Adresse',
ipAddress: 'IP-Adresse / Hostname',
accessCode: 'Zugangscode',
model: 'Modell',
nozzleCount: 'Düsenanzahl',
@@ -207,6 +207,7 @@ export default {
reconnect: 'Neu verbinden',
mqttDebug: 'MQTT-Debug',
activeNozzle: 'Aktiv: {{nozzle}} Düse',
nozzleRack: 'Düsenhalter',
// Firmware
firmwareUpdate: 'Firmware-Update',
firmwareInstructions: 'Gehen Sie auf dem Touchscreen des Druckers zu',
@@ -1093,6 +1094,10 @@ export default {
enableRetry: 'Wiederholung aktivieren',
// Home Assistant
homeAssistantDescription: 'Smart Plugs über Home Assistant steuern',
environmentManagedLabel: '(Umgebungsvariable)',
autoEnabledViaEnv: 'Automatisch über Umgebungsvariablen aktiviert',
urlFromEnvReadOnly: 'Wert wird über HA_URL Umgebungsvariable gesetzt (schreibgeschützt)',
tokenFromEnvReadOnly: 'Wert wird über HA_TOKEN Umgebungsvariable gesetzt (schreibgeschützt)',
// MQTT
mqttConnectedTo: 'Verbunden mit',
// Prometheus
@@ -1506,6 +1511,7 @@ export default {
recording: 'Aufnahme',
startRecording: 'Aufnahme starten',
stopRecording: 'Aufnahme stoppen',
chamberLight: 'Kammerbeleuchtung umschalten',
},
// Groups management
@@ -1803,6 +1809,12 @@ export default {
support: {
debugLoggingActive: 'Debug-Protokollierung ist aktiv',
manageLogs: 'Verwalten',
collectItem7: 'Drucker-Verbindungsstatus und Firmware-Versionen',
collectItem8: 'Integrationsstatus (Spoolman, MQTT, HA)',
collectItem9: 'Netzwerkschnittstellen (nur Subnetze)',
collectItem10: 'Python-Paketversionen',
collectItem11: 'Datenbankzustandsprüfungen',
collectItem12: 'Docker-Umgebungsdetails',
},
// File manager
@@ -2179,6 +2191,7 @@ export default {
linkSuccess: 'Spule erfolgreich mit Spoolman verknüpft',
linkFailed: 'Verknüpfung mit Spoolman fehlgeschlagen',
spoolId: 'Spulen-ID',
fillSourceLabel: '(Spoolman)',
weight: 'Gewicht',
remaining: 'Verbleibend',
disableWeightSync: 'AMS-Gewichtsschätzung deaktivieren',
+14 -1
View File
@@ -115,7 +115,7 @@ export default {
deletePrinter: 'Delete Printer',
printerName: 'Printer Name',
serialNumber: 'Serial Number',
ipAddress: 'IP Address',
ipAddress: 'IP Address / Hostname',
accessCode: 'Access Code',
model: 'Model',
nozzleCount: 'Nozzle Count',
@@ -207,6 +207,7 @@ export default {
reconnect: 'Reconnect',
mqttDebug: 'MQTT Debug',
activeNozzle: 'Active: {{nozzle}} nozzle',
nozzleRack: 'Nozzle Rack',
// Firmware
firmwareUpdate: 'Firmware Update',
firmwareInstructions: 'On the printer\'s touchscreen, go to',
@@ -1093,6 +1094,10 @@ export default {
enableRetry: 'Enable retry',
// Home Assistant
homeAssistantDescription: 'Control smart plugs via Home Assistant',
environmentManagedLabel: '(Environment Managed)',
autoEnabledViaEnv: 'Automatically enabled via environment variables',
urlFromEnvReadOnly: 'Value set by HA_URL environment variable (read-only)',
tokenFromEnvReadOnly: 'Value set by HA_TOKEN environment variable (read-only)',
// MQTT
mqttConnectedTo: 'Connected to',
// Prometheus
@@ -1506,6 +1511,7 @@ export default {
recording: 'Recording',
startRecording: 'Start Recording',
stopRecording: 'Stop Recording',
chamberLight: 'Toggle chamber light',
},
// Groups management
@@ -1803,6 +1809,12 @@ export default {
support: {
debugLoggingActive: 'Debug logging is active',
manageLogs: 'Manage',
collectItem7: 'Printer connectivity and firmware versions',
collectItem8: 'Integration status (Spoolman, MQTT, HA)',
collectItem9: 'Network interfaces (subnets only)',
collectItem10: 'Python package versions',
collectItem11: 'Database health checks',
collectItem12: 'Docker environment details',
},
// File manager
@@ -2179,6 +2191,7 @@ export default {
linkSuccess: 'Spool linked to Spoolman successfully',
linkFailed: 'Failed to link spool',
spoolId: 'Spool ID',
fillSourceLabel: '(Spoolman)',
weight: 'Weight',
remaining: 'Remaining',
disableWeightSync: 'Disable AMS Estimated Weight Sync',
File diff suppressed because it is too large Load Diff
+81 -5
View File
@@ -1,9 +1,13 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { RefreshCw, AlertTriangle, Camera, Maximize, Minimize, WifiOff, ZoomIn, ZoomOut } from 'lucide-react';
import { api } from '../api/client';
import { api, getAuthToken } from '../api/client';
import { useToast } from '../contexts/ToastContext';
import { useAuth } from '../contexts/AuthContext';
import { ChamberLight } from '../components/icons/ChamberLight';
import { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModal';
const MAX_RECONNECT_ATTEMPTS = 5;
const INITIAL_RECONNECT_DELAY = 2000; // 2 seconds
@@ -12,10 +16,14 @@ const STALL_CHECK_INTERVAL = 5000; // Check every 5 seconds
export function CameraPage() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const { hasPermission } = useAuth();
const { printerId } = useParams<{ printerId: string }>();
const id = parseInt(printerId || '0', 10);
const [streamMode, setStreamMode] = useState<'stream' | 'snapshot'>('stream');
const [showSkipObjectsModal, setShowSkipObjectsModal] = useState(false);
const [streamError, setStreamError] = useState(false);
const [streamLoading, setStreamLoading] = useState(true);
const [imageKey, setImageKey] = useState(Date.now());
@@ -43,6 +51,39 @@ export function CameraPage() {
enabled: id > 0,
});
// Fetch printer status for light toggle and skip objects
const { data: status } = useQuery({
queryKey: ['printerStatus', id],
queryFn: () => api.getPrinterStatus(id),
refetchInterval: 30000,
enabled: id > 0,
});
// Chamber light mutation with optimistic update
const chamberLightMutation = useMutation({
mutationFn: (on: boolean) => api.setChamberLight(id, on),
onMutate: async (on) => {
await queryClient.cancelQueries({ queryKey: ['printerStatus', id] });
const previousStatus = queryClient.getQueryData(['printerStatus', id]);
queryClient.setQueryData(['printerStatus', id], (old: typeof status) => ({
...old,
chamber_light: on,
}));
return { previousStatus };
},
onSuccess: (_, on) => {
showToast(`Chamber light ${on ? 'on' : 'off'}`);
},
onError: (error: Error, _, context) => {
if (context?.previousStatus) {
queryClient.setQueryData(['printerStatus', id], context.previousStatus);
}
showToast(error.message || t('printers.toast.failedToControlChamberLight'), 'error');
},
});
const isPrintingWithObjects = (status?.state === 'RUNNING' || status?.state === 'PAUSE' || status?.state === 'PAUSED') && (status?.printable_objects_count ?? 0) >= 2;
// Update document title
useEffect(() => {
if (printer) {
@@ -64,11 +105,14 @@ export function CameraPage() {
const sendStopOnce = () => {
if (id > 0 && !stopSentRef.current) {
stopSentRef.current = true;
navigator.sendBeacon(stopUrl);
const headers: Record<string, string> = {};
const token = getAuthToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
fetch(stopUrl, { method: 'POST', keepalive: true, headers }).catch(() => {});
}
};
// Handle page unload/close with sendBeacon (more reliable than fetch on unload)
// Handle page unload/close with keepalive fetch (more reliable than sendBeacon, supports auth)
const handleBeforeUnload = () => {
sendStopOnce();
};
@@ -303,7 +347,10 @@ export function CameraPage() {
const stopStream = () => {
if (id > 0) {
fetch(`/api/v1/printers/${id}/camera/stop`).catch(() => {});
const headers: Record<string, string> = {};
const token = getAuthToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
fetch(`/api/v1/printers/${id}/camera/stop`, { method: 'POST', headers }).catch(() => {});
}
};
@@ -577,6 +624,28 @@ export function CameraPage() {
{t('camera.snapshot')}
</button>
</div>
<button
onClick={() => chamberLightMutation.mutate(!status?.chamber_light)}
disabled={!status?.connected || chamberLightMutation.isPending || !hasPermission('printers:control')}
className={`p-1.5 rounded disabled:opacity-50 ${status?.chamber_light ? 'bg-yellow-500/20 hover:bg-yellow-500/30' : 'hover:bg-bambu-dark-tertiary'}`}
title={!hasPermission('printers:control') ? t('printers.permission.noControl') : t('camera.chamberLight')}
>
<ChamberLight on={status?.chamber_light ?? false} className="w-4 h-4" />
</button>
<button
onClick={() => setShowSkipObjectsModal(true)}
disabled={!isPrintingWithObjects || !hasPermission('printers:control')}
className={`p-1.5 rounded disabled:opacity-50 ${isPrintingWithObjects && hasPermission('printers:control') ? 'hover:bg-bambu-dark-tertiary' : ''}`}
title={
!hasPermission('printers:control')
? t('printers.permission.noControl')
: !isPrintingWithObjects
? t('printers.skipObjects.onlyWhilePrinting')
: t('printers.skipObjects.tooltip')
}
>
<SkipObjectsIcon className="w-4 h-4 text-bambu-gray" />
</button>
<button
onClick={refresh}
disabled={isDisabled}
@@ -700,6 +769,13 @@ export function CameraPage() {
</div>
</div>
</div>
{/* Skip Objects Modal */}
<SkipObjectsModal
printerId={id}
isOpen={showSkipObjectsModal}
onClose={() => setShowSkipObjectsModal(false)}
/>
</div>
);
}
+155 -268
View File
@@ -33,7 +33,6 @@ import {
Pause,
Play,
X,
Monitor,
Fan,
Wind,
AirVent,
@@ -45,22 +44,10 @@ import {
Home,
} from 'lucide-react';
// Custom Skip Objects icon - arrow jumping over boxes
const SkipObjectsIcon = ({ className }: { className?: string }) => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
{/* Three boxes at the bottom */}
<rect x="2" y="15" width="5" height="5" rx="0.5" />
<rect x="9.5" y="15" width="5" height="5" rx="0.5" fill="currentColor" opacity="0.3" />
<rect x="17" y="15" width="5" height="5" rx="0.5" />
{/* Curved arrow jumping over first box */}
<path d="M4 12 C4 6, 14 6, 14 12" />
<polyline points="12,10 14,12 12,14" />
</svg>
);
import { useNavigate } from 'react-router-dom';
import { api, discoveryApi, firmwareApi } from '../api/client';
import { formatDateOnly } from '../utils/date';
import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus } from '../api/client';
import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus, LinkedSpoolInfo } from '../api/client';
import { Card, CardContent } from '../components/Card';
import { Button } from '../components/Button';
import { ConfirmModal } from '../components/ConfirmModal';
@@ -75,6 +62,7 @@ import { LinkSpoolModal } from '../components/LinkSpoolModal';
import { ConfigureAmsSlotModal } from '../components/ConfigureAmsSlotModal';
import { useToast } from '../contexts/ToastContext';
import { ChamberLight } from '../components/icons/ChamberLight';
import { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModal';
// Complete Bambu Lab filament color mapping by tray_id_name
// Source: https://github.com/queengooborg/Bambu-Lab-RFID-Library
@@ -422,6 +410,55 @@ function NozzleBadge({ side }: { side: 'L' | 'R' }) {
);
}
// H2C Nozzle Rack Card — 2×3 grid showing 6-position tool-changer dock
function NozzleRackCard({ slots }: { slots: import('../api/client').NozzleRackSlot[] }) {
const { t } = useTranslation();
// Filter to dock slots only (exclude the mounted/active entry which is typically id=0 or stat indicates mounted)
// Show up to 6 dock positions
const dockSlots = slots.slice(0, 6);
return (
<div className="text-center px-2 py-1.5 bg-bambu-dark rounded-lg" style={{ minWidth: '80px' }}>
<p className="text-[9px] text-bambu-gray mb-0.5">{t('printers.nozzleRack')}</p>
<div className="grid grid-cols-3 gap-0.5">
{dockSlots.map((slot, i) => {
const isEmpty = !slot.nozzle_diameter && !slot.nozzle_type;
const isMounted = slot.stat === 1;
// Type abbreviation: S=stainless, H=hardened
const typeAbbr = slot.nozzle_type?.includes('hardened') ? 'H' : slot.nozzle_type?.includes('stainless') ? 'S' : '';
return (
<div
key={slot.id ?? i}
className={`rounded px-0.5 py-0.5 text-center ${
isEmpty
? 'bg-bambu-dark-tertiary/30 opacity-40'
: isMounted
? 'bg-green-900/40 ring-1 ring-green-500/60'
: 'bg-bambu-dark-tertiary/50'
}`}
title={isEmpty ? `Slot ${i + 1}: empty` : `Slot ${i + 1}: ${slot.nozzle_diameter}mm ${slot.nozzle_type || ''} ${isMounted ? '(mounted)' : ''}`}
>
{isEmpty ? (
<p className="text-[9px] text-bambu-gray">—</p>
) : (
<>
<p className={`text-[10px] font-medium ${isMounted ? 'text-green-400' : 'text-white'}`}>
{slot.nozzle_diameter || '?'}
</p>
{typeAbbr && (
<p className="text-[8px] text-bambu-gray leading-none">{typeAbbr}</p>
)}
</>
)}
</div>
);
})}
</div>
</div>
);
}
// Water drop SVG - empty outline (Bambu Lab style from bambu-humidity)
function WaterDropEmpty({ className }: { className?: string }) {
return (
@@ -649,6 +686,17 @@ function getFillBarColor(fillLevel: number): string {
return '#ef4444'; // Red - critical (< 15%)
}
// Calculate fill level from Spoolman weight data (used as fallback when AMS reports 0%)
function getSpoolmanFillLevel(
linkedSpool: LinkedSpoolInfo | undefined
): number | null {
if (!linkedSpool?.remaining_weight || !linkedSpool?.filament_weight
|| linkedSpool.filament_weight <= 0) return null;
return Math.min(100, Math.round(
(linkedSpool.remaining_weight / linkedSpool.filament_weight) * 100
));
}
function formatTime(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
@@ -935,7 +983,7 @@ function PrinterCard({
};
spoolmanEnabled?: boolean;
hasUnlinkedSpools?: boolean;
linkedSpools?: Record<string, number>;
linkedSpools?: Record<string, LinkedSpoolInfo>;
spoolmanUrl?: string | null;
timeFormat?: 'system' | '12h' | '24h';
cameraViewMode?: 'window' | 'embedded';
@@ -1265,23 +1313,13 @@ function PrinterCard({
// Query for printable objects (for skip functionality)
// Fetch when printing with 2+ objects OR when modal is open
const isPrintingWithObjects = (status?.state === 'RUNNING' || status?.state === 'PAUSE' || status?.state === 'PAUSED') && (status?.printable_objects_count ?? 0) >= 2;
const { data: objectsData, refetch: refetchObjects } = useQuery({
const { data: objectsData } = useQuery({
queryKey: ['printableObjects', printer.id],
queryFn: () => api.getPrintableObjects(printer.id),
enabled: showSkipObjectsModal || isPrintingWithObjects,
refetchInterval: showSkipObjectsModal ? 5000 : (isPrintingWithObjects ? 30000 : false), // 5s when modal open, 30s otherwise
});
// Skip objects mutation
const skipObjectsMutation = useMutation({
mutationFn: (objectIds: number[]) => api.skipObjects(printer.id, objectIds),
onSuccess: (data) => {
showToast(data.message || t('printers.skipObjects.objectsSkipped'));
refetchObjects();
},
onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSkipObjects'), 'error'),
});
// State for tracking which AMS slot is being refreshed
const [refreshingSlot, setRefreshingSlot] = useState<{ amsId: number; slotId: number } | null>(null);
// Track if we've seen the printer enter "busy" state (ams_status_main !== 0)
@@ -2020,6 +2058,10 @@ function PrinterCard({
<p className={`text-[11px] font-bold ${activeNozzle === 'R' ? 'text-amber-400' : 'text-gray-500'}`}>R</p>
</div>
)}
{/* H2C nozzle rack (tool-changer dock) */}
{status.nozzle_rack && status.nozzle_rack.length > 0 && (
<NozzleRackCard slots={status.nozzle_rack} />
)}
</div>
);
})()}
@@ -2218,6 +2260,15 @@ function PrinterCard({
// Get saved slot preset mapping (for user-configured slots)
const slotPreset = slotPresets?.[globalTrayId];
// Spoolman fill level fallback (when AMS reports 0%)
const trayTag = tray?.tray_uuid?.toUpperCase();
const linkedSpool = trayTag ? linkedSpools?.[trayTag] : undefined;
const spoolmanFill = getSpoolmanFillLevel(linkedSpool);
const effectiveFill = hasFillLevel && tray.remain > 0
? tray.remain
: (spoolmanFill ?? (hasFillLevel ? tray.remain : null));
const fillSource = (hasFillLevel && tray.remain === 0 && spoolmanFill !== null) ? 'spoolman' as const : 'ams' as const;
// Build filament data for hover card
const filamentData = tray?.tray_type ? {
vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
@@ -2225,8 +2276,9 @@ function PrinterCard({
colorName: getBambuColorName(tray.tray_id_name) || hexToBasicColorName(tray.tray_color),
colorHex: tray.tray_color || null,
kFactor: formatKValue(tray.k),
fillLevel: hasFillLevel ? tray.remain : null,
fillLevel: effectiveFill,
trayUuid: tray.tray_uuid || null,
fillSource,
} : null;
// Check if this specific slot is being refreshed
@@ -2251,12 +2303,12 @@ function PrinterCard({
</div>
{/* Fill bar */}
<div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
{hasFillLevel && tray ? (
{effectiveFill !== null && effectiveFill >= 0 && tray ? (
<div
className="h-full rounded-full transition-all"
style={{
width: `${tray.remain}%`,
backgroundColor: getFillBarColor(tray.remain),
width: `${effectiveFill}%`,
backgroundColor: getFillBarColor(effectiveFill),
}}
/>
) : tray?.tray_type ? (
@@ -2322,7 +2374,7 @@ function PrinterCard({
spoolman={{
enabled: spoolmanEnabled,
hasUnlinkedSpools,
linkedSpoolId: filamentData.trayUuid ? linkedSpools?.[filamentData.trayUuid.toUpperCase()] : undefined,
linkedSpoolId: filamentData.trayUuid ? linkedSpools?.[filamentData.trayUuid.toUpperCase()]?.id : undefined,
spoolmanUrl,
onLinkSpool: spoolmanEnabled && filamentData.trayUuid ? (uuid) => {
setLinkSpoolModal({
@@ -2396,6 +2448,15 @@ function PrinterCard({
// Get saved slot preset mapping (for user-configured slots)
const slotPreset = slotPresets?.[globalTrayId];
// Spoolman fill level fallback (when AMS reports 0%)
const htTrayTag = tray?.tray_uuid?.toUpperCase();
const htLinkedSpool = htTrayTag ? linkedSpools?.[htTrayTag] : undefined;
const htSpoolmanFill = getSpoolmanFillLevel(htLinkedSpool);
const htEffectiveFill = hasFillLevel && tray.remain > 0
? tray.remain
: (htSpoolmanFill ?? (hasFillLevel ? tray.remain : null));
const htFillSource = (hasFillLevel && tray.remain === 0 && htSpoolmanFill !== null) ? 'spoolman' as const : 'ams' as const;
// Build filament data for hover card
const filamentData = tray?.tray_type ? {
vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
@@ -2403,8 +2464,9 @@ function PrinterCard({
colorName: getBambuColorName(tray.tray_id_name) || hexToBasicColorName(tray.tray_color),
colorHex: tray.tray_color || null,
kFactor: formatKValue(tray.k),
fillLevel: hasFillLevel ? tray.remain : null,
fillLevel: htEffectiveFill,
trayUuid: tray.tray_uuid || null,
fillSource: htFillSource,
} : null;
const htSlotId = tray?.id ?? 0;
@@ -2430,12 +2492,12 @@ function PrinterCard({
</div>
{/* Fill bar */}
<div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
{hasFillLevel ? (
{htEffectiveFill !== null && htEffectiveFill >= 0 ? (
<div
className="h-full rounded-full transition-all"
style={{
width: `${tray.remain}%`,
backgroundColor: getFillBarColor(tray.remain),
width: `${htEffectiveFill}%`,
backgroundColor: getFillBarColor(htEffectiveFill),
}}
/>
) : tray?.tray_type ? (
@@ -2513,7 +2575,7 @@ function PrinterCard({
spoolman={{
enabled: spoolmanEnabled,
hasUnlinkedSpools,
linkedSpoolId: filamentData.trayUuid ? linkedSpools?.[filamentData.trayUuid.toUpperCase()] : undefined,
linkedSpoolId: filamentData.trayUuid ? linkedSpools?.[filamentData.trayUuid.toUpperCase()]?.id : undefined,
spoolmanUrl,
onLinkSpool: spoolmanEnabled && filamentData.trayUuid ? (uuid) => {
setLinkSpoolModal({
@@ -2601,6 +2663,11 @@ function PrinterCard({
// Get saved slot preset mapping (external spool uses amsId=255, trayId=0)
const extSlotPreset = slotPresets?.[255 * 4 + 0];
// Spoolman fill level for external spool
const extTrayTag = extTray.tray_uuid?.toUpperCase();
const extLinkedSpool = extTrayTag ? linkedSpools?.[extTrayTag] : undefined;
const extSpoolmanFill = getSpoolmanFillLevel(extLinkedSpool);
// Build filament data for hover card
const extFilamentData = {
vendor: (isBambuLabSpool(extTray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
@@ -2608,8 +2675,9 @@ function PrinterCard({
colorName: getBambuColorName(extTray.tray_id_name) || hexToBasicColorName(extTray.tray_color),
colorHex: extTray.tray_color || null,
kFactor: formatKValue(extTray.k),
fillLevel: null, // External spool has unknown fill level
fillLevel: extSpoolmanFill, // Use Spoolman data if available
trayUuid: extTray.tray_uuid || null,
fillSource: extSpoolmanFill !== null ? 'spoolman' as const : undefined,
};
const extSlotContent = (
@@ -2624,9 +2692,19 @@ function PrinterCard({
<div className="text-[9px] text-white font-bold truncate">
{extTray.tray_type || 'Spool'}
</div>
{/* Unknown fill level - subtle bar */}
{/* Fill bar - use Spoolman data if available */}
<div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
<div className="h-full w-full rounded-full bg-white/50 dark:bg-gray-500/40" />
{extSpoolmanFill !== null ? (
<div
className="h-full rounded-full transition-all"
style={{
width: `${extSpoolmanFill}%`,
backgroundColor: getFillBarColor(extSpoolmanFill),
}}
/>
) : (
<div className="h-full w-full rounded-full bg-white/50 dark:bg-gray-500/40" />
)}
</div>
</div>
);
@@ -2643,7 +2721,7 @@ function PrinterCard({
spoolman={{
enabled: spoolmanEnabled,
hasUnlinkedSpools,
linkedSpoolId: extFilamentData.trayUuid ? linkedSpools?.[extFilamentData.trayUuid.toUpperCase()] : undefined,
linkedSpoolId: extFilamentData.trayUuid ? linkedSpools?.[extFilamentData.trayUuid.toUpperCase()]?.id : undefined,
spoolmanUrl,
onLinkSpool: spoolmanEnabled && extFilamentData.trayUuid ? (uuid) => {
setLinkSpoolModal({
@@ -3250,221 +3328,12 @@ function PrinterCard({
/>
)}
{/* Skip Objects Popup */}
{showSkipObjectsModal && (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={() => setShowSkipObjectsModal(false)}
onKeyDown={(e) => e.key === 'Escape' && setShowSkipObjectsModal(false)}
tabIndex={-1}
ref={(el) => el?.focus()}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50 z-0" />
{/* Modal */}
<div
className="relative z-10 bg-white dark:bg-bambu-dark border border-gray-200 dark:border-bambu-dark-tertiary rounded-xl shadow-2xl w-[560px] max-h-[85vh] flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-bambu-dark-tertiary bg-gray-50 dark:bg-bambu-dark">
<div className="flex items-center gap-2">
<SkipObjectsIcon className="w-4 h-4 text-bambu-green" />
<span className="text-sm font-medium text-gray-900 dark:text-white">{t('printers.skipObjects.title')}</span>
</div>
<button
onClick={() => setShowSkipObjectsModal(false)}
className="p-1 text-gray-500 dark:text-bambu-gray hover:text-gray-900 dark:hover:text-white rounded transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
{!objectsData ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin text-bambu-gray" />
</div>
) : objectsData.objects.length === 0 ? (
<div className="text-center py-8 px-4 text-bambu-gray">
<p className="text-sm">{t('printers.noObjectsFound')}</p>
<p className="text-xs mt-1 opacity-70">{t('printers.objectsLoadedOnPrintStart')}</p>
</div>
) : (
<div className="flex flex-col overflow-hidden">
{/* Info Banner */}
<div className="flex items-center gap-3 px-4 py-2.5 bg-blue-50 dark:bg-blue-500/10 border-b border-gray-200 dark:border-bambu-dark-tertiary">
<div className="flex-shrink-0 w-8 h-8 rounded-lg bg-blue-100 dark:bg-blue-500/20 flex items-center justify-center">
<Monitor className="w-4 h-4 text-blue-500 dark:text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-blue-600 dark:text-blue-300">{t('printers.skipObjects.matchIdsInfo')}</p>
<p className="text-[10px] text-blue-500/70 dark:text-blue-300/60">{t('printers.skipObjects.printerShowsIds')}</p>
</div>
<div className="flex-shrink-0 text-xs text-gray-500 dark:text-bambu-gray">
{objectsData.skipped_count}/{objectsData.total} {t('printers.skipObjects.skipped')}
</div>
</div>
{/* Layer Warning */}
{(status?.layer_num ?? 0) <= 1 && (
<div className="flex items-center gap-2 px-4 py-2 bg-amber-50 dark:bg-amber-500/10 border-b border-gray-200 dark:border-bambu-dark-tertiary">
<AlertCircle className="w-4 h-4 text-amber-500 dark:text-amber-400 flex-shrink-0" />
<p className="text-xs text-amber-600 dark:text-amber-400">
{t('printers.skipObjects.waitForLayer', { layer: status?.layer_num ?? 0 })}
</p>
</div>
)}
{/* Content: Image + List side by side */}
<div className="flex flex-1 overflow-hidden">
{/* Left: Preview Image with object markers */}
<div className="w-52 flex-shrink-0 p-4 border-r border-gray-200 dark:border-bambu-dark-tertiary bg-gray-50 dark:bg-bambu-dark-secondary overflow-y-auto">
<div className="relative">
{status?.cover_url ? (
<img
src={`${status.cover_url}?view=top`}
alt={t('printers.printPreview')}
className="w-full aspect-square object-contain rounded-lg bg-gray-900 dark:bg-gray-900 border border-gray-300 dark:border-gray-600"
/>
) : (
<div className="w-full aspect-square rounded-lg bg-gray-100 dark:bg-bambu-dark flex items-center justify-center">
<Box className="w-8 h-8 text-gray-300 dark:text-bambu-gray/30" />
</div>
)}
{/* Object ID markers overlay - positioned based on object data */}
{objectsData.objects.length > 0 && (
<div className="absolute inset-0 pointer-events-none">
{objectsData.objects.map((obj, idx) => {
let x: number, y: number;
// Use position data if available, otherwise fall back to grid
if (obj.x != null && obj.y != null && objectsData.bbox_all) {
// bbox_all defines the visible area in the top_N.png image
// Format: [x_min, y_min, x_max, y_max] in mm
const [xMin, yMin, xMax, yMax] = objectsData.bbox_all;
const bboxWidth = xMax - xMin;
const bboxHeight = yMax - yMin;
// The image shows bbox_all area with some padding (~5-10%)
const padding = 8;
const contentArea = 100 - (padding * 2);
// Map object position to image percentage
x = padding + ((obj.x - xMin) / bboxWidth) * contentArea;
// Y axis: image Y increases downward, but 3D Y increases toward back
y = padding + ((yMax - obj.y) / bboxHeight) * contentArea;
// Clamp to valid range
x = Math.max(5, Math.min(95, x));
y = Math.max(5, Math.min(95, y));
} else if (obj.x != null && obj.y != null) {
// Fallback: use full build plate (256mm)
const buildPlate = 256;
x = (obj.x / buildPlate) * 100;
y = 100 - (obj.y / buildPlate) * 100;
x = Math.max(5, Math.min(95, x));
y = Math.max(5, Math.min(95, y));
} else {
// Fallback: arrange in a grid pattern over the build plate area
const cols = Math.ceil(Math.sqrt(objectsData.objects.length));
const row = Math.floor(idx / cols);
const col = idx % cols;
const rows = Math.ceil(objectsData.objects.length / cols);
x = 15 + (col * (70 / cols)) + (35 / cols);
y = 15 + (row * (70 / rows)) + (35 / rows);
}
return (
<div
key={obj.id}
className={`absolute flex items-center justify-center w-6 h-6 rounded-full text-[10px] font-bold shadow-lg ${
obj.skipped
? 'bg-red-500 text-white line-through'
: 'bg-bambu-green text-black'
}`}
style={{
left: `${x}%`,
top: `${y}%`,
transform: 'translate(-50%, -50%)'
}}
title={obj.name}
>
{obj.id}
</div>
);
})}
</div>
)}
{/* Object count overlay */}
<div className="absolute bottom-2 right-2 px-2 py-1 bg-white/90 dark:bg-black/80 rounded text-[10px] text-gray-700 dark:text-white shadow-sm">
{t('printers.skipObjects.activeCount', { count: objectsData.objects.filter(o => !o.skipped).length })}
</div>
</div>
</div>
{/* Right: Object List with prominent IDs */}
<div className="flex-1 min-w-0 overflow-y-auto">
{objectsData.objects.map((obj) => (
<div
key={obj.id}
className={`
flex items-center gap-3 px-4 py-3 border-b border-gray-200 dark:border-bambu-dark-tertiary/50 last:border-0
${obj.skipped ? 'bg-red-50 dark:bg-red-500/10' : 'hover:bg-gray-50 dark:hover:bg-bambu-dark/50'}
`}
>
{/* Large prominent ID badge */}
<div className={`
w-12 h-12 flex-shrink-0 rounded-lg flex flex-col items-center justify-center
${obj.skipped
? 'bg-red-100 dark:bg-red-500/20 border border-red-300 dark:border-red-500/40'
: 'bg-green-100 dark:bg-bambu-green/20 border border-green-300 dark:border-bambu-green/40'}
`}>
<span className={`text-lg font-mono font-bold ${obj.skipped ? 'text-red-500 dark:text-red-400' : 'text-green-600 dark:text-bambu-green'}`}>
{obj.id}
</span>
<span className={`text-[8px] uppercase tracking-wider ${obj.skipped ? 'text-red-400/60' : 'text-green-500/60 dark:text-bambu-green/60'}`}>
ID
</span>
</div>
{/* Object name and status */}
<div className="flex-1 min-w-0">
<span className={`block text-sm truncate ${obj.skipped ? 'text-red-500 dark:text-red-400 line-through' : 'text-gray-900 dark:text-white'}`}>
{obj.name}
</span>
{obj.skipped && (
<span className="text-[10px] text-red-400/60">{t('printers.willBeSkipped')}</span>
)}
</div>
{/* Skip button */}
{!obj.skipped ? (
<button
onClick={() => skipObjectsMutation.mutate([obj.id])}
disabled={skipObjectsMutation.isPending || (status?.layer_num ?? 0) <= 1 || !hasPermission('printers:control')}
className={`px-4 py-2 text-xs font-medium rounded-lg transition-colors ${
(status?.layer_num ?? 0) <= 1 || !hasPermission('printers:control')
? 'bg-gray-100 dark:bg-bambu-dark text-gray-400 dark:text-bambu-gray/50 cursor-not-allowed'
: 'bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-500/30 border border-red-300 dark:border-red-500/30'
}`}
title={!hasPermission('printers:control') ? t('printers.permission.noControl') : ((status?.layer_num ?? 0) <= 1 ? t('printers.skipObjects.waitForLayer', { layer: status?.layer_num ?? 0 }) : t('printers.skipObjects.skip'))}
>
{t('printers.skipObjects.skip')}
</button>
) : (
<span className="px-4 py-2 text-xs text-red-500 dark:text-red-400/70 bg-red-100 dark:bg-red-500/10 rounded-lg">
{t('printers.skipObjects.skipped')}
</span>
)}
</div>
))}
</div>
</div>
</div>
)}
</div>
</div>
)}
{/* Skip Objects Modal */}
<SkipObjectsModal
printerId={printer.id}
isOpen={showSkipObjectsModal}
onClose={() => setShowSkipObjectsModal(false)}
/>
{/* HMS Error Modal */}
{showHMSModal && (
@@ -3569,13 +3438,18 @@ function AddPrinterModal({
const [discoveryError, setDiscoveryError] = useState('');
const [hasScanned, setHasScanned] = useState(false);
const [isDocker, setIsDocker] = useState(false);
const [subnet, setSubnet] = useState('192.168.1.0/24');
const [detectedSubnets, setDetectedSubnets] = useState<string[]>([]);
const [subnet, setSubnet] = useState('');
const [scanProgress, setScanProgress] = useState({ scanned: 0, total: 0 });
// Fetch discovery info on mount
useEffect(() => {
discoveryApi.getInfo().then(info => {
setIsDocker(info.is_docker);
if (info.subnets.length > 0) {
setDetectedSubnets(info.subnets);
setSubnet(info.subnets[0]);
}
}).catch(() => {
// Ignore errors, assume not Docker
});
@@ -3740,14 +3614,27 @@ function AddPrinterModal({
<label className="block text-sm text-bambu-gray mb-1">
{t('printers.discovery.subnetToScan')}
</label>
<input
type="text"
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 text-sm"
value={subnet}
onChange={(e) => setSubnet(e.target.value)}
placeholder="192.168.1.0/24"
disabled={discovering}
/>
{detectedSubnets.length > 0 ? (
<select
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 text-sm"
value={subnet}
onChange={(e) => setSubnet(e.target.value)}
disabled={discovering}
>
{detectedSubnets.map(s => (
<option key={s} value={s}>{s}</option>
))}
</select>
) : (
<input
type="text"
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 text-sm"
value={subnet}
onChange={(e) => setSubnet(e.target.value)}
placeholder="192.168.1.0/24"
disabled={discovering}
/>
)}
<p className="mt-1 text-xs text-bambu-gray">
{t('printers.discovery.dockerNote')}
</p>
@@ -3846,11 +3733,11 @@ function AddPrinterModal({
<input
type="text"
required
pattern="\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}"
pattern="(\d{1,3}(\.\d{1,3}){3}|[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*)"
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"
value={form.ip_address}
onChange={(e) => setForm({ ...form, ip_address: e.target.value })}
placeholder="192.168.1.100"
placeholder="192.168.1.100 or printer.local"
/>
</div>
<div>
@@ -4223,11 +4110,11 @@ function EditPrinterModal({
<input
type="text"
required
pattern="\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}"
pattern="(\d{1,3}(\.\d{1,3}){3}|[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*)"
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"
value={form.ip_address}
onChange={(e) => setForm({ ...form, ip_address: e.target.value })}
placeholder="192.168.1.100"
placeholder="192.168.1.100 or printer.local"
/>
</div>
<div>
+67 -19
View File
@@ -1974,18 +1974,29 @@ export function SettingsPage() {
</p>
<div className="flex items-center justify-between">
<div>
<div className="flex-1">
<p className="text-white">{t('settings.enableHomeAssistant')}</p>
<p className="text-xs text-bambu-gray">{t('settings.homeAssistantDescription')}</p>
{localSettings.ha_env_managed && (
<div className="flex items-center gap-1 mt-1">
<Lock className="w-3 h-3 text-bambu-green" />
<span className="text-xs text-bambu-green">
{t('settings.autoEnabledViaEnv')}
</span>
</div>
)}
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={localSettings.ha_enabled ?? false}
onChange={(e) => updateSetting('ha_enabled', e.target.checked)}
disabled={localSettings.ha_env_managed}
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>
<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 ${
localSettings.ha_env_managed ? 'opacity-60 cursor-not-allowed' : ''
}`}></div>
</label>
</div>
@@ -1994,30 +2005,67 @@ export function SettingsPage() {
<div>
<label className="block text-sm text-bambu-gray mb-1">
Home Assistant URL
{localSettings.ha_url_from_env && (
<span className="ml-2 text-xs text-bambu-green">
{t('settings.environmentManagedLabel')}
</span>
)}
</label>
<input
type="text"
value={localSettings.ha_url ?? ''}
onChange={(e) => updateSetting('ha_url', e.target.value)}
placeholder="http://192.168.1.100:8123"
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"
/>
<div className="relative">
<input
type="text"
value={localSettings.ha_url ?? ''}
onChange={(e) => updateSetting('ha_url', e.target.value)}
placeholder="http://192.168.1.100:8123"
disabled={localSettings.ha_url_from_env}
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 ${
localSettings.ha_url_from_env ? 'opacity-60 cursor-not-allowed' : ''
}`}
/>
{localSettings.ha_url_from_env && (
<Lock className="absolute right-3 top-2.5 w-4 h-4 text-bambu-gray" />
)}
</div>
{localSettings.ha_url_from_env && (
<p className="text-xs text-bambu-gray mt-1">
{t('settings.urlFromEnvReadOnly')}
</p>
)}
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">
Long-Lived Access Token
{localSettings.ha_token_from_env && (
<span className="ml-2 text-xs text-bambu-green">
{t('settings.environmentManagedLabel')}
</span>
)}
</label>
<input
type="password"
value={localSettings.ha_token ?? ''}
onChange={(e) => updateSetting('ha_token', e.target.value)}
placeholder="eyJ0eXAiOiJKV1QiLC..."
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"
/>
<p className="text-xs text-bambu-gray mt-1">
Create a token in HA: Profile → Long-Lived Access Tokens → Create Token
</p>
<div className="relative">
<input
type="password"
value={localSettings.ha_token ?? ''}
onChange={(e) => updateSetting('ha_token', e.target.value)}
placeholder="eyJ0eXAiOiJKV1QiLC..."
disabled={localSettings.ha_token_from_env}
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 ${
localSettings.ha_token_from_env ? 'opacity-60 cursor-not-allowed' : ''
}`}
/>
{localSettings.ha_token_from_env && (
<Lock className="absolute right-3 top-2.5 w-4 h-4 text-bambu-gray" />
)}
</div>
{localSettings.ha_token_from_env ? (
<p className="text-xs text-bambu-gray mt-1">
{t('settings.tokenFromEnvReadOnly')}
</p>
) : (
<p className="text-xs text-bambu-gray mt-1">
Create a token in HA: Profile → Long-Lived Access Tokens → Create Token
</p>
)}
</div>
{localSettings.ha_url && localSettings.ha_token && (
+6
View File
@@ -324,6 +324,12 @@ export function SystemInfoPage() {
<li>• {t('support.collectItem4', 'Printer models and nozzle counts')}</li>
<li>• {t('support.collectItem5', 'Non-sensitive settings (themes, formats)')}</li>
<li>• {t('support.collectItem6', 'Debug logs (sanitized)')}</li>
<li>• {t('support.collectItem7', 'Printer connectivity and firmware versions')}</li>
<li>• {t('support.collectItem8', 'Integration status (Spoolman, MQTT, HA)')}</li>
<li>• {t('support.collectItem9', 'Network interfaces (subnets only)')}</li>
<li>• {t('support.collectItem10', 'Python package versions')}</li>
<li>• {t('support.collectItem11', 'Database health checks')}</li>
<li>• {t('support.collectItem12', 'Docker environment details')}</li>
</ul>
</div>
<div>
+3
View File
@@ -6,6 +6,9 @@ pytest-xdist>=3.5.0
httpx>=0.27.0
ruff>=0.8.0
# Required by pyftpdlib TLS_FTPHandler for mock FTP server tests
pyOpenSSL>=24.0.0
# Security scanning
bandit[sarif]>=1.7.0
pip-audit>=2.7.0
File diff suppressed because one or more lines are too long
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-BZQD54OI.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-togsBDt6.css">
<script type="module" crossorigin src="/assets/index-CBPKqOAD.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BTJM8cN7.css">
</head>
<body>
<div id="root"></div>
+1 -1
View File
@@ -1,3 +1,3 @@
#!/bin/bash
./test_frontend.sh && ./test_backend.sh && ./test_docker.sh
./test_frontend.sh && ./test_backend.sh --full && ./test_docker.sh
+6 -1
View File
@@ -2,5 +2,10 @@
cd backend
ruff check && ruff format --check
../venv/bin/python3 -m pytest tests/ -v -n 14
if [ "$1" = "--full" ]; then
../venv/bin/python3 -m pytest tests/ -v -n 14
else
../venv/bin/python3 -m pytest tests/ -v -n 14 --ignore=tests/unit/services/test_bambu_ftp.py
fi
cd ..
+2 -2
View File
@@ -10,9 +10,9 @@ git add .
git commit -m "Updated Wiki"
git push
cd ../bambuddy-languages
cd ../bambuddy-telemetry/
git add .
git commit -m "Updated Bambuddy Languages"
git commit -m "Updated Stats"
git push
cd ../spoolbuddy-website