anycubic-cloud-api 0.4.26__tar.gz → 0.4.27__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.27}/PKG-INFO +1 -1
  2. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/pyproject.toml +1 -1
  3. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api/data_models/printer.py +4 -1
  4. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api/data_models/printer_properties.py +63 -46
  5. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api/helpers/helpers.py +53 -0
  6. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27/src/anycubic_cloud_api.egg-info}/PKG-INFO +1 -1
  7. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api.egg-info/SOURCES.txt +1 -0
  8. anycubic_cloud_api-0.4.27/tests/test_null_tolerant_parsing.py +143 -0
  9. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/COPYRIGHT +0 -0
  10. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/LICENSE +0 -0
  11. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/MANIFEST.in +0 -0
  12. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/README.md +0 -0
  13. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/setup.cfg +0 -0
  14. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api/__init__.py +0 -0
  15. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api/anycubic_api.py +0 -0
  16. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api/api/base.py +0 -0
  17. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api/api/functions.py +0 -0
  18. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api/api/mqtt.py +0 -0
  19. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api/const/api_endpoints.py +0 -0
  20. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api/const/const.py +0 -0
  21. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api/const/enums.py +0 -0
  22. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api/const/error_codes.py +0 -0
  23. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api/const/lan.py +0 -0
  24. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api/const/mqtt.py +0 -0
  25. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api/const/regions.py +0 -0
  26. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api/data_models/consumable.py +0 -0
  27. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api/data_models/files.py +0 -0
  28. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api/data_models/gcode_file.py +0 -0
  29. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api/data_models/orders.py +0 -0
  30. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api/data_models/print_response.py +0 -0
  31. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api/data_models/print_speed_mode.py +0 -0
  32. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api/data_models/printing_settings.py +0 -0
  33. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api/data_models/project.py +0 -0
  34. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api/exceptions/error_strings.py +0 -0
  35. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api/exceptions/exceptions.py +0 -0
  36. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api/lan/__init__.py +0 -0
  37. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api/lan/client.py +0 -0
  38. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api/lan/commands.py +0 -0
  39. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api/lan/handshake.py +0 -0
  40. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api/models/auth.py +0 -0
  41. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api/models/cloud_upload.py +0 -0
  42. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api/models/http.py +0 -0
  43. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api/resources/anycubic_mqqt_tls_ca.crt +0 -0
  44. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api/resources/anycubic_mqqt_tls_client.crt +0 -0
  45. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api/resources/anycubic_mqqt_tls_client.key +0 -0
  46. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api.egg-info/dependency_links.txt +0 -0
  47. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api.egg-info/requires.txt +0 -0
  48. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/src/anycubic_cloud_api.egg-info/top_level.txt +0 -0
  49. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/tests/test_auth_mode_fallback.py +0 -0
  50. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/tests/test_axis_position.py +0 -0
  51. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/tests/test_endpoint_urls.py +0 -0
  52. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/tests/test_lan_client.py +0 -0
  53. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/tests/test_lan_commands.py +0 -0
  54. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/tests/test_lan_handshake.py +0 -0
  55. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/tests/test_lan_reports.py +0 -0
  56. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/tests/test_partial_reports.py +0 -0
  57. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/tests/test_printer_error_codes.py +0 -0
  58. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/tests/test_project_null_status.py +0 -0
  59. {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.27}/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.27
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.27"
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,14 +275,18 @@ 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:
@@ -298,14 +306,18 @@ class AnycubicMachineExternalShelves:
298
306
  if data is None:
299
307
  return None
300
308
 
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']
309
+ # Coerced exactly as the constructor does. This path wrote whatever
310
+ # arrived straight onto the slots, so a null over MQTT produced an
311
+ # object whose own properties then raised on read instead.
312
+ self._type = as_str(data.get('type'))
313
+ self._color = as_int_list(data.get('color'))
314
+ self._loaded = as_int(data.get('loaded'))
315
+ self._status_type = as_int(data.get('status_type'))
316
+ self._current_status = as_int(data.get('current_status'))
306
317
 
307
318
  @property
308
319
  def id(self) -> int:
320
+ """The holder's id, or -1 when the printer did not name one."""
309
321
  return self._id
310
322
 
311
323
  @property
@@ -400,10 +412,10 @@ class AnycubicFeedStatus:
400
412
  current_status: int,
401
413
  slot_index: int,
402
414
  ) -> 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)
415
+ self._code = as_int(code, -1)
416
+ self._type = as_int(type)
417
+ self._current_status = as_int(current_status)
418
+ self._slot_index = as_int(slot_index, -1)
407
419
 
408
420
  @classmethod
409
421
  def from_json(cls, data: dict[str, Any] | None) -> AnycubicFeedStatus | None:
@@ -449,10 +461,10 @@ class AnycubicDryingStatus:
449
461
  duration: int,
450
462
  remain_time: int,
451
463
  ) -> 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)
464
+ self._status = as_int(status)
465
+ self._target_temp = as_int(target_temp)
466
+ self._duration = as_int(duration)
467
+ self._remain_time = as_int(remain_time)
456
468
 
457
469
  @classmethod
458
470
  def from_json(cls, data: dict[str, Any] | None) -> AnycubicDryingStatus | None:
@@ -691,11 +703,14 @@ class AnycubicMultiColorBox:
691
703
  slots: list[dict[str, Any]],
692
704
  humidity: float | None = None,
693
705
  ) -> None:
706
+ # `id` keeps raising on purpose: it is the ACE's identity and the
707
+ # printer dict is keyed by it, so a box with no id cannot be placed.
708
+ # Everything after it is telemetry and must not be able to sink the box.
694
709
  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)
710
+ self._status: int = as_int(status)
711
+ self._model_id: int = as_int(model_id)
712
+ self._auto_feed: int = as_int(auto_feed)
713
+ self._loaded_slot: int = as_int(loaded_slot, -1)
699
714
  self.set_feed_status(feed_status)
700
715
  self.set_current_temperature(temp)
701
716
  self._humidity: float | None = (
@@ -712,7 +727,7 @@ class AnycubicMultiColorBox:
712
727
  raise AnycubicDataParsingError(ErrorsDataParsing.ace.format(slots))
713
728
 
714
729
  def set_auto_feed(self, auto_feed: int) -> None:
715
- self._auto_feed = int(auto_feed)
730
+ self._auto_feed = as_int(auto_feed)
716
731
 
717
732
  def set_drying_status(self, drying_status: dict[str, Any] | None) -> None:
718
733
  self._drying_status = AnycubicDryingStatus.from_json(drying_status)
@@ -721,10 +736,12 @@ class AnycubicMultiColorBox:
721
736
  self._feed_status = AnycubicFeedStatus.from_json(feed_status)
722
737
 
723
738
  def set_slot_loaded(self, slot_num: int) -> None:
724
- self._loaded_slot = slot_num
739
+ self._loaded_slot = as_int(slot_num, -1)
725
740
 
726
741
  def set_current_temperature(self, temp: int) -> None:
727
- self._temp = int(temp)
742
+ # Reached from the constructor as well as from MQTT, so a null
743
+ # temperature took the whole box down and not merely the reading.
744
+ self._temp = as_int(temp)
728
745
 
729
746
  def update_slots_with_mqtt_data(self, slot_list: list[dict[str, Any]] | None) -> None:
730
747
  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.27
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,143 @@
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
+ class TestTheReportedPayload:
43
+ def test_firmware_2019_external_shelves_parses(self):
44
+ """The payload that took people's printers away."""
45
+ shelves = AnycubicMachineExternalShelves.from_json(SHELVES_2019)
46
+
47
+ assert shelves is not None
48
+ assert shelves.id == -1 # not named -> stated as unknown
49
+ assert shelves.loaded is False
50
+ assert shelves.color == [255, 255, 255]
51
+ assert shelves.material_type is None # '' means empty, not "empty"
52
+
53
+ @pytest.mark.parametrize("field", sorted(SHELVES_2019))
54
+ def test_any_single_field_may_be_null(self, field):
55
+ """Not just the two that happened to be null this time.
56
+
57
+ Anycubic nulls fields without warning, so each is checked on its own
58
+ rather than trusting that the next firmware picks the same two.
59
+ """
60
+ payload = dict(SHELVES_2019)
61
+ payload[field] = None
62
+
63
+ assert AnycubicMachineExternalShelves.from_json(payload) is not None
64
+
65
+ def test_a_null_inside_the_colour_list_is_survived(self):
66
+ payload = dict(SHELVES_2019, color=[255, None, 255])
67
+
68
+ assert AnycubicMachineExternalShelves.from_json(payload).color == [255, 255]
69
+
70
+ def test_the_mqtt_update_path_is_coerced_too(self):
71
+ """It wrote raw values onto the slots, so a null landed intact and
72
+ the object's own properties raised on read instead of on parse."""
73
+ shelves = AnycubicMachineExternalShelves.from_json(SHELVES_2019)
74
+
75
+ shelves.update_with_mqtt_data(dict(SHELVES_2019, loaded=None, color=None))
76
+
77
+ assert shelves.loaded is False
78
+ assert shelves.color == []
79
+
80
+
81
+ class TestTheSiblingsWithTheSameShape:
82
+ """Every constructor fed by `.get(key, <default>)` had the same hole."""
83
+
84
+ def test_feed_status_survives_nulls(self):
85
+ assert AnycubicFeedStatus.from_json(
86
+ {"code": None, "type": None, "current_status": None, "slot_index": None}
87
+ ) is not None
88
+
89
+ def test_drying_status_survives_nulls(self):
90
+ drying = AnycubicDryingStatus.from_json(
91
+ {"status": None, "target_temp": None, "duration": None, "remain_time": None}
92
+ )
93
+
94
+ assert drying is not None
95
+ assert drying.is_drying is False
96
+
97
+ def test_a_multi_colour_box_survives_null_telemetry(self):
98
+ box = AnycubicMultiColorBox.from_json({
99
+ "id": 0, "status": None, "model_id": None, "auto_feed": None,
100
+ "loaded_slot": None, "temp": None, "slots": [],
101
+ })
102
+
103
+ assert box is not None
104
+ assert box.loaded_slot == -1
105
+
106
+ def test_but_a_box_with_no_id_still_raises(self):
107
+ """`id` is the ACE's identity and the printer dict is keyed by it.
108
+ A box that cannot be placed must not be silently invented."""
109
+ with pytest.raises((TypeError, ValueError, KeyError)):
110
+ AnycubicMultiColorBox.from_json({"id": None, "slots": []})
111
+
112
+ def test_machine_tool_info_survives_nulls(self):
113
+ assert AnycubicMachineToolInfo.from_json(
114
+ {k: None for k in (
115
+ "id", "typd_id", "model_id", "type_function_id", "parent_id",
116
+ "function_name", "function_des", "control", "param",
117
+ "icon_url", "function_type", "status", "show_place",
118
+ )}
119
+ ) is not None
120
+
121
+
122
+ class TestTheCoercionHelpers:
123
+ @pytest.mark.parametrize(
124
+ ("value", "expected"),
125
+ [(None, 0), ("", 0), ("nonsense", 0), ([], 0), (7, 7), ("7", 7), (7.9, 7), (True, 1)],
126
+ )
127
+ def test_as_int(self, value, expected):
128
+ assert as_int(value) == expected
129
+
130
+ def test_as_int_uses_the_stated_fallback(self):
131
+ assert as_int(None, -1) == -1
132
+
133
+ def test_as_str_does_not_stringify_none(self):
134
+ """str(None) is "None", which would read as a filament called None."""
135
+ assert as_str(None) == ""
136
+ assert as_str("PLA") == "PLA"
137
+
138
+ @pytest.mark.parametrize(
139
+ ("value", "expected"),
140
+ [(None, []), ("nope", []), ([1, None, 3], [1, 3]), ([1, 2, 3], [1, 2, 3])],
141
+ )
142
+ def test_as_int_list(self, value, expected):
143
+ assert as_int_list(value) == expected