diff --git a/.github/workflows/release-docker-image.yml b/.github/workflows/release-docker-image.yml index 780f2893..244523b8 100644 --- a/.github/workflows/release-docker-image.yml +++ b/.github/workflows/release-docker-image.yml @@ -115,6 +115,13 @@ jobs: fi echo "TAGS=$TAGS" >> "$GITHUB_ENV" + elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + # Manual run without tag: use the selected branch name + BRANCH="${{ github.ref_name }}" + BRANCH_TAG=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9._-]/-/g') + if [[ -n "$BRANCH_TAG" ]]; then + echo "TAGS=ghcr.io/${{ env.REPO_NAME }}:${BRANCH_TAG}" >> "$GITHUB_ENV" + fi elif [[ "${{ github.ref }}" == "refs/heads/main" ]]; then # Dev image for pushes to main (and equivalent contexts) echo "TAGS=ghcr.io/${{ env.REPO_NAME }}:dev" >> "$GITHUB_ENV" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6718f90d..e323829f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,6 +14,7 @@ jobs: env: TRACK_LAYER_USAGE: ${{ matrix.layer_tracking }} OPENSPOOLMAN_TEST_DATA: "1" + OPENSPOOLMAN_MQTT_LOG_PATH: ${{ github.workspace }}/logs/mqtt.log steps: - name: Checkout uses: actions/checkout@v4 diff --git a/README.md b/README.md index 5a597dc7..a582368c 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ SpoolMan can print QR-code stickers for every spool; follow the SpoolMan label g - set `DISABLE_MISMATCH_WARNING` to `True` to hide mismatch warnings in the UI (mismatches are still detected and logged to `logs/filament_mismatch.json`, including the detected color difference when applicable). - set `CLEAR_ASSIGNMENT_WHEN_EMPTY` to `True` if you want OpenSpoolMan to clear any SpoolMan assignment and reset the AMS tray whenever the printer reports no spool in that slot. - set `COLOR_DISTANCE_TOLERANCE` to an integer (default `40`) if you want to make the perceptual ΔE threshold for tray/spool color mismatch warnings stricter or more lenient; when either side (AMS tray or SpoolMan spool) lacks a color the warning is skipped and the UI shows "Color not set". - - By default, the app reads `data/3d_printer_logs.db` for print history; override it through `OPENSPOOLMAN_PRINT_HISTORY_DB` or via the screenshot helper (which targets `data/demo.db` by default). + - By default, the app reads `data/3d_printer_logs.db` for print history; override it through `OPENSPOOLMAN_PRINT_HISTORY_DB` or via the screenshot helper (which targets `data/demo.db` by default). When schema migrations are required, OpenSpoolMan creates a copy with a `.v2` suffix, migrates that copy, and will prefer the versioned file on subsequent starts so you can roll back to the original if needed. - Run SpoolMan. - Add these extra fields in SpoolMan: diff --git a/agents.md b/agents.md index bafbdcd2..94b373f7 100644 --- a/agents.md +++ b/agents.md @@ -52,6 +52,7 @@ Key folders/files you will interact with: ### 4.1 Local Python run (development) 1. Configure environment (see §5). Create `config.env` from `config.env.template` or export env vars. +1. Activate the correct Python environment (virtualenv/conda) for this repo before running or testing. 2. Start the server: - `python wsgi.py` diff --git a/app.py b/app.py index 9161b297..3fa117cb 100644 --- a/app.py +++ b/app.py @@ -562,7 +562,7 @@ def _to_int(value): per_page = 50 offset = max((page - 1) * per_page, 0) - ams_slot = request.args.get("ams_slot") + filament_id = request.args.get("filament_id") or request.args.get("ams_slot") print_id = request.args.get("print_id") spool_id = request.args.get("spool_id") old_spool_id = request.args.get("old_spool_id") @@ -570,7 +570,7 @@ def _to_int(value): if not old_spool_id: old_spool_id = -1 - if READ_ONLY_MODE and all([ams_slot, print_id, spool_id]): + if READ_ONLY_MODE and all([filament_id, print_id, spool_id]): return render_template('error.html', exception="Live read-only mode: updating print-to-spool assignments is disabled.") def _consume_for_spool(spool_id_value, grams_value=None, length_value=None): @@ -581,9 +581,9 @@ def _consume_for_spool(spool_id_value, grams_value=None, length_value=None): elif grams_value is not None: spoolman_client.consumeSpool(spool_id_value, use_weight=grams_value) - if all([ams_slot, print_id, spool_id]): - filament = print_history_service.get_filament_for_slot(print_id, ams_slot) - print_history_service.update_filament_spool(print_id, ams_slot, spool_id) + if all([filament_id, print_id, spool_id]): + filament = print_history_service.get_filament_for_filament_id(print_id, filament_id) + print_history_service.update_filament_spool(print_id, filament_id, spool_id) if(filament["spool_id"] != int(spool_id) and (not old_spool_id or (old_spool_id and filament["spool_id"] == int(old_spool_id)))): grams_used = _to_float(filament.get("grams_used")) @@ -676,7 +676,7 @@ def _consume_for_spool(spool_id_value, grams_value=None, length_value=None): def print_select_spool(): try: - ams_slot = request.args.get("ams_slot") + filament_id = request.args.get("filament_id") or request.args.get("ams_slot") print_id = request.args.get("print_id") old_spool_id = request.args.get("old_spool_id") @@ -685,7 +685,7 @@ def print_select_spool(): if not old_spool_id: old_spool_id = -1 - if not all([ams_slot, print_id]): + if not all([filament_id, print_id]): return render_template('error.html', exception="Missing spool ID or print ID.") spools = mqtt_bambulab.fetchSpools() @@ -693,7 +693,7 @@ def print_select_spool(): materials = extract_materials(spools) selected_materials = [] - filament = print_history_service.get_filament_for_slot(print_id, ams_slot) + filament = print_history_service.get_filament_for_filament_id(print_id, filament_id) try: filament_material = filament["filament_type"] if filament else None @@ -706,7 +706,7 @@ def print_select_spool(): return render_template( 'print_select_spool.html', spools=spools, - ams_slot=ams_slot, + filament_id=filament_id, print_id=print_id, old_spool_id=old_spool_id, change_spool=change_spool, diff --git a/data/print_history_db.md b/data/print_history_db.md index e4650210..90293ddb 100644 --- a/data/print_history_db.md +++ b/data/print_history_db.md @@ -1,6 +1,6 @@ Demo print history database =========================== -- Default location: `data/3d_printer_logs.db`. +- Default location: `data/3d_printer_logs.db`. If a schema migration is needed, OpenSpoolMan copies the database to a `.migrated` filename, migrates that copy, and will prefer the migrated file afterward so the original remains available for rollback. - For screenshots or demos, override with `OPENSPOOLMAN_PRINT_HISTORY_DB=/path/to/data/demo.db` or pass `--print-history-db` to `scripts/generate_screenshots.py`/`pytest -m screenshots`. - Schema matches `print_history.py` (`prints` + `filament_usage`). Populate it from your own data or via a live snapshot export; demo rows are no longer bundled by default. diff --git a/docs/ams_mapping_print_history_fix.md b/docs/ams_mapping_print_history_fix.md new file mode 100644 index 00000000..9f91a1ad --- /dev/null +++ b/docs/ams_mapping_print_history_fix.md @@ -0,0 +1,24 @@ +# AMS Mapping / Print History Fix + +## Root cause +- Print history stored `filament_id` from the 3MF, but the filament usage tracker wrote updates using tool index + 1. +- AMS mapping indices are tool indices, not 3MF filament IDs. The mapping between tool index and filament ID was inconsistent. +- Negative AMS mapping values (e.g., `-1` placeholders) were parsed as real AMS targets, which can produce wrong tray/spool resolution. + +## Changes +- `tools_3mf.py` + - Build `tool_index_to_filament_id` / `filament_id_to_tool_index` mappings based on tool indices (contiguous) or usage order only when gaps exist. +- `spoolman_service.py` + - Resolve AMS mapping by tool index using `filament_id_to_tool_index`. + - Treat negative AMS mapping values as `unused`. +- `filament_usage_tracker.py` + - Resolve the 3MF `filament_id` from the tool index before writing history. + - Skip history updates when a filament ID cannot be resolved (avoid wrong assignments). +- `mqtt_bambulab.py` + - Local tray mapping now stores AMS mappings by tool index consistently. +- `tests/test_printhistory_spool_assignment_from_replay.py` + - Expected spool assignment now derives the tool index mapping from 3MF metadata. + +## Tests +- `python -m pytest -s tests/test_printhistory_spool_assignment_from_replay.py` +- `python -m pytest -s tests/test_printhistory_accounting.py tests/test_ams_mapping_from_replay.py` diff --git a/docs/pytest_best_practices_audit.md b/docs/pytest_best_practices_audit.md new file mode 100644 index 00000000..01961223 --- /dev/null +++ b/docs/pytest_best_practices_audit.md @@ -0,0 +1,53 @@ +# Pytest Best-Practices Audit – OpenSpoolMan + +## Inventory (Tests and Entry Points) +- `tests/test_ams_model_detection.py` -> `mqtt_bambulab.identify_ams_model_from_module`, `identify_ams_models_from_modules`, `identify_ams_models_by_id` +- `tests/test_ams_mapping_parser.py` -> `spoolman_service.parse_ams_mapping`, `parse_ams_mapping_value` +- `tests/test_ams_mapping_from_replay.py` -> `mqtt_bambulab` replay path via `mqtt_replay`, `print_history` spool assignment +- `tests/test_printhistory_replay_e2e.py` -> `print_history` inserts via `mqtt_bambulab` replay +- `tests/test_printhistory_accounting.py` -> `print_history`, `filament_usage_tracker` consumption via `mqtt_replay` +- `tests/test_printhistory_spool_assignment_from_replay.py` -> `print_history` spool assignment via `mqtt_replay` +- `tests/test_mqtt_replay.py` -> `mqtt_bambulab.processMessage`, `FilamentUsageTracker.on_message` (replay of payloads) +- `tests/test_print_run_logic.py` -> `mqtt_bambulab.processMessage`, `bambu_state` run tracking helpers +- `tests/test_filament_mismatch.py` -> `spoolman_service.augmentTrayDataWithSpoolMan` +- `tests/test_tray_remaining.py` -> `templates/fragments/tray.html` (rendering behavior) +- `tests/test_screenshots.py` -> `scripts/generate_screenshots.py` (Playwright, optional) +- `tests/test_policy_no_test_logic.py` -> guard against test-owned log parsing + +## Changes Applied +- `pytest.ini` + - Added `addopts = -ra --strict-markers --strict-config` for stricter pytest defaults. + - Added `testpaths = tests` and `python_files = test_*.py` to avoid collecting non-test helper modules. +- `requirements.txt` + - Added `pytest-asyncio` to make the asyncio config explicit and avoid implicit plugin availability. +- `tests/test_ams_model_detection.py` + - Converted repeated tests into parameterized cases for clarity and maintainability. +- `tests/test_ams_mapping_parser.py` + - Split monolithic test into focused tests and added parameterized value parsing. +- `tests/test_tray_remaining.py` + - Parameterized AMS model visibility check to reduce duplication. +- `tests/test_print_run_logic.py` + - Added `print_run_env` fixture to centralize setup, reset global state, and ensure deterministic feature flags. + - Renamed tests to clearer `test__when_` style. +- `tests/test_filament_mismatch.py` + - Renamed tests for clearer intent. + - Replaced aggregated failure loop with parameterized cases for isolated, deterministic failures. +- `tests/test_mqtt_replay.py` + - Wraps production `map_filament` and `_resolve_tray_mapping` to observe assignments before state is cleared (observation only, no logic duplication). +- `tests/test_printhistory_replay_e2e.py` + - Simplified assertions to direct dict comparisons for clarity. +- `tests/test_printhistory_spool_assignment_from_replay.py` + - Simplified expected-vs-actual comparisons using direct dict equality. + +## Best-Practice Goals Covered +- Clear Arrange/Act/Assert flow with focused tests and fixtures. +- Extensive parameterization to reduce duplicate tests and improve failure granularity. +- Deterministic setup via fixture-managed global state and feature flags. +- Assertions compare direct outputs instead of re-implementing business rules. + +## Anti-Patterns Removed +- Monolithic tests with multiple independent behaviors bundled together. + +## New Fixtures and Parameterization +- `print_run_env` fixture in `tests/test_print_run_logic.py` for state reset and shared mocks. +- Parameterized test cases in `tests/test_ams_model_detection.py`, `tests/test_ams_mapping_parser.py`, `tests/test_tray_remaining.py`, and `tests/test_filament_mismatch.py`. diff --git a/docs/test_audit.md b/docs/test_audit.md new file mode 100644 index 00000000..5e9f8399 --- /dev/null +++ b/docs/test_audit.md @@ -0,0 +1,43 @@ +# Test Audit – OpenSpoolMan + +## Inventory +- `tests/test_ams_model_detection.py` -> `mqtt_bambulab.identify_ams_model_from_module`, `identify_ams_models_from_modules`, `identify_ams_models_by_id` +- `tests/test_ams_mapping_parser.py` -> `spoolman_service.parse_ams_mapping`, `parse_ams_mapping_value` +- `tests/test_ams_mapping_from_replay.py` -> `mqtt_bambulab` replay path via `mqtt_replay` fixture + spool assignment persisted in `print_history` +- `tests/test_printhistory_replay_e2e.py` -> `print_history` inserts via `mqtt_bambulab` replay +- `tests/test_printhistory_accounting.py` -> `print_history` + `filament_usage_tracker` consumption via `mqtt_replay` +- `tests/test_printhistory_spool_assignment_from_replay.py` -> `print_history` spool assignment via `mqtt_replay` +- `tests/test_mqtt_replay.py` -> `mqtt_bambulab.processMessage`, `FilamentUsageTracker.on_message` (replay of real payloads) +- `tests/test_print_run_logic.py` -> `mqtt_bambulab.processMessage`, `bambu_state` run tracking helpers +- `tests/test_filament_mismatch.py` -> `spoolman_service.augmentTrayDataWithSpoolMan` mismatch detection +- `tests/test_tray_remaining.py` -> Jinja template `templates/fragments/tray.html` +- `tests/test_screenshots.py` -> `scripts/generate_screenshots.py` (Playwright, optional) +- `tests/test_policy_no_test_logic.py` -> guard test to prevent log parsing in tests +- `test.py` (script) -> `mqtt_bambulab.processMessage` (manual replay helper, not pytest) + +## Changes Applied +- Replaced test-owned MQTT log parsing with production helpers. + - `tests/conftest.py`: now uses `mqtt_bambulab.replay_mqtt_log`. + - `tests/test_mqtt_replay.py`: now uses `mqtt_bambulab.iter_mqtt_payloads_from_log`. + - `test.py`: now uses `mqtt_bambulab.iter_mqtt_payloads_from_log`. +- Removed manual AMS mapping derivation in `tests/test_printhistory_spool_assignment_from_replay.py`. + - The test now asserts against expected spool IDs from fixture JSON, using production replay output only. +- Added a guard test to catch direct MQTT log parsing in tests. + +## Anti-Patterns Removed +- Direct parsing of MQTT log lines in tests (`split("::")`, manual JSON extraction). +- Manual reconstruction of AMS-to-spool mappings inside tests. + +## New Production Entry Points +Added to `mqtt_bambulab.py`: +- `iter_mqtt_payloads_from_lines(lines)` +- `iter_mqtt_payloads_from_log(log_path)` +- `replay_mqtt_payloads(payloads)` +- `replay_mqtt_log(log_path)` + +These are thin wrappers around the existing ingestion path and do not re-implement business rules. + +## Rationale +- Tests now feed raw fixture data into production code and assert on production outputs or side effects. +- Mapping and parsing logic lives in production modules, eliminating test-owned business logic. +- The guard test prevents regressions toward log parsing in tests. diff --git a/filament_usage_tracker.py b/filament_usage_tracker.py index 6a924708..39cacf92 100644 --- a/filament_usage_tracker.py +++ b/filament_usage_tracker.py @@ -286,7 +286,11 @@ def on_message(self, message: dict) -> None: self._mc_remaining_time_minutes = None if command == "project_file": - self._pending_project_file = print_obj + if TRACK_LAYER_USAGE and self.active_model is None: + self._handle_print_start(print_obj) + self._pending_project_file = None + else: + self._pending_project_file = print_obj if command == "push_status": if "layer_num" in print_obj: @@ -604,21 +608,24 @@ def _apply_usage_for_filament(self, filament: int, usage_mm: float) -> bool: usage_grams = self._mm_to_grams(total_mm, diameter_mm, density) usage_rounded = round(total_mm, 5) - filament_key = filament + 1 - previous_grams = self.cumulative_grams_used.get(filament_key, 0.0) - self.cumulative_grams_used[filament_key] = previous_grams + usage_grams - previous_length = self.cumulative_length_used.get(filament_key, 0.0) + filament_id = self._resolve_filament_id(filament) + tracking_key = filament_id if filament_id is not None else f"tool_{filament}" + previous_grams = self.cumulative_grams_used.get(tracking_key, 0.0) + self.cumulative_grams_used[tracking_key] = previous_grams + usage_grams + previous_length = self.cumulative_length_used.get(tracking_key, 0.0) cumulative_length = previous_length + usage_rounded - self.cumulative_length_used[filament_key] = cumulative_length + self.cumulative_length_used[tracking_key] = cumulative_length - grams_rounded = round(self.cumulative_grams_used[filament_key], 2) + grams_rounded = round(self.cumulative_grams_used[tracking_key], 2) log(f"[filament-tracker] Consume spool {spool_id} for filament {filament} with {usage_rounded}mm ({grams_rounded}g cumulative) (tray_uid={tray_uid})") consumeSpool(spool_id, use_length=usage_rounded) - if self.print_id: - update_filament_spool(self.print_id, filament_key, spool_id) - update_filament_grams_used(self.print_id, filament_key, grams_rounded, length_used=cumulative_length) + if self.print_id and filament_id is not None: + update_filament_spool(self.print_id, filament_id, spool_id) + update_filament_grams_used(self.print_id, filament_id, grams_rounded, length_used=cumulative_length) + elif self.print_id and filament_id is None: + log(f"[filament-tracker] Skipping print history update for tool {filament}: missing filament_id mapping") self._filament_spool_id_map[filament] = spool_id self._spool_data_cache[spool_id] = spool_data @@ -737,7 +744,11 @@ def _bind_initial_spools(self) -> None: if spool_id is None: continue - update_filament_spool(self.print_id, filament_index + 1, spool_id) + filament_id = self._resolve_filament_id(filament_index) + if filament_id is not None: + update_filament_spool(self.print_id, filament_id, spool_id) + else: + log(f"[filament-tracker] Skipping initial spool bind for tool {filament_index}: missing filament_id mapping") self._filament_spool_id_map[filament_index] = spool_id if spool_id not in self._spool_data_cache: @@ -806,6 +817,33 @@ def _resolve_tray_mapping(self, filament_index: int) -> int | None: return self.ams_mapping[filament_index] return EXTERNAL_SPOOL_ID + def _resolve_filament_id(self, filament_index: int) -> int | None: + metadata = self.print_metadata or {} + tool_to_filament = metadata.get("tool_index_to_filament_id") or {} + if tool_to_filament: + direct = tool_to_filament.get(filament_index) + if direct is None: + direct = tool_to_filament.get(str(filament_index)) + try: + return int(direct) if direct is not None else None + except (TypeError, ValueError): + return None + + filament_id_order = metadata.get("filament_id_order") or [] + if filament_id_order: + try: + ordered_ids = [int(fid) for fid in filament_id_order] + except (TypeError, ValueError): + ordered_ids = [] + if ordered_ids == sorted(ordered_ids): + start = ordered_ids[0] if ordered_ids else 0 + if ordered_ids == list(range(start, start + len(ordered_ids))): + if 0 <= filament_index < len(ordered_ids): + return ordered_ids[filament_index] + return None + + return filament_index + 1 + def _lookup_spool_for_tray(self, tray_uid: str): for spool in fetchSpools(): extra = spool.get("extra") or {} @@ -852,11 +890,11 @@ def _attempt_print_resume(self, task_id, subtask_id) -> None: self.cumulative_length_used = {} if self.print_id: existing_usage = get_all_filament_usage_for_print(self.print_id) - for ams_slot, usage in existing_usage.items(): + for filament_id, usage in existing_usage.items(): grams_value = usage.get("grams_used") if isinstance(usage, dict) else usage length_value = usage.get("length_used") if isinstance(usage, dict) else None if grams_value is not None: - self.cumulative_grams_used[ams_slot] = grams_value + self.cumulative_grams_used[filament_id] = grams_value if length_value is not None: - self.cumulative_length_used[ams_slot] = length_value - log(f"[filament-tracker] Resumed cumulative usage for filament {ams_slot}: {grams_value}g, {length_value or 0}mm") + self.cumulative_length_used[filament_id] = length_value + log(f"[filament-tracker] Resumed cumulative usage for filament {filament_id}: {grams_value}g, {length_value or 0}mm") diff --git a/mqtt_bambulab.py b/mqtt_bambulab.py index 2ced0046..ad5d8650 100644 --- a/mqtt_bambulab.py +++ b/mqtt_bambulab.py @@ -1,9 +1,11 @@ -import json -import ssl -import traceback -from threading import Thread +import json +import os +import ssl +import traceback +from pathlib import Path +from threading import Thread from typing import Any, Iterable import paho.mqtt.client as mqtt @@ -51,7 +53,8 @@ PENDING_PRINT_METADATA = {} FILAMENT_TRACKER = FilamentUsageTracker() LAST_LAN_PROJECT = {} -LOG_FILE = "/home/app/logs/mqtt.log" +LOG_FILE = os.getenv("OPENSPOOLMAN_MQTT_LOG_PATH", "/home/app/logs/mqtt.log") +_LOG_WRITE_FAILED = False PENDING_PRINT_REFERENCE = {} PRINTER_MODEL_NAME = get_printer_model_name(PRINTER_ID) LAST_PREPARE_LOGGED = object() @@ -251,6 +254,41 @@ def _mask_mqtt_payload(payload: str) -> str: return masked + +def iter_mqtt_payloads_from_lines(lines: Iterable[str]) -> Iterable[dict[str, Any]]: + """Yield decoded MQTT payloads from log lines (format: ' :: ').""" + for line in lines: + if "::" not in line: + continue + payload_raw = line.split("::", 1)[1].strip() + if not payload_raw: + continue + try: + payload = json.loads(payload_raw) + except (TypeError, ValueError): + continue + if isinstance(payload, dict): + yield payload + + +def iter_mqtt_payloads_from_log(log_path: str | Path) -> Iterable[dict[str, Any]]: + """Yield decoded MQTT payloads from a saved mqtt.log file.""" + path = Path(log_path) + with path.open("r", encoding="utf-8") as handle: + yield from iter_mqtt_payloads_from_lines(handle) + + +def replay_mqtt_payloads(payloads: Iterable[dict[str, Any]]) -> None: + """Replay decoded MQTT payloads through the on_message handler.""" + for payload in payloads: + msg = type("ReplayMsg", (), {"payload": json.dumps(payload).encode("utf-8")})() + on_message(None, None, msg) + + +def replay_mqtt_log(log_path: str | Path) -> None: + """Replay an mqtt.log file through the on_message handler.""" + replay_mqtt_payloads(iter_mqtt_payloads_from_log(log_path)) + def _maybe_download_metadata(source: str | None, reason: str) -> dict | None: if not source: log(f"[DEBUG] Metadata download skipped: no source ({reason}).") @@ -275,43 +313,53 @@ def map_filament(tray_tar): # Anzahl der erkannten Wechsel change_count = len(PENDING_PRINT_METADATA["filamentChanges"]) - 1 # -1, weil der erste Eintrag kein Wechsel ist - filament_order = PENDING_PRINT_METADATA.get("filamentOrder") or {} - ordered_filaments = sorted(filament_order.items(), key=lambda entry: entry[1]) - assigned_trays = PENDING_PRINT_METADATA.setdefault("assigned_trays", []) - filament_assigned = None - if tray_tar not in assigned_trays: - assigned_trays.append(tray_tar) - unique_index = len(assigned_trays) - 1 - if unique_index < len(ordered_filaments): - filament_assigned = ordered_filaments[unique_index][0] - else: - for filamentId, usage_count in filament_order.items(): - if usage_count == change_count: - filament_assigned = filamentId - break - - if filament_assigned is not None: - mapping = PENDING_PRINT_METADATA.setdefault("ams_mapping", []) - filament_idx = int(filament_assigned) - while len(mapping) <= filament_idx: - mapping.append(None) - mapping[filament_idx] = tray_tar - log(f"✅ Tray {tray_tar} assigned to Filament {filament_assigned}") - - for filament, tray in enumerate(mapping): - if tray is None: - continue - log(f" Filament pos: {filament} → Tray {tray}") - - target_filaments = set(filament_order.keys()) - if target_filaments: - assigned_filaments = { - idx for idx, tray in enumerate(PENDING_PRINT_METADATA.get("ams_mapping", [])) - if tray is not None - } - if target_filaments.issubset(assigned_filaments): - log("\n✅ All trays assigned:") - return True + filament_order = PENDING_PRINT_METADATA.get("filamentOrder") or {} + ordered_filaments = sorted(filament_order.items(), key=lambda entry: entry[1]) + assigned_trays = PENDING_PRINT_METADATA.setdefault("assigned_trays", []) + filament_order_keys = {int(key) for key in filament_order.keys() if str(key).isdigit()} + filament_assigned = None + if tray_tar not in assigned_trays: + assigned_trays.append(tray_tar) + unique_index = len(assigned_trays) - 1 + if unique_index < len(ordered_filaments): + filament_assigned = ordered_filaments[unique_index][0] + else: + for filamentId, usage_count in filament_order.items(): + if usage_count == change_count: + filament_assigned = filamentId + break + + if filament_assigned is not None: + mapping = PENDING_PRINT_METADATA.setdefault("ams_mapping", []) + try: + filament_idx = int(filament_assigned) + except (TypeError, ValueError): + filament_idx = None + mapping_index = filament_idx + while len(mapping) <= mapping_index: + mapping.append(None) + mapping[mapping_index] = tray_tar + log(f"✅ Tray {tray_tar} assigned to Filament {filament_assigned}") + + for filament, tray in enumerate(mapping): + if tray is None: + continue + log(f" Filament pos: {filament} → Tray {tray}") + + target_filaments_raw = set(filament_order.keys()) + if target_filaments_raw and all(str(key).isdigit() for key in target_filaments_raw): + target_filaments = {int(key) for key in target_filaments_raw} + else: + target_filaments = target_filaments_raw + if target_filaments: + assigned_filaments = { + idx + for idx, tray in enumerate(PENDING_PRINT_METADATA.get("ams_mapping", [])) + if tray is not None + } + if target_filaments.issubset(assigned_filaments): + log("\n✅ All trays assigned:") + return True return False @@ -322,14 +370,16 @@ def processMessage(data): # Prepare AMS spending estimation if "print" in data: + data = copy.deepcopy(data) update_dict(PRINTER_STATE, data) print_block = PRINTER_STATE.get("print", {}) + incoming_print = data.get("print", {}) gcode_state = extract_gcode_state(data) print_status = extract_print_status(data) job_label = get_job_label(data) raw_prepare_percent = extract_prepare_percent(data) normalized_prepare_percent = normalize_prepare_percent(raw_prepare_percent) - if "gcode_file_prepare_percent" in print_block: + if "gcode_file_prepare_percent" in incoming_print: current_prepare = (raw_prepare_percent, normalized_prepare_percent) if current_prepare != LAST_PREPARE_LOGGED: log(f"[DEBUG] prepare_percent raw={raw_prepare_percent!r} normalized={normalized_prepare_percent}") @@ -340,40 +390,92 @@ def processMessage(data): supports_early = Features.SUPPORTS_EARLY_FTP_DOWNLOAD in MODEL_FEATURES.get(PRINTER_MODEL_NAME, set()) early_ready = active_now or is_prepare_ready_for_early_download(normalized_prepare_percent) - if print_block.get("command") == "project_file" and print_block.get("url"): + if incoming_print.get("command") == "project_file" and incoming_print.get("url"): log( "[print] Incoming print command: " - f"url={print_block.get('url')}, " - f"gcode_file={print_block.get('gcode_file')}, " - f"print_type={print_block.get('print_type')}, " - f"task_id={print_block.get('task_id')}, " - f"subtask_id={print_block.get('subtask_id')}, " - f"use_ams={print_block.get('use_ams')}, " - f"ams_mapping={print_block.get('ams_mapping')}" + f"url={incoming_print.get('url')}, " + f"gcode_file={incoming_print.get('gcode_file')}, " + f"print_type={incoming_print.get('print_type')}, " + f"task_id={incoming_print.get('task_id')}, " + f"subtask_id={incoming_print.get('subtask_id')}, " + f"use_ams={incoming_print.get('use_ams')}, " + f"ams_mapping={incoming_print.get('ams_mapping')}" ) + is_lan_project = _is_lan_project_url(incoming_print.get("url")) + history_print_type = "lan" if is_lan_project else "cloud" + job_label = get_job_label(data) PENDING_PRINT_REFERENCE = { - "url": print_block.get("url"), - "gcode_file": print_block.get("gcode_file"), - "task_id": print_block.get("task_id"), - "subtask_id": print_block.get("subtask_id"), - "print_type": print_block.get("print_type"), - "use_ams": print_block.get("use_ams"), - "ams_mapping": print_block.get("ams_mapping"), - "subtask_name": print_block.get("subtask_name"), + "url": incoming_print.get("url"), + "gcode_file": incoming_print.get("gcode_file"), + "task_id": incoming_print.get("task_id"), + "subtask_id": incoming_print.get("subtask_id"), + "print_type": incoming_print.get("print_type"), + "use_ams": incoming_print.get("use_ams"), + "ams_mapping": incoming_print.get("ams_mapping"), + "subtask_name": incoming_print.get("subtask_name"), + "history_print_type": history_print_type, + "job_label": job_label, } - if _is_lan_project_url(print_block.get("url")): + if is_lan_project: LAST_LAN_PROJECT = { - "task_id": print_block.get("task_id"), - "subtask_id": print_block.get("subtask_id"), - "file": print_block.get("gcode_file") or print_block.get("subtask_name"), + "task_id": incoming_print.get("task_id"), + "subtask_id": incoming_print.get("subtask_id"), + "file": incoming_print.get("gcode_file") or incoming_print.get("subtask_name"), "timestamp": time.time(), } + if not PENDING_PRINT_METADATA: + metadata = _maybe_download_metadata(incoming_print.get("url"), "project-file") + if metadata: + PENDING_PRINT_METADATA = metadata + PENDING_PRINT_METADATA["print_type"] = history_print_type + PENDING_PRINT_METADATA["task_id"] = incoming_print.get("task_id") + PENDING_PRINT_METADATA["subtask_id"] = incoming_print.get("subtask_id") + if TRACK_LAYER_USAGE: + FILAMENT_TRACKER.set_print_metadata(PENDING_PRINT_METADATA) + print_id = insert_print( + incoming_print.get("subtask_name") or PENDING_PRINT_METADATA.get("file") or "Print", + history_print_type, + PENDING_PRINT_METADATA.get("image"), + ) + PENDING_PRINT_METADATA["print_id"] = print_id + if incoming_print.get("use_ams"): + PENDING_PRINT_METADATA["ams_mapping"] = incoming_print.get("ams_mapping") or [] + else: + PENDING_PRINT_METADATA["ams_mapping"] = [EXTERNAL_SPOOL_ID] + PENDING_PRINT_METADATA["complete"] = True + PENDING_PRINT_REFERENCE["print_id"] = print_id + PENDING_PRINT_REFERENCE["history_created"] = True + PENDING_PRINT_REFERENCE["accounted"] = True + PENDING_PRINT_REFERENCE["metadata"] = PENDING_PRINT_METADATA + + for id, filament in PENDING_PRINT_METADATA.get("filaments", {}).items(): + parsed_grams = _parse_grams(filament.get("used_g")) + parsed_length_m = _parse_grams(filament.get("used_m")) + estimated_length_mm = parsed_length_m * 1000 if parsed_length_m is not None else None + grams_used = parsed_grams if parsed_grams is not None else 0.0 + length_used = estimated_length_mm if estimated_length_mm is not None else 0.0 + if TRACK_LAYER_USAGE: + grams_used = 0.0 + length_used = 0.0 + insert_filament_usage( + print_id, + filament["type"], + filament["color"], + grams_used, + id, + estimated_grams=parsed_grams, + length_used=length_used, + estimated_length=estimated_length_mm, + ) + if supports_early and early_ready and job_label and not PENDING_PRINT_METADATA: if PRINT_RUN_REGISTRY.mark_early_download_started(PRINTER_ID, job_label): metadata = _maybe_download_metadata(job_label, "early-download") if metadata: PENDING_PRINT_METADATA = metadata + if PENDING_PRINT_REFERENCE: + PENDING_PRINT_REFERENCE["metadata"] = PENDING_PRINT_METADATA if PENDING_PRINT_REFERENCE: PENDING_PRINT_METADATA["task_id"] = PENDING_PRINT_REFERENCE.get("task_id") PENDING_PRINT_METADATA["subtask_id"] = PENDING_PRINT_REFERENCE.get("subtask_id") @@ -383,6 +485,13 @@ def processMessage(data): if PRINT_RUN_REGISTRY.can_start_new_run(PRINTER_ID): PRINT_RUN_REGISTRY.start_run(PRINTER_ID, job_label, data) + existing_print_id = None + if PENDING_PRINT_REFERENCE and PENDING_PRINT_REFERENCE.get("print_id"): + if _matches_print_identity(print_block, PENDING_PRINT_REFERENCE): + existing_print_id = PENDING_PRINT_REFERENCE.get("print_id") + elif not (print_block.get("task_id") or print_block.get("subtask_id")): + existing_print_id = PENDING_PRINT_REFERENCE.get("print_id") + source = ( (PENDING_PRINT_REFERENCE or {}).get("url") or (PENDING_PRINT_REFERENCE or {}).get("gcode_file") @@ -390,9 +499,16 @@ def processMessage(data): or print_block.get("gcode_file") or job_label ) - history_print_type = print_block.get("print_type") or ("lan" if _is_lan_project_url(source) else "cloud") + history_print_type = ( + (PENDING_PRINT_REFERENCE or {}).get("history_print_type") + or print_block.get("print_type") + or ("lan" if _is_lan_project_url(source) else "cloud") + ) if not PENDING_PRINT_METADATA: - PENDING_PRINT_METADATA = _maybe_download_metadata(source, "print-start") or {} + if PENDING_PRINT_REFERENCE and PENDING_PRINT_REFERENCE.get("metadata"): + PENDING_PRINT_METADATA = PENDING_PRINT_REFERENCE.get("metadata") or {} + else: + PENDING_PRINT_METADATA = _maybe_download_metadata(source, "print-start") or {} if PENDING_PRINT_METADATA: PENDING_PRINT_METADATA["print_type"] = history_print_type @@ -415,32 +531,38 @@ def processMessage(data): PENDING_PRINT_METADATA["complete"] = True if not PENDING_PRINT_METADATA.get("tracking_started"): - print_id = insert_print( - PENDING_PRINT_METADATA.get("file") or print_block.get("subtask_name") or "Print", - history_print_type, - PENDING_PRINT_METADATA.get("image"), - ) - PENDING_PRINT_METADATA["print_id"] = print_id - - for id, filament in PENDING_PRINT_METADATA.get("filaments", {}).items(): - parsed_grams = _parse_grams(filament.get("used_g")) - parsed_length_m = _parse_grams(filament.get("used_m")) - estimated_length_mm = parsed_length_m * 1000 if parsed_length_m is not None else None - grams_used = parsed_grams if parsed_grams is not None else 0.0 - length_used = estimated_length_mm if estimated_length_mm is not None else 0.0 - if TRACK_LAYER_USAGE: - grams_used = 0.0 - length_used = 0.0 - insert_filament_usage( - print_id, - filament["type"], - filament["color"], - grams_used, - id, - estimated_grams=parsed_grams, - length_used=length_used, - estimated_length=estimated_length_mm, + if existing_print_id: + print_id = existing_print_id + PENDING_PRINT_METADATA["print_id"] = print_id + if PENDING_PRINT_REFERENCE and PENDING_PRINT_REFERENCE.get("accounted"): + PENDING_PRINT_METADATA["skip_spend"] = True + else: + print_id = insert_print( + PENDING_PRINT_METADATA.get("file") or print_block.get("subtask_name") or "Print", + history_print_type, + PENDING_PRINT_METADATA.get("image"), ) + PENDING_PRINT_METADATA["print_id"] = print_id + + for id, filament in PENDING_PRINT_METADATA.get("filaments", {}).items(): + parsed_grams = _parse_grams(filament.get("used_g")) + parsed_length_m = _parse_grams(filament.get("used_m")) + estimated_length_mm = parsed_length_m * 1000 if parsed_length_m is not None else None + grams_used = parsed_grams if parsed_grams is not None else 0.0 + length_used = estimated_length_mm if estimated_length_mm is not None else 0.0 + if TRACK_LAYER_USAGE: + grams_used = 0.0 + length_used = 0.0 + insert_filament_usage( + print_id, + filament["type"], + filament["color"], + grams_used, + id, + estimated_grams=parsed_grams, + length_used=length_used, + estimated_length=estimated_length_mm, + ) if history_print_type == "local": FILAMENT_TRACKER.start_local_print_from_metadata(PENDING_PRINT_METADATA) @@ -455,6 +577,8 @@ def processMessage(data): if final_now: PRINT_RUN_REGISTRY.finalize_run(PRINTER_ID, data) + if PENDING_PRINT_METADATA: + PENDING_PRINT_METADATA = {} #if ("gcode_state" in data["print"] and data["print"]["gcode_state"] == "RUNNING") and ("print_type" in data["print"] and data["print"]["print_type"] != "local") \ # and ("tray_tar" in data["print"] and data["print"]["tray_tar"] != "255") and ("stg_cur" in data["print"] and data["print"]["stg_cur"] == 0 and PRINT_CURRENT_STAGE != 0): @@ -504,7 +628,7 @@ def processMessage(data): if mapped: PENDING_PRINT_METADATA["complete"] = True - if PENDING_PRINT_METADATA and PENDING_PRINT_METADATA.get("complete"): + if PENDING_PRINT_METADATA and PENDING_PRINT_METADATA.get("complete") and not PENDING_PRINT_METADATA.get("skip_spend"): if TRACK_LAYER_USAGE: if PENDING_PRINT_METADATA.get("print_type") == "local": FILAMENT_TRACKER.apply_ams_mapping(PENDING_PRINT_METADATA.get("ams_mapping") or []) @@ -574,7 +698,13 @@ def on_message(client, userdata, msg): } if "print" in data: - append_to_rotating_file("/home/app/logs/mqtt.log", _mask_mqtt_payload(msg.payload.decode())) + global _LOG_WRITE_FAILED + if LOG_FILE and not _LOG_WRITE_FAILED: + try: + append_to_rotating_file(LOG_FILE, _mask_mqtt_payload(msg.payload.decode())) + except OSError as exc: + _LOG_WRITE_FAILED = True + log(f"[WARNING] Failed to write MQTT log to {LOG_FILE!r}: {exc}") #print(data) diff --git a/print_history.py b/print_history.py index 7c6cc140..b67b772f 100644 --- a/print_history.py +++ b/print_history.py @@ -1,4 +1,5 @@ import os +import shutil import sqlite3 from datetime import datetime from pathlib import Path @@ -7,6 +8,32 @@ DEFAULT_DB_NAME = "3d_printer_logs.db" DB_ENV_VAR = "OPENSPOOLMAN_PRINT_HISTORY_DB" +PRIMARY_VERSION_SUFFIX = ".v2" + + +def _is_versioned_name(path: Path) -> bool: + return PRIMARY_VERSION_SUFFIX in path.stem + + +def _strip_version_suffix(stem: str) -> str: + if PRIMARY_VERSION_SUFFIX in stem: + return stem.split(PRIMARY_VERSION_SUFFIX)[0] + return stem + + +def _find_latest_versioned(base_path: Path) -> Path | None: + if _is_versioned_name(base_path) and base_path.exists(): + return base_path + + parent = base_path.parent + if not parent.exists(): + return None + + pattern = f"{base_path.stem}{PRIMARY_VERSION_SUFFIX}*{base_path.suffix}" + candidates = [p for p in parent.glob(pattern) if p.is_file()] + if not candidates: + return None + return max(candidates, key=lambda p: p.stat().st_mtime) def _default_db_path() -> Path: @@ -14,9 +41,13 @@ def _default_db_path() -> Path: env_path = os.getenv(DB_ENV_VAR) if env_path: - return Path(env_path).expanduser().resolve() + base = Path(env_path).expanduser().resolve() + versioned = _find_latest_versioned(base) + return versioned or base - return Path(__file__).resolve().parent / "data" / DEFAULT_DB_NAME + base = Path(__file__).resolve().parent / "data" / DEFAULT_DB_NAME + versioned = _find_latest_versioned(base) + return versioned or base db_config = {"db_path": str(_default_db_path())} # Configuration for database location @@ -29,6 +60,73 @@ def _ensure_column(cursor: sqlite3.Cursor, table: str, column: str, definition: cursor.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}") +def _rename_column(cursor: sqlite3.Cursor, table: str, old: str, new: str) -> None: + cursor.execute(f"PRAGMA table_info({table})") + columns = {row[1] for row in cursor.fetchall()} + if old in columns and new not in columns: + try: + cursor.execute(f"ALTER TABLE {table} RENAME COLUMN {old} TO {new}") + except sqlite3.OperationalError: + cursor.execute(f"ALTER TABLE {table} ADD COLUMN {new} INTEGER") + cursor.execute(f"UPDATE {table} SET {new} = {old} WHERE {new} IS NULL") + +def _table_columns(cursor: sqlite3.Cursor, table: str) -> set[str] | None: + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table,)) + if cursor.fetchone() is None: + return None + cursor.execute(f"PRAGMA table_info({table})") + return {row[1] for row in cursor.fetchall()} + + +def _needs_migration(db_path: Path) -> bool: + if not db_path.exists(): + return False + if db_path.stat().st_size == 0: + return False + + conn = sqlite3.connect(db_path) + try: + cursor = conn.cursor() + tables = { + row[0] + for row in cursor.execute("SELECT name FROM sqlite_master WHERE type='table'") + } + if not tables: + return False + + usage_columns = _table_columns(cursor, "filament_usage") + if usage_columns is None: + return True + if "filament_id" not in usage_columns: + return True + for required in ("estimated_grams", "length_used", "estimated_length"): + if required not in usage_columns: + return True + + tracking_columns = _table_columns(cursor, "print_layer_tracking") + if tracking_columns is None: + return True + for required in ("predicted_end_time", "actual_end_time"): + if required not in tracking_columns: + return True + + return False + finally: + conn.close() + + +def _versioned_target_path(source: Path) -> Path: + stem = _strip_version_suffix(source.stem) + suffix = source.suffix + if not stem.endswith(PRIMARY_VERSION_SUFFIX): + stem = f"{stem}{PRIMARY_VERSION_SUFFIX}" + candidate = source.with_name(f"{stem}{suffix}") + if candidate.exists(): + timestamp = datetime.now().strftime("%Y%m%d%H%M%S") + candidate = source.with_name(f"{stem}.{timestamp}{suffix}") + return candidate + + def create_database() -> None: """ Ensure the SQLite schema exists (used for both fresh and upgrading databases). @@ -36,6 +134,17 @@ def create_database() -> None: db_path = Path(db_config["db_path"]) db_path.parent.mkdir(parents=True, exist_ok=True) + if _needs_migration(db_path): + source_path = db_path + migrated_path = _versioned_target_path(source_path) + shutil.copy2(source_path, migrated_path) + db_config["db_path"] = str(migrated_path) + db_path = migrated_path + log( + "[print-history] Detected schema changes; " + f"copied {source_path.name!r} to {migrated_path.name!r} and migrating the copy." + ) + conn = sqlite3.connect(db_path) cursor = conn.cursor() @@ -57,7 +166,7 @@ def create_database() -> None: filament_type TEXT NOT NULL, color TEXT NOT NULL, grams_used REAL NOT NULL, - ams_slot INTEGER NOT NULL, + filament_id INTEGER NOT NULL, estimated_grams REAL, length_used REAL, estimated_length REAL, @@ -65,6 +174,8 @@ def create_database() -> None: ) ''') + _rename_column(cursor, "filament_usage", "ams_slot", "filament_id") + cursor.execute(''' CREATE TABLE IF NOT EXISTS print_layer_tracking ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -146,7 +257,7 @@ def insert_filament_usage( filament_type: str, color: str, grams_used: float, - ams_slot: int, + filament_id: int, estimated_grams: float | None = None, length_used: float | None = None, estimated_length: float | None = None, @@ -157,14 +268,14 @@ def insert_filament_usage( conn = sqlite3.connect(db_config["db_path"]) cursor = conn.cursor() cursor.execute(''' - INSERT INTO filament_usage (print_id, filament_type, color, grams_used, ams_slot, estimated_grams, length_used, estimated_length) + INSERT INTO filament_usage (print_id, filament_type, color, grams_used, filament_id, estimated_grams, length_used, estimated_length) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ''', (print_id, filament_type, color, grams_used, ams_slot, estimated_grams, length_used, estimated_length)) + ''', (print_id, filament_type, color, grams_used, filament_id, estimated_grams, length_used, estimated_length)) conn.commit() conn.close() log( "[print-history] filament usage inserted " - f"print_id={print_id} ams_slot={ams_slot} type={filament_type!r} color={color} " + f"print_id={print_id} filament_id={filament_id} type={filament_type!r} color={color} " f"grams_used={grams_used} estimated_grams={estimated_grams} " f"length_used={length_used} estimated_length={estimated_length}" ) @@ -178,13 +289,13 @@ def update_filament_spool(print_id: int, filament_id: int, spool_id: int) -> Non cursor.execute(''' UPDATE filament_usage SET spool_id = ? - WHERE ams_slot = ? AND print_id = ? + WHERE filament_id = ? AND print_id = ? ''', (spool_id, filament_id, print_id)) conn.commit() conn.close() log( "[print-history] spool assigned " - f"print_id={print_id} ams_slot={filament_id} spool_id={spool_id}" + f"print_id={print_id} filament_id={filament_id} spool_id={spool_id}" ) def update_filament_grams_used(print_id: int, filament_id: int, grams_used: float, length_used: float | None = None) -> None: @@ -205,13 +316,13 @@ def update_filament_grams_used(print_id: int, filament_id: int, grams_used: floa cursor.execute(f''' UPDATE filament_usage SET {set_clause} - WHERE ams_slot = ? AND print_id = ? + WHERE filament_id = ? AND print_id = ? ''', params) conn.commit() conn.close() log( "[print-history] filament billed " - f"print_id={print_id} ams_slot={filament_id} grams_used={grams_used} length_used={length_used}" + f"print_id={print_id} filament_id={filament_id} grams_used={grams_used} length_used={length_used}" ) @@ -241,7 +352,8 @@ def get_prints_with_filament(limit: int | None = None, offset: int | None = None 'estimated_grams', f.estimated_grams, 'length_used', f.length_used, 'estimated_length', f.estimated_length, - 'ams_slot', f.ams_slot + 'filament_id', f.filament_id, + 'ams_slot', f.filament_id )) FROM filament_usage f WHERE f.print_id = p.id ) AS filament_info FROM prints p @@ -276,19 +388,27 @@ def get_prints_by_spool(spool_id: int): conn.close() return prints -def get_filament_for_slot(print_id: int, ams_slot: int): +def get_filament_for_filament_id(print_id: int, filament_id: int): conn = sqlite3.connect(db_config["db_path"]) conn.row_factory = sqlite3.Row # Enable column name access cursor = conn.cursor() cursor.execute(''' SELECT * FROM filament_usage - WHERE print_id = ? AND ams_slot = ? - ''', (print_id, ams_slot)) + WHERE print_id = ? AND filament_id = ? + ''', (print_id, filament_id)) row = cursor.fetchone() conn.close() - return dict(row) if row else None + if not row: + return None + result = dict(row) + result.setdefault("ams_slot", result.get("filament_id")) + return result + + +def get_filament_for_slot(print_id: int, ams_slot: int): + return get_filament_for_filament_id(print_id, ams_slot) def _ensure_layer_tracking_entry(print_id: int): conn = sqlite3.connect(db_config["db_path"]) @@ -353,19 +473,19 @@ def get_layer_tracking_for_prints(print_ids: list[int]): def get_all_filament_usage_for_print(print_id: int): """ Retrieves all filament usage entries for a specific print. - Returns a dict mapping ams_slot to a dict with grams_used and length_used. + Returns a dict mapping filament_id to a dict with grams_used and length_used. """ conn = sqlite3.connect(db_config["db_path"]) conn.row_factory = sqlite3.Row cursor = conn.cursor() cursor.execute(''' - SELECT ams_slot, grams_used, length_used FROM filament_usage + SELECT filament_id, grams_used, length_used FROM filament_usage WHERE print_id = ? ''', (print_id,)) results = { - row["ams_slot"]: { + row["filament_id"]: { "grams_used": row["grams_used"], "length_used": row["length_used"], } diff --git a/pytest.ini b/pytest.ini index 12ab6b13..1bbc257d 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,3 +1,6 @@ [pytest] +addopts = -ra --strict-markers --strict-config +testpaths = tests +python_files = test_*.py markers = screenshots: generate UI screenshots (requires --generate-screenshots) diff --git a/requirements.txt b/requirements.txt index 83ac12be..ee3b0cfd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,5 @@ pyopenssl==24.3.0 pycurl==7.45.6 gunicorn==23.0.0 pytest==8.3.4 +pytest-asyncio==1.3.0 python-dotenv==1.0.1 diff --git a/scripts/screenshot_config.json b/scripts/screenshot_config.json index b153e67b..207a28ff 100644 --- a/scripts/screenshot_config.json +++ b/scripts/screenshot_config.json @@ -36,7 +36,7 @@ }, { "name": "change_spool", - "route": "/print_select_spool?ams_slot=1&print_id=352&old_spool_id=33&change_spool=True", + "route": "/print_select_spool?filament_id=1&print_id=352&old_spool_id=33&change_spool=True", "output": "docs/img/change_spool.PNG", "max_height": {"desktop": 1100, "mobile": 1100} } diff --git a/spoolman_service.py b/spoolman_service.py index f81a0848..296dc054 100644 --- a/spoolman_service.py +++ b/spoolman_service.py @@ -86,6 +86,10 @@ def parse_ams_mapping_value(value): except (TypeError, ValueError): return result + if v < 0: + result["source_type"] = "unused" + return result + if v == 255: result["source_type"] = "unload" return result @@ -107,6 +111,49 @@ def parse_ams_mapping_value(value): log(f"[WARNING] AMS mapping value {v} is outside expected ranges; treating as encoded.") return {"source_type": "ams", "ams_id": ams_id, "slot_id": slot_id} + +def _coerce_int(value): + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _is_sequential_ids(values: list[int]) -> bool: + if not values: + return False + sorted_values = sorted(values) + start = sorted_values[0] + return sorted_values == list(range(start, start + len(sorted_values))) + + +def _resolve_tool_index_for_filament(printdata: dict, filament_id) -> int | None: + filament_id_int = _coerce_int(filament_id) + filament_to_tool = printdata.get("filament_id_to_tool_index") or {} + if filament_to_tool: + direct = filament_to_tool.get(filament_id_int) + if direct is None: + direct = filament_to_tool.get(str(filament_id_int)) + if direct is not None: + return _coerce_int(direct) + + filament_id_order = printdata.get("filament_id_order") or [] + if filament_id_order and filament_id_int is not None: + try: + ordered_ids = [_coerce_int(fid) for fid in filament_id_order] + except Exception: + ordered_ids = [] + if all(val is not None for val in ordered_ids): + ordered_ids = [val for val in ordered_ids if val is not None] + if ordered_ids == sorted(ordered_ids) and _is_sequential_ids(ordered_ids): + if filament_id_int in ordered_ids: + return ordered_ids.index(filament_id_int) + return None + + if not filament_id_order and filament_id_int is not None: + return filament_id_int - 1 + return None + def parse_ams_mapping(ams_mapping, ams_mapping2=None): if not ams_mapping: return [] @@ -454,7 +501,6 @@ def spendFilaments(printdata): ams_mapping = [EXTERNAL_SPOOL_ID] parsed_mapping = parse_ams_mapping(ams_mapping, printdata.get("ams_mapping2")) external_default = bool(ams_mapping) and ams_mapping[0] == EXTERNAL_SPOOL_ID - """ "ams_mapping": [ 1, @@ -468,11 +514,23 @@ def spendFilaments(printdata): """ ams_usage = [] for filamentId, filament in printdata["filaments"].items(): + try: + filament_id = int(filamentId) + except (TypeError, ValueError): + filament_id = filamentId + if external_default: entry = {"source_type": "external", "ams_id": None, "slot_id": None} else: - index = filamentId - 1 - entry = parsed_mapping[index] if 0 <= index < len(parsed_mapping) else {"source_type": "unknown", "ams_id": None, "slot_id": None} + index = _resolve_tool_index_for_filament(printdata, filament_id) + if index is None: + log(f"[WARNING] Skipping filament usage for filament {filament_id} due to missing tool index mapping") + continue + entry = ( + parsed_mapping[index] + if 0 <= index < len(parsed_mapping) + else {"source_type": "unknown", "ams_id": None, "slot_id": None} + ) if entry["source_type"] == "external": ams_id = EXTERNAL_SPOOL_AMS_ID @@ -481,10 +539,10 @@ def spendFilaments(printdata): ams_id = entry["ams_id"] tray_id = entry["slot_id"] else: - log(f"[WARNING] Skipping filament usage for filament {filamentId} due to AMS mapping source={entry['source_type']}") + log(f"[WARNING] Skipping filament usage for filament {filament_id} due to AMS mapping source={entry['source_type']}") continue - ams_usage.append({"trayUid": trayUid(ams_id, tray_id), "id": filamentId, "usedGrams":float(filament["used_g"])}) + ams_usage.append({"trayUid": trayUid(ams_id, tray_id), "id": filament_id, "usedGrams":float(filament["used_g"])}) for spool in fetchSpools(): #TODO: What if there is a mismatch between AMS and SpoolMan? diff --git a/templates/fragments/list_prints.html b/templates/fragments/list_prints.html index b00cc49b..18c8d281 100644 --- a/templates/fragments/list_prints.html +++ b/templates/fragments/list_prints.html @@ -159,6 +159,7 @@
#{{ filament['spool'].id }} – {{ filament['spool'].filament.vendor.name }} – {{ filament['spool'].filament.material }}
+ Filament ID: {{ filament['filament_id'] }} {{ filament['spool'].filament.name }} {% if filament['grams_used'] is not none %} @@ -185,14 +186,14 @@
#{{ filament['spool'].id }} – {{ filament['spool'].filament.vendor.name }}
- + {% else %}
- +
@@ -204,6 +205,7 @@
#{{ filament['spool'].id }} – {{ filament['spool'].filament.vendor.name }}
No spool assigned - {{ filament['filament_type'] }}
+ Filament ID: {{ filament['filament_id'] }} {{ '%.2f' | format(filament['grams_used'] or 0) }}g printed diff --git a/templates/fragments/list_spools.html b/templates/fragments/list_spools.html index 0d35baf9..b875e39e 100644 --- a/templates/fragments/list_spools.html +++ b/templates/fragments/list_spools.html @@ -117,7 +117,7 @@
{% endif %} {% if action_assign_print %} - + Assign diff --git a/templates/print_select_spool.html b/templates/print_select_spool.html index d39f4f70..f9450fbc 100644 --- a/templates/print_select_spool.html +++ b/templates/print_select_spool.html @@ -5,13 +5,15 @@ {% if change_spool %}

Change spool

+
Filament ID: {{ filament_id }}
{% else %}

Assign spool to print

+
Filament ID: {{ filament_id }}
{% endif %} {% with action_assign_print=True, materials=materials, selected_materials=selected_materials %}{% include 'fragments/list_spools.html' %}{% endwith %} {% endblock %} diff --git a/test.py b/test.py index ca7a972b..37f963a6 100644 --- a/test.py +++ b/test.py @@ -1,19 +1,13 @@ -import json -import re from dotenv import load_dotenv -from mqtt_bambulab import processMessage +from mqtt_bambulab import iter_mqtt_payloads_from_log, processMessage from logger import log load_dotenv() def run_test(): - i = 1 - with open("mqtt.log", "r", encoding="utf-8") as file: - for line in file: - cleaned_line = re.sub(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} :: ", "", line.strip()) - log("row "+ str(i)) - i= i + 1 - processMessage(json.loads(cleaned_line)) + for idx, payload in enumerate(iter_mqtt_payloads_from_log("mqtt.log"), start=1): + log("row " + str(idx)) + processMessage(payload) run_test() diff --git a/test_data.py b/test_data.py index 0c670ac9..ef27127d 100644 --- a/test_data.py +++ b/test_data.py @@ -179,25 +179,31 @@ def get_prints_with_filament(limit=50, offset=0): return prints, len(dataset.get("prints", [])) -def get_filament_for_slot(print_id, ams_slot): +def get_filament_for_filament_id(print_id, filament_id): dataset = _ensure_dataset_loaded() for print_job in dataset.get("prints", []): if int(print_job.get("id")) != int(print_id): continue for filament in json.loads(print_job.get("filament_info", "[]")): - if int(filament.get("ams_slot")) == int(ams_slot): + current_id = filament.get("filament_id", filament.get("ams_slot")) + if current_id is not None and int(current_id) == int(filament_id): return filament return None -def update_filament_spool(print_id, ams_slot, spool_id): +def get_filament_for_slot(print_id, ams_slot): + return get_filament_for_filament_id(print_id, ams_slot) + + +def update_filament_spool(print_id, filament_id, spool_id): dataset = _ensure_dataset_loaded() for print_job in dataset.get("prints", []): if int(print_job.get("id")) != int(print_id): continue filaments = json.loads(print_job.get("filament_info", "[]")) for filament in filaments: - if int(filament.get("ams_slot")) == int(ams_slot): + current_id = filament.get("filament_id", filament.get("ams_slot")) + if current_id is not None and int(current_id) == int(filament_id): filament["spool_id"] = int(spool_id) print_job["filament_info"] = json.dumps(filaments) return True @@ -221,6 +227,7 @@ def wait_for_seed_ready(timeout=10): "spoolman_client.consumeSpool": consumeSpool, "spoolman_client.patchExtraTags": patchExtraTags, "print_history.get_prints_with_filament": get_prints_with_filament, + "print_history.get_filament_for_filament_id": get_filament_for_filament_id, "print_history.get_filament_for_slot": get_filament_for_slot, "print_history.update_filament_spool": update_filament_spool, "spoolman_service.fetchSpools": fetchSpools, diff --git a/tests/conftest.py b/tests/conftest.py index 3f6ed7aa..d13f01f4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -39,18 +39,6 @@ def _copy_model_to_temp(model_path: Path) -> Path: return temp_model_path -def _iter_payloads(log_path: Path): - with log_path.open() as handle: - for line in handle: - if "::" not in line: - continue - try: - payload = json.loads(line.split("::", 1)[1].strip()) - except Exception: - continue - yield payload - - def _make_spool(spool_id: int, tray_uid: str, color_hex: str = "FFFFFF") -> dict: return { "id": spool_id, @@ -190,9 +178,7 @@ def _fake_get_meta(_url: str): existing_prints = {p.name for p in prints_dir.iterdir() if p.is_file()} try: - for payload in _iter_payloads(log_path): - msg = type("FakeMsg", (), {"payload": json.dumps(payload).encode("utf-8")})() - mqtt_bambulab.on_message(None, None, msg) + mqtt_bambulab.replay_mqtt_log(log_path) finally: temp_model_path.unlink(missing_ok=True) current_prints = {p.name for p in prints_dir.iterdir() if p.is_file()} diff --git a/tests/test_ams_mapping_from_replay.py b/tests/test_ams_mapping_from_replay.py index 430465e5..398c0928 100644 --- a/tests/test_ams_mapping_from_replay.py +++ b/tests/test_ams_mapping_from_replay.py @@ -47,7 +47,7 @@ def _get_print_by_id(print_id: int): def _filament_info_by_slot(print_row: dict) -> dict[int, dict]: filament_info = json.loads(print_row["filament_info"]) - return {entry["ams_slot"]: entry for entry in filament_info} + return {entry["filament_id"]: entry for entry in filament_info} def _iter_cases(): @@ -60,7 +60,7 @@ def _iter_cases(): @pytest.mark.parametrize("track_layer_usage", [False, True], ids=["no_layer_tracking", "layer_tracking"]) @pytest.mark.parametrize("log_path, expected", list(_iter_cases())) -def test_replay_ams_mapping_assigns_spools( +def test_assigns_spools_when_replaying_ams_mapping( mqtt_replay, fake_spoolman, monkeypatch, diff --git a/tests/test_ams_mapping_parser.py b/tests/test_ams_mapping_parser.py index 92c1f05f..3c48a46e 100644 --- a/tests/test_ams_mapping_parser.py +++ b/tests/test_ams_mapping_parser.py @@ -1,7 +1,9 @@ +import pytest + import spoolman_service -def test_parse_ht_direct_with_mapping2_match(monkeypatch): +def test_returns_mapping_when_mapping2_matches(monkeypatch): logs = [] monkeypatch.setattr(spoolman_service, "log", lambda msg: logs.append(msg)) @@ -16,23 +18,20 @@ def test_parse_ht_direct_with_mapping2_match(monkeypatch): assert not any("AMS mapping mismatch" in msg for msg in logs) -def test_parse_ams_mapping_cases(): +def test_parses_ams_mapping_when_values_provided(): parsed = spoolman_service.parse_ams_mapping([1, 512, 3, 0, 2]) assert [entry["ams_id"] for entry in parsed] == [0, 128, 0, 0, 0] assert [entry["slot_id"] for entry in parsed] == [1, 0, 3, 0, 2] - unload = spoolman_service.parse_ams_mapping_value(255) - assert unload["source_type"] == "unload" - assert unload["ams_id"] is None - assert unload["slot_id"] is None - - external = spoolman_service.parse_ams_mapping_value(254) - assert external["source_type"] == "external" - assert external["ams_id"] is None - assert external["slot_id"] is None - - # Deterministic handling for values between 256 and 511 (encoded standard) - encoded = spoolman_service.parse_ams_mapping_value(500) - assert encoded["source_type"] == "ams" - assert encoded["ams_id"] == 125 - assert encoded["slot_id"] == 0 + +@pytest.mark.parametrize( + "value, expected", + [ + (255, {"source_type": "unload", "ams_id": None, "slot_id": None}), + (254, {"source_type": "external", "ams_id": None, "slot_id": None}), + (500, {"source_type": "ams", "ams_id": 125, "slot_id": 0}), + ], + ids=["unload", "external", "encoded"], +) +def test_parses_mapping_value_when_code_provided(value, expected): + assert spoolman_service.parse_ams_mapping_value(value) == expected diff --git a/tests/test_ams_model_detection.py b/tests/test_ams_model_detection.py index 652595f7..56f6c313 100644 --- a/tests/test_ams_model_detection.py +++ b/tests/test_ams_model_detection.py @@ -1,27 +1,23 @@ -import mqtt_bambulab - - -def test_identifies_ams_lite_module(): - module = {"name": "ams_f1/0", "product_name": "AMS Lite"} - assert mqtt_bambulab.identify_ams_model_from_module(module) == "AMS Lite" - +import pytest -def test_identifies_ams_2_pro_module(): - module = {"name": "n3f/1", "product_name": "AMS 2 Pro (2)"} - assert mqtt_bambulab.identify_ams_model_from_module(module) == "AMS 2 Pro" - - -def test_identifies_ams_ht_module(): - module = {"name": "ams_ht/0", "product_name": "AMS HT"} - assert mqtt_bambulab.identify_ams_model_from_module(module) == "AMS HT" +import mqtt_bambulab -def test_falls_back_to_ams_when_name_matches(): - module = {"name": "ams/3", "product_name": "AMS (3)"} - assert mqtt_bambulab.identify_ams_model_from_module(module) == "AMS" +@pytest.mark.parametrize( + "module, expected", + [ + ({"name": "ams_f1/0", "product_name": "AMS Lite"}, "AMS Lite"), + ({"name": "n3f/1", "product_name": "AMS 2 Pro (2)"}, "AMS 2 Pro"), + ({"name": "ams_ht/0", "product_name": "AMS HT"}, "AMS HT"), + ({"name": "ams/3", "product_name": "AMS (3)"}, "AMS"), + ], + ids=["lite", "2pro", "ht", "ams"], +) +def test_returns_expected_model_when_module_signature_matches(module, expected): + assert mqtt_bambulab.identify_ams_model_from_module(module) == expected -def test_detects_multiple_modules(): +def test_returns_models_when_multiple_modules_are_present(): modules = [ {"name": "ams_f1/0", "product_name": "AMS Lite"}, {"name": "n3f/0", "product_name": "AMS 2 Pro (1)"}, @@ -33,7 +29,7 @@ def test_detects_multiple_modules(): assert results["ams/0"]["model"] == "AMS" -def test_detects_models_by_numeric_id(): +def test_returns_models_by_id_when_modules_have_numeric_suffixes(): modules = [ {"name": "ams_f1/0", "product_name": "AMS Lite"}, {"name": "n3f/1", "product_name": "AMS 2 Pro (2)"}, diff --git a/tests/test_filament_mismatch.py b/tests/test_filament_mismatch.py index 46d7c235..ffda765a 100644 --- a/tests/test_filament_mismatch.py +++ b/tests/test_filament_mismatch.py @@ -45,7 +45,7 @@ def _run_case(tray, spool, ams_id=0, tray_id="tray-1", log_stub=None): return tray -def test_match_with_extra_type(): +def test_matches_when_spool_extra_type_matches_tray_sub_brand(): tray = _make_tray("PLA", "PLA CF") spool = _make_spool("PLA", "CF") result = _run_case(tray, spool) @@ -56,7 +56,7 @@ def test_match_with_extra_type(): assert result["spool_sub_brand"] == "CF" -def test_match_when_material_contains_subtype(): +def test_matches_when_spool_material_includes_subtype(): tray = _make_tray("PLA", "PLA CF") spool = _make_spool("PLA CF", "-") result = _run_case(tray, spool) @@ -67,7 +67,7 @@ def test_match_when_material_contains_subtype(): assert result["spool_sub_brand"] == "CF" -def test_mismatch_when_subtype_missing_on_spool(): +def test_flags_mismatch_when_spool_subtype_missing(): tray = _make_tray("PLA", "PLA CF") spool = _make_spool("PLA", "") result = _run_case(tray, spool) @@ -78,7 +78,7 @@ def test_mismatch_when_subtype_missing_on_spool(): assert result["spool_sub_brand"] == "" -def test_material_mismatch_even_if_subtype_matches(): +def test_flags_mismatch_when_material_differs_even_with_subtype(): tray = _make_tray("PLA", "PLA CF") spool = _make_spool("ABS CF", "CF") result = _run_case(tray, spool) @@ -89,7 +89,7 @@ def test_material_mismatch_even_if_subtype_matches(): assert result["spool_sub_brand"] == "CF" -def test_variant_type_requires_exact_material_match(): +def test_flags_mismatch_when_variant_type_material_differs(): tray = _make_tray("PLA-S", "Support for PLA") spool = _make_spool("PLA", "Support for PLA") result = _run_case(tray, spool) @@ -98,7 +98,7 @@ def test_variant_type_requires_exact_material_match(): assert result["mismatch"] is True # main type differs because tray expects PLA-S -def test_variant_type_matches_when_spool_material_exact(): +def test_matches_when_variant_type_material_matches(): tray = _make_tray("PLA-S", "Support for PLA") spool = _make_spool("PLA-S", "Support for PLA") result = _run_case(tray, spool) @@ -107,7 +107,7 @@ def test_variant_type_matches_when_spool_material_exact(): assert result["mismatch"] is False -def test_mismatch_warning_can_be_disabled(monkeypatch): +def test_hides_mismatch_when_warning_disabled(monkeypatch): monkeypatch.setattr(svc, "DISABLE_MISMATCH_WARNING", True) tray = _make_tray("PLA", "PLA CF") spool = _make_spool("PLA", "") @@ -117,7 +117,7 @@ def test_mismatch_warning_can_be_disabled(monkeypatch): assert result["mismatch"] is False # hidden in UI -def test_color_mismatch_logs_distance(): +def test_logs_color_distance_when_color_mismatch(): tray = _make_tray("PLA", "") tray["tray_color"] = "FFFFFF" spool = _make_spool("PLA", "") @@ -207,23 +207,28 @@ def _capture_log(tray_logged, spool_logged, color_distance=None, reason="materia ] -def test_bambu_base_profiles_match_tray_expectations(): - failures = [] - for tray_type, tray_sub_brands, spool_material, spool_type, expected_match in BAMBULAB_BASE_MAPPINGS: - tray = _make_tray(tray_type, tray_sub_brands) - spool = _make_spool(spool_material, spool_type, spool_extra_type=spool_type) +@pytest.mark.parametrize( + "tray_type,tray_sub_brands,spool_material,spool_type,expected_match", + BAMBULAB_BASE_MAPPINGS, + ids=[ + f"{tray_type}|{tray_sub_brands}|{spool_material}|{spool_type}|{expected_match}" + for tray_type, tray_sub_brands, spool_material, spool_type, expected_match in BAMBULAB_BASE_MAPPINGS + ], +) +def test_bambu_base_profile_expectations_when_tray_and_spool_compare( + tray_type, + tray_sub_brands, + spool_material, + spool_type, + expected_match, +): + tray = _make_tray(tray_type, tray_sub_brands) + spool = _make_spool(spool_material, spool_type, spool_extra_type=spool_type) - result = _run_case(tray, spool) - - ctx = f"(tray_type={tray_type!r}, tray_sub_brands={tray_sub_brands!r}, spool_material={spool_material!r}, spool_type={spool_type!r})" - if result["matched"] is not True: - failures.append(f"{ctx} did not match tray/spool assignment") - continue - - if expected_match and result.get("mismatch_detected"): - failures.append(f"{ctx} unexpectedly triggered mismatch") - if not expected_match and not result.get("mismatch_detected"): - failures.append(f"{ctx} should trigger mismatch but did not") + result = _run_case(tray, spool) - if failures: - pytest.fail("\n".join(failures)) + assert result["matched"] is True + if expected_match: + assert not result.get("mismatch_detected") + else: + assert result.get("mismatch_detected") diff --git a/tests/test_mqtt_replay.py b/tests/test_mqtt_replay.py index 78614c26..eebce321 100644 --- a/tests/test_mqtt_replay.py +++ b/tests/test_mqtt_replay.py @@ -3,7 +3,6 @@ import logging import os import shutil -import tempfile from pathlib import Path import pytest @@ -109,7 +108,12 @@ def _fake(_url: str): @pytest.mark.parametrize("log_path", _iter_log_files(), ids=lambda p: p.name) -def test_mqtt_log_tray_detection(log_path, monkeypatch, caplog): +def test_records_expected_tray_assignments_when_replaying_log( + log_path, + monkeypatch, + caplog, + tmp_path, +): expected = _load_expected(log_path) expected_assignments_raw = expected.get("expected_assignments") or {} expected_assignments = {str(k): str(v) for k, v in expected_assignments_raw.items()} @@ -119,9 +123,7 @@ def test_mqtt_log_tray_detection(log_path, monkeypatch, caplog): if not model_path.exists(): pytest.skip(f"Missing test model: {model_path}") - temp_file = tempfile.NamedTemporaryFile(suffix=".3mf", delete=False) - temp_file.close() - temp_model_path = Path(temp_file.name) + temp_model_path = tmp_path / "model.3mf" shutil.copy2(model_path, temp_model_path) _stub_spoolman(monkeypatch) @@ -133,6 +135,9 @@ def test_mqtt_log_tray_detection(log_path, monkeypatch, caplog): mqtt_bambulab.PRINTER_STATE = {} mqtt_bambulab.PRINTER_STATE_LAST = {} mqtt_bambulab.PENDING_PRINT_METADATA = {} + mqtt_bambulab.PENDING_PRINT_REFERENCE = {} + mqtt_bambulab.PRINT_RUN_REGISTRY.reset() + mqtt_bambulab.LAST_LAN_PROJECT = {} assignments = {} last_tracker_mapping: list[int] | None = None @@ -145,9 +150,11 @@ def _record_map(tray_tar): result = original_map_filament(tray_tar) metadata = mqtt_bambulab.PENDING_PRINT_METADATA or {} for idx, tray in enumerate(metadata.get("ams_mapping", [])): + if tray is None: + continue assignments[str(idx)] = str(tray) return result - + monkeypatch.setattr(mqtt_bambulab, "map_filament", _record_map) original_resolve = FilamentUsageTracker._resolve_tray_mapping @@ -156,36 +163,21 @@ def _record_resolve(self, filament_index): if result is not None: assignments[str(filament_index)] = str(result) return result - + monkeypatch.setattr(FilamentUsageTracker, "_resolve_tray_mapping", _record_resolve) monkeypatch.setattr(FilamentUsageTracker, "_retrieve_model", lambda self, _: str(temp_model_path)) - try: - with log_path.open() as handle: - for line in handle: - if "::" not in line: - continue - try: - payload = json.loads(line.split("::", 1)[1].strip()) - except Exception: - continue - - mqtt_bambulab.processMessage(payload) - mqtt_bambulab.FILAMENT_TRACKER.on_message(payload) - metadata = mqtt_bambulab.PENDING_PRINT_METADATA - if metadata: - for idx, tray in enumerate(metadata.get("ams_mapping", [])): - assignments[str(idx)] = str(tray) - print_obj = payload.get("print", {}) - if print_obj.get("command") == "project_file": - ams_mapping = print_obj.get("ams_mapping") or [] - for idx, tray in enumerate(ams_mapping): - assignments[str(idx)] = str(tray) - tracker_mapping = mqtt_bambulab.FILAMENT_TRACKER.ams_mapping - if tracker_mapping: - last_tracker_mapping = list(tracker_mapping) - finally: - temp_model_path.unlink(missing_ok=True) + for payload in mqtt_bambulab.iter_mqtt_payloads_from_log(log_path): + mqtt_bambulab.processMessage(payload) + mqtt_bambulab.FILAMENT_TRACKER.on_message(payload) + metadata = mqtt_bambulab.PENDING_PRINT_METADATA or {} + for idx, tray in enumerate(metadata.get("ams_mapping", [])): + if tray is None: + continue + assignments[str(idx)] = str(tray) + tracker_mapping = mqtt_bambulab.FILAMENT_TRACKER.ams_mapping + if tracker_mapping: + last_tracker_mapping = list(tracker_mapping) if expected_assignments: missing = { diff --git a/tests/test_policy_no_test_logic.py b/tests/test_policy_no_test_logic.py new file mode 100644 index 00000000..d4aea1ee --- /dev/null +++ b/tests/test_policy_no_test_logic.py @@ -0,0 +1,18 @@ +from pathlib import Path + + +def test_no_direct_mqtt_log_parsing_in_tests(): + test_root = Path(__file__).resolve().parent + offenders = [] + + for path in test_root.rglob("*.py"): + if path.name == Path(__file__).name: + continue + text = path.read_text(encoding="utf-8") + if "split(\"::\"" in text or "split('::'" in text: + offenders.append(str(path)) + + assert not offenders, ( + "Direct mqtt.log parsing detected in tests. " + f"Move log parsing into production helpers instead: {', '.join(offenders)}" + ) diff --git a/tests/test_print_run_logic.py b/tests/test_print_run_logic.py index 3246eda7..c39a064a 100644 --- a/tests/test_print_run_logic.py +++ b/tests/test_print_run_logic.py @@ -1,3 +1,5 @@ +import pytest + import mqtt_bambulab from bambu_state import Features, PRINT_RUN_REGISTRY, get_job_label from filament_usage_tracker import FilamentUsageTracker @@ -23,68 +25,72 @@ def _mock_metadata(): } -def test_print_start_detected_by_state_not_prepare_percent(monkeypatch): +@pytest.fixture +def print_run_env(monkeypatch): _reset_state() - calls = [] - monkeypatch.setattr(mqtt_bambulab, "insert_print", lambda *args, **kwargs: calls.append(args) or 1) + insert_calls = [] + metadata_calls = [] + + def _get_metadata(url): + metadata_calls.append(url) + return _mock_metadata() + + monkeypatch.setattr( + mqtt_bambulab, + "insert_print", + lambda *args, **kwargs: insert_calls.append(args) or 1, + ) monkeypatch.setattr(mqtt_bambulab, "insert_filament_usage", lambda *args, **kwargs: None) - monkeypatch.setattr(mqtt_bambulab, "getMetaDataFrom3mf", lambda _url: _mock_metadata()) + monkeypatch.setattr(mqtt_bambulab, "getMetaDataFrom3mf", _get_metadata) monkeypatch.setattr(mqtt_bambulab, "TRACK_LAYER_USAGE", False) + monkeypatch.setitem(mqtt_bambulab.MODEL_FEATURES, mqtt_bambulab.PRINTER_MODEL_NAME, set()) + + return {"insert_calls": insert_calls, "metadata_calls": metadata_calls} + +def test_starts_run_when_state_running_not_prepare_percent(print_run_env): mqtt_bambulab.processMessage( {"print": {"gcode_state": "PREPARE", "gcode_file_prepare_percent": "99", "gcode_file": "Testprint.3mf"}} ) assert PRINT_RUN_REGISTRY.get_active_run(PRINTER_ID) is None - assert calls == [] + assert print_run_env["insert_calls"] == [] mqtt_bambulab.processMessage( {"print": {"gcode_state": "RUNNING", "gcode_file": "Testprint.3mf", "print_type": "local"}} ) assert PRINT_RUN_REGISTRY.get_active_run(PRINTER_ID) is not None - assert len(calls) == 1 + assert len(print_run_env["insert_calls"]) == 1 -def test_prepare_percent_99_allows_early_download_when_supported(monkeypatch): - _reset_state() - - calls = [] - monkeypatch.setattr(mqtt_bambulab, "getMetaDataFrom3mf", lambda _url: calls.append(_url) or _mock_metadata()) - monkeypatch.setattr(mqtt_bambulab, "insert_print", lambda *args, **kwargs: 1) - monkeypatch.setattr(mqtt_bambulab, "insert_filament_usage", lambda *args, **kwargs: None) - monkeypatch.setitem(mqtt_bambulab.MODEL_FEATURES, mqtt_bambulab.PRINTER_MODEL_NAME, {Features.SUPPORTS_EARLY_FTP_DOWNLOAD}) - monkeypatch.setattr(mqtt_bambulab, "TRACK_LAYER_USAGE", False) +def test_triggers_early_download_when_prepare_99_and_supported(monkeypatch, print_run_env): + monkeypatch.setitem( + mqtt_bambulab.MODEL_FEATURES, + mqtt_bambulab.PRINTER_MODEL_NAME, + {Features.SUPPORTS_EARLY_FTP_DOWNLOAD}, + ) mqtt_bambulab.processMessage( {"print": {"gcode_state": "PREPARE", "gcode_file_prepare_percent": "99", "url": "ftp://printer/Testprint.3mf"}} ) assert PRINT_RUN_REGISTRY.get_active_run(PRINTER_ID) is None - assert len(calls) == 1 + assert len(print_run_env["metadata_calls"]) == 1 mqtt_bambulab.processMessage( {"print": {"gcode_state": "RUNNING", "url": "ftp://printer/Testprint.3mf", "print_type": "local"}} ) assert PRINT_RUN_REGISTRY.get_active_run(PRINTER_ID) is not None - assert len(calls) == 1 - - -def test_prepare_percent_99_does_not_trigger_early_download_when_not_supported(monkeypatch): - _reset_state() + assert len(print_run_env["metadata_calls"]) == 1 - calls = [] - monkeypatch.setattr(mqtt_bambulab, "getMetaDataFrom3mf", lambda _url: calls.append(_url) or _mock_metadata()) - monkeypatch.setattr(mqtt_bambulab, "insert_print", lambda *args, **kwargs: 1) - monkeypatch.setattr(mqtt_bambulab, "insert_filament_usage", lambda *args, **kwargs: None) - monkeypatch.setitem(mqtt_bambulab.MODEL_FEATURES, mqtt_bambulab.PRINTER_MODEL_NAME, set()) - monkeypatch.setattr(mqtt_bambulab, "TRACK_LAYER_USAGE", False) +def test_skips_early_download_when_prepare_99_and_unsupported(print_run_env): mqtt_bambulab.processMessage( {"print": {"gcode_state": "PREPARE", "gcode_file_prepare_percent": "99", "url": "ftp://printer/Testprint.3mf"}} ) - assert calls == [] + assert print_run_env["metadata_calls"] == [] -def test_url_preferred_over_gcode_file_for_label(): +def test_prefers_url_when_present_in_payload(): payload = {"print": {"url": "ftp://printer/Testprint.3mf", "gcode_file": "Other.3mf"}} assert get_job_label(payload) == "ftp://printer/Testprint.3mf" @@ -92,31 +98,15 @@ def test_url_preferred_over_gcode_file_for_label(): assert get_job_label(payload) == "Only.3mf" -def test_no_double_start_while_active(monkeypatch): - _reset_state() - - calls = [] - monkeypatch.setattr(mqtt_bambulab, "insert_print", lambda *args, **kwargs: calls.append(args) or 1) - monkeypatch.setattr(mqtt_bambulab, "insert_filament_usage", lambda *args, **kwargs: None) - monkeypatch.setattr(mqtt_bambulab, "getMetaDataFrom3mf", lambda _url: _mock_metadata()) - monkeypatch.setattr(mqtt_bambulab, "TRACK_LAYER_USAGE", False) - +def test_does_not_start_new_run_when_active(print_run_env): payload = {"print": {"gcode_state": "RUNNING", "gcode_file": "Testprint.3mf", "print_type": "local"}} mqtt_bambulab.processMessage(payload) mqtt_bambulab.processMessage(payload) - assert len(calls) == 1 + assert len(print_run_env["insert_calls"]) == 1 -def test_same_label_can_run_twice_after_final(monkeypatch): - _reset_state() - - calls = [] - monkeypatch.setattr(mqtt_bambulab, "insert_print", lambda *args, **kwargs: calls.append(args) or 1) - monkeypatch.setattr(mqtt_bambulab, "insert_filament_usage", lambda *args, **kwargs: None) - monkeypatch.setattr(mqtt_bambulab, "getMetaDataFrom3mf", lambda _url: _mock_metadata()) - monkeypatch.setattr(mqtt_bambulab, "TRACK_LAYER_USAGE", False) - +def test_allows_same_label_after_finish(print_run_env): start_payload = {"print": {"gcode_state": "RUNNING", "gcode_file": "Testprint.3mf", "print_type": "local"}} finish_payload = {"print": {"gcode_state": "FINISH", "gcode_file": "Testprint.3mf", "print_type": "local"}} @@ -124,4 +114,4 @@ def test_same_label_can_run_twice_after_final(monkeypatch): mqtt_bambulab.processMessage(finish_payload) mqtt_bambulab.processMessage(start_payload) - assert len(calls) == 2 + assert len(print_run_env["insert_calls"]) == 2 diff --git a/tests/test_printhistory_accounting.py b/tests/test_printhistory_accounting.py index 2be49770..b0a7b15a 100644 --- a/tests/test_printhistory_accounting.py +++ b/tests/test_printhistory_accounting.py @@ -46,7 +46,7 @@ def _resolve_model_path(log_path: Path, expected: dict) -> Path: def _filament_info_by_slot(print_row: dict) -> dict[int, dict]: filament_info = json.loads(print_row["filament_info"]) - return {entry["ams_slot"]: entry for entry in filament_info} + return {entry["filament_id"]: entry for entry in filament_info} def _sum_consume_calls(calls: list[dict]) -> dict[int, dict[str, float]]: @@ -71,7 +71,7 @@ def _iter_cases(): @pytest.mark.parametrize("track_layer_usage", [False, True], ids=["no_layer_tracking", "layer_tracking"]) @pytest.mark.parametrize("log_path, expected", list(_iter_cases())) -def test_replay_accounts_filament_consumption_correctly( +def test_accounts_filament_consumption_when_replaying_log( mqtt_replay, fake_spoolman, log_path, diff --git a/tests/test_printhistory_replay_e2e.py b/tests/test_printhistory_replay_e2e.py index e66d8d14..65738df7 100644 --- a/tests/test_printhistory_replay_e2e.py +++ b/tests/test_printhistory_replay_e2e.py @@ -29,7 +29,7 @@ def _get_print_by_id(print_id: int): ("Testqube_local.log", "local"), ], ) -def test_replay_creates_printhistory_entry_and_detects_type( +def test_creates_printhistory_entry_when_replaying_log( mqtt_replay, log_name, expected_type ): log_path = LOG_ROOT / "A1" / "01.07.00.00" / log_name @@ -43,7 +43,7 @@ def test_replay_creates_printhistory_entry_and_detects_type( assert print_row["print_type"] == expected_type -def test_replay_detects_filaments_and_maps_to_spools(mqtt_replay, fake_spoolman): +def test_maps_filaments_to_spools_when_replaying_log(mqtt_replay, fake_spoolman): log_path = LOG_ROOT / "A1" / "01.07.00.00" / "Testqube_Multicolor_cloud.log" before_id = _max_print_id() mqtt_replay(log_path) @@ -68,16 +68,16 @@ def test_replay_detects_filaments_and_maps_to_spools(mqtt_replay, fake_spoolman) 4: "#0000FF", } - for entry in filament_info: - ams_slot = entry["ams_slot"] - assert entry["spool_id"] == expected_spool_ids[ams_slot] - assert entry["color"].upper() == expected_colors[ams_slot] + actual_spool_ids = {entry["filament_id"]: entry["spool_id"] for entry in filament_info} + actual_colors = {entry["filament_id"]: entry["color"].upper() for entry in filament_info} + assert actual_spool_ids == expected_spool_ids + assert actual_colors == expected_colors consumed_spools = {call["spool_id"] for call in fake_spoolman["consume_calls"]} assert consumed_spools == set(expected_spool_ids.values()) -def test_replay_appends_history_entry_on_repeat(mqtt_replay, fake_spoolman): +def test_appends_history_entry_when_replaying_log_twice(mqtt_replay, fake_spoolman): log_path = LOG_ROOT / "A1" / "01.07.00.00" / "Testqube_cloud.log" before_id = _max_print_id() diff --git a/tests/test_printhistory_spool_assignment_from_replay.py b/tests/test_printhistory_spool_assignment_from_replay.py new file mode 100644 index 00000000..2e131f4b --- /dev/null +++ b/tests/test_printhistory_spool_assignment_from_replay.py @@ -0,0 +1,97 @@ +import json +from pathlib import Path + +import pytest + +import conftest +import print_history + + +LOG_ROOT = Path(__file__).resolve().parent / "MQTT" + + +def _max_print_id() -> int: + prints, _ = print_history.get_prints_with_filament() + return max((print_row["id"] for print_row in prints), default=0) + + +def _get_print_by_id(print_id: int): + prints, _ = print_history.get_prints_with_filament() + for print_row in prints: + if print_row["id"] == print_id: + return print_row + return None + + +def _load_expected(log_path: Path) -> dict: + expected_path = log_path.with_suffix(".expected.json") + if not expected_path.exists(): + return {} + return json.loads(expected_path.read_text()) + + +def _resolve_model_path(log_path: Path, expected: dict) -> Path: + model_override = expected.get("model_path") + if model_override: + return Path(model_override) + + base_name = log_path.stem + for suffix in ("_local", "_cloud"): + idx = base_name.find(suffix) + if idx != -1: + base_name = base_name[:idx] + break + return LOG_ROOT / f"{base_name}.gcode.3mf" + + +def _expected_spool_ids(expected: dict) -> dict[int, int | None]: + mapping_root = expected.get("mapping") or {} + mapping_cfg = mapping_root.get("no_layer_tracking") or mapping_root.get("layer_tracking") + if not mapping_cfg: + return {} + expected_spools = mapping_cfg.get("expected_spool_ids") or {} + normalized: dict[int, int | None] = {} + for slot, spool_id in expected_spools.items(): + slot_id = int(slot) + if spool_id is None: + normalized[slot_id] = None + else: + normalized[slot_id] = int(spool_id) + return normalized + + +def _iter_cases(): + for log_path in conftest.iter_mqtt_logs(): + yield pytest.param(log_path, _load_expected(log_path), id=log_path.name) + + +@pytest.mark.parametrize("log_path, expected", list(_iter_cases())) +def test_assigns_spools_when_replaying_print_history( + mqtt_replay, + log_path, + expected, +): + expected_spools = _expected_spool_ids(expected) + if not expected_spools: + pytest.skip(f"No expected spool mapping for {log_path.name}") + + model_path = _resolve_model_path(log_path, expected) + if not model_path.exists(): + pytest.skip(f"Missing 3MF model for replay: {model_path}") + + before_id = _max_print_id() + mqtt_replay( + log_path, + model_path=model_path, + metadata_overrides=expected.get("metadata_overrides"), + ) + + after_id = _max_print_id() + assert after_id == before_id + 1 + print_row = _get_print_by_id(after_id) + assert print_row is not None + + filament_info = json.loads(print_row["filament_info"]) + actual_spools = {int(entry["filament_id"]): entry.get("spool_id") for entry in filament_info} + observed_spools = {filament_id: actual_spools.get(filament_id) for filament_id in expected_spools} + assert observed_spools == expected_spools diff --git a/tests/test_printhistory_versioning.py b/tests/test_printhistory_versioning.py new file mode 100644 index 00000000..26ecbdb0 --- /dev/null +++ b/tests/test_printhistory_versioning.py @@ -0,0 +1,22 @@ +import os +import time +from pathlib import Path + +import print_history + + +def test_versioned_target_path_uses_v2_suffix(tmp_path: Path) -> None: + base_path = tmp_path / "3d_printer_logs.db" + target = print_history._versioned_target_path(base_path) + assert target.name == "3d_printer_logs.v2.db" + + +def test_find_latest_versioned_ignores_migrated(tmp_path: Path) -> None: + base_path = tmp_path / "3d_printer_logs.db" + legacy = tmp_path / "3d_printer_logs.migrated.db" + + legacy.write_text("legacy") + now = int(time.time()) + os.utime(legacy, (now, now)) + + assert print_history._find_latest_versioned(base_path) is None diff --git a/tests/test_tray_remaining.py b/tests/test_tray_remaining.py index 610a4318..de44979e 100644 --- a/tests/test_tray_remaining.py +++ b/tests/test_tray_remaining.py @@ -1,3 +1,4 @@ +import pytest from jinja2 import Environment, FileSystemLoader @@ -28,12 +29,20 @@ def _unmapped_tray_data(): } -def test_unmapped_remaining_hidden_for_ams_lite(): - html = _render_tray(tray_data=_unmapped_tray_data(), ams_models_by_id={0: "AMS Lite"}) - assert "Remaining" not in html - - -def test_unmapped_remaining_shown_for_other_models(): - html = _render_tray(tray_data=_unmapped_tray_data(), ams_models_by_id={0: "AMS"}) - assert "Remaining" in html - assert "42%" in html +@pytest.mark.parametrize( + "ams_models_by_id, expect_visible", + [ + ({0: "AMS Lite"}, False), + ({0: "AMS"}, True), + ], + ids=["ams_lite", "ams"], +) +def test_unmapped_remaining_visibility_when_ams_model_changes( + ams_models_by_id, expect_visible +): + html = _render_tray(tray_data=_unmapped_tray_data(), ams_models_by_id=ams_models_by_id) + if expect_visible: + assert "Remaining" in html + assert "42%" in html + else: + assert "Remaining" not in html diff --git a/tools_3mf.py b/tools_3mf.py index 02e931be..33cc9a57 100644 --- a/tools_3mf.py +++ b/tools_3mf.py @@ -76,9 +76,9 @@ def parse_date(item): except ValueError: return None -def get_filament_order(file): - filament_order = {} - switch_count = 0 +def get_filament_order(file, fallback_ids=None): + filament_order = {} + switch_count = 0 for line in file: match_filament = re.match(r"^M620 S(\d+)[^;\r\n]*", line.decode("utf-8").strip()) @@ -89,7 +89,10 @@ def get_filament_order(file): switch_count += 1 if len(filament_order) == 0: - filament_order = {1:0} + if fallback_ids: + filament_order = {int(filament_id): idx for idx, filament_id in enumerate(fallback_ids)} + else: + filament_order = {1:0} return filament_order @@ -424,23 +427,34 @@ def getMetaDataFrom3mf(url): usage = {} filaments= {} - filamentId = 1 + filament_id_order = [] for plate in root.findall(".//plate"): for filament in plate.findall(".//filament"): used_g = filament.attrib.get("used_g") - #filamentId = int(filament.attrib.get("id")) - - usage[filamentId] = used_g - filaments[filamentId] = {"id": filamentId, + filament_raw = filament.attrib.get("id") + try: + filament_id = int(filament_raw) + except (TypeError, ValueError): + filament_id = len(filament_id_order) + 1 + + if filament_id in filaments: + continue + + filament_id_order.append(filament_id) + usage[filament_id] = used_g + filaments[filament_id] = { + "id": filament_id, "tray_info_idx": filament.attrib.get("tray_info_idx"), "type":filament.attrib.get("type"), "color": filament.attrib.get("color"), "used_g": used_g, - "used_m":filament.attrib.get("used_m")} - filamentId += 1 + "used_m":filament.attrib.get("used_m"), + } metadata["filaments"] = filaments metadata["usage"] = usage + metadata["filament_id_order"] = filament_id_order + metadata["filament_id_to_index"] = {fid: idx for idx, fid in enumerate(filament_id_order)} else: log(f"File '{slice_info_path}' not found in the archive.") return {} @@ -456,7 +470,33 @@ def getMetaDataFrom3mf(url): metadata["gcode_path"] = gcode_path if gcode_path in z.namelist(): with z.open(gcode_path) as gcode_file: - metadata["filamentOrder"] = get_filament_order(gcode_file) + metadata["filamentOrder"] = get_filament_order(gcode_file, fallback_ids=filament_id_order) + tool_order = sorted(metadata["filamentOrder"].items(), key=lambda entry: entry[1]) + tool_indices_by_usage = [] + for tool_id, _order in tool_order: + try: + tool_indices_by_usage.append(int(tool_id)) + except (TypeError, ValueError): + continue + + tool_indices = sorted(set(tool_indices_by_usage)) + if filament_id_order: + tool_to_filament = {} + filament_to_tool = {} + expected_indices = list(range(len(filament_id_order))) + if tool_indices == expected_indices: + for idx, filament_id in enumerate(filament_id_order): + tool_to_filament[idx] = filament_id + filament_to_tool[filament_id] = idx + elif tool_indices_by_usage and len(tool_indices_by_usage) == len(filament_id_order): + for idx, tool_id in enumerate(tool_indices_by_usage): + filament_id = filament_id_order[idx] + tool_to_filament[tool_id] = filament_id + filament_to_tool[filament_id] = tool_id + + if tool_to_filament: + metadata["tool_index_to_filament_id"] = tool_to_filament + metadata["filament_id_to_tool_index"] = filament_to_tool log(metadata)