anycubic-cloud-api 0.4.33__tar.gz → 0.4.35__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 (63) hide show
  1. {anycubic_cloud_api-0.4.33/src/anycubic_cloud_api.egg-info → anycubic_cloud_api-0.4.35}/PKG-INFO +1 -1
  2. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/pyproject.toml +1 -1
  3. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api/api/mqtt.py +5 -1
  4. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api/data_models/printer.py +171 -2
  5. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api/data_models/project.py +16 -0
  6. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35/src/anycubic_cloud_api.egg-info}/PKG-INFO +1 -1
  7. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api.egg-info/SOURCES.txt +2 -0
  8. anycubic_cloud_api-0.4.35/tests/test_filament_change_0435.py +133 -0
  9. anycubic_cloud_api-0.4.35/tests/test_print_reports_0434.py +273 -0
  10. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/COPYRIGHT +0 -0
  11. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/LICENSE +0 -0
  12. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/MANIFEST.in +0 -0
  13. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/README.md +0 -0
  14. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/setup.cfg +0 -0
  15. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api/__init__.py +0 -0
  16. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api/anycubic_api.py +0 -0
  17. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api/api/base.py +0 -0
  18. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api/api/functions.py +0 -0
  19. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api/const/api_endpoints.py +0 -0
  20. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api/const/const.py +0 -0
  21. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api/const/enums.py +0 -0
  22. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api/const/error_codes.py +0 -0
  23. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api/const/lan.py +0 -0
  24. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api/const/mqtt.py +0 -0
  25. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api/const/regions.py +0 -0
  26. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api/data_models/consumable.py +0 -0
  27. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api/data_models/files.py +0 -0
  28. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api/data_models/gcode_file.py +0 -0
  29. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api/data_models/orders.py +0 -0
  30. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api/data_models/print_response.py +0 -0
  31. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api/data_models/print_speed_mode.py +0 -0
  32. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api/data_models/printer_properties.py +0 -0
  33. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api/data_models/printing_settings.py +0 -0
  34. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api/exceptions/error_strings.py +0 -0
  35. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api/exceptions/exceptions.py +0 -0
  36. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api/helpers/helpers.py +0 -0
  37. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api/lan/__init__.py +0 -0
  38. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api/lan/client.py +0 -0
  39. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api/lan/commands.py +0 -0
  40. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api/lan/handshake.py +0 -0
  41. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api/models/auth.py +0 -0
  42. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api/models/cloud_upload.py +0 -0
  43. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api/models/http.py +0 -0
  44. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api/resources/anycubic_mqqt_tls_ca.crt +0 -0
  45. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api/resources/anycubic_mqqt_tls_client.crt +0 -0
  46. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api/resources/anycubic_mqqt_tls_client.key +0 -0
  47. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api.egg-info/dependency_links.txt +0 -0
  48. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api.egg-info/requires.txt +0 -0
  49. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/src/anycubic_cloud_api.egg-info/top_level.txt +0 -0
  50. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/tests/test_auth_mode_fallback.py +0 -0
  51. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/tests/test_axis_position.py +0 -0
  52. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/tests/test_endpoint_urls.py +0 -0
  53. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/tests/test_exchange_rate_limit.py +0 -0
  54. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/tests/test_lan_client.py +0 -0
  55. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/tests/test_lan_commands.py +0 -0
  56. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/tests/test_lan_handshake.py +0 -0
  57. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/tests/test_lan_reports.py +0 -0
  58. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/tests/test_null_tolerant_parsing.py +0 -0
  59. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/tests/test_partial_reports.py +0 -0
  60. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/tests/test_printer_error_codes.py +0 -0
  61. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/tests/test_project_null_status.py +0 -0
  62. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/tests/test_regions.py +0 -0
  63. {anycubic_cloud_api-0.4.33 → anycubic_cloud_api-0.4.35}/tests/test_secondary_ace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: anycubic-cloud-api
3
- Version: 0.4.33
3
+ Version: 0.4.35
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.33"
7
+ version = "0.4.35"
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"
@@ -171,8 +171,12 @@ class AnycubicMQTTAPI(AnycubicAPIFunctions):
171
171
  printer.process_mqtt_update(topic, AnycubicConsumableData(payload))
172
172
 
173
173
  except AnycubicMQTTUnhandledData as e:
174
+ # The message was applied; only fields this version has no use
175
+ # for were left over. That is for whoever extends the parser,
176
+ # not something a user can act on, and at warning level it was
177
+ # repeated every few seconds through every print (#42).
174
178
  redacted_topic = redact_part_from_mqtt_topic(topic, 6)
175
- self._log_to_warn(
179
+ self._log_to_debug(
176
180
  f"Anycubic MQTT Message unhandled data in: {e}\n"
177
181
  f" on MQTT topic: {redacted_topic}\n"
178
182
  f" with type: {e.unhandled_mqtt_type}, "
@@ -24,6 +24,7 @@ from ..exceptions.exceptions import (
24
24
  AnycubicMQTTUnknownUpdate,
25
25
  )
26
26
  from ..helpers.helpers import (
27
+ as_int,
27
28
  get_part_from_mqtt_topic,
28
29
  time_duration_string_to_delta,
29
30
  timedelta_to_dhm_string,
@@ -52,6 +53,46 @@ if TYPE_CHECKING:
52
53
  from .print_response import AnycubicPrintResponse
53
54
  from .printer_properties import AnycubicDryingStatus, AnycubicMaterialMapping
54
55
 
56
+ # Job progress a print update can carry, applied to the job it is about.
57
+ _PRINT_PROGRESS_KEYS = (
58
+ 'curr_layer',
59
+ 'total_layers',
60
+ 'filename',
61
+ 'print_time',
62
+ 'progress',
63
+ 'remain_time',
64
+ 'supplies_usage',
65
+ )
66
+
67
+ # The steps of a filament change, as a Kobra X with an ACE 2 Pro reports
68
+ # them (#33). The ACE unfeed is the mirror of the feed, not yet seen.
69
+ _FILAMENT_CHANGE_STATES = frozenset({
70
+ 'extfilbox_filament_feeding',
71
+ 'extfilbox_filament_unfeeding',
72
+ 'ace_filament_feeding',
73
+ 'ace_filament_unfeeding',
74
+ 'nozzle_purging',
75
+ })
76
+
77
+ # Printers with a feeder of their own, which shares the box list with any
78
+ # ACE fitted: the Kobra X.
79
+ _PRINTERS_WITH_OWN_FEEDER = frozenset({20030})
80
+
81
+ # How a printer words a refused order when it gives no code.
82
+ _REFUSED_STATES = frozenset({'failed', 'fail', 'error', 'refused'})
83
+
84
+ # What a print report says about the file being printed, which the job already
85
+ # knows by name. Read so the report is not flagged as unhandled data on every
86
+ # progress tick (#34, #42) -- nothing here changes what is shown.
87
+ _PRINT_FILE_KEYS = (
88
+ 'file_root_path',
89
+ 'filetype',
90
+ 'display_filename',
91
+ 'origin3mf',
92
+ 'source_info',
93
+ 'temp_gcode',
94
+ )
95
+
55
96
 
56
97
  def _drain(consumable: Any) -> dict[str, Any]:
57
98
  """Read every remaining key, returning them as a plain dict.
@@ -1283,6 +1324,29 @@ class AnycubicPrinter:
1283
1324
  settings['target_hotbed_temp'],
1284
1325
  settings['target_nozzle_temp'],
1285
1326
  )
1327
+ # The light is reported by its own message; here it only repeats.
1328
+ settings.get('light')
1329
+ # Read again now it is empty, which is what marks a nested block
1330
+ # consumed; left in place it was reported as unhandled data.
1331
+ data.get('settings')
1332
+
1333
+ if 'channel_no' in data:
1334
+ self._apply_active_channel(data['channel_no'], data.get('unit_no'))
1335
+
1336
+ # A Kobra X reports progress this way between info reports, with
1337
+ # no task id. It was logged as unhandled and dropped (#34).
1338
+ if any(key in data for key in _PRINT_PROGRESS_KEYS):
1339
+ self._apply_print_progress(project_id, data)
1340
+ return
1341
+ elif action == 'start' and state in _FILAMENT_CHANGE_STATES:
1342
+ # Each step of a filament change, naming the channel (and, for an
1343
+ # ACE, the unit). Only a feed says what is now loaded (#33).
1344
+ data = payload.get('data') or {}
1345
+ # Read on every step, so the ones that change nothing are not
1346
+ # reported as unhandled.
1347
+ channel_no, unit_no = data.get('channel_no'), data.get('unit_no')
1348
+ if state.endswith('_filament_feeding'):
1349
+ self._apply_active_channel(channel_no, unit_no)
1286
1350
  return
1287
1351
  elif action in ['start', 'stop'] and state in ['failed']:
1288
1352
  err_msg = payload.get('msg')
@@ -1296,6 +1360,101 @@ class AnycubicPrinter:
1296
1360
  else:
1297
1361
  raise AnycubicMQTTUnknownUpdate(ErrorsMQTTUpdate.job_status)
1298
1362
 
1363
+ def _apply_active_channel(self, channel_no: Any, unit_no: Any = None) -> None:
1364
+ """Follow the filament channel the printer says it is printing from.
1365
+
1366
+ A Kobra X reports each filament change as a print update carrying
1367
+ ``channel_no``, and stops sending the box's own ``loaded_slot`` after
1368
+ the first few changes of a print, which is why the loaded slot froze
1369
+ (#34). Each box numbers its channels from 1; an ACE adds ``unit_no``
1370
+ and the printer's own feeder sends none (#33, Kobra X + ACE 2 Pro).
1371
+
1372
+ With one box the channel can only mean that box. With several, an
1373
+ ACE's ``unit_no`` picks it on a printer whose boxes are all ACEs.
1374
+ On a Kobra X with an ACE, which list entry is the printer's own
1375
+ feeder is not yet confirmed, so nothing is guessed there: a wrong
1376
+ slot is worse than a stale one.
1377
+ """
1378
+ channel = as_int(channel_no, 0)
1379
+ boxes = self._multi_color_box
1380
+
1381
+ if channel < 1 or not boxes:
1382
+ return
1383
+
1384
+ if len(boxes) == 1:
1385
+ box = boxes[0]
1386
+ else:
1387
+ unit = as_int(unit_no, 0)
1388
+ if unit < 1 or unit > len(boxes) or self._machine_type in _PRINTERS_WITH_OWN_FEEDER:
1389
+ return
1390
+ box = boxes[unit - 1]
1391
+
1392
+ if channel <= len(box.slots):
1393
+ box.set_slot_loaded(channel - 1)
1394
+
1395
+ def _apply_print_progress(
1396
+ self,
1397
+ project_id: int,
1398
+ data: AnycubicConsumableData,
1399
+ ) -> None:
1400
+ """Apply a progress-only print update to the job it is about.
1401
+
1402
+ Sent without a task id, it can only be about the job in hand. With
1403
+ no job to apply it to it is dropped, as the cloud path always has.
1404
+ """
1405
+ if not self._check_latest_project_id_valid(project_id):
1406
+ data.force_empty()
1407
+ return
1408
+
1409
+ assert self._latest_project
1410
+ self._latest_project.update_with_mqtt_print_status_data(None, data)
1411
+
1412
+ def _report_refused_drying(
1413
+ self,
1414
+ state: str | None,
1415
+ payload: AnycubicConsumableData,
1416
+ ) -> bool:
1417
+ """Say so when the printer will not start a dry cycle.
1418
+
1419
+ A start the ACE refuses -- 65 C on an ACE Pro that tops out at 55 is
1420
+ code 11505 -- came back as a setDry reply that was not a success. It
1421
+ was thrown away as "not understood", so the button press looked like
1422
+ it had worked and nothing in Home Assistant said otherwise (#42).
1423
+
1424
+ The code is kept as the printer's latest error, the same as any other
1425
+ fault, so the last-error sensors show it too. A reply that is neither
1426
+ a fault code nor a failed state is left to be reported as unknown.
1427
+ """
1428
+ data = payload.remaining_data.get('data')
1429
+ code = payload.remaining_data.get('code')
1430
+
1431
+ if isinstance(data, AnycubicConsumableData) and 'code' in data:
1432
+ code = data.remaining_data.get('code')
1433
+
1434
+ if not isinstance(code, int) or isinstance(code, bool) or code in PRINTER_OK_CODES:
1435
+ code = None
1436
+
1437
+ if code is None and str(state or '').lower() not in _REFUSED_STATES:
1438
+ return False
1439
+
1440
+ message = payload.get('msg')
1441
+
1442
+ if code is not None:
1443
+ self._record_error_code(code, message)
1444
+ reason = f"code {code}: {describe_printer_code(code)}"
1445
+ else:
1446
+ reason = str(message or state or 'no reason given')
1447
+
1448
+ self._api_parent._log_to_warn(
1449
+ f"The printer refused to start drying on the ACE ({reason})."
1450
+ )
1451
+
1452
+ payload.get('data')
1453
+ if isinstance(data, AnycubicConsumableData):
1454
+ data.force_empty()
1455
+
1456
+ return True
1457
+
1299
1458
  def _process_mqtt_update_multicolorbox(
1300
1459
  self,
1301
1460
  action: str,
@@ -1337,8 +1496,14 @@ class AnycubicPrinter:
1337
1496
  continue
1338
1497
 
1339
1498
  assert self._multi_color_box
1340
- self._multi_color_box[box_id].set_current_temperature(box['temp'])
1341
- self._multi_color_box[box_id].set_drying_status(box['drying_status'])
1499
+ # An ACE 2 Pro's periodic drying report carries no `temp`, and
1500
+ # reading it raised every six minutes, so drying never updated
1501
+ # (#33). Keep the last reading rather than inventing one.
1502
+ if 'temp' in box:
1503
+ self._multi_color_box[box_id].set_current_temperature(box['temp'])
1504
+ self._multi_color_box[box_id].set_drying_status(box.get('drying_status'))
1505
+ return
1506
+ elif action == 'setDry' and self._report_refused_drying(state, payload):
1342
1507
  return
1343
1508
  elif action == 'feedFilament' and state == 'done':
1344
1509
  data = payload['data']['multi_color_box']
@@ -1734,6 +1899,10 @@ class AnycubicPrinter:
1734
1899
  remaining_data.get('taskid')
1735
1900
  remaining_data.get('localtask')
1736
1901
 
1902
+ if msg_type == 'print':
1903
+ for key in _PRINT_FILE_KEYS:
1904
+ remaining_data.get(key)
1905
+
1737
1906
  payload.get('data')
1738
1907
  payload.get('msg')
1739
1908
  payload.get('timestamp')
@@ -46,6 +46,15 @@ PRINT_STATES_NOT_IN_PROGRESS = frozenset({
46
46
  'stopped',
47
47
  })
48
48
 
49
+ # The speed presets an FDM printer takes over its own network, in the shape
50
+ # the cloud sends them. 1, 2 and 3 are what a Kobra S1 and a Kobra 4 report
51
+ # and accept; a printer that sends its own list is never overridden.
52
+ LAN_PRINT_SPEED_MODES: tuple[dict[str, Any], ...] = (
53
+ {'title': 'Silent', 'print_speed_mode': 1},
54
+ {'title': 'Standard', 'print_speed_mode': 2},
55
+ {'title': 'Sport', 'print_speed_mode': 3},
56
+ )
57
+
49
58
 
50
59
  class AnycubicProject:
51
60
  __slots__ = (
@@ -305,6 +314,13 @@ class AnycubicProject:
305
314
  project_type=data.get('project_type'),
306
315
  localtask=data.get('localtask'),
307
316
  print_speed_mode=data.get('print_speed_mode'),
317
+ # The cloud sends the printer's speed presets with the job; the
318
+ # printer does not. Without a list the speed select stayed
319
+ # unavailable through every LAN print, though the printer takes
320
+ # the change locally as the slicer shows (discussion #37).
321
+ available_print_speed_modes=(
322
+ data.get('print_speed_model_des') or list(LAN_PRINT_SPEED_MODES)
323
+ ),
308
324
  )
309
325
 
310
326
  @classmethod
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: anycubic-cloud-api
3
- Version: 0.4.33
3
+ Version: 0.4.35
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
@@ -47,12 +47,14 @@ tests/test_auth_mode_fallback.py
47
47
  tests/test_axis_position.py
48
48
  tests/test_endpoint_urls.py
49
49
  tests/test_exchange_rate_limit.py
50
+ tests/test_filament_change_0435.py
50
51
  tests/test_lan_client.py
51
52
  tests/test_lan_commands.py
52
53
  tests/test_lan_handshake.py
53
54
  tests/test_lan_reports.py
54
55
  tests/test_null_tolerant_parsing.py
55
56
  tests/test_partial_reports.py
57
+ tests/test_print_reports_0434.py
56
58
  tests/test_printer_error_codes.py
57
59
  tests/test_project_null_status.py
58
60
  tests/test_regions.py
@@ -0,0 +1,133 @@
1
+ """Filament-change reports and the ACE 2 Pro's drying report (0.4.35).
2
+
3
+ From a Kobra X with an ACE 2 Pro (#33). Each step of a filament change
4
+ arrives as a print ``start`` naming ``channel_no``, counted from 1 within
5
+ its box, plus ``unit_no`` when the feed comes from an ACE. These were
6
+ dropped as unhandled. The ACE 2 Pro's periodic drying report has no
7
+ ``temp``, and reading it raised every six minutes.
8
+ """
9
+
10
+ from unittest.mock import MagicMock
11
+
12
+ import pytest
13
+
14
+ from anycubic_cloud_api.data_models.consumable import AnycubicConsumableData
15
+ from anycubic_cloud_api.data_models.printer import AnycubicPrinter
16
+
17
+ KOBRA_X = 20030
18
+ KOBRA_S1 = 20025
19
+
20
+
21
+ def slot(index):
22
+ return {"index": index, "sku": "", "type": "PLA", "color": [1, 2, 3], "status": 5, "edit_status": 1}
23
+
24
+
25
+ def box(box_id, slots=4):
26
+ return {"id": box_id, "loaded_slot": -1, "temp": 21, "slots": [slot(i) for i in range(slots)]}
27
+
28
+
29
+ def make_printer(boxes, machine_type=KOBRA_X):
30
+ return AnycubicPrinter(
31
+ api_parent=MagicMock(),
32
+ machine_type=machine_type,
33
+ machine_name="Printer",
34
+ id=1,
35
+ multi_color_box=boxes,
36
+ ignore_init_errors=True,
37
+ )
38
+
39
+
40
+ def message(msg_type, action, state, data):
41
+ return AnycubicConsumableData({
42
+ "type": msg_type, "action": action, "state": state,
43
+ "code": 200, "msg": "done", "msgid": "m", "timestamp": 1, "data": data,
44
+ })
45
+
46
+
47
+ def topic(machine_type, kind):
48
+ return f"anycubic/anycubicCloud/v1/printer/public/{machine_type}/key/{kind}/report"
49
+
50
+
51
+ def feed(printer, state, channel_no, unit_no=None, machine_type=KOBRA_X):
52
+ data = {"channel_no": channel_no, "taskid": "t"}
53
+ if unit_no is not None:
54
+ data["unit_no"] = unit_no
55
+ printer.process_mqtt_update(topic(machine_type, "print"), message("print", "start", state, data))
56
+
57
+
58
+ def loaded(printer):
59
+ return [b.loaded_slot for b in printer.multi_color_box]
60
+
61
+
62
+ class TestFilamentChange:
63
+ @pytest.mark.parametrize(("state", "unit_no"), [
64
+ ("extfilbox_filament_feeding", None),
65
+ ("ace_filament_feeding", 1),
66
+ ])
67
+ def test_one_box_follows_the_feed(self, state, unit_no):
68
+ printer = make_printer([box(0)])
69
+
70
+ feed(printer, state, 3, unit_no)
71
+
72
+ assert loaded(printer) == [2]
73
+
74
+ @pytest.mark.parametrize("state", [
75
+ "extfilbox_filament_unfeeding", "ace_filament_unfeeding", "nozzle_purging",
76
+ ])
77
+ def test_other_steps_are_read_but_change_nothing(self, state):
78
+ """Consumed, so not logged as unhandled; only a feed says what is loaded."""
79
+ printer = make_printer([box(0)])
80
+ printer.multi_color_box[0].set_slot_loaded(1)
81
+
82
+ feed(printer, state, 3, 1)
83
+
84
+ assert loaded(printer) == [1]
85
+
86
+ @pytest.mark.parametrize(("unit_no", "expected"), [(1, [1, -1]), (2, [-1, 1])])
87
+ def test_an_ace_unit_picks_its_box_on_a_printer_of_aces(self, unit_no, expected):
88
+ """A Kobra S1 has no feeder of its own: unit 1 is the first ACE."""
89
+ printer = make_printer([box(0), box(1)], machine_type=KOBRA_S1)
90
+
91
+ feed(printer, "ace_filament_feeding", 2, unit_no, machine_type=KOBRA_S1)
92
+
93
+ assert loaded(printer) == expected
94
+
95
+ @pytest.mark.parametrize(("state", "unit_no"), [
96
+ ("extfilbox_filament_feeding", None),
97
+ ("ace_filament_feeding", 1),
98
+ ])
99
+ def test_a_kobra_x_with_an_ace_guesses_nothing(self, state, unit_no):
100
+ """Which list entry is the Kobra X's own feeder is unconfirmed (#33)."""
101
+ printer = make_printer([box(0), box(1)])
102
+
103
+ feed(printer, state, 1, unit_no)
104
+
105
+ assert loaded(printer) == [-1, -1]
106
+
107
+ @pytest.mark.parametrize(("channel_no", "unit_no"), [(5, 1), (0, 1), (2, 3), ("x", 1), (2, None)])
108
+ def test_a_channel_or_unit_that_fits_no_box_changes_nothing(self, channel_no, unit_no):
109
+ printer = make_printer([box(0), box(1)], machine_type=KOBRA_S1)
110
+
111
+ feed(printer, "ace_filament_feeding", channel_no, unit_no, machine_type=KOBRA_S1)
112
+
113
+ assert loaded(printer) == [-1, -1]
114
+
115
+
116
+ class TestAce2ProDryingReport:
117
+ REPORT = {"multi_color_box": [{
118
+ "drying_status": {"duration": 14400, "humidity": 28, "remain_time": 162, "status": 1, "target_temp": 50},
119
+ "id": 0,
120
+ }]}
121
+
122
+ def test_a_report_without_temp_still_updates_drying(self):
123
+ printer = make_printer([box(0)])
124
+
125
+ printer.process_mqtt_update(
126
+ topic(KOBRA_X, "multiColorBox"),
127
+ message("multiColorBox", "autoUpdateDryStatus", "success", self.REPORT),
128
+ )
129
+
130
+ ace = printer.multi_color_box[0]
131
+ assert ace.drying_status.is_drying
132
+ assert ace.drying_status.target_temperature == 50
133
+ assert ace.current_temperature == 21 # the last reading is kept
@@ -0,0 +1,273 @@
1
+ """Print and ACE reports that were being dropped or misread (0.4.34).
2
+
3
+ From a Kobra X (#34) and a Kobra S1 with two ACE Pro units (#42): the active
4
+ filament channel arrived in print updates nobody read, progress-only updates
5
+ were logged as unhandled, a refused dry cycle passed silently, and a LAN job
6
+ had no speed presets for the speed select to offer (discussion #37).
7
+ """
8
+
9
+ from unittest.mock import MagicMock
10
+
11
+ import pytest
12
+
13
+ from anycubic_cloud_api.data_models.consumable import AnycubicConsumableData
14
+ from anycubic_cloud_api.data_models.printer import AnycubicPrinter
15
+ from anycubic_cloud_api.data_models.project import AnycubicProject
16
+ from anycubic_cloud_api.exceptions.exceptions import AnycubicMQTTUnknownUpdate
17
+
18
+
19
+ def slot(index):
20
+ return {
21
+ "index": index,
22
+ "sku": "",
23
+ "type": "PLA",
24
+ "color": [1, 2, 3],
25
+ "status": 5,
26
+ "edit_status": 1,
27
+ }
28
+
29
+
30
+ def box(box_id, slots=4):
31
+ return {
32
+ "id": box_id,
33
+ "loaded_slot": -1,
34
+ "slots": [slot(i) for i in range(slots)],
35
+ }
36
+
37
+
38
+ def make_printer(boxes, parent=None):
39
+ return AnycubicPrinter(
40
+ api_parent=parent or MagicMock(),
41
+ machine_type=20030,
42
+ machine_name="Kobra X",
43
+ id=1,
44
+ multi_color_box=boxes,
45
+ ignore_init_errors=True,
46
+ )
47
+
48
+
49
+ def message(msg_type, action, state, data, **envelope):
50
+ return AnycubicConsumableData({
51
+ "type": msg_type,
52
+ "action": action,
53
+ "state": state,
54
+ "msgid": "m",
55
+ "timestamp": 1,
56
+ "data": data,
57
+ **envelope,
58
+ })
59
+
60
+
61
+ def lan_job(task_id=42, **fields):
62
+ return {
63
+ "task_id": task_id,
64
+ "filename": "cube.gcode.3mf",
65
+ "progress": 0,
66
+ "print_status": 1,
67
+ **fields,
68
+ }
69
+
70
+
71
+ TOPIC = "anycubic/anycubicCloud/v1/printer/public/20030/key/print/report"
72
+ ACE_TOPIC = "anycubic/anycubicCloud/v1/printer/public/20030/key/multiColorBox/report"
73
+
74
+
75
+ class TestActiveChannel:
76
+ def _channel(self, printer, channel_no):
77
+ printer.process_mqtt_update(
78
+ TOPIC,
79
+ message(
80
+ "print", "update", "updated",
81
+ {"channel_no": channel_no, "settings": {"light": 0}},
82
+ ),
83
+ )
84
+
85
+ @pytest.mark.parametrize(("channel_no", "slot_index"), [(1, 0), (3, 2), (4, 3)])
86
+ def test_a_channel_change_moves_the_loaded_slot(self, channel_no, slot_index):
87
+ printer = make_printer([box(0)])
88
+
89
+ self._channel(printer, channel_no)
90
+
91
+ assert printer.primary_multi_color_box_loaded_slot == slot_index
92
+
93
+ @pytest.mark.parametrize("channel_no", [1, 5])
94
+ def test_with_two_boxes_and_no_unit_nothing_is_guessed(self, channel_no):
95
+ """Channels are numbered per box (#33), so without a unit the box is unknown.
96
+
97
+ 0.4.34 counted across boxes and put channel 5 in the second box.
98
+ """
99
+ printer = make_printer([box(0), box(1)])
100
+ before = [b.loaded_slot for b in printer.multi_color_box]
101
+
102
+ self._channel(printer, channel_no)
103
+
104
+ assert [b.loaded_slot for b in printer.multi_color_box] == before
105
+
106
+ @pytest.mark.parametrize("channel_no", [0, -1, 9, None, "x"])
107
+ def test_a_channel_with_no_slot_changes_nothing(self, channel_no):
108
+ printer = make_printer([box(0)])
109
+ printer._multi_color_box[0].set_slot_loaded(1)
110
+
111
+ self._channel(printer, channel_no)
112
+
113
+ assert printer.primary_multi_color_box_loaded_slot == 1
114
+
115
+ def test_a_printer_with_no_ace_ignores_it(self):
116
+ printer = make_printer(None)
117
+
118
+ self._channel(printer, 2)
119
+
120
+ assert printer.connected_ace_units == 0
121
+
122
+
123
+ class TestProgressUpdates:
124
+ PROGRESS = {
125
+ "curr_layer": 23,
126
+ "file_root_path": "cube.gcode.3mf",
127
+ "filename": "cube.gcode.3mf",
128
+ "filetype": 0,
129
+ "print_time": 7,
130
+ "progress": 10,
131
+ "remain_time": 66,
132
+ "settings": {"light": 0},
133
+ "supplies_usage": 1074,
134
+ "total_layers": 263,
135
+ }
136
+
137
+ def test_progress_without_a_task_id_reaches_the_job(self):
138
+ printer = make_printer([box(0)])
139
+ printer._apply_lan_project(AnycubicConsumableData(lan_job()))
140
+
141
+ printer.process_mqtt_update(
142
+ TOPIC, message("print", "update", "updated", dict(self.PROGRESS))
143
+ )
144
+
145
+ project = printer.latest_project
146
+ assert project is not None
147
+ assert project.progress_percentage == 10
148
+ assert project.print_time_remaining_minutes == 66
149
+
150
+ def test_progress_with_no_job_is_dropped_quietly(self):
151
+ printer = make_printer([box(0)])
152
+
153
+ printer.process_mqtt_update(
154
+ TOPIC, message("print", "update", "updated", dict(self.PROGRESS))
155
+ )
156
+
157
+ assert printer.latest_project is None
158
+
159
+ def test_progress_for_another_job_is_not_applied(self):
160
+ printer = make_printer([box(0)])
161
+ printer._apply_lan_project(AnycubicConsumableData(lan_job(task_id=42)))
162
+
163
+ printer.process_mqtt_update(
164
+ TOPIC,
165
+ message("print", "update", "updated", {"taskid": 7, **self.PROGRESS}),
166
+ )
167
+
168
+ assert printer.latest_project.progress_percentage == 0
169
+
170
+ def test_the_s1_file_fields_are_not_left_over(self):
171
+ """These four were reported as unhandled every 30-60 s (#42)."""
172
+ printer = make_printer([box(0)])
173
+ printer._apply_lan_project(AnycubicConsumableData(lan_job()))
174
+
175
+ printer.process_mqtt_update(
176
+ TOPIC,
177
+ message(
178
+ "print", "update", "updated",
179
+ {
180
+ "display_filename": "cube",
181
+ "origin3mf": "cube.3mf",
182
+ "source_info": "slicer",
183
+ "temp_gcode": "/tmp/x",
184
+ "progress": 50,
185
+ },
186
+ ),
187
+ )
188
+
189
+ assert printer.latest_project.progress_percentage == 50
190
+
191
+ def test_a_print_start_with_file_fields_is_handled(self):
192
+ printer = make_printer([box(0)])
193
+ printer._apply_lan_project(AnycubicConsumableData(lan_job()))
194
+
195
+ printer.process_mqtt_update(
196
+ TOPIC,
197
+ message(
198
+ "print", "start", "printing",
199
+ {"file_root_path": "cube.gcode.3mf", "filetype": 0},
200
+ ),
201
+ )
202
+
203
+
204
+ class TestRefusedDrying:
205
+ def _printer(self):
206
+ parent = MagicMock()
207
+ logs: list[str] = []
208
+ parent._log_to_warn = logs.append
209
+ return make_printer([box(0), box(1)], parent), logs
210
+
211
+ def test_a_refusal_code_is_reported_and_kept(self):
212
+ printer, logs = self._printer()
213
+
214
+ printer.process_mqtt_update(
215
+ ACE_TOPIC,
216
+ message("multiColorBox", "setDry", "failed", {}, code=11505, msg="fail"),
217
+ )
218
+
219
+ assert len(logs) == 1
220
+ assert "11505" in logs[0]
221
+ assert "target temperature" in logs[0]
222
+ assert printer.latest_error_code == 11505
223
+
224
+ def test_a_code_inside_the_data_counts_too(self):
225
+ printer, logs = self._printer()
226
+
227
+ printer.process_mqtt_update(
228
+ ACE_TOPIC,
229
+ message("multiColorBox", "setDry", "done", {"code": 11505}, code=200),
230
+ )
231
+
232
+ assert "11505" in logs[0]
233
+ assert printer.latest_error_code == 11505
234
+
235
+ def test_a_failed_state_without_a_code_is_still_reported(self):
236
+ printer, logs = self._printer()
237
+
238
+ printer.process_mqtt_update(
239
+ ACE_TOPIC,
240
+ message("multiColorBox", "setDry", "failed", None, code=200, msg="busy"),
241
+ )
242
+
243
+ assert logs == ["The printer refused to start drying on the ACE (busy)."]
244
+
245
+ def test_an_unfamiliar_reply_is_not_called_a_refusal(self):
246
+ printer, logs = self._printer()
247
+
248
+ with pytest.raises(AnycubicMQTTUnknownUpdate):
249
+ printer.process_mqtt_update(
250
+ ACE_TOPIC,
251
+ message("multiColorBox", "setDry", "doing", {}, code=200),
252
+ )
253
+
254
+ assert logs == []
255
+
256
+
257
+ class TestLanSpeedModes:
258
+ def test_a_lan_job_offers_the_standard_presets(self):
259
+ project = AnycubicProject.from_lan_project(MagicMock(), 1, lan_job())
260
+
261
+ assert project is not None
262
+ assert [m.mode for m in project.available_print_speed_modes] == [1, 2, 3]
263
+ project.validate_print_speed_mode(3)
264
+
265
+ def test_a_list_the_printer_sends_is_used_instead(self):
266
+ project = AnycubicProject.from_lan_project(
267
+ MagicMock(),
268
+ 1,
269
+ lan_job(print_speed_model_des=[{"title": "Ludicrous", "print_speed_mode": 4}]),
270
+ )
271
+
272
+ assert project is not None
273
+ assert [m.title for m in project.available_print_speed_modes] == ["Ludicrous"]