anycubic-cloud-api 0.4.28__tar.gz → 0.4.32__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. {anycubic_cloud_api-0.4.28/src/anycubic_cloud_api.egg-info → anycubic_cloud_api-0.4.32}/PKG-INFO +1 -1
  2. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/pyproject.toml +1 -1
  3. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api/api/functions.py +4 -0
  4. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api/data_models/printer.py +84 -11
  5. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api/data_models/project.py +5 -2
  6. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32/src/anycubic_cloud_api.egg-info}/PKG-INFO +1 -1
  7. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api.egg-info/SOURCES.txt +2 -1
  8. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/tests/test_axis_position.py +9 -0
  9. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/tests/test_lan_reports.py +79 -0
  10. anycubic_cloud_api-0.4.32/tests/test_secondary_ace.py +111 -0
  11. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/COPYRIGHT +0 -0
  12. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/LICENSE +0 -0
  13. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/MANIFEST.in +0 -0
  14. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/README.md +0 -0
  15. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/setup.cfg +0 -0
  16. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api/__init__.py +0 -0
  17. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api/anycubic_api.py +0 -0
  18. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api/api/base.py +0 -0
  19. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api/api/mqtt.py +0 -0
  20. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api/const/api_endpoints.py +0 -0
  21. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api/const/const.py +0 -0
  22. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api/const/enums.py +0 -0
  23. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api/const/error_codes.py +0 -0
  24. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api/const/lan.py +0 -0
  25. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api/const/mqtt.py +0 -0
  26. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api/const/regions.py +0 -0
  27. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api/data_models/consumable.py +0 -0
  28. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api/data_models/files.py +0 -0
  29. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api/data_models/gcode_file.py +0 -0
  30. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api/data_models/orders.py +0 -0
  31. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api/data_models/print_response.py +0 -0
  32. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api/data_models/print_speed_mode.py +0 -0
  33. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api/data_models/printer_properties.py +0 -0
  34. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api/data_models/printing_settings.py +0 -0
  35. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api/exceptions/error_strings.py +0 -0
  36. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api/exceptions/exceptions.py +0 -0
  37. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api/helpers/helpers.py +0 -0
  38. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api/lan/__init__.py +0 -0
  39. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api/lan/client.py +0 -0
  40. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api/lan/commands.py +0 -0
  41. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api/lan/handshake.py +0 -0
  42. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api/models/auth.py +0 -0
  43. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api/models/cloud_upload.py +0 -0
  44. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api/models/http.py +0 -0
  45. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api/resources/anycubic_mqqt_tls_ca.crt +0 -0
  46. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api/resources/anycubic_mqqt_tls_client.crt +0 -0
  47. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api/resources/anycubic_mqqt_tls_client.key +0 -0
  48. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api.egg-info/dependency_links.txt +0 -0
  49. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api.egg-info/requires.txt +0 -0
  50. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/src/anycubic_cloud_api.egg-info/top_level.txt +0 -0
  51. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/tests/test_auth_mode_fallback.py +0 -0
  52. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/tests/test_endpoint_urls.py +0 -0
  53. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/tests/test_lan_client.py +0 -0
  54. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/tests/test_lan_commands.py +0 -0
  55. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/tests/test_lan_handshake.py +0 -0
  56. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/tests/test_null_tolerant_parsing.py +0 -0
  57. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/tests/test_partial_reports.py +0 -0
  58. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/tests/test_printer_error_codes.py +0 -0
  59. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/tests/test_project_null_status.py +0 -0
  60. {anycubic_cloud_api-0.4.28 → anycubic_cloud_api-0.4.32}/tests/test_regions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: anycubic-cloud-api
3
- Version: 0.4.28
3
+ Version: 0.4.32
4
4
  Summary: Async client for the Anycubic Cloud API and its MQTT stream
5
5
  Author: WaresWichall, Nino Bondonno
6
6
  Maintainer: Nino Bondonno
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "anycubic-cloud-api"
7
- version = "0.4.28"
7
+ version = "0.4.32"
8
8
  description = "Async client for the Anycubic Cloud API and its MQTT stream"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -1807,8 +1807,12 @@ class AnycubicAPIFunctions(AnycubicAPIBase):
1807
1807
  return None
1808
1808
 
1809
1809
  if box_id >= 0:
1810
+ # The box has to be named: the sender numbers unnamed boxes by
1811
+ # position, so a lone {'status': 0} always stopped box 0 and a
1812
+ # second ACE could never be stopped from here (#39).
1810
1813
  order_params: list[dict[str, Any]] | dict[str, Any] = {
1811
1814
  'status': 0,
1815
+ 'box_id': box_id,
1812
1816
  }
1813
1817
  else:
1814
1818
  order_params = [
@@ -150,6 +150,7 @@ class AnycubicPrinter:
150
150
  "_axis_position",
151
151
  "_multi_color_box",
152
152
  "_latest_project",
153
+ "_lan_last_job",
153
154
  "_fan_speed",
154
155
  "_aux_fan_speed",
155
156
  "_box_fan_level",
@@ -260,6 +261,11 @@ class AnycubicPrinter:
260
261
  self._set_multi_color_box(multi_color_box)
261
262
 
262
263
  self._latest_project: AnycubicProject | None = None
264
+ # The printer's own record of the job that finished last, from the
265
+ # local info report. Kept because the running job is cleared the moment
266
+ # the printer goes idle, and without this a finished LAN print could
267
+ # never be charged to its spool (hass-anycubic G1).
268
+ self._lan_last_job: dict[str, Any] | None = None
263
269
  # None until the printer says so. It used to default to 0, which
264
270
  # is indistinguishable from a fan that is genuinely stopped.
265
271
  self._fan_speed: int | None = None
@@ -445,6 +451,36 @@ class AnycubicPrinter:
445
451
 
446
452
  self._parameter.update_current_temps(hotbed, nozzle)
447
453
 
454
+ def _set_lan_last_job(self, data: dict[str, Any]) -> None:
455
+ if not data:
456
+ return
457
+
458
+ def as_int(value: Any) -> int | None:
459
+ try:
460
+ return int(value)
461
+ except (TypeError, ValueError):
462
+ return None
463
+
464
+ def as_float(value: Any) -> float | None:
465
+ try:
466
+ return float(value)
467
+ except (TypeError, ValueError):
468
+ return None
469
+
470
+ self._lan_last_job = {
471
+ 'task_id': as_int(data.get('task_id')),
472
+ # Millimetres of filament the printer says it extruded.
473
+ 'supplies_usage_mm': as_float(data.get('supplies_usage')),
474
+ 'print_status': as_int(data.get('print_status')),
475
+ 'state': data.get('state') if isinstance(data.get('state'), str) else None,
476
+ 'filename': data.get('filename') if isinstance(data.get('filename'), str) else None,
477
+ }
478
+
479
+ @property
480
+ def lan_last_job(self) -> dict[str, Any] | None:
481
+ """The last finished job as the printer reported it locally, if any."""
482
+ return self._lan_last_job
483
+
448
484
  def _apply_lan_project(self, project: Any) -> None:
449
485
  """Track the running job from the printer's own report of it.
450
486
 
@@ -480,8 +516,19 @@ class AnycubicPrinter:
480
516
  if self._latest_project is None:
481
517
  return
482
518
 
519
+ # A Kobra X sends print_status 0 mid-print, and 0 is not a status.
520
+ # Raising on it threw away the whole info report -- temperatures,
521
+ # progress, layers -- 20 times in two minutes (#38). An unrecognised
522
+ # status now leaves the job's status as it was and applies the rest.
523
+ try:
524
+ print_status: AnycubicPrintStatus | None = AnycubicPrintStatus(
525
+ int(data.get('print_status')) # type: ignore[arg-type]
526
+ )
527
+ except (TypeError, ValueError):
528
+ print_status = None
529
+
483
530
  self._latest_project.update_with_mqtt_print_status_data(
484
- AnycubicPrintStatus(int(data.get('print_status') or 0)),
531
+ print_status,
485
532
  data,
486
533
  paused=data.get('pause'),
487
534
  )
@@ -1027,13 +1074,16 @@ class AnycubicPrinter:
1027
1074
  ) -> None:
1028
1075
  if action == 'query' and state == 'done':
1029
1076
  data = payload['data']
1030
- coords = data['coordinates']
1031
-
1032
- self._axis_position = AnycubicAxisPosition(
1033
- x=coords['x'],
1034
- y=coords['y'],
1035
- z=coords['z'],
1036
- )
1077
+ # A Kobra X answers the query without coordinates at all during a
1078
+ # print. No reading is better than a raise that loses the report.
1079
+ coords = data.get('coordinates')
1080
+
1081
+ if coords:
1082
+ self._axis_position = AnycubicAxisPosition(
1083
+ x=coords['x'],
1084
+ y=coords['y'],
1085
+ z=coords['z'],
1086
+ )
1037
1087
 
1038
1088
  # Nested payloads are only released once emptied, and anything left
1039
1089
  # behind raises AnycubicMQTTUnhandledData at the end of dispatch.
@@ -1499,9 +1549,9 @@ class AnycubicPrinter:
1499
1549
  data.get('model')
1500
1550
  self._apply_lan_project(data.get('project'))
1501
1551
 
1502
- # Whatever ran before this one. Nothing reads it yet, and the printer
1503
- # sends null for it far more often than not.
1504
- _drain(data.get('last_project'))
1552
+ # The job that finished last. Null until one has, and then it stays --
1553
+ # so a null here never clears what is already known.
1554
+ self._set_lan_last_job(_drain(data.get('last_project')))
1505
1555
  _drain(data)
1506
1556
 
1507
1557
  def _process_mqtt_update_ai_settings(
@@ -2365,6 +2415,29 @@ class AnycubicPrinter:
2365
2415
 
2366
2416
  return None
2367
2417
 
2418
+ @property
2419
+ def secondary_multi_color_box_loaded_slot(self) -> int | None:
2420
+ """Which slot is feeding on the second ACE, or None.
2421
+
2422
+ Mirrors primary_multi_color_box_loaded_slot. A printer with two boxes
2423
+ reported this all along -- it was parsed and then had no accessor, so
2424
+ the only way to know which slot the second unit was feeding from was
2425
+ to not know (hass-anycubic #33).
2426
+ """
2427
+ box = self.secondary_multi_color_box
2428
+
2429
+ if not box:
2430
+ return None
2431
+
2432
+ slot = box.loaded_slot
2433
+
2434
+ # -1 means nothing is loaded; surface that as unknown rather than a
2435
+ # slot number that doesn't exist.
2436
+ if slot is not None and slot >= 0:
2437
+ return slot
2438
+
2439
+ return None
2440
+
2368
2441
  @property
2369
2442
  def secondary_multi_color_box_current_temperature(self) -> int:
2370
2443
  if self.secondary_multi_color_box:
@@ -683,14 +683,17 @@ class AnycubicProject:
683
683
 
684
684
  def update_with_mqtt_print_status_data(
685
685
  self,
686
- print_status: AnycubicPrintStatus,
686
+ # None when the printer sent a status that is not one: the job keeps
687
+ # the status it had, and everything else in the report still applies.
688
+ print_status: AnycubicPrintStatus | None,
687
689
  # Only read from, by key -- so a plain report dict off the wire is as
688
690
  # good as the wrapped form, and both reach here.
689
691
  mqtt_data: Mapping[str, Any] | None = None,
690
692
  paused: int | None = None,
691
693
  reason: str | None = None,
692
694
  ) -> None:
693
- self._print_status = int(print_status)
695
+ if print_status is not None:
696
+ self._print_status = int(print_status)
694
697
 
695
698
  if paused is not None:
696
699
  self._set_pause(paused)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: anycubic-cloud-api
3
- Version: 0.4.28
3
+ Version: 0.4.32
4
4
  Summary: Async client for the Anycubic Cloud API and its MQTT stream
5
5
  Author: WaresWichall, Nino Bondonno
6
6
  Maintainer: Nino Bondonno
@@ -54,4 +54,5 @@ tests/test_null_tolerant_parsing.py
54
54
  tests/test_partial_reports.py
55
55
  tests/test_printer_error_codes.py
56
56
  tests/test_project_null_status.py
57
- tests/test_regions.py
57
+ tests/test_regions.py
58
+ tests/test_secondary_ace.py
@@ -68,6 +68,15 @@ class TestMqttDispatch:
68
68
 
69
69
  assert payload.is_empty, f"left behind: {payload.remaining_data}"
70
70
 
71
+ def test_a_reply_without_coordinates_is_harmless(self):
72
+ """A Kobra X answers the query with no coordinates mid-print (#38)."""
73
+ printer = self._printer()
74
+ payload = self._msg(data={})
75
+
76
+ printer.process_mqtt_update("a/b/c/d/e/f/g/axis/report", payload)
77
+
78
+ assert printer.axis_position is None
79
+
71
80
  def test_an_unexpected_action_is_reported(self):
72
81
  from anycubic_cloud_api import AnycubicMQTTUnknownUpdate
73
82
 
@@ -678,6 +678,7 @@ class TestTheRunningJob:
678
678
  assert printer.latest_project_print_in_progress is True
679
679
  assert printer.latest_project_print_is_paused is False
680
680
 
681
+
681
682
  def test_a_paused_job_reads_as_paused(self):
682
683
  printer = make_printer()
683
684
 
@@ -775,3 +776,81 @@ class TestTheFinishedJobBlock:
775
776
  apply(printer, self._report())
776
777
 
777
778
  assert printer.latest_project is None
779
+
780
+
781
+ class TestAStatusThatIsNotOne:
782
+ """A Kobra X reports print_status 0 mid-print, which is not a status.
783
+
784
+ Raising on it discarded the whole info report -- temperatures, progress,
785
+ layers -- 20 times in two minutes (#38).
786
+ """
787
+
788
+ @pytest.mark.parametrize("status", [0, None, 8])
789
+ def test_the_rest_of_the_report_still_applies(self, status):
790
+ printer = make_printer()
791
+ job = deepcopy(LAN_JOB)
792
+ job["print_status"] = status
793
+ job["progress"] = 75
794
+
795
+ apply(printer, info_with_job(job))
796
+
797
+ assert printer.latest_project_progress_percentage == 75
798
+
799
+ def test_the_status_already_known_is_kept(self):
800
+ printer = make_printer()
801
+ apply(printer, info_with_job(LAN_JOB))
802
+ job = deepcopy(LAN_JOB)
803
+ job["print_status"] = 0
804
+
805
+ apply(printer, info_with_job(job))
806
+
807
+ assert printer.latest_project_print_in_progress is True
808
+
809
+
810
+ class TestTheLastFinishedJob:
811
+ """hass-anycubic G1: over LAN the running job is cleared as soon as the
812
+ printer goes idle, so the finished job is only ever in `last_project`."""
813
+
814
+ FINISHED = {
815
+ "task_id": 119231778,
816
+ "filename": ".3mf_temp/0622-1002-Spectacular Wolt (1)_plate(01)_PLA_0.2_45s.gcode",
817
+ "progress": 100, "curr_layer": 5, "total_layers": 5, "print_time": 2,
818
+ "remain_time": 0, "supplies_usage": 65, "pause": 0, "state": "finished",
819
+ "print_status": 2, "print_speed_mode": 2, "project_type": 2,
820
+ "localtask": "aff63521-084c-4dfc-9cf7-d112c7159551",
821
+ }
822
+
823
+ def _idle_with_last(self, last):
824
+ report = info_with_job(None)
825
+ report["data"]["last_project"] = deepcopy(last)
826
+ return report
827
+
828
+ def test_it_is_kept(self):
829
+ printer = make_printer()
830
+ apply(printer, self._idle_with_last(self.FINISHED))
831
+
832
+ job = printer.lan_last_job
833
+ assert job["task_id"] == 119231778
834
+ assert job["supplies_usage_mm"] == 65.0
835
+ assert job["print_status"] == 2
836
+ assert job["state"] == "finished"
837
+
838
+ def test_a_later_null_does_not_forget_it(self):
839
+ printer = make_printer()
840
+ apply(printer, self._idle_with_last(self.FINISHED))
841
+ apply(printer, self._idle_with_last(None))
842
+
843
+ assert printer.lan_last_job["task_id"] == 119231778
844
+
845
+ def test_nothing_before_the_first_finish(self):
846
+ printer = make_printer()
847
+ apply(printer, info_with_job(None))
848
+
849
+ assert printer.lan_last_job is None
850
+
851
+ def test_the_report_is_still_fully_consumed(self):
852
+ printer = make_printer()
853
+ report = AnycubicConsumableData(self._idle_with_last(self.FINISHED))
854
+ printer.process_mqtt_update("a/b/c/d/e/f/g/info/report", report)
855
+
856
+ assert report.is_empty, report.remaining_data
@@ -0,0 +1,111 @@
1
+ """The second ACE unit, which the library parsed and then hid.
2
+
3
+ hass-anycubic #33: a Kobra S1 can take two ACE Pro units and a Kobra X up to
4
+ four. The boxes are parsed into a list of whatever the printer reports, and
5
+ connected_ace_units counts them correctly -- but only the first two have
6
+ accessors, and until now the second had no way to report which slot was
7
+ feeding.
8
+ """
9
+
10
+ from unittest.mock import MagicMock
11
+
12
+ import pytest
13
+
14
+ from anycubic_cloud_api.data_models.printer import AnycubicPrinter
15
+
16
+
17
+ def _box(box_id: int, loaded_slot: int = -1) -> dict:
18
+ return {
19
+ "id": box_id, "status": 1, "model_id": 40002, "auto_feed": 0,
20
+ "loaded_slot": loaded_slot, "temp": 25, "slots": [],
21
+ }
22
+
23
+
24
+ def _printer(*boxes: dict) -> AnycubicPrinter:
25
+ return AnycubicPrinter(
26
+ api_parent=MagicMock(), machine_type=20025, machine_name="x", id=1,
27
+ ignore_init_errors=True, multi_color_box=list(boxes) or None,
28
+ )
29
+
30
+
31
+ class TestHowManyBoxesAreSeen:
32
+ @pytest.mark.parametrize("count", [1, 2, 3, 4])
33
+ def test_every_reported_box_is_counted(self, count):
34
+ """Three and four are parsed even though only two are reachable --
35
+ so the count is honest about hardware the accessors cannot reach."""
36
+ printer = _printer(*[_box(i) for i in range(count)])
37
+
38
+ assert printer.connected_ace_units == count
39
+
40
+ def test_no_boxes_is_zero_not_an_error(self):
41
+ assert _printer().connected_ace_units == 0
42
+
43
+ @pytest.mark.parametrize("count", [3, 4])
44
+ def test_more_than_two_boxes_does_not_break_the_accessors(self, count):
45
+ """A four-unit printer must not raise; the extra boxes are merely
46
+ invisible until the entity scheme is indexed rather than named."""
47
+ printer = _printer(*[_box(i) for i in range(count)])
48
+
49
+ assert printer.primary_multi_color_box is not None
50
+ assert printer.secondary_multi_color_box is not None
51
+
52
+
53
+ class TestTheSecondBoxCanNameItsLoadedSlot:
54
+ def test_it_reports_the_feeding_slot(self):
55
+ printer = _printer(_box(0, loaded_slot=0), _box(1, loaded_slot=2))
56
+
57
+ assert printer.primary_multi_color_box_loaded_slot == 0
58
+ assert printer.secondary_multi_color_box_loaded_slot == 2
59
+
60
+ def test_nothing_feeding_is_unknown_not_minus_one(self):
61
+ """-1 is the printer's way of saying nothing is loaded. Reporting it
62
+ as a slot number would put slot -1 on someone's dashboard."""
63
+ printer = _printer(_box(0), _box(1, loaded_slot=-1))
64
+
65
+ assert printer.secondary_multi_color_box_loaded_slot is None
66
+
67
+ def test_a_single_box_printer_has_no_second_slot(self):
68
+ printer = _printer(_box(0, loaded_slot=1))
69
+
70
+ assert printer.secondary_multi_color_box_loaded_slot is None
71
+
72
+ def test_no_ace_at_all_is_not_an_error(self):
73
+ assert _printer().secondary_multi_color_box_loaded_slot is None
74
+
75
+
76
+ class TestStoppingTheDryer:
77
+ """hass-anycubic #39: stopping the second ACE's dryer did nothing.
78
+
79
+ The stop order named no box, and the sender numbers unnamed boxes by
80
+ position, so every single-box stop went to box 0.
81
+ """
82
+
83
+ async def _sent_boxes(self, **kwargs):
84
+ from unittest.mock import AsyncMock
85
+
86
+ from anycubic_cloud_api.anycubic_api import AnycubicAPI
87
+
88
+ api = AnycubicAPI(session=MagicMock(), cookie_jar=MagicMock())
89
+ api._send_anycubic_order = AsyncMock(return_value="msgid")
90
+ printer = _printer(_box(0), _box(1))
91
+
92
+ await api.multi_color_box_drying_stop(printer, **kwargs)
93
+
94
+ order = api._send_anycubic_order.await_args.kwargs["order_request"]
95
+ return order.order_data["multi_color_box"]
96
+
97
+ async def test_the_second_box_is_the_one_stopped(self):
98
+ boxes = await self._sent_boxes(box_id=1)
99
+
100
+ assert [box["id"] for box in boxes] == [1]
101
+ assert boxes[0]["drying_status"]["status"] == 0
102
+
103
+ async def test_the_first_box_is_still_stopped_by_name(self):
104
+ boxes = await self._sent_boxes(box_id=0)
105
+
106
+ assert [box["id"] for box in boxes] == [0]
107
+
108
+ async def test_no_box_named_stops_every_box(self):
109
+ boxes = await self._sent_boxes()
110
+
111
+ assert [box["id"] for box in boxes] == [0, 1]