"""H3C: firmware version source, stopped-print usage, and the maintenance schedule. - The H3C wiki page is /en/h3c/manual/firmware-release-history and marks versions as id="ota-01010300" with no date. - The H3C's mc_percent counts its ~8 min start sequence (22% at layer 0 on a 31 min print), so a stopped print is charged by layers printed, not percent. - The H3C gets its own maintenance tasks from Bambu's H3C guides, in place of the generic rail, hotend and PTFE ones (and never "Clean Carbon Rods"). """ from unittest.mock import AsyncMock, patch import pytest from backend.app.api.routes.maintenance import ( DEFAULT_MAINTENANCE_TYPES, H3C_MAINTENANCE_TYPES, _should_apply_to_printer, ) from backend.app.services.firmware_check import API_KEY_TO_WIKI_PATH, FirmwareCheckService from backend.app.utils.printer_models import get_rod_type, usage_progress # Trimmed from wiki.bambulab.com/en/h3c/manual/firmware-release-history (2026-11-07), # with a second, older heading added and an AMS-firmware-style number in prose. H3C_WIKI_SAMPLE = """

OTA 01.01.03.00

New Features

  1. Added support for connecting up to 4 auxiliary AMS / AMS 2 Pro / AMS HT units (requires AMS firmware 02.00.19.47).

OTA 01.01.02.00

Bug fixes.

""" class TestH3CFirmwareSource: def test_wiki_path_is_the_one_bambu_publishes(self): assert API_KEY_TO_WIKI_PATH["h3c"] == "/en/h3c/manual/firmware-release-history" @pytest.mark.asyncio async def test_ota_anchors_parse_without_dates(self): svc = FirmwareCheckService() resp = AsyncMock() resp.status_code = 200 resp.text = H3C_WIKI_SAMPLE with patch.object(svc._client, "get", AsyncMock(return_value=resp)) as get: versions = await svc._fetch_all_versions_from_wiki("h3c") assert get.call_args.args[0] == "https://wiki.bambulab.com/en/h3c/manual/firmware-release-history" assert versions == [("01.01.03.00", None), ("01.01.02.00", None)] @pytest.mark.asyncio async def test_dated_anchors_still_win_over_ota_ones(self): svc = FirmwareCheckService() resp = AsyncMock() resp.status_code = 200 resp.text = '

01.03.00.00

' + H3C_WIKI_SAMPLE with patch.object(svc._client, "get", AsyncMock(return_value=resp)): versions = await svc._fetch_all_versions_from_wiki("h2d") assert versions == [("01.03.00.00", "20260303")] class TestStoppedPrintProgress: def test_h3c_warm_up_is_charged_nothing(self): # Captured: 22% with layer_num 0 during the start sequence. assert usage_progress("H3C", 22.0, 0, 180) == 0.0 assert usage_progress("N10", 22.0, None, 180) == 0.0 def test_h3c_is_charged_by_layers_printed(self): assert usage_progress("H3C", 61.0, 45, 180) == 25.0 assert usage_progress("H3C", 99.0, 200, 180) == 100.0 def test_h3c_without_layer_count_is_charged_nothing(self): assert usage_progress("H3C", 50.0, 90, 0) == 0.0 @pytest.mark.parametrize("model", ["X1C", "H2D", "P2S", "A1", None, ""]) def test_other_models_keep_the_printer_percent(self, model): assert usage_progress(model, 22.0, 0, 180) == 22.0 assert usage_progress(model, 0, 0, 0) == 0 def test_non_string_model_is_not_h3c(self): assert usage_progress(object(), 30, 0, 100) == 30 def test_completion_payload_reports_layer_progress_for_h3c(self): from backend.app.services.bambu_mqtt import BambuMQTTClient client = BambuMQTTClient(ip_address="192.168.1.100", serial_number="TEST", access_code="1", model="H3C") done = {} client.on_print_start = lambda data: None client.on_print_complete = lambda data: done.update(data) client._process_message( {"print": {"gcode_state": "PREPARE", "gcode_file": "/data/Metadata/plate_2.gcode", "subtask_name": "T"}} ) client._process_message({"print": {"gcode_state": "RUNNING", "mc_percent": 22, "layer_num": 0}}) client._process_message({"print": {"gcode_state": "FAILED", "mc_percent": 22, "layer_num": 0}}) assert done.get("status") in ("failed", "aborted", "cancelled") assert done["last_progress"] == 0.0 assert done["last_layer_num"] == 0 def test_completion_payload_unchanged_for_other_models(self): from backend.app.services.bambu_mqtt import BambuMQTTClient client = BambuMQTTClient(ip_address="192.168.1.100", serial_number="TEST", access_code="1", model="X1C") done = {} client.on_print_start = lambda data: None client.on_print_complete = lambda data: done.update(data) client._process_message( {"print": {"gcode_state": "PREPARE", "gcode_file": "/data/Metadata/plate_1.gcode", "subtask_name": "T"}} ) client._process_message({"print": {"gcode_state": "RUNNING", "mc_percent": 22, "layer_num": 0}}) client._process_message({"print": {"gcode_state": "FAILED", "mc_percent": 22, "layer_num": 0}}) assert done["last_progress"] == 22 class TestH3CMaintenance: def test_h3c_has_linear_rails_not_carbon_rods(self): assert get_rod_type("H3C") == "linear_rail" assert get_rod_type("N10") == "linear_rail" @pytest.mark.parametrize("model", ["H3C", "N10"]) def test_h3c_gets_its_own_tasks_and_the_shared_ones(self, model): every = DEFAULT_MAINTENANCE_TYPES + H3C_MAINTENANCE_TYPES shown = {t["name"] for t in every if _should_apply_to_printer(t["name"], model)} assert {t["name"] for t in H3C_MAINTENANCE_TYPES} <= shown assert {"Check Belt Tension", "Clean Build Plate"} <= shown for replaced in ( "Clean Carbon Rods", "Lubricate Linear Rails", "Clean Linear Rails", "Clean Nozzle/Hotend", "Check PTFE Tube", ): assert replaced not in shown @pytest.mark.parametrize("model", ["X1C", "P1S", "H2D", "H2C", "P2S", "A1", "UNKNOWN", None]) def test_other_models_never_get_h3c_tasks(self, model): for t in H3C_MAINTENANCE_TYPES: assert _should_apply_to_printer(t["name"], model) is False def test_other_models_keep_their_generic_tasks(self): assert _should_apply_to_printer("Lubricate Linear Rails", "H2D") is True assert _should_apply_to_printer("Clean Nozzle/Hotend", "H2D") is True assert _should_apply_to_printer("Check PTFE Tube", "X1C") is True assert _should_apply_to_printer("Clean Carbon Rods", "X1C") is True def test_task_definitions_are_complete(self): # The icon names the frontend's iconMap knows (MaintenancePage.tsx). icons = { "Droplet", "Flame", "Ruler", "Sparkles", "Square", "Cable", "Wrench", "Calendar", "Timer", "Cog", "Fan", "Zap", "Wind", "Thermometer", "Layers", "Box", "Target", "RefreshCw", "Settings", "Filter", "CircleDot", } # fmt: skip names = [t["name"] for t in DEFAULT_MAINTENANCE_TYPES + H3C_MAINTENANCE_TYPES] assert len(names) == len(set(names)) for t in H3C_MAINTENANCE_TYPES: assert len(t["name"]) <= 100 assert t["interval_type"] in ("hours", "days") assert t["default_interval_hours"] > 0 assert t["icon"] in icons assert t["wiki_url"].startswith("https://wiki.bambulab.com/en/h3c/") def test_air_filter_uses_bambus_rated_print_hours(self): f = next(t for t in H3C_MAINTENANCE_TYPES if t["name"] == "Replace Air Filter (H3C)") assert (f["default_interval_hours"], f["interval_type"]) == (1440.0, "hours") def _printer(model: str, serial: str): from backend.app.models.printer import Printer return Printer(name=model, serial_number=serial, ip_address="192.0.2.9", access_code="1", model=model) async def _type_names(db_session) -> tuple[set[str], set[str]]: """(names GET /maintenance/types lists, names stored) after it ran.""" from sqlalchemy import select from backend.app.api.routes.maintenance import get_maintenance_types from backend.app.models.maintenance import MaintenanceType listed = {t.name for t in await get_maintenance_types(db=db_session, _=None)} stored = {t.name for t in (await db_session.execute(select(MaintenanceType))).scalars()} return listed, stored @pytest.mark.asyncio async def test_without_an_h3c_the_types_are_exactly_the_defaults(db_session): db_session.add(_printer("X1C", "SN-X1C-9")) await db_session.commit() listed, stored = await _type_names(db_session) assert listed == stored == {t["name"] for t in DEFAULT_MAINTENANCE_TYPES} @pytest.mark.asyncio async def test_an_h3c_adds_its_own_types(db_session): h3c = _printer("N10", "SN-H3C-9") db_session.add(h3c) await db_session.commit() listed, stored = await _type_names(db_session) expected = {t["name"] for t in DEFAULT_MAINTENANCE_TYPES + H3C_MAINTENANCE_TYPES} assert listed == stored == expected # Removing the H3C hides them again; they are kept for a printer added later. await db_session.delete(h3c) await db_session.commit() listed, stored = await _type_names(db_session) assert listed == {t["name"] for t in DEFAULT_MAINTENANCE_TYPES} assert stored == expected @pytest.mark.asyncio async def test_ensure_default_types_stores_interval_type_and_wiki_url(db_session): from sqlalchemy import select from backend.app.api.routes.maintenance import ensure_default_types from backend.app.models.maintenance import MaintenanceType db_session.add(_printer("H3C", "SN-H3C-8")) await db_session.commit() await ensure_default_types(db_session) rows = {t.name: t for t in (await db_session.execute(select(MaintenanceType))).scalars()} desiccant = rows["AMS 3H: Check Desiccant"] assert (desiccant.interval_type, desiccant.default_interval_hours) == ("days", 30.0) assert desiccant.wiki_url == "https://wiki.bambulab.com/en/h3c/maintenance/ams_3h" assert desiccant.is_system is True # The generic ones are created as before. assert rows["Clean Build Plate"].interval_type == "hours" assert rows["Clean Build Plate"].wiki_url is None @pytest.mark.asyncio async def test_h3c_tasks_count_from_when_the_printer_was_added(db_session): from datetime import datetime, timedelta from sqlalchemy import select from backend.app.api.routes.maintenance import _get_printer_maintenance_internal from backend.app.models.maintenance import MaintenanceHistory from backend.app.models.printer import Printer added = datetime.utcnow() - timedelta(days=4) printer = Printer( name="H3C", serial_number="SN-H3C-1", ip_address="192.0.2.1", access_code="1", model="H3C", created_at=added ) db_session.add(printer) await db_session.commit() overview = await _get_printer_maintenance_internal(printer.id, db_session) items = {i.maintenance_type_name: i for i in overview.maintenance_items} rails = items["Lubricate X/Y Rails (H3C)"] assert rails.is_due is False assert 25.5 < rails.days_until_due < 26.5 # 30-day task, printer added 4 days ago assert items["Clean Extruder Wheel (H3C)"].is_due is False # 7 days, 4 gone assert overview.due_count == 0 assert (await db_session.execute(select(MaintenanceHistory))).scalars().first() is None @pytest.mark.asyncio async def test_generic_tasks_keep_never_done_semantics(db_session): from backend.app.api.routes.maintenance import _get_printer_maintenance_internal from backend.app.models.printer import Printer printer = Printer(name="X1C", serial_number="SN-X1C-1", ip_address="192.0.2.2", access_code="1", model="X1C") db_session.add(printer) await db_session.commit() overview = await _get_printer_maintenance_internal(printer.id, db_session) assert all(i.last_performed_at is None for i in overview.maintenance_items)