anycubic-cloud-api 0.4.34__tar.gz → 0.4.36__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 (64) hide show
  1. {anycubic_cloud_api-0.4.34/src/anycubic_cloud_api.egg-info → anycubic_cloud_api-0.4.36}/PKG-INFO +1 -1
  2. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/pyproject.toml +1 -1
  3. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api/api/mqtt.py +6 -5
  4. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api/data_models/printer.py +81 -18
  5. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api/data_models/printer_properties.py +18 -0
  6. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api/helpers/helpers.py +28 -0
  7. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36/src/anycubic_cloud_api.egg-info}/PKG-INFO +1 -1
  8. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api.egg-info/SOURCES.txt +2 -0
  9. anycubic_cloud_api-0.4.36/tests/test_filament_change_0435.py +133 -0
  10. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/tests/test_print_reports_0434.py +9 -4
  11. anycubic_cloud_api-0.4.36/tests/test_printer_slot_0436.py +149 -0
  12. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/COPYRIGHT +0 -0
  13. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/LICENSE +0 -0
  14. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/MANIFEST.in +0 -0
  15. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/README.md +0 -0
  16. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/setup.cfg +0 -0
  17. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api/__init__.py +0 -0
  18. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api/anycubic_api.py +0 -0
  19. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api/api/base.py +0 -0
  20. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api/api/functions.py +0 -0
  21. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api/const/api_endpoints.py +0 -0
  22. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api/const/const.py +0 -0
  23. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api/const/enums.py +0 -0
  24. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api/const/error_codes.py +0 -0
  25. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api/const/lan.py +0 -0
  26. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api/const/mqtt.py +0 -0
  27. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api/const/regions.py +0 -0
  28. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api/data_models/consumable.py +0 -0
  29. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api/data_models/files.py +0 -0
  30. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api/data_models/gcode_file.py +0 -0
  31. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api/data_models/orders.py +0 -0
  32. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api/data_models/print_response.py +0 -0
  33. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api/data_models/print_speed_mode.py +0 -0
  34. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api/data_models/printing_settings.py +0 -0
  35. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api/data_models/project.py +0 -0
  36. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api/exceptions/error_strings.py +0 -0
  37. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api/exceptions/exceptions.py +0 -0
  38. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api/lan/__init__.py +0 -0
  39. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api/lan/client.py +0 -0
  40. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api/lan/commands.py +0 -0
  41. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api/lan/handshake.py +0 -0
  42. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api/models/auth.py +0 -0
  43. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api/models/cloud_upload.py +0 -0
  44. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api/models/http.py +0 -0
  45. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api/resources/anycubic_mqqt_tls_ca.crt +0 -0
  46. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api/resources/anycubic_mqqt_tls_client.crt +0 -0
  47. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api/resources/anycubic_mqqt_tls_client.key +0 -0
  48. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api.egg-info/dependency_links.txt +0 -0
  49. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api.egg-info/requires.txt +0 -0
  50. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/src/anycubic_cloud_api.egg-info/top_level.txt +0 -0
  51. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/tests/test_auth_mode_fallback.py +0 -0
  52. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/tests/test_axis_position.py +0 -0
  53. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/tests/test_endpoint_urls.py +0 -0
  54. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/tests/test_exchange_rate_limit.py +0 -0
  55. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/tests/test_lan_client.py +0 -0
  56. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/tests/test_lan_commands.py +0 -0
  57. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/tests/test_lan_handshake.py +0 -0
  58. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/tests/test_lan_reports.py +0 -0
  59. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/tests/test_null_tolerant_parsing.py +0 -0
  60. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/tests/test_partial_reports.py +0 -0
  61. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/tests/test_printer_error_codes.py +0 -0
  62. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/tests/test_project_null_status.py +0 -0
  63. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/tests/test_regions.py +0 -0
  64. {anycubic_cloud_api-0.4.34 → anycubic_cloud_api-0.4.36}/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.34
3
+ Version: 0.4.36
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.34"
7
+ version = "0.4.36"
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"
@@ -42,6 +42,7 @@ from ..helpers.helpers import (
42
42
  get_mqtt_ssl_path_key,
43
43
  get_part_from_mqtt_topic,
44
44
  get_ssl_cert_directory,
45
+ redact_for_log,
45
46
  redact_part_from_mqtt_topic,
46
47
  )
47
48
  from .functions import AnycubicAPIFunctions
@@ -151,7 +152,7 @@ class AnycubicMQTTAPI(AnycubicAPIFunctions):
151
152
  if self._mqtt_topic_is_user_topic(topic):
152
153
  self._log_to_debug(
153
154
  f"Anycubic MQTT USER Msg Received on `{topic}`:\n"
154
- f" {payload}"
155
+ f" {redact_for_log(payload)}"
155
156
  )
156
157
 
157
158
  else:
@@ -182,7 +183,7 @@ class AnycubicMQTTAPI(AnycubicAPIFunctions):
182
183
  f" with type: {e.unhandled_mqtt_type}, "
183
184
  f"action: {e.unhandled_mqtt_action}, "
184
185
  f"state: {e.unhandled_mqtt_state}\n"
185
- f" unhandled data: {e.unhandled_mqtt_data}"
186
+ f" unhandled data: {redact_for_log(e.unhandled_mqtt_data)}"
186
187
  )
187
188
 
188
189
  except AnycubicMQTTUnknownUpdate as e:
@@ -193,7 +194,7 @@ class AnycubicMQTTAPI(AnycubicAPIFunctions):
193
194
  self._log_to_debug(
194
195
  f"Anycubic MQTT Message not understood: {e}\n"
195
196
  f" on MQTT topic: {redacted_topic}\n"
196
- f" {payload}"
197
+ f" {redact_for_log(payload)}"
197
198
  )
198
199
 
199
200
  except Exception as e:
@@ -203,7 +204,7 @@ class AnycubicMQTTAPI(AnycubicAPIFunctions):
203
204
  self._log_to_error(
204
205
  f"Anycubic MQTT Message error: {error_type}: {e}\n"
205
206
  f" on MQTT topic: {redacted_topic}\n"
206
- f" {payload}\n"
207
+ f" {redact_for_log(payload)}\n"
207
208
  f"{tb}"
208
209
  )
209
210
 
@@ -230,7 +231,7 @@ class AnycubicMQTTAPI(AnycubicAPIFunctions):
230
231
  self._log_to_debug(
231
232
  f"Anycubic MQTT Message processed on topic: "
232
233
  f"{redact_part_from_mqtt_topic(topic, 6)}\n"
233
- f" {payload}"
234
+ f" {redact_for_log(payload)}"
234
235
  )
235
236
 
236
237
  def _mqtt_publish_on_topic(
@@ -64,6 +64,20 @@ _PRINT_PROGRESS_KEYS = (
64
64
  'supplies_usage',
65
65
  )
66
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
+
67
81
  # How a printer words a refused order when it gives no code.
68
82
  _REFUSED_STATES = frozenset({'failed', 'fail', 'error', 'refused'})
69
83
 
@@ -438,6 +452,12 @@ class AnycubicPrinter:
438
452
  updated.get(box.box_id, box) for box in known_boxes
439
453
  ])
440
454
 
455
+ if known_boxes:
456
+ previous = {box.box_id: box for box in known_boxes}
457
+ for box in self._multi_color_box:
458
+ if (known := previous.get(box.box_id)) is not None and known is not box:
459
+ box.keep_printer_slot_from(known)
460
+
441
461
  except Exception as e:
442
462
  self._initialisation_error = True
443
463
  # Swallowing this leaves the ACE list empty, which presents as
@@ -1317,13 +1337,23 @@ class AnycubicPrinter:
1317
1337
  data.get('settings')
1318
1338
 
1319
1339
  if 'channel_no' in data:
1320
- self._apply_active_channel(data['channel_no'])
1340
+ self._apply_active_channel(data['channel_no'], data.get('unit_no'))
1321
1341
 
1322
1342
  # A Kobra X reports progress this way between info reports, with
1323
1343
  # no task id. It was logged as unhandled and dropped (#34).
1324
1344
  if any(key in data for key in _PRINT_PROGRESS_KEYS):
1325
1345
  self._apply_print_progress(project_id, data)
1326
1346
  return
1347
+ elif action == 'start' and state in _FILAMENT_CHANGE_STATES:
1348
+ # Each step of a filament change, naming the channel (and, for an
1349
+ # ACE, the unit). Only a feed says what is now loaded (#33).
1350
+ data = payload.get('data') or {}
1351
+ # Read on every step, so the ones that change nothing are not
1352
+ # reported as unhandled.
1353
+ channel_no, unit_no = data.get('channel_no'), data.get('unit_no')
1354
+ if state.endswith('_filament_feeding'):
1355
+ self._apply_active_channel(channel_no, unit_no)
1356
+ return
1327
1357
  elif action in ['start', 'stop'] and state in ['failed']:
1328
1358
  err_msg = payload.get('msg')
1329
1359
  self._is_printing = 1
@@ -1336,31 +1366,37 @@ class AnycubicPrinter:
1336
1366
  else:
1337
1367
  raise AnycubicMQTTUnknownUpdate(ErrorsMQTTUpdate.job_status)
1338
1368
 
1339
- def _apply_active_channel(self, channel_no: Any) -> None:
1369
+ def _apply_active_channel(self, channel_no: Any, unit_no: Any = None) -> None:
1340
1370
  """Follow the filament channel the printer says it is printing from.
1341
1371
 
1342
1372
  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.
1373
+ ``channel_no``, and stops sending the box's own ``loaded_slot`` after
1374
+ the first few changes of a print, which is why the loaded slot froze
1375
+ (#34). Each box numbers its channels from 1; an ACE adds ``unit_no``
1376
+ and the printer's own feeder sends none (#33, Kobra X + ACE 2 Pro).
1377
+
1378
+ With one box the channel can only mean that box. With several, an
1379
+ ACE's ``unit_no`` picks it on a printer whose boxes are all ACEs.
1380
+ On a Kobra X with an ACE, which list entry is the printer's own
1381
+ feeder is not yet confirmed, so nothing is guessed there: a wrong
1382
+ slot is worse than a stale one.
1348
1383
  """
1349
1384
  channel = as_int(channel_no, 0)
1385
+ boxes = self._multi_color_box
1350
1386
 
1351
- if channel < 1 or not self._multi_color_box:
1387
+ if channel < 1 or not boxes:
1352
1388
  return
1353
1389
 
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)
1390
+ if len(boxes) == 1:
1391
+ box = boxes[0]
1392
+ else:
1393
+ unit = as_int(unit_no, 0)
1394
+ if unit < 1 or unit > len(boxes) or self._machine_type in _PRINTERS_WITH_OWN_FEEDER:
1361
1395
  return
1396
+ box = boxes[unit - 1]
1362
1397
 
1363
- index -= slot_count
1398
+ if channel <= len(box.slots):
1399
+ box.set_slot_loaded(channel - 1)
1364
1400
 
1365
1401
  def _apply_print_progress(
1366
1402
  self,
@@ -1466,8 +1502,12 @@ class AnycubicPrinter:
1466
1502
  continue
1467
1503
 
1468
1504
  assert self._multi_color_box
1469
- self._multi_color_box[box_id].set_current_temperature(box['temp'])
1470
- self._multi_color_box[box_id].set_drying_status(box['drying_status'])
1505
+ # An ACE 2 Pro's periodic drying report carries no `temp`, and
1506
+ # reading it raised every six minutes, so drying never updated
1507
+ # (#33). Keep the last reading rather than inventing one.
1508
+ if 'temp' in box:
1509
+ self._multi_color_box[box_id].set_current_temperature(box['temp'])
1510
+ self._multi_color_box[box_id].set_drying_status(box.get('drying_status'))
1471
1511
  return
1472
1512
  elif action == 'setDry' and self._report_refused_drying(state, payload):
1473
1513
  return
@@ -1508,9 +1548,32 @@ class AnycubicPrinter:
1508
1548
  if self.external_shelves:
1509
1549
  self.external_shelves.update_with_mqtt_data(data)
1510
1550
  return
1551
+ elif action == 'auto' and state == 'done':
1552
+ # Sent as a filament change completes, naming the feeding slot as
1553
+ # "<box>-<slot>", both counted from 0 ("0-3" while the screen shows
1554
+ # slot 4 on a Kobra X, #34); empty while the filament is retracted.
1555
+ data = payload.get('data') or {}
1556
+ self._apply_current_filament(data.get('current_filament'))
1557
+ return
1511
1558
  else:
1512
1559
  raise AnycubicMQTTUnknownUpdate(ErrorsMQTTUpdate.shelves)
1513
1560
 
1561
+ def _apply_current_filament(self, current_filament: Any) -> None:
1562
+ """Follow the printer's own "<box>-<slot>" reading, where the box is certain.
1563
+
1564
+ Only with a single box: which list entry the printer's box number
1565
+ means beside an ACE is not confirmed (#33). An empty reading is a
1566
+ retract for calibration or wiping, not a change of slot, so the last
1567
+ slot is kept.
1568
+ """
1569
+ boxes = self._multi_color_box
1570
+ if not isinstance(current_filament, str) or not boxes or len(boxes) != 1:
1571
+ return
1572
+ box_no, _, slot_no = current_filament.partition('-')
1573
+ box_index, slot_index = as_int(box_no, -1), as_int(slot_no, -1)
1574
+ if box_index == 0 and 0 <= slot_index < len(boxes[0].slots):
1575
+ boxes[0].set_slot_loaded(slot_index)
1576
+
1514
1577
  def _process_mqtt_update_file(
1515
1578
  self,
1516
1579
  action: str,
@@ -702,6 +702,7 @@ class AnycubicMultiColorBox:
702
702
  "_model_id",
703
703
  "_auto_feed",
704
704
  "_loaded_slot",
705
+ "_slot_from_printer",
705
706
  "_feed_status",
706
707
  "_temp",
707
708
  "_humidity",
@@ -734,6 +735,9 @@ class AnycubicMultiColorBox:
734
735
  self._model_id: int = as_int(model_id)
735
736
  self._auto_feed: int = as_int(auto_feed)
736
737
  self._loaded_slot: int = as_int(loaded_slot, -1)
738
+ # Whether the printer itself has reported the loaded slot, as opposed
739
+ # to a cloud record that only knew it at some earlier point.
740
+ self._slot_from_printer = False
737
741
  self.set_feed_status(feed_status)
738
742
  self.set_current_temperature(temp)
739
743
  self._humidity: float | None = (
@@ -759,7 +763,21 @@ class AnycubicMultiColorBox:
759
763
  self._feed_status = AnycubicFeedStatus.from_json(feed_status)
760
764
 
761
765
  def set_slot_loaded(self, slot_num: int) -> None:
766
+ """A loaded slot reported by the printer (MQTT or LAN)."""
762
767
  self._loaded_slot = as_int(slot_num, -1)
768
+ self._slot_from_printer = True
769
+
770
+ def keep_printer_slot_from(self, previous: AnycubicMultiColorBox) -> None:
771
+ """Carry over a slot the printer reported, past a cloud refresh.
772
+
773
+ The cloud's record of the loaded slot goes stale during a print: a
774
+ Kobra X kept reporting slot 4 while each cloud poll said slot 2, and
775
+ the sensor flipped to 2 mid-print (#34). What the printer said last
776
+ wins until it says something else.
777
+ """
778
+ if previous._slot_from_printer:
779
+ self._loaded_slot = previous._loaded_slot
780
+ self._slot_from_printer = True
763
781
 
764
782
  def set_current_temperature(self, temp: int) -> None:
765
783
  # Reached from the constructor as well as from MQTT, so a null
@@ -164,6 +164,34 @@ def get_part_from_mqtt_topic(topic: str, part: int) -> str | None:
164
164
  return split_topic[part]
165
165
 
166
166
 
167
+ # Identifiers a printer puts in its own reports. A debug log gets pasted into
168
+ # public issues, and only the topic's printer key was being redacted: a
169
+ # Kobra X's PrintStart report carried its cn_code in the clear (#34).
170
+ _LOG_REDACT_KEYS = frozenset({
171
+ 'cn_code', 'cn', 'sn', 'serial', 'machine_sn', 'mac', 'machine_mac',
172
+ 'ip', 'key', 'printer_key', 'device_id', 'deviceId', 'user_id', 'email',
173
+ })
174
+ _LOG_REDACT_PARTS = ('token', 'secret', 'password')
175
+
176
+
177
+ def redact_for_log(data: Any) -> Any:
178
+ """A copy of an MQTT payload that is safe to put in a log."""
179
+ if isinstance(data, dict):
180
+ return {
181
+ key: (
182
+ "**REDACTED**"
183
+ if isinstance(key, str)
184
+ and data[key] not in (None, '')
185
+ and (key in _LOG_REDACT_KEYS or any(part in key.lower() for part in _LOG_REDACT_PARTS))
186
+ else redact_for_log(data[key])
187
+ )
188
+ for key in data
189
+ }
190
+ if isinstance(data, list):
191
+ return [redact_for_log(item) for item in data]
192
+ return data
193
+
194
+
167
195
  def redact_part_from_mqtt_topic(topic: str, part: int) -> str:
168
196
  split_topic = topic.split("/")
169
197
  new_chunk = list()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: anycubic-cloud-api
3
- Version: 0.4.34
3
+ Version: 0.4.36
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,6 +47,7 @@ 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
@@ -55,6 +56,7 @@ tests/test_null_tolerant_parsing.py
55
56
  tests/test_partial_reports.py
56
57
  tests/test_print_reports_0434.py
57
58
  tests/test_printer_error_codes.py
59
+ tests/test_printer_slot_0436.py
58
60
  tests/test_project_null_status.py
59
61
  tests/test_regions.py
60
62
  tests/test_secondary_ace.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
@@ -90,13 +90,18 @@ class TestActiveChannel:
90
90
 
91
91
  assert printer.primary_multi_color_box_loaded_slot == slot_index
92
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."""
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
+ """
95
99
  printer = make_printer([box(0), box(1)])
100
+ before = [b.loaded_slot for b in printer.multi_color_box]
96
101
 
97
- self._channel(printer, 5)
102
+ self._channel(printer, channel_no)
98
103
 
99
- assert printer.secondary_multi_color_box_loaded_slot == 0
104
+ assert [b.loaded_slot for b in printer.multi_color_box] == before
100
105
 
101
106
  @pytest.mark.parametrize("channel_no", [0, -1, 9, None, "x"])
102
107
  def test_a_channel_with_no_slot_changes_nothing(self, channel_no):
@@ -0,0 +1,149 @@
1
+ """The printer's own loaded slot, and logs safe to paste (0.4.36).
2
+
3
+ From a Kobra X printing from slot 4 (#34): the sensor flipped to 2 mid-print
4
+ with no message from the printer. Each cloud poll rebuilt the box from the
5
+ cloud's record, whose loaded slot was stale. The printer also names the
6
+ feeding slot itself in its extfilbox report, and its PrintStart report put
7
+ the printer's cn_code into the debug log.
8
+ """
9
+
10
+ import pytest
11
+ from test_filament_change_0435 import (
12
+ KOBRA_S1,
13
+ KOBRA_X,
14
+ box,
15
+ make_printer,
16
+ message,
17
+ topic,
18
+ )
19
+
20
+ from anycubic_cloud_api.helpers.helpers import redact_for_log
21
+
22
+
23
+ def cloud_info(boxes, machine_type=KOBRA_X):
24
+ return {
25
+ "id": 1, "name": "Printer", "key": "k", "machine_type": machine_type, "model": "Printer",
26
+ "img": "", "device_status": 1, "is_printing": 2, "type_function_ids": [], "version": None,
27
+ "multi_color_box": boxes,
28
+ }
29
+
30
+
31
+ def stale_box(box_id, loaded_slot):
32
+ b = box(box_id)
33
+ b["loaded_slot"] = loaded_slot
34
+ return b
35
+
36
+
37
+ def loaded(printer):
38
+ return [b.loaded_slot for b in printer.multi_color_box]
39
+
40
+
41
+ class TestCloudPollKeepsThePrintersSlot:
42
+ def test_a_slot_the_printer_reported_survives_a_cloud_poll(self):
43
+ printer = make_printer([box(0)])
44
+ printer.multi_color_box[0].set_slot_loaded(3)
45
+
46
+ printer.update_from_info_json(cloud_info([stale_box(0, 1)]))
47
+
48
+ assert loaded(printer) == [3]
49
+
50
+ def test_without_a_printer_report_the_cloud_value_is_used(self):
51
+ printer = make_printer([box(0)])
52
+
53
+ printer.update_from_info_json(cloud_info([stale_box(0, 1)]))
54
+
55
+ assert loaded(printer) == [1]
56
+
57
+ def test_the_next_printer_report_still_wins(self):
58
+ printer = make_printer([box(0)])
59
+ printer.multi_color_box[0].set_slot_loaded(3)
60
+ printer.update_from_info_json(cloud_info([stale_box(0, 1)]))
61
+
62
+ printer.multi_color_box[0].set_slot_loaded(0)
63
+
64
+ assert loaded(printer) == [0]
65
+
66
+ def test_each_box_keeps_its_own(self):
67
+ printer = make_printer([box(0), box(1)], machine_type=KOBRA_S1)
68
+ printer.multi_color_box[1].set_slot_loaded(2)
69
+
70
+ printer.update_from_info_json(cloud_info([stale_box(0, 1), stale_box(1, 0)], machine_type=KOBRA_S1))
71
+
72
+ assert loaded(printer) == [1, 2]
73
+
74
+ def test_other_cloud_fields_still_update(self):
75
+ printer = make_printer([box(0)])
76
+ printer.multi_color_box[0].set_slot_loaded(3)
77
+ refreshed = stale_box(0, 1)
78
+ refreshed["temp"] = 33
79
+
80
+ printer.update_from_info_json(cloud_info([refreshed]))
81
+
82
+ assert printer.multi_color_box[0].current_temperature == 33
83
+
84
+
85
+ def extfilbox(printer, current_filament, machine_type=KOBRA_X):
86
+ printer.process_mqtt_update(
87
+ topic(machine_type, "extfilbox"),
88
+ message("extfilbox", "auto", "done", {"current_filament": current_filament}),
89
+ )
90
+
91
+
92
+ class TestCurrentFilament:
93
+ @pytest.mark.parametrize(("reading", "slot"), [("0-3", 3), ("0-0", 0)])
94
+ def test_one_box_follows_the_printer(self, reading, slot):
95
+ printer = make_printer([box(0)])
96
+
97
+ extfilbox(printer, reading)
98
+
99
+ assert loaded(printer) == [slot]
100
+
101
+ @pytest.mark.parametrize("reading", ["", None, "0-9", "1-2", "x", "0-", "-1"])
102
+ def test_a_retract_or_unknown_reading_keeps_the_slot(self, reading):
103
+ printer = make_printer([box(0)])
104
+ printer.multi_color_box[0].set_slot_loaded(3)
105
+
106
+ extfilbox(printer, reading)
107
+
108
+ assert loaded(printer) == [3]
109
+
110
+ def test_with_two_boxes_nothing_is_guessed(self):
111
+ printer = make_printer([box(0), box(1)])
112
+
113
+ extfilbox(printer, "0-3")
114
+
115
+ assert loaded(printer) == [-1, -1]
116
+
117
+ def test_it_survives_the_next_cloud_poll(self):
118
+ printer = make_printer([box(0)])
119
+ extfilbox(printer, "0-3")
120
+
121
+ printer.update_from_info_json(cloud_info([stale_box(0, 1)]))
122
+
123
+ assert loaded(printer) == [3]
124
+
125
+
126
+ class TestRedactForLog:
127
+ def test_printer_identifiers_are_redacted_wherever_they_are(self):
128
+ payload = {"type": "buried", "data": {"cn_code": "CN123", "nested": [{"machine_mac": "AA:BB"}], "ip": "10.0.0.2"}}
129
+
130
+ assert redact_for_log(payload) == {
131
+ "type": "buried",
132
+ "data": {"cn_code": "**REDACTED**", "nested": [{"machine_mac": "**REDACTED**"}], "ip": "**REDACTED**"},
133
+ }
134
+
135
+ @pytest.mark.parametrize("key", ["token", "casdoor_user_token", "rtc_token", "app_secret", "password"])
136
+ def test_credentials_are_redacted(self, key):
137
+ assert redact_for_log({key: "secret-value"}) == {key: "**REDACTED**"}
138
+
139
+ def test_ordinary_fields_and_empty_values_pass_through(self):
140
+ payload = {"progress": 42, "taskid": "131448158", "cn_code": "", "state": "printing", "slots": [1, 2]}
141
+
142
+ assert redact_for_log(payload) == payload
143
+
144
+ def test_the_input_is_not_changed(self):
145
+ payload = {"data": {"cn_code": "CN123"}}
146
+
147
+ redact_for_log(payload)
148
+
149
+ assert payload == {"data": {"cn_code": "CN123"}}