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.
- {anycubic_cloud_api-0.4.26/src/anycubic_cloud_api.egg-info → anycubic_cloud_api-0.4.28}/PKG-INFO +1 -1
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/pyproject.toml +1 -1
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/data_models/printer.py +4 -1
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/data_models/printer_properties.py +86 -46
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/helpers/helpers.py +53 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28/src/anycubic_cloud_api.egg-info}/PKG-INFO +1 -1
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api.egg-info/SOURCES.txt +1 -0
- anycubic_cloud_api-0.4.28/tests/test_null_tolerant_parsing.py +180 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/COPYRIGHT +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/LICENSE +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/MANIFEST.in +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/README.md +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/setup.cfg +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/__init__.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/anycubic_api.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/api/base.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/api/functions.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/api/mqtt.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/const/api_endpoints.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/const/const.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/const/enums.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/const/error_codes.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/const/lan.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/const/mqtt.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/const/regions.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/data_models/consumable.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/data_models/files.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/data_models/gcode_file.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/data_models/orders.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/data_models/print_response.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/data_models/print_speed_mode.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/data_models/printing_settings.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/data_models/project.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/exceptions/error_strings.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/exceptions/exceptions.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/lan/__init__.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/lan/client.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/lan/commands.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/lan/handshake.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/models/auth.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/models/cloud_upload.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/models/http.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/resources/anycubic_mqqt_tls_ca.crt +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/resources/anycubic_mqqt_tls_client.crt +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/resources/anycubic_mqqt_tls_client.key +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api.egg-info/dependency_links.txt +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api.egg-info/requires.txt +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api.egg-info/top_level.txt +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/tests/test_auth_mode_fallback.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/tests/test_axis_position.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/tests/test_endpoint_urls.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/tests/test_lan_client.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/tests/test_lan_commands.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/tests/test_lan_handshake.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/tests/test_lan_reports.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/tests/test_partial_reports.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/tests/test_printer_error_codes.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/tests/test_project_null_status.py +0 -0
- {anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/tests/test_regions.py +0 -0
|
@@ -1127,7 +1127,10 @@ class AnycubicPrinter:
|
|
|
1127
1127
|
state: str,
|
|
1128
1128
|
payload: AnycubicConsumableData,
|
|
1129
1129
|
) -> None:
|
|
1130
|
-
|
|
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 =
|
|
31
|
+
self._spool_index = as_int(spool_index, -1)
|
|
31
32
|
self._filament_used = float(filament_used)
|
|
32
|
-
self._material_type =
|
|
33
|
-
self._color_red =
|
|
34
|
-
self._color_green =
|
|
35
|
-
self._color_blue =
|
|
36
|
-
self._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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
self.
|
|
214
|
-
self.
|
|
215
|
-
self.
|
|
216
|
-
self.
|
|
217
|
-
self.
|
|
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 =
|
|
220
|
-
self._function_type =
|
|
221
|
-
self._status =
|
|
222
|
-
self._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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
self.
|
|
281
|
-
self.
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
self.
|
|
305
|
-
self.
|
|
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 =
|
|
404
|
-
self._type =
|
|
405
|
-
self._current_status =
|
|
406
|
-
self._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 =
|
|
453
|
-
self._target_temp =
|
|
454
|
-
self._duration =
|
|
455
|
-
self._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 =
|
|
696
|
-
self._model_id: int =
|
|
697
|
-
self._auto_feed: int =
|
|
698
|
-
self._loaded_slot: int =
|
|
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 =
|
|
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
|
-
|
|
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:
|
{anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/helpers/helpers.py
RENAMED
|
@@ -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:
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/anycubic_api.py
RENAMED
|
File without changes
|
|
File without changes
|
{anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/api/functions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/const/const.py
RENAMED
|
File without changes
|
{anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/const/enums.py
RENAMED
|
File without changes
|
{anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/const/error_codes.py
RENAMED
|
File without changes
|
|
File without changes
|
{anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/const/mqtt.py
RENAMED
|
File without changes
|
{anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/const/regions.py
RENAMED
|
File without changes
|
|
File without changes
|
{anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/data_models/files.py
RENAMED
|
File without changes
|
|
File without changes
|
{anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/data_models/orders.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/lan/__init__.py
RENAMED
|
File without changes
|
{anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/lan/client.py
RENAMED
|
File without changes
|
{anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/lan/commands.py
RENAMED
|
File without changes
|
{anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/lan/handshake.py
RENAMED
|
File without changes
|
{anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/models/auth.py
RENAMED
|
File without changes
|
|
File without changes
|
{anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api/models/http.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{anycubic_cloud_api-0.4.26 → anycubic_cloud_api-0.4.28}/src/anycubic_cloud_api.egg-info/requires.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|