pycentauri 0.1.0__tar.gz → 0.2.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.
- pycentauri-0.2.0/CHANGELOG.md +71 -0
- {pycentauri-0.1.0 → pycentauri-0.2.0}/PKG-INFO +57 -4
- {pycentauri-0.1.0 → pycentauri-0.2.0}/README.md +47 -3
- {pycentauri-0.1.0 → pycentauri-0.2.0}/pyproject.toml +8 -1
- {pycentauri-0.1.0 → pycentauri-0.2.0}/src/pycentauri/__init__.py +1 -1
- {pycentauri-0.1.0 → pycentauri-0.2.0}/src/pycentauri/cli.py +63 -19
- {pycentauri-0.1.0 → pycentauri-0.2.0}/src/pycentauri/client.py +38 -15
- {pycentauri-0.1.0 → pycentauri-0.2.0}/src/pycentauri/discovery.py +15 -5
- {pycentauri-0.1.0 → pycentauri-0.2.0}/src/pycentauri/mcp/server.py +32 -14
- pycentauri-0.2.0/src/pycentauri/server.py +396 -0
- {pycentauri-0.1.0 → pycentauri-0.2.0}/tests/test_client.py +51 -0
- pycentauri-0.2.0/tests/test_discovery.py +63 -0
- pycentauri-0.2.0/tests/test_server.py +106 -0
- pycentauri-0.1.0/CHANGELOG.md +0 -21
- pycentauri-0.1.0/tests/test_discovery.py +0 -63
- {pycentauri-0.1.0 → pycentauri-0.2.0}/.github/workflows/ci.yml +0 -0
- {pycentauri-0.1.0 → pycentauri-0.2.0}/.github/workflows/publish.yml +0 -0
- {pycentauri-0.1.0 → pycentauri-0.2.0}/.gitignore +0 -0
- {pycentauri-0.1.0 → pycentauri-0.2.0}/CONTRIBUTING.md +0 -0
- {pycentauri-0.1.0 → pycentauri-0.2.0}/LICENSE +0 -0
- {pycentauri-0.1.0 → pycentauri-0.2.0}/examples/snapshot.py +0 -0
- {pycentauri-0.1.0 → pycentauri-0.2.0}/examples/start_print.py +0 -0
- {pycentauri-0.1.0 → pycentauri-0.2.0}/examples/status_watch.py +0 -0
- {pycentauri-0.1.0 → pycentauri-0.2.0}/src/pycentauri/camera.py +0 -0
- {pycentauri-0.1.0 → pycentauri-0.2.0}/src/pycentauri/mcp/__init__.py +0 -0
- {pycentauri-0.1.0 → pycentauri-0.2.0}/src/pycentauri/mcp/__main__.py +0 -0
- {pycentauri-0.1.0 → pycentauri-0.2.0}/src/pycentauri/models.py +0 -0
- {pycentauri-0.1.0 → pycentauri-0.2.0}/src/pycentauri/py.typed +0 -0
- {pycentauri-0.1.0 → pycentauri-0.2.0}/src/pycentauri/sdcp.py +0 -0
- {pycentauri-0.1.0 → pycentauri-0.2.0}/tests/__init__.py +0 -0
- {pycentauri-0.1.0 → pycentauri-0.2.0}/tests/integration/__init__.py +0 -0
- {pycentauri-0.1.0 → pycentauri-0.2.0}/tests/test_sdcp.py +0 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. This project follows
|
|
4
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html) and [Keep a
|
|
5
|
+
Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.2.0] - 2026-04-22
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- **HTTP + SSE server.** `centauri server [--host IP] [--bind 127.0.0.1]
|
|
13
|
+
[--port 8787] [--enable-control]` runs a FastAPI app that wraps the
|
|
14
|
+
same client library used by the CLI and MCP server:
|
|
15
|
+
- `GET /` — health, version, connection state
|
|
16
|
+
- `GET /status` — latest status snapshot (JSON)
|
|
17
|
+
- `GET /attributes` — printer attributes (JSON)
|
|
18
|
+
- `GET /snapshot` — single JPEG frame from the webcam
|
|
19
|
+
- `GET /discover` — UDP LAN scan
|
|
20
|
+
- `GET /events/status` — Server-Sent Events stream of live status pushes
|
|
21
|
+
- `POST /print/{start,pause,resume,stop}` — only registered with
|
|
22
|
+
`--enable-control` (mirrors the MCP security posture — the routes
|
|
23
|
+
literally don't exist without the flag)
|
|
24
|
+
- New optional extra: `pip install 'pycentauri[server]'` pulls in
|
|
25
|
+
FastAPI + uvicorn + sse-starlette.
|
|
26
|
+
- The server holds a single long-lived WebSocket for its lifetime with
|
|
27
|
+
auto-reconnect and exponential backoff, so it never bumps against the
|
|
28
|
+
printer's 5-slot limit and HTTP requests return cached pushes instantly.
|
|
29
|
+
|
|
30
|
+
## [0.1.1] - 2026-04-22
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
- `Printer.status()`, `attributes()`, and all control methods hung
|
|
34
|
+
indefinitely when the printer was in a paused or errored state. The
|
|
35
|
+
firmware doesn't push `Attributes` spontaneously outside idle/active
|
|
36
|
+
states, and every SDCP command needs a `MainboardID` in its envelope,
|
|
37
|
+
so the client would deadlock waiting for a push that never comes.
|
|
38
|
+
- The CLI and MCP server now pre-discover the printer over UDP before
|
|
39
|
+
opening the WebSocket and pass the mainboard ID into `Printer.connect()`.
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
- `Printer.connect(..., mainboard_id=...)` — pre-seed the mainboard ID
|
|
43
|
+
(e.g. from a prior `discover()`) so the client can send commands
|
|
44
|
+
immediately, without waiting for the printer's first `Attributes` push.
|
|
45
|
+
- `Printer.wait_for_mainboard()` now raises a `PrinterError` with a
|
|
46
|
+
pointer at the `mainboard_id=` workaround instead of a bare
|
|
47
|
+
`asyncio.TimeoutError`.
|
|
48
|
+
|
|
49
|
+
### Changed
|
|
50
|
+
- `discover()` retransmits the probe multiple times within the timeout
|
|
51
|
+
window, improving reliability on busy or lossy networks. Also binds
|
|
52
|
+
explicitly to `0.0.0.0` so loopback delivery works on macOS.
|
|
53
|
+
|
|
54
|
+
### Known limits
|
|
55
|
+
- Elegoo firmware accepts at most **5 concurrent WebSocket connections**
|
|
56
|
+
on port 3030. The 6th attempt is rejected at the HTTP upgrade with
|
|
57
|
+
`HTTP 500 "too many client"`. Slots release immediately on close.
|
|
58
|
+
|
|
59
|
+
## [0.1.0] - 2026-04-22
|
|
60
|
+
|
|
61
|
+
### Added
|
|
62
|
+
- Async Python client for Elegoo Centauri Carbon printers speaking SDCP v3 over
|
|
63
|
+
WebSocket (`ws://<host>:3030/websocket`).
|
|
64
|
+
- UDP broadcast discovery on port 3000 with the `M99999` probe.
|
|
65
|
+
- MJPEG snapshot grabber for the built-in webcam
|
|
66
|
+
(`/network-device-manager/network/camera`).
|
|
67
|
+
- `centauri` CLI with `discover`, `status`, `watch`, `snapshot`, `attributes`,
|
|
68
|
+
`files`, `print {start,pause,resume,stop}`, `upload`, `mcp`.
|
|
69
|
+
- Optional MCP server (`python -m pycentauri.mcp`) with read-only tools by
|
|
70
|
+
default; control tools registered only when `--enable-control` is set.
|
|
71
|
+
- Apache-2.0 license.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pycentauri
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Python client and MCP server for Elegoo Centauri Carbon 3D printers
|
|
5
5
|
Project-URL: Homepage, https://github.com/bjan/pycentauri
|
|
6
6
|
Project-URL: Repository, https://github.com/bjan/pycentauri
|
|
@@ -28,13 +28,22 @@ Requires-Dist: pydantic>=2.7
|
|
|
28
28
|
Requires-Dist: typer>=0.12
|
|
29
29
|
Requires-Dist: websockets>=12.0
|
|
30
30
|
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: fastapi>=0.110; extra == 'dev'
|
|
32
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
33
|
+
Requires-Dist: mcp>=1.2; extra == 'dev'
|
|
31
34
|
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
32
35
|
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
33
36
|
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
34
37
|
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
35
38
|
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
39
|
+
Requires-Dist: sse-starlette>=2.0; extra == 'dev'
|
|
40
|
+
Requires-Dist: uvicorn[standard]>=0.29; extra == 'dev'
|
|
36
41
|
Provides-Extra: mcp
|
|
37
42
|
Requires-Dist: mcp>=1.2; extra == 'mcp'
|
|
43
|
+
Provides-Extra: server
|
|
44
|
+
Requires-Dist: fastapi>=0.110; extra == 'server'
|
|
45
|
+
Requires-Dist: sse-starlette>=2.0; extra == 'server'
|
|
46
|
+
Requires-Dist: uvicorn[standard]>=0.29; extra == 'server'
|
|
38
47
|
Description-Content-Type: text/markdown
|
|
39
48
|
|
|
40
49
|
# pycentauri
|
|
@@ -43,12 +52,14 @@ Python client, CLI, and MCP server for the [Elegoo Centauri
|
|
|
43
52
|
Carbon](https://www.elegoo.com/) 3D printer.
|
|
44
53
|
|
|
45
54
|
`pycentauri` speaks the printer's native SDCP v3 protocol over its local
|
|
46
|
-
WebSocket (port 3030) — no cloud account required. It exposes
|
|
55
|
+
WebSocket (port 3030) — no cloud account required. It exposes four surfaces:
|
|
47
56
|
|
|
48
57
|
1. An **async Python library** for direct integration.
|
|
49
58
|
2. A **`centauri` CLI** for quick status checks, snapshots, and control.
|
|
50
59
|
3. An **MCP server** so AI agents (Claude Code, Claude Desktop, Cursor, any
|
|
51
60
|
MCP-compatible client) can monitor and drive the printer as a tool.
|
|
61
|
+
4. An **HTTP + SSE server** for dashboards, reverse-proxy integration, and
|
|
62
|
+
anything that wants a plain REST API.
|
|
52
63
|
|
|
53
64
|
> **Status:** alpha. The protocol has been reverse-engineered from the official
|
|
54
65
|
> [`elegoo-link`](https://github.com/ELEGOO-3D/elegoo-link) C++ SDK and the
|
|
@@ -59,8 +70,10 @@ WebSocket (port 3030) — no cloud account required. It exposes three surfaces:
|
|
|
59
70
|
## Install
|
|
60
71
|
|
|
61
72
|
```sh
|
|
62
|
-
pip install pycentauri
|
|
63
|
-
pip install "pycentauri[mcp]"
|
|
73
|
+
pip install pycentauri # library + CLI
|
|
74
|
+
pip install "pycentauri[mcp]" # + MCP server
|
|
75
|
+
pip install "pycentauri[server]" # + HTTP REST/SSE server
|
|
76
|
+
pip install "pycentauri[mcp,server]" # everything
|
|
64
77
|
```
|
|
65
78
|
|
|
66
79
|
## Quick start — CLI
|
|
@@ -112,6 +125,31 @@ async def main():
|
|
|
112
125
|
asyncio.run(main())
|
|
113
126
|
```
|
|
114
127
|
|
|
128
|
+
## Quick start — HTTP server
|
|
129
|
+
|
|
130
|
+
```sh
|
|
131
|
+
# Read-only, bound to loopback so only this box can hit it:
|
|
132
|
+
centauri server --host 192.168.1.209 --port 8787
|
|
133
|
+
|
|
134
|
+
# Read + write, bound to all interfaces (put a reverse proxy in front):
|
|
135
|
+
centauri server --host 192.168.1.209 --bind 0.0.0.0 --port 8787 --enable-control
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
| Method | Path | Notes |
|
|
139
|
+
|---|---|---|
|
|
140
|
+
| `GET` | `/` | Health + version |
|
|
141
|
+
| `GET` | `/status` | Latest status push (cached; updates every ~5s) |
|
|
142
|
+
| `GET` | `/attributes` | Printer attributes |
|
|
143
|
+
| `GET` | `/snapshot` | `image/jpeg` response |
|
|
144
|
+
| `GET` | `/discover` | LAN scan |
|
|
145
|
+
| `GET` | `/events/status` | Server-Sent Events stream of pushes |
|
|
146
|
+
| `POST` | `/print/start` | Body: `{"filename": "cube.gcode"}`. Requires `--enable-control`. |
|
|
147
|
+
| `POST` | `/print/{pause,resume,stop}` | Requires `--enable-control`. |
|
|
148
|
+
|
|
149
|
+
The server holds a single long-lived WebSocket to the printer and reuses
|
|
150
|
+
it for every request — no per-request reconnect, and it won't bump into
|
|
151
|
+
the firmware's 5-slot limit.
|
|
152
|
+
|
|
115
153
|
## Quick start — MCP
|
|
116
154
|
|
|
117
155
|
Register the server with your agent. With Claude Code:
|
|
@@ -145,6 +183,21 @@ Control actions are gated behind an explicit `enable_control=True` (library) or
|
|
|
145
183
|
registered when the flag is off, so an LLM never sees them. Still: leaving a
|
|
146
184
|
printer running unattended with write-capable agents is your responsibility.
|
|
147
185
|
|
|
186
|
+
## Known firmware quirks
|
|
187
|
+
|
|
188
|
+
- **5 concurrent WebSocket connections max.** The printer's SDCP server
|
|
189
|
+
accepts up to 5 open WebSockets on port 3030; the 6th returns HTTP 500
|
|
190
|
+
with body `"too many client"`. Slots release immediately when a
|
|
191
|
+
connection closes — the CLI and MCP server each open/close one per
|
|
192
|
+
invocation, so this is almost never a problem in practice.
|
|
193
|
+
- **Paused / errored states don't auto-push Attributes.** The printer only
|
|
194
|
+
sends its `Attributes` frame spontaneously while idle or printing. In
|
|
195
|
+
paused and errored states it stays silent until asked. Since every SDCP
|
|
196
|
+
command needs the printer's `MainboardID`, the client takes care of this
|
|
197
|
+
by pre-seeding the mainboard ID from a UDP discovery on every connect
|
|
198
|
+
(as of v0.1.1). If you call `Printer.connect()` directly without
|
|
199
|
+
discovery, pass `mainboard_id=` yourself.
|
|
200
|
+
|
|
148
201
|
## Credits & licensing
|
|
149
202
|
|
|
150
203
|
- Protocol reference: [`elegoo-link`](https://github.com/ELEGOO-3D/elegoo-link)
|
|
@@ -4,12 +4,14 @@ Python client, CLI, and MCP server for the [Elegoo Centauri
|
|
|
4
4
|
Carbon](https://www.elegoo.com/) 3D printer.
|
|
5
5
|
|
|
6
6
|
`pycentauri` speaks the printer's native SDCP v3 protocol over its local
|
|
7
|
-
WebSocket (port 3030) — no cloud account required. It exposes
|
|
7
|
+
WebSocket (port 3030) — no cloud account required. It exposes four surfaces:
|
|
8
8
|
|
|
9
9
|
1. An **async Python library** for direct integration.
|
|
10
10
|
2. A **`centauri` CLI** for quick status checks, snapshots, and control.
|
|
11
11
|
3. An **MCP server** so AI agents (Claude Code, Claude Desktop, Cursor, any
|
|
12
12
|
MCP-compatible client) can monitor and drive the printer as a tool.
|
|
13
|
+
4. An **HTTP + SSE server** for dashboards, reverse-proxy integration, and
|
|
14
|
+
anything that wants a plain REST API.
|
|
13
15
|
|
|
14
16
|
> **Status:** alpha. The protocol has been reverse-engineered from the official
|
|
15
17
|
> [`elegoo-link`](https://github.com/ELEGOO-3D/elegoo-link) C++ SDK and the
|
|
@@ -20,8 +22,10 @@ WebSocket (port 3030) — no cloud account required. It exposes three surfaces:
|
|
|
20
22
|
## Install
|
|
21
23
|
|
|
22
24
|
```sh
|
|
23
|
-
pip install pycentauri
|
|
24
|
-
pip install "pycentauri[mcp]"
|
|
25
|
+
pip install pycentauri # library + CLI
|
|
26
|
+
pip install "pycentauri[mcp]" # + MCP server
|
|
27
|
+
pip install "pycentauri[server]" # + HTTP REST/SSE server
|
|
28
|
+
pip install "pycentauri[mcp,server]" # everything
|
|
25
29
|
```
|
|
26
30
|
|
|
27
31
|
## Quick start — CLI
|
|
@@ -73,6 +77,31 @@ async def main():
|
|
|
73
77
|
asyncio.run(main())
|
|
74
78
|
```
|
|
75
79
|
|
|
80
|
+
## Quick start — HTTP server
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
# Read-only, bound to loopback so only this box can hit it:
|
|
84
|
+
centauri server --host 192.168.1.209 --port 8787
|
|
85
|
+
|
|
86
|
+
# Read + write, bound to all interfaces (put a reverse proxy in front):
|
|
87
|
+
centauri server --host 192.168.1.209 --bind 0.0.0.0 --port 8787 --enable-control
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
| Method | Path | Notes |
|
|
91
|
+
|---|---|---|
|
|
92
|
+
| `GET` | `/` | Health + version |
|
|
93
|
+
| `GET` | `/status` | Latest status push (cached; updates every ~5s) |
|
|
94
|
+
| `GET` | `/attributes` | Printer attributes |
|
|
95
|
+
| `GET` | `/snapshot` | `image/jpeg` response |
|
|
96
|
+
| `GET` | `/discover` | LAN scan |
|
|
97
|
+
| `GET` | `/events/status` | Server-Sent Events stream of pushes |
|
|
98
|
+
| `POST` | `/print/start` | Body: `{"filename": "cube.gcode"}`. Requires `--enable-control`. |
|
|
99
|
+
| `POST` | `/print/{pause,resume,stop}` | Requires `--enable-control`. |
|
|
100
|
+
|
|
101
|
+
The server holds a single long-lived WebSocket to the printer and reuses
|
|
102
|
+
it for every request — no per-request reconnect, and it won't bump into
|
|
103
|
+
the firmware's 5-slot limit.
|
|
104
|
+
|
|
76
105
|
## Quick start — MCP
|
|
77
106
|
|
|
78
107
|
Register the server with your agent. With Claude Code:
|
|
@@ -106,6 +135,21 @@ Control actions are gated behind an explicit `enable_control=True` (library) or
|
|
|
106
135
|
registered when the flag is off, so an LLM never sees them. Still: leaving a
|
|
107
136
|
printer running unattended with write-capable agents is your responsibility.
|
|
108
137
|
|
|
138
|
+
## Known firmware quirks
|
|
139
|
+
|
|
140
|
+
- **5 concurrent WebSocket connections max.** The printer's SDCP server
|
|
141
|
+
accepts up to 5 open WebSockets on port 3030; the 6th returns HTTP 500
|
|
142
|
+
with body `"too many client"`. Slots release immediately when a
|
|
143
|
+
connection closes — the CLI and MCP server each open/close one per
|
|
144
|
+
invocation, so this is almost never a problem in practice.
|
|
145
|
+
- **Paused / errored states don't auto-push Attributes.** The printer only
|
|
146
|
+
sends its `Attributes` frame spontaneously while idle or printing. In
|
|
147
|
+
paused and errored states it stays silent until asked. Since every SDCP
|
|
148
|
+
command needs the printer's `MainboardID`, the client takes care of this
|
|
149
|
+
by pre-seeding the mainboard ID from a UDP discovery on every connect
|
|
150
|
+
(as of v0.1.1). If you call `Printer.connect()` directly without
|
|
151
|
+
discovery, pass `mainboard_id=` yourself.
|
|
152
|
+
|
|
109
153
|
## Credits & licensing
|
|
110
154
|
|
|
111
155
|
- Protocol reference: [`elegoo-link`](https://github.com/ELEGOO-3D/elegoo-link)
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pycentauri"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Python client and MCP server for Elegoo Centauri Carbon 3D printers"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -35,12 +35,15 @@ dependencies = [
|
|
|
35
35
|
|
|
36
36
|
[project.optional-dependencies]
|
|
37
37
|
mcp = ["mcp>=1.2"]
|
|
38
|
+
server = ["fastapi>=0.110", "uvicorn[standard]>=0.29", "sse-starlette>=2.0"]
|
|
38
39
|
dev = [
|
|
39
40
|
"pytest>=8.0",
|
|
40
41
|
"pytest-asyncio>=0.23",
|
|
41
42
|
"ruff>=0.5",
|
|
42
43
|
"mypy>=1.10",
|
|
43
44
|
"pytest-cov>=5.0",
|
|
45
|
+
"httpx>=0.27",
|
|
46
|
+
"pycentauri[mcp,server]",
|
|
44
47
|
]
|
|
45
48
|
|
|
46
49
|
[project.scripts]
|
|
@@ -63,6 +66,10 @@ target-version = "py310"
|
|
|
63
66
|
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"]
|
|
64
67
|
ignore = ["E501"]
|
|
65
68
|
|
|
69
|
+
[tool.ruff.lint.per-file-ignores]
|
|
70
|
+
# FastAPI's idiomatic DI uses `Depends()` in default values; that's B008.
|
|
71
|
+
"src/pycentauri/server.py" = ["B008"]
|
|
72
|
+
|
|
66
73
|
[tool.mypy]
|
|
67
74
|
python_version = "3.10"
|
|
68
75
|
strict = true
|
|
@@ -55,9 +55,21 @@ def _echo_err(msg: str) -> None:
|
|
|
55
55
|
typer.echo(msg, err=True)
|
|
56
56
|
|
|
57
57
|
|
|
58
|
-
async def
|
|
58
|
+
async def _resolve_target(host: str | None) -> tuple[str, str | None]:
|
|
59
|
+
"""Return (host, mainboard_id). Mainboard_id may be None if discovery failed.
|
|
60
|
+
|
|
61
|
+
Always runs a brief UDP discovery so we can pre-seed the mainboard ID on
|
|
62
|
+
``Printer.connect()`` — the printer doesn't push Attributes in paused/
|
|
63
|
+
errored states, and without a mainboard ID every command would hang.
|
|
64
|
+
"""
|
|
59
65
|
if host:
|
|
60
|
-
|
|
66
|
+
# Short discovery to learn the mainboard; don't fail if it times out.
|
|
67
|
+
found = await discover_printers(timeout=1.0, retries=2)
|
|
68
|
+
for p in found:
|
|
69
|
+
if p.host == host and p.mainboard_id:
|
|
70
|
+
return host, p.mainboard_id
|
|
71
|
+
return host, None
|
|
72
|
+
|
|
61
73
|
found = await discover_printers(timeout=2.5)
|
|
62
74
|
if not found:
|
|
63
75
|
_echo_err("No printers found on the LAN. Pass --host explicitly.")
|
|
@@ -67,7 +79,7 @@ async def _resolve_host(host: str | None) -> str:
|
|
|
67
79
|
for p in found:
|
|
68
80
|
_echo_err(f" {p.host} {p.machine_name or '?'} {p.firmware_version or '?'}")
|
|
69
81
|
raise typer.Exit(code=2)
|
|
70
|
-
return found[0].host
|
|
82
|
+
return found[0].host, found[0].mainboard_id
|
|
71
83
|
|
|
72
84
|
|
|
73
85
|
def _run(coro: asyncio.coroutines.Coroutine[object, object, object]) -> object: # type: ignore[name-defined]
|
|
@@ -128,8 +140,8 @@ def cmd_status(host: HostOpt = None, as_json: JsonOpt = False) -> None:
|
|
|
128
140
|
"""Print the printer's current status once and exit."""
|
|
129
141
|
|
|
130
142
|
async def run() -> None:
|
|
131
|
-
h = await
|
|
132
|
-
async with await Printer.connect(h) as printer:
|
|
143
|
+
h, mid = await _resolve_target(host)
|
|
144
|
+
async with await Printer.connect(h, mainboard_id=mid) as printer:
|
|
133
145
|
st = await printer.status()
|
|
134
146
|
if as_json:
|
|
135
147
|
typer.echo(json.dumps(st.raw, indent=2, default=str))
|
|
@@ -178,8 +190,8 @@ def cmd_watch(
|
|
|
178
190
|
"""Stream live status updates until interrupted (Ctrl-C)."""
|
|
179
191
|
|
|
180
192
|
async def run() -> None:
|
|
181
|
-
h = await
|
|
182
|
-
async with await Printer.connect(h, push_period_ms=period_ms) as printer:
|
|
193
|
+
h, mid = await _resolve_target(host)
|
|
194
|
+
async with await Printer.connect(h, push_period_ms=period_ms, mainboard_id=mid) as printer:
|
|
183
195
|
async for st in printer.watch():
|
|
184
196
|
if as_json:
|
|
185
197
|
typer.echo(json.dumps(st.raw, default=str))
|
|
@@ -201,8 +213,8 @@ def cmd_attributes(host: HostOpt = None, as_json: JsonOpt = False) -> None:
|
|
|
201
213
|
"""Print the printer's attributes (model, firmware, capabilities)."""
|
|
202
214
|
|
|
203
215
|
async def run() -> None:
|
|
204
|
-
h = await
|
|
205
|
-
async with await Printer.connect(h) as printer:
|
|
216
|
+
h, mid = await _resolve_target(host)
|
|
217
|
+
async with await Printer.connect(h, mainboard_id=mid) as printer:
|
|
206
218
|
attrs = await printer.attributes()
|
|
207
219
|
if as_json:
|
|
208
220
|
typer.echo(json.dumps(attrs.raw, indent=2, default=str))
|
|
@@ -226,8 +238,8 @@ def cmd_snapshot(
|
|
|
226
238
|
"""Save a JPEG snapshot from the built-in webcam."""
|
|
227
239
|
|
|
228
240
|
async def run() -> None:
|
|
229
|
-
h = await
|
|
230
|
-
async with await Printer.connect(h) as printer:
|
|
241
|
+
h, mid = await _resolve_target(host)
|
|
242
|
+
async with await Printer.connect(h, mainboard_id=mid) as printer:
|
|
231
243
|
jpeg = await printer.snapshot(timeout=timeout)
|
|
232
244
|
if str(out) == "-":
|
|
233
245
|
sys.stdout.buffer.write(jpeg)
|
|
@@ -266,8 +278,8 @@ def cmd_print_start(
|
|
|
266
278
|
raise typer.Exit(code=2)
|
|
267
279
|
|
|
268
280
|
async def run() -> None:
|
|
269
|
-
h = await
|
|
270
|
-
async with await Printer.connect(h, enable_control=True) as printer:
|
|
281
|
+
h, mid = await _resolve_target(host)
|
|
282
|
+
async with await Printer.connect(h, enable_control=True, mainboard_id=mid) as printer:
|
|
271
283
|
result = await printer.start_print(
|
|
272
284
|
filename, storage=storage, auto_leveling=auto_leveling, timelapse=timelapse
|
|
273
285
|
)
|
|
@@ -284,8 +296,8 @@ def cmd_print_pause(host: HostOpt = None, enable_control: ControlOpt = False) ->
|
|
|
284
296
|
raise typer.Exit(code=2)
|
|
285
297
|
|
|
286
298
|
async def run() -> None:
|
|
287
|
-
h = await
|
|
288
|
-
async with await Printer.connect(h, enable_control=True) as printer:
|
|
299
|
+
h, mid = await _resolve_target(host)
|
|
300
|
+
async with await Printer.connect(h, enable_control=True, mainboard_id=mid) as printer:
|
|
289
301
|
await printer.pause()
|
|
290
302
|
typer.echo("paused")
|
|
291
303
|
|
|
@@ -300,8 +312,8 @@ def cmd_print_resume(host: HostOpt = None, enable_control: ControlOpt = False) -
|
|
|
300
312
|
raise typer.Exit(code=2)
|
|
301
313
|
|
|
302
314
|
async def run() -> None:
|
|
303
|
-
h = await
|
|
304
|
-
async with await Printer.connect(h, enable_control=True) as printer:
|
|
315
|
+
h, mid = await _resolve_target(host)
|
|
316
|
+
async with await Printer.connect(h, enable_control=True, mainboard_id=mid) as printer:
|
|
305
317
|
await printer.resume()
|
|
306
318
|
typer.echo("resumed")
|
|
307
319
|
|
|
@@ -316,14 +328,46 @@ def cmd_print_stop(host: HostOpt = None, enable_control: ControlOpt = False) ->
|
|
|
316
328
|
raise typer.Exit(code=2)
|
|
317
329
|
|
|
318
330
|
async def run() -> None:
|
|
319
|
-
h = await
|
|
320
|
-
async with await Printer.connect(h, enable_control=True) as printer:
|
|
331
|
+
h, mid = await _resolve_target(host)
|
|
332
|
+
async with await Printer.connect(h, enable_control=True, mainboard_id=mid) as printer:
|
|
321
333
|
await printer.stop()
|
|
322
334
|
typer.echo("stop sent")
|
|
323
335
|
|
|
324
336
|
_run(run())
|
|
325
337
|
|
|
326
338
|
|
|
339
|
+
@app.command("server")
|
|
340
|
+
def cmd_server(
|
|
341
|
+
host: HostOpt = None,
|
|
342
|
+
bind: str = typer.Option(
|
|
343
|
+
"127.0.0.1", "--bind", help="Interface to bind. Use 0.0.0.0 to expose on LAN."
|
|
344
|
+
),
|
|
345
|
+
port: int = typer.Option(8787, "--port", "-p"),
|
|
346
|
+
enable_control: ControlOpt = False,
|
|
347
|
+
log_level: str = typer.Option("info", "--log-level"),
|
|
348
|
+
) -> None:
|
|
349
|
+
"""Run the HTTP + SSE server (requires `pip install 'pycentauri[server]'`)."""
|
|
350
|
+
try:
|
|
351
|
+
from pycentauri.server import run as run_server
|
|
352
|
+
except ImportError as err:
|
|
353
|
+
_echo_err("Server support not installed. Install with: pip install 'pycentauri[server]'")
|
|
354
|
+
_echo_err(f"(missing dependency: {err})")
|
|
355
|
+
raise typer.Exit(code=1) from err
|
|
356
|
+
|
|
357
|
+
async def resolve() -> tuple[str, str | None]:
|
|
358
|
+
return await _resolve_target(host)
|
|
359
|
+
|
|
360
|
+
h, mid = asyncio.run(resolve())
|
|
361
|
+
run_server(
|
|
362
|
+
h,
|
|
363
|
+
bind=bind,
|
|
364
|
+
port=port,
|
|
365
|
+
enable_control=enable_control,
|
|
366
|
+
mainboard_id=mid,
|
|
367
|
+
log_level=log_level,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
327
371
|
@app.command("mcp")
|
|
328
372
|
def cmd_mcp(
|
|
329
373
|
enable_control: ControlOpt = False,
|
|
@@ -69,6 +69,7 @@ class Printer:
|
|
|
69
69
|
*,
|
|
70
70
|
enable_control: bool = False,
|
|
71
71
|
push_period_ms: int = DEFAULT_PUSH_PERIOD_MS,
|
|
72
|
+
mainboard_id: str | None = None,
|
|
72
73
|
) -> None:
|
|
73
74
|
self.host = host
|
|
74
75
|
self.enable_control = enable_control
|
|
@@ -76,9 +77,11 @@ class Printer:
|
|
|
76
77
|
|
|
77
78
|
self._ws: ClientConnection | None = None
|
|
78
79
|
self._reader: asyncio.Task[None] | None = None
|
|
79
|
-
self._mainboard_id: str | None = None
|
|
80
|
+
self._mainboard_id: str | None = mainboard_id or None
|
|
80
81
|
|
|
81
82
|
self._mainboard_event = asyncio.Event()
|
|
83
|
+
if self._mainboard_id:
|
|
84
|
+
self._mainboard_event.set()
|
|
82
85
|
self._latest_status: Status | None = None
|
|
83
86
|
self._latest_status_event = asyncio.Event()
|
|
84
87
|
self._latest_attributes: Attributes | None = None
|
|
@@ -96,9 +99,23 @@ class Printer:
|
|
|
96
99
|
enable_control: bool = False,
|
|
97
100
|
push_period_ms: int = DEFAULT_PUSH_PERIOD_MS,
|
|
98
101
|
connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
|
|
102
|
+
mainboard_id: str | None = None,
|
|
99
103
|
) -> Self:
|
|
100
|
-
"""Open a WebSocket to the printer and start the reader task.
|
|
101
|
-
|
|
104
|
+
"""Open a WebSocket to the printer and start the reader task.
|
|
105
|
+
|
|
106
|
+
``mainboard_id`` lets callers pre-seed the printer's serial/mainboard
|
|
107
|
+
identifier (e.g. from a prior discovery). When set, the client will
|
|
108
|
+
not wait for the printer's spontaneous ``Attributes`` push before
|
|
109
|
+
sending commands — which matters in paused/error states, where the
|
|
110
|
+
firmware doesn't push Attributes until prompted, and every command
|
|
111
|
+
requires ``MainboardID`` in its envelope.
|
|
112
|
+
"""
|
|
113
|
+
self = cls(
|
|
114
|
+
host,
|
|
115
|
+
enable_control=enable_control,
|
|
116
|
+
push_period_ms=push_period_ms,
|
|
117
|
+
mainboard_id=mainboard_id,
|
|
118
|
+
)
|
|
102
119
|
url = f"ws://{host}:{WS_PORT}{WS_PATH}"
|
|
103
120
|
self._ws = await asyncio.wait_for(connect(url, max_size=None), timeout=connect_timeout)
|
|
104
121
|
self._reader = asyncio.create_task(self._read_loop(), name=f"pycentauri-reader-{host}")
|
|
@@ -125,20 +142,26 @@ class Printer:
|
|
|
125
142
|
return self._mainboard_id
|
|
126
143
|
|
|
127
144
|
async def wait_for_mainboard(self, timeout: float = 5.0) -> str:
|
|
128
|
-
"""Block until the printer has reported its mainboard ID.
|
|
145
|
+
"""Block until the printer has reported its mainboard ID.
|
|
146
|
+
|
|
147
|
+
Returns immediately if ``mainboard_id=`` was passed to
|
|
148
|
+
:meth:`connect`. Otherwise waits for the first ``Attributes`` push
|
|
149
|
+
from the printer. The printer only pushes Attributes spontaneously
|
|
150
|
+
in idle/active states — when paused or errored it stays silent until
|
|
151
|
+
asked, so callers that run in those states should pass ``mainboard_id``
|
|
152
|
+
explicitly (e.g. from a prior :func:`pycentauri.discover`).
|
|
153
|
+
"""
|
|
129
154
|
if self._mainboard_id:
|
|
130
155
|
return self._mainboard_id
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
)
|
|
141
|
-
await asyncio.wait_for(self._mainboard_event.wait(), timeout=timeout)
|
|
156
|
+
try:
|
|
157
|
+
await asyncio.wait_for(self._mainboard_event.wait(), timeout=timeout)
|
|
158
|
+
except asyncio.TimeoutError as err:
|
|
159
|
+
raise PrinterError(
|
|
160
|
+
"printer did not push Attributes within "
|
|
161
|
+
f"{timeout}s. Pass mainboard_id=... to Printer.connect() "
|
|
162
|
+
"(discover() provides one) — the firmware does not push "
|
|
163
|
+
"Attributes while paused or errored."
|
|
164
|
+
) from err
|
|
142
165
|
assert self._mainboard_id is not None
|
|
143
166
|
return self._mainboard_id
|
|
144
167
|
|
|
@@ -68,19 +68,22 @@ async def discover(
|
|
|
68
68
|
timeout: float = DEFAULT_TIMEOUT,
|
|
69
69
|
broadcast_address: str = "255.255.255.255",
|
|
70
70
|
port: int = DISCOVERY_PORT,
|
|
71
|
+
retries: int = 3,
|
|
71
72
|
) -> list[DiscoveredPrinter]:
|
|
72
73
|
"""Broadcast the SDCP discovery probe and collect responders.
|
|
73
74
|
|
|
74
75
|
Blocks for ``timeout`` seconds. Returns one entry per responding printer,
|
|
75
|
-
de-duplicated by source IP.
|
|
76
|
-
|
|
76
|
+
de-duplicated by source IP. The probe is retransmitted ``retries`` times
|
|
77
|
+
at evenly-spaced intervals within the timeout window, since UDP probes
|
|
78
|
+
can be dropped on busy or congested networks. Safe to call concurrently
|
|
79
|
+
from multiple tasks; each call uses its own UDP socket.
|
|
77
80
|
"""
|
|
78
81
|
loop = asyncio.get_running_loop()
|
|
79
82
|
|
|
80
83
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
81
84
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
|
82
85
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
83
|
-
sock.bind(("", 0))
|
|
86
|
+
sock.bind(("0.0.0.0", 0))
|
|
84
87
|
sock.setblocking(False)
|
|
85
88
|
|
|
86
89
|
transport, protocol = await loop.create_datagram_endpoint(
|
|
@@ -88,8 +91,15 @@ async def discover(
|
|
|
88
91
|
sock=sock,
|
|
89
92
|
)
|
|
90
93
|
try:
|
|
91
|
-
|
|
92
|
-
|
|
94
|
+
tries = max(1, retries)
|
|
95
|
+
interval = timeout / max(tries, 1) / 2
|
|
96
|
+
for _ in range(tries):
|
|
97
|
+
transport.sendto(DISCOVERY_PROBE, (broadcast_address, port))
|
|
98
|
+
await asyncio.sleep(interval)
|
|
99
|
+
# Listen for the remainder of the budget for late replies.
|
|
100
|
+
remaining = max(0.0, timeout - interval * tries)
|
|
101
|
+
if remaining:
|
|
102
|
+
await asyncio.sleep(remaining)
|
|
93
103
|
finally:
|
|
94
104
|
transport.close()
|
|
95
105
|
|