Commit Graph
674 Commits
Author SHA1 Message Date
maziggy d82f4e032f fix(inventory): handle PFCN cloud preset IDs in assign-via-MQTT (#1648)
Reporter on an H2D + Polymaker PLA Matte spool noticed that assigning
  the spool from the Dashboard left the slicer's filament dropdown
  showing "unknown", but clicking Configure right after made the
  slicer recognize it correctly. "Configure" felt like a mandatory
  follow-up step rather than a refinement.

  Bambu cloud uses three preset-ID shapes:
    GFS…   — Bambu official cloud preset
    PFUS…  — cloud user-created preset
    PFCN…  — cloud shared / partner preset (Polymaker's "(Custom)"
             Bambu Lab H2D variants ship this prefix)

  apply_spool_to_slot_via_mqtt only routed GFS and PFUS through the
  cloud-detail lookup that extracts the underlying filament_id. PFCN
  slipped past the cloud-lookup branch, fell into the local-preset
  int() parse path, raised ValueError, dropped into
  normalize_slicer_filament which returns any P-prefix unchanged, and
  the raw PFCN landed in tray_info_idx. The printer's calibration
  table can't index that, so the slicer rendered "unknown". The
  Configure modal rescued every assign because it does its own
  getCloudSettingDetail and writes the resolved filament_id.

  Extend the cloud-detail-lookup branch (inventory.py:129) and the
  discard safety net (inventory.py:223) to include PFCN alongside
  GFS/PFUS. Three behaviours fall out:

    * Cloud-authenticated: the real filament_id from
      detail["filament_id"] ships as tray_info_idx (Polymaker PLA
      Matte resolves to GFL05).
    * Cloud unavailable: raw PFCN discarded, the slot reuses an
      existing valid P-prefix preset if material matches.

  Source comment now lists all three cloud-ID shapes so the next time
  Bambu invents a new prefix the maintainer doesn't have to re-derive
  the structure from a bug report.
2026-06-06 10:17:09 +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 4851f54595 feat(file-manager): split "All Files" into internal-only + External views (#1621)
Reporter linked a NAS and the auto-imported files drowned their own
  Bambuddy uploads in the "All Files" sidebar listing. There was no filter
  to escape it — only per-folder clicks. Restore the pre-external semantics:
  "All Files" now lists managed-storage files only. The combined
  across-every-external view moves to a new sibling sidebar entry,
  "External", that only appears when at least one external folder is linked.

  Backend: GET /api/v1/library/files gains internal_only and external_only
  query flags. Filter is on LibraryFile.is_external. Both flags set is a
  400, not a silent pick-one.

  Frontend: new topLevelView state on FileManagerPage (default internal);
  the query passes the scope only when selectedFolderId is null. Mobile
  selector dropdown uses __top:internal / __top:external sentinels so the
  same state round-trips through option values. Empty-state copy
  distinguishes internal-empty from external-empty.
2026-06-05 10:11:03 +02:00
maziggy 54389a54aa fix(library): MakerWorld URL import honours external folder destinations (#1645)
Reported and root-caused by @needo37. Importing a model via the MakerWorld
  URL-download feature into a writable external folder (e.g. SMB/NFS-mounted
  NAS) saved the 3MF into Bambuddy's internal managed library dir, not the
  external mount. The file card showed in the File Manager under the
  external folder, but the bytes never landed on the NAS, and the on-disk
  copy was UUID-renamed so a find by the original basename matched nothing.

  Root cause was save_3mf_bytes_to_library at backend/app/api/routes/library.py:422:
  it accepted folder_id but never loaded the folder, never inspected
  is_external / external_path, hardcoded the destination to
  get_library_files_dir() with a UUID name, and left the LibraryFile row
  with is_external=False. So the row's folder_id pointed at the external
  folder while its bytes and is_external flag both said "managed/internal".
  Same class of bug as #1112, which had been fixed for the multipart-upload
  and move paths but never applied to this byte-import path.

  Fix mirrors the multipart-upload path directly:
  - Load target_folder from folder_id when non-None.
  - Feed it to _resolve_upload_destination(target_folder, filename), which
    already returns (dest, is_external) and enforces the 403-read-only /
    400-unwritable-or-missing / 409-collision rejections.
  - Write bytes to dest (real filename for external, UUID for managed).
  - Persist the row with file_path=_stored_file_path(dest, is_external)
    and is_external=is_external.

  The route-layer read-only guard at makerworld.py:256-260 is preserved -
  it returns the friendlier error before the upstream download burns
  bandwidth - and _resolve_upload_destination's identical check stays as
  defence-in-depth for any future caller that skips the route gate.
  Thumbnails continue to live under the managed get_library_thumbnails_dir()
  regardless of the 3MF's location, matching the upload path.
2026-06-05 09:29:53 +02:00
maziggy 9554ebd05d refactor(inventory): rename /reset-usage to /reset-consumed-counter to match what it actually does (issue #1644)
The old endpoint name implied that calling it would drop weight_used to
  0. In practice it only stamps weight_used_baseline = weight_used so the
  Inventory page's "Total Consumed" widget (weight_used - baseline) reads
  0 going forward, while remaining (label_weight - weight_used) is
  preserved. Calling the endpoint via curl and seeing weight_used
  unchanged in the JSON response is confusing.

  New paths:
  - internal: /api/v1/inventory/spools/{id}/reset-consumed-counter
             /api/v1/inventory/spools/reset-consumed-counter-bulk
  - spoolman: /api/v1/spoolman/inventory/spools/{id}/reset-consumed-counter
             /api/v1/spoolman/inventory/spools/reset-consumed-counter-bulk

  Behaviour is unchanged in both modes; internal stamps the baseline
  directly, Spoolman-mode PATCHes upstream used_weight=0 and the
  _map_spoolman_spool read mapping reconstructs the same "displayed
  consumed = 0, remaining unchanged" Bambuddy-visible shape. Parity
  between modes was already in place and is preserved.

  The Spoolman-client method reset_spool_usage keeps its name because it
  describes what is sent upstream to Spoolman, not what Bambuddy's
  endpoint promises to callers.

  Frontend:
  - api.resetSpoolUsage / bulkResetSpoolUsage (and Spoolman variants)
    renamed to resetSpoolConsumedCounter / bulkResetSpoolConsumedCounter.
  - Button labels: "Reset usage to 0" -> "Reset counter" / "Reset all
    counters" (short, unambiguous); tooltips and confirm-modal bodies
    still spell out the full semantics.
2026-06-05 09:22:05 +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 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 cc25cbe774 fix(archives): #1608 suppress card time-accuracy badge for multi-run archives
compute_time_accuracy in routes/archives.py compares the archive row's
  own started_at / completed_at (which reflect the latest run only)
  against archive.print_time_seconds (which the #1593 parser fix
  correctly stores as the sum across plates). For a 3-plate file printed
  plate-by-plate the ratio is ~300%, producing a "+188%" card badge that
  means nothing — apples to oranges. The 5-500% sanity band catches
  truly broken values but lets this deterministic N×100% shape through.
  Reporter's archive #65 was 3 plates over 9 runs.

  compute_time_accuracy gains an optional run_aggregate argument and
  returns both actual_time_seconds and time_accuracy as null when the
  aggregate reports more than one logged run. The frontend already falls
  through to print_time_seconds for the time display
  (actual_time_seconds || print_time_seconds) and gates the badge on
  time_accuracy being truthy, so multi-run archives now show the slicer
  estimate with no badge. Single-run archives keep the original
  behaviour verbatim.

  The fix is applied at every call site that renders an archive card:
  archive_to_response now threads run_aggregate through, and the three
  endpoints that previously didn't load the aggregate (archives.py
  search fast-path and FTS path, single-archive PATCH, and
  projects.list_project_archives) now batch-load it via the existing
  _load_run_aggregates helper.

  The stats endpoint's per-run accuracy aggregation at archives.py:940
  already uses PrintLogEntry.duration_seconds with its own 50-200% band
  filter and is untouched.
2026-06-03 10:14:57 +02:00
maziggy 673001e3cd fix(maintenance): #1596 persist wiki_url on Custom Type create + correct
inline mutation type

  POST /api/v1/maintenance/types hard-coded the MaintenanceType
  constructor and silently dropped `wiki_url`, so the Documentation URL
  field disappeared after save. PATCH worked because it uses
  `data.model_dump(exclude_unset=True) + setattr`, which is why editing
  a freshly-created type DID save the URL — masking the bug under any
  "save then immediately fix it" retest. Reporter @BurntOutHylian
  pre-triaged the issue to the exact constructor call at
  routes/maintenance.py:206-213; fix is the missing `wiki_url=data.wiki_url`
  argument.

  Frontend nit from the same report: MaintenancePage.tsx:1131's
  `updateTypeMutation` declared `data: Partial<{ name; default_interval_hours;
  interval_type; icon }>` — omitting `wiki_url`. The value reached the
  API correctly at runtime because `api.updateMaintenanceType` accepts
  `Partial<MaintenanceTypeCreate>` (which has wiki_url), but the inline
  type lied about the payload shape. Extended the inline `Partial<{...}>`
  to include `wiki_url?: string | null`. Pure type fix — no runtime change.
2026-06-02 14:58:10 +02:00
maziggy a837a3acbd ● fix(library): #1600 thumbnail extraction for external-folder .gcode.3mf
files + unify file_type classification across ingest paths

  #1600: external-folder sliced outputs landed
  with no thumbnail. Cause: four backend ingest paths classified
  LibraryFile.file_type differently for the same .gcode.3mf family.
  upload / ZIP-extract / in-process used os.path.splitext()[1] which
  returns .3mf for foo.gcode.3mf and stored file_type="3mf", matching
  the thumbnail-extraction gate at library.py:1467 (file_type == "3mf").
  External-folder scan explicitly detected the compound and stored
  file_type="gcode.3mf" — preserving "sliced output" identity — but
  then skipped both the "3mf" gate and the "gcode" gate, so the file
  landed with thumbnail_path = None. Same compound-extension drift that
  bit #1543 in the 3D preview, in a surface that audit didn't trace
  back to.

  Unified fix:

  - New classify_file_type(filename) helper in routes/library.py is the
    single source of truth. Returns "gcode.3mf" for sliced outputs and
    ext[1:] otherwise.
  - Applied to every ingest path: upload (line 1704), ZIP-extract
    (1998), external-folder scan (the bug site — the manual compound
    check is replaced), and in-process save_3mf_from_bytes (471, used
    by MakerWorld import).
  - External-scan thumbnail gate widened to
    `if file_type in ("3mf", "gcode.3mf"):` — a .gcode.3mf IS a 3MF zip
    with Metadata/plate_1.png; ThreeMFParser doesn't care about the
    trailing extension.
  - gcode-download endpoint at GET /library/files/{id}/gcode had the
    same drift in reverse: gate was `elif file.file_type == "3mf":` so
    a row stored with file_type="gcode.3mf" (the external-scan path's
    pre-unification behaviour, and the canonical going forward) got
    rejected with HTTP 400. Widened to the same compound-aware tuple.

  One-shot DB migration in core/database.py::run_migrations backfills
  existing legacy rows:

    UPDATE library_files
       SET file_type = 'gcode.3mf'
     WHERE file_type = '3mf'
       AND LOWER(filename) LIKE '%.gcode.3mf'

  Idempotent (post-update rows no longer match the file_type='3mf'
  predicate, so re-runs at every boot are no-ops) and dialect-neutral
  (LOWER + LIKE are identical under SQLite and Postgres). Without the
  backfill, users would have a permanent split state: old uploads at
  '3mf', new uploads at 'gcode.3mf' — which would double-bucket sliced
  outputs in the dashboard stats query at line 4615 and show two
  entries in the file-manager filter dropdown for the same conceptual
  type.

  Frontend untouched. FileManagerPage.tsx and ProjectDetailPage.tsx
  already accept both '3mf' and 'gcode.3mf' per the #1543 fix. After
  the migration the DB only contains canonical values, so the legacy
  '3mf' branches in the frontend become dead code for sliced files —
  they stay as defence-in-depth in case any future ingest path I
  missed reverts to the legacy classifier.
2026-06-02 14:51:08 +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 1e08c25a9f fix(stats): #1593 multi-plate parser + per-run project rollup + carry-over system totals + accuracy band
Two stacked causes under-reported multi-plate prints in the project
  rollup and the archive card.

  Root cause 1 - parser only read plate 1.

  ThreeMFParser._parse_slice_info used root.find(".//plate") and pulled
  prediction / weight from that one element. Any multi-plate file's
  archive-level print_time_seconds / filament_used_grams reflected
  plate 1 alone. The /plates endpoint already looped findall and was
  correct, which is why the plate carousel showed the right numbers
  while the archive card was wrong.

  Fix: loop every <plate> and sum prediction + weight. Per-plate
  concepts (plate_number, _plate_index, printable_objects) only set
  when there's exactly one plate - for multi-plate exports the
  archive represents all plates and a single index doesn't apply at
  the file level. bed_type keeps the first plate's value as a
  best-effort default. Malformed prediction / weight on individual
  plates skip cleanly rather than poison the sum.

  Root cause 2 - project rollup aggregated PrintArchive, not the
  per-run log.

  compute_project_stats and list_projects quick-stats summed
  PrintArchive.print_time_seconds / filament_used_grams / cost /
  energy_* WHERE project_id. A reprint reuses the source archive row
  and writes a new PrintLogEntry, so 3 sequential runs collapsed to 1
  archive - and that archive's numbers were already plate-1-only from
  cause 1. The Archive Print Log path was already correct because it
  drove off print_log_entries (archives.py:420 comment).

  Fix: both compute_project_stats and the list_projects quick-stats
  block inner-join print_log_entries -> print_archives WHERE
  archives.project_id. total_archives becomes COUNT(PrintLogEntry.id),
  failed_prints counts runs in failed/aborted/cancelled/stopped,
  completed_items is SUM(PrintArchive.quantity) for runs where
  status='completed', time/filament/cost/energy from PrintLogEntry.
  Orphan log rows (archive_id IS NULL post archive deletion) are
  excluded by the inner join.

  Same-shape fixes carried forward (no follow-ups per project rule):

  system.py system-info totals: total_print_time / total_filament
  had the same bug shape - summed PrintArchive directly so reprints
  collapsed to one row. Now sums PrintLogEntry.duration_seconds /
  filament_used_grams. The semantic shift is also a correctness
  improvement: the field now reflects time the printer actually spent
  printing, not slicer-estimated time.

  archives.py time-accuracy metric: estimate / actual per run where
  estimate = PrintArchive.print_time_seconds. Post-parser-fix
  multi-plate archives have file-level estimate but per-run actual =
  one plate, so ratio = N x 100% for an N-plate file. The calc now
  clamps each row to the [50%, 200%] plausibility band before
  contributing to the printer-level average; single-plate accuracy
  (the case the metric is designed for) stays fully included.

  Backfill: users with AMS spool tracking - the reporter's case - have
  per-run filament_used_grams from the tracked spool delta, so stats
  become correct immediately. Users without tracking fall back to the
  archive estimate and undercount until they reprint. Archive card
  still reads PrintArchive.filament_used_grams directly so old
  multi-plate archives keep plate-1-only numbers until reslice -
  forward-only as the reporter accepted.
2026-06-02 13:35:06 +02:00
maziggy c9bc5eb4e9 fix(webhook): #1584 PrinterState dataclass attribute access in status / stop / cancel
webhook.py treated printer_manager.get_status() return as a dict and
  called .get(...) on it. The return is a PrinterState dataclass
  (backend/app/services/bambu_mqtt.py), so the call raised AttributeError
  and Starlette surfaced it as a generic 500 for every printer with a
  status row. Non-existent printers correctly returned 404 because the
  early "Printer not found" branch fired before the crash.

  Reporter's repro matched exactly: id 1 (existing printer) returned 500,
  id 2 and id 3 (no row) returned 404. Verified end-to-end against a live
  PG-backed instance with the reporter's key shape — same 500 before the
  patch, 200 with the correct payload after.

  8 crash sites across 3 routes:
    - webhook_get_printer_status   GET  /printer/{id}/status      5 sites
    - webhook_stop_print           POST /printer/{id}/stop        2 sites
    - webhook_cancel_print         POST /printer/{id}/cancel      2 sites

  Every status.get("X", default) replaced with status.X if status else
  default. Pydantic response schema unchanged; PrinterState's dataclass
  defaults cleanly cover the "registered but never connected" branch so
  the status route now returns 200 with connected=false, state=null
  rather than crashing.
2026-06-02 12:57:51 +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 396e9aa09e security: harden path-traversal class across routes + services; fifth CI backstop
Two attacker-controlled strings were being joined to library_dir with no
  resolve + containment check in the project ZIP import endpoint:

    - linked_folders[*].name from the request's project.json
    - per-entry zf.namelist() paths from the ZIP itself

  An absolute path in either field collapsed the join (Path("/lib") / "/etc"
  becomes Path("/etc") because pathlib discards the left side when the right
  is absolute) and the next write_bytes landed wherever the attacker chose.

  Adjacent finding from the routes audit: GET /archives/{id}/photos/{filename}
  had NO validation on filename and FileResponse-served arbitrary paths -
  the DELETE counterpart at least gated on the photos membership check.

  Adjacent finding from the services audit: ArchiveService.attach_timelapse
  wrote archive_dir / filename where filename ultimately came from a printer's
  FTP listing (compromised-printer threat model) or the /timelapse/select
  query param. A malicious printer that exposes a directory entry with ..
  segments could write the timelapse outside the archive directory.

  New backend/app/utils/safe_path.py::safe_join_under(parent, *parts) is the
  single source of truth: rejects empty / null-byte / absolute parts up-front,
  joins under parent, resolves both sides, asserts is_relative_to. Returns the
  resolved canonical path on success, raises HTTPException(400) on escape, or
  PathTraversalError when http=False (for service-layer callers that need to
  match a non-HTTP return contract).

  Wired into the import vectors, both archive photo handlers, and the
  attach_timelapse service. The full audit sweep inspected every Path/Name
  join in backend/app/api/routes/ AND backend/app/services/ - 25 route-layer
  sites + 8 service-layer sites confirmed safe and tagged with
  # SEC-PATH-OK: <reason> so future audits trust the inline guard at a glance.

  Fifth CI backstop test_route_path_arithmetic_is_safe_joined_or_marked
  AST-walks both layers and fails the build on any <dir-like>/<bare variable>
  join that doesn't either route through safe_join_under or carry the marker.
  The services layer is in scope because it receives values verbatim from the
  routes AND from external sources Bambuddy has no control over (the printer
  FTP-listing case above).

  SECURITY.md gets a fifth rule + a fifth row in the CI test mapping table;
  the rule now names the printer FTP-listing case explicitly so future
  services-layer audits set the right expectation.

--------------

  fix(library): suppress warning storm when bulk-uploading ZIPs of empty/stub STL files

  Uploading a ZIP of stub or empty STL files (e.g. the 24-byte
  "solid test\nendsolid test" shape) produced one WARNING per file in
  stl_thumbnail.py::generate_stl_thumbnail. The warnings were technically
  correct - trimesh returns a valid Mesh with zero vertices, the safeguard
  matches, and the function returns None so the library entry is still
  created without a thumbnail - but the volume turned a successful upload
  into thousands of WARNING lines in the journal.

  Two changes:

  1. The per-file "Failed to load STL or empty mesh" message in
     stl_thumbnail.py is now logger.debug instead of logger.warning. It's
     a per-file content observation, not an actionable error; the caller
     already handles None correctly. The branch now catches the rare
     "large enough but trimesh still can't parse it" case, visible in
     debug logs without spamming production.

  2. New module constant MIN_USABLE_STL_BYTES = 200 (smallest binary STL
     with one triangle is 134B, smallest ASCII ~150B; 200 is a safe floor
     below any real STL). The three thumbnail call sites in library.py
     (extract_zip_file, single-file upload, _backfill_external_stl_thumbnails)
     pre-skip files below this size before calling generate_stl_thumbnail.
     Stubs never enter the trimesh pipeline at all.

  Behavior is unchanged for real STLs: any file >=200 bytes runs through
  the existing pipeline, MAX_VERTICES still triggers simplification at
  100k vertices for the 256x256 thumbnail render, large files still get
  thumbnails.

------------

  fix(stl-thumbnail): silence matplotlib first-import noise (writable cache + font_manager log level)

  On first STL upload, three matplotlib-internal log lines surfaced:

    WARNING [matplotlib] /opt/claude/.config/matplotlib is not a writable directory
    INFO    [matplotlib.font_manager] Failed to extract font properties from NotoColorEmoji.ttf
    INFO    [matplotlib.font_manager] generated new fontManager

  The writable-dir warning fired because Bambuddy's $HOME isn't writable for
  matplotlib's default config path; matplotlib fell back to /tmp/matplotlib-XXX
  which lost the font cache on every host reboot, so font_manager rebuilt it
  each cold start - producing another batch of INFO lines.

  Fix is two small additions in stl_thumbnail.py before the matplotlib import:

  1. New _configure_matplotlib_cache() sets MPLCONFIGDIR to
     settings.base_dir/.cache/matplotlib (mkdir if missing) so the cache
     persists across container restarts and the writable-dir warning never
     fires. Respects an externally-set MPLCONFIGDIR so operators who chose
     their own path aren't overridden. Best-effort with a debug fallback if
     settings can't be imported or the mkdir fails.

  2. logging.getLogger("matplotlib.font_manager").setLevel(WARNING) at module
     import demotes the per-font INFO scan that fires when font_manager
     builds its cache cold. Real font warnings (>= WARNING) still surface.

  3 new tests: font_manager logger at WARNING after module import;
  _configure_matplotlib_cache creates the directory under base_dir and sets
  MPLCONFIGDIR; an externally-set MPLCONFIGDIR is preserved verbatim.
  5516 backend tests green, frontend gates clean.
2026-06-02 12:12:54 +02:00
maziggy be15a375a6 fix(oidc): #1569 populate User.email from standard 'email' claim when email_claim is preferred_username
When an operator configures `Email Claim = preferred_username` (e.g. Authentik) the
  primary `_resolve_provider_email` correctly rejects the identity value as non-email
  shaped and returns None, leaving auto-provisioned users with `email=None` even though
  the same token carries a valid standard `email` claim.

  Add a narrow fallback in the auto-create-users branch only: when
  `provider.email_claim != "email"` and the primary returned None, resolve the standard
  `email` claim with the same Fall A/B shape + email_verified enforcement and use it for
  `User.email` and `UserOIDCLink.provider_email`.

  The auto-link-existing-accounts gate is left on the primary `provider_email`, so the
  GHSA Fall-B / Fall-C guards remain intact - the fallback never feeds account matching.
2026-06-02 10:23:21 +02:00
maziggy b7d7c82501 fix(security): WebSocket auth gate + audit-driven hardening sweep 2026-06-02 10:02:17 +02:00
maziggy ec51394196 fix(security): GHSA-r2qv-8222-hqg3 — allowlist API-key permissions (CVSS 9.9)
API-key permission gates went from a 17-entry admin denylist with the three
  documented scope flags (can_read_status / can_queue / can_control_printer)
  enforced only inside /api/v1/webhook/* to an explicit per-Permission
  allowlist consulted by every dependency:

    - core/auth.py: _APIKEY_SCOPE_BY_PERMISSION maps every non-admin
      Permission to one scope flag on APIKey; unmapped = 403.
      _check_apikey_permissions now takes the api_key and checks the flag.
    - require_any_permission_if_auth_enabled + require_ownership_permission
      were returning None for any valid key with zero scope check; both now
      invoke _check_apikey_permissions and fail closed.
    - Two new scope flags on api_keys: can_manage_library (LIBRARY_UPLOAD /
      UPDATE_OWN / DELETE_OWN / MAKERWORLD_IMPORT) and can_manage_inventory
      (INVENTORY_CREATE / UPDATE / DELETE / FORECAST_WRITE — required by
      SpoolBuddy kiosks). Default TRUE, backfilled from can_queue so existing
      "queue-only" keys keep working and hardened "read-only" keys do not
      silently gain writes.
    - CLOUD_AUTH now routed through can_access_cloud for defence-in-depth
      alongside the existing _cloud_api_key_gate.
    - Migration column-existence check (_api_keys_column_exists) gates the
      backfill so user-edited values are never overwritten on restart.

  Structural drift backstop: test_every_permission_has_a_classification fails
  CI on any new Permission added without an explicit scope mapping —
  prevents the denylist-shape regression that grew the prior surface.

  Backend 5469 tests green; ruff clean. Frontend build green; i18n parity
  green across 9 locales (5005 leaves each, +6 new keys). Wiki permissions
  table + allowlist callout + upgrade notes updated.
2026-06-02 08:28:24 +02:00
maziggy 597762685c fix(virtual-printer): #1558 Send pre-flight + slicer-surface audit bundle
#1558: cached-as-base push_status only forced gcode_state=IDLE while letting
  the real printer's live-progress fields (mc_percent, stg_cur, layer_num, ...)
  leak through. Bambu Studio's Send pre-flight read them as busy and refused.
  The cached branch now overrides the activity-field set the same way it
  already overrode storage indicators (#1228) and protocol fields.

  Same bundle ships a multi-round VP audit that found adjacent bugs in the
  same family:

  - #1558: cached branch zeroes mc_print_stage / mc_percent / mc_remaining_time / stg / stg_cur / layer_num / total_layer_num / print_error
  - MQTT auth: per-IP rate-limit (5/60s lockout), hmac.compare_digest, access_code redacted in DEBUG log
  - FTP cmd_STOR streams chunks to disk + 4 GiB cap (was buffering whole upload)
  - Sticky-keys allowlist extended with upgrade_state / xcam / hw_switch_state / nozzle_diameter / nozzle_type / online / ams_status
  - _pending_files cleanup in finally for archive / queue / dispatch handlers
  - _add_to_print_queue position uses MAX+1 (was hardcoded 1)
  - DELETE VP removes orphan PendingUpload rows + upload_dir from disk
  - Per-VP cert regenerates on shared-CA rotation (real signature verification, not DN match)
  - DHCP target-IP refresh + queue_force_color_match toggle now restart proxy VPs
  - Per-slicer bridge-response routing (multi-slicer cross-leak fix via sequence_id map)
  - Child-service readiness barrier (FTP / MQTT / Bind / SSDP) — no false is_running before sockets bind
  - H2D Pro O1E / O2D model codes added (experimental, needs field confirmation)
  - FTP passive port range widened 50000-51000; docker-compose + wiki updated
  - VP refresh_loop crash now unbinds raw_message_handler; tailscale catches asyncio.TimeoutError; SlicerProxyManager lifecycle hardening
2026-05-30 13:34:10 +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
maziggy 2241924312 fix(library): reject FAT32-illegal filename chars at rename/upload/queue time (#1540)
Bambu printer SD cards are FAT32/exFAT, which forbids < > : " / \ | ? *
  plus control chars and trailing dots/spaces. Library rename only blocked
  path separators, so a name like L|R.3mf was accepted and only failed
  later at FTP upload with 553 Could not create file - far from the rename
  action that caused it. Bambu Studio refuses these names in its save
  dialog; Bambuddy now does the same.

  New backend/app/utils/filename.py centralises validation. Wired into
  update_file, upload_file, print_library_file, and queue add. Existing
  rows with bad names are left alone (no silent rewrite of user data);
  users get an actionable 400 pointing at rename.

  Frontend rename modal mirrors the same set client-side with inline error.
  New fileManager.invalidFilenameChar i18n key translated across all 9
  locales. 26 new tests in test_filename_validation.py.
2026-05-27 09:54:07 +02:00
maziggy e9beb1e8fc fix(archives): handle fallback archives in source-3MF upload (#1531)
Archives created from prints Bambuddy didn't archive (cloud / Handy /
  SD-card prints) carry file_path="". The two source-upload routes
  computed the destination as (base_dir / archive.file_path).parent /
  "source", which collapsed to base_dir.parent / "source" for fallback
  rows — sending the file to /app/source/ (outside the data volume,
  orphaned on container restart) and raising 500 on the final
  relative_to.

  Centralise the destination math in _resolve_source_3mf_path. Normal
  archives keep the <archive>/source/<filename> layout. Fallback
  archives land at <base_dir>/archive/no_source/<id>/<filename>, which
  stays inside the data volume and is addressable by every existing
  read site. The helper also asserts the resolved directory is under
  base_dir.resolve() so a corrupted row fails with a clear message
  instead of writing outside the volume.

  Both upload routes (upload_source_3mf and upload_source_3mf_by_name)
  now route through the helper. Two regression tests in
  TestUploadSourceThreeMF pin both branches.
2026-05-26 11:04:46 +02:00
maziggy 4387a09162 fix(spoolbuddy): route weight sync by inventory mode exclusively (#1530)
POST /spoolbuddy/scale/update-spool-weight tried the local DB first
  and only fell back to Spoolman on a local miss. Combined with
  nfc/tag-scanned's post-#1119 always-Spoolman routing, a stale local
  Spool row sharing a numeric id with a Spoolman spool would absorb
  the sync silently while the Spoolman row stayed unchanged.

  Mirror the routing already used by nfc/tag-scanned: pick the branch
  via _get_spoolman_client_or_none() and never cross. Local mode now
  returns 404 on a local miss instead of falling through.

  New TestUpdateSpoolWeightSpoolman.test_stale_local_row_does_not_shadow_spoolman
  asserts both directions: Spoolman gets the update, the colliding
  local row's weight_used and last_scale_weight are untouched.
2026-05-26 10:36:37 +02:00
maziggy 554a73070f fix(maintenance): paused prints no longer accumulate runtime hours (#1521)
PAUSE counted toward runtime_seconds equally with RUNNING, inflating
  hours-based maintenance thresholds (rod lube, belt check, nozzle clean)
  by however long overnight or extended pauses lasted. Maintenance items
  track mechanical wear, which is zero while paused, so the predicate
  now excludes PAUSE. Field-comment and docstring trail across main.py /
  models/printer.py / maintenance.py updated to match. Existing
  runtime_seconds values cannot be retroactively split — only future
  accumulation is fixed.

  Adds 3 regression tests pinning PAUSE non-accumulation, RUNNING
  accumulation, and the FINISH state's last_runtime_update clear
  (prevents idle-time back-bill when the printer next goes RUNNING).
2026-05-25 09:04:47 +02:00
maziggy 1e734fb7c6 fix(stats): cancelled prints get their own bucket; gauge denominator excludes them (#1390 follow-up)
Reporter (@IndividualGhost1905) saw Total: 20 / Success: 18 / Failed: 1
  and asked where the 20th print went. The Quick Stats endpoint counted
  status == "completed" → Successful and status == "failed" → Failed, but
  used a raw count(*) for Total Prints, so the four other PrintLogEntry
  statuses (aborted, stopped, cancelled, skipped) silently inflated the
  total without showing up in any breakdown row. The earlier #1390 round
  had committed a test locking in this exact behaviour
  ("uses total_prints as denominator so cancelled/stopped events count"),
  which was wrong: it conflated user intent with print quality.

  Three-bucket classification, applied across the whole stats surface
  and matching how the rest of the codebase already groups statuses
  (main.py:430, 1729; failure_analysis status filter):

    successful = completed
    failed     = failed + aborted    (printer-detected quality failures)
    cancelled  = stopped + cancelled + skipped  (user/queue stopped)

  Quick Stats endpoint returns the new cancelled_prints field;
  ArchiveStats.cancelled_prints defaults to 0 so older fixtures still
  parse. SuccessRateWidget gauge now divides by successful + failed
  only — a cancelled roll no longer drags the gauge down — and a
  Cancelled row appears in the breakdown so the missing prints don't
  silently vanish from Total Prints.

  Failure Analysis service applies the same denominator fix to both
  the headline failure_rate and the per-week trend, so a week with
  several cancellations and zero failures reads as 0% rather than
  a misleading "failed / total".

  i18n: new stats.cancelled key in all 9 locales with real
  translations (no English fallback), parity script clean.

  Tests: the existing 'uses total_prints as denominator' assertion
  is inverted to assert the new behaviour (40 / 20 / 35 → 67% gauge,
  Cancelled: 35 visible). The unchanged-display path (140 / 10 / 0
  → 93%) still holds since 140 / (140 + 10) = 93.33% rounds the
  same. 33 StatsPage tests + 6 backend stats/failure tests green.
2026-05-24 15:21:10 +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 eae96da56e fix(camera): probe ffmpeg for the right RTSP socket-timeout flag (#1504)
A previous attempt swapped `-timeout` → `-stimeout` unconditionally to
  fix EADDRINUSE on the reporter's transitional ffmpeg. That broke every
  install on a modern ffmpeg (5+/6+/7+) — current Debian/Ubuntu/Homebrew
  — where `-stimeout` was removed and `-timeout` is back to meaning
  socket I/O. Verified locally: `ffmpeg -stimeout ...` errors
  "Unrecognized option 'stimeout'" on ffmpeg 7.1.
  install on a modern ffmpeg (5+/6+/7+) — current Debian/Ubuntu/Homebrew
  — where `-stimeout` was removed and `-timeout` is back to meaning
  socket I/O. Verified locally: `ffmpeg -stimeout ...` errors
  "Unrecognized option 'stimeout'" on ffmpeg 7.1.

  ffmpeg has shipped THREE arrangements of this option over time and
  Bambuddy supports the full range:

  - Pre-deprecation (early 4.x and earlier): `-timeout` is socket I/O.
  - Transitional (~late-4.x, Jammy-era): `-timeout` is deprecated and
    repurposed to RTSP listen-mode timeout; any non-zero value implies
    `-listen`, which makes ffmpeg bind the TLS-proxy port and fail with
    EADDRINUSE. `-stimeout` is the replacement socket I/O option.
  - Modern (5.x / 6.x / 7.x): `-stimeout` REMOVED. `-timeout` is back to
    socket I/O — the original meaning.

  So no single literal is correct on all installs.

  Fix: `rtsp_socket_timeout_flag()` in services/camera.py probes
  `ffmpeg -h demuxer=rtsp` once and picks `-stimeout` when ffmpeg
  advertises it (covers transitional + older builds that kept it as an
  alias), else `-timeout` (modern + pre-deprecation). Cached at module
  level for the process lifetime — ffmpeg doesn't swap mid-run.

  The function returns the option name without a leading dash; callers
  prepend it themselves so a formatting bug can't pass an empty flag.

  Wired into both RTSP ffmpeg call sites in lockstep: routes/camera.py
  (printer camera) and services/external_camera.py (external RTSP),
  which use the same TLS-proxy + ffmpeg pattern and would hit the same
  regression on either ffmpeg cohort.

  Tests: 8 in test_ffmpeg_rtsp_timeout_flag.py — 6 probe unit tests
  (prefers stimeout when advertised, falls back to timeout on modern,
  defaults to timeout when ffmpeg missing or probe raises, caches across
  calls, trailing-space substring guard against `-listen_timeout`
  false-positives), 2 parametrised guards against either RTSP ffmpeg
  argv re-hard-coding a literal instead of consuming the probe. 37
  probe + existing external-camera tests green.
2026-05-24 08:49:21 +02:00
maziggy c0c07bc509 fix(archives): cross-model re-slice no longer carries source printer_id
When the user re-sliced an H2D archive for X1C, the new archive card and
  reprint modal both showed the source printer's name (e.g. "Workshop H2C")
  even though sliced_for_model on the same row correctly read "X1C".

  slice_and_persist_as_archive copied source_archive.printer_id verbatim
  onto the new PrintArchive row. Both the archive card
  (ArchivesPage.tsx:3571) and the reprint modal read printer_id first and
  only fall back to sliced_for_model when it's None. With printer_id set
  to the source's H2D printer, neither could see that the new file is for
  a different model.

  Same shape as the sliced_for_model fix already in the same function — a
  cross-model re-slice means the source's physical printer isn't where
  this output prints anymore. When the slicer-baked target model differs
  from source_archive.sliced_for_model, drop printer_id so both surfaces
  fall back to the sliced_for_model badge ("X1C"). Same-model re-slices
  keep printer_id so the reprint modal still pre-selects the source
  printer.

  Edge case: when source_archive.sliced_for_model is None (older archives
  that predate that column being populated), we can't tell whether this
  is a cross-model re-slice. Fail open and preserve printer_id rather
  than spuriously nulling it.

  slice_and_persist (the library-file path) doesn't have this bug —
  LibraryFile has no printer_id column.

  Tests in TestSliceArchiveResliceModel cover all three branches: cross-
  model nulls printer_id; same-model keeps it; unknown source model
  preserves it.

  Surfaced via the bambuddy demo doing H2D -> X1C re-slices after the
  .bbscfg System-tier slicing fix landed.
2026-05-23 14:25:12 +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 7ea4410b21 fix(queue): insufficient-filament warning now fires on every dispatch path (#1496)
The pre-print deficit warning from #720 only ran inside the PrintModal
  submit flow. Both the green ▶ button on a staged queue row (POST
  /queue/{id}/start) and the Virtual Printer queue-mode intake bypassed
  it — auto_dispatch=True VP intakes would dispatch unsupervised onto
  spools that physically can't complete the print.

  Extracted the deficit check into backend/app/services/filament_deficit.py
  (single source of truth, both internal inventory and Spoolman modes).
  POST /queue/{id}/start returns 409 with a structured deficit payload
  unless ?skip_filament_check=true. The dispatch scheduler runs the same
  check before each _start_print; a deficit promotes the item to
  manual_start + sets a new filament_short flag (idempotent migration on
  print_queue). The flag clears automatically on the next tick when the
  operator swaps a spool to one with enough material.

  Frontend ▶ catches the 409 and opens a confirm modal showing each
  shorted slot's required vs remaining grams; the row now renders a
  yellow "Insufficient filament" badge when filament_short is set.
  Translated across all 9 locales.
2026-05-23 08:47:46 +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 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 71e58e6cf1 fix(library): show the filename, not the embedded 3MF Title (#1489)
File Manager cards, search and sort keyed off file_metadata.print_name,
  which ThreeMFParser lifts from the 3MF's <metadata name="Title">. That
  title is the in-app project title — generic "Exported 3D Model" for any
  Bambu Studio "Save As", a marketing title for a MakerWorld download —
  and almost never the filename the user saved as. A card for
  Whatever.3mf showed "Exported 3D Model"; correcting it needed a rename
  round-trip, since the Rename dialog disables Save while the name is
  unchanged.

  The slicer-output write path already dropped print_name for this exact
  reason; the four other paths that store parsed 3MF metadata onto a
  LibraryFile did not — external-folder scan, managed multipart upload,
  the multi-file ZIP-upload branch, and MakerWorld import.

  Add a shared _without_print_name() helper and apply it at all four
  import paths; switch the slicer path to it so there is one rule. A
  LibraryFile's display name is its filename — only PrintArchive carries
  a real print_name, which is untouched. Remove the now-redundant
  filename->print_name mirroring in the rename route.

  Add a one-time idempotent data migration (_migrate_drop_library_print_name,
  SQLite json_remove / PostgreSQL jsonb key-removal branched on
  is_sqlite()) so libraries imported before the fix correct themselves
  without the rename workaround. No frontend change: print_name || filename
  yields the filename once print_name is gone.

  Tests: 6 new in test_library_print_name.py cover _without_print_name and
  the migration (incl. idempotency, siblings preserved, null metadata).
  SQLite migration branch verified by test; PostgreSQL branch verified
  against a real Postgres instance.
2026-05-22 10:22:14 +02:00
maziggy 056f06a396 fix(camera): capture ffmpeg stderr when an RTSP stream stalls (#1395)
A P2S support bundle on 0.2.5b1 — per-model probesize fix already
  applied — showed the camera still failing: ffmpeg connects, stays alive
  30+ seconds, emits zero JPEG bytes, the 30s stdout.read times out,
  reconnect loop repeats. No ffmpeg stderr appeared anywhere in the log to
  explain why.

  The cause was a diagnostic bug, not the camera path. _read_ffmpeg_stderr
  called process.stderr.read() — read-to-EOF. A stalled-but-still-alive
  ffmpeg (the P2S RTSP failure mode) never closes stderr, so the read
  blocked until the 2s wait_for timeout and returned None, discarding the
  banner + stream-analysis lines ffmpeg had already printed. ffmpeg stderr
  was captured only when it fully exited; once the probesize bump turned
  the earlier crash into a hang, the diagnostic went dark.

  Drain stderr incrementally in bounded 8KB chunks (64KB cap), returning
  whatever ffmpeg printed so far whether or not it has exited. Also log
  the resolved per-model probesize/analyzeduration on the info-level
  "Starting RTSP camera stream" line, and log the full ffmpeg argv at
  debug level with only the credential-bearing camera URL redacted
  instead of hiding the entire command.

  No behaviour change to streaming — this makes the unresolved P2S RTSP
  stall diagnosable in the next support bundle.
2026-05-22 10:02:41 +02:00
maziggy 3286ccd7d2 fix(inventory): send honest Bambuddy User-Agent on FilamentColors.xyz sync
The Color Catalog sync built its httpx.AsyncClient with no User-Agent, so
  it leaked httpx's default python-httpx/x.y string - the only outbound
  client that did; bambu_cloud, makerworld and firmware_check all send
  Bambuddy/1.0 (+https://github.com/maziggy/bambuddy). It now sends the
  same honest UA.

  Found while investigating an issue - a Cloudflare 403 on the sync that
  turned out to be the reporter's network/IP reputation, not Bambuddy. The
  UA leak was a separate inconsistency found in passing; this change does
  not by itself resolve a Cloudflare IP block.
2026-05-22 08:34:54 +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 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 e1a236e408 fix(spoolman): decide spool assignability from the slot-assignment ledger, not extra.tag (#1122)
GET /spoolman/spools/unlinked hid any spool with a non-empty
  extra.tag from the AMS-slot assignment picker. extra.tag is only an
  RFID/NFC matching key -- OpenSpoolman writes its own NFC tag value
  into that same Spoolman field -- so every OpenSpoolman-tagged spool
  became un-assignable in Bambuddy even when it occupied no slot.

  get_unlinked_spools now determines assignability from the
  spoolman_slot_assignments table (the documented source of truth for
  slot assignments) and ignores extra.tag entirely. Both link_spool and
  the AMS auto-sync upsert a row there for every occupied slot, so the
  ledger is complete. get_linked_spools and find_spool_by_tag still use
  extra.tag -- they are genuine tag-match maps and are unaffected.

  Internal-inventory mode needs no parallel change: it stores tags in
  its own DB with no Spoolman extra collision.

  Updates test_get_unlinked_spools_success and adds
  test_get_unlinked_spools_excludes_slot_assigned.
2026-05-21 09:33:47 +02:00
maziggy b06f8f6951 fix(spool-assignments): union both assignment tables in the missing-spool check + symmetric mode-switch clear (#1473)
notify_missing_spool_assignments_on_print_start queried only the legacy
  SpoolAssignment table. In Spoolman mode that table is empty -- bindings
  live in spoolman_slot_assignments -- so every used tray was flagged
  missing, firing a false-positive notification on every print.

  - spool_assignment_notifications.py: the assigned-tray set is now the
    union of SpoolAssignment + SpoolmanSlotAssignment rows. Union-only,
    so legacy-mode behavior cannot regress.
  - settings.py: the Spoolman toggle cleared SpoolAssignment on switch-on
    but never cleared SpoolmanSlotAssignment on switch-off. Added the
    symmetric clear so stale Spoolman rows can't leak into a later
    internal-mode session and mask a real missing-assignment warning.

  Adds 3 notification tests + 1 mode-switch integration test. An audit
  of the remaining SpoolAssignment consumers confirmed usage_tracker,
  spool_tag_matcher and routes/inventory are correctly internal-mode-only.
2026-05-21 09:15:10 +02:00
maziggy 5b3962e3e6 fix(obico): Failure Detection status panel shows thresholds for the selected sensitivity (#1469)
The Status panel's Low / High thresholds readout was stuck at 0.38 /
  0.78 regardless of the Sensitivity dropdown, so the setting looked
  dead. Detection itself was correct -- classify() always used the real
  sensitivity -- but get_status() computed the displayed thresholds with
  a hardcoded thresholds("medium").

  get_status() now takes an optional sensitivity argument and the
  /obico/status route passes settings["sensitivity"] (it already loads
  settings fresh). The readout updates as soon as the change is saved.
2026-05-21 08:45:59 +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
maziggy 12b0c138f7 fix(spoolman): clear stale fallback-tag links on assign + link, prefer slot-assignment over tag-link in UI (#1457)
Reporter on a P1S with non-RFID spools saw an old, almost-empty spool in
  the AMS hover card's "Spulen-ID" block while the "Zugewiesen" block
  correctly showed the freshly assigned full spool. Two layers compounded:

    (1) Non-RFID slots fall back to a deterministic per-slot tag
        (hash(serial) + ams_id + tray_id). The Link / Assign routes wrote
        that tag to Spoolman extra.tag but never cleared it from the
        previous holder on re-binding.

    (2) The frontend's hover-card resolver preferred the (stale) tag-link
        over the user's explicit slot-assignment. Same precedence bug in
        SpoolBuddy's fill-bar resolver and slot-action picker.

  Frontend: swap precedence at 5 sites — slot-assignment outranks tag-link
  everywhere. FilamentHoverCard's existing match-dedupe then collapses the
  two "Open in Inventory" buttons back into one.

  Backend: new _clear_stale_tag_links() in spoolman_inventory.py, called
  from POST /spoolman/inventory/slot-assignments (with the slot's
  deterministic fallback tag) and POST /spoolman/spools/{id}/link (with
  the literal tag being bound — works for RFID and fallback). Best-effort:
  Spoolman 5xx and per-spool patch failures log + continue, never wedge
  the bind. get_fallback_spool_tag_for_slot promoted to a public helper
  mirroring the frontend's signature exactly.
2026-05-20 11:00:54 +02:00
maziggy badf0bed04 Fix: Failure Analysis widget honours edited failure_reason / status (#1444)
PrintLogEntry.failure_reason is captured once at print-completion time
  (main.py:3641) by copying archive.failure_reason — which is NULL while
  the user hasn't classified the failure yet. The PATCH /archives/{id}
  route then writes only to print_archives via a generic setattr loop,
  so the log entry stays NULL and failure_analysis.py keeps grouping the
  print as "Unknown". Same desync hits status — flipping it in the modal
  never reached the entry either.

  Mirror failure_reason and status from the PATCH payload to the latest
  PrintLogEntry for that archive (highest id). Latest-only because
  archive.failure_reason / status already reflect the latest run's outcome
  (each reprint clears the archive value at main.py:2195 and rewrites it
  at completion), so the Edit Archive modal is implicitly editing the
  latest run — reprints of an archive that succeeded on the second attempt
  keep the earlier failed run's original classification intact.

  Scoped to those two fields only. cost / print_name / printer_id stay
  unmirrored because per-run values legitimately diverge from archive
  ones (partial-print cost on a failed run vs source archive's full-print
  cost — see _compute_run_filament_grams at main.py:596).
2026-05-20 09:26:56 +02:00
MartinNYHC 12a352e5b8 Merge branch 'main' into dev 2026-05-19 14:12:50 +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