Commit Graph
2881 Commits
Author SHA1 Message Date
maziggy 0de71ca412 fix(printer): "off" flow_cali / nozzle_offset_cali now actually suppress the stage
The Re-print and Schedule modal toggles for Flow Calibration and Nozzle
  Offset Calibration accepted "off" correctly and flowed it through to the
  project_file MQTT publish — Bambuddy sent extrude_cali_flag: 2 and
  nozzle_offset_cali: 2 per the "1 = run, 2 = skip" reading inherited from
  the #1478 / #1682 work. Live test on H2D 01.x: with both toggles off,
  the stg queue still scheduled stage 8 ("Calibrating dynamic flow") and
  stage 39 ("Nozzle offset calibration"), and the printer ran both at
  print start.

  Root cause: 2 means "skip the explicit pass but still apply / verify
  stored PA via the calibration stage" — close to a no-op K-factor wise
  but the per-print physical sequence still runs. 0 is the encoding that
  actually drops the stage from stg. A BambuStudio Send-dialog capture
  on the same firmware showed 0 for both fields when the user unchecked
  the calibrations — contradicting the #1478 commit's read of "BambuStudio
  never sends 0."

  Fix:
  - extrude_cali_flag = 1 if flow_cali else 0  (was: else 2)
  - nozzle_offset_cali = 1 if (nozzle_offset_cali and is_dual_nozzle) else 0
    (was: else 2)

  Dual-nozzle gate stays; single-nozzle prints continue to force-skip the
  nozzle-offset cali their head doesn't support (#1682). The 1 (run)
  branch is unchanged.

  Verified live on the same H2D after the patch: stg dropped to
  [29, 13, 4, 14, 3] (cooling, homing, filament change, nozzle cleaning,
  vibration comp). Stages 8 and 39 gone.

  Vibration compensation is NOT fixed by this commit: vibration_cali is a
  bool in our and BambuStudio's wire format, and the H2D firmware queues
  stage 3 regardless of the false value. Firmware-side, not solvable at
  the dispatch layer with the current field. Filed as a follow-up.
2026-06-12 16:33:56 +02:00
maziggy 7190fc2d13 fix(logs): demote benign "not connected" + "may linger" warnings
Two warnings polluting every A1 support bundle on healthy prints, both
  unrelated to the timelapse-default behaviour the issue actually reports.

  1. mqtt_bridge.py's post-bind nudge calls request_status_update on the
     real printer's MQTT client to populate the bridge cache without
     waiting for the next periodic pushall. The bind frequently races the
     TLS handshake, especially on A1 firmware. Skip the nudge when
     state.connected is False — the periodic pushall fills the cache
     anyway. The WARNING in bambu_mqtt.py stays for the genuinely-
     actionable callers (refresh-status API, bug reporter).

  2. Post-finish SD-card cleanup (and the symmetric forced-timelapse dir
     walk) used delete_file_async's bool return to drive a WARNING when
     all candidates failed. A1 firmware self-cleans the SD card before
     our cleanup runs — every candidate FTP-DELE returns 550, we burn
     the retry budget, then WARN on a successful print. Introduce
     DeleteResult.{DELETED,NOT_FOUND,FAILED} so the helpers only WARN
     on real network/auth/transient failures. NOT_FOUND advances to the
     next candidate without consuming the 2s backoff. User-facing delete
     endpoint returns 404 on NOT_FOUND.
2026-06-12 15:13:40 +02:00
maziggy 1bcd5c8ba5 feat(support): bundle redacted cached push_status per connected printer
The support bundle shipped support-info.json + bambuddy.log, but the raw
  shape of the printer's MQTT push_status — the field that blocks per-model
  work like AMS Backup detection (deferred in 85fbd7fc) and every vt_tray /
  vir_slot / mapping shape regression — was never captured.

  Each connected printer now contributes push-status/printer-{i}.json with
  {model, firmware_version, captured_at, raw_data}, indexed against
  support-info.json["printers"]. Two-pass redaction: a structural walk
  drops user-private keys (subtask_name, gcode_file, subtask_id, task_id,
  project_id, design_id, profile_id, model_id, gcode_state,
  gcode_file_prepare_percent) and rewrites net.info[*].ip to 0.0.0.0
  (matches the #1429 VP bridge fix); then the JSON runs through the same
  DB-derived sensitive_strings sanitizer the log path uses, catching any
  printer name / serial / access code / cloud email that leaked into a
  nested string field.

  print.cfg, print.option, ams, vt_tray, vir_slot, mapping,
  ams_extruder_map, and hardware fields are all preserved — those are the
  fields per-model work needs.

  Always-on inside the existing debug-logging-required gate; no opt-in
  toggle (the bundle is already user-initiated and downloads locally
  before the user chooses to send).
2026-06-12 14:52:21 +02:00
maziggy 9c4252911b fix(vp): overlay incoming dict-shaped push_status fields onto cache instead of replacing (#1622 round 5)
Right after the slicer picks a filament for the external spool (vt_tray, ams_id=255),
  Bambu firmware pushes a partial vt_tray carrying just {tray_info_idx, tray_color} -
  ~18 fields shorter than the pushall shape the slicer expects. The #1622 round-4
  per-field accumulate (da799447) only carried over prev keys NOT in new, so the
  cached vt_tray was replaced wholesale with the 2-field partial. The next 1 Hz
  cached-as-base push delivered the stripped dict and BambuStudio rendered the
  external slot as invalid (color only, no tray_type / state / k / n / cali_idx /
  nozzle_temp_*). Reload restored it because the reconnect-triggered pushall
  re-seeded vt_tray, then the cycle repeated. AMS slots didn't suffer because
  _merge_ams_dict deep-merged them.

  Fix: for every top-level push_status key whose prev AND new are both dicts,
  overlay incoming keys onto prev rather than replace. ams is excluded (already
  deep-merged). The same shape protects device / online / upgrade_state / ipcam /
  upload / net against future firmware partials. net.info IP rewrite is unaffected -
  _rewrite_net_info_ips runs before caching and overlay lets the freshly-rewritten
  list win over prev when present.
2026-06-12 14:04:15 +02:00
maziggy 912f9feba2 test(users/groups): generate privilege-escalation test password at runtime
GitGuardian still flagged the file after the previous round even though
  every call site used a constant — the constant itself was a static
  string built by concatenation, which the generic-password detector still
  matched on. Generate the test credential per process via secrets.token_urlsafe
  so no password literal lives in the source, and mark the single line where
  the variable is bound with the standard `pragma: allowlist secret` marker
  ggshield / detect-secrets honour.
2026-06-12 13:27:17 +02:00
maziggy d45bcb87ed test(users/groups): hoist privilege-escalation test passwords to a fixture constant
GitGuardian flagged the seven hard-coded passwords used by the
  privilege-escalation regression suite as potential secrets. They are
  test-only credentials whose value is irrelevant — the suite asserts
  the admin authorization gate, not password handling — but the pattern
  matches the high-confidence detector.

  Replace each call-site literal with a single _FIXTURE_PW module
  constant, built from string concatenation so it doesn't hash to a
  recognisable token, with a comment explaining the purpose and the
  complexity rule it satisfies. No behavioural change; all 11 tests
  still pass.
2026-06-12 13:22:45 +02:00
maziggy 61df0b68d2 fix(print-modal): allow cross-extruder AMS slot picks on dual-nozzle (#1722)
Before this change, the Re-print and Schedule modals' per-filament
  slot dropdown hid every slot whose extruder didn't match the filament's
  slicer-assigned nozzle. On H2D with AMS A+C on the left and B on the
  right, an L-assigned filament could only pick A or C and an R-assigned
  one could only pick B — locking the user out of cross-extruder picks
  even when they'd intentionally loaded the required filament into the
  other AMS.

  The slicer wasn't the source of the asymmetry: BambuStudio Desktop,
  OrcaSlicer Desktop, and the Bambuddy sidecar all produced identical
  filament_map values for the same source 3MF. Bambuddy's UI filter was.

  Drop the f.extruderId === item.nozzle_id clause in FilamentMapping's
  loadedFilaments filter. Single-nozzle and FTS short-circuits stay; the
  L/R row badge stays as a hint to the slicer's intent. Printer firmware
  decides at start-print whether the cross-extruder ams_mapping is valid.
2026-06-12 13:12:20 +02:00
maziggy f2a3917e90 Security hardening (maziggy/bambuddy-security #1) 2026-06-12 11:11:38 +02:00
maziggy 857a071306 fix(library): preview sidecar-sliced .gcode.3mf rows as G-code, not ZIP bytes (#1709)
slice_and_persist writes a .gcode.3mf ZIP container but persisted the row
  with file_type="gcode". The G-code preview endpoint short-circuits on
  file_type == "gcode" and returns the bytes as text/plain, so the embedded
  viewer received the raw ZIP body instead of the embedded toolpath.

  - Persist file_type="gcode.3mf" on sliced rows (matches _classify_file_type
    and external-scan rows).
  - get_gcode also routes to the unzip branch when the filename ends with
    .gcode.3mf, so rows already written under the bug self-heal on first
    preview without a DB migration.
  - Extend FileManagerPage badge + viewer-eye gate and ProjectDetailPage badge
    to accept "gcode.3mf"; isSlicedFilename / isSliceableFilename already do.
  - Add test_library_get_gcode_recovers_legacy_gcode_type_for_3mf: legacy
    row preview must be text/plain, contain G28, and NOT start with PK.
2026-06-12 10:42:02 +02:00
maziggy 908227e4df Updated BACKERS 2026-06-12 10:15:02 +02:00
maziggy 1c42a9f1fd remove(slicer): drop bundle import; fix cloud preset type/from for CLI (#1712)
Bundle import never delivered what it implied: BambuStudio's .bbscfg export
  strips system processes/filaments, so importing a bundle left users without
  process presets and slicing fell back to embedded settings on STL. Bundle
  mode also hid the standard tier behind a constrained dropdown, the actual
  trap reported here.

  Removed end-to-end:
  - backend: POST/GET/DELETE /slicer/bundles*, SliceRequest.bundle,
    SliceBundleSpec, dispatch fork in library.py, bundle-context params on
    the filament-requirements endpoints, bundle-fingerprint cache key in
    slice_preview.py, SlicerApiService.{import,list,get,delete}_bundle and
    slice_with_bundle, BundleSummary / BundleNotFoundError.
  - frontend: BundlePicker + BundleStringDropdown, isBundleMode + every
    branch, bundle state/queries/dispatch in SliceModal.tsx, SlicerBundle /
    SliceBundleSpec types, three bundle API methods. buildCompatibilityIndex
    loses its bundle path; presetCompatibility keeps compatible_printers
    plus the @BBL fallback.
  - SlicerBundlesPanel turns into a permanent static notice explaining the
    removal, alternative import paths, and the new slice-time lookup order
    (Imported > Orca Cloud > Bambu Cloud > Standard sidecar fallback).
  - i18n: slicerBundlesRemoved.{title,description,alternatives,lookupOrder}
    translated across all 11 locales; slice.bundle*, slicerBundles.* keys
    removed.

  Fixed (surfaced by removing bundle mode):
  - _resolve_cloud and _resolve_orca_cloud now force type per slot and pin
    from: "system" on the payload before json.dumps. Bambu Cloud ships
    type as "printer"/"print" and routinely empty `from`; the BS CLI's
    --load-settings parser rejects both with return -5 / "input preset
    file invalid". Standard tier already did this; cloud paths now match.
2026-06-12 10:14:06 +02:00
maziggy 2a83aeec49 Updated .gitignore 2026-06-12 09:04:28 +02:00
maziggy da799447f6 fix(vp): accumulate cached push_status per-field instead of allowlist (#1622)
Bridge cache replaced prev state wholesale on each incremental, re-merging
  only a 14-key allowlist. Capability/lifecycle fields (cali_version,
  print_type, mc_print_stage, device, ...) drained out within one 1Hz tick,
  greying out BambuStudio's Device-tab UIs (manage-calibration, AMS-slot
  dropdown) once the cache thinned. Most P1S users miss it by timing — they
  click Device tab while the cache is still fat from the connect pushall.

  Switch to per-field accumulate matching bambu_mqtt.py's internal state
  handler: prev keys carry over verbatim when not present in the incoming
  push, new values overwrite when present. _merge_ams_dict for partial AMS
  blobs unchanged (#1387 / #1371 regression guards stay green).
  _SLICER_VISIBLE_STICKY_KEYS removed — new logic is a strict superset.
2026-06-11 17:23:49 +02:00
maziggy 282aefc564 Sync and housekeeping 2026-06-11 15:39:02 +02:00
maziggy 5644f11495 debug(vp): env-flagged bridge-synthesised reply trace for slicer↔printer #1622 round-3 triage
Round-2 cmd.jsonl from shaddowlink proves the bridge forwards both commands and
  responses correctly: ams_filament_setting round-trips with result=success on P1S,
  the cached push_status carries tray_info_idx=GFA11/tray_type=PLA-AERO/K-n/cali_idx
  intact, and the visible "unload" symptom comes from the slicer's choice of
  extrusion_cali_set (push K direct, P1S firmware rejects) vs extrusion_cali_sel
  (select by id, both H2D and P1S accept). The open question is what makes the
  slicer pick _set vs _sel — likely the info.get_version response Bambuddy
  synthesises or the first cached pushall reply the slicer reads at connect.
  Round 2 captured neither; the JSONL had slicer_to_bridge and printer_to_slicer
  but no direction for the bridge's own synthesised replies.

  Same BAMBUDDY_VP_DUMP_WIRE=1 flag now also appends a bridge_to_slicer line for
  every bridge-synthesised reply (info.get_version answer, project_file ack,
  on-demand pushall response). Capture is in _publish_to_report — the single
  chokepoint — gated on a new log_event param; the 1Hz periodic push threads
  log_event=False so the JSONL isn't flooded (~60 lines/min/VP) because
  dump_wire already covers cache shape per tick.

  Diagnostic-only, no data-path change. Default param preserves every existing
  call site's behaviour.
2026-06-11 15:09:24 +02:00
maziggy 6b477088a2 fix(queue): extend Charcoal-style label fix to Specific-Printer panel (#1718 round 3)
Round 2 fixed the model-mode FilamentOverride: tray_info_idx →
  sub-brand, plus a material-disambiguated colour name from a new
  /inventory/colors/by-material endpoint. The printer-mode panel that
  renders the same 3MF (FilamentMapping) was reading the same raw
  fields — item.type for the required label, getColorName(item.color)
  for the swatch tooltip — and was not touched, so picking "Specific
  Printer" still showed "Required: PLA - Black" for a slice the
  "Any H2D" branch already labelled "Bambu PLA Matte - Charcoal".

  Extract the three-query resolution machinery from FilamentOverride
  into a shared hook useFilamentLabels (returns positional
  {resolvedName, colorLabel} per slot). Both panels call it; both
  read the same labels. The hook also owns extractMaterialHint so the
  "strip leading brand token" rule has one source of truth.

  FilamentMapping required-side now reads {resolvedName} instead of
  {item.type}; swatch tooltip reads `Required: {resolvedName} -
  {colorLabel}` instead of `Required: {item.type} -
  getColorName(item.color)`.
2026-06-11 14:49:52 +02:00
maziggy 3b1395e3c0 fix(queue): surface force-color-match checkbox in specific-printer mode (#1717)
The Print Queue's schedule dialog hid the per-slot "Force color match"
  checkbox when a specific printer was picked, even though the scheduler
  in print_scheduler.py:535 honours force_color_match regardless of how
  the queue item was created. Model-mode ("Any A1") rendered
  FilamentOverride which carries the checkbox; printer-mode rendered
  FilamentMapping which had no force-match UI at all. Pure UI gap.

  Extend FilamentMapping to accept optional forceColorMatch +
  onForceColorMatchChange props, mirroring FilamentOverride's signature,
  and render the same <Palette>-iconed checkbox under each filament row
  when a handler is wired. PrintModal/index.tsx passes the existing
  forceColorMatch state through — same object both modes write into so
  toggling between modes preserves the user's selection. No new i18n
  keys (printModal.forceColorMatch already ships in all 11 locales).
2026-06-11 13:51:39 +02:00
maziggy 19eed8eba0 debug(vp): env-flagged command-flow trace for slicer↔printer #1622 round-2 triage
The shape-of-payload dump shipped earlier rules out cache wipes —
  shaddowlink's round-1 captures show AMS data reaches the slicer
  byte-identical to what the printer sent. The remaining symptom
  (picking a generic filament in archive mode "unloads" the slot) lives
  on the command path, which the snapshot dump doesn't see: it writes
  only the cached _latest_print_state and the periodic 1Hz push.

  Add append_event() in _debug.py — same env flag, separate file at
  <log_dir>/vp_wire/<vp>_cmd.jsonl. One JSONL line per event with UTC
  iso timestamp, direction (slicer_to_bridge / printer_to_slicer), MQTT
  topic, <channel>.<command> grep handle, and parsed payload. Wired at
  two points: mqtt_server._handle_publish for slicer publishes (after
  JSON decode so the trace matches what the bridge actually parsed) and
  mqtt_bridge._on_printer_raw "everything else" branch for printer
  responses (after serial rewrite so the trace matches what the slicer
  sees on the wire). Pushall / get_version stay out — both are handled
  locally and never round-trip through the bridge.

  Bytes payloads get the same \x00-tolerance fix from #927 so
  OrcaSlicer's C-string-null publishes parse cleanly; un-parseable
  bytes fall back to {"raw": "..."} so every line stays valid JSON.
2026-06-11 13:35:20 +02:00
maziggy 6ef0df6ca0 fix(updater): route every git step through app_dir for separate-mount installs (#1715)
Native installs that follow the systemd template
    WorkingDirectory=/opt/bambuddy
    Environment="DATA_DIR=/srv/bambuddy/data"
  (or any layout where DATA_DIR is not a subdirectory of the install)
  could not apply in-app updates. Every git subprocess in _perform_update
  used cwd=settings.base_dir and safe.directory={base_dir}. On standard
  installs (DATA_DIR=INSTALL_PATH/data) this happened to work by accident
  because git walks up from a subdirectory of the repo to find .git; on
  separate-mount layouts the walk has nowhere to go and every call
  returns "fatal: not a git repository." safe.directory was also wrong
  even on the standard install -- it must equal the repo root git
  discovers, not the data dir.

  Resolve app_dir = settings.app_dir at the top of _perform_update and
  route all four git subprocesses (remote get-url, remote set-url, fetch,
  reset --hard) and the embedded safe.directory through it. Rename the
  base_dir parameter on _origin_points_at_repo to app_dir so the
  signature documents the contract.
2026-06-11 12:40:32 +02:00
maziggy d459b6eabb fix(slicer): preset visibility + lookup precedence + signed-out banner + AMS slot badges (#1712)
Four #1712 issues from the 2026-06-04 Orca Cloud integration:

  (1) Tier order put Orca Cloud above everything across SliceModal,
  auto-pick scoring, dropdown groups, the AMS slot picker, and the
  backend precedence. Bambu-Cloud-only users saw their profiles
  deprioritised behind an empty Orca tier.

  (2) Cross-tier dedup hid a same-named preset in all but the highest-
  priority tier. A user with both a local-imported and an Orca-synced
  "Bambu PLA Basic" couldn't see the Orca copy as a picker option.

  (3) CloudStatusBanner nagged signed-out users with a permanent
  "Sign in to Orca Cloud" line at the top of every slice -- even after
  explicit logout. Bambu Cloud had the symmetric problem.

  (4) ConfigureAmsSlotModal source badges were inconsistent: Orca rows
  showed only "Custom" (no source identity), Bambu Cloud built-in rows
  had no badge at all, and the orthogonal isUser-driven "Custom" badge
  collided with the source badge for cloud user presets.

  Order is local > orca_cloud > cloud > standard everywhere it lives
  (SliceModal SLICE_MODAL_TIER_ORDER + TIER_BONUS + dropdown tier list,
  ConfigureAmsSlotModal sourceOrder, and backend precedence). The order
  drives auto-pick + visual group rendering; it does NOT hide profiles.

  _dedupe_by_name replaced with _enrich_cloud_metadata: every tier
  returns its full list across all three slots (printer / process /
  filament). The function still backfills Bambu Cloud filament metadata
  from same-named local / orca_cloud / standard entries so cloud
  filaments score in pickFilamentForSlot.

  CloudStatusBanner silently no-ops on not_authenticated for both clouds;
  expired / unreachable still surface. The not_authenticated i18n keys
  stay in the locale files dormant.

  ConfigureAmsSlotModal: one source badge per row, one colour per source
  (green Local / purple Orca Cloud / bambu-blue Bambu Cloud / amber
  Built-in). The legacy isUser-driven "Custom" badge is gone; every row
  identifies its tier consistently.
2026-06-11 12:17:45 +02:00
maziggy 4ab7339fe4 debug(vp): env-flagged wire-payload dump for slicer-mirror triage (#1622)
Add a BAMBUDDY_VP_DUMP_WIRE=1 escape hatch that writes the bridge's
  cached push_status (in) and the 1Hz slicer-facing copy (out) to
  <log_dir>/vp_wire/<vp_name>_<direction>.json, overwritten each tick.

  #1622's symptom — empty filament dropdown in slicer's AMS slot details
  for P1S/A1 but not H2D in non-proxy VP modes — needs visibility into
  the actual wire bytes flowing through the bridge to bisect between
  "cache is missing fields" and "_send_status_report strips them on copy."
  The existing logs prove the bridge is bound and pushing at 1Hz, but
  not what's in the payload.

  Off by default, single env flag, single file per VP per direction
  (bounded disk footprint), failures swallowed at debug so a broken
  dump can never break the 1Hz loop. 21 tests pin the helper contract:
  disabled-by-default, atomic writes, sanitized vp_name (no path
  escape), per-call env check so toggling without restart works.
2026-06-11 10:02:22 +02:00
maziggy bf781e0566 fix(notifications): reprint-from-archive sent original print's finish photo (#1707)
Reprints reuse the source archive row via the expected-print promotion
  branch, but that branch never reset archive.timelapse_path. The stale
  path made _scan_for_timelapse_with_retries early-return (so the new
  run's MP4 was never downloaded), and _capture_finish_photo_from_timelapse
  then extracted the *original* run's last frame and shipped it to Telegram
  as the new run's finish photo. Surface was specific to the
  timelapse-prefer path (data.timelapse_was_active=true and no external
  camera) — fallback live-camera paths were unaffected, which is why this
  took a P2S reporter to surface.

  Clear archive.timelapse_path at promotion and unlink the stale on-disk
  MP4 (best-effort; missing files are logged and skipped). The orphan
  unlink also kills a long-standing file-leak: every reprint used to leave
  its predecessor's timelapse on disk forever. archive.photos is
  deliberately left alone — accumulating one finish photo per run is
  correct.
2026-06-11 09:23:03 +02:00
maziggy 6316ca929e install/windows-installer.ps1 2026-06-10 15:57:57 +02:00
maziggy 7bcb6b0bcf Updated README 2026-06-10 14:00:32 +02:00
maziggy 7bb11df268 feat(installer): unversioned alias on stable/beta tag releases
Adds bambuddy-windows-x64-setup.exe (unversioned) alongside the
  date-stamped bambuddy-<version>-windows-x64-setup.exe for stable
  and beta tag releases. Lets external surfaces (website, wiki,
  newsletters) link to a stable URL that survives version bumps:

    https://github.com/maziggy/bambuddy/releases/latest/download/
      bambuddy-windows-x64-setup.exe

  Daily prereleases are excluded — GitHub's `latest` redirect skips
  prereleases so the alias would add no value there, and an
  unversioned name next to a date-stamped versioned one on a daily
  release page is semantically confusing.
2026-06-10 13:41:00 +02:00
maziggy 1b8c0b2601 feat(installer): match installer filename to release tag
When the Windows build is triggered by a tag push (the path
  docker-publish.sh and docker-publish-daily-beta.sh both take), use
  the tag as the installer version instead of APP_VERSION. So a daily
  tag v0.2.5b1-daily.20260610 produces

    bambuddy-0.2.5b1-daily.20260610-windows-x64-setup.exe

  matching the docker `daily` image and the GitHub prerelease. The
  stable docker-publish.sh path is unchanged (tag v0.2.5b1 →
  bambuddy-0.2.5b1-windows-x64-setup.exe). Manual workflow_dispatch
  and local builds fall back to APP_VERSION as before.
2026-06-10 13:31:30 +02:00
maziggy 106ba7938a chore(installer): add RunOnceId to UninstallRun entries
Silences Inno Setup's "missing RunOnceId" warning. Both commands
  (stop service, remove firewall rule) are already idempotent, so the
  behavioural effect is nil — this is purely a cleanliness fix to keep
  the build log warning-free.
2026-06-10 13:25:05 +02:00
maziggy 10e190b72b fix(installer): vendor nssm.exe instead of fetching at build time
nssm.cc returned 503 mid-CI-run, breaking the staging step. NSSM 2.24
  hasn't shipped a new release since 2014, so the binary is effectively
  static — vendoring under installers/windows/vendor/nssm.exe makes
  builds reproducible and removes the only single-source download in
  the pipeline. SHA-256 pinned in the comment in build.py for audit.

  ffmpeg stays fetched from BtbN's GitHub mirror — it's actively
  maintained and large (~80MB) so vendoring it would be unreasonable;
  GitHub Releases are also far more reliable than nssm.cc.
2026-06-10 13:14:23 +02:00
maziggy 3d86ed73eb fix(installer): stop Bambuddy service before file copy on upgrade
Upgrading over a running install was failing with permission-denied
  errors on python.exe / .pyd / nssm.exe — the service held file locks
  during the [Files] copy phase. Add a PrepareToInstall hook that
  detects an existing install ({app}\bin\nssm.exe present) and stops
  the service before the overwrite. FileExists guards a no-op on
  first-time installs; the post-install [Run] step re-registers and
  starts the service fresh either way.
2026-06-10 13:09:46 +02:00
maziggy 91686e22f1 y feat(installer): version from APP_VERSION + Bambuddy icon
- build.py now reads APP_VERSION from backend/app/core/config.py
    (the canonical source used everywhere else — /system, support
    bundles, FastAPI title) instead of pyproject.toml's stale 0.1.5.
    Next installer is bambuddy-0.2.5b1-windows-x64-setup.exe.

  - installers/windows/bambuddy.ico is a multi-resolution .ico
    (16/32/48/64/128/256) generated from frontend/public/img/
    favicon.png. Wired into SetupIconFile (installer .exe icon),
    UninstallDisplayIcon (Add/Remove Programs), and the Start Menu /
    desktop shortcuts — replaces the NSSM placeholder everywhere a
    user sees Bambuddy on Windows.
2026-06-10 13:09:03 +02:00
maziggy 4b2babe752 fix(network-utils): enumerate interfaces on Windows via psutil
get_network_interfaces() used fcntl ioctls and get_all_interface_ips()
  shelled out to `ip -j addr show` — both Linux-only. On Windows the
  fcntl path raised ImportError and returned [], so the VP "bind IP"
  dropdown showed no interfaces.

  Add a Windows branch using psutil.net_if_addrs() + net_if_stats()
  (psutil is already a dep). Same dict shape as the Linux path, filters
  loopback / link-local / down interfaces by address class instead of
  by name prefix. No name-based exclusion — users may legitimately
  bind a VP to a Hyper-V / WSL / Tailscale virtual adapter.

  The Linux fcntl path is untouched.
2026-06-10 12:57:23 +02:00
maziggy 28258ec76e ci(windows-installer): add explicit permissions block
Address CodeQL actions/missing-workflow-permissions finding. Least-
  privilege at workflow level: contents: write is required by
  softprops/action-gh-release to attach the installer .exe to a tag
  release; all other steps are read-only.
2026-06-10 12:36:36 +02:00
maziggy 77843547ca fix(installer): point at vite's actual outDir, stage gcode_viewer/
vite.config.ts sets outDir to '../static' so the bundle lands at
  <repo>/static/ — not frontend/dist/. Matches the runtime expectation
  in config.py where static_dir = _app_dir / "static".

  Also stage gcode_viewer/ next to static/ so the 3D preview iframe
  routes in main.py (resolved via static_dir.parent / "gcode_viewer")
  find their assets.

  Strip macOS metadata files (.DS_Store, ._.*) from both copies — they
  leak in from dev boxes and would only bloat the installer.
2026-06-10 12:29:07 +02:00
maziggy 1b502c359f fix(installer): bootstrap setuptools + wheel into embedded Python
get-pip.py installs only pip itself; the embedded Python distribution
  ships without setuptools or wheel. pip needs setuptools.build_meta as
  the PEP 517 build backend for any sdist-only package — Bambuddy's
  requirements.txt hits this on pyftpdlib 2.2.0 (sdist-only on PyPI).
  Install both right after the pip bootstrap so requirements.txt installs
  cleanly.
2026-06-10 12:24:25 +02:00
maziggy f327b7ffeb ci(windows-installer): drop Inno Setup install step
windows-latest runners ship Inno Setup 6.7.1 pre-installed under the
  same path we already hardcode for ISCC.exe; the choco install was trying
  to downgrade to 6.2.2 and failing on the version mismatch.
2026-06-10 12:18:01 +02:00
maziggy 8711c54ec3 feat(installer): scaffold Windows installer build pipeline
Lays down the Inno Setup + embedded Python pipeline for producing a
  self-contained Bambuddy Windows installer .exe. The installer ships
  an embedded Python 3.13, the pre-built React bundle, NSSM (service
  supervisor) and ffmpeg — no host Python or Node required on the
  target machine.

  Architecture:
  - Install: C:\Program Files\Bambuddy (admin install, one-time UAC)
  - Data:    C:\ProgramData\Bambuddy\data (preserved on uninstall)
  - Service: registered via NSSM, runs as LocalSystem, autostart on boot
  - UI:      browser at http://localhost:8000 (Start Menu shortcut)

  Files:
  - installers/windows/build.py            stages embedded Python + deps,
                                           frontend bundle, NSSM, ffmpeg
  - installers/windows/bambuddy.iss        Inno Setup compiler script
  - installers/windows/service/*.bat       NSSM register/deregister
  - .github/workflows/windows-installer.yml CI build on tag push + manual
                                           dispatch, uploads .exe artifact

  build.py hard-fails on non-Windows hosts; Wine cross-build is an
  unsupported escape hatch behind --allow-non-windows. v1 ships unsigned
  (SmartScreen warns on first run) — production signing will be wired up
  via SignPath OSS once the application is approved.

  See installers/windows/README.md for build prerequisites and the
  embedded-Python ._pth gotchas.
2026-06-10 12:13:53 +02:00
maziggy f898556941 fix(a2l): transparent printer-card image + getPrinterImage resolver
Drop the off-white background on the A2L marketing render so it composites
  cleanly on the dark theme (every other printer image in public/img/printers/
  is RGBA with transparent corners; A2L shipped as opaque #F7F7F7). Resize to
  320x320 to match the rest of the artwork.

  Also wire A2L into getPrinterImage so the printer card actually shows the new
  artwork -- without this the resolver fell through to default.png for both
  the A2L display name and the N9 internal SSDP code.

  - frontend/public/img/printers/a2l.png: new, 320x320 RGBA, transparent
  - frontend/src/utils/printer.ts: A2L / N9 -> a2l.png, placed above the a1mini
    branch
  - frontend/src/__tests__/utils/printer.test.ts: 4 cases mirroring the X2D
    shape -- display name, case-insensitive variants, N9 internal code,
    regression guard against accidentally matching A2M / A1 / A1 Mini
2026-06-10 11:09:02 +02:00
maziggy 61dd0f8897 Updated README 2026-06-10 11:02:24 +02:00
maziggy defec334c4 feat(a2l): add Bambu Lab A2L support (#1684)
Internal code N9 (from BambuStudio resources/profiles/BBL/machine/Bambu Lab A2L.json),
  serial prefix 26A19 (5-char, same shape as H2C's late 31B8B). Capabilities from
  Bambu's official A2L specs page: linear rail, single FDM extruder + integrated
  cutter/plotter, no Ethernet (2.4 GHz Wi-Fi only), low-rate chamber camera on
  port 6000.

  The BambuStudio profile's use_double_extruder_default_texture: true flag
  describes two TOOL HEADS (FDM + cutter), not dual filament extrusion — A2L
  must NOT be classified as dual-nozzle or AMS routing will target the deputy
  slot and firmware rejects with 07FF_8012.

  Registry updates: printer_models.py, firmware_check.py, virtual_printer/manager.py,
  virtual_printer/mqtt_server.py, PrintersPage.tsx, SpoolBuddyAmsPage.tsx.
  12 new test cases in TestA2LModel pin every dimension.
2026-06-10 10:59:27 +02:00
maziggy e737c84c6e fix(diagnostic): skip external_storage check on A1 / A1 Mini (#1703)
A1 and A1 Mini ship without a MicroSD slot at all - there is no
  firmware-side "Store sent files on external storage" toggle and the
  slicers don't surface a slicer-side equivalent either. The connection
  diagnostic was reading state.store_to_sdcard (home_flag bit 11), which
  is never set on these models, so the check fell through to fail for
  every A1-series user. Combined with the absent slicer UI it left users
  thinking Bambuddy was wrong about a setting their hardware does not
  have.

  New NO_EXTERNAL_STORAGE_MODELS frozenset in utils/printer_models.py
  enumerates A1, A1 Mini, and their internal codes (N1, N2S, A04, A11,
  A12). has_external_storage() returns False for those, True for
  everything else. Unknown models default to True so the check stays
  active for future Bambu lineup additions - new no-slot models must be
  added to the set explicitly.

  The diagnostic now short-circuits to skip before reading
  store_to_sdcard when printer.model is in the set. X1, P1, P2S, H2,
  and X2D are unchanged - the bit-off -> fail signal is still the right
  read for them.

  The companion FTP-upload-timeout symptom in the same bug report (ftp
  code 28 from BambuStudio when sending to the proxy VP) is a separate
  Docker-bridge-mode networking constraint, not addressed here.
2026-06-10 08:54:24 +02:00
maziggy 0793022818 fix(ams): keep slot card preset name in sync after spool swaps
The slot card on PrintersPage shows slot_preset_mappings.preset_name
  first in its display fallback chain. Three write paths swap which
  spool occupies a given slot:

  - internal manual assign (inventory.apply_spool_to_slot_via_mqtt)
  - internal RFID auto-assign (spool_tag_matcher.auto_assign_spool)
  - Spoolman RFID sync (main.auto_sync_spoolman_ams_trays)

  Only the first one was reconciling the row. After an RFID-driven
  spool change, the card kept surfacing the previous spool's preset
  name until the user opened Configure Slot manually.

  Reporter saw H2D-1 / AMS-B3 displaying "Bambu PLA Silk+" for a
  freshly-inserted Bambu PLA-CF spool. The matching row in
  slot_preset_mappings was last written in March when a PLA Silk+
  spool had been in that slot - confirmed live in the database.

  New backend/app/services/slot_preset_writer.py exposes a primitive
  upsert_slot_preset plus two derivation wrappers: one for the
  internal Spool ORM object, one for the Spoolman API dict shape.
  All three call sites now go through the helper, so the row stays
  in lockstep with the assigned spool regardless of inventory mode.

  Bug shape exists in both inventory modes and the patch fixes both
  per feedback_inventory_modes_parity. The Spoolman path was latent
  for users who'd never manually picked a slot preset; the same
  "stale row overrides correct catalog name" symptom appeared for
  those who had.

  Existing stale rows self-heal on the next RFID-driven swap.
2026-06-10 08:39:21 +02:00
maziggy 60b253f98a fix(failure-reason): consistent camelCase keys across UI surfaces
The Stats page's Failure Analysis widget and the per-archive run
  sub-table rendered the raw PrintLogEntry.failure_reason value
  without translating, so the camelCase keys saved by the new
  Print Log row editor (#1687 part 4) surfaced as literal
  "filamentRunout" / "cloggedNozzle" text. The Print Log table did
  translate the value, so the inconsistency was visible from one
  surface to the next.

  EditArchiveModal was also still saving the localised label as the
  column value while the new editor saved the key - same column,
  two formats, two failure modes (group fragmentation on language
  switch, new PATCH validation rejection on round-trip).

  Three surfaces fixed in one drop:

  1. StatsPage.tsx and PrintLogTable.tsx wrap the value in
     t('editArchive.failureReasons.${reason}', { defaultValue: reason }) -
     the defaultValue path keeps legacy translated-text rows rendering
     unchanged.

  2. EditArchiveModal stores the camelCase key on save and reverse-
     looks up any legacy translated-text value against the current
     locale on open. Every save thereafter converts that row forward
     to the key format, so the column self-heals over time.

  3. Added htmlFor/id to the failure-reason label/select pair (a11y
     plus testability).
2026-06-10 08:02:36 +02:00
maziggy 8c326256db fix(system): emit boot_time and generated_at as tz-aware UTC
datetime.fromtimestamp(ts) and datetime.now() return naive local
  datetimes; .isoformat() then emits no tz marker. The frontend's
  parseUTCDate helper appends 'Z' to bare strings, treats the value
  as UTC, then converts to local for display — applying the local
  offset twice. Reporter on UTC+3 saw boot_time +3h ahead while
  uptime was correct (uptime is a backend-side delta of two
  naive-local values, so the missing tz info cancels out).

  Fix: pass tz=timezone.utc to datetime.fromtimestamp and
  datetime.now in system.py's boot_time / uptime path, plus the two
  adjacent generated_at sites in system.py and support.py.
2026-06-10 07:49:46 +02:00
maziggy e01d3a7979 feat(mqtt): one-shot device identification probe for unknown models
Logs device.dev_model_name / dev_product_name / dev_id / project_name
  at INFO level once per client session, falling back to device.keys()
  if none of the known fields are present.

  The MQTT push_status carries the model code in device.dev_model_name
  on every message, but nothing in bambu_mqtt.py reads or logs that
  field — so adding a new printer model meant chasing the code through
  either Bambu cloud or a manual mosquitto_sub. A2L (#1684) was the
  case that surfaced this: get_version also failed because the firmware
  disconnected right after request topic subscription, so the support
  bundle had no way to disclose the model.

  INFO level so the line lands in support bundles without enabling
  debug. One-shot via _device_id_logged, mirroring the existing
  _nozzle_fields_logged flag at line 2095, so push_status spam is
  avoided.

  Future-proofs against Bambu renaming the field (the fallback dumps
  device.keys() so a rename like model_name without the dev_ prefix
  is still observable). 3 unit tests in TestDeviceIdentificationProbe
  pin all three branches.
2026-06-10 07:38:02 +02:00
maziggy 2bb69cb079 fix(printer-models): swap A1 / A1 Mini in PRINTER_MODEL_ID_MAP
N1 and N2S were flipped relative to every other registry that names
  them - firmware_check.py (N2S -> "a1"), virtual_printer/manager.py
  (both the model map and the serial-prefix map: N2S -> 039 = A1,
  N1 -> 030 = A1 Mini), and printer_manager.py A1_MODELS all agree on
  N2S = A1, N1 = A1 Mini. Only printer_models.py had it backwards, so
  any path that resolved an A1-family printer by internal code rather
  than serial prefix would silently misclassify.

  Also fixes the matching comments in LINEAR_RAIL_MODELS - cosmetic only
  (both codes were already in the frozenset) but kept the file
  self-consistent.

  New TestA1SeriesModelIds regression test pins both directions so a
  future re-flip fails loudly.
2026-06-10 07:29:31 +02:00
maziggy 4b888a7f52 Updated README 2026-06-09 15:49:02 +02:00
maziggy c617da222d Updated README 2026-06-09 15:45:24 +02:00
maziggy 4ff4bffe9f fix(restore): pause timer-based DB writers before swap (Postgres deadlock)
close_all_connections() only disposes the engine's connection pool —
  asyncio tasks like print_scheduler.run() and the smart-plug snapshot
  loop wake on their 30 s cadence and lazily reopen pool connections
  holding RowExclusiveLock on print_queue / smart_plug_energy_snapshots.
  The restore's DROP TABLE ... CASCADE pass needs AccessExclusiveLock on
  every public table, producing an AB/BA deadlock that rolls back the
  entire restore transaction.

  Reproduced 2026-06-09 restoring a native install's backup into a fresh
  Docker+Postgres deploy:
    asyncpg.exceptions.DeadlockDetectedError: deadlock detected
    Process X waits for AccessExclusiveLock on relation 109940
    Process Y waits for RowExclusiveLock on relation 110182

  Fix:
  - Layer 1: pause print_scheduler / smart_plug_manager /
    notification_service / background_dispatch via their existing stop
    affordances before close_all_connections(), with a 1.0 s sleep for
    in-flight loop iterations to release sessions. Restore handler
    already requires a container restart on success, so the paused
    services come back via the next lifespan startup.

  - Layer 2: prepend SET LOCAL lock_timeout = '10s' to the begin-block
    in _import_sqlite_to_postgres so any reactive writer (per-printer
    MQTT, hourly AMS history recorder) that slips through the pause
    window fails fast and visibly instead of producing a new deadlock.
2026-06-09 13:54:55 +02:00
maziggy 50ad5539b0 chore(deps): pin aiohttp >=3.14.0 for CVE-2026-34993 + CVE-2026-47265
pywebpush brings aiohttp in transitively with no version bound, so the
  resolver kept installing 3.13.5. Both CVEs are fixed in 3.14.0; direct
  floor pin here, same shape as the existing idna / urllib3 / starlette
  transitive pins. Our usage in services/external_camera.py is unaffected
  by 3.14.0 (ClientSession, ClientTimeout, ClientError, iter_chunked all
  unchanged); 29 external_camera tests pass on 3.14.1; pip-audit clean.
2026-06-09 13:40:26 +02:00
maziggy 53b318c797 fix(ams): surface active K-profile when no slot preset is resolvable (#1689 follow-up)
Configure Slot dropped to "default 0.020" on reopen for slots that were
  physically loaded but unconfigured (tray_type="", no slot_preset_mappings
  row). The #1689 cali_idx safety net was unreachable from that path —
  matchingKProfiles early-returned [] on !selectedPresetInfo before the
  safety net ran.

  Split the early return so the cali_idx fallback survives the no-preset
  case: when selectedPresetInfo is null but slotInfo.caliIdx > 0, return
  the active profile as a single-item list (extruder-matched when known).
  Strictly additive; caliIdx == 0/null still returns [], existing matcher
  unchanged when a preset is resolvable.
2026-06-09 12:45:36 +02:00