fix(backup): stop two backed-up K-profiles claiming one live slot (#2656)

`_match_kprofile` ends in a single-candidate fallback, and the per-nozzle
loop called it once per entry with no record of which live profiles were
already taken. Two backup entries sharing a `filament_id` and matching on
neither `setting_id` nor `name` both resolved to the same live profile, both
got the same `cali_idx`, and both went into the batch — so the second
overwrote the first on the printer while the tally counted two restored.

Reachable in the ordinary way: the user deletes one of a pair after the
backup, and the delete-then-add re-key this code already reasons about is
exactly what strips the `setting_id` match.

Fix: thread a `claimed` set of slot ids through the loop; a live profile can
only stand in for one entry. A displaced entry falls through to
`cali_idx: -1` — add-as-new is the safe outcome — and folds into the
existing `kprofilesUnmatched` note rather than earning a new code.

The single-candidate fallback is still judged against every candidate rather
than the unclaimed ones. Two live profiles for one filament are ambiguous
whether or not another entry has taken one, and narrowing to "available"
would turn a guess the code deliberately refuses into a match.

Tests: 2 regression (the displaced entry is added rather than aliased, and
keeps its own setting_id) + 2 controls (two genuine matches keep their own
slots; a claimed slot does not make an ambiguous pair matchable). Both
regressions confirmed failing against the pre-fix service.
This commit is contained in:
jmoore-skild
2026-08-04 08:57:34 -04:00
parent 582a6b18bc
commit cb6a4e6d88
2 changed files with 127 additions and 7 deletions
+27 -7
View File
@@ -1486,12 +1486,19 @@ class GitHubRestoreService:
profile_dicts = []
unmatched = 0
# A live profile can only stand in for one backed-up entry. Two
# entries resolving to the same cali_idx both go into the batch,
# the second overwrites the first on the printer, and the tally
# counts two restored where one landed.
claimed: set[int] = set()
for p in profiles:
if not isinstance(p, dict):
continue
match = self._match_kprofile(p, current)
match = self._match_kprofile(p, current, claimed)
if match is None:
unmatched += 1
else:
claimed.add(match.slot_id)
profile_dicts.append(
{
"filament_id": p.get("filament_id", ""),
@@ -1554,7 +1561,7 @@ class GitHubRestoreService:
return []
@staticmethod
def _match_kprofile(entry: dict, current: list):
def _match_kprofile(entry: dict, current: list, claimed: set[int]):
"""Find the live profile a backed-up entry corresponds to.
``setting_id`` is the filament preset the profile was calibrated for and
@@ -1562,30 +1569,43 @@ class GitHubRestoreService:
back to the display name, which Bambuddy's own editor preserves.
Both are scoped by ``filament_id`` — the same preset on a different
filament is a different profile.
``claimed`` holds the slot ids already taken by earlier entries in this
nozzle's loop, and no live profile may be claimed twice. Without it, two
backed-up entries sharing a ``filament_id`` and matching on neither
``setting_id`` nor ``name`` both fell through to the single-candidate
arm and both took the same slot — reachable whenever the user has since
deleted one of a pair, because the delete-then-add re-key is what strips
the ``setting_id`` match. Returning None for the displaced entry means
``cali_idx: -1``, i.e. add-as-new, which is the safe outcome.
"""
filament_id = entry.get("filament_id")
if not filament_id:
return None
candidates = [c for c in current if c.filament_id == filament_id]
if not candidates:
available = [c for c in candidates if c.slot_id not in claimed]
if not available:
return None
setting_id = entry.get("setting_id")
if setting_id:
for c in candidates:
for c in available:
if c.setting_id == setting_id:
return c
name = entry.get("name")
if name:
for c in candidates:
for c in available:
if c.name == name:
return c
# Exactly one profile for this filament and no better discriminator:
# treat it as the same profile rather than duplicating it.
return candidates[0] if len(candidates) == 1 else None
# treat it as the same profile rather than duplicating it. Judged
# against every candidate rather than the unclaimed ones, because two
# live profiles for one filament are ambiguous whether or not another
# entry has already taken one of them.
return available[0] if len(candidates) == 1 else None
# Singleton instance