anycubic-cloud-api 0.4.32__tar.gz → 0.4.34__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 (62) hide show
  1. {anycubic_cloud_api-0.4.32/src/anycubic_cloud_api.egg-info → anycubic_cloud_api-0.4.34}/PKG-INFO +1 -1
  2. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/pyproject.toml +1 -1
  3. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/__init__.py +2 -0
  4. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/api/base.py +23 -2
  5. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/api/mqtt.py +5 -1
  6. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/const/const.py +6 -0
  7. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/data_models/printer.py +135 -0
  8. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/data_models/project.py +16 -0
  9. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/exceptions/error_strings.py +3 -0
  10. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/exceptions/exceptions.py +4 -0
  11. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34/src/anycubic_cloud_api.egg-info}/PKG-INFO +1 -1
  12. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api.egg-info/SOURCES.txt +2 -0
  13. anycubic_cloud_api-0.4.34/tests/test_exchange_rate_limit.py +94 -0
  14. anycubic_cloud_api-0.4.34/tests/test_print_reports_0434.py +268 -0
  15. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/COPYRIGHT +0 -0
  16. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/LICENSE +0 -0
  17. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/MANIFEST.in +0 -0
  18. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/README.md +0 -0
  19. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/setup.cfg +0 -0
  20. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/anycubic_api.py +0 -0
  21. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/api/functions.py +0 -0
  22. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/const/api_endpoints.py +0 -0
  23. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/const/enums.py +0 -0
  24. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/const/error_codes.py +0 -0
  25. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/const/lan.py +0 -0
  26. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/const/mqtt.py +0 -0
  27. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/const/regions.py +0 -0
  28. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/data_models/consumable.py +0 -0
  29. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/data_models/files.py +0 -0
  30. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/data_models/gcode_file.py +0 -0
  31. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/data_models/orders.py +0 -0
  32. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/data_models/print_response.py +0 -0
  33. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/data_models/print_speed_mode.py +0 -0
  34. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/data_models/printer_properties.py +0 -0
  35. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/data_models/printing_settings.py +0 -0
  36. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/helpers/helpers.py +0 -0
  37. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/lan/__init__.py +0 -0
  38. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/lan/client.py +0 -0
  39. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/lan/commands.py +0 -0
  40. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/lan/handshake.py +0 -0
  41. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/models/auth.py +0 -0
  42. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/models/cloud_upload.py +0 -0
  43. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/models/http.py +0 -0
  44. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/resources/anycubic_mqqt_tls_ca.crt +0 -0
  45. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/resources/anycubic_mqqt_tls_client.crt +0 -0
  46. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/resources/anycubic_mqqt_tls_client.key +0 -0
  47. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api.egg-info/dependency_links.txt +0 -0
  48. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api.egg-info/requires.txt +0 -0
  49. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api.egg-info/top_level.txt +0 -0
  50. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/tests/test_auth_mode_fallback.py +0 -0
  51. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/tests/test_axis_position.py +0 -0
  52. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/tests/test_endpoint_urls.py +0 -0
  53. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/tests/test_lan_client.py +0 -0
  54. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/tests/test_lan_commands.py +0 -0
  55. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/tests/test_lan_handshake.py +0 -0
  56. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/tests/test_lan_reports.py +0 -0
  57. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/tests/test_null_tolerant_parsing.py +0 -0
  58. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/tests/test_partial_reports.py +0 -0
  59. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/tests/test_printer_error_codes.py +0 -0
  60. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/tests/test_project_null_status.py +0 -0
  61. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/tests/test_regions.py +0 -0
  62. {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/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.32
3
+ Version: 0.4.34
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.32"
7
+ version = "0.4.34"
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"
@@ -19,6 +19,7 @@ from .data_models.project import AnycubicProject
19
19
  from .exceptions.exceptions import (
20
20
  AnycubicAPIError,
21
21
  AnycubicAPIParsingError,
22
+ AnycubicRateLimitError,
22
23
  AnycubicAuthError,
23
24
  AnycubicAuthTokensExpired,
24
25
  AnycubicCloudUploadError,
@@ -40,6 +41,7 @@ __all__ = [
40
41
  "AnycubicAPI",
41
42
  "AnycubicAPIError",
42
43
  "AnycubicAPIParsingError",
44
+ "AnycubicRateLimitError",
43
45
  "AnycubicAuthError",
44
46
  "AnycubicAuthMode",
45
47
  "AnycubicAuthTokensExpired",
@@ -13,6 +13,9 @@ from ..const.api_endpoints import API_ENDPOINT
13
13
  from ..const.const import (
14
14
  ACCESS_TOKEN_LOGIN_RETRIES,
15
15
  ACCESS_TOKEN_LOGIN_RETRY_INTERVAL,
16
+ ACCESS_TOKEN_RATE_LIMIT_MARKERS,
17
+ ACCESS_TOKEN_RATE_LIMIT_RETRIES,
18
+ ACCESS_TOKEN_RATE_LIMIT_WAIT,
16
19
  DEFAULT_USER_AGENT,
17
20
  MAX_API_FETCH_TIME_WARN,
18
21
  WARN_INTERVAL_API_DURATION,
@@ -32,6 +35,7 @@ from ..exceptions.exceptions import (
32
35
  AnycubicAPIParsingError,
33
36
  AnycubicAuthError,
34
37
  AnycubicAuthTokensExpired,
38
+ AnycubicRateLimitError,
35
39
  )
36
40
  from ..models.auth import AnycubicAuthentication, AnycubicAuthMode
37
41
  from ..models.http import HTTP_METHODS, AnycubicAPIEndpoint
@@ -346,12 +350,23 @@ class AnycubicAPIBase:
346
350
 
347
351
  async def _get_user_token_with_access_token_with_retry(self) -> None:
348
352
  retries = ACCESS_TOKEN_LOGIN_RETRIES
349
- for x in range(retries):
353
+ rate_limit_waits = ACCESS_TOKEN_RATE_LIMIT_RETRIES
354
+ x = 0
355
+ while True:
350
356
  try:
351
357
  await self._get_user_token_with_access_token()
352
358
  return
359
+ except AnycubicRateLimitError:
360
+ # Not a refusal: wait out the cooldown. If it persists the
361
+ # error propagates as transient, never as an auth failure, so
362
+ # it can neither trigger the web fallback nor a re-auth.
363
+ if rate_limit_waits <= 0:
364
+ raise
365
+ rate_limit_waits -= 1
366
+ await asyncio.sleep(ACCESS_TOKEN_RATE_LIMIT_WAIT)
353
367
  except AnycubicAuthError:
354
- if x < retries - 1:
368
+ x += 1
369
+ if x < retries:
355
370
  await asyncio.sleep(ACCESS_TOKEN_LOGIN_RETRY_INTERVAL)
356
371
  else:
357
372
  raise
@@ -366,6 +381,12 @@ class AnycubicAPIBase:
366
381
  )
367
382
  if not resp or not resp['data']:
368
383
  server_message = resp.get('msg') if resp else None
384
+ if server_message and any(
385
+ marker in str(server_message) for marker in ACCESS_TOKEN_RATE_LIMIT_MARKERS
386
+ ):
387
+ error_message = ErrorsAuth.access_token_rate_limited.format(server_message)
388
+ self._log_to_debug(error_message)
389
+ raise AnycubicRateLimitError(error_message)
369
390
  error_message = ErrorsAuth.access_token_login_failed.format(server_message)
370
391
  self._log_to_debug(error_message)
371
392
  raise AnycubicAuthError(error_message)
@@ -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}, "
@@ -41,6 +41,12 @@ WARN_INTERVAL_API_DURATION = 60 * 10
41
41
 
42
42
  ACCESS_TOKEN_LOGIN_RETRIES = 2
43
43
  ACCESS_TOKEN_LOGIN_RETRY_INTERVAL = 2
44
+ # The token exchange is rate-limited: a second exchange within ~3 s of a
45
+ # successful one is refused with this message (measured 2026-09-28). It is not
46
+ # a login failure, so it is retried after a cooldown and never treated as one.
47
+ ACCESS_TOKEN_RATE_LIMIT_MARKERS = ("请求过于频繁", "too frequent")
48
+ ACCESS_TOKEN_RATE_LIMIT_RETRIES = 3
49
+ ACCESS_TOKEN_RATE_LIMIT_WAIT = 6
44
50
 
45
51
 
46
52
  class AnycubicServerMessage:
@@ -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,32 @@ 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
+ # How a printer words a refused order when it gives no code.
68
+ _REFUSED_STATES = frozenset({'failed', 'fail', 'error', 'refused'})
69
+
70
+ # What a print report says about the file being printed, which the job already
71
+ # knows by name. Read so the report is not flagged as unhandled data on every
72
+ # progress tick (#34, #42) -- nothing here changes what is shown.
73
+ _PRINT_FILE_KEYS = (
74
+ 'file_root_path',
75
+ 'filetype',
76
+ 'display_filename',
77
+ 'origin3mf',
78
+ 'source_info',
79
+ 'temp_gcode',
80
+ )
81
+
55
82
 
56
83
  def _drain(consumable: Any) -> dict[str, Any]:
57
84
  """Read every remaining key, returning them as a plain dict.
@@ -1283,6 +1310,19 @@ class AnycubicPrinter:
1283
1310
  settings['target_hotbed_temp'],
1284
1311
  settings['target_nozzle_temp'],
1285
1312
  )
1313
+ # The light is reported by its own message; here it only repeats.
1314
+ settings.get('light')
1315
+ # Read again now it is empty, which is what marks a nested block
1316
+ # consumed; left in place it was reported as unhandled data.
1317
+ data.get('settings')
1318
+
1319
+ if 'channel_no' in data:
1320
+ self._apply_active_channel(data['channel_no'])
1321
+
1322
+ # A Kobra X reports progress this way between info reports, with
1323
+ # no task id. It was logged as unhandled and dropped (#34).
1324
+ if any(key in data for key in _PRINT_PROGRESS_KEYS):
1325
+ self._apply_print_progress(project_id, data)
1286
1326
  return
1287
1327
  elif action in ['start', 'stop'] and state in ['failed']:
1288
1328
  err_msg = payload.get('msg')
@@ -1296,6 +1336,95 @@ class AnycubicPrinter:
1296
1336
  else:
1297
1337
  raise AnycubicMQTTUnknownUpdate(ErrorsMQTTUpdate.job_status)
1298
1338
 
1339
+ def _apply_active_channel(self, channel_no: Any) -> None:
1340
+ """Follow the filament channel the printer says it is printing from.
1341
+
1342
+ A Kobra X reports each filament change as a print update carrying
1343
+ only ``channel_no``, and stops sending the ACE's own ``loaded_slot``
1344
+ after the first few changes of a print -- which is why the loaded
1345
+ slot froze for the rest of the job (#34). The channel is numbered
1346
+ from 1 across every box in order, so channel 5 on a printer whose
1347
+ first box has four slots is the first slot of the second box.
1348
+ """
1349
+ channel = as_int(channel_no, 0)
1350
+
1351
+ if channel < 1 or not self._multi_color_box:
1352
+ return
1353
+
1354
+ index = channel - 1
1355
+
1356
+ for box in self._multi_color_box:
1357
+ slot_count = len(box.slots)
1358
+
1359
+ if index < slot_count:
1360
+ box.set_slot_loaded(index)
1361
+ return
1362
+
1363
+ index -= slot_count
1364
+
1365
+ def _apply_print_progress(
1366
+ self,
1367
+ project_id: int,
1368
+ data: AnycubicConsumableData,
1369
+ ) -> None:
1370
+ """Apply a progress-only print update to the job it is about.
1371
+
1372
+ Sent without a task id, it can only be about the job in hand. With
1373
+ no job to apply it to it is dropped, as the cloud path always has.
1374
+ """
1375
+ if not self._check_latest_project_id_valid(project_id):
1376
+ data.force_empty()
1377
+ return
1378
+
1379
+ assert self._latest_project
1380
+ self._latest_project.update_with_mqtt_print_status_data(None, data)
1381
+
1382
+ def _report_refused_drying(
1383
+ self,
1384
+ state: str | None,
1385
+ payload: AnycubicConsumableData,
1386
+ ) -> bool:
1387
+ """Say so when the printer will not start a dry cycle.
1388
+
1389
+ A start the ACE refuses -- 65 C on an ACE Pro that tops out at 55 is
1390
+ code 11505 -- came back as a setDry reply that was not a success. It
1391
+ was thrown away as "not understood", so the button press looked like
1392
+ it had worked and nothing in Home Assistant said otherwise (#42).
1393
+
1394
+ The code is kept as the printer's latest error, the same as any other
1395
+ fault, so the last-error sensors show it too. A reply that is neither
1396
+ a fault code nor a failed state is left to be reported as unknown.
1397
+ """
1398
+ data = payload.remaining_data.get('data')
1399
+ code = payload.remaining_data.get('code')
1400
+
1401
+ if isinstance(data, AnycubicConsumableData) and 'code' in data:
1402
+ code = data.remaining_data.get('code')
1403
+
1404
+ if not isinstance(code, int) or isinstance(code, bool) or code in PRINTER_OK_CODES:
1405
+ code = None
1406
+
1407
+ if code is None and str(state or '').lower() not in _REFUSED_STATES:
1408
+ return False
1409
+
1410
+ message = payload.get('msg')
1411
+
1412
+ if code is not None:
1413
+ self._record_error_code(code, message)
1414
+ reason = f"code {code}: {describe_printer_code(code)}"
1415
+ else:
1416
+ reason = str(message or state or 'no reason given')
1417
+
1418
+ self._api_parent._log_to_warn(
1419
+ f"The printer refused to start drying on the ACE ({reason})."
1420
+ )
1421
+
1422
+ payload.get('data')
1423
+ if isinstance(data, AnycubicConsumableData):
1424
+ data.force_empty()
1425
+
1426
+ return True
1427
+
1299
1428
  def _process_mqtt_update_multicolorbox(
1300
1429
  self,
1301
1430
  action: str,
@@ -1340,6 +1469,8 @@ class AnycubicPrinter:
1340
1469
  self._multi_color_box[box_id].set_current_temperature(box['temp'])
1341
1470
  self._multi_color_box[box_id].set_drying_status(box['drying_status'])
1342
1471
  return
1472
+ elif action == 'setDry' and self._report_refused_drying(state, payload):
1473
+ return
1343
1474
  elif action == 'feedFilament' and state == 'done':
1344
1475
  data = payload['data']['multi_color_box']
1345
1476
  for box in data:
@@ -1734,6 +1865,10 @@ class AnycubicPrinter:
1734
1865
  remaining_data.get('taskid')
1735
1866
  remaining_data.get('localtask')
1736
1867
 
1868
+ if msg_type == 'print':
1869
+ for key in _PRINT_FILE_KEYS:
1870
+ remaining_data.get(key)
1871
+
1737
1872
  payload.get('data')
1738
1873
  payload.get('msg')
1739
1874
  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
@@ -194,6 +194,9 @@ class ErrorsAuth:
194
194
  access_token_login_failed = str(
195
195
  "Failed to login with access_token, server message: {0}"
196
196
  )
197
+ access_token_rate_limited = str(
198
+ "Anycubic rate-limited the access_token login, server message: {0}"
199
+ )
197
200
 
198
201
 
199
202
  class ErrorsAuthTokenExpired:
@@ -15,6 +15,10 @@ class AnycubicAPIParsingError(AnycubicAPIError):
15
15
  pass
16
16
 
17
17
 
18
+ class AnycubicRateLimitError(AnycubicAPIParsingError):
19
+ """The cloud asked us to slow down. Transient, never an auth failure."""
20
+
21
+
18
22
  class AnycubicFileNotFoundError(AnycubicAPIError):
19
23
  pass
20
24
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: anycubic-cloud-api
3
- Version: 0.4.32
3
+ Version: 0.4.34
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
@@ -46,12 +46,14 @@ src/anycubic_cloud_api/resources/anycubic_mqqt_tls_client.key
46
46
  tests/test_auth_mode_fallback.py
47
47
  tests/test_axis_position.py
48
48
  tests/test_endpoint_urls.py
49
+ tests/test_exchange_rate_limit.py
49
50
  tests/test_lan_client.py
50
51
  tests/test_lan_commands.py
51
52
  tests/test_lan_handshake.py
52
53
  tests/test_lan_reports.py
53
54
  tests/test_null_tolerant_parsing.py
54
55
  tests/test_partial_reports.py
56
+ tests/test_print_reports_0434.py
55
57
  tests/test_printer_error_codes.py
56
58
  tests/test_project_null_status.py
57
59
  tests/test_regions.py
@@ -0,0 +1,94 @@
1
+ """The access-token exchange is rate-limited, and that is not a login failure.
2
+
3
+ Measured 2026-09-28: a second exchange of the same slicer token within about
4
+ 3 s of a successful one is answered `code 0`, `msg` "请求过于频繁。请稍后再试"
5
+ ("requests too frequent, try again later") and no data; after 5 s or more it
6
+ succeeds. The retry loop tried twice, 2 s apart -- both inside the cooldown --
7
+ then treated it as a refused token: the web fallback ran, userInfo refused the
8
+ access token as a web token, and the integration asked the user to
9
+ re-authenticate with a perfectly good token.
10
+ """
11
+
12
+ from unittest.mock import AsyncMock, MagicMock, patch
13
+
14
+ import pytest
15
+
16
+ from anycubic_cloud_api import AnycubicAPI
17
+ from anycubic_cloud_api.exceptions.exceptions import (
18
+ AnycubicAPIParsingError,
19
+ AnycubicAuthError,
20
+ AnycubicRateLimitError,
21
+ )
22
+ from anycubic_cloud_api.models.auth import AnycubicAuthMode
23
+
24
+ RATE_LIMITED = {"code": 0, "msg": "请求过于频繁。请稍后再试", "data": None}
25
+ REFUSED = {"code": 0, "msg": "User does not exist", "data": None}
26
+ SUCCESS = {"code": 1, "msg": "Login successful", "data": {"token": "user-token"}}
27
+
28
+
29
+ def slicer_api():
30
+ api = AnycubicAPI(session=MagicMock(), cookie_jar=MagicMock())
31
+ api.set_authentication(auth_token="eyJ.access.token", auth_mode=AnycubicAuthMode.SLICER)
32
+ return api
33
+
34
+
35
+ async def run_exchange(api, answers):
36
+ fetch = AsyncMock(side_effect=answers)
37
+ sleep = AsyncMock()
38
+ with patch.object(api, "_fetch_api_resp", fetch), patch("asyncio.sleep", sleep):
39
+ await api._get_user_token_with_access_token_with_retry()
40
+ return fetch, sleep
41
+
42
+
43
+ async def test_a_rate_limit_waits_out_the_cooldown_and_succeeds():
44
+ api = slicer_api()
45
+
46
+ fetch, sleep = await run_exchange(api, [RATE_LIMITED, RATE_LIMITED, SUCCESS])
47
+
48
+ assert fetch.await_count == 3
49
+ assert api.anycubic_auth._auth_token == "user-token"
50
+ # each wait covers the ~3 s cooldown, unlike the 2 s refusal retry
51
+ assert all(call.args[0] >= 5 for call in sleep.await_args_list)
52
+
53
+
54
+ async def test_a_persistent_rate_limit_is_transient_not_an_auth_failure():
55
+ api = slicer_api()
56
+
57
+ with pytest.raises(AnycubicRateLimitError) as err:
58
+ await run_exchange(api, [RATE_LIMITED] * 10)
59
+
60
+ # The integration treats parsing errors as "try later"; it must never
61
+ # see this as an auth failure, which is what triggers re-authentication.
62
+ assert isinstance(err.value, AnycubicAPIParsingError)
63
+ assert not isinstance(err.value, AnycubicAuthError)
64
+
65
+
66
+ async def test_a_rate_limit_does_not_use_up_the_refusal_retries():
67
+ api = slicer_api()
68
+
69
+ fetch, _ = await run_exchange(api, [RATE_LIMITED, REFUSED, SUCCESS])
70
+
71
+ assert fetch.await_count == 3
72
+ assert api.anycubic_auth._auth_token == "user-token"
73
+
74
+
75
+ async def test_a_genuine_refusal_still_fails_after_two_attempts():
76
+ api = slicer_api()
77
+
78
+ with pytest.raises(AnycubicAuthError):
79
+ await run_exchange(api, [REFUSED, REFUSED])
80
+
81
+
82
+ async def test_the_web_fallback_is_not_triggered_by_a_rate_limit():
83
+ """_check_can_access_api retries as a web token only on AnycubicAuthError."""
84
+ api = slicer_api()
85
+ fetch = AsyncMock(side_effect=[RATE_LIMITED] * 10)
86
+ with (
87
+ patch.object(api, "_fetch_api_resp", fetch),
88
+ patch("asyncio.sleep", AsyncMock()),
89
+ pytest.raises(AnycubicRateLimitError),
90
+ ):
91
+ await api._check_can_access_api()
92
+
93
+ assert api.anycubic_auth._auth_mode == AnycubicAuthMode.SLICER
94
+ assert api.anycubic_auth._auth_access_token == "eyJ.access.token"
@@ -0,0 +1,268 @@
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
+ def test_channels_beyond_the_first_box_land_in_the_next(self):
94
+ """Channel 5 with four slots in the first box is the second box's first."""
95
+ printer = make_printer([box(0), box(1)])
96
+
97
+ self._channel(printer, 5)
98
+
99
+ assert printer.secondary_multi_color_box_loaded_slot == 0
100
+
101
+ @pytest.mark.parametrize("channel_no", [0, -1, 9, None, "x"])
102
+ def test_a_channel_with_no_slot_changes_nothing(self, channel_no):
103
+ printer = make_printer([box(0)])
104
+ printer._multi_color_box[0].set_slot_loaded(1)
105
+
106
+ self._channel(printer, channel_no)
107
+
108
+ assert printer.primary_multi_color_box_loaded_slot == 1
109
+
110
+ def test_a_printer_with_no_ace_ignores_it(self):
111
+ printer = make_printer(None)
112
+
113
+ self._channel(printer, 2)
114
+
115
+ assert printer.connected_ace_units == 0
116
+
117
+
118
+ class TestProgressUpdates:
119
+ PROGRESS = {
120
+ "curr_layer": 23,
121
+ "file_root_path": "cube.gcode.3mf",
122
+ "filename": "cube.gcode.3mf",
123
+ "filetype": 0,
124
+ "print_time": 7,
125
+ "progress": 10,
126
+ "remain_time": 66,
127
+ "settings": {"light": 0},
128
+ "supplies_usage": 1074,
129
+ "total_layers": 263,
130
+ }
131
+
132
+ def test_progress_without_a_task_id_reaches_the_job(self):
133
+ printer = make_printer([box(0)])
134
+ printer._apply_lan_project(AnycubicConsumableData(lan_job()))
135
+
136
+ printer.process_mqtt_update(
137
+ TOPIC, message("print", "update", "updated", dict(self.PROGRESS))
138
+ )
139
+
140
+ project = printer.latest_project
141
+ assert project is not None
142
+ assert project.progress_percentage == 10
143
+ assert project.print_time_remaining_minutes == 66
144
+
145
+ def test_progress_with_no_job_is_dropped_quietly(self):
146
+ printer = make_printer([box(0)])
147
+
148
+ printer.process_mqtt_update(
149
+ TOPIC, message("print", "update", "updated", dict(self.PROGRESS))
150
+ )
151
+
152
+ assert printer.latest_project is None
153
+
154
+ def test_progress_for_another_job_is_not_applied(self):
155
+ printer = make_printer([box(0)])
156
+ printer._apply_lan_project(AnycubicConsumableData(lan_job(task_id=42)))
157
+
158
+ printer.process_mqtt_update(
159
+ TOPIC,
160
+ message("print", "update", "updated", {"taskid": 7, **self.PROGRESS}),
161
+ )
162
+
163
+ assert printer.latest_project.progress_percentage == 0
164
+
165
+ def test_the_s1_file_fields_are_not_left_over(self):
166
+ """These four were reported as unhandled every 30-60 s (#42)."""
167
+ printer = make_printer([box(0)])
168
+ printer._apply_lan_project(AnycubicConsumableData(lan_job()))
169
+
170
+ printer.process_mqtt_update(
171
+ TOPIC,
172
+ message(
173
+ "print", "update", "updated",
174
+ {
175
+ "display_filename": "cube",
176
+ "origin3mf": "cube.3mf",
177
+ "source_info": "slicer",
178
+ "temp_gcode": "/tmp/x",
179
+ "progress": 50,
180
+ },
181
+ ),
182
+ )
183
+
184
+ assert printer.latest_project.progress_percentage == 50
185
+
186
+ def test_a_print_start_with_file_fields_is_handled(self):
187
+ printer = make_printer([box(0)])
188
+ printer._apply_lan_project(AnycubicConsumableData(lan_job()))
189
+
190
+ printer.process_mqtt_update(
191
+ TOPIC,
192
+ message(
193
+ "print", "start", "printing",
194
+ {"file_root_path": "cube.gcode.3mf", "filetype": 0},
195
+ ),
196
+ )
197
+
198
+
199
+ class TestRefusedDrying:
200
+ def _printer(self):
201
+ parent = MagicMock()
202
+ logs: list[str] = []
203
+ parent._log_to_warn = logs.append
204
+ return make_printer([box(0), box(1)], parent), logs
205
+
206
+ def test_a_refusal_code_is_reported_and_kept(self):
207
+ printer, logs = self._printer()
208
+
209
+ printer.process_mqtt_update(
210
+ ACE_TOPIC,
211
+ message("multiColorBox", "setDry", "failed", {}, code=11505, msg="fail"),
212
+ )
213
+
214
+ assert len(logs) == 1
215
+ assert "11505" in logs[0]
216
+ assert "target temperature" in logs[0]
217
+ assert printer.latest_error_code == 11505
218
+
219
+ def test_a_code_inside_the_data_counts_too(self):
220
+ printer, logs = self._printer()
221
+
222
+ printer.process_mqtt_update(
223
+ ACE_TOPIC,
224
+ message("multiColorBox", "setDry", "done", {"code": 11505}, code=200),
225
+ )
226
+
227
+ assert "11505" in logs[0]
228
+ assert printer.latest_error_code == 11505
229
+
230
+ def test_a_failed_state_without_a_code_is_still_reported(self):
231
+ printer, logs = self._printer()
232
+
233
+ printer.process_mqtt_update(
234
+ ACE_TOPIC,
235
+ message("multiColorBox", "setDry", "failed", None, code=200, msg="busy"),
236
+ )
237
+
238
+ assert logs == ["The printer refused to start drying on the ACE (busy)."]
239
+
240
+ def test_an_unfamiliar_reply_is_not_called_a_refusal(self):
241
+ printer, logs = self._printer()
242
+
243
+ with pytest.raises(AnycubicMQTTUnknownUpdate):
244
+ printer.process_mqtt_update(
245
+ ACE_TOPIC,
246
+ message("multiColorBox", "setDry", "doing", {}, code=200),
247
+ )
248
+
249
+ assert logs == []
250
+
251
+
252
+ class TestLanSpeedModes:
253
+ def test_a_lan_job_offers_the_standard_presets(self):
254
+ project = AnycubicProject.from_lan_project(MagicMock(), 1, lan_job())
255
+
256
+ assert project is not None
257
+ assert [m.mode for m in project.available_print_speed_modes] == [1, 2, 3]
258
+ project.validate_print_speed_mode(3)
259
+
260
+ def test_a_list_the_printer_sends_is_used_instead(self):
261
+ project = AnycubicProject.from_lan_project(
262
+ MagicMock(),
263
+ 1,
264
+ lan_job(print_speed_model_des=[{"title": "Ludicrous", "print_speed_mode": 4}]),
265
+ )
266
+
267
+ assert project is not None
268
+ assert [m.title for m in project.available_print_speed_modes] == ["Ludicrous"]