test_h3c_maintenance_firmware_usage.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. """H3C: firmware version source, stopped-print usage, and the maintenance schedule.
  2. - The H3C wiki page is /en/h3c/manual/firmware-release-history and marks
  3. versions as id="ota-01010300" with no date.
  4. - The H3C's mc_percent counts its ~8 min start sequence (22% at layer 0 on a
  5. 31 min print), so a stopped print is charged by layers printed, not percent.
  6. - The H3C gets its own maintenance tasks from Bambu's H3C guides, in place of
  7. the generic rail, hotend and PTFE ones (and never "Clean Carbon Rods").
  8. """
  9. from unittest.mock import AsyncMock, patch
  10. import pytest
  11. from backend.app.api.routes.maintenance import (
  12. DEFAULT_MAINTENANCE_TYPES,
  13. H3C_MAINTENANCE_TYPES,
  14. _should_apply_to_printer,
  15. )
  16. from backend.app.services.firmware_check import API_KEY_TO_WIKI_PATH, FirmwareCheckService
  17. from backend.app.utils.printer_models import get_rod_type, usage_progress
  18. # Trimmed from wiki.bambulab.com/en/h3c/manual/firmware-release-history (2026-11-07),
  19. # with a second, older heading added and an AMS-firmware-style number in prose.
  20. H3C_WIKI_SAMPLE = """
  21. <h2 id="ota-01010300" class="toc-header"><a class="toc-anchor" href="#ota-01010300">¶</a> OTA 01.01.03.00</h2>
  22. <p><strong>New Features</strong></p>
  23. <ol><li><p>Added support for connecting up to 4 auxiliary AMS / AMS 2 Pro / AMS HT units
  24. (requires AMS firmware 02.00.19.47).</p></li></ol>
  25. <h2 id="ota-01010200" class="toc-header"><a class="toc-anchor" href="#ota-01010200">¶</a> OTA 01.01.02.00</h2>
  26. <p>Bug fixes.</p>
  27. """
  28. class TestH3CFirmwareSource:
  29. def test_wiki_path_is_the_one_bambu_publishes(self):
  30. assert API_KEY_TO_WIKI_PATH["h3c"] == "/en/h3c/manual/firmware-release-history"
  31. @pytest.mark.asyncio
  32. async def test_ota_anchors_parse_without_dates(self):
  33. svc = FirmwareCheckService()
  34. resp = AsyncMock()
  35. resp.status_code = 200
  36. resp.text = H3C_WIKI_SAMPLE
  37. with patch.object(svc._client, "get", AsyncMock(return_value=resp)) as get:
  38. versions = await svc._fetch_all_versions_from_wiki("h3c")
  39. assert get.call_args.args[0] == "https://wiki.bambulab.com/en/h3c/manual/firmware-release-history"
  40. assert versions == [("01.01.03.00", None), ("01.01.02.00", None)]
  41. @pytest.mark.asyncio
  42. async def test_dated_anchors_still_win_over_ota_ones(self):
  43. svc = FirmwareCheckService()
  44. resp = AsyncMock()
  45. resp.status_code = 200
  46. resp.text = '<h2 id="h-01030000-20260303">01.03.00.00</h2>' + H3C_WIKI_SAMPLE
  47. with patch.object(svc._client, "get", AsyncMock(return_value=resp)):
  48. versions = await svc._fetch_all_versions_from_wiki("h2d")
  49. assert versions == [("01.03.00.00", "20260303")]
  50. class TestStoppedPrintProgress:
  51. def test_h3c_warm_up_is_charged_nothing(self):
  52. # Captured: 22% with layer_num 0 during the start sequence.
  53. assert usage_progress("H3C", 22.0, 0, 180) == 0.0
  54. assert usage_progress("N10", 22.0, None, 180) == 0.0
  55. def test_h3c_is_charged_by_layers_printed(self):
  56. assert usage_progress("H3C", 61.0, 45, 180) == 25.0
  57. assert usage_progress("H3C", 99.0, 200, 180) == 100.0
  58. def test_h3c_without_layer_count_is_charged_nothing(self):
  59. assert usage_progress("H3C", 50.0, 90, 0) == 0.0
  60. @pytest.mark.parametrize("model", ["X1C", "H2D", "P2S", "A1", None, ""])
  61. def test_other_models_keep_the_printer_percent(self, model):
  62. assert usage_progress(model, 22.0, 0, 180) == 22.0
  63. assert usage_progress(model, 0, 0, 0) == 0
  64. def test_non_string_model_is_not_h3c(self):
  65. assert usage_progress(object(), 30, 0, 100) == 30
  66. def test_completion_payload_reports_layer_progress_for_h3c(self):
  67. from backend.app.services.bambu_mqtt import BambuMQTTClient
  68. client = BambuMQTTClient(ip_address="192.168.1.100", serial_number="TEST", access_code="1", model="H3C")
  69. done = {}
  70. client.on_print_start = lambda data: None
  71. client.on_print_complete = lambda data: done.update(data)
  72. client._process_message(
  73. {"print": {"gcode_state": "PREPARE", "gcode_file": "/data/Metadata/plate_2.gcode", "subtask_name": "T"}}
  74. )
  75. client._process_message({"print": {"gcode_state": "RUNNING", "mc_percent": 22, "layer_num": 0}})
  76. client._process_message({"print": {"gcode_state": "FAILED", "mc_percent": 22, "layer_num": 0}})
  77. assert done.get("status") in ("failed", "aborted", "cancelled")
  78. assert done["last_progress"] == 0.0
  79. assert done["last_layer_num"] == 0
  80. def test_completion_payload_unchanged_for_other_models(self):
  81. from backend.app.services.bambu_mqtt import BambuMQTTClient
  82. client = BambuMQTTClient(ip_address="192.168.1.100", serial_number="TEST", access_code="1", model="X1C")
  83. done = {}
  84. client.on_print_start = lambda data: None
  85. client.on_print_complete = lambda data: done.update(data)
  86. client._process_message(
  87. {"print": {"gcode_state": "PREPARE", "gcode_file": "/data/Metadata/plate_1.gcode", "subtask_name": "T"}}
  88. )
  89. client._process_message({"print": {"gcode_state": "RUNNING", "mc_percent": 22, "layer_num": 0}})
  90. client._process_message({"print": {"gcode_state": "FAILED", "mc_percent": 22, "layer_num": 0}})
  91. assert done["last_progress"] == 22
  92. class TestH3CMaintenance:
  93. def test_h3c_has_linear_rails_not_carbon_rods(self):
  94. assert get_rod_type("H3C") == "linear_rail"
  95. assert get_rod_type("N10") == "linear_rail"
  96. @pytest.mark.parametrize("model", ["H3C", "N10"])
  97. def test_h3c_gets_its_own_tasks_and_the_shared_ones(self, model):
  98. every = DEFAULT_MAINTENANCE_TYPES + H3C_MAINTENANCE_TYPES
  99. shown = {t["name"] for t in every if _should_apply_to_printer(t["name"], model)}
  100. assert {t["name"] for t in H3C_MAINTENANCE_TYPES} <= shown
  101. assert {"Check Belt Tension", "Clean Build Plate"} <= shown
  102. for replaced in (
  103. "Clean Carbon Rods",
  104. "Lubricate Linear Rails",
  105. "Clean Linear Rails",
  106. "Clean Nozzle/Hotend",
  107. "Check PTFE Tube",
  108. ):
  109. assert replaced not in shown
  110. @pytest.mark.parametrize("model", ["X1C", "P1S", "H2D", "H2C", "P2S", "A1", "UNKNOWN", None])
  111. def test_other_models_never_get_h3c_tasks(self, model):
  112. for t in H3C_MAINTENANCE_TYPES:
  113. assert _should_apply_to_printer(t["name"], model) is False
  114. def test_other_models_keep_their_generic_tasks(self):
  115. assert _should_apply_to_printer("Lubricate Linear Rails", "H2D") is True
  116. assert _should_apply_to_printer("Clean Nozzle/Hotend", "H2D") is True
  117. assert _should_apply_to_printer("Check PTFE Tube", "X1C") is True
  118. assert _should_apply_to_printer("Clean Carbon Rods", "X1C") is True
  119. def test_task_definitions_are_complete(self):
  120. # The icon names the frontend's iconMap knows (MaintenancePage.tsx).
  121. icons = {
  122. "Droplet", "Flame", "Ruler", "Sparkles", "Square", "Cable", "Wrench", "Calendar", "Timer", "Cog",
  123. "Fan", "Zap", "Wind", "Thermometer", "Layers", "Box", "Target", "RefreshCw", "Settings", "Filter",
  124. "CircleDot",
  125. } # fmt: skip
  126. names = [t["name"] for t in DEFAULT_MAINTENANCE_TYPES + H3C_MAINTENANCE_TYPES]
  127. assert len(names) == len(set(names))
  128. for t in H3C_MAINTENANCE_TYPES:
  129. assert len(t["name"]) <= 100
  130. assert t["interval_type"] in ("hours", "days")
  131. assert t["default_interval_hours"] > 0
  132. assert t["icon"] in icons
  133. assert t["wiki_url"].startswith("https://wiki.bambulab.com/en/h3c/")
  134. def test_air_filter_uses_bambus_rated_print_hours(self):
  135. f = next(t for t in H3C_MAINTENANCE_TYPES if t["name"] == "Replace Air Filter (H3C)")
  136. assert (f["default_interval_hours"], f["interval_type"]) == (1440.0, "hours")
  137. def _printer(model: str, serial: str):
  138. from backend.app.models.printer import Printer
  139. return Printer(name=model, serial_number=serial, ip_address="192.0.2.9", access_code="1", model=model)
  140. async def _type_names(db_session) -> tuple[set[str], set[str]]:
  141. """(names GET /maintenance/types lists, names stored) after it ran."""
  142. from sqlalchemy import select
  143. from backend.app.api.routes.maintenance import get_maintenance_types
  144. from backend.app.models.maintenance import MaintenanceType
  145. listed = {t.name for t in await get_maintenance_types(db=db_session, _=None)}
  146. stored = {t.name for t in (await db_session.execute(select(MaintenanceType))).scalars()}
  147. return listed, stored
  148. @pytest.mark.asyncio
  149. async def test_without_an_h3c_the_types_are_exactly_the_defaults(db_session):
  150. db_session.add(_printer("X1C", "SN-X1C-9"))
  151. await db_session.commit()
  152. listed, stored = await _type_names(db_session)
  153. assert listed == stored == {t["name"] for t in DEFAULT_MAINTENANCE_TYPES}
  154. @pytest.mark.asyncio
  155. async def test_an_h3c_adds_its_own_types(db_session):
  156. h3c = _printer("N10", "SN-H3C-9")
  157. db_session.add(h3c)
  158. await db_session.commit()
  159. listed, stored = await _type_names(db_session)
  160. expected = {t["name"] for t in DEFAULT_MAINTENANCE_TYPES + H3C_MAINTENANCE_TYPES}
  161. assert listed == stored == expected
  162. # Removing the H3C hides them again; they are kept for a printer added later.
  163. await db_session.delete(h3c)
  164. await db_session.commit()
  165. listed, stored = await _type_names(db_session)
  166. assert listed == {t["name"] for t in DEFAULT_MAINTENANCE_TYPES}
  167. assert stored == expected
  168. @pytest.mark.asyncio
  169. async def test_ensure_default_types_stores_interval_type_and_wiki_url(db_session):
  170. from sqlalchemy import select
  171. from backend.app.api.routes.maintenance import ensure_default_types
  172. from backend.app.models.maintenance import MaintenanceType
  173. db_session.add(_printer("H3C", "SN-H3C-8"))
  174. await db_session.commit()
  175. await ensure_default_types(db_session)
  176. rows = {t.name: t for t in (await db_session.execute(select(MaintenanceType))).scalars()}
  177. desiccant = rows["AMS 3H: Check Desiccant"]
  178. assert (desiccant.interval_type, desiccant.default_interval_hours) == ("days", 30.0)
  179. assert desiccant.wiki_url == "https://wiki.bambulab.com/en/h3c/maintenance/ams_3h"
  180. assert desiccant.is_system is True
  181. # The generic ones are created as before.
  182. assert rows["Clean Build Plate"].interval_type == "hours"
  183. assert rows["Clean Build Plate"].wiki_url is None
  184. @pytest.mark.asyncio
  185. async def test_h3c_tasks_count_from_when_the_printer_was_added(db_session):
  186. from datetime import datetime, timedelta
  187. from sqlalchemy import select
  188. from backend.app.api.routes.maintenance import _get_printer_maintenance_internal
  189. from backend.app.models.maintenance import MaintenanceHistory
  190. from backend.app.models.printer import Printer
  191. added = datetime.utcnow() - timedelta(days=4)
  192. printer = Printer(
  193. name="H3C", serial_number="SN-H3C-1", ip_address="192.0.2.1", access_code="1", model="H3C", created_at=added
  194. )
  195. db_session.add(printer)
  196. await db_session.commit()
  197. overview = await _get_printer_maintenance_internal(printer.id, db_session)
  198. items = {i.maintenance_type_name: i for i in overview.maintenance_items}
  199. rails = items["Lubricate X/Y Rails (H3C)"]
  200. assert rails.is_due is False
  201. assert 25.5 < rails.days_until_due < 26.5 # 30-day task, printer added 4 days ago
  202. assert items["Clean Extruder Wheel (H3C)"].is_due is False # 7 days, 4 gone
  203. assert overview.due_count == 0
  204. assert (await db_session.execute(select(MaintenanceHistory))).scalars().first() is None
  205. @pytest.mark.asyncio
  206. async def test_generic_tasks_keep_never_done_semantics(db_session):
  207. from backend.app.api.routes.maintenance import _get_printer_maintenance_internal
  208. from backend.app.models.printer import Printer
  209. printer = Printer(name="X1C", serial_number="SN-X1C-1", ip_address="192.0.2.2", access_code="1", model="X1C")
  210. db_session.add(printer)
  211. await db_session.commit()
  212. overview = await _get_printer_maintenance_internal(printer.id, db_session)
  213. assert all(i.last_performed_at is None for i in overview.maintenance_items)