Commit Graph
103 Commits
Author SHA1 Message Date
maziggy fc32b388de fix(stats): align Filament Used / By Time / Success Rate with Total Consumed and Total Prints (#1390 follow-up)
Three independent root causes behind the divergences the reporter
  flagged after the archived-spool fix shipped — fixed together.

  (1) Filament Used vs Total Consumed.
  _compute_run_filament_grams returned the slicer estimate for completed
  prints even when inventory had measured the actual AMS weight delta.
  That made Stats and Inventory two different sources of truth: Stats
  showed slicer-estimate grams, Inventory showed AMS-tracked grams, and
  the two never agreed. Reordered the helper so the tracked spool delta
  (same source that drives weight_used behind Total Consumed) takes
  priority for every status. Slicer estimate stays as the fallback when
  no inventory was tracked; partial-progress scale stays as the fallback
  for failed/cancelled with no tracker. The _run_cost block right next
  to it was already tracker-first; only filament_used_grams was
  inconsistent.

  (2) Printer Stats By Time vs Quick Stats Print Time.
  /archives/slim only set actual_time_seconds when status == "completed".
  For failed/cancelled rows the frontend fell back to print_time_seconds
  (the slicer's full-print estimate — wrong number for a print that
  failed at 15%). Quick Stats already summed elapsed duration across
  all statuses, so the two halves of the page disagreed by the
  (estimate - actual-elapsed) gap on every non-completed event. Dropped
  the completed-only gate; failed/cancelled now report measured elapsed.

  (3) Success Rate %.
  Was successful / (successful + failed), excluding cancelled / stopped
  from the denominator. With "Total Prints: N" displayed right above
  the gauge that produced confusing numbers — 4 successful, 0 failed,
  48 cancelled showed 100% out of an apparent 52 prints. Switched to
  successful / total_prints — matches the count the user reads from
  the widget header.
2026-05-19 10:53:36 +02:00
maziggy f645bd2bbe fix(stats): per-event data for all widgets, not just Quick Stats (#1390)
After #1378 moved Quick Stats to print_log_entries, six widgets and
  Failure Analysis still iterated the archive list. That made reprints
  multiply event-based widgets while leaving archive-based ones unchanged,
  and made hard-deleted archives drop from archive-based widgets while
  their orphan events kept feeding Quick Stats.

  Swap the data source in two places:

  - GET /archives/slim now reads PrintLogEntry, LEFT JOINs the archive for
    the sliced print_time_seconds estimate, prefers PrintLogEntry's own
    duration_seconds as the measured-time field. StatsPage is the only
    caller -- every widget realigns in one step.
  - FailureAnalysisService swapped from PrintArchive to PrintLogEntry for
    every aggregation. project_id filter still resolves through archives
    but counts matching events.

  Conftest archive_factory now syncs the synthesized event's created_at
  with the archive's so backdated test data survives the change.
2026-05-17 14:38:31 +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 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 b5a83924eb fix(cost): top-up untracked filament at default rate so multi-color
archives stop reporting near-zero cost (#1344)

  Reporter @nicktags hit $0.01 on a 110.3g multi-color print with the
  global default filament cost set to $10/kg. archive.py initial cost
  calc was correct (~$1.10), then usage_tracker.on_print_complete
  overwrote archive.cost with sum(r.cost for r in results) -- where
  results only includes AMS trays mapped to a spool in Bambuddy's
  inventory. On a multi-color print where 3 of 4 used trays had no
  inventory spool, only the one tracked slot's tiny share (~1g) survived
  and the archive recorded $0.01.

  The overwrite logic dates to #505 (Feb 2026) and is correct for
  fully-tracked single-color prints, but the multi-color slicer feature
  in 0.2.4 (988c0055) made the partial-inventory state common -- users
  slice + print multi-color from Bambuddy without first setting up an
  inventory entry for every tray.

  Cover the gap: any filament weight not represented in results gets
  charged at the global default rate. For a fully-tracked print,
  untracked grams = 0 and the top-up adds nothing, so the single-color
  behavior is preserved. For a partial print, the missing slots are
  priced at the user's documented default rate so the archive cost
  reflects the whole print.

  Three call sites updated to share the same logic:
    - usage_tracker.py: live cost-update on print complete
    - archives.py rescan_archive: per-archive manual recalc
    - archives.py recalculate_all_costs: bulk recalc button
2026-05-15 15:43:37 +02:00
maziggy 9ba1e34729 fix(archives): keep Quick Stats contribution when deleting a print (#1343)
Reported by @IndividualGhost1905: printing the same model ten times and
  then deleting nine archive entries (to keep the file list tidy) silently
  rewound the totals on the Statistics page — total prints, filament,
  cost, and per-print energy all dropped back to whatever the surviving
  row contributed, as if the other nine prints had never happened.

  Root cause: every metric in get_archive_stats is recomputed live from
  PrintArchive rows via COUNT / SUM, so removing a row removes its
  contribution. Energy in the default "Total" mode already survived
  deletion because it reads the smart-plug lifetime counters — that's
  the architectural shape we now generalise to the rest.

  Fix: soft delete with opt-in hard purge.

  Backend:
  - New nullable, indexed deleted_at column on print_archives, dialect-
    conditional migration (DATETIME on SQLite, TIMESTAMP on PostgreSQL).
  - ArchiveService.soft_delete_archive flips deleted_at and removes the
    files from disk (still reclaims storage); the path-safety checks were
    extracted into _resolve_archive_dir_for_delete so soft and hard delete
    share the rules.
  - DELETE /archives/{id} accepts ?purge_stats=true; default is soft.
  - Listings filter deleted_at IS NULL: list_archives, search FTS + LIKE
    fallback, GET /{id} (404 on soft-deleted), tag listing, duplicate
    detection (so a 1-live + 9-soft-deleted group no longer marks the
    survivor as a duplicate), and ArchiveComparisonService's "similar"
    suggestions. GET /stats and GET /slim deliberately do NOT filter so
    Quick Stats and the dashboard widgets keep counting deleted prints.

  Frontend:
  - ConfirmModal gained an optional children slot.
  - ArchivesPage (both card and detail views) own a per-instance
    deletePurgeStats boolean and render an opt-in checkbox in the delete
    dialog; resets to off on every close so the destructive option is
    never sticky.
  - api.deleteArchive(id, purgeStats?) appends ?purge_stats=true only
    when the box is ticked.
  - One new i18n key archives.modal.deletePurgeStats added across all 8
    locales (full German, English fallbacks elsewhere).
2026-05-15 09:21:43 +02:00
maziggy 4ee4bdb0d3 fix(archives): scan_timelapse picked stale video at false offset (#1278)
scan_timelapse's Strategy 2 matched filename timestamps against both
  archive.started_at and archive.completed_at across seven hypothesised tz
  offsets. The filename is always print-START time, so the end-time branch
  was a semantic mistake — and the dense offset set [0, +-1, +-7, +-8]
  let an unrelated video coincidentally land within minutes of any later
  archive at some offset.

  Extract Strategy 2 into _match_timelapse_by_timestamp(): compare only
  against start time, and refuse to auto-pick when the next-best different
  video is within a 15-minute ambiguity margin. The route then returns
  available_files and the frontend's existing manual-selection dialog
  takes over — which is the fallback the reporter explicitly asked for.

  Surfaces in LAN-Only mode where the printer can't reach NTP and its
  clock drifts (e.g. P2S filenames in CST while server is in UTC, the
  8h offset that exposed this bug).
2026-05-12 09:20:53 +02:00
maziggy 79d54a8d53 feat(archives): build-plate icon on cards + uniform printer/model line (#1253)
Show an OrcaSlicer-style bed icon in the archive card's printer-name row
  indicating which build plate the print was sliced for (Cool /
  Cool SuperTack / Engineering / High Temp / Textured PEI / Smooth PEI),
  with the full plate name in the hover tooltip. Closes the gap where
  users had to remember which plate matched a re-print or open the
  source 3MF in a slicer just to read the bed setting.

  Card row also unified: archives with a real Bambuddy-printer
  association used to render "H2D-1 GCODE ..." while slicer-only uploads
  rendered "Sliced for X1C GCODE ..." -- same line, two different shapes.
  Drop the "Sliced for " prefix so both render as a uniform
  "<name-or-model> [bed-icon] GCODE <hash>" row, scanning identically
  regardless of provenance.

  Backend: new bed_type column on print_archives (idempotent ALTER TABLE
  migration; SQLite + Postgres safe). Populated from curr_bed_type in
  Metadata/slice_info.config (per-plate, authoritative -- that's what
  got sent to the printer for the exported plate) with a fallback to
  project_settings.config for older 3MF shapes. Wired through both
  archive_to_response() (the hand-rolled dict converter that bypasses
  from_attributes -- easy to miss) and the /rescan endpoint, so old
  archives can be re-parsed via the existing per-archive Rescan button.

  Backfill script (scripts/backfill_archive_bed_type.py, --dry-run
  supported) re-opens every NULL archive's 3MF on disk to populate the
  column. Auto-loads .env from project root before importing backend
  modules (config.py reads DATABASE_URL from os.environ at import time,
  not from pydantic-settings at Settings() time) and prints the resolved
  DB URL with credentials redacted, so operators can confirm they're
  hitting the intended database -- Postgres or SQLite.

  Frontend: 6 OrcaSlicer-style PNGs ship in frontend/public/img/bed/ --
  under /img/ because that path is already statically mounted; a
  toplevel /bed-icons/ tried first hit the SPA catch-all and returned
  index.html as text/html. New utils/bedType.ts maps slicer strings
  case-insensitively, covering both Bambu Studio and OrcaSlicer naming
  variants for the same physical plate. Unmapped or NULL bed_type
  simply omits the icon, so cards stay clean for pre-feature archives.
2026-05-10 09:54:10 +02:00
maziggy 3f58fc74b4 fix(http): RFC 6266-encode Content-Disposition so non-ASCII filenames don't crash response (issue #1245)
Reported by @1000Delta. The printer file download (and three sibling
  endpoints) raised UnicodeEncodeError: 'latin-1' codec can't encode
  characters... on any filename outside U+0000..U+00FF (Chinese,
  Japanese, Arabic, accented Latin), because the route pushed `filename`
  straight into Content-Disposition: attachment; filename="...".
  Starlette/uvicorn encodes response headers as latin-1, so the assignment
  crashed at write-time.

  New backend/app/utils/http.py::build_content_disposition emits both an
  ASCII-stripped legacy filename="..." fallback and an RFC 5987
  filename*=UTF-8''<percent-encoded> parameter. Every modern browser
  prefers the *= form, so the original Unicode filename round-trips
  through Save-As intact.

  Same shape was latent in three siblings and fixed in the same PR
  (no deferred follow-ups): archive QR endpoint (archive.print_name
  from 3MF metadata), project ZIP export (project.name — the existing
  isalnum() sanitiser passes non-ASCII through), and the PDF label
  streamer (latent today, callers ASCII-only but the helper hardens it).
2026-05-08 14:06:40 +02:00
maziggy 8a31397171 feat(slicer): bundle-aware preview slice for accurate gram estimates
The SliceModal's preview slice runs against unsliced project files to
  discover per-plate AMS slot consumption. Until now it always used
  slice_without_profiles — accurate slot mapping (a model property) but
  gram numbers were derived from the file's embedded process settings,
  which can drift from the triplet the real print will use.

  When the caller provides a bundle id + printer/process/filament preset
  names, get_preview_filaments now routes through slice_with_bundle so
  the preview's gram numbers match what the real print will produce.
  Cache key picks up a bundle-context fingerprint so different bundle
  picks on the same file occupy distinct entries.

  Backend:
  - slice_preview.get_preview_filaments: optional bundle_* params
  - library.py + archives.py: forward params via /filament-requirements

  Frontend (forward-compat for the upcoming SliceModal Bundle tier):
  - api.getLibraryFileFilamentRequirements / getArchiveFilamentRequirements
    accept an optional 4th-arg bundle context object
2026-05-06 09:10:35 +02:00
maziggy d5153f1de3 feat(slicer): live progress + filament discovery polish + OrcaSlicer warning
End-to-end live progress, two correctness fixes, and a UX warning around
  the upstream OrcaSlicer bugs we discovered while testing.

  LIVE PROGRESS
  =============

  Wire OrcaSlicer / BambuStudio's --pipe progress channel through the
  sidecar -> Bambuddy -> persistent toast so a user-initiated slice shows
  "{name} -- Generating G-code (75%) -- 47s" instead of just elapsed time.
  The same wiring covers the SliceModal's filament-analysis preview slice
  (the real slice that fires before profile picking, used to discover
  which AMS slots an unsliced plate consumes) and the embedded-settings
  fallback path triggered by Orca's --load-settings segfault on complex
  H2D models.

  - Sidecar (orca-slicer-api/bambuddy/profile-resolver, separate commit):
    switch /slice from execFile to spawn, mkfifo per request, parse the
    CLI's structured JSON progress events into a per-process
    ProgressStore, expose GET /slice/progress/:requestId.
  - Bambuddy backend: slicer_api.slice_with_profiles + slice_without_profiles
    accept request_id + on_progress, spawn a 1Hz parallel poller that
    forwards each snapshot via SliceDispatchService.set_progress(job_id,
    ...) onto the matching SliceJob; GET /slice-jobs/:id includes the
    latest snapshot on every poll. The 404 from the early-race window
    (POST fired before sidecar's progressStore.start) is treated as a
    retry rather than terminal -- otherwise the poller bailed before any
    progress could ever arrive.
  - /api/v1/slicer/preview-progress/:requestId proxies the sidecar's
    progress endpoint for the modal's filament-discovery flow (the
    /filament-requirements call is server-originated; the browser can't
    reach the sidecar directly).
  - Frontend: SliceJobTrackerContext re-renders the persistent toast with
    the new format when a useful progress frame is present, falls back
    to elapsed-time-only when the sidecar hasn't emitted yet or doesn't
    support progress. SliceModal.FilamentAnalysisSpinner generates a
    per-(source, plate) UUID, polls the proxy at 1Hz, and mirrors the
    inline spinner contents into a separate persistent toast so the
    preview slice doesn't feel silent either.

  CORRECTNESS FIXES
  =================

  - MakerWorld imports were persisting URL-encoded filenames verbatim
    ("stormtrooper-helmet%20h2d.3mf"). Backend now urllib.parse.unquote
    s the manifest-supplied name and the URL path-tail fallback before
    passing to save_3mf_bytes_to_library; frontend defensively
    decodeURIComponent s in the slice toast / analysis spinner so
    already-imported rows display cleanly without a backfill migration.
  - The fallback path's slice_without_profiles call now forwards the
    same request_id + on_progress as the primary slice_with_profiles
    call so the toast keeps updating across the segfault -> embedded-
    settings retry boundary instead of going blank.

  ORCASLICER WARNING
  ==================

  Verified two upstream OrcaSlicer CLI bugs reproduce on the latest
  nightly (2.4.0-dev, 2026-04-28) with the help of an isolated AppImage
  extract and a minimal sentinel-value-injected cube fixture:
    - OrcaSlicer/OrcaSlicer#12426 -- SIGSEGV in
      update_values_to_printer_extruders_for_multiple_filaments on
      painted multi-extruder 3MFs (commented on the existing thread,
      not a new issue)
    - OrcaSlicer/OrcaSlicer#13386 -- CLI strict-validates parameter
      values BambuStudio writes by default (solid_infill_filament: 0,
      tree_support_wall_count: -1, prime_tower_brim_width: -1) and
      rejects with exit 238, even though Orca's own GUI tolerates
      them (filed by us alongside this change)

  Settings -> Workflow -> Slicer card renders an amber inline warning
  under the preferred-slicer dropdown when orcaslicer is selected,
  linking both upstream issues and recommending BambuStudio until the
  fixes land. Option stays pickable -- users who only slice STLs aren't
  affected by either bug.
2026-04-28 16:36:49 +02:00
maziggy 3fa0b621dd fix(slicer): correct multi-color slice output + UX polish across the slice pipeline
The slicer CLI silently substitutes embedded defaults for any AMS slot
  the user didn't supply a profile for. When a multi-color project (e.g.
  a MakerWorld helmet with white shell + grey support filament configured
  project-wide) was sliced for a "single-color" plate, the CLI took the
  user's white pick for slot 1 and quietly filled slot 2 with the source
  3MF's embedded grey support filament — producing a slice the user never
  asked for. Same silent-fallback class as the strip-removal bug.

  Backend `/filament-requirements` now returns the FULL project AMS slot
  list (from project_settings.config) with a `used_in_plate: bool` flag
  per entry. The flag comes from the cached preview slice for unsliced
  files; sliced files (where slice_info.config already pre-filters by
  used_g > 0) get used_in_plate=true on every entry. SliceModal renders
  one dropdown per project slot — slots flagged used_in_plate=true are
  editable, slots flagged false are auto-picked from project metadata
  via the existing colour-match scoring and disabled with a
  "-- not used by this plate" suffix. The wire format always carries a
  profile per project slot, so the CLI never falls back to embedded
  defaults.

  Adjacent UX fixes in the same session, since they all hit the same
  flow:

  - Persistent slice-progress toast: SliceJobTrackerProvider now opens
    a persistent loading toast per active job ("Slicing X -- 47s") with
    a 1Hz elapsed-time tick and replaces it with the existing transient
    success/error toast on terminal state. The previous start+finish
    toast pair left a UX dead zone where users couldn't tell whether a
    long slice was still running.

  - "Analyzing plate filaments..." spinner now shows elapsed seconds and,
    after 5s, a hint that the wait is a one-time preview slice (cached;
    re-opens are instant). Addresses "is anything happening?" on first
    open of an unsliced complex multi-color 3MF.

  - Pre-slice printer-mismatch warning + disabled Slice button: the
    plates response now exposes `source_printer_model` from
    project_settings.config; SliceModal compares against the picked
    printer profile name and surfaces an inline warning + disables Slice
    on mismatch. The CLI rejects cross-printer slices (rc=-16) and used
    to fall back to embedded settings, producing wrong-printer g-code
    that errored at print dispatch.

  - Sliced-archive card now reflects the actually-used filament list,
    not the source's project-wide AMS config:
    slice_and_persist_as_archive reads filament_type / filament_color
    from the sliced output's slice_info.config (which already gates on
    used_g > 0) instead of inheriting from the source archive. A 16+
    swatch card on what was actually a 2-color print was the visible
    symptom.

  - MakerWorld URL-paste resolver enriches each instance with
    `compatibility` + `otherCompatibility` from
    design.instances[].extention.modelInfo. The /instances/hits payload
    omits this so every instance row used to look identical; users
    blindly picked the first one regardless of whether it matched their
    printer.

  Tests: 27 SliceModal tests (2 new for the disabled-row contract +
  3 for printer-mismatch + 4 for multi-color rendering); 4 new
  SliceJobTrackerContext tests for the persistent-toast lifecycle;
  backend filament-requirements / slice-preview / threemf-tools
  suites green.

  i18n: new keys slice.queuedToast, slice.runningToast,
  slice.analyzingPlateFilamentsHint, slice.notUsedByPlate,
  slice.printerMismatch, makerworld.slicedFor, makerworld.alsoCompatible
  across all 8 UI languages (English + German fully translated, the six
  others seeded with English copies pending native translation, matching
  the project's existing flow for newly-added user-facing features).
2026-04-28 14:50:28 +02:00
maziggy c1f69ee0cc fix(slicer): wrong-printer slicing + sliced-archive filament list + per-instance MakerWorld compat
Five stacked slice-pipeline bugs that each made the modal's profile picker
  theatrical for 3MF inputs:

  (1) `_strip_3mf_embedded_settings` removed `model_settings.config` /
      `slice_info.config` / `cut_information.xml` along with
      `project_settings.config`. The CLI silently exited after
      "Initializing StaticPrintConfigs" — exit 0, no result.json — and
      Bambuddy masked the failure by re-running with embedded settings
      and the source's bound printer. Strip removed from the dispatch
      path entirely.

  (2) Standard-tier preset stubs lacked the `type` field, so the CLI
      rejected `--load-settings` with rc=-5 ("input preset file is
      invalid") and the same masking fallback fired. Added
      `_SLOT_TO_PROFILE_TYPE` so each stub carries the right
      machine/process/filament discriminator.

  (3) Sliced-archive cards listed every project-wide AMS slot (16+
      swatches for a 2-color print). `slice_and_persist_as_archive` now
      reads `filament_type` / `filament_color` from the sliced output's
      `slice_info.config` (which `ThreeMFParser` already gates on
      `used_g > 0`) instead of inheriting from the source archive.

  (4) SliceModal had no warning when the picked printer profile didn't
      match the source 3MF — the CLI rejects cross-printer slices
      (rc=-16) and fell back to embedded settings, producing wrong-printer
      g-code that errored at print dispatch. Plates response now exposes
      `source_printer_model`; the modal compares against the picked
      profile name and disables Slice + shows an inline warning on
      mismatch.

  (5) MakerWorld URL-paste resolver listed plate instances without
      showing which printer each was sliced for (`/instances/hits`
      omits compatibility info that lives on `design.instances[]
      .extention.modelInfo`). The resolve route now joins both payloads
      by instance ID and forwards `compatibility` + `otherCompatibility`
      onto each hit; the MakerWorld page renders "Sliced for {primary}"
      + "Also marked compatible: ..." per row.

  Tests: 6 unit tests for `extract_source_printer_model_from_3mf`, 1 for
  filtered filament metadata via ThreeMFParser, 2 for makerworld resolve
  compat-merge (happy path + missing modelInfo), 3 frontend SliceModal
  tests for the printer-mismatch warning + Slice-disabled gate. New i18n
  keys `slice.printerMismatch`, `makerworld.slicedFor`,
  `makerworld.alsoCompatible` across all 8 locales.
2026-04-28 13:58:56 +02:00
maziggy 988c00554e feat(slicer): multi-color slicing + per-plate filament discovery
The slice modal previously rendered exactly one filament dropdown and
  silently truncated multi-color 3MFs to a single profile, producing wrong
  colours on every multi-filament print. End-to-end fix across sidecar,
  backend, and frontend.

  Sidecar (orca-slicer-api / bambuddy/profile-resolver, separate commit):
    - /slice accepts up to 16 repeated filamentProfile parts; slicing
      service materializes each and joins paths with `;` for
      --load-filaments.
    - /profiles/bundled emits filament_type and filament_colour per leaf
      so the bundled tier carries metadata into the modal.

  Bambuddy backend:
    - SliceRequest gains filament_presets: list[PresetRef]. Validator
      accepts three shapes (multi-color array, source-aware singular,
      legacy bare-int id) and lands them all on a populated array before
      the route handler runs — fully backwards-compatible.
    - SlicerApiService.slice_with_profiles takes filament_profile_jsons:
      list[str] and sends one filamentProfile multipart part per profile
      (in submission order) so the sidecar receives N profiles cleanly.
    - New service slice_preview runs the sidecar's slice_without_profiles
      against an unsliced project file's embedded settings, parses the
      result's slice_info.config, and returns the canonical per-plate
      filament list. Cached by (kind, source_id, plate_id, content_hash)
      with LRU eviction at 256 entries, per-key asyncio.Lock prevents
      thundering-herd; transient sidecar failures are NOT cached so they
      retry naturally; parse failures ARE cached (deterministic property
      of the input, no point re-running).
    - /filament-requirements endpoint chain: slice_info.config (existing,
      sliced files) → preview-slice (new, unsliced project files) →
      project_settings.config + painted-face heuristic with 5% noise
      threshold (sidecar-down fallback).
    - threemf_tools gains extract_project_filaments_from_3mf and
      extract_plate_extruder_set_from_3mf — the latter unions object
      top-level extruder, per-part overrides, and painted-face quadtree
      leaves (1-E nibbles in paint_color attrs of <triangle> elements
      inside per-object .model files).
    - Cloud preset listing no longer fetches per-preset detail (Bambu's
      rate limit at ~10/sec returns 429 on every request for users with
      50+ presets). Unified-listing dedup pass instead backfills metadata
      cross-tier so a cloud entry that wins dedup over a same-named local
      entry inherits the local's filament_type / filament_colour.

  Frontend:
    - SliceModal multi-step: plate-picker first when the source is a
      multi-plate 3MF, then preset dropdowns. One filament dropdown per
      AMS slot the plate actually uses, each pre-picked by metadata
      match against user's local + standard presets via existing
      colorsAreSimilar / normalizeColorForCompare utils.
    - SliceModal-only tier priority is now local → cloud → standard
      (was cloud → local → standard). Other consumers of /slicer/presets
      keep the existing cloud-first order.
    - Submits filament_presets array; backfills the legacy singular
      filament_preset from the array's first entry for stale-tab
      compatibility.
    - i18n keys added across all 8 locales: slice.filamentSlot,
      slice.tier.{local,cloud,standard}, slice.cloud.{notAuthenticated,
      expired,unreachable}, slice.noPresetsForSlot,
      slice.allPresetsRequired (en + de fully translated; six others
      seeded with English copies pending native translation, matching
      the project's existing flow).

  Permissions: no new endpoint paths added. Preview-slice runs inside
  /filament-requirements (LIBRARY_READ / ARCHIVES_READ) and multi-filament
  dispatch runs inside POST /slice (LIBRARY_UPLOAD). No auth surface
  widened.

  Tests: 6 SliceRequest schema tests for multi-filament + legacy-new
  precedence; 9 unit tests for slice_preview cache behaviour (LRU
  eviction with lock cleanup, content-hash invalidation, concurrent
  thundering-herd guard, no-cache-poison on transient sidecar failure);
  15 unit tests for the two new threemf_tools helpers (5 + 10 cases
  including the 60/40 painted-threshold regression pin); a multi-filament
  wire-format test pinning the multipart part count + order; 22 frontend
  SliceModal tests covering plate picker, multi-color render,
  metadata-aware pre-pick, manual override, and the new tier order.
2026-04-28 12:20:59 +02:00
maziggy 6deaa513af ● feat(slicer): server-side slicing via OrcaSlicer / Bambu Studio sidecar
Adds an optional slicer-api/ Compose stack and wires Bambuddy's File
  Manager, Archives, and MakerWorld pages to a new server-side Slice flow.
  Slicing runs as an in-memory background job (POST returns 202 + job_id,
  polled via GET /api/v1/slice-jobs/{id}) so a multi-minute slice no
  longer pins the modal; result lands as a new .gcode.3mf in the same
  folder (or new archive for archive sources) with the embedded
  thumbnail extracted.

  Backend
  - New services: slice_dispatch (in-memory dispatcher, 30min retention
    sweep) and slicer_api (HTTP bridge with 4xx/5xx/connection error
    split that drives the 3MF embedded-settings fallback retry path).
  - New schemas: SliceRequest, SliceResponse, SliceArchiveResponse,
    SliceJobEnqueueResponse.
  - New routes: POST /library/files/{id}/slice,
    POST /archives/{id}/slice, GET /api/v1/slice-jobs/{id} (gated on
    LIBRARY_READ since job IDs are sequential and the body leaks source
    filenames and result IDs).
  - AppSettings + env defaults: use_slicer_api, orcaslicer_api_url,
    bambu_studio_api_url. DB-stored values override env defaults.

  Frontend
  - New SliceModal handles preset gating; enqueues then closes
    immediately.
  - New SliceJobTrackerProvider polls active jobs at app level, surfaces
    a single toast per job (queued -> running -> completed / failed)
    and invalidates library/archives queries on terminal status.
  - Settings -> Workflow -> Slicer card: preferred slicer dropdown,
    Use Slicer API toggle, contextual sidecar URL field.
  - File Manager / Archives / MakerWorld get a Slice button gated on
    the Use Slicer API setting.
  - gcode-viewer adapter learns ?library_file=<id> so sliced library
    files preview inline.

  i18n
  - New slice.* and settings.{useSlicerApi,slicerCard,orcaslicerApiUrl,
    bambuStudioApiUrl,slicerApiUrlDescription,useSlicerApiDescription}
    + fileManager.noPermissionSlice keys across all 8 locales (en, de,
    fr, it, ja, pt-BR, zh-CN, zh-TW). English fully translated, German
    fully translated, the other six seeded with English fallbacks
    pending native translation.

  Tests
  - 10 backend integration tests in test_library_slice_api.py covering
    validation (404/400), happy-path enqueue, sidecar-down, 3MF
    embedded-settings fallback, STL no-fallback, and preset-error ->
    failed job paths.
  - New unit tests in test_slicer_api.py for the HTTP bridge.
  - 5 new SliceModal frontend tests covering preset gating, library +
    archive enqueue paths, error surface, and preset-load failure.
  - Existing SettingsPage tests adjusted: slicer dropdown asserts now
    switch to the Workflow tab first; added a beforeEach URL reset so
    one test's tab click doesn't bleed into sibling tests.

  Sidecar
  - New slicer-api/ folder is self-contained and optional. Two services
    (orca-slicer-api on 3003, bambu-studio-api on 3001 behind --profile
    bambu) build via Docker git-build-context from
    maziggy/orca-slicer-api@bambuddy/profile-resolver. The fork patches
    the OrcaSlicer CLI's profile compatibility quirks (inherits-chain
    resolver, from:User -> system rewrite, '# ' clone-prefix strip,
    sentinel-value strip) empirically required to slice real GUI
    exports without segfaulting the CLI.

  Docs
  - CHANGELOG entry under [0.2.4b1] - Unreleased Added.
  - README File Manager bullet for the new server-side Slice button.
  - bambuddy-website features.html: new card under "Configurable Slicer".
  - bambuddy-wiki: new page features/slicer-api.md + nav entry +
    features index card.

  Notes
  - Opt-in: with Use Slicer API off, the existing "open in desktop
    slicer via URI" flow is the default and unchanged.
  - 3MF inputs that segfault the CLI on --load-settings transparently
    retry with embedded settings; the resulting job carries
    used_embedded_settings: true.
  - Sliced files always export as .gcode.3mf so File Manager picks up
    the embedded thumbnail; file_type is set to "gcode" (blue badge).
2026-04-27 15:28:37 +02:00
maziggy c44b62195a refactor(gcode-viewer): archive-scoped previews, bed from capabilities, plate picker
Reshapes the embedded PrettyGCode viewer (landed in #963) into a focused
  archive-preview tool, matching Bambuddy's data model instead of the
  OctoPrint-style "connected-printer + library file picker" flow it shipped
  with. Reached only from the Archives page 3D-preview button; URL
  /gcode-viewer?archive=<id>[&plate=<N>].

  Backend:
  - /archives/{id}/gcode accepts ?plate=N and resolves the filename by
    parsing the suffix as int, so zero-padded names like plate_01.gcode
    are found when the plates endpoint reports index 1.
  - /archives/{id}/plates gains top-level has_gcode: bool. Source-only
    3MFs (PNG/JSON fallback path) surface the flag so the frontend can
    skip the picker instead of sending the user into a dead viewer.
  - printer_state_to_dict injects name + model into every WS snapshot so
    consumers render proper labels on the initial tick without racing a
    separate /printers fetch.
  - /gcode-viewer (no trailing slash) dropped from the backend so reloads
    fall through to the SPA catch-all and keep the layout shell; only
    /gcode-viewer/ (trailing slash) and /gcode-viewer/<path> remain for
    the iframe + static assets.

  Frontend:
  - PlatePickerModal shown only for multi-plate archives with sliced
    gcode, grid layout with thumbnails matching the Re-print modal.
  - Source-only archives show a noGcode toast instead of the empty
    viewer.
  - ArchivesPage navigate path swapped to /gcode-viewer?archive=<id> with
    no trailing slash; GCodeViewerPage iframe forwards
    window.location.search so the archive reference survives both the
    initial navigate and a full-page reload.
  - Viewer iframe's auth path: fetch intercept injects Bearer; a 401
    redirects to / so the SPA handles login.

  Viewer adapter:
  - Stripped the printer selector, WebSocket subscription, library file
    picker, tryAutoLoadPrintingFile, BAMBU_BED_SIZES, and updatePrinter-
    Selector. The viewer no longer observes live printer state.
  - Bed size derived from /archives/{id}/capabilities.build_volume
    (extracted from the 3MF's printable_area/printable_height), so H2D,
    H-family, and any future printer render on the correct bed without
    a hardcoded map.
  - loadArchiveById accepts a plate param; fetch intercept rewrites
    __bambuddy_archive_<id>[_plate<N>] to /archives/<id>/gcode[?plate=N].

  Nav + locale cleanup:
  - Sidebar "GCode Viewer" nav entry removed (viewer is archive-scoped
    now, not a destination page).
  - 32 orphaned gcodeViewer locale keys deleted across all 8 locales.
  - platePicker.{title, hint, plateLabel, objectCount, noGcode} keys
    added in all 8 locales.

  ArchivesPage: the now-unreachable ModelViewerModal render paths + its
  showViewer state removed. ModelViewerModal itself stays — File Manager
  still uses it for library file previews (plate picker + .3mf 3D model).

  pre-commit:
  - gcode_viewer/ excluded from trailing-whitespace + end-of-file-fixer
    so vendored third-party JS libs don't drift away from upstream.

  Incidental sweeps picked up by pre-commit and kept (unrelated but
  benign):
  - NotificationsPage.tsx: single trailing-whitespace line removed.
  - spoolbuddy/scripts/pn5180_diag.py: dead `import gpiod` dropped —
    the pn5180 driver module imported at line 27 does its own
    `import gpiod` and `gpiod.Chip()` calls, so the diag script's
    top-level import was never referenced.

  Tests:
  - 6 new cases in test_gcode_viewer.py for the backend plate / has_gcode
    behaviour (plate=N resolution, zero-padded filenames, missing-plate
    404, no-plate fallback, plate=0 rejection, has_gcode true/false).
  - 3 new cases in test_printer_manager.py for name/model WS injection.
  - PlatePickerModal.test.tsx — 6 frontend cases covering render,
    plate-name composition, onSelect payload, backdrop close, and
    thumbnail fallback.
2026-04-22 13:03:09 +02:00
Sn0rrii ba1c97c808 feat: Two-Factor Authentication (TOTP, Email OTP) and OIDC/SSO – full implementation with admin UI (#933)
feat: Two-Factor Authentication (TOTP, Email OTP) and OIDC/SSO – full implementation with admin UI (#933)
2026-04-13 13:24:28 +02:00
maziggy 8266d225d2 fix(energy): date-range energy in total mode + restart-resilient per-print tracking (#941)
The Statistics page reported "Gesamt" (All Time) kWh correctly but showed
  zero for Today/Week/Month in total-consumption mode. Two bugs drove it:

  1. The starting plug counter was kept in an in-memory dict
     `_print_energy_start` that was lost on any backend restart mid-print, so
     the per-print `energy_kwh` delta silently never got computed. The stats
     endpoint's fallback path `SUM(PrintArchive.energy_kwh)` therefore summed
     to zero for users running in total mode.
  2. Total-consumption mode has no per-print delta by design — it includes
     idle/preheat/standby — so the fallback to archive rows was the wrong
     strategy even when the data existed.

  Fix, in two parts:

  - Persist `energy_start_kwh` on the archive row and read it back from a
    fresh session at print end. Deletes `_print_energy_start` and its 5
    call sites, replacing them with a single `_record_energy_start()` helper.
    Per-print tracking is now restart-resilient regardless of tracking mode.
  - Add hourly `smart_plug_energy_snapshots` table + `_snapshot_loop()` in
    SmartPlugManager. Rewrote the `/archives/stats` energy branch as
    `_sum_snapshot_deltas()` which computes per-plug
    `max(0, last-in-range - baseline)` where baseline is the latest snapshot
    at or before the range start, falling back to the earliest-ever snapshot
    and signalling `energy_data_warming_up` when no pre-range baseline
    exists (fresh upgrade). MQTT plugs are skipped from snapshots since they
    only report "today" and have no lifetime counter.

  Frontend: QuickStatsWidget renders an AlertTriangle next to Energy Used /
  Energy Cost with a tooltip when `energy_data_warming_up` is true, so the
  "low values right after upgrading" situation is explained in-product.
  Fully localised across 7 UI languages.

  Tests: new backend unit tests cover the snapshot delta arithmetic
  (baseline/endpoint, counter reset clamp, multi-plug, warming-up fallback,
  endpoint windowing), per-print restart resilience via expunge_all, and the
  snapshot task lifecycle (start idempotent, stop cancels). Frontend tests
  assert the warning icon appears only when the flag is set and only on the
  energy tiles.

  Docs: updated `CHANGELOG.md`, `README.md`, wiki `features/energy.md`,
  wiki `features/statistics.md`, and website `features.html` with the new
  behaviour and warming-up explanation.
2026-04-11 11:14:54 +02:00
maziggy 7fa00ed475 Fix path traversal vulnerability in file upload endpoints
Archive upload endpoints used the client-supplied filename directly
  in file paths, allowing an authenticated attacker to write files
  outside the intended directory (e.g. ../../evil.3mf bypasses the
  .3mf extension check). Added _safe_filename() helper that normalizes
  backslashes and extracts the basename before constructing paths.
2026-04-04 12:41:01 +02:00
maziggy 610431d6b7 Add optional PostgreSQL database support
Bambuddy can now use an external PostgreSQL database via the
  DATABASE_URL environment variable. SQLite remains the default.
  Dialect-aware helpers handle upserts, PRAGMAs, FTS (FTS5 vs
  tsvector+GIN), backup/restore, and health checks. All migration
  blocks use savepoints to prevent Postgres transaction poisoning.
  Backups are always portable SQLite format regardless of backend.
  Cross-database restore imports SQLite backups into PostgreSQL
  with automatic boolean/datetime conversion, NOT NULL default
  filling, and FK constraint handling.
2026-04-03 11:33:29 +02:00
maziggy 914adde5aa Add REST/Webhook smart plug type (#472) 2026-04-01 10:06:01 +02:00
maziggy ec0ae162e8 Add per-user statistics filtering (#730)
Admins can now filter the Statistics page by user via a new
  stats:filter_by_user permission. A user dropdown appears in the stats
  header showing all users plus "No User (System)" for prints without
  attribution. The filter applies to all stats widgets, failure analysis,
  and CSV/Excel exports. Backend validates the permission on all 4 stats
  endpoints, returning 403 if the filter is used without authorization.
2026-03-31 12:13:58 +02:00
maziggy 3887938e8f Security: add token-based auth for all media endpoints
Camera streams, snapshots, thumbnails, timelapse videos, photos, QR
  codes, and cover images served via <img>/<video> tags were previously
  unauthenticated because browser media elements cannot send Authorization
  headers. When auth is enabled, these endpoints are now protected by a
  reusable stream token (?token=xxx) obtained from POST
  /printers/camera/stream-token (requires CAMERA_VIEW permission).
2026-03-27 12:58:08 +01:00
Keybored 92c3ce3993 [Feature] Rework Archive duplicates tagging (#718)
[Feature] Rework Archive duplicates tagging (#718)
2026-03-16 15:40:24 +01:00
maziggy d8770b2bce Fix energy costs not updating per timeframe in statistics (#695)
In "total" energy tracking mode, the stats endpoint queried smart plug
  lifetime counters which can't be filtered by date range. Energy costs
  and kWh stayed the same regardless of timeframe selection. Fall back to
  per-print archive data when date filters are active.
2026-03-14 10:18:27 +01:00
AneoPsy ff3d3a82a7 feat(stats): add hourly heatmap for short timeframes and fix timezone bugs
Print Activity now shows an hourly heatmap (hours x days) for timeframes
≤7 days and dynamically adjusts calendar months for longer ranges.
Adds hourly granularity to Filament Trends, persists timeframe selection,
fixes optional chaining in QuickStatsWidget, and fixes UTC/local timezone
mismatches in date key generation. Also hardens the /archives/slim limit
param and fixes empty query string handling in API client.
2026-03-01 03:43:40 -10:00
AneoPsy 168c00f47d feat(stats): add /archives/slim endpoint and fix dashboard bugs
- Add lightweight GET /archives/slim endpoint with column-level SELECT
  (13 fields vs 46), skipping duplicate detection for ~70-80% payload
  reduction on the stats dashboard
- Add ArchiveSlim Pydantic schema and TypeScript type
- Switch StatsPage and FilamentTrends to use ArchiveSlim
- Fix critical bug in failure_analysis.py: use effective_days for week
  count, separate non-date filters, build fresh week_filter per loop
- Fix busiestDay timezone bug: parse YYYY-MM-DD with split() + local
  Date constructor instead of new Date() which creates UTC midnight
- Fix success streak ordering: sort by completed_at || created_at
  instead of created_at alone
- Add 6 integration tests for /archives/slim endpoint
2026-03-01 03:11:28 -10:00
AneoPsy 792a86f1e5 feat(stats): add new widgets, shared MetricToggle, and timeframe filtering
Add Print Time of Day, Color Distribution, and Records widgets to the
Stats page. Extract shared MetricToggle component for consistent
weight/prints/time toggles. Add date range filtering to archives and
stats API endpoints. Reorganize widgets into parent cards (Printer Stats
and Filament Trends) and fix Dashboard layout persistence for new widgets.
2026-03-01 03:11:28 -10:00
maziggy fe5bb1fd90 Fix naive-vs-aware datetime crash from 0.2.1 timezone migration
The timezone fix (ed36eaf) replaced datetime.utcnow() with
datetime.now(timezone.utc) across ~80 call sites, but SQLAlchemy's
SQLite DateTime columns strip tzinfo on read, returning naive datetimes.
Any Python-side comparison (subtraction, <, >) between an aware "now"
and a naive DB value raises TypeError.

Add `if value.tzinfo is None: value = value.replace(tzinfo=timezone.utc)`
guards at all 8 affected comparison sites:
- maintenance.py: last_performed_at subtraction (500 on /maintenance/overview)
- auth.py: API key expires_at check (2 locations)
- print_scheduler.py: scheduled_time comparison
- smart_plug_manager.py: auto_off_pending_since elapsed calc
- smart_plugs.py: power_alert_last_triggered cooldown
- main.py: last_runtime_update elapsed calc
- archives.py: timelapse completed_at fallback
2026-02-27 17:39:32 +01:00
maziggy ed36eafbec Fix timestamps off by timezone offset in non-UTC containers (#504)
All backend timestamps used datetime.now() (server local time) or the
deprecated datetime.utcnow(). The frontend's parseUTCDate() assumes
timestamps without timezone indicators are UTC and appends 'Z', so
stored timestamps were off by the timezone offset when the container's
timezone wasn't UTC.

Backend: replaced datetime.now() and datetime.utcnow() with
datetime.now(timezone.utc) across 16 files (~80 call sites) for all
database fields and DB comparisons. Cosmetic timestamps (filenames,
user-facing local time formatting) intentionally left as local time.

Frontend: replaced 13 new Date(backendTimestamp) calls with
parseUTCDate() across 8 files to correctly interpret UTC timestamps.
2026-02-24 08:18:02 +01:00
maziggy 055243683c Fix archive endpoints crash with "Is a directory" for BambuStudio prints (#475)
When FTP download of a 3MF fails (e.g. BambuStudio-initiated prints),
the fallback archive has file_path="". Path.exists() on
settings.base_dir / "" resolves to the base directory itself and
returns True, so ZipFile() then fails with [Errno 21] Is a directory.

Replace .exists() with .is_file() across all 15 archive route checks
and 1 in main.py. Add file_path truthiness guard for finish photo
capture to prevent saving photos under the base directory.
2026-02-21 11:35:18 +01:00
maziggy 442059bf2a Fix finish photo 404 for prints started from BambuStudio (#474)
When a print was started from BambuStudio (not Bambuddy), the
auto-archive has an empty file_path. The photo save code uses
base_dir / Path("").parent which resolves correctly, but the
serve/upload/delete endpoints used (base_dir / "").parent which
goes one directory level too high — returning 404 despite the
photo existing on disk.
2026-02-21 08:53:41 +01:00
maziggy 1404242e0c Fix cost tracking issues 2026-02-20 16:31:16 +01:00
MartinNYHC 965000c3ca Merge branch '0.2.1b2' into feature/cost_tracker 2026-02-20 16:26:01 +01:00
Wesley Reuel Marques SilvaandMartinNYHC ceb2de7164 Adding the energy cost from 2 to 3 decimal precision (#416)
* Adding the energy cost from 2 to 3 decimal precision

* Complying with pr

* Complying with PR

* Complying with PR

* Fix energy cost display precision in ProjectDetailPage

* Change energy cost formatting to two decimal places

* Change decimal precision for energy cost display

---------

Co-authored-by: MartinNYHC <mz@v8w.de>
2026-02-20 16:23:00 +01:00
Keybored 85645ce184 Merge branch '0.2.1b2' into feature/cost_tracker 2026-02-20 15:44:34 +01:00
Matteo Parenti 427b08f9f6 Add archive_id to SpoolUsageHistory model and update cost calculations in usage_tracker 2026-02-20 13:39:41 +01:00
Matteo Parenti 9f19e8a2ff Add currency symbol, hide projected cost for no costs configured, add cost rounding to decimal point, fixed isExternal logic 2026-02-20 11:36:26 +01:00
Ryan Ewen 4a625c1ef2 Add background print dispatching + tests 2026-02-20 08:51:13 +01:00
Matteo Parenti 071c2c702a Refactor cost calculation logic to utilize pre-fetched spool usage costs and remove redundant imports; update InventoryPage to adjust cost display format 2026-02-19 18:22:07 +01:00
Matteo Parenti 8043bb18ba Refactor cost calculation logic to prioritize spool usage history and update schema validation for cost_per_kg 2026-02-19 16:55:25 +01:00
maziggy a903816b8a fix(slicer): token-based auth for Open in Slicer downloads (#433)
Slicer protocol handlers (bambustudio://, orcaslicer://) launch the
slicer app which fetches files via HTTP but cannot send auth headers.
The global auth middleware returned 401, causing "importing failed."

Add short-lived, single-use download tokens: the frontend fetches a
token via authenticated POST, then builds a /dl/{token}/{filename} URL
the slicer can access without auth headers. Tokens are validated
server-side (5-min expiry, single-use). Applies to archive files,
source 3MFs, and library files.

Also fix platform-specific URL formats to match slicer source code:
- macOS: bambustudioopen:// with encodeURIComponent
- Windows/Linux: bambustudio://open?file= (was wrongly using macOS scheme on Linux)
- OrcaSlicer: orcaslicer://open?file=
2026-02-18 16:37:35 +01:00
maziggy bc78f3f456 feat(timelapse): add upload and remove timelapse management (#406)
When the auto-scan attached the wrong timelapse (e.g. from a different
print), there was no way to fix it — the file couldn't be removed and
re-scanning was disabled once a timelapse was attached.
2026-02-17 08:49:32 +01:00
maziggy c82521efc9 fix(timelapse): support AVI format from P1-series printers (#405)
P1S/P1P save timelapses as AVI (MJPEG), but the scanner only looked
for MP4 files — so P1-series timelapses were never found or attached.
2026-02-17 08:29:47 +01:00
maziggy cf7c459099 fix(inventory): fix usage tracking for remapped AMS slots and slicer prints
Three fixes for inventory spool usage not updating after prints:

1. Store ams_mapping from print command (reprint, library print, queue)
   so the usage tracker maps 3MF slots to the actual physical trays
   the user selected, not the default slicer mapping.

2. Track last_loaded_tray on printer state — the last valid tray_now
   (0-253) seen during printing. On H2D printers, tray_now is always
   255 in AMS data; the real tray resolves via snow field ~44s after
   print start but reverts to "unloaded" at completion. The fallback
   chain is: tray_now_at_start > current tray_now > last_loaded_tray.

3. Use color similarity (Euclidean RGB distance, threshold 50) instead
   of exact hex match for auto-unlink fingerprint checks. RFID sensors
   report slightly different shades across reads (e.g. distance ~43.6
   for the same spool), causing false assignment unlinks after prints.
2026-02-16 15:46:32 +01:00
maziggy 2de2d3be8d fix: detect archive duplicates by print name in list view (#315)
The duplicate badge on archive cards only matched by content hash,
so re-sliced prints of the same model (different GCODE, same name)
were not flagged. Now also matches by print name (case-insensitive),
consistent with the detail view's find_duplicates() logic.
2026-02-15 14:05:11 +01:00
maziggy 045326d8ca fix: disable reprint for archives without 3MF file (#376)
When a print is sent from an external slicer and Bambuddy can't
download the 3MF during auto-archiving, the fallback archive has no
file. Reprinting such archives tried to upload the data directory,
causing a confusing SD card error. Backend now returns a clear 404
for empty file_path and checks is_file() instead of exists(). Frontend
disables Print/Schedule/Open in Slicer for file-less archives with an
explanatory tooltip. Added i18n key in all 5 locales.
2026-02-15 10:28:23 +01:00
maziggy f9b47282a1 Nozzle-aware AMS mapping for dual-nozzle printers, BL spool detection fix, AMS startup fix, SQLite WAL (#318)
Dual-nozzle H2D/H2D Pro: filament matching now respects nozzle assignments
from the 3MF file. Each AMS unit feeds a specific nozzle (L/R), and the
scheduler/frontend constrain matching to only trays on the correct nozzle.
Falls back to unfiltered matching when no trays exist on the target nozzle.
L/R badges shown in the filament mapping UI. Translated in en/de/ja/it.

Fix AMS slot config overwritten on startup: on_ams_change unconditionally
unlinked BL spool assignments on every MQTT pushall, then re-assigned them
sending ams_filament_setting without setting_id — clearing the printer's
filament preset. Now compares spool RFID identifiers before unlinking.

Fix BL spool detection false positives: removed tray_info_idx from detection
logic in both backend is_bambu_lab_spool() and frontend isBambuLabSpool().
Third-party spools using Bambu generic presets had GF-prefixed tray_info_idx
values, causing misidentification. Now uses only tray_uuid and tag_uid.

SQLite WAL mode with 5s busy timeout reduces "database is locked" errors.
2026-02-13 11:48:32 +01:00
maziggy 4dc3aaea5b Fix energy cost showing 0.00 in "Total Consumption" mode (fixes #284)
homeassistant_service.get_energy() was called without first configuring
the service with the HA URL and token, so it returned None for all
Home Assistant smart plugs. Added configure() call before the plug
loop, matching the pattern used in main.py and smart_plugs.py.
2026-02-07 11:08:18 +01:00
maziggy 4b46e443dc Fix critical FTP upload failure and revert dangerous exception narrowing
The CodeQL cleanup in "Housekeeping" (2b11efd) bulk-narrowed except
clauses across 50+ files, breaking FTP uploads on ALL printer models.
ftplib.error_perm (550 errors) is not a subclass of ftplib.error_reply,
so diagnose_storage() CWD failures escaped the handler and prevented
STOR from ever executing — causing 100% upload failure and HTTP 500s
on /api/v1/archives/{id}/reprint and /api/v1/library/files/{id}/print.

FTP fixes:
- Remove diagnose_storage() from upload hot path
- Change all except (OSError, ftplib.error_reply) to
  except (OSError, ftplib.Error) across bambu_ftp.py

Exception handling reverts (9 files):
- Revert narrowed except clauses back to except Exception in route
  handlers and service code where broad catches are intentional
  defensive programming (archive parsing, HTTP clients, 3MF/ZIP
  processing, Home Assistant, firmware checks)
- Keep narrow exceptions only where safe (single-op blocks like
  int(), file.unlink(), socket.close())
- Remove unused XMLParseError imports from archive.py, threemf_tools.py

Closes #287
2026-02-07 09:20:06 +01:00