pycentauri 0.4.0__tar.gz → 0.4.2__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.
- {pycentauri-0.4.0 → pycentauri-0.4.2}/CHANGELOG.md +34 -0
- {pycentauri-0.4.0 → pycentauri-0.4.2}/PKG-INFO +74 -45
- {pycentauri-0.4.0 → pycentauri-0.4.2}/README.md +72 -43
- {pycentauri-0.4.0 → pycentauri-0.4.2}/pyproject.toml +2 -2
- pycentauri-0.4.2/src/pycentauri/__init__.py +28 -0
- {pycentauri-0.4.0 → pycentauri-0.4.2}/src/pycentauri/cli.py +0 -13
- {pycentauri-0.4.0 → pycentauri-0.4.2}/src/pycentauri/models.py +21 -4
- {pycentauri-0.4.0 → pycentauri-0.4.2}/src/pycentauri/web/app.js +35 -16
- {pycentauri-0.4.0 → pycentauri-0.4.2}/src/pycentauri/web/styles.css +9 -5
- pycentauri-0.4.0/src/pycentauri/__init__.py +0 -18
- {pycentauri-0.4.0 → pycentauri-0.4.2}/.gitignore +0 -0
- {pycentauri-0.4.0 → pycentauri-0.4.2}/LICENSE +0 -0
- {pycentauri-0.4.0 → pycentauri-0.4.2}/src/pycentauri/camera.py +0 -0
- {pycentauri-0.4.0 → pycentauri-0.4.2}/src/pycentauri/client.py +0 -0
- {pycentauri-0.4.0 → pycentauri-0.4.2}/src/pycentauri/discovery.py +0 -0
- {pycentauri-0.4.0 → pycentauri-0.4.2}/src/pycentauri/mcp/__init__.py +0 -0
- {pycentauri-0.4.0 → pycentauri-0.4.2}/src/pycentauri/mcp/__main__.py +0 -0
- {pycentauri-0.4.0 → pycentauri-0.4.2}/src/pycentauri/mcp/server.py +0 -0
- {pycentauri-0.4.0 → pycentauri-0.4.2}/src/pycentauri/py.typed +0 -0
- {pycentauri-0.4.0 → pycentauri-0.4.2}/src/pycentauri/rtsp.py +0 -0
- {pycentauri-0.4.0 → pycentauri-0.4.2}/src/pycentauri/sdcp.py +0 -0
- {pycentauri-0.4.0 → pycentauri-0.4.2}/src/pycentauri/server.py +0 -0
- {pycentauri-0.4.0 → pycentauri-0.4.2}/src/pycentauri/web/__init__.py +0 -0
- {pycentauri-0.4.0 → pycentauri-0.4.2}/src/pycentauri/web/index.html +0 -0
- {pycentauri-0.4.0 → pycentauri-0.4.2}/tests/__init__.py +0 -0
- {pycentauri-0.4.0 → pycentauri-0.4.2}/tests/integration/__init__.py +0 -0
- {pycentauri-0.4.0 → pycentauri-0.4.2}/tests/test_client.py +0 -0
- {pycentauri-0.4.0 → pycentauri-0.4.2}/tests/test_discovery.py +0 -0
- {pycentauri-0.4.0 → pycentauri-0.4.2}/tests/test_rtsp.py +0 -0
- {pycentauri-0.4.0 → pycentauri-0.4.2}/tests/test_sdcp.py +0 -0
- {pycentauri-0.4.0 → pycentauri-0.4.2}/tests/test_server.py +0 -0
|
@@ -6,6 +6,40 @@ Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.4.2] - 2026-04-22
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- Complete `PrintInfo.Status` code table, lifted verbatim from the
|
|
13
|
+
authoritative enum in Elegoo's `elegoo-link` SDK. Previously the web
|
|
14
|
+
UI (and the `pycentauri.models.PrintStatus` constants) only covered
|
|
15
|
+
about half the codes — which meant users hit `CODE·20` literally
|
|
16
|
+
during the routine PREHEAT-DONE transition between prints, and
|
|
17
|
+
similarly for 11, 14, 15, 16, 17, 19, 21, 22, 23–26.
|
|
18
|
+
- Added a distinct visual class for the ERROR state (code 14) in the
|
|
19
|
+
web UI so it renders in red with a soft glow instead of falling into
|
|
20
|
+
the generic "unknown" bucket.
|
|
21
|
+
- Renamed a couple of previously-incorrect labels: code 12 was shown as
|
|
22
|
+
"PREPARING", it's actually "RESUMING"; code 18 was shown as
|
|
23
|
+
"RESUMED", it's actually "PRINT START" — both per the SDK.
|
|
24
|
+
|
|
25
|
+
## [0.4.1] - 2026-04-22
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
- README audited end-to-end for accuracy: removed claims about a
|
|
29
|
+
`~/.config/pycentauri/config.toml` loader that was never implemented,
|
|
30
|
+
an `upload_file` / `list_files` MCP tool that doesn't exist, and a
|
|
31
|
+
`centauri files` command that was a stub. Added the RTSP endpoints
|
|
32
|
+
(`/api/rtsp/*`) and the FastAPI auto-docs URLs to the endpoint table,
|
|
33
|
+
plus a note that the RTSP bridge itself isn't gated by
|
|
34
|
+
`--enable-control`.
|
|
35
|
+
- Updated the package tagline on PyPI and in the module docstring to
|
|
36
|
+
reflect all six surfaces rather than just three.
|
|
37
|
+
|
|
38
|
+
### Removed
|
|
39
|
+
- The stub `centauri files` CLI command. It always exited with
|
|
40
|
+
"not yet supported" and was only there to reserve the name; not
|
|
41
|
+
worth the README lie.
|
|
42
|
+
|
|
9
43
|
## [0.4.0] - 2026-04-22
|
|
10
44
|
|
|
11
45
|
### Added
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pycentauri
|
|
3
|
-
Version: 0.4.
|
|
4
|
-
Summary: Python client
|
|
3
|
+
Version: 0.4.2
|
|
4
|
+
Summary: Local-network toolkit for Elegoo Centauri Carbon 3D printers: async Python client, CLI, MCP server, REST/SSE HTTP server, web UI, and RTSP bridge
|
|
5
5
|
Project-URL: Homepage, https://github.com/bjan/pycentauri
|
|
6
6
|
Project-URL: Repository, https://github.com/bjan/pycentauri
|
|
7
7
|
Project-URL: Issues, https://github.com/bjan/pycentauri/issues
|
|
@@ -48,8 +48,10 @@ Description-Content-Type: text/markdown
|
|
|
48
48
|
|
|
49
49
|
# pycentauri
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
Carbon](https://www.elegoo.com/) 3D printer.
|
|
51
|
+
Local-network toolkit for the [Elegoo Centauri
|
|
52
|
+
Carbon](https://www.elegoo.com/) 3D printer. Python library, CLI, MCP
|
|
53
|
+
server, REST/SSE server, built-in web UI, and an RTSP bridge — all
|
|
54
|
+
powered by the same async client.
|
|
53
55
|
|
|
54
56
|
`pycentauri` speaks the printer's native SDCP v3 protocol over its local
|
|
55
57
|
WebSocket (port 3030) — no cloud account required. It exposes six surfaces:
|
|
@@ -68,18 +70,21 @@ WebSocket (port 3030) — no cloud account required. It exposes six surfaces:
|
|
|
68
70
|
> **Status:** alpha. The protocol has been reverse-engineered from the official
|
|
69
71
|
> [`elegoo-link`](https://github.com/ELEGOO-3D/elegoo-link) C++ SDK and the
|
|
70
72
|
> [`CentauriLink`](https://github.com/CentauriLink/Centauri-Link) project. It
|
|
71
|
-
> works against the original Centauri Carbon on current firmware
|
|
72
|
-
> Centauri Carbon 2 (which uses MQTT) is not supported.
|
|
73
|
+
> works against the original Centauri Carbon on current firmware (tested on
|
|
74
|
+
> V1.1.46). The newer Centauri Carbon 2 (which uses MQTT) is not supported.
|
|
73
75
|
|
|
74
76
|
## Install
|
|
75
77
|
|
|
76
78
|
```sh
|
|
77
79
|
pip install pycentauri # library + CLI
|
|
78
80
|
pip install "pycentauri[mcp]" # + MCP server
|
|
79
|
-
pip install "pycentauri[server]" # + HTTP REST/SSE server
|
|
80
|
-
pip install "pycentauri[mcp,server]" #
|
|
81
|
+
pip install "pycentauri[server]" # + HTTP REST/SSE server + web UI
|
|
82
|
+
pip install "pycentauri[mcp,server]" # all Python surfaces
|
|
81
83
|
```
|
|
82
84
|
|
|
85
|
+
The RTSP bridge additionally requires [MediaMTX](https://github.com/bluenviron/mediamtx/releases)
|
|
86
|
+
and `ffmpeg` on `$PATH` — see the RTSP section below for install hints.
|
|
87
|
+
|
|
83
88
|
## Quick start — CLI
|
|
84
89
|
|
|
85
90
|
```sh
|
|
@@ -96,17 +101,19 @@ centauri watch --host 192.168.1.209
|
|
|
96
101
|
# Grab a webcam snapshot
|
|
97
102
|
centauri snapshot --host 192.168.1.209 shot.jpg
|
|
98
103
|
|
|
99
|
-
#
|
|
100
|
-
centauri
|
|
104
|
+
# Printer attributes (model, firmware, mainboard ID, capabilities)
|
|
105
|
+
centauri attributes --host 192.168.1.209
|
|
101
106
|
|
|
102
|
-
# Control actions require --enable-control
|
|
107
|
+
# Control actions — require --enable-control
|
|
103
108
|
centauri print start cube.gcode --host 192.168.1.209 --enable-control
|
|
104
|
-
centauri print pause
|
|
105
|
-
centauri print resume
|
|
106
|
-
centauri print stop
|
|
109
|
+
centauri print pause --host 192.168.1.209 --enable-control
|
|
110
|
+
centauri print resume --host 192.168.1.209 --enable-control
|
|
111
|
+
centauri print stop --host 192.168.1.209 --enable-control
|
|
107
112
|
```
|
|
108
113
|
|
|
109
|
-
The host can also come from `PYCENTAURI_HOST`
|
|
114
|
+
The host can also come from the `PYCENTAURI_HOST` environment variable.
|
|
115
|
+
If neither is set, every command auto-discovers via a 2.5 s UDP broadcast
|
|
116
|
+
and bails out if it finds zero or more than one printer.
|
|
110
117
|
|
|
111
118
|
## Quick start — Python
|
|
112
119
|
|
|
@@ -117,26 +124,30 @@ from pycentauri import Printer
|
|
|
117
124
|
async def main():
|
|
118
125
|
async with await Printer.connect("192.168.1.209") as printer:
|
|
119
126
|
status = await printer.status()
|
|
120
|
-
print(status.
|
|
127
|
+
print(status.print_status, status.progress, status.temp_nozzle)
|
|
121
128
|
|
|
122
129
|
jpeg = await printer.snapshot()
|
|
123
130
|
with open("shot.jpg", "wb") as f:
|
|
124
131
|
f.write(jpeg)
|
|
125
132
|
|
|
126
133
|
async for update in printer.watch():
|
|
127
|
-
print(update.
|
|
134
|
+
print(update.print_status, update.progress)
|
|
128
135
|
|
|
129
136
|
asyncio.run(main())
|
|
130
137
|
```
|
|
131
138
|
|
|
139
|
+
Control actions (`start_print`, `pause`, `resume`, `stop`) require
|
|
140
|
+
`Printer.connect(..., enable_control=True)`.
|
|
141
|
+
|
|
132
142
|
## Quick start — HTTP server
|
|
133
143
|
|
|
134
144
|
```sh
|
|
135
145
|
# Read-only, bound to loopback so only this box can hit it:
|
|
136
146
|
centauri server --host 192.168.1.209 --port 8787
|
|
137
147
|
|
|
138
|
-
# Read + write, bound to all interfaces (put a reverse proxy in front):
|
|
139
|
-
centauri server --host 192.168.1.209 --bind 0.0.0.0 --port 8787
|
|
148
|
+
# Read + write + RTSP, bound to all interfaces (put a reverse proxy in front):
|
|
149
|
+
centauri server --host 192.168.1.209 --bind 0.0.0.0 --port 8787 \
|
|
150
|
+
--enable-control --rtsp
|
|
140
151
|
```
|
|
141
152
|
|
|
142
153
|
| Method | Path | Notes |
|
|
@@ -144,12 +155,15 @@ centauri server --host 192.168.1.209 --bind 0.0.0.0 --port 8787 --enable-control
|
|
|
144
155
|
| `GET` | `/` | Redirects to `/ui/` |
|
|
145
156
|
| `GET` | `/ui/` | Built-in web dashboard |
|
|
146
157
|
| `GET` | `/api/info` | Health + version (JSON) |
|
|
147
|
-
| `GET` | `/status` | Latest status push (cached; updates every ~
|
|
158
|
+
| `GET` | `/status` | Latest status push (cached; updates every ~5 s) |
|
|
148
159
|
| `GET` | `/attributes` | Printer attributes |
|
|
149
160
|
| `GET` | `/snapshot` | `image/jpeg` response |
|
|
150
161
|
| `GET` | `/stream` | MJPEG stream proxied from the printer (embeds in `<img>`) |
|
|
151
162
|
| `GET` | `/discover` | LAN scan |
|
|
152
163
|
| `GET` | `/events/status` | Server-Sent Events stream of pushes |
|
|
164
|
+
| `GET` | `/api/rtsp` | RTSP bridge state (when `--rtsp` is set) |
|
|
165
|
+
| `POST` | `/api/rtsp/{start,stop}` | Start/stop the RTSP bridge (when `--rtsp` is set) |
|
|
166
|
+
| `GET` | `/docs` / `/redoc` | Auto-generated OpenAPI docs |
|
|
153
167
|
| `POST` | `/print/start` | Body: `{"filename": "cube.gcode"}`. Requires `--enable-control`. |
|
|
154
168
|
| `POST` | `/print/{pause,resume,stop}` | Requires `--enable-control`. |
|
|
155
169
|
|
|
@@ -160,7 +174,7 @@ the firmware's 5-slot limit.
|
|
|
160
174
|
## Quick start — RTSP bridge
|
|
161
175
|
|
|
162
176
|
```sh
|
|
163
|
-
# Install dependencies first:
|
|
177
|
+
# Install system dependencies first:
|
|
164
178
|
# mediamtx https://github.com/bluenviron/mediamtx/releases (or `brew install mediamtx`)
|
|
165
179
|
# ffmpeg `brew install ffmpeg` or `apt install ffmpeg`
|
|
166
180
|
|
|
@@ -170,64 +184,79 @@ centauri rtsp --host 192.168.1.209
|
|
|
170
184
|
|
|
171
185
|
# Or, integrated with the HTTP server + web UI
|
|
172
186
|
centauri server --host 192.168.1.209 --rtsp --bind 0.0.0.0
|
|
173
|
-
#
|
|
174
|
-
#
|
|
187
|
+
# The web UI gains a STREAM panel with a start/stop toggle, a copy-URL button,
|
|
188
|
+
# and live status: http://<this-host>:8787/ui/
|
|
175
189
|
```
|
|
176
190
|
|
|
177
191
|
Open that URL in **VLC** (Media → Open Network Stream), point **Home Assistant**
|
|
178
192
|
at it via the Generic Camera integration, or feed it to **Frigate** / **Jellyfin**
|
|
179
193
|
/ **Synology Surveillance** for NVR recording and motion detection.
|
|
180
194
|
|
|
181
|
-
MediaMTX runs the ffmpeg transcode only while at least one client is
|
|
182
|
-
connected, so idle cost is zero. Tunable: `--fps`,
|
|
183
|
-
`--path`, `--port`, `--bind
|
|
184
|
-
|
|
195
|
+
MediaMTX runs the ffmpeg transcode only while at least one client is
|
|
196
|
+
actually connected, so idle cost is zero. Tunable flags: `--fps`,
|
|
197
|
+
`--bitrate`, `--preset`, `--path`, `--port`, `--bind` (standalone),
|
|
198
|
+
or `--rtsp-fps`, `--rtsp-bitrate`, `--rtsp-path`, `--rtsp-port`,
|
|
199
|
+
`--rtsp-bind` (on `centauri server`). Run `centauri rtsp --help` or
|
|
200
|
+
`centauri server --help` for the full list.
|
|
185
201
|
|
|
186
202
|
## Quick start — MCP
|
|
187
203
|
|
|
188
204
|
Register the server with your agent. With Claude Code:
|
|
189
205
|
|
|
190
206
|
```sh
|
|
191
|
-
claude mcp add pycentauri --
|
|
192
|
-
|
|
193
|
-
|
|
207
|
+
claude mcp add pycentauri --env PYCENTAURI_HOST=192.168.1.209 \
|
|
208
|
+
-- python -m pycentauri.mcp
|
|
209
|
+
|
|
210
|
+
# Or, with control actions enabled (gives the agent start/pause/resume/stop):
|
|
211
|
+
claude mcp add pycentauri --env PYCENTAURI_HOST=192.168.1.209 \
|
|
212
|
+
-- python -m pycentauri.mcp --enable-control
|
|
194
213
|
```
|
|
195
214
|
|
|
196
|
-
|
|
197
|
-
arbitrary IP
|
|
215
|
+
Setting `PYCENTAURI_HOST` in the MCP server's launch env means the agent
|
|
216
|
+
can't be tricked into targeting an arbitrary IP through prompt injection —
|
|
217
|
+
the host is pinned at spawn time. Tools exposed:
|
|
198
218
|
|
|
199
219
|
| Tool | Always available | Description |
|
|
200
220
|
|---|---|---|
|
|
201
221
|
| `get_status` | yes | State, temperatures, progress, elapsed/remaining |
|
|
202
|
-
| `get_attributes` | yes | Model, firmware, mainboard ID |
|
|
203
|
-
| `
|
|
204
|
-
| `get_snapshot` | yes | Webcam frame as MCP `Image` content |
|
|
222
|
+
| `get_attributes` | yes | Model, firmware, mainboard ID, capabilities |
|
|
223
|
+
| `get_snapshot` | yes | Webcam frame as MCP `Image` content (LLMs see the picture) |
|
|
205
224
|
| `discover_printers` | yes | LAN scan |
|
|
206
|
-
| `start_print` | only with `--enable-control` | Starts a print |
|
|
225
|
+
| `start_print` | only with `--enable-control` | Starts a print from a file already on the printer |
|
|
207
226
|
| `pause_print` | only with `--enable-control` | Pauses the current print |
|
|
208
227
|
| `resume_print` | only with `--enable-control` | Resumes a paused print |
|
|
209
228
|
| `stop_print` | only with `--enable-control` | Stops the current print |
|
|
210
|
-
|
|
229
|
+
|
|
230
|
+
Control tools aren't just gated — they're not *registered* without the
|
|
231
|
+
flag, so an LLM that wasn't given the `--enable-control` launch can't see
|
|
232
|
+
them in the tool list at all.
|
|
211
233
|
|
|
212
234
|
## Safety
|
|
213
235
|
|
|
214
|
-
|
|
215
|
-
`--enable-control` flag (CLI / MCP). Destructive MCP tools
|
|
216
|
-
registered when the flag is off, so an LLM never sees them. Still:
|
|
217
|
-
printer running unattended with write-capable agents is your
|
|
236
|
+
Printer control actions are gated behind an explicit `enable_control=True`
|
|
237
|
+
(library) or `--enable-control` flag (CLI / MCP). Destructive MCP tools
|
|
238
|
+
aren't registered when the flag is off, so an LLM never sees them. Still:
|
|
239
|
+
leaving a printer running unattended with write-capable agents is your
|
|
240
|
+
responsibility, as is securing the HTTP surface (it's unauthenticated — put
|
|
241
|
+
a reverse proxy with auth in front if you expose it beyond loopback).
|
|
242
|
+
|
|
243
|
+
The RTSP bridge itself isn't gated by `--enable-control` because it only
|
|
244
|
+
reads the webcam feed and doesn't change printer state; it's enabled
|
|
245
|
+
per-server via the `--rtsp` flag.
|
|
218
246
|
|
|
219
247
|
## Known firmware quirks
|
|
220
248
|
|
|
221
249
|
- **5 concurrent WebSocket connections max.** The printer's SDCP server
|
|
222
250
|
accepts up to 5 open WebSockets on port 3030; the 6th returns HTTP 500
|
|
223
251
|
with body `"too many client"`. Slots release immediately when a
|
|
224
|
-
connection closes — the CLI
|
|
225
|
-
|
|
252
|
+
connection closes — the CLI each opens and closes one per invocation,
|
|
253
|
+
the HTTP and MCP servers hold a single long-lived one, so this is
|
|
254
|
+
almost never a problem in practice.
|
|
226
255
|
- **Paused / errored states don't auto-push Attributes.** The printer only
|
|
227
256
|
sends its `Attributes` frame spontaneously while idle or printing. In
|
|
228
257
|
paused and errored states it stays silent until asked. Since every SDCP
|
|
229
|
-
command needs the printer's `MainboardID`, the client
|
|
230
|
-
|
|
258
|
+
command needs the printer's `MainboardID`, the client handles this by
|
|
259
|
+
pre-seeding the mainboard ID from a UDP discovery on every connect
|
|
231
260
|
(as of v0.1.1). If you call `Printer.connect()` directly without
|
|
232
261
|
discovery, pass `mainboard_id=` yourself.
|
|
233
262
|
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# pycentauri
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
Carbon](https://www.elegoo.com/) 3D printer.
|
|
3
|
+
Local-network toolkit for the [Elegoo Centauri
|
|
4
|
+
Carbon](https://www.elegoo.com/) 3D printer. Python library, CLI, MCP
|
|
5
|
+
server, REST/SSE server, built-in web UI, and an RTSP bridge — all
|
|
6
|
+
powered by the same async client.
|
|
5
7
|
|
|
6
8
|
`pycentauri` speaks the printer's native SDCP v3 protocol over its local
|
|
7
9
|
WebSocket (port 3030) — no cloud account required. It exposes six surfaces:
|
|
@@ -20,18 +22,21 @@ WebSocket (port 3030) — no cloud account required. It exposes six surfaces:
|
|
|
20
22
|
> **Status:** alpha. The protocol has been reverse-engineered from the official
|
|
21
23
|
> [`elegoo-link`](https://github.com/ELEGOO-3D/elegoo-link) C++ SDK and the
|
|
22
24
|
> [`CentauriLink`](https://github.com/CentauriLink/Centauri-Link) project. It
|
|
23
|
-
> works against the original Centauri Carbon on current firmware
|
|
24
|
-
> Centauri Carbon 2 (which uses MQTT) is not supported.
|
|
25
|
+
> works against the original Centauri Carbon on current firmware (tested on
|
|
26
|
+
> V1.1.46). The newer Centauri Carbon 2 (which uses MQTT) is not supported.
|
|
25
27
|
|
|
26
28
|
## Install
|
|
27
29
|
|
|
28
30
|
```sh
|
|
29
31
|
pip install pycentauri # library + CLI
|
|
30
32
|
pip install "pycentauri[mcp]" # + MCP server
|
|
31
|
-
pip install "pycentauri[server]" # + HTTP REST/SSE server
|
|
32
|
-
pip install "pycentauri[mcp,server]" #
|
|
33
|
+
pip install "pycentauri[server]" # + HTTP REST/SSE server + web UI
|
|
34
|
+
pip install "pycentauri[mcp,server]" # all Python surfaces
|
|
33
35
|
```
|
|
34
36
|
|
|
37
|
+
The RTSP bridge additionally requires [MediaMTX](https://github.com/bluenviron/mediamtx/releases)
|
|
38
|
+
and `ffmpeg` on `$PATH` — see the RTSP section below for install hints.
|
|
39
|
+
|
|
35
40
|
## Quick start — CLI
|
|
36
41
|
|
|
37
42
|
```sh
|
|
@@ -48,17 +53,19 @@ centauri watch --host 192.168.1.209
|
|
|
48
53
|
# Grab a webcam snapshot
|
|
49
54
|
centauri snapshot --host 192.168.1.209 shot.jpg
|
|
50
55
|
|
|
51
|
-
#
|
|
52
|
-
centauri
|
|
56
|
+
# Printer attributes (model, firmware, mainboard ID, capabilities)
|
|
57
|
+
centauri attributes --host 192.168.1.209
|
|
53
58
|
|
|
54
|
-
# Control actions require --enable-control
|
|
59
|
+
# Control actions — require --enable-control
|
|
55
60
|
centauri print start cube.gcode --host 192.168.1.209 --enable-control
|
|
56
|
-
centauri print pause
|
|
57
|
-
centauri print resume
|
|
58
|
-
centauri print stop
|
|
61
|
+
centauri print pause --host 192.168.1.209 --enable-control
|
|
62
|
+
centauri print resume --host 192.168.1.209 --enable-control
|
|
63
|
+
centauri print stop --host 192.168.1.209 --enable-control
|
|
59
64
|
```
|
|
60
65
|
|
|
61
|
-
The host can also come from `PYCENTAURI_HOST`
|
|
66
|
+
The host can also come from the `PYCENTAURI_HOST` environment variable.
|
|
67
|
+
If neither is set, every command auto-discovers via a 2.5 s UDP broadcast
|
|
68
|
+
and bails out if it finds zero or more than one printer.
|
|
62
69
|
|
|
63
70
|
## Quick start — Python
|
|
64
71
|
|
|
@@ -69,26 +76,30 @@ from pycentauri import Printer
|
|
|
69
76
|
async def main():
|
|
70
77
|
async with await Printer.connect("192.168.1.209") as printer:
|
|
71
78
|
status = await printer.status()
|
|
72
|
-
print(status.
|
|
79
|
+
print(status.print_status, status.progress, status.temp_nozzle)
|
|
73
80
|
|
|
74
81
|
jpeg = await printer.snapshot()
|
|
75
82
|
with open("shot.jpg", "wb") as f:
|
|
76
83
|
f.write(jpeg)
|
|
77
84
|
|
|
78
85
|
async for update in printer.watch():
|
|
79
|
-
print(update.
|
|
86
|
+
print(update.print_status, update.progress)
|
|
80
87
|
|
|
81
88
|
asyncio.run(main())
|
|
82
89
|
```
|
|
83
90
|
|
|
91
|
+
Control actions (`start_print`, `pause`, `resume`, `stop`) require
|
|
92
|
+
`Printer.connect(..., enable_control=True)`.
|
|
93
|
+
|
|
84
94
|
## Quick start — HTTP server
|
|
85
95
|
|
|
86
96
|
```sh
|
|
87
97
|
# Read-only, bound to loopback so only this box can hit it:
|
|
88
98
|
centauri server --host 192.168.1.209 --port 8787
|
|
89
99
|
|
|
90
|
-
# Read + write, bound to all interfaces (put a reverse proxy in front):
|
|
91
|
-
centauri server --host 192.168.1.209 --bind 0.0.0.0 --port 8787
|
|
100
|
+
# Read + write + RTSP, bound to all interfaces (put a reverse proxy in front):
|
|
101
|
+
centauri server --host 192.168.1.209 --bind 0.0.0.0 --port 8787 \
|
|
102
|
+
--enable-control --rtsp
|
|
92
103
|
```
|
|
93
104
|
|
|
94
105
|
| Method | Path | Notes |
|
|
@@ -96,12 +107,15 @@ centauri server --host 192.168.1.209 --bind 0.0.0.0 --port 8787 --enable-control
|
|
|
96
107
|
| `GET` | `/` | Redirects to `/ui/` |
|
|
97
108
|
| `GET` | `/ui/` | Built-in web dashboard |
|
|
98
109
|
| `GET` | `/api/info` | Health + version (JSON) |
|
|
99
|
-
| `GET` | `/status` | Latest status push (cached; updates every ~
|
|
110
|
+
| `GET` | `/status` | Latest status push (cached; updates every ~5 s) |
|
|
100
111
|
| `GET` | `/attributes` | Printer attributes |
|
|
101
112
|
| `GET` | `/snapshot` | `image/jpeg` response |
|
|
102
113
|
| `GET` | `/stream` | MJPEG stream proxied from the printer (embeds in `<img>`) |
|
|
103
114
|
| `GET` | `/discover` | LAN scan |
|
|
104
115
|
| `GET` | `/events/status` | Server-Sent Events stream of pushes |
|
|
116
|
+
| `GET` | `/api/rtsp` | RTSP bridge state (when `--rtsp` is set) |
|
|
117
|
+
| `POST` | `/api/rtsp/{start,stop}` | Start/stop the RTSP bridge (when `--rtsp` is set) |
|
|
118
|
+
| `GET` | `/docs` / `/redoc` | Auto-generated OpenAPI docs |
|
|
105
119
|
| `POST` | `/print/start` | Body: `{"filename": "cube.gcode"}`. Requires `--enable-control`. |
|
|
106
120
|
| `POST` | `/print/{pause,resume,stop}` | Requires `--enable-control`. |
|
|
107
121
|
|
|
@@ -112,7 +126,7 @@ the firmware's 5-slot limit.
|
|
|
112
126
|
## Quick start — RTSP bridge
|
|
113
127
|
|
|
114
128
|
```sh
|
|
115
|
-
# Install dependencies first:
|
|
129
|
+
# Install system dependencies first:
|
|
116
130
|
# mediamtx https://github.com/bluenviron/mediamtx/releases (or `brew install mediamtx`)
|
|
117
131
|
# ffmpeg `brew install ffmpeg` or `apt install ffmpeg`
|
|
118
132
|
|
|
@@ -122,64 +136,79 @@ centauri rtsp --host 192.168.1.209
|
|
|
122
136
|
|
|
123
137
|
# Or, integrated with the HTTP server + web UI
|
|
124
138
|
centauri server --host 192.168.1.209 --rtsp --bind 0.0.0.0
|
|
125
|
-
#
|
|
126
|
-
#
|
|
139
|
+
# The web UI gains a STREAM panel with a start/stop toggle, a copy-URL button,
|
|
140
|
+
# and live status: http://<this-host>:8787/ui/
|
|
127
141
|
```
|
|
128
142
|
|
|
129
143
|
Open that URL in **VLC** (Media → Open Network Stream), point **Home Assistant**
|
|
130
144
|
at it via the Generic Camera integration, or feed it to **Frigate** / **Jellyfin**
|
|
131
145
|
/ **Synology Surveillance** for NVR recording and motion detection.
|
|
132
146
|
|
|
133
|
-
MediaMTX runs the ffmpeg transcode only while at least one client is
|
|
134
|
-
connected, so idle cost is zero. Tunable: `--fps`,
|
|
135
|
-
`--path`, `--port`, `--bind
|
|
136
|
-
|
|
147
|
+
MediaMTX runs the ffmpeg transcode only while at least one client is
|
|
148
|
+
actually connected, so idle cost is zero. Tunable flags: `--fps`,
|
|
149
|
+
`--bitrate`, `--preset`, `--path`, `--port`, `--bind` (standalone),
|
|
150
|
+
or `--rtsp-fps`, `--rtsp-bitrate`, `--rtsp-path`, `--rtsp-port`,
|
|
151
|
+
`--rtsp-bind` (on `centauri server`). Run `centauri rtsp --help` or
|
|
152
|
+
`centauri server --help` for the full list.
|
|
137
153
|
|
|
138
154
|
## Quick start — MCP
|
|
139
155
|
|
|
140
156
|
Register the server with your agent. With Claude Code:
|
|
141
157
|
|
|
142
158
|
```sh
|
|
143
|
-
claude mcp add pycentauri --
|
|
144
|
-
|
|
145
|
-
|
|
159
|
+
claude mcp add pycentauri --env PYCENTAURI_HOST=192.168.1.209 \
|
|
160
|
+
-- python -m pycentauri.mcp
|
|
161
|
+
|
|
162
|
+
# Or, with control actions enabled (gives the agent start/pause/resume/stop):
|
|
163
|
+
claude mcp add pycentauri --env PYCENTAURI_HOST=192.168.1.209 \
|
|
164
|
+
-- python -m pycentauri.mcp --enable-control
|
|
146
165
|
```
|
|
147
166
|
|
|
148
|
-
|
|
149
|
-
arbitrary IP
|
|
167
|
+
Setting `PYCENTAURI_HOST` in the MCP server's launch env means the agent
|
|
168
|
+
can't be tricked into targeting an arbitrary IP through prompt injection —
|
|
169
|
+
the host is pinned at spawn time. Tools exposed:
|
|
150
170
|
|
|
151
171
|
| Tool | Always available | Description |
|
|
152
172
|
|---|---|---|
|
|
153
173
|
| `get_status` | yes | State, temperatures, progress, elapsed/remaining |
|
|
154
|
-
| `get_attributes` | yes | Model, firmware, mainboard ID |
|
|
155
|
-
| `
|
|
156
|
-
| `get_snapshot` | yes | Webcam frame as MCP `Image` content |
|
|
174
|
+
| `get_attributes` | yes | Model, firmware, mainboard ID, capabilities |
|
|
175
|
+
| `get_snapshot` | yes | Webcam frame as MCP `Image` content (LLMs see the picture) |
|
|
157
176
|
| `discover_printers` | yes | LAN scan |
|
|
158
|
-
| `start_print` | only with `--enable-control` | Starts a print |
|
|
177
|
+
| `start_print` | only with `--enable-control` | Starts a print from a file already on the printer |
|
|
159
178
|
| `pause_print` | only with `--enable-control` | Pauses the current print |
|
|
160
179
|
| `resume_print` | only with `--enable-control` | Resumes a paused print |
|
|
161
180
|
| `stop_print` | only with `--enable-control` | Stops the current print |
|
|
162
|
-
|
|
181
|
+
|
|
182
|
+
Control tools aren't just gated — they're not *registered* without the
|
|
183
|
+
flag, so an LLM that wasn't given the `--enable-control` launch can't see
|
|
184
|
+
them in the tool list at all.
|
|
163
185
|
|
|
164
186
|
## Safety
|
|
165
187
|
|
|
166
|
-
|
|
167
|
-
`--enable-control` flag (CLI / MCP). Destructive MCP tools
|
|
168
|
-
registered when the flag is off, so an LLM never sees them. Still:
|
|
169
|
-
printer running unattended with write-capable agents is your
|
|
188
|
+
Printer control actions are gated behind an explicit `enable_control=True`
|
|
189
|
+
(library) or `--enable-control` flag (CLI / MCP). Destructive MCP tools
|
|
190
|
+
aren't registered when the flag is off, so an LLM never sees them. Still:
|
|
191
|
+
leaving a printer running unattended with write-capable agents is your
|
|
192
|
+
responsibility, as is securing the HTTP surface (it's unauthenticated — put
|
|
193
|
+
a reverse proxy with auth in front if you expose it beyond loopback).
|
|
194
|
+
|
|
195
|
+
The RTSP bridge itself isn't gated by `--enable-control` because it only
|
|
196
|
+
reads the webcam feed and doesn't change printer state; it's enabled
|
|
197
|
+
per-server via the `--rtsp` flag.
|
|
170
198
|
|
|
171
199
|
## Known firmware quirks
|
|
172
200
|
|
|
173
201
|
- **5 concurrent WebSocket connections max.** The printer's SDCP server
|
|
174
202
|
accepts up to 5 open WebSockets on port 3030; the 6th returns HTTP 500
|
|
175
203
|
with body `"too many client"`. Slots release immediately when a
|
|
176
|
-
connection closes — the CLI
|
|
177
|
-
|
|
204
|
+
connection closes — the CLI each opens and closes one per invocation,
|
|
205
|
+
the HTTP and MCP servers hold a single long-lived one, so this is
|
|
206
|
+
almost never a problem in practice.
|
|
178
207
|
- **Paused / errored states don't auto-push Attributes.** The printer only
|
|
179
208
|
sends its `Attributes` frame spontaneously while idle or printing. In
|
|
180
209
|
paused and errored states it stays silent until asked. Since every SDCP
|
|
181
|
-
command needs the printer's `MainboardID`, the client
|
|
182
|
-
|
|
210
|
+
command needs the printer's `MainboardID`, the client handles this by
|
|
211
|
+
pre-seeding the mainboard ID from a UDP discovery on every connect
|
|
183
212
|
(as of v0.1.1). If you call `Printer.connect()` directly without
|
|
184
213
|
discovery, pass `mainboard_id=` yourself.
|
|
185
214
|
|
|
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pycentauri"
|
|
7
|
-
version = "0.4.
|
|
8
|
-
description = "Python client
|
|
7
|
+
version = "0.4.2"
|
|
8
|
+
description = "Local-network toolkit for Elegoo Centauri Carbon 3D printers: async Python client, CLI, MCP server, REST/SSE HTTP server, web UI, and RTSP bridge"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
11
11
|
license-files = ["LICENSE"]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Local-network toolkit for Elegoo Centauri Carbon 3D printers.
|
|
2
|
+
|
|
3
|
+
Six surfaces, all backed by the same async client:
|
|
4
|
+
|
|
5
|
+
* :class:`pycentauri.Printer` — asyncio SDCP client
|
|
6
|
+
* the ``centauri`` CLI (``python -m pycentauri.cli``)
|
|
7
|
+
* an MCP stdio server at ``python -m pycentauri.mcp``
|
|
8
|
+
* a FastAPI + SSE HTTP server at :mod:`pycentauri.server`
|
|
9
|
+
* a static web UI bundled in :mod:`pycentauri.web`
|
|
10
|
+
* an RTSP/H.264 bridge via MediaMTX in :mod:`pycentauri.rtsp`
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from pycentauri.client import ControlDisabledError, Printer, PrinterError
|
|
14
|
+
from pycentauri.discovery import DiscoveredPrinter, discover
|
|
15
|
+
from pycentauri.models import Attributes, PrintInfo, Status
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Attributes",
|
|
19
|
+
"ControlDisabledError",
|
|
20
|
+
"DiscoveredPrinter",
|
|
21
|
+
"PrintInfo",
|
|
22
|
+
"Printer",
|
|
23
|
+
"PrinterError",
|
|
24
|
+
"Status",
|
|
25
|
+
"discover",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
__version__ = "0.4.2"
|
|
@@ -250,19 +250,6 @@ def cmd_snapshot(
|
|
|
250
250
|
_run(run())
|
|
251
251
|
|
|
252
252
|
|
|
253
|
-
@app.command("files")
|
|
254
|
-
def cmd_files(host: HostOpt = None, as_json: JsonOpt = False) -> None:
|
|
255
|
-
"""List files stored on the printer.
|
|
256
|
-
|
|
257
|
-
Note: the file-list command isn't wired in v0.1 (the upstream SDK also
|
|
258
|
-
marks it as not-implemented on CC); this is a stub so the command name
|
|
259
|
-
is reserved.
|
|
260
|
-
"""
|
|
261
|
-
|
|
262
|
-
_echo_err("file listing is not yet supported on the original Centauri Carbon firmware")
|
|
263
|
-
raise typer.Exit(code=1)
|
|
264
|
-
|
|
265
|
-
|
|
266
253
|
@print_cmd.command("start")
|
|
267
254
|
def cmd_print_start(
|
|
268
255
|
filename: Annotated[str, typer.Argument(help="File name as it appears on the printer.")],
|
|
@@ -178,10 +178,13 @@ class Status(BaseModel):
|
|
|
178
178
|
|
|
179
179
|
|
|
180
180
|
class PrintStatus:
|
|
181
|
-
"""
|
|
181
|
+
"""``PrintInfo.Status`` codes from the official Elegoo SDK.
|
|
182
182
|
|
|
183
|
-
|
|
184
|
-
|
|
183
|
+
Sourced verbatim from
|
|
184
|
+
``src/lan/adapters/elegoo_fdm_cc/elegoo_fdm_cc_message_adapter.cpp``
|
|
185
|
+
in the ``ELEGOO-3D/elegoo-link`` repository. Codes in the 2-4 and 23-26
|
|
186
|
+
ranges are resin-printer / LCD-specific and typically aren't surfaced
|
|
187
|
+
by the Centauri Carbon, but are kept for forward-compatibility.
|
|
185
188
|
"""
|
|
186
189
|
|
|
187
190
|
IDLE = 0
|
|
@@ -195,8 +198,22 @@ class PrintStatus:
|
|
|
195
198
|
STOPPED = 8
|
|
196
199
|
COMPLETED = 9
|
|
197
200
|
FILE_CHECKING = 10
|
|
198
|
-
|
|
201
|
+
PRINTER_CHECKING = 11
|
|
202
|
+
RESUMING = 12
|
|
199
203
|
PRINTING = 13
|
|
204
|
+
ERROR = 14
|
|
205
|
+
AUTO_LEVELING = 15
|
|
206
|
+
PREHEATING = 16
|
|
207
|
+
RESONANCE_TESTING = 17
|
|
208
|
+
PRINT_START = 18
|
|
209
|
+
AUTO_LEVELING_COMPLETED = 19
|
|
210
|
+
PREHEATING_COMPLETED = 20
|
|
211
|
+
HOMING_COMPLETED = 21
|
|
212
|
+
RESONANCE_TESTING_COMPLETED = 22
|
|
213
|
+
AUTO_FEEDING = 23
|
|
214
|
+
UNLOADING = 24
|
|
215
|
+
UNLOADING_ABNORMAL = 25
|
|
216
|
+
UNLOADING_PAUSED = 26
|
|
200
217
|
|
|
201
218
|
|
|
202
219
|
class Attributes(BaseModel):
|
|
@@ -5,22 +5,38 @@
|
|
|
5
5
|
const $ = (id) => document.getElementById(id);
|
|
6
6
|
|
|
7
7
|
// PrintInfo.Status → display label + semantic class.
|
|
8
|
-
//
|
|
8
|
+
// Table is authoritative, lifted from Elegoo's own elegoo-link SDK
|
|
9
|
+
// (src/lan/adapters/elegoo_fdm_cc/elegoo_fdm_cc_message_adapter.cpp:33–62).
|
|
10
|
+
// Codes 2/3/4 and 23–26 are resin-printer / LCD-specific — kept here for
|
|
11
|
+
// completeness in case a future firmware surfaces them on the Carbon.
|
|
9
12
|
const PRINT_STATUS = {
|
|
10
|
-
0: { label: "IDLE",
|
|
11
|
-
1: { label: "HOMING",
|
|
12
|
-
2: { label: "DROPPING",
|
|
13
|
-
3: { label: "EXPOSING",
|
|
14
|
-
4: { label: "LIFTING",
|
|
15
|
-
5: { label: "PAUSING",
|
|
16
|
-
6: { label: "PAUSED",
|
|
17
|
-
7: { label: "STOPPING",
|
|
18
|
-
8: { label: "STOPPED",
|
|
19
|
-
9: { label: "COMPLETED",
|
|
20
|
-
10: { label: "
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
13
|
+
0: { label: "IDLE", cls: "state-idle" },
|
|
14
|
+
1: { label: "HOMING", cls: "state-printing" },
|
|
15
|
+
2: { label: "DROPPING", cls: "state-printing" },
|
|
16
|
+
3: { label: "EXPOSING", cls: "state-printing" },
|
|
17
|
+
4: { label: "LIFTING", cls: "state-printing" },
|
|
18
|
+
5: { label: "PAUSING", cls: "state-paused" },
|
|
19
|
+
6: { label: "PAUSED", cls: "state-paused" },
|
|
20
|
+
7: { label: "STOPPING", cls: "state-stopped" },
|
|
21
|
+
8: { label: "STOPPED", cls: "state-stopped" },
|
|
22
|
+
9: { label: "COMPLETED", cls: "state-completed" },
|
|
23
|
+
10: { label: "FILE CHECK", cls: "state-printing" },
|
|
24
|
+
11: { label: "SELF CHECK", cls: "state-printing" },
|
|
25
|
+
12: { label: "RESUMING", cls: "state-printing" },
|
|
26
|
+
13: { label: "PRINTING", cls: "state-printing" },
|
|
27
|
+
14: { label: "ERROR", cls: "state-err" },
|
|
28
|
+
15: { label: "LEVELING", cls: "state-printing" },
|
|
29
|
+
16: { label: "PREHEATING", cls: "state-printing" },
|
|
30
|
+
17: { label: "RESONANCE", cls: "state-printing" },
|
|
31
|
+
18: { label: "PRINT START", cls: "state-printing" },
|
|
32
|
+
19: { label: "LEVEL DONE", cls: "state-printing" },
|
|
33
|
+
20: { label: "PREHEAT DONE", cls: "state-printing" },
|
|
34
|
+
21: { label: "HOMING DONE", cls: "state-printing" },
|
|
35
|
+
22: { label: "RESONANCE OK", cls: "state-printing" },
|
|
36
|
+
23: { label: "AUTO FEED", cls: "state-printing" },
|
|
37
|
+
24: { label: "UNLOADING", cls: "state-printing" },
|
|
38
|
+
25: { label: "UNLOAD ERR", cls: "state-err" },
|
|
39
|
+
26: { label: "UNLOAD PAUSE", cls: "state-paused" },
|
|
24
40
|
};
|
|
25
41
|
|
|
26
42
|
function fmtSeconds(s) {
|
|
@@ -107,7 +123,10 @@ function renderStatus(raw) {
|
|
|
107
123
|
const info = PRINT_STATUS[pstatus];
|
|
108
124
|
const sw = $("print-status");
|
|
109
125
|
const sc = $("print-status-code");
|
|
110
|
-
sw.classList.remove(
|
|
126
|
+
sw.classList.remove(
|
|
127
|
+
"state-idle", "state-printing", "state-paused",
|
|
128
|
+
"state-stopped", "state-completed", "state-err",
|
|
129
|
+
);
|
|
111
130
|
if (info) {
|
|
112
131
|
sw.textContent = info.label;
|
|
113
132
|
sw.classList.add(info.cls);
|
|
@@ -480,11 +480,15 @@ body {
|
|
|
480
480
|
text-transform: uppercase;
|
|
481
481
|
line-height: 1;
|
|
482
482
|
}
|
|
483
|
-
.state-word.state-idle
|
|
484
|
-
.state-word.state-printing
|
|
485
|
-
.state-word.state-paused
|
|
486
|
-
.state-word.state-stopped
|
|
487
|
-
.state-word.state-completed{ color: var(--cyan); }
|
|
483
|
+
.state-word.state-idle { color: var(--fg-dim); }
|
|
484
|
+
.state-word.state-printing { color: var(--ok); }
|
|
485
|
+
.state-word.state-paused { color: var(--warn); }
|
|
486
|
+
.state-word.state-stopped { color: var(--danger); }
|
|
487
|
+
.state-word.state-completed { color: var(--cyan); }
|
|
488
|
+
.state-word.state-err {
|
|
489
|
+
color: var(--danger);
|
|
490
|
+
text-shadow: 0 0 10px rgba(235, 92, 78, 0.45);
|
|
491
|
+
}
|
|
488
492
|
|
|
489
493
|
/* ---- Thermal gauges --------------------------------------- */
|
|
490
494
|
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
"""Python client for Elegoo Centauri Carbon 3D printers."""
|
|
2
|
-
|
|
3
|
-
from pycentauri.client import ControlDisabledError, Printer, PrinterError
|
|
4
|
-
from pycentauri.discovery import DiscoveredPrinter, discover
|
|
5
|
-
from pycentauri.models import Attributes, PrintInfo, Status
|
|
6
|
-
|
|
7
|
-
__all__ = [
|
|
8
|
-
"Attributes",
|
|
9
|
-
"ControlDisabledError",
|
|
10
|
-
"DiscoveredPrinter",
|
|
11
|
-
"PrintInfo",
|
|
12
|
-
"Printer",
|
|
13
|
-
"PrinterError",
|
|
14
|
-
"Status",
|
|
15
|
-
"discover",
|
|
16
|
-
]
|
|
17
|
-
|
|
18
|
-
__version__ = "0.4.0"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|