anycubic-cloud-api 0.2.0__tar.gz → 0.3.0__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 (47) hide show
  1. {anycubic_cloud_api-0.2.0/src/anycubic_cloud_api.egg-info → anycubic_cloud_api-0.3.0}/PKG-INFO +1 -1
  2. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/pyproject.toml +1 -1
  3. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/src/anycubic_cloud_api/data_models/printer.py +160 -0
  4. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0/src/anycubic_cloud_api.egg-info}/PKG-INFO +1 -1
  5. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/src/anycubic_cloud_api.egg-info/SOURCES.txt +1 -0
  6. anycubic_cloud_api-0.3.0/tests/test_lan_reports.py +214 -0
  7. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/LICENSE +0 -0
  8. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/README.md +0 -0
  9. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/setup.cfg +0 -0
  10. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/src/anycubic_cloud_api/__init__.py +0 -0
  11. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/src/anycubic_cloud_api/anycubic_api.py +0 -0
  12. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/src/anycubic_cloud_api/api/base.py +0 -0
  13. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/src/anycubic_cloud_api/api/functions.py +0 -0
  14. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/src/anycubic_cloud_api/api/mqtt.py +0 -0
  15. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/src/anycubic_cloud_api/const/api_endpoints.py +0 -0
  16. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/src/anycubic_cloud_api/const/const.py +0 -0
  17. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/src/anycubic_cloud_api/const/enums.py +0 -0
  18. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/src/anycubic_cloud_api/const/lan.py +0 -0
  19. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/src/anycubic_cloud_api/const/mqtt.py +0 -0
  20. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/src/anycubic_cloud_api/data_models/consumable.py +0 -0
  21. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/src/anycubic_cloud_api/data_models/files.py +0 -0
  22. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/src/anycubic_cloud_api/data_models/gcode_file.py +0 -0
  23. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/src/anycubic_cloud_api/data_models/orders.py +0 -0
  24. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/src/anycubic_cloud_api/data_models/print_response.py +0 -0
  25. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/src/anycubic_cloud_api/data_models/print_speed_mode.py +0 -0
  26. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/src/anycubic_cloud_api/data_models/printer_properties.py +0 -0
  27. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/src/anycubic_cloud_api/data_models/printing_settings.py +0 -0
  28. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/src/anycubic_cloud_api/data_models/project.py +0 -0
  29. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/src/anycubic_cloud_api/exceptions/error_strings.py +0 -0
  30. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/src/anycubic_cloud_api/exceptions/exceptions.py +0 -0
  31. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/src/anycubic_cloud_api/helpers/helpers.py +0 -0
  32. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/src/anycubic_cloud_api/lan/__init__.py +0 -0
  33. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/src/anycubic_cloud_api/lan/client.py +0 -0
  34. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/src/anycubic_cloud_api/lan/handshake.py +0 -0
  35. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/src/anycubic_cloud_api/models/auth.py +0 -0
  36. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/src/anycubic_cloud_api/models/cloud_upload.py +0 -0
  37. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/src/anycubic_cloud_api/models/http.py +0 -0
  38. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/src/anycubic_cloud_api/resources/anycubic_mqqt_tls_ca.crt +0 -0
  39. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/src/anycubic_cloud_api/resources/anycubic_mqqt_tls_client.crt +0 -0
  40. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/src/anycubic_cloud_api/resources/anycubic_mqqt_tls_client.key +0 -0
  41. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/src/anycubic_cloud_api.egg-info/dependency_links.txt +0 -0
  42. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/src/anycubic_cloud_api.egg-info/requires.txt +0 -0
  43. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/src/anycubic_cloud_api.egg-info/top_level.txt +0 -0
  44. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/tests/test_axis_position.py +0 -0
  45. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/tests/test_lan_client.py +0 -0
  46. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/tests/test_lan_handshake.py +0 -0
  47. {anycubic_cloud_api-0.2.0 → anycubic_cloud_api-0.3.0}/tests/test_partial_reports.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: anycubic-cloud-api
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Async client for the Anycubic Cloud API and its MQTT stream
5
5
  Author: 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.2.0"
7
+ version = "0.3.0"
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"
@@ -49,8 +49,34 @@ if TYPE_CHECKING:
49
49
  from .printer_properties import AnycubicDryingStatus, AnycubicMaterialMapping
50
50
 
51
51
 
52
+ def _drain(consumable: Any) -> dict[str, Any]:
53
+ """Read every remaining key, returning them as a plain dict.
54
+
55
+ Reading is what marks a key consumed, and the payload contract fails loudly
56
+ on anything left behind -- so blocks whose keys vary by model are taken
57
+ wholesale rather than named one by one.
58
+ """
59
+ if not consumable:
60
+ return {}
61
+
62
+ drained = dict(consumable.remaining_data)
63
+
64
+ for key in list(drained):
65
+ consumable.get(key)
66
+
67
+ return drained
68
+
69
+
52
70
  class AnycubicPrinter:
53
71
  __slots__ = (
72
+ "_camera_stream_url",
73
+ "_file_upload_url",
74
+ "_features",
75
+ "_ai_settings",
76
+ "_chamber_temp",
77
+ "_target_chamber_temp",
78
+ "_local_firmware_version",
79
+ "_local_ip",
54
80
  "_ignore_init_errors",
55
81
  "_initialisation_error",
56
82
  "_api_parent",
@@ -1188,6 +1214,134 @@ class AnycubicPrinter:
1188
1214
  'brightness': int(light.get('brightness') or 0),
1189
1215
  }
1190
1216
 
1217
+
1218
+ def _process_mqtt_update_info(
1219
+ self,
1220
+ action: str,
1221
+ state: str,
1222
+ payload: AnycubicConsumableData,
1223
+ ) -> None:
1224
+ """A whole-printer snapshot.
1225
+
1226
+ Only the local connection sends this; over the cloud the same ground is
1227
+ covered by the separate status, temperature and fan reports. It is the
1228
+ only place the printer names its own stream and upload endpoints, and
1229
+ the only place it lists which features it physically has.
1230
+ """
1231
+ data = payload['data']
1232
+
1233
+ if not data:
1234
+ return
1235
+
1236
+ urls = data.get('urls')
1237
+
1238
+ if urls:
1239
+ self._camera_stream_url = urls.get('rtspUrl')
1240
+ self._file_upload_url = urls.get('fileUploadurl')
1241
+
1242
+ self._features = _drain(data.get('features')) or self.features
1243
+
1244
+ temp = data.get('temp')
1245
+
1246
+ if temp:
1247
+ if self.parameter:
1248
+ self.parameter.update_current_temps(
1249
+ temp.get('curr_hotbed_temp'),
1250
+ temp.get('curr_nozzle_temp'),
1251
+ )
1252
+ # A Kobra S1 reports chamber temperatures of zero because it has no
1253
+ # chamber sensor, so they are kept raw rather than turned into an
1254
+ # always-zero entity.
1255
+ self._chamber_temp = temp.get('curr_chamber_temp')
1256
+ self._target_chamber_temp = temp.get('target_chamber_temp')
1257
+ _drain(temp)
1258
+
1259
+ if 'fan_speed_pct' in data:
1260
+ self._fan_speed = int(data['fan_speed_pct'])
1261
+ if 'print_speed_mode' in data:
1262
+ self._print_speed_mode = int(data['print_speed_mode'])
1263
+
1264
+ self._local_firmware_version = data.get('version')
1265
+ self._local_ip = data.get('ip')
1266
+
1267
+ # Consumed but not modelled: the printer's own name and model strings
1268
+ # duplicate what the device already carries, and the project blocks are
1269
+ # covered by the print report.
1270
+ data.get('printerName')
1271
+ data.get('model')
1272
+ data.get('state')
1273
+ data.get('project')
1274
+ data.get('last_project')
1275
+ data.get('aux_fan_speed_pct')
1276
+ data.get('box_fan_level')
1277
+ _drain(data)
1278
+
1279
+ def _process_mqtt_update_ai_settings(
1280
+ self,
1281
+ action: str,
1282
+ state: str,
1283
+ payload: AnycubicConsumableData,
1284
+ ) -> None:
1285
+ """Foreign-object and first-layer detection settings."""
1286
+ data = payload['data']
1287
+
1288
+ if not data:
1289
+ return
1290
+
1291
+ self._ai_settings = _drain(data.get('ai_settings')) or self.ai_settings
1292
+
1293
+ _drain(data)
1294
+
1295
+
1296
+ @property
1297
+ def camera_stream_url(self) -> str | None:
1298
+ """Where the printer serves its camera, when it says.
1299
+
1300
+ Only reported over the local connection.
1301
+ """
1302
+ return getattr(self, '_camera_stream_url', None)
1303
+
1304
+ @property
1305
+ def file_upload_url(self) -> str | None:
1306
+ """Signed endpoint for uploading gcode straight to the printer."""
1307
+ return getattr(self, '_file_upload_url', None)
1308
+
1309
+ @property
1310
+ def features(self) -> dict[str, Any]:
1311
+ """What this model says it can physically do.
1312
+
1313
+ Reported only over the local connection, and the most reliable way to
1314
+ tell models apart without owning one.
1315
+ """
1316
+ return dict(getattr(self, '_features', {}) or {})
1317
+
1318
+ @property
1319
+ def ai_settings(self) -> dict[str, Any]:
1320
+ """Foreign-object / first-layer detection settings."""
1321
+ return dict(getattr(self, '_ai_settings', {}) or {})
1322
+
1323
+ @property
1324
+ def ai_detection_enabled(self) -> bool | None:
1325
+ settings = self.ai_settings
1326
+ return bool(settings['status']) if 'status' in settings else None
1327
+
1328
+ @property
1329
+ def local_firmware_version(self) -> str | None:
1330
+ return getattr(self, '_local_firmware_version', None)
1331
+
1332
+ @property
1333
+ def chamber_temperature(self) -> float | None:
1334
+ """Chamber temperature, where the printer has a sensor for it.
1335
+
1336
+ An open-frame or unsensored machine reports zero here, which is why
1337
+ this is not turned into an entity without checking it first.
1338
+ """
1339
+ return getattr(self, '_chamber_temp', None)
1340
+
1341
+ @property
1342
+ def target_chamber_temperature(self) -> float | None:
1343
+ return getattr(self, '_target_chamber_temp', None)
1344
+
1191
1345
  def process_mqtt_update(
1192
1346
  self,
1193
1347
  topic: str,
@@ -1242,6 +1396,12 @@ class AnycubicPrinter:
1242
1396
  elif msg_type == 'light':
1243
1397
  self._process_mqtt_update_light(action, state, payload)
1244
1398
 
1399
+ elif msg_type == 'info':
1400
+ self._process_mqtt_update_info(action, state, payload)
1401
+
1402
+ elif msg_type == 'aiSettings':
1403
+ self._process_mqtt_update_ai_settings(action, state, payload)
1404
+
1245
1405
  else:
1246
1406
  raise AnycubicMQTTUnknownUpdate(ErrorsMQTTUpdate.unknown.format(msg_type))
1247
1407
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: anycubic-cloud-api
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Async client for the Anycubic Cloud API and its MQTT stream
5
5
  Author: Nino Bondonno
6
6
  Maintainer: Nino Bondonno
@@ -41,4 +41,5 @@ src/anycubic_cloud_api/resources/anycubic_mqqt_tls_client.key
41
41
  tests/test_axis_position.py
42
42
  tests/test_lan_client.py
43
43
  tests/test_lan_handshake.py
44
+ tests/test_lan_reports.py
44
45
  tests/test_partial_reports.py
@@ -0,0 +1,214 @@
1
+ """Parsing the reports only the local connection sends.
2
+
3
+ The payloads here are captured verbatim from a Kobra S1 on firmware 2.7.2.7 in
4
+ LAN Mode. The payload contract fails loudly on any key left unread, so these
5
+ double as a check that nothing in a real report goes unhandled.
6
+ """
7
+
8
+ from unittest.mock import MagicMock
9
+
10
+ import pytest
11
+
12
+ from anycubic_cloud_api.data_models.consumable import AnycubicConsumableData
13
+ from anycubic_cloud_api.data_models.printer import AnycubicPrinter
14
+
15
+ INFO_REPORT = {
16
+ "type": "info",
17
+ "action": "report",
18
+ "timestamp": 244516,
19
+ "msgid": "b85ca099-9792-4e2d-83df-cdd6f1a7296d",
20
+ "state": "done",
21
+ "code": 200,
22
+ "msg": "done",
23
+ "data": {
24
+ "printerName": "Anycubic Kobra S1",
25
+ "urls": {
26
+ "fileUploadurl": "http://10.0.66.28:18910/gcode_upload?s=SIGNED",
27
+ "rtspUrl": "http://10.0.66.28:18088/flv",
28
+ },
29
+ "project": None,
30
+ "last_project": None,
31
+ "model": "Anycubic Kobra S1",
32
+ "ip": "10.0.66.28",
33
+ "version": "2.7.2.7",
34
+ "state": "free",
35
+ "temp": {
36
+ "curr_hotbed_temp": 31,
37
+ "curr_nozzle_temp": 34,
38
+ "target_hotbed_temp": 0,
39
+ "target_nozzle_temp": 0,
40
+ },
41
+ "print_speed_mode": 2,
42
+ "fan_speed_pct": 0,
43
+ "aux_fan_speed_pct": 0,
44
+ "box_fan_level": 0,
45
+ "features": {
46
+ "auto_leveling_support": True,
47
+ "vibration_compensation_support": True,
48
+ "flow_calibration_support": True,
49
+ "drying_first_support": True,
50
+ "camera_timelapse_support": True,
51
+ "gcode_3mf_support": True,
52
+ "delete_batch_support": True,
53
+ "preheating_support": True,
54
+ "fod_support": True,
55
+ "shengwang_rtc_support": True,
56
+ "pre_cancel_support": True,
57
+ "shengwang_rdt_support": True,
58
+ },
59
+ },
60
+ }
61
+
62
+ AI_SETTINGS_REPORT = {
63
+ "type": "aiSettings",
64
+ "action": "query",
65
+ "timestamp": 248130,
66
+ "msgid": "21af7a88-0205-421f-8f21-3a355bd7e9ff",
67
+ "state": "done",
68
+ "code": 200,
69
+ "msg": "done",
70
+ "data": {
71
+ "ai_settings": {
72
+ "status": 0,
73
+ "type": 2,
74
+ "count": 60,
75
+ "notice_type": [0, 1],
76
+ "sensitivity_level": [1, 1],
77
+ }
78
+ },
79
+ }
80
+
81
+ TOPIC = "anycubic/anycubicCloud/v1/printer/public/20025/DEVICE/info"
82
+
83
+
84
+ def make_printer():
85
+ return AnycubicPrinter(
86
+ api_parent=MagicMock(),
87
+ machine_type=20025,
88
+ machine_name="Anycubic Kobra S1",
89
+ id=1,
90
+ ignore_init_errors=True,
91
+ )
92
+
93
+
94
+ def apply(printer, report):
95
+ printer.process_mqtt_update(TOPIC, AnycubicConsumableData(report))
96
+
97
+
98
+ class TestInfoReport:
99
+ def test_the_real_report_is_fully_consumed(self):
100
+ """Anything unread raises -- so this passing means nothing was missed."""
101
+ apply(make_printer(), INFO_REPORT)
102
+
103
+ def test_the_camera_url_is_picked_up(self):
104
+ printer = make_printer()
105
+
106
+ apply(printer, INFO_REPORT)
107
+
108
+ assert printer.camera_stream_url == "http://10.0.66.28:18088/flv"
109
+
110
+ def test_the_upload_endpoint_is_picked_up(self):
111
+ printer = make_printer()
112
+
113
+ apply(printer, INFO_REPORT)
114
+
115
+ assert printer.file_upload_url.endswith("/gcode_upload?s=SIGNED")
116
+
117
+ def test_the_capability_map_is_kept(self):
118
+ """The most reliable way to tell models apart without owning one."""
119
+ printer = make_printer()
120
+
121
+ apply(printer, INFO_REPORT)
122
+
123
+ assert printer.features["fod_support"] is True
124
+ assert printer.features["camera_timelapse_support"] is True
125
+ assert len(printer.features) == 12
126
+
127
+ def test_the_firmware_version_is_kept(self):
128
+ printer = make_printer()
129
+
130
+ apply(printer, INFO_REPORT)
131
+
132
+ assert printer.local_firmware_version == "2.7.2.7"
133
+
134
+ def test_fan_and_speed_are_applied(self):
135
+ printer = make_printer()
136
+
137
+ apply(printer, INFO_REPORT)
138
+
139
+ assert printer._fan_speed == 0
140
+ assert printer._print_speed_mode == 2
141
+
142
+ def test_a_chamberless_printer_reports_nothing_for_it(self):
143
+ """A Kobra S1 has no chamber sensor and simply omits the fields."""
144
+ printer = make_printer()
145
+
146
+ apply(printer, INFO_REPORT)
147
+
148
+ assert printer.chamber_temperature is None
149
+
150
+ def test_a_chamber_reading_is_kept_when_present(self):
151
+ printer = make_printer()
152
+ report = {**INFO_REPORT, "data": {**INFO_REPORT["data"]}}
153
+ report["data"]["temp"] = {**report["data"]["temp"], "curr_chamber_temp": 41}
154
+
155
+ apply(printer, report)
156
+
157
+ assert printer.chamber_temperature == 41
158
+
159
+ @pytest.mark.parametrize(
160
+ "missing", ["urls", "features", "temp", "version", "print_speed_mode"]
161
+ )
162
+ def test_a_report_missing_a_block_still_parses(self, missing):
163
+ """Other models will not send all of these."""
164
+ report = {**INFO_REPORT, "data": {**INFO_REPORT["data"]}}
165
+ del report["data"][missing]
166
+
167
+ apply(make_printer(), report)
168
+
169
+ def test_an_empty_report_is_harmless(self):
170
+ apply(make_printer(), {**INFO_REPORT, "data": {}})
171
+
172
+ def test_a_later_report_without_features_keeps_the_known_ones(self):
173
+ printer = make_printer()
174
+ apply(printer, INFO_REPORT)
175
+
176
+ stripped = {**INFO_REPORT, "data": {**INFO_REPORT["data"]}}
177
+ del stripped["data"]["features"]
178
+ apply(printer, stripped)
179
+
180
+ assert len(printer.features) == 12
181
+
182
+
183
+ class TestAiSettings:
184
+ def test_the_real_report_is_fully_consumed(self):
185
+ apply(make_printer(), AI_SETTINGS_REPORT)
186
+
187
+ def test_the_settings_are_kept(self):
188
+ printer = make_printer()
189
+
190
+ apply(printer, AI_SETTINGS_REPORT)
191
+
192
+ assert printer.ai_settings["count"] == 60
193
+ assert printer.ai_settings["sensitivity_level"] == [1, 1]
194
+
195
+ def test_detection_off_reads_false_not_none(self):
196
+ printer = make_printer()
197
+
198
+ apply(printer, AI_SETTINGS_REPORT)
199
+
200
+ assert printer.ai_detection_enabled is False
201
+
202
+ def test_detection_on_reads_true(self):
203
+ printer = make_printer()
204
+ report = {"data": {"ai_settings": {"status": 1}}, **{
205
+ k: v for k, v in AI_SETTINGS_REPORT.items() if k != "data"
206
+ }}
207
+
208
+ apply(printer, report)
209
+
210
+ assert printer.ai_detection_enabled is True
211
+
212
+ def test_an_unseen_printer_reports_nothing(self):
213
+ assert make_printer().ai_detection_enabled is None
214
+ assert make_printer().ai_settings == {}