Merge branch '0.2.1b' into codex/add-systemd-updater-script

This commit is contained in:
MartinNYHC
2026-02-19 08:24:46 +01:00
committed by GitHub
12 changed files with 370 additions and 112 deletions
+3 -1
View File
@@ -16,9 +16,11 @@ All notable changes to Bambuddy will be documented in this file.
- **H2D Tray Disambiguation Triggers on Single-Nozzle Printers** — The `tray_now <= 3` check for H2D dual-nozzle disambiguation matched any printer loading from AMS 0 (trays 0-3). On P2S, X1C, and X1E with multiple AMS units, this caused warning log spam every second. Now uses a persistent `_is_dual_nozzle` flag detected from `device.extruder.info` (>= 2 entries), which only dual-nozzle printers (H2D, H2D Pro) report.
- **AMS-HT Snow Slot Mismatch Log Spam on H2D** — The snow-based tray_now disambiguation computed `snow_slot = -1` for AMS-HT trays (IDs 128-135), causing a "slot mismatch" debug log on every MQTT update even though the result was correct. Now correctly computes `snow_slot = 0` for AMS-HT single-slot units.
- **Color Tooltip Clipped Behind Adjacent Swatches** — Color swatch hover tooltips in the spool form were rendered behind neighboring swatches due to missing z-index on the hover state. Added `hover:z-20` and tooltip `z-20` classes.
- **Print Queue Shows UUID Hash Instead of Filename** ([#438](https://github.com/maziggy/bambuddy/issues/438)) — When printing a library file, the Print Queue and archive displayed the UUID-hex disk filename (e.g., `c65887535303404eba1525176a0f78dc`) instead of the original human-readable name. Library files are stored on disk with UUID filenames for uniqueness, but `archive_print()` used the disk path as the display name. Now passes the original `LibraryFile.filename` through to `archive_print()` from both the print scheduler and the direct-print-from-library flow, so the archive's `filename`, `print_name`, and directory name all use the human-readable name.
- **Usage Tracking Wrong Spool on Dual-Nozzle / Multi-AMS Printers** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — On H2C, H2D Pro, and other dual-nozzle printers with multiple AMS units, the usage tracker attributed filament consumption to the wrong spools. The MQTT `mapping` field — a per-print array that maps slicer filament slots to physical AMS trays — was preserved in state but never parsed or used. The tracker fell back to `slot_id - 1` as the global tray ID, which is incorrect when AMS hardware IDs differ from sequential indices (e.g., AMS-HT units with ID 128). Now decodes the MQTT mapping field from its snow encoding (`ams_hw_id * 256 + local_slot`) into bambuddy global tray IDs and uses it as a universal mapping source — working for all printer models and all print sources (slicer, queue, reprint) without relying on `tray_now` disambiguation.
- **Usage Tracking Wrong Spool on Dual-Nozzle / Multi-AMS Printers** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — On H2C, H2D Pro, and other dual-nozzle printers with multiple AMS units, the usage tracker attributed filament consumption to the wrong spools. The MQTT `mapping` field — a per-print array that maps slicer filament slots to physical AMS trays — was preserved in state but never parsed or used. The tracker fell back to `slot_id - 1` as the global tray ID, which is incorrect when AMS hardware IDs differ from sequential indices (e.g., AMS-HT units with ID 128). Now decodes the MQTT mapping field from its snow encoding (`ams_hw_id * 256 + local_slot`) into bambuddy global tray IDs and uses it as a universal mapping source — working for all printer models and all print sources (slicer, queue, reprint) without relying on `tray_now` disambiguation. For printers that don't provide the MQTT mapping field (A1, A1 Mini, P1S, P2S), a color-matching fallback compares 3MF filament slot colors against AMS tray colors to resolve the correct slot-to-tray mapping. Gracefully returns no match when colors are ambiguous (duplicate tray colors) or unavailable.
- **npm audit: suppress moderate ajv ReDoS finding** — Added `audit-level=high` to `frontend/.npmrc` so `npm audit` exits cleanly. The ajv@6 ReDoS (GHSA-2g4f-4pwh-qvx6) is a transitive dependency of eslint@9 with no patched v6 release; ajv@8 override breaks eslint. The vulnerability requires crafted `$data` schema input — not an attack vector in a linting config.
- **npm audit: fix minimatch ReDoS finding** — Added an npm override for `minimatch@^10.2.1` in `package.json` to resolve the high-severity ReDoS (GHSA-3ppc-4f35-3m26) affecting minimatch@3.x/9.x pulled in transitively by eslint@9, typescript-eslint, and @vitest/coverage-v8. Eslint@9 pins minimatch@3.x with no patched release; eslint@10 upgrades to minimatch@10 but is not yet available. The override forces the patched version across the tree. Verified lint, build, and all tests pass.
- **Spool Form Allows Empty Brand & Subtype** ([#417](https://github.com/maziggy/bambuddy/issues/417)) — The spool add/edit modal did not require Brand or Subtype fields, allowing spools to be saved without them. When such a spool was assigned to an AMS slot, the `tray_sub_brands` sent to the printer was incomplete (e.g., just "PETG" instead of "PETG Basic"), causing BambuStudio to not recognize the filament profile. Brand and Subtype are now mandatory fields with validation errors shown on submit.
- **Open in Slicer Fails When Authentication Enabled** ([#421](https://github.com/maziggy/bambuddy/issues/421)) — The "Open in Slicer" buttons for BambuStudio and OrcaSlicer failed with "importing failed" when authentication was enabled. Slicer protocol handlers (`bambustudio://`, `orcaslicer://`) launch the slicer app which fetches the file via HTTP — but cannot send authentication headers, so the global auth middleware returned 401. Additionally, the URL format was wrong on Linux (used the macOS-only `bambustudioopen://` scheme instead of `bambustudio://open?file=`). Fixed with short-lived, single-use download tokens: the frontend fetches a token via an authenticated POST endpoint, then builds a `/dl/{token}/{filename}` URL that the slicer can access without auth headers. The token is validated server-side (5-minute expiry, single-use). Platform-specific URL formats now match the actual slicer source code: macOS uses `bambustudioopen://` with URL encoding, Windows/Linux use `bambustudio://open?file=`, and OrcaSlicer uses `orcaslicer://open?file=`.
+1
View File
@@ -1795,6 +1795,7 @@ async def print_library_file(
archive = await archive_service.archive_print(
printer_id=printer_id,
source_file=file_path,
original_filename=lib_file.filename,
)
if not archive:
+7 -3
View File
@@ -831,6 +831,7 @@ class ArchiveService:
source_file: Path,
print_data: dict | None = None,
created_by_id: int | None = None,
original_filename: str | None = None,
) -> PrintArchive | None:
"""Archive a 3MF file with metadata.
@@ -839,6 +840,8 @@ class ArchiveService:
source_file: Path to the 3MF file
print_data: Print data from MQTT (optional)
created_by_id: User ID who created this archive (optional, for user tracking)
original_filename: Original human-readable filename (optional, for library files
stored with UUID names)
"""
# Verify printer exists if specified
if printer_id is not None:
@@ -849,7 +852,8 @@ class ArchiveService:
# Create archive directory structure
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
archive_name = f"{timestamp}_{source_file.stem}"
display_stem = Path(original_filename).stem if original_filename else source_file.stem
archive_name = f"{timestamp}_{display_stem}"
# Use "unassigned" folder for archives without a printer
printer_folder = str(printer_id) if printer_id is not None else "unassigned"
archive_dir = settings.archive_dir / printer_folder / archive_name
@@ -923,12 +927,12 @@ class ArchiveService:
# Create archive record
archive = PrintArchive(
printer_id=printer_id,
filename=source_file.name,
filename=original_filename or source_file.name,
file_path=str(dest_file.relative_to(settings.base_dir)),
file_size=dest_file.stat().st_size,
content_hash=content_hash,
thumbnail_path=thumbnail_path,
print_name=metadata.get("print_name") or source_file.stem,
print_name=metadata.get("print_name") or display_stem,
print_time_seconds=metadata.get("print_time_seconds"),
filament_used_grams=metadata.get("filament_used_grams"),
filament_type=metadata.get("filament_type"),
+1
View File
@@ -915,6 +915,7 @@ class PrintScheduler:
archive = await archive_service.archive_print(
printer_id=item.printer_id,
source_file=file_path,
original_filename=filename,
)
if archive:
item.archive_id = archive.id
+95 -1
View File
@@ -63,6 +63,90 @@ def _decode_mqtt_mapping(mapping_raw: list | None) -> list[int] | None:
return result
def _match_slots_by_color(
filament_usage: list[dict],
ams_raw: dict | list | None,
) -> list[int] | None:
"""Match 3MF filament slots to AMS trays by color.
Fallback mapping for printers that don't provide the MQTT mapping field
or request topic subscription (e.g. A1, A1 Mini, P1S, P2S).
Compares the 3MF slicer filament color (per slot) against each AMS tray's
color to find a unique match. Only returns a mapping if every used slot
matches exactly one tray (no ambiguity).
Args:
filament_usage: List of 3MF slot dicts with 'slot_id', 'color', 'type'
ams_raw: raw_data["ams"] dict or list from printer state
Returns:
List of global tray IDs indexed by slicer slot (0-based), or None.
"""
if not filament_usage or not ams_raw:
return None
ams_data = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
if not ams_data:
return None
# Build map of normalized color → list of global tray IDs
color_to_trays: dict[str, list[int]] = {}
for ams_unit in ams_data:
ams_id = int(ams_unit.get("id", 0))
for tray in ams_unit.get("tray", []):
tray_id = int(tray.get("id", 0))
tray_color = tray.get("tray_color", "")
tray_type = tray.get("tray_type", "")
if not tray_color or not tray_type:
continue
# Normalize AMS color: strip alpha (last 2 chars), lowercase
norm = tray_color[:6].lower() if len(tray_color) >= 6 else tray_color.lower()
if ams_id >= 128:
global_id = ams_id # AMS-HT
else:
global_id = ams_id * 4 + tray_id
color_to_trays.setdefault(norm, []).append(global_id)
if not color_to_trays:
return None
# Find max slot_id to size the result array
max_slot = max(u.get("slot_id", 0) for u in filament_usage)
if max_slot <= 0:
return None
result = [-1] * max_slot
used_trays: set[int] = set()
for usage in filament_usage:
slot_id = usage.get("slot_id", 0)
if slot_id <= 0:
continue
slot_color = usage.get("color", "").lstrip("#").lower()
if len(slot_color) < 6:
return None # Can't match without a valid color
slot_color = slot_color[:6] # Strip alpha if present
candidates = color_to_trays.get(slot_color, [])
# Filter out trays already claimed by another slot
available = [t for t in candidates if t not in used_trays]
if len(available) != 1:
# Ambiguous (multiple trays with same color) or no match
return None
result[slot_id - 1] = available[0]
used_trays.add(available[0])
# Only return if at least one valid mapping exists
if all(v < 0 for v in result):
return None
logger.info("[UsageTracker] Color-matched slot_to_tray: %s", result)
return result
@dataclass
class PrintSession:
printer_id: int
@@ -392,13 +476,23 @@ async def _track_from_3mf(
except (json.JSONDecodeError, TypeError):
pass
# 4. Color-match 3MF filament slots to AMS trays (for printers without mapping field)
if not slot_to_tray:
state = printer_manager.get_status(printer_id)
raw_data = getattr(state, "raw_data", None) if state else None
if raw_data:
matched = _match_slots_by_color(filament_usage, raw_data.get("ams"))
if matched:
slot_to_tray = matched
mapping_source = "color_match"
logger.info(
"[UsageTracker] 3MF: slot_to_tray=%s (source: %s)",
slot_to_tray,
mapping_source or "none",
)
# 3. For single-filament non-queue prints, use tray_now from printer state
# 5. For single-filament non-queue prints, use tray_now from printer state
# Priority: tray_now_at_start > current tray_now > last_loaded_tray > vt_tray check
nonzero_slots = [u for u in filament_usage if u.get("used_g", 0) > 0]
tray_now_override: int | None = None
+156
View File
@@ -15,6 +15,7 @@ from backend.app.services.usage_tracker import (
PrintSession,
_active_sessions,
_decode_mqtt_mapping,
_match_slots_by_color,
_track_from_3mf,
on_print_complete,
on_print_start,
@@ -886,6 +887,161 @@ class TestDecodeMqttMapping:
assert _decode_mqtt_mapping(["foo", 0]) == [-1, 0]
class TestMatchSlotsByColor:
"""Tests for _match_slots_by_color() — color-based filament slot to AMS tray matching."""
def _ams(self, trays):
"""Build AMS data from list of (ams_id, tray_id, color_hex, tray_type) tuples."""
units: dict[int, list] = {}
for ams_id, tray_id, color, tray_type in trays:
units.setdefault(ams_id, []).append({"id": tray_id, "tray_color": color, "tray_type": tray_type})
return [{"id": aid, "tray": t} for aid, t in units.items()]
def _usage(self, slots):
"""Build filament_usage from list of (slot_id, color_hex) tuples."""
return [{"slot_id": sid, "used_g": 10.0, "type": "PLA", "color": color} for sid, color in slots]
def test_none_inputs(self):
assert _match_slots_by_color(None, None) is None
assert _match_slots_by_color([], None) is None
assert _match_slots_by_color(None, {"ams": []}) is None
def test_empty_ams(self):
usage = self._usage([(1, "#FF0000")])
assert _match_slots_by_color(usage, {"ams": []}) is None
def test_single_slot_single_tray(self):
"""One 3MF slot matches one AMS tray by color."""
ams = self._ams([(0, 0, "FF0000FF", "PLA")])
usage = self._usage([(1, "#FF0000")])
assert _match_slots_by_color(usage, {"ams": ams}) == [0]
def test_a1_mini_three_colors(self):
"""A1 Mini: 3 slots match 3 distinct AMS trays."""
ams = self._ams(
[
(0, 0, "FF0000FF", "PLA"), # Red
(0, 1, "00FF00FF", "PLA"), # Green
(0, 2, "0000FFFF", "PLA"), # Blue
]
)
usage = self._usage([(1, "#FF0000"), (2, "#00FF00"), (3, "#0000FF")])
assert _match_slots_by_color(usage, {"ams": ams}) == [0, 1, 2]
def test_dual_ams_p2s_like(self):
"""P2S with dual AMS: slots from second AMS unit."""
ams = self._ams(
[
(0, 0, "AAAAAAFF", "PLA"),
(0, 1, "BBBBBBFF", "PLA"),
(1, 0, "CC0000FF", "PETG"), # global_id=4
(1, 1, "00CC00FF", "PETG"), # global_id=5
]
)
usage = self._usage([(1, "#CC0000"), (2, "#00CC00")])
assert _match_slots_by_color(usage, {"ams": ams}) == [4, 5]
def test_ams_ht_global_id(self):
"""AMS-HT (ams_id >= 128) uses raw ams_id as global tray ID."""
ams = self._ams(
[
(0, 0, "FF0000FF", "PLA"),
(128, 0, "0000FFFF", "PLA"), # AMS-HT → global_id=128
]
)
usage = self._usage([(1, "#FF0000"), (2, "#0000FF")])
assert _match_slots_by_color(usage, {"ams": ams}) == [0, 128]
def test_ambiguous_same_color_returns_none(self):
"""Two trays with the same color → ambiguous → None."""
ams = self._ams(
[
(0, 0, "FF0000FF", "PLA"),
(0, 1, "FF0000FF", "PLA"), # Same red
]
)
usage = self._usage([(1, "#FF0000")])
assert _match_slots_by_color(usage, {"ams": ams}) is None
def test_no_matching_color_returns_none(self):
"""3MF slot color not found in any AMS tray → None."""
ams = self._ams([(0, 0, "00FF00FF", "PLA")])
usage = self._usage([(1, "#FF0000")]) # Red, but AMS has green
assert _match_slots_by_color(usage, {"ams": ams}) is None
def test_color_normalization_strips_alpha(self):
"""AMS colors (RRGGBBAA) and 3MF colors (#RRGGBB) match after normalization."""
ams = self._ams([(0, 0, "AABBCC80", "PLA")]) # 8-char with alpha
usage = self._usage([(1, "#AABBCC")]) # 6-char with #
assert _match_slots_by_color(usage, {"ams": ams}) == [0]
def test_case_insensitive(self):
"""Color matching is case-insensitive."""
ams = self._ams([(0, 0, "aaBBccFF", "PLA")])
usage = self._usage([(1, "#AAbbCC")])
assert _match_slots_by_color(usage, {"ams": ams}) == [0]
def test_empty_tray_color_skipped(self):
"""Trays with empty color are skipped (not matched)."""
ams = self._ams(
[
(0, 0, "", "PLA"),
(0, 1, "FF0000FF", "PLA"),
]
)
usage = self._usage([(1, "#FF0000")])
assert _match_slots_by_color(usage, {"ams": ams}) == [1]
def test_empty_tray_type_skipped(self):
"""Trays with empty tray_type are skipped (unloaded slot)."""
ams = self._ams(
[
(0, 0, "FF0000FF", ""), # Empty slot
(0, 1, "FF0000FF", "PLA"), # Loaded slot
]
)
usage = self._usage([(1, "#FF0000")])
assert _match_slots_by_color(usage, {"ams": ams}) == [1]
def test_short_slot_color_returns_none(self):
"""3MF slot with color < 6 chars → can't match → None."""
ams = self._ams([(0, 0, "FF0000FF", "PLA")])
usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#FFF"}]
assert _match_slots_by_color(usage, {"ams": ams}) is None
def test_slot_id_zero_skipped(self):
"""Slots with slot_id=0 are skipped."""
ams = self._ams([(0, 0, "FF0000FF", "PLA")])
usage = [{"slot_id": 0, "used_g": 10.0, "type": "PLA", "color": "#FF0000"}]
assert _match_slots_by_color(usage, {"ams": ams}) is None
def test_ams_data_as_list(self):
"""Handles ams_raw as a plain list (some printer models)."""
ams_list = [{"id": 0, "tray": [{"id": 0, "tray_color": "FF0000FF", "tray_type": "PLA"}]}]
usage = self._usage([(1, "#FF0000")])
assert _match_slots_by_color(usage, ams_list) == [0]
def test_same_color_two_trays_disambiguated_by_usage(self):
"""Two trays same color, two slots same color → unique assignment via used_trays tracking."""
ams = self._ams(
[
(0, 0, "FF0000FF", "PLA"),
(0, 1, "FF0000FF", "PLA"),
]
)
# Two slots both wanting red — first gets tray 0, second gets tray 1? No.
# When first slot takes the only available, second has 1 left → should work
usage = self._usage([(1, "#FF0000"), (2, "#FF0000")])
# First slot: candidates=[0,1], available=[0,1], len!=1 → None
assert _match_slots_by_color(usage, {"ams": ams}) is None
def test_dict_wrapper_with_ams_key(self):
"""Standard dict format with 'ams' key."""
ams_data = {"ams": [{"id": 0, "tray": [{"id": 0, "tray_color": "00FF00FF", "tray_type": "PLA"}]}]}
usage = self._usage([(1, "#00FF00")])
assert _match_slots_by_color(usage, ams_data) == [0]
class TestMqttMappingIntegration:
"""Integration tests: MQTT mapping field used in _track_from_3mf."""
+21 -101
View File
@@ -3317,32 +3317,6 @@
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@@ -3723,11 +3697,13 @@
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz",
"integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==",
"dev": true,
"license": "MIT"
"engines": {
"node": "20 || >=22"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.19",
@@ -3740,14 +3716,15 @@
}
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz",
"integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/browserslist": {
@@ -4003,13 +3980,6 @@
"node": ">= 0.8"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true,
"license": "MIT"
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -5033,32 +5003,6 @@
"node": ">=10.13.0"
}
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/glob/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/globals": {
"version": "16.5.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz",
@@ -6138,16 +6082,18 @@
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "10.2.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.1.tgz",
"integrity": "sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
"brace-expansion": "^5.0.2"
},
"engines": {
"node": "*"
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
@@ -7481,32 +7427,6 @@
"node": ">=18"
}
},
"node_modules/test-exclude/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/test-exclude/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/three": {
"version": "0.181.2",
"resolved": "https://registry.npmjs.org/three/-/three-0.181.2.tgz",
+3
View File
@@ -41,6 +41,9 @@
"recharts": "^3.5.1",
"three": "^0.181.2"
},
"overrides": {
"minimatch": "^10.2.1"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/postcss": "^4.1.17",
+4 -4
View File
@@ -50,8 +50,8 @@ describe('slicer utility', () => {
openInSlicer('http://localhost:8000/file.3mf', 'bambu_studio');
expect(appendSpy).toHaveBeenCalled();
expect(createdLink.href).toContain('bambustudio://');
expect(createdLink.href).toContain(encodeURIComponent('http://localhost:8000/file.3mf'));
expect(createdLink.href).toContain('bambustudio://open?file=');
expect(createdLink.href).toContain('http://localhost:8000/file.3mf');
expect(clickSpy).toHaveBeenCalled();
expect(removeSpy).toHaveBeenCalled();
});
@@ -63,11 +63,11 @@ describe('slicer utility', () => {
expect(createdLink.href).toContain('bambustudioopen://');
});
it('uses bambustudioopen:// protocol on Linux for bambu_studio', () => {
it('uses bambustudio://open?file= on Linux for bambu_studio', () => {
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (X11; Linux x86_64)');
openInSlicer('http://localhost:8000/file.3mf', 'bambu_studio');
expect(createdLink.href).toContain('bambustudioopen://');
expect(createdLink.href).toContain('bambustudio://open?file=');
});
it('uses orcaslicer:// protocol for orcaslicer on all platforms', () => {
+77
View File
@@ -14,6 +14,7 @@
# --tz TIMEZONE Timezone (default: system timezone or UTC)
# --build Build from source instead of using pre-built image
# --yes, -y Non-interactive mode, accept defaults
# --redirect-990 Add iptables redirect from 990 -> 9990 (Linux only)
# --help, -h Show this help message
#
@@ -42,6 +43,7 @@ BUILD_FROM_SOURCE="false"
NON_INTERACTIVE="false"
OS_TYPE=""
DOCKER_CMD=""
REDIRECT_990="false"
# -----------------------------------------------------------------------------
# Helper Functions
@@ -140,6 +142,7 @@ show_help() {
echo " --tz TIMEZONE Timezone (default: system timezone or UTC)"
echo " --build Build from source instead of using pre-built image"
echo " --yes, -y Non-interactive mode, accept defaults"
echo " --redirect-990 Add iptables redirect from 990 -> 9990 (Linux only)"
echo " --help, -h Show this help message"
echo ""
echo "Examples:"
@@ -380,6 +383,10 @@ parse_args() {
NON_INTERACTIVE="true"
shift
;;
--redirect-990)
REDIRECT_990="true"
shift
;;
--help|-h)
show_help
;;
@@ -391,6 +398,53 @@ parse_args() {
done
}
check_sudo() {
if ! command -v sudo &>/dev/null; then
log_error "sudo is required for iptables redirect but is not installed. Skipping iptables redirect."
return 1
fi
if ! command -v iptables &>/dev/null; then
log_error "iptables is required for iptables redirect but is not installed. Skipping iptables redirect."
return 1
fi
return 0
}
configure_iptables_redirect() {
if [[ "$OS_TYPE" != "linux" ]]; then
log_warn "iptables redirect only supported on Linux. Skipping."
return
fi
if [[ "$REDIRECT_990" != "true" ]]; then
return
fi
if ! check_sudo; then
return
fi
log_info "Configuring iptables redirect: 990 -> 9990"
# Check if rule already exists
if sudo iptables -t nat -C PREROUTING -p tcp --dport 990 -j REDIRECT --to-port 9990 2>/dev/null; then
log_warn "PREROUTING rule already exists. Skipping."
else
sudo iptables -t nat -A PREROUTING -p tcp --dport 990 -j REDIRECT --to-port 9990
log_success "Added PREROUTING redirect rule"
fi
if sudo iptables -t nat -C OUTPUT -o lo -p tcp --dport 990 -j REDIRECT --to-port 9990 2>/dev/null; then
log_warn "OUTPUT rule already exists. Skipping."
else
sudo iptables -t nat -A OUTPUT -o lo -p tcp --dport 990 -j REDIRECT --to-port 9990
log_success "Added OUTPUT redirect rule"
fi
log_warn "Note: iptables rules are NOT persistent after reboot."
log_warn "To persist them, install iptables-persistent or use a firewall manager."
}
gather_config() {
echo ""
echo -e "${BOLD}Installation Configuration${NC}"
@@ -412,6 +466,20 @@ gather_config() {
prompt "Bind address" "$DEFAULT_BIND_ADDRESS" BIND_ADDRESS
fi
# Redirect port 990 -> 9990 (Linux only)
if [[ "$OS_TYPE" == "linux" ]]; then
if [[ "$NON_INTERACTIVE" == "true" ]]; then
# In non-interactive mode, only set REDIRECT_990 if explicitly passed
: # Do nothing, REDIRECT_990 is set only if --redirect-990 is passed
elif [[ "$REDIRECT_990" != "true" ]]; then
echo ""
echo "Optional network configuration:"
if prompt_yes_no "Add iptables redirect (990 -> 9990)?" "n"; then
REDIRECT_990="true"
fi
fi
fi
# Timezone
detect_timezone
prompt "Timezone" "$TIMEZONE" TIMEZONE
@@ -432,6 +500,7 @@ gather_config() {
echo -e " Bind address: ${GREEN}$BIND_ADDRESS${NC}"
echo -e " Timezone: ${GREEN}$TIMEZONE${NC}"
echo -e " Build source: ${GREEN}$BUILD_FROM_SOURCE${NC}"
echo -e " Redirect 990: ${GREEN}$REDIRECT_990${NC}"
echo ""
if ! prompt_yes_no "Proceed with installation?" "y"; then
@@ -493,6 +562,7 @@ main() {
create_env_file
customize_compose
start_container
configure_iptables_redirect
# Done!
echo ""
@@ -531,6 +601,13 @@ main() {
echo -e " ${BOLD}Documentation:${NC} ${CYAN}https://wiki.bambuddy.cool${NC}"
echo ""
# Warn about iptables persistence
if [[ "$REDIRECT_990" == "true" ]] && [[ "$OS_TYPE" == "linux" ]]; then
echo -e " ${YELLOW}Note:${NC} iptables redirect rules do NOT survive reboot."
echo -e " Install 'iptables-persistent' if persistence is required."
echo ""
fi
if [[ "$OS_TYPE" == "macos" ]]; then
echo -e " ${YELLOW}Note:${NC} Printer discovery may not work with Docker Desktop."
echo -e " Add printers manually using their IP address."
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -23,7 +23,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-BTn6l3An.js"></script>
<script type="module" crossorigin src="/assets/index-DqAJp9_u.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-EqFdfChN.css">
</head>
<body>