mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-17 19:36:28 +02:00
Merge branch '0.2.0b' into 0.2.0b
This commit is contained in:
@@ -748,7 +748,7 @@ async def assign_spool(
|
||||
matching_kp = None
|
||||
for kp in spool.k_profiles:
|
||||
if kp.printer_id == data.printer_id and kp.nozzle_diameter == nozzle_diameter:
|
||||
if slot_extruder is not None and kp.extruder_id is not None and kp.extruder_id != slot_extruder:
|
||||
if slot_extruder is not None and kp.extruder is not None and kp.extruder != slot_extruder:
|
||||
continue
|
||||
matching_kp = kp
|
||||
break
|
||||
@@ -954,6 +954,99 @@ async def clear_spool_usage_history(
|
||||
return {"status": "cleared"}
|
||||
|
||||
|
||||
# ── AMS Weight Sync ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/sync-ams-weights")
|
||||
async def sync_weights_from_ams(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
||||
):
|
||||
"""Force-sync spool weight_used from live AMS remain% data.
|
||||
|
||||
Overwrites the database weight_used for every assigned spool using the
|
||||
current AMS remain% from connected printers. This is a manual recovery
|
||||
tool — it bypasses the normal "only increase" guard.
|
||||
"""
|
||||
from backend.app.services.printer_manager import printer_manager
|
||||
|
||||
result = await db.execute(select(SpoolAssignment).options(selectinload(SpoolAssignment.spool)))
|
||||
assignments = list(result.scalars().all())
|
||||
logger.info("AMS weight sync: found %d assignments", len(assignments))
|
||||
|
||||
synced = 0
|
||||
skipped = 0
|
||||
|
||||
for assignment in assignments:
|
||||
spool = assignment.spool
|
||||
if not spool:
|
||||
logger.debug("AMS weight sync: assignment %d has no spool", assignment.id)
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
state = printer_manager.get_status(assignment.printer_id)
|
||||
if not state or not state.raw_data:
|
||||
logger.info(
|
||||
"AMS weight sync: printer %d not connected, skipping spool %d",
|
||||
assignment.printer_id,
|
||||
spool.id,
|
||||
)
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
ams_raw = state.raw_data.get("ams", [])
|
||||
if isinstance(ams_raw, dict):
|
||||
ams_raw = ams_raw.get("ams", [])
|
||||
tray = _find_tray_in_ams_data(ams_raw, assignment.ams_id, assignment.tray_id)
|
||||
if not tray:
|
||||
logger.info(
|
||||
"AMS weight sync: no tray data for spool %d (printer %d AMS%d-T%d)",
|
||||
spool.id,
|
||||
assignment.printer_id,
|
||||
assignment.ams_id,
|
||||
assignment.tray_id,
|
||||
)
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
remain_raw = tray.get("remain")
|
||||
if remain_raw is None:
|
||||
logger.debug("AMS weight sync: no remain value for spool %d", spool.id)
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
remain_val = int(remain_raw)
|
||||
except (TypeError, ValueError):
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
if remain_val < 0 or remain_val > 100:
|
||||
logger.debug("AMS weight sync: invalid remain=%s for spool %d", remain_raw, spool.id)
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
lw = spool.label_weight or 1000
|
||||
new_used = round(lw * (100 - remain_val) / 100.0, 1)
|
||||
old_used = spool.weight_used or 0
|
||||
|
||||
if round(old_used, 1) != new_used:
|
||||
logger.info(
|
||||
"AMS weight sync: spool %d weight_used %s -> %s (remain=%d%%)",
|
||||
spool.id,
|
||||
old_used,
|
||||
new_used,
|
||||
remain_val,
|
||||
)
|
||||
spool.weight_used = new_used
|
||||
synced += 1
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
await db.commit()
|
||||
return {"synced": synced, "skipped": skipped}
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
+26
-1
@@ -689,7 +689,7 @@ async def on_ams_change(printer_id: int, ams_data: list):
|
||||
remain_val = int(remain_raw)
|
||||
except (TypeError, ValueError):
|
||||
remain_val = -1
|
||||
if 0 <= remain_val <= 100:
|
||||
if 1 <= remain_val <= 100:
|
||||
lw = existing_assignment.spool.label_weight or 1000
|
||||
new_used = round(lw * (100 - remain_val) / 100.0, 1)
|
||||
current_used = existing_assignment.spool.weight_used or 0
|
||||
@@ -2759,6 +2759,31 @@ async def on_print_complete(printer_id: int, data: dict):
|
||||
logging.getLogger(__name__).warning(f"Queue item update failed: {e}")
|
||||
|
||||
log_timing("Queue item update")
|
||||
|
||||
# Cleanup: delete uploaded file from printer SD card to prevent phantom prints (Issue #374)
|
||||
# The print scheduler uploads .3mf files to the SD card root (/). Some printers (e.g. P1S)
|
||||
# auto-start files found in root on power cycle, causing ghost prints.
|
||||
try:
|
||||
printer_info = printer_manager.get_printer(printer_id)
|
||||
if printer_info and subtask_name:
|
||||
from backend.app.services.bambu_ftp import delete_file_async
|
||||
|
||||
remote_path = f"/{subtask_name}.3mf"
|
||||
logger.info("Cleaning up uploaded file from printer SD card: %s", remote_path)
|
||||
delete_result = await delete_file_async(
|
||||
printer_info.ip_address,
|
||||
printer_info.access_code,
|
||||
remote_path,
|
||||
printer_model=printer_info.model,
|
||||
)
|
||||
if delete_result:
|
||||
logger.info("Deleted %s from printer %s SD card", remote_path, printer_info.name)
|
||||
else:
|
||||
logger.debug("File delete returned False for %s (may not exist)", remote_path)
|
||||
except Exception as e:
|
||||
logger.debug("SD card file cleanup failed for printer %s: %s (non-critical)", printer_id, e)
|
||||
|
||||
log_timing("SD card cleanup")
|
||||
logger.info("[CALLBACK] on_print_complete finished for printer %s, archive %s", printer_id, archive_id)
|
||||
|
||||
|
||||
|
||||
@@ -188,7 +188,9 @@ class NotificationService:
|
||||
else:
|
||||
return False, f"HTTP {response.status_code}: {response.text[:200]}"
|
||||
|
||||
async def _send_ntfy(self, config: dict, title: str, message: str) -> tuple[bool, str]:
|
||||
async def _send_ntfy(
|
||||
self, config: dict, title: str, message: str, image_data: bytes | None = None
|
||||
) -> tuple[bool, str]:
|
||||
"""Send notification via ntfy."""
|
||||
server = config.get("server", "https://ntfy.sh").rstrip("/")
|
||||
topic = config.get("topic", "").strip()
|
||||
@@ -204,7 +206,14 @@ class NotificationService:
|
||||
headers["Authorization"] = f"Bearer {auth_token}"
|
||||
|
||||
client = await self._get_client()
|
||||
response = await client.post(url, content=message, headers=headers)
|
||||
|
||||
if image_data:
|
||||
# ntfy supports image attachments via multipart form-data
|
||||
headers["Filename"] = "photo.jpg"
|
||||
headers["Message"] = message
|
||||
response = await client.put(url, content=image_data, headers=headers)
|
||||
else:
|
||||
response = await client.post(url, content=message, headers=headers)
|
||||
|
||||
if response.status_code in (200, 204):
|
||||
return True, "Message sent successfully"
|
||||
@@ -257,7 +266,7 @@ class NotificationService:
|
||||
except Exception:
|
||||
return False, f"HTTP {response.status_code}: {response.text[:200]}"
|
||||
|
||||
async def _send_telegram(self, config: dict, message: str) -> tuple[bool, str]:
|
||||
async def _send_telegram(self, config: dict, message: str, image_data: bytes | None = None) -> tuple[bool, str]:
|
||||
"""Send notification via Telegram bot."""
|
||||
bot_token = config.get("bot_token", "").strip()
|
||||
chat_id = config.get("chat_id", "").strip()
|
||||
@@ -265,8 +274,6 @@ class NotificationService:
|
||||
if not bot_token or not chat_id:
|
||||
return False, "Bot token and chat ID are required"
|
||||
|
||||
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
||||
|
||||
# Escape underscores in the message body so Telegram Markdown
|
||||
# parsing doesn't break on job names like "A1_plate_8" or error
|
||||
# codes like "0300_0001". The title is already wrapped in *bold*
|
||||
@@ -276,14 +283,24 @@ class NotificationService:
|
||||
body_part = body_part.replace("_", "\\_")
|
||||
message = f"{title_part}\n{body_part}"
|
||||
|
||||
data = {
|
||||
"chat_id": chat_id,
|
||||
"text": message,
|
||||
"parse_mode": "Markdown",
|
||||
}
|
||||
|
||||
client = await self._get_client()
|
||||
response = await client.post(url, json=data)
|
||||
|
||||
if image_data:
|
||||
# Use sendPhoto to attach the thumbnail with the caption
|
||||
url = f"https://api.telegram.org/bot{bot_token}/sendPhoto"
|
||||
response = await client.post(
|
||||
url,
|
||||
data={"chat_id": chat_id, "caption": message, "parse_mode": "Markdown"},
|
||||
files={"photo": ("photo.jpg", image_data, "image/jpeg")},
|
||||
)
|
||||
else:
|
||||
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
||||
data = {
|
||||
"chat_id": chat_id,
|
||||
"text": message,
|
||||
"parse_mode": "Markdown",
|
||||
}
|
||||
response = await client.post(url, json=data)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
@@ -451,11 +468,11 @@ class NotificationService:
|
||||
if provider.provider_type == "callmebot":
|
||||
return await self._send_callmebot(config, f"{title}\n{message}")
|
||||
elif provider.provider_type == "ntfy":
|
||||
return await self._send_ntfy(config, title, message)
|
||||
return await self._send_ntfy(config, title, message, image_data=image_data)
|
||||
elif provider.provider_type == "pushover":
|
||||
return await self._send_pushover(config, title, message, image_data=image_data)
|
||||
elif provider.provider_type == "telegram":
|
||||
return await self._send_telegram(config, f"*{title}*\n{message}")
|
||||
return await self._send_telegram(config, f"*{title}*\n{message}", image_data=image_data)
|
||||
elif provider.provider_type == "email":
|
||||
return await self._send_email(config, title, message)
|
||||
elif provider.provider_type == "discord":
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
"""Unit tests for the AMS weight sync calculation logic.
|
||||
|
||||
Tests the weight_used calculation and remain% validation extracted from
|
||||
the POST /inventory/sync-ams-weights endpoint, without requiring a database.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.api.routes.inventory import _find_tray_in_ams_data
|
||||
|
||||
|
||||
def _calc_weight_used(label_weight: int | None, remain: int) -> float:
|
||||
"""Reproduce the weight calculation from sync_weights_from_ams."""
|
||||
lw = label_weight or 1000
|
||||
return round(lw * (100 - remain) / 100.0, 1)
|
||||
|
||||
|
||||
def _is_valid_remain(remain_raw) -> tuple[bool, int]:
|
||||
"""Reproduce the remain% validation from sync_weights_from_ams.
|
||||
|
||||
Returns (is_valid, parsed_value). parsed_value is only meaningful
|
||||
when is_valid is True.
|
||||
"""
|
||||
if remain_raw is None:
|
||||
return False, 0
|
||||
try:
|
||||
val = int(remain_raw)
|
||||
except (TypeError, ValueError):
|
||||
return False, 0
|
||||
if val < 0 or val > 100:
|
||||
return False, val
|
||||
return True, val
|
||||
|
||||
|
||||
class TestWeightCalculation:
|
||||
"""Test the weight_used = label_weight * (100 - remain) / 100 formula."""
|
||||
|
||||
def test_remain_100_means_no_usage(self):
|
||||
"""A full spool (remain=100) should have weight_used=0."""
|
||||
assert _calc_weight_used(1000, 100) == 0.0
|
||||
|
||||
def test_remain_50_with_1000g_spool(self):
|
||||
"""Half-used 1000g spool should have weight_used=500."""
|
||||
assert _calc_weight_used(1000, 50) == 500.0
|
||||
|
||||
def test_remain_0_means_fully_used(self):
|
||||
"""An empty spool (remain=0) should have weight_used equal to label_weight.
|
||||
|
||||
Unlike the on_ams_change guard, the sync endpoint processes remain=0
|
||||
since it is a manual recovery tool.
|
||||
"""
|
||||
assert _calc_weight_used(1000, 0) == 1000.0
|
||||
|
||||
def test_respects_label_weight_500g(self):
|
||||
"""500g spool at remain=50 should have weight_used=250."""
|
||||
assert _calc_weight_used(500, 50) == 250.0
|
||||
|
||||
def test_respects_label_weight_250g(self):
|
||||
"""250g spool at remain=75 should have weight_used=62.5."""
|
||||
assert _calc_weight_used(250, 75) == 62.5
|
||||
|
||||
def test_none_label_weight_defaults_to_1000(self):
|
||||
"""When label_weight is None, it defaults to 1000g."""
|
||||
assert _calc_weight_used(None, 50) == 500.0
|
||||
|
||||
def test_result_is_rounded_to_one_decimal(self):
|
||||
"""Weight used should be rounded to 1 decimal place.
|
||||
|
||||
For a 1000g spool at remain=33, weight_used = 1000 * 67 / 100 = 670.0
|
||||
"""
|
||||
assert _calc_weight_used(1000, 33) == 670.0
|
||||
|
||||
def test_odd_fraction_rounds_correctly(self):
|
||||
"""750g spool at remain=33 → 750 * 67/100 = 502.5."""
|
||||
assert _calc_weight_used(750, 33) == 502.5
|
||||
|
||||
def test_small_spool_small_remain(self):
|
||||
"""200g spool at remain=1 → 200 * 99/100 = 198.0."""
|
||||
assert _calc_weight_used(200, 1) == 198.0
|
||||
|
||||
|
||||
class TestRemainValidation:
|
||||
"""Test the remain% bounds and type validation."""
|
||||
|
||||
def test_remain_minus_1_is_invalid(self):
|
||||
"""remain=-1 (firmware 'unknown') should be skipped."""
|
||||
valid, _ = _is_valid_remain(-1)
|
||||
assert valid is False
|
||||
|
||||
def test_remain_101_is_invalid(self):
|
||||
"""remain=101 (out of range) should be skipped."""
|
||||
valid, _ = _is_valid_remain(101)
|
||||
assert valid is False
|
||||
|
||||
def test_remain_negative_large_is_invalid(self):
|
||||
"""Large negative remain values should be skipped."""
|
||||
valid, _ = _is_valid_remain(-50)
|
||||
assert valid is False
|
||||
|
||||
def test_remain_200_is_invalid(self):
|
||||
"""remain=200 should be skipped."""
|
||||
valid, _ = _is_valid_remain(200)
|
||||
assert valid is False
|
||||
|
||||
def test_remain_none_is_invalid(self):
|
||||
"""remain=None (missing from tray data) should be skipped."""
|
||||
valid, _ = _is_valid_remain(None)
|
||||
assert valid is False
|
||||
|
||||
def test_remain_non_numeric_string_is_invalid(self):
|
||||
"""Non-numeric string remain should be skipped."""
|
||||
valid, _ = _is_valid_remain("abc")
|
||||
assert valid is False
|
||||
|
||||
def test_remain_0_is_valid(self):
|
||||
"""remain=0 should be valid (manual recovery handles empty spools)."""
|
||||
valid, val = _is_valid_remain(0)
|
||||
assert valid is True
|
||||
assert val == 0
|
||||
|
||||
def test_remain_100_is_valid(self):
|
||||
"""remain=100 should be valid."""
|
||||
valid, val = _is_valid_remain(100)
|
||||
assert valid is True
|
||||
assert val == 100
|
||||
|
||||
def test_remain_50_is_valid(self):
|
||||
"""remain=50 should be valid."""
|
||||
valid, val = _is_valid_remain(50)
|
||||
assert valid is True
|
||||
assert val == 50
|
||||
|
||||
def test_remain_string_number_is_valid(self):
|
||||
"""Numeric string remain (e.g. '75') should be parsed as int."""
|
||||
valid, val = _is_valid_remain("75")
|
||||
assert valid is True
|
||||
assert val == 75
|
||||
|
||||
|
||||
class TestFindTrayInAmsData:
|
||||
"""Test the _find_tray_in_ams_data helper used by the sync endpoint."""
|
||||
|
||||
def test_finds_matching_tray(self):
|
||||
"""Should return the matching tray dict."""
|
||||
ams_data = [
|
||||
{
|
||||
"id": 0,
|
||||
"tray": [
|
||||
{"id": 0, "remain": 80},
|
||||
{"id": 1, "remain": 50},
|
||||
],
|
||||
},
|
||||
]
|
||||
tray = _find_tray_in_ams_data(ams_data, ams_id=0, tray_id=1)
|
||||
assert tray is not None
|
||||
assert tray["remain"] == 50
|
||||
|
||||
def test_returns_none_for_missing_ams_unit(self):
|
||||
"""Should return None when the AMS unit ID is not found."""
|
||||
ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]
|
||||
assert _find_tray_in_ams_data(ams_data, ams_id=1, tray_id=0) is None
|
||||
|
||||
def test_returns_none_for_missing_tray(self):
|
||||
"""Should return None when the tray ID is not found."""
|
||||
ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]
|
||||
assert _find_tray_in_ams_data(ams_data, ams_id=0, tray_id=3) is None
|
||||
|
||||
def test_returns_none_for_empty_data(self):
|
||||
"""Should return None for empty AMS data."""
|
||||
assert _find_tray_in_ams_data([], ams_id=0, tray_id=0) is None
|
||||
|
||||
def test_returns_none_for_none_data(self):
|
||||
"""Should return None for None AMS data."""
|
||||
assert _find_tray_in_ams_data(None, ams_id=0, tray_id=0) is None
|
||||
|
||||
def test_multi_ams_unit_lookup(self):
|
||||
"""Should find trays across multiple AMS units."""
|
||||
ams_data = [
|
||||
{"id": 0, "tray": [{"id": 0, "remain": 80}]},
|
||||
{"id": 1, "tray": [{"id": 2, "remain": 30}]},
|
||||
]
|
||||
tray = _find_tray_in_ams_data(ams_data, ams_id=1, tray_id=2)
|
||||
assert tray is not None
|
||||
assert tray["remain"] == 30
|
||||
|
||||
def test_ams_ht_high_id(self):
|
||||
"""Should find trays in AMS-HT units (id >= 128)."""
|
||||
ams_data = [{"id": 128, "tray": [{"id": 0, "remain": 65}]}]
|
||||
tray = _find_tray_in_ams_data(ams_data, ams_id=128, tray_id=0)
|
||||
assert tray is not None
|
||||
assert tray["remain"] == 65
|
||||
|
||||
|
||||
class TestSyncSkipLogic:
|
||||
"""Test combinations that exercise the sync/skip decision path."""
|
||||
|
||||
def test_same_value_is_skipped(self):
|
||||
"""When old weight_used matches new, the spool is skipped (no DB write)."""
|
||||
# Simulating the endpoint logic: if round(old_used, 1) == new_used → skip
|
||||
label_weight = 1000
|
||||
remain = 50
|
||||
new_used = _calc_weight_used(label_weight, remain)
|
||||
old_used = 500.0 # Already matches
|
||||
assert round(old_used, 1) == new_used # → would be skipped
|
||||
|
||||
def test_different_value_is_synced(self):
|
||||
"""When old weight_used differs from new, the spool is synced."""
|
||||
label_weight = 1000
|
||||
remain = 50
|
||||
new_used = _calc_weight_used(label_weight, remain)
|
||||
old_used = 300.0 # Different
|
||||
assert round(old_used, 1) != new_used # → would be synced
|
||||
|
||||
def test_none_old_used_treated_as_zero(self):
|
||||
"""When old weight_used is None (new spool), it defaults to 0."""
|
||||
old_used = None
|
||||
effective_old = old_used or 0
|
||||
new_used = _calc_weight_used(1000, 80) # 200.0
|
||||
assert effective_old == 0
|
||||
assert round(effective_old, 1) != new_used # → would be synced
|
||||
|
||||
def test_remain_0_synced_not_skipped(self):
|
||||
"""remain=0 is valid and produces weight_used=label_weight.
|
||||
|
||||
This is distinct from on_ams_change behavior where remain=0 is
|
||||
ignored. The sync endpoint processes it as a manual recovery tool.
|
||||
"""
|
||||
valid, val = _is_valid_remain(0)
|
||||
assert valid is True
|
||||
new_used = _calc_weight_used(1000, val)
|
||||
assert new_used == 1000.0
|
||||
|
||||
def test_remain_minus_1_never_reaches_calc(self):
|
||||
"""remain=-1 fails validation before weight calculation."""
|
||||
valid, _ = _is_valid_remain(-1)
|
||||
assert valid is False
|
||||
# The endpoint would skip += 1 and continue
|
||||
|
||||
def test_remain_101_never_reaches_calc(self):
|
||||
"""remain=101 fails validation before weight calculation."""
|
||||
valid, _ = _is_valid_remain(101)
|
||||
assert valid is False
|
||||
Reference in New Issue
Block a user