Commit Graph
612 Commits
Author SHA1 Message Date
maziggy b8916ac3de fix(presets): match Bambu cloud @BBL A1M as A1 Mini (#1649)
Reporter on an A1 Mini saw the AMS slot Configure dropdown render no
  Bambu / Generic filament profiles, and saw the Profiles tab strip
  A1 Mini results when filtering by that model. Bambu rolled out a
  profile rename mid-2026: the @BBL <code> suffix on 106 cloud profiles
  shifted from the long display form to a terse model code -- e.g.
  "Bambu PLA Basic @BBL A1 Mini ..." is now
  "Bambu PLA Basic @BBL A1M ...". User-authored profiles still use the
  long form. Bambuddy's filters did a verbatim uppercase compare
  ("A1M" vs "A1 MINI"), so every renamed cloud profile silently
  disappeared from the picker.

  Centralize the alias check in slicerPrinterMatch.ts. New
  PRINTER_MODEL_SUFFIX_ALIASES table maps "A1 Mini" <-> "A1M"
  bidirectionally; exported matchesPrinterModelSuffix() does the
  case-insensitive compare with the alias fallback. Two consumer
  sites swap to the helper:

    * ConfigureAmsSlotModal.tsx (Orca cloud and Bambu cloud filter
      branches) -- the AMS slot picker, hit directly and reached from
      SpoolBuddy's AMS page via mapModelCode(printer?.model)
    * slicerPrinterMatch.ts:classifyByBambuName -- the SliceModal
      Process / Filament compatibility check

  Backend printer_models.py also gets a "Bambu Lab A1M" -> "A1 Mini"
  entry so server-side 3MF model normalization stays consistent if a
  3MF ever embeds the short form.

  Kept the alias table narrow on purpose. Wide-net aliasing (e.g.
  "X1" <-> "X1C") would silently collapse physically distinct
  printers. When Bambu introduces the next rename, it is one new row
  in the table -- /api/v1/cloud/settings is the place to grep, called
  out in the source comment.
2026-06-06 09:10:27 +02:00
maziggy 19073f5c84 fix(vp): auto-derive access code from target printer in non-proxy modes
Non-proxy VPs (Archive / Review / Queue) with a target printer set up
  a live-mirror bridge that forwards the slicer's MQTT and RTSPS auth
  bytes through to the real printer. The slicer holds one code in its
  profile (the one it bound the VP with), and that code has to satisfy
  both the VP listener and the real printer at the far end of the
  bridge. If the codes diverge the bridge silently fails at the second
  hop — slicer reaches .49:8883, FINs before sending a ClientHello,
  retries identically. The wiki framed the code-match requirement as a
  camera-only concern; it isn't, all bridged protocols inherit.

  Fix removes the foot-gun instead of re-documenting it. When a target
  is selected on a non-proxy VP the access-code field switches to a
  read-only display showing the target's code with an Eye-toggle
  reveal; the backend auto-inherits on every create / update (any
  explicit access_code submitted alongside a target is silently
  overridden as belt-and-braces for non-UI clients). The required-when-
  enabling check now treats target-set as satisfying the access-code
  requirement. Standalone (no-target) non-proxy VPs still get the
  editable input + Save button.

  One-shot startup migration corrects any pre-existing mismatched
  rows: SELECTs diverged VPs and logs one INFO line per row for the
  audit trail, then UPDATEs via correlated subquery. Idempotent and
  portable between SQLite and Postgres.
2026-06-05 17:19:16 +02:00
maziggy 18d534c945 feat(orca-cloud): integrate Orca Cloud profile sync across UI, slicer and SpoolBuddy
Reads, lists, and slices with profiles from a user's Orca Cloud account
  (OrcaSlicer 2.4.0-alpha's Supabase-backed sync) alongside the existing
  Bambu Cloud integration. Four sign-in providers (Google / Apple / GitHub /
  email+password); password defaults. Paste-flow PKCE because Orca's
  Supabase project only allowlists localhost redirect_to — open feature
  request at OrcaSlicer/OrcaSlicer#14028.

  Surfaces:
  - Profiles tab: new "Orca Cloud" tab next to "Bambu Cloud" with the same
    rich layout (search + 5 filter dropdowns + 3-column grouped grid +
    read-only detail modal)
  - SliceModal: 4-tier preset picker (orca_cloud > local > bambu cloud >
    standard); separate status banner per cloud; metadata-aware pre-pick
    scores Orca filaments above local (Orca's sync_pull returns full
    content inline so filament_type / filament_colour come for free, no
    per-setting fetch rate-limit dance)
  - ConfigureAmsSlotModal: orca_cloud as a new preset source (prefixed
    orca_<UUID> to match local_/builtin_); generic Bambu filament-ID
    derivation from parsed material (printer firmware can't grok Orca
    UUIDs); slot mapping persists preset_source='orca_cloud'
  - SpoolForm / SpoolBuddyWriteTagPage: Orca filaments merge into the
    cloud preset list via Promise.allSettled (OrcaProfileMeta is
    structurally identical to SlicerSetting)

  Backend:
  - services/orca_cloud.py: OrcaCloudService with PKCE / token exchange /
    single-use refresh rotation / get_user_info / list_profiles via the
    bare /sync/pull bootstrap path
  - routes/orca_cloud.py: 7 endpoints (auth/start, auth/finish,
    auth/password, status, logout, profiles, profiles/{id}); router-level
    _cloud_api_key_gate + per-route cloud_caller() so API-keyed callers
    (SpoolBuddy kiosk) properly resolve their owner User; just-in-time
    refresh with atomic persist-before-API-call
  - routes/slicer_presets.py: _fetch_orca_cloud_presets mirrors the Bambu
    Cloud fetcher (status vocabulary, 5min cache, permission shortcut);
    _dedupe_by_name extended to 4 tiers; UnifiedPresetsResponse gains
    orca_cloud + orca_cloud_status
  - services/preset_resolver.py: PresetRef.source extended with
    "orca_cloud"; _resolve_orca_cloud walks list + filters
  - 8 columns on users table for tokens (5 persistent) + transient PKCE
    handshake state with 10-min TTL (3); dialect-branched DATETIME /
    TIMESTAMP; auth-disabled mode falls back to Settings table
  - orca_cloud:auth permission folded into can_access_cloud API-key scope
    (same trust dimension)
2026-06-04 15:50:44 +02:00
maziggy 24ce250176 fix(labels): single PDF per click (#1628)
window.open(url, '_blank', 'noopener,noreferrer') returns null even on
  success per the WindowFeatures spec — `noopener` deliberately suppresses
  the return reference. The label-print modal treated null as "popup
  blocked → fall back to <a download> click", so the fallback fired on
  every click. Result: window.open opened the blob tab (downloading a
  random-named PDF on systems without an inline viewer) AND the fallback
  downloaded bambuddy-labels.pdf — two identical PDFs per click.

  Drop noopener,noreferrer. The blob is same-origin, the destination is a
  passive PDF preview tab with no script context, and noreferrer is no-op
  for blob URLs. window.open now returns a real window reference on
  success and the if (!win) fallback only fires on genuine popup-block.
2026-06-04 11:04:57 +02:00
maziggy c571ad86dd feat(diagnostic): printer_publishing check + countdown UI (#1622)
The existing connection diagnostic proved TCP + TLS + auth + SUBSCRIBE but
  not that the printer was actually publishing reports. A wrong-cased serial
  passes mqtt_auth because the broker accepts the subscription regardless;
  the user-visible symptom is empty AMS / no K-profiles / no custom filaments
  in the slicer Device tab because the VP cached state is empty. Bambuddy
  already logged the actionable hint at bambu_mqtt.py:498 but only to
  container logs.

  New printer_publishing check turns that warning into a structured
  diagnostic result. Pass = bridge has seen at least one report since the
  last (re)connect; fail = zero reports across the wait window with fix-text
  pointing at the case-sensitive serial. Bounded 10s poll on the on-demand
  UI route, no wait on the support-package gathering path so bundling stays
  fast. Exits the moment a message arrives — typical wall-clock is 1-2s.

  Frontend renders an elapsed-seconds counter plus a "Listening for status
  report — up to 10s" hint during the pending state so the wait doesn't look
  hung. PUBLISH_WAIT_DEFAULT_SECONDS pinned on both sides.

  report_messages_since_connect exposed as a public property on
  BambuMQTTClient so the diagnostic doesn't reach into private state.
2026-06-04 10:54:04 +02:00
maziggy a1cb5d5b4d fix(backup): interpret scheduled-backup HH:MM as local time, not UTC (#1602 follow-up)
The Scheduled Local Backups time-of-day picker was interpreted as UTC by
  _calculate_next_run, so a UTC+3 user had to enter 18:00 to get a 21:00
  local backup. The UI labeled the field "UTC" but it was still surprising.

  Picker is now interpreted in the container's local timezone, resolved
  from the TZ env var via zoneinfo.ZoneInfo (same source the Support page's
  environment.timezone shows). UTC fallback when TZ is unset or
  unrecognised. The /local-backup/status endpoint exposes the resolved
  zone, and the UI renders it next to the field via a new
  backup.localTimeHint i18n key with real translations in all 10
  non-English locales.

  One-time behaviour change for users who entered a UTC time as a
  workaround: the first scheduled cycle after upgrade will run at their
  local TZ offset earlier than expected. Re-enter the time as local once
  and it is correct from then on. No migration is shipped; migrating
  around a DST boundary would be ambiguous.
2026-06-04 10:17:10 +02:00
maziggy e38267065c fix(frontend): #1602 render print-run / spool-usage / camera-token / SpoolBuddy timestamps in local time
Four frontend formatDate / formatDateTime helpers called new Date(iso)
  directly on backend timestamps that have no timezone indicator. Per
  ECMAScript, a bare "2026-06-02T07:50:00" is parsed as local time, so
  a UTC-stored value got displayed as if its numeric components were
  already local — visually identical to UTC. Same shape as the #504
  fix from Feb 2026, which patched 13 sites but missed these four:
  PrintLogTable and SpoolUsageHistory hadn't been written yet;
  CameraTokensPage and SpoolBuddySettingsPage existed but were
  overlooked.

  Reporter #2 (@IndividualGhost1905) confirmed with a UTC+3 host: print
  log shows UTC clock value, system date shows local. PrintLogTable is
  the "logs/completion time" they called out; the other three are the
  same pattern in nearby surfaces.

  Replaced the bare new Date(iso) calls with parseUTCDate(iso) from
  utils/date.ts — the same helper every other date formatter in the
  codebase already uses. It appends "Z" to naive ISO strings and
  parses TZ-tagged strings as-is. CameraTokensPage.isExpired got the
  same fix because comparing a misparsed Date against Date.now() would
  produce false "not expired" / "expired" results around the TZ-offset
  boundary.

  Reporter #1's printer-card ETA complaint (10:50 + 57m showing 09:48)
  is NOT addressed by this fix. That ETA comes from
  formatETA(remainingMinutes) which is purely client-side (new Date()
  plus minutes from the WebSocket payload, then toLocaleTimeString)
  — for it to render UTC the browser timezone itself would need to
  be UTC, which is a browser / OS config issue.

  Audit confirmed no other regressions: grepped every new Date( call
  in frontend/src/. Remaining sites either pass an epoch-ms number,
  use the result only for .getTime() arithmetic where the offset
  cancels, already wrap in parseUTCDate fallback, or consume a
  backend timestamp that includes "+00:00" (FailureDetectionSettings
  reads obico_detection.py's tz-aware isoformat).
2026-06-03 10:39:07 +02:00
maziggy 5d6d928b3f fix(virtual-printer): #1429 net.info[*].ip cache leak + mode wire-value rename
#1429 (reported by @TrickShotMLG02, confirmed by @Mape6 on a flat single-LAN
  that rules out subnet / mDNS-reflector theories): with the physical printer
  off the slicer's "Send" landed in Bambuddy's archive; once the printer
  powered on every subsequent "Send" went straight to the printer's SD card
  and bypassed Bambuddy. Bundle analysis: mape6-before showed clean FTP
  receive + archive lines, mape6-after had zero FTP attempts to Bambuddy
  once the printer was online.

  Cause: mqtt_bridge.py::_resolve_client encoded _target_ip_uint32_le /
  _vp_ip_uint32_le ONLY on client-identity change and early-returned on
  every refresh tick when the same client object was still bound. If
  target_client.ip_address was empty at first bind (DB row stale, or client
  constructed before SSDP refresh filled it in), the encoding stayed None,
  the net.info[*].ip rewrite block was skipped, the cache filled with the
  real printer IP, sticky-key preservation kept the poisoned net value
  alive across every subsequent incremental push, and the slicer followed
  the leaked IP. Only Bambuddy-restart-with-printer-off cleared it — the
  workaround both reporters independently arrived at. Same shape on
  multi-NIC printers (X1C, H2D Pro): the rewrite only matched entries
  whose ip equalled _target_ip_uint32_le, so a secondary interface IP
  Bambuddy never saw would leak through unchanged.

  Bridge fix:
  - _resolve_client calls a new _refresh_ip_encoding() on every refresh
    tick, even when client identity is unchanged; self-heals once
    ip_address becomes valid.
  - _refresh_ip_encoding() sweeps the existing _latest_print_state when
    encoding becomes valid for the first time. Without the sweep,
    sticky-key preservation keeps the pre-arm poisoned cache alive
    forever — incremental pushes that don't include net carry the bad
    value forward.
  - _rewrite_net_info_ips() rewrites EVERY non-zero net.info[].ip entry
    that doesn't already equal the VP IP, not just entries matching
    _target_ip_uint32_le. Multi-NIC printers stop leaking secondary
    interfaces. Zero-IP placeholders are left alone so "active interface"
    detection still works.
  - INFO logging on encoding arm/update and on cache sweep so future
    bundles directly answer "did the rewrite fire?".

  Mode wire-value rename (#1429 follow-up, separate confusion source):
  - Both reporters' support bundles showed mode: immediate while the UI
    said "Archive"; @TrickShotMLG02 quoted: "I have no idea why it says
    immediate in the support-info.json file. In the webui the printer is
    set to archive". UI button "Archive" had always saved immediate, and
    "Queue" had always saved print_queue. Canonical wire values are now
    archive / review / queue / proxy, matching the button labels 1:1.
  - New normalize_vp_mode() + VP_MODE_* constants in
    models/virtual_printer.py; manager.py normalises on construction so
    a legacy row read pre-migration still dispatches correctly.
  - core/database.py::run_migrations rewrites existing virtual_printers
    and settings rows; idempotent (re-runs are no-ops); identical SQL
    under SQLite and Postgres.
  - API routes accept both legacy and canonical on input, normalise
    before storage. GET /settings/virtual-printer normalises on read so
    the frontend's mode-button highlight works for stale legacy values.
  - Three frontend VP components (VirtualPrinterSettings,
    VirtualPrinterCard, VirtualPrinterAddDialog) switched click handlers
    and type aliases to canonical; each got its own normalizeMode()
    helper so a stale-cached settings payload still highlights the right
    button. Two pre-existing `printer.mode === 'queue' ? 'review'`
    legacy mappings in VirtualPrinterCard were the source of a test
    failure caught mid-implementation where the new canonical 'queue'
    was being mis-aliased back to 'review' and hiding the auto-dispatch
    + force-color-match toggles.

  mode handler is NOT the dispatch bug: manager.py::_archive_file (the
  handler for archive mode) doesn't dispatch to the physical printer.
  The "files end up on the printer's SD card" symptom was the IP-leak
  from the bridge cache. The mode rename is purely clarity / support-
  bundle accuracy.
2026-06-02 14:33:20 +02:00
maziggy 171848a0fc fix(slicer-presets): #1581 SliceModal refresh + invalidate on local-profile delete/import
Two-part fix for the reporter's "removed profiles still show on the slice
  menu" symptom.

  Local half (real bug). LocalProfilesView's import and delete mutations
  invalidated ['localPresets'] (the management view's own query) but not
  ['slicerPresets'] (the SliceModal's unified preset query, staleTime 60s).
  A freshly-deleted preset kept rendering in the slice dropdown until that
  staleTime elapsed plus a refocus/remount. Both mutations now also call
  queryClient.invalidateQueries({queryKey: ['slicerPresets']}).

  Cloud half (opt-in cache bypass). _fetch_cloud_presets keeps a 5-minute
  per-(user, token) in-process cache (slicer_presets.py:69, balances
  "users see freshly-saved presets quickly" against "busy install doesn't
  hit Bambu Cloud once per modal open"). Users delete cloud presets in
  Bambu Studio / Bambu Handy, not in Bambuddy, so there's no event hook
  to invalidate on. Rather than shorten the TTL globally, the listing
  endpoint gains an opt-in ?refresh=true query param that bypasses both
  the cloud cache AND the 1-hour bundled-preset cache for that one call;
  the fresh result is still written back so subsequent normal callers
  keep hitting the cache.

  New SliceModal "Refresh" button. Lives in the preset section header
  next to the cloud-status banner. Calls getSlicerPresets({refresh: true})
  and writes the fresh slots into the ['slicerPresets'] cache via
  queryClient.setQueryData so the spinner stops immediately rather than
  triggering a second refetch. RefreshCw icon spins while in-flight;
  disabled during slice enqueue to prevent double-fire.
2026-06-02 12:42:16 +02:00
maziggy 40cd45e2d5 fix(library): render 3D preview for sliced .gcode.3mf files (#1543)
Reporter exported a multi-plate .gcode.3mf from Bambu Studio to the
  shared folder Bambuddy watches and the 3D preview tab came up empty;
  if he re-uploaded the same file via the file manager, the preview
  worked. Root cause: two paths classify file_type differently. The
  shared-folder scan at backend/app/api/routes/library.py:1343-1348
  does a compound-extension check and tags the file `gcode.3mf`; the
  upload path at the same file's :1588 does a single `ext[1:]` and tags
  it `3mf`. Then frontend/src/components/ModelViewerModal.tsx:71-73 had

    hasModel = normalizedType === '3mf' || 'stl'
    hasGcode = normalizedType === 'gcode' || '3mf'

  Neither matched `gcode.3mf`, so the capabilities object landed with
  both flags false and the modal rendered an empty bed.
  FileManagerPage.tsx:858 also gated the Preview-3D context action on
  `file_type === '3mf' || 'gcode' || 'stl'`, so for shared-folder files
  the menu entry didn't even appear, and the type pill at :765-770 had
  no colour case for `gcode.3mf` so it fell through to the generic
  gray.

  Fix (frontend-only, no backend churn):

  - ModelViewerModal.tsx introduces an
    `isThreeMfFamily = normalizedType === '3mf' || normalizedType === 'gcode.3mf'`
    predicate used in two branches — the capabilities check
    (`hasModel = isThreeMfFamily || 'stl'`, `hasGcode = isThreeMfFamily
    || 'gcode'`) and the plates-loading branch that previously hard-
    gated on `!== '3mf'` and would have returned setPlatesData(null)
    for the shared-folder file.

  - FileManagerPage.tsx adds `gcode.3mf` to the Preview-3D action gate
    and shares the gcode blue type-pill colour so sliced-output files
    are visually distinguishable from source 3MFs.

  The compound `gcode.3mf` classification on the backend is intentionally
  preserved — it carries useful "this is a sliced output" semantics that
  other UI surfaces could use later. The `canOpenInSlicer` and
  `sliceableType` checks at ModelViewerModal.tsx:269, 277-280 are
  deliberately left alone — a sliced output isn't openable in the slicer,
  and `sliceableType` already explicitly excludes `.gcode` and
  `.gcode.3mf` per the comment.

  Out of scope (separate Bambu-Studio format limitation, not a Bambuddy
  bug): Vlado's secondary observation that the upload-path 3D preview
  "shows only one plate" even though his project has 5 plates — Bambu
  Studio's .gcode.3mf export contains the model data and g-code for the
  active plate only, not the entire multi-plate project. The print
  picker enumerates plates via gcode_*.gcode entries inside the zip
  (a separate code path), which is why the user can still pick the
  plate at print time. The empty-bed fix is the data point that closes
  the user-visible bug.
2026-05-29 11:29:40 +02:00
maziggy 9347921378 fix(inventory): drop profile-only mismatch popup and clarify reconfigure intent (#1552)
Reporter assigned a spool to a slot whose stored slicer profile differed
  from the new spool's, got the Cancel / Assign Anyway popup, and was under
  the impression that confirming the popup just linked the spool in
  Bambuddy's DB without pushing the new profile to the AMS — i.e. that he
  then had to manually open Configure AMS Slot to fix it. The auto-push has
  actually been in place since the assign route existed:
  backend/app/api/routes/inventory.py::assign_spool calls
  apply_spool_to_slot_via_mqtt after upserting the SpoolAssignment row,
  which publishes ams_filament_setting + extrusion_cali_sel over MQTT, and
  backend/app/api/routes/spoolman_inventory.py::assign_spoolman_slot does
  the same for the Spoolman backend. The only short-circuit is when
  firmware reports the slot explicitly empty (tray_state in {9, 10}), in
  which case main.py::on_ams_change deferred-replays the configure once a
  spool appears. So the popup was friction without revealing what it did.

  Two changes:

  - frontend/src/components/AssignSpoolModal.tsx and
    spoolbuddy/AssignToAmsModal.tsx: profile-only mismatch no longer
    fires the popup. The condition becomes
    `if (materialMatchResult !== 'exact')` instead of
    `materialMatchResult !== 'exact' || !profileMatches`. The 'profile'
    member is dropped from the mismatchType union and its standalone
    branch in each popup render body is removed as dead code. Material
    mismatch still warns — Bambu firmware can refuse the print when the
    type is wrong.

  - Every firing warning (material, partial, material+profile,
    partial+profile) now appends one line via a new
    inventory.assignReconfigureNote i18n key:
    "The AMS slot will be reconfigured to use the spool's profile."
    Real translations across all 9 locales per
    feedback_translate_dont_fallback; parity script clean at 4999
    leaves per locale.
2026-05-29 10:38:11 +02:00
maziggy 632334953c fix(inventory): support transparent / clear filament end-to-end (#1545)
Reporter wanted to select a transparent filament colour in the spool
  editor; CMW-ISS confirmed on v0.2.5b1 that AMS-detected transparent
  spools were silently labelled "Black" in the filament-mapping dropdown
  because the colour name resolver dropped the alpha byte and the underlying
  RGB 000000 HSL-bucketed to "Black". Spoolman already supported 8-digit
  hex; the built-in inventory didn't.

  Eight collapsing sites fixed together so transparent reaches the user
  intact:

  - frontend/src/utils/colors.ts: hexToColorName / getColorName /
    resolveSpoolColorName / isLightColor short-circuit to "Clear" for
    alpha=00 before HSL bucketing or catalog lookup
  - frontend/src/utils/amsHelpers.ts::normalizeColor preserves the alpha
    byte when alpha < FF (normalizeColorForCompare unchanged so type/colour
    matching is unaffected)
  - frontend/src/components/spool-form/constants.ts: new
    { name: 'Clear', hex: '00000000' } preset in QUICK_COLORS
  - frontend/src/components/spool-form/ColorSection.tsx: hex draft accepts
    0-8 chars, commits at 6 (+FF) or 8 verbatim; blur pads 7-char to 8;
    selectColor passes 6-char as +FF / 8-char verbatim; isSelected matches
    on full rgba; swatch buttons paint a checkerboard for alpha=00
  - backend/app/api/routes/printers.py::get_available_filaments preserves
    the full rgba on both AMS and vt_tray branches (6-char dedup key
    unchanged)
  - backend/app/services/spoolman.py::parse_ams_tray drops the silent
    00000000 -> F5E6D3FF cream rewrite — the swatch renderer paints a
    checkerboard underlay for alpha < FF already (added in #1154), so the
    rewrite was hidden technical debt that made every AMS-detected
    transparent spool land in inventory as cream
  - backend/app/services/spool_tag_matcher.py::create_spool_from_tray
    short-circuits the colour-catalog lookup for alpha=00 and stores
    color_name="Clear" directly — otherwise an RFID-tagged transparent
    Bambu spool would resolve against the #000000 catalog row (or "Black"
    via the HSL fallback) before the frontend's resolver ever saw it
  - Two shared helpers in utils/colors.ts — getSwatchStyle(rgba) (style
    object: checkerboard for alpha=00) and spoolColorString(rgba)
    (8-char hex string for SVG fill) — applied to every simple-swatch
    site that would otherwise have rendered Clear spools as solid black:
    LabelTemplatePickerModal, SpoolBuddyInventoryPage (SpoolCircle + dot),
    SpoolBuddyAmsPage (both branches), SpoolBuddyWriteTagPage (4 sites),
    ForecastPanel, AssignToAmsModal, AssignSpoolModal (both branches),
    InventorySpoolInfoCard, TagDetectedModal, SpoolInfoCard, LinkSpoolModal,
    and the FilamentSwatch tooltip title fallback

  Intentionally NOT changed: native <input type="color"> keeps 6-char hex
  (can't pick alpha; onChange still emits +FF, correct); Spoolman's
  _find_or_create_filament strips alpha (Spoolman catalog is 6-char only);
  print_scheduler colour matching strips alpha (auto-mapping treats Clear
  as Black for slot compatibility); label_renderer prints "#RRGGBB" on the
  physical label (printers can't print transparency, swatch fill via
  _color_from_hex still honours alpha).
2026-05-29 10:15:53 +02:00
Timothy Trowbridge 052e928107 feat: add system theme detection (prefers-color-scheme) (#1501)
feat: add system theme detection (prefers-color-scheme)
2026-05-25 09:21:53 +02:00
maziggy 3b9633a178 ● feat(support): include sanitized connection / VP / log-health diagnostics in support bundle and bug report (#1506 follow-up)
The three diagnostic surfaces shipped earlier this month
  (6bc6a1d6 VP setup diagnostic, e222a0ef log-health scanner,
  ed31b8f4 connection diagnostic in the bug-report bubble) were
  only ever shown to the *user*. A bug report arriving in the
  maintainer's inbox carried raw logs but no diagnostic results —
  the user-visible "your X1C can't reach MQTT" finding never made
  it into the issue body, so the maintainer had to ask the user
  to re-run and paste.

  New `services/diagnostic_snapshot.collect_diagnostic_snapshot`
  runs all three concurrently with a per-probe 15 s wall-clock cap
  (so total ≈ max(per-cap), not sum — fleet size doesn't matter)
  and is fail-soft per probe: a crash inside one printer's check
  emits `{"printer_id": N, "error": "..."}` for that entry rather
  than nuking the whole snapshot. The snapshot is then added as a
  `diagnostics` top-level key by `_collect_support_info()`, so both
  flows (POST /support/bundle and POST /bug-report/submit via
  `support_info=...`) pick it up without their own changes.

  Private-data sanitization
  -------------------------
  The diagnostic schemas embed raw IPv4 in five field shapes that
  must not land in a submitted GitHub issue or a shared support ZIP:

    - PrinterDiagnosticResult.ip_address (top-level)
    - DiagnosticCheck.params.printer_ip (network-mode check)
    - DiagnosticCheck.params.host_ip (network-mode check)
    - VPDiagnosticResult per-check params.bind_ip (VP setup)
    - IPs embedded in log-health sample lines

  The first two carry the printer's own IP (already in the
  existing `collect_sensitive_strings` table via the Printer rows);
  host_ip and bind_ip are NOT in the DB so a sensitive_strings-only
  pass missed them.

  Fix: `_sanitize_recursive` walks the full snapshot tree, masks
  DB-known values with the same `[PRINTER]/[IP]/[SERIAL]/[ACCESS_CODE]`
  labels the log sanitizer applies (via the shared
  `collect_sensitive_strings`), then an IPv4-regex pass catches any
  IP the DB didn't cover — most importantly the Bambuddy host IP
  returned by `_get_host_ip()` and the VP `bind_ip` the user picked
  at setup. Recursive walk so arbitrary nested dicts/lists don't
  slip through future schema additions.

  Live-DB smoke test against the dev fleet: zero raw IPv4 instances
  in the serialized snapshot output; all five field shapes plus the
  embedded log samples render as `[IP]`.

  Progress indicators
  -------------------
  The bubble's "submitting" view and the System page's Download
  button now render a static four-line checklist showing what's
  running (printer connectivity → VP setup → log scan →
  submit / build ZIP). Static, not faked phase progress — we can't
  actually track server-side phases without SSE and the honest
  "here's what's happening" list communicates the longer wait
  without lying about percentage complete.

  9 new i18n keys, real translations in all 9 locales (no English
  fallback). parity script clean at 4993 leaves per locale.

  Tests: 6 new in test_diagnostic_snapshot.py
    - empty-input shape stable (the three top-level keys always present)
    - per-printer / per-VP result coverage (lists match input lengths)
    - fail-soft on a single-probe crash (other entries + log-health
      still complete)
    - timed_out marker when a probe exceeds the per-probe cap
      (test patches the cap to 0.05 s)
    - end-to-end IP sanitization across all five field shapes plus
      log-sample IPs, with a final JSON-serialize-and-regex sweep
      asserting zero raw IPv4 escapes anywhere in the result
    - concurrent execution proof (4 × 0.2 s probes complete in
      < 0.5 s; sequential would be 0.8 s)
2026-05-24 10:16:30 +02:00
maziggy 5f473801f8 fix(file-manager): list-view actions clipped + preview modal slice button now respects Slicer API setting
Two small bugs reported in chat:

  1. List-view actions column was clipped off the right edge. The grid
     template fixed the actions column at 80px - a sliced 3MF row renders
     7 icon buttons (Print, Schedule, Slice, 3D Preview, Download, Rename,
     Delete) which need ~220px. The wrapper's overflow-hidden (there for
     rounded corners) then swallowed everything past column 80px.

     Switched the trailing column from 80px to min-content so it sizes to
     the widest action strip across all rows, and replaced overflow-hidden
     with overflow-x-auto so narrow viewports get a scrollbar instead of
     vanished icons. Rounded corners survive because the border-radius is
     on the wrapper itself, not on the overflow box.

  2. The 3MF preview modal's "Open in Slicer" button always launched the
     external slicer (BambuStudio / Orca) regardless of Settings -> Workflow
     -> Slicer -> Use Slicer API. With the API enabled, the file-row Cog
     already opens the in-app Bambuddy SliceModal - the preview-modal
     button should match.

     Added an optional onSliceWithBambuddy callback to ModelViewerModal.
     When set AND settings.use_slicer_api is true AND we're in library mode
     on a sliceable type (.3mf/.stl/.step/.stp), the header button becomes
     a Cog labelled "Slice" and calls the in-app handler. Otherwise it
     falls back to the existing ExternalLink "Open in Slicer" button.
     FileManagerPage wires the callback to close the viewer and open the
     SliceModal with the same file, gated on the same isSliceableFilename
     + library:upload permission checks the row's Cog already uses. No new
     i18n keys - reuses slice.action ("Slice") that the file row already
     ships in all 9 locales.
2026-05-23 13:21:44 +02:00
maziggy 3058c5789b fix(slice): @BBL name fallback for users without slicer bundles (#1325 follow-up)
The first cut of #1325 swapped a stale hardcoded model table for
  bundle-based compatibility matching: a cloud / standard process or
  filament preset was classified by consulting the user's uploaded
  Slicer Bundles (.bbscfg). That works perfectly when bundles cover
  every printer in the user's cloud catalogue, and silently no-ops
  otherwise - every cloud preset resolves to 'unknown', nothing moves
  into "Other printers", and the dropdowns look identical to the
  pre-fix state. The reporter saw exactly this on a clean install
  with no bundles uploaded.

  Restored BambuStudio's `@BBL <token>` name convention as a third
  tier below the bundle path, but driven by the canonical backend
  PRINTER_MODEL_MAP - exposed via a new GET /slicer/printer-models
  route - rather than a manually-maintained frontend table. The
  matcher inverts the registry into short-code -> printer-fragment
  ("X1C" -> "X1 Carbon"), normalises whitespace + case so "A1 mini"
  and "A1 Mini" compare equal, and falls back to raw-token compare
  for models not yet in the registry (so a future "Q1" matches
  without a code change). Adding a new Bambu model still touches
  exactly the one backend file already listed in the Bambu Model
  Codes registry.

  Tests: 2 new in test_slicer_presets.py (route returns the full
  PRINTER_MODEL_MAP, route returns a copy not the live dict); 11
  new in slicerPrinterMatch.test.ts covering registry-driven X1C
  vs X1 Carbon, A1 vs A1 mini, H2D vs H2D Pro, P2S / H2C / H2S /
  X2D (which the original hardcoded list was missing), raw-token
  fallback, registry-not-loaded-yet degradation, and the
  precedence rules between compatible_printers / bundle / @BBL
  name. 38 slicer-presets + 36 slicerPrinterMatch + 34 SliceModal
  tests green; backend ruff clean; frontend build clean.
2026-05-23 12:52:04 +02:00
maziggy 4686d108ef feat(slice): cross-printer re-slicing across nozzle classes + multi-plate slice-all
Re-slicing a 3MF authored for a single-nozzle printer (X1C, P1S, A1, P2S)
  onto a dual-nozzle printer (H2D / H2D Pro) — or vice versa — previously
  failed with "G-code in unprintable area of multi-extruder printers" (the
  source's bed-coordinate layout lands in the H2D's per-nozzle dead zone)
  or, on multi-color projects, a hard SIGSEGV inside the slicer's ZFiller
  polygon-clipping. Earlier shipped a fail-fast 400 guard; this drop lifts
  it and actually does the conversion by forwarding the sidecar's existing
  --arrange flag when the source and target nozzle classes differ. BS
  itself reconciles the embedded project_settings.config against the new
  printer that way, the same way the GUI's "Switch Printer" operation
  does. The guard becomes a kept-for-compat no-op.

  Slice-all-plates added to the SliceModal: a checkbox for multi-plate
  sources sends plate=0 to the backend, which forwards --slice 0 to the
  BS CLI. Same-class slice-all produces one multi-plate output 3MF in a
  single sidecar call. Cross-class slice-all loops per plate (BS's
  --arrange is project-wide and would otherwise consolidate every plate's
  objects onto one bed) and merges the per-plate outputs into one
  multi-plate 3MF locally via the new merge_plate_3mfs helper. The toast
  shows "Plate 2 of 5 — Generating G-code (47%)" through the loop.

  Three side fixes surfaced during testing:
  - substitute_unused_plate_filaments overwrites unused-slot filaments
    with the slot-1 selection before slicing so BS's loaded-filament
    temperature validator doesn't reject a PLA print whose unused slot 2
    defaulted to ABS in the dropdown
  - re-sliced archive thumbnail now prefers the source's per-plate
    render (Metadata/plate_N.png) over the project-wide MakerWorld cover
    art, because BS CLI with --arrange skips writing a fresh per-plate
    preview
  - re-sliced archive bed_type now lifts from the sliced output's
    curr_bed_type onto the PrintArchive column the card actually reads

  Schema: SliceRequest.plate range relaxed from ge=1 to ge=0 to admit
  the "all plates" sentinel; SlicerApiService.slice_with_profiles /
  slice_with_bundle take an `arrange` parameter.

  Tests: 26 in test_slicer_3mf_convert (count / merge / substitute /
  extract), 3 in test_slicer_api (arrange wire format), 9 in
  test_library_slice_api (guard no-op, bed_type lift, thumbnail
  fallback, new cross-class slice-all loop integration test), 2 in
  test_archive_service (Auxiliaries fallback), 4 in SliceModal.test
  (plate=0 toggle), 2 in SliceJobTrackerContext.test (multi-plate toast
  prefix). 659 backend + 42 frontend green; backend ruff clean,
  frontend build clean, i18n parity green at 4984 keys × 9 locales.
2026-05-23 12:16:34 +02:00
maziggy ebba1385d3 feat(spoolbuddy-settings): show CPU load on the device card
The SpoolBuddy daemon already reports load_avg (1/5/15 min) and
  cpu_count in its heartbeat, but the Settings -> SpoolBuddy card only
  showed CPU temp / memory / disk / system uptime. Adds a fifth tile
  rendering the 1-min load with core count and percent-of-cores --
  e.g. "1.20 / 4 (30%)" -- next to the existing CPU temp tile. Falls
  back to a bare load number when cpu_count is missing and hides the
  tile when the daemon doesn't emit load_avg.

  settings.spoolbuddy.cpuLoad translated across all 9 locales.
2026-05-23 09:19:56 +02:00
maziggy e222a0ef0e feat(system): log-health scanner + Add/Edit-Printer setup pre-flight
Adds a passive log-health check that complements the active Connection
  Diagnostic. Scans Bambuddy's recent app log against a curated allowlist
  catalog of known failure signatures (rejected access code, FTPS :990
  timeout, FTPS TLS failure, flapping MQTT, unreachable camera, SQLite
  "database is locked" contention), dedupes and classifies each finding
  as layer8/environment/bug, and deep-links to the troubleshooting wiki.
  Sample log lines are sanitized before they leave the process. Exposed
  via GET /system/health and surfaced on two surfaces sharing one
  SystemHealthPanel component: a System Health section on the System
  page, and inline in the bug reporter when the form opens.

  The Add-Printer and Edit-Printer dialogs gained a setup-time pre-flight:
  saving runs the connection diagnostic and, on a failed check, warns with
  a "save anyway" escape hatch instead of silently saving a printer that
  will immediately show offline.

  Log read/parse/sanitize primitives extracted from routes/support.py into
  a shared services/log_reader.py (behaviour-preserving); affected support
  tests repointed accordingly.

  Tests: test_log_health.py (11), test_system_api.py (2 new),
  SystemHealthPanel + BugReportBubble + AddPrinterPreflight +
  EditPrinterPreflight (8 frontend). All strings translated across the 9
  locales. Backend ruff clean, full unit suite green, frontend build +
  eslint clean, i18n parity green.
2026-05-22 16:31:16 +02:00
maziggy 6bc6a1d683 feat(virtual-printer): setup diagnostic + one-click slicer-certificate export
Two recurring virtual-printer support pains, both on the Virtual Printers
  settings page.

  Setup check: a stethoscope action on each VP card runs a pass/fail/warn/skip
  checklist — VP enabled, services running, bind interface still exists, access
  code set, target printer (proxy mode), and a live TCP probe of the FTP / MQTT
  / discovery ports on the bind IP. start_server swallows per-service bind
  errors, so a service object can exist while nothing is listening; probing the
  bind IP from outside is the only reliable signal and it catches the common
  "VP not visible in the slicer" bind-IP-conflict and stale-interface cases.

  Slicer certificate: virtual printers present a TLS cert signed by a shared CA
  the slicer must trust. Until now users had to docker exec in and cat
  bbl_ca.crt. A "Slicer certificate" row on the settings card now offers Copy
  and Download (bambuddy-virtual-printer-ca.crt) plus the SHA-256 fingerprint.
  GET /virtual-printers/ca-certificate returns only the public certificate; the
  CA private key never leaves the backend. The CA is generated on demand so the
  button works before the first VP is enabled.

  Backend:
  - services/virtual_printer/diagnostic.py — run_vp_diagnostic + port probes
  - schemas/virtual_printer.py — VPDiagnosticResult
  - CertificateService.get_ca_certificate_info() + manager helper
  - routes: GET /virtual-printers/ca-certificate, /{vp_id}/diagnostic

  Frontend:
  - VirtualPrinterDiagnosticModal.tsx; stethoscope button on VirtualPrinterCard
  - caCert row on VirtualPrinterList; utils/clipboard.ts (shared copy w/
    non-secure-context fallback + downloadTextFile), de-duplicating the
    existing FQDN-copy logic
  - vpDiagnostic.* + virtualPrinter.caCert.* across all 9 locales

  9 backend unit tests + 4 route integration tests + 6 frontend tests.
  Backend ruff clean, frontend build clean, i18n parity green.
2026-05-22 13:24:35 +02:00
maziggy ed31b8f4a4 fix(ui): collapse bug-report connection diagnostic for multi-printer setups
The "Report a Bug" panel scans every configured printer on open and shows
  connection problems inline. The first cut rendered a full ~6-row checklist
  per problem printer, stacked — on a large fleet that pushed the description
  box and Submit button below the fold in the max-w-md / max-h-80vh panel.

  The diagnostic section is now a compact summary: one "N of M printers have
  connection issues" line plus the affected printers as collapsed rows
  (healthy printers count toward M, render no detail). Each row expands on
  demand to that printer's full checklist via the shared Collapsible widget;
  a single problem auto-expands since that's the case where inline detail is
  wanted without a click. Panel height is now fixed regardless of fleet size.

  - BugReportBubble.tsx: diagnostic query pairs each result with the printer
    name (new DiagnosticEntry type); render block uses Collapsible rows
  - i18n: new bugReport.diagnosticSummary ({{problems}}/{{total}}) replaces
    static diagnosticHeading; diagnosticIntro reworded count-neutral — all 9
    locales
  - 2 new BugReportBubble tests (collapsed-then-expand; single auto-expand)
2026-05-22 12:48:21 +02:00
maziggy 4925b4c830 fix(slice): re-slice correctness — model label, honest errors, filament usage, nozzle guard
Five follow-up fixes to cross-printer re-slicing, all surfaced while
  testing archive re-slices.

  1. Re-sliced archive now records the printer it was sliced FOR.
     slice_and_persist_as_archive copied sliced_for_model from the source
     archive, so re-slicing X1C->H2D still showed "X1C sliced". Read it
     from the freshly-sliced 3MF's parsed metadata instead, falling back
     to the source only when absent.

  2. Real slicer rejections are surfaced instead of silently masked.
     _run_slicer_with_fallback retried with the 3MF's embedded settings on
     any sidecar 5xx — including genuine content rejections (object off
     the bed, incompatible filament temps), which "succeeded" only by
     re-slicing for the source's original printer. A new
     _slicer_rejection_message detects the slicer's own error string and
     surfaces it as a 400; the embedded-settings fallback is kept only for
     true CLI crashes.

  3. A failed slice opens an error modal, not a 3s toast. The slicer's
     reason is actionable and a toast hides it before it can be read. New
     AlertModal (acknowledge-only); SliceJobTrackerContext shows it on a
     failed job. New slice.failedTitle key in all 9 locales.

  4. Sliced files no longer report "0 g" filament usage. The sidecar
     doesn't always populate the X-Filament-Used-* headers;
     ThreeMFParser._parse_gcode_header now also reads the slicer's own
     "total filament weight/length" from the G-code header, and both
     slice-persist paths fall back to it when the sidecar reports 0.

  5. Nozzle-class re-slice guard. Re-slicing across the single-nozzle <->
     dual-nozzle boundary (e.g. X1C -> H2D) fails BambuStudio's
     multi-extruder validation; both slice routes now reject it up front
     with a clear 400. The dual-nozzle model classification — previously
     an inline tuple duplicated across start_print and the K-profile
     routes — is centralized into DUAL_NOZZLE_MODELS / is_dual_nozzle_model
     in printer_models.py, consumed by all three sites and the guard.

  Full cross-nozzle-class re-slicing (dual-nozzle project_settings
  reconciliation) remains separately tracked.

  Tests: _slicer_rejection_message, _canonical_printer_model,
  guard_nozzle_class_reslice, is_dual_nozzle_model, the G-code-header
  filament parse, AlertModal, and end-to-end slice-API coverage including
  an X1C-archive-to-H2D 400. Backend ruff + i18n parity clean; frontend
  build clean.
2026-05-22 12:34:08 +02:00
maziggy 50d1984820 fix(printer): stop the File Manager polling the printer over FTPS every 30s (#1480)
FileManagerModal ran its file-listing query with refetchInterval 30000,
  opening a fresh FTPS connection (full TLS handshake) to the printer every
  30s while the modal was open. On fragile controllers like the P1S that
  load tipped MQTT, FTP and the camera into simultaneous timeouts. Drop the
  interval; the listing still refreshes on open, directory change, after
  upload/delete, and via the manual Refresh button.

  Also log STL thumbnail failures with a traceback (exc_info) so a
  data-specific "str / str" TypeError can be located from a support bundle.
2026-05-22 08:26:50 +02:00
maziggy e0247fc6a6 fix(slicer): filter process/filament presets by uploaded bundles, not preset names (#1325)
The process dropdown still mixed @BBL P2S presets into an X1C list:
  slicerPrinterMatch matched cloud/standard presets by parsing the
  @BBL <model> name suffix against a hardcoded model-code allow-list
  that was missing P2S, H2C and X2D — so those presets resolved to
  "unknown" and stayed in the main list instead of "Other printers".

  Drop both hardcoded model tables. Compatibility now comes from the
  user's uploaded Slicer Bundles: a bundle is scoped to one printer and
  lists the presets it ships, so a preset matches a printer exactly when
  some bundle for that printer contains it. New models are covered the
  moment their bundle is uploaded.
2026-05-22 08:01:42 +02:00
maziggy 17e39921bb fix(pwa): add in-app install button and self-host the Inter font (#1460)
Bambuddy installed as a PWA on desktop but not on Android. Two causes:

  - Chrome for Android removed the automatic install banner in Chrome 108.
    With no beforeinstallprompt handler, Android had no install path. New
    InstallAppButton captures the event and re-fires it from the sidebar.
  - index.css pulled Inter from fonts.googleapis.com: breaks offline, trips
    CSP, and the service worker answered the failed cross-origin request
    with cached index.html. Inter is now self-hosted; the SW skips all
    cross-origin requests and caches the font; CSP drops the Google hosts.
2026-05-22 07:40:44 +02:00
maziggy e738645b0d feat(slicer): filter slice profiles by printer + default from the 3MF (issue #1325)
The Slice dialog listed every process / filament preset regardless of
  the chosen printer, and always defaulted to the first listed preset
  rather than what the 3MF was prepared with (#1325).
  Matching uses the slicer's own compatible_printers list for imported
  (local) presets and falls back to the "@BBL <model>" name suffix for
  cloud / standard presets. Compatibility-unknown presets are never
  hidden.

  Defaults: the printer and process dropdowns default to the preset
  names embedded in the source 3MF's project_settings.config when those
  presets are available; the per-slot filament and process pre-picks
  prefer a printer-compatible preset, and switching the printer re-picks
  any selection left incompatible.

  - UnifiedPreset gains compatible_printers, exposed for the local tier
  - plates endpoints return embedded_printer / embedded_process
  - new frontend util slicerPrinterMatch.ts; extract_embedded_presets_from_3mf
  - slice.otherPrinters added across all 9 locales
2026-05-21 13:49:20 +02:00
maziggy 7eba29624b feat(slicer): filter process & filament profiles by selected printer (issue #1325)
The Slice dialog listed every process / filament preset regardless of
  the chosen printer (#1325). Picking a printer profile now filters both
  dropdowns to compatible presets; presets resolving to a different Bambu
  model move into a trailing "Other printers" group.

  Matching uses the slicer's own compatible_printers list for imported
  (local) presets, and falls back to the "@BBL <model>" name suffix for
  cloud / standard presets where no compatibility metadata is available,
  so all three tiers are covered. Compatibility-unknown presets (custom
  or untagged) are never hidden. The pre-pick and printer-switch paths
  follow the same rule.

  - UnifiedPreset gains compatible_printers, exposed for the local tier
  - new frontend util slicerPrinterMatch.ts with the matching logic
  - slice.otherPrinters added across all 9 locales
2026-05-21 13:33:13 +02:00
maziggy 76e327f4a1 feat: connection diagnostic for "printer won't connect" triage
A triage review of the last 200 closed issues found ~1/3 were
  user-side setup errors — printer not in LAN developer mode, blocked
  ports, Docker bridge networking, wrong access code, cross-subnet —
  each costing a multi-round-trip support exchange.

  Add a Connection Diagnostic that runs those checks automatically:
  - backend/app/services/printer_diagnostic.py: TCP probes of MQTT
    8883 / FTPS 990 / RTSPS 322, LAN developer mode, Docker network
    mode, printer/host subnet match, MQTT credential class; each
    check returns pass/fail/warn/skip with a localized fix.
  - Routes: GET /printers/{id}/diagnostic (saved printer) and
    POST /printers/diagnostic (pre-save Add-Printer flow).
  - ConnectionDiagnostic.tsx: modal + shared checklist, surfaced from
    the printer card actions menu, an offline-printer quick button,
    the Add-Printer dialog, and a new System-page section.
  - The in-app bug reporter scans configured printers when the form
    opens and always shows the result inline — a healthy confirmation,
    or the detected problem and its fix.
  - config.yml troubleshooting link repointed to the rendered wiki
    page; bug_report.yml gains a diagnostic checkbox.

  Diagnostic strings translated across all 8 locales. Backend service
  unit tests (15) + frontend modal tests (3). Ruff clean, frontend
  build clean, i18n parity green.
2026-05-21 11:00:59 +02:00
maziggy 016e01781a fix(profiles): keep the Local Profiles search bar mounted when nothing matches (#1470)
The search bar was rendered only when totalCount > 0, but totalCount is
  the sum of the post-filter preset lists. A query that matched nothing
  dropped it to 0 and unmounted the search bar along with the columns,
  so the user couldn't clear the query without a page refresh.

  Gate the search bar on hasAnyPresets (pre-filter count) instead, so it
  stays visible as long as any preset is imported. Split the empty state:
  "No local presets yet" when nothing is imported, vs a new "No presets
  match your search" message when a query filtered everything out.
2026-05-21 08:51:55 +02:00
maziggy ed27b27adb feat(slice): cross-printer re-slicing — drop the gate, the banner, and the dead plumbing
Step 0 empirical test on 2026-05-20 disproved the "CLI cannot re-slice a
  3MF for a different printer" assumption: feeding an 18-color H2D-bound
  Trent900.3mf to the X1C bundle via /slice produces valid X1C G-code in
  1.8s, with bed (256x256), kinematics, nozzle count, machine_start_gcode,
  and bed_exclude_area all coming from the target bundle.

  - SliceModal: drop !printerMismatch from isReady; remove the banner and
    the sourcePrinterModel / printerProfileName / printerMismatch state
    entirely. Cross-printer slicing is now indistinguishable from a normal
    slice; the picker already shows the target printer.
  - Remove slice.printerMismatch from all 8 locales.
  - API cleanup: drop source_printer_model from /library/files/{id}/plates
    and /archives/{id}/plates responses, drop the field from
    frontend/src/types/plates.ts (PlateMetadata + LibraryFilePlatesResponse),
    delete extract_source_printer_model_from_3mf from threemf_tools.py and
    its 6 unit tests. Zero remaining consumers.
  - i18n discipline cleanup in SliceModal.tsx (same drop): strip every
    inline English defaultValue / positional fallback from t() calls (22
    sites). Add slice.bundle / slice.bundleNone / slice.bundleAllRequired
    to all 8 locales — they had no entry in any locale file and were being
    served from the inline English fallback for every non-English user.
  - Tests: rewrite the mismatch-warning test to assert "no banner, Slice
    enabled" when models differ (regression guard); delete 2 obsolete
    tests covering gate states that no longer exist.
2026-05-20 12:21:33 +02:00
Seb 14919a80a5 Merge pull request #1440 from Person2099/fix/filament-override-ams-mapping-dispatch
fix: compute AMS mapping from force-colour overrides when 3MF reqs unavailable
2026-05-20 11:25:45 +02:00
maziggy fcee1a6f7e Fix: Print Activity heatmap buckets by local date, not UTC date (#1446)
PrintCalendar.tsx had three instances of the same UTC-shortcut anti-pattern:

  1. Bucketing input dates via `date.split('T')[0]` — gives the UTC day
     while the cell tooltip rendered local via `toLocaleDateString`. Same
     data, two renderers, only one was tz-correct. Reporter on CDT (UTC-5)
     saw evening prints jump to "tomorrow's" cell.

  2. Per-cell lookup key built via `day.toISOString().split('T')[0]`. The
     `day` Date objects produced by the calendar-generation loop are
     local-tz (constructed via `new Date()` + `setDate`), so `toISOString`
     shifted them back to UTC before the lookup — would have re-broken the
     join even after the bucketing fix.

  3. "Today" ring comparison used `new Date().toISOString().split('T')[0]`
     too — at 23:00 local the ring would have moved to UTC-tomorrow's cell.

  Fix adds a `localDateKey(input: string | Date): string` helper to
  utils/date.ts that wraps parseUTCDate and formats via the local-tz
  getters (`getFullYear` / `getMonth` / `getDate` with two-digit padding),
  returning a stable comparable YYYY-MM-DD. PrintCalendar.tsx uses it in
  all three spots so the buckets, the cell join, and the today ring share
  the same local-tz axis as the user's tooltip label.

  Backend stays UTC. Bucketing is a presentation concern and the browser
  already knows the user's tz.

  Stragglers flagged for follow-up: StatsPage.computeDateRange builds
  dateFrom/dateTo for backend stats queries using getUTC* getters, so a
  "this week" picked at 23:00 local on Sunday in CDT sends UTC-Monday-based
  ranges to the backend. Fixing it properly also needs the backend to
  filter on a tz-shifted UTC range, and Bambuddy has no user-tz setting
  model today. localDateKey is in place for reuse when that work lands.
2026-05-20 09:45:28 +02:00
maziggy a4e8ae3ab4 Fix: SpoolBuddy Write-Tag page honours Spoolman mode + complete ID surface (#1439)
Two bugs surfaced by @flom89 in the same thread:

  (1) The Write-Tag page hardcoded api.getSpools and friends regardless of
  inventory backend. Users in Spoolman mode saw internal spools they never
  created and a successful tag write would have bound the NFC tag to the
  wrong backend (the backend write-tag route is mode-aware; the frontend
  was driving it with the wrong IDs). Fix follows the InventoryPageRouter
  pattern: detect spoolmanMode from getSpoolmanSettings, gate the spool
  fetch on spoolmanModeReady to avoid the initial wrong-backend request,
  and branch all 6 API call sites (list, autocomplete, untag, K-profile
  save, single create, bulk create — the bulk variant returns a different
  envelope shape so the duck-typed check from SpoolFormModal is mirrored).

  (2) The spool ID was missing from three more SpoolBuddy components
  beyond the first round of #1439 work — SpoolInfoCard (the dashboard's
  right-side found-tag panel — the "main screen" view the reporter named),
  InventorySpoolInfoCard, and SpoolBuddyAmsPage's assigned-spool block.
  Same #1385 pattern (#<id> in muted small monospace with shrink-0).
2026-05-20 09:19:25 +02:00
maziggy 235a189e31 Fix: 3D preview no longer freezes the page on complex multi-part 3MFs (#1412)
The browser-side 3MF parser in ModelViewer.tsx runs entirely on the main
  thread (JSZip extract + DOMParser + vertex/triangle iteration), and Bambu's
  external-component shape chains one of these per part. Multi-color parted
  statues from MakerWorld can spend tens of seconds in straight-line JS,
  during which the modal close button can't fire and the browser shows
  "page unresponsive".

  Add nextTick() yields at four hot spots:
  - every 20 000 vertex iterations inside parseMeshFromDoc
  - every 20 000 triangle iterations inside parseMeshFromDoc
  - the matching loops in parse3MF's direct-mesh path
  - once per top-level <object> iteration in parse3MF
  - once per <component> iteration in parse3MF

  This doesn't make parsing faster — it surrenders control to the browser
  between batches so the modal stays interactive. Proper Web Worker refactor
  is a tracked follow-up.
2026-05-20 08:31:09 +02:00
maziggy 1677efb2c6 fix(labels): replace incorrect ams_30x15 preset with correct AMS holder sizes (#1426)
Reporter — the same person who originally requested the labels
  feature in #809 — discovered that the ams_30x15 preset's 30x15 mm
  dimension didn't actually fit any variant of the MakerWorld AMS
  Filament Label Holder (model 752566) it advertised. Two new
  presets replace it:

  - ams_holder_74x33 (74 x 33 mm) matches the printable label STL
    bundled in the MakerWorld project
  - ams_holder_75x55 (75 x 55 mm) fits the cardstock-insert variant
    the reporter validated on bench

  Both cross the 20 mm height threshold so they land in the roomy
  layout branch — swatch on the left, QR on the right, multi-line
  text (brand, material, hex code, spool ID) in the middle. The
  old 30x15 mm preset couldn't fit a QR code; the new ones do.

  No DB migration: the preset name was never persisted. Callers
  scripting the old ams_30x15 value get a clean 422 at the route's
  Literal validator with the new valid values listed.

  i18n: replaced inventory.labels.templates.ams.{label,hint} with
  amsHolderSmall and amsHolderLarge across all 8 locales with real
  translations; parity guard cleaned of the stale English-fallback
  cognate entries. Parity holds at 4856 leaves per locale.

  Tests: backend label renderer + integration tests cover both new
  presets; LabelTemplatePickerModal test updated for the 6-button
  grid and the new template value in the API-call assertion.
2026-05-19 13:14:03 +02:00
maziggy 9bcaafbc1a fix(assign-spool): refresh printer status after assignment so card updates without manual Force-refresh (#1414 follow-up)
Reporter (@snozzlebert on A1 mini external slot) saw the
  Filament page Location column update correctly after Assign
  Spool, but the Printer card kept showing "Empty slot" until
  they manually pressed Force-refresh. MQTT command was going
  through fine; gap was client-side.

  AssignSpoolModal's two onSuccess callbacks invalidated the
  inventory / slot-assignment queries but never invalidated
  ['printerStatus', printerId] and never issued a pushall. For
  Bambu RFID-tagged spools the printer echoes the new tray_type
  on its own; for non-RFID spools and A1 mini external slots
  the firmware doesn't volunteer that state change.

  Added nudgePrinterRepublish() helper called from both onSuccess
  paths: api.refreshPrinterStatus(printerId) to issue the pushall
  (same call the Force-refresh button uses) plus invalidate
  printerStatus so the refetch lands. Refresh failures are
  swallowed — the assignment itself succeeded; a stale-cache
  nudge that didn't go through shouldn't surface as "assign
  failed". Same pattern as ConfigureAmsSlotModal since #1235,
  with the extra pushall because assign-spool affects firmware-
  side state.
2026-05-19 11:41:53 +02:00
maziggy e2df0fc601 fix(ams): physically-empty slots report state=9 and render distinctly from reset slots (#1322 follow-up)
Two-part fix for the #1322 follow-up by @RosdasHH.

  Data layer.
  The previous narrow heuristic in printer_manager.py only caught
  the bare {"id": N} payload firmware sends right after a printer
  restart. In steady-state operation — and on the more common
  post-Reset-Slot path on P1S and A1 Mini BMCU — firmware sends a
  populated payload and signals emptiness via the tray_exist_bits
  bitmask. We already parse that bitmask and use it to wipe stale
  tray_type / tray_color / tag_uid fields, but never touched the
  state field, so downstream readers (printers.py API serializer,
  inventory.py's tray_state in {9, 10} short-circuit, AMS card)
  saw state: null and had to guess from absent payload fields.

  Fix lifts tray["state"] = 9 (int — not "9"; inventory.py:1358
  uses == not `in {...}` so a string would silently miss and the
  reporter's deadlock would come back) to the outer `if not
  slot_exists` branch, so the bitmask path now writes the
  canonical "no spool" code for every empty slot regardless of
  stale fields. The narrow heuristic in printer_manager.py:797
  stays as belt-and-suspenders for any MQTT path that doesn't
  flow through _handle_ams_data.

  UI layer.
  With the data flow now consistent, the AMS slot card renders
  physically-empty slots distinctly from reset slots, per
  reporter's mockup. New helper getEmptySlotKind(tray) returns
  "physical" (state ∈ {9, 10}), "reset" (any other empty state),
  or null (loaded). The inline label below the slot circle reads
  "Empty" for physical and "Reset" for reset; pre-fix both showed
  an em-dash. FilamentSlotCircle gains an emptyKind prop that
  picks a quieter dashed border colour for reset slots so the
  visual hierarchy reads loaded > reset > physically empty.
  EmptySlotHoverCard gains a kind prop and switches between
  "Empty slot" and "Slot reset — no spool assigned".
2026-05-19 11:21:03 +02:00
maziggy fcd1801aab feat(inventory): sort-by-colour toggle in label-print modal (#1410)
Reporter asked for an option to order printed label sheets by colour
  instead of spool number so multi-colour rolls group related colours
  together physically on the sheet.

  Backend (labels.py) already preserves caller order, so this is
  frontend-only. LabelTemplatePickerModal gains a "Sort: By ID / By
  colour" chip pair next to the material filter. Colour mode converts
  each spool's rgba to HSL: chromatic colours (s >= 0.1) cluster in
  bucket 0 ordered by hue 0..360, achromatic colours go in bucket 1
  ordered by lightness so neutrals trail the rainbow black -> white.
  Stable tiebreaker on spool ID.

  Also fixes a latent issue exposed by the same code: the submit was
  always re-sorting selected IDs ascending, which would have clobbered
  any frontend order. Submit now uses sortedSpools.filter().map() so
  the visible order flows through to the PDF.

  Session-only state; toggle resets to "By ID" each time the modal
  opens. 3 new i18n keys translated across all 8 locales (parity 4852
  leaves). 2 new modal tests pin the colour-sort payload order and
  the unchanged ID-default. 17 modal tests + i18n parity + build all
  green.
2026-05-18 12:53:07 +02:00
Chanakyan ed5af66839 feat(inventory): show spool ID in edit modal and AMS hover card (#1385) (#1402)
feat(inventory): show spool ID in edit modal and AMS hover card (#1385)
2026-05-18 12:34:42 +02:00
maziggy 134847a3bd feat(camera): in-app diagnostic for "Connection lost" (#1395 follow-up)
Step 2 of the camera architecture overhaul agreed after #1395. When
  the camera viewer hits its error state OR before a print at any
  time, a Diagnose button runs a staged check against the printer and
  renders the result inline: which stage failed, how long it took,
  and a translated remediation hint. Cuts off the "user opens a
  'camera broken' ticket → ask for support bundle → triage" loop at
  the user's screen.

  Backend

  - New `backend/app/services/camera_diagnose.py` orchestrator with
    CameraDiagnoseResult / CameraDiagnoseStage dataclasses.
  - New POST /printers/{id}/camera/diagnose route in camera.py.
  - Stages:
      tcp_reachable — TCP socket open to 322 (RTSP) / 6000 (chamber)
        with 3 s timeout. Distinguishes timeout, refused, and host-
        unreachable into distinct summary codes so the frontend can
        show a precise remediation (firewall vs LAN-only off vs
        wrong IP).
      first_frame — captures one JPEG end-to-end via the existing
        capture_camera_frame_bytes pipeline. Auth + RTSP handshake +
        first keyframe collapse into one stage; the user-facing
        answer is the same regardless of which sub-layer failed.
  - Live-stream shortcut: when a viewer is currently watching the
    camera with a buffered frame < 10 s old, the diagnostic skips
    the real test and returns live_stream_active_healthy. Opening a
    fresh socket would kick the live viewer off on single-camera-
    connection firmwares (the #1348 reconnect-storm trigger), so we
    trust the real-world evidence instead.
  - Response surfaces protocol, port, and profile name for support
    triage — lets us ask "what does your modal say?" instead of
    "send the support bundle".

  Frontend

  - New CameraDiagnoseModal renders one row per stage with green-
    check / red-X / grey-skipped icons, the per-stage duration in
    ms, a remediation banner styled by overall status, and a Run
    again button.
  - Two entry points:
      1. The viewer's error overlay grows a Diagnose button next to
         Retry. Retry stays the primary action; Diagnose is the
         escape hatch for users who can't see what's wrong.
      2. A stethoscope icon in the viewer's always-visible control
         bar, between Refresh and Fullscreen. Pre-flight testing
         ("did my firmware update break the camera?", "is the
         camera up before I send a print?") doesn't require waiting
         for the stream to fail first.
  - Also lifted the previously-hard-coded "Camera unavailable" /
    "Retry" strings into camera.unavailable / camera.retry so the
    error UI is fully translated alongside the new keys.
2026-05-18 10:52:02 +02:00
maziggy 8d52c713ef fix(inventory): hex colour field accepts character-by-character typing (#1407)
Reporter typed into the Add Spool modal's hex colour input and only
  the first character stuck - everything after that defaulted to "0"
  with no way to override except by pasting the full hex.

  Pre-fix, the #1055 fix aggressively normalized the input to a valid
  8-char rgba on every keystroke. After typing the first char the
  controlled input value snapped to e.g. "A00000", the browser placed
  the cursor at the end, and the user's next keystroke landed at
  position 7. The #1055 fix's 7-char branch then truncated that byte
  away, leaving the form state unchanged - so the user appeared to
  type nothing.

  Fix splits the typing-state from the backend-state:

  - The hex input gets its own `hexDraft` useState holding 0-6 chars
    freely. Typing one char at a time works naturally because the
    controlled value matches what the user typed.
  - `updateField('rgba', ...)` fires only when the draft reaches a
    complete 6-char RGB (commits as `<6chars>FF`). Below that, the
    form state stays untouched - no mid-keystroke snap.
  - On blur, a partial 1-5 char draft is right-padded with `0` and
    committed. Keeps the #1055 invariant: anything reaching the
    backend is exactly 8 hex chars matching /^[0-9A-F]{8}$/.
  - A `useEffect` resyncs the draft when an external action (color
    picker, swatch click, edit-mode load) changes the canonical hex.
  - Paste of 7-/8-char strings truncates to the leading RGB. Bambu
    filaments are opaque; the UI never exposed an alpha affordance,
    so dropping the (undocumented) paste-with-alpha case is fine.
2026-05-18 09:57:07 +02:00
maziggy e61a454a0f fix(inventory): "Reset usage to 0" preserves remaining in both modes (#1390)
Reporter saw a 544 g spool jump to 1000 g after pressing the eraser.
  "Spools and remaining weights are not changed" - the dialog promised
  this; the implementation did the opposite. Root cause was an
  architectural conflation: `weight_used` did double duty as the
  resettable "consumed since tracking started" counter AND as the basis
  for the displayed remaining (`label_weight - weight_used`), so zeroing
  it correctly cleared the stat but unavoidably reset remaining to full.

  Spoolman has separate `used_weight` and `remaining_weight` fields, so
  the API call there was correct - but Bambuddy's frontend was also
  computing remaining as `label_weight - weight_used` for Spoolman
  spools (ignoring Spoolman's real `remaining_weight` field), so the
  same visual bug bit there too. Inventory-mode parity required fixing
  both halves in one drop.

  Internal mode

  - New `weight_used_baseline` column (Float DEFAULT 0) on `spool`.
  - Reset stamps `baseline = weight_used` and leaves `weight_used` alone.
  - Displayed consumed = `weight_used - baseline`; remaining =
    `label_weight - weight_used` (unchanged).
  - Subsequent prints continue to grow `weight_used`, so the resettable
    counter naturally tracks post-reset delta and remaining keeps
    decrementing across the reset.

  Spoolman mode

  - `_map_spoolman_spool` now reads Spoolman's `remaining_weight` field
    and returns a synthetic `weight_used = label - remaining` so the
    frontend's remaining calc matches Spoolman's real stored value;
    `weight_used_baseline = synthetic - real_used_weight` so the consumed
    counter (`weight_used - baseline`) matches Spoolman's `used_weight`.
  - Fallback path (no `remaining_weight` set) preserves the old behavior.
  - Related fix: `update_spool` (Spoolman PATCH) was deriving the default
    `weight_used` from `used_weight`, so editing unrelated fields AFTER
    a reset would patch Spoolman with `remaining_weight = label - 0 =
    label`, trampling the real value. Now derives from
    `remaining_weight` so non-weight edits preserve physical state.

  Frontend

  - `InventoryPage` `totalConsumed` aggregate switched to
    `Math.max(0, weight_used - (weight_used_baseline ?? 0))`.
  - `ForecastPanel` `computeDeltaRate`, `totalUsedG`, and the per-spool
    "consumed" table cell got the same treatment so forecast and
    inventory aggregates stay coherent across a reset.
  - `?? 0` keeps pre-migration installs rendering correctly until
    `init_db()` runs the idempotent ALTER TABLE.

  Migration

  - `ALTER TABLE spool ADD COLUMN weight_used_baseline REAL DEFAULT 0`
    via `_safe_execute` - SQLite and Postgres both accept it; verified
    end-to-end on Postgres 16.
2026-05-18 08:51:27 +02:00
maziggy 48a7024b96 security(github-backup): refuse to save against a non-private repository
While auditing real-world Bambuddy backup repos on GitHub I found
  several left public. That's a serious leak: the settings backup only
  filters bambu_cloud_token and auth_secret_key, so mqtt_username,
  mqtt_password, ha_token, prometheus_token, bambu_cloud_email,
  external_url, and the printer access codes (via K-profiles) were going
  to whatever visibility the user picked.

  Hard guard at every save and re-checked on every push:

  - POST /github-backup/config and PATCH /github-backup/config (when URL,
    token, or provider changes) run a connection test internally and
    return 400 unless is_private comes back True.
  - run_backup() re-checks before each scheduled or manual push, so a
    repository that flipped from private to public gets a clear
    "Backup aborted: the target repository is no longer private" failure.

  Each provider's test_connection now returns is_private (GitHub /
  Gitea / Forgejo read data.private, GitLab reads visibility=="private";
  "internal" is treated as non-private). None means "couldn't determine"
  and is also rejected -- safer to fail closed.

  Frontend renders visibility inline on Test Connection: green check when
  private, red warning panel listing every credential at risk when public,
  yellow when unknown.

---

  ui(github-backup): show save-failure messages inline on the card

  The new "repository is not private" rejection message is ~250 characters
  listing every credential the backup carries (MQTT password, HA token,
  Prometheus token, Bambu Cloud email, printer access codes), which clips
  badly in a toast.

  Both the initial-setup save and the debounced autosave now stash the
  backend's error message into a saveError state and render it as a red
  inline banner above the test-result block, with whitespace-pre-wrap so
  the full message stays readable. The banner clears on success, on the
  next save attempt, and when the user starts editing URL / token / provider
  -- the three fields whose changes invalidate the privacy check -- so it
  doesn't linger after the user has already addressed the cause.

  Short success toasts (Settings saved, Token updated, Backup enabled) are
  unchanged.
2026-05-17 15:30:05 +02:00
maziggy 74dcaf5142 refactor(settings): Spool Catalog now identical in internal and Spoolman modes
Drop the Spoolman-mode hijack that replaced the local spool tare catalog
  with an inline filament editor — two unrelated concepts that should never
  have shared a card. Spool Catalog now renders the same way in both modes;
  Spoolman users edit filament name and spool_weight in Spoolman's own UI.

  Also eliminates the GET /spoolman/inventory/filaments 400 probe that fired
  on the Filament settings page whenever Spoolman was disabled.

  - frontend/src/components/SpoolCatalogSettings.tsx rewritten (752 -> 444 lines)
  - frontend/src/components/SpoolWeightUpdateModal.tsx deleted (orphan)
  - SpoolCatalogSettings test file rewritten to match the simplified component
  - PATCH /spoolman/inventory/filaments/{id} backend route left in place
2026-05-17 13:48:05 +02:00
maziggy bff240e90a fix(uploads): pre-flight validation for 3MF/gcode + visible upload errors (#1401)
Reporter @iitazz uploaded slicer output to Bambuddy, clicked Print,
  and the printer rejected every job with "Printing stopped because
  the printer was unable to parse the 3mf file". Support bundle showed
  the stored library file ended in .gcode (not .gcode.3mf), and
  background_dispatch.py appends ".3mf" to filenames that don't
  already end in .gcode.3mf/.3mf — so raw gcode shipped to the printer
  named .gcode.3mf and the firmware's 3MF parser choked. Same shape
  also surfaced as "File is not a zip file" on Bambuddy's own plate
  parser.

  New validate_print_file_upload() helper in library.py runs at upload
  time:
    - Reject filenames ending in .gcode (but not .gcode.3mf) with a
      clear message — Bambu printers need .gcode.3mf zip containers,
      not raw gcode.
    - For .3mf / .gcode.3mf uploads, verify body starts with PK\x03\x04
      (ZIP magic); reject otherwise pointing at the slicer's "Export
      Plate Sliced File" action.

  Applied to every relevant upload route: POST /library/files (covers
  File Manager + printer-card drag-drop), POST /archives/upload,
  POST /archives/upload-bulk (rejects per-row so one bad file doesn't
  abort the batch), POST /archives/{id}/source, POST /archives/upload-source.
  Runs after _resolve_upload_destination so folder-permission errors
  (403 readonly, 400 missing-path, 409 collision) still take precedence.
  STL / image / other non-print uploads bypass the validator.

  FileUploadModal frontend fix: the modal auto-closed after every
  batch regardless of per-file results, so a 400 rejection was captured
  but invisible. Now:
    - Errors render inline as red text under the file row instead of
      as a hover-only title tooltip.
    - Modal stays open if any file ended with status='error', so the
      user can read the backend's remediation message before closing.
    - Successful-only batches still auto-close as before.

  UploadModal (bulk archive) was already showing inline errors and
  not auto-closing — no change needed there.
2026-05-17 13:09:58 +02:00
maziggy 6f2cec5eb3 feat(smart-plugs): auto-off after AMS drying completes (#1349)
Reporter Kyobinoyo asked for the equivalent of the existing
  print-finish auto-off but triggered when AMS drying ends.

  Two new SmartPlug columns: auto_off_after_drying (default false),
  off_delay_after_drying_minutes (default 10 — AMS chamber is hot
  post-cycle so longer cooldown than the print-finish default of 5).
  SQLite + Postgres migrations both idempotent.

  Trigger lives in BambuMQTTClient — per-AMS _previous_dry_times
  tracks the dry_time > 0 → 0 falling edge and fires a new
  on_drying_complete(ams_id) callback. Plumbed through
  PrinterManager.set_drying_complete_callback to
  SmartPlugManager.on_drying_complete(printer_id, db), which walks
  linked plugs and respects the per-plug toggle. Catches queue,
  ambient and manual drying identically because it observes firmware
  state, not scheduler intent.

  Frontend: single "Auto Off After Drying" toggle + delay input on
  the smart plug card, next to the existing print-finish auto-off
  section.

  Per-AMS plug routing (separate plug for AMS only, per-AMS targeting
  on dual-AMS printers) deferred — Bambuddy's plug model is
  plug→printer, so the trigger fires whenever any AMS on the linked
  printer finishes a cycle.
2026-05-17 12:26:42 +02:00
maziggy e7045597bc fix(archives): bulk and auto purge now honour the soft / hard delete choice from #1343 (#1390 follow-up)
Reporter IndividualGhost1905 followed up after the #1378 / #1343
  backfill landed and pointed at the next inconsistency: the per-
  archive delete dialog has had a "Also remove this print from Quick
  Stats" checkbox since #1343, but the "Purge Old" button and the
  scheduled daily auto-purge sweeper both ignored that choice and
  hard-deleted unconditionally. From the user side this looked like
  "automatically deleted from statistics without any warning" — half-
  true, and the inconsistency was real either way.

  The actual current shape (before this fix):

    - POST /archives/purge -> archive_purge_service.purge_older_than
      -> ArchiveService.delete_archive (hard). Archive row dropped.
      Linked PrintLogEntry rows have ON DELETE SET NULL so they
      survive as orphans with archive_id=NULL. Quick Stats keeps the
      filament / cost / energy contribution because the log rows are
      still there, but the archive-list-iterating widgets (Filament
      Trends, By Material, Color Distribution, Printer Stats) lose
      the row, and Time Accuracy loses its join target. Visibly
      inconsistent.
    - Scheduled _maybe_run_auto_purge -> same code path, same effect.
    - Single-archive DELETE /archives/{id} -> already takes
      purge_stats=true|false (default false=soft) and routes either
      soft_delete_archive (keeps everything, flips deleted_at) or
      deletes PrintLogEntry rows first + hard-deletes archive.

  The fix threads the same purge_stats flag through every bulk surface
  with soft as the default, matching the single-archive default:

  Backend:
    - archive_purge_service.purge_older_than(..., purge_stats=False)
      -> per-row soft_delete_archive when False, per-row
      PrintLogEntry deletion + delete_archive when True. Each runs in
      its own session (same pattern the sweeper already used).
    - preview_purge gains the same kwarg so the eligible-count
      matches what an actual run would touch: soft mode excludes
      already-soft-deleted rows, hard mode counts them as eligible
      for promotion.
    - get_settings / set_settings now persist archive_auto_purge_stats
      (default False). _maybe_run_auto_purge reads it on every tick.
    - ArchivePurgeRequest / ArchivePurgeResponse / ArchivePurgeSettings
      schemas extended.
    - Route /archives/purge accepts the body flag, /purge/preview
      accepts the query param, /purge/settings GET + PUT echo the
      setting.

  Frontend:
    - "Purge old archives" modal: new "Also remove from statistics"
      checkbox under the preview, unchecked by default. Plumbed into
      the preview query key + the execute mutation.
    - Settings -> Archives auto-purge card: matching toggle next to
      the days slider, disabled when auto-purge itself is off.
    - api.previewArchivePurge / api.executeArchivePurge accept the
      flag; ArchivePurgeSettings type gains purge_stats.
    - All 8 locales (en, de, fr, it, ja, pt-BR, zh-CN, zh-TW) get
      new purgeStatsLabel / purgeStatsHint / purgeStatsDescription
      keys, plus rewritten effect / warning copy in archivePurge and
      archiveAutoPurge to reflect that the default no longer
      "permanently removes from the database" but instead hides the
      row + removes files while keeping Quick Stats intact. i18n
      parity check clean: 4814 keys across all 8 locales, no fallback.

  Behaviour change for existing users on auto-purge: the sweeper used
  to hard-delete by default and now soft-deletes by default. After
  the upgrade those installs start *preserving* more data in Quick
  Stats rather than losing it — safer direction of the two, but worth
  the explicit call-out. Anyone who wants the old behaviour ticks the
  new toggle once and it persists.
2026-05-17 10:54:28 +02:00
maziggy 856b849ffa fix(stats): per-event aggregation so reprints add to Quick Stats instead of overwriting (#1378)
Statistics now aggregate over PrintLogEntry (one row per print event,
  the same table backing the global Print Log) rather than PrintArchive
  (one row per file). A reprint creates a new PrintLogEntry instead of
  overwriting the source archive's runtime fields, so:

  - a 100 g successful print + a 10 g failed reprint correctly sums to
    110 g / 2 prints / 1 successful / 1 failed in Quick Stats and the
    Prometheus /metrics endpoint (previously the failed reprint silently
    replaced the source archive's data; totals dropped from 100 g to 10 g)
  - the archive's card cost/energy_kwh are preserved on reprints (only
    the first run writes them); per-run actuals live on PrintLogEntry
  - failed/cancelled/stopped reprints record partial-aware filament: sum
    of tracked spool deltas when inventory is set up, else estimate
    scaled to progress%, else None — prevents the full slicer estimate
    from inflating totals on a print that stopped at 10 % progress

  PrintLogEntry gains six columns: archive_id (nullable FK, ON DELETE
  SET NULL so log entries survive archive deletion preserving #1343
  soft-delete-vs-stats decoupling), cost, energy_kwh, energy_cost,
  failure_reason, created_by_id. Idempotent SQLite + Postgres migrations.

  New per-archive surface:

  - archive list response carries run_count / last_run_at /
    total_filament_actual_grams / successful_run_count / failed_run_count
    via a single batch JOIN, no N+1
  - new GET /archives/{id}/runs endpoint returns every PrintLogEntry for
    the archive (ARCHIVES_READ permission, newest-first ordering)
  - archive cards render an orange "N prints" badge for archives with
    more than one run; clicking the badge opens a dedicated PrintLogModal
    with date/status/duration/filament/cost columns plus failure_reason
    under failed runs. Also reachable via the context menu's new "Print
    Log" entry (works for single-run archives too), and embedded at the
    top of the Edit Archive modal for context.

  The purge_stats=true delete path now hard-deletes linked PrintLogEntry
  rows up front so the archive's contribution truly leaves the totals;
  without it, ON DELETE SET NULL would orphan the runs and leave them
  counting toward stats.
2026-05-16 11:34:40 +02:00
maziggy d6364646f8 feat(auth): manual LDAP user provisioning from the UI (#1298)
Reporter @Fuechslein flagged that disabling LDAP auto-provision left admins
  with no UI path to onboard new users — the create-user form had zero LDAP
  awareness and the only workaround was hand-editing the database.

  Add a Local / LDAP tab toggle to the create-user modal (hidden when LDAP is
  disabled). The LDAP tab is a debounced directory search (≥2 chars, 300ms)
  that returns up to 25 matches via the service-account bind, annotated with
  already_provisioned so existing usernames render disabled. Clicking
  "Provision user" re-resolves via the service bind and creates the user
  through the same _provision_ldap_user helper the auto-provision login path
  uses, so group mapping, default-group fallback, and email sync are identical
  regardless of which path created the user.

  The picker component is shared across all four create-user modal paths
  (UsersPage basic + advanced, SettingsPage basic + advanced).

  Two ldap3 schema-check workarounds were needed for OpenLDAP installs:
  - Open the search connection with check_names=False so ldap3 doesn't reject
    the cross-schema OR filter (sAMAccountName/displayName are AD-only)
  - Request attributes=["*"] because ldap3's build_attribute_selection
    validates each named attribute against the server schema regardless of
    check_names, and only the * wildcard is in its hard-coded exclusion list

  Login/lookup paths keep check_names=True so typos in user_filter still fail
  loudly.

  Backend
  - New routes: GET /auth/ldap/search, POST /auth/ldap/provision (both gated
    by USERS_CREATE; 503 details include ldap3 exception class + message)
  - Extract _open_service_connection + _extract_user_info helpers so
    authenticate_ldap_user, lookup_ldap_user, and search_ldap_users share the
    bind and attribute-extraction logic

  Frontend
  - New LdapUserPicker component (debounced search, result list, provision
    mutation, already-provisioned guard, error surface)
  - Tab toggle wired into UsersPage and SettingsPage modals, plus
    CreateUserAdvancedAuthModal props
  - 14 i18n keys added to en.ts (other locales fall back to English)
2026-05-15 12:05:43 +02:00
maziggy f7c94cbc7b fix(inventory): add CF/GF to subtype dropdown on the Add/Edit Spool form (#1345)
Adding a third-party PETG-CF spool via the Material=PETG + Subtype=CF
  flow (same shape as the existing PETG HF) hit a missing option: KNOWN_VARIANTS
  in spool-form/constants.ts didn't list CF or GF. Users had to type it
  freehand into the "create new" tail of the dropdown.

  Added both: CF (matches PETG-CF / PLA-CF / ASA-CF / PA-CF) and GF (the
  natural pair for ABS-GF / PA6-GF). parsePresetName is unaffected — its
  materials list is iterated longest-first, so cloud presets like
  "Bambu PETG-CF Black" still resolve to material=PETG-CF with empty
  afterMaterial.
2026-05-15 09:47:21 +02:00