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.
- {anycubic_cloud_api-0.4.32/src/anycubic_cloud_api.egg-info → anycubic_cloud_api-0.4.34}/PKG-INFO +1 -1
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/pyproject.toml +1 -1
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/__init__.py +2 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/api/base.py +23 -2
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/api/mqtt.py +5 -1
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/const/const.py +6 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/data_models/printer.py +135 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/data_models/project.py +16 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/exceptions/error_strings.py +3 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/exceptions/exceptions.py +4 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34/src/anycubic_cloud_api.egg-info}/PKG-INFO +1 -1
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api.egg-info/SOURCES.txt +2 -0
- anycubic_cloud_api-0.4.34/tests/test_exchange_rate_limit.py +94 -0
- anycubic_cloud_api-0.4.34/tests/test_print_reports_0434.py +268 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/COPYRIGHT +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/LICENSE +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/MANIFEST.in +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/README.md +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/setup.cfg +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/anycubic_api.py +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/api/functions.py +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/const/api_endpoints.py +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/const/enums.py +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/const/error_codes.py +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/const/lan.py +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/const/mqtt.py +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/const/regions.py +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/data_models/consumable.py +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/data_models/files.py +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/data_models/gcode_file.py +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/data_models/orders.py +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/data_models/print_response.py +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/data_models/print_speed_mode.py +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/data_models/printer_properties.py +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/data_models/printing_settings.py +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/helpers/helpers.py +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/lan/__init__.py +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/lan/client.py +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/lan/commands.py +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/lan/handshake.py +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/models/auth.py +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/models/cloud_upload.py +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/models/http.py +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/resources/anycubic_mqqt_tls_ca.crt +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/resources/anycubic_mqqt_tls_client.crt +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/resources/anycubic_mqqt_tls_client.key +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api.egg-info/dependency_links.txt +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api.egg-info/requires.txt +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api.egg-info/top_level.txt +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/tests/test_auth_mode_fallback.py +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/tests/test_axis_position.py +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/tests/test_endpoint_urls.py +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/tests/test_lan_client.py +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/tests/test_lan_commands.py +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/tests/test_lan_handshake.py +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/tests/test_lan_reports.py +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/tests/test_null_tolerant_parsing.py +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/tests/test_partial_reports.py +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/tests/test_printer_error_codes.py +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/tests/test_project_null_status.py +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/tests/test_regions.py +0 -0
- {anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/tests/test_secondary_ace.py +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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}, "
|
{anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/const/const.py
RENAMED
|
@@ -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
|
|
{anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api.egg-info/SOURCES.txt
RENAMED
|
@@ -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"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/anycubic_api.py
RENAMED
|
File without changes
|
{anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/api/functions.py
RENAMED
|
File without changes
|
|
File without changes
|
{anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/const/enums.py
RENAMED
|
File without changes
|
{anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/const/error_codes.py
RENAMED
|
File without changes
|
|
File without changes
|
{anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/const/mqtt.py
RENAMED
|
File without changes
|
{anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/const/regions.py
RENAMED
|
File without changes
|
|
File without changes
|
{anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/data_models/files.py
RENAMED
|
File without changes
|
|
File without changes
|
{anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/data_models/orders.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/helpers/helpers.py
RENAMED
|
File without changes
|
{anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/lan/__init__.py
RENAMED
|
File without changes
|
{anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/lan/client.py
RENAMED
|
File without changes
|
{anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/lan/commands.py
RENAMED
|
File without changes
|
{anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/lan/handshake.py
RENAMED
|
File without changes
|
{anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/models/auth.py
RENAMED
|
File without changes
|
|
File without changes
|
{anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api/models/http.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{anycubic_cloud_api-0.4.32 → anycubic_cloud_api-0.4.34}/src/anycubic_cloud_api.egg-info/requires.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|