anycubic-cloud-client 0.1.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.
- anycubic_cloud_client-0.1.0/.gitignore +13 -0
- anycubic_cloud_client-0.1.0/LICENSE +21 -0
- anycubic_cloud_client-0.1.0/PKG-INFO +217 -0
- anycubic_cloud_client-0.1.0/README.md +186 -0
- anycubic_cloud_client-0.1.0/docs/ACCEPTANCE.md +80 -0
- anycubic_cloud_client-0.1.0/docs/CLEAN-ROOM.md +63 -0
- anycubic_cloud_client-0.1.0/docs/INTEGRATION-SPEC.md +203 -0
- anycubic_cloud_client-0.1.0/docs/PROTOCOL.md +4314 -0
- anycubic_cloud_client-0.1.0/docs/QUESTIONS.md +30 -0
- anycubic_cloud_client-0.1.0/pyproject.toml +100 -0
- anycubic_cloud_client-0.1.0/src/anycubic_cloud_client/__init__.py +253 -0
- anycubic_cloud_client-0.1.0/src/anycubic_cloud_client/agora.py +790 -0
- anycubic_cloud_client-0.1.0/src/anycubic_cloud_client/agora_sdp.py +436 -0
- anycubic_cloud_client-0.1.0/src/anycubic_cloud_client/client.py +1471 -0
- anycubic_cloud_client-0.1.0/src/anycubic_cloud_client/credentials.py +89 -0
- anycubic_cloud_client-0.1.0/src/anycubic_cloud_client/errors.py +144 -0
- anycubic_cloud_client-0.1.0/src/anycubic_cloud_client/firmware.py +119 -0
- anycubic_cloud_client-0.1.0/src/anycubic_cloud_client/messages.py +650 -0
- anycubic_cloud_client-0.1.0/src/anycubic_cloud_client/models.py +1217 -0
- anycubic_cloud_client-0.1.0/src/anycubic_cloud_client/mqtt.py +549 -0
- anycubic_cloud_client-0.1.0/src/anycubic_cloud_client/mqtt_identity.py +223 -0
- anycubic_cloud_client-0.1.0/src/anycubic_cloud_client/orders.py +390 -0
- anycubic_cloud_client-0.1.0/src/anycubic_cloud_client/printing.py +295 -0
- anycubic_cloud_client-0.1.0/src/anycubic_cloud_client/py.typed +0 -0
- anycubic_cloud_client-0.1.0/src/anycubic_cloud_client/regions.py +157 -0
- anycubic_cloud_client-0.1.0/src/anycubic_cloud_client/signing.py +118 -0
- anycubic_cloud_client-0.1.0/src/anycubic_cloud_client/tokens.py +377 -0
- anycubic_cloud_client-0.1.0/tests/__init__.py +0 -0
- anycubic_cloud_client-0.1.0/tests/conftest.py +287 -0
- anycubic_cloud_client-0.1.0/tests/payloads.py +380 -0
- anycubic_cloud_client-0.1.0/tests/test_agora.py +877 -0
- anycubic_cloud_client-0.1.0/tests/test_basics.py +348 -0
- anycubic_cloud_client-0.1.0/tests/test_client_auth.py +756 -0
- anycubic_cloud_client-0.1.0/tests/test_client_endpoints.py +1127 -0
- anycubic_cloud_client-0.1.0/tests/test_firmware.py +83 -0
- anycubic_cloud_client-0.1.0/tests/test_messages.py +633 -0
- anycubic_cloud_client-0.1.0/tests/test_models.py +450 -0
- anycubic_cloud_client-0.1.0/tests/test_mqtt.py +743 -0
- anycubic_cloud_client-0.1.0/tests/test_printing.py +230 -0
- anycubic_cloud_client-0.1.0/tests/test_tokens.py +295 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nino Bondonno
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: anycubic-cloud-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async client for the Anycubic cloud: sign-in, HTTP API, cloud MQTT, files, printing and camera signalling.
|
|
5
|
+
Project-URL: Source, https://github.com/Nino6689/anycubic-cloud-client
|
|
6
|
+
Project-URL: Issues, https://github.com/Nino6689/anycubic-cloud-client/issues
|
|
7
|
+
Author: Nino Bondonno
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: 3d-printer,anycubic,asyncio,home-assistant,mqtt
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Framework :: AsyncIO
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Home Automation
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.13
|
|
21
|
+
Requires-Dist: aiohttp>=3.10
|
|
22
|
+
Requires-Dist: anycubic-lan>=0.1.0
|
|
23
|
+
Requires-Dist: bcrypt>=4.0
|
|
24
|
+
Requires-Dist: cryptography>=43
|
|
25
|
+
Requires-Dist: paho-mqtt>=2.1
|
|
26
|
+
Provides-Extra: test
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'test'
|
|
28
|
+
Requires-Dist: pytest-cov>=5; extra == 'test'
|
|
29
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# anycubic-cloud-client
|
|
33
|
+
|
|
34
|
+
An async Python client for the **Anycubic cloud**: account sign-in, the cloud
|
|
35
|
+
HTTP API, the cloud MQTT feed, files, printing and the cloud camera's
|
|
36
|
+
signalling. It is written for the `anycubic_cloud` Home Assistant integration
|
|
37
|
+
(version 3.0), but it has no Home Assistant code.
|
|
38
|
+
|
|
39
|
+
The protocol is described in [`docs/PROTOCOL.md`](docs/PROTOCOL.md) and the API
|
|
40
|
+
the integration needs in [`docs/INTEGRATION-SPEC.md`](docs/INTEGRATION-SPEC.md).
|
|
41
|
+
The code is written under clean-room rules ([`docs/CLEAN-ROOM.md`](docs/CLEAN-ROOM.md)).
|
|
42
|
+
Open questions are in [`docs/QUESTIONS.md`](docs/QUESTIONS.md).
|
|
43
|
+
|
|
44
|
+
## No Anycubic credentials inside
|
|
45
|
+
|
|
46
|
+
Anycubic has no public API. Its cloud expects the credentials of its own apps:
|
|
47
|
+
client and app identifiers, an app secret, and a TLS client certificate and key
|
|
48
|
+
for the MQTT broker. This library **does not contain any of them**. The caller
|
|
49
|
+
passes them in a `CloudSecrets` object when it creates a client, and releases
|
|
50
|
+
are checked for key material before publishing. The library works as far as
|
|
51
|
+
those credentials let it.
|
|
52
|
+
|
|
53
|
+
This project is **not affiliated with or endorsed by Anycubic**. If Anycubic
|
|
54
|
+
publishes an official developer API, this library will move to it.
|
|
55
|
+
|
|
56
|
+
## Install
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
pip install anycubic-cloud-client
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Python 3.13 or later. Dependencies: `aiohttp`, `paho-mqtt`, `cryptography`,
|
|
63
|
+
`bcrypt` and [`anycubic-lan`](https://github.com/Nino6689/anycubic-lan), whose
|
|
64
|
+
report parser is reused for the MQTT bodies the cloud shares with LAN Mode.
|
|
65
|
+
|
|
66
|
+
## Usage
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
import asyncio
|
|
70
|
+
|
|
71
|
+
import aiohttp
|
|
72
|
+
|
|
73
|
+
from anycubic_cloud_client import (
|
|
74
|
+
AnycubicCloudClient,
|
|
75
|
+
AnycubicCloudError,
|
|
76
|
+
AuthMode,
|
|
77
|
+
CloudMqttClient,
|
|
78
|
+
CloudSecrets,
|
|
79
|
+
CredentialsRejectedError,
|
|
80
|
+
Region,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def main(secrets: CloudSecrets, pasted_token: str, stored: dict) -> None:
|
|
85
|
+
async with aiohttp.ClientSession() as session:
|
|
86
|
+
cloud = AnycubicCloudClient.from_entry(
|
|
87
|
+
session,
|
|
88
|
+
secrets,
|
|
89
|
+
token=pasted_token, # the Slicer Next access token
|
|
90
|
+
auth_mode=AuthMode.SLICER,
|
|
91
|
+
region=Region.INTERNATIONAL,
|
|
92
|
+
store=stored, # the saved token store, if any
|
|
93
|
+
)
|
|
94
|
+
try:
|
|
95
|
+
account = await cloud.check() # exchange + userInfo, with retries
|
|
96
|
+
except CredentialsRejectedError as err:
|
|
97
|
+
print("ask the user for a new token:", err.reason)
|
|
98
|
+
return
|
|
99
|
+
if cloud.tokens_changed:
|
|
100
|
+
stored = cloud.export_token_store() # save it, then:
|
|
101
|
+
cloud.mark_tokens_saved()
|
|
102
|
+
|
|
103
|
+
printers = await cloud.get_printers()
|
|
104
|
+
latest = await cloud.get_latest_jobs([p.id for p in printers if p.id])
|
|
105
|
+
for printer in printers:
|
|
106
|
+
detail = await cloud.get_printer(printer.id)
|
|
107
|
+
print(detail.name, detail.nozzle_temp, latest[printer.id])
|
|
108
|
+
|
|
109
|
+
link = CloudMqttClient(cloud)
|
|
110
|
+
link.add_message_listener(lambda message: print(message.kind, message.update))
|
|
111
|
+
for printer in printers:
|
|
112
|
+
link.subscribe_printer(printer.key, printer.machine_type)
|
|
113
|
+
await link.connect()
|
|
114
|
+
try:
|
|
115
|
+
await link.wait_until_connected() # never send before the link exists
|
|
116
|
+
await cloud.query_peripherals(printers[0].id) # the answer comes over MQTT
|
|
117
|
+
await asyncio.sleep(30)
|
|
118
|
+
except AnycubicCloudError as err:
|
|
119
|
+
print("cloud error:", err)
|
|
120
|
+
finally:
|
|
121
|
+
await link.disconnect()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# The caller builds CloudSecrets from wherever it keeps Anycubic's app
|
|
125
|
+
# credentials; this library never ships them.
|
|
126
|
+
secrets = CloudSecrets(
|
|
127
|
+
app_id="...",
|
|
128
|
+
app_secret="...",
|
|
129
|
+
client_id_web="...",
|
|
130
|
+
client_id_app="...",
|
|
131
|
+
mqtt_ca_pem=b"-----BEGIN CERTIFICATE-----...",
|
|
132
|
+
mqtt_client_cert_pem=b"-----BEGIN CERTIFICATE-----...",
|
|
133
|
+
mqtt_client_key_pem=b"-----BEGIN RSA PRIVATE KEY-----...",
|
|
134
|
+
)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`CloudSecrets` is validated when it is built and its `repr` never shows a
|
|
138
|
+
value. Tokens, signatures and secrets are never logged.
|
|
139
|
+
|
|
140
|
+
### Setting up right after a sign-in
|
|
141
|
+
|
|
142
|
+
The cloud refuses a second exchange of the same access token within a few
|
|
143
|
+
seconds (a rate limit, PROTOCOL A §4.3). The client waits it out (10 s by
|
|
144
|
+
default, `rate_limit_delay=` to change it) and raises
|
|
145
|
+
`ServiceUnavailableError`, never a credentials error, if it persists. To
|
|
146
|
+
avoid it altogether, reuse the tokens of the sign-in instead of exchanging
|
|
147
|
+
again:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
result = await sign_in_any(session, secrets, pasted_token)
|
|
151
|
+
stored = result.tokens.to_store() # save this as the token store
|
|
152
|
+
cloud = AnycubicCloudClient.from_entry(
|
|
153
|
+
session,
|
|
154
|
+
secrets,
|
|
155
|
+
token=pasted_token,
|
|
156
|
+
auth_mode=result.auth_mode,
|
|
157
|
+
store=result.tokens, # a TokenState or a saved store dict
|
|
158
|
+
)
|
|
159
|
+
await cloud.check() # userInfo only: no second exchange
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### Lights
|
|
163
|
+
|
|
164
|
+
`set_light(printer_id, on)` sends the light type the printer reported over
|
|
165
|
+
the cloud MQTT link (the lowest one), and 1 only when none has been reported
|
|
166
|
+
yet (PROTOCOL B §5.4.7). The link records the types as `light` messages
|
|
167
|
+
arrive; the printer id is tied to its key by `get_printers()` or
|
|
168
|
+
`get_printer()`. To restore types remembered across a restart, call
|
|
169
|
+
`cloud.note_light_types(printer_key, types)`. An explicit `light_type=` wins.
|
|
170
|
+
|
|
171
|
+
## What is supported
|
|
172
|
+
|
|
173
|
+
- **Sign-in** in the three modes (web, Android, slicer) and both regions
|
|
174
|
+
(international, China): the token exchange with its retry and its
|
|
175
|
+
rate-limit cooldown, the web fallback, the displaced-token retry, `sign_in_any()` for a config flow, the
|
|
176
|
+
2.x token-store format in and out, and token helpers (extraction from
|
|
177
|
+
pasted text or a Slicer Next config file, unverified claims, the RS256
|
|
178
|
+
pre-check against the JWKS with signature trimming).
|
|
179
|
+
- **Errors that stay distinct**: credentials rejected (invalid, expired, wrong
|
|
180
|
+
token type), service unavailable, unexpected response, printer removed,
|
|
181
|
+
order refused, file not found, secrets invalid. Only the first may lead to
|
|
182
|
+
re-authentication.
|
|
183
|
+
- **HTTP**: printers and their detail (firmware, ACE units, external holder,
|
|
184
|
+
capabilities), the job list (fetched once for all printers) and job
|
|
185
|
+
detail, sliced-file detail, rename, printer and ACE firmware updates.
|
|
186
|
+
- **Orders** in their exact body shapes: pause, resume, cancel, light, fans,
|
|
187
|
+
temperatures, speed mode and print settings, axis moves and homing,
|
|
188
|
+
motors off, ACE feed/retract/drying/slot/auto-refill, file lists and
|
|
189
|
+
deletes on the printer and USB stick, AI detection, peripherals and light
|
|
190
|
+
queries, and a generic `send_order()`.
|
|
191
|
+
- **Files and printing**: cloud file list, delete and quota; upload (the
|
|
192
|
+
storage lock is always released, also after a failure); print a cloud file,
|
|
193
|
+
a printer or USB file, or a fresh upload, with the ACE slot mapping and
|
|
194
|
+
G-code header parsing.
|
|
195
|
+
- **Cloud MQTT**: identity per mode, the pinned-CA TLS context, subscription
|
|
196
|
+
and routing, parsing of every message kind (reusing `anycubic-lan` where the
|
|
197
|
+
body is the LAN body), reconnect with back-off, printers subscribed as soon
|
|
198
|
+
as they are added, a clear error when the broker refuses the login, and
|
|
199
|
+
firmware update progress.
|
|
200
|
+
- **Cloud camera**: the camera-open order with its retry after a fresh login,
|
|
201
|
+
and an Agora WebRTC **signalling** client that turns the credentials and a
|
|
202
|
+
browser's SDP offer into an SDP answer (no media passes through it).
|
|
203
|
+
Encrypted channels work out of the box: the library ships Agora's public
|
|
204
|
+
key from its Web SDK (`agora-rtc-sdk-ng` 4.24.0, PROTOCOL D §1.7) as the
|
|
205
|
+
default, so a caller passes nothing. `sdk_public_key_pem` stays as an
|
|
206
|
+
optional override ([Q6](docs/QUESTIONS.md)).
|
|
207
|
+
|
|
208
|
+
## Credits
|
|
209
|
+
|
|
210
|
+
The Agora signalling (`agora.py`, `agora_sdp.py`) is adapted from the Agora
|
|
211
|
+
client of [homeassistant_petkit](https://github.com/Jezza34000/homeassistant_petkit)
|
|
212
|
+
by @Jezza34000, used under its MIT licence; its copyright notice is kept in
|
|
213
|
+
those files.
|
|
214
|
+
|
|
215
|
+
## Licence
|
|
216
|
+
|
|
217
|
+
MIT, see [`LICENSE`](LICENSE).
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# anycubic-cloud-client
|
|
2
|
+
|
|
3
|
+
An async Python client for the **Anycubic cloud**: account sign-in, the cloud
|
|
4
|
+
HTTP API, the cloud MQTT feed, files, printing and the cloud camera's
|
|
5
|
+
signalling. It is written for the `anycubic_cloud` Home Assistant integration
|
|
6
|
+
(version 3.0), but it has no Home Assistant code.
|
|
7
|
+
|
|
8
|
+
The protocol is described in [`docs/PROTOCOL.md`](docs/PROTOCOL.md) and the API
|
|
9
|
+
the integration needs in [`docs/INTEGRATION-SPEC.md`](docs/INTEGRATION-SPEC.md).
|
|
10
|
+
The code is written under clean-room rules ([`docs/CLEAN-ROOM.md`](docs/CLEAN-ROOM.md)).
|
|
11
|
+
Open questions are in [`docs/QUESTIONS.md`](docs/QUESTIONS.md).
|
|
12
|
+
|
|
13
|
+
## No Anycubic credentials inside
|
|
14
|
+
|
|
15
|
+
Anycubic has no public API. Its cloud expects the credentials of its own apps:
|
|
16
|
+
client and app identifiers, an app secret, and a TLS client certificate and key
|
|
17
|
+
for the MQTT broker. This library **does not contain any of them**. The caller
|
|
18
|
+
passes them in a `CloudSecrets` object when it creates a client, and releases
|
|
19
|
+
are checked for key material before publishing. The library works as far as
|
|
20
|
+
those credentials let it.
|
|
21
|
+
|
|
22
|
+
This project is **not affiliated with or endorsed by Anycubic**. If Anycubic
|
|
23
|
+
publishes an official developer API, this library will move to it.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
pip install anycubic-cloud-client
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Python 3.13 or later. Dependencies: `aiohttp`, `paho-mqtt`, `cryptography`,
|
|
32
|
+
`bcrypt` and [`anycubic-lan`](https://github.com/Nino6689/anycubic-lan), whose
|
|
33
|
+
report parser is reused for the MQTT bodies the cloud shares with LAN Mode.
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import asyncio
|
|
39
|
+
|
|
40
|
+
import aiohttp
|
|
41
|
+
|
|
42
|
+
from anycubic_cloud_client import (
|
|
43
|
+
AnycubicCloudClient,
|
|
44
|
+
AnycubicCloudError,
|
|
45
|
+
AuthMode,
|
|
46
|
+
CloudMqttClient,
|
|
47
|
+
CloudSecrets,
|
|
48
|
+
CredentialsRejectedError,
|
|
49
|
+
Region,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def main(secrets: CloudSecrets, pasted_token: str, stored: dict) -> None:
|
|
54
|
+
async with aiohttp.ClientSession() as session:
|
|
55
|
+
cloud = AnycubicCloudClient.from_entry(
|
|
56
|
+
session,
|
|
57
|
+
secrets,
|
|
58
|
+
token=pasted_token, # the Slicer Next access token
|
|
59
|
+
auth_mode=AuthMode.SLICER,
|
|
60
|
+
region=Region.INTERNATIONAL,
|
|
61
|
+
store=stored, # the saved token store, if any
|
|
62
|
+
)
|
|
63
|
+
try:
|
|
64
|
+
account = await cloud.check() # exchange + userInfo, with retries
|
|
65
|
+
except CredentialsRejectedError as err:
|
|
66
|
+
print("ask the user for a new token:", err.reason)
|
|
67
|
+
return
|
|
68
|
+
if cloud.tokens_changed:
|
|
69
|
+
stored = cloud.export_token_store() # save it, then:
|
|
70
|
+
cloud.mark_tokens_saved()
|
|
71
|
+
|
|
72
|
+
printers = await cloud.get_printers()
|
|
73
|
+
latest = await cloud.get_latest_jobs([p.id for p in printers if p.id])
|
|
74
|
+
for printer in printers:
|
|
75
|
+
detail = await cloud.get_printer(printer.id)
|
|
76
|
+
print(detail.name, detail.nozzle_temp, latest[printer.id])
|
|
77
|
+
|
|
78
|
+
link = CloudMqttClient(cloud)
|
|
79
|
+
link.add_message_listener(lambda message: print(message.kind, message.update))
|
|
80
|
+
for printer in printers:
|
|
81
|
+
link.subscribe_printer(printer.key, printer.machine_type)
|
|
82
|
+
await link.connect()
|
|
83
|
+
try:
|
|
84
|
+
await link.wait_until_connected() # never send before the link exists
|
|
85
|
+
await cloud.query_peripherals(printers[0].id) # the answer comes over MQTT
|
|
86
|
+
await asyncio.sleep(30)
|
|
87
|
+
except AnycubicCloudError as err:
|
|
88
|
+
print("cloud error:", err)
|
|
89
|
+
finally:
|
|
90
|
+
await link.disconnect()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# The caller builds CloudSecrets from wherever it keeps Anycubic's app
|
|
94
|
+
# credentials; this library never ships them.
|
|
95
|
+
secrets = CloudSecrets(
|
|
96
|
+
app_id="...",
|
|
97
|
+
app_secret="...",
|
|
98
|
+
client_id_web="...",
|
|
99
|
+
client_id_app="...",
|
|
100
|
+
mqtt_ca_pem=b"-----BEGIN CERTIFICATE-----...",
|
|
101
|
+
mqtt_client_cert_pem=b"-----BEGIN CERTIFICATE-----...",
|
|
102
|
+
mqtt_client_key_pem=b"-----BEGIN RSA PRIVATE KEY-----...",
|
|
103
|
+
)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`CloudSecrets` is validated when it is built and its `repr` never shows a
|
|
107
|
+
value. Tokens, signatures and secrets are never logged.
|
|
108
|
+
|
|
109
|
+
### Setting up right after a sign-in
|
|
110
|
+
|
|
111
|
+
The cloud refuses a second exchange of the same access token within a few
|
|
112
|
+
seconds (a rate limit, PROTOCOL A §4.3). The client waits it out (10 s by
|
|
113
|
+
default, `rate_limit_delay=` to change it) and raises
|
|
114
|
+
`ServiceUnavailableError`, never a credentials error, if it persists. To
|
|
115
|
+
avoid it altogether, reuse the tokens of the sign-in instead of exchanging
|
|
116
|
+
again:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
result = await sign_in_any(session, secrets, pasted_token)
|
|
120
|
+
stored = result.tokens.to_store() # save this as the token store
|
|
121
|
+
cloud = AnycubicCloudClient.from_entry(
|
|
122
|
+
session,
|
|
123
|
+
secrets,
|
|
124
|
+
token=pasted_token,
|
|
125
|
+
auth_mode=result.auth_mode,
|
|
126
|
+
store=result.tokens, # a TokenState or a saved store dict
|
|
127
|
+
)
|
|
128
|
+
await cloud.check() # userInfo only: no second exchange
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Lights
|
|
132
|
+
|
|
133
|
+
`set_light(printer_id, on)` sends the light type the printer reported over
|
|
134
|
+
the cloud MQTT link (the lowest one), and 1 only when none has been reported
|
|
135
|
+
yet (PROTOCOL B §5.4.7). The link records the types as `light` messages
|
|
136
|
+
arrive; the printer id is tied to its key by `get_printers()` or
|
|
137
|
+
`get_printer()`. To restore types remembered across a restart, call
|
|
138
|
+
`cloud.note_light_types(printer_key, types)`. An explicit `light_type=` wins.
|
|
139
|
+
|
|
140
|
+
## What is supported
|
|
141
|
+
|
|
142
|
+
- **Sign-in** in the three modes (web, Android, slicer) and both regions
|
|
143
|
+
(international, China): the token exchange with its retry and its
|
|
144
|
+
rate-limit cooldown, the web fallback, the displaced-token retry, `sign_in_any()` for a config flow, the
|
|
145
|
+
2.x token-store format in and out, and token helpers (extraction from
|
|
146
|
+
pasted text or a Slicer Next config file, unverified claims, the RS256
|
|
147
|
+
pre-check against the JWKS with signature trimming).
|
|
148
|
+
- **Errors that stay distinct**: credentials rejected (invalid, expired, wrong
|
|
149
|
+
token type), service unavailable, unexpected response, printer removed,
|
|
150
|
+
order refused, file not found, secrets invalid. Only the first may lead to
|
|
151
|
+
re-authentication.
|
|
152
|
+
- **HTTP**: printers and their detail (firmware, ACE units, external holder,
|
|
153
|
+
capabilities), the job list (fetched once for all printers) and job
|
|
154
|
+
detail, sliced-file detail, rename, printer and ACE firmware updates.
|
|
155
|
+
- **Orders** in their exact body shapes: pause, resume, cancel, light, fans,
|
|
156
|
+
temperatures, speed mode and print settings, axis moves and homing,
|
|
157
|
+
motors off, ACE feed/retract/drying/slot/auto-refill, file lists and
|
|
158
|
+
deletes on the printer and USB stick, AI detection, peripherals and light
|
|
159
|
+
queries, and a generic `send_order()`.
|
|
160
|
+
- **Files and printing**: cloud file list, delete and quota; upload (the
|
|
161
|
+
storage lock is always released, also after a failure); print a cloud file,
|
|
162
|
+
a printer or USB file, or a fresh upload, with the ACE slot mapping and
|
|
163
|
+
G-code header parsing.
|
|
164
|
+
- **Cloud MQTT**: identity per mode, the pinned-CA TLS context, subscription
|
|
165
|
+
and routing, parsing of every message kind (reusing `anycubic-lan` where the
|
|
166
|
+
body is the LAN body), reconnect with back-off, printers subscribed as soon
|
|
167
|
+
as they are added, a clear error when the broker refuses the login, and
|
|
168
|
+
firmware update progress.
|
|
169
|
+
- **Cloud camera**: the camera-open order with its retry after a fresh login,
|
|
170
|
+
and an Agora WebRTC **signalling** client that turns the credentials and a
|
|
171
|
+
browser's SDP offer into an SDP answer (no media passes through it).
|
|
172
|
+
Encrypted channels work out of the box: the library ships Agora's public
|
|
173
|
+
key from its Web SDK (`agora-rtc-sdk-ng` 4.24.0, PROTOCOL D §1.7) as the
|
|
174
|
+
default, so a caller passes nothing. `sdk_public_key_pem` stays as an
|
|
175
|
+
optional override ([Q6](docs/QUESTIONS.md)).
|
|
176
|
+
|
|
177
|
+
## Credits
|
|
178
|
+
|
|
179
|
+
The Agora signalling (`agora.py`, `agora_sdp.py`) is adapted from the Agora
|
|
180
|
+
client of [homeassistant_petkit](https://github.com/Jezza34000/homeassistant_petkit)
|
|
181
|
+
by @Jezza34000, used under its MIT licence; its copyright notice is kept in
|
|
182
|
+
those files.
|
|
183
|
+
|
|
184
|
+
## Licence
|
|
185
|
+
|
|
186
|
+
MIT, see [`LICENSE`](LICENSE).
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Acceptance reports
|
|
2
|
+
|
|
3
|
+
Functional results from the specification team's tests: observed behaviour against expected, never code.
|
|
4
|
+
The implementation team fixes the items under **To fix** and marks each one `**Fixed in <sha>.**`.
|
|
5
|
+
|
|
6
|
+
## Live test — 2026-09-28, `clean/implementation-v0.1.0` @ `e8f7ad8`
|
|
7
|
+
|
|
8
|
+
**Method.** The public API only. The real account from a 2.x install was used: Slicer mode, international,
|
|
9
|
+
with a Kobra S1 and one ACE Pro. `CloudSecrets` were built by the caller, as the integration will build them.
|
|
10
|
+
Nothing was written back to the live install.
|
|
11
|
+
|
|
12
|
+
**Passed.**
|
|
13
|
+
|
|
14
|
+
- `CloudSecrets` shows every field as `<set>` and never shows a value.
|
|
15
|
+
- `check()` works in two ways. With the saved store it succeeds in 0.5 s without exchanging. From the entry alone it
|
|
16
|
+
exchanges, succeeds, and sets `tokens_changed`. The exported store keys are exactly `auth_access_token`, `auth_mode`,
|
|
17
|
+
`auth_token` and `device_id`.
|
|
18
|
+
- `sign_in_any()` picks SLICER in 0.6 s. A made-up token raises `CredentialsRejectedError` with reason `invalid`.
|
|
19
|
+
- `get_printers`, `get_printer` (firmware installed and target, ACE firmware, tools, external holder, ACE units with
|
|
20
|
+
slots and drying), `get_latest_jobs`, `get_storage_quota` and `list_cloud_files` all return typed data that
|
|
21
|
+
matches the account.
|
|
22
|
+
- **The first ACE's box id is 0** in the cloud printer detail too (answers the open point in INTEGRATION-SPEC §11).
|
|
23
|
+
- MQTT connects in 0.5 s with mutual TLS. Peripherals, ACE info, light, video and work-status messages arrive and
|
|
24
|
+
are understood. Bodies that match LAN are passed on as `anycubic-lan` reports.
|
|
25
|
+
- Fan to 20 % and back (`fan/setSpeed` `done`). Local and USB file lists: a real list of printer files, and an empty
|
|
26
|
+
USB list.
|
|
27
|
+
- `open_camera()` returns the full credentials block (channel, token, uids, encryption mode, key and salt).
|
|
28
|
+
- Light orders with the printer's reported light type (2) are done by the printer (`light/control` `done`).
|
|
29
|
+
|
|
30
|
+
**To fix.**
|
|
31
|
+
|
|
32
|
+
| # | Observed | Expected |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| L1 | `set_light()` defaults `light_type` to 1. The Kobra S1 answers `light/control` **failed** and the light doesn't change, although the HTTP order is accepted. With `light_type=2` it works. | PROTOCOL B §5.4.7: send the light type the printer **last reported** (from its `light` reports), and 1 only if none has been reported. Make the default do that: for example, the MQTT link records the reported types and the client uses them when the caller passes none. A caller should not have to know. |
|
|
35
|
+
| L2 | Two exchanges of the same access token within about 3 s: the second is refused on both attempts (2 s apart). The client then falls back to web and raises `CredentialsRejectedError`. The server's answer was a **rate limit**: `code` 0, `msg` `请求过于频繁。请稍后再试` (PROTOCOL A §4.3, now documented). | A rate-limit answer is transient. Wait out the cooldown (≥ 5 s) and try again, never fall back to web, and never raise a credentials verdict. If it persists, raise `ServiceUnavailableError`. Refusal log lines should include the server's `msg`, never the token. Also make it easy to reuse a sign-in's tokens (e.g. `SignInResult.tokens` feeding `from_entry(store=…)`), so a setup straight after a sign-in doesn't exchange again. |
|
|
36
|
+
|
|
37
|
+
- L1: **Fixed in af47a5b.** `set_light()` without `light_type` sends the lowest type the printer reported in its `light`
|
|
38
|
+
messages (recorded by the MQTT link), and 1 only when none has been reported. An explicit value still wins.
|
|
39
|
+
- L2: **Fixed in 7ddca36.** The rate-limit answer is retried after a cooldown (10 s by default, injectable), never
|
|
40
|
+
leads to the web fallback or a credentials verdict, and raises `ServiceUnavailableError` after 3 retries. Refusals
|
|
41
|
+
are logged with the server's `msg`. `from_entry(store=result.tokens)` reuses a sign-in's tokens (README).
|
|
42
|
+
|
|
43
|
+
**Not tested live yet:** the upload and print paths (they'd start a real print), the Agora stream end to end
|
|
44
|
+
(it needs a browser offer; this will be tested through the integration), firmware update (it would update the
|
|
45
|
+
printer), and reconnect/backoff under a real outage.
|
|
46
|
+
|
|
47
|
+
## Round 2 re-test — 2026-09-28, `clean/implementation-v0.1.0` @ `e5df26f`
|
|
48
|
+
|
|
49
|
+
Re-tested live against the same account.
|
|
50
|
+
|
|
51
|
+
- **L1 confirmed fixed.** `set_light()` with no `light_type` sent the type the printer had reported, and the printer answered
|
|
52
|
+
`light/control` **done** for both off and on.
|
|
53
|
+
- **L2 confirmed fixed.** `sign_in_any()` followed at once by a fresh client from the entry alone, with no pause in between, signs in.
|
|
54
|
+
Previously this ended in `CredentialsRejectedError`.
|
|
55
|
+
- The similarity check is unchanged (see `CLEAN-ROOM.md`), and the secret scan found no hits in 38 files.
|
|
56
|
+
|
|
57
|
+
**Result: v0.1.0 accepted.**
|
|
58
|
+
|
|
59
|
+
## Integration test — 2026-09-28, `hass-anycubic-next` PR #4 (`clean/cloud` @ `a60314f`) with v0.1.0
|
|
60
|
+
|
|
61
|
+
Through the integration, on the live account: sign-in, the config flow, polling, MQTT, commands and the
|
|
62
|
+
panel all worked. One library item was found.
|
|
63
|
+
|
|
64
|
+
| # | Observed | Expected |
|
|
65
|
+
|---|---|---|
|
|
66
|
+
| L3 | The cloud camera never streams. Opening it in Home Assistant fails with *"The camera channel is encrypted: pass the Agora SDK public key (sdk_public_key_pem)"*. The integration doesn't pass the key, and nothing tells it to. | Ship Agora's public key (PROTOCOL D §1.7, 'Agora's key', answer to Q6) as the default. `sdk_public_key_pem` becomes an optional override. Add a test that joins an encrypted channel without passing a key, checking that the join message carries `aes_secret` wrapped under that key: decrypt it in the test with a throwaway key only when the override is used, and otherwise check its length and base64. |
|
|
67
|
+
|
|
68
|
+
- L3: **Fixed in 92225c7.** `agora.AGORA_SDK_PUBLIC_KEY` holds Agora's key (base64 SPKI text from PROTOCOL D §1.7) and is
|
|
69
|
+
the default; `sdk_public_key_pem` is only an optional override. Tests join an encrypted channel with no key passed (the
|
|
70
|
+
join carries `aes_secret` as base64 of exactly 128 bytes) and with a throwaway override key (the secret decrypts to the
|
|
71
|
+
channel key's raw UTF-8 bytes).
|
|
72
|
+
|
|
73
|
+
## Round 3 re-test — 2026-09-28, `clean/round-3` @ `a2a398c` (PR #2)
|
|
74
|
+
|
|
75
|
+
- The default key in `agora.py` is byte-for-byte the one in Agora's `agora-rtc-sdk-ng` 4.24.0 bundle.
|
|
76
|
+
- **L3 confirmed fixed live.** The cloud camera streams: WebKit opened `camera.…_cloud_camera` through Home Assistant's WebRTC, and the live
|
|
77
|
+
picture of the printer's chamber appeared. The integration passes no key, so the default is what's used.
|
|
78
|
+
- Similarity is unchanged, and the secret scan found no hits in 36 files.
|
|
79
|
+
|
|
80
|
+
**Result: accepted.**
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Clean-room record
|
|
2
|
+
|
|
3
|
+
This library is written so that it contains **no code or expression** from
|
|
4
|
+
these GPL-3.0 projects:
|
|
5
|
+
|
|
6
|
+
- `WaresWichall/hass-anycubic_cloud` (the original Anycubic Cloud integration)
|
|
7
|
+
- `Nino6689/hass-anycubic` and `Nino6689/anycubic-cloud-api` (its fork and the
|
|
8
|
+
fork's API library, published on PyPI as `anycubic-cloud-api`)
|
|
9
|
+
|
|
10
|
+
The fork's maintainer (Nino Bondonno) has worked on those projects, so the
|
|
11
|
+
work is split into two teams.
|
|
12
|
+
|
|
13
|
+
## Roles
|
|
14
|
+
|
|
15
|
+
| Role | Who | May read | Must not read |
|
|
16
|
+
|---|---|---|---|
|
|
17
|
+
| **Specification team** ("dirty") | Nino Bondonno, and Claude sessions on his machine that have read the GPL projects | Everything | — |
|
|
18
|
+
| **Implementation team** ("clean") | A separate Claude cloud session per phase, with no access to Nino's machine | This repository's `docs/`; `Nino6689/anycubic-lan` and `Nino6689/hass-anycubic-next` (both MIT); Python and dependency documentation; the MIT project `Jezza34000/homeassistant_petkit` for its Agora client | The three repositories above, the **source** of the PyPI package `anycubic-cloud-api`, any 0.x release of `anycubic-cloud-frontend`, and any other Anycubic integration or client |
|
|
19
|
+
|
|
20
|
+
Rules:
|
|
21
|
+
|
|
22
|
+
1. The specification team writes **facts and requirements only**: protocol
|
|
23
|
+
behaviour, formulas, field names, captured payloads with personal data
|
|
24
|
+
removed. It never writes or edits code in this repository.
|
|
25
|
+
2. The implementation team writes **all code**, from `docs/` and public
|
|
26
|
+
documentation. When a fact is missing it asks in `docs/QUESTIONS.md`; it
|
|
27
|
+
does not look elsewhere.
|
|
28
|
+
3. Feedback from live testing reaches the implementation team only as
|
|
29
|
+
functional reports (observed behaviour against expected), never as code.
|
|
30
|
+
4. Before publication, the code is compared mechanically with the GPL
|
|
31
|
+
projects and the result is recorded below.
|
|
32
|
+
|
|
33
|
+
## Anycubic's credentials
|
|
34
|
+
|
|
35
|
+
The cloud only answers clients that present Anycubic's own app credentials:
|
|
36
|
+
client ids, an app id, an app secret, and a TLS CA, client certificate and
|
|
37
|
+
client key for MQTT. These are Anycubic's data. **This library contains none of
|
|
38
|
+
them**, and neither do its specification or tests. `docs/` names each one
|
|
39
|
+
only by its role. The library takes them from its caller as one object (see
|
|
40
|
+
`INTEGRATION-SPEC.md`). Tests use obvious dummy values.
|
|
41
|
+
|
|
42
|
+
The `anycubic_cloud` 3.0 integration supplies them at run time from the
|
|
43
|
+
`anycubic-cloud-api` package, which every 2.x install already has. It loads
|
|
44
|
+
the named resource files and constants listed in the integration's own spec;
|
|
45
|
+
nobody needs to read that package's source for this.
|
|
46
|
+
|
|
47
|
+
## Log
|
|
48
|
+
|
|
49
|
+
| Date | Team | Session | Inputs given | Outputs | Did not read the excluded repos |
|
|
50
|
+
|---|---|---|---|---|---|
|
|
51
|
+
| 2026-09-28 | Specification | Claude (local, Nino's machine) and four local helper sessions | The 2.x integration `hass-anycubic` 2.9.4 and library `anycubic-cloud-api` 0.4.32 source, tests and captured fixtures; field notes | `PROTOCOL.md` (Parts A–D), `INTEGRATION-SPEC.md`, this file, `README.md`, `LICENSE`, CI workflows. Credentials named by role only; scanned against the real values: no match | n/a — specification team |
|
|
52
|
+
| 2026-09-28 | Implementation | Claude cloud session (routine, v0.1.0) | This repository's `docs/` (`PROTOCOL.md` Parts A–D, `INTEGRATION-SPEC.md`, `CLEAN-ROOM.md`, `QUESTIONS.md`), `README.md` and CI workflows; `Nino6689/anycubic-lan` (`src/anycubic_lan/` `__init__.py`, `reports.py`, `models.py`, `exceptions.py`, `client.py`; `pyproject.toml`; `README.md`) and its PyPI wheel 0.1.0 (compared: identical to that source); `Nino6689/hass-anycubic-next` `docs/BEHAVIOUR.md` (§2.17, §2.18, §5, §6, §7, §9), `docs/COMPAT.md` (§6), `docs/CLOUD.md`, `docs/DECISIONS.md`; `Jezza34000/homeassistant_petkit` at commit `a749d54` (`LICENSE`, `custom_components/petkit/agora_api.py`, `agora_websocket.py`, `agora_sdp.py`, `webrtc_common.py`; `camera.py` searched for its Agora calls only) | `src/anycubic_cloud_client/` (all modules; `agora.py` and `agora_sdp.py` adapted from petkit with its MIT notice), `tests/`, `pyproject.toml`, `README.md`, `docs/QUESTIONS.md` Q1–Q15, this row. The Agora SDK's npm package was not fetched (Q6) | Yes |
|
|
53
|
+
| 2026-09-28 | Specification | Claude (local) | PR #1 @ `e8f7ad8`, black-box against a real account; the 2.x code for the light-type fact | `ACCEPTANCE.md` (L1, L2); PROTOCOL A §3.7, §4.2 and §4.3 (the rate limit, success code 1), B §2.3.9 (the function names are compatibility data) | n/a — specification team |
|
|
54
|
+
| 2026-09-28 | Implementation | Claude cloud session (round 2) | This repository: `docs/CLEAN-ROOM.md`, `docs/ACCEPTANCE.md`, the `origin/main` diff of `docs/PROTOCOL.md` (A §3.7, §4.2, §4.3; B §2.3.9), PROTOCOL B §5.4.7 and C §4.7, `README.md`, CI workflow, and the branch's own `src/` and `tests/`; `Nino6689/anycubic-lan` `src/anycubic_lan/reports.py` (its `Light`/`LightReport` parser) and the installed PyPI wheel's exports | L1 and L2 fixes in `client.py`, `mqtt.py`, `messages.py`; tests; `README.md` (reusing a sign-in's tokens, lights); the fix notes in `ACCEPTANCE.md`; this row | Yes |
|
|
55
|
+
| 2026-09-28 | Specification | Claude (local) | PR #1 @ `e5df26f`, black-box against the live account | L1 and L2 confirmed on hardware; v0.1.0 accepted; similarity and secret scan unchanged | n/a — specification team |
|
|
56
|
+
| 2026-09-28 | Implementation | Claude cloud session (round 3) | This repository: `docs/CLEAN-ROOM.md`, `docs/ACCEPTANCE.md` (L3), `docs/QUESTIONS.md` (Q6 and its answer), the `origin/main` diff of `docs/PROTOCOL.md` (D §1.7 'Agora's key' and the encryption-fields row), `README.md`, the CI and publish workflows, and the repository's own `src/anycubic_cloud_client/agora.py`, `__init__.py` and `tests/test_agora.py`; the `cryptography` package's API | L3 fix in `agora.py` (Agora's key as the default, override kept); tests in `test_agora.py`; `README.md`; Q6 marked applied in `QUESTIONS.md`; the fix note in `ACCEPTANCE.md`; this row. No key file added; the wheel was built and checked for key files: none | Yes |
|
|
57
|
+
| 2026-09-28 | Specification | Claude (local) | Agora's `agora-rtc-sdk-ng` 4.24.0 npm package (to extract its public key); PR #2 run through the integration against the live account | PROTOCOL D §1.7 'Agora's key' with provenance; L3 confirmed live (the camera streams) | n/a — specification team |
|
|
58
|
+
|
|
59
|
+
## Similarity checks
|
|
60
|
+
|
|
61
|
+
| Date | Artefact | Compared against | Longest identical run (non-blank, non-comment lines) | Result |
|
|
62
|
+
|---|---|---|---|---|
|
|
63
|
+
| 2026-09-28 | `src/`, `tests/` @ `e8f7ad8` | 2.x integration, 2.x library, WaresWichall | 25: Agora join-message fields. The same block is in the MIT `homeassistant_petkit` `agora_websocket.py`, which both 3.0 and 2.x adapted with credit. 23: the function-id names, which are compatibility data (BEH §2.14 `supported_functions`) given in PROTOCOL B §2.3.9. 6: SDP field names from the same MIT source. 5: order names from the spec's order table. 4: dataclass field names | Clean |
|