anycubic-cloud-api 0.4.26__tar.gz → 0.4.28__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 (59) hide show
  1. {anycubic_cloud_api-0.4.26/src/anycubic_cloud_api.egg-info → anycubic_cloud_api-0.4.28}/PKG-INFO +1 -1
  2. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/pyproject.toml +1 -1
  3. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/data_models/printer.py +4 -1
  4. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/data_models/printer_properties.py +86 -46
  5. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/helpers/helpers.py +53 -0
  6. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28/src/anycubic_cloud_api.egg-info}/PKG-INFO +1 -1
  7. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api.egg-info/SOURCES.txt +1 -0
  8. anycubic_cloud_api-0.4.28/tests/test_null_tolerant_parsing.py +180 -0
  9. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/COPYRIGHT +0 -0
  10. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/LICENSE +0 -0
  11. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/MANIFEST.in +0 -0
  12. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/README.md +0 -0
  13. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/setup.cfg +0 -0
  14. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/__init__.py +0 -0
  15. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/anycubic_api.py +0 -0
  16. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/api/base.py +0 -0
  17. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/api/functions.py +0 -0
  18. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/api/mqtt.py +0 -0
  19. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/const/api_endpoints.py +0 -0
  20. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/const/const.py +0 -0
  21. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/const/enums.py +0 -0
  22. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/const/error_codes.py +0 -0
  23. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/const/lan.py +0 -0
  24. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/const/mqtt.py +0 -0
  25. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/const/regions.py +0 -0
  26. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/data_models/consumable.py +0 -0
  27. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/data_models/files.py +0 -0
  28. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/data_models/gcode_file.py +0 -0
  29. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/data_models/orders.py +0 -0
  30. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/data_models/print_response.py +0 -0
  31. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/data_models/print_speed_mode.py +0 -0
  32. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/data_models/printing_settings.py +0 -0
  33. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/data_models/project.py +0 -0
  34. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/exceptions/error_strings.py +0 -0
  35. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/exceptions/exceptions.py +0 -0
  36. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/lan/__init__.py +0 -0
  37. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/lan/client.py +0 -0
  38. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/lan/commands.py +0 -0
  39. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/lan/handshake.py +0 -0
  40. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/models/auth.py +0 -0
  41. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/models/cloud_upload.py +0 -0
  42. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/models/http.py +0 -0
  43. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/resources/anycubic_mqqt_tls_ca.crt +0 -0
  44. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/resources/anycubic_mqqt_tls_client.crt +0 -0
  45. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/resources/anycubic_mqqt_tls_client.key +0 -0
  46. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api.egg-info/dependency_links.txt +0 -0
  47. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api.egg-info/requires.txt +0 -0
  48. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api.egg-info/top_level.txt +0 -0
  49. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/tests/test_auth_mode_fallback.py +0 -0
  50. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/tests/test_axis_position.py +0 -0
  51. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/tests/test_endpoint_urls.py +0 -0
  52. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/tests/test_lan_client.py +0 -0
  53. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/tests/test_lan_commands.py +0 -0
  54. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/tests/test_lan_handshake.py +0 -0
  55. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/tests/test_lan_reports.py +0 -0
  56. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/tests/test_partial_reports.py +0 -0
  57. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/tests/test_printer_error_codes.py +0 -0
  58. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/tests/test_project_null_status.py +0 -0
  59. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/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.26
3
+ Version: 0.4.28
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.26"
7
+ version = "0.4.28"
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"
@@ -1127,7 +1127,10 @@ class AnycubicPrinter:
1127
1127
  state: str,
1128
1128
  payload: AnycubicConsumableData,
1129
1129
  ) -> None:
1130
- project_id = payload.get('data', {}).get('taskid', -1)
1130
+ # `or {}` rather than a plain default: a present-but-null `data`
1131
+ # hands back None from .get and the chained .get then raises
1132
+ # AttributeError. Same trap as the null coercions, different shape.
1133
+ project_id = (payload.get('data') or {}).get('taskid', -1)
1131
1134
  if action == 'start' and state == 'printing':
1132
1135
  data = payload['data']
1133
1136
  self._is_printing = 2
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  from typing import Any
4
4
 
5
5
  from ..exceptions.error_strings import ErrorsDataParsing
6
+ from ..helpers.helpers import as_int, as_int_list, as_str
6
7
  from ..exceptions.exceptions import AnycubicDataParsingError
7
8
 
8
9
 
@@ -27,13 +28,13 @@ class AnycubicMaterialMapping:
27
28
  color_blue: int,
28
29
  paint_index: int = 0,
29
30
  ) -> None:
30
- self._spool_index = int(spool_index)
31
+ self._spool_index = as_int(spool_index, -1)
31
32
  self._filament_used = float(filament_used)
32
- self._material_type = str(material_type)
33
- self._color_red = int(color_red)
34
- self._color_green = int(color_green)
35
- self._color_blue = int(color_blue)
36
- self._paint_index = int(paint_index)
33
+ self._material_type = as_str(material_type)
34
+ self._color_red = as_int(color_red)
35
+ self._color_green = as_int(color_green)
36
+ self._color_blue = as_int(color_blue)
37
+ self._paint_index = as_int(paint_index, -1)
37
38
 
38
39
  @property
39
40
  def spool_index(self) -> int:
@@ -207,19 +208,22 @@ class AnycubicMachineToolInfo:
207
208
  status: int,
208
209
  show_place: int,
209
210
  ) -> None:
210
- self._id = int(id)
211
- self._typd_id = int(typd_id)
212
- self._model_id = int(model_id)
213
- self._type_function_id = int(type_function_id)
214
- self._parent_id = int(parent_id)
215
- self._function_name = str(function_name)
216
- self._function_des = str(function_des)
217
- self._control = int(control)
211
+ # Descriptive data about what the machine can do. Thirteen fields
212
+ # read straight out of the cloud's dict, any one of which arriving
213
+ # null used to raise and cost the caller the whole printer.
214
+ self._id = as_int(id, -1)
215
+ self._typd_id = as_int(typd_id, -1)
216
+ self._model_id = as_int(model_id, -1)
217
+ self._type_function_id = as_int(type_function_id, -1)
218
+ self._parent_id = as_int(parent_id, -1)
219
+ self._function_name = as_str(function_name)
220
+ self._function_des = as_str(function_des)
221
+ self._control = as_int(control)
218
222
  self._param = param
219
- self._icon_url = str(icon_url)
220
- self._function_type = int(function_type)
221
- self._status = int(status)
222
- self._show_place = int(show_place)
223
+ self._icon_url = as_str(icon_url)
224
+ self._function_type = as_int(function_type)
225
+ self._status = as_int(status)
226
+ self._show_place = as_int(show_place)
223
227
 
224
228
  @classmethod
225
229
  def from_json(cls, data: dict[str, Any] | None) -> AnycubicMachineToolInfo | None:
@@ -271,20 +275,47 @@ class AnycubicMachineExternalShelves:
271
275
  status_type: int,
272
276
  current_status: int,
273
277
  ) -> None:
274
- self._id = int(id)
275
- self._type = str(type)
276
- self._color = list([
277
- int(x) for x in color
278
- ])
279
- self._loaded = int(loaded)
280
- self._status_type = int(status_type)
281
- self._current_status = int(current_status)
278
+ # Every one of these is coerced defensively. Firmware 2.0.1.9 began
279
+ # sending this object with `id` and `loaded` null, and the bare
280
+ # int() calls that used to be here raised TypeError -- which failed
281
+ # the whole printer's parse and left the entry in setup_retry with
282
+ # every entity unavailable (#28). The holder is an accessory; not
283
+ # knowing its id is not a reason to lose the printer.
284
+ self._id = as_int(id, -1)
285
+ self._type = as_str(type)
286
+ self._color = as_int_list(color)
287
+ self._loaded = as_int(loaded)
288
+ self._status_type = as_int(status_type)
289
+ self._current_status = as_int(current_status)
282
290
 
283
291
  @classmethod
284
292
  def from_json(cls, data: dict[str, Any] | None) -> AnycubicMachineExternalShelves | None:
285
293
  if data is None:
286
294
  return None
287
295
 
296
+ # An external shelf is a distinct accessory from the ACE, and firmware
297
+ # 2.0.1.9 reports the field whether or not one is attached -- entirely
298
+ # null when it is not. Reported by the owner of the printer in #28,
299
+ # who had an ACE 2 Pro represented perfectly well elsewhere in the
300
+ # same payload and no shelf at all.
301
+ #
302
+ # Tolerating those nulls stopped the crash but would then have built a
303
+ # holder that does not exist, so `external_spool_loaded` would state
304
+ # flatly that nothing is loaded in a thing nobody owns. Absent is not
305
+ # empty, and an entity that is confidently wrong is worse than one
306
+ # that admits it does not know.
307
+ #
308
+ # Deliberately conservative: any one of an id, a material or a loaded
309
+ # flag is enough to accept it, because no real shelf's payload has
310
+ # been seen yet and a shelf that reports only some of them must still
311
+ # come through.
312
+ if (
313
+ data.get('id') is None
314
+ and not data.get('type')
315
+ and data.get('loaded') is None
316
+ ):
317
+ return None
318
+
288
319
  return cls(
289
320
  id=data['id'],
290
321
  type=data['type'],
@@ -298,14 +329,18 @@ class AnycubicMachineExternalShelves:
298
329
  if data is None:
299
330
  return None
300
331
 
301
- self._type = data['type']
302
- self._color = data['color']
303
- self._loaded = data['loaded']
304
- self._status_type = data['status_type']
305
- self._current_status = data['current_status']
332
+ # Coerced exactly as the constructor does. This path wrote whatever
333
+ # arrived straight onto the slots, so a null over MQTT produced an
334
+ # object whose own properties then raised on read instead.
335
+ self._type = as_str(data.get('type'))
336
+ self._color = as_int_list(data.get('color'))
337
+ self._loaded = as_int(data.get('loaded'))
338
+ self._status_type = as_int(data.get('status_type'))
339
+ self._current_status = as_int(data.get('current_status'))
306
340
 
307
341
  @property
308
342
  def id(self) -> int:
343
+ """The holder's id, or -1 when the printer did not name one."""
309
344
  return self._id
310
345
 
311
346
  @property
@@ -400,10 +435,10 @@ class AnycubicFeedStatus:
400
435
  current_status: int,
401
436
  slot_index: int,
402
437
  ) -> None:
403
- self._code = int(code)
404
- self._type = int(type)
405
- self._current_status = int(current_status)
406
- self._slot_index = int(slot_index)
438
+ self._code = as_int(code, -1)
439
+ self._type = as_int(type)
440
+ self._current_status = as_int(current_status)
441
+ self._slot_index = as_int(slot_index, -1)
407
442
 
408
443
  @classmethod
409
444
  def from_json(cls, data: dict[str, Any] | None) -> AnycubicFeedStatus | None:
@@ -449,10 +484,10 @@ class AnycubicDryingStatus:
449
484
  duration: int,
450
485
  remain_time: int,
451
486
  ) -> None:
452
- self._status = int(status)
453
- self._target_temp = int(target_temp)
454
- self._duration = int(duration)
455
- self._remain_time = int(remain_time)
487
+ self._status = as_int(status)
488
+ self._target_temp = as_int(target_temp)
489
+ self._duration = as_int(duration)
490
+ self._remain_time = as_int(remain_time)
456
491
 
457
492
  @classmethod
458
493
  def from_json(cls, data: dict[str, Any] | None) -> AnycubicDryingStatus | None:
@@ -691,11 +726,14 @@ class AnycubicMultiColorBox:
691
726
  slots: list[dict[str, Any]],
692
727
  humidity: float | None = None,
693
728
  ) -> None:
729
+ # `id` keeps raising on purpose: it is the ACE's identity and the
730
+ # printer dict is keyed by it, so a box with no id cannot be placed.
731
+ # Everything after it is telemetry and must not be able to sink the box.
694
732
  self._id: int = int(id)
695
- self._status: int = int(status)
696
- self._model_id: int = int(model_id)
697
- self._auto_feed: int = int(auto_feed)
698
- self._loaded_slot: int = int(loaded_slot)
733
+ self._status: int = as_int(status)
734
+ self._model_id: int = as_int(model_id)
735
+ self._auto_feed: int = as_int(auto_feed)
736
+ self._loaded_slot: int = as_int(loaded_slot, -1)
699
737
  self.set_feed_status(feed_status)
700
738
  self.set_current_temperature(temp)
701
739
  self._humidity: float | None = (
@@ -712,7 +750,7 @@ class AnycubicMultiColorBox:
712
750
  raise AnycubicDataParsingError(ErrorsDataParsing.ace.format(slots))
713
751
 
714
752
  def set_auto_feed(self, auto_feed: int) -> None:
715
- self._auto_feed = int(auto_feed)
753
+ self._auto_feed = as_int(auto_feed)
716
754
 
717
755
  def set_drying_status(self, drying_status: dict[str, Any] | None) -> None:
718
756
  self._drying_status = AnycubicDryingStatus.from_json(drying_status)
@@ -721,10 +759,12 @@ class AnycubicMultiColorBox:
721
759
  self._feed_status = AnycubicFeedStatus.from_json(feed_status)
722
760
 
723
761
  def set_slot_loaded(self, slot_num: int) -> None:
724
- self._loaded_slot = slot_num
762
+ self._loaded_slot = as_int(slot_num, -1)
725
763
 
726
764
  def set_current_temperature(self, temp: int) -> None:
727
- self._temp = int(temp)
765
+ # Reached from the constructor as well as from MQTT, so a null
766
+ # temperature took the whole box down and not merely the reading.
767
+ self._temp = as_int(temp)
728
768
 
729
769
  def update_slots_with_mqtt_data(self, slot_list: list[dict[str, Any]] | None) -> None:
730
770
  if slot_list is None:
@@ -17,6 +17,59 @@ REX_GCODE_DATA_KEY_VALUE: re.Pattern[Any] = re.compile(r'; ([a-zA-Z0-9_\[\] ]+)
17
17
  REX_PRINT_TOTAL_TIME: re.Pattern[Any] = re.compile(r'^([\d]+)hour([\d]+)min$')
18
18
 
19
19
 
20
+ def as_int(value: Any, default: int = 0) -> int:
21
+ """A whole number out of whatever the cloud sent, or `default`.
22
+
23
+ Anycubic adds fields and nulls them without warning: firmware 2.0.1.9
24
+ began sending `external_shelves` with `id` and `loaded` set to null, and
25
+ `int(None)` raised, which failed the parse of the whole printer and left
26
+ every entity unavailable behind a config entry stuck in setup_retry.
27
+
28
+ Note that `data.get("key", 0)` does NOT protect against this. The default
29
+ only applies when the key is ABSENT; a key that is present and null hands
30
+ back None and raises anyway. That is precisely how the above shipped.
31
+
32
+ A single unreadable telemetry field is never worth losing the printer
33
+ over, so this returns the stated fallback rather than raising.
34
+ """
35
+ if isinstance(value, bool):
36
+ # bool is an int subclass, so int(True) is 1 either way -- spelled out
37
+ # so the behaviour is a decision rather than an accident.
38
+ return int(value)
39
+
40
+ try:
41
+ return int(value)
42
+ except (TypeError, ValueError):
43
+ return default
44
+
45
+
46
+ def as_str(value: Any, default: str = "") -> str:
47
+ """Text out of whatever the cloud sent. `None` becomes `default`, not "None"."""
48
+ if value is None:
49
+ return default
50
+
51
+ return str(value)
52
+
53
+
54
+ def as_int_list(value: Any) -> list[int]:
55
+ """A list of whole numbers, dropping anything that is not one.
56
+
57
+ Colours arrive here. A null list, or a list with a null in it, used to
58
+ raise from inside the comprehension and take the printer down with it.
59
+ """
60
+ if not isinstance(value, (list, tuple)):
61
+ return []
62
+
63
+ numbers: list[int] = []
64
+ for item in value:
65
+ try:
66
+ numbers.append(int(item))
67
+ except (TypeError, ValueError):
68
+ continue
69
+
70
+ return numbers
71
+
72
+
20
73
  def timedelta_to_total_minutes(
21
74
  delta: timedelta,
22
75
  ) -> float:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: anycubic-cloud-api
3
- Version: 0.4.26
3
+ Version: 0.4.28
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
@@ -50,6 +50,7 @@ tests/test_lan_client.py
50
50
  tests/test_lan_commands.py
51
51
  tests/test_lan_handshake.py
52
52
  tests/test_lan_reports.py
53
+ tests/test_null_tolerant_parsing.py
53
54
  tests/test_partial_reports.py
54
55
  tests/test_printer_error_codes.py
55
56
  tests/test_project_null_status.py
@@ -0,0 +1,180 @@
1
+ """Nulls in the cloud payload must not cost the caller the printer.
2
+
3
+ Reported as hass-anycubic #28: Anycubic firmware 2.0.1.9 began sending
4
+ `external_shelves` with `id` and `loaded` set to null. `int(None)` raised,
5
+ the whole printer failed to parse, and the config entry sat in setup_retry
6
+ with every entity unavailable -- on a response the cloud had answered
7
+ successfully, carrying complete telemetry.
8
+
9
+ The trap is worth stating plainly, because the code looked defended:
10
+
11
+ data.get('loaded', False)
12
+
13
+ only supplies the default when the key is ABSENT. A key that is present and
14
+ null returns None, and the int() after it raises anyway. That is exactly how
15
+ this shipped.
16
+ """
17
+
18
+ import pytest
19
+
20
+ from anycubic_cloud_api.data_models.printer_properties import (
21
+ AnycubicDryingStatus,
22
+ AnycubicFeedStatus,
23
+ AnycubicMachineExternalShelves,
24
+ AnycubicMachineToolInfo,
25
+ AnycubicMultiColorBox,
26
+ )
27
+ from anycubic_cloud_api.helpers.helpers import as_int, as_int_list, as_str
28
+
29
+ # Verbatim from the issue, nulls and all.
30
+ SHELVES_2019 = {
31
+ "brand_name": None,
32
+ "color": [255, 255, 255],
33
+ "current_status": 11,
34
+ "id": None,
35
+ "loaded": None,
36
+ "material_name": None,
37
+ "status_type": 2,
38
+ "type": "",
39
+ }
40
+
41
+
42
+ # A shelf that is actually attached: it names itself and what is in it.
43
+ # Invented, because no real payload has been seen -- so it is deliberately
44
+ # minimal and every test that uses it says what it is relying on.
45
+ SHELF_PRESENT = {
46
+ "brand_name": "Anycubic",
47
+ "color": [240, 243, 245],
48
+ "current_status": 11,
49
+ "id": 1,
50
+ "loaded": 1,
51
+ "material_name": "PLA",
52
+ "status_type": 2,
53
+ "type": "PLA",
54
+ }
55
+
56
+
57
+ class TestTheReportedPayload:
58
+ def test_firmware_2019_reports_no_shelf_rather_than_crashing(self):
59
+ """The payload that took people's printers away.
60
+
61
+ It parses now -- and the right answer is that there is no shelf. The
62
+ reporter had an ACE 2 Pro, represented fully elsewhere in the same
63
+ response, and no external shelf at all: 2.0.1.9 sends this field
64
+ whether or not the accessory exists.
65
+ """
66
+ assert AnycubicMachineExternalShelves.from_json(SHELVES_2019) is None
67
+
68
+ def test_it_does_not_raise(self):
69
+ """Separate from the assertion above on purpose. The bug was a crash;
70
+ returning None is the judgement. Both must hold, and a later change to
71
+ the judgement must not be able to quietly reintroduce the crash."""
72
+ try:
73
+ AnycubicMachineExternalShelves.from_json(SHELVES_2019)
74
+ except Exception as err: # noqa: BLE001 - the whole point is that nothing escapes
75
+ pytest.fail(f"the reported payload raised {err!r}")
76
+
77
+ @pytest.mark.parametrize("field", sorted(SHELF_PRESENT))
78
+ def test_a_present_shelf_survives_any_single_null(self, field):
79
+ """Anycubic nulls fields without warning, so each is checked on its
80
+ own rather than trusting the next firmware to pick the same two."""
81
+ payload = dict(SHELF_PRESENT)
82
+ payload[field] = None
83
+
84
+ # Nulling any single field still leaves at least one of id, type or
85
+ # loaded, so the shelf stays identifiable in every case here.
86
+ assert AnycubicMachineExternalShelves.from_json(payload) is not None
87
+
88
+ def test_a_shelf_known_only_by_its_material_still_counts(self):
89
+ """Conservative on purpose: no real shelf payload has been seen, so
90
+ any one of an id, a material or a loaded flag is enough."""
91
+ payload = dict(SHELVES_2019, type="PLA")
92
+
93
+ assert AnycubicMachineExternalShelves.from_json(payload) is not None
94
+
95
+ def test_a_shelf_known_only_by_its_id_still_counts(self):
96
+ payload = dict(SHELVES_2019, id=1)
97
+
98
+ shelves = AnycubicMachineExternalShelves.from_json(payload)
99
+ assert shelves is not None
100
+ assert shelves.id == 1
101
+
102
+ def test_a_null_inside_the_colour_list_is_survived(self):
103
+ payload = dict(SHELF_PRESENT, color=[255, None, 255])
104
+
105
+ assert AnycubicMachineExternalShelves.from_json(payload).color == [255, 255]
106
+
107
+ def test_the_mqtt_update_path_is_coerced_too(self):
108
+ """It wrote raw values onto the slots, so a null landed intact and
109
+ the object's own properties raised on read instead of on parse."""
110
+ shelves = AnycubicMachineExternalShelves.from_json(SHELF_PRESENT)
111
+
112
+ shelves.update_with_mqtt_data(dict(SHELF_PRESENT, loaded=None, color=None))
113
+
114
+ assert shelves.loaded is False
115
+ assert shelves.color == []
116
+
117
+
118
+ class TestTheSiblingsWithTheSameShape:
119
+ """Every constructor fed by `.get(key, <default>)` had the same hole."""
120
+
121
+ def test_feed_status_survives_nulls(self):
122
+ assert AnycubicFeedStatus.from_json(
123
+ {"code": None, "type": None, "current_status": None, "slot_index": None}
124
+ ) is not None
125
+
126
+ def test_drying_status_survives_nulls(self):
127
+ drying = AnycubicDryingStatus.from_json(
128
+ {"status": None, "target_temp": None, "duration": None, "remain_time": None}
129
+ )
130
+
131
+ assert drying is not None
132
+ assert drying.is_drying is False
133
+
134
+ def test_a_multi_colour_box_survives_null_telemetry(self):
135
+ box = AnycubicMultiColorBox.from_json({
136
+ "id": 0, "status": None, "model_id": None, "auto_feed": None,
137
+ "loaded_slot": None, "temp": None, "slots": [],
138
+ })
139
+
140
+ assert box is not None
141
+ assert box.loaded_slot == -1
142
+
143
+ def test_but_a_box_with_no_id_still_raises(self):
144
+ """`id` is the ACE's identity and the printer dict is keyed by it.
145
+ A box that cannot be placed must not be silently invented."""
146
+ with pytest.raises((TypeError, ValueError, KeyError)):
147
+ AnycubicMultiColorBox.from_json({"id": None, "slots": []})
148
+
149
+ def test_machine_tool_info_survives_nulls(self):
150
+ assert AnycubicMachineToolInfo.from_json(
151
+ {k: None for k in (
152
+ "id", "typd_id", "model_id", "type_function_id", "parent_id",
153
+ "function_name", "function_des", "control", "param",
154
+ "icon_url", "function_type", "status", "show_place",
155
+ )}
156
+ ) is not None
157
+
158
+
159
+ class TestTheCoercionHelpers:
160
+ @pytest.mark.parametrize(
161
+ ("value", "expected"),
162
+ [(None, 0), ("", 0), ("nonsense", 0), ([], 0), (7, 7), ("7", 7), (7.9, 7), (True, 1)],
163
+ )
164
+ def test_as_int(self, value, expected):
165
+ assert as_int(value) == expected
166
+
167
+ def test_as_int_uses_the_stated_fallback(self):
168
+ assert as_int(None, -1) == -1
169
+
170
+ def test_as_str_does_not_stringify_none(self):
171
+ """str(None) is "None", which would read as a filament called None."""
172
+ assert as_str(None) == ""
173
+ assert as_str("PLA") == "PLA"
174
+
175
+ @pytest.mark.parametrize(
176
+ ("value", "expected"),
177
+ [(None, []), ("nope", []), ([1, None, 3], [1, 3]), ([1, 2, 3], [1, 2, 3])],
178
+ )
179
+ def test_as_int_list(self, value, expected):
180
+ assert as_int_list(value) == expected