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.
Files changed (40) hide show
  1. anycubic_cloud_client-0.1.0/.gitignore +13 -0
  2. anycubic_cloud_client-0.1.0/LICENSE +21 -0
  3. anycubic_cloud_client-0.1.0/PKG-INFO +217 -0
  4. anycubic_cloud_client-0.1.0/README.md +186 -0
  5. anycubic_cloud_client-0.1.0/docs/ACCEPTANCE.md +80 -0
  6. anycubic_cloud_client-0.1.0/docs/CLEAN-ROOM.md +63 -0
  7. anycubic_cloud_client-0.1.0/docs/INTEGRATION-SPEC.md +203 -0
  8. anycubic_cloud_client-0.1.0/docs/PROTOCOL.md +4314 -0
  9. anycubic_cloud_client-0.1.0/docs/QUESTIONS.md +30 -0
  10. anycubic_cloud_client-0.1.0/pyproject.toml +100 -0
  11. anycubic_cloud_client-0.1.0/src/anycubic_cloud_client/__init__.py +253 -0
  12. anycubic_cloud_client-0.1.0/src/anycubic_cloud_client/agora.py +790 -0
  13. anycubic_cloud_client-0.1.0/src/anycubic_cloud_client/agora_sdp.py +436 -0
  14. anycubic_cloud_client-0.1.0/src/anycubic_cloud_client/client.py +1471 -0
  15. anycubic_cloud_client-0.1.0/src/anycubic_cloud_client/credentials.py +89 -0
  16. anycubic_cloud_client-0.1.0/src/anycubic_cloud_client/errors.py +144 -0
  17. anycubic_cloud_client-0.1.0/src/anycubic_cloud_client/firmware.py +119 -0
  18. anycubic_cloud_client-0.1.0/src/anycubic_cloud_client/messages.py +650 -0
  19. anycubic_cloud_client-0.1.0/src/anycubic_cloud_client/models.py +1217 -0
  20. anycubic_cloud_client-0.1.0/src/anycubic_cloud_client/mqtt.py +549 -0
  21. anycubic_cloud_client-0.1.0/src/anycubic_cloud_client/mqtt_identity.py +223 -0
  22. anycubic_cloud_client-0.1.0/src/anycubic_cloud_client/orders.py +390 -0
  23. anycubic_cloud_client-0.1.0/src/anycubic_cloud_client/printing.py +295 -0
  24. anycubic_cloud_client-0.1.0/src/anycubic_cloud_client/py.typed +0 -0
  25. anycubic_cloud_client-0.1.0/src/anycubic_cloud_client/regions.py +157 -0
  26. anycubic_cloud_client-0.1.0/src/anycubic_cloud_client/signing.py +118 -0
  27. anycubic_cloud_client-0.1.0/src/anycubic_cloud_client/tokens.py +377 -0
  28. anycubic_cloud_client-0.1.0/tests/__init__.py +0 -0
  29. anycubic_cloud_client-0.1.0/tests/conftest.py +287 -0
  30. anycubic_cloud_client-0.1.0/tests/payloads.py +380 -0
  31. anycubic_cloud_client-0.1.0/tests/test_agora.py +877 -0
  32. anycubic_cloud_client-0.1.0/tests/test_basics.py +348 -0
  33. anycubic_cloud_client-0.1.0/tests/test_client_auth.py +756 -0
  34. anycubic_cloud_client-0.1.0/tests/test_client_endpoints.py +1127 -0
  35. anycubic_cloud_client-0.1.0/tests/test_firmware.py +83 -0
  36. anycubic_cloud_client-0.1.0/tests/test_messages.py +633 -0
  37. anycubic_cloud_client-0.1.0/tests/test_models.py +450 -0
  38. anycubic_cloud_client-0.1.0/tests/test_mqtt.py +743 -0
  39. anycubic_cloud_client-0.1.0/tests/test_printing.py +230 -0
  40. anycubic_cloud_client-0.1.0/tests/test_tokens.py +295 -0
@@ -0,0 +1,13 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ build/
6
+ dist/
7
+ *.egg-info/
8
+ .coverage
9
+ coverage.xml
10
+ htmlcov/
11
+ .pytest_cache/
12
+ .mypy_cache/
13
+ .ruff_cache/
@@ -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 |